From 93099a16b2912571cbb9ff150e517723de70de9b Mon Sep 17 00:00:00 2001
From: Iwan Eising
Date: Mon, 24 Aug 2026 21:22:24 +0400
Subject: [PATCH 1/3] feat(doppelganger-api-detector): add scanContracts task
and response coverage tracking
detectDoppelgangerApis only answers whether an endpoint has at least one
contract test at all, hiding how many response codes are declared and how
well each is actually exercised. Adds a new scanContracts task reporting,
per declared-and-implemented endpoint, its declared response code count and
contract test count, plus - behind the includeResponseCoverage DSL flag,
since it costs more to compute - a per-response-code test count breakdown.
Contract test status codes are detected best-effort from MockMvc, WebTestClient,
REST Assured, and Spring Cloud Contract call/DSL shapes.
Response coverage history is tracked, when enabled, in its own branch-agnostic
NDJSON file (responseCoverageHistoryFile), separate from contractHistoryFile
since it's a Doppelganger-only concern with a different record shape (a live
test-count gauge rather than milestone timestamps).
Depends on api-detector-core's new DescribedEndpoint.responseCodes() (not yet
released) - the version catalog entry needs bumping once that version ships.
Co-Authored-By: Claude Sonnet 5
---
doppelganger-api-detector/README.adoc | 105 ++++-
doppelganger-api-detector/build.gradle | 2 +
...response-coverage-history-file-format.adoc | 156 +++++++
.../doppelganger/ContractScanSupport.java | 176 +++++++
.../DetectDoppelgangerApisTask.java | 104 +----
.../DoppelgangerApiDetectorExtension.java | 97 +++-
.../DoppelgangerApiDetectorPlugin.java | 70 ++-
.../doppelganger/ScanContractsTask.java | 433 ++++++++++++++++++
.../detect/ContractEvidenceMatcher.java | 36 ++
.../detect/ContractVerificationSource.java | 23 +
.../detect/DoppelgangerApiFinder.java | 17 +-
.../detect/EndpointResponseCoverage.java | 46 ++
.../detect/ResponseCoverageAnalyzer.java | 76 +++
.../detect/VerifiedContractTest.java | 18 +
.../ResponseCoverageHistoryStore.java | 182 ++++++++
.../ResponseCoverageHistoryUpdater.java | 90 ++++
.../progress/ResponseCoverageRecord.java | 43 ++
.../report/ResponseCoverageTableWriter.java | 91 ++++
.../report/ScanContractsReportWriter.java | 171 +++++++
.../scan/OpenApiRequestValidatorScanner.java | 34 +-
.../doppelganger/scan/RestDocsScanner.java | 34 +-
.../scan/SpringCloudContractScanner.java | 36 +-
.../doppelganger/scan/StatusCodeDetector.java | 114 +++++
.../resources/scan-contracts-preamble.adoc | 38 ++
.../DoppelgangerApiDetectorPluginTest.java | 128 ++++++
.../doppelganger/ScanContractsTaskTest.java | 310 +++++++++++++
.../detect/ResponseCoverageAnalyzerTest.java | 136 ++++++
.../ResponseCoverageHistoryStoreTest.java | 115 +++++
.../ResponseCoverageHistoryUpdaterTest.java | 156 +++++++
.../OpenApiRequestValidatorScannerTest.java | 23 +
.../scan/RestDocsScannerTest.java | 56 +++
.../scan/SpringCloudContractScannerTest.java | 34 ++
.../scan/StatusCodeDetectorTest.java | 77 ++++
33 files changed, 3076 insertions(+), 151 deletions(-)
create mode 100644 doppelganger-api-detector/docs/response-coverage-history-file-format.adoc
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ContractScanSupport.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTask.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ContractEvidenceMatcher.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/EndpointResponseCoverage.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ResponseCoverageAnalyzer.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/VerifiedContractTest.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryStore.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryUpdater.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageRecord.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/report/ResponseCoverageTableWriter.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/report/ScanContractsReportWriter.java
create mode 100644 doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetector.java
create mode 100644 doppelganger-api-detector/src/main/resources/scan-contracts-preamble.adoc
create mode 100644 doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTaskTest.java
create mode 100644 doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/detect/ResponseCoverageAnalyzerTest.java
create mode 100644 doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryStoreTest.java
create mode 100644 doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryUpdaterTest.java
create mode 100644 doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetectorTest.java
diff --git a/doppelganger-api-detector/README.adoc b/doppelganger-api-detector/README.adoc
index e740d016..29031028 100644
--- a/doppelganger-api-detector/README.adoc
+++ b/doppelganger-api-detector/README.adoc
@@ -270,6 +270,35 @@ doppelgangerApiDetector {
// excludePaths.add('/actuator/health')
// excludeFiles.from('doppelganger-exclusions.yaml')
// excludeWellKnown.add('spring-boot-actuator')
+
+ // Configuration for the separate `scanContracts` task (see "Contract Response Coverage"
+ // below) - reuses controllerDirs, testDirs, rootDocument, contractsDir, the useXxx source
+ // flags, and the exclude* properties above.
+
+ // Whether scanContracts additionally computes, for every declared response code, how many
+ // contract tests cover it. The more expensive of the two statistics it can report.
+ // Default: false
+ includeResponseCoverage = false
+
+ // Name of scanContracts' own generated AsciiDoc report file, written to the same reportDir.
+ // Default: 'contract-coverage.adoc'
+ // scanContractsReportFileName = 'contract-coverage.adoc'
+
+ // Persists a per-(endpoint, response code) history of contract test coverage over time.
+ // Only meaningful together with includeResponseCoverage - see "Contract Response Coverage".
+ // Default: false
+ trackResponseCoverageHistory = false
+
+ // NDJSON file the response coverage history is read from/written to. Defaults into the
+ // project directory (not build/), since it's meant to be committed. A separate file from
+ // contractHistoryFile - response coverage is a Doppelganger-only concern.
+ // Default: file('doppelganger-api-detector-response-coverage-history.ndjson')
+ // responseCoverageHistoryFile = file('doppelganger-api-detector-response-coverage-history.ndjson')
+
+ // When true, responseCoverageHistoryFile is written back to disk. Only consulted when
+ // trackResponseCoverageHistory is true; the file is always read either way.
+ // Default: same as trackResponseCoverageHistory
+ // updateResponseCoverageHistory = true
}
----
@@ -290,6 +319,12 @@ doppelgangerApiDetector {
// excludePaths.add("/actuator/health")
// excludeFiles.from("doppelganger-exclusions.yaml")
// excludeWellKnown.add("spring-boot-actuator")
+
+ includeResponseCoverage.set(false)
+ // scanContractsReportFileName.set("contract-coverage.adoc")
+ trackResponseCoverageHistory.set(false)
+ // responseCoverageHistoryFile.set(file("doppelganger-api-detector-response-coverage-history.ndjson"))
+ // updateResponseCoverageHistory.set(true)
}
----
@@ -301,15 +336,17 @@ doppelgangerApiDetector {
|===
| Task name | Group | Description
| `detectDoppelgangerApis` | verification | Scans OpenAPI documentation, `@RestController` implementations, and test-level verification evidence, and reports endpoints that are declared and implemented but never verified against their contract.
+| `scanContracts` | verification | Scans the same candidate endpoints, but reports response-code and contract-test *coverage* rather than a pass/fail verdict - see "Contract Response Coverage" below. Never fails the build on its own initiative.
|===
-The task is **not** wired into `check` or `build` automatically. Teams generating their OpenAPI documentation from code, or migrating their tests onto one of the supported verification mechanisms gradually, would otherwise see every build fail immediately.
+Neither task is wired into `check` or `build` automatically. Teams generating their OpenAPI documentation from code, or migrating their tests onto one of the supported verification mechanisms gradually, would otherwise see every build fail immediately.
-Running the report explicitly:
+Running the reports explicitly:
[source,shell]
----
./gradlew detectDoppelgangerApis
+./gradlew scanContracts
----
To make the task part of every build, wire it into `check` (or `build`) yourself once you're ready to enforce it:
@@ -398,6 +435,70 @@ If a run is skipped for some other reason and you need to force it regardless (e
./gradlew detectDoppelgangerApis --rerun
----
+== Contract Response Coverage (`scanContracts`)
+
+NOTE: For a complete field-by-field reference to `responseCoverageHistoryFile` - its exact JSON schema, fingerprint algorithm, and lifecycle semantics - see link:docs/response-coverage-history-file-format.adoc[Response Coverage History File Format].
+
+`detectDoppelgangerApis` answers a binary question per endpoint: does it have *at least one* contract test at all? That collapses two more useful, more granular signals into a single yes/no. `scanContracts` separates them out, for every endpoint both declared in the OpenAPI documentation and implemented by a `@RestController` method - the same candidate set `detectDoppelgangerApis` computes:
+
+* *Declared response codes* - how many distinct response codes (`200`, `400`, `401`, `403`, `404`, `422`, `500`, `503`, ...) the OpenAPI operation actually documents.
+* *Contract test count* - how many distinct contract tests exist for the endpoint, across every enabled verification source (Spring RestDocs, the Atlassian OpenAPI request validator, Spring Cloud Contract) - reusing the same three sources `detectDoppelgangerApis` uses.
+
+Setting `includeResponseCoverage = true` adds a third, more expensive statistic: for every declared response code, how many of those contract tests were detected to actually assert it - including a response code declared but covered by *zero* tests, so a coverage gap is visible at a glance. This is not merely hidden when disabled; it is never computed, since detecting each test's asserted status code costs more than simply counting tests.
+
+=== A worked example
+
+An endpoint `GET /v1/foobars` declares two response codes, `200` and `404`. Two contract tests assert a `200` response and one asserts a `404`. With `includeResponseCoverage = true`, `scanContracts` reports: `200` covered by 2 test(s), `404` covered by 1 test(s).
+
+[source,groovy]
+----
+doppelgangerApiDetector {
+ rootDocument = file('src/main/resources/openapi/openapi.yaml')
+ includeResponseCoverage = true
+}
+----
+
+[source,shell]
+----
+./gradlew scanContracts
+----
+
+=== How a test's status code is detected
+
+A test's asserted status code is detected best-effort from the same call-chain shapes each verification source already recognises:
+
+[cols="1,3",options="header"]
+|===
+| Source | Status code shapes recognised
+| Spring RestDocs | MockMvc `status().isOk()` / `status().isNotFound()` / ... (the well-known `StatusResultMatchers` method names) or the numeric `status().is(404)`; WebTestClient `expectStatus().isOk()` or the numeric `expectStatus().isEqualTo(404)`.
+| Atlassian OpenAPI request validator | REST Assured `.then().statusCode(404)`.
+| Spring Cloud Contract | The contract's own `response { status 200 }` (Groovy) or `response: status: 200` (YAML) block.
+|===
+
+A test whose status code can't be detected this way - asserted through a variable, a helper method, or a custom matcher - still counts towards the endpoint's overall contract test count; it simply contributes no evidence to any specific response code's count, and is called out in the report so the data is never silently lossy.
+
+[NOTE]
+====
+A detected status code is matched against a declared response code by *exact string equality only*: a test asserting `404` counts towards a declared `"404"` response, never towards a declared `"4XX"` range wildcard or a `"default"` response - even though either might, in the OpenAPI specification's own semantics, legitimately cover that same test. This is the same class of accepted heuristic imprecision documented elsewhere in this plugin family (e.g. how a Spring Cloud Contract example path is matched against a template).
+====
+
+=== Tracking response coverage history
+
+Setting `trackResponseCoverageHistory = true` persists, across builds, a history of contract test coverage per endpoint and response code - keyed by a fingerprint of the endpoint's verb, path, and response code, tracking a live test-count gauge rather than one-time milestone timestamps. It is only meaningful together with `includeResponseCoverage`: `scanContracts` fails eagerly if `trackResponseCoverageHistory` is `true` while `includeResponseCoverage` is `false`, since there would be no per-response-code data to persist.
+
+Like `contractHistoryFile`, the plugin itself has no dependency on git or any other version control system - branch-based control over when to advance the history is entirely a CI concern, expressed through `updateResponseCoverageHistory` (overridable for the whole build via the `-PdoppelgangerApiDetector.updateResponseCoverageHistory` project property, independently of `-PdoppelgangerApiDetector.updateContractHistory`).
+
+`responseCoverageHistoryFile` is a separate NDJSON file from `contractHistoryFile` - deliberately not folded into the shared cross-plugin schema Shadow and Mirage API Detector also read, since response coverage is a Doppelganger-only concern with its own record shape:
+
+[source,json]
+----
+{"schemaVersion":1}
+{"fingerprint":"a1f3c9d0e21b7f44-200","verb":"GET","path":"/v1/foobars","responseCode":"200","testCount":2,"firstDeclaredAt":"2026-01-14T09:02:11Z","firstCoveredAt":"2026-01-20T11:15:44Z","lastSeenAt":"2026-08-12T07:00:00Z","removedAt":null}
+{"fingerprint":"a1f3c9d0e21b7f44-404","verb":"GET","path":"/v1/foobars","responseCode":"404","testCount":1,"firstDeclaredAt":"2026-01-14T09:02:11Z","firstCoveredAt":"2026-02-01T08:30:00Z","lastSeenAt":"2026-08-12T07:00:00Z","removedAt":null}
+----
+
+The generated report reflects the loaded (and possibly just-advanced) history as a `== Response Coverage Over Time` section, once `trackResponseCoverageHistory` finds at least one record.
+
== System Under Test Version
Every generated report includes a line just below the title identifying the version of the system under test that was scanned:
diff --git a/doppelganger-api-detector/build.gradle b/doppelganger-api-detector/build.gradle
index 8de716bc..cf743d44 100644
--- a/doppelganger-api-detector/build.gradle
+++ b/doppelganger-api-detector/build.gradle
@@ -98,6 +98,7 @@ jacocoTestReport {
'com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorExtension.class',
'com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPlugin.class',
'com/arc_e_tect/gradle/doppelganger/DetectDoppelgangerApisTask.class',
+ 'com/arc_e_tect/gradle/doppelganger/ScanContractsTask.class',
])
}))
}
@@ -110,6 +111,7 @@ jacocoTestCoverageVerification {
'com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorExtension.class',
'com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPlugin.class',
'com/arc_e_tect/gradle/doppelganger/DetectDoppelgangerApisTask.class',
+ 'com/arc_e_tect/gradle/doppelganger/ScanContractsTask.class',
])
}))
}
diff --git a/doppelganger-api-detector/docs/response-coverage-history-file-format.adoc b/doppelganger-api-detector/docs/response-coverage-history-file-format.adoc
new file mode 100644
index 00000000..7a615a2c
--- /dev/null
+++ b/doppelganger-api-detector/docs/response-coverage-history-file-format.adoc
@@ -0,0 +1,156 @@
+= Response Coverage History File Format
+:toc: left
+:toc-title: Contents
+:toclevels: 2
+
+This document is a complete, precise reference for the NDJSON file `doppelgangerApiDetector.responseCoverageHistoryFile` reads and writes.
+It is written for two audiences at once: a human maintainer, and an AI coding assistant (GitHub Copilot, JetBrains Junie, Claude, or similar) that needs to parse this file, reason about its semantics correctly, or build tooling around it.
+Every claim below is backed by the actual source referenced in <>; if this document and the source ever disagree, the source is authoritative.
+
+== What this file is
+
+`responseCoverageHistoryFile` is a durable, git-committed log of *how thoroughly each declared response code, for each endpoint, is covered by contract tests* - and when that coverage first appeared.
+It exists to answer questions a single build's report cannot: "has this endpoint's `404` response ever actually been exercised by a test?", "how many response codes gained test coverage in the last month?", "did a response code that used to be tested lose its coverage?"
+
+It is only written when `doppelgangerApiDetector.trackResponseCoverageHistory` is set to `true` (which itself requires `includeResponseCoverage = true` - see link:../README.adoc#_contract_response_coverage_scancontracts[the README's "Contract Response Coverage" section]).
+Unlike `contractHistoryFile`, this format is **not** shared with Shadow or Mirage API Detector - response coverage is a Doppelganger-only concern, since only Doppelganger's `scanContracts` task has any notion of contract tests or their asserted status codes.
+
+[#source-of-truth]
+== Source of truth
+
+The format, parsing, and update logic live entirely inside this plugin (not the shared `api-detector-core` library):
+
+[cols="1,3",options="header"]
+|===
+| Class | Responsibility
+| `com.arc_e_tect.gradle.doppelganger.progress.ResponseCoverageRecord` | The record type - one Java `record` per history entry, described field-by-field below.
+| `com.arc_e_tect.gradle.doppelganger.progress.ResponseCoverageHistoryStore` | Reads/writes the NDJSON file. Hand-rolled JSON serialization (no library dependency) via a fixed regex per line - the same approach `api-detector-core`'s `ContractHistoryStore` uses for `contractHistoryFile`.
+| `com.arc_e_tect.gradle.doppelganger.progress.ResponseCoverageHistoryUpdater` | Computes the *next* history map from the previous one plus the current run's `EndpointResponseCoverage` rows. This is where the "milestones stamped once" vs. "test count refreshed every run" distinction actually lives.
+| `com.arc_e_tect.gradle.doppelganger.report.ResponseCoverageTableWriter` | Renders the `== Response Coverage Over Time` section of the generated `contract-coverage.adoc` report *from* this file's loaded content.
+|===
+
+`ScanContractsTask` (`com.arc_e_tect.gradle.doppelganger.ScanContractsTask`) is a thin caller: it loads the file, calls the updater with this run's coverage rows, optionally saves the result, and passes it to the report writer. It never touches the file format directly.
+
+== Quick facts
+
+[cols="1,3",options="header"]
+|===
+| |
+| Format | Newline-delimited JSON (NDJSON) - exactly one JSON object per line, no enclosing array, no trailing comma.
+| Encoding | UTF-8.
+| Line ending | `\n` (written via `PrintWriter.println`, platform default - in practice `\n` on Linux/macOS CI).
+| Ordering | Every write sorts all records by `fingerprint` (lexicographic string sort) before writing, so re-saving an unchanged history byte-for-byte reproduces the same file.
+| Default location | `doppelganger-api-detector-response-coverage-history.ndjson`, directly in the project directory (**not** under `build/`) - deliberately, since the file is meant to be committed to version control.
+| Configurable via | `doppelgangerApiDetector.responseCoverageHistoryFile` (a `RegularFileProperty`).
+| Created when | The first time `scanContracts` runs with `includeResponseCoverage = true`, `trackResponseCoverageHistory = true`, and `updateResponseCoverageHistory` resolving to `true`, and the file does not already exist.
+| Missing file | Treated as an empty history - **not** an error.
+| Malformed line | Skipped with a `WARNING`-level `java.util.logging` message identifying the line number. Never fails the build.
+| Schema version field | The file's first line is always `{"schemaVersion":1}`, purely additive and never required on read - a file written before this existed simply has no such line.
+|===
+
+== When it is written
+
+`doppelgangerApiDetector.responseCoverageHistoryFile` is:
+
+* *Always read* when `trackResponseCoverageHistory` is `true`, on every run, regardless of `updateResponseCoverageHistory`.
+* *Written back* only when `trackResponseCoverageHistory` **and** `updateResponseCoverageHistory` are both `true`. `updateResponseCoverageHistory` defaults to the same value as `trackResponseCoverageHistory`, and is typically driven from CI via the `-PdoppelgangerApiDetector.updateResponseCoverageHistory` project property - independent of the `-PdoppelgangerApiDetector.updateContractHistory` override `contractHistoryFile` uses.
+
+`scanContracts` fails eagerly, before anything is scanned, if `trackResponseCoverageHistory` is `true` while `includeResponseCoverage` is `false` - there would be no per-response-code data to persist.
+
+== Record schema
+
+Every line is one JSON object with exactly these nine keys, always present (using JSON `null` for an absent value - never an omitted key):
+
+[cols="1,1,1,4",options="header"]
+|===
+| Key | JSON type | Nullable | Meaning
+| `fingerprint` | string | No | Stable identifier for this (endpoint, response code) pair. See <>. Acts as this record's primary key.
+| `verb` | string (enum name) | No | The HTTP verb, e.g. `GET`, `POST`. Since every OpenAPI operation is described under exactly one concrete verb, `ANY` never appears here.
+| `path` | string | No | The endpoint's path template, e.g. `/v1/foobars`.
+| `responseCode` | string | No | The response code this record tracks, exactly as declared in the OpenAPI document - e.g. `"200"`, `"404"`, `"5XX"`, `"default"`.
+| `testCount` | integer | No | The number of contract tests detected to assert this response code, as of the most recent run that observed it. Unlike every timestamp field below, this is a *live gauge*, refreshed on every run - never a "first observed" value.
+| `firstDeclaredAt` | string (ISO-8601 instant) or `null` | Yes | The instant this response code was *first* observed declared in the OpenAPI documentation for this endpoint. Stamped once; never overwritten.
+| `firstCoveredAt` | string (ISO-8601 instant) or `null` | Yes | The instant `testCount` was *first* observed greater than zero for this response code. `null` while a declared response code has never once had a covering test. Stamped once; never overwritten, even if `testCount` later drops back to zero.
+| `lastSeenAt` | string (ISO-8601 instant) or `null` | Yes | The instant of the most recent run that observed this response code as still declared. Refreshed forward on every run it's seen in.
+| `removedAt` | string (ISO-8601 instant) or `null` | Yes | `null` while the response code is still declared. Set to the instant of the first run that no longer found it among the endpoint's declared response codes. Records are **never deleted**. If the same fingerprint reappears in a later run, `removedAt` reverts to `null` and the record resumes advancing normally.
+|===
+
+Instants are serialized via plain `Instant.toString()` - standard ISO-8601 UTC, e.g. `"2026-02-20T11:15:44Z"`.
+
+[#fingerprint]
+=== How `fingerprint` is computed
+
+`fingerprint` is `-`, where `` is computed exactly as `api-detector-core`'s `EndpointFingerprint.fingerprint(Described)` computes it for `contractHistoryFile` (the first 16 hex characters of `SHA-256(" ")`) - reused as-is, not recomputed independently:
+
+[source,text]
+----
+endpointFingerprint = sha256_hex(verb.name() + " " + path.trim())[0:16]
+fingerprint = endpointFingerprint + "-" + responseCode
+----
+
+A tool that already computes `contractHistoryFile` endpoint fingerprints for the same endpoint can derive this file's fingerprint directly by appending `-` - the two files are deliberately correlatable this way, even though they are never merged into one schema.
+
+=== JSON Schema
+
+[source,json]
+----
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://arc-e-tect.com/schemas/response-coverage-history-record.json",
+ "title": "ResponseCoverageRecord",
+ "description": "One line of a doppelgangerApiDetector responseCoverageHistoryFile.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["fingerprint", "verb", "path", "responseCode", "testCount", "firstDeclaredAt", "firstCoveredAt", "lastSeenAt", "removedAt"],
+ "properties": {
+ "fingerprint": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{16}-.+$",
+ "description": "'<16-hex-char endpoint fingerprint>-'."
+ },
+ "verb": {
+ "type": "string",
+ "enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"]
+ },
+ "path": { "type": "string", "minLength": 1 },
+ "responseCode": { "type": "string", "minLength": 1 },
+ "testCount": { "type": "integer", "minimum": 0 },
+ "firstDeclaredAt": { "type": ["string", "null"], "format": "date-time" },
+ "firstCoveredAt": { "type": ["string", "null"], "format": "date-time" },
+ "lastSeenAt": { "type": ["string", "null"], "format": "date-time" },
+ "removedAt": { "type": ["string", "null"], "format": "date-time" }
+ }
+}
+----
+
+== Example file content
+
+A realistic `responseCoverageHistoryFile` for the worked example from the README - `GET /v1/foobars` declaring `200` and `404` - after several months of builds:
+
+[source,json]
+----
+{"schemaVersion":1}
+{"fingerprint":"a1f3c9d0e21b7f44-200","verb":"GET","path":"/v1/foobars","responseCode":"200","testCount":2,"firstDeclaredAt":"2026-01-14T09:02:11Z","firstCoveredAt":"2026-01-20T11:15:44Z","lastSeenAt":"2026-08-12T07:00:00Z","removedAt":null}
+{"fingerprint":"a1f3c9d0e21b7f44-404","verb":"GET","path":"/v1/foobars","responseCode":"404","testCount":1,"firstDeclaredAt":"2026-01-14T09:02:11Z","firstCoveredAt":"2026-02-01T08:30:00Z","lastSeenAt":"2026-08-12T07:00:00Z","removedAt":null}
+{"fingerprint":"a1f3c9d0e21b7f44-500","verb":"GET","path":"/v1/foobars","responseCode":"500","testCount":0,"firstDeclaredAt":"2026-06-01T08:00:00Z","firstCoveredAt":null,"lastSeenAt":"2026-08-12T07:00:00Z","removedAt":null}
+----
+
+Reading these three lines: `200` has been covered by two tests since shortly after it was declared; `404` gained its one covering test about two weeks after being declared; `500` was declared more recently and has never had a covering test at all (`firstCoveredAt` is still `null`, `testCount` is `0`) - exactly the kind of gap `includeResponseCoverage = true` exists to surface.
+
+== Relationship to the generated report
+
+Every `scanContracts` run that has `trackResponseCoverageHistory = true` and at least one record in the (loaded or just-updated) history renders a `== Response Coverage Over Time` section in `contract-coverage.adoc`, computed *entirely* from this file's content by `ResponseCoverageTableWriter`:
+
+[cols="1,3",options="header"]
+|===
+| Report row | Computed as
+| `Tracked since` | The minimum non-null value across `firstDeclaredAt`, `firstCoveredAt`, `lastSeenAt`, and `removedAt`, over every record in the file.
+| `Response codes currently tracked` | Count of records where `removedAt` is `null`.
+| `Response codes currently covered by at least one test` | Count of records where `removedAt` is `null` and `testCount > 0`.
+| `Newly covered in the last 7/30 days` | Count of records whose `firstCoveredAt` falls within `[now - 7 days, now]` / `[now - 30 days, now]`.
+| `Removed (no longer declared)` | Count of records where `removedAt` is non-null, as of this run.
+|===
+
+== Parsing this file yourself
+
+Since there is no JSON library dependency in the writer (`ResponseCoverageHistoryStore` hand-rolls its own line format via a fixed regular expression - see <>), a tool that wants strict format compatibility should mirror that same parser rather than assuming a generic JSON-lines parser behaves identically on every edge case. For everyday consumption, each line is valid standalone JSON, and any standard JSON parser applied line-by-line reads every well-formed record correctly; the only free-text fields are `path` and `responseCode` (backslash and double-quote are backslash-escaped, nothing else).
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ContractScanSupport.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ContractScanSupport.java
new file mode 100644
index 00000000..338b3208
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ContractScanSupport.java
@@ -0,0 +1,176 @@
+package com.arc_e_tect.gradle.doppelganger;
+
+import com.arc_e_tect.gradle.detector.core.console.ScanProgressReporter;
+import com.arc_e_tect.gradle.detector.core.exclude.ExclusionRule;
+import com.arc_e_tect.gradle.detector.core.exclude.ExclusionRuleFile;
+import com.arc_e_tect.gradle.detector.core.exclude.WellKnownExclusionSets;
+import com.arc_e_tect.gradle.detector.core.model.Endpoint;
+import com.arc_e_tect.gradle.detector.core.scan.ControllerScanner;
+import org.gradle.api.GradleException;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.logging.Logger;
+import org.gradle.api.provider.ListProperty;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Bootstrapping-gap handling and scanning building blocks shared by {@link DetectDoppelgangerApisTask}
+ * and {@link ScanContractsTask} - both scan the same kind of {@code controllerDirs}, apply the same
+ * exclusion rules, and need to treat a not-yet-existing configured directory as a warning rather
+ * than a build failure. Deliberately package-private and stateless: every method takes exactly the
+ * inputs it needs rather than a reference to either task, so it stays trivially reusable without
+ * coupling the two task classes to each other.
+ */
+final class ContractScanSupport {
+
+ private ContractScanSupport() {}
+
+ /**
+ * The result of checking a configured set of source directories: every {@code .java} file
+ * found under an existing one, which configured entries don't exist yet, and whether at least
+ * one configured entry exists at all.
+ *
+ * @param javaFiles every {@code .java} file found recursively under an existing directory
+ * @param missingDirs configured directories that don't exist yet
+ * @param anyDirExists whether at least one configured directory exists
+ */
+ record DirectoryScanResult(List javaFiles, List missingDirs, boolean anyDirExists) {
+
+ /**
+ * Whether every configured directory is missing - distinct from "zero directories were
+ * configured at all", which is a valid, complete input rather than a bootstrapping gap.
+ */
+ boolean allConfiguredDirsMissing() {
+ return !missingDirs.isEmpty() && !anyDirExists;
+ }
+ }
+
+ /**
+ * Scans {@code dirs} for {@code .java} files, recording which configured entries don't exist
+ * yet.
+ *
+ * @param dirs the configured source directories
+ * @return the scan result
+ */
+ static DirectoryScanResult scanJavaSourceDirs(Iterable dirs) {
+ List missingDirs = new ArrayList<>();
+ List javaFiles = new ArrayList<>();
+ boolean anyDirExists = false;
+ for (File dir : dirs) {
+ if (dir.isDirectory()) {
+ anyDirExists = true;
+ javaFiles.addAll(collectJavaFiles(dir));
+ } else {
+ missingDirs.add(dir);
+ }
+ }
+ return new DirectoryScanResult(javaFiles, missingDirs, anyDirExists);
+ }
+
+ /**
+ * Recursively collects every {@code .java} file under {@code dir}.
+ *
+ * @param dir the directory to search; a non-directory path yields an empty list
+ * @return every {@code .java} file found, in no particular order
+ */
+ static List collectJavaFiles(File dir) {
+ List files = new ArrayList<>();
+ collectJavaFiles(dir, files);
+ return files;
+ }
+
+ private static void collectJavaFiles(File dir, List files) {
+ if (!dir.isDirectory()) {
+ return;
+ }
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isFile() && child.getName().endsWith(".java")) {
+ files.add(child);
+ } else if (child.isDirectory()) {
+ collectJavaFiles(child, files);
+ }
+ }
+ }
+
+ /**
+ * Scans every file in {@code controllerFiles} for {@code @RestController} endpoints, reporting
+ * determinate progress to {@code logger} as it goes.
+ *
+ * @param controllerFiles the {@code .java} files to scan
+ * @param logger the task's logger, used for the progress banner
+ * @return every endpoint found, across all scanned files
+ * @throws GradleException if a file cannot be scanned
+ */
+ static List scanControllerFiles(List controllerFiles, Logger logger) {
+ ControllerScanner controllerScanner = new ControllerScanner();
+ List implemented = new ArrayList<>();
+ ScanProgressReporter progress =
+ ScanProgressReporter.determinate(logger, "Scanning @RestController classes", controllerFiles.size());
+ for (File javaFile : controllerFiles) {
+ try {
+ implemented.addAll(controllerScanner.scan(javaFile));
+ } catch (IOException e) {
+ throw new GradleException("doppelgangerApiDetector: failed to scan " + javaFile, e);
+ }
+ progress.step();
+ }
+ progress.complete();
+ return implemented;
+ }
+
+ /**
+ * Resolves every configured exclusion rule - {@code excludePaths}, {@code excludeFiles}, and
+ * {@code excludeWellKnown} - into one combined list. A missing {@code excludeFiles} entry only
+ * warns, the same way a missing source directory does; a malformed rule string or an
+ * unrecognised well-known set name fails the build outright, since those are build-script/file
+ * mistakes, not a "not built yet" bootstrapping gap.
+ *
+ * @param excludePaths exclusion rule strings
+ * @param excludeFiles exclusion rule files
+ * @param excludeWellKnown bundled well-known exclusion set names
+ * @param warnings appended to for a missing {@code excludeFiles} entry
+ * @return the combined, resolved exclusion rules
+ * @throws GradleException if a rule string, file, or well-known set name is invalid
+ */
+ static List resolveExclusionRules(
+ ListProperty excludePaths, ConfigurableFileCollection excludeFiles,
+ ListProperty excludeWellKnown, List warnings) {
+ List rules = new ArrayList<>();
+ for (String entry : excludePaths.get()) {
+ try {
+ rules.add(ExclusionRule.parse(entry));
+ } catch (IllegalArgumentException e) {
+ throw new GradleException(
+ "doppelgangerApiDetector: invalid `excludePaths` entry: " + e.getMessage(), e);
+ }
+ }
+ for (File file : excludeFiles) {
+ if (!file.isFile()) {
+ warnings.add("Configured `excludeFiles` entry does not exist yet: `" + file + "`.");
+ continue;
+ }
+ try {
+ rules.addAll(ExclusionRuleFile.load(file));
+ } catch (IOException e) {
+ throw new GradleException("doppelgangerApiDetector: failed to read excludeFiles entry " + file, e);
+ } catch (IllegalArgumentException e) {
+ throw new GradleException("doppelgangerApiDetector: " + e.getMessage(), e);
+ }
+ }
+ for (String name : excludeWellKnown.get()) {
+ try {
+ rules.addAll(WellKnownExclusionSets.resolve(name));
+ } catch (IllegalArgumentException e) {
+ throw new GradleException("doppelgangerApiDetector: " + e.getMessage(), e);
+ }
+ }
+ return rules;
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DetectDoppelgangerApisTask.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DetectDoppelgangerApisTask.java
index 31980237..7bf7e38d 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DetectDoppelgangerApisTask.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DetectDoppelgangerApisTask.java
@@ -4,8 +4,6 @@
import com.arc_e_tect.gradle.detector.core.detect.ContractSetOperations;
import com.arc_e_tect.gradle.detector.core.exclude.ExclusionFilter;
import com.arc_e_tect.gradle.detector.core.exclude.ExclusionRule;
-import com.arc_e_tect.gradle.detector.core.exclude.ExclusionRuleFile;
-import com.arc_e_tect.gradle.detector.core.exclude.WellKnownExclusionSets;
import com.arc_e_tect.gradle.detector.core.model.Endpoint;
import com.arc_e_tect.gradle.detector.core.openapi.DescribedEndpoint;
import com.arc_e_tect.gradle.detector.core.openapi.OpenApiEndpointCollector;
@@ -13,7 +11,6 @@
import com.arc_e_tect.gradle.detector.core.progress.ContractHistoryUpdater;
import com.arc_e_tect.gradle.detector.core.progress.ContractProgressRecord;
import com.arc_e_tect.gradle.detector.core.progress.LegacyContractHistoryFormatException;
-import com.arc_e_tect.gradle.detector.core.scan.ControllerScanner;
import com.arc_e_tect.gradle.doppelganger.detect.ContractVerificationSource;
import com.arc_e_tect.gradle.doppelganger.detect.DoppelgangerApiFinder;
import com.arc_e_tect.gradle.doppelganger.report.DoppelgangerApiReportWriter;
@@ -351,24 +348,15 @@ public void generate() {
List warnings = new ArrayList<>();
- List missingControllerDirs = new ArrayList<>();
- List controllerFiles = new ArrayList<>();
- boolean anyControllerDirExists = false;
- for (File dir : getControllerDirs()) {
- if (dir.isDirectory()) {
- anyControllerDirExists = true;
- controllerFiles.addAll(collectJavaFiles(dir));
- } else {
- missingControllerDirs.add(dir);
- }
- }
+ ContractScanSupport.DirectoryScanResult controllerScan =
+ ContractScanSupport.scanJavaSourceDirs(getControllerDirs());
// Deliberately distinct from "controllerDirs has zero entries at all", which is a valid,
// complete input (nothing to scan by design) rather than a bootstrapping gap, and must not
// silently skip detection below.
- boolean controllerSourceMissing = !missingControllerDirs.isEmpty() && !anyControllerDirExists;
- if (!missingControllerDirs.isEmpty()) {
- if (anyControllerDirExists) {
- for (File dir : missingControllerDirs) {
+ boolean controllerSourceMissing = controllerScan.allConfiguredDirsMissing();
+ if (!controllerScan.missingDirs().isEmpty()) {
+ if (controllerScan.anyDirExists()) {
+ for (File dir : controllerScan.missingDirs()) {
warnings.add("Configured `controllerDirs` entry does not exist yet: `" + dir + "`.");
}
} else {
@@ -381,19 +369,7 @@ public void generate() {
int phase = 0;
phase = announcePhase(phase, totalPhases, "Scanning @RestController classes...");
- ControllerScanner controllerScanner = new ControllerScanner();
- List implemented = new ArrayList<>();
- ScanProgressReporter controllerScanProgress = ScanProgressReporter.determinate(
- getLogger(), "Scanning @RestController classes", controllerFiles.size());
- for (File javaFile : controllerFiles) {
- try {
- implemented.addAll(controllerScanner.scan(javaFile));
- } catch (IOException e) {
- throw new GradleException("doppelgangerApiDetector: failed to scan " + javaFile, e);
- }
- controllerScanProgress.step();
- }
- controllerScanProgress.complete();
+ List implemented = ContractScanSupport.scanControllerFiles(controllerScan.javaFiles(), getLogger());
phase = announcePhase(phase, totalPhases, "Collecting OpenAPI endpoints...");
boolean openApiAvailable = isRootDocumentAvailable();
@@ -419,7 +395,8 @@ public void generate() {
List doppelgangers = inputComplete
? new DoppelgangerApiFinder().findDoppelgangers(declaredAndImplemented, verified) : List.of();
- List exclusionRules = resolveExclusionRules(warnings);
+ List exclusionRules = ContractScanSupport.resolveExclusionRules(
+ getExcludePaths(), getExcludeFiles(), getExcludeWellKnown(), warnings);
List reportableDoppelgangers = ExclusionFilter.excludeMatching(doppelgangers, exclusionRules);
List excludedDoppelgangers = ExclusionFilter.onlyMatching(doppelgangers, exclusionRules);
@@ -452,47 +429,6 @@ reportableDoppelgangers, excludedDoppelgangers, getSystemUnderTestVersion().get(
}
}
- /**
- * Resolves every configured exclusion rule - {@link #getExcludePaths()},
- * {@link #getExcludeFiles()}, and {@link #getExcludeWellKnown()} - into one combined list. A
- * missing {@code excludeFiles} entry only warns, the same way a missing {@code controllerDirs}
- * or {@code testDirs} entry does; a malformed rule string or an unrecognised well-known set
- * name fails the build outright, since those are build-script/file mistakes, not a
- * "not built yet" bootstrapping gap.
- */
- private List resolveExclusionRules(List warnings) {
- List rules = new ArrayList<>();
- for (String entry : getExcludePaths().get()) {
- try {
- rules.add(ExclusionRule.parse(entry));
- } catch (IllegalArgumentException e) {
- throw new GradleException(
- "doppelgangerApiDetector: invalid `excludePaths` entry: " + e.getMessage(), e);
- }
- }
- for (File file : getExcludeFiles()) {
- if (!file.isFile()) {
- warnings.add("Configured `excludeFiles` entry does not exist yet: `" + file + "`.");
- continue;
- }
- try {
- rules.addAll(ExclusionRuleFile.load(file));
- } catch (IOException e) {
- throw new GradleException("doppelgangerApiDetector: failed to read excludeFiles entry " + file, e);
- } catch (IllegalArgumentException e) {
- throw new GradleException("doppelgangerApiDetector: " + e.getMessage(), e);
- }
- }
- for (String name : getExcludeWellKnown().get()) {
- try {
- rules.addAll(WellKnownExclusionSets.resolve(name));
- } catch (IllegalArgumentException e) {
- throw new GradleException("doppelgangerApiDetector: " + e.getMessage(), e);
- }
- }
- return rules;
- }
-
/**
* Whether {@link #getRootDocument()} is both configured and points to a file that actually
* exists - the precondition for OpenAPI-based comparison being meaningful at all.
@@ -724,26 +660,4 @@ private List scanTestDirs(ContractVerificationSource source) throws IO
return results;
}
- private List collectJavaFiles(File dir) {
- List files = new ArrayList<>();
- collectJavaFiles(dir, files);
- return files;
- }
-
- private void collectJavaFiles(File dir, List files) {
- if (!dir.isDirectory()) {
- return;
- }
- File[] children = dir.listFiles();
- if (children == null) {
- return;
- }
- for (File child : children) {
- if (child.isFile() && child.getName().endsWith(".java")) {
- files.add(child);
- } else if (child.isDirectory()) {
- collectJavaFiles(child, files);
- }
- }
- }
}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorExtension.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorExtension.java
index 453b61ad..0b3fc385 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorExtension.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorExtension.java
@@ -30,12 +30,22 @@
* // excludePaths.add('/actuator/health') // default: empty
* // excludeFiles.from('doppelganger-exclusions.yaml') // default: empty
* // excludeWellKnown.add('spring-boot-actuator') // default: empty
+ *
+ * // Configuration for the separate `scanContracts` task - reuses controllerDirs, testDirs,
+ * // rootDocument, contractsDir, useRestDocs/useOpenApiRequestValidator/useSpringCloudContract,
+ * // and the exclude* properties above.
+ * includeResponseCoverage = false // default
+ * // scanContractsReportFileName = 'contract-coverage.adoc' // default
+ * trackResponseCoverageHistory = false // default
+ * // responseCoverageHistoryFile = file('doppelganger-api-detector-response-coverage-history.ndjson') // default
+ * updateResponseCoverageHistory = trackResponseCoverageHistory // default; see getUpdateResponseCoverageHistory()
* }
*
*
* {@code updateContractHistory} can be overridden for the whole build from the command line,
* e.g. {@code -PdoppelgangerApiDetector.updateContractHistory=true} - see
- * {@link #getUpdateContractHistory()}.
+ * {@link #getUpdateContractHistory()}. {@code updateResponseCoverageHistory} has its own,
+ * independent override - see {@link #getUpdateResponseCoverageHistory()}.
*/
public abstract class DoppelgangerApiDetectorExtension {
@@ -74,6 +84,23 @@ public DoppelgangerApiDetectorExtension() {}
public static final String UPDATE_CONTRACT_HISTORY_OVERRIDE_PROPERTY =
"doppelgangerApiDetector.updateContractHistory";
+ /** Default name of the {@code scanContracts} task's generated AsciiDoc report file. */
+ public static final String DEFAULT_SCAN_CONTRACTS_REPORT_FILE_NAME = "contract-coverage.adoc";
+
+ /** Default name of the persisted response coverage history file. */
+ public static final String DEFAULT_RESPONSE_COVERAGE_HISTORY_FILE_NAME =
+ "doppelganger-api-detector-response-coverage-history.ndjson";
+
+ /**
+ * Name of the Gradle project property that overrides
+ * {@link #getUpdateResponseCoverageHistory()} from the command line for every project in the
+ * build, e.g. {@code -PdoppelgangerApiDetector.updateResponseCoverageHistory=true}. Takes
+ * precedence over any project's own configured {@code updateResponseCoverageHistory} value.
+ * The value is parsed as a boolean.
+ */
+ public static final String UPDATE_RESPONSE_COVERAGE_HISTORY_OVERRIDE_PROPERTY =
+ "doppelgangerApiDetector.updateResponseCoverageHistory";
+
/**
* Directories to search recursively for {@code @RestController} classes. One or more
* directories may be configured. Defaults to {@value #DEFAULT_CONTROLLER_DIR}.
@@ -275,4 +302,72 @@ public DoppelgangerApiDetectorExtension() {}
* @return mutable list property of well-known exclusion set names
*/
public abstract ListProperty getExcludeWellKnown();
+
+ /**
+ * Whether the {@code scanContracts} task additionally computes, for every declared response
+ * code, how many contract tests cover it. Defaults to {@code false}: the breakdown is not
+ * merely hidden when disabled, it is never computed - this is the more expensive of the two
+ * statistics {@code scanContracts} can report, since it requires detecting the asserted status
+ * code of every matching contract test, not just whether one exists.
+ *
+ * For example, an endpoint {@code GET /v1/foobars} declaring response codes {@code 200} and
+ * {@code 404}, with two contract tests asserting {@code 200} and one asserting {@code 404},
+ * reports {@code 200} as covered by 2 test(s) and {@code 404} as covered by 1 test(s) when this
+ * is {@code true}.
+ *
+ * @return mutable boolean property controlling whether response coverage is computed
+ */
+ public abstract Property getIncludeResponseCoverage();
+
+ /**
+ * Name of the {@code scanContracts} task's generated AsciiDoc report file (without path),
+ * written to the same {@link #getReportDir()}. Defaults to
+ * {@value #DEFAULT_SCAN_CONTRACTS_REPORT_FILE_NAME}.
+ *
+ * @return mutable string property for the scanContracts report file name
+ */
+ public abstract Property getScanContractsReportFileName();
+
+ /**
+ * Whether to persist, across builds, a history of response code coverage - keyed by endpoint
+ * fingerprint and response code, tracking a live test-count gauge rather than milestone
+ * timestamps. Defaults to {@code false}. Only meaningful together with
+ * {@link #getIncludeResponseCoverage()} - {@code scanContracts} fails eagerly if this is
+ * {@code true} while that is {@code false}, since there would be no per-response-code data to
+ * persist.
+ *
+ * @return mutable boolean property controlling whether response coverage history is tracked
+ */
+ public abstract Property getTrackResponseCoverageHistory();
+
+ /**
+ * File that the persisted response coverage history is read from and, when
+ * {@link #getUpdateResponseCoverageHistory()} is {@code true}, written back to. Defaults to
+ * {@value #DEFAULT_RESPONSE_COVERAGE_HISTORY_FILE_NAME} directly in the project directory -
+ * deliberately not under {@code build/}, for the same reason as
+ * {@link #getContractHistoryFile()}. Only consulted when
+ * {@link #getTrackResponseCoverageHistory()} is {@code true}. Deliberately a separate file from
+ * {@link #getContractHistoryFile()}: response coverage is a Doppelganger-only concern with a
+ * different record schema, not shared with Shadow or Mirage API Detector.
+ *
+ * @return mutable file property for the response coverage history file
+ */
+ public abstract RegularFileProperty getResponseCoverageHistoryFile();
+
+ /**
+ * Whether {@link #getResponseCoverageHistoryFile()} is written back to disk after being updated
+ * with the current run's coverage. Defaults to the same value as
+ * {@link #getTrackResponseCoverageHistory()}. Only consulted when
+ * {@link #getTrackResponseCoverageHistory()} is {@code true}; the history file is always read
+ * regardless of this property's value.
+ *
+ * The {@value #UPDATE_RESPONSE_COVERAGE_HISTORY_OVERRIDE_PROPERTY} project property, when
+ * set, overrides this property for every project in the build - the same
+ * per-branch-CI-pipeline pattern {@link #getUpdateContractHistory()} supports, independently of
+ * it.
+ *
+ * @return mutable boolean property controlling whether the response coverage history file is
+ * written back
+ */
+ public abstract Property getUpdateResponseCoverageHistory();
}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPlugin.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPlugin.java
index 24749608..e40cba03 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPlugin.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPlugin.java
@@ -52,9 +52,12 @@
*/
public class DoppelgangerApiDetectorPlugin implements Plugin {
- /** Name of the Gradle task registered by this plugin. */
+ /** Name of the doppelganger-detection Gradle task registered by this plugin. */
public static final String TASK_NAME = "detectDoppelgangerApis";
+ /** Name of the contract-coverage-scanning Gradle task registered by this plugin. */
+ public static final String SCAN_CONTRACTS_TASK_NAME = "scanContracts";
+
/** Creates a new plugin instance. Instantiated by Gradle infrastructure. */
public DoppelgangerApiDetectorPlugin() {}
@@ -88,13 +91,31 @@ public void apply(Project project) {
ext.getExcludePaths().convention(List.of());
ext.getExcludeWellKnown().convention(List.of());
+ ext.getIncludeResponseCoverage().convention(false);
+ ext.getScanContractsReportFileName().convention(
+ DoppelgangerApiDetectorExtension.DEFAULT_SCAN_CONTRACTS_REPORT_FILE_NAME);
+ ext.getTrackResponseCoverageHistory().convention(false);
+ ext.getResponseCoverageHistoryFile().convention(project.getLayout().getProjectDirectory()
+ .file(DoppelgangerApiDetectorExtension.DEFAULT_RESPONSE_COVERAGE_HISTORY_FILE_NAME));
+ // updateResponseCoverageHistory defaults to trackResponseCoverageHistory's own value,
+ // tracking it live rather than snapshotting it at this point - same pattern as
+ // updateContractHistory above.
+ ext.getUpdateResponseCoverageHistory().convention(ext.getTrackResponseCoverageHistory());
+
// The -PdoppelgangerApiDetector.updateContractHistory= project property, when
// set, overrides updateContractHistory for every project in the build - regardless of what
// any project's own extension configures - typically used to advance the committed history
// only from the branch(es) whose CI pipeline should, without touching the build script.
Provider updateContractHistoryCliOverride = project.getProviders()
.gradleProperty(DoppelgangerApiDetectorExtension.UPDATE_CONTRACT_HISTORY_OVERRIDE_PROPERTY)
- .map(DoppelgangerApiDetectorPlugin::parseUpdateContractHistory);
+ .map(value -> parseBooleanOverride(
+ DoppelgangerApiDetectorExtension.UPDATE_CONTRACT_HISTORY_OVERRIDE_PROPERTY, value));
+ // Independent override for the response coverage history file - see
+ // getUpdateResponseCoverageHistory()'s own javadoc.
+ Provider updateResponseCoverageHistoryCliOverride = project.getProviders()
+ .gradleProperty(DoppelgangerApiDetectorExtension.UPDATE_RESPONSE_COVERAGE_HISTORY_OVERRIDE_PROPERTY)
+ .map(value -> parseBooleanOverride(
+ DoppelgangerApiDetectorExtension.UPDATE_RESPONSE_COVERAGE_HISTORY_OVERRIDE_PROPERTY, value));
TaskProvider taskProvider =
project.getTasks().register(TASK_NAME, DetectDoppelgangerApisTask.class, task -> {
@@ -119,32 +140,62 @@ public void apply(Project project) {
task.getExcludeWellKnown().set(ext.getExcludeWellKnown());
});
+ TaskProvider scanContractsTaskProvider =
+ project.getTasks().register(SCAN_CONTRACTS_TASK_NAME, ScanContractsTask.class, task -> {
+ task.getControllerDirs().from(ext.getControllerDirs());
+ task.getTestDirs().from(ext.getTestDirs());
+ task.getRootDocument().set(ext.getRootDocument());
+ task.getOpenApiDir().set(ext.getOpenApiDir());
+ task.getContractsDir().set(ext.getContractsDir());
+ task.getUseRestDocs().set(ext.getUseRestDocs());
+ task.getUseOpenApiRequestValidator().set(ext.getUseOpenApiRequestValidator());
+ task.getUseSpringCloudContract().set(ext.getUseSpringCloudContract());
+ task.getIncludeResponseCoverage().set(ext.getIncludeResponseCoverage());
+ task.getReportDir().set(ext.getReportDir());
+ task.getReportFileName().set(ext.getScanContractsReportFileName());
+ task.getSystemUnderTestVersion().set(ext.getSystemUnderTestVersion());
+ task.getTrackResponseCoverageHistory().set(ext.getTrackResponseCoverageHistory());
+ task.getResponseCoverageHistoryFile().set(ext.getResponseCoverageHistoryFile());
+ task.getUpdateResponseCoverageHistory().set(updateResponseCoverageHistoryCliOverride
+ .orElse(ext.getUpdateResponseCoverageHistory()));
+ task.getExcludePaths().set(ext.getExcludePaths());
+ task.getExcludeFiles().from(ext.getExcludeFiles());
+ task.getExcludeWellKnown().set(ext.getExcludeWellKnown());
+ });
+
// Default controllerDirs/testDirs only when the user has not configured them themselves;
// deferred to afterEvaluate so the check happens once the build script has had a chance to
- // configure the extension.
+ // configure the extension. Applied identically to both tasks' providers, since they share
+ // the same controllerDirs/testDirs configuration.
project.afterEvaluate(p -> {
- if (ext.getControllerDirs().isEmpty()) {
+ boolean controllerDirsUserConfigured = !ext.getControllerDirs().isEmpty();
+ boolean testDirsUserConfigured = !ext.getTestDirs().isEmpty();
+ if (!controllerDirsUserConfigured) {
taskProvider.configure(task -> task.getControllerDirs()
.from(p.file(DoppelgangerApiDetectorExtension.DEFAULT_CONTROLLER_DIR)));
+ scanContractsTaskProvider.configure(task -> task.getControllerDirs()
+ .from(p.file(DoppelgangerApiDetectorExtension.DEFAULT_CONTROLLER_DIR)));
}
- boolean testDirsUserConfigured = !ext.getTestDirs().isEmpty();
if (!testDirsUserConfigured) {
taskProvider.configure(task -> task.getTestDirs()
.from(p.file(DoppelgangerApiDetectorExtension.DEFAULT_TEST_DIR)));
+ scanContractsTaskProvider.configure(task -> task.getTestDirs()
+ .from(p.file(DoppelgangerApiDetectorExtension.DEFAULT_TEST_DIR)));
}
// See DetectDoppelgangerApisTask#getTestDirsUserConfigured(): only a user-configured
// testDirs entry that doesn't exist yet is a bootstrapping gap worth suppressing
// detection for - the plugin's own default missing just means this project has no such
// evidence, by design.
taskProvider.configure(task -> task.getTestDirsUserConfigured().set(testDirsUserConfigured));
+ scanContractsTaskProvider.configure(task -> task.getTestDirsUserConfigured().set(testDirsUserConfigured));
});
}
/**
- * Parses the {@code -PdoppelgangerApiDetector.updateContractHistory=} project property's
- * value, accepting {@code true}/{@code false} case-insensitively.
+ * Parses a {@code -PdoppelgangerApiDetector.=} project property's value,
+ * accepting {@code true}/{@code false} case-insensitively.
*/
- private static boolean parseUpdateContractHistory(String value) {
+ private static boolean parseBooleanOverride(String propertyName, String value) {
String normalized = value.trim().toLowerCase(Locale.ROOT);
if ("true".equals(normalized)) {
return true;
@@ -153,8 +204,7 @@ private static boolean parseUpdateContractHistory(String value) {
return false;
}
throw new GradleException(
- "doppelgangerApiDetector: invalid value '" + value + "' for -P"
- + DoppelgangerApiDetectorExtension.UPDATE_CONTRACT_HISTORY_OVERRIDE_PROPERTY
+ "doppelgangerApiDetector: invalid value '" + value + "' for -P" + propertyName
+ "; expected 'true' or 'false'");
}
}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTask.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTask.java
new file mode 100644
index 00000000..5ecacd89
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTask.java
@@ -0,0 +1,433 @@
+package com.arc_e_tect.gradle.doppelganger;
+
+import com.arc_e_tect.gradle.detector.core.console.ScanProgressReporter;
+import com.arc_e_tect.gradle.detector.core.detect.ContractSetOperations;
+import com.arc_e_tect.gradle.detector.core.exclude.ExclusionFilter;
+import com.arc_e_tect.gradle.detector.core.exclude.ExclusionRule;
+import com.arc_e_tect.gradle.detector.core.model.Endpoint;
+import com.arc_e_tect.gradle.detector.core.openapi.DescribedEndpoint;
+import com.arc_e_tect.gradle.detector.core.openapi.OpenApiEndpointCollector;
+import com.arc_e_tect.gradle.doppelganger.detect.ContractVerificationSource;
+import com.arc_e_tect.gradle.doppelganger.detect.EndpointResponseCoverage;
+import com.arc_e_tect.gradle.doppelganger.detect.ResponseCoverageAnalyzer;
+import com.arc_e_tect.gradle.doppelganger.detect.VerifiedContractTest;
+import com.arc_e_tect.gradle.doppelganger.progress.ResponseCoverageHistoryStore;
+import com.arc_e_tect.gradle.doppelganger.progress.ResponseCoverageHistoryUpdater;
+import com.arc_e_tect.gradle.doppelganger.progress.ResponseCoverageRecord;
+import com.arc_e_tect.gradle.doppelganger.report.ScanContractsReportWriter;
+import com.arc_e_tect.gradle.doppelganger.scan.OpenApiRequestValidatorScanner;
+import com.arc_e_tect.gradle.doppelganger.scan.OpenApiServerBasePath;
+import com.arc_e_tect.gradle.doppelganger.scan.RestDocsScanner;
+import com.arc_e_tect.gradle.doppelganger.scan.SpringCloudContractScanner;
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.DirectoryProperty;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.ListProperty;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.InputFiles;
+import org.gradle.api.tasks.Internal;
+import org.gradle.api.tasks.Optional;
+import org.gradle.api.tasks.OutputDirectory;
+import org.gradle.api.tasks.PathSensitive;
+import org.gradle.api.tasks.PathSensitivity;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.work.DisableCachingByDefault;
+
+import javax.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Gradle task that, for every endpoint both declared in the configured OpenAPI documentation and
+ * implemented by a {@code @RestController} method, reports how many response codes its operation
+ * declares and how many contract tests exist for it - and, when
+ * {@link DoppelgangerApiDetectorExtension#getIncludeResponseCoverage()} is enabled, how many of
+ * those tests cover each declared response code.
+ *
+ * Unlike {@link DetectDoppelgangerApisTask}, this task never fails the build on its own
+ * initiative - it is purely a reporting task, answering "how well is this API covered", not "is
+ * this API compliant".
+ *
+ * Registered automatically by {@link DoppelgangerApiDetectorPlugin} under the name
+ * {@code scanContracts}.
+ */
+@DisableCachingByDefault(because = "Report depends on source, test, contract, and OpenAPI document content and is cheap to regenerate")
+public abstract class ScanContractsTask extends DefaultTask {
+
+ /** Directories to search recursively for {@code @RestController} classes. */
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public abstract ConfigurableFileCollection getControllerDirs();
+
+ /** Directories to search recursively for test classes. */
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public abstract ConfigurableFileCollection getTestDirs();
+
+ /** See {@link DetectDoppelgangerApisTask#getTestDirsUserConfigured()}. */
+ @Input
+ public abstract Property getTestDirsUserConfigured();
+
+ /** The root OpenAPI document describing the API. */
+ @Optional
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public abstract RegularFileProperty getRootDocument();
+
+ /** Directory where OpenAPI descriptions are stored. */
+ @Optional
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public abstract DirectoryProperty getOpenApiDir();
+
+ /** Directory searched for Spring Cloud Contract DSL files when {@link #getUseSpringCloudContract()} is {@code true}. */
+ @Optional
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public abstract DirectoryProperty getContractsDir();
+
+ /** Whether to treat Spring RestDocs test methods as verification evidence. */
+ @Input
+ public abstract Property getUseRestDocs();
+
+ /** Whether to treat Atlassian OpenAPI request validator usage as verification evidence. */
+ @Input
+ public abstract Property getUseOpenApiRequestValidator();
+
+ /** Whether to treat Spring Cloud Contract DSL files as verification evidence. */
+ @Input
+ public abstract Property getUseSpringCloudContract();
+
+ /**
+ * Whether to additionally compute, for every declared response code, how many contract tests
+ * cover it. Defaults to {@code false}: the breakdown is not merely hidden when disabled, it is
+ * never computed.
+ *
+ * @return mutable boolean property controlling whether response coverage is computed
+ */
+ @Input
+ public abstract Property getIncludeResponseCoverage();
+
+ /** Directory the AsciiDoc report is written to. */
+ @OutputDirectory
+ public abstract DirectoryProperty getReportDir();
+
+ /** Name of the generated AsciiDoc report file (without path). */
+ @Input
+ public abstract Property getReportFileName();
+
+ /** Version of the system under test whose {@code @RestController} classes were scanned. */
+ @Input
+ public abstract Property getSystemUnderTestVersion();
+
+ /**
+ * Whether to persist, across builds, a history of response code coverage. Only meaningful
+ * together with {@link #getIncludeResponseCoverage()} - see {@link #generate()}'s eager
+ * validation of that combination.
+ */
+ @Input
+ public abstract Property getTrackResponseCoverageHistory();
+
+ /**
+ * File that the persisted response coverage history is read from and, when
+ * {@link #getUpdateResponseCoverageHistory()} is {@code true}, written back to. See
+ * {@link DetectDoppelgangerApisTask#getContractHistoryFile()} for why this is {@code @Internal}
+ * rather than tracked through Gradle's file-content-based up-to-date checking.
+ */
+ @Internal
+ public abstract RegularFileProperty getResponseCoverageHistoryFile();
+
+ /**
+ * The absolute path of {@link #getResponseCoverageHistoryFile()}, tracked as a plain
+ * {@code @Input} value - see {@link DetectDoppelgangerApisTask#getContractHistoryFilePath()}.
+ *
+ * @return the response coverage history file's absolute path, or {@code null} if unset
+ */
+ @Input
+ @Optional
+ public String getResponseCoverageHistoryFilePath() {
+ return getResponseCoverageHistoryFile().map(file -> file.getAsFile().getAbsolutePath()).getOrNull();
+ }
+
+ /**
+ * Whether {@link #getResponseCoverageHistoryFile()} is written back to disk after being updated
+ * with the current run's coverage. Only consulted when {@link #getTrackResponseCoverageHistory()}
+ * is {@code true}; the history file is always read regardless.
+ */
+ @Input
+ public abstract Property getUpdateResponseCoverageHistory();
+
+ /** Exclusion rule strings - see {@link DoppelgangerApiDetectorExtension#getExcludePaths()}. */
+ @Input
+ public abstract ListProperty getExcludePaths();
+
+ /** External exclusion rule files - see {@link DoppelgangerApiDetectorExtension#getExcludeFiles()}. */
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public abstract ConfigurableFileCollection getExcludeFiles();
+
+ /** Bundled well-known exclusion set names - see {@link DoppelgangerApiDetectorExtension#getExcludeWellKnown()}. */
+ @Input
+ public abstract ListProperty getExcludeWellKnown();
+
+ /** Creates the task. Instantiated by Gradle infrastructure via {@link javax.inject.Inject}. */
+ @Inject
+ public ScanContractsTask() {
+ setGroup("verification");
+ setDescription("Scans OpenAPI documentation, @RestController implementations, and contract test "
+ + "evidence, and reports the declared response codes and contract test count for every "
+ + "declared-and-implemented endpoint - and, when includeResponseCoverage is enabled, how many "
+ + "tests cover each declared response code.");
+ getTestDirsUserConfigured().convention(true);
+ }
+
+ /**
+ * Task action: scans the same candidate endpoints {@link DetectDoppelgangerApisTask} does, but
+ * reports response-code and contract-test coverage rather than a pass/fail verdict. Never fails
+ * the build on its own initiative. Bootstrapping-gap handling (a missing {@link #getRootDocument()},
+ * empty {@link #getControllerDirs()}, etc.) follows the same "warn, don't fail" philosophy as
+ * {@link DetectDoppelgangerApisTask#generate()} - see that method's javadoc for the full rationale.
+ *
+ * Two DSL configurations are rejected eagerly: every verification source disabled at once (no
+ * test evidence could ever be gathered), {@link #getUseSpringCloudContract()} enabled with
+ * {@link #getContractsDir()} unconfigured, and - specific to this task -
+ * {@link #getTrackResponseCoverageHistory()} enabled while {@link #getIncludeResponseCoverage()}
+ * is disabled, since there would be no per-response-code data to persist.
+ */
+ @TaskAction
+ public void generate() {
+ boolean useRestDocs = getUseRestDocs().get();
+ boolean useOpenApiRequestValidator = getUseOpenApiRequestValidator().get();
+ boolean useSpringCloudContract = getUseSpringCloudContract().get();
+ if (!useRestDocs && !useOpenApiRequestValidator && !useSpringCloudContract) {
+ throw new GradleException("doppelgangerApiDetector: at least one of useRestDocs, "
+ + "useOpenApiRequestValidator, or useSpringCloudContract must be enabled - with all "
+ + "three disabled, no contract test evidence could ever be gathered.");
+ }
+ if (useSpringCloudContract && !getContractsDir().isPresent()) {
+ throw new GradleException("doppelgangerApiDetector: contractsDir must be configured when "
+ + "useSpringCloudContract is enabled - it has no default location.");
+ }
+ boolean includeResponseCoverage = getIncludeResponseCoverage().get();
+ if (getTrackResponseCoverageHistory().get() && !includeResponseCoverage) {
+ throw new GradleException("doppelgangerApiDetector: trackResponseCoverageHistory requires "
+ + "includeResponseCoverage to be enabled - with it disabled, no per-response-code "
+ + "coverage is computed for there to be a history of.");
+ }
+
+ List warnings = new ArrayList<>();
+
+ ContractScanSupport.DirectoryScanResult controllerScan =
+ ContractScanSupport.scanJavaSourceDirs(getControllerDirs());
+ boolean controllerSourceMissing = controllerScan.allConfiguredDirsMissing();
+ if (!controllerScan.missingDirs().isEmpty()) {
+ if (controllerScan.anyDirExists()) {
+ for (File dir : controllerScan.missingDirs()) {
+ warnings.add("Configured `controllerDirs` entry does not exist yet: `" + dir + "`.");
+ }
+ } else {
+ warnings.add("None of the configured `controllerDirs` exist yet. Contract scanning was "
+ + "skipped for this run - once at least one exists, re-run this task to check it.");
+ }
+ }
+
+ int totalPhases = countTotalPhases();
+ int phase = 0;
+
+ phase = announcePhase(phase, totalPhases, "Scanning @RestController classes...");
+ List implemented = ContractScanSupport.scanControllerFiles(controllerScan.javaFiles(), getLogger());
+
+ phase = announcePhase(phase, totalPhases, "Collecting OpenAPI endpoints...");
+ boolean openApiAvailable = isRootDocumentAvailable();
+ File rootDocument = openApiAvailable ? getRootDocument().getAsFile().get() : null;
+ List described;
+ if (openApiAvailable) {
+ ScanProgressReporter openApiProgress =
+ ScanProgressReporter.indeterminate(getLogger(), "Resolving OpenAPI documents");
+ described = new OpenApiEndpointCollector().collect(rootDocument, file -> openApiProgress.step());
+ openApiProgress.complete();
+ } else {
+ described = List.of();
+ warnings.add(describeMissingRootDocument());
+ }
+
+ // Note the argument order relative to DetectDoppelgangerApisTask: the DescribedEndpoint side
+ // must be preserved here, since it's the one carrying responseCodes().
+ List declaredAndImplemented = ContractSetOperations.intersection(described, implemented);
+
+ VerificationTestScan verificationScan = collectVerifiedTests(phase, totalPhases, rootDocument, warnings);
+
+ boolean inputComplete =
+ openApiAvailable && !controllerSourceMissing && !verificationScan.verificationInputMissing();
+
+ List exclusionRules = ContractScanSupport.resolveExclusionRules(
+ getExcludePaths(), getExcludeFiles(), getExcludeWellKnown(), warnings);
+ List candidates =
+ ExclusionFilter.excludeMatching(declaredAndImplemented, exclusionRules);
+
+ List coverage = inputComplete
+ ? new ResponseCoverageAnalyzer().analyze(candidates, verificationScan.tests(), includeResponseCoverage)
+ : List.of();
+
+ Map history = !getTrackResponseCoverageHistory().get() ? Map.of()
+ : inputComplete ? updateResponseCoverageHistory(coverage) : loadResponseCoverageHistoryForDisplay();
+
+ File outputDir = getReportDir().getAsFile().get();
+ File outputFile = new File(outputDir, getReportFileName().get());
+ try {
+ new ScanContractsReportWriter().write(
+ outputFile, coverage, includeResponseCoverage, getSystemUnderTestVersion().get(), warnings,
+ history);
+ } catch (IOException e) {
+ throw new GradleException("doppelgangerApiDetector: failed to write report to " + outputFile, e);
+ }
+
+ getLogger().lifecycle(
+ "Contract Scan: scanned {} declared-and-implemented endpoint(s). Report → {}",
+ coverage.size(), outputFile);
+ }
+
+ private boolean isRootDocumentAvailable() {
+ return getRootDocument().isPresent() && getRootDocument().getAsFile().get().isFile();
+ }
+
+ private String describeMissingRootDocument() {
+ if (!getRootDocument().isPresent()) {
+ return "`rootDocument` is not configured yet. Contract scanning was skipped for this run - "
+ + "configure it once your OpenAPI documentation exists.";
+ }
+ return "The configured `rootDocument` does not exist yet: `" + getRootDocument().getAsFile().get()
+ + "`. Contract scanning was skipped for this run - once the file exists, re-run this task to "
+ + "check it.";
+ }
+
+ private Map loadResponseCoverageHistoryForDisplay() {
+ File historyFile = getResponseCoverageHistoryFile().getAsFile().get();
+ return new ResponseCoverageHistoryStore().load(historyFile);
+ }
+
+ private Map updateResponseCoverageHistory(List currentRun) {
+ File historyFile = getResponseCoverageHistoryFile().getAsFile().get();
+ ResponseCoverageHistoryStore store = new ResponseCoverageHistoryStore();
+ Map previous = store.load(historyFile);
+ Map updated =
+ new ResponseCoverageHistoryUpdater().update(previous, currentRun, Instant.now());
+ if (getUpdateResponseCoverageHistory().get()) {
+ store.save(historyFile, updated.values());
+ }
+ return updated;
+ }
+
+ /**
+ * The verification evidence found by every enabled source, together with whether at least one
+ * currently-enabled source was unable to gather any evidence at all - the same
+ * "was every enabled source usable" signal {@link DetectDoppelgangerApisTask#generate()} computes
+ * for itself, just carrying {@link VerifiedContractTest} instead of a plain {@link Endpoint}.
+ */
+ private record VerificationTestScan(List tests, boolean verificationInputMissing) {}
+
+ private VerificationTestScan collectVerifiedTests(
+ int phase, int totalPhases, File rootDocument, List warnings) {
+ boolean useRestDocs = getUseRestDocs().get();
+ boolean useOpenApiRequestValidator = getUseOpenApiRequestValidator().get();
+ boolean useSpringCloudContract = getUseSpringCloudContract().get();
+
+ boolean testDirsNeeded = useRestDocs || useOpenApiRequestValidator;
+ boolean testDirsUserConfigured = getTestDirsUserConfigured().get();
+ List missingTestDirs = new ArrayList<>();
+ boolean anyTestDirExists = false;
+ if (testDirsNeeded) {
+ for (File dir : getTestDirs()) {
+ if (dir.isDirectory()) {
+ anyTestDirExists = true;
+ } else {
+ missingTestDirs.add(dir);
+ }
+ }
+ if (testDirsUserConfigured && !missingTestDirs.isEmpty()) {
+ if (anyTestDirExists) {
+ for (File dir : missingTestDirs) {
+ warnings.add("Configured `testDirs` entry does not exist yet: `" + dir + "`.");
+ }
+ } else {
+ warnings.add("None of the configured `testDirs` exist yet, so no Spring RestDocs or "
+ + "OpenAPI request validator verification evidence could be gathered for this run.");
+ }
+ }
+ }
+ boolean testDirsSourceMissing =
+ testDirsUserConfigured && testDirsNeeded && !missingTestDirs.isEmpty() && !anyTestDirExists;
+
+ boolean contractsDirConfigured = getContractsDir().isPresent();
+ File contractsDir = contractsDirConfigured ? getContractsDir().getAsFile().get() : null;
+ boolean contractsDirExists = contractsDirConfigured && contractsDir.isDirectory();
+ boolean contractsDirSourceMissing = useSpringCloudContract && contractsDirConfigured && !contractsDirExists;
+ if (contractsDirSourceMissing) {
+ warnings.add("Configured `contractsDir` does not exist yet: `" + contractsDir + "`.");
+ }
+
+ List tests = new ArrayList<>();
+ try {
+ if (useRestDocs) {
+ phase = announcePhase(phase, totalPhases, "Scanning Spring RestDocs verification evidence...");
+ String serverBasePath = rootDocument == null ? "" : OpenApiServerBasePath.resolve(rootDocument);
+ tests.addAll(scanTestDirsWithStatusCodes(new RestDocsScanner(serverBasePath)));
+ }
+ if (useOpenApiRequestValidator) {
+ phase = announcePhase(phase, totalPhases,
+ "Scanning OpenAPI request validator verification evidence...");
+ tests.addAll(scanTestDirsWithStatusCodes(new OpenApiRequestValidatorScanner()));
+ }
+ if (useSpringCloudContract && contractsDirExists) {
+ announcePhase(phase, totalPhases, "Scanning Spring Cloud Contract verification evidence...");
+ tests.addAll(new SpringCloudContractScanner().scanWithStatusCodes(contractsDir));
+ }
+ } catch (IOException e) {
+ throw new GradleException("doppelgangerApiDetector: failed to scan verification evidence", e);
+ }
+
+ boolean anySourceEnabled = useRestDocs || useOpenApiRequestValidator || useSpringCloudContract;
+ boolean anyEnabledSourceUsable = (useRestDocs && !testDirsSourceMissing)
+ || (useOpenApiRequestValidator && !testDirsSourceMissing)
+ || (useSpringCloudContract && !contractsDirSourceMissing);
+ boolean verificationInputMissing = anySourceEnabled && !anyEnabledSourceUsable;
+
+ return new VerificationTestScan(tests, verificationInputMissing);
+ }
+
+ private int countTotalPhases() {
+ int total = 2;
+ if (getUseRestDocs().get()) {
+ total++;
+ }
+ if (getUseOpenApiRequestValidator().get()) {
+ total++;
+ }
+ if (getUseSpringCloudContract().get() && getContractsDir().isPresent()
+ && getContractsDir().getAsFile().get().isDirectory()) {
+ total++;
+ }
+ return total;
+ }
+
+ private int announcePhase(int phase, int totalPhases, String phaseLabel) {
+ int nextPhase = phase + 1;
+ getLogger().lifecycle("Contract Scan: [{}/{}] {}", nextPhase, totalPhases, phaseLabel);
+ return nextPhase;
+ }
+
+ private List scanTestDirsWithStatusCodes(ContractVerificationSource source) throws IOException {
+ List results = new ArrayList<>();
+ for (File testDir : getTestDirs()) {
+ results.addAll(source.scanWithStatusCodes(testDir));
+ }
+ return results;
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ContractEvidenceMatcher.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ContractEvidenceMatcher.java
new file mode 100644
index 00000000..56efe0b2
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ContractEvidenceMatcher.java
@@ -0,0 +1,36 @@
+package com.arc_e_tect.gradle.doppelganger.detect;
+
+import com.arc_e_tect.gradle.detector.core.Described;
+import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+import com.arc_e_tect.gradle.detector.core.model.PathMatcher;
+
+/**
+ * Whether one piece of verification evidence counts as verifying a candidate endpoint - shared by
+ * {@link DoppelgangerApiFinder} and {@link ResponseCoverageAnalyzer} so both apply exactly the same
+ * matching rule.
+ *
+ * Deliberately not {@code api-detector-core}'s {@code ContractSetOperations}: see
+ * {@link DoppelgangerApiFinder}'s own javadoc for why a concrete, resolved verified path (e.g. from
+ * Spring Cloud Contract) needs {@link PathMatcher#matchesConcrete(String, String)} rather than the
+ * symmetric, template-vs-template matching {@code ContractSetOperations} performs.
+ */
+final class ContractEvidenceMatcher {
+
+ private ContractEvidenceMatcher() {}
+
+ /**
+ * Returns whether {@code verifiedEntry} counts as verification evidence for {@code candidate}:
+ * same verb - or either side carries {@link HttpVerb#ANY} - and {@code verifiedEntry}'s path is
+ * a valid instance of {@code candidate}'s path template.
+ *
+ * @param verifiedEntry the verb + path of a piece of verification evidence
+ * @param candidate the verb + path template of the candidate endpoint being checked
+ * @return {@code true} when {@code verifiedEntry} verifies {@code candidate}
+ */
+ static boolean verifies(Described verifiedEntry, Described candidate) {
+ boolean verbMatches = candidate.verb() == HttpVerb.ANY
+ || verifiedEntry.verb() == HttpVerb.ANY
+ || candidate.verb() == verifiedEntry.verb();
+ return verbMatches && PathMatcher.matchesConcrete(verifiedEntry.path(), candidate.path());
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ContractVerificationSource.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ContractVerificationSource.java
index ab8b3fef..59bdb82a 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ContractVerificationSource.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ContractVerificationSource.java
@@ -4,6 +4,7 @@
import java.io.File;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.List;
/**
@@ -33,4 +34,26 @@ public interface ContractVerificationSource {
* @throws IOException if a file under {@code rootDir} cannot be read
*/
List scan(File rootDir) throws IOException;
+
+ /**
+ * Scans {@code rootDir} recursively, same as {@link #scan(File)}, but additionally reports the
+ * HTTP status code each piece of evidence was detected to assert, when a source is able to
+ * determine one.
+ *
+ * The default implementation delegates to {@link #scan(File)} and reports every entry with
+ * no status code, so an implementation that has no meaningful way to detect one simply inherits
+ * correct, backward-compatible behavior without overriding anything.
+ *
+ * @param rootDir the directory to scan recursively; scanning a non-existent or non-directory
+ * path returns an empty list rather than failing
+ * @return possibly-empty list of verified contract tests, never {@code null}
+ * @throws IOException if a file under {@code rootDir} cannot be read
+ */
+ default List scanWithStatusCodes(File rootDir) throws IOException {
+ List results = new ArrayList<>();
+ for (Endpoint endpoint : scan(rootDir)) {
+ results.add(new VerifiedContractTest(endpoint, null));
+ }
+ return results;
+ }
}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/DoppelgangerApiFinder.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/DoppelgangerApiFinder.java
index 3ddcd9a5..18ce87a8 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/DoppelgangerApiFinder.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/DoppelgangerApiFinder.java
@@ -1,7 +1,6 @@
package com.arc_e_tect.gradle.doppelganger.detect;
import com.arc_e_tect.gradle.detector.core.model.Endpoint;
-import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
import com.arc_e_tect.gradle.detector.core.model.PathMatcher;
import java.util.ArrayList;
@@ -44,24 +43,12 @@ public DoppelgangerApiFinder() {}
public List findDoppelgangers(List declaredAndImplemented, List verified) {
List doppelgangers = new ArrayList<>();
for (Endpoint candidate : declaredAndImplemented) {
- boolean isVerified = verified.stream().anyMatch(verifiedEntry -> verifies(verifiedEntry, candidate));
+ boolean isVerified = verified.stream()
+ .anyMatch(verifiedEntry -> ContractEvidenceMatcher.verifies(verifiedEntry, candidate));
if (!isVerified) {
doppelgangers.add(candidate);
}
}
return doppelgangers;
}
-
- /**
- * Returns whether {@code verifiedEntry} counts as verification evidence for
- * {@code candidate}: same verb - or either side carries {@link HttpVerb#ANY}, the same
- * ANY-verb tolerance the rest of the detector family applies - and {@code verifiedEntry}'s
- * path is a valid instance of {@code candidate}'s path template.
- */
- private boolean verifies(Endpoint verifiedEntry, Endpoint candidate) {
- boolean verbMatches = candidate.verb() == HttpVerb.ANY
- || verifiedEntry.verb() == HttpVerb.ANY
- || candidate.verb() == verifiedEntry.verb();
- return verbMatches && PathMatcher.matchesConcrete(verifiedEntry.path(), candidate.path());
- }
}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/EndpointResponseCoverage.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/EndpointResponseCoverage.java
new file mode 100644
index 00000000..8f40d704
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/EndpointResponseCoverage.java
@@ -0,0 +1,46 @@
+package com.arc_e_tect.gradle.doppelganger.detect;
+
+import com.arc_e_tect.gradle.detector.core.openapi.DescribedEndpoint;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The {@code scanContracts} result for a single endpoint: how many response codes its OpenAPI
+ * operation declares, how many contract tests exist for it in total, and - only when
+ * {@code includeResponseCoverage} is enabled - how many of those tests cover each declared
+ * response code.
+ *
+ * @param endpoint the declared-and-implemented endpoint this row describes,
+ * carrying its declared response codes via
+ * {@link DescribedEndpoint#responseCodes()}
+ * @param contractTestCount the number of distinct contract tests found for this endpoint,
+ * across every enabled verification source, regardless of
+ * whether their asserted status code could be determined
+ * @param untrackedTestCount the number of those tests whose asserted status code either
+ * could not be determined, or was determined but is not among
+ * this endpoint's declared response codes - counted in
+ * {@link #contractTestCount()} but not in
+ * {@link #testCountByResponseCode()}; always {@code 0} when
+ * response coverage was not requested
+ * @param testCountByResponseCode for every response code {@link DescribedEndpoint#responseCodes()}
+ * declares, the number of tests detected to assert it - including
+ * an entry with value {@code 0} for a declared code no test
+ * covers; empty when response coverage was not requested
+ */
+public record EndpointResponseCoverage(
+ DescribedEndpoint endpoint,
+ int contractTestCount,
+ int untrackedTestCount,
+ Map testCountByResponseCode) {
+
+ /**
+ * The declared response codes for this endpoint - a convenience view of
+ * {@link DescribedEndpoint#responseCodes()}.
+ *
+ * @return the declared response codes, sorted, possibly empty
+ */
+ public List declaredResponseCodes() {
+ return endpoint.responseCodes();
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ResponseCoverageAnalyzer.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ResponseCoverageAnalyzer.java
new file mode 100644
index 00000000..d202989e
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/ResponseCoverageAnalyzer.java
@@ -0,0 +1,76 @@
+package com.arc_e_tect.gradle.doppelganger.detect;
+
+import com.arc_e_tect.gradle.detector.core.openapi.DescribedEndpoint;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Computes, for every endpoint both declared in the OpenAPI documentation and implemented by a
+ * {@code @RestController} method, how many contract tests exist for it and - only when requested -
+ * how many of those tests cover each of its declared response codes.
+ *
+ * A verified test's status code is matched against a declared response code by exact
+ * string equality only: a test detected to assert {@code "404"} counts towards a declared
+ * {@code "404"} response, never towards a declared {@code "4XX"} range wildcard or a
+ * {@code "default"} response, even though either might, in the OpenAPI document's own semantics,
+ * legitimately cover that same test. Resolving that would require interpreting the OpenAPI
+ * response-matching rules themselves, not just comparing two strings - the same kind of imprecision
+ * {@link com.arc_e_tect.gradle.detector.core.model.PathMatcher#matchesConcrete(String, String)}
+ * already documents for path matching.
+ */
+public class ResponseCoverageAnalyzer {
+
+ /** Creates a new {@code ResponseCoverageAnalyzer}. */
+ public ResponseCoverageAnalyzer() {}
+
+ /**
+ * Computes the coverage rows for {@code declaredAndImplemented}.
+ *
+ * @param declaredAndImplemented the candidate endpoints - declared in the OpenAPI
+ * documentation and implemented by a {@code @RestController}
+ * method - carrying their declared response codes
+ * @param verifiedTests every piece of verification evidence gathered from the
+ * enabled {@link ContractVerificationSource}s
+ * @param includeResponseCoverage whether to compute the per-response-code breakdown at all;
+ * when {@code false}, {@link EndpointResponseCoverage#testCountByResponseCode()}
+ * is empty and {@link EndpointResponseCoverage#untrackedTestCount()}
+ * is {@code 0} for every row - the breakdown is not merely
+ * hidden, it is never computed
+ * @return one {@link EndpointResponseCoverage} per candidate, in {@code declaredAndImplemented}'s
+ * order
+ */
+ public List analyze(
+ List declaredAndImplemented, List verifiedTests,
+ boolean includeResponseCoverage) {
+ List results = new ArrayList<>();
+ for (DescribedEndpoint candidate : declaredAndImplemented) {
+ List matching = verifiedTests.stream()
+ .filter(test -> ContractEvidenceMatcher.verifies(test.endpoint(), candidate))
+ .toList();
+
+ if (!includeResponseCoverage) {
+ results.add(new EndpointResponseCoverage(candidate, matching.size(), 0, Map.of()));
+ continue;
+ }
+
+ Map testCountByResponseCode = new LinkedHashMap<>();
+ for (String responseCode : candidate.responseCodes()) {
+ testCountByResponseCode.put(responseCode, 0);
+ }
+ int untracked = 0;
+ for (VerifiedContractTest test : matching) {
+ String statusCode = test.statusCode();
+ if (statusCode != null && testCountByResponseCode.containsKey(statusCode)) {
+ testCountByResponseCode.merge(statusCode, 1, Integer::sum);
+ } else {
+ untracked++;
+ }
+ }
+ results.add(new EndpointResponseCoverage(candidate, matching.size(), untracked, testCountByResponseCode));
+ }
+ return results;
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/VerifiedContractTest.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/VerifiedContractTest.java
new file mode 100644
index 00000000..a12a9964
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/detect/VerifiedContractTest.java
@@ -0,0 +1,18 @@
+package com.arc_e_tect.gradle.doppelganger.detect;
+
+import com.arc_e_tect.gradle.detector.core.model.Endpoint;
+
+/**
+ * A single piece of contract verification evidence, together with the HTTP status code it was
+ * detected to assert - the richer counterpart of a plain {@link Endpoint} produced by
+ * {@link ContractVerificationSource#scanWithStatusCodes(java.io.File)}.
+ *
+ * @param endpoint the verified endpoint, identifying both what it verifies (verb + path) and,
+ * via {@link Endpoint#declaringClass()}/{@link Endpoint#methodSignature()}, the
+ * test (or contract file) that supplied the evidence
+ * @param statusCode the HTTP status code this test was detected to assert, e.g. {@code "404"}, or
+ * {@code null} when the verification source found the test but could not
+ * determine what status it asserts
+ */
+public record VerifiedContractTest(Endpoint endpoint, String statusCode) {
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryStore.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryStore.java
new file mode 100644
index 00000000..fdcd702b
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryStore.java
@@ -0,0 +1,182 @@
+package com.arc_e_tect.gradle.doppelganger.progress;
+
+import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Reads and writes {@link ResponseCoverageRecord}s as newline-delimited JSON (NDJSON), one record
+ * per line, sorted by {@link ResponseCoverageRecord#fingerprint()} so that git diffs of the
+ * persisted file are minimal and stable - the same hand-rolled, dependency-free approach
+ * {@code api-detector-core}'s {@code ContractHistoryStore} uses for the cross-plugin contract
+ * progress history.
+ */
+public class ResponseCoverageHistoryStore {
+
+ private static final Logger LOGGER = Logger.getLogger(ResponseCoverageHistoryStore.class.getName());
+
+ private static final int CURRENT_SCHEMA_VERSION = 1;
+ private static final Pattern SCHEMA_VERSION_LINE = Pattern.compile("^\\{\"schemaVersion\":(\\d+)\\}$");
+
+ private static final String STRING_FIELD = "\"((?:[^\"\\\\]|\\\\.)*)\"";
+ private static final String INSTANT_FIELD = "(null|\"[^\"]*\")";
+ private static final Pattern LINE_PATTERN = Pattern.compile(
+ "^\\{"
+ + "\"fingerprint\":" + STRING_FIELD + ","
+ + "\"verb\":" + STRING_FIELD + ","
+ + "\"path\":" + STRING_FIELD + ","
+ + "\"responseCode\":" + STRING_FIELD + ","
+ + "\"testCount\":(\\d+),"
+ + "\"firstDeclaredAt\":" + INSTANT_FIELD + ","
+ + "\"firstCoveredAt\":" + INSTANT_FIELD + ","
+ + "\"lastSeenAt\":" + INSTANT_FIELD + ","
+ + "\"removedAt\":" + INSTANT_FIELD
+ + "\\}$");
+
+ /** Creates a new {@code ResponseCoverageHistoryStore}. */
+ public ResponseCoverageHistoryStore() {}
+
+ /**
+ * Loads the response coverage history from {@code file}.
+ *
+ * @param file the NDJSON history file; need not exist
+ * @return the records keyed by fingerprint; empty when {@code file} doesn't exist. A line that
+ * fails to parse is skipped with a {@code WARN}-level log message identifying the line
+ * number - it never fails the build.
+ */
+ public Map load(File file) {
+ Map records = new LinkedHashMap<>();
+ if (!file.isFile()) {
+ return records;
+ }
+
+ List lines = readLines(file);
+
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ if (i == 0 && SCHEMA_VERSION_LINE.matcher(line).matches()) {
+ continue;
+ }
+ ResponseCoverageRecord record = parseLine(line);
+ if (record != null) {
+ records.put(record.fingerprint(), record);
+ continue;
+ }
+ LOGGER.log(Level.WARNING,
+ "doppelgangerApiDetector: skipping malformed response coverage history line {0} in {1}",
+ new Object[] {i + 1, file});
+ }
+ return records;
+ }
+
+ /**
+ * Writes {@code records} to {@code file} as NDJSON, sorted by
+ * {@link ResponseCoverageRecord#fingerprint()}, overwriting any existing content.
+ *
+ * @param file the NDJSON history file to write
+ * @param records the records to persist
+ */
+ public void save(File file, Collection records) {
+ List sorted = new ArrayList<>(records);
+ sorted.sort(Comparator.comparing(ResponseCoverageRecord::fingerprint));
+
+ try (PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
+ writer.println("{\"schemaVersion\":" + CURRENT_SCHEMA_VERSION + "}");
+ for (ResponseCoverageRecord record : sorted) {
+ writer.println(toJson(record));
+ }
+ } catch (IOException e) {
+ throw new IllegalStateException(
+ "doppelgangerApiDetector: could not write response coverage history file: " + file, e);
+ }
+ }
+
+ private List readLines(File file) {
+ try {
+ return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new IllegalStateException(
+ "doppelgangerApiDetector: could not read response coverage history file: " + file, e);
+ }
+ }
+
+ private String toJson(ResponseCoverageRecord record) {
+ return "{"
+ + "\"fingerprint\":\"" + record.fingerprint() + "\","
+ + "\"verb\":\"" + record.verb().name() + "\","
+ + "\"path\":\"" + escape(record.path()) + "\","
+ + "\"responseCode\":\"" + escape(record.responseCode()) + "\","
+ + "\"testCount\":" + record.testCount() + ","
+ + "\"firstDeclaredAt\":" + instantJson(record.firstDeclaredAt()) + ","
+ + "\"firstCoveredAt\":" + instantJson(record.firstCoveredAt()) + ","
+ + "\"lastSeenAt\":" + instantJson(record.lastSeenAt()) + ","
+ + "\"removedAt\":" + instantJson(record.removedAt())
+ + "}";
+ }
+
+ private String instantJson(Instant instant) {
+ return instant == null ? "null" : "\"" + instant + "\"";
+ }
+
+ private String escape(String value) {
+ return value.replace("\\", "\\\\").replace("\"", "\\\"");
+ }
+
+ private String unescape(String value) {
+ StringBuilder result = new StringBuilder(value.length());
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (c == '\\' && i + 1 < value.length()) {
+ i++;
+ result.append(value.charAt(i));
+ } else {
+ result.append(c);
+ }
+ }
+ return result.toString();
+ }
+
+ private ResponseCoverageRecord parseLine(String line) {
+ Matcher matcher = LINE_PATTERN.matcher(line);
+ if (!matcher.matches()) {
+ return null;
+ }
+ try {
+ return new ResponseCoverageRecord(
+ matcher.group(1),
+ HttpVerb.valueOf(matcher.group(2)),
+ unescape(matcher.group(3)),
+ unescape(matcher.group(4)),
+ Integer.parseInt(matcher.group(5)),
+ parseInstant(matcher.group(6)),
+ parseInstant(matcher.group(7)),
+ parseInstant(matcher.group(8)),
+ parseInstant(matcher.group(9)));
+ } catch (DateTimeParseException | IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private Instant parseInstant(String jsonValue) {
+ return "null".equals(jsonValue) ? null : Instant.parse(jsonValue.substring(1, jsonValue.length() - 1));
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryUpdater.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryUpdater.java
new file mode 100644
index 00000000..6580cb68
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryUpdater.java
@@ -0,0 +1,90 @@
+package com.arc_e_tect.gradle.doppelganger.progress;
+
+import com.arc_e_tect.gradle.detector.core.progress.EndpointFingerprint;
+import com.arc_e_tect.gradle.doppelganger.detect.EndpointResponseCoverage;
+
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Produces the next {@link ResponseCoverageRecord} map to persist, from the previously persisted
+ * map and this run's {@link EndpointResponseCoverage} rows - the response-coverage counterpart of
+ * {@code api-detector-core}'s {@code ContractHistoryUpdater}.
+ *
+ * Unlike that shared updater, {@link ResponseCoverageRecord#testCount()} is a live gauge:
+ * refreshed on every run that observes the (endpoint, response code) pair, not a milestone stamped
+ * once. {@link ResponseCoverageRecord#firstDeclaredAt()} and
+ * {@link ResponseCoverageRecord#firstCoveredAt()} - the latter set the first time
+ * {@code testCount > 0} - are the only fields with that once-only semantics. Every previously
+ * persisted record whose fingerprint is not seen this run keeps its last known values with
+ * {@code removedAt} stamped the first time it goes missing; a record that reappears has
+ * {@code removedAt} reset to {@code null}. Records are never deleted.
+ */
+public class ResponseCoverageHistoryUpdater {
+
+ private final EndpointFingerprint fingerprinter = new EndpointFingerprint();
+
+ /** Creates a new {@code ResponseCoverageHistoryUpdater}. */
+ public ResponseCoverageHistoryUpdater() {}
+
+ /**
+ * Computes the updated history map to persist.
+ *
+ * @param existing the previously persisted history, keyed by fingerprint; empty on a first
+ * run
+ * @param currentRun this run's response coverage rows, computed with
+ * {@code includeResponseCoverage} enabled
+ * @param now the instant to stamp newly-reached milestones and newly-observed removals
+ * with
+ * @return the updated history map, keyed by fingerprint
+ */
+ public Map update(
+ Map existing, List currentRun, Instant now) {
+ Map updated = new LinkedHashMap<>(existing);
+ Set seen = new HashSet<>();
+
+ for (EndpointResponseCoverage row : currentRun) {
+ String endpointFingerprint = fingerprinter.fingerprint(row.endpoint());
+ for (Map.Entry entry : row.testCountByResponseCode().entrySet()) {
+ String responseCode = entry.getKey();
+ int testCount = entry.getValue();
+ String fingerprint = endpointFingerprint + "-" + responseCode;
+ seen.add(fingerprint);
+
+ ResponseCoverageRecord record = updated.getOrDefault(fingerprint, blank(fingerprint, responseCode));
+ Instant firstDeclaredAt = record.firstDeclaredAt() != null ? record.firstDeclaredAt() : now;
+ Instant firstCoveredAt = record.firstCoveredAt() != null ? record.firstCoveredAt()
+ : testCount > 0 ? now : null;
+
+ updated.put(fingerprint, new ResponseCoverageRecord(
+ fingerprint, row.endpoint().verb(), row.endpoint().path(), responseCode, testCount,
+ firstDeclaredAt, firstCoveredAt, now, null));
+ }
+ }
+
+ for (Map.Entry entry : existing.entrySet()) {
+ if (!seen.contains(entry.getKey())) {
+ updated.put(entry.getKey(), markRemoved(entry.getValue(), now));
+ }
+ }
+
+ return updated;
+ }
+
+ private ResponseCoverageRecord blank(String fingerprint, String responseCode) {
+ return new ResponseCoverageRecord(fingerprint, null, null, responseCode, 0, null, null, null, null);
+ }
+
+ private ResponseCoverageRecord markRemoved(ResponseCoverageRecord record, Instant now) {
+ if (record.removedAt() != null) {
+ return record;
+ }
+ return new ResponseCoverageRecord(
+ record.fingerprint(), record.verb(), record.path(), record.responseCode(), record.testCount(),
+ record.firstDeclaredAt(), record.firstCoveredAt(), record.lastSeenAt(), now);
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageRecord.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageRecord.java
new file mode 100644
index 00000000..a1937a07
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageRecord.java
@@ -0,0 +1,43 @@
+package com.arc_e_tect.gradle.doppelganger.progress;
+
+import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+
+import java.time.Instant;
+
+/**
+ * Persisted, per-endpoint-and-response-code history of contract test coverage, keyed by
+ * {@code -} (see {@link ResponseCoverageHistoryUpdater}).
+ *
+ * Deliberately separate from {@code api-detector-core}'s cross-plugin
+ * {@code ContractProgressRecord}: response coverage is a Doppelganger-only concern with a different
+ * shape - {@link #testCount()} is a live gauge refreshed every run, not a milestone timestamp
+ * stamped once.
+ *
+ * @param fingerprint the stable identifier for this endpoint + response code pair
+ * @param verb the endpoint's current HTTP verb; not part of the key, refreshed on every
+ * run that observes this endpoint
+ * @param path the endpoint's current path template; not part of the key, refreshed on
+ * every run that observes this endpoint
+ * @param responseCode the response code this record tracks, e.g. {@code "200"}, {@code "404"}
+ * @param testCount the number of contract tests detected to cover this response code as of
+ * the most recent run that observed it
+ * @param firstDeclaredAt when this response code was first observed declared in the OpenAPI
+ * documentation, or {@code null} if it never has been
+ * @param firstCoveredAt when this response code was first observed covered by at least one
+ * contract test ({@code testCount > 0}), or {@code null} if it never has been
+ * @param lastSeenAt when this response code was last present in a run that could see it, or
+ * {@code null} for a record that has never actually been seen
+ * @param removedAt when this response code was first observed missing from the endpoint's
+ * declared response codes, or {@code null} while it's still declared
+ */
+public record ResponseCoverageRecord(
+ String fingerprint,
+ HttpVerb verb,
+ String path,
+ String responseCode,
+ int testCount,
+ Instant firstDeclaredAt,
+ Instant firstCoveredAt,
+ Instant lastSeenAt,
+ Instant removedAt) {
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/report/ResponseCoverageTableWriter.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/report/ResponseCoverageTableWriter.java
new file mode 100644
index 00000000..cb176dc2
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/report/ResponseCoverageTableWriter.java
@@ -0,0 +1,91 @@
+package com.arc_e_tect.gradle.doppelganger.report;
+
+import com.arc_e_tect.gradle.doppelganger.progress.ResponseCoverageRecord;
+
+import java.io.PrintWriter;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.stream.Stream;
+
+/**
+ * Writes the {@code == Response Coverage Over Time} AsciiDoc table section for the
+ * {@code scanContracts} report, from a loaded/advanced {@link ResponseCoverageRecord} history map.
+ */
+public class ResponseCoverageTableWriter {
+
+ private static final DateTimeFormatter TRACKED_SINCE_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss 'UTC'").withZone(ZoneOffset.UTC);
+
+ /** Creates a new {@code ResponseCoverageTableWriter}. */
+ public ResponseCoverageTableWriter() {}
+
+ /**
+ * Writes the {@code == Response Coverage Over Time} section to {@code writer}, or nothing at
+ * all when {@code history} is empty.
+ *
+ * @param writer the AsciiDoc output to append to
+ * @param history the response coverage history to summarise, keyed by fingerprint
+ */
+ public void write(PrintWriter writer, Map history) {
+ if (history.isEmpty()) {
+ return;
+ }
+
+ Instant now = Instant.now();
+ Instant trackedSince = history.values().stream()
+ .flatMap(record -> Stream.of(
+ record.firstDeclaredAt(), record.firstCoveredAt(), record.lastSeenAt(), record.removedAt()))
+ .filter(instant -> instant != null)
+ .min(Comparator.naturalOrder())
+ .orElse(null);
+
+ long trackedCodes = history.values().stream().filter(record -> record.removedAt() == null).count();
+ long coveredCodes = history.values().stream()
+ .filter(record -> record.removedAt() == null && record.testCount() > 0)
+ .count();
+ long removedNotSeen = history.values().stream().filter(record -> record.removedAt() != null).count();
+
+ writer.println("== Response Coverage Over Time");
+ writer.println();
+ writer.println("[cols=\"1,1\",options=\"header\"]");
+ writer.println("|===");
+ writer.println("| Metric | Value");
+ writer.println();
+ writer.println("| Tracked since");
+ writer.println("| " + (trackedSince != null ? TRACKED_SINCE_FORMATTER.format(trackedSince) : "N/A"));
+ writer.println();
+ writer.println("| Response codes currently tracked");
+ writer.println("| " + trackedCodes);
+ writer.println();
+ writer.println("| Response codes currently covered by at least one test");
+ writer.println("| " + coveredCodes);
+ writer.println();
+ writeWindowedMetric(writer, "Newly covered", history, now);
+ writer.println("| Removed (no longer declared)");
+ writer.println("| " + removedNotSeen);
+ writer.println("|===");
+ writer.println();
+ }
+
+ private void writeWindowedMetric(
+ PrintWriter writer, String label, Map history, Instant now) {
+ writer.println("| " + label + " in the last 7 days");
+ writer.println("| " + countWithin(history, now, Duration.ofDays(7)));
+ writer.println();
+ writer.println("| " + label + " in the last 30 days");
+ writer.println("| " + countWithin(history, now, Duration.ofDays(30)));
+ writer.println();
+ }
+
+ private long countWithin(Map history, Instant now, Duration window) {
+ Instant threshold = now.minus(window);
+ return history.values().stream()
+ .map(ResponseCoverageRecord::firstCoveredAt)
+ .filter(coveredAt -> coveredAt != null && coveredAt.isAfter(threshold))
+ .count();
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/report/ScanContractsReportWriter.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/report/ScanContractsReportWriter.java
new file mode 100644
index 00000000..cb38b11c
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/report/ScanContractsReportWriter.java
@@ -0,0 +1,171 @@
+package com.arc_e_tect.gradle.doppelganger.report;
+
+import com.arc_e_tect.gradle.doppelganger.detect.EndpointResponseCoverage;
+import com.arc_e_tect.gradle.doppelganger.progress.ResponseCoverageRecord;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Writes the AsciiDoc {@code scanContracts} report: for every endpoint both declared in the
+ * OpenAPI documentation and implemented by a {@code @RestController} method, its declared response
+ * codes and contract test count, plus - when {@code includeResponseCoverage} was enabled - a
+ * per-response-code test count breakdown.
+ */
+public class ScanContractsReportWriter {
+
+ /** Classpath resource holding the "what does this report show" preamble, bundled with the plugin. */
+ static final String PREAMBLE_RESOURCE = "scan-contracts-preamble.adoc";
+
+ private final ResponseCoverageTableWriter historyTableWriter = new ResponseCoverageTableWriter();
+
+ /** Creates a new {@code ScanContractsReportWriter}. */
+ public ScanContractsReportWriter() {}
+
+ /**
+ * Writes the report to {@code outputFile}, creating its parent directory if necessary.
+ *
+ * @param outputFile target AsciiDoc file
+ * @param coverage the coverage rows to render, one per declared-and-implemented
+ * endpoint
+ * @param includeResponseCoverage whether {@code coverage}'s per-response-code breakdown was
+ * computed; when {@code false}, only the declared response code
+ * count and contract test count are rendered
+ * @param systemUnderTestVersion version of the system under test that was scanned
+ * @param warnings non-fatal configuration gaps to render as a {@code WARNING}
+ * admonition right after the report header; when empty, no such
+ * admonition is written
+ * @param responseCoverageHistory response coverage history to render as a
+ * {@code == Response Coverage Over Time} section, keyed by
+ * fingerprint; when empty, no such section is written
+ * @throws IOException if the output file cannot be written
+ */
+ public void write(
+ File outputFile, List coverage, boolean includeResponseCoverage,
+ String systemUnderTestVersion, List warnings,
+ Map responseCoverageHistory) throws IOException {
+ File parent = outputFile.getParentFile();
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
+ throw new IOException("Could not create output directory: " + parent);
+ }
+
+ try (PrintWriter writer = new PrintWriter(outputFile, StandardCharsets.UTF_8)) {
+ writer.println("= Contract Scan Report");
+ writer.println(":toc:");
+ writer.println(":toclevels: 2");
+ writer.println();
+ writer.println("System Under Test version: " + systemUnderTestVersion);
+ writer.println();
+ writer.println("Generated: " + LocalDateTime.now()
+ .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+ writer.println();
+ writeWarnings(writer, warnings);
+ writer.println("Scanned " + coverage.size()
+ + " endpoint(s) both declared in the OpenAPI documentation and implemented by a "
+ + "`@RestController` class.");
+ writer.println();
+ writer.print(loadPreamble());
+ writer.println();
+ historyTableWriter.write(writer, responseCoverageHistory);
+ writer.println("== Endpoint Coverage");
+ writer.println();
+
+ if (coverage.isEmpty()) {
+ writer.println("None found - no endpoint is both declared in the OpenAPI documentation and "
+ + "implemented by a `@RestController` class.");
+ } else {
+ writeCoverageTable(writer, coverage, includeResponseCoverage);
+ }
+ }
+ }
+
+ private void writeCoverageTable(
+ PrintWriter writer, List coverage, boolean includeResponseCoverage) {
+ List sorted = coverage.stream()
+ .sorted(Comparator.comparing(row -> row.endpoint().path())
+ .thenComparing(row -> row.endpoint().verb().name()))
+ .toList();
+
+ writer.println("[cols=\"1,3,1,1\",options=\"header\"]");
+ writer.println("|===");
+ writer.println("| HTTP Verb | Path | Declared Response Codes | Contract Test Count");
+
+ for (EndpointResponseCoverage row : sorted) {
+ writer.println();
+ writer.println("| " + row.endpoint().verb());
+ writer.println("| " + row.endpoint().path());
+ writer.println("| " + row.declaredResponseCodes().size() + " (" + String.join(", ", row.declaredResponseCodes()) + ")");
+ writer.println("| " + row.contractTestCount());
+ }
+ writer.println("|===");
+ writer.println();
+
+ if (includeResponseCoverage) {
+ writeResponseCoverageBreakdown(writer, sorted);
+ }
+ }
+
+ private void writeResponseCoverageBreakdown(PrintWriter writer, List sorted) {
+ writer.println("== Response Code Coverage");
+ writer.println();
+
+ for (EndpointResponseCoverage row : sorted) {
+ if (row.declaredResponseCodes().isEmpty()) {
+ continue;
+ }
+ writer.println("=== " + row.endpoint().verb() + " " + row.endpoint().path());
+ writer.println();
+ writer.println("[cols=\"1,1\",options=\"header\"]");
+ writer.println("|===");
+ writer.println("| Response Code | Contract Test Count");
+
+ for (Map.Entry entry : row.testCountByResponseCode().entrySet()) {
+ writer.println();
+ writer.println("| " + entry.getKey());
+ writer.println("| " + entry.getValue());
+ }
+ writer.println("|===");
+ writer.println();
+
+ if (row.untrackedTestCount() > 0) {
+ writer.println(row.untrackedTestCount()
+ + (row.untrackedTestCount() == 1 ? " test asserts" : " tests assert")
+ + " a response status that could not be matched to a declared response code (or could "
+ + "not be detected at all), and " + (row.untrackedTestCount() == 1 ? "is" : "are") + " "
+ + "not reflected above.");
+ writer.println();
+ }
+ }
+ }
+
+ private void writeWarnings(PrintWriter writer, List warnings) {
+ if (warnings.isEmpty()) {
+ return;
+ }
+ writer.println("[WARNING]");
+ writer.println("====");
+ for (String warning : warnings) {
+ writer.println("* " + warning);
+ }
+ writer.println("====");
+ writer.println();
+ }
+
+ private String loadPreamble() throws IOException {
+ try (InputStream stream = ScanContractsReportWriter.class.getClassLoader()
+ .getResourceAsStream(PREAMBLE_RESOURCE)) {
+ if (stream == null) {
+ throw new IOException("Missing bundled resource: " + PREAMBLE_RESOURCE);
+ }
+ return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/OpenApiRequestValidatorScanner.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/OpenApiRequestValidatorScanner.java
index 76e3f5fc..c959c220 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/OpenApiRequestValidatorScanner.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/OpenApiRequestValidatorScanner.java
@@ -4,6 +4,7 @@
import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
import com.arc_e_tect.gradle.detector.core.model.PathTemplates;
import com.arc_e_tect.gradle.doppelganger.detect.ContractVerificationSource;
+import com.arc_e_tect.gradle.doppelganger.detect.VerifiedContractTest;
import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
@@ -47,20 +48,30 @@ public OpenApiRequestValidatorScanner() {}
@Override
public List scan(File rootDir) throws IOException {
List endpoints = new ArrayList<>();
- for (File javaFile : collectJavaFiles(rootDir)) {
- endpoints.addAll(scanFile(javaFile));
+ for (VerifiedContractTest test : scanWithStatusCodes(rootDir)) {
+ endpoints.add(test.endpoint());
}
return endpoints;
}
- private List scanFile(File sourceFile) throws IOException {
- List endpoints = new ArrayList<>();
+ /** {@inheritDoc} */
+ @Override
+ public List scanWithStatusCodes(File rootDir) throws IOException {
+ List tests = new ArrayList<>();
+ for (File javaFile : collectJavaFiles(rootDir)) {
+ tests.addAll(scanFile(javaFile));
+ }
+ return tests;
+ }
+
+ private List scanFile(File sourceFile) throws IOException {
+ List tests = new ArrayList<>();
ParseResult parseResult = new JavaParser(
new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21))
.parse(sourceFile);
if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
- return endpoints;
+ return tests;
}
CompilationUnit cu = parseResult.getResult().get();
@@ -69,17 +80,17 @@ private List scanFile(File sourceFile) throws IOException {
cu.findAll(ClassOrInterfaceDeclaration.class).forEach(cls -> {
String declaringClass = buildFqcn(cu, cls);
cls.getMethods().forEach(method -> {
- Endpoint endpoint = endpointForMethod(method, declaringClass, fileName);
- if (endpoint != null) {
- endpoints.add(endpoint);
+ VerifiedContractTest test = verifiedTestForMethod(method, declaringClass, fileName);
+ if (test != null) {
+ tests.add(test);
}
});
});
- return endpoints;
+ return tests;
}
- private Endpoint endpointForMethod(MethodDeclaration method, String declaringClass, String fileName) {
+ private VerifiedContractTest verifiedTestForMethod(MethodDeclaration method, String declaringClass, String fileName) {
List calls = method.findAll(MethodCallExpr.class);
boolean validated = calls.stream().anyMatch(this::isValidationCall);
@@ -98,8 +109,9 @@ private Endpoint endpointForMethod(MethodDeclaration method, String declaringCla
if (verbAndPath != null) {
String signature = method.getNameAsString() + "()";
int line = method.getBegin().map(p -> p.line).orElse(0);
- return new Endpoint(
+ Endpoint endpoint = new Endpoint(
verbAndPath.verb(), verbAndPath.path(), declaringClass, signature, fileName, line);
+ return new VerifiedContractTest(endpoint, StatusCodeDetector.detect(calls).orElse(null));
}
}
return null;
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/RestDocsScanner.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/RestDocsScanner.java
index 803ad5a8..aed2c8ed 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/RestDocsScanner.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/RestDocsScanner.java
@@ -4,6 +4,7 @@
import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
import com.arc_e_tect.gradle.detector.core.model.PathTemplates;
import com.arc_e_tect.gradle.doppelganger.detect.ContractVerificationSource;
+import com.arc_e_tect.gradle.doppelganger.detect.VerifiedContractTest;
import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
@@ -75,20 +76,30 @@ public RestDocsScanner(String basePathToStrip) {
@Override
public List scan(File rootDir) throws IOException {
List endpoints = new ArrayList<>();
- for (File javaFile : collectJavaFiles(rootDir)) {
- endpoints.addAll(scanFile(javaFile));
+ for (VerifiedContractTest test : scanWithStatusCodes(rootDir)) {
+ endpoints.add(test.endpoint());
}
return endpoints;
}
- private List scanFile(File sourceFile) throws IOException {
- List endpoints = new ArrayList<>();
+ /** {@inheritDoc} */
+ @Override
+ public List scanWithStatusCodes(File rootDir) throws IOException {
+ List tests = new ArrayList<>();
+ for (File javaFile : collectJavaFiles(rootDir)) {
+ tests.addAll(scanFile(javaFile));
+ }
+ return tests;
+ }
+
+ private List scanFile(File sourceFile) throws IOException {
+ List tests = new ArrayList<>();
ParseResult parseResult = new JavaParser(
new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21))
.parse(sourceFile);
if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
- return endpoints;
+ return tests;
}
CompilationUnit cu = parseResult.getResult().get();
@@ -97,17 +108,17 @@ private List scanFile(File sourceFile) throws IOException {
cu.findAll(ClassOrInterfaceDeclaration.class).forEach(cls -> {
String declaringClass = buildFqcn(cu, cls);
cls.getMethods().forEach(method -> {
- Endpoint endpoint = endpointForMethod(method, declaringClass, fileName);
- if (endpoint != null) {
- endpoints.add(endpoint);
+ VerifiedContractTest test = verifiedTestForMethod(method, declaringClass, fileName);
+ if (test != null) {
+ tests.add(test);
}
});
});
- return endpoints;
+ return tests;
}
- private Endpoint endpointForMethod(MethodDeclaration method, String declaringClass, String fileName) {
+ private VerifiedContractTest verifiedTestForMethod(MethodDeclaration method, String declaringClass, String fileName) {
List calls = method.findAll(MethodCallExpr.class);
boolean documented = calls.stream().anyMatch(call -> isAndDoDocument(call)
@@ -125,8 +136,9 @@ private Endpoint endpointForMethod(MethodDeclaration method, String declaringCla
if (verbAndPath != null) {
String signature = method.getNameAsString() + "()";
int line = method.getBegin().map(p -> p.line).orElse(0);
- return new Endpoint(verbAndPath.verb(), stripBasePath(verbAndPath.path()), declaringClass,
+ Endpoint endpoint = new Endpoint(verbAndPath.verb(), stripBasePath(verbAndPath.path()), declaringClass,
signature, fileName, line);
+ return new VerifiedContractTest(endpoint, StatusCodeDetector.detect(calls).orElse(null));
}
}
return null;
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/SpringCloudContractScanner.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/SpringCloudContractScanner.java
index 277e57f9..ea2a9b6e 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/SpringCloudContractScanner.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/SpringCloudContractScanner.java
@@ -4,6 +4,7 @@
import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
import com.arc_e_tect.gradle.detector.core.model.PathTemplates;
import com.arc_e_tect.gradle.doppelganger.detect.ContractVerificationSource;
+import com.arc_e_tect.gradle.doppelganger.detect.VerifiedContractTest;
import java.io.File;
import java.io.IOException;
@@ -28,10 +29,14 @@ public class SpringCloudContractScanner implements ContractVerificationSource {
Pattern.compile("method\\s*\\(?\\s*['\"]([A-Za-z]+)['\"]");
private static final Pattern GROOVY_URL =
Pattern.compile("url(?:Path)?\\s*\\(?\\s*['\"]([^'\"]+)['\"]");
+ private static final Pattern GROOVY_STATUS =
+ Pattern.compile("status\\s*\\(?\\s*(\\d{3})");
private static final Pattern YAML_METHOD =
Pattern.compile("(?i)^\\s*method\\s*:\\s*['\"]?([A-Za-z]+)['\"]?\\s*$");
private static final Pattern YAML_URL =
Pattern.compile("(?i)^\\s*url(?:Path)?\\s*:\\s*['\"]?([^'\"]+?)['\"]?\\s*$");
+ private static final Pattern YAML_STATUS =
+ Pattern.compile("(?i)^\\s*status\\s*:\\s*(\\d{3})\\s*$");
/** Creates a new {@code SpringCloudContractScanner}. */
public SpringCloudContractScanner() {}
@@ -40,24 +45,36 @@ public SpringCloudContractScanner() {}
@Override
public List scan(File rootDir) throws IOException {
List endpoints = new ArrayList<>();
+ for (VerifiedContractTest test : scanWithStatusCodes(rootDir)) {
+ endpoints.add(test.endpoint());
+ }
+ return endpoints;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public List scanWithStatusCodes(File rootDir) throws IOException {
+ List tests = new ArrayList<>();
for (File contractFile : collectContractFiles(rootDir)) {
- Endpoint endpoint = scanFile(contractFile, rootDir);
- if (endpoint != null) {
- endpoints.add(endpoint);
+ VerifiedContractTest test = scanFile(contractFile, rootDir);
+ if (test != null) {
+ tests.add(test);
}
}
- return endpoints;
+ return tests;
}
- private Endpoint scanFile(File file, File rootDir) throws IOException {
+ private VerifiedContractTest scanFile(File file, File rootDir) throws IOException {
boolean yaml = file.getName().endsWith(".yml");
Pattern methodPattern = yaml ? YAML_METHOD : GROOVY_METHOD;
Pattern urlPattern = yaml ? YAML_URL : GROOVY_URL;
+ Pattern statusPattern = yaml ? YAML_STATUS : GROOVY_STATUS;
List lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
HttpVerb verb = null;
String path = null;
+ String status = null;
int verbLine = 0;
for (int i = 0; i < lines.size(); i++) {
@@ -75,6 +92,12 @@ private Endpoint scanFile(File file, File rootDir) throws IOException {
path = PathTemplates.normalize(matcher.group(1));
}
}
+ if (status == null) {
+ Matcher matcher = statusPattern.matcher(line);
+ if (matcher.find()) {
+ status = matcher.group(1);
+ }
+ }
}
if (verb == null || path == null) {
@@ -83,7 +106,8 @@ private Endpoint scanFile(File file, File rootDir) throws IOException {
String declaringClass = relativeParentPath(rootDir, file);
String methodSignature = stripExtension(file.getName());
- return new Endpoint(verb, path, declaringClass, methodSignature, file.getName(), verbLine);
+ Endpoint endpoint = new Endpoint(verb, path, declaringClass, methodSignature, file.getName(), verbLine);
+ return new VerifiedContractTest(endpoint, status);
}
private HttpVerb parseVerb(String raw) {
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetector.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetector.java
new file mode 100644
index 00000000..29ea3a2a
--- /dev/null
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetector.java
@@ -0,0 +1,114 @@
+package com.arc_e_tect.gradle.doppelganger.scan;
+
+import com.github.javaparser.ast.expr.Expression;
+import com.github.javaparser.ast.expr.MethodCallExpr;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Best-effort detection of the HTTP status code a test method asserts, from the same
+ * {@code List} a {@link ContractVerificationSource} AST scanner already collects
+ * for that method - not exhaustive, since a test can assert a response's status in ways this
+ * cannot recognise (a variable, a helper method, a custom matcher); such tests still count towards
+ * an endpoint's overall contract test count, they simply contribute no evidence to any specific
+ * response code's count.
+ *
+ * Recognises three independent shapes, matched by simple method name only, the same way the
+ * scanners that use this class already match request-builder calls:
+ *
+ * - MockMvc: {@code status().isOk()} / {@code status().isNotFound()} / ... (a fixed set of
+ * well-known {@code org.springframework.test.web.servlet.result.StatusResultMatchers} method
+ * names), or the numeric {@code status().is(404)}.
+ * - REST Assured: {@code .statusCode(404)}.
+ * - WebTestClient: {@code .expectStatus().isNotFound()}, or the numeric
+ * {@code .expectStatus().isEqualTo(404)}.
+ *
+ */
+public final class StatusCodeDetector {
+
+ private static final Map MOCKMVC_STATUS_METHODS = Map.ofEntries(
+ Map.entry("isOk", "200"),
+ Map.entry("isCreated", "201"),
+ Map.entry("isAccepted", "202"),
+ Map.entry("isNoContent", "204"),
+ Map.entry("isMovedPermanently", "301"),
+ Map.entry("isFound", "302"),
+ Map.entry("isNotModified", "304"),
+ Map.entry("isBadRequest", "400"),
+ Map.entry("isUnauthorized", "401"),
+ Map.entry("isForbidden", "403"),
+ Map.entry("isNotFound", "404"),
+ Map.entry("isMethodNotAllowed", "405"),
+ Map.entry("isConflict", "409"),
+ Map.entry("isGone", "410"),
+ Map.entry("isUnprocessableEntity", "422"),
+ Map.entry("isTooManyRequests", "429"),
+ Map.entry("isInternalServerError", "500"),
+ Map.entry("isNotImplemented", "501"),
+ Map.entry("isBadGateway", "502"),
+ Map.entry("isServiceUnavailable", "503"));
+
+ private StatusCodeDetector() {}
+
+ /**
+ * Detects the status code asserted anywhere among {@code calls}, or {@link Optional#empty()}
+ * when none of the recognised shapes are present.
+ *
+ * @param calls every method call expression found in a test method's body
+ * @return the detected status code, e.g. {@code "404"}, or {@link Optional#empty()}
+ */
+ public static Optional detect(List calls) {
+ for (MethodCallExpr call : calls) {
+ Optional fromMockMvcOrWebTestClient = fromStatusScopedCall(call);
+ if (fromMockMvcOrWebTestClient.isPresent()) {
+ return fromMockMvcOrWebTestClient;
+ }
+ Optional fromRestAssured = fromStatusCodeCall(call);
+ if (fromRestAssured.isPresent()) {
+ return fromRestAssured;
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * {@code status().isXxx()} / {@code status().is(NNN)} (MockMvc) or
+ * {@code expectStatus().isXxx()} / {@code expectStatus().isEqualTo(NNN)} (WebTestClient) - the
+ * call is scoped on a no-argument call named {@code status} or {@code expectStatus}.
+ */
+ private static Optional fromStatusScopedCall(MethodCallExpr call) {
+ boolean statusScoped = call.getScope()
+ .filter(MethodCallExpr.class::isInstance)
+ .map(MethodCallExpr.class::cast)
+ .filter(scope -> scope.getArguments().isEmpty()
+ && (scope.getNameAsString().equals("status") || scope.getNameAsString().equals("expectStatus")))
+ .isPresent();
+ if (!statusScoped) {
+ return Optional.empty();
+ }
+ String name = call.getNameAsString();
+ if (MOCKMVC_STATUS_METHODS.containsKey(name)) {
+ return Optional.of(MOCKMVC_STATUS_METHODS.get(name));
+ }
+ if ((name.equals("is") || name.equals("isEqualTo")) && !call.getArguments().isEmpty()) {
+ return numericLiteral(call.getArgument(0));
+ }
+ return Optional.empty();
+ }
+
+ /** {@code .statusCode(NNN)} (REST Assured). */
+ private static Optional fromStatusCodeCall(MethodCallExpr call) {
+ if (!call.getNameAsString().equals("statusCode") || call.getArguments().isEmpty()) {
+ return Optional.empty();
+ }
+ return numericLiteral(call.getArgument(0));
+ }
+
+ private static Optional numericLiteral(Expression argument) {
+ return argument.isIntegerLiteralExpr()
+ ? Optional.of(argument.asIntegerLiteralExpr().asNumber().toString())
+ : Optional.empty();
+ }
+}
diff --git a/doppelganger-api-detector/src/main/resources/scan-contracts-preamble.adoc b/doppelganger-api-detector/src/main/resources/scan-contracts-preamble.adoc
new file mode 100644
index 00000000..0fb7584e
--- /dev/null
+++ b/doppelganger-api-detector/src/main/resources/scan-contracts-preamble.adoc
@@ -0,0 +1,38 @@
+== What Does This Report Show?
+
+The Doppelganger API Detector's own report answers a binary question per endpoint: does it have
+*at least one* contract test at all? That collapses two more useful, more granular signals into a
+single yes/no. This report separates them out, for every endpoint both declared in the OpenAPI
+documentation and implemented by a `@RestController` method:
+
+* *Declared response codes* - how many distinct response codes (`200`, `404`, `422`, `500`, ...)
+ the OpenAPI operation actually documents.
+* *Contract test count* - how many distinct contract tests exist for the endpoint, across every
+ enabled verification source (Spring RestDocs, the Atlassian OpenAPI request validator, Spring
+ Cloud Contract).
+
+When `includeResponseCoverage` is enabled, a third, more expensive statistic is added: for every
+declared response code, how many of those contract tests were detected to actually assert it -
+including a response code declared but covered by *zero* tests, so a coverage gap is visible at a
+glance rather than hidden behind an aggregate "N tests" count.
+
+=== A Worked Example
+
+An endpoint `GET /v1/foobars` declares two response codes, `200` and `404`. Two contract tests
+assert a `200` response and one asserts a `404`. With `includeResponseCoverage` enabled, this
+report shows: `200` covered by 2 test(s), `404` covered by 1 test(s) - both declared codes have at
+least one covering test, but the relative depth of coverage between them is visible too.
+
+=== A Known Limitation
+
+A test's asserted status code is matched against a declared response code by *exact string
+equality only*: a test detected to assert `404` counts towards a declared `"404"` response, never
+towards a declared `"4XX"` range wildcard or a `"default"` response - even though either might, in
+the OpenAPI specification's own semantics, legitimately cover that same test. Such a test still
+counts towards the endpoint's overall contract test count; it simply isn't attributed to any single
+response code row.
+
+Status code detection itself is best-effort: a test that asserts its response status through a
+variable, a helper method, or a custom matcher this scanner does not recognise still counts towards
+the endpoint's overall contract test count, it just contributes no evidence to any specific response
+code's count.
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPluginTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPluginTest.java
index 49c9e308..22e75c7b 100644
--- a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPluginTest.java
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/DoppelgangerApiDetectorPluginTest.java
@@ -313,6 +313,134 @@ void invalidUpdateContractHistoryPropertyThrowsDescriptiveError() throws Excepti
+ "-PdoppelgangerApiDetector.updateContractHistory; expected 'true' or 'false'");
}
+ @Test
+ @DisplayName("registers the scanContracts task when applied")
+ void registersScanContractsTask() {
+ Project project = projectWithPlugin();
+
+ assertThat(project.getTasks().findByName(DoppelgangerApiDetectorPlugin.SCAN_CONTRACTS_TASK_NAME)).isNotNull();
+ }
+
+ @Test
+ @DisplayName("extension default: includeResponseCoverage is false")
+ void extensionDefaultIncludeResponseCoverageIsFalse() {
+ Project project = projectWithPlugin();
+
+ assertThat(extension(project).getIncludeResponseCoverage().get()).isFalse();
+ }
+
+ @Test
+ @DisplayName("extension default: scanContractsReportFileName is contract-coverage.adoc")
+ void extensionDefaultScanContractsReportFileName() {
+ Project project = projectWithPlugin();
+
+ assertThat(extension(project).getScanContractsReportFileName().get()).isEqualTo("contract-coverage.adoc");
+ }
+
+ @Test
+ @DisplayName("extension default: trackResponseCoverageHistory is false")
+ void extensionDefaultTrackResponseCoverageHistoryIsFalse() {
+ Project project = projectWithPlugin();
+
+ assertThat(extension(project).getTrackResponseCoverageHistory().get()).isFalse();
+ }
+
+ @Test
+ @DisplayName("extension default: responseCoverageHistoryFile is doppelganger-api-detector-response-coverage-history.ndjson in the project directory")
+ void extensionDefaultResponseCoverageHistoryFile() {
+ Project project = projectWithPlugin();
+
+ assertThat(extension(project).getResponseCoverageHistoryFile().get().getAsFile())
+ .isEqualTo(new File(project.getProjectDir(),
+ "doppelganger-api-detector-response-coverage-history.ndjson"));
+ }
+
+ @Test
+ @DisplayName("extension default: updateResponseCoverageHistory follows trackResponseCoverageHistory")
+ void extensionDefaultUpdateResponseCoverageHistoryFollowsTrackResponseCoverageHistory() {
+ Project project = projectWithPlugin();
+
+ extension(project).getTrackResponseCoverageHistory().set(true);
+
+ assertThat(extension(project).getUpdateResponseCoverageHistory().get()).isTrue();
+ }
+
+ @Test
+ @DisplayName("wires the scanContracts task's properties from the extension")
+ void wiresScanContractsTaskPropertiesFromExtension() {
+ Project project = projectWithPlugin();
+ project.setVersion("2.5.0");
+ extension(project).getIncludeResponseCoverage().set(true);
+ extension(project).getTrackResponseCoverageHistory().set(true);
+
+ ((ProjectInternal) project).evaluate();
+
+ ScanContractsTask task = (ScanContractsTask)
+ project.getTasks().getByName(DoppelgangerApiDetectorPlugin.SCAN_CONTRACTS_TASK_NAME);
+ assertThat(task.getSystemUnderTestVersion().get()).isEqualTo("2.5.0");
+ assertThat(task.getIncludeResponseCoverage().get()).isTrue();
+ assertThat(task.getTrackResponseCoverageHistory().get()).isTrue();
+ assertThat(task.getUpdateResponseCoverageHistory().get()).isTrue();
+ assertThat(task.getResponseCoverageHistoryFile().get().getAsFile())
+ .isEqualTo(new File(project.getProjectDir(),
+ "doppelganger-api-detector-response-coverage-history.ndjson"));
+ assertThat(task.getReportFileName().get()).isEqualTo("contract-coverage.adoc");
+ }
+
+ @Test
+ @DisplayName("scanContracts also defaults controllerDirs/testDirs after evaluation when unset")
+ void scanContractsTaskDefaultsDirsAfterEvaluation() {
+ Project project = projectWithPlugin();
+
+ ((ProjectInternal) project).evaluate();
+
+ ScanContractsTask task = (ScanContractsTask)
+ project.getTasks().getByName(DoppelgangerApiDetectorPlugin.SCAN_CONTRACTS_TASK_NAME);
+ assertThat(task.getControllerDirs().getFiles())
+ .containsExactly(new File(project.getProjectDir(), "src/main/java"));
+ assertThat(task.getTestDirs().getFiles())
+ .containsExactly(new File(project.getProjectDir(), "src/testContract/java"));
+ assertThat(task.getTestDirsUserConfigured().get()).isFalse();
+ }
+
+ @Test
+ @DisplayName("the -PdoppelgangerApiDetector.updateResponseCoverageHistory property accepts trimmed, case-insensitive booleans")
+ void updateResponseCoverageHistoryPropertyAcceptsTrimmedCaseInsensitiveBooleans() throws Exception {
+ java.nio.file.Files.writeString(
+ tempDir.resolve("gradle.properties"),
+ "doppelgangerApiDetector.updateResponseCoverageHistory= FaLsE \n");
+
+ Project project = projectWithPlugin();
+ extension(project).getTrackResponseCoverageHistory().set(true);
+ extension(project).getUpdateResponseCoverageHistory().set(true);
+
+ ((ProjectInternal) project).evaluate();
+
+ ScanContractsTask task = (ScanContractsTask)
+ project.getTasks().getByName(DoppelgangerApiDetectorPlugin.SCAN_CONTRACTS_TASK_NAME);
+ assertThat(task.getUpdateResponseCoverageHistory().get()).isFalse();
+ }
+
+ @Test
+ @DisplayName("an invalid -PdoppelgangerApiDetector.updateResponseCoverageHistory value throws a descriptive GradleException")
+ void invalidUpdateResponseCoverageHistoryPropertyThrowsDescriptiveError() throws Exception {
+ java.nio.file.Files.writeString(
+ tempDir.resolve("gradle.properties"),
+ "doppelgangerApiDetector.updateResponseCoverageHistory=maybe\n");
+
+ Project project = projectWithPlugin();
+
+ ((ProjectInternal) project).evaluate();
+ ScanContractsTask task = (ScanContractsTask)
+ project.getTasks().getByName(DoppelgangerApiDetectorPlugin.SCAN_CONTRACTS_TASK_NAME);
+
+ assertThatThrownBy(() -> task.getUpdateResponseCoverageHistory().get())
+ .isInstanceOf(RuntimeException.class)
+ .hasRootCauseInstanceOf(org.gradle.api.GradleException.class)
+ .hasRootCauseMessage("doppelgangerApiDetector: invalid value 'maybe' for "
+ + "-PdoppelgangerApiDetector.updateResponseCoverageHistory; expected 'true' or 'false'");
+ }
+
private Project projectWithPlugin() {
Project project = ProjectBuilder.builder().withProjectDir(tempDir.toFile()).build();
project.getPluginManager().apply("java");
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTaskTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTaskTest.java
new file mode 100644
index 00000000..bccc64d4
--- /dev/null
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTaskTest.java
@@ -0,0 +1,310 @@
+package com.arc_e_tect.gradle.doppelganger;
+
+import org.gradle.api.GradleException;
+import org.gradle.api.Project;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("ScanContractsTask")
+class ScanContractsTaskTest {
+
+ @TempDir
+ Path tempDir;
+
+ private Project project;
+ private File controllerDir;
+ private File testDir;
+ private File reportDir;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ project = ProjectBuilder.builder().withProjectDir(tempDir.toFile()).build();
+
+ controllerDir = new File(tempDir.toFile(), "src/main/java/com/example");
+ Files.createDirectories(controllerDir.toPath());
+ Files.writeString(controllerDir.toPath().resolve("FoobarController.java"), """
+ package com.example;
+
+ import org.springframework.web.bind.annotation.GetMapping;
+ import org.springframework.web.bind.annotation.RequestMapping;
+ import org.springframework.web.bind.annotation.RestController;
+
+ @RestController
+ @RequestMapping("/v1/foobars")
+ public class FoobarController {
+
+ @GetMapping
+ public String listFoobars() { return "[]"; }
+ }
+ """);
+
+ testDir = new File(tempDir.toFile(), "src/test/java/com/example");
+ Files.createDirectories(testDir.toPath());
+
+ reportDir = new File(tempDir.toFile(), "build/reports/doppelganger-api-detector");
+ }
+
+ @Test
+ @DisplayName("the /v1/foobars worked example: 200 covered by 2 tests, 404 covered by 1")
+ void worksThroughTheFoobarsExample() throws Exception {
+ writeFoobarTests();
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+ task.getIncludeResponseCoverage().set(true);
+
+ task.generate();
+
+ String content = Files.readString(new File(reportDir, "contract-coverage.adoc").toPath());
+ assertThat(content).contains("== Response Code Coverage");
+ assertThat(content).containsSubsequence("| 200", "| 2");
+ assertThat(content).containsSubsequence("| 404", "| 1");
+ }
+
+ @Test
+ @DisplayName("reports the declared response code count and contract test count per endpoint")
+ void reportsDeclaredResponseCodeCountAndContractTestCount() throws Exception {
+ writeFoobarTests();
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+
+ task.generate();
+
+ String content = Files.readString(new File(reportDir, "contract-coverage.adoc").toPath());
+ assertThat(content).contains("GET");
+ assertThat(content).contains("/v1/foobars");
+ assertThat(content).contains("2 (200, 404)");
+ assertThat(content).contains("| 3");
+ }
+
+ @Test
+ @DisplayName("when includeResponseCoverage is false, no per-response-code breakdown section is written")
+ void noBreakdownSectionWhenResponseCoverageDisabled() throws Exception {
+ writeFoobarTests();
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+
+ task.generate();
+
+ String content = Files.readString(new File(reportDir, "contract-coverage.adoc").toPath());
+ assertThat(content).doesNotContain("== Response Code Coverage");
+ }
+
+ @Test
+ @DisplayName("does not throw when rootDocument is not configured, and warns instead")
+ void doesNotThrowWhenRootDocumentNotConfigured() throws Exception {
+ ScanContractsTask task = newTask();
+ task.getControllerDirs().from(controllerDir);
+ task.getTestDirs().from(testDir);
+ task.getReportDir().set(reportDir);
+ task.getReportFileName().set("contract-coverage.adoc");
+ task.getUseRestDocs().set(true);
+ task.getUseOpenApiRequestValidator().set(false);
+ task.getUseSpringCloudContract().set(false);
+ task.getSystemUnderTestVersion().set("1.0.0");
+
+ task.generate();
+
+ String content = Files.readString(new File(reportDir, "contract-coverage.adoc").toPath());
+ assertThat(content)
+ .contains("[WARNING]")
+ .contains("`rootDocument` is not configured yet")
+ .contains("None found");
+ }
+
+ @Test
+ @DisplayName("does not throw when none of the configured controllerDirs exist")
+ void doesNotThrowWhenNoControllerDirsExist() throws Exception {
+ File missing = new File(tempDir.toFile(), "src/main/java/does-not-exist");
+ ScanContractsTask task = newTask();
+ task.getControllerDirs().from(missing);
+ task.getTestDirs().from(testDir);
+ task.getRootDocument().set(openApiFixture("foobars.yaml"));
+ task.getReportDir().set(reportDir);
+ task.getReportFileName().set("contract-coverage.adoc");
+ task.getUseRestDocs().set(true);
+ task.getUseOpenApiRequestValidator().set(false);
+ task.getUseSpringCloudContract().set(false);
+ task.getSystemUnderTestVersion().set("1.0.0");
+
+ task.generate();
+
+ String content = Files.readString(new File(reportDir, "contract-coverage.adoc").toPath());
+ assertThat(content)
+ .contains("[WARNING]")
+ .contains("Contract scanning was skipped for this run");
+ }
+
+ @Test
+ @DisplayName("fails eagerly when every verification source is disabled")
+ void failsEagerlyWhenEveryVerificationSourceDisabled() throws Exception {
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+ task.getUseRestDocs().set(false);
+
+ assertThatThrownBy(task::generate)
+ .isInstanceOf(GradleException.class)
+ .hasMessageContaining("at least one of useRestDocs");
+ }
+
+ @Test
+ @DisplayName("fails eagerly when useSpringCloudContract is enabled without contractsDir")
+ void failsEagerlyWhenSpringCloudContractEnabledWithoutContractsDir() throws Exception {
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+ task.getUseSpringCloudContract().set(true);
+
+ assertThatThrownBy(task::generate)
+ .isInstanceOf(GradleException.class)
+ .hasMessageContaining("contractsDir must be configured");
+ }
+
+ @Test
+ @DisplayName("fails eagerly when trackResponseCoverageHistory is enabled without includeResponseCoverage")
+ void failsEagerlyWhenTrackHistoryEnabledWithoutIncludeResponseCoverage() throws Exception {
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+ task.getTrackResponseCoverageHistory().set(true);
+ task.getIncludeResponseCoverage().set(false);
+
+ assertThatThrownBy(task::generate)
+ .isInstanceOf(GradleException.class)
+ .hasMessageContaining("trackResponseCoverageHistory requires includeResponseCoverage");
+ }
+
+ @Test
+ @DisplayName("persists response coverage history when tracking and updating are both enabled")
+ void persistsResponseCoverageHistoryWhenEnabled() throws Exception {
+ writeFoobarTests();
+ File historyFile = new File(tempDir.toFile(), "response-coverage-history.ndjson");
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+ task.getIncludeResponseCoverage().set(true);
+ task.getTrackResponseCoverageHistory().set(true);
+ task.getResponseCoverageHistoryFile().set(historyFile);
+ task.getUpdateResponseCoverageHistory().set(true);
+
+ task.generate();
+
+ String historyContent = Files.readString(historyFile.toPath());
+ assertThat(historyContent).contains("\"responseCode\":\"200\",\"testCount\":2");
+ assertThat(historyContent).contains("\"responseCode\":\"404\",\"testCount\":1");
+ }
+
+ @Test
+ @DisplayName("does not write the history file when updateResponseCoverageHistory is false")
+ void doesNotWriteHistoryFileWhenUpdateDisabled() throws Exception {
+ writeFoobarTests();
+ File historyFile = new File(tempDir.toFile(), "response-coverage-history.ndjson");
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+ task.getIncludeResponseCoverage().set(true);
+ task.getTrackResponseCoverageHistory().set(true);
+ task.getResponseCoverageHistoryFile().set(historyFile);
+ task.getUpdateResponseCoverageHistory().set(false);
+
+ task.generate();
+
+ assertThat(historyFile).doesNotExist();
+ }
+
+ @Test
+ @DisplayName("an excluded endpoint is not reported")
+ void excludedEndpointIsNotReported() throws Exception {
+ writeFoobarTests();
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+ task.getExcludePaths().set(List.of("/v1/foobars"));
+
+ task.generate();
+
+ String content = Files.readString(new File(reportDir, "contract-coverage.adoc").toPath());
+ assertThat(content).contains("None found");
+ }
+
+ @Test
+ @DisplayName("an unrecognised excludeWellKnown name fails the build")
+ void unrecognisedExcludeWellKnownNameFailsBuild() throws Exception {
+ ScanContractsTask task = configuredTask(openApiFixture("foobars.yaml"));
+ task.getExcludeWellKnown().set(List.of("not-a-real-set"));
+
+ assertThatThrownBy(task::generate)
+ .isInstanceOf(GradleException.class)
+ .hasMessageContaining("not-a-real-set");
+ }
+
+ private void writeFoobarTests() throws Exception {
+ Files.writeString(testDir.toPath().resolve("FoobarControllerDocTest.java"), """
+ package com.example;
+
+ class FoobarControllerDocTest {
+
+ void listFoobarsOk1() throws Exception {
+ mockMvc.perform(get("/v1/foobars"))
+ .andExpect(status().isOk())
+ .andDo(document("list-foobars-ok-1"));
+ }
+
+ void listFoobarsOk2() throws Exception {
+ mockMvc.perform(get("/v1/foobars"))
+ .andExpect(status().isOk())
+ .andDo(document("list-foobars-ok-2"));
+ }
+
+ void listFoobarsNotFound() throws Exception {
+ mockMvc.perform(get("/v1/foobars"))
+ .andExpect(status().isNotFound())
+ .andDo(document("list-foobars-not-found"));
+ }
+ }
+ """);
+ }
+
+ private ScanContractsTask configuredTask(File rootDocument) {
+ ScanContractsTask task = newTask();
+ task.getControllerDirs().from(controllerDir);
+ task.getTestDirs().from(testDir);
+ task.getRootDocument().set(rootDocument);
+ task.getReportDir().set(reportDir);
+ task.getReportFileName().set("contract-coverage.adoc");
+ task.getUseRestDocs().set(true);
+ task.getUseOpenApiRequestValidator().set(false);
+ task.getUseSpringCloudContract().set(false);
+ task.getSystemUnderTestVersion().set("1.0.0");
+ return task;
+ }
+
+ private ScanContractsTask newTask() {
+ ScanContractsTask task = project.getTasks().create("scanContractsUnderTest", ScanContractsTask.class);
+ task.getIncludeResponseCoverage().set(false);
+ task.getTrackResponseCoverageHistory().set(false);
+ return task;
+ }
+
+ private File openApiFixture(String name) throws Exception {
+ File dir = new File(tempDir.toFile(), "openapi");
+ Files.createDirectories(dir.toPath());
+ File file = new File(dir, name);
+ String content = switch (name) {
+ case "foobars.yaml" -> """
+ openapi: 3.0.3
+ info:
+ title: Test API
+ version: "1.0"
+ paths:
+ /v1/foobars:
+ get:
+ operationId: listFoobars
+ responses:
+ '200':
+ description: OK
+ '404':
+ description: Not Found
+ """;
+ default -> throw new IllegalArgumentException("Unknown fixture: " + name);
+ };
+ Files.writeString(file.toPath(), content);
+ return file;
+ }
+}
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/detect/ResponseCoverageAnalyzerTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/detect/ResponseCoverageAnalyzerTest.java
new file mode 100644
index 00000000..9e9e2f28
--- /dev/null
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/detect/ResponseCoverageAnalyzerTest.java
@@ -0,0 +1,136 @@
+package com.arc_e_tect.gradle.doppelganger.detect;
+
+import com.arc_e_tect.gradle.detector.core.model.Endpoint;
+import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+import com.arc_e_tect.gradle.detector.core.openapi.DescribedEndpoint;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("ResponseCoverageAnalyzer")
+class ResponseCoverageAnalyzerTest {
+
+ private final ResponseCoverageAnalyzer analyzer = new ResponseCoverageAnalyzer();
+
+ @Test
+ @DisplayName("the /v1/foobars worked example: 200 covered by 2 tests, 404 covered by 1")
+ void worksThroughTheFoobarsExample() {
+ DescribedEndpoint foobars = new DescribedEndpoint(
+ HttpVerb.GET, "/v1/foobars", "listFoobars", List.of(), List.of("200", "404"));
+ List tests = List.of(
+ verified(HttpVerb.GET, "/v1/foobars", "200"),
+ verified(HttpVerb.GET, "/v1/foobars", "200"),
+ verified(HttpVerb.GET, "/v1/foobars", "404"));
+
+ List results = analyzer.analyze(List.of(foobars), tests, true);
+
+ assertThat(results).hasSize(1);
+ EndpointResponseCoverage row = results.get(0);
+ assertThat(row.contractTestCount()).isEqualTo(3);
+ assertThat(row.untrackedTestCount()).isZero();
+ assertThat(row.testCountByResponseCode()).containsExactly(
+ Map.entry("200", 2), Map.entry("404", 1));
+ }
+
+ @Test
+ @DisplayName("a declared response code with no covering test is reported with count 0")
+ void reportsZeroForUncoveredDeclaredCode() {
+ DescribedEndpoint endpoint = new DescribedEndpoint(
+ HttpVerb.GET, "/orders", null, List.of(), List.of("200", "500"));
+ List tests = List.of(verified(HttpVerb.GET, "/orders", "200"));
+
+ List results = analyzer.analyze(List.of(endpoint), tests, true);
+
+ assertThat(results.get(0).testCountByResponseCode()).containsExactly(
+ Map.entry("200", 1), Map.entry("500", 0));
+ }
+
+ @Test
+ @DisplayName("when includeResponseCoverage is false, the breakdown is empty and never computed")
+ void skipsBreakdownWhenResponseCoverageNotRequested() {
+ DescribedEndpoint endpoint = new DescribedEndpoint(
+ HttpVerb.GET, "/orders", null, List.of(), List.of("200", "404"));
+ List tests = List.of(
+ verified(HttpVerb.GET, "/orders", "200"),
+ verified(HttpVerb.GET, "/orders", "404"));
+
+ List results = analyzer.analyze(List.of(endpoint), tests, false);
+
+ EndpointResponseCoverage row = results.get(0);
+ assertThat(row.contractTestCount()).isEqualTo(2);
+ assertThat(row.untrackedTestCount()).isZero();
+ assertThat(row.testCountByResponseCode()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("a test whose status code cannot be determined counts towards the total but not any response code")
+ void countsUndetectedStatusAsUntracked() {
+ DescribedEndpoint endpoint = new DescribedEndpoint(
+ HttpVerb.GET, "/orders", null, List.of(), List.of("200"));
+ List tests = List.of(
+ verified(HttpVerb.GET, "/orders", "200"),
+ verified(HttpVerb.GET, "/orders", null));
+
+ List results = analyzer.analyze(List.of(endpoint), tests, true);
+
+ EndpointResponseCoverage row = results.get(0);
+ assertThat(row.contractTestCount()).isEqualTo(2);
+ assertThat(row.untrackedTestCount()).isEqualTo(1);
+ assertThat(row.testCountByResponseCode()).containsExactly(Map.entry("200", 1));
+ }
+
+ @Test
+ @DisplayName("a detected status code not among the declared response codes counts as untracked")
+ void countsUndeclaredDetectedStatusAsUntracked() {
+ DescribedEndpoint endpoint = new DescribedEndpoint(
+ HttpVerb.GET, "/orders", null, List.of(), List.of("200"));
+ List tests = List.of(verified(HttpVerb.GET, "/orders", "500"));
+
+ List results = analyzer.analyze(List.of(endpoint), tests, true);
+
+ EndpointResponseCoverage row = results.get(0);
+ assertThat(row.contractTestCount()).isEqualTo(1);
+ assertThat(row.untrackedTestCount()).isEqualTo(1);
+ assertThat(row.testCountByResponseCode()).containsExactly(Map.entry("200", 0));
+ }
+
+ @Test
+ @DisplayName("a verified test for a different endpoint does not contribute to this endpoint's counts")
+ void ignoresUnrelatedVerifiedTests() {
+ DescribedEndpoint endpoint = new DescribedEndpoint(
+ HttpVerb.GET, "/orders", null, List.of(), List.of("200"));
+ List tests = List.of(verified(HttpVerb.POST, "/orders", "201"));
+
+ List results = analyzer.analyze(List.of(endpoint), tests, true);
+
+ assertThat(results.get(0).contractTestCount()).isZero();
+ }
+
+ @Test
+ @DisplayName("computes independent rows for multiple candidate endpoints")
+ void computesIndependentRowsForMultipleEndpoints() {
+ DescribedEndpoint getOrders = new DescribedEndpoint(
+ HttpVerb.GET, "/orders", null, List.of(), List.of("200"));
+ DescribedEndpoint postOrders = new DescribedEndpoint(
+ HttpVerb.POST, "/orders", null, List.of(), List.of("201"));
+ List tests = List.of(
+ verified(HttpVerb.GET, "/orders", "200"),
+ verified(HttpVerb.POST, "/orders", "201"),
+ verified(HttpVerb.POST, "/orders", "201"));
+
+ List results =
+ analyzer.analyze(List.of(getOrders, postOrders), tests, true);
+
+ assertThat(results).hasSize(2);
+ assertThat(results.get(0).contractTestCount()).isEqualTo(1);
+ assertThat(results.get(1).contractTestCount()).isEqualTo(2);
+ }
+
+ private VerifiedContractTest verified(HttpVerb verb, String path, String statusCode) {
+ return new VerifiedContractTest(new Endpoint(verb, path, "TestClass", "test()", "Test.java", 1), statusCode);
+ }
+}
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryStoreTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryStoreTest.java
new file mode 100644
index 00000000..67f15472
--- /dev/null
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryStoreTest.java
@@ -0,0 +1,115 @@
+package com.arc_e_tect.gradle.doppelganger.progress;
+
+import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("ResponseCoverageHistoryStore")
+class ResponseCoverageHistoryStoreTest {
+
+ @TempDir
+ Path tempDir;
+
+ private final ResponseCoverageHistoryStore store = new ResponseCoverageHistoryStore();
+
+ @Test
+ @DisplayName("load returns an empty map when the file does not exist")
+ void loadReturnsEmptyMapWhenFileDoesNotExist() {
+ File missingFile = tempDir.resolve("missing.ndjson").toFile();
+
+ assertThat(store.load(missingFile)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("round-trips a record's every field through save then load")
+ void roundTripsRecordFieldsThroughSaveThenLoad() {
+ File file = tempDir.resolve("history.ndjson").toFile();
+ ResponseCoverageRecord record = new ResponseCoverageRecord(
+ "3c7a1f0e9b224dd1-200", HttpVerb.GET, "/v1/foobars", "200", 2,
+ Instant.parse("2026-01-10T09:00:00Z"),
+ Instant.parse("2026-01-15T10:00:00Z"),
+ Instant.parse("2026-08-12T07:00:00Z"),
+ null);
+
+ store.save(file, List.of(record));
+ Map loaded = store.load(file);
+
+ assertThat(loaded.get("3c7a1f0e9b224dd1-200")).isEqualTo(record);
+ }
+
+ @Test
+ @DisplayName("round-trips a null firstCoveredAt and removedAt")
+ void roundTripsNullInstants() {
+ File file = tempDir.resolve("history.ndjson").toFile();
+ ResponseCoverageRecord record = new ResponseCoverageRecord(
+ "3c7a1f0e9b224dd1-404", HttpVerb.GET, "/v1/foobars", "404", 0,
+ Instant.parse("2026-01-10T09:00:00Z"), null, Instant.parse("2026-08-12T07:00:00Z"), null);
+
+ store.save(file, List.of(record));
+ Map loaded = store.load(file);
+
+ assertThat(loaded.get("3c7a1f0e9b224dd1-404").firstCoveredAt()).isNull();
+ assertThat(loaded.get("3c7a1f0e9b224dd1-404").removedAt()).isNull();
+ }
+
+ @Test
+ @DisplayName("writes a schemaVersion marker as the file's first line")
+ void writesSchemaVersionMarkerAsFirstLine() throws IOException {
+ File file = tempDir.resolve("history.ndjson").toFile();
+
+ store.save(file, List.of());
+
+ List lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
+ assertThat(lines.get(0)).isEqualTo("{\"schemaVersion\":1}");
+ }
+
+ @Test
+ @DisplayName("writes records sorted by fingerprint")
+ void writesRecordsSortedByFingerprint() throws IOException {
+ File file = tempDir.resolve("history.ndjson").toFile();
+ ResponseCoverageRecord b = new ResponseCoverageRecord(
+ "b-fingerprint", HttpVerb.GET, "/b", "200", 1, Instant.now(), null, Instant.now(), null);
+ ResponseCoverageRecord a = new ResponseCoverageRecord(
+ "a-fingerprint", HttpVerb.GET, "/a", "200", 1, Instant.now(), null, Instant.now(), null);
+
+ store.save(file, List.of(b, a));
+
+ List lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
+ assertThat(lines.get(1)).contains("a-fingerprint");
+ assertThat(lines.get(2)).contains("b-fingerprint");
+ }
+
+ @Test
+ @DisplayName("skips a malformed line without failing the build")
+ void skipsMalformedLine() throws IOException {
+ File file = tempDir.resolve("history.ndjson").toFile();
+ Files.writeString(file.toPath(), "{\"schemaVersion\":1}\nnot json at all\n");
+
+ assertThat(store.load(file)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("tolerates a file with no schemaVersion marker")
+ void toleratesFileWithNoSchemaVersionMarker() throws IOException {
+ File file = tempDir.resolve("history.ndjson").toFile();
+ Files.writeString(file.toPath(), "{\"fingerprint\":\"abc-200\",\"verb\":\"GET\",\"path\":\"/x\","
+ + "\"responseCode\":\"200\",\"testCount\":1,\"firstDeclaredAt\":\"2026-01-01T00:00:00Z\","
+ + "\"firstCoveredAt\":null,\"lastSeenAt\":\"2026-01-01T00:00:00Z\",\"removedAt\":null}\n");
+
+ Map loaded = store.load(file);
+
+ assertThat(loaded).containsKey("abc-200");
+ }
+}
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryUpdaterTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryUpdaterTest.java
new file mode 100644
index 00000000..35b73c27
--- /dev/null
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/progress/ResponseCoverageHistoryUpdaterTest.java
@@ -0,0 +1,156 @@
+package com.arc_e_tect.gradle.doppelganger.progress;
+
+import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+import com.arc_e_tect.gradle.detector.core.openapi.DescribedEndpoint;
+import com.arc_e_tect.gradle.doppelganger.detect.EndpointResponseCoverage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("ResponseCoverageHistoryUpdater")
+class ResponseCoverageHistoryUpdaterTest {
+
+ private final ResponseCoverageHistoryUpdater updater = new ResponseCoverageHistoryUpdater();
+
+ @Test
+ @DisplayName("a first-seen response code is stamped with firstDeclaredAt, lastSeenAt, and, if covered, firstCoveredAt")
+ void firstSeenCoveredCodeIsStampedOnFirstRun() {
+ Instant now = Instant.parse("2026-08-24T10:00:00Z");
+ EndpointResponseCoverage row = coverage("/orders", HttpVerb.GET, Map.of("200", 1));
+
+ Map updated = updater.update(Map.of(), List.of(row), now);
+
+ ResponseCoverageRecord record = onlyRecord(updated);
+ assertThat(record.responseCode()).isEqualTo("200");
+ assertThat(record.testCount()).isEqualTo(1);
+ assertThat(record.firstDeclaredAt()).isEqualTo(now);
+ assertThat(record.firstCoveredAt()).isEqualTo(now);
+ assertThat(record.lastSeenAt()).isEqualTo(now);
+ assertThat(record.removedAt()).isNull();
+ }
+
+ @Test
+ @DisplayName("a declared but uncovered response code gets firstDeclaredAt but not firstCoveredAt")
+ void declaredButUncoveredCodeHasNoFirstCoveredAt() {
+ Instant now = Instant.parse("2026-08-24T10:00:00Z");
+ EndpointResponseCoverage row = coverage("/orders", HttpVerb.GET, Map.of("404", 0));
+
+ Map updated = updater.update(Map.of(), List.of(row), now);
+
+ assertThat(onlyRecord(updated).firstCoveredAt()).isNull();
+ }
+
+ @Test
+ @DisplayName("firstCoveredAt is stamped the first time testCount becomes positive, on a later run")
+ void firstCoveredAtStampedWhenCodeBecomesCoveredLater() {
+ Instant declared = Instant.parse("2026-08-01T00:00:00Z");
+ Instant covered = Instant.parse("2026-08-10T00:00:00Z");
+ Map afterFirstRun =
+ updater.update(Map.of(), List.of(coverage("/orders", HttpVerb.GET, Map.of("404", 0))), declared);
+
+ Map afterSecondRun = updater.update(
+ afterFirstRun, List.of(coverage("/orders", HttpVerb.GET, Map.of("404", 1))), covered);
+
+ ResponseCoverageRecord record = onlyRecord(afterSecondRun);
+ assertThat(record.firstDeclaredAt()).isEqualTo(declared);
+ assertThat(record.firstCoveredAt()).isEqualTo(covered);
+ assertThat(record.testCount()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("firstDeclaredAt and firstCoveredAt are never overwritten once set")
+ void milestonesAreNeverOverwritten() {
+ Instant first = Instant.parse("2026-08-01T00:00:00Z");
+ Instant second = Instant.parse("2026-08-15T00:00:00Z");
+ Map afterFirstRun =
+ updater.update(Map.of(), List.of(coverage("/orders", HttpVerb.GET, Map.of("200", 3))), first);
+
+ Map afterSecondRun = updater.update(
+ afterFirstRun, List.of(coverage("/orders", HttpVerb.GET, Map.of("200", 5))), second);
+
+ ResponseCoverageRecord record = onlyRecord(afterSecondRun);
+ assertThat(record.firstDeclaredAt()).isEqualTo(first);
+ assertThat(record.firstCoveredAt()).isEqualTo(first);
+ assertThat(record.testCount()).isEqualTo(5);
+ assertThat(record.lastSeenAt()).isEqualTo(second);
+ }
+
+ @Test
+ @DisplayName("a response code missing from a later run is stamped with removedAt, not deleted")
+ void missingCodeIsStampedRemovedNotDeleted() {
+ Instant first = Instant.parse("2026-08-01T00:00:00Z");
+ Instant second = Instant.parse("2026-08-15T00:00:00Z");
+ Map afterFirstRun =
+ updater.update(Map.of(), List.of(coverage("/orders", HttpVerb.GET, Map.of("200", 1))), first);
+
+ Map afterSecondRun = updater.update(afterFirstRun, List.of(), second);
+
+ ResponseCoverageRecord record = onlyRecord(afterSecondRun);
+ assertThat(record.removedAt()).isEqualTo(second);
+ assertThat(record.testCount()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("a removed record's removedAt is reset to null when it reappears")
+ void reappearingRecordHasRemovedAtReset() {
+ Instant first = Instant.parse("2026-08-01T00:00:00Z");
+ Instant removed = Instant.parse("2026-08-10T00:00:00Z");
+ Instant reappeared = Instant.parse("2026-08-20T00:00:00Z");
+ Map afterFirstRun =
+ updater.update(Map.of(), List.of(coverage("/orders", HttpVerb.GET, Map.of("200", 1))), first);
+ Map afterRemoval = updater.update(afterFirstRun, List.of(), removed);
+
+ Map afterReappearance = updater.update(
+ afterRemoval, List.of(coverage("/orders", HttpVerb.GET, Map.of("200", 2))), reappeared);
+
+ assertThat(onlyRecord(afterReappearance).removedAt()).isNull();
+ }
+
+ @Test
+ @DisplayName("a removedAt already set is never overwritten by a later run that still doesn't see it")
+ void removedAtIsStampedOnlyOnce() {
+ Instant first = Instant.parse("2026-08-01T00:00:00Z");
+ Instant removed = Instant.parse("2026-08-10T00:00:00Z");
+ Instant stillMissing = Instant.parse("2026-08-20T00:00:00Z");
+ Map afterFirstRun =
+ updater.update(Map.of(), List.of(coverage("/orders", HttpVerb.GET, Map.of("200", 1))), first);
+ Map afterRemoval = updater.update(afterFirstRun, List.of(), removed);
+
+ Map stillRemoved = updater.update(afterRemoval, List.of(), stillMissing);
+
+ assertThat(onlyRecord(stillRemoved).removedAt()).isEqualTo(removed);
+ }
+
+ @Test
+ @DisplayName("each declared response code for an endpoint gets its own record")
+ void eachResponseCodeGetsItsOwnRecord() {
+ Instant now = Instant.parse("2026-08-24T10:00:00Z");
+ EndpointResponseCoverage row = coverage("/v1/foobars", HttpVerb.GET, Map.of("200", 2, "404", 1));
+
+ Map updated = updater.update(Map.of(), List.of(row), now);
+
+ assertThat(updated).hasSize(2);
+ assertThat(updated.values())
+ .extracting(ResponseCoverageRecord::responseCode, ResponseCoverageRecord::testCount)
+ .containsExactlyInAnyOrder(
+ org.assertj.core.groups.Tuple.tuple("200", 2),
+ org.assertj.core.groups.Tuple.tuple("404", 1));
+ }
+
+ private EndpointResponseCoverage coverage(String path, HttpVerb verb, Map testCountByResponseCode) {
+ DescribedEndpoint endpoint = new DescribedEndpoint(
+ verb, path, null, List.of(), List.copyOf(testCountByResponseCode.keySet()));
+ return new EndpointResponseCoverage(endpoint, testCountByResponseCode.values().stream()
+ .mapToInt(Integer::intValue).sum(), 0, testCountByResponseCode);
+ }
+
+ private ResponseCoverageRecord onlyRecord(Map records) {
+ assertThat(records).hasSize(1);
+ return records.values().iterator().next();
+ }
+}
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/OpenApiRequestValidatorScannerTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/OpenApiRequestValidatorScannerTest.java
index 3b3b6943..80074e7e 100644
--- a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/OpenApiRequestValidatorScannerTest.java
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/OpenApiRequestValidatorScannerTest.java
@@ -2,6 +2,7 @@
import com.arc_e_tect.gradle.detector.core.model.Endpoint;
import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+import com.arc_e_tect.gradle.doppelganger.detect.VerifiedContractTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -80,6 +81,28 @@ void returnsEmptyListForMissingDirectory(@TempDir Path tempDir) throws Exception
assertThat(scanner.scan(missing)).isEmpty();
}
+ @Test
+ @DisplayName("scanWithStatusCodes() detects the REST Assured .then().statusCode(...) assertion")
+ void scanWithStatusCodesDetectsStatusCode() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().startsWith("getOrderWithFilter"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly("200");
+ }
+
+ @Test
+ @DisplayName("scanWithStatusCodes() reports null when a validated request asserts no status")
+ void scanWithStatusCodesReportsNullWhenNoStatusAsserted() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().startsWith("createOrderWithDirectValidation"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly((String) null);
+ }
+
private static File fixtureDir() {
URL url = OpenApiRequestValidatorScannerTest.class.getClassLoader().getResource("fixtures/openapivalidator");
if (url == null) {
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/RestDocsScannerTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/RestDocsScannerTest.java
index 6fbeb2df..739748bb 100644
--- a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/RestDocsScannerTest.java
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/RestDocsScannerTest.java
@@ -2,6 +2,7 @@
import com.arc_e_tect.gradle.detector.core.model.Endpoint;
import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+import com.arc_e_tect.gradle.doppelganger.detect.VerifiedContractTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -219,6 +220,61 @@ void ignoresDynamicPathWebTestClientNewCoverage() throws Exception {
assertThat(endpoints).noneMatch(e -> e.methodSignature().startsWith("deleteItemDynamicPathWebTestClient"));
}
+ @Test
+ @DisplayName("scanWithStatusCodes() detects the MockMvc status().isOk() assertion")
+ void scanWithStatusCodesDetectsMockMvcIsOk() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().startsWith("getOrder"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly("200");
+ }
+
+ @Test
+ @DisplayName("scanWithStatusCodes() detects the MockMvc status().isCreated() assertion")
+ void scanWithStatusCodesDetectsMockMvcIsCreated() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().startsWith("createOrder"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly("201");
+ }
+
+ @Test
+ @DisplayName("scanWithStatusCodes() detects the WebTestClient expectStatus().isOk() assertion")
+ void scanWithStatusCodesDetectsWebTestClientIsOk() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().startsWith("getItemWebTestClient"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly("200");
+ }
+
+ @Test
+ @DisplayName("scanWithStatusCodes() reports null when a documented REST Assured test asserts no status")
+ void scanWithStatusCodesReportsNullWhenNoStatusAsserted() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().startsWith("getItemRestAssured"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly((String) null);
+ }
+
+ @Test
+ @DisplayName("scan() still returns the same endpoints as scanWithStatusCodes(), status codes aside")
+ void scanIsConsistentWithScanWithStatusCodes() throws Exception {
+ List endpoints = scanner.scan(fixtureDir());
+ List fromStatusCodes = scanner.scanWithStatusCodes(fixtureDir()).stream()
+ .map(VerifiedContractTest::endpoint)
+ .toList();
+
+ assertThat(endpoints).containsExactlyElementsOf(fromStatusCodes);
+ }
+
private static File fixtureDir() {
URL url = RestDocsScannerTest.class.getClassLoader().getResource("fixtures/restdocs");
if (url == null) {
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/SpringCloudContractScannerTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/SpringCloudContractScannerTest.java
index 6dd10f07..a4426c9f 100644
--- a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/SpringCloudContractScannerTest.java
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/SpringCloudContractScannerTest.java
@@ -2,6 +2,7 @@
import com.arc_e_tect.gradle.detector.core.model.Endpoint;
import com.arc_e_tect.gradle.detector.core.model.HttpVerb;
+import com.arc_e_tect.gradle.doppelganger.detect.VerifiedContractTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -90,6 +91,39 @@ void returnsEmptyListForMissingDirectory(@TempDir Path tempDir) throws Exception
assertThat(scanner.scan(missing)).isEmpty();
}
+ @Test
+ @DisplayName("scanWithStatusCodes() reads status(...) from a Groovy contract's response block")
+ void scanWithStatusCodesReadsStatusFromGroovyContract() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().equals("shouldReturnOrder"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly("200");
+ }
+
+ @Test
+ @DisplayName("scanWithStatusCodes() reads status(...) written in call-argument style")
+ void scanWithStatusCodesReadsStatusInCallArgumentStyle() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().equals("shouldCreateOrder"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly("201");
+ }
+
+ @Test
+ @DisplayName("scanWithStatusCodes() reads status from a YAML contract's response block")
+ void scanWithStatusCodesReadsStatusFromYamlContract() throws Exception {
+ List tests = scanner.scanWithStatusCodes(fixtureDir());
+
+ assertThat(tests)
+ .filteredOn(t -> t.endpoint().methodSignature().equals("shouldDeleteOrder"))
+ .extracting(VerifiedContractTest::statusCode)
+ .containsExactly("204");
+ }
+
private static File fixtureDir() {
URL url = SpringCloudContractScannerTest.class.getClassLoader().getResource("fixtures/contracts");
if (url == null) {
diff --git a/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetectorTest.java b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetectorTest.java
new file mode 100644
index 00000000..72e54d5d
--- /dev/null
+++ b/doppelganger-api-detector/src/test/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetectorTest.java
@@ -0,0 +1,77 @@
+package com.arc_e_tect.gradle.doppelganger.scan;
+
+import com.github.javaparser.StaticJavaParser;
+import com.github.javaparser.ast.expr.MethodCallExpr;
+import com.github.javaparser.ast.stmt.BlockStmt;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("StatusCodeDetector")
+class StatusCodeDetectorTest {
+
+ @Test
+ @DisplayName("detects MockMvc status().isOk()")
+ void detectsMockMvcIsOk() {
+ assertThat(detect("mockMvc.perform(get(\"/x\")).andExpect(status().isOk());")).contains("200");
+ }
+
+ @Test
+ @DisplayName("detects MockMvc status().isNotFound()")
+ void detectsMockMvcIsNotFound() {
+ assertThat(detect("mockMvc.perform(get(\"/x\")).andExpect(status().isNotFound());")).contains("404");
+ }
+
+ @Test
+ @DisplayName("detects MockMvc status().is(NNN) numeric form")
+ void detectsMockMvcNumericStatus() {
+ assertThat(detect("mockMvc.perform(get(\"/x\")).andExpect(status().is(422));")).contains("422");
+ }
+
+ @Test
+ @DisplayName("detects REST Assured .statusCode(NNN)")
+ void detectsRestAssuredStatusCode() {
+ assertThat(detect("given().when().get(\"/x\").then().statusCode(503);")).contains("503");
+ }
+
+ @Test
+ @DisplayName("detects WebTestClient .expectStatus().isOk()")
+ void detectsWebTestClientIsOk() {
+ assertThat(detect("webTestClient.get().uri(\"/x\").exchange().expectStatus().isOk();")).contains("200");
+ }
+
+ @Test
+ @DisplayName("detects WebTestClient .expectStatus().isEqualTo(NNN) numeric form")
+ void detectsWebTestClientNumericStatus() {
+ assertThat(detect("webTestClient.get().uri(\"/x\").exchange().expectStatus().isEqualTo(418);"))
+ .contains("418");
+ }
+
+ @Test
+ @DisplayName("returns empty when no recognised status assertion is present")
+ void returnsEmptyWhenNoStatusAsserted() {
+ assertThat(detect("given().when().get(\"/x\");")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("returns empty for a status assertion via a variable, not a literal")
+ void returnsEmptyForNonLiteralStatus() {
+ assertThat(detect("given().when().get(\"/x\").then().statusCode(expectedStatus);")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("returns empty for an empty call list")
+ void returnsEmptyForEmptyCallList() {
+ assertThat(StatusCodeDetector.detect(List.of())).isEmpty();
+ }
+
+ private Optional detect(String statement) {
+ BlockStmt block = StaticJavaParser.parseBlock("{ " + statement + " }");
+ List calls = block.findAll(MethodCallExpr.class);
+ return StatusCodeDetector.detect(calls);
+ }
+}
From 8f428a930fdb0be6c0726b918d6ed491f8c965e2 Mon Sep 17 00:00:00 2001
From: Iwan Eising
Date: Mon, 24 Aug 2026 22:04:15 +0400
Subject: [PATCH 2/3] chore(doppelganger-api-detector): bump api-detector-core
to 1.4.0
Picks up the published DescribedEndpoint.responseCodes() support the
scanContracts task depends on (Arc-E-Tect/SoftwareEngineeringDoneRight-Library#75).
Co-Authored-By: Claude Sonnet 5
---
doppelganger-api-detector/gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/doppelganger-api-detector/gradle/libs.versions.toml b/doppelganger-api-detector/gradle/libs.versions.toml
index a50ba14c..31a643c5 100644
--- a/doppelganger-api-detector/gradle/libs.versions.toml
+++ b/doppelganger-api-detector/gradle/libs.versions.toml
@@ -9,7 +9,7 @@ owasp-dependency-check = { id = "org.owasp.dependencycheck", version.re
owasp-dependency-check = "13.0.0"
-api-detector-core = "1.3.0"
+api-detector-core = "1.4.0"
javaparser = "3.28.2"
swagger-parser = "2.1.47"
junit-jupiter = "6.1.3"
From 74468e2eff650c54425dff0154f8cf673d576dc8 Mon Sep 17 00:00:00 2001
From: Iwan Eising
Date: Mon, 24 Aug 2026 22:11:37 +0400
Subject: [PATCH 3/3] fix(doppelganger-api-detector): fix broken javadoc link
failing the javadoc task
StatusCodeDetector referenced ContractVerificationSource by simple name
without an import, which javac tolerates but javadoc's stricter reference
resolution treats as a hard error - failing ./gradlew build (via the
javadoc task) in CI, though not check/test, which is why it was missed
locally before pushing. Fully-qualifies the reference, and adds the
missing @return tags ScanContractsTask's getters were flagged for while
in there.
Co-Authored-By: Claude Sonnet 5
---
.../doppelganger/ScanContractsTask.java | 102 +++++++++++++++---
.../doppelganger/scan/StatusCodeDetector.java | 3 +-
2 files changed, 89 insertions(+), 16 deletions(-)
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTask.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTask.java
index 5ecacd89..4c8e32e5 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTask.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/ScanContractsTask.java
@@ -61,47 +61,87 @@
@DisableCachingByDefault(because = "Report depends on source, test, contract, and OpenAPI document content and is cheap to regenerate")
public abstract class ScanContractsTask extends DefaultTask {
- /** Directories to search recursively for {@code @RestController} classes. */
+ /**
+ * Directories to search recursively for {@code @RestController} classes.
+ *
+ * @return mutable file collection of controller source directories
+ */
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
public abstract ConfigurableFileCollection getControllerDirs();
- /** Directories to search recursively for test classes. */
+ /**
+ * Directories to search recursively for test classes.
+ *
+ * @return mutable file collection of test source directories
+ */
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
public abstract ConfigurableFileCollection getTestDirs();
- /** See {@link DetectDoppelgangerApisTask#getTestDirsUserConfigured()}. */
+ /**
+ * See {@link DetectDoppelgangerApisTask#getTestDirsUserConfigured()}.
+ *
+ * @return mutable boolean property, {@code true} when {@link #getTestDirs()} reflects the
+ * user's own configuration rather than only the plugin's default
+ */
@Input
public abstract Property getTestDirsUserConfigured();
- /** The root OpenAPI document describing the API. */
+ /**
+ * The root OpenAPI document describing the API.
+ *
+ * @return mutable file property for the root OpenAPI document
+ */
@Optional
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
public abstract RegularFileProperty getRootDocument();
- /** Directory where OpenAPI descriptions are stored. */
+ /**
+ * Directory where OpenAPI descriptions are stored.
+ *
+ * @return mutable directory property for the OpenAPI description directory
+ */
@Optional
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
public abstract DirectoryProperty getOpenApiDir();
- /** Directory searched for Spring Cloud Contract DSL files when {@link #getUseSpringCloudContract()} is {@code true}. */
+ /**
+ * Directory searched for Spring Cloud Contract DSL files when
+ * {@link #getUseSpringCloudContract()} is {@code true}.
+ *
+ * @return mutable directory property for the Spring Cloud Contract directory
+ */
@Optional
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
public abstract DirectoryProperty getContractsDir();
- /** Whether to treat Spring RestDocs test methods as verification evidence. */
+ /**
+ * Whether to treat Spring RestDocs test methods as verification evidence.
+ *
+ * @return mutable boolean property controlling whether the Spring RestDocs source is enabled
+ */
@Input
public abstract Property getUseRestDocs();
- /** Whether to treat Atlassian OpenAPI request validator usage as verification evidence. */
+ /**
+ * Whether to treat Atlassian OpenAPI request validator usage as verification evidence.
+ *
+ * @return mutable boolean property controlling whether the OpenAPI request validator source
+ * is enabled
+ */
@Input
public abstract Property getUseOpenApiRequestValidator();
- /** Whether to treat Spring Cloud Contract DSL files as verification evidence. */
+ /**
+ * Whether to treat Spring Cloud Contract DSL files as verification evidence.
+ *
+ * @return mutable boolean property controlling whether the Spring Cloud Contract source is
+ * enabled
+ */
@Input
public abstract Property getUseSpringCloudContract();
@@ -115,15 +155,27 @@ public abstract class ScanContractsTask extends DefaultTask {
@Input
public abstract Property getIncludeResponseCoverage();
- /** Directory the AsciiDoc report is written to. */
+ /**
+ * Directory the AsciiDoc report is written to.
+ *
+ * @return mutable directory property for the report output directory
+ */
@OutputDirectory
public abstract DirectoryProperty getReportDir();
- /** Name of the generated AsciiDoc report file (without path). */
+ /**
+ * Name of the generated AsciiDoc report file (without path).
+ *
+ * @return mutable string property for the report file name
+ */
@Input
public abstract Property getReportFileName();
- /** Version of the system under test whose {@code @RestController} classes were scanned. */
+ /**
+ * Version of the system under test whose {@code @RestController} classes were scanned.
+ *
+ * @return mutable string property for the system-under-test version
+ */
@Input
public abstract Property getSystemUnderTestVersion();
@@ -131,6 +183,8 @@ public abstract class ScanContractsTask extends DefaultTask {
* Whether to persist, across builds, a history of response code coverage. Only meaningful
* together with {@link #getIncludeResponseCoverage()} - see {@link #generate()}'s eager
* validation of that combination.
+ *
+ * @return mutable boolean property controlling whether response coverage history is tracked
*/
@Input
public abstract Property getTrackResponseCoverageHistory();
@@ -140,6 +194,8 @@ public abstract class ScanContractsTask extends DefaultTask {
* {@link #getUpdateResponseCoverageHistory()} is {@code true}, written back to. See
* {@link DetectDoppelgangerApisTask#getContractHistoryFile()} for why this is {@code @Internal}
* rather than tracked through Gradle's file-content-based up-to-date checking.
+ *
+ * @return mutable file property for the response coverage history file
*/
@Internal
public abstract RegularFileProperty getResponseCoverageHistoryFile();
@@ -160,20 +216,36 @@ public String getResponseCoverageHistoryFilePath() {
* Whether {@link #getResponseCoverageHistoryFile()} is written back to disk after being updated
* with the current run's coverage. Only consulted when {@link #getTrackResponseCoverageHistory()}
* is {@code true}; the history file is always read regardless.
+ *
+ * @return mutable boolean property controlling whether the response coverage history file is
+ * written back
*/
@Input
public abstract Property getUpdateResponseCoverageHistory();
- /** Exclusion rule strings - see {@link DoppelgangerApiDetectorExtension#getExcludePaths()}. */
+ /**
+ * Exclusion rule strings - see {@link DoppelgangerApiDetectorExtension#getExcludePaths()}.
+ *
+ * @return mutable list property of exclusion rule strings
+ */
@Input
public abstract ListProperty getExcludePaths();
- /** External exclusion rule files - see {@link DoppelgangerApiDetectorExtension#getExcludeFiles()}. */
+ /**
+ * External exclusion rule files - see {@link DoppelgangerApiDetectorExtension#getExcludeFiles()}.
+ *
+ * @return mutable file collection of exclusion rule files
+ */
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
public abstract ConfigurableFileCollection getExcludeFiles();
- /** Bundled well-known exclusion set names - see {@link DoppelgangerApiDetectorExtension#getExcludeWellKnown()}. */
+ /**
+ * Bundled well-known exclusion set names - see
+ * {@link DoppelgangerApiDetectorExtension#getExcludeWellKnown()}.
+ *
+ * @return mutable list property of well-known exclusion set names
+ */
@Input
public abstract ListProperty getExcludeWellKnown();
diff --git a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetector.java b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetector.java
index 29ea3a2a..16b55b86 100644
--- a/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetector.java
+++ b/doppelganger-api-detector/src/main/java/com/arc_e_tect/gradle/doppelganger/scan/StatusCodeDetector.java
@@ -9,7 +9,8 @@
/**
* Best-effort detection of the HTTP status code a test method asserts, from the same
- * {@code List} a {@link ContractVerificationSource} AST scanner already collects
+ * {@code List} a {@link com.arc_e_tect.gradle.doppelganger.detect.ContractVerificationSource}
+ * AST scanner already collects
* for that method - not exhaustive, since a test can assert a response's status in ways this
* cannot recognise (a variable, a helper method, a custom matcher); such tests still count towards
* an endpoint's overall contract test count, they simply contribute no evidence to any specific