Skip to content

Quality gates

Every gate uses [quality-gates.<id>] with command (string or argument array), optional enabled (default true) and optional profiles (default profile when omitted). The command defines the actual tool invocation. Ward does not install gate tools during check.

CategoryIDToolTypical purpose
Build & TestscompileMaven Compiler / Gradle compilationCompile the project.
testsJUnit 5Run the project’s automated tests.
coverageJaCoCoEnforce or report test coverage.
mutationPITRun mutation testing.
Code QualitycheckstyleCheckstyleCheck Java style rules.
pmdPMDFind common Java design and implementation problems.
spotbugsSpotBugsRun static analysis for potential bugs.
sonarSonarQubeCode smells, bugs, duplication, overall quality.
formatSpotless / google-java-formatCheck source formatting.
SecuritysemgrepSemgrepSAST and custom security rules.
gitleaksGitleaksDetect committed secrets.
dependency-checkOWASP Dependency-CheckScan dependencies for known CVEs.
zapOWASP ZAPDAST against a running application.
trivyTrivyVulnerabilities in dependencies, containers, and IaC.
ArchitecturearchunitArchUnitRun architecture rules/tests.
enforcerMaven Enforcer / Gradle checksDependency and build rules.
Dependencies & CompliancelicenseLicense Maven Plugin / Gradle License Report (checkLicense)Dependency license policy.
api-compatjapicmpBinary/API compatibility between versions.
REST API contractopenapi-lintvacuumLint the committed OpenAPI spec with every built-in rule.
openapi-diffoasdiffBreaking changes in uncommitted spec edits.
openapi-compatoasdiffBreaking changes on the branch against origin/main.
TypeScript & JavaScripttscTypeScript compilerType-check the project.
eslintESLintLint TypeScript and JavaScript sources.
prettierPrettierCheck source formatting.
biomeBiomeLint and check formatting of sources in one run; replaces prettier and eslint where the project uses Biome.
vitest / jest / bun-testVitest / Jest / Bun testRun the project’s automated tests.
strykerStrykerJSRun mutation testing, optionally with a minimum score.
dependency-cruiserdependency-cruiserCheck dependency and architecture rules.
Customcommand—Run an arbitrary configured project check.
Traceabilityopenspec-junit-traceabilityWard source analysisRequire one explicitly annotated JUnit mapping per live OpenSpec scenario.

ward init offers every gate but selects none unless you choose a preset.

These gates need network access, a running application, or a comparison baseline, so enable them deliberately, for example in a CI-only profile with profiles = ["ci"]:

  • zap
  • trivy
  • sonar
  • dependency-check
  • api-compat
  • semgrep
PresetGatesMinimums
standardcompile, tests, format, Checkstyle, PMD, SpotBugs, coverage70% line coverage
strictstandard plus ArchUnit, Enforcer, and PIT mutation testing85% line, 75% branch, 60% mutation
standard (TypeScript)tsc, the test runner (vitest, jest or bun-test), prettier and eslint, or biome in a Biome project70% line coverage
standard (JavaScript)the test runner (vitest, jest or bun-test), prettier and eslint, or biome in a Biome project70% line coverage
strict (TypeScript)standard plus stryker, with typescript-eslint strict-type-checked85% line, 75% branch, 60% mutation
strict (JavaScript)standard plus stryker85% line, 75% branch, 60% mutation

For TypeScript and JavaScript, presets write no rule files under config/. Init adds the tools to package.json and writes the tools’ own configs when the project has none: an ESLint flat config, vitest.config.mjs or jest.config.mjs with the coverage thresholds, and stryker.config.mjs. Bun checks lines only, because it does not measure branch coverage. See Node projects.

Strict mutation testing can add a lot to check runtime. When license is selected, strict writes a license allowlist instead of the GPL/AGPL denylist; see what init changes in your build.

Presets may write these rule files:

  • config/pmd-ruleset.xml
  • config/checkstyle.xml
  • config/spotbugs-exclude.xml (standard)
  • config/enforcer-rules.xml

The files carry a managed-by: ward marker. Unmarked user files are never overwritten.

For Maven-backed gates, init picks a version from its embedded Java/Maven compatibility catalog before adding a missing plugin to pom.xml.

  • Existing compatible versions are kept.
  • Incompatible versions need confirmation, or --yes for explicit automation.
  • Unknown compatibility, or no compatible version in the catalog, stops init before it writes anything and reports how to fix it.

For Gradle projects, the same gate IDs map to Gradle tasks and pinned plugin declarations, in either the Groovy or the Kotlin build script. See what init changes.

semgrep, gitleaks, trivy, and zap-baseline.py must already be on the path.

  • A missing tool is reported as unavailable with an install link, and other gates still run.
  • ward init can install supported tools with your consent; check never installs them.
  • When a command contains --report-path <path> inside the project, Ward creates the parent directory before running it. The command itself never changes.

tsc, eslint, prettier, biome, vitest, jest, bun-test, stryker, and dependency-cruiser run their command from the project root, exactly as written, and pass when it exits with code 0.

  • Ward does not add flags and does not choose a package manager. Put the runner in the command, such as pnpm exec, npx, yarn or bunx, so the project’s locally installed tool runs.
  • For a TypeScript project, ward init offers tsc, eslint, prettier, the project’s test runner, and semgrep with the p/typescript ruleset, and writes their commands with the package manager’s runner; see Node projects.
  • For a Vue, Svelte, or Astro project, the type-check gate is vue-tsc, svelte-check, or astro-check instead of tsc, because only the framework’s checker reads its component files. The framework comes from the project’s declared packages; see Node projects. Angular’s type-check gate is ngc, which checks its templates, and its test gate is ng-test. A type-check configuration with no inputs fails the gate and names the file.
  • For a Vue, Svelte, or Astro project, stryker mutates only src, and the generated ESLint config ignores .astro/ (Astro) or .svelte-kit/ (Svelte).
  • For a JavaScript project, it offers the same gates without tsc, and semgrep with the p/javascript ruleset.
  • In a project that uses Biome, init offers biome in place of prettier, and offers eslint only with the project’s own ESLint config. The biome command is <runner> biome ci --colors=off --reporter=checkstyle .; under strict it adds --error-on-warnings. See Node projects.
  • Init also offers stryker, in the ci profile, and strict selects it. It offers dependency-cruiser too, which strict selects; see Node projects.
[quality-gates.eslint]
command = ["pnpm", "exec", "eslint", ".", "--format", "json"]
profiles = ["default"]
[quality-gates.tsc]
command = ["npx", "tsc", "--noEmit", "--pretty", "false"]
profiles = ["pre-push"]

Without minimum, the stryker gate passes or fails on Stryker’s exit code alone. With minimum = <0-100>, Ward also reads Stryker’s JSON report after a successful run and fails the gate when the mutation score is below the minimum.

[quality-gates.stryker]
command = ["npx", "stryker", "run", "--reporters", "clear-text,progress,json"]
minimum = 60
profiles = ["ci"]
  • The score is detected / valid × 100. Detected mutants were killed or timed out. Valid mutants are the detected ones plus those that survived or that no test covers. Ignored, pending, compile-error, and runtime-error mutants do not count.
  • The command must enable Stryker’s json reporter. Ward reads the report at Stryker’s default path, reports/mutation/mutation.json.
  • A report that is missing, older than the gate run, unreadable, or without valid mutants is an execution error, never a pass. The message says which case applies.
  • Stryker’s own thresholds.break also fails the gate, through Stryker’s exit code. The stryker.config.mjs that init writes with strict uses break: 60 and sets no minimum, so the threshold lives in one place. Tune it there. Use minimum when you keep your own Stryker config and want Ward to report the score.

The mutation gate accepts the same minimum. Without it, the gate passes or fails on PIT’s exit code, which already enforces any mutationThreshold in your build. With it, Ward also reads PIT’s XML report after a successful run and fails the gate when the score is below the minimum.

[quality-gates.mutation]
command = ["./mvnw", "-DoutputFormats=XML,HTML", "pitest:mutationCoverage"]
minimum = 60
profiles = ["ci"]
  • The score is PIT’s own: detected mutations divided by all mutations. It matches what mutationThreshold would check.
  • Ward reads target/pit-reports/mutations.xml for Maven and build/reports/pitest/mutations.xml for Gradle. PIT writes only HTML by default, so enable XML output: -DoutputFormats=XML,HTML for Maven, or outputFormats = ['XML', 'HTML'] in the Gradle pitest block. Timestamped report folders are not read.
  • A report that is missing, older than the gate run, unreadable, or without mutations is an execution error, never a pass.

openspec-junit-traceability is the one built-in gate that needs no command:

[quality-gates.openspec-junit-traceability]
enabled = true

Each mapping must be declared directly on a @Test, @ParameterizedTest, or @RepeatedTest method, using matching exact identities:

@Tag("spec:identity/user-auth")
@DisplayName("Successful sign-in")
@Test
void signsIn() {}
  • Only the existence of a mapping is checked, not test execution or assertions.
  • Empty live specs fail with no scenarios found.
  • Active-change specs are excluded.

api-compat is for published libraries. It compares the latest released artifact (RELEASE) with the jar the current build produces, so a released baseline must be resolvable from a Maven repository.

On Maven, init:

  • runs -DskipTests package japicmp:cmp;
  • points japicmp’s new version at ${project.build.directory}/${project.build.finalName}.jar, so a custom finalName works;
  • limits the comparison to ${project.groupId}.*, so classes bundled from dependencies are not reported;
  • offers the gate only when pom.xml declares <distributionManagement>.

Applications shipped as binaries, such as CLIs, have no Java API consumers and should not enable it.

openapi-lint, openapi-diff, and openapi-compat check a committed (contract-first) OpenAPI spec. Each one needs a spec option with the project-relative path of that file. Ward appends the spec to the command, so the command names only the tool and its flags:

[quality-gates.openapi-lint]
profiles = ["default", "pre-commit", "pre-push", "ci"]
command = ["vacuum", "lint", "-z", "-n", "warn"]
spec = "src/main/resources/openapi.yml"
[quality-gates.openapi-diff]
profiles = ["default", "pre-commit"]
command = ["oasdiff", "breaking", "--fail-on", "ERR"]
spec = "src/main/resources/openapi.yml"
[quality-gates.openapi-compat]
profiles = ["pre-push", "ci"]
command = ["oasdiff", "breaking", "--fail-on", "ERR"]
spec = "src/main/resources/openapi.yml"
base = "origin/main" # the default

vacuum and oasdiff must be on the path; a missing tool makes the gate unavailable. The flags and behavior below were verified with vacuum 0.35.1 and oasdiff 1.34.1.

GateCompares the spec with
openapi-diffThe spec at HEAD, so it catches a breaking edit before you commit it. In pre-push and ci everything is committed, so it would compare the file with itself; use openapi-compat there.
openapi-compatThe merge base of HEAD and base, so only the changes on your branch count. When HEAD is that merge base, as on main after a merge, it compares with the first parent of HEAD.

A spec that does not exist at the baseline is new: the gate passes and its summary says there was no baseline.

Ward never fetches:

  • Fetch the base branch before pushing.
  • Give CI a checkout with the base branch history, for example actions/checkout with fetch-depth: 0. Without a merge base, openapi-compat fails and says so.
  • A base ref that does not exist is a configuration error (exit 2).

These gates accept no way to silence a rule, in any profile:

  • openapi-lint requires -z (every built-in rule, including OWASP) and -n with warn, info, or hint. Besides those it accepts only output flags: -b, -q, -d, -s, -a, -x, -m, -t and their long forms, --no-clip, --abs-paths, --pipeline-output, --github-annotations, --no-update-check, and --min-score. Rulesets (-r), ignore files, and --config are rejected, because a ruleset can lower a rule’s severity even in hard mode.
  • openapi-diff and openapi-compat require --fail-on ERR or --fail-on WARN, and accept only --format, --color, --lang, --flatten-allof, --flatten-params, and --include-path-params. Ignore files, path and extension filters, --config, --open (which uploads the comparison), and --fetch are rejected.

Any other flag, a repeated flag, or a spec path in the command is a configuration error (exit 2) that names the argument.

Before running, the gates also fail when:

  • the project root contains a file the tool loads on its own: vacuum.conf.yaml, .oasdiff.yaml, .oasdiff.yml, or .oasdiff.json;
  • OASDIFF_CONFIG is set;
  • the spec contains x-lint-ignore.

The Maven and Gradle dependency-check configurations read the NVD API key from the NVD_API_KEY environment variable and never write it to a build file. Without the variable, scans may be very slow or rate-limited. Request a key from the NVD API service.

Sonatype OSS Index needs credentials, so init turns its analyzer off: <ossindexAnalyzerEnabled>false on Maven, analyzers { ossIndex { enabled = false } } on Gradle.

To turn it on, set OSS_INDEX_PASSWORD (and OSS_INDEX_USERNAME if your token needs one) in the environment and flip the setting:

  • Gradle reads the variables directly.
  • Maven reads credentials from a settings.xml server whose id matches the generated ossIndexServerId (ossindex). Reference the variables there instead of writing values to a file in the project:
<server>
<id>ossindex</id>
<username>${env.OSS_INDEX_USERNAME}</username>
<password>${env.OSS_INDEX_PASSWORD}</password>
</server>

If you do not want to manage NVD or OSS Index credentials, use the trivy gate. It also scans Maven and Gradle dependencies for known vulnerabilities and needs no API key.

zap requires an explicit target in its command. Init writes <TARGET_URL> as a placeholder. Replace it with an authorized target before running check, for example command = ["zap-baseline.py", "-t", "http://localhost:8080"].

An enabled zap gate without -t <url>, or still containing <TARGET_URL>, is a configuration error (exit 2). Ward never infers, starts, or scans a target on its own.

When checkstyle, pmd, gitleaks, compile, format, eslint, biome, or tsc fails, Ward turns the tool’s output into findings with rule, severity, file, line, column, message, and a rule documentation link when known. They appear in JSON and SARIF output.

GateSourceMavenGradleTypeScript & JavaScript
checkstyleXML reporttarget/checkstyle-result.xmlbuild/reports/checkstyle/*.xml
pmdXML reporttarget/pmd.xmlbuild/reports/pmd/*.xml
gitleaksSARIF reporttarget/gitleaks.sarifbuild/gitleaks.sarif.ward/reports/gitleaks.sarif
compilejavac messages in the output
formatSpotless file list in the output (file only, no line)
eslintESLint JSON in the output; the command must pass --format json
biomeCheckstyle XML on standard output; the command must pass --reporter=checkstyle. Lint rules keep Biome’s category, such as lint/suspicious/noExplicitAny, and format findings have no line.
tscCompiler diagnostics in the output; the command must pass --pretty false
  • Only default report locations are read, and only reports written during the current run. An older report is ignored.
  • TypeScript and JavaScript projects keep the reports Ward reads under .ward/reports/ instead of target/ or build/. Stryker’s mutation report is the exception: it stays at Stryker’s own reports/mutation/mutation.json, because only Stryker’s configuration file can move it.
  • A missing or malformed report never changes the gate result; the gate just has no findings.
  • Gitleaks findings never contain the matched secret.
  • ESLint’s default stylish output and tsc’s pretty output give no findings. The gate result does not change. An ESLint parsing error is reported with the rule eslint.
  • Other gates have no findings yet.

ward init writes a [quality-gates.<id>.partial] table for checkstyle, pmd, format, semgrep, gitleaks, eslint, prettier, biome, openapi-lint, and openapi-diff. After an agent edit, and for the files a commit stages, a gate:

  • is skipped when no edited file is in its inputs;
  • runs in full when one of its config files was edited;
  • is narrowed to the edited files when the tool supports it:
GateMavenGradle
checkstyle-Dcheckstyle.includes=**/<file name>-PwardPaths=<paths>, with a preset
pmdfull, with PMD’s analysis cache-PwardPaths=<paths>, with a preset
format-DspotlessFiles=.*/<path>, a regular expression matched against the absolute pathfull
semgrepeach path as its own argumenteach path as its own argument
gitleaks--no-git --source=<path> for each path, so uncommitted edits are scannedsame as Maven
openapi-lint, openapi-difffull, only when the spec is editedfull, only when the spec is edited

In a TypeScript or JavaScript project, eslint and prettier take edited .ts, .tsx, .mts, .cts, .js, .jsx, .mjs, and .cjs files, each as its own argument. Their tables set command to the full command without its . target, so a narrowed check runs, for example, npx prettier --check src/app.ts. An edit of their configs or package.json runs them in full. biome takes the same form: a narrowed run passes --no-errors-on-unmatched, so an edited file that Biome ignores passes. tsc and the test runners have no table, so they run in full in pre-push and ci.

Gradle notes:

  • With a preset, the Checkstyle and PMD blocks init writes filter their sources to wardPaths only when the property is set, so checkstyleMain and pmdMain still check the whole main source set otherwise.
  • Without a preset there is no filter, and those gates run in full.
  • Gradle’s single-file Spotless mode applies formatting instead of checking it, so Gradle format runs in full.

The forms above were verified against the real tools: a path format a tool does not understand analyzes no files and passes. The full check at the end of an agent turn, Git hooks, and CI always check the whole project.

OpenSpec is an optional project tool, not a quality-gate menu entry. A configuration may register the two validations independently:

[quality-gates.openspec]
enabled = true
command = ["openspec", "validate", "--all", "--strict", "--no-interactive"]
[quality-gates.openspec-archived]
enabled = true
command = ["openspec", "validate", "--archived", "--no-interactive"]
  • The configured executable runs directly from the target root. Relative and absolute paths are honored, with no PATH fallback.
  • Both validations need an openspec/ directory at the project root.
  • A missing executable is reported as UNAVAILABLE. A missing or non-directory openspec/ is a FAILED result.
  • Check and doctor never install or scaffold OpenSpec.

In a monorepo, openspec/ often lives at the repository root while the Java project sits in a subdirectory. Set root on each OpenSpec gate to the directory that holds openspec/, relative to the project root:

[quality-gates.openspec]
enabled = true
command = ["openspec", "validate", "--all", "--strict", "--no-interactive"]
root = "../.."
  • The gate then requires openspec/ in that directory and runs the command from it.
  • Ward never searches other parents. If root has no openspec/, the gate fails and names the configured root.
  • root must be relative and stay inside the Git repository. An absolute path is a configuration error, and a path that leaves the repository fails the gate without running it.
  • ward init writes root for a nested project without its own openspec/ when the repository root has one. Doctor reports the prerequisites of the configured root.
  • Targeted ward spec validate -- <paths> uses the same root, and the approved-artifact guard protects that repository-level openspec/ too.

The legacy identifiers architecture (use archunit) and compliance (use enforcer; license checks now use license) are still read, so existing configurations need no change.

Ward validates registry identifiers; the configured command is the gate-specific part and must be supplied. See configuration syntax.