diff --git a/docs/design/review-toolchain-adapters.md b/docs/design/review-toolchain-adapters.md index 6b9c90e8f0a..cbf4ffcd086 100644 --- a/docs/design/review-toolchain-adapters.md +++ b/docs/design/review-toolchain-adapters.md @@ -2,11 +2,11 @@ ## Status -Accepted, implemented. This document covers the extraction of the toolchain -adapter boundary: the npm-specific `qwen review build-test` behavior moves -behind an internal contract without changing its command-line interface or -report format. The phase that adds the first second adapter appends its own -section here. +P0 extraction plus P1 Maven implementation. P0 extracted the existing +npm-specific `qwen review build-test` behavior behind an internal toolchain +adapter without changing its command-line interface or report format. P1 adds +the Maven adapter, which widens the report's `toolchain` discriminant to +`"npm" | "maven" | "unsupported"` — see Report semantics below. ## Problem @@ -168,6 +168,40 @@ P0 changes: - `packages/cli/src/commands/review/build-test.test.ts` - Remains the end-to-end compatibility suite for the command facade. +P1 changes: + +- `packages/cli/src/commands/review/build-test.ts` + - Widens the `toolchain` discriminant, registers the Maven adapter, and + prefers npm on a mixed root with a Maven-unverified disclosure, falling + to the Maven adapter only when npm concedes without executing. +- `packages/cli/src/commands/review/lib/toolchain.ts` + - Widens the `install` contract's documented semantics to cover Maven's + best-effort `dependency:go-offline` warm-up alongside `npm ci`. +- `packages/cli/src/commands/review/lib/npm-toolchain.ts` + - Names the Maven adapter in the mixed-root selection guard's rationale. +- `packages/cli/src/commands/review/lib/maven-toolchain.ts` + - Owns Maven reactor discovery, changed-file ownership, the scoped + lifecycle run, and the Surefire/Failsafe evidence. +- `packages/cli/src/commands/review/lib/maven-toolchain.test.ts` + - Pins reactor parsing, ownership, classification, and evidence behavior. +- `packages/cli/src/commands/review/lib/disk.ts` + - Shared disk-space preflight used by both adapters. +- `packages/cli/src/commands/review/base-tree.ts` + - Skips Maven merge bases before checkout (root-pom probe, + npm-applicability probe, nested-pom probe). +- `packages/cli/src/commands/review/test-plan.ts` + - Settles Maven command claims and Surefire test-count claims against the + recorded runs. +- `packages/cli/src/commands/review/lib/agent-briefs.ts` + - Agent 7's Maven branch and the fail-closed fallback rules. +- `packages/cli/src/commands/review/test-delta.ts` + - Keeps the base-side rerun grammar npm-only: Maven lifecycle commands + the Maven adapter records are skipped and disclosed, never re-executed + in the base worktree. +- Test pins: `build-test.test.ts`, `base-tree.test.ts`, `test-plan.test.ts`, + `agent-prompt.test.ts`, and `test-delta.test.ts` grow the Maven branches + beside `lib/maven-toolchain.test.ts`. + ## Testing Focused tests must prove: @@ -181,23 +215,376 @@ Focused tests must prove: remains unchanged through `runBuildTest`. 6. The serialized report shape remains unchanged. +P1 adds the Maven oracle set, pinned by `lib/maven-toolchain.test.ts` plus +the Maven branches of the `test-plan`, `base-tree`, `build-test`, +`agent-prompt`, and `test-delta` suites: + +1. Selector safety: a directory name carrying `,`, `:`, `%`, or a leading + `-`/`!` cannot reach a `-pl` selector and widens the run to the full + reactor; any other name passes as a safe bare token when its characters + allow, or is quoted for the platform shell (POSIX single-quote wrap, + win32 `"…"` under the `%`-rejection and filename gates) — never + interpolated bare into a `shell: true` command line when unsafe. +2. Ownership: changed paths map to the nearest ancestor project, skipping + `src/` fixture trees; a changed POM is reactor-wide; documentation (doc + extensions in doc-shaped locations only) and repository metadata are + exempted. A project Maven rejects as absent from the active reactor becomes + the unsupported handoff. +3. One root-cwd wrapper/Maven lifecycle command with `-pl -am`, + preceded by a best-effort `dependency:go-offline` warm-up on its own + deadline; reactor-wide inputs — and a `-pl` selector past the + launch-safe length — disable narrowing. +4. Fresh Surefire/Failsafe evidence: quote-aware, multi-suite parsing; stale + XML ignored; a green exit over fresh failing reports — or over framed + errors Maven did not fail on — is a failure, never a pass. +5. Timeout and spawn death are always infrastructure, never a finding — no + input exception exists for them. Acquisition failures are infrastructure + with the diff-inputs exceptions, never a finding. +6. Downstream consumers: `base-tree` skips Maven bases before checkout, + `test-plan` settles Maven claims against recorded runs, Agent 7's + brief carries the Maven branch, and `test-delta` refuses Maven lifecycle + commands in the npm-only rerun grammar. + Verification commands: ```bash -cd packages/cli && npx vitest run src/commands/review/ +cd packages/cli && npx vitest run src/commands/review/build-test.test.ts src/commands/review/lib/npm-toolchain.test.ts src/commands/review/lib/maven-toolchain.test.ts src/commands/review/test-plan.test.ts src/commands/review/base-tree.test.ts src/commands/review/agent-prompt.test.ts src/commands/review/test-delta.test.ts +npm run build npm run typecheck ``` +## P1: Maven multi-module verification + +P1 is driven by active use in `alibaba/fastjson2` and `alibaba/druid`, not by a +hypothetical future language plugin. Both are root Maven reactors with checked-in +wrappers, shared core modules, downstream extension or starter modules, nested or +profile-activated modules, and broad CI matrices. Maven support is complete only +when it produces useful deterministic evidence for those repository shapes. + +### Reference constraints + +Fastjson2 and Druid establish these requirements: + +- Always run a checked-in wrapper from the resolved reactor root (`./mvnw`, or + `mvnw.cmd` on win32, where `./mvnw` is not runnable). On POSIX a checked-in + `./mvnw` without the executable bit (a `core.fileMode=false` checkout) also + falls back to the system `mvn`, because running it would die with exit 126 + and turn the whole run into an infrastructure handoff that verifies + nothing. A checked-in wrapper that is empty (0 bytes) also falls back to + the system `mvn` on both platforms: it passes the existence and exec-bit + gates, exits 0, and would otherwise certify a build that never started. A + DIRECTORY carrying the wrapper name falls back for a different reason: it + passes the same gates but dies exit 126 on execution on POSIX, and cannot + execute at all on win32 (where the gate is a regular file with non-zero + size, not an exec bit). Druid's older wrapper depends + on the process cwd and fails when invoked by absolute path from another + repository. When no wrapper exists, + use the system `mvn`. +- Module directory and artifactId are not interchangeable. Druid's `core` + directory produces artifactId `druid`; report paths use module directories, + while Maven remains responsible for resolving the selected reactor projects. +- Core changes must exercise Maven's upstream reactor expansion. The selected + command uses `-am`; downstream (`-amd`) expansion selects the whole reactor on + exactly the repositories that motivated P1, and a run that spends its entire + deadline timing out proves nothing, so downstream coverage stays with the + project's CI matrix. P1 does not claim this is a recursively computed + dependency-graph closure. +- Root `pom.xml` and `.mvn/**` affect the whole reactor and disable module + narrowing. Of the two wrapper scripts, only the one this platform executes + does: every wrapper repo ships both `mvnw` and `mvnw.cmd`, and a change + confined to the other platform's wrapper cannot affect this run, so it is + inert for narrowing (the report discloses when it was changed but not + exercised). +- Profile modules must not be treated as unconditionally active. P1 discovers + module ownership from a nearest-ancestor filesystem walk over `pom.xml` + locations (never from `` aggregation lists), and Maven remains the + authority on whether a selected project belongs to the active reactor under + the current JDK and profiles. A rejected selector fails closed and is never + reported as a successful partial verification. +- External smoke runs must not use `clean`. Existing Surefire/Failsafe reports + may be stale, so only XML files created or updated by the current invocation + are evidence. + +### Adapter selection + +P1 still uses root-level `applies(root)` detection. A root where both npm and +Maven apply does NOT fail closed to `toolchain: "unsupported"`: build-test +prefers npm — preserving what a review verified on this shape before the Maven +adapter existed, since running nothing at all once shipped a broken JS build +through review with zero evidence — and discloses the unverified Maven half in +the note. When npm concedes or returns WITHOUT executing (an unsupported +handoff, a workspace-shaped diff mapping zero affected files, an install disk +preflight, or an exhausted budget), the Maven adapter runs instead and its own +caveat discloses the unscopable npm side. P1 does not yet model nested +toolchain roots or changed-file ownership across toolchains. + +This is intentionally conservative. P1 does not aggregate multiple toolchains: +whichever half runs, the other is named unverified rather than silently +validated, so a green run on one side never certifies the other. + +### Reactor and module ownership + +P1 does not model the Maven reactor. Maven is the authority on which projects +it contains, and this adapter reads that answer back rather than recomputing +it: + +1. Exempt documentation (doc extensions in doc-shaped locations only) and + repository metadata; such paths select nothing. The exemption runs BEFORE + ownership: a README-only or `.github/`-only diff maps to no project. +2. Assign each changed path to the nearest ancestor directory holding a + `pom.xml`, skipping directories strictly beneath a `src/` tree: a POM + there is OFTEN maven-invoker or archetype test data, but a reactor can + also aggregate a real module under a bare `src/` path + (`src/core`). `src/test/` and `src/it/` are the + principled fixture shapes. Fail closed to reactor-wide when the walk + skipped a src-nested POM and either that POM is not one of those fixture + shapes or the walk would collapse to the ROOT project: the skipped POM + may be a real module, `-pl .` compiles only the root, and + `-pl -am` adds only UPSTREAM projects, so a mis-skipped + collapse leaves the changed module untested under a green verdict. A + root collapse with NO skipped POM is trusted: root-owned `src/` changes + narrow to `-pl . -am`. +3. Use repository-relative project paths as the `-pl` selectors, and fail + closed to the full reactor when a directory name cannot be expressed in one + (`,` and `:` change what a selector means to Maven; `%` expands in cmd.exe; + a leading `-` or `!` reads as an option or an exclusion). +4. Treat any changed POM as reactor-wide. A POM is parent config for + everything that aggregates or inherits it, and `-pl -am` would + compile the aggregator and test nothing that changed. +5. Let Maven reject the selector. `Could not find the selected project in the +reactor` is the authoritative answer for a standalone or profile-inactive + project — evaluated against the real effective model, the active profiles, + and the current JDK, and returned before anything is compiled. That + rejection becomes the structured unsupported handoff. + +An earlier revision of this design parsed the POMs directly: literal +`` recursion, CDATA and comment handling, `` `relativePath` +resolution with the artifactId match Maven itself applies, named and deleted +parent files, and an aggregation-plus-inheritance closure over all of it. That +is a second, weaker model of exactly what the next command evaluates for real, +and it was weakest on the shapes that motivated P1: profile-activated modules +in Druid and Flink, where a text-level parse cannot evaluate activation and had +to fail closed. Removing it deleted ~670 lines of adapter source and ~1360 +lines of its tests, and moved the profile-activation answer from an +approximation to Maven's own. + +Parent inheritance, dependencies, optional edges, dependency management, and +reactor ordering remain Maven's job through `-am`, as before. + +### Commands + +P1 performs one lifecycle invocation per verification target to avoid paying for +the reactor twice. When dependency acquisition is enabled (the default), a +best-effort warm-up runs first on its own deadline: + +- Dependency warm-up: `./mvnw --batch-mode --no-transfer-progress [-pl -am] dependency:go-offline -q`. + A review worktree is cold by construction, and without this step the cold + resolve shares the single lifecycle deadline with compilation and the tests. + The warm-up never blocks the lifecycle run: its known gaps resolve inside the + lifecycle command as before, and a partial local repository — unlike a + partial `node_modules` — is content-addressed and resumable. +- Normal verification: `./mvnw --batch-mode --no-transfer-progress [-pl -am] test`. +- Build-only base preparation: `./mvnw --batch-mode --no-transfer-progress [-pl -am] test-compile`. +- When no checked-in wrapper exists, use `mvn` with the same arguments. + +The command always runs with the reactor root as cwd. P1 does not inject project +profiles or `clean`; project rules and CI remain responsible for broader JDK, +OS, profile, integration-test, and packaging matrices. + +The `-pl` selector is capped: a mid-level aggregator change closes over every +aggregation and inheritance descendant, and the comma-joined selector can +approach cmd.exe's 8191-character command-line limit on the large reactors P1 +targets. Past the cap the run widens to the full reactor and discloses it. + +### Report semantics + +> **Rule — judging pass/fail from untrusted tool output.** Prefer the tool's +> authoritative _structured_ signals (exit code + machine-readable reports) +> over scraping human stdout. Parse the structured output with a **real** +> parser and treat a parse failure as fail-closed. "Pass" requires _positive +> structural evidence_ — exit 0 and a parsed report with no failing element — +> never the mere absence of a failure substring. Where a grammar must be +> parsed (CLI args), use one spec-referenced tokenizer and fail-closed on +> ambiguity: imprecision can only ever be stricter, never release. The +> human-stdout scrapers are a _fallback_ for runs that produced no reports; +> with reports present the structured signals judge, and the scrapers can +> only ever convict under a distrusted exit 0. Same lesson as #9020 — read +> what the tool authoritatively reports, don't enumerate the ways its output +> can lie — applied to build results. + +Concretely, the Maven verdict is a fail-closed state machine over three +authoritative signals — the exit code, the parsed report elements, and the +`.mvn/maven.config` settings that change what exit 0 means (fail-never / +testFailureIgnore, or an unreadable config grammar assumed to carry them). +A distrusted exit 0 (one of those settings, or an ambiguous config) lets the +framed-stdout failure scans convict even beside clean reports — the strict +direction. When no fresh reports exist, the stdout channel is the only +evidence left, so the scrapers judge there (Surefire `Tests run:` summaries +survive a relocated ``, and framed errors a fail-never +setting swallowed live nowhere else). When reports exist, a non-zero exit is +attributed to the PR — over-attribution, never an environmental wash. + +`BuildTestReport.toolchain` widens to `"npm" | "maven" | "unsupported"`. +Existing fields are generalized without changing their JSON shape: + +- `affected`: changed Maven module directories, or `.` for a reactor-wide + change. +- `buildSet`: selectors handed to Maven. It does not pretend to enumerate every + project Maven adds through `-am`. +- `widenedWith`: remains npm-specific and is empty for Maven. +- `install`: the Maven warm-up command when dependency acquisition is enabled + (null when it is not). Whatever the warm-up misses still resolves inside the + lifecycle command, whose result is the one the verdicts read. +- `build`: contains the Maven `test-compile` command in build-only mode. +- `test`: contains the Maven `test` command in normal mode. +- `timedOut`, `ok`, and `note`: retain their current cross-toolchain meaning. + +Command results carry six optional classification flags consumed by +`test-plan`: + +- `CommandResult.infrastructure`: the adapter classified the failure as + environmental (Maven/Java or dependency acquisition, an unlaunchable + wrapper), so a Test Plan claim must not be settled against it. +- `CommandResult.swallowedFailure`: the command exited 0 but its output + records failures Maven did not fail on (a fail-never setting, or a + skip-tests setting that suppressed the whole test phase), so a Test Plan + claim must not be ruled reproduced against it. +- `CommandResult.evidenceCapped`: the adapter refused to certify the run + because part of its evidence was never read or cannot corroborate a pass + (reports rejected by the parser, reports unseen past a truncated sweep, or + failure-evidence lines dropped by the output trim's rescue cap) + and a Test Plan claim must not be settled against it. The flag is + exit-code independent: on an exit-0 run it withholds a pass; on a + non-zero exit the exit remains definitive. +- `CommandResult.testsSuppressed`: a skip setting suppressed the entire test + phase (`Tests are skipped.`) — zero tests ran, so count claims must not + adjudicate against the run and a contradiction is worded as suppression, + not recorded failures. +- `CommandResult.neverRan`: the command exited 0 but cannot prove the + toolchain started, and no fresh reports exist. With an unmodified launcher + that means no fresh reports and no Maven-framed output (a stub wrapper). + A `-q`/`--quiet` setting strips every framed line, and a `-l`/`--log-file` + setting redirects the whole stdout to a file — with no reports on disk, + neither state can prove a build started, so both land here. A wrapper the + diff itself modified controls the output channel, so with no fresh + reports it lands here too; but when it DID surface fresh reports the + structured evidence judges the run (a failing report still overrides a + green exit), and a green verdict carries a note naming the diff-changed + wrapper caveat instead of silently trusting it. Either way the run is not + certified on stdout alone, and a Test Plan claim must not be ruled + reproduced against a never-ran run. +- `CommandResult.swallowedReports`: the command exited 0 over fresh failing + Surefire/Failsafe reports (a `testFailureIgnore`-style setting swallowed + them). It is a FAILED run for ruling and count purposes: a Test Plan claim + must not be ruled reproduced against it, and its derived pass count must + not adjudicate a count claim. Distinct from `swallowedFailure` (which keys + on the output's framed wording) — this flag keys on the parsed reports + themselves. + +Command results additionally carry `maven` — the lifecycle phase, `-pl` +module set, and `-am` flag the adapter rendered the command from — so +`test-plan` settles scope claims against structured values instead of +parsing the command line back. + +Dependency/plugin resolution failures and unavailable wrapper/runtime are +infrastructure outcomes (the unlaunchable-wrapper guarantee on POSIX only — +win32 wrapper-launch deaths remain attributed to the diff until the +predicate gap noted in Risks is closed), except when the diff changed the +inputs that could +have caused them: dependency-input changes (POMs, `.mvn/**`, the settings or +repository locations `.mvn/maven.config` references, and the wrapper file +this platform executes) suppress the resolution carve-out, and a change to +the executed wrapper — the script OR its `.mvn/wrapper/**` configuration, +which names the distribution the script downloads — suppresses the +launch-failure carve-out, so a PR-caused breakage is filed against the PR, +not the environment. Unframed launch diagnostics (`mvn: command not found`, +JAVA_HOME errors) count only in the output preceding the first Maven-framed +line; once Maven is talking, those words in a test's own stdout cannot +launder a source failure into infrastructure. Timeout and spawn +death are always infrastructure — no input exception exists for them — but +when the interrupted run still produced fresh failing reports, those failures +stay visible as test evidence, and when its captured output ALSO records +Surefire `Tests run:` summaries with non-zero failures, or source or goal +failures a fail-never/fail-at-end setting never exited on, the note +discloses them — none is framed as purely environmental. Compiler and +test failures remain deterministic build/test evidence, and a zero exit that +Maven's own `[ERROR]`/`[FATAL]` framing contradicts (a fail-never setting) +counts as a failure, not a pass. +Classification uses both command output and whether the current invocation +produced fresh Surefire/Failsafe reports; a resolution failure with no fresh +reports is filed as a source defect only when the diff changed the +resolution inputs. + +### Test reports + +Before invoking Maven, record existing Surefire/Failsafe XML paths and mtimes. +After it returns, parse only reports created or updated after the invocation +started. Reports are parsed with a strict, throwing XML parser (`saxes`): +well-formedness, CDATA, comments, entities, and self-closing tags are the +parser's job. A report the parser rejects (oversized, unreadable, or +malformed) is treated as failing — unknown evidence joins the fail-closed +rejections and the run is never certified green over it. This replaces an +earlier hand-rolled tag walk: XML has infinitely many corners, and each +review round found the next one — the same anti-pattern the review skill hit +with a hand-rolled CommonMark scanner and closed by adopting a real parser +(#9020). The strict parser closes the class instead of enumerating it, and +removes the surefire-stdout CDATA exemption along with every swallowing +probe. Every fresh report is parsed — the earlier parse-count cap once let a +failing report ordered past the cap certify green — so the sweep's path cap +and the per-file size cap are the only bounds. + +Normalized Maven evidence must retain module-relative identity so two modules +with the same test class cannot be conflated. Fresh report summaries are appended +to the bounded command output for Agent 7 and test-plan consumption; raw stale +reports are ignored. Surefire writes one XML per test class, so clean reports roll +up per project dir — UNcapped, one attributed line per project, so no module +can land in an unattributed omitted tail; the failing-report and failing-case +lines stay capped, preserving attribution (per-project rollups, not a +byte-order slice). The block is appended after the command output is trimmed. + +### Downstream integration + +P1 updates the existing consumers that otherwise reject or misread Maven: + +- `base-tree` builds only npm merge bases in this release. Its A/B consumer + (`test-delta`) reruns npm test commands, and Agent 7's Maven branch discloses + that base-side Maven attribution is unavailable, so a Maven base build would be + cost without a consumer; lift this gate when Maven delta attribution exists. +- Agent 7 has an explicit Maven branch, describes modules rather than npm + workspaces, and treats wrapper/dependency acquisition failures as + infrastructure. +- `test-plan` recognizes Maven/Surefire test counts and actual Maven command + execution. +- Maven test failures do not enter the npm-only `test-delta` rerun path in P1. + Agent 7 discloses that base A/B attribution was not performed rather than + asking an npm grammar to rerun Maven. +- Deterministic Maven findings continue using `Source: [build]` and + `Source: [test]`; `compose-review` needs no toolchain-specific change. + +Full Maven-aware base test-delta and failure demotion are deferred until their +identity schema can explicitly carry module and report provenance. P1 must not +infer Java test ownership from npm `--workspace` conventions. + +### P1 scope boundaries + +P1 does not implement: + +- Gradle; +- JaCoCo or changed-line coverage; +- Maven mutation testing; +- arbitrary user-selected profiles; +- automatic JDK/OS matrix execution; +- multi-toolchain result aggregation; +- Maven-aware base-side test-delta. + ## Future phases -### A second toolchain +### Gradle -The boundary exists so a second language lands as a registration rather than -another branch in `build-test.ts`. Whichever comes first — Maven, Gradle — -should prefer a checked-in wrapper, take its project model from the build tool -itself rather than re-deriving one from the manifests, select the projects the -diff changed, and parse the JUnit XML the run produced. A build it cannot -model must fail closed to an unsupported handoff, never to a partial green. +A Gradle adapter should prefer `gradlew`, discover projects from Gradle's own +model where possible, select changed projects, execute project-scoped compile +and test tasks, and parse JUnit XML. Dynamic builds that cannot be modeled must +fail closed to an unsupported handoff. ### Coverage artifacts @@ -209,26 +596,45 @@ for a concrete untested behavior, not an automatic Critical threshold. A later orchestration layer may detect multiple verification roots and invoke one adapter per target. It should aggregate evidence while preserving each -command's toolchain, root, module, and infrastructure status. This phase avoids -specifying that before two real adapters demonstrate the common boundary. +command's toolchain, root, module, and infrastructure status. P0 avoids +specifying this before two real adapters demonstrate the common boundary. ## Risks +- **Win32 wrapper-launch deaths are not yet infrastructure:** a broken but + present `mvnw.cmd` produces cmd.exe diagnostics that match none of the + POSIX launch-failure shapes, so on Windows such an environment outage stays + attributed to the diff. The infrastructure guarantee for an unlaunchable + wrapper holds on POSIX only; close the predicate gap before relying on it + on win32. - **Accidental report drift:** protected by the existing `build-test` suite and explicit report-shape assertions. -- **Adapter abstraction without behavior:** this phase is justified only if npm +- **Adapter abstraction without behavior:** P0 is justified only if npm detection and execution move behind the adapter rather than adding an empty interface around unchanged branching. - **Premature generalization:** the contract intentionally excludes coverage, mutation, CI discovery, and multi-toolchain aggregation. -- **False applicability:** the npm adapter applies only when the root +- **False applicability:** P0's npm adapter applies only when the root `package.json` can scope something — workspaces or a root build/test script. A package.json with neither workspaces nor build/test scripts (husky, a lint - config, a script-less docs site) does not apply, so a future adapter can own - such a root alone rather than losing it to a manifest npm cannot scope. + config, a script-less docs site) does not apply, so the Maven adapter owns + such a root alone. npm applies only when a declared `workspaces` field is + fully modeled and resolves to at least one package, or — with no workspaces + declared — the root defines a build/test script (the shape where both + adapters apply: build-test runs npm and discloses the unverified Maven + half, per the Adapter selection rule). A root whose workspaces gate refuses npm (an unmodeled or + zero-package glob) falls to the Maven adapter ALONE with the mixed-root + disclosure note, not the fail-closed handoff — all finer-grained support + decisions remain in the one execution path whose existing tests already + fail closed to a structured handoff. ## Open questions -Report-schema widening for a second toolchain — the `toolchain` discriminant -and any per-command classification flags — is deferred to the phase that -introduces that behavior, along with multi-toolchain aggregation. +None. P1 settled the report-schema widening it introduced (`toolchain` +discriminant, `CommandResult.infrastructure`, +`CommandResult.swallowedFailure`, `CommandResult.evidenceCapped`, +`CommandResult.testsSuppressed`, `CommandResult.neverRan`, +`CommandResult.swallowedReports`, +`CommandResult.rescueOverflow`, `CommandResult.maven`); +multi-toolchain aggregation remains a decision for the phase that introduces +that behavior. diff --git a/package-lock.json b/package-lock.json index bd3f95e41b6..a4da4acc21f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23747,7 +23747,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -27413,7 +27412,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/y18n": { @@ -27910,6 +27908,7 @@ "qrcode-terminal": "^0.12.0", "react": "^19.2.4", "read-package-up": "^11.0.0", + "saxes": "^6.0.0", "shell-quote": "^1.9.0", "simple-git": "^3.36.0", "string-width": "^7.1.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index d74949c4d22..85044ed843a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -81,6 +81,7 @@ "qrcode-terminal": "^0.12.0", "react": "^19.2.4", "read-package-up": "^11.0.0", + "saxes": "^6.0.0", "shell-quote": "^1.9.0", "simple-git": "^3.36.0", "string-width": "^7.1.0", @@ -107,14 +108,14 @@ "@types/node": "^22.0.0", "@types/prompts": "^2.4.9", "@types/qrcode-terminal": "^0.12.2", - "@types/ws": "^8.5.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", "@types/supertest": "^6.0.3", - "@types/yauzl": "^2.9.1", + "@types/ws": "^8.5.0", "@types/yargs": "^17.0.32", + "@types/yauzl": "^2.9.1", "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", diff --git a/packages/cli/src/commands/review/agent-prompt.test.ts b/packages/cli/src/commands/review/agent-prompt.test.ts index 155d8f0a349..6b12e38020b 100644 --- a/packages/cli/src/commands/review/agent-prompt.test.ts +++ b/packages/cli/src/commands/review/agent-prompt.test.ts @@ -2166,8 +2166,30 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => { // send the reader to fix a prompt that is correct. const p = buildRoleBrief(PR_PLAN, '7'); expect(p).not.toContain(PLAN.diffPathAbsolute); - expect(p).toContain('npm run build'); + expect(p).toContain('`toolchain: "maven"`'); + expect(p).toContain('Do not run `test-delta` for Maven in this release'); expect(p).toContain('Source: [build]'); + // The only steering against hand-run full builds — the pattern the same + // paragraph records as timing out 71 times and verifying nothing. The + // prohibition is scoped to what build-test runs: the unsupported + // fallback below it is the sanctioned hand-run path, and the wording + // must not forbid it. + expect(p).toContain( + 'Do **not** substitute hand-written npm or Maven commands ' + + 'for what `build-test` runs — the `toolchain: "unsupported"` fallback ' + + 'below is the only sanctioned hand-run path', + ); + // The unsupported bullet's steering rules: a fail-closed adapter + // result must not be replaced by an ad hoc command, the mixed-root + // note names the Maven half the npm run did not verify (and forbids + // filling it ad hoc), and a CI-named command lifts neither rule. + // Reverting the bullet to the old precedence list left all tests green + // before these pins. + expect(p).toContain( + 'do not replace that fail-closed result with an ad hoc Maven command', + ); + expect(p).toContain('do not run Maven ad hoc to fill the gap'); + expect(p).toContain('does **not** lift the two rules above'); }); it('pins Agent 7 to the PR worktree and hands it the test-efficacy probe', () => { diff --git a/packages/cli/src/commands/review/base-tree.test.ts b/packages/cli/src/commands/review/base-tree.test.ts index 6bb61c7c353..6d811761a10 100644 --- a/packages/cli/src/commands/review/base-tree.test.ts +++ b/packages/cli/src/commands/review/base-tree.test.ts @@ -27,7 +27,12 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runBaseTree, type BaseTreeReport } from './base-tree.js'; +import type { Argv } from 'yargs'; +import { + baseTreeCommand, + runBaseTree, + type BaseTreeReport, +} from './base-tree.js'; import { baseWorktreePath } from './lib/paths.js'; import type { BuildTestReport } from './build-test.js'; @@ -250,6 +255,594 @@ describe('runBaseTree', () => { expect( existsSync(join(baseWorktreePath(worktree), '.qwen-review-base-ok')), ).toBe(false); + // "Not buildable" is a SETTLED answer too: the failed marker is what + // keeps later shards from re-paying the same cold checkout. + expect( + existsSync(join(baseWorktreePath(worktree), '.qwen-review-base-failed')), + ).toBe(true); + }); + + it('does NOT run a Maven merge-base build nothing could consume', () => { + // A/B attribution reruns npm test commands (test-delta); Agent 7's brief + // says the same for Maven in this release. Commit the pom so the base + // tree selects the Maven adapter, and pin that the build never runs. + writeFileSync(join(repo, 'pom.xml'), ''); + git(repo, 'add', 'pom.xml'); + git(repo, 'commit', '-qam', 'maven base'); + const mavenSha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: mavenSha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + expect(r.note).toContain('not run'); + // The attribution guard is what stops a downstream agent from reading an + // unavailable A/B as something attributable to the PR — pin it like the + // sibling failed-build note does. + expect(r.note).toMatch(/never a finding against the PR/); + // The gate answers from the object store (git cat-file) BEFORE the + // checkout: a large Java reactor never materialises a tree just to + // learn it will not be built. + expect(existsSync(baseWorktreePath(worktree))).toBe(false); + }); + + it('does NOT check out a nested-pom base the Maven gate exists to skip', () => { + // Standalone module poms with no root aggregator miss the root `pom.xml` + // probe, but the base is Maven just the same and cannot be consumed. + mkdirSync(join(repo, 'app'), { recursive: true }); + writeFileSync(join(repo, 'app', 'pom.xml'), ''); + git(repo, 'add', 'app'); + git(repo, 'commit', '-qam', 'nested maven base'); + const nestedSha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: nestedSha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + expect(existsSync(baseWorktreePath(worktree))).toBe(false); + }); + + it('does NOT treat a deep fixture pom as a Maven base', () => { + // Vendored samples, archetype fixtures, and maven-invoker ITs live deeper + // than `/pom.xml`; counting one would permanently — and silently — + // disable A/B attribution for a repo that merely ships one. + const fixture = join(repo, 'src', 'test', 'resources', 'projects', 'it'); + mkdirSync(fixture, { recursive: true }); + writeFileSync(join(fixture, 'pom.xml'), ''); + git(repo, 'add', 'src'); + git(repo, 'commit', '-qam', 'fixture pom'); + const fixtureSha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: fixtureSha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + }); + + it('still detects a nested pom under a git-quoted directory name', () => { + // Non-ASCII names (and names with quotes, tabs, or backslashes) come + // back C-quoted from `ls-tree` under core.quotePath; the probe must + // resolve the raw name anyway, or the base slips past the gate. + mkdirSync(join(repo, 'm\u00f3dulo'), { recursive: true }); + writeFileSync(join(repo, 'm\u00f3dulo', 'pom.xml'), ''); + git(repo, 'add', 'm\u00f3dulo'); + git(repo, 'commit', '-qam', 'non-ascii nested maven base'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + expect(existsSync(baseWorktreePath(worktree))).toBe(false); + }); + + it('does NOT let a husky-only package.json suppress the nested-pom probe', () => { + // A script-less, workspace-less manifest is not an npm project under + // the adapter's applies rule, so a standalone Maven module beside it + // must still be caught before checkout. + mkdirSync(join(repo, 'app'), { recursive: true }); + writeFileSync(join(repo, 'app', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ scripts: { prepare: 'husky' } }), + ); + git(repo, 'add', 'app', 'package.json'); + git(repo, 'commit', '-qam', 'husky + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + }); + + it('suppresses the nested-pom probe for an npm-applicable package.json', () => { + // A build/test script (or workspaces) makes the base npm's to consume; + // the probe stays home and the build decides. + mkdirSync(join(repo, 'app'), { recursive: true }); + writeFileSync(join(repo, 'app', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ scripts: { build: 'tsc' } }), + ); + git(repo, 'add', 'app', 'package.json'); + git(repo, 'commit', '-qam', 'npm + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + }); + + it('does NOT treat an unmodeled workspace glob as npm-applicable', () => { + // `packages/**` scopes nothing the npm adapter can model (applies() + // declines it); suppressing the nested-pom probe for the blob would make + // a standalone-module Maven base pay the cold checkout this gate exists + // to prevent. The fixture pairs the unmodeled glob with a modeled one + // resolving a real package: dropping the conjunct then makes the blob + // npm-applicable and this test red. + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + mkdirSync(join(repo, 'app'), { recursive: true }); + writeFileSync( + join(repo, 'app', 'package.json'), + JSON.stringify({ + name: 'app', + scripts: { build: 'tsc', test: 'vitest' }, + }), + ); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['packages/**', 'app'] }), + ); + git(repo, 'add', 'java', 'app', 'package.json'); + git(repo, 'commit', '-qam', 'unmodeled glob + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + expect(existsSync(baseWorktreePath(worktree))).toBe(false); + }); + + it('does NOT treat a zero-package workspace glob as npm-applicable', () => { + // A modeled glob resolving to NO package at the base scopes nothing + // either — the nested-pom probe must still run. + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ); + git(repo, 'add', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'empty glob + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + }); + + it('suppresses the nested-pom probe when a modeled glob resolves to a base package', () => { + // The positive control for the two tests above: a modeled glob with at + // least one member package at the base IS npm-applicable, so the probe + // stays home even beside a nested pom, and the build decides. + mkdirSync(join(repo, 'packages', 'app'), { recursive: true }); + writeFileSync( + join(repo, 'packages', 'app', 'package.json'), + JSON.stringify({ name: '@x/app', scripts: { build: 'tsc' } }), + ); + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ); + git(repo, 'add', 'packages', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'workspace + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + }); + + it('suppresses the nested-pom probe for OBJECT-form workspaces too', () => { + // npm accepts `{ workspaces: { packages: [...] } }` as well as the + // array form; the gate's blobIsNpmProject must too, or a base declaring + // the object form reads npm-inapplicable beside a nested pom — a false + // Maven handoff that permanently disables A/B attribution there while + // the on-disk twin accepts the same repo. + mkdirSync(join(repo, 'packages', 'app'), { recursive: true }); + writeFileSync( + join(repo, 'packages', 'app', 'package.json'), + JSON.stringify({ name: '@x/app', scripts: { build: 'tsc' } }), + ); + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: { packages: ['packages/*'] } }), + ); + git(repo, 'add', 'packages', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'object workspaces + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + }); + + it('models ./-prefixed workspace globs like their bare form', () => { + // The on-disk twin strips a leading `./` from each glob; without it + // here, `workspaceDirFor` never matched the expanded dirs and an npm + // base was misclassified as Maven, losing A/B attribution. + mkdirSync(join(repo, 'packages', 'app'), { recursive: true }); + writeFileSync( + join(repo, 'packages', 'app', 'package.json'), + JSON.stringify({ name: '@x/app', scripts: { build: 'tsc' } }), + ); + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['./packages/*'] }), + ); + git(repo, 'add', 'packages', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'dot-slash workspace + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + }); + + it('models the empty-string workspace glob as the root package, like the disk twin', () => { + // `"workspaces": [""]` names the root itself as a member: on disk the + // manifest probe joins `//package.json` and applies() accepts it. + // The blob twin probed the unresolvable `:/package.json` instead + // and misread the base as Maven-only, permanently disabling A/B there. + mkdirSync(join(repo, 'app'), { recursive: true }); + writeFileSync(join(repo, 'app', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ name: '@x/root', workspaces: [''] }), + ); + git(repo, 'add', 'app', 'package.json'); + git(repo, 'commit', '-qam', 'root-as-member glob + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + }); + + it('suppresses the nested-pom probe for literal workspace members too', () => { + // Every twin-parity branch has a dedicated test except the LITERAL + // member (`workspaces: ["packages/app"]`, no `*`): a future edit + // breaking it would make blobIsNpmProject refuse a literal-workspaces + // merge base, and beside a standalone nested Maven module the probe + // would permanently disable A/B attribution for that repo shape while + // every other base-tree test stayed green. + mkdirSync(join(repo, 'packages', 'app'), { recursive: true }); + writeFileSync( + join(repo, 'packages', 'app', 'package.json'), + JSON.stringify({ name: '@x/app', scripts: { build: 'tsc' } }), + ); + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['packages/app'] }), + ); + git(repo, 'add', 'packages', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'literal workspace member + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + }); + + it('does NOT count an unreadable member manifest as an npm package', () => { + // applies() requires at least one readable package: a manifest that + // does not parse lands in `skipped` on disk, so counting it on blob + // EXISTENCE alone suppressed the nested-pom probe for a standalone- + // module Maven base. + mkdirSync(join(repo, 'packages', 'app'), { recursive: true }); + writeFileSync(join(repo, 'packages', 'app', 'package.json'), '{oops'); + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ); + git(repo, 'add', 'packages', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'broken member manifest + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + }); + + it('does NOT count a member whose manifest parses to no usable name', () => { + // hasUsableManifestAt mirrors readWorkspacePackages' skip rule: a + // manifest without a non-empty string `name` is not a package. A base + // whose only members lack names must stay npm-inapplicable, or the + // nested-pom probe is suppressed for a base the disk side rejects. + mkdirSync(join(repo, 'packages', 'app'), { recursive: true }); + writeFileSync( + join(repo, 'packages', 'app', 'package.json'), + JSON.stringify({ scripts: { build: 'tsc' } }), + ); + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ); + git(repo, 'add', 'packages', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'nameless member + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + }); + + it('does NOT count a negation-excluded workspace member as npm-applicable', () => { + // The on-disk twin puts a negated member in `skipped`, not `packages`; + // excluding the ONLY member leaves nothing npm-applicable, so the + // nested-pom probe must still run. + mkdirSync(join(repo, 'packages', 'app'), { recursive: true }); + writeFileSync( + join(repo, 'packages', 'app', 'package.json'), + JSON.stringify({ name: '@x/app', scripts: { build: 'tsc' } }), + ); + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['packages/*', '!packages/app'] }), + ); + git(repo, 'add', 'packages', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'negated member + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + }); + + it('still counts a workspace when its negation excludes nothing', () => { + // The positive twin: a negation matching no member leaves the real + // member npm-applicable, so the probe stays home and the build decides. + mkdirSync(join(repo, 'packages', 'app'), { recursive: true }); + writeFileSync( + join(repo, 'packages', 'app', 'package.json'), + JSON.stringify({ name: '@x/app', scripts: { build: 'tsc' } }), + ); + mkdirSync(join(repo, 'java'), { recursive: true }); + writeFileSync(join(repo, 'java', 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ workspaces: ['packages/*', '!packages/ghost'] }), + ); + git(repo, 'add', 'packages', 'java', 'package.json'); + git(repo, 'commit', '-qam', 'harmless negation + nested maven'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + }); + + it('does NOT treat a DIRECTORY named pom.xml as a Maven base', () => { + // `git cat-file -e` exits 0 for trees too; the probe must require a + // BLOB, or a directory named pom.xml beside an npm-buildable layout + // misfires the gate and permanently disables A/B for that base. + mkdirSync(join(repo, 'pom.xml'), { recursive: true }); + writeFileSync(join(repo, 'pom.xml', 'inner.txt'), 'not a pom'); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ scripts: { build: 'tsc' } }), + ); + git(repo, 'add', 'pom.xml', 'package.json'); + git(repo, 'commit', '-qam', 'pom.xml dir + npm base'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + expect(r.note).not.toContain('Maven'); + }); + + it('does NOT treat a nested DIRECTORY named pom.xml as a Maven base', () => { + // The nested variant of the same misfire: `app/pom.xml` as a tree + // entry must not fire the nested-pom probe. + mkdirSync(join(repo, 'app', 'pom.xml'), { recursive: true }); + writeFileSync(join(repo, 'app', 'pom.xml', 'inner.txt'), 'not a pom'); + git(repo, 'add', 'app'); + git(repo, 'commit', '-qam', 'nested pom.xml dir base'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(builds).toHaveLength(1); + expect(r.available).toBe(true); + expect(r.note).not.toContain('Maven'); + }); + + it('treats a base carrying BOTH a root pom.xml and an npm package.json as Maven', () => { + // The root-pom branch of the gate is unconditional: the npm half does + // not rescue a Maven root. Symmetrizing the gate to condition the root + // branch on !npmAtBase would let this polyglot base pay the cold + // checkout, and multi-toolchain aggregation (a declared future phase) + // would leave that symmetrized gate as the only defense. + writeFileSync(join(repo, 'pom.xml'), ''); + writeFileSync( + join(repo, 'package.json'), + JSON.stringify({ scripts: { build: 'tsc' } }), + ); + git(repo, 'add', 'pom.xml', 'package.json'); + git(repo, 'commit', '-qam', 'polyglot base'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + expect(r.note).toMatch(/never a finding against the PR/); + }); + + it.skipIf(process.platform === 'win32')( + 'still detects a nested pom under a directory named with a line terminator', + () => { + // A regex `.` cannot span `\n`, and a `\n` in a name is a standard + // core.quotePath escape — the probe parses the NUL-delimited entries + // structurally, or this base slips past the gate. + mkdirSync(join(repo, 'bad\ndir'), { recursive: true }); + writeFileSync(join(repo, 'bad\ndir', 'pom.xml'), ''); + git(repo, 'add', 'bad\ndir'); + git(repo, 'commit', '-qam', 'newline-dir nested maven base'); + const sha = git(repo, 'rev-parse', 'HEAD'); + + const builds: string[] = []; + const r = run({ plan: { mergeBaseSha: sha } }, (w) => { + builds.push(w); + return okBuild; + }); + + expect(r.available).toBe(false); + expect(builds).toEqual([]); + expect(r.note).toContain('Maven'); + }, + ); + + it('is NOT available for a Maven build report the delta machinery cannot consume', () => { + const mavenBuild = { + ok: true, + toolchain: 'maven', + build: [{ command: './mvnw test-compile', exitCode: 0 }], + note: 'built', + } as unknown as BuildTestReport; + + const r = run({}, () => mavenBuild); + + expect(r.available).toBe(false); + expect( + existsSync(join(baseWorktreePath(worktree), '.qwen-review-base-ok')), + ).toBe(false); + // The sibling handoff pin's twin: a later verifier shard must not repay + // the cold checkout plus a full Maven build to relearn the same + // "unavailable". + expect( + existsSync(join(baseWorktreePath(worktree), '.qwen-review-base-failed')), + ).toBe(true); }); it('is NOT available when npm scoped nothing to compile', () => { @@ -302,3 +895,20 @@ describe('runBaseTree', () => { expect(r.note).toMatch(/base worktree could not be created/); }); }); + +describe('the base-tree CLI option contract', () => { + it("says the --install step is the npm toolchain's alone", () => { + // The help text is the reviewer's only window into the flag; without the + // caveat it implies an npm-ci-style install runs for every toolchain, + // and Maven never runs one (it resolves inside the lifecycle command). + const options: Record = {}; + const recorder = { + option: (name: string, spec: { describe?: string }) => { + options[name] = spec; + return recorder; + }, + } as unknown as Argv; + (baseTreeCommand.builder as (y: Argv) => Argv)(recorder); + expect(options['install']?.describe).toContain('npm toolchain only'); + }); +}); diff --git a/packages/cli/src/commands/review/base-tree.ts b/packages/cli/src/commands/review/base-tree.ts index 9b6b798b3d9..2fbb5fb8982 100644 --- a/packages/cli/src/commands/review/base-tree.ts +++ b/packages/cli/src/commands/review/base-tree.ts @@ -60,6 +60,10 @@ import { type SweepResult, } from './lib/worktree.js'; import { runBuildTest, type BuildTestReport } from './build-test.js'; +import { + hasUnmodeledWorkspaceGlob, + workspaceDirFor, +} from './lib/workspaces.js'; export interface BaseTreeReport { /** @@ -106,6 +110,205 @@ function git(cwd: string, ...args: string[]): void { } } +/** + * Every sibling capture in these commands raises Node's 1 MiB spawnSync + * default (`gh.ts`, `git.ts`, `build-test.ts`, `test-delta.ts`): on + * overflow the child returns `error.code: 'ENOBUFS'` with `status: null`, + * which the probes below would swallow into the same answer as 'absent' — + * silently settling a base the probes never answered. + */ +const GIT_PROBE_MAX_BUFFER = 64 * 1024 * 1024; + +/** + * Accepted symlink carve-out for all three git probes below: they are + * symlink-blind where the disk-side twins follow links. `gitBlob` reads a + * symlinked manifest as the link-target PATH text (JSON.parse fails, the + * manifest reads as absent), `gitHasPath` accepts a symlinked `pom.xml` + * blob, and `gitTreeChildDirs` keeps only mode-040000 entries (a symlinked + * workspace member is dropped). Every outcome is conservative — lost A/B + * attribution for a symlink-shaped base, never a wrong verdict — and the + * repository shapes this gate screens for (root reactors, workspace + * monorepos) do not hang their manifests on symlinks. Resolving + * mode-120000 entries (bounded hop count, entry-mode gates) is left out + * until a real base needs it. + */ +function gitHasPath(cwd: string, sha: string, path: string): boolean { + // A BLOB, not mere existence: `cat-file -e` exits 0 for a DIRECTORY + // too, and a dir named `pom.xml` is not a Maven project — reading one + // as such misfires the gate into a false "Maven base" note that + // permanently disables A/B attribution for that base. + const r = spawnSync('git', ['cat-file', '-t', `${sha}:${path}`], { + cwd, + encoding: 'utf8', + maxBuffer: GIT_PROBE_MAX_BUFFER, + }); + return !r.error && r.status === 0 && (r.stdout ?? '').trim() === 'blob'; +} + +function gitBlob(cwd: string, sha: string, path: string): string | null { + const r = spawnSync('git', ['cat-file', 'blob', `${sha}:${path}`], { + cwd, + encoding: 'utf8', + maxBuffer: GIT_PROBE_MAX_BUFFER, + }); + if (r.error || r.status !== 0) return null; + return r.stdout ?? ''; +} + +/** + * The base-tree twin of readWorkspacePackages' manifest gate: a member + * counts only when its manifest parses and has a usable `name` — the disk + * side puts every other manifest in `skipped`, not `packages`, and + * `npmToolchainAdapter.applies` requires at least one package. + */ +function hasUsableManifestAt(cwd: string, sha: string, path: string): boolean { + const blob = gitBlob(cwd, sha, path); + if (blob === null) return false; + try { + const pkg = JSON.parse(blob) as { name?: unknown } | null; + return pkg !== null && typeof pkg.name === 'string' && pkg.name !== ''; + } catch { + return false; + } +} + +/** + * A root package.json the npm adapter would actually apply to — mirroring + * `npmToolchainAdapter.applies` against the BASE tree: MODELED workspace + * globs that resolve to at least one package there, or a root build/test + * script. A husky/lint-config/docs-tooling-only manifest applies to nothing, + * and an unmodeled glob (`packages/**`, `foo-*`) or a zero-package glob + * scopes nothing either, so suppressing the nested-pom probe for any of them + * would let a standalone-module Maven base pay the cold checkout this gate + * exists to prevent. + */ +function blobIsNpmProject(blob: string, cwd: string, sha: string): boolean { + try { + const pkg = JSON.parse(blob) as { + workspaces?: unknown; + scripts?: Record; + }; + const ws = pkg.workspaces; + const globs = ( + Array.isArray(ws) + ? ws + : Array.isArray((ws as { packages?: unknown } | undefined)?.packages) + ? ((ws as { packages: unknown[] }).packages as unknown[]) + : [] + ).filter((g): g is string => typeof g === 'string'); + if (globs.length > 0) { + return ( + !hasUnmodeledWorkspaceGlob(globs) && + workspaceDirsAt(cwd, sha, globs).some( + (dir) => + // A directory a negation excludes is not a workspace — the same + // check readWorkspacePackages applies on disk. + workspaceDirFor(`${dir}/package.json`, globs) === dir && + hasUsableManifestAt( + cwd, + sha, + // The root-as-member shape (`"workspaces": [""]`): the disk + // twin joins `//package.json` and applies; the blob ref + // must drop the leading slash or `:/package.json` is + // unresolvable and the twins diverge. + dir === '' ? 'package.json' : `${dir}/package.json`, + ), + ) + ); + } + return ( + typeof pkg.scripts === 'object' && + pkg.scripts !== null && + ('build' in pkg.scripts || 'test' in pkg.scripts) + ); + } catch { + return false; + } +} + +/** + * The dirs the workspace globs expand to in the tree at `sha` — the base-tree + * twin of readWorkspacePackages' on-disk expansion (negations excluded there, + * as here, by the caller's workspaceDirFor check). + */ +function workspaceDirsAt(cwd: string, sha: string, globs: string[]): string[] { + const dirs = new Set(); + for (const glob of globs) { + if (glob.startsWith('!')) continue; + // Strip a leading `./` exactly as workspaceDirCandidates does on disk. + const g = glob.replace(/^\.\//, '').replace(/\/$/, ''); + if (g.endsWith('/*')) { + const base = g.slice(0, -2); + for (const child of gitTreeChildDirs(cwd, sha, base)) { + dirs.add(base ? `${base}/${child}` : child); + } + } else { + dirs.add(g); + } + } + return [...dirs]; +} + +/** Direct children (mode 040000) of `dir` in the tree at `sha` — all of them + * when `dir` is empty. */ +function gitTreeChildDirs(cwd: string, sha: string, dir: string): string[] { + // `sha:dir` lists the CHILDREN of dir with bare names (a pathspec without + // the colon lists dir itself); `sha:` alone lists the root tree. + const r = spawnSync('git', ['ls-tree', '-z', `${sha}:${dir}`], { + cwd, + encoding: 'utf8', + maxBuffer: GIT_PROBE_MAX_BUFFER, + }); + if (r.error || r.status !== 0) return []; + const dirs: string[] = []; + // `-z` output is NUL-delimited and NEVER C-quoted, so names with non-ASCII + // bytes, quotes, tabs, backslashes — or line terminators — survive. Parse + // structurally, not with a regex: `.` cannot span a line terminator, and a + // `\n` in a name is the standard core.quotePath escape. The first tab ends + // the OID: an object id is hex and contains no tab. + for (const entry of (r.stdout ?? '').split('\0')) { + if (!entry.startsWith('040000 tree ')) continue; + const tab = entry.indexOf('\t'); + if (tab >= 0) dirs.push(entry.slice(tab + 1)); + } + return dirs; +} + +/** + * Nested-pom bases (standalone modules, no root aggregator) miss the root + * `pom.xml` probe but are Maven just the same; when the base carries no + * npm-applicable root `package.json` either, a depth-1 listing settles it + * before checkout. Only a DIRECT child counts: a pom deeper than + * `/pom.xml` is a vendored sample, an archetype fixture, or a + * maven-invoker IT, and counting one would permanently — and silently — + * disable A/B attribution for a repo that merely ships one. + */ +function gitTreeHasNestedPom(cwd: string, sha: string): boolean { + const dirs = gitTreeChildDirs(cwd, sha, ''); + if (dirs.length === 0) return false; + // The batch probe below is line-based: a name carrying a line terminator + // (PR-choosable) would split into two wrong refs, so those names keep + // the per-dir argv probe, which hands the whole path as ONE argument. + if (!dirs.every((dir) => !/[\r\n]/.test(dir))) { + return dirs.some((dir) => gitHasPath(cwd, sha, `${dir}/pom.xml`)); + } + // ONE batched `cat-file` for every root dir, not one spawn each: step 4's + // verifier shards all offer this command, the gate's answer is a pure + // function of baseSha's tree, and the per-dir spawns multiplied into + // N shards x O(D) synchronous processes on the review's critical path. + // `--batch-check` answers a missing path with ` missing` on its own + // line and still exits 0; a DIRECTORY named pom.xml answers `tree` and is + // not a nested pom, exactly as the per-dir blob gate before it. + const r = spawnSync('git', ['cat-file', '--batch-check'], { + cwd, + encoding: 'utf8', + input: dirs.map((dir) => `${sha}:${dir}/pom.xml`).join('\n') + '\n', + maxBuffer: GIT_PROBE_MAX_BUFFER, + }); + if (r.error || r.status !== 0) return false; + return (r.stdout ?? '').split('\n').some((line) => / blob \d+$/.test(line)); +} + export function runBaseTree(args: BaseTreeArgs): BaseTreeReport { const unavailable = (note: string): BaseTreeReport => ({ available: false, @@ -201,6 +404,33 @@ export function runBaseTree(args: BaseTreeArgs): BaseTreeReport { } catch { // No failed-marker: proceed to build. } + // A/B attribution reruns the recorded npm test commands (test-delta); no + // other toolchain has a delta consumer in this release — Agent 7's brief + // says the same for Maven. The gate sits AFTER the marker checks: step 4 + // launches its verifier shards together, and once a tree stands (built or + // failed) every later shard is answered by its marker without re-scanning. + // It still runs before the checkout, so a Maven base never pays for a tree + // that would not be built — including the nested-pom shape (standalone + // modules, no root aggregator), settled by a depth-1 listing when the base + // has no npm-applicable root package.json either. A husky-only manifest + // leaves no consumable npm half, so it must not suppress the probe. + const mavenBaseNote = + `the merge base is a Maven project, and this release's A/B attribution only reruns npm test ` + + 'commands — a base-side Maven build could not be consumed, so it was not run ' + + '(never a finding against the PR)'; + // The root pom decides the gate alone; probing it FIRST spares every + // root-pom Maven base the npm workspace scan the second check pays. + if (gitHasPath(worktree, baseSha, 'pom.xml')) { + return unavailable(mavenBaseNote); + } + const npmAtBase = (() => { + if (!gitHasPath(worktree, baseSha, 'package.json')) return false; + const blob = gitBlob(worktree, baseSha, 'package.json'); + return blob !== null && blobIsNpmProject(blob, worktree, baseSha); + })(); + if (!npmAtBase && gitTreeHasNestedPom(worktree, baseSha)) { + return unavailable(mavenBaseNote); + } // A real mutual-exclusion lock around sweep+add+build, not just the marker. // The reuse fast path covers the AFTER-build window; this covers the build // itself: measured in review, shard B's opening sweep deleted the tree shard @@ -367,7 +597,9 @@ export const baseTreeCommand: CommandModule = { .option('install', { type: 'boolean', default: true, - describe: 'Run `npm ci` first when node_modules is absent', + describe: + 'Run `npm ci` first when node_modules is absent (npm toolchain only; ' + + 'Maven resolves dependencies inside its lifecycle command)', }), handler: (argv) => { const args = argv as unknown as BaseTreeArgs; diff --git a/packages/cli/src/commands/review/build-test.test.ts b/packages/cli/src/commands/review/build-test.test.ts index beeda8cbde7..f2667e608dc 100644 --- a/packages/cli/src/commands/review/build-test.test.ts +++ b/packages/cli/src/commands/review/build-test.test.ts @@ -5,7 +5,13 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + chmodSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -14,6 +20,7 @@ import { unresolvedWorkspaceDeps, buildRunEnv, } from './build-test.js'; +import { mavenToolchainAdapter } from './lib/maven-toolchain.js'; import { npmToolchainAdapter, unresolvedWorkspaceDeps as toolchainUnresolvedWorkspaceDeps, @@ -134,9 +141,9 @@ describe('runBuildTest', () => { it('treats a package.json with no build role as no npm project at all', () => { // Docs sites, husky, and lint configs put a script-less package.json in - // repos with nothing npm can scope. It must not make npm apply, or such a - // root would claim the selection away from a second adapter that could - // have verified the diff. + // repos with nothing npm can scope. It must not make npm apply — that is + // what used to collide with a root pom.xml and drop the whole repo to + // `unsupported` where the Maven adapter could have verified the diff. // The handoff note is still npm's precise one: the repo IS npm-shaped, // and naming why it cannot be scoped beats a generic "no project here". writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'r' })); @@ -203,16 +210,223 @@ describe('runBuildTest', () => { ok: true, timedOut: [], note: - 'No supported npm project here to scope. Fall back to the ' + + 'No supported npm or Maven project here to scope. Fall back to the ' + 'build/test precedence in your brief — installing dependencies first — ' + 'and give each command a deadline it can actually meet.', }); }); + it('prefers npm and discloses the Maven half when both apply at the root', () => { + // Running NOTHING at all on this shape shipped a broken JS build + // through review with zero evidence — npm is what a review verified + // here before the Maven adapter existed, so it runs, and the note + // discloses the Maven half a green npm run must not certify. + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'polyglot', scripts: { build: 'exit 0' } }), + ); + writeFileSync(join(root, 'pom.xml'), ''); + writePlan(['src/a.ts']); + + const rep = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 60, + install: false, + exec: (command) => ({ + command, + exitCode: 0, + seconds: 1, + timedOut: false, + output: '', + }), + }); + + expect(rep.toolchain).toBe('npm'); + expect(rep.build.length).toBeGreaterThan(0); + expect(rep.ok).toBe(true); + expect(rep.note).toContain('Maven also applies'); + expect(rep.note).toContain('NOT verified'); + }); + + it('runs the Maven half when npm concedes a mixed root without executing', () => { + // The mixed-root note says "this run executed the npm toolchain only" + // — a lie when npm's run() concedes WITHOUT executing (a cold + // yarn/pnpm/bun repo — the common case for a review worktree): + // nothing ran, and the Maven half was never attempted. The Maven + // adapter must run instead of certifying nothing; its own mixed-root + // caveat discloses the unscopable npm side. + rmSync(join(root, 'package-lock.json')); + rmSync(join(root, 'node_modules'), { recursive: true, force: true }); + writeFileSync(join(root, 'yarn.lock'), ''); + pkg('.', { + name: 'polyglot', + scripts: { build: 'exit 0', test: 'exit 0' }, + }); + writeFileSync( + join(root, 'pom.xml'), + 'core', + ); + mkdirSync(join(root, 'core'), { recursive: true }); + writeFileSync(join(root, 'core', 'pom.xml'), ''); + writePlan(['core/src/Main.java']); + + const rep = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 60, + install: true, + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return { + command, + exitCode: 0, + seconds: 1, + timedOut: false, + output: '[INFO] BUILD SUCCESS', + }; + }, + }); + + expect(rep.toolchain).toBe('maven'); + expect(rep.ok).toBe(true); + expect(rep.note).not.toContain('executed the npm toolchain only'); + expect(rep.note).toContain('Mixed root: a root package.json exists'); + }); + + it('runs the Maven half when npm maps zero affected files at a workspace mixed root', () => { + // R1-22/R1-1: the fallback keyed only on `toolchain === 'unsupported'` + // missed npm's workspace-mode return for a diff that touches only Maven + // modules — `toolchain: 'npm'`, `ok: true`, but zero commands executed — + // so the changed modules were never compiled or tested and the note + // falsely claimed the npm toolchain ran. Fire the fallback on + // no-execution, not just on the unsupported concession. + pkg('.', { + name: 'polyglot', + workspaces: ['packages/*'], + scripts: { build: 'exit 0', test: 'exit 0' }, + }); + mkdirSync(join(root, 'packages', 'a'), { recursive: true }); + pkg('packages/a', { + name: 'a', + scripts: { build: 'exit 0', test: 'exit 0' }, + }); + writeFileSync( + join(root, 'pom.xml'), + 'core', + ); + mkdirSync(join(root, 'core'), { recursive: true }); + writeFileSync(join(root, 'core', 'pom.xml'), ''); + // The diff touches ONLY the Maven module — npm maps zero workspaces. + writePlan(['core/src/Main.java']); + + const rep = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 60, + install: false, + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return { + command, + exitCode: 0, + seconds: 1, + timedOut: false, + output: '[INFO] BUILD SUCCESS', + }; + }, + }); + + expect(rep.toolchain).toBe('maven'); + expect(rep.ok).toBe(true); + expect(rep.note).not.toContain('executed the npm toolchain only'); + }); + + it('does not replace a failing-but-executed npm run at a mixed root', () => { + // R1-23: the fallback's negative boundary — an npm run that DID execute + // and failed must not be discarded under the Maven adapter's verdict. A + // reformulation like `!report.ok` would pass the positive mixed-root + // tests yet silently drop a genuine npm failure. + pkg('.', { + name: 'polyglot', + scripts: { build: 'exit 1', test: 'exit 0' }, + }); + writeFileSync(join(root, 'pom.xml'), ''); + writePlan(['src/a.ts']); + + const rep = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 60, + install: false, + exec: (command) => ({ + command, + exitCode: command.includes('build') ? 1 : 0, + seconds: 1, + timedOut: false, + output: command.includes('build') ? 'error: build failed' : '', + }), + }); + + expect(rep.toolchain).toBe('npm'); + expect(rep.ok).toBe(false); + expect(rep.note).toContain('Maven also applies'); + }); + + it('does not fall back to Maven when npm ran an install but no build/test commands', () => { + // The fallback's `install === null` clause's negative boundary: npm + // returns a NON-NULL install with zero build/test commands when the + // install itself failed. That is a genuine npm-side failure and must + // not be discarded under a Maven run that could certify green — a + // reformulation of the fallback to just `build.length === 0 && + // test.length === 0` would launder it. + pkg('.', { + name: 'polyglot', + scripts: { build: 'exit 0', test: 'exit 0' }, + }); + writeFileSync(join(root, 'pom.xml'), ''); + writePlan(['src/a.ts']); + // Cold the node_modules marker so `npm ci` actually runs (a warm tree + // skips the install, leaving install null — the fallback's positive + // case, not this negative boundary). + rmSync(join(root, 'node_modules', '.package-lock.json')); + + const rep = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 60, + install: true, + exec: (command) => ({ + command, + // The install fails; no build/test command is ever reached. + exitCode: command.trim().startsWith('npm') ? 1 : 0, + seconds: 1, + timedOut: false, + output: 'npm error install failed', + }), + }); + + expect(rep.toolchain).toBe('npm'); + expect(rep.ok).toBe(false); + expect(rep.note).toContain('Maven also applies'); + }); + it('coerces fractional and zero deadlines at the spawn boundary', () => { // spawnSync validates `timeout` as an unsigned integer: a decimal // --timeout used to throw ERR_OUT_OF_RANGE out of the whole call (no // report, no --out file), and --timeout 0 armed no kill timer at all. + // The boundary is in build-test.ts, above every adapter, so the fixture + // stays npm — this case must not move when a toolchain is added. pkg('.', { name: 'r', scripts: { test: 'vitest run' } }); writePlan(['src/a.ts']); @@ -270,6 +484,98 @@ describe('runBuildTest', () => { ).toThrow(/--budget must be a finite number/); }); + it('leaves a Maven repo with a build-less package.json to the Maven adapter', () => { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'husky-only', scripts: { prepare: 'husky' } }), + ); + writeFileSync(join(root, 'pom.xml'), ''); + writePlan(['src/Main.java']); + const exec = vi.fn(); + const sentinel = { toolchain: 'maven' } as ReturnType; + const runSpy = vi + .spyOn(mavenToolchainAdapter, 'run') + .mockReturnValue(sentinel); + + const report = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 5, + install: false, + exec, + }); + + expect(report).toBe(sentinel); + expect(runSpy).toHaveBeenCalledOnce(); + runSpy.mockRestore(); + }); + + it('delegates Maven-only repositories through the facade', () => { + writeFileSync(join(root, 'pom.xml'), ''); + writePlan(['src/Main.java']); + const exec = vi.fn(); + const sentinel = { toolchain: 'maven' } as ReturnType; + const runSpy = vi + .spyOn(mavenToolchainAdapter, 'run') + .mockReturnValue(sentinel); + + const report = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 7, + install: false, + buildOnly: true, + exec, + }); + + expect(report).toBe(sentinel); + expect(runSpy).toHaveBeenCalledOnce(); + expect(runSpy).toHaveBeenCalledWith({ + root, + changedFiles: ['src/Main.java'], + timeout: 7, + install: false, + buildOnly: true, + exec, + }); + }); + + it('carries the Maven classification flags and command facts into the recorded CommandResult', () => { + // The adapter's own unit tests cover the classification, but nothing + // pinned that the fields survive into the report shape test-plan + // consumes — vitest transpiles without type-checking, so a renamed or + // dropped field anywhere in between reads as undefined downstream. + writeFileSync(join(root, 'pom.xml'), ''); + writePlan(['src/Main.java']); + const report = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 5, + install: false, + exec: (command: string) => ({ + command, + exitCode: 0, + seconds: 1, + timedOut: false, + output: + '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-checkstyle-plugin:3.3.1:check on project fixture', + }), + }); + + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(false); + const recorded = report.test[0]; + expect(recorded?.swallowedFailure).toBe(true); + expect(recorded?.maven).toEqual({ + lifecycle: 'test', + modules: ['.'], + alsoMake: true, + globalSkip: false, + }); + expect(recorded?.infrastructure).toBeUndefined(); + expect(recorded?.evidenceCapped).toBeUndefined(); + }); + it('reports `unsupported` — not a false "nothing to build" — for an unmodeled glob', () => { // `packages/**` matches real paths that the walker cannot resolve, so a diff // inside it would otherwise yield an empty affected set and a confident green. @@ -300,6 +606,128 @@ describe('runBuildTest', () => { expect(rep.note).not.toContain('no package to build'); }); + it('selects Maven when the npm half uses unmodeled workspace globs', () => { + // The guard exists for exactly this root: npm cannot scope `packages/**`. + // The fixture pairs the unmodeled glob with a modeled one resolving a + // real package. The pin is on the SELECTION PATH, not just the outcome: + // `applies()` must refuse npm here, so npm's run() is never invoked and + // Maven is selected directly. (Asserting only that Maven ran would also + // pass via the concede-then-fallback path if the conjunct were dropped, + // because npm's run() concedes unsupported at its own unmodeled gate + // before executing.) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'frontend', + workspaces: ['packages/**', 'apps/*'], + }), + ); + mkdirSync(join(root, 'apps/a'), { recursive: true }); + writeFileSync( + join(root, 'apps/a/package.json'), + JSON.stringify({ name: 'a', scripts: { build: 'tsc', test: 'vitest' } }), + ); + writeFileSync(join(root, 'pom.xml'), ''); + writePlan(['src/Main.java']); + const exec = vi.fn(); + const sentinel = { toolchain: 'maven' } as ReturnType; + const runSpy = vi + .spyOn(mavenToolchainAdapter, 'run') + .mockReturnValue(sentinel); + const npmRunSpy = vi.spyOn(npmToolchainAdapter, 'run'); + + const report = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 5, + install: false, + exec, + }); + + expect(report).toBe(sentinel); + expect(runSpy).toHaveBeenCalledOnce(); + // The applies() gate refused npm: its run() was never reached. + expect(npmRunSpy).not.toHaveBeenCalled(); + runSpy.mockRestore(); + npmRunSpy.mockRestore(); + }); + + it.skipIf(process.platform === 'win32')( + 'records rescueOverflow from the real executor end to end', + () => { + // Both halves of the rescue-overflow contract are otherwise pinned + // through seams that bypass the real-executor wiring: direct + // trimOutput tests and fixture execs injecting rescueOverflow. This + // drives runBuildTest's OWN run executor (no injected exec) with + // output carrying >40 evidence lines in the omitted middle, so + // deleting the wiring ships red. + writeFileSync(join(root, 'pom.xml'), ''); + writeFileSync( + join(root, 'mvnw'), + [ + '#!/bin/sh', + 'i=0', + 'while [ $i -lt 40 ]; do', + ' echo "[INFO] padding line $i xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"', + ' i=$((i+1))', + 'done', + 'i=0', + 'while [ $i -lt 60 ]; do', + ' echo "[ERROR] Failed to execute goal org.example:plugin:1:check (check) on project m$i: boom"', + ' i=$((i+1))', + 'done', + 'i=0', + 'while [ $i -lt 120 ]; do', + ' echo "[INFO] tail padding line $i yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"', + ' i=$((i+1))', + 'done', + 'exit 1', + '', + ].join('\n'), + ); + chmodSync(join(root, 'mvnw'), 0o755); + writePlan(['src/Main.java']); + + const report = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 30, + install: false, + }); + + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(false); + expect(report.test[0]?.rescueOverflow).toBe(true); + expect(report.test[0]?.evidenceCapped).toBe(true); + }, + ); + + it('selects Maven when the npm glob matches zero packages', () => { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'frontend', workspaces: ['packages/*'] }), + ); + writeFileSync(join(root, 'pom.xml'), ''); + writePlan(['src/Main.java']); + const exec = vi.fn(); + const sentinel = { toolchain: 'maven' } as ReturnType; + const runSpy = vi + .spyOn(mavenToolchainAdapter, 'run') + .mockReturnValue(sentinel); + + const report = runBuildTest({ + plan: planPath, + worktree: root, + timeout: 5, + install: false, + exec, + }); + + expect(report).toBe(sentinel); + expect(runSpy).toHaveBeenCalledOnce(); + runSpy.mockRestore(); + }); + it('reinstalls when node_modules exists but is INCOMPLETE (no .package-lock.json)', () => { // A partial tree — left by a timed-out install here, or by the agent's own shell // kill one level up — has the directory but not npm's completeness marker. Gating @@ -793,15 +1221,128 @@ describe('runBuildTest', () => { const summary = 'Tests 3 failed | 1132 passed (1135)'; const trimmed = trimOutput( 'head\n' + 'x'.repeat(3000) + `\n${summary}\n` + 'y'.repeat(9000), - ); + ).text; expect(trimmed).toContain(summary); expect(trimmed).toContain('runner summaries kept'); // The colored form a real pipe delivers is rescued too. const colored = `Tests\x1b[2m \x1b[22m\x1b[31m3 failed\x1b[39m | 1132 passed`; + expect( + trimOutput('h\n' + 'x'.repeat(3000) + `\n${colored}\n` + 'y'.repeat(9000)) + .text, + ).toContain(colored); + }); + + it('rescues Maven dependency-failure lines from a trimmed middle', () => { + // Maven infra classification runs on trimmed output; when the error + // summary lands in the omitted middle, the rescue is what keeps a + // network outage classified as infrastructure instead of a Critical. + const line = + '[ERROR] Could not resolve dependencies for project example:core:jar:1'; + const trimmed = trimOutput( + 'head\n' + 'x'.repeat(3000) + `\n${line}\n` + 'y'.repeat(9000), + ).text; + expect(trimmed).toContain(line); + expect(trimmed).toContain('dependency failures'); + // The colored form a `-Dstyle.color=always` reactor delivers — the SGR + // strip is what the predicate runs on, and the rescued line keeps its + // original bytes. + const colored = + '\x1b[1;31m[ERROR]\x1b[m Could not resolve dependencies for project example:core:jar:1'; + expect( + trimOutput('h\n' + 'x'.repeat(3000) + `\n${colored}\n` + 'y'.repeat(9000)) + .text, + ).toContain(colored); + }); + + it('rescues Maven source-failure lines from a trimmed middle', () => { + // The source markers outrank the infra carve-out; one lost to the trim + // would launder a compile failure into infrastructure. + const line = '[ERROR] COMPILATION ERROR :'; + const trimmed = trimOutput( + 'head\n' + 'x'.repeat(3000) + `\n${line}\n` + 'y'.repeat(9000), + ).text; + expect(trimmed).toContain(line); + expect(trimmed).toContain('source failures'); + // The colored form too — losing the SGR strip here would drop the marker + // that keeps a compile failure from laundering into infrastructure. + const colored = '\x1b[1;31m[ERROR]\x1b[m COMPILATION ERROR :'; + expect( + trimOutput('h\n' + 'x'.repeat(3000) + `\n${colored}\n` + 'y'.repeat(9000)) + .text, + ).toContain(colored); + }); + + it('rescues Maven goal-failure lines from a trimmed middle', () => { + // The swallowed-failure check runs on trimmed output; a fail-never + // plugin goal failure lost to the trim would read the run green. + const line = + '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-checkstyle-plugin:3.3.1:check (validate) on project core: You have 1 Checkstyle violation.'; + const trimmed = trimOutput( + 'head\n' + 'x'.repeat(3000) + `\n${line}\n` + 'y'.repeat(9000), + ).text; + expect(trimmed).toContain(line); + expect(trimmed).toContain('goal failures'); + }); + + it('rescues Maven disk-failure lines from a trimmed middle', () => { + // The launch-failure classification runs on trimmed output; an ENOSPC + // line lost to the trim would file a disk failure against the PR (or, + // under fail-never, read the run green). + const line = + '[ERROR] Failed to write target/x.txt: No space left on device'; + const trimmed = trimOutput( + 'head\n' + 'x'.repeat(3000) + `\n${line}\n` + 'y'.repeat(9000), + ).text; + expect(trimmed).toContain(line); + expect(trimmed).toContain('disk failures'); + }); + + it('rescues the Maven skipped-tests marker from a trimmed middle', () => { + // The adapter's testsSuppressed guard reads the skip marker from the + // trimmed output: a large reactor's trailing Reactor Summary pushes every + // `Tests are skipped.` line into the omitted middle, and losing the marker + // certified a run that tested zero. + const line = '[INFO] Tests are skipped.'; + const trimmed = trimOutput( + 'head\n' + 'x'.repeat(3000) + `\n${line}\n` + 'y'.repeat(9000), + ).text; + expect(trimmed).toContain(line); + expect(trimmed).toContain('skipped-test markers'); + // The colored form too — the rescue strips SGR before the predicate and + // keeps the original bytes. + const colored = '\x1b[1;34m[INFO]\x1b[m Tests are skipped.'; + expect( + trimOutput('h\n' + 'x'.repeat(3000) + `\n${colored}\n` + 'y'.repeat(9000)) + .text, + ).toContain(colored); + }); + + it('rescues Surefire stdout summaries from a trimmed middle', () => { + // The adapter's exit-0 stdout cross-check is the ONE defense for + // relocated-`` runs, and it reads these summaries + // from the trimmed output: a large reactor's trailing Reactor Summary + // pushes them into the omitted middle exactly like the skip marker. + for (const framing of ['[INFO]', '[ERROR]']) { + const line = `${framing} Tests run: 5, Failures: 2, Errors: 0, Skipped: 0`; + const trimmed = trimOutput( + 'head\n' + 'x'.repeat(3000) + `\n${line}\n` + 'y'.repeat(9000), + ).text; + expect(trimmed).toContain(line); + } expect( trimOutput( - 'h\n' + 'x'.repeat(3000) + `\n${colored}\n` + 'y'.repeat(9000), - ), + 'head\n' + + 'x'.repeat(3000) + + '\n[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0\n' + + 'y'.repeat(9000), + ).text, + ).toContain('Surefire stdout summaries'); + // The colored form too — the rescue strips SGR before the predicate. + const colored = + '\x1b[1;31m[ERROR]\x1b[m Tests run: 5, Failures: 2, Errors: 0, Skipped: 0'; + expect( + trimOutput('h\n' + 'x'.repeat(3000) + `\n${colored}\n` + 'y'.repeat(9000)) + .text, ).toContain(colored); }); @@ -815,10 +1356,107 @@ describe('runBuildTest', () => { ) + '\n' + 'y'.repeat(9000); - const trimmed = trimOutput(hostile); + const trimmed = trimOutput(hostile).text; expect(trimmed.length).toBeLessThan(hostile.length / 4); }); + it('discloses when rescued lines outgrow the rescue cap', () => { + // The omission marker claims every category was kept; once the cap + // drops matching lines it must say so instead of asserting the + // impossible. + const input = + 'h'.repeat(2500) + + '\n' + + Array.from({ length: 50 }, (_, i) => `Tests: ${i} failed`).join('\n') + + '\n' + + 't'.repeat(6500); + const trimmed = trimOutput(input).text; + expect(trimmed).toContain( + 'runner summaries kept — first 40 matches kept, failure evidence before benign lines, 10 more omitted', + ); + }); + + it('aligns both cuts to line boundaries so seam-straddling lines survive', () => { + // The cuts used to sit at fixed CHARACTER offsets while every rescue + // and classification predicate is line-anchored: a verdict-critical + // line straddling either cut fragmented into two pieces that matched + // nothing — neither rescued nor disclosed. No hostile input needed. + const headLine = + '[ERROR] Failed to execute goal org.example:plugin:1:check (check) on project core: boom'; + // `headLine` starts at offset 1999, straddling the old 2000-char head + // cut; the newline before it re-roots the head. + const headSeam = + 'head\n' + 'x'.repeat(1993) + '\n' + headLine + '\n' + 'y'.repeat(9000); + expect(trimOutput(headSeam).text).toContain(headLine); + expect(trimOutput(headSeam).evidenceDropped).toBe(false); + + const tailLine = '[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0'; + // `tailLine` straddles the old len-6000 tail cut. + const tailSeam = + 'head\n' + 'x'.repeat(3000) + '\n' + tailLine + '\n' + 'y'.repeat(5970); + expect(trimOutput(tailSeam).text).toContain(tailLine); + }); + + it('keeps failure-evidence lines when benign matches outgrow the rescue cap', () => { + // One shared cap in positional order let benign matches (green Surefire + // summaries) exhaust the slots and drop a failing module's summary — + // the adapter then read the trimmed output green over a failing run. + // Evidence takes the slots first. + const evidence = '[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0'; + const benign = Array.from( + { length: 50 }, + () => '[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0', + ).join('\n'); + const input = + 'head\n' + + 'x'.repeat(3000) + + `\n${benign}\n${evidence}\n` + + 'y'.repeat(9000); + const trimmed = trimOutput(input); + expect(trimmed.text).toContain(evidence); + expect(trimmed.evidenceDropped).toBe(false); + }); + + it('keeps the skipped-tests marker when benign matches outgrow the rescue cap', () => { + // The marker carries a VERDICT (suppression), not a count: with benign + // matches filling the 40 rescue slots, the benign classification + // dropped every `Tests are skipped.` line from the rescued middle + // while `evidenceDropped` stayed false — and the adapter certified a + // run that tested zero. As evidence, the marker takes a slot ahead of + // benign lines. + const marker = '[INFO] Tests are skipped.'; + const benign = Array.from( + { length: 50 }, + () => '[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0', + ).join('\n'); + const input = + 'head\n' + + 'x'.repeat(3000) + + `\n${benign}\n${marker}\n` + + 'y'.repeat(9000); + const trimmed = trimOutput(input); + expect(trimmed.text).toContain(marker); + expect(trimmed.evidenceDropped).toBe(false); + }); + + it('fails closed when evidence lines themselves outgrow the rescue cap', () => { + // When the cap drops EVIDENCE lines the trimmed output no longer holds + // the verdict's inputs — the flag the Maven adapter folds into + // evidenceCapped must say so; benign-only overflow stays disclosed text. + const evidence = Array.from( + { length: 45 }, + (_, i) => + `[ERROR] Failed to execute goal org.example:plugin:1:check (check) on project m${i}: boom`, + ).join('\n'); + const input = + 'head\n' + 'x'.repeat(3000) + `\n${evidence}\n` + 'y'.repeat(9000); + const trimmed = trimOutput(input); + expect(trimmed.evidenceDropped).toBe(true); + expect(trimmed.text).toContain( + 'first 40 matches kept, failure evidence before benign lines, 5 more omitted', + ); + }); + it('buildOnly builds the same set but runs NO tests', () => { // For the merge-base tree an A/B probe compares against: base's suite was // green before this PR existed, so running it measures nothing about the diff --git a/packages/cli/src/commands/review/build-test.ts b/packages/cli/src/commands/review/build-test.ts index 2acfe20d029..64e5043d832 100644 --- a/packages/cli/src/commands/review/build-test.ts +++ b/packages/cli/src/commands/review/build-test.ts @@ -42,6 +42,17 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { + ANSI_SGR_RE, + isDependencyFailureLine, + isDiskFailureLine, + isFailingSurefireSummaryLine, + isGoalFailureLine, + isSourceFailureLine, + isSurefireSummaryLine, + isTestsSkippedLine, + mavenToolchainAdapter, +} from './lib/maven-toolchain.js'; import { npmToolchainAdapter } from './lib/npm-toolchain.js'; import { selectToolchainAdapter, @@ -49,14 +60,34 @@ import { } from './lib/toolchain.js'; import { type TestScope } from './lib/workspace-scope.js'; -/** - * The root toolchains build-test can select. One today; the registry exists so - * the next one is a registration rather than another branch in this file. - */ +/** The root toolchains build-test can select. */ export const toolchainAdapters: readonly ReviewToolchainAdapter[] = [ npmToolchainAdapter, + mavenToolchainAdapter, ]; +/** + * What a Maven lifecycle command this run generated actually scopes. + * + * The adapter builds the command line FROM these values, so a consumer that + * needs them reads them here rather than parsing the string back. Parsing is + * for the free text a PR author writes in a Test Plan; re-deriving our own + * command's meaning from its rendering adds a second grammar that can drift + * from the one that produced it. + */ +export interface MavenCommandFacts { + /** The lifecycle phase the command ends in — `test` or `test-compile`. */ + lifecycle: string; + /** Repo-relative `-pl` module dirs, or null for a reactor-wide run. */ + modules: string[] | null; + /** Whether `-am` upstream expansion was passed. */ + alsoMake: boolean; + /** A GLOBAL skip setting applied (config-declared), so the whole run's test + * phase was suppressed — as opposed to a module-local skip seen only in + * stdout. Absent on non-Maven results and older fixtures. */ + globalSkip?: boolean; +} + /** A command this run actually executed, and what it did. */ export interface CommandResult { command: string; @@ -66,6 +97,64 @@ export interface CommandResult { timedOut: boolean; /** Trimmed output: enough to correlate a failure with the diff. */ output: string; + /** + * The adapter classified this failure as infrastructure — Maven/Java or + * dependency acquisition, an unlaunchable wrapper: a result `test-plan` + * must not settle a Test Plan claim against. + */ + infrastructure?: boolean; + /** + * The command exited 0 but its output records failures Maven did not fail + * on (a fail-never or skip-tests setting swallowed them): `test-plan` + * must not rule a Test Plan claim reproduced against this run. + */ + swallowedFailure?: boolean; + /** + * The command's verdict cannot stand on its evidence: fresh reports the + * parser rejected or a truncated sweep never saw, or the output trim + * dropped failure-evidence lines past its rescue cap. The adapter refused + * to certify the run, and `test-plan` must not settle a Test Plan claim + * against it. Exit-code independent — on an exit-0 run it withholds a + * pass; on a non-zero exit the exit remains definitive. (A `-l`/`--log- + * file` redirect is NOT a producer — it routes to `neverRan`. Reports + * past the parse CAP are the opposite: disclosed in the note, because the + * parsed reports remain evidence.) + */ + evidenceCapped?: boolean; + /** + * A skip setting suppressed the entire test phase (`Tests are skipped.`): + * zero tests ran, and `test-plan` must word the contradiction as + * suppression rather than recorded failures. + */ + testsSuppressed?: boolean; + /** + * The command exited 0 over fresh failing Surefire/Failsafe reports (a + * `testFailureIgnore`-style setting swallowed them). None of the other + * flags fire for this shape — they all key on the ABSENCE of fresh + * failing reports — yet the verdict is `ok: false`: consumers filtering + * on the flags must read this as a failed run, never as a pass. + */ + swallowedReports?: boolean; + /** + * The command exited 0 but cannot prove the toolchain started: no fresh + * reports and no toolchain output (an empty or stub wrapper passes the + * launch gates and exits 0). The run verified nothing, and `test-plan` + * must not rule a claim reproduced against it. + */ + neverRan?: boolean; + /** + * The output trim's rescue cap dropped failure-evidence lines from the + * omitted middle (dependency/source/goal/disk failures, module errors, + * failing Surefire summaries): the classifiers read an output whose + * verdict-relevant lines may be gone — the same epistemic state as + * `evidenceCapped`, which the Maven adapter folds it into. + */ + rescueOverflow?: boolean; + /** + * Present on a Maven LIFECYCLE command (not the dependency warm-up): what + * it scopes, as the adapter knew it when it built the command line. + */ + maven?: MavenCommandFacts; /** * The deadline the command was actually given (ms) — the whole-call budget * shortens it below the per-command default, and the timeout note must @@ -76,8 +165,8 @@ export interface CommandResult { export interface BuildTestReport { /** The scoped toolchain that ran, or `unsupported` when selection was unsafe. */ - toolchain: 'npm' | 'unsupported'; - /** Workspace dirs the diff changed. */ + toolchain: 'npm' | 'maven' | 'unsupported'; + /** Workspace or Maven module dirs the diff changed. */ affected: string[]; /** What was built, dependencies first — after any widening. */ buildSet: string[]; @@ -154,17 +243,37 @@ const MODULE_ERROR_RE = /Cannot find module '[^']+'|Could not resolve "[^"]+"/; */ const RUNNER_SUMMARY_RE = /^\s*(?:Tests?|Test Files):?\s+\d/; -/** SGR color sequences — stripped per line before the summary test, because a - * real runner interleaves them BETWEEN tokens (`Tests\x1b[2m \x1b[22m3 failed`), - * where no anchored pattern can step over them. The rescued line itself keeps - * its original bytes. */ -// eslint-disable-next-line no-control-regex -- ESC is the character under test -const ANSI_SGR_RE = /\x1b\[[0-9;]*m/g; - -export function trimOutput(s: string): string { - if (s.length <= KEEP_HEAD + KEEP_TAIL) return s; - const middle = s.slice(KEEP_HEAD, s.length - KEEP_TAIL); - // Rescue module-resolution errors from the omitted middle. The widening loop +/** SGR color sequences come from the Maven adapter's `ANSI_SGR_RE` export, + * shared so the rescue below and the adapter's own classification strip the + * same bytes: a real runner interleaves them BETWEEN tokens + * (`Tests\x1b[2m \x1b[22m3 failed`), where no anchored pattern can step + * over them. The rescued line itself keeps its original bytes. */ +export function trimOutput(s: string): { + /** The trimmed output the report carries. */ + text: string; + /** + * The rescue cap dropped failure-evidence lines from the omitted middle: + * classification reads an output whose verdict-relevant lines may be + * gone — the Maven adapter folds this into `evidenceCapped`. + */ + evidenceDropped: boolean; +} { + if (s.length <= KEEP_HEAD + KEEP_TAIL) + return { text: s, evidenceDropped: false }; + // Align both cuts to line boundaries: every rescue and classification + // predicate is line-anchored, so a verdict-critical line straddling a + // mid-line cut would fragment into two pieces that match nothing — + // neither rescued nor classified. + const headNewline = s.lastIndexOf('\n', KEEP_HEAD); + const headEnd = headNewline === -1 ? KEEP_HEAD : headNewline + 1; + const tailNewline = s.indexOf('\n', s.length - KEEP_TAIL); + const tailStart = tailNewline === -1 ? s.length - KEEP_TAIL : tailNewline + 1; + // Head and tail already cover the whole string line-wise — one long line + // spans the middle, and splitting it would break the very line the + // alignment exists to protect. + if (headEnd >= tailStart) return { text: s, evidenceDropped: false }; + const middle = s.slice(headEnd, tailStart); + // Rescue verdict-relevant lines from the omitted middle. The widening loop // reads this trimmed output to decide what to add to the build set — a `Cannot // find module` line lost to trimming (a long TypeScript log can push one past the // head and before the tail) would end the widening early and surface a real @@ -172,21 +281,80 @@ export function trimOutput(s: string): string { // CAPPED: the rescue exists to save a handful of summary/module-error lines, // and an uncapped predicate made the whole trim a no-op on 40k lines of // `Test : …` prose (measured in review — 1.6 MB in, 1.6 MB out). Past the - // cap the trim's bounded-output contract wins and the rest stays omitted. + // cap the trim's bounded-output contract wins and the rest stays omitted — + // but evidence lines take the slots FIRST: benign matches (green Surefire + // summaries, skip markers, runner summaries) must not exhaust the cap in + // positional order and drop the failure lines a verdict reads. Dropped + // evidence lines fail closed through `evidenceDropped`. const RESCUE_MAX = 40; - const rescued = middle - .split('\n') - .filter( - (l) => - MODULE_ERROR_RE.test(l) || - RUNNER_SUMMARY_RE.test(l.replace(ANSI_SGR_RE, '')), - ) - .slice(0, RESCUE_MAX); - const omitted = s.length - KEEP_HEAD - KEEP_TAIL; + const matched: Array<{ line: string; index: number; evidence: boolean }> = []; + middle.split('\n').forEach((line, index) => { + // The widening loop reads module errors from the ORIGINAL bytes. + if (MODULE_ERROR_RE.test(line)) { + matched.push({ line, index, evidence: true }); + return; + } + // Strip SGR ONCE per line: the classifiers below all read the same + // stripped copy. + const stripped = line.replace(ANSI_SGR_RE, ''); + // Maven infra classification runs on this trimmed output; a + // dependency-failure line lost to the trim would file a network + // outage against the PR, a source-failure line lost there would + // launder a compile error into infrastructure, a goal-failure line + // lost there would read a fail-never plugin failure green, and a + // disk-failure line lost there would file an ENOSPC death against + // the PR (or, under fail-never, read the run green) — the exact + // errors this command prevents. A FAILING Surefire stdout summary is + // the same class: the exit-0 cross-check's one defense for + // relocated-`` runs. + if ( + isDependencyFailureLine(stripped) || + isSourceFailureLine(stripped) || + isGoalFailureLine(stripped) || + isDiskFailureLine(stripped) || + isFailingSurefireSummaryLine(stripped) || + // The adapter's testsSuppressed guard reads the skip marker from this + // trimmed output; a large reactor's trailing Reactor Summary pushes + // every `Tests are skipped.` line into the omitted middle, and losing + // it certifies a run that tested zero. The marker carries a verdict + // (suppression), so it takes an evidence slot — benign matches must + // not exhaust the cap and drop it while `evidenceDropped` stays + // false. + isTestsSkippedLine(stripped) + ) { + matched.push({ line, index, evidence: true }); + return; + } + // Green Surefire summaries and runner summaries carry counts, never + // verdicts. + if (isSurefireSummaryLine(stripped) || RUNNER_SUMMARY_RE.test(stripped)) { + matched.push({ line, index, evidence: false }); + } + }); + const evidenceCount = matched.reduce( + (sum, item) => sum + (item.evidence ? 1 : 0), + 0, + ); + const kept = + matched.length <= RESCUE_MAX + ? matched + : [ + ...matched.filter((item) => item.evidence).slice(0, RESCUE_MAX), + ...matched + .filter((item) => !item.evidence) + .slice(0, RESCUE_MAX - Math.min(evidenceCount, RESCUE_MAX)), + ].sort((a, b) => a.index - b.index); + const rescued = kept.map((item) => item.line); + const evidenceDropped = evidenceCount > RESCUE_MAX; + const omitted = middle.length; + const dropped = matched.length - kept.length; const marker = rescued.length - ? `\n\n... [${omitted} characters omitted; module-resolution errors and runner summaries kept] ...\n${rescued.join('\n')}\n\n` + ? `\n\n... [${omitted} characters omitted; module-resolution errors, dependency failures, source failures, goal failures, disk failures, skipped-test markers, Surefire stdout summaries, and runner summaries kept${dropped > 0 ? ` — first ${RESCUE_MAX} matches kept, failure evidence before benign lines, ${dropped} more omitted` : ''}] ...\n${rescued.join('\n')}\n\n` : `\n\n... [${omitted} characters omitted] ...\n\n`; - return s.slice(0, KEEP_HEAD) + marker + s.slice(-KEEP_TAIL); + return { + text: s.slice(0, headEnd) + marker + s.slice(tailStart), + evidenceDropped, + }; } /** @@ -236,12 +404,14 @@ function run(command: string, cwd: string, timeoutMs: number): CommandResult { // also matches an external SIGTERM (a container stop), and it misses a non-default // `killSignal`. Check the authoritative one first. const timedOut = spawnTimedOut(r); + const trimmed = trimOutput(`${r.stdout ?? ''}${r.stderr ?? ''}`); return { command, exitCode: r.status, seconds: Math.round((Date.now() - started) / 1000), timedOut, - output: trimOutput(`${r.stdout ?? ''}${r.stderr ?? ''}`), + output: trimmed.text, + ...(trimmed.evidenceDropped ? { rescueOverflow: true } : {}), deadlineMs, }; } @@ -343,26 +513,34 @@ export function runBuildTest(args: BuildTestArgs): BuildTestReport { ); if (!adapter) { if (applicable.length > 1) { - // Unreachable with one registered adapter, and deliberately kept: the - // selection contract is "exactly one, or nothing", and the second - // adapter must land in a file that already refuses to guess between - // them rather than one that has to grow the branch. - return { - toolchain: 'unsupported', - affected: [], - buildSet: [], - widenedWith: [], - install: null, - build: [], - test: [], - ok: true, - timedOut: [], - note: - 'More than one toolchain applies at the repository root. build-test will ' + - 'not guess which one owns this diff, so it ran nothing — report the ' + - 'ambiguity as a handoff instead of substituting ad hoc build or test ' + - 'commands.', - }; + // Both toolchains apply at the root. Preferring npm preserves what a + // review verified on this shape before the Maven adapter existed — + // running NOTHING at all shipped a broken JS build through review + // with zero evidence. The note discloses the Maven half so a green + // npm run never certifies it. + const report = npmToolchainAdapter.run(runArgs); + // npm's applies() held but run() can still concede or return WITHOUT + // executing — a cold yarn/pnpm/bun repo concedes `unsupported`; a + // workspace-shaped diff that maps zero affected files, an install disk + // preflight, or an exhausted budget all return `toolchain: 'npm'` with + // zero commands executed. In every such shape nothing ran, so there is + // no npm half to disclose and the Maven half must run instead of + // certifying nothing (or appending a note that claims the npm + // toolchain ran). The Maven adapter's own mixed-root caveat discloses + // the unscopable npm side of this root. + if ( + report.toolchain === 'unsupported' || + (report.install === null && + report.build.length === 0 && + report.test.length === 0) + ) { + return mavenToolchainAdapter.run(runArgs); + } + report.note += + ' Mixed root: Maven also applies at the repository root (pom.xml), but ' + + 'this run executed the npm toolchain only — Maven-built modules were ' + + 'NOT verified here.'; + return report; } // A root package.json marks an npm-shaped repo that npm's own gate refused // (an unmodeled workspace glob, workspaces that resolve to no package, or @@ -386,7 +564,7 @@ export function runBuildTest(args: BuildTestArgs): BuildTestReport { ok: true, timedOut: [], note: - 'No supported npm project here to scope. Fall back to the ' + + 'No supported npm or Maven project here to scope. Fall back to the ' + 'build/test precedence in your brief — installing dependencies first — ' + 'and give each command a deadline it can actually meet.', }; @@ -397,8 +575,8 @@ export function runBuildTest(args: BuildTestArgs): BuildTestReport { export const buildTestCommand: CommandModule = { command: 'build-test', describe: - 'Build the workspaces the diff changes (and what they compile against), ' + - 'test those plus their dependents, with a deadline the commands can ' + + 'Build what the diff changes (npm: plus their dependents; Maven: plus ' + + 'their upstream closure), test it, with a deadline the commands can ' + 'actually meet', builder: (yargs) => yargs @@ -445,7 +623,9 @@ export const buildTestCommand: CommandModule = { type: 'boolean', default: true, describe: - 'Fetch dependencies first: `npm ci` when node_modules is absent', + 'Fetch dependencies first: `npm ci` when node_modules is absent (npm), ' + + 'or a best-effort `dependency:go-offline` warm-up with its own deadline ' + + '(Maven)', }) .option('build-only', { type: 'boolean', diff --git a/packages/cli/src/commands/review/lib/agent-briefs.ts b/packages/cli/src/commands/review/lib/agent-briefs.ts index 53e304a0bef..fe21eff25a6 100644 --- a/packages/cli/src/commands/review/lib/agent-briefs.ts +++ b/packages/cli/src/commands/review/lib/agent-briefs.ts @@ -522,13 +522,14 @@ You are undirected on purpose. Do not restrict yourself to the list.`, readsDiff: false, brief: `You are **Agent 7: Build & Test Verification**. You do not review the diff — you run the project's own deterministic checks and report what they say. Your evidence is **the commands you ran and their output**; a return that names no command has not done this job. -**Run \`qwen review build-test\` (the exact command, with its \`--plan\` and \`--worktree\`, is below).** It installs if needed, then builds only the workspaces the diff changes plus everything they compile against, and tests the changed ones plus every workspace that depends on them — reading the plan for what changed and the root \`package.json\` for the workspace layout. Do **not** substitute \`npm run build\` / \`npm test\` by hand. The old brief did, with a 120-second deadline, and this repo's cold full build is 125 seconds: measured across the harness's own transcripts, that command timed out **71 times** and verified nothing. \`build-test\` scopes the build, gives it a deadline it can meet, and — this is the part a hand-run command gets wrong — reports a timeout as **infrastructure, not a finding**. A build that runs out of time is never a Critical against someone's pull request. +**Run \`qwen review build-test\` (the exact command, with its \`--plan\` and \`--worktree\`, is below).** It detects supported npm or Maven projects, scopes the run to what the diff changed, and reports the commands and evidence as JSON. Scope differs by toolchain — npm builds the changed workspaces, their dependencies, AND their dependents; Maven runs \`-pl -am\` — the changed modules plus their **upstream** dependency closure only — UNLESS the run is reactor-wide: a POM, \`.mvn/\`, or root build-config change runs the whole reactor, downstream included, and the recorded command shows which shape ran. A module-scoped Maven run therefore does NOT verify the downstream modules that consume the changed ones (a POM or API change can still break them); their coverage stays with the project's CI, so never report a module as verified when it was only a dependent of what a module-scoped run ran. Do **not** substitute hand-written npm or Maven commands for what \`build-test\` runs — the \`toolchain: "unsupported"\` fallback below is the only sanctioned hand-run path. The old npm brief used a 120-second full-build deadline; measured across the harness's own transcripts, it timed out **71 times** and verified nothing. \`build-test\` scopes the run, gives each command a deadline it can meet, and — this is the part a hand-run command gets wrong — reports a timeout or dependency-acquisition failure as **infrastructure, not a finding**. A build that runs out of time is never a Critical against someone's pull request. Read the JSON it prints: -- \`toolchain: "npm"\` → use its \`build[]\` / \`test[]\` results. A failure in a file **the diff changed** is a **Critical** (\`Source: [build]\` or \`[test]\`); a failure in a file it did **not** touch is pre-existing — say so, do not file it against this PR. A non-empty \`timedOut\`, or a failed \`install\`, is environment/infrastructure — informational, never a Critical. On \`ok: true\`, name the workspaces built and the commands run; a return that names no command is a whiff. Report the TEST coverage from \`testScope\`, never from assumption. \`testScope.workspaces\` lists exactly the suites that ran — say "tests scoped to — the changed workspaces and their declared dependents that define a test script". \`testScope.notRun\`, when present, names suites the whole-call budget stopped before they ran — say they did not run, never fold them into the coverage. When \`testScope.caveat\` is present, the scope may be incomplete — quote the caveat and say exactly that. A green run is a claim about those suites only — do not phrase it as the whole suite passing. -- **When any \`test[]\` command failed (exit non-zero, not a timeout), MEASURE which failures are the PR's before ruling by path.** The path rule above misclassifies in both directions — an environment-flaky test in a touched file gets filed as a Critical it did not cause, and a PR that breaks a test in an UNTOUCHED file gets waved through as pre-existing. The measurement is two commands: \`qwen review base-tree --plan --worktree --out /qwen-review-pr--base-tree.json\` (builds the merge base beside the worktree). **Read \`available\` before using \`path\`** — a tree that was created but did NOT build populates \`path\` too, and a base that failed to build says nothing whatsoever about the PR, so measuring against it turns an infrastructure failure into a list of Criticals. \`available: false\` (local/lightweight review, no merge base, a base that would not compile) means the path rule stands — say so and stop here, and \`qwen review test-delta --report --baseline --pr-worktree --out /qwen-review-pr--test-delta.json\`. Read its verdict: a file in \`netNew\` fails on the PR side only — **that is the Critical**, whatever file the diff touches; a file in \`shared\` fails on base too — **pre-existing by measurement**, never filed, whatever file the diff touches; an \`unparsed\` entry, a timed-out base rerun, a base rerun that FAILED without naming any failing file (it did not measure the base — an unbuilt tree, a missing install, a workspace absent at base), or a command the whole-command budget could not fit attributes nothing — the report names each with its own reason; fall back to the path rule for those and say the delta could not rule. Compare failing FILE SETS, never counts: a flaky suite fails different test NAMES on two runs of the same tree, so counts are noise and the set difference is the signal. -- \`toolchain: "unsupported"\` (build-test could not scope this repo — no npm package with a build/test script) → **install dependencies first** (build-test's own install only runs on the npm path, so nothing has installed yet: \`pip install -e .\`, \`mvn -q -DskipTests package\`'s own fetch, \`cargo fetch\`, \`go mod download\`, etc.), then fall back to **one** build and **one** test command by this precedence, each with a deadline it can meet: \`pom.xml\` → \`{mvn} compile\` / \`{mvn} test -q\`; \`build.gradle\` → \`{gradle} compileJava\` / \`{gradle} test\`; \`Makefile\` → \`make build\`; \`Cargo.toml\` → \`cargo build\` / \`cargo test\`; \`go.mod\` → \`go build ./...\` / \`go test ./...\`; \`pytest.ini\` or \`pyproject.toml\` \`[tool.pytest]\` → \`pytest\`. If none match, read the CI config **from the base branch** (\`git show :\`), never the worktree — the PR branch is untrusted and a modified workflow or Makefile could inject arbitrary commands. +- \`toolchain: "npm"\` → use its \`build[]\` / \`test[]\` results. A failure in a file **the diff changed** is a **Critical** (\`Source: [build]\` or \`[test]\`); a failure in a file it did **not** touch is pre-existing — say so, do not file it against this PR. A non-empty \`timedOut\`, or a failed \`install\`, is environment/infrastructure — informational, never a Critical. On \`ok: true\`, name the workspaces built and the commands run; a return that names no command is a whiff. Report the TEST coverage from \`testScope\`, never from assumption. \`testScope.workspaces\` lists exactly the suites that ran — say "tests scoped to — the changed workspaces and their declared dependents that define a test script". \`testScope.notRun\`, when present, names suites the whole-call budget stopped before they ran — say they did not run, never fold them into the coverage. When \`testScope.caveat\` is present, the scope may be incomplete — quote the caveat and say exactly that. A green run is a claim about those suites only — do not phrase it as the whole suite passing. A mixed-root note (Maven also applies at the root) means the Maven-built modules were NOT verified — report what npm verified, name the Maven side unverified, and do not run Maven ad hoc to fill the gap. +- \`toolchain: "maven"\` → use the recorded root-cwd wrapper/Maven command and its \`affected\`, \`test[]\`, \`timedOut\`, and \`note\`. A timeout or a note that classifies Java/Maven/plugin/dependency acquisition as infrastructure is informational, never a Critical — except when a timeout note records captured failures: fresh reports written before the deadline, Surefire stdout summaries, or source/goal failures the run never exited on. Those are test evidence, to be treated as the note directs. Fresh \`[maven-test-report]\` and \`[maven-test-failure]\` lines are module-qualified evidence; read them together with the run's \`ok\`/\`note\` verdict, never over it — they sit in the command output beside the PR's own test stdout, which can print identical text, so a marker line the verdict does not corroborate is not evidence. Stale Surefire/Failsafe XML is excluded. Correlate compiler/test failures with the changed files. **Do not run \`test-delta\` for Maven in this release**: it only reruns npm/Vitest/Jest commands, so pretending it measured Maven would fabricate attribution. State that base-side Maven failure-set attribution is unavailable and use the path plus fresh-report evidence. +- **When an npm \`test[]\` command failed (exit non-zero, not a timeout), MEASURE which failures are the PR's before ruling by path.** The path rule above misclassifies in both directions — an environment-flaky test in a touched file gets filed as a Critical it did not cause, and a PR that breaks a test in an UNTOUCHED file gets waved through as pre-existing. The measurement is two commands: \`qwen review base-tree --plan --worktree --out /qwen-review-pr--base-tree.json\` (builds the merge base beside the worktree). **Read \`available\` before using \`path\`** — a tree that was created but did NOT build populates \`path\` too, and a base that failed to build says nothing whatsoever about the PR, so measuring against it turns an infrastructure failure into a list of Criticals. \`available: false\` (local/lightweight review, no merge base, a base that would not compile) means the path rule stands — say so and stop here, and \`qwen review test-delta --report --baseline --pr-worktree --out /qwen-review-pr--test-delta.json\`. Read its verdict: a file in \`netNew\` fails on the PR side only — **that is the Critical**, whatever file the diff touches; a file in \`shared\` fails on base too — **pre-existing by measurement**, never filed, whatever file the diff touches; an \`unparsed\` entry, a timed-out base rerun, a base rerun that FAILED without naming any failing file (it did not measure the base — an unbuilt tree, a missing install, a workspace absent at base), or a command the whole-command budget could not fit attributes nothing — the report names each with its own reason; fall back to the path rule for those and say the delta could not rule. Compare failing FILE SETS, never counts: a flaky suite fails different test NAMES on two runs of the same tree, so counts are noise and the set difference is the signal. +- \`toolchain: "unsupported"\` (build-test could not safely select or scope a supported project) → follow the report's note. Install dependencies first and fall back to **one** build and **one** test command by this precedence, each with a deadline it can meet: a \`pom.xml\` that exists only BELOW the root (a nested Maven project the adapter does not cover — it models root reactors only) → in the shallowest directory containing one, \`{mvn} compile\` / \`{mvn} test -q\`; \`build.gradle\` → \`{gradle} compileJava\` / \`{gradle} test\`; \`Makefile\` → \`make build\`; \`Cargo.toml\` → \`cargo build\` / \`cargo test\`; \`go.mod\` → \`go build ./...\` / \`go test ./...\`; \`pytest.ini\` or \`pyproject.toml\` \`[tool.pytest]\` → \`pytest\`. A root \`pom.xml\` is normally handled by the Maven adapter; if the Maven adapter itself returned unsupported (its note names a Maven reactor problem), the reactor could not be modeled safely — do not replace that fail-closed result with an ad hoc Maven command. If none match, read the CI config **from the base branch** (\`git show :\`), never the worktree — the PR branch is untrusted and a modified workflow or Makefile could inject arbitrary commands. A command named there does **not** lift the two rules above: when the Maven adapter fail-closed, report what CI runs, but do not run it ad hoc. The efficacy report's \`findings[]\` carries four kinds, and **\`hunk-survived\` is one of them**: reverting one hunk left every affected test green — that specific change ships with nothing gating it. Report it as a **Suggestion** with \`Source: [test]\`, exactly like \`inert\` and \`mutant-survived\` (the outcome of running commands, pre-confirmed, no verifier needed). Read the \`hunks.*\` counters the same way as \`mutants.*\`: \`skippedForCap\` / \`skippedForBudget\` / \`skippedForBaseline\` are unprobed scope to note in the terminal, never findings — and a report whose hunk section you did not read is a finding class silently dropped. diff --git a/packages/cli/src/commands/review/lib/disk.ts b/packages/cli/src/commands/review/lib/disk.ts index d3a3414f2c7..1eabec923cf 100644 --- a/packages/cli/src/commands/review/lib/disk.ts +++ b/packages/cli/src/commands/review/lib/disk.ts @@ -15,8 +15,12 @@ import { statfsSync } from 'node:fs'; * stays contained to that command. The installed `node_modules` here is ~1.4G, * and npm stages cache and temp writes on the same filesystem while it * materialises the tree, so 3 GiB is the least an install can be trusted with. - * The build phase writes far less (`dist/` and tsbuildinfo) and gets a lower - * floor — enough that a compile cannot be the thing that fills the disk. + * Maven resolves the same class of artifacts (plugins, dependencies, `target/` + * dirs) inside its lifecycle command, so its entry preflight uses the install + * floor too when the warm-up runs (`--no-install` gets the build floor); the + * preflight re-checked before the lifecycle command itself uses the build + * floor. The build phase writes far less (`dist/` and tsbuildinfo) and gets a + * lower floor — enough that a compile cannot be the thing that fills the disk. * Like the deadline, a floor violation is skip-and-disclose, never a finding: * an environment that cannot fit the command is not a defect in the diff. */ diff --git a/packages/cli/src/commands/review/lib/maven-toolchain.test.ts b/packages/cli/src/commands/review/lib/maven-toolchain.test.ts new file mode 100644 index 00000000000..a0654060eb7 --- /dev/null +++ b/packages/cli/src/commands/review/lib/maven-toolchain.test.ts @@ -0,0 +1,4921 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { BuildTestReport, CommandResult } from '../build-test.js'; +import type { ToolchainRunArgs } from './toolchain.js'; +import { observedTestCounts } from '../test-plan.js'; +import { + detectMavenOwnership, + isDependencyFailureLine, + isSourceFailureLine, + mavenExecutable, + mavenToolchainAdapter, + NOTE_CORRELATE_ERRORS, + NOTE_INFRASTRUCTURE_EVIDENCE, + NOTE_MAVEN_TEST_PASSED, + reportPaths, + shellSelector, +} from './maven-toolchain.js'; + +const statfsSyncMock = vi.hoisted(() => vi.fn()); +vi.mock('node:fs', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + const mock = { ...actual, statfsSync: statfsSyncMock }; + return { ...mock, default: mock }; +}); + +// Plenty of disk by default, so this suite behaves the same on a nearly-full +// machine as on an empty one — the low-disk case below opts in explicitly. +beforeEach(() => { + statfsSyncMock.mockReturnValue({ bavail: 16 * 1024 ** 3, bsize: 1 }); +}); + +const pom = (modules: string[] = []): string => ` + + 4.0.0 + example + fixture + 1 + + ${modules.map((module) => `${module}`).join('\n ')} + + +`; + +const result = ( + command: string, + overrides: Partial = {}, +): CommandResult => ({ + command, + exitCode: 0, + seconds: 1, + timedOut: false, + // A real Maven run always frames output; the empty-output exit-0 shape + // is the adapter's "never ran" classification, not a clean run. + output: '[INFO] BUILD SUCCESS', + ...overrides, +}); + +describe('maven toolchain adapter', () => { + let root: string; + let sandbox: string; + + beforeEach(() => { + sandbox = mkdtempSync(join(tmpdir(), 'maven-toolchain-')); + // One level deeper than the mkdtemp root: the reactor-escape fixtures + // create `../outside`, which must land inside this sandbox and get + // cleaned, not at a fixed path in the shared OS tmpdir. + root = join(sandbox, 'repo'); + mkdirSync(root); + }); + + afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }); + }); + + function writeProject(dir: string, modules: string[] = []): void { + const path = join(root, dir); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, 'pom.xml'), pom(modules)); + } + + function writeReactor(): void { + writeProject('.', ['core', 'extension', 'nested-parent']); + writeProject('core'); + writeProject('extension'); + writeProject('nested-parent', ['nested-leaf']); + writeProject('nested-parent/nested-leaf'); + } + + function writeWrapper(): void { + writeFileSync(join(root, 'mvnw'), '#!/bin/sh\n'); + chmodSync(join(root, 'mvnw'), 0o755); + } + + /** The wrapper this platform actually executes (`mvnw.cmd` on win32): + * classification arms keyed on the EXECUTED wrapper need the fixture in + * its platform form, or they read the `mvn` fallback state instead. */ + function writeExecutedWrapper(): void { + if (process.platform === 'win32') { + writeFileSync(join(root, 'mvnw.cmd'), '@echo off\r\n'); + } else { + writeWrapper(); + } + } + const executedWrapperName = + process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw'; + + /** + * The adapter over the sandbox reactor, with this suite's standard run + * arguments: the temp `root`, a 5s per-command deadline, no dependency + * warm-up, and an executor that reports every command as clean. + * + * Anything a case actually cares about it passes in `opts` — an `exec` that + * scripts a failure or records the command line, `install: true`, + * `buildOnly`, a `budget`. Spreading last means an override reads at the + * call site instead of hiding in lines of identical setup. + */ + const runAdapter = ( + changedFiles: string[], + opts: Partial> = {}, + ): BuildTestReport => + mavenToolchainAdapter.run({ + root, + changedFiles, + timeout: 5, + install: false, + exec: (command) => result(command), + ...opts, + }); + + it('marks Maven build files reactor-wide, scopes root sources to the root project, and leaves docs without targets', () => { + writeReactor(); + + expect( + detectMavenOwnership(root, [ + 'pom.xml', + '.mvn/maven.config', + 'mvnw', + 'mvnw.cmd', + ]), + ).toEqual({ + reactorWide: true, + modules: [], + }); + + // The root artifact's own src/ is owned by the root project '.': it + // verifies with `-pl . -am`, not the entire reactor. + expect( + detectMavenOwnership(root, ['src/main/java/example/Root.java']), + ).toEqual({ + reactorWide: false, + modules: ['.'], + }); + + expect(runAdapter(['docs/guide.md'])).toMatchObject({ + toolchain: 'maven', + affected: [], + buildSet: [], + build: [], + test: [], + ok: true, + }); + }); + + it('leaves module documentation changes without a Maven target', () => { + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/README.md', 'core/docs/guide.md'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(true); + expect(report.affected).toEqual([]); + expect(calls).toEqual([]); + }); + + it('still builds the owning module for documentation-extension files under its src/', () => { + // The src/ guard is re-rooted to the owning module: a .txt under a + // module's source tree is test data, not documentation. + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/src/test/resources/expected.txt'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.toolchain).toBe('maven'); + expect(calls).toEqual([ + 'mvn --batch-mode --no-transfer-progress -pl core -am test', + ]); + }); + + it('keeps verifying the owning module when a test-fixture POM sits under its src/', () => { + // maven-invoker ITs, archetype fixtures, and src/test/resources/projects/* + // trees are test DATA: Maven never builds them as reactor modules and no + // profile activates them. Reading one as a standalone project used to fail + // the WHOLE diff closed — including the real source change beside it. + writeReactor(); + writeProject('core/src/test/resources/projects/sample'); + const calls: string[] = []; + + const report = runAdapter( + [ + 'core/src/main/java/Core.java', + 'core/src/test/resources/projects/sample/App.java', + ], + { + exec: (command) => { + calls.push(command); + return result(command); + }, + }, + ); + + expect(report.toolchain).toBe('maven'); + expect(calls).toEqual([ + 'mvn --batch-mode --no-transfer-progress -pl core -am test', + ]); + }); + + it('fails closed to reactor-wide when a skipped src/ POM would collapse to the root', () => { + writeProject('.'); + writeProject('src/test/resources/projects/sample'); + const calls: string[] = []; + + const report = runAdapter(['src/test/resources/projects/sample/App.java'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + // The `src/` skip exists for test-data POMs, but the walk cannot tell a + // fixture from a REAL module nested under `src/` (a reactor can + // aggregate `src/core`). Collapsing to `-pl .` would + // compile only the root and certify the changed module untested, so the + // skip fails closed: the whole reactor runs instead. + expect(report.toolchain).toBe('maven'); + expect(calls).toEqual(['mvn --batch-mode --no-transfer-progress test']); + expect(report.affected).toEqual(['.']); + }); + + it('scopes root-project source fixtures with documentation extensions to the root project', () => { + writeProject('.'); + const calls: string[] = []; + + const report = runAdapter(['src/test/resources/expected.txt'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.affected).toEqual(['.']); + expect(calls).toEqual([ + 'mvn --batch-mode --no-transfer-progress -pl . -am test', + ]); + }); + + it('prefers the wrapper, runs from root, narrows modules, and forwards timeout', () => { + writeReactor(); + // The wrapper a platform can actually execute: win32 `cmd.exe` runs + // `mvnw.cmd` and cannot run `./mvnw`; POSIX needs the executable bit. + const windows = process.platform === 'win32'; + if (windows) { + writeFileSync(join(root, 'mvnw.cmd'), '@echo off\n'); + } else { + writeWrapper(); + } + const calls: Array<[string, string, number]> = []; + // The deadline is wall clock from the top of the call: freeze it so + // the forwarded deadline asserts exactly. + const clock = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock); + + let report: ReturnType; + try { + report = runAdapter( + [ + 'extension/src/main/java/example/Extension.java', + 'core/src/main/java/example/Core.java', + ], + { + timeout: 17, + exec: (command, cwd, timeout) => { + calls.push([command, cwd, timeout]); + return result(command); + }, + }, + ); + } finally { + nowSpy.mockRestore(); + } + + const executable = windows ? 'mvnw.cmd' : './mvnw'; + expect(calls).toEqual([ + [ + `${executable} --batch-mode --no-transfer-progress -pl core,extension -am test`, + root, + 17_000, + ], + ]); + expect(report).toMatchObject({ + toolchain: 'maven', + affected: ['core', 'extension'], + buildSet: ['core', 'extension'], + install: null, + build: [], + ok: true, + }); + expect(report.test[0]?.command).toContain('-pl core,extension -am test'); + // Agent 7 reads the note as evidence: it must say the run did NOT build + // downstream dependents, or a dependent module gets reported as verified. + expect(report.note).toContain('upstream dependencies only'); + expect(report.note).toContain('downstream dependents were NOT built'); + }); + + it('uses mvn and test-compile for build-only mode', () => { + writeReactor(); + const report = runAdapter(['core/src/main/java/example/Core.java'], { + buildOnly: true, + exec: (command, cwd) => { + expect(cwd).toBe(root); + return result(command); + }, + }); + + expect(report.test).toEqual([]); + expect(report.build[0]?.command).toBe( + 'mvn --batch-mode --no-transfer-progress -pl core -am test-compile', + ); + }); + + it('does not narrow reactor-wide changes', () => { + writeReactor(); + const report = runAdapter(['.mvn/maven.config']); + + expect(report.affected).toEqual(['.']); + expect(report.test[0]?.command).toBe( + 'mvn --batch-mode --no-transfer-progress test', + ); + // A full-reactor run must not carry the narrowed-run scope statement. + expect(report.note).not.toContain('downstream dependents were NOT built'); + }); + + it('discloses that a reactor-wide timeout is expected to exceed the deadline', () => { + // On the large reactors this adapter targets, a root-POM change selects + // the whole reactor, and `test` over it cannot finish in the default + // deadline — say so, so no agent spends turns re-deriving it. + writeReactor(); + + const report = runAdapter(['pom.xml'], { + timeout: 300, + exec: (command) => + result(command, { exitCode: null, timedOut: true, seconds: 300 }), + }); + + expect(report.note).toContain('infrastructure result'); + expect(report.note).toContain('reactor-wide'); + expect(report.note).toContain('same scope'); + }); + + it('classifies timeout and dependency resolution without fresh reports as infrastructure', () => { + writeReactor(); + const timeout = runAdapter(['core/src/Main.java'], { + timeout: 2, + exec: (command) => + result(command, { exitCode: null, timedOut: true, seconds: 2 }), + }); + expect(timeout.ok).toBe(false); + expect(timeout.timedOut).toEqual([timeout.test[0]?.command]); + expect(timeout.note).toContain('infrastructure result'); + + const resolution = runAdapter(['core/src/Main.java'], { + timeout: 2, + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + expect(resolution.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(resolution.test[0]).toMatchObject({ infrastructure: true }); + expect(timeout.test[0]?.infrastructure).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')( + 'does not classify a changed wrapper permission failure as infrastructure', + () => { + // On win32 `mvnw` is the other platform's wrapper and is skipped by + // ownership, so the adapter sees no Maven target to run. + + writeReactor(); + writeWrapper(); + + const report = runAdapter(['mvnw'], { + exec: (command) => + result(command, { + exitCode: 126, + output: '/bin/sh: ./mvnw: Permission denied', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not hide a permission failure behind `./mvnw` spelled differently', + () => { + // On win32 the normalized `./mvnw` path is the other platform's + // wrapper and is skipped by ownership, so no permission-failure note + // is produced. + + // The guard compares normalized paths: `./mvnw` and absolute paths name + // the same wrapper the raw comparison missed. + writeReactor(); + writeWrapper(); + + const report = runAdapter(['./mvnw'], { + exec: (command) => + result(command, { + exitCode: 126, + output: '/bin/sh: ./mvnw: Permission denied', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }, + ); + + it.each([ + ['sh: 1: mvn: not found', 127], + // zsh names the command LAST; it also prints this in sh-compat mode. + ['zsh: command not found: mvn', 127], + ['sh: command not found: mvn', 127], + // PowerShell's phrasing when Maven is absent. + [ + "mvn: The term 'mvn' is not recognized as a name of a cmdlet, function, script file, or operable program", + 127, + ], + // fish's phrasing. + ['fish: Unknown command: mvn', 127], + // cmd.exe's wording when Maven is absent on Windows (exit 9009). + ["'mvn' is not recognized as an internal or external command", 9009], + [ + '[ERROR] Failed to execute goal on project core: java.io.IOException: No space left on device', + 1, + ], + ['Error: The JAVA_HOME environment variable is not defined correctly', 1], + // mvn.cmd/mvnw.cmd on Windows when JAVA_HOME is UNSET names the failure + // differently than the POSIX wrapper's "not defined correctly". + ['Error: JAVA_HOME not found in your environment.', 1], + // mvn.cmd/mvnw.cmd on Windows, when JAVA_HOME points at an invalid + // directory. + ['ERROR: JAVA_HOME is set to an invalid directory: C:\\old\\jdk', 1], + ['Unable to locate a Java Runtime', 1], + ])( + 'classifies unchanged Maven startup failures as infrastructure', + (output, exitCode) => { + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode, output }), + }); + + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }, + ); + + it('does not classify unframed disk-full words as a launch failure', () => { + // `No space left on device` without Maven's `[ERROR]` framing is a test + // exercising a disk-full path, not an outage; free text cannot tell the + // two apart, so the framing decides, as for dependency failures. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: 'java.io.IOException: No space left on device', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')( + 'classifies an unchanged wrapper launch failure as infrastructure', + () => { + // The guard needs `executable === './mvnw'`, unreachable on win32. + writeReactor(); + writeWrapper(); + + const runWith = (exitCode: number, output: string) => + runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode, output }), + }); + + const denied = runWith(126, '/bin/sh: ./mvnw: Permission denied'); + expect(denied.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // A CRLF-committed wrapper dies at shebang resolution on Linux. + const crlf = runWith( + 126, + '/bin/sh: ./mvnw: /bin/sh^M: bad interpreter: No such file or directory', + ); + expect(crlf.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // Some shells report the same death with exit 127. + const crlf127 = runWith( + 127, + '/bin/sh: ./mvnw: /usr/bin/env: bad interpreter: No such file or directory', + ); + expect(crlf127.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // bash >= 5.2 reports the same death with new wording. + const bash52 = runWith( + 127, + '/bin/sh: line 1: ./mvnw: cannot execute: required file not found', + ); + expect(bash52.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // dash's bare wording. + const dash = runWith(127, 'sh: ./mvnw: not found'); + expect(dash.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // A CRLF `#!/usr/bin/env sh` shebang names env, not the wrapper. + const envCrlf = runWith( + 127, + "/usr/bin/env: 'sh\\r': No such file or directory", + ); + expect(envCrlf.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }, + ); + + it('does not file a dependency failure as infrastructure when the diff changed build inputs', () => { + writeReactor(); + const output = + '[ERROR] Could not resolve dependencies for project example:core'; + + for (const changed of ['pom.xml', '.mvn/maven.config', 'core/pom.xml']) { + const report = runAdapter([changed], { + exec: (command) => result(command, { exitCode: 1, output }), + }); + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + } + + // No executable wrapper exists on disk, so the run used the system + // `mvn`: a wrapper file this run never executed cannot have caused its + // resolution failure, so the carve-out stays — with a disclosure. Only + // this platform's wrapper is exercised: a change confined to the OTHER + // platform's wrapper leaves no Maven target to run at all (see the + // other-platform-wrapper test below). + const platformWrapper = process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw'; + for (const changed of [platformWrapper]) { + const report = runAdapter([changed], { + exec: (command) => result(command, { exitCode: 1, output }), + }); + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.note).toContain('wrapper change itself was not exercised'); + } + }); + + it('does not treat an inner permission error as a wrapper startup failure', () => { + writeReactor(); + writeWrapper(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: 'Failed to write target/generated.txt: Permission denied', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('attributes dependency wording beside fresh reports to the PR, not the environment', () => { + // State-machine rule: when fresh reports exist, the STRUCTURED evidence + // says tests ran — the stdout scrapers are a fallback for runs with NO + // reports, so dependency wording beside reports cannot launder the run + // into an environmental result. Over-attribution to the PR is the + // documented preference over an environmental wash. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:extension', + }); + }, + }); + + expect(report.test[0]?.output).toContain('[maven-test-report]'); + expect(report.test[0]?.infrastructure).toBeUndefined(); + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('keeps fresh failing tests as source evidence despite infrastructure words', () => { + // The output is Maven-FRAMED: absent the fresh-failure guard it WOULD + // classify as infrastructure, so the assertions genuinely pin the + // precedence of fresh failing XML over the dependency carve-out. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:extension', + }); + }, + }); + + expect(report.test[0]?.output).toContain('[maven-test-failure]'); + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + it('treats exit 0 with fresh failing reports as a failure, not a pass', () => { + // surefire `testFailureIgnore` (or -Dmaven.test.failure.ignore) lets + // `mvn test` exit 0 over failing tests; the verdict must read the XML. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.output).toContain('[maven-test-failure]'); + // The shape's own flag: none of the other verdict flags fire for an + // exit-0 run over fresh failing reports, and test-delta/test-plan's + // count mining filter on them. + expect(report.test[0]?.swallowedReports).toBe(true); + expect(report.note).toContain('exited 0'); + expect(report.note).toContain('test failures, not a pass'); + expect(report.note).not.toContain(NOTE_MAVEN_TEST_PASSED); + }); + + it('skips malformed report directories without aborting Maven', () => { + writeReactor(); + const reportPath = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(join(root, 'core', 'target'), { recursive: true }); + writeFileSync(reportPath, 'not a directory'); + const calls: string[] = []; + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toHaveLength(1); + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(true); + expect(report.test[0]?.output).not.toContain('[maven-test-report]'); + }); + + it('ignores stale XML and appends fresh module-qualified Surefire and Failsafe summaries', () => { + writeReactor(); + const staleDir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(staleDir, { recursive: true }); + const stale = join(staleDir, 'TEST-Stale.xml'); + writeFileSync( + stale, + '', + ); + utimesSync(stale, new Date(1_000), new Date(1_000)); + + const report = runAdapter( + ['core/src/Main.java', 'extension/src/Extension.java'], + { + exec: (command) => { + const coreDir = join(root, 'core', 'target', 'surefire-reports'); + const extensionDir = join( + root, + 'extension', + 'target', + 'failsafe-reports', + ); + mkdirSync(coreDir, { recursive: true }); + mkdirSync(extensionDir, { recursive: true }); + writeFileSync( + join(coreDir, 'TEST-SameTest.xml'), + '', + ); + writeFileSync( + join(extensionDir, 'TEST-SameTest.xml'), + '', + ); + return result(command, { + exitCode: 1, + output: '[ERROR] Tests failed', + }); + }, + }, + ); + + const output = report.test[0]?.output ?? ''; + expect(output).not.toContain('TEST-Stale.xml'); + expect(output).toContain( + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0', + ); + expect(output).toContain( + '[maven-test-failure] core/target/surefire-reports/TEST-SameTest.xml: example.SameTest#coreFailure', + ); + // The clean rollup carries the per-report CLAMPED passed total + // (3 tests - 1 skipped), not the raw pre-aggregated Σtests/Σskipped: + // test-plan parses counts per line with its own clamp, and raw totals + // would parse to a different count than the per-report truth. + expect(output).toContain( + '[maven-test-report] extension (1 report(s)): tests=2, failures=0, errors=0, skipped=0', + ); + expect(output).not.toContain( + 'extension/target/failsafe-reports/TEST-SameTest.xml', + ); + expect(report.note).toContain('module-qualified'); + }); + + it('quotes exotic module selectors for the platform shell', () => { + // Plain selectors stay bare; anything else is quoted for the shell the + // command actually runs under — POSIX quoting is literal in cmd.exe. + expect(shellSelector(['core', 'extension'])).toBe('core,extension'); + expect(shellSelector(['my module'], 'linux')).toBe("'my module'"); + expect(shellSelector(['my module'], 'win32')).toBe('"my module"'); + }); + + it.each([ + // `,` separates `-pl` arguments and `:` makes Maven read the selector as + // `[groupId]:artifactId` coordinates instead of a path: both change what + // the selector MEANS, so quoting cannot rescue them. + ['a,b'], + ['a:b'], + // cmd.exe expands %VAR% even inside `"…"`. + ['a%b'], + // A leading `-` is re-read as an option by commons-cli (`-pl -rf` dies + // with 'Missing argument for option: pl'); a leading `!` is Maven's + // exclusion operator — quoting preserves the bytes, not the semantics. + ['-rf'], + ['!foo'], + ])('refuses a selector it cannot express for %s', (module) => { + // These are directory names read off disk now, not entries a POM parser + // pre-filtered — the gate has to live in the selector itself. + expect(shellSelector([module], 'linux')).toBeNull(); + expect(shellSelector([module], 'win32')).toBeNull(); + }); + + it('widens to the full reactor for a module a selector cannot carry', () => { + // Failing closed to the whole reactor is the safe direction: it verifies + // more than asked, where a mis-quoted selector verifies the wrong thing. + writeProject('.', ['od,d']); + writeProject('od,d'); + const calls: string[] = []; + + const report = runAdapter(['od,d/src/main/java/Main.java'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toEqual(['mvn --batch-mode --no-transfer-progress test']); + expect(report.affected).toEqual(['.']); + expect(report.note).toContain('cannot express'); + }); + + it('runs the whole reactor for a module POM change', () => { + // A POM is parent config for everything that aggregates or inherits it. + // This adapter models none of those edges — Maven applies the real ones + // inside the command — so the scope widens instead of guessing a closure. + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['nested-parent/pom.xml'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toEqual(['mvn --batch-mode --no-transfer-progress test']); + expect(report.affected).toEqual(['.']); + }); + + it('reports unsupported when Maven rejects the selected project', () => { + // The out-of-reactor / profile-inactive answer comes from Maven, which + // evaluates profile activation, `` inheritance, and the current + // JDK, and rejects an unknown selector before compiling anything. Nothing + // here re-derives that from the POM text. + writeProject('.', ['core']); + writeProject('core'); + // On disk but absent from the reactor Maven actually assembles. + writeProject('admin'); + + const report = runAdapter(['admin/src/main/java/Admin.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not find the selected project in the reactor: admin\n', + }), + }); + + // `unsupported` is the structured handoff, not a failed build: it carries + // no build/test evidence for a verdict to read. + expect(report.toolchain).toBe('unsupported'); + expect(report.build).toEqual([]); + expect(report.test).toEqual([]); + expect(report.note).toContain('admin'); + expect(report.note).toContain('profile-inactive'); + }); + + it('collects fresh reports from a project directory nothing enumerated', () => { + // The report sweep walks the worktree instead of a list of reactor + // projects: which projects are active is Maven's answer, and a report + // directory only exists where Maven actually ran. + writeProject('.', ['core']); + writeProject('core'); + const reports = join( + root, + 'core', + 'generated-child', + 'target', + 'surefire-reports', + ); + mkdirSync(reports, { recursive: true }); + + const report = runAdapter(['core/src/main/java/Main.java'], { + exec: (command) => { + writeFileSync( + join(reports, 'TEST-child.GenTest.xml'), + '', + ); + return result(command); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.output).toContain('core/generated-child'); + expect(report.test[0]?.output).toContain('tests=4'); + }); + + it('selects the wrapper a platform can execute', () => { + writeProject('.'); + writeFileSync(join(root, 'mvnw'), '#!/bin/sh\n'); + chmodSync(join(root, 'mvnw'), 0o755); + writeFileSync(join(root, 'mvnw.cmd'), '@echo off\n'); + + expect(mavenExecutable(root, 'linux')).toBe('./mvnw'); + expect(mavenExecutable(root, 'darwin')).toBe('./mvnw'); + expect(mavenExecutable(root, 'win32')).toBe('mvnw.cmd'); + + rmSync(join(root, 'mvnw')); + rmSync(join(root, 'mvnw.cmd')); + expect(mavenExecutable(root, 'linux')).toBe('mvn'); + expect(mavenExecutable(root, 'win32')).toBe('mvn'); + }); + + it.skipIf(process.platform === 'win32')( + 'falls back to mvn for a wrapper without the executable bit', + () => { + // A `core.fileMode=false` checkout commits mvnw mode 644; running it + // would die with exit 126 and zero verification, so prefer system mvn. + writeProject('.'); + writeFileSync(join(root, 'mvnw'), '#!/bin/sh\n'); + expect(mavenExecutable(root, 'linux')).toBe('mvn'); + + chmodSync(join(root, 'mvnw'), 0o755); + expect(mavenExecutable(root, 'linux')).toBe('./mvnw'); + }, + ); + + it('leaves repository metadata without Maven targets', () => { + writeReactor(); + + const metadata = [ + '.github/workflows/ci.yml', + '.gitignore', + '.gitattributes', + 'LICENSE', + 'CODEOWNERS', + '.editorconfig', + ]; + expect(detectMavenOwnership(root, metadata)).toEqual({ + reactorWide: false, + modules: [], + }); + + const calls: string[] = []; + const report = runAdapter(['.github/workflows/ci.yml'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(true); + expect(calls).toEqual([]); + }); + + it('rolls clean reports up per project dir and caps failing reports', () => { + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const coreDir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(coreDir, { recursive: true }); + for (let i = 0; i < 150; i++) { + writeFileSync( + join(coreDir, `TEST-Clean${i}.xml`), + '', + ); + } + for (let i = 0; i < 120; i++) { + writeFileSync( + join(coreDir, `TEST-Fail${i}.xml`), + '', + ); + } + return result(command, { exitCode: 1, output: '[ERROR] Tests failed' }); + }, + }); + + const output = report.test[0]?.output ?? ''; + // 150 clean reports become ONE rollup line, keeping the count shape + // test-plan parses; 270 per-report lines would have bypassed the trim. + expect(output).toContain( + '[maven-test-report] core (150 report(s)): tests=300, failures=0, errors=0, skipped=0', + ); + expect(output).not.toContain('TEST-Clean0.xml'); + // Failing reports roll up per PROJECT like the clean side — the + // per-report lines' byte-order cap lost module attribution for + // everything past the bound. The per-report case markers survive (they + // are the module-qualified failure evidence); the COUNT line is the + // rollup. + expect(output).toContain( + '[maven-test-report] core (120 failing report(s)): tests=120, failures=120, errors=0, skipped=0', + ); + expect(output).toContain( + '[maven-test-failure] core/target/surefire-reports/TEST-Fail0.xml: example.FailTest#fails', + ); + }); + + it('keeps per-project clamped totals now that the clean rollup is uncapped', () => { + // The clean rollup no longer caps: every project keeps its own + // attributed line, so no module can land in an unattributed omitted + // tail (the shape that once mis-ruled a scoped count claim). The + // per-report clamp still applies inside each line: mod99 records one + // test with three skipped, which clamps to zero passed. + const modules = Array.from({ length: 120 }, (_, i) => `mod${i}`); + writeProject('.', modules); + for (const module of modules) writeProject(module); + + const report = runAdapter(['mod0/src/main/java/Main.java'], { + exec: (command) => { + for (const module of modules) { + const dir = join(root, module, 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Clean.xml'), + module === 'mod99' + ? '' + : '', + ); + } + return result(command); + }, + }); + + const output = report.test[0]?.output ?? ''; + // All 120 projects carry an attributed line; nothing is omitted. + expect( + output.match(/\[maven-test-report\] mod\d+ \(1 report\(s\)\)/g), + ).toHaveLength(120); + expect(output).not.toContain('omitted'); + expect(output).toContain( + '[maven-test-report] mod99 (1 report(s)): tests=0, failures=0, errors=0, skipped=0', + ); + // 119 projects pass one each; mod99 clamps to zero. The second reading + // is the changed module's (`mod0`) subtotal, emitted beside the + // reactor-wide readings so a scoped count claim can settle. + expect(observedTestCounts(report)).toEqual([119, 1]); + // The green note's totals cover all 120 reports. + expect(report.note).toContain( + 'Maven test passed with fresh reports: 120 tests', + ); + }); + + it('carries clamped passed totals in the failing omission marker', () => { + const modules = Array.from({ length: 103 }, (_, i) => `mod${i}`); + writeProject('.', modules); + for (const module of modules) writeProject(module); + + const report = runAdapter(['mod0/src/main/java/Main.java'], { + exec: (command) => { + for (const module of modules) { + const dir = join(root, module, 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Fail.xml'), + // mod99 sorts into the omitted tail and passes zero despite + // recording a test; its batchmates each pass one. + module === 'mod99' + ? '' + : '', + ); + } + return result(command, { exitCode: 1, output: '[ERROR] Tests failed' }); + }, + }); + + const output = report.test[0]?.output ?? ''; + expect(output).toContain( + '[maven-test-report] 3 more failing project rollup(s) omitted: ' + + 'tests=2, failures=0, errors=0, skipped=0', + ); + // The omission marker still carries the clamped passed totals (the + // agent reads them), but a FAILED run no longer contributes counts to + // claim adjudication (R2-11): its partial pass totals once ruled a + // count claim `reproduces` while the command-claim twin ruled + // `contradicted`. observedTestCounts now excludes non-zero-exit runs. + expect(observedTestCounts(report)).toEqual([]); + }); + + it('caps failing case lines', () => { + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const coreDir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(coreDir, { recursive: true }); + const cases = Array.from( + { length: 250 }, + (_, i) => + ``, + ).join(''); + writeFileSync( + join(coreDir, 'TEST-Big.xml'), + `${cases}`, + ); + return result(command, { exitCode: 1, output: '[ERROR] Tests failed' }); + }, + }); + + const output = report.test[0]?.output ?? ''; + expect(output).toContain( + '[maven-test-failure] 50 more failing case(s) omitted', + ); + expect(output.match(/\[maven-test-failure\] core\//g)).toHaveLength(200); + }); + + it('treats unframed network words as source evidence, and Maven-framed ones as infrastructure', () => { + writeReactor(); + + const unframed = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: 'java.net.ConnectException: Connection refused', + }), + }); + expect(unframed.note).toContain(NOTE_CORRELATE_ERRORS); + expect(unframed.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + const framed = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Failed to execute goal on project core: Could not transfer artifact org.example:dep:jar:1: Connection refused', + }), + }); + expect(framed.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('does not launder a compile failure into infrastructure when dependency words share the output', () => { + // A flaky mirror, or an upstream module pulled in by `-am`, can put one + // `[ERROR] Could not transfer artifact` line in the same output as a + // real compile error. The compile failure writes no Surefire XML, so + // only the source markers keep it from reading as infrastructure. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: [ + '[ERROR] Could not transfer artifact org.foo:bar:jar:1.0 from/to central: Connection timed out', + '[ERROR] COMPILATION ERROR :', + '[ERROR] /tmp/x/core/src/main/java/Main.java:[12,5] cannot find symbol', + ].join('\n'), + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + it('keeps source-failure markers framed — unframed words stay infrastructure', () => { + writeReactor(); + + const runWith = (output: string) => + runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 1, output }), + }); + + const dependencyLine = + '[ERROR] Could not resolve dependencies for project example:core'; + + // Every Maven-framed marker outranks the dependency carve-out... + for (const marker of [ + '[ERROR] COMPILATION ERROR :', + '[ERROR] /tmp/x/core/src/main/java/Main.java:[12,5] cannot find symbol', + '[ERROR] There are test failures.', + ]) { + const report = runWith(`${dependencyLine}\n${marker}`); + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + } + + // ...but the same words in a test's own stdout do not. + const unframed = runWith(`${dependencyLine}\nCOMPILATION ERROR`); + expect(unframed.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(unframed.test[0]).toMatchObject({ infrastructure: true }); + }); + + it('classifies a spawn-level death without an exit code as infrastructure', () => { + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: null, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.timedOut).toEqual([]); + expect(report.note).toContain('without an exit code'); + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('discloses successful tests without fresh XML', () => { + writeReactor(); + const report = runAdapter(['core/src/Main.java']); + + expect(report.ok).toBe(true); + expect(report.note).toContain('no fresh Surefire/Failsafe XML'); + }); + + it('keeps fresh failing reports as test evidence when the run times out', () => { + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + timeout: 2, + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { exitCode: null, timedOut: true, seconds: 2 }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.note).toContain('ran out of time'); + expect(report.note).toContain('1 failure(s) and 1 error(s)'); + expect(report.note).toContain('treat those as test failures'); + expect(report.note).not.toContain('not a defect in the diff'); + expect(report.test[0]?.output).toContain('[maven-test-failure]'); + }); + + it('keeps fresh failing reports as test evidence when the run dies without an exit code', () => { + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { exitCode: null }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.note).toContain('ended without an exit code'); + expect(report.note).toContain('1 failure(s) and 0 error(s)'); + expect(report.note).toContain('treat those as test failures'); + expect(report.note).not.toContain( + 'This is infrastructure evidence, not a source finding.', + ); + }); + + it.skipIf(process.platform === 'win32')( + 'does not classify a launch failure as infrastructure when the wrapper changed', + () => { + // On win32 `mvnw` is the other platform's wrapper and is skipped by + // ownership, so the adapter sees no Maven target to run. + + // The PR's own wrapper edit may be what broke startup; the pinned intent + // (changed-wrapper failures are never environmental) covers the + // launch-failure disjunct too, not just the 126/127 wrapper one. + writeReactor(); + writeWrapper(); + + const report = runAdapter(['mvnw'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + 'Error: The JAVA_HOME environment variable is not defined correctly', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not launder a PR-caused fallback launch failure into infrastructure', + () => { + // The diff drops the wrapper's executable bit; mavenExecutable falls + // back to system mvn, executedWrapper is null, and a runner without + // system Maven dies 127. That death is the diff's own doing — filing + // it as infrastructure would let a PR that broke the build ship with + // no finding. + writeReactor(); + writeFileSync(join(root, 'mvnw'), '#!/bin/sh\n'); // no executable bit + + const report = runAdapter(['mvnw'], { + exec: (command) => + result(command, { exitCode: 127, output: 'sh: 1: mvn: not found' }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'discloses when a changed wrapper falls back to the system mvn', + () => { + // On win32 `mvnw` is dropped by ownership, only the Java source is + // owned, and the run narrows to `-pl core -am test` instead of the + // reactor-wide command pinned here. + + // No executable bit: mavenExecutable falls back to system mvn, so the + // wrapper the diff changes is never executed — the run must say so. + writeReactor(); + writeFileSync(join(root, 'mvnw'), '#!/bin/sh\n'); + const calls: string[] = []; + + const report = runAdapter( + ['mvnw', 'core/src/main/java/example/Core.java'], + { + exec: (command) => { + calls.push(command); + return result(command); + }, + }, + ); + + expect(calls).toEqual(['mvn --batch-mode --no-transfer-progress test']); + expect(report.note).toContain('wrapper change itself was not exercised'); + // The fallback is a green run: keying neverRan's wrapper disjunct on + // ANY changed wrapper (instead of the executed one) would read it as + // never run and fail it. + expect(report.ok).toBe(true); + expect(report.test[0]?.neverRan).toBeUndefined(); + }, + ); + + it('does not treat a test-fixture POM as a dependency input', () => { + // A fixture pom.xml under a module's src/ tree cannot change the reactor's + // dependency resolution; a genuine outage there stays infrastructure. + writeReactor(); + writeProject('core/src/test/resources/projects/sample'); + + const report = runAdapter( + ['core/src/test/resources/projects/sample/pom.xml'], + { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }, + ); + + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('reports insufficient disk space instead of running Maven on a full disk', () => { + statfsSyncMock.mockReturnValue({ bavail: 5.4e8, bsize: 1 }); // ~0.5G free + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.ok).toBe(false); + expect(report.build).toEqual([]); + expect(report.test).toEqual([]); + expect(calls).toEqual([]); + expect(report.note).toContain('Insufficient disk space'); + }); + + it('re-checks the disk floor after the warm-up, before the lifecycle', () => { + // The warm-up is the phase that fills the disk: a cold reactor's + // dependency:go-offline can consume the headroom the preflight + // passed, and the lifecycle must not run on the now-full disk. + statfsSyncMock + .mockReturnValueOnce({ bavail: 16 * 1024 ** 3, bsize: 1 }) + .mockReturnValueOnce({ bavail: 0, bsize: 1 }); + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/src/Main.java'], { + timeout: 60, + install: true, + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + // Only the warm-up ran; the lifecycle was skipped and disclosed. + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('dependency:go-offline'); + expect(report.ok).toBe(false); + expect(report.build).toEqual([]); + expect(report.test).toEqual([]); + expect(report.note).toContain('Insufficient disk space'); + expect(report.note).toContain('warm-up'); + }); + + it('attributes failures to the failing cases in declaration order', () => { + // Surefire writes passing cases self-closing, in execution order: a + // passing case must not absorb the following case's failure, and the + // real failing cases must be the ones reported. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '' + + '' + + '' + + '', + ); + return result(command, { exitCode: 1, output: '[ERROR] Tests failed' }); + }, + }); + + const output = report.test[0]?.output ?? ''; + expect(output).toContain('example.CoreTest#beta'); + expect(output).toContain('example.CoreTest#delta'); + expect(output).not.toContain('CoreTest#alpha'); + expect(output).not.toContain('CoreTest#gamma'); + }); + + it('keeps counts and identity when attribute values carry `>`', () => { + // A \`>\` is legal unescaped inside a quoted XML attribute value — + // parameterized-test and @DisplayName names carry them. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '', + ); + return result(command, { exitCode: 1, output: '[ERROR] Tests failed' }); + }, + }); + + expect(report.ok).toBe(false); + const output = report.test[0]?.output ?? ''; + expect(output).toContain('tests=1, failures=1, errors=0, skipped=0'); + expect(output).toContain('example.T#fails [x > y]'); + }); + + it('aggregates every suite in one report file', () => { + // Aggregate JUnit writers (jest-junit, karma) emit several + // elements per file; reading only the first undercounts later suites' + // failures to zero and discards the failing cases. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Aggregate.xml'), + '' + + '' + + '' + + '' + + '' + + '' + + '', + ); + return result(command); + }, + }); + + expect(report.ok).toBe(false); + const output = report.test[0]?.output ?? ''; + expect(output).toContain('tests=2, failures=1, errors=0, skipped=0'); + expect(output).toContain('example.Two#fails'); + expect(report.note).toContain('exited 0'); + }); + + it('ignores oversized report files rather than parsing them', () => { + // Evidence files are PR-controlled: the size cap keeps a multi-megabyte + // file from burning the outer deadline, at the cost of its evidence. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Huge.xml'), + '' + + ' { + // A fail-never setting (-fn/--fail-never) makes Maven exit 0 over a + // compilation failure; no Surefire XML exists for freshFailures to see. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-fn\n'); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '[ERROR] COMPILATION ERROR :\n' + + '[ERROR] /x/core/src/main/java/example/Main.java:[12,5] cannot find symbol', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.test[0]?.infrastructure).toBeUndefined(); + expect(report.note).toContain('exited 0'); + expect(report.note).toContain('fail-never'); + expect(report.note).not.toContain(NOTE_MAVEN_TEST_PASSED); + }); + + it('classifies a fail-never dependency failure as infrastructure', () => { + // The same masking at the dependency phase — unless the diff changed + // the resolution inputs — stays environmental like the exit-1 form. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.note).toContain('fail-never'); + }); + + it('keeps a fail-never dependency failure PR-attributed when the inputs changed', () => { + // The exit-0 half of the dependency carve-out exception: with resolution + // inputs changed, the swallowed failure stays a failed run — not green, + // and not laundered into an environmental result. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-fn\n'); + + const report = runAdapter(['core/pom.xml'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBeUndefined(); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.note).toContain('fail-never'); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('keeps Kotlin compile failures source-attributed beside dependency words', () => { + writeReactor(); + + const report = runAdapter(['core/src/Main.kt'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not transfer artifact org.example:lib:pom:1 from central: Connection timed out\n' + + '[ERROR] Failed to execute goal org.jetbrains.kotlin:kotlin-maven-plugin:1.9.0:compile (default-compile) on project core: Compilation failure\n' + + '[ERROR] /x/core/src/main/kotlin/example/Main.kt: (12, 5): Unresolved reference: foo', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + it('still builds the owning module for compilable files under a doc prefix', () => { + // The docs?/ prefix exempts documentation EXTENSIONS only: a .java file + // under doc/ is compilable input, not documentation. + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/README.md', 'core/doc/Helper.java'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.toolchain).toBe('maven'); + expect(report.affected).toEqual(['core']); + expect(calls).toEqual([ + 'mvn --batch-mode --no-transfer-progress -pl core -am test', + ]); + }); + + it('leaves module repository metadata without Maven targets', () => { + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/LICENSE', 'core/.gitignore'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(true); + expect(report.affected).toEqual([]); + expect(calls).toEqual([]); + }); + + it('treats settings referenced by .mvn/maven.config as dependency inputs', () => { + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-s\nsettings.xml\n'); + writeFileSync(join(root, 'settings.xml'), '\n'); + + const report = runAdapter(['settings.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('fails closed past the read cap for .mvn/maven.config', () => { + // The one PR-controlled read without a size cap: a config past the + // cap is treated like an unreadable one (its referenced locations + // unknown), while the config FILE itself stays a dependency input + // through the `.mvn/` prefix — so the suppression still stands. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + `-s\nsettings.xml ${'x'.repeat(2 * 1024 * 1024)}\n`, + ); + writeFileSync(join(root, 'settings.xml'), '\n'); + + // Oversized: the settings reference is unknown, so a dependency + // outage over a changed settings.xml keeps the infrastructure + // carve-out... + const oversized = runAdapter(['settings.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + expect(oversized.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // ...and under the cap the identical config suppresses it. + writeFileSync(join(root, '.mvn', 'maven.config'), '-s\nsettings.xml\n'); + const undersized = runAdapter(['settings.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + expect(undersized.note).toContain(NOTE_CORRELATE_ERRORS); + expect(undersized.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('leaves no Maven target when only the other platform wrapper changed', () => { + // POSIX executes ./mvnw, win32 mvnw.cmd; a change confined to the other + // platform's wrapper cannot affect this platform's run, so no reactor-wide + // run burns the deadline verifying nothing. + writeReactor(); + const win32 = process.platform === 'win32'; + const executed = win32 ? 'mvnw.cmd' : 'mvnw'; + const other = win32 ? 'mvnw' : 'mvnw.cmd'; + writeFileSync(join(root, executed), win32 ? '@echo off\n' : '#!/bin/sh\n'); + if (!win32) chmodSync(join(root, executed), 0o755); + writeFileSync(join(root, other), win32 ? '#!/bin/sh\n' : '@echo off\n'); + const calls: string[] = []; + + const report = runAdapter([other], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toEqual([]); + expect(report.ok).toBe(true); + expect(report.note).toContain('no Maven target'); + + // The carve-out itself still holds in its reachable shape: when the diff + // ALSO changes module sources, the other platform's wrapper is not a + // resolution input and cannot suppress the dependency carve-out. + const mixed = runAdapter([other, 'core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + expect(mixed.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(mixed.note).toContain('wrapper change itself was not exercised'); + }); + + it('clamps negative report counts to zero', () => { + // A malformed failures="-3" must not cancel legitimate counts when + // totals roll up across reports. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '', + ); + return result(command); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.output).toContain( + '[maven-test-report] core (1 report(s)): tests=1, failures=0, errors=0, skipped=0', + ); + }); + + it('runs a dependency warm-up with its own deadline when installing', () => { + // A review worktree is cold by construction; without the warm-up the + // cold resolve shares the single lifecycle deadline with compilation + // and the tests. The warm-up runs first, narrowed to the same scope, + // and its result is recorded as the report's install. + writeReactor(); + const calls: Array<[string, number]> = []; + + const report = mavenToolchainAdapter.run({ + root, + changedFiles: ['core/src/main/java/example/Core.java'], + timeout: 9, + // A budget above the two 9s deadlines keeps them whole; the budget + // regime itself is pinned by the tests below. + budget: 600, + install: true, + exec: (command, _cwd, timeoutMs) => { + calls.push([command, timeoutMs]); + return result(command); + }, + }); + + expect(calls).toEqual([ + [ + 'mvn --batch-mode --no-transfer-progress -pl core -am dependency:go-offline -q', + 9_000, + ], + ['mvn --batch-mode --no-transfer-progress -pl core -am test', 9_000], + ]); + expect(report.install?.command).toContain('dependency:go-offline'); + expect(report.ok).toBe(true); + expect(report.note).not.toContain('Dependency warm-up'); + }); + + it('keeps module attribution for failing projects past the rollup cap', () => { + // The omitted-failing-rollup marker used to zero the failure counts of + // every project past the cap; when the case-line cap ALSO dropped the + // claimed module's lines, both attribution channels went dark and the + // `-am` carve-out discarded a run that failed inside the claim. Each + // omitted project keeps one module-prefixed failure marker. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + for (let i = 0; i < 101; i++) { + const module = `m${String(i).padStart(3, '0')}`; + const dir = join(root, module, 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Fail.xml'), + '' + + `` + + '', + ); + } + return result(command, { exitCode: 1 }); + }, + }); + + const output = report.test[0]?.output ?? ''; + // m100 sorts last in byte order and sits past the 100-project rollup + // cap — its failure attribution survives as a module-prefixed marker. + expect(output).toContain('[maven-test-failure] m100/target/'); + expect(output).toContain('1 more failing project rollup(s) omitted'); + expect(report.ok).toBe(false); + }, 30_000); + + it('keeps the lifecycle verdict when the warm-up fails or times out', () => { + // The warm-up is best-effort: a partial local repository is + // content-addressed and resumable (unlike a partial node_modules), so + // no warm-up outcome may block the lifecycle run or change its verdict. + writeReactor(); + + const timedOut = mavenToolchainAdapter.run({ + root, + changedFiles: ['core/src/Main.java'], + timeout: 5, + // Keep both 5s deadlines whole despite the warm-up's wall time. + budget: 600, + install: true, + exec: (command, _cwd, timeoutMs) => + command.includes('dependency:go-offline') + ? result(command, { + exitCode: null, + timedOut: true, + seconds: 5, + deadlineMs: timeoutMs, + }) + : result(command), + }); + expect(timedOut.ok).toBe(true); + expect(timedOut.test).toHaveLength(1); + // The report-level list names EVERY command killed by its deadline — + // the warm-up too, like the npm adapter's install command. + expect( + timedOut.timedOut.some((c) => c.includes('dependency:go-offline')), + ).toBe(true); + expect(timedOut.note).toContain('Dependency warm-up'); + expect(timedOut.note).toContain('ran out of time (5s)'); + + const failed = runAdapter(['core/src/Main.java'], { + budget: 600, + install: true, + exec: (command) => + command.includes('dependency:go-offline') + ? result(command, { exitCode: 1 }) + : result(command), + }); + expect(failed.ok).toBe(true); + expect(failed.note).toContain('Dependency warm-up'); + expect(failed.note).toContain('exited 1'); + + // A spawn death without a deadline is its own arm: the note must not + // read "ran out of time" for a warm-up the deadline never touched. + const spawnDied = runAdapter(['core/src/Main.java'], { + budget: 600, + install: true, + exec: (command) => + command.includes('dependency:go-offline') + ? result(command, { exitCode: null }) + : result(command), + }); + expect(spawnDied.ok).toBe(true); + expect(spawnDied.note).toContain('Dependency warm-up'); + expect(spawnDied.note).toContain('ended without an exit code'); + }); + + it('widens to the full reactor when the -pl selector exceeds the launch-safe length', () => { + // A wide diff selects many modules at once; on large reactors the + // comma-joined selector approaches cmd.exe's 8191-character line limit, + // so past the cap the run widens to the full reactor instead of shipping + // a command line the platform may refuse to launch. + const leaves = Array.from( + { length: 100 }, + (_, i) => + `module-with-a-rather-long-directory-name-${String(i).padStart(2, '0')}`, + ); + writeProject('.', ['agg']); + writeProject('agg', leaves); + for (const leaf of leaves) writeProject(`agg/${leaf}`); + const calls: string[] = []; + + const report = mavenToolchainAdapter.run({ + root, + // Source files, not POMs: a POM change is reactor-wide on its own, and + // this case must reach the selector-length guard instead. + changedFiles: leaves.map((leaf) => `agg/${leaf}/src/main/java/Main.java`), + timeout: 5, + install: false, + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.affected).toEqual(['.']); + expect(calls).toEqual(['mvn --batch-mode --no-transfer-progress test']); + expect(report.note).toContain('selector exceeded 4096 characters'); + expect(report.note).toContain('full reactor'); + }); + + it('does not launder a test-printed launch diagnostic into infrastructure', () => { + // Unframed launch words count only in the prelude before Maven's own + // output starts: once a Maven-framed line has appeared, a test printing + // `mvn: command not found` in its stdout must not mask a source failure. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: [ + '[INFO] Scanning for projects...', + '[INFO] --- surefire:test ---', + 'sh: 1: mvn: not found', + ].join('\n'), + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + it('treats .mvn/wrapper configuration as part of the wrapper', () => { + // maven-wrapper.properties names the distribution ./mvnw downloads and + // executes; a diff touching it controls what the wrapper runs exactly + // as one touching the script does, so the startup failure is the + // diff's to answer for, not the environment's. + writeReactor(); + writeExecutedWrapper(); + + const report = runAdapter(['.mvn/wrapper/maven-wrapper.properties'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + 'Error: The JAVA_HOME environment variable is not defined correctly', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + it('builds the owning module for resource-like text files outside doc locations', () => { + // A .txt is only exempted at doc-shaped locations: a resource wired + // into the artifact via maven-resources-plugin (which points at + // arbitrary dirs) must keep the build instead of silently skipping it. + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/config/messages.txt'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.affected).toEqual(['core']); + expect(calls).toEqual([ + 'mvn --batch-mode --no-transfer-progress -pl core -am test', + ]); + }); + + it('still exempts doc-extension files at the module top level and in site/', () => { + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/notes.txt', 'core/site/index.rst'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(report.ok).toBe(true); + expect(report.affected).toEqual([]); + expect(calls).toEqual([]); + }); + + it('does not parse CDATA-wrapped report content as markup', () => { + // `` CDATA is the standard vehicle for test output that + // itself contains XML; scanning it as markup fabricated phantom suites + // and failure evidence for a passing one-test suite. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '' + + '' + + ']]>' + + '', + ); + return result(command); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.output).toContain('tests=1, failures=0'); + expect(report.test[0]?.output).not.toContain('[maven-test-failure]'); + }); + + it('files a resolution failure against a diff that deleted a POM', () => { + // `legacy/pom.xml` exists only in the diff — the shape a deletion leaves. + // Deleting a POM is one of the likeliest ways a diff breaks resolution + // (`Non-resolvable parent POM`, a module Maven can no longer read), and + // deciding WHICH deleted POMs could have caused THIS failure needs the + // effective model this adapter deliberately does not carry. So any + // changed POM withdraws the infrastructure carve-out: over-attributing + // costs a visible failure carrying Maven's own output, while + // under-attributing ships the diff's own breakage as someone else's + // outage. + writeReactor(); + + const report = runAdapter(['core/src/Main.java', 'legacy/pom.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + it('parses a report of unterminated openers in linear time', () => { + // The quadratic pre-fix regex scan spent seconds per 256 KiB of + // never-closed ` { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '' + + '` never comes: the interrupted + // header walk discards every later body, so the report is rejected and + // the run fails closed instead of reading the surviving prefix green. + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }, 20_000); + + it('parses every fresh report — failures ordered past the old cap are still evidence', () => { + // The count cap this test once disclosed was itself the leak: a failing + // report ordered past the cap stayed unread while the parsed prefix read + // clean, certifying green over a failed run. The cap is gone — EVERY + // fresh report is parsed (the sweep's path cap and the per-file size cap + // still bound the cost), so the failure is evidence wherever it sorts. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + for (let i = 0; i < 1000; i++) { + writeFileSync( + join(dir, `TEST-Case${String(i).padStart(4, '0')}.xml`), + '' + + `` + + '', + ); + } + // Sorts AFTER every TEST-Case… report — exactly where the old cap + // stopped reading. + for (let i = 0; i < 5; i++) { + writeFileSync( + join(dir, `TEST-ZFailed${i}.xml`), + '' + + `` + + '', + ); + } + return result(command); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBeUndefined(); + expect(report.test[0]?.output).toContain( + '[maven-test-failure] core/target/surefire-reports/TEST-ZFailed0.xml: example.ZFailed0#fails', + ); + }, 30_000); + + it('caps the failing cases one report accumulates, and counts the drop', () => { + // One report can carry tens of thousands of failing `` + // entries; the parse caps them while building, and the omission + // marker accounts for the drop instead of silently losing it. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + const cases = Array.from( + { length: 250 }, + (_, i) => + ``, + ).join(''); + writeFileSync( + join(dir, 'TEST-Bulk.xml'), + `${cases}`, + ); + return result(command); + }, + }); + + expect(report.ok).toBe(false); + const output = report.test[0]?.output ?? ''; + expect(output).toContain('tests=250, failures=250'); + expect(output).toContain('50 more failing case(s) omitted'); + // The kept case lines stop at the display cap. + expect(output.match(/\[maven-test-failure\] core\/target/g)?.length).toBe( + 200, + ); + }, 30_000); + + it('parses a suite header of unpaired attribute-name runs in linear time', () => { + // A million-character attribute-name run with no `=` is malformed XML; + // the strict parser rejects it in one linear pass (the hand-rolled + // attribute regex backtracked quadratically here). Fail-closed AND + // bounded-time: the adversarial report reads as unknown evidence, never + // green, never a hang. + writeReactor(); + const startedAt = Date.now(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + `', + ); + return result(command); + }, + }); + + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }, 20_000); + it('walks stacked openers-before-closers reports in linear time', () => { + // Every opener preceding every closer used to re-find the same early + // closing tag for each later opener — quadratic inside the 2 MiB cap, + // and still reporting ok:true while burning the outer deadline. Bodies + // are consumed forward-only now, so the walk stays O(n). + writeReactor(); + const startedAt = Date.now(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + ''.repeat(30_000) + + ''.repeat(30_000) + + '', + ); + return result(command); + }, + }); + + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(report.ok).toBe(true); + }, 20_000); + + it('treats attached -s settings as dependency inputs too', () => { + // commons-cli accepts the attached short form (`-sci/settings.xml`); + // missing it laundered a PR-caused resolution break into + // infrastructure. + writeReactor(); + mkdirSync(join(root, '.mvn')); + mkdirSync(join(root, 'ci')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-sci/settings.xml\n'); + writeFileSync(join(root, 'ci', 'settings.xml'), '\n'); + + const report = runAdapter(['ci/settings.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('quotes the deadline the lifecycle actually ran under in its timeout note', () => { + // The warm-up spends shared budget first, so the lifecycle fires a + // shorter deadline than the --timeout flag; the note must quote the + // number that fired, not the flag default. + writeReactor(); + let clock = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock); + + try { + const report = runAdapter(['core/src/Main.java'], { + timeout: 300, + budget: 60, + install: true, + exec: (command, _cwd, timeoutMs) => { + clock += 45_000; + return command.includes('dependency:go-offline') + ? result(command, { deadlineMs: timeoutMs }) + : result(command, { + exitCode: null, + timedOut: true, + seconds: 15, + deadlineMs: timeoutMs, + }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.note).toContain('ran out of time (15s)'); + expect(report.note).not.toContain('ran out of time (300s)'); + } finally { + nowSpy.mockRestore(); + } + }); + + it('spends the whole-call budget across warm-up and lifecycle', () => { + // The warm-up and the lifecycle command share one budget: each gets + // the smaller of its own deadline and what remains, and --budget + // shortens the sum (both execs would otherwise take the full 300s). + writeReactor(); + let clock = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock); + const calls: Array<[string, number]> = []; + + try { + const report = runAdapter(['core/src/Main.java'], { + timeout: 300, + budget: 60, + install: true, + exec: (command, _cwd, timeoutMs) => { + calls.push([command, timeoutMs]); + clock += 40_000; + return result(command); + }, + }); + + expect(calls).toEqual([ + [ + 'mvn --batch-mode --no-transfer-progress -pl core -am dependency:go-offline -q', + 60_000, + ], + ['mvn --batch-mode --no-transfer-progress -pl core -am test', 20_000], + ]); + expect(report.ok).toBe(true); + } finally { + nowSpy.mockRestore(); + } + }); + + it('discloses instead of attempting a lifecycle below the attempt floor', () => { + // A warm-up that spends the budget leaves less than the 15s floor for + // the lifecycle: an "attempt" would manufacture a fake timeout, so the + // run discloses that nothing could be built or tested. + writeReactor(); + let clock = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock); + const calls: string[] = []; + + try { + const report = runAdapter(['core/src/Main.java'], { + timeout: 300, + budget: 60, + install: true, + exec: (command, _cwd, timeoutMs) => { + calls.push(command); + clock += 50_000; + // A cold reactor's warm-up really does eat the budget by timing + // out; the disclosure must survive the budget early-return. The + // recorded deadline mirrors the production exec: the note must + // quote the 60s that fired, not the 300s flag default. + return command.includes('dependency:go-offline') + ? result(command, { + exitCode: null, + timedOut: true, + seconds: 50, + deadlineMs: timeoutMs, + }) + : result(command); + }, + }); + + expect(calls).toEqual([ + 'mvn --batch-mode --no-transfer-progress -pl core -am dependency:go-offline -q', + ]); + expect(report.ok).toBe(false); + expect(report.test).toEqual([]); + expect(report.install?.command).toContain('dependency:go-offline'); + expect(report.note).toContain('whole-call budget (60s) was spent'); + expect(report.note).toContain('informational'); + expect(report.note).toContain('ran out of time (60s)'); + } finally { + nowSpy.mockRestore(); + } + }); + + it('runs nothing when the budget is below the attempt floor from the start', () => { + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/src/Main.java'], { + timeout: 300, + budget: 5, + install: true, + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toEqual([]); + expect(report.ok).toBe(false); + expect(report.install).toBeNull(); + // Nothing ever ran, so the note must not claim the budget "was + // spent" — it names the floor the grant fell short of instead. + expect(report.note).toContain('granted budget (5s) is below the'); + expect(report.note).toContain('15s minimum'); + expect(report.note).not.toContain('was spent'); + }); + + it('applies the attempt floor to a --no-install run with no warm-up', () => { + // The disclosure check runs unconditionally after the warm-up block: a + // lifecycle-only run below the floor must not execute a sub-floor + // deadline and record the manufactured timeout as a real run. + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['core/src/Main.java'], { + timeout: 300, + budget: 5, + install: false, + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toEqual([]); + expect(report.install).toBeNull(); + expect(report.ok).toBe(false); + expect(report.note).toContain('granted budget (5s) is below the'); + expect(report.note).toContain('15s minimum'); + }); + + it.each([ + ['[ERROR] Non-resolvable import POM for example:bom:1 at line 42'], + ['[ERROR] Failure to find example:core:jar:1 in central was cached'], + ['[ERROR] Could not find artifact example:core:jar:1 in central'], + ['[ERROR] Failed to collect dependencies at example:core:jar:1'], + ['[ERROR] Failed to read artifact descriptor for example:core:jar:1'], + ['[ERROR] Non-resolvable parent POM for example:core:1'], + [ + '[ERROR] org.apache.maven.plugin.dependency.PluginResolutionException: boom', + ], + [ + '[ERROR] org.eclipse.aether.resolution.DependencyResolutionException: boom', + ], + ["[ERROR] No plugin found for prefix 'jetty'"], + ['[ERROR] Unknown host: repo.maven.apache.org'], + ['[ERROR] Name or service not known'], + ['[ERROR] Temporary failure in name resolution'], + ['[ERROR] Connection reset'], + [ + '[ERROR] PKIX path building failed: unable to find valid certification path', + ], + ['[ERROR] status code: 401, reason phrase: Unauthorized'], + ['[ERROR] status code: 403, reason phrase: Forbidden'], + ['[ERROR] status code: 407, reason phrase: Proxy Authentication Required'], + ['[ERROR] status code: 429, reason phrase: Too Many Requests'], + ['[ERROR] status code: 503, reason phrase: Service Unavailable'], + ])('classifies %s as a dependency failure', (line) => { + expect(isDependencyFailureLine(line)).toBe(true); + }); + + it('falls back to mvn for an EMPTY executable wrapper', () => { + // An empty ./mvnw passes the existence/exec-bit gates and exits 0 over + // a build that never started — the run would read green. + writeProject('.'); + writeFileSync(join(root, 'mvnw'), ''); + chmodSync(join(root, 'mvnw'), 0o755); + expect(mavenExecutable(root, 'linux')).toBe('mvn'); + + writeFileSync(join(root, 'mvnw'), '#!/bin/sh\n'); + expect(mavenExecutable(root, 'linux')).toBe('./mvnw'); + + // The win32 branch carries the same size gate on `mvnw.cmd`. + writeFileSync(join(root, 'mvnw.cmd'), ''); + expect(mavenExecutable(root, 'win32')).toBe('mvn'); + writeFileSync(join(root, 'mvnw.cmd'), '@echo off\n'); + expect(mavenExecutable(root, 'win32')).toBe('mvnw.cmd'); + }); + + it('reads a fail-never plugin goal failure as a swallowed failure', () => { + // Under fail-never Maven exits 0 over ANY failed goal, and only the + // compile/dependency/launch classes were recognized before: a + // checkstyle goal failure read green. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-fn\n'); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '[INFO] BUILD SUCCESS\n' + + '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-checkstyle-plugin:3.3.1:check (validate) on project core: You have 1 Checkstyle violation.', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.note).toContain('fail-never'); + }); + + it('keeps a swallowed dependency goal failure infrastructure', () => { + // `Failed to execute goal on project …` matches the goal framing too; + // a dependency-class death must keep its acquisition carve-out. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '[ERROR] Failed to execute goal on project core: Could not resolve dependencies for project example:core:jar:1', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('does not read forged framing as swallowed failure over green fresh reports', () => { + // Surefire echoes test stdout into the build output verbatim, so a + // fully green run whose test PRINTS an `[ERROR] Failed to execute goal` + // line used to flip swallowedFailure and read the run as failing. With + // green fresh reports and no fail-never setting, framed lines cannot be + // Maven's own — Maven prints no `[ERROR]` on success. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: + 'some test printed:\n' + + '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-checkstyle-plugin:3.3.1:check on project fixture: boom\n' + + '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.swallowedFailure).toBeUndefined(); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + it('keeps fail-never swallowed failures failing beside green fresh reports', () => { + // The one setting that lets framed `[ERROR]` failures coexist with a + // green exit AND green reports: a multi-module run where an upstream + // module tested green and a later module's goal failure was swallowed. + // Detectable from `.mvn/maven.config`, so the defense stays off there. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-fn\n'); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: + '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-checkstyle-plugin:3.3.1:check on project extension: boom\n' + + '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + }); + + it('folds rescue overflow into evidenceCapped', () => { + // The trim's rescue cap can drop failure-evidence lines before the + // adapter classifies the output: the same epistemic state as the + // fresh-report gaps — refuse to certify, never read green. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, rescueOverflow: true }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + expect(report.note).toContain('rescue cap'); + }); + + it('classifies a localized system-mvn launch death as infrastructure', () => { + // The shape arm covered only `./mvnw`; a system `mvn` launch death was + // classified exclusively by the English-only wording regexes — under a + // non-English LANG the environmental absence read as a source failure. + // No wrapper in this fixture: the platform falls back to system `mvn`. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 127, + output: 'mvn : commande introuvable', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('discloses the unscopable npm half of a mixed root', () => { + // npm's gate refused this root package.json (an unmodeled glob), so + // Maven was selected ALONE — the green run must not certify files no + // Maven module owns. + writeReactor(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'frontend', workspaces: ['packages/**'] }), + ); + + const report = runAdapter(['core/src/Main.java']); + + expect(report.ok).toBe(true); + expect(report.note).toContain('files outside the Maven reactor'); + expect(report.note).toContain('were NOT verified'); + }); + + it('treats a changed path outside the worktree as unowned, never as reactor evidence', () => { + // The `../outside` escape the sandbox nesting above exists to contain: + // an out-of-worktree path must not reach owningProject, whose upward + // walk could find a pom.xml ABOVE the worktree and emit an + // out-of-worktree `-pl` selector. + writeReactor(); + writeProject('../outside'); + + expect( + detectMavenOwnership(root, ['../outside/src/main/java/Main.java']), + ).toEqual({ reactorWide: false, modules: [] }); + expect( + detectMavenOwnership(root, [ + 'core/../../outside/src/main/java/Main.java', + ]), + ).toEqual({ reactorWide: false, modules: [] }); + }); + + it('fails closed to reactor-wide for a real module nested under a src/ path', () => { + // The positive control for the root-collapse guard: a reactor can + // aggregate `src/core`, and `-pl .` would compile only + // the root — the changed module untested under a green verdict. + writeProject('.', ['src/core']); + writeProject('src/core'); + const calls: string[] = []; + + const report = runAdapter(['src/core/src/main/java/Foo.java'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toEqual(['mvn --batch-mode --no-transfer-progress test']); + expect(report.affected).toEqual(['.']); + }); + + it('reads a skip-tests setting as a swallowed failure, never a pass', () => { + // `-DskipTests` exits 0 having run ZERO tests, and Surefire's skip path + // emits no framed error and no XML — without the marker check the run + // was certified green over nothing. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: '[INFO] Tests are skipped.\n[INFO] BUILD SUCCESS', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.note).toContain('Tests are skipped.'); + expect(report.note).toContain('nothing was tested'); + }); + + it('classifies a wrapper distribution-download failure as infrastructure', () => { + // The canonical cold-worktree acquisition failure: the download dies + // before Maven's JVM starts, so the diagnostics are unframed and exit 1 + // — not the 126/127 wrapper-launch shapes. + writeReactor(); + writeWrapper(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + "wget: unable to resolve host address 'repo.maven.apache.org'\n", + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('classifies colored Maven output exactly like plain output', () => { + // `-Dstyle.color=always` interleaves SGR codes before the framed tokens; + // every classification predicate anchors on the framing, so the strip + // must happen before classification — colored bytes once laundered a + // failed compile under fail-never into a green verdict. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-fn\n'); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '\x1b[1;31m[ERROR]\x1b[m COMPILATION ERROR\n' + + '\x1b[1;31m[ERROR]\x1b[m /repo/core/src/Main.java:[3,5] cannot find symbol\n' + + '\x1b[1;32m[INFO]\x1b[m BUILD SUCCESS', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.note).toContain('fail-never'); + }); + + it('fails closed when the report sweep is truncated by a wide fan-out', () => { + // One directory holding more than MAX_DIR_ENTRIES entries used to cost + // an unbounded Dirent array AND truncated silently — a fresh failing + // report beyond the truncation point would read green. + writeReactor(); + const wide = join(root, 'wide'); + mkdirSync(wide); + for (let i = 0; i < 10_001; i++) { + writeFileSync(join(wide, `f${i}`), ''); + } + + const report = runAdapter(['core/src/Main.java']); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + expect(report.note).toContain('not certified as a pass'); + expect(report.test[0]?.output).toContain('the report sweep was truncated'); + }, 30_000); + + it('does not flag a directory read exactly to the entry cap as truncated', () => { + // The cap check used to run BEFORE the read, so a directory holding + // exactly MAX_DIR_ENTRIES entries — read to exhaustion — still flagged + // truncated, and the flag's propagation to evidenceCapped refused + // certification of a fully-green, fully-read run. + writeReactor(); + const wide = join(root, 'wide'); + mkdirSync(wide); + for (let i = 0; i < 10_000; i++) { + writeFileSync(join(wide, `f${i}`), ''); + } + + expect(reportPaths(root)).toEqual({ paths: [], truncated: false }); + }, 30_000); + + it('fails closed when a fresh report is too large to parse', () => { + // A masked exit 0 over one oversized failing report: the size cap + // rejects the parse, and the rejection must count as unknown evidence — + // not fail open where the count cap fails closed. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Big.xml'), + ``, + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + expect(report.test[0]?.output).toContain('could not be parsed'); + }); + + it('never follows a symlinked report directory out of the worktree', () => { + // The queue descent skips symlinks via Dirent.isDirectory(), but the + // direct report-dir listing resolves them: a symlinked surefire-reports + // once injected outside stale reports as fresh evidence. + writeReactor(); + const outside = join(sandbox, 'outside-reports'); + mkdirSync(outside); + writeFileSync( + join(outside, 'TEST-Stale.xml'), + '', + ); + mkdirSync(join(root, 'core', 'target'), { recursive: true }); + symlinkSync(outside, join(root, 'core', 'target', 'surefire-reports')); + + const report = runAdapter(['core/src/Main.java']); + + expect(report.ok).toBe(true); + expect(report.test[0]?.output).not.toContain('TEST-Stale.xml'); + }); + + it('never collects a symlinked report FILE pointing outside the worktree', () => { + // The directory-symlink twin's file-level counterpart: a `*.xml` entry + // inside a REAL report dir that is itself a symlink is excluded only by + // the Dirent.isFile() conjunct — statSync freshness then follows the + // link, so an outside file a run never produced would otherwise read as + // fresh evidence (green or failing direction alike). + writeReactor(); + const outsideFile = join(sandbox, 'outside-TEST-Forged.xml'); + writeFileSync( + outsideFile, + '', + ); + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + symlinkSync(outsideFile, join(dir, 'TEST-Linked.xml')); + + const report = runAdapter(['core/src/Main.java']); + + expect(report.test[0]?.output).not.toContain('TEST-Linked.xml'); + expect(report.test[0]?.output).not.toContain('forged'); + }); + + it('ignores a commented-out testsuite in a fresh report', () => { + // The twin of the CDATA case: aggregate writers emit commented-out + // markup, and scanning it fabricated phantom failure evidence. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Real.xml'), + '\n' + + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.output).not.toContain('Ghost'); + expect(report.test[0]?.output).not.toContain('[maven-test-failure]'); + }); + + it('emits a fallback failure line for a report with failures but no case bodies', () => { + // The invariant test-plan's guards key on: failures>0 ⇒ at least one + // [maven-test-failure] line. A header-only failing report emitted none. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-HeaderOnly.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.output).toContain( + '[maven-test-failure] core/target/surefire-reports/TEST-HeaderOnly.xml: ' + + '1 failure(s), 0 error(s) recorded without case detail', + ); + }); + + it('treats -Dmaven.repo.local locations referenced by .mvn/maven.config as dependency inputs', () => { + // The twin of the settings-inputs case: the launcher injects the + // property into the very command the adapter runs, so a changed local + // repository location must suppress the infrastructure carve-out. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-Dmaven.repo.local=local-repo\n', + ); + mkdirSync(join(root, 'local-repo')); + + const report = runAdapter(['local-repo/corrupt.jar'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('treats every -Dmaven.repo.local.tail entry as a dependency input', () => { + // Maven 3.9's chained local repositories: EVERY entry is a resolution + // location the PR can change. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-Dmaven.repo.local.tail=repo-a,repo-b\n', + ); + mkdirSync(join(root, 'repo-a')); + mkdirSync(join(root, 'repo-b')); + + const report = runAdapter(['repo-b/corrupt.jar'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('fails closed to reactor-wide for a real module nested under a non-root aggregator src/ path', () => { + // The R1-1 positive control beyond the root: `agg` aggregates + // `src/core`; collapsing to `agg` would run + // `-pl agg -am`, and `-am` adds only UPSTREAM projects — the changed + // module would never compile or test under a green verdict. + writeProject('.', ['agg']); + writeProject('agg', ['src/core']); + writeProject('agg/src/core'); + const calls: string[] = []; + + const report = runAdapter(['agg/src/core/src/main/java/Foo.java'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toEqual(['mvn --batch-mode --no-transfer-progress test']); + expect(report.affected).toEqual(['.']); + }); + + it('still scopes to the owning module when only test-data-shape fixtures sit under its src/', () => { + // `src/test/` and `src/it/` are the principled fixture shapes: the skip + // there must not cost the module a reactor-wide run. + writeReactor(); + writeProject('core/src/it/projects/sample'); + const calls: string[] = []; + + runAdapter( + ['core/src/main/java/Core.java', 'core/src/it/projects/sample/App.java'], + { + exec: (command) => { + calls.push(command); + return result(command); + }, + }, + ); + + expect(calls).toEqual([ + 'mvn --batch-mode --no-transfer-progress -pl core -am test', + ]); + }); + + it('cross-checks surefire stdout summaries against a relocated report directory', () => { + // Reports written to a non-default `` sit outside the + // sweep; the framed `Tests run:` summary Surefire prints even under + // testFailureIgnore is the cross-check that keeps the run green no + // more. A FAILING summary is `[ERROR]`-framed on real Maven (verified + // 3.8.7 / surefire 3.2.5) — the `[INFO]` twin stays covered because + // the regex admits both. + writeReactor(); + for (const framing of ['[ERROR]', '[INFO]']) { + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + `${framing} Tests run: 5, Failures: 2, Errors: 0, Skipped: 0\n` + + '[INFO] BUILD SUCCESS', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.test[0]?.infrastructure).toBeUndefined(); + expect(report.note).toContain('testFailureIgnore'); + } + }); + + // chmod is the only lever this case has: a root user reads through it, + // so the branch under test is unreachable there — the repo convention. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'fails closed when a reports directory is unreadable', + () => { + // An unreadable directory is the same epistemic state as the caps: the + // sweep did not see everything. chmod 000 is within what a PR's own + // test/shutdown hook can do — the threat model this file grants. + writeReactor(); + const dir = join(root, 'core', 'target', 'surefire-reports'); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + chmodSync(dir, 0o000); + return result(command); + }, + }); + + // Restore so the sandbox cleanup can remove the tree. + chmodSync(dir, 0o755); + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }, + ); + + it('rejects a report whose comment swallows a later failing suite', () => { + // A raw `` + // sits inside a LATER failing suite: honoring it swallowed the failing + // header. The comment interior closes elements still open where it + // started — the swallowing shape — so the report joins the parser's + // fail-closed rejections instead of reading green. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + 'before ', + ); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }); + + it('keeps CDATA-wrapped system-out with XML samples parseable', () => { + // Surefire's own writer wraps test stdout in CDATA immediately after + // the `` open tag, and that stdout routinely contains XML + // samples closing the very elements open around the section — that one + // shape stays exempt from the swallowing probe the twin below applies. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + ' sample]]>' + + '', + ); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.evidenceCapped).toBeUndefined(); + }); + + it('rejects a report whose CDATA swallows a later failing suite', () => { + // The CDATA twin of the comment swallow: a raw `` text — with OTHER content before it, not the tight + // surefire shape — whose `]]>` sits inside a LATER failing suite + // deletes that suite's evidence. The interior-close probe applies to + // CDATA too, so the report joins the parser's fail-closed rejections + // instead of reading green. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + 'before ' + + '' + + '' + + ' after ]]>', + ); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }); + + it('reads a CDATA-wrapped markup sample as opaque text, never as evidence', () => { + // The strict parser treats CDATA as character data: the suite, case, and + // `` text inside the section are NOT markup, so they cannot + // forge a verdict in either direction. The hand-rolled scanner needed a + // swallowing-shape probe here; the parser makes the whole class + // structural — the phantom suite reads as text and the run certifies on + // the real suite alone. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '' + + '' + + 'boom' + + ']]>' + + '', + ); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.evidenceCapped).toBeUndefined(); + expect(report.test[0]?.output).not.toContain('[maven-test-failure]'); + }); + + it('rejects a report holding an unterminated CDATA section', () => { + // Kept verbatim, the opaque text would be scanned as markup by the + // body walk: a planted `` inside it cuts the case body + // before its `` evidence and the failing report parses + // green. An unterminated section therefore joins the parser's + // fail-closed rejections instead of staying in the scan. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '' + + 'boom' + + '', + ); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }); + + it('reads failing case bodies as failures when the header is zeroed', () => { + // A rewritten report: `failures="0" errors="0"` attributes over a live + // `` body. The parsed proof of failure is authoritative — the + // green-wash this adapter's threat model exists to catch. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '', + ); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.output).toContain( + '[maven-test-failure] core/target/surefire-reports/TEST-Core.xml: example.T#fails', + ); + }); + + it('does not cut a testcase body on a close-tag-shaped attribute value', () => { + // A well-formed writer escapes the value (`</testcase>`); the + // parser then reads it as content, not markup, and the `` + // after it still belongs to the case. The raw unescaped spelling is + // malformed XML and joins the fail-closed rejections (pinned by the + // malformed-report tests) — either way a failing case cannot parse + // away into a green read. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '' + + 'boom' + + '', + ); + return result(command); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.output).toContain( + '[maven-test-failure] core/target/surefire-reports/TEST-Core.xml: example.CoreTest#t', + ); + }); + + it('rejects a report nesting a testcase inside a quoted attribute', () => { + // A `` body lands in no body the evidence floor scans, reading + // the failing case away. The raw-opener count sees the hidden header + // and rejects the report like the other unreadable shapes. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + 'boom\'>' + + '', + ); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }); + + it('refuses a report whose last testcase never closes', () => { + // A file truncated mid-case has no closing tag to attribute a body to; + // returning the partially-parsed prefix read the recorded failure body + // away into a green verdict. Fail closed like the interrupted header + // walk — the rejection counts as unknown evidence. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + 'boom' + + '', + ); + return result(command); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + expect(report.note).toContain('failure status is unknown'); + }); + + it('renders an errors-only report rollup with the errors folded into failures', () => { + // Every rollup emitter hardcodes errors=0 and folds errors into + // failures=; pin the real emitter path end-to-end. The fixture carries + // its error ONLY in the header (no failing case body): dropping + // `summary.errors` from failedCount would render failures=0 here while + // every other assertion stayed green, and the rollup would read clean. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command); + }, + }); + + expect(report.test[0]?.output).toContain( + '[maven-test-report] core (1 failing report(s)): tests=1, failures=1, errors=0, skipped=0', + ); + expect(report.ok).toBe(false); + }); + + it('treats a fully-read zero-suite report as known-empty, not unknown', () => { + // A small suite-less XML a PR's own tests write (failsafe-summary.xml is + // the same shape) contributes no evidence and no gap — it must not hold + // the whole run uncertified. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'failsafe-summary.xml'), ''); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.evidenceCapped).toBeUndefined(); + }); + + it('classifies a wrapper SHA-256 validation failure as infrastructure', () => { + // apache/maven-wrapper prints this verbatim on a checksum mismatch; the + // pinning fixture uses the wording a real wrapper emits. + writeReactor(); + writeWrapper(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + 'Error: Failed to validate Maven distribution SHA-256, ' + + 'your Maven distribution might be compromised.', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + }); + + it('classifies a silent bootstrap download death as infrastructure', () => { + // Both wrapper generations try wget before curl, and the distribution + // download runs it quiet: a DNS failure dies exit 4 with EMPTY output — + // no wording to match. The curl fallback dies the same way on its own + // codes (resolve, connect, HTTP error, timeout). The absence of any + // Maven-framed line pins the death to bootstrap. + writeReactor(); + writeExecutedWrapper(); + + for (const exitCode of [4, 6, 7, 8, 22, 28]) { + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + } + }); + + it('classifies the curl bootstrap die message as infrastructure', () => { + // Hosts without wget fall back to `curl --silent`, which suppresses + // curl's own `curl: (N)` line; the wrapper's die wording is all the + // output the death leaves. + writeReactor(); + writeWrapper(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + 'curl: Failed to fetch https://archive.apache.org/dist/maven/' + + 'maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.zip', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + }); + + it('classifies the wrapper-jar SHA-256 wording as infrastructure', () => { + // The jar-mode wrapper names the WRAPPER where the distribution mode + // names the distribution; both are checksum verdicts this run's + // launcher printed before Maven existed. + writeReactor(); + writeWrapper(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + 'Error: Failed to validate Maven wrapper SHA-256, your Maven ' + + 'wrapper might be compromised.', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + }); + + it('classifies the missing checksum-tool message as infrastructure', () => { + // Both wrapper generations print this verbatim and exit 1 when a + // checksum was requested and neither sha256sum nor shasum exists. + writeReactor(); + writeWrapper(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + 'Checksum validation was requested but neither ' + + "'sha256sum' or 'shasum' are available.", + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + }); + + it.skipIf(process.platform === 'win32')( + 'classifies a localized wrapper launch failure as infrastructure', + () => { + // bash/dash localize the 126/127 diagnostics under a non-English + // LANG; the classification keys on the shape — an unmodified wrapper + // dying at a launch exit code with no Maven-framed output — not the + // wording. + writeReactor(); + writeWrapper(); + + for (const [output, exitCode] of [ + ['/bin/sh: 1: ./mvnw: Keine Berechtigung', 126], + ['sh: ./mvnw: Datei oder Verzeichnis nicht gefunden', 127], + ] as const) { + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode, output }), + }); + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + } + }, + ); + + it('reads a PR-modified wrapper with no fresh reports as never run, whatever it echoes', () => { + // A wrapper the PR modifies CONTROLS both evidence channels: a stub + // `#!/bin/sh` edit keeps the exec bit, echoes a framed line, and + // exits 0. With zero fresh reports, framed output proves nothing the + // stub could not forge, so the run reads unverified whether it echoes + // or stays silent. A modified wrapper that surfaces fresh reports is + // the sibling test's case — reports are the one evidence channel a + // bare echo-stub does not produce on its own terms. + writeReactor(); + writeExecutedWrapper(); + + const framed = runAdapter([executedWrapperName, 'core/src/Main.java'], { + exec: (command) => + result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }), + }); + expect(framed.ok).toBe(false); + expect(framed.test[0]?.neverRan).toBe(true); + expect(framed.note).toContain('changed by the diff'); + + // The silent stub twin lands the same way, and the note names the + // diff's part in it. + const silent = runAdapter([executedWrapperName, 'core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + expect(silent.ok).toBe(false); + expect(silent.test[0]?.neverRan).toBe(true); + expect(silent.note).toContain('changed by the diff'); + }); + + it('reads a PR-modified wrapper writing fresh green reports as a real run', () => { + // The sibling twin: fresh reports plus framed output are the evidence + // of a build that ran — classifying the run "never ran" over them + // asserted a false contradiction for the ordinary wrapper-bump case. + writeReactor(); + writeExecutedWrapper(); + + const report = runAdapter([executedWrapperName, 'core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.neverRan).toBeUndefined(); + expect(report.note).toContain(NOTE_MAVEN_TEST_PASSED); + // The green verdict over a diff-modified executed wrapper carries the + // trust caveat — pin it, or a later edit could silently drop it and + // let the green read unqualified. + expect(report.note).toContain( + 'executed a Maven wrapper the diff itself changed', + ); + expect(report.note).toContain('confirm the wrapper change is benign'); + }); + + it('does not fail a green run when a test echoes a failing Surefire summary', () => { + // Surefire echoes test stdout verbatim: a plugin-integration test + // that prints a child build's failing summary records it in the + // captured output of a fully green run. With visible green fresh + // reports the echoed summary is test output, not Maven's verdict — + // positive failure evidence it becomes only where no reports are + // visible at all (the relocated-`` shape). + writeReactor(); + + const green = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: + '[INFO] BUILD SUCCESS\n' + + '[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0', + }); + }, + }); + expect(green.ok).toBe(true); + expect(green.test[0]?.swallowedFailure).toBeUndefined(); + expect(green.note).toContain(NOTE_MAVEN_TEST_PASSED); + + // The relocated-reports twin keeps its defense: with no visible + // reports, the stdout summary is the only signal. + const relocated = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '[INFO] BUILD SUCCESS\n' + + '[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0', + }), + }); + expect(relocated.ok).toBe(false); + expect(relocated.test[0]?.swallowedFailure).toBe(true); + }); + + it('detects single-dash long fail-never and quiet spellings in maven.config', () => { + // commons-cli accepts `-fail-never`/`-quiet` exactly like the `--` + // twins; missing them silently disarmed the exit-0 green-wash defense + // for a spelling the PR-writable config can carry. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-fail-never\n'); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '[INFO] BUILD SUCCESS\n' + + '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-checkstyle-plugin:3.3.1:check on project core: boom', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.note).toContain('fail-never'); + }); + + it('refuses certification when maven.config redirects output to a log file', () => { + // `-l`/`--log-file` redirects the ENTIRE stdout to a file, but Surefire + // reports go to DISK regardless — so clean fresh reports are complete + // structural evidence and judge the run green even though every stdout + // scan reads nothing. The old model treated the redirected stdout as an + // unread-evidence gap; the state machine judges on parsed reports when + // they exist. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-l\nbuild.log\n'); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.evidenceCapped).toBeUndefined(); + expect(report.note).toContain(NOTE_MAVEN_TEST_PASSED); + }); + + it('reads a log-file run with no reports as never run, not green', () => { + // The twin with no structural evidence: stdout is redirected away AND no + // reports exist, so nothing proves a build ran — fail closed to neverRan + // rather than certifying the empty run. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-l\nbuild.log\n'); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.neverRan).toBe(true); + expect(report.note).toContain('--log-file'); + expect(report.note).toContain('unverified'); + }); + + it('reads post-test-phase disk and dependency deaths at a failing exit as infrastructure', () => { + // `-pl -am` builds AND tests the upstream modules first, so the + // first `[INFO] Running` line prints long before the changed module + // resolves — a dependency-resolution or disk death AFTER it is still + // the run's own death, and cutting the scan at the first test phase + // filed a transient outage (and a mid-command ENOSPC) as a defect in + // the PR. A failing exit carries no exit-0 forgery premise, so the + // acquisition scans read the whole output. + writeReactor(); + + const afterTests = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[INFO] Running com.example.EchoTest\n' + + '[ERROR] simulated ENOSPC: No space left on device', + }), + }); + expect(afterTests.test[0]?.infrastructure).toBe(true); + expect(afterTests.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // The same wording BEFORE any test phase is Maven's own, unchanged. + const genuine = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: '[ERROR] simulated ENOSPC: No space left on device', + }), + }); + expect(genuine.test[0]?.infrastructure).toBe(true); + expect(genuine.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('does not launder a compile failure into infrastructure when upstream test stdout echoes infrastructure words', () => { + // `-pl -am` runs the upstream modules' tests first, and a + // passing upstream test can echo dependency- or disk-wording lines in + // its own stdout. The acquisition arms read the whole output, so the + // echo matches — but a genuine framed compile failure later in the + // same output is the run's real verdict and must stay PR-attributed: + // a compile failure writes no Surefire XML for freshFailures to see, + // so only the source-failure suppression keeps the echo from + // laundering it into an infrastructure result. + writeReactor(); + + const echoedDependency = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[INFO] Running com.example.EchoTest\n' + + '[ERROR] Could not resolve dependencies for project example:upstream\n' + + '[ERROR] COMPILATION ERROR :\n' + + '[ERROR] /tmp/x/core/src/main/java/Main.java:[12,5] cannot find symbol', + }), + }); + expect(echoedDependency.test[0]?.infrastructure).toBeUndefined(); + expect(echoedDependency.note).toContain(NOTE_CORRELATE_ERRORS); + expect(echoedDependency.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // The disk arm's twin: an upstream test exercising an ENOSPC path + // prints the framed disk wording; the changed module's genuine + // compile failure still stays PR-attributed. + const echoedDisk = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[INFO] Running com.example.EchoTest\n' + + '[ERROR] simulated ENOSPC: No space left on device\n' + + '[ERROR] COMPILATION ERROR :\n' + + '[ERROR] /tmp/x/core/src/main/java/Main.java:[12,5] cannot find symbol', + }), + }); + expect(echoedDisk.test[0]?.infrastructure).toBeUndefined(); + expect(echoedDisk.note).toContain(NOTE_CORRELATE_ERRORS); + expect(echoedDisk.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('does not discard a green run on a forged selector rejection', () => { + // Exit-0 + green fresh reports + no fail-never: Maven prints no + // `[ERROR]`, so a framed selector-rejection line is test stdout — the + // passing run must survive exactly like every other exit-0 framing + // scan. + writeProject('.', ['core']); + writeProject('core'); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: + '[INFO] BUILD SUCCESS\n' + + '[ERROR] Could not find the selected project in the reactor: core', + }); + }, + }); + + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(true); + expect(report.note).toContain(NOTE_MAVEN_TEST_PASSED); + }); + + it('keeps fresh failing reports on a run with forged selector-rejection wording', () => { + // Exit 0 + fresh reports + rejection wording is always forgery: a + // genuine rejection fail-fasts non-zero before any test runs, so it + // never coexists with fresh reports — FAILING ones included. The + // forged line must not discard the run into `unsupported` and hide + // the captured genuine failures (the green twin is the test above). + writeProject('.', ['core']); + writeProject('core'); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: + '[INFO] BUILD SUCCESS\n' + + '[ERROR] Could not find the selected project in the reactor: core', + }); + }, + }); + + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(false); + expect(report.test).toHaveLength(1); + expect(report.test[0]?.output).toContain('[maven-test-failure]'); + expect(report.note).toContain('test failures, not a pass'); + }); + + it('never reads a commented-out failure sample as a failing case', () => { + // Comments are opaque to the parser: a `` sample inside one is + // text, not verdict markup. The hand-rolled scanner probed this shape + // for swallowing; the strict parser makes it structural — the case has + // no failure element, the report reads clean, and nothing forges a + // phantom failure (nor erases a real one, which would be genuine + // markup the parser counts). + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '', + ); + return result(command, { exitCode: 0, output: '[INFO] BUILD SUCCESS' }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.test[0]?.evidenceCapped).toBeUndefined(); + expect(report.test[0]?.output).not.toContain('[maven-test-failure]'); + }); + + it('keeps stray unclosed fragments of other names out of the mirror probe', () => { + // Test output wrapped in CDATA routinely carries unclosed markup + // fragments (a printed generic type, an HTML log); only the names the + // parse reads may reject the report. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + 'boom ]]>' + + '', + ); + return result(command, { exitCode: 1, output: '[INFO] BUILD FAILURE' }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBeUndefined(); + }); + + it('treats detached -D maven.repo.local spellings in maven.config as dependency inputs', () => { + // The config reader hands Maven one argument per line; commons-cli + // pairs a value-less `-D` with the next line exactly like the attached + // `-Dmaven.repo.local=…` spelling. + writeReactor(); + mkdirSync(join(root, '.mvn')); + mkdirSync(join(root, 'custom-repo')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-D\nmaven.repo.local=custom-repo\n', + ); + + const report = runAdapter(['custom-repo/org/example/lib.jar'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.test[0]?.infrastructure).toBeUndefined(); + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it.skipIf(process.platform === 'win32')( + 'sanitizes control characters out of marker line names', + () => { + // A report path is PR-controlled text: a newline in a directory name + // split the appended marker and forged a second line inside the + // classified output (win32 forbids control chars in names, so the + // vector is POSIX-only). + writeReactor(); + const forgedName = + 'evil\n[ERROR] Could not resolve dependencies for project example:core:jar:1'; + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, forgedName, 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Evil.xml'), + '', + ); + return result(command, { + exitCode: 1, + output: + '[ERROR] Failed to execute goal org.example:plugin:1:check on project core: boom', + }); + }, + }); + + // The forged `[ERROR]` line never lands in the classified output: no + // infrastructure classification off it, and the marker carries the + // sanitized single-line name. + expect(report.test[0]?.infrastructure).toBeUndefined(); + const output = report.test[0]?.output ?? ''; + expect(output).not.toContain('\n[ERROR] Could not resolve dependencies'); + expect(output).toContain('evil_[ERROR] Could not resolve dependencies'); + }, + ); + + it('treats single-dash long settings spellings in maven.config as dependency inputs', () => { + // commons-cli accepts `-settings ` exactly like `--settings`; + // reading the token through the `-s` prefix regex recorded `ettings` + // and let the PR's own breakage launder into infrastructure. + writeReactor(); + mkdirSync(join(root, '.mvn')); + mkdirSync(join(root, 'ci')); + writeFileSync(join(root, 'ci', 'conf.xml'), '\n'); + + const withConfig = (config: string) => { + writeFileSync(join(root, '.mvn', 'maven.config'), config); + return runAdapter(['ci/conf.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + }; + + const paired = withConfig('-settings\nci/conf.xml\n'); + expect(paired.note).toContain(NOTE_CORRELATE_ERRORS); + expect(paired.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + const attached = withConfig('-settings=ci/conf.xml\n'); + expect(attached.note).toContain(NOTE_CORRELATE_ERRORS); + expect(attached.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('splits -Dmaven.repo.local.tail on comma only, never on |', () => { + // Maven parses the chain with `split(",")`: a `|` is part of a path. + // Reading it as a separator recorded a phantom input that could + // withdraw the infrastructure carve-out for an unrelated outage. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-Dmaven.repo.local.tail=cache|warm\n', + ); + mkdirSync(join(root, 'cache|warm')); + + const own = runAdapter(['cache|warm/corrupt.jar'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + expect(own.note).toContain(NOTE_CORRELATE_ERRORS); + expect(own.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + + // The phantom half of the old split must NOT record as an input: a + // change under `cache/` alone keeps the carve-out. + mkdirSync(join(root, 'cache')); + const phantom = runAdapter(['cache/corrupt.jar'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + expect(phantom.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('widens the run to the full reactor for a root-level miscellaneous file', () => { + // A root file no exemption claims is the unknown-input case: it must + // widen the run, not drop it. + writeReactor(); + const calls: string[] = []; + + const report = runAdapter(['checkstyle.xml'], { + exec: (command) => { + calls.push(command); + return result(command); + }, + }); + + expect(calls).toEqual(['mvn --batch-mode --no-transfer-progress test']); + expect(report.affected).toEqual(['.']); + }); + + it('parses a failing case whose classname carries İ through the fallback scan', () => { + // `İ`.toLowerCase() lengthens UTF-16 text — the parse must still + // attribute the failure body to its case with Unicode names intact. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Ilk.xml'), + '' + + '' + + 'boom' + + '' + + '', + ); + return result(command, { + exitCode: 1, + output: 'There are test failures.', + }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.output).toContain('İlk#fails'); + }); + + it('rejects a report whose unclosed attribute quote hides failure bodies', () => { + // A greenwash shape: the suite header says zero failures, an opener's + // quote never closes, and every `` body sits after the hole. + // The interrupted walk discards those bodies, so the report must be + // rejected — reading the surviving prefix would greenwash the run. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + ''); + } + + const sweep = reportPaths(root, 3); + expect(sweep.truncated).toBe(true); + expect(sweep.paths).toHaveLength(2); + }); + + it('reads an unframed selector-rejection wording from test stdout as evidence, not rejection', () => { + // The classifier anchors on Maven's framing: a PR test echoing the + // wording must not discard the run's fresh evidence into `unsupported`. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: + 'some test printed: Could not find the selected project in the reactor: core\n' + + '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.toolchain).toBe('maven'); + expect(report.ok).toBe(true); + }); + + it('falls back to mvn when the wrapper name is a directory', () => { + // A directory named `mvnw` is searchable (passes X_OK) but dies exit 126 + // on execution; the isFile() gate treats it as absent on both platforms. + writeProject('.'); + mkdirSync(join(root, 'mvnw')); + chmodSync(join(root, 'mvnw'), 0o755); + expect(mavenExecutable(root, 'linux')).toBe('mvn'); + + mkdirSync(join(root, 'mvnw.cmd')); + expect(mavenExecutable(root, 'win32')).toBe('mvn'); + }); + + it('reads a non-empty stub wrapper that exits 0 as never run, not as tested nothing', () => { + // Trimming the wrapper to `#!/bin/sh` keeps the exec bit and passes the + // size gate: exit 0, zero reports, zero Maven output. Enumerating + // wrapper shapes misses it; classifying the run does not. The fixture + // writes the wrapper this platform EXECUTES so the neverRan path is + // pinned through its executed-wrapper variant on both platforms. + writeReactor(); + writeExecutedWrapper(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.neverRan).toBe(true); + expect(report.note).toContain('the build never ran'); + }); + + it('mirrors Maven line-by-line maven.config reading for spaced arguments', () => { + // Maven reads one argument per line: `-s` and a spaced path are two + // lines. Whitespace tokenizing recorded the truncated path and let the + // PR's own breakage launder into infrastructure. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-s\nci/my settings.xml\n', + ); + mkdirSync(join(root, 'ci')); + writeFileSync(join(root, 'ci', 'my settings.xml'), '\n'); + + const report = runAdapter(['ci/my settings.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('does not read maven.config comment lines as arguments', () => { + // A `#` line naming flags must not record a spurious input that could + // withdraw the carve-out for an unrelated environmental failure. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '# -s ci/real.xml\n'); + mkdirSync(join(root, 'ci')); + writeFileSync(join(root, 'ci', 'real.xml'), '\n'); + + const report = runAdapter(['ci/real.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('does not read -legacy-local-repository as a log-file flag', () => { + // commons-cli matches the single-dash long spelling before the `-l` + // short option: `-legacy-local-repository` starts with `-l` but is a + // valueless flag, not an attached log-file value. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-legacy-local-repository\n', + ); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(true); + expect(report.note).not.toContain('--log-file'); + }); + + it.each([ + '--define=maven.repo.local=custom-repo\n', + '-define=maven.repo.local=custom-repo\n', + '-D=maven.repo.local=custom-repo\n', + ])( + 'treats the attached define spelling %j as a dependency input', + (config) => { + // The tokenizer normalizes every attached define spelling to the + // `-D=` shape before the property prefixes read it — a + // changed local-repository location in any spelling suppresses the + // infrastructure carve-out. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), config); + mkdirSync(join(root, 'custom-repo')); + + const report = runAdapter(['custom-repo/corrupt.jar'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }, + ); + + it('distrusts a bare exit 0 when the config grammar is ambiguous', () => { + // A flag the tokenizer cannot classify fails the verdict CLOSED: the + // run proceeds as if fail-never were set, so framed failure wording + // convicts even beside clean reports — imprecision can only ever be + // stricter, never release. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '--some-unknown-flag\n'); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: + '[INFO] BUILD SUCCESS\n' + + '[ERROR] Failed to execute goal org.apache.maven.plugins:maven-checkstyle-plugin:3.3.1:check on project core', + }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.note).toContain('unreadable'); + }); + + it.each([ + '-s=ci/settings.xml\n', + // Maven reads one argument PER LINE: the paired forms are two lines. + '--settings\nci/settings.xml\n', + '--settings=ci/settings.xml\n', + '-gs\nci/settings.xml\n', + '-gs=ci/settings.xml\n', + ])( + 'treats the %j maven.config spelling as a settings dependency input', + (config) => { + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), config); + mkdirSync(join(root, 'ci')); + writeFileSync(join(root, 'ci', 'settings.xml'), '\n'); + + const report = runAdapter(['ci/settings.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }, + ); + + it('treats a POM nested under a bare src/ module path as a resolution input', () => { + // A reactor can aggregate `src/core`: that POM feeds + // resolution like any other. Excluding EVERY src/-nested POM laundered + // the PR's own resolution breakage into an infrastructure outage. + writeProject('.', ['src/core']); + writeProject('src/core'); + + const report = runAdapter(['src/core/pom.xml'], { + exec: (command) => + result(command, { + exitCode: 1, + output: '[ERROR] Non-resolvable parent POM for example:core', + }), + }); + + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it.skipIf(process.platform === 'win32')( + 'treats a symlinked .mvn/maven.config as unreadable instead of hanging on it', + () => { + // The isFile() gate: a symlink to a character device reports size 0 + // and passes any size cap — readFileSync would block forever. + writeReactor(); + mkdirSync(join(root, '.mvn')); + symlinkSync('/dev/zero', join(root, '.mvn', 'maven.config')); + + const report = runAdapter(['core/src/Main.java']); + + expect(report.ok).toBe(true); + }, + ); + + it('distrusts a bare exit 0 over an oversized maven.config it cannot tokenize', () => { + // R1-4: Maven reads the same file with no size cap and honors its + // exit-0-changing flags, so a config that exists but cannot be + // tokenized must set `ambiguous` and distrust a bare exit 0 — the + // cap's fail-closed intent, applied to the verdict path too. Green + // reports plus a framed source failure in stdout convict only when the + // config is ambiguous; the under-cap control stays green. + writeReactor(); + const exec = (command: string) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS\n[ERROR] COMPILATION ERROR :', + }); + }; + + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '--fail-never\n' + '#'.repeat(2 * 1024 * 1024 + 1), + ); + const capped = runAdapter(['core/src/Main.java'], { exec }); + expect(capped.ok).toBe(false); + expect(capped.test[0]?.swallowedFailure).toBe(true); + }); + + it('distrusts scope-altering flags in maven.config instead of reading them as inert', () => { + // R1-5: `-pl moduleA` in a PR-writable config makes Maven build only + // moduleA while the harness believes it ran the reactor — a RELEASE. + // Scope-altering flags therefore fail closed to ambiguous. + writeReactor(); + const exec = (command: string) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS\n[ERROR] COMPILATION ERROR :', + }); + }; + + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-pl moduleA\n'); + const scoped = runAdapter(['core/src/Main.java'], { exec }); + expect(scoped.ok).toBe(false); + expect(scoped.test[0]?.swallowedFailure).toBe(true); + + // The valueless twin and a profile flag behave the same. + writeFileSync(join(root, '.mvn', 'maven.config'), '-N\n'); + expect(runAdapter(['core/src/Main.java'], { exec }).ok).toBe(false); + writeFileSync(join(root, '.mvn', 'maven.config'), '-P ci\n'); + expect(runAdapter(['core/src/Main.java'], { exec }).ok).toBe(false); + }); + + it('reads a -Dtest filter as exit-semantics-changing like skipTests', () => { + // R1-6: `-Dtest=…`/`-Dsurefire.failIfNoSpecifiedTests=false` can filter + // execution to zero tests and let the run exit 0 — classify them like + // skipTests so a zero-test run is not certified green. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-Dtest=___zzz___\n-Dsurefire.failIfNoSpecifiedTests=false\n', + ); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.testsSuppressed).toBe(true); + }); + + it('classifies the deprecated maven.test.skip.exec skip property like skipTests', () => { + // R2-4: `-Dmaven.test.skip.exec=true` skips test execution and lets the + // run exit 0 — the regex must admit the `.exec` suffix or the property + // reads inert and a skipped run certifies green. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-Dmaven.test.skip.exec=true\n', + ); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.testsSuppressed).toBe(true); + }); + + it.each([ + '-Dgroups=nonexistent\n', + '-DexcludedGroups=everything\n', + '-Dsurefire.includesFile=matches-nothing.txt\n', + '-Dsurefire.excludesFile=excludes-all.txt\n', + ])('classifies the zero-test selection filter %j like skipTests', (cfg) => { + // R2-5: these filters select tests by category or file list; a value + // matching nothing runs ZERO tests and exits 0 with no reports and no + // skip marker. Same strict classification as the other test filters. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), cfg); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.testsSuppressed).toBe(true); + }); + + it('records a bare -Dmaven.repo.local define as a dependency input', () => { + // R2-16: Maven defaults a valueless define to `true` and + // `-Dmaven.repo.local` redirects the local repository, so the bare + // spelling is a dependency input too (suppressing the infrastructure + // carve-out over changes under it), exactly like the `=value` twin. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-Dmaven.repo.local\n'); + mkdirSync(join(root, 'true')); + + const report = runAdapter(['true/corrupt.jar'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] Could not resolve dependencies for project example:core', + }), + }); + + expect(report.note).toContain('Correlate compiler or test errors'); + expect(report.note).not.toContain('infrastructure evidence'); + }); + + it('tokenizes a CR-only maven.config like the LF twin (no scope-altering bypass)', () => { + // R2-1: Maven's Files.lines terminates a line on a lone \r too; a + // CR-only config must not mash several arguments into one token and + // bypass the scope-altering fail-closed classification. + writeReactor(); + const exec = (command: string) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] BUILD SUCCESS\n[ERROR] COMPILATION ERROR :', + }); + }; + + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-pl\rmoduleA\r'); + const cr = runAdapter(['core/src/Main.java'], { exec }); + expect(cr.ok).toBe(false); + expect(cr.test[0]?.swallowedFailure).toBe(true); + }); + + it('distrusts a build-only run whose config skips compilation itself', () => { + // R2-7: `-Dmaven.main.skip` skips the compile mojos a build-only run + // exists to verify, while still exiting 0 — distrust the bare exit 0 + // instead of certifying green over a run that compiled nothing. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-Dmaven.main.skip=true\n', + ); + + const report = runAdapter(['core/src/Main.java'], { + buildOnly: true, + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.note).toContain('compilation was suppressed'); + }); + + it('escalates a config-declared dependency input under .github/ to reactor-wide', () => { + // R2-10: a settings file the config itself declares (`-s + // .github/settings.xml`) is a build input no matter where it lives — + // the `.github/` metadata exemption must not swallow it and certify + // "no Maven target" with zero commands. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync( + join(root, '.mvn', 'maven.config'), + '-s\n.github/settings.xml\n', + ); + mkdirSync(join(root, '.github')); + writeFileSync(join(root, '.github', 'settings.xml'), ''); + + expect(detectMavenOwnership(root, ['.github/settings.xml'])).toEqual({ + reactorWide: true, + modules: [], + }); + }); + + it('fires testsSuppressed on a module-local skip even when upstream reports exist', () => { + // R1-9: the marker must not be gated on the absence of reports — a + // module-local `` writes no reports while `-am` upstream + // modules do, and the changed module's "Tests are skipped." still means + // part of the scope was not tested. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'upstream', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Up.xml'), + '', + ); + return result(command, { + exitCode: 0, + output: '[INFO] Tests are skipped.\n[INFO] BUILD SUCCESS', + }); + }, + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.testsSuppressed).toBe(true); + }); + + it('exempts build-only runs from the skip and never-ran guards', () => { + // R1-31: a build-only (test-compile) run has no test phase, so a skip + // setting is irrelevant and the missing reports are structural — the + // guards must not misread a successful compile as suppressed/never-ran. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-DskipTests\n'); + + const report = runAdapter(['core/src/Main.java'], { + buildOnly: true, + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(true); + expect(report.test.length).toBe(0); + expect(report.build[0]?.testsSuppressed).toBeUndefined(); + expect(report.build[0]?.neverRan).toBeUndefined(); + }); + + it('exercises the wrapper-skip arm through the platform parameter', () => { + // The arm that skips the OTHER platform's wrapper, reachable without + // depending on the host's process.platform. + writeReactor(); + expect(detectMavenOwnership(root, ['mvnw'], 'win32')).toEqual({ + reactorWide: false, + modules: [], + }); + expect(detectMavenOwnership(root, ['mvnw.cmd'], 'linux')).toEqual({ + reactorWide: false, + modules: [], + }); + // The SAME platform's wrapper is still reactor-wide evidence. + expect(detectMavenOwnership(root, ['mvnw'], 'linux').reactorWide).toBe( + true, + ); + }); + + it('decodes XML entities in failing case identities', () => { + // Parameterized Surefire names escape `<` / `&`; the decoded identity is + // what Agent 7 correlates against the changed files. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + '' + + '', + ); + return result(command, { exitCode: 1, output: '[ERROR] Tests failed' }); + }, + }); + + expect(report.test[0]?.output).toContain( + '[maven-test-failure] core/target/surefire-reports/TEST-Core.xml: example.T#a & b < c', + ); + }); + + it('pins the Scala and Groovy source-failure alternants', () => { + // For Scala/Groovy repos these alternants are the ONLY source-failure + // signal; a regex rewrite breaking just them must not ship green. + expect( + isSourceFailureLine( + '[ERROR] /tmp/x/core/src/main/scala/Main.scala:12: not found: value foo', + ), + ).toBe(true); + expect( + isSourceFailureLine( + '[ERROR] /tmp/x/core/src/main/groovy/Main.groovy: 3: unable to resolve class', + ), + ).toBe(true); + }); + + it('keys the entry disk floor on the install flag', () => { + // 2 GiB free sits inside the [1 GiB build, 3 GiB install) window: the + // install floor skips a warm-cache run it should admit. + writeReactor(); + statfsSyncMock.mockReturnValue({ bavail: 2 * 1024 ** 3, bsize: 1 }); + + const installing = runAdapter(['core/src/Main.java'], { install: true }); + expect(installing.ok).toBe(false); + expect(installing.note).toContain('Insufficient disk space'); + + const warmCache = runAdapter(['core/src/Main.java'], { install: false }); + expect(warmCache.ok).toBe(true); + expect(warmCache.note).not.toContain('Insufficient disk space'); + }); + + it('does not blame the warm-up in the second preflight note when none ran', () => { + // A --no-install run passes the first preflight, then free space falls + // below the build floor: the note must not assert a download that never + // happened. + writeReactor(); + statfsSyncMock.mockReturnValueOnce({ bavail: 16 * 1024 ** 3, bsize: 1 }); + statfsSyncMock.mockReturnValue({ bavail: 0.5 * 1024 ** 3, bsize: 1 }); + + const report = runAdapter(['core/src/Main.java'], { install: false }); + + expect(report.ok).toBe(false); + expect(report.note).toContain( + 'free space fell below the build floor between the preflight and the lifecycle command', + ); + expect(report.note).not.toContain('warm-up consumed'); + }); + + it('keeps a wrapper-config-only diff from withdrawing the mvn launch carve-out', () => { + // `.mvn/wrapper/**` feeds the wrapper scripts — which never ran here (no + // wrapper in the tree; the system `mvn` launch died). The config cannot + // have caused that death, so the outage stays infrastructure. + writeReactor(); + const report = runAdapter(['.mvn/wrapper/maven-wrapper.properties'], { + exec: (command) => + result(command, { exitCode: 127, output: 'sh: 1: mvn: not found' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBe(true); + }); + + it('attributes a timed-out reactor-wide run to the selector the diff made unexpressible', () => { + // The timedOut note must carry the TRUE widening cause: a selectorUnsafe + // widening is not "inputs every module inherits". + writeProject('.', ['od,d']); + writeProject('od,d'); + + const report = runAdapter(['od,d/src/main/java/Main.java'], { + exec: (command) => result(command, { exitCode: null, timedOut: true }), + }); + + expect(report.ok).toBe(false); + expect(report.note).toContain('ran out of time'); + expect(report.note).toContain('cannot express'); + expect(report.note).not.toContain('inputs every module inherits'); + }); + + it('fails closed when the report sweep exceeds the scanned-directory cap', () => { + // The scan-count cap is the same fail-closed state as the fan-out bound; + // the cap is a parameter so the test reaches it without building 20,000 + // directories. + writeReactor(); + for (let i = 0; i < 10; i++) { + mkdirSync(join(root, `d${i}`, 'nested'), { recursive: true }); + } + + expect(reportPaths(root, 5).truncated).toBe(true); + expect(reportPaths(root, 100).truncated).toBe(false); + }); + + it('caps the accumulated report paths when the per-dimension product blows up', () => { + // Every cap bounded ONE dimension; nothing bounded their product — two + // modules x both report dirs x 5,100 XMLs respects the per-dir entry + // cap and the scanned-dir cap yet accumulates past the path cap. The + // sweep must stop collecting instead of retaining and statSync-ing + // hundreds of thousands of paths. + writeReactor(); + for (const module of ['core', 'extension']) { + for (const reportDir of ['surefire-reports', 'failsafe-reports']) { + const dir = join(root, module, 'target', reportDir); + mkdirSync(dir, { recursive: true }); + for (let i = 0; i < 5_100; i++) { + writeFileSync(join(dir, `t${reportDir[0]}${i}.xml`), ''); + } + } + } + + const { paths, truncated } = reportPaths(root); + + expect(truncated).toBe(true); + expect(paths.length).toBe(20_000); + }, 60_000); + + it('rejects a multi-root malformed report fail-closed in bounded time', () => { + // A self-closing suite followed by tens of thousands of stray + // openers/closers is malformed (multiple roots, mismatched names). The + // hand-rolled stack scans went quadratic on exactly this shape; the + // strict parser walks it once, rejects it, and the rejection fails + // closed — unknown evidence, never green, never a hang. + writeReactor(); + const startedAt = Date.now(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '' + + ''.repeat(50_000) + + ''.repeat(50_000) + + '', + ); + return result(command); + }, + }); + + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }, 20_000); + + it('prints per-report clamped totals on the failing rollup', () => { + // Surefire does not guarantee tests >= failures + skipped within one + // report, and test-plan clamps per parsed LINE: raw pre-aggregated + // totals would let one anomalous report cancel its batchmates' passed + // counts (report A below parses to -3 passed without the per-report + // clamp). The failing rollup must emit the same clamped shape the + // clean rollup does. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(root, 'core', 'target', 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Anomalous.xml'), + '', + ); + writeFileSync( + join(dir, 'TEST-Normal.xml'), + '' + + '' + + '', + ); + return result(command, { exitCode: 1, output: '[ERROR] Tests failed' }); + }, + }); + + // Per-report truth: A passed max(0, 2-2-3)=0, B passed max(0, 10-1-0)=9 + // — so tests = 9 passed + 3 failed with skipped zeroed, and the line + // clamp parses back to 9 instead of the old wash-down to 6. + expect(report.test[0]?.output).toContain( + '[maven-test-report] core (2 failing report(s)): ' + + 'tests=12, failures=3, errors=0, skipped=0', + ); + }); + + it('reads [ERROR]-framed stdout test failures as source-side, not infrastructure', () => { + // Real Maven frames a failing module's stdout summary `[ERROR]`, and a + // test throwing ConnectException prints dependency-flavored wording the + // dependency matcher claims. With the failing reports relocated out of + // the sweep, the stdout summary is the only evidence that the run + // executed failing tests — that is source-side, never an acquisition + // outage. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 1, + output: + '[ERROR] ConnTest.connects:7 \u00bb Connect Connection refused\n' + + '[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n' + + '[INFO] BUILD FAILURE', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.infrastructure).toBeUndefined(); + expect(report.test[0]?.swallowedFailure).toBeUndefined(); + expect(report.note).toContain(NOTE_CORRELATE_ERRORS); + expect(report.note).not.toContain(NOTE_INFRASTRUCTURE_EVIDENCE); + }); + + it('does not launder a swallowed stdout test failure into infrastructure', () => { + // The exit-0 twin of the ConnectException wash: a fail-never run whose + // stdout records executed failing tests beside dependency-flavored + // wording is a swallowed test failure, not an acquisition outage. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: 0, + output: + '[ERROR] Could not transfer artifact org.example:lib:pom:1 from central: Connection timed out\n' + + '[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0\n' + + '[INFO] BUILD SUCCESS', + }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.swallowedFailure).toBe(true); + expect(report.test[0]?.infrastructure).toBeUndefined(); + }); + + // chmod is the only lever this case has; the repo convention for it. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'fails closed when a target directory is unreadable', + () => { + // The lstat gate one level ABOVE the report dir: an unreadable + // `target` used to read as 'no reports dir here', certifying green a + // run whose fresh failing reports the sweep could not see. chmod 000 + // is within the threat model this file grants. + writeReactor(); + const target = join(root, 'core', 'target'); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => { + const dir = join(target, 'surefire-reports'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'TEST-Core.xml'), + '', + ); + chmodSync(target, 0o000); + return result(command); + }, + }); + + // Restore so the sandbox cleanup can remove the tree. + chmodSync(target, 0o755); + expect(report.ok).toBe(false); + expect(report.test[0]?.evidenceCapped).toBe(true); + }, + ); + + it('names a quiet maven.config as the never-ran alternative cause', () => { + // `-q`/`--quiet` in the PR-writable config strips every framed line the + // neverRan check keys on: a quiet run with no reports exits 0 with + // empty output, indistinguishable there from a wrapper that never + // started — the note must name the real alternative. + writeReactor(); + mkdirSync(join(root, '.mvn')); + writeFileSync(join(root, '.mvn', 'maven.config'), '-q\n'); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.neverRan).toBe(true); + expect(report.note).toContain('`-q`/`--quiet`'); + }); + + it('does not name the quiet setting when the config has none', () => { + // Control for the note above: the same empty-output shape without the + // flag points at the wrapper only. + writeReactor(); + + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => result(command, { exitCode: 0, output: '' }), + }); + + expect(report.ok).toBe(false); + expect(report.test[0]?.neverRan).toBe(true); + expect(report.note).toContain('empty or'); + expect(report.note).not.toContain('--quiet'); + }); + + it('names stdout-recorded failures in a timed-out run', () => { + // The deadline kill is infrastructure, but the captured framed `Tests + // run:` summaries are failures Surefire already recorded — the note + // must not assert a purely informational result over them. + writeReactor(); + const report = runAdapter(['core/src/Main.java'], { + exec: (command) => + result(command, { + exitCode: null, + timedOut: true, + output: '[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0', + }), + }); + + expect(report.ok).toBe(false); + expect(report.note).toContain('ran out of time'); + expect(report.note).toContain('treat those as test failures'); + }); + + it('omits the mixed-root caveat when package.json is a directory', () => { + // npm's applies() fails closed on a DIRECTORY named package.json + // (EISDIR swallowed to no manifests), so Maven runs alone with no npm + // half — the caveat would be false. + writeReactor(); + mkdirSync(join(root, 'package.json')); + + const report = runAdapter(['core/src/Main.java']); + + expect(report.ok).toBe(true); + expect(report.note).not.toContain('Mixed root'); + }); + + it('keeps the mixed-root caveat for a real root package.json', () => { + writeReactor(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ scripts: { build: 'tsc' } }), + ); + + const report = runAdapter(['core/src/Main.java']); + + expect(report.ok).toBe(true); + expect(report.note).toContain('Mixed root'); + }); + + it('applies() requires a REGULAR pom.xml file', () => { + // A DIRECTORY named pom.xml passes existsSync but selects Maven over a + // shape `mvn` refuses to build — the same isFile() gate mavenExecutable + // and mavenConfigDependencyInputs apply. This is also what keeps a + // polyglot base selecting npm instead of falling unsupported. + const plain = join(sandbox, 'applies-plain'); + mkdirSync(plain); + expect(mavenToolchainAdapter.applies(plain)).toBe(false); + + writeFileSync(join(plain, 'pom.xml'), pom()); + expect(mavenToolchainAdapter.applies(plain)).toBe(true); + + const dirPom = join(sandbox, 'applies-dir'); + mkdirSync(join(dirPom, 'pom.xml'), { recursive: true }); + expect(mavenToolchainAdapter.applies(dirPom)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/review/lib/maven-toolchain.ts b/packages/cli/src/commands/review/lib/maven-toolchain.ts new file mode 100644 index 00000000000..cf13e61321a --- /dev/null +++ b/packages/cli/src/commands/review/lib/maven-toolchain.ts @@ -0,0 +1,2463 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + accessSync, + constants, + existsSync, + lstatSync, + opendirSync, + readFileSync, + statSync, +} from 'node:fs'; +import type { Dirent } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import type { + BuildTestReport, + CommandResult, + MavenCommandFacts, +} from '../build-test.js'; +import { + BUILD_MIN_FREE_BYTES, + INSTALL_MIN_FREE_BYTES, + freeDiskBytes, + gib, +} from './disk.js'; +import { shellQuotePath } from './shell-quote.js'; +import { SaxesParser } from 'saxes'; +import type { ReviewToolchainAdapter, ToolchainRunArgs } from './toolchain.js'; + +export interface MavenOwnership { + reactorWide: boolean; + modules: string[]; +} + +/** + * SGR color sequences. Every classification regex in this file anchors on + * Maven's `[INFO]`/`[ERROR]` framing, and a `-Dstyle.color=always` config + * interleaves these codes BEFORE and BETWEEN tokens, defeating every + * anchored predicate — so the executed output is stripped ONCE at + * ingestion, before any classification or marker mining reads it. + * `build-test`'s trim rescue shares this constant: same bytes, same answer. + */ +// eslint-disable-next-line no-control-regex -- ESC is the character under test +export const ANSI_SGR_RE = /\x1b\[[0-9;]*m/g; + +/** + * Stable note markers, asserted by the test oracle. The prose around them + * may be reworded freely; changing a marker is a deliberate contract edit — + * one constant here, and every test that imports it follows. Each branch's + * marker is distinct, so the assertions still discriminate which branch + * produced the note (they only decouple from the surrounding wording). + */ +export const NOTE_INFRASTRUCTURE_EVIDENCE = 'infrastructure evidence'; +export const NOTE_CORRELATE_ERRORS = 'Correlate compiler or test errors'; +export const NOTE_MAVEN_TEST_PASSED = 'Maven test passed'; + +const REACTOR_WIDE_FILES = new Set(['pom.xml', 'mvnw', 'mvnw.cmd']); +/** + * `failsafe-reports` is forward-looking today: this adapter only ever runs + * `test` and `test-compile`, and Failsafe binds to `integration-test` / + * `verify`, so any XML found there is filtered out as stale. The scan stays + * — one readdir per project per snapshot — so the evidence is picked up if a + * later change ever runs a Failsafe phase. + */ +const REPORT_DIRS = ['surefire-reports', 'failsafe-reports']; + +/** + * Surefire writes one XML per test class, so a green full-reactor run yields + * thousands of reports. Clean AND failing reports therefore roll up per + * project dir — one attributed line per project, so module attribution + * survives any reactor size. The FAILING-side lines stay capped: that block + * is appended AFTER the command output was trimmed, so it carries its own + * bound; the cap preserves attribution (per-project rollups, not a + * byte-order slice). + */ +const MAX_FAILING_REPORT_LINES = 100; +const MAX_FAILURE_CASE_LINES = 200; + +/** + * cmd.exe refuses command lines past 8191 characters, and containerized + * execve enforces ARG_MAX. A POM change at a mid-level aggregator closes + * over every aggregation AND inheritance descendant, and on the 200-400 + * module reactors this adapter targets the comma-joined `-pl` selector can + * approach those limits — a command line the platform refuses to launch is + * not a scope. Past the cap the run widens to the full reactor instead. + * 4096 leaves headroom for the executable, flags, and environment. + */ +const MAX_SELECTOR_CHARS = 4096; + +/** + * Cap evidence files before reading them: Surefire/Failsafe XML is + * PR-controlled (the PR's own tests can write into `target/surefire-reports/` + * during the run, and the mtime freshness filter accepts any writer), so an + * uncapped read of a multi-gigabyte file is this harness's own denial-of- + * service surface. 2 MiB is far beyond any realistic per-class report; an + * oversized file simply contributes no evidence. + */ +const MAX_REPORT_BYTES = 2 * 1024 * 1024; + +/** + * `.mvn/maven.config` carries the same class of cap: it is read line-based + * (each non-empty, non-`#` line is ONE argument — an argument may carry a + * space, e.g. `ci/my settings.xml`), and an uncapped multi-megabyte config + * a PR commits is this harness's own denial-of-service surface — measured at + * 37 MB the read/tokenize costs seconds of synchronous CPU and hundreds of + * MB of transient heap, scaling linearly to GitHub's 100 MB per-file limit. + * An oversized config contributes no settings inputs (and reads ambiguous). + */ +const MAX_CONFIG_BYTES = 2 * 1024 * 1024; + +/** + * Cap the report sweep: it walks the worktree for `target/` + * directories, and a PR controls how many directories exist. Past the cap the + * sweep stops and reports the truncation — a truncated sweep can miss failure + * evidence, so the run refuses to certify a pass (see `evidenceCapped`). + */ +const MAX_SCANNED_DIRS = 20_000; + +/** + * Bound the enumeration of ONE directory as well as the sweep's scan count: + * `readdirSync` materializes the full Dirent array at once, and a PR- + * controlled wide fan-out (a single directory holding hundreds of thousands + * of children) made that array the sweep's dominant memory cost. Entries are + * streamed through `opendirSync` and reading stops past this bound; the + * directory then counts as truncation, failing closed like the scan cap. + */ +const MAX_DIR_ENTRIES = 10_000; + +/** + * Cap the sweep's PATH accumulation itself: every other cap bounds ONE + * dimension (scanned dirs, entries per dir, report bytes), but nothing + * bounded their product — 20k scanned dirs x both report dirs x 10k entries + * each accumulates hundreds of millions of paths, and snapshotReports + + * freshTestSummaries statSync and retain every one. A PR controls how many + * directories and report files exist, so the product is this harness's own + * denial-of-service surface. Past the cap the sweep stops collecting and + * reports truncation, failing closed like the other caps. + */ +const MAX_REPORT_PATHS = 20_000; + +/** + * Cap the failing cases one report accumulates, while building it: the + * display caps in appendTestSummaries apply after every report was + * materialized, and one report can carry tens of thousands of failing + * `` entries. The dropped count still joins the omission + * marker, so count adjudication sees the truncation. + */ +const MAX_FAILURE_CASES_PER_REPORT = 200; + +/** + * Below this much remaining whole-call budget a Maven command is NOT + * attempted — the same floor as the npm adapter, for the same reason: Maven + * cannot boot and produce signal in a few hundred milliseconds, so an + * "attempt" would manufacture a fake timeout where an honest disclosure says + * exactly what happened. + */ +const BUDGET_MIN_ATTEMPT_MS = 15_000; + +function toPosix(path: string): string { + return path.split(sep).join('/'); +} + +function isInside(root: string, path: string): boolean { + const rel = relative(root, path); + return ( + rel === '' || + (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel)) + ); +} + +function normalizedChangedPath( + root: string, + changedFile: string, +): string | null { + const absolute = resolve(root, changedFile); + if (!isInside(root, absolute)) return null; + return toPosix(relative(root, absolute)); +} + +function isDocumentationPath(path: string): boolean { + // Anchored to the WHOLE relative path: a DIRECTORY named `README` must + // not exempt its entire subtree — files of any extension — from + // verification. + if (/^README(?:\.[^/]*)?$/i.test(path)) return true; + // The `src/` guard alone would skip a compilable file under a module's + // `doc/` tree; a documentation path is a documentation EXTENSION first. + if (path.startsWith('src/')) return false; + if (!/\.(?:md|mdx|adoc|rst|txt)$/i.test(path)) return false; + // Outside `src/`, the extension alone is not enough: a `.txt` can be a + // resource wired into the artifact (maven-resources-plugin points at + // arbitrary dirs), and skipping the build on it would be a fail-open in an + // otherwise fail-closed design. Exempt only doc-shaped locations: a + // `docs?/` or `site/` tree, or the module/root top level itself. + const dir = dirname(path); + return dir === '.' || /^(?:docs?|site)$/i.test(dir.split('/')[0]); +} + +/** + * Repository metadata that cannot change what Maven builds: VCS/CI config, + * licenses, editor rules. Anything NOT recognized here still runs the reactor + * — a root `checkstyle.xml` or build script affects the build, and failing + * closed costs time while failing open ships an unverified diff. + */ +function isRepoMetadataPath(path: string): boolean { + // `[^/]*` keeps the LICENSE/NOTICE extension run inside the final + // segment: a DIRECTORY with one of those names must not exempt its + // subtree from verification. + return ( + /^(?:\.git(?:ignore|attributes|modules)|\.editorconfig|CODEOWNERS|LICENSE(?:\.[^/]*)?|NOTICE(?:\.[^/]*)?)$/.test( + path, + ) || path.startsWith('.github/') + ); +} + +/** + * The Maven project directory that owns a path: the nearest ancestor holding a + * `pom.xml`. + * + * Directories strictly beneath a `src/` tree are skipped. A POM there is OFTEN + * test data — maven-invoker ITs, archetype fixtures, + * `src/test/resources/projects/*` — but a reactor can also aggregate a real + * module there (`src/core`). The skip is principled only + * for the test-data shapes (`src/test/`, `src/it/`); when the walk collapses + * onto a POM it skipped, it fails closed — to the ROOT always (`-pl .` + * compiles only the root), and elsewhere unless every skipped POM was a + * test-data shape — because `-pl -am` adds only UPSTREAM projects, + * so a mis-collapsed target leaves the changed module untested under a green + * verdict. Returning null escalates the path to a reactor-wide run instead. + * + * Whether the project this returns is ACTIVE under the current profiles, JDK, + * and `` inheritance is deliberately NOT decided here: Maven decides + * it, by accepting or rejecting the `-pl` selector this ownership produces + * (see SELECTOR_REJECTED_RE). Approximating that answer from the POM text + * means shipping a second, weaker model of the thing the very next command + * evaluates for real. + */ +function owningProject(root: string, path: string): string | null { + let dir = dirname(join(root, path)); + let skippedPomBeneathSrc = false; + let skippedTestDataOnly = true; + while (isInside(root, dir)) { + const rel = toPosix(relative(root, dir)) || '.'; + // Strictly BENEATH `src/`: a real project located exactly AT a `src` path + // is not test data. + if (/(?:^|\/)src\//.test(rel)) { + if (existsSync(join(dir, 'pom.xml'))) { + skippedPomBeneathSrc = true; + // `src/test/` and `src/it/` trees are the principled fixture shapes + // (invoker ITs, archetype test projects); any OTHER src/-nested POM + // can be a real module a reactor aggregates (`src/core`). + if (!/(?:^|\/)src\/(?:test|it)\//.test(rel)) { + skippedTestDataOnly = false; + } + } + } else if (existsSync(join(dir, 'pom.xml'))) { + // Fail closed when the collapse cannot be trusted: at the ROOT a + // skipped POM may be a real `src/…` and `-pl .` + // would compile only the root, and anywhere else a skipped POM that + // is not a test-data shape is the same risk — `-pl -am` + // adds only UPSTREAM projects, so the changed module would go + // untested under a green verdict. Null escalates to reactor-wide. + if (skippedPomBeneathSrc && (rel === '.' || !skippedTestDataOnly)) { + return null; + } + return rel; + } + if (dir === root) break; + dir = dirname(dir); + } + return null; +} + +export function detectMavenOwnership( + root: string, + changedFiles: readonly string[], + platform: string = process.platform, +): MavenOwnership { + const modules = new Set(); + let reactorWide = false; + // A settings/repository file the config itself declares is a build input + // no matter where it lives — the metadata exemption below must not + // swallow it (a `-s .github/settings.xml` change redirects resolution). + const configDeclaredInputs = new Set( + analyzeMavenConfig(root).dependencyInputs, + ); + // Every wrapper repo ships both platform variants, but only one is ever + // executed: a change confined to the OTHER platform's wrapper cannot affect + // this platform's run, so it neither escalates to reactor-wide nor falls + // into the unowned catch-all (which would run the whole reactor to verify + // nothing). + const otherPlatformWrapper = platform === 'win32' ? 'mvnw' : 'mvnw.cmd'; + + for (const changedFile of changedFiles) { + const path = normalizedChangedPath(root, changedFile); + if (path === null) continue; + if (path === otherPlatformWrapper) continue; + if (REACTOR_WIDE_FILES.has(path) || path.startsWith('.mvn/')) { + reactorWide = true; + continue; + } + // A settings/repository file the config itself declares is a build input + // no matter where it lives or which project owns it — the + // documentation/metadata exemptions below must not swallow it (a + // `-s .github/settings.xml` change redirects resolution), so it + // escalates to reactor-wide ahead of every ownership reading. + if (configDeclaredInputs.has(path)) { + reactorWide = true; + continue; + } + const owner = owningProject(root, path); + if (owner === null) { + // Outside every Maven project. Documentation and repository metadata + // cannot change what Maven builds; anything else can. + if (!isDocumentationPath(path) && !isRepoMetadataPath(path)) { + reactorWide = true; + } + continue; + } + // A project's own POM is parent config: Maven merges it into every project + // that aggregates it or declares it as ``, and this adapter models + // none of those edges — `-pl -am` would compile the aggregator and + // test nothing that actually changed. A POM change runs the reactor and + // lets Maven apply the real inheritance. + if (path === (owner === '.' ? 'pom.xml' : `${owner}/pom.xml`)) { + reactorWide = true; + continue; + } + if (owner === '.') { + if (isDocumentationPath(path) || isRepoMetadataPath(path)) continue; + // Source owned by the root project scopes to `-pl . -am`: no other + // module compiles the root artifact's own `src/`, and on the large + // reactors this adapter targets a reactor-wide run can spend its whole + // deadline proving nothing. Anything ELSE at the root (a build script, a + // checkstyle config) can affect every module. + if (path === 'src' || path.startsWith('src/')) modules.add('.'); + else reactorWide = true; + continue; + } + // Documentation and metadata are judged MODULE-relatively, so the `src/` + // guard means the module's own source tree: `core/README.md` is a no-op + // run, but `core/src/test/resources/expected.txt` is test data and must + // keep building. + const inModule = path.slice(owner.length + 1); + if (isDocumentationPath(inModule) || isRepoMetadataPath(inModule)) continue; + modules.add(owner); + } + + return { reactorWide, modules: [...modules].sort() }; +} + +interface ReportSnapshot { + mtimes: Map; + /** The pre-run sweep stopped early: the freshness baseline is incomplete. */ + truncated: boolean; +} + +interface MavenTestSummary { + report: string; + tests: number; + failures: number; + errors: number; + skipped: number; + failedCases: string[]; + /** Failing cases dropped by MAX_FAILURE_CASES_PER_REPORT while parsing. */ + droppedCases: number; +} + +/** + * A directory's entries, streamed one at a time so a PR-controlled wide + * fan-out cannot materialize an unbounded Dirent array. Reading stops past + * MAX_DIR_ENTRIES and reports the truncation; an unreadable directory + * returns null. + */ +function readDirBounded( + dir: string, +): { entries: Dirent[]; truncated: boolean } | null { + let handle; + try { + handle = opendirSync(dir); + } catch { + return null; + } + const entries: Dirent[] = []; + let truncated = false; + try { + for (;;) { + // Read BEFORE the cap check: a directory holding exactly + // MAX_DIR_ENTRIES entries is read to exhaustion and must not report + // truncation — the flag propagates to evidenceCapped and would + // refuse certification of a fully-read green run. + const entry = handle.readSync(); + if (entry === null) break; + if (entries.length >= MAX_DIR_ENTRIES) { + truncated = true; + break; + } + entries.push(entry); + } + } catch { + // A mid-read throw (EIO/ESTALE on a network-backed worktree) is the + // same epistemic state as an unreadable directory: join the fail-closed + // truncation path instead of escaping the sweep. + return null; + } finally { + // A failing close must not throw past both the truncation logic and the + // return-null rescue — the fail-closed guarantee covers it too. + try { + handle.closeSync(); + } catch { + truncated = true; + } + } + return { entries, truncated }; +} + +/** + * Every `/target//*.xml` in the worktree. + * + * The sweep walks the tree rather than a list of reactor projects: which + * projects are active is Maven's answer, not this adapter's, and a report + * directory only exists where Maven actually ran. Symlinks are never + * followed: a Dirent's `isDirectory()` is false for one, and the report-dir + * read itself is gated on `lstatSync` (which does not resolve the link), so + * neither the descent nor the direct listing can escape the worktree. + * + * `truncated` reports that the sweep stopped early — the scanned-directory + * cap, the per-directory fan-out bound, an unreadable directory, or a queue + * that outgrew the scan budget. A truncated sweep can miss failure evidence, + * so the caller fails closed on it exactly like the fresh-report cap. + */ +export function reportPaths( + root: string, + maxScannedDirs: number = MAX_SCANNED_DIRS, +): { paths: string[]; truncated: boolean } { + const paths: string[] = []; + const queue: string[] = [root]; + let scanned = 0; + let truncated = false; + let pathsCapped = false; + while (queue.length > 0 && scanned < maxScannedDirs && !pathsCapped) { + const dir = queue.pop() as string; + scanned += 1; + const listing = readDirBounded(dir); + // An unreadable directory is the same epistemic state as the caps: the + // sweep did not see everything, so it fails closed instead of skipping on. + if (listing === null) { + truncated = true; + continue; + } + if (listing.truncated) truncated = true; + for (const entry of listing.entries) { + if (!entry.isDirectory()) continue; + if (entry.name === '.git' || entry.name === 'node_modules') continue; + const child = join(dir, entry.name); + if (entry.name !== 'target') { + // A wide fan-out can enqueue far more directories than the scan + // budget will ever pop; the backlog itself is the memory cost, so + // stop enqueuing and count it as truncation. Not pinnable by a + // behavior test: the end-of-loop `queue.length > 0` check sets the + // same flag whenever this guard fires, and which paths survive the + // LIFO pop depends on the filesystem's entry order — the bound is + // the point, not the observable outcome. + if (queue.length >= maxScannedDirs) { + truncated = true; + continue; + } + queue.push(child); + continue; + } + // Never descend INTO `target`: it holds unpacked dependencies and + // generated sources, and the only paths of interest sit one level down. + for (const reportDir of REPORT_DIRS) { + const reports = join(child, reportDir); + // lstat does NOT follow a symlink: a symlinked report dir would + // resolve outside the worktree and inject its stale reports as + // fresh evidence. + try { + if (!lstatSync(reports).isDirectory()) continue; + } catch (error) { + // Absence (ENOENT/ENOTDIR) is 'no reports dir here'; any OTHER + // error — EACCES on an unreadable `target`, chmod 000 within the + // threat model this file grants — means the sweep did not see + // everything and must fail closed like the sibling caps. + if ( + (error as NodeJS.ErrnoException).code !== 'ENOENT' && + (error as NodeJS.ErrnoException).code !== 'ENOTDIR' + ) { + truncated = true; + } + continue; + } + const files = readDirBounded(reports); + if (files === null) { + truncated = true; + continue; + } + if (files.truncated) truncated = true; + for (const file of files.entries) { + if (paths.length >= MAX_REPORT_PATHS) { + pathsCapped = true; + break; + } + if (file.isFile() && file.name.endsWith('.xml')) { + paths.push(join(reports, file.name)); + } + } + if (pathsCapped) break; + } + if (pathsCapped) break; + } + } + if (pathsCapped) truncated = true; + if (queue.length > 0) truncated = true; + return { paths, truncated }; +} + +function snapshotReports(root: string): ReportSnapshot { + // Freshness is an mtime comparison, and some filesystems resolve mtimes at + // 1s granularity: a report rewritten inside the same tick reads as stale and + // is dropped. That degrades in the safe direction — absent test-count + // evidence, never a wrong verdict — so no sub-second workaround is worth it. + const { paths, truncated } = reportPaths(root); + const mtimes = new Map(); + for (const path of paths) { + try { + mtimes.set(path, statSync(path).mtimeMs); + } catch { + // The report disappeared while the snapshot was being taken. + } + } + return { mtimes, truncated }; +} + +/** + * Parse one Surefire/Failsafe XML report with a STRICT XML parser (saxes): + * well-formedness, CDATA, comments, entities, self-closing tags, and + * nesting are the parser's job. This replaces a hand-rolled tag walk whose + * surface kept growing one adversarial XML corner per review round — + * the same anti-pattern the review skill once hit with a hand-rolled + * CommonMark scanner and closed by adopting a real parser (#9020). + * + * The strictness IS the threat model: reports live in worktree files the PR + * controls, so anything the parser rejects is unreadable verdict evidence — + * fail-closed, joined with the other rejections, never read green. Content + * the parser treats as text (a `` sample inside ``, + * CDATA-wrapped stdout, commented-out markup) can therefore never be read + * as verdict markup, by construction. + * + * A bare multi-`` document with no root element is malformed XML + * and rejects like any other shape; aggregate writers that wrap their + * suites in a `` root parse normally and every suite counts. + */ +function parseTestReport( + root: string, + path: string, +): MavenTestSummary | 'no-suites' | null { + let xml: string; + try { + // The size cap bounds parse cost on PR-controlled files; a reject here + // joins the other fail-closed rejections upstream. + if (statSync(path).size > MAX_REPORT_BYTES) return null; + xml = readFileSync(path, 'utf8'); + } catch { + return null; + } + // A BOM would trip the parser's grammar before the declaration; real + // writers emit one, so strip it rather than fail-closing a valid report. + if (xml.charCodeAt(0) === 0xfeff) xml = xml.slice(1); + + const parser = new SaxesParser(); + // saxes reports well-formedness errors through the error event and keeps + // going; the first one condemns the whole report, and every handler + // below stops acting once it fires. + let malformed = false; + parser.on('error', () => { + malformed = true; + }); + + let suites = 0; + let tests = 0; + let failures = 0; + let errors = 0; + let skipped = 0; + const failedCases: string[] = []; + let droppedCases = 0; + + const countAttribute = ( + attributes: Record, + name: string, + ): number => { + const value = Number.parseInt(attributes[name] ?? '0', 10); + // A malformed report's negative count must not cancel legitimate + // counts from its neighbours when totals roll up across reports. + return Number.isFinite(value) ? Math.max(0, value) : 0; + }; + + // The testcase currently open, if any: ``/`` elements + // belong to it. saxes fires closetag for self-closing tags too, so the + // stack cannot drift. + const openCases: Array<{ + className: string; + name: string; + failing: boolean; + }> = []; + + parser.on('opentag', (node) => { + if (malformed) return; + const name = node.name.toLowerCase(); + if (name === 'testsuite') { + // Aggregate counts across EVERY suite in the file: aggregate JUnit + // writers emit several `` elements under a `` + // root, and reading only the first undercounts later failures. + suites += 1; + tests += countAttribute(node.attributes, 'tests'); + failures += countAttribute(node.attributes, 'failures'); + errors += countAttribute(node.attributes, 'errors'); + skipped += countAttribute(node.attributes, 'skipped'); + } else if (name === 'testcase') { + openCases.push({ + className: node.attributes['classname'] ?? '', + name: node.attributes['name'] ?? '', + failing: false, + }); + } else if ( + (name === 'failure' || name === 'error') && + openCases.length > 0 + ) { + openCases[openCases.length - 1].failing = true; + } + }); + parser.on('closetag', (node) => { + if (malformed) return; + if (node.name.toLowerCase() !== 'testcase') return; + const testcase = openCases.pop(); + if (testcase === undefined || !testcase.failing) return; + if (failedCases.length >= MAX_FAILURE_CASES_PER_REPORT) { + // Keep counting but stop materializing: one report can carry tens of + // thousands of failing cases, and the display cap in + // appendTestSummaries only ever shows a bounded prefix — the dropped + // count still joins the omission marker. + droppedCases += 1; + return; + } + const caseName = testcase.name === '' ? 'unknown' : testcase.name; + failedCases.push( + testcase.className === '' + ? caseName + : `${testcase.className}#${caseName}`, + ); + }); + + parser.write(xml); + parser.close(); + if (malformed) return null; + // A file read IN FULL that carries zero elements contributes + // no evidence and no gap — its failure status is provably known-empty, + // unlike the rejections the caller counts. + if (suites === 0) return 'no-suites'; + return { + report: toPosix(relative(root, path)), + tests, + failures, + errors, + skipped, + failedCases, + droppedCases, + }; +} + +function freshTestSummaries( + root: string, + before: ReportSnapshot, +): { + summaries: MavenTestSummary[]; + rejected: number; + truncated: boolean; +} { + const fresh: string[] = []; + const { paths, truncated } = reportPaths(root); + for (const path of paths) { + let mtime: number; + try { + mtime = statSync(path).mtimeMs; + } catch { + continue; + } + const previous = before.mtimes.get(path); + if (previous !== undefined && mtime <= previous) continue; + fresh.push(path); + } + // Byte-order, not localeCompare: evidence-line order must not depend on + // the host's ICU/locale settings (readdir order is not). All paths share + // the same root prefix, so absolute and relative order agree. + fresh.sort(); + const summaries: MavenTestSummary[] = []; + // EVERY fresh report is parsed — a count cap once let failures ordered + // past the cap certify green, because the parsed prefix read clean while + // the unread remainder's failure status was unknown. The sweep's path cap + // and the per-file size cap bound the cost; a parse the strict parser + // REFUSES (oversized, unreadable, or malformed) is unknown evidence and + // fails closed: a disclosed gap there is one a PR can weaponize to hide + // a failing report. A zero-suite file read in full is the opposite — + // known-empty, no gap. + let rejected = 0; + for (const path of fresh) { + const parsed = parseTestReport(root, path); + if (parsed === 'no-suites') continue; + if (parsed) summaries.push(parsed); + else rejected += 1; + } + return { + summaries, + rejected, + // A truncated PRE-run sweep makes the freshness baseline incomplete (a + // committed stale report the pre-walk missed reads as fresh), so both + // truncations fail closed. + truncated: truncated || before.truncated, + }; +} + +/** Report paths are always `/target//` (see reportPaths). */ +function projectDirOf(report: string): string { + return dirname(dirname(dirname(report))); +} + +/** + * Marker lines are mined line-by-line from `output` by the classifiers and + * by test-plan: a newline or control character in a PR-controlled report + * path or case name would split the marker and forge a second line inside + * the scanned output. + */ +function markerSafe(text: string): string { + // U+2028/U+2029 are line terminators for the `m`-flag regexes that mine + // these markers, so a PR-controlled name carrying one (saxes decodes + // `
` to a real U+2028) could forge or split a marker line — strip + // them with the ASCII controls. + // eslint-disable-next-line no-control-regex -- control chars are what gets stripped + return text.replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, '_'); +} + +/** Evidence the run could not read: rejected by the parser, or unseen past a truncated sweep. */ +interface FreshEvidenceGaps { + rejected: number; + truncated: boolean; +} + +/** + * NOTE: the `[maven-test-report]`/`[maven-test-failure]` markers below are + * text-mined by test-plan out of `output`, which is dominated by the PR's + * own test stdout — like the npm console-summary parsing, they are NOT + * tamper-proof. Verdicts that must survive a hostile PR belong in a + * structured report field, not in mined text. + */ +function appendTestSummaries( + result: CommandResult, + summaries: MavenTestSummary[], + gaps: FreshEvidenceGaps, +): CommandResult { + if (summaries.length === 0 && gaps.rejected === 0 && !gaps.truncated) { + return result; + } + + const clean = new Map(); + const failing: MavenTestSummary[] = []; + for (const summary of summaries) { + if (summaryIsFailing(summary)) { + failing.push(summary); + } else { + const project = projectDirOf(summary.report); + const group = clean.get(project); + if (group) group.push(summary); + else clean.set(project, [summary]); + } + } + + const lines: string[] = []; + const cleanGroups = [...clean.entries()].map(([project, group]) => { + // The printed totals are the per-report CLAMPED passed sum, not raw + // Σtests/Σskipped: test-plan parses counts per LINE with its own clamp, + // and Surefire does not guarantee tests >= skipped within one report + // (class-level @Disabled), so raw pre-aggregated totals would parse to + // a different passed count than the clamped per-report truth. + const clampedPassed = group.reduce( + (sum, item) => sum + Math.max(0, item.tests - item.skipped), + 0, + ); + return ( + `[maven-test-report] ${markerSafe(project)} (${group.length} report(s)): ` + + `tests=${clampedPassed}, failures=0, errors=0, skipped=0` + ); + }); + const cleanLines = cleanGroups; + // One line per project dir, UNcapped: bounded by module count, and every + // project keeps its own attributed line — a cap here once dropped module + // attribution past the bound, mis-ruling a scoped count claim whose + // module landed in the omitted tail. + lines.push(...cleanLines); + + // One line per PROJECT dir, like the clean rollup: per-report lines + // collapsed into a byte-order slice that lost ALL module attribution past + // the cap — an upstream module's hundred failing reports pushed the claimed + // module's one past the bound, its failure vanished from every line + // test-plan attributes with, and the `-am` carve-out discarded the run. + // The rollup keeps attribution to the module count, and the failure count + // is floored at the body evidence like the failing bucket itself. + const failedCount = (summary: MavenTestSummary): number => + Math.max( + summary.failures + summary.errors, + summary.failedCases.length + summary.droppedCases, + ); + const failingByProject = new Map(); + for (const summary of failing) { + const project = projectDirOf(summary.report); + const group = failingByProject.get(project); + if (group) group.push(summary); + else failingByProject.set(project, [summary]); + } + const reportLines = [...failingByProject.entries()].map( + ([project, group]) => { + const failures = group.reduce((sum, item) => sum + failedCount(item), 0); + // Per-report CLAMPED passed totals, for the same count-preservation + // reason as the clean rollup: test-plan clamps per parsed LINE, and + // Surefire does not guarantee tests >= failures + skipped within one + // report (class-level @Disabled, rerunFailingTestsCount reruns), so + // raw pre-aggregated totals would let one anomalous report cancel its + // batchmates' passed counts. Emitting tests = passed + failures with + // skipped zeroed makes the line clamp parse back to `passed` while + // `failures` stays non-zero for failureInsideClaim attribution. + const passed = group.reduce( + (sum, item) => + sum + Math.max(0, item.tests - failedCount(item) - item.skipped), + 0, + ); + return ( + `[maven-test-report] ${markerSafe(project)} (${group.length} failing report(s)): ` + + `tests=${passed + failures}, failures=${failures}, errors=0, skipped=0` + ); + }, + ); + if (reportLines.length > MAX_FAILING_REPORT_LINES) { + const omittedProjects = [...failingByProject.entries()].slice( + MAX_FAILING_REPORT_LINES, + ); + reportLines.length = MAX_FAILING_REPORT_LINES; + const omittedSummaries = omittedProjects.flatMap(([, group]) => group); + // Per-report clamped passed totals and zeroed failure fields, for the + // same count-preservation reason as the clean marker above. + const passed = omittedSummaries.reduce( + (sum, item) => + sum + Math.max(0, item.tests - failedCount(item) - item.skipped), + 0, + ); + reportLines.push( + `[maven-test-report] ${omittedProjects.length} more failing project rollup(s) omitted: ` + + `tests=${passed}, failures=0, errors=0, skipped=0`, + ); + // The case lines below carry their own cap, so BOTH attribution + // channels can drop the same module's evidence on a wide-enough + // reactor: keep one module-prefixed failure marker per omitted + // project, or the `-am` carve-out in test-plan discards a run that + // failed inside the claim and reads it unchecked where a narrower + // reactor contradicts. + for (const [project, group] of omittedProjects) { + const failures = group.reduce((sum, item) => sum + failedCount(item), 0); + reportLines.push( + `[maven-test-failure] ${project === '.' ? '' : `${markerSafe(project)}/`}target/: ` + + `${failures} failure(s) past the ${MAX_FAILING_REPORT_LINES}-project rollup cap`, + ); + } + } + lines.push(...reportLines); + + const caseLines = failing.flatMap((summary) => { + const cases = summary.failedCases.map( + (testcase) => + `[maven-test-failure] ${markerSafe(summary.report)}: ${markerSafe(testcase)}`, + ); + // The invariant test-plan's guards key on: failures>0 ⇒ at least one + // [maven-test-failure] line. A report whose header records + // failures with no failing body emits none — hold the + // invariant with a fallback line rather than letting the failure + // vanish from the mined text. + if (cases.length === 0 && summary.droppedCases === 0) { + cases.push( + `[maven-test-failure] ${markerSafe(summary.report)}: ${summary.failures} ` + + `failure(s), ${summary.errors} error(s) recorded without case detail`, + ); + } + return cases; + }); + // The per-report parse cap dropped cases BEFORE this point; their count + // joins the omission marker so count adjudication sees the truncation. + const droppedCases = failing.reduce( + (sum, summary) => sum + summary.droppedCases, + 0, + ); + const totalCaseLines = caseLines.length + droppedCases; + if (totalCaseLines > MAX_FAILURE_CASE_LINES) { + const omitted = totalCaseLines - MAX_FAILURE_CASE_LINES; + caseLines.length = Math.min(caseLines.length, MAX_FAILURE_CASE_LINES); + caseLines.push( + `[maven-test-failure] ${omitted} more failing case(s) omitted`, + ); + } + lines.push(...caseLines); + + if (gaps.rejected > 0) { + lines.push( + `[maven-test-report] ${gaps.rejected} fresh report(s) could not be parsed ` + + '(oversized or unreadable): their failure status is unknown', + ); + } + if (gaps.truncated) { + lines.push( + '[maven-test-report] the report sweep was truncated (a scan cap was ' + + 'reached, a directory could not be read, or the report-path ' + + 'accumulation cap was reached), so some fresh reports may be unseen', + ); + } + + return { ...result, output: `${result.output}\n${lines.join('\n')}`.trim() }; +} + +function unsupportedReport(note: string): BuildTestReport { + return { + toolchain: 'unsupported', + affected: [], + buildSet: [], + widenedWith: [], + install: null, + build: [], + test: [], + ok: true, + timedOut: [], + note, + }; +} + +/** + * How a non-clean best-effort dependency warm-up ended. The timeout note + * quotes the deadline that was actually applied — the whole-call budget + * shortens it below the `--timeout` flag — not the flag default. + */ +function warmUpOutcome( + install: CommandResult, + deadlineSeconds: number, +): string { + return ( + `Dependency warm-up (\`${install.command}\`) ` + + (install.timedOut + ? `ran out of time (${deadlineSeconds}s)` + : install.exitCode === null + ? 'ended without an exit code (a spawn failure or signal outside the deadline)' + : `exited ${install.exitCode}`) + ); +} + +function mavenReport( + fields: Omit, +): BuildTestReport { + return { toolchain: 'maven', ...fields }; +} + +/** + * Maven-framed disk exhaustion. The line-level form is exported so + * `build-test`'s output trim rescues it from the omitted middle — the + * classification below runs on that trimmed output, and an ENOSPC line + * lost to the trim would file a disk failure against the PR (or, under + * fail-never, read the run green). + */ +const DISK_FAILURE_LINE_RE = /^\[(?:ERROR|FATAL)\].*No space left on device/i; + +export function isDiskFailureLine(line: string): boolean { + return DISK_FAILURE_LINE_RE.test(line); +} + +/** + * Shell and JVM launch diagnostics. The runner-missing and JAVA_HOME forms + * are printed bare by the shell or the mvn launcher — never with Maven + * framing — so requiring `[ERROR]` there would miss the real thing. The + * unframed scan therefore stops at the first Maven-framed line: these + * diagnostics precede any Maven output, and once Maven is talking, a test + * printing `mvn: command not found` in its own stdout must not launder a + * source failure into infrastructure. `No space left on device` is different: + * a test exercising a disk-full path can print it in its own stdout at any + * point, so only Maven's own `[ERROR]`/`[FATAL]` framing tells the outage + * from test output — the same argument DEPENDENCY_FAILURE_LINE_RE encodes. + */ +function isLaunchFailure(output: string): boolean { + const lines = output.split('\n'); + const prelude: string[] = []; + for (const line of lines) { + if (/^\[(?:INFO|WARNING|ERROR|FATAL)\]/.test(line)) break; + prelude.push(line); + } + return ( + prelude.some( + (line) => + /(?:mvn|java): (?:command )?not found/i.test(line) || + /command not found: (?:mvn|java)(?:\.cmd)?\b/i.test(line) || + /(?:mvn|java)(?:\.cmd)?'? is not recognized as an internal or external command/i.test( + line, + ) || + /The term '?(?:mvn|java)'? is not recognized/i.test(line) || + /Unknown command: (?:mvn|java)\b/i.test(line) || + /JAVA_HOME.*(?:not defined|not found|incorrectly|invalid directory)/i.test( + line, + ) || + /Unable to locate a Java Runtime/i.test(line) || + // Wrapper bootstrap failures: the distribution download dies before + // Maven's JVM starts, so these wordings can only appear in the + // unframed prelude — the canonical cold-worktree acquisition + // failure, and every review worktree is cold. The wordings are the + // ones the real wrappers emit (apache/maven-wrapper and takari both + // try wget BEFORE curl): apache prints the SHA-256 message verbatim + // on a checksum mismatch, and downloader errors carry curl's + // `curl: (N)` or wget's `wget: …` shapes. + /Failed to validate Maven (?:distribution|wrapper)/i.test(line) || + /Maven distribution.*(?:checksum|corrupt|compromised|invalid)/i.test( + line, + ) || + /Failed to download Maven distribution/i.test(line) || + // The checksum-tool message both wrapper generations print when a + // checksum was requested and neither sha256sum nor shasum exists. + /^Checksum validation was requested but neither/i.test(line) || + // The curl fallback's die message — `curl --silent` suppresses + // curl's own `curl: (N)` line, so the wrapper's die wording is the + // only one on hosts without wget (every macOS host, slim Linux + // containers). + /^curl: Failed to fetch/i.test(line) || + /^(?:curl: \(\d+\)|wget: )/.test(line), + ) || lines.some(isDiskFailureLine) + ); +} + +/** + * Dependency/network/plugin failures count only when Maven itself frames them: + * a test that fails printing `Connection refused` in its stdout is a source + * finding, not a network outage, and free-text matching cannot tell the two + * apart. Maven's own error lines carry the `[ERROR]`/`[FATAL]` prefix. The + * line-level form is exported so `build-test`'s output trim rescues these from + * the omitted middle — the classification below runs on that trimmed output, + * and a marker lost to the trim would file a network outage against the PR. + */ +const DEPENDENCY_FAILURE_LINE_RE = + /^\[(?:ERROR|FATAL)\].*(?:Could not resolve dependencies|Failed to (?:collect|read artifact descriptor)|Could not transfer artifact|Could not find artifact|Failure to find|Non-resolvable parent POM|Non-resolvable import POM|PluginResolutionException|DependencyResolutionException|No plugin found for prefix|Unknown host|Name or service not known|Temporary failure in name resolution|Connection (?:reset|refused|timed out)|PKIX path building failed|status code: (?:401|403|407|429|5\d\d))/i; + +export function isDependencyFailureLine(line: string): boolean { + return DEPENDENCY_FAILURE_LINE_RE.test(line); +} + +function isDependencyFailure(output: string): boolean { + return output.split('\n').some(isDependencyFailureLine); +} + +/** + * Compile and test failure markers Maven itself prints once a run reaches + * building or executing code. A dependency outage can share the output with + * them (a flaky mirror, or an upstream module pulled in by `-am`), and the + * acquisition carve-out must not launder the source failure into an + * infrastructure result: a compile failure writes no Surefire XML, so + * `freshFailures` cannot see it. `[ERROR]`-framed and line-level like + * DEPENDENCY_FAILURE_LINE_RE, for the same trim-rescue reason. + * + * The line shapes cover the JVM compilers Maven hosts: Java's `.java:[l,c]`, + * Kotlin's `.kt: (l, c):`, Scala's `.scala:l:`, Groovy's `.groovy: l:`, plus + * the compiler-plugin goal framing a failed compile ends with. + */ +const SOURCE_FAILURE_LINE_RE = + /^\[(?:ERROR|FATAL)\](?: COMPILATION ERROR| There are test failures| .*\.java:\[\d+,\d+\]| .*\.kts?: ?\(\d+, ?\d+\)| .*\.scala:\d+| .*\.groovy: ?\d+| Failed to execute goal .*Compilation failure)/i; + +export function isSourceFailureLine(line: string): boolean { + return SOURCE_FAILURE_LINE_RE.test(line); +} + +function isSourceFailure(output: string): boolean { + return output.split('\n').some(isSourceFailureLine); +} + +/** + * Maven's framing for ANY failed goal. Under fail-never Maven exits 0 over + * every plugin failure, and the class predicates above only recognize the + * compile/dependency/launch shapes: a checkstyle, enforcer, spotless, or + * jacoco-check goal failure matches none of them, and the zero exit would + * read green. Kept OUT of SOURCE_FAILURE_LINE_RE: a dependency failure's + * `Failed to execute goal on project …` framing must keep reaching the + * dependency class, and the acquisition carve-out negates only the narrow + * source predicate — this wider one feeds the swallowed-failure check. + */ +const GOAL_FAILURE_LINE_RE = /^\[(?:ERROR|FATAL)\] Failed to execute goal /i; + +export function isGoalFailureLine(line: string): boolean { + return GOAL_FAILURE_LINE_RE.test(line); +} + +function isGoalFailure(output: string): boolean { + return output.split('\n').some(isGoalFailureLine); +} + +/** + * Surefire's marker that a skip setting suppressed the entire test phase. + * Printed for `-DskipTests`, `-Dmaven.test.skip=true`, and POM-configured + * `` alike — the one line that distinguishes "tested, zero + * reports" from "never tested at all". + */ +const TESTS_SKIPPED_LINE_RE = /^\[INFO\] Tests are skipped\./; + +export function isTestsSkippedLine(line: string): boolean { + return TESTS_SKIPPED_LINE_RE.test(line); +} + +/** + * Maven-framed framing predicate shared by the never-ran and bootstrap + * checks: once any `[INFO]`/`[WARNING]`/`[ERROR]`/`[FATAL]` line exists, + * Maven's JVM ran. + */ +const MAVEN_FRAMED_LINE_RE = /^\[(?:INFO|WARNING|ERROR|FATAL)\]/; + +function hasMavenFramedLine(output: string): boolean { + return output.split('\n').some((line) => MAVEN_FRAMED_LINE_RE.test(line)); +} + +/** + * Surefire prints a framed `Tests run: N, Failures: M, Errors: K` summary + * per module (and again under `Results:`) even under `testFailureIgnore`, + * when the exit code is 0. A FAILING module is `[ERROR]`-framed (verified on + * Maven 3.8.7 / Surefire 3.2.5, both the per-test-set and the Results line), + * a green one `[INFO]` — anchoring on `[INFO]` alone made the cross-check + * dead against real failing output. The report sweep can miss reports + * written to a non-default ``, so the stdout summary is + * the cross-check that keeps a relocated failing report from certifying + * green. The line-level form is exported so `build-test`'s output trim + * rescues these from the omitted middle — the classification and the + * cross-check both run on that trimmed output. + */ +const SUREFIRE_SUMMARY_LINE_RE = + /^\[(?:INFO|ERROR|FATAL)\] Tests run: \d+, Failures: (\d+), Errors: (\d+)/; + +export function isSurefireSummaryLine(line: string): boolean { + return SUREFIRE_SUMMARY_LINE_RE.test(line); +} + +/** + * A Surefire stdout summary recording failures. The line-level form exists + * for the same reason its siblings do: `build-test`'s trim rescue keeps + * these lines ahead of benign matches, and the exit-0 cross-check reads + * them from the trimmed output. + */ +export function isFailingSurefireSummaryLine(line: string): boolean { + const match = SUREFIRE_SUMMARY_LINE_RE.exec(line); + return match !== null && (Number(match[1]) > 0 || Number(match[2]) > 0); +} + +function hasStdoutTestFailure(output: string): boolean { + return output.split('\n').some(isFailingSurefireSummaryLine); +} + +/** + * A report is failing on its header counts OR its body evidence: a report + * whose `failures="0" errors="0"` attributes contradict its ``/ + * `` testcase bodies (a green-wash the file's threat model admits — + * PR-writable reports rewritten after the run) is failing, and the parsed + * proof of failure is not discarded. + */ +function summaryIsFailing(summary: MavenTestSummary): boolean { + return ( + summary.failures > 0 || + summary.errors > 0 || + summary.failedCases.length > 0 || + summary.droppedCases > 0 + ); +} + +function hasFreshTestFailure(summaries: MavenTestSummary[]): boolean { + return summaries.some(summaryIsFailing); +} + +/** + * Maven's rejection of a `-pl` selector naming a project it does not have in + * the active reactor. This is the ONE piece of Maven's model this adapter + * reads back, and it reads it from Maven rather than recomputing it: profile + * activation, `` inheritance, and JDK-conditional membership all land + * here already evaluated. + */ +const SELECTOR_REJECTED_RE = + /^\[(?:ERROR|FATAL)\] Could not find the selected project in the reactor:\s*([^\n]*)/m; + +function summaryTotals(summaries: MavenTestSummary[]) { + return summaries.reduce( + (sum, item) => { + const headerFailed = item.failures + item.errors; + const bodyFailed = item.failedCases.length + item.droppedCases; + return { + tests: sum.tests + item.tests, + // Body evidence is authoritative like the failing bucket's: a + // zeroed header over failing bodies still reports a non-zero total. + failures: + sum.failures + item.failures + Math.max(0, bodyFailed - headerFailed), + errors: sum.errors + item.errors, + skipped: sum.skipped + item.skipped, + }; + }, + { tests: 0, failures: 0, errors: 0, skipped: 0 }, + ); +} + +/** + * The `-pl` argument for a module set, or null when it cannot be handed to a + * shell safely — the caller then widens to the full reactor. + * + * These are directory names read off disk, so the character gate lives here: + * nothing upstream filters them any more. `,` separates `-pl` arguments and + * `:` makes Maven read a selector as `[groupId]:artifactId` coordinates + * instead of a path, so both change the MEANING of the selector; `%` is + * cmd.exe variable expansion, which a `"…"` wrap does not stop. A LEADING + * `-` makes Maven's commons-cli re-read the value as an option (`-pl -rf` + * dies with 'Missing argument for option: pl'), and a leading `!` is + * Maven's exclusion operator — quoting preserves the value but not the + * semantics, so both widen to the full reactor like the rest. + */ +export function shellSelector( + modules: string[], + platform: string = process.platform, +): string | null { + if (modules.length === 0) return null; + if ( + modules.some( + (module) => + /[,:%]/.test(module) || + module.startsWith('-') || + module.startsWith('!'), + ) + ) { + return null; + } + const selector = modules.join(','); + if (/^[A-Za-z0-9_./,-]+$/.test(selector)) return selector; + // The command runs through cmd.exe on Windows, where POSIX quoting is + // literal. With `%` rejected above, the remaining hazards are `"` and `|`, + // and a Windows filename can contain neither — so the wrap holds. + return platform === 'win32' ? `"${selector}"` : shellQuotePath(selector); +} + +/** + * The wrapper a platform can actually execute. Every wrapper repo ships both + * `mvnw` and `mvnw.cmd`; `./mvnw` is not runnable under win32 `cmd.exe`. On + * POSIX a wrapper without the executable bit (a `core.fileMode=false` + * checkout) falls back to the system `mvn` rather than dying with exit 126 + * and turning the whole run into an infrastructure handoff that verifies + * nothing. + */ +export function mavenExecutable( + root: string, + platform: string = process.platform, +): string { + if (platform === 'win32') { + try { + const stats = statSync(join(root, 'mvnw.cmd')); + // isFile() matters like its size gate: a DIRECTORY named `mvnw.cmd` + // passes existence and size checks but cannot execute. + if (stats.isFile() && stats.size > 0) return 'mvnw.cmd'; + } catch { + // absent + } + return 'mvn'; + } + const wrapper = join(root, 'mvnw'); + try { + accessSync(wrapper, constants.X_OK); + const stats = statSync(wrapper); + // An EMPTY wrapper passes the existence/exec-bit gates, exits 0, and + // the run would certify a build that never started — fall back to + // system `mvn` exactly like the missing-bit case. A DIRECTORY named + // `mvnw` is searchable (passes X_OK) but dies exit 126 on execution — + // same fallback, same reason as the isFile() gate in + // mavenConfigDependencyInputs. + if (!stats.isFile() || stats.size === 0) return 'mvn'; + return './mvnw'; + } catch { + return 'mvn'; + } +} + +/** + * The argument tokens of `.mvn/maven.config`. Maven reads it line-by-line — + * each non-empty, non-`#` line is ONE argument (MavenCli: + * `Files.lines(...).filter(arg -> !arg.isEmpty() && !arg.startsWith("#"))`), + * no whitespace splitting: an argument can carry a space (`ci/my + * settings.xml`), and a `#` line is a comment even when its text names + * flags. Mirror that reader; whitespace tokenizing recorded a truncated path + * for spaced arguments and tokenized comments into inputs. + */ +function mavenConfigTokens(root: string): string[] | null { + const configPath = join(root, '.mvn', 'maven.config'); + let config: string; + try { + // Oversized configs fail closed like an unreadable one — the `.mvn/` + // prefix still marks the config file itself as a dependency input in the + // changed-files check. The isFile() gate matters as much as the size cap: + // a symlink to /dev/zero or a FIFO reports size 0, passes the cap, and + // hangs readFileSync forever. + const stats = statSync(configPath); + if (!stats.isFile() || stats.size > MAX_CONFIG_BYTES) return null; + config = readFileSync(configPath, 'utf8'); + } catch (error) { + // No config file is a legitimate, readable state (no facts, no + // ambiguity). A config that EXISTS but cannot be tokenized (read error + // on a regular file under the cap) is the ambiguous state — Maven reads + // the same file with no cap and honors its exit-0-changing flags, so we + // must distrust a bare exit 0 over it. `statSync` threw for a missing + // path; distinguish via the code. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + return null; + } + // Maven reads the file with Files.lines, whose readLine semantics + // terminate a line on \n, \r\n, OR a lone \r — split on all three, or a + // CR-only config mashes several arguments into one token and bypasses the + // classification below (a scope-altering flag read as inert releases). + return config + .split(/\r\n|\n|\r/) + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('#')); +} + +/** + * Everything the verdict reads from `.mvn/maven.config`, classified by ONE + * spec-referenced tokenizer: the settings/local-repository locations Maven's + * launcher injects into this run (dependency inputs), the settings that + * change what exit 0 means (fail-never, testFailureIgnore), the settings + * that silence or redirect the stdout channel (quiet, log-file), skip-tests + * settings — and whether any token defied classification. + * + * Ambiguity fails CLOSED: when the tokenizer cannot confidently read the + * config, the verdict proceeds as if fail-never/testFailureIgnore were set + * and distrusts a bare exit 0. Arg-parsing imprecision can then only ever + * be stricter, never release. + */ +interface MavenConfigFacts { + quiet: boolean; + failNever: boolean; + testFailureIgnore: boolean; + skipTests: boolean; + /** `-Dmaven.main.skip` — compilation itself is skipped. */ + mainSkip: boolean; + logFile: boolean; + ambiguous: boolean; + dependencyInputs: string[]; +} + +function analyzeMavenConfig(root: string): MavenConfigFacts { + const facts: MavenConfigFacts = { + quiet: false, + failNever: false, + testFailureIgnore: false, + skipTests: false, + mainSkip: false, + logFile: false, + ambiguous: false, + dependencyInputs: [], + }; + const addInput = (value: string | undefined): void => { + if (!value) return; + const path = normalizedChangedPath(root, value); + if (path !== null) facts.dependencyInputs.push(path); + }; + // commons-cli accepts these spellings of two settings that change what + // exit 0 means; keep them beside the tokenizer that classifies them. + const quietFlags = new Set(['-q', '--quiet', '-quiet']); + const failNeverFlags = new Set(['-fn', '--fail-never', '-fail-never']); + // Flags that take the NEXT config line as their value (Maven reads the + // file line-by-line and hands each line to commons-cli as one argument). + const settingsFlags = new Set([ + '-s', + '--settings', + '-settings', + '-gs', + '--global-settings', + '-global-settings', + ]); + const logFileFlags = new Set(['-l', '--log-file', '-log-file']); + // Paired flags that do NOT change scope or exit-0 semantics (thread count + // only). Scope-altering paired flags are NOT consumed here — see the + // scope-altering sets below. + const otherPairedFlags = new Set(['-T', '--threads']); + // Scope-altering flags must not read as inert: `-pl moduleA` in a + // PR-writable config makes Maven build only `moduleA` while the harness + // believes it ran the reactor, certifying green over modules whose tests + // never ran — a RELEASE, inverting the invariant that imprecision can only + // be stricter. So any scope-altering flag makes the config ambiguous + // (fail closed). + const scopeAlteringPaired = new Set([ + '-pl', + '--projects', + '-rf', + '--resume-from', + '-P', + '--activate-profiles', + ]); + const scopeAlteringValueless = new Set(['-N', '--non-recursive']); + // Valueless flags commons-cli knows: classified so they do not trip the + // ambiguity fail-closed below. Not exhaustive — an unknown flag is + // exactly the shape ambiguity exists to catch. + const valuelessFlags = new Set([ + '-ff', + '--fail-fast', + '-fae', + '--fail-at-end', + '-B', + '--batch-mode', + '-ntp', + '--no-transfer-progress', + '-o', + '--offline', + '-U', + '--update-snapshots', + '-e', + '--errors', + '-X', + '--debug', + '-N', + '--non-recursive', + '-am', + '--also-make', + '-amd', + '--also-make-dependents', + '-llr', + '--legacy-local-repository', + '-legacy-local-repository', + // commons-cli single-dash long spelling; starts with `-l` but redirects + // nothing, so it must not read as an attached log-file value. + '-lax-checksums', + '--lax-checksums', + ]); + const classifyDefine = (property: string): void => { + // Maven 3.9's chained local repositories: EVERY entry is a local- + // repository location. The two prefixes are disjoint — + // `-Dmaven.repo.local.tail=` diverges from `-Dmaven.repo.local=` at + // `.tail`, not `=`. Maven splits the tail property on COMMA only + // (DefaultRepositorySystemSessionFactory: `localRepoTail.split(",")`); + // a `|` is part of a path here, not a separator. + if (property.startsWith('maven.repo.local.tail=')) { + for (const part of property + .slice('maven.repo.local.tail='.length) + .split(',')) { + if (!part) continue; + addInput(part); + } + return; + } + // Maven defaults a VALUELESS define to `true`, and + // `-Dmaven.repo.local`/`-Dmaven.repo.local.tail` with no `=` redirect + // the local repository to `/true` — so the bare spellings are + // dependency inputs too; requiring the `=` let them slip past. + if ( + property === 'maven.repo.local.tail' || + property.startsWith('maven.repo.local.tail=') + ) { + const value = + property === 'maven.repo.local.tail' + ? 'true' + : property.slice('maven.repo.local.tail='.length); + for (const part of value.split(',')) { + if (!part) continue; + addInput(part); + } + return; + } + if ( + property === 'maven.repo.local' || + property.startsWith('maven.repo.local=') + ) { + addInput( + property === 'maven.repo.local' + ? 'true' + : property.slice('maven.repo.local='.length), + ); + return; + } + // A bare property (no `=`) defaults to true in Maven. + if (/^maven\.test\.failure\.ignore(=true)?$/i.test(property)) { + facts.testFailureIgnore = true; + return; + } + if (/^(skipTests|maven\.test\.skip(?:\.exec)?)(=true)?$/i.test(property)) { + facts.skipTests = true; + return; + } + // Properties that can filter execution down to ZERO tests: `-Dtest=…` / + // `-Dit.test=…` select classes by pattern, and + // `-Dsurefire.failIfNoSpecifiedTests=false` suppresses the "no tests + // matched" failure — together they let a run execute nothing and exit 0. + // They change what exit 0 means exactly like skipTests, so classify them + // the same way (strict: distrust a bare exit 0 over a PR-writable test + // filter). + if ( + /^(test|it\.test)=/.test(property) || + /^surefire\.failIfNoSpecifiedTests(=false)?$/i.test(property) + ) { + facts.skipTests = true; + return; + } + // The zero-test selection filters: `-Dgroups=…`/`-DexcludedGroups=…` + // select tests by JUnit category and `-Dsurefire.includesFile=…`/ + // `-Dsurefire.excludesFile=…` by file list; a value matching nothing + // runs ZERO tests and exits 0 with no reports and no skip marker. Same + // strict classification as the other test filters. + if ( + /^(groups|excludedGroups)=/.test(property) || + /^surefire\.(includes|excludes)File=/.test(property) + ) { + facts.skipTests = true; + return; + } + // `-Dmaven.main.skip=true` (maven-compiler-plugin's documented skip + // property) skips COMPILATION ITSELF — in build-only mode that is the + // very work the run exists to verify, so record it as a compile-skip + // fact the build-only verdict distrusts. + if (/^maven\.main\.skip(=true)?$/i.test(property)) { + facts.mainSkip = true; + return; + } + }; + const tokens = mavenConfigTokens(root); + // A config that EXISTS but cannot be tokenized (oversized, not a regular + // file, unreadable) is ambiguous: Maven reads it with no cap and honors + // its exit-0-changing flags, so we must distrust a bare exit 0 over it. + if (tokens === null) { + facts.ambiguous = true; + return facts; + } + for (let i = 0; i < tokens.length; i += 1) { + let token = tokens[i]; + // commons-cli pairs a value-less `-D` with the NEXT line exactly like + // the attached spelling — join the pair so defines see one shape. + if ( + (token === '-D' || token === '--define' || token === '-define') && + tokens[i + 1] !== undefined + ) { + token = `-D${tokens[i + 1]}`; + i += 1; + } + if (quietFlags.has(token)) { + facts.quiet = true; + continue; + } + if (failNeverFlags.has(token)) { + facts.failNever = true; + continue; + } + // Scope-altering flags fail closed to ambiguous (see the sets above). + if (scopeAlteringPaired.has(token)) { + facts.ambiguous = true; + i += 1; + continue; + } + if (scopeAlteringValueless.has(token)) { + facts.ambiguous = true; + continue; + } + // Attached scope-altering spellings (`-pl`, `-P`, + // `-rf`, and the `--…=…` forms). + if ( + /^(-pl|-P|-rf|--projects|--resume-from|--activate-profiles).+/.test(token) + ) { + facts.ambiguous = true; + continue; + } + if (settingsFlags.has(token) || logFileFlags.has(token)) { + const value = tokens[i + 1]; + i += 1; + if (settingsFlags.has(token)) addInput(value); + else facts.logFile = true; + continue; + } + if (otherPairedFlags.has(token)) { + i += 1; + continue; + } + // The attached thread-count spellings (`-T1C`, `--threads=1C`) carry + // their value in-token — consume them inert like the paired form, or + // they fall through to the ambiguity catch-all and distrust a bare + // exit 0 over the common attached spelling. + if (/^-T.+/.test(token) || /^--threads=.+/.test(token)) { + continue; + } + // Attached define spellings: `-D…`, `--define=…`, `-define=…`, and the + // short form's `=` separator (`-D=…`, which commons-cli strips). + if (token.startsWith('-D')) { + classifyDefine(token.slice(2).replace(/^=/, '')); + continue; + } + if (token.startsWith('--define=')) { + classifyDefine(token.slice('--define='.length)); + continue; + } + if (token.startsWith('-define=')) { + classifyDefine(token.slice('-define='.length)); + continue; + } + // Attached settings/log-file spellings — checked BEFORE the + // attached-short regexes, because `-settings=…` also starts with `-s`. + if (token.startsWith('--settings=')) { + addInput(token.slice('--settings='.length)); + continue; + } + if (token.startsWith('--global-settings=')) { + addInput(token.slice('--global-settings='.length)); + continue; + } + if (token.startsWith('-settings=')) { + addInput(token.slice('-settings='.length)); + continue; + } + if (token.startsWith('-global-settings=')) { + addInput(token.slice('-global-settings='.length)); + continue; + } + if (token.startsWith('--log-file=') || token.startsWith('-log-file=')) { + facts.logFile = true; + continue; + } + // Known valueless flags win over the attached-short regexes: + // `-legacy-local-repository` starts with `-l` but is the single-dash + // long spelling commons-cli matches before the `-l` short option, so it + // must not read as an attached log-file value. + if (valuelessFlags.has(token)) continue; + // commons-cli also accepts the attached short forms (`-s`): the + // remainder of a token whose option bears an argument becomes the + // value; the `=` of an attached `-s=` is part of the separator + // and stripped for single-char short options. + if (/^-gs.+/.test(token)) { + addInput(token.slice('-gs'.length).replace(/^=/, '')); + continue; + } + if (/^-s.+/.test(token)) { + addInput(token.slice('-s'.length).replace(/^=/, '')); + continue; + } + if (/^-l.+/.test(token)) { + facts.logFile = true; + continue; + } + if (!token.startsWith('-')) continue; // goals and bare values are inert + // A flag this tokenizer cannot classify: fail closed. + facts.ambiguous = true; + } + return facts; +} + +function runMavenToolchain(args: ToolchainRunArgs): BuildTestReport { + const perCommandMs = args.timeout * 1000; + /** The deadline a command was actually given, in whole seconds — the + * whole-call budget shortens it below the flag default, and timeout + * notes must quote the number that fired. */ + const deadlineSecs = (r: CommandResult): number => + Math.round((r.deadlineMs ?? perCommandMs) / 1000); + // The floor never exceeds the caller's own per-command deadline: a run + // whose whole budget is one short deadline still gets that attempt, + // exactly as it did before budgeting existed. + const attemptFloorMs = Math.min(BUDGET_MIN_ATTEMPT_MS, perCommandMs); + // The whole-call budget the npm adapter runs under, for the same reason: + // the warm-up and the lifecycle command SUM against the outer tool + // timeout, and a cold reactor whose warm-up takes the whole sum leaves + // the lifecycle command nothing — or the outer kill discards the report. + // It is wall clock from the TOP of the call, like the npm adapter's and + // the toolchain.ts contract: ownership detection and the report sweep are + // PR-controlled work too — a wide worktree costs real time before the first + // exec — and charging only exec time let that work run uncounted while the + // commands were still granted the full budget, summing past the outer tool + // timeout whose kill discards the report. + const callBudgetMs = + (args.budget ?? Math.max(args.timeout, args.timeout * 2 - 30)) * 1000; + const callStartedAt = Date.now(); + let ranACommand = false; + /** Budget left for the whole call; every phase spends from it. */ + const remainingMs = (): number => callBudgetMs - (Date.now() - callStartedAt); + /** + * Whether a command may still be attempted. The floor never exceeds the + * caller's own per-command deadline: a run whose whole budget is one + * short deadline still gets that attempt, exactly as it did before + * budgeting existed — so the FIRST attempt keys on the granted budget, + * not the remainder (pre-command parsing spends a few milliseconds of + * wall clock, and comparing the floor to the remainder would starve a + * budget that equals one short deadline, including the zero-deadline + * edge the spawn boundary coerces to 1ms). Later attempts key on what + * remains. + */ + const enoughForAttempt = (): boolean => + ranACommand + ? remainingMs() >= attemptFloorMs + : callBudgetMs >= attemptFloorMs; + const ownership = detectMavenOwnership(args.root, args.changedFiles); + if (!ownership.reactorWide && ownership.modules.length === 0) { + return mavenReport({ + affected: [], + buildSet: [], + widenedWith: [], + install: null, + build: [], + test: [], + ok: true, + timedOut: [], + note: + `The diff changes ${args.changedFiles.length} file(s), but none of them needs a Maven build ` + + 'or test (documentation, repository metadata, or nothing inside a Maven module). There is no ' + + 'Maven target to run — this is a complete answer.', + }); + } + + const executable = mavenExecutable(args.root); + // The wrapper is the script AND its configuration: + // `.mvn/wrapper/maven-wrapper.properties` names the distribution the script + // downloads and executes, so a diff touching it controls what `./mvnw` + // runs exactly as one touching the script does. + const wrapperConfigChanged = args.changedFiles.some((file) => { + const path = normalizedChangedPath(args.root, file); + return path !== null && path.startsWith('.mvn/wrapper/'); + }); + const wrapperChanged = + wrapperConfigChanged || + args.changedFiles.some((file) => { + const path = normalizedChangedPath(args.root, file); + return path === 'mvnw' || path === 'mvnw.cmd'; + }); + // Every wrapper repo ships both platform variants, but only ONE is ever + // executed here: a diff touching only the other platform's wrapper cannot + // affect this run, so the carve-out suppressions below key on the file + // this platform executes, not on either wrapper. + const executedWrapper = + executable === './mvnw' + ? 'mvnw' + : executable === 'mvnw.cmd' + ? 'mvnw.cmd' + : null; + const executedWrapperChanged = + executedWrapper !== null && + (wrapperConfigChanged || + args.changedFiles.some( + (file) => normalizedChangedPath(args.root, file) === executedWrapper, + )); + // The platform-preferred wrapper, whether or not it was executed: when the + // diff deletes it (or drops its executable bit), mavenExecutable falls back + // to system `mvn`, executedWrapper is null, and the fallback's launch death + // is the diff's own doing, not an environmental result. + const platformWrapper = process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw'; + const platformWrapperChanged = args.changedFiles.some( + (file) => normalizedChangedPath(args.root, file) === platformWrapper, + ); + // The dependency carve-out below must not file a PR-caused breakage as + // environmental: when the diff changed a POM, `.mvn/**`, the settings or + // repository locations `.mvn/maven.config` references, or the executed + // wrapper (which can redirect the local repository or settings), the + // resolution failure may be the diff's own doing. + // + // All config facts come from ONE tokenizer pass (see analyzeMavenConfig): + // dependency inputs, the settings that change what exit 0 means + // (fail-never / testFailureIgnore), the settings that silence or redirect + // the stdout channel (quiet / log-file), skip-tests settings, and an + // ambiguity flag that fails the verdict closed. + const configFacts = analyzeMavenConfig(args.root); + // Distrust a bare exit 0: fail-never and testFailureIgnore let Maven exit + // 0 over failures, and an UNREADABLE config grammar is assumed to carry + // them too (the tokenizer's fail-closed direction — imprecision can only + // ever be stricter, never release). + const distrustExit0 = + configFacts.failNever || + configFacts.testFailureIgnore || + configFacts.ambiguous; + const dependencyInputsChanged = args.changedFiles.some((file) => { + const path = normalizedChangedPath(args.root, file); + if (path === null) return false; + if (path.startsWith('.mvn/')) return true; + if (executedWrapper !== null && path === executedWrapper) return true; + if ( + configFacts.dependencyInputs.some( + (input) => path === input || path.startsWith(`${input}/`), + ) + ) { + return true; + } + // ANY POM outside test-data shapes, whether or not Maven ends up + // treating it as a reactor member. Which POMs feed resolution is the + // effective model's answer — a `` file, an aggregator the diff + // just activated, a POM the diff deleted — and guessing it here is the + // approximation this adapter no longer makes. Over-counting only + // withdraws an infrastructure carve-out, which files a failure against + // the PR instead of the environment; under-counting ships the PR's own + // breakage as someone else's outage. The exclusion therefore covers only + // the fixture locations (`src/test/`, `src/it/` — invoker ITs, archetype + // projects): a reactor CAN aggregate a real module under a bare `src/` + // path (`src/core` — the same premise the ownership + // walk fails closed on), and that POM feeds resolution like any other. + return ( + /(?:^|\/)pom\.xml$/.test(path) && !/(?:^|\/)src\/(?:test|it)\//.test(path) + ); + }); + const lifecycle = args.buildOnly ? 'test-compile' : 'test'; + // `-am` builds the changed modules plus their upstream closure. `-amd` + // (downstream) selects the whole reactor on exactly the repos this adapter + // was built for, and a run that spends its whole deadline proving nothing + // is the failure this command exists to avoid — downstream coverage stays + // the project's CI matrix, as with the npm adapter's scope. + const selector = ownership.reactorWide + ? null + : shellSelector(ownership.modules); + const selectorOverflow = + selector !== null && selector.length > MAX_SELECTOR_CHARS; + // A module directory a shell selector cannot carry safely widens the run + // rather than narrowing it wrongly. + const selectorUnsafe = !ownership.reactorWide && selector === null; + const reactorWide = + ownership.reactorWide || selectorOverflow || selectorUnsafe; + const affected = reactorWide ? ['.'] : ownership.modules; + const buildSet = reactorWide ? ['.'] : ownership.modules; + // `selector` is non-null whenever `reactorWide` is false: a null selector + // sets `selectorUnsafe`, which sets `reactorWide`. Read it through a local + // so the narrowing can never interpolate a null into the command line. + const narrowing = + reactorWide || selector === null ? '' : ` -pl ${selector} -am`; + const command = `${executable} --batch-mode --no-transfer-progress${narrowing} ${lifecycle}`; + // The same three values the command line was just rendered from. They ride + // on the recorded result so `test-plan` can settle a claim against what + // this run scoped without parsing the rendering back into its inputs. + const mavenFacts: MavenCommandFacts = { + lifecycle, + modules: narrowing === '' ? null : ownership.modules, + alsoMake: narrowing !== '', + globalSkip: configFacts.skipTests, + }; + // Disk preflight, mirroring the npm adapter: Maven resolves plugins and + // dependencies inside the lifecycle command, and a run that dies on ENOSPC + // leaves a full disk that fails every agent scheduled after this one. + // The 3 GiB install floor applies to the warm-up that downloads; a + // --no-install run's contract in this file is "assume warm, fetch + // nothing", so it gets the lifecycle's own 1 GiB build floor instead — + // the second preflight below already admits the same command at it. + const entryFloorBytes = args.install + ? INSTALL_MIN_FREE_BYTES + : BUILD_MIN_FREE_BYTES; + const free = freeDiskBytes(args.root); + if (free !== null && free < entryFloorBytes) { + return mavenReport({ + affected, + buildSet, + widenedWith: [], + install: null, + build: [], + test: [], + ok: false, + timedOut: [], + note: + `Insufficient disk space (${gib(free)}G free, need ~${gib(entryFloorBytes)}G): ` + + `skipped \`${command}\`. Maven resolves dependencies inside the lifecycle ` + + 'command, so nothing could be built or tested. This is an environment ' + + 'issue, not a code finding — report it as informational.', + }); + } + // Dependency warm-up on its own deadline. A review worktree is cold by + // construction, and Maven resolves dependencies and plugins INSIDE the + // lifecycle command, sharing the single deadline with compilation and the + // tests — a cold resolve on the large reactors this adapter targets can + // spend the whole budget downloading and verify nothing, exactly the + // timeout-as-infrastructure outcome the command exists to prevent. + // `dependency:go-offline` is best-effort: it has known gaps (some plugin + // dependencies resolve lazily), and the lifecycle command resolves what it + // missed exactly as before. Unlike a partial `node_modules`, a partial + // local repository is content-addressed and resumable — never worse than + // none — so no warm-up outcome blocks the lifecycle run. Gated on the same + // install flag as `npm ci`: `--no-install` means "assume warm, fetch + // nothing". + let install: CommandResult | null = null; + if (args.install && enoughForAttempt()) { + ranACommand = true; + install = args.exec( + `${executable} --batch-mode --no-transfer-progress${narrowing} dependency:go-offline -q`, + args.root, + Math.max(0, Math.min(perCommandMs, remainingMs())), + ); + } + if (!enoughForAttempt()) { + // "Was spent" needs a consumer: name the floor instead when the + // grant itself was below it from the start. + let note = + !ranACommand && callBudgetMs < attemptFloorMs + ? `The granted budget (${Math.round(callBudgetMs / 1000)}s) is below the ` + + `${Math.round(attemptFloorMs / 1000)}s minimum a Maven attempt needs, so nothing ` + + 'could be started, built, or tested. This is an infrastructure result, ' + + 'not a defect in the diff — report it as informational.' + : `The whole-call budget (${Math.round(callBudgetMs / 1000)}s) was spent ` + + `before \`${command}\` could start, so nothing could be built or tested. ` + + 'This is an infrastructure result, not a defect in the diff — report it as informational.'; + if (install) { + note += + ` ${warmUpOutcome(install, deadlineSecs(install))} — the budget it consumed ` + + 'is what stopped the lifecycle command.'; + } + return mavenReport({ + affected, + buildSet, + widenedWith: [], + install, + build: [], + test: [], + ok: false, + timedOut: install?.timedOut ? [install.command] : [], + note, + }); + } + // The warm-up is the phase that fills the disk — re-check the floor + // before the lifecycle command, mirroring the npm adapter's SECOND + // preflight: a cold reactor's dependency:go-offline can consume the + // headroom the pre-warm-up check passed, and a lifecycle that dies on + // ENOSPC leaves a full disk that fails every agent scheduled after it. + const freeForLifecycle = freeDiskBytes(args.root); + if (freeForLifecycle !== null && freeForLifecycle < BUILD_MIN_FREE_BYTES) { + return mavenReport({ + affected, + buildSet, + widenedWith: [], + install, + build: [], + test: [], + ok: false, + timedOut: install?.timedOut ? [install.command] : [], + note: + `Insufficient disk space (${gib(freeForLifecycle)}G free, need ~${gib(BUILD_MIN_FREE_BYTES)}G): ` + + `skipped \`${command}\` — ` + + (install + ? 'the dependency warm-up consumed the headroom the preflight before it passed.' + : 'free space fell below the build floor between the preflight and the lifecycle command.') + + ' This is an environment issue, not a code finding — report it as informational.', + }); + } + // A build-only run never reads the evidence, so it skips the snapshot too + // — on a large reactor that is a readdir + statSync sweep of every + // reports dir for nothing. + const before = args.buildOnly ? null : snapshotReports(args.root); + ranACommand = true; + const executedRaw = args.exec( + command, + args.root, + Math.max(0, Math.min(perCommandMs, remainingMs())), + ); + // Strip SGR once, before ANY classification reads the output: every + // predicate below anchors on Maven's `[INFO]`/`[ERROR]` framing, and a + // `-Dstyle.color=always` in `.mvn/maven.config` interleaves color codes + // that defeat all of them — colored bytes would launder a failed compile + // into a green verdict. + const executed = { + ...executedRaw, + output: executedRaw.output.replace(ANSI_SGR_RE, ''), + }; + // Maven's own answer to "is this project in the active reactor". It is the + // authority on profile activation, `` inheritance, and JDK- + // conditional membership, and it rejects an unknown selector before + // compiling anything — so a standalone or profile-inactive project costs one + // fast failure here instead of a second reactor model in this file. + const rejected = SELECTOR_REJECTED_RE.exec(executed.output); + const fresh = before + ? freshTestSummaries(args.root, before) + : { summaries: [], rejected: 0, truncated: false }; + const summaries = fresh.summaries; + const hasReports = summaries.length > 0; + const result = { + ...appendTestSummaries(executed, summaries, { + rejected: fresh.rejected, + truncated: fresh.truncated, + }), + maven: mavenFacts, + }; + // The report-level contract names EVERY command killed by its deadline — + // the npm adapter pushes its install command, and a non-empty `timedOut` + // is the brief's infrastructure signal, so a warm-up timeout must not + // read as if nothing timed out. + const timedOut = [ + ...(install?.timedOut ? [install.command] : []), + ...(result.timedOut ? [result.command] : []), + ]; + // A fresh report recording failures outranks a green exit: surefire's + // `testFailureIgnore` (or `-Dmaven.test.failure.ignore`) lets `mvn test` + // exit 0 over failing tests, and the verdict must read the evidence. + const freshFailures = hasFreshTestFailure(summaries); + // This exit-0 shape carries NONE of the classification flags recorded + // below — swallowedFailure/testsSuppressed/neverRan all key on the + // ABSENCE of fresh failing reports, and evidenceCapped keys on unread + // evidence — yet the verdict is ok:false. Record the shape itself so + // test-delta's failure filter and test-plan's count mining read it as a + // failed run instead of reporting the all-clear over it. + const swallowedReports = + result.exitCode === 0 && !result.timedOut && freshFailures; + // A genuine selector rejection fail-fasts BEFORE any test runs, at every + // exit code and under every fail mode — so it never coexists with fresh + // reports. When reports exist the wording was echoed by test stdout, and + // discarding the run would hide captured evidence (genuine failures, or + // the very green the forged line claims to protect) behind an unsupported + // handoff. The same holds when fresh reports exist but were REJECTED or + // the sweep/trim was capped: those are the evidence states the machine + // below fails closed on via `evidenceCapped`, and an echoed rejection line + // must not discard them into an unsupported handoff either. + if ( + rejected && + summaries.length === 0 && + fresh.rejected === 0 && + !fresh.truncated && + result.rescueOverflow !== true + ) { + return unsupportedReport( + `Maven rejected the selected project(s) — ${rejected[1].trim()} — as not part of the active reactor. ` + + 'They are standalone or profile-inactive under the current profiles and JDK, so this run verified ' + + 'nothing and no other scope was guessed.', + ); + } + // Evidence the run could not read fails CLOSED — a rejected parse + // (oversized, unreadable, or malformed XML the strict parser refused) and + // a truncated sweep (freshness baseline incomplete) are gaps a PR can + // weaponize to hide a failing report, and the trim's rescue cap dropping + // failure-evidence lines is the same epistemic state: classification read + // an output whose verdict-relevant lines may be gone. + const evidenceCapped = + fresh.rejected > 0 || fresh.truncated || result.rescueOverflow === true; + // --- The verdict state machine --- + // Three authoritative signals only: the exit code, the parsed report + // elements, and the config settings that change what exit 0 means + // (fail-never/testFailureIgnore, or an ambiguous config grammar assumed + // to carry them). Green requires POSITIVE structural evidence — exit 0 + // and no parsed failing element — never the mere absence of a failure + // wording. The human-stdout scrapers (source/dependency/launch/goal + // wording, `Tests run:` summaries) are a FALLBACK for runs that produced + // no reports; with reports present the structured signals judge, and the + // scrapers can only ever CONVICT under a distrusted exit 0 — arg-parsing + // imprecision can only be stricter, never release. + // A skip setting (`-DskipTests`/`-Dmaven.test.skip=true` in + // `.mvn/maven.config`, or a POM ``) lets `mvn test` exit 0 + // having executed ZERO tests, and Surefire's skip path emits none of the + // framed errors the scrapers scan for — without this check a run that + // tested nothing is certified green, and Test Plan count claims become + // uncontradictable. The config spelling and the stdout marker both feed + // it. The marker is NOT gated on the absence of reports: a module-local + // skip writes no reports while `-am` upstream modules do, and any "Tests + // are skipped." line inside the run's own scope means part of that scope + // was not tested. Build-only runs have no test phase, so skip settings + // are irrelevant to them. + const testsSuppressed = + !args.buildOnly && + (configFacts.skipTests || + result.output.split('\n').some((line) => isTestsSkippedLine(line))); + // A build-only run has no test phase, but `-Dmaven.main.skip` skips the + // COMPILATION ITSELF — the very work the run exists to verify — while + // still exiting 0. Distrust a bare exit 0 over it exactly like a + // suppressed test phase, or the run certifies green having compiled + // nothing. + const compileSuppressed = args.buildOnly && configFacts.mainSkip; + const stdoutTestFailures = hasStdoutTestFailure(result.output); + // A NON-EMPTY wrapper can still exit 0 without launching Maven (a stub + // `#!/bin/sh` edit keeps the exec bit): zero fresh reports AND zero + // Maven-framed output means the build never started — "never ran", not + // "tested nothing". A diff-modified wrapper CONTROLS the output channel + // (a stub can echo framed lines), and a `-q`/`--quiet` or `-l`/`--log- + // file` setting silences or redirects it — with no reports on disk, none + // of those states can prove a build started, so all read unverified. An + // UNMODIFIED wrapper's evidence still decides: fresh reports or framed + // output show a build demonstrably ran — a plain `.mvn/wrapper/` bump + // runs the whole reactor green and must not read as never run — while a + // silent stub still fails this check. + const neverRan = + result.exitCode === 0 && + !result.timedOut && + !testsSuppressed && + // A build-only run's evidence is the compile's exit code — it skips the + // report snapshot, so `hasReports` is structurally false; the + // quiet/log-file/wrapper signals must not misread it as "never ran". + !args.buildOnly && + !hasReports && + (executedWrapperChanged || + configFacts.quiet || + configFacts.logFile || + !hasMavenFramedLine(result.output)); + const swallowedFailure = + result.exitCode === 0 && + !result.timedOut && + !freshFailures && + (testsSuppressed || + compileSuppressed || + // No reports: the stdout channel is the only evidence left, so the + // scrapers judge — surefire's `Tests run:` summaries survive a + // relocated `` the sweep cannot see, and framed + // errors a fail-never setting swallowed live nowhere else. + (!hasReports && + (stdoutTestFailures || + isSourceFailure(result.output) || + isDependencyFailure(result.output) || + isLaunchFailure(result.output) || + isGoalFailure(result.output))) || + // Distrusted exit 0 (fail-never/testFailureIgnore set, or the config + // grammar unreadable): exit-code semantics are broken, so framed + // failure evidence convicts even beside clean reports. Surefire + // echoes test stdout verbatim, so this arm stays conviction-only and + // restricted to the wordings a real fail-never build frames — the + // strict direction. + (distrustExit0 && + hasReports && + (stdoutTestFailures || + isSourceFailure(result.output) || + isGoalFailure(result.output)))); + const ok = + result.exitCode === 0 && + !result.timedOut && + !freshFailures && + !swallowedFailure && + !evidenceCapped && + !neverRan; + // Environmental classification for a failing run. When reports exist the + // structured evidence says tests RAN — the non-zero exit is attributed to + // the PR side (over-attribution, never an environmental wash). With no + // reports, the scrapers classify acquisition deaths: `-pl -am` + // builds AND tests the upstream modules first, so the first `[INFO] + // Running` line prints long before the changed module is even resolved, + // and a dependency-resolution, launch, or disk death after it is still + // the run's own death — cutting at the first test phase once filed a + // transient registry outage (and a mid-command ENOSPC) as a defect in the + // PR. Every carve-out carries a diff-inputs exception: when the PR + // changed the wrapper or the dependency inputs, the failure may be the + // diff's own doing and must not be laundered into an environmental + // result. + const acquisitionFailure = + !ok && + !freshFailures && + !hasReports && + // Capped evidence can launder a source failure into infrastructure: + // the trim's rescue cap drops lines positionally, so dependency-flavored + // lines can survive while a source-class line is lost — and the + // dependency arm below would then match. `ok` already refuses capped + // runs; the acquisition classification must not read them either. Fail + // toward over-attribution — the direction this machine's own comment + // mandates. + !evidenceCapped && + !isSourceFailure(result.output) && + // Executed failing tests record themselves in the stdout summaries even + // when the sweep misses their XML: dependency-flavored assertion text + // (`Connection refused`, `Unknown host`) otherwise matches the + // dependency matcher and launders a genuine test failure into an + // infrastructure result. + !stdoutTestFailures && + result.exitCode !== null && + ((isLaunchFailure(result.output) && + !executedWrapperChanged && + !(executable === 'mvn' && platformWrapperChanged)) || + (isDependencyFailure(result.output) && !dependencyInputsChanged) || + // Shape-classified, not wording-classified: bash/dash localize + // these diagnostics under a non-English LANG, so the match keys on + // the structure — an unmodified wrapper dying at a launch exit code + // with no Maven-framed output — like the silent bootstrap arm below. + (executable === './mvnw' && + !executedWrapperChanged && + (result.exitCode === 126 || result.exitCode === 127) && + !hasMavenFramedLine(result.output)) || + // The system-`mvn` twin of the shape arm: the wording regexes above + // only match English, contradicting the shape arm's own localization + // rationale. A diff that removed the platform wrapper answers for + // the fallback's launch death, so the carve-out stays suppressed + // there exactly like the wrapper arm. + (executable === 'mvn' && + !platformWrapperChanged && + (result.exitCode === 126 || result.exitCode === 127) && + !hasMavenFramedLine(result.output)) || + // Wrapper bootstrap download deaths with NO wording to match: wget + // (both wrapper generations try it before curl) runs `--quiet` in the + // distribution download, so a DNS failure exits 4 and a server error + // exits 8 with an EMPTY unframed output; the curl fallback dies on + // its codes 6/7/22/28 (resolve, connect, HTTP error, timeout) the + // same way. If Maven's JVM had started, framed output would exist — + // its absence pins the death to bootstrap. + (executedWrapper !== null && + !executedWrapperChanged && + [4, 6, 7, 8, 22, 28].includes(result.exitCode) && + !hasMavenFramedLine(result.output))); + const recorded = { + ...result, + // These flags are how test-plan and test-delta see the adapter's exit-0 + // ok:false outcomes: a run carrying ANY of them must not settle a Test + // Plan claim, and test-delta must not report the all-clear over it. + // They are set independently — a swallowed failure under a fail-never + // setting can coincide with capped evidence (the no-reports swallowed + // arm plus a rescue-overflow trim are reachable together). + // `infrastructure`, by contrast, cannot coincide with `evidenceCapped`: + // acquisitionFailure gates on `!evidenceCapped`. + ...(acquisitionFailure ? { infrastructure: true } : {}), + ...(swallowedFailure ? { swallowedFailure: true } : {}), + ...(evidenceCapped ? { evidenceCapped: true } : {}), + ...(testsSuppressed ? { testsSuppressed: true } : {}), + ...(neverRan ? { neverRan: true } : {}), + ...(swallowedReports ? { swallowedReports: true } : {}), + }; + const report = mavenReport({ + affected, + buildSet, + widenedWith: [], + install, + build: args.buildOnly ? [recorded] : [], + test: args.buildOnly ? [] : [recorded], + ok, + timedOut, + note: '', + }); + + if ((result.timedOut || result.exitCode === null) && freshFailures) { + // A deadline kill or spawn death does not retroactively excuse the test + // failures Surefire/Failsafe already recorded: name the interruption as + // infrastructure, but keep the captured regressions as test evidence. + const totals = summaryTotals(summaries); + const cause = result.timedOut + ? `ran out of time (${deadlineSecs(result)}s)` + : 'ended without an exit code (a spawn failure or signal outside the deadline)'; + report.note = + `\`${result.command}\` ${cause} — that part is infrastructure. But fresh ` + + `Surefire/Failsafe reports written before it record ${totals.failures} ` + + `failure(s) and ${totals.errors} error(s): treat those as test failures, ` + + 'not as a pass or as purely environmental.'; + } else if ( + (result.timedOut || result.exitCode === null) && + stdoutTestFailures + ) { + // The sibling arm's principle, applied to stdout evidence: the + // interruption does not retroactively excuse the failures Surefire's + // framed summaries already recorded — name them instead of asserting a + // purely informational result. + const cause = result.timedOut + ? `ran out of time (${deadlineSecs(result)}s)` + : 'ended without an exit code (a spawn failure or signal outside the deadline)'; + report.note = + `\`${result.command}\` ${cause} — that part is infrastructure. But its ` + + 'captured output records Surefire test failures (`Tests run: …` summaries ' + + 'with non-zero Failures/Errors): treat those as test failures, not as ' + + 'purely environmental.'; + } else if ( + (result.timedOut || result.exitCode === null) && + (isSourceFailure(result.output) || isGoalFailure(result.output)) + ) { + // The sibling arms' principle, applied to source/goal evidence: under + // fail-never/fail-at-end a PR-caused compile or goal failure does not + // end the build — it runs on to the deadline, and the interruption + // must not launder the captured failure into pure infrastructure. + const cause = result.timedOut + ? `ran out of time (${deadlineSecs(result)}s)` + : 'ended without an exit code (a spawn failure or signal outside the deadline)'; + report.note = + `\`${result.command}\` ${cause} — that part is infrastructure. But its ` + + 'captured output records source or goal failures (`[ERROR]`-framed ' + + 'compile or plugin-goal errors) the run never exited on: correlate them ' + + 'with the changed files before treating this as purely environmental.'; + } else if (result.timedOut) { + report.note = + `\`${result.command}\` ran out of time (${deadlineSecs(result)}s). This is an infrastructure result, ` + + 'not a defect in the diff — report it as informational.'; + if (selectorOverflow) { + report.note += + ' The scope widened to reactor-wide because the changed-module `-pl` selector exceeded ' + + `${MAX_SELECTOR_CHARS} characters; on large reactors that scope usually cannot finish ` + + 'within this deadline, so re-running it at the same scope will spend the same budget ' + + 'for the same result.'; + } else if (selectorUnsafe) { + report.note += + ' The scope widened to reactor-wide because a changed module directory carries a ' + + 'character a `-pl` selector cannot express; on large reactors that scope usually cannot ' + + 'finish within this deadline, so re-running it at the same scope will spend the same ' + + 'budget for the same result.'; + } else if (ownership.reactorWide) { + report.note += + ' The scope is reactor-wide because the diff changes inputs every module inherits; ' + + 'on large reactors that scope usually cannot finish within this deadline, so re-running ' + + 'it at the same scope will spend the same budget for the same result.'; + } + } else if (result.exitCode === null) { + // A spawn-level death (output past maxBuffer, an outside signal) leaves no + // exit code and nothing to correlate — infrastructure, like a timeout. + report.note = + `\`${result.command}\` ended without an exit code (a spawn failure or signal outside the deadline). ` + + `This is ${NOTE_INFRASTRUCTURE_EVIDENCE}, not a source finding.`; + } else if (acquisitionFailure) { + report.note = + `\`${result.command}\` failed while acquiring or starting Maven, Java, plugins, or dependencies` + + (result.exitCode === 0 + ? ' — a fail-never setting masked the failure with exit 0' + : '') + + `. This is ${NOTE_INFRASTRUCTURE_EVIDENCE}, not a source finding.`; + } else if (!ok && result.exitCode === 0 && freshFailures) { + const totals = summaryTotals(summaries); + // A fail-never setting produces the identical shape (exit 0 over failing + // reports), so name the actual cause instead of hardcoding one — the + // same distinction the generic exit-0 arm's cascade makes. + const swallowCause = configFacts.failNever + ? 'a fail-never setting (`-fn`/`--fail-never`)' + : 'a testFailureIgnore-style setting'; + report.note = + `\`${result.command}\` exited 0 but fresh Surefire/Failsafe reports record ` + + `${totals.failures} failure(s) and ${totals.errors} error(s) — ${swallowCause} ` + + 'is swallowing them. Treat these as test failures, not a pass.'; + } else if (!ok && result.exitCode === 0 && evidenceCapped) { + const gapReasons: string[] = []; + if (fresh.rejected > 0) { + gapReasons.push( + `${fresh.rejected} fresh report(s) could not be parsed (oversized, ` + + 'unreadable, or malformed XML the strict parser refused)', + ); + } + if (fresh.truncated) { + gapReasons.push( + 'the report sweep was truncated, so some fresh reports may be unseen', + ); + } + if (result.rescueOverflow === true) { + gapReasons.push( + 'the output trim dropped failure-evidence lines past its rescue cap', + ); + } + report.note = + `\`${result.command}\` exited 0, but ${gapReasons.join('; ')} — their ` + + 'failure status is unknown, so the run is not certified as a pass.' + + (swallowedFailure + ? ' The output also records failures Maven did not fail on.' + : ''); + } else if (!ok && result.exitCode === 0 && compileSuppressed) { + report.note = + `\`${result.command}\` exited 0 but compilation was suppressed: a ` + + '`-Dmaven.main.skip` setting in `.mvn/maven.config` skips the compile ' + + 'mojos a build-only run exists to verify, so nothing was compiled. ' + + 'Treat this as an unverified run, not a pass.'; + } else if (!ok && result.exitCode === 0 && testsSuppressed) { + const suppressionSource = configFacts.skipTests + ? 'a skip setting (`-DskipTests`/`-Dmaven.test.skip`) in `.mvn/maven.config`' + : 'Maven reporting `Tests are skipped.` — a skip setting (`-DskipTests`/' + + '`-Dmaven.test.skip` in `.mvn/maven.config` or a POM ``)'; + // Word the scope honestly: a module-local skip coexists with fresh + // passing reports from other in-scope modules, so "skipped every test" + // is false whenever any report shows a test DID run. + report.note = hasReports + ? `\`${result.command}\` exited 0 with part of the scope suppressed: ${suppressionSource} ` + + 'skipped tests for part of the scope, so that part was not tested ' + + '(other modules ran, per the appended reports). ' + + 'Treat this as an unverified run, not a pass.' + : `\`${result.command}\` exited 0 with the test phase suppressed: ${suppressionSource} ` + + 'skipped every test, so nothing was tested. ' + + 'Treat this as an unverified run, not a pass.'; + } else if (!ok && result.exitCode === 0 && neverRan) { + const cause = executedWrapperChanged + ? 'the wrapper this run executed is changed by the diff, so it controls the output ' + + 'channel and any framed lines in it prove nothing' + : configFacts.logFile + ? 'a `-l`/`--log-file` setting in `.mvn/maven.config` redirected the build output ' + + 'to a file, so the stdout channel proves nothing' + : configFacts.quiet + ? 'a `-q`/`--quiet` (or single-dash `-quiet`) setting in `.mvn/maven.config` ' + + 'suppressed every line Maven prints' + : 'no fresh reports and no Maven output at all, so the build never ran ' + + '(an empty or stub wrapper passes the launch gates and exits 0)'; + report.note = + `\`${result.command}\` exited 0 without verifiable evidence that a build ran — ` + + `${cause}. Treat this as an unverified run, not a pass.`; + } else if (!ok && result.exitCode === 0) { + const distrustedCause = configFacts.failNever + ? 'a fail-never setting (e.g. `-fn`/`--fail-never` in `.mvn/maven.config`)' + : configFacts.testFailureIgnore + ? 'a testFailureIgnore-style setting (`-Dmaven.test.failure.ignore`)' + : configFacts.ambiguous + ? 'an unreadable `.mvn/maven.config` (unrecognized arguments — assumed to ' + + 'swallow failures, the strict direction)' + : 'a testFailureIgnore-style surefire setting, though echoed test output prints the same lines'; + report.note = + `\`${result.command}\` exited 0 but the evidence records failures the exit code did ` + + `not fail on — ${distrustedCause}. Treat this as a failed run, not a pass.`; + } else if (!ok) { + report.note = + `\`${result.command}\` failed. ${NOTE_CORRELATE_ERRORS} with the changed files; ` + + 'fresh module-qualified Surefire/Failsafe summaries are appended when available.'; + } else if (args.buildOnly) { + report.note = + `Maven compiled ${reactorWide ? 'the full reactor' : ownership.modules.join(', ')}. ` + + 'Tests were not run (build-only).'; + } else if (summaries.length === 0) { + report.note = + `Maven tested ${reactorWide ? 'the full reactor' : ownership.modules.join(', ')} successfully, ` + + 'but produced no fresh Surefire/Failsafe XML (reports written to a non-default directory are not seen here), ' + + 'so test-count evidence is unavailable.'; + } else { + const totals = summaryTotals(summaries); + report.note = + `${NOTE_MAVEN_TEST_PASSED} with fresh reports: ${totals.tests} tests, ${totals.failures} failures, ` + + `${totals.errors} errors, ${totals.skipped} skipped across ${summaries.length} report(s).`; + } + if (!reactorWide) { + report.note += + ' Scope: this run covered the changed modules and their upstream dependencies only ' + + '(`-pl … -am`); downstream dependents were NOT built — a POM or API change can break ' + + "modules this run never compiled, and that coverage stays with the project's CI."; + } else if (selectorOverflow) { + report.note += + ` Scope: the changed-module \`-pl\` selector exceeded ${MAX_SELECTOR_CHARS} characters — ` + + 'a command line platforms may refuse to launch — so this run covered the full reactor ' + + 'instead of the changed modules and their upstream dependencies.'; + } else if (selectorUnsafe) { + report.note += + ' Scope: a changed module directory carries a character a `-pl` selector cannot express ' + + '(`,` and `:` change what the selector means to Maven; `%` expands in cmd.exe; a leading ' + + '`-` or `!` reads as an option or an exclusion), so this run covered the full reactor ' + + 'instead of the changed modules and their upstream dependencies.'; + } + if (install && (install.timedOut || install.exitCode !== 0)) { + report.note += + ` ${warmUpOutcome(install, deadlineSecs(install))} — it is best-effort, and the ` + + 'lifecycle outcome above stands on its own.'; + } + if (wrapperChanged && !executedWrapperChanged) { + report.note += + executable === 'mvn' + ? ' Note: the diff changes the Maven wrapper, but this run used the system ' + + '`mvn` instead of it, so the wrapper change itself was not exercised.' + : ` Note: the diff changes the Maven wrapper, but this run executed \`${executable}\`, ` + + 'so the wrapper change itself was not exercised.'; + } + if (ok && executedWrapperChanged) { + // The run certified green, but the wrapper it executed is one the diff + // itself changed. Fresh failing reports still override a green exit, so + // this only fires over structurally clean evidence — yet the wrapper is + // the very program that LAUNCHED the build, and a diff-controlled + // launcher could pin versions, inject settings, or skip work. Name the + // caveat next to the green verdict instead of silently trusting it. + report.note += + ' Note: this run is green, but it executed a Maven wrapper the diff itself ' + + 'changed — a diff-controlled launcher could inject settings or skip work, ' + + 'so confirm the wrapper change is benign before relying on this verdict.'; + } + if (isRegularFile(join(args.root, 'package.json'))) { + // A mixed root: npm's applies() refused the root package.json (an + // unmodeled workspace glob, a zero-package glob, or no build/test + // script), so Maven was selected ALONE — the npm half is unscopable + // here, and a green Maven run must not certify it. The isFile() gate + // matters: a DIRECTORY named `package.json` fails npm's applies() too + // (EISDIR swallowed to no manifests), so the caveat would be false. + report.note += + ' Mixed root: a root package.json exists that this run did not scope — ' + + 'files outside the Maven reactor (npm/frontend sources) were NOT verified.'; + } + return report; +} + +/** Existence AND regular file: a DIRECTORY carrying the name passes + * existsSync but is not a manifest — the same gate mavenExecutable and + * the config tokenizer apply. */ +function isRegularFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +export const mavenToolchainAdapter: ReviewToolchainAdapter = { + applies: (root) => isRegularFile(join(root, 'pom.xml')), + run: runMavenToolchain, +}; diff --git a/packages/cli/src/commands/review/lib/npm-toolchain.ts b/packages/cli/src/commands/review/lib/npm-toolchain.ts index 81f3ea27be5..688422766fa 100644 --- a/packages/cli/src/commands/review/lib/npm-toolchain.ts +++ b/packages/cli/src/commands/review/lib/npm-toolchain.ts @@ -801,11 +801,10 @@ export const npmToolchainAdapter: ReviewToolchainAdapter = { // actually scope something: MODELED workspaces that resolve to at least one // package, or a root build/test script. Mirroring the run-side gate here // matters at mixed roots: an unmodeled-glob declaration (`packages/**`, - // `foo-*`) or a zero-package glob used to apply npm anyway, block a second - // adapter's selection, and drop the repo to the very `unsupported` handoff - // this guard exists to prevent — even though npm.run would immediately - // concede unsupported and the other adapter alone would have succeeded. - // When ZERO adapters + // `foo-*`) or a zero-package glob used to apply npm anyway, block Maven + // selection, and drop the repo to the very `unsupported` handoff this guard + // exists to prevent — even though npm.run would immediately concede + // unsupported and mvn.run alone would have succeeded. When ZERO adapters // apply at an npm-shaped root, runBuildTest delegates here anyway so the // report carries runNpmToolchain's precise handoff note (the unmodeled-glob // gate below is that diagnostic path, not dead code). diff --git a/packages/cli/src/commands/review/lib/toolchain.ts b/packages/cli/src/commands/review/lib/toolchain.ts index 53874d6f5e6..9e019e141a7 100644 --- a/packages/cli/src/commands/review/lib/toolchain.ts +++ b/packages/cli/src/commands/review/lib/toolchain.ts @@ -11,7 +11,9 @@ export interface ToolchainRunArgs { changedFiles: string[]; timeout: number; /** - * Gates the adapter's dependency-acquisition step — npm's `npm ci` today. + * Gates the dependency-acquisition step: npm's `npm ci` and Maven's + * best-effort `dependency:go-offline` warm-up. Maven still resolves + * whatever the warm-up misses inside its lifecycle command. */ install: boolean; buildOnly?: boolean; diff --git a/packages/cli/src/commands/review/test-delta.test.ts b/packages/cli/src/commands/review/test-delta.test.ts index bf4c9ee3037..999e065437b 100644 --- a/packages/cli/src/commands/review/test-delta.test.ts +++ b/packages/cli/src/commands/review/test-delta.test.ts @@ -9,7 +9,7 @@ // pre-existing flake into a public Critical (or the reverse). The base rerun // itself is a seam — one command in one cwd — so the exec is injected. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -23,6 +23,22 @@ import { } from './test-delta.js'; import type { BuildTestReport, CommandResult } from './build-test.js'; +// A passthrough spy on the real spawn: the fractional-timeout pin below must +// observe the OPTIONS handed to spawnSync. A reverted coercion throws +// ERR_OUT_OF_RANGE synchronously and nothing on the call path catches it, so +// the outcome-level test catches the revert as a hard failure — but a crash +// only proves spawn REJECTED the value; this probe proves what it RECEIVED. +const spawnSpy = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', async (importOriginal) => { + const actual = + (await importOriginal()) as typeof import('node:child_process'); + spawnSpy.mockImplementation((...args: unknown[]) => + (actual.spawnSync as (...a: unknown[]) => unknown)(...args), + ); + const mod = { ...actual, spawnSync: spawnSpy }; + return { ...mod, default: mod }; +}); + const cmd = (over: Partial): CommandResult => ({ command: 'npm test --workspace="packages/core"', exitCode: 1, @@ -172,6 +188,52 @@ describe('runTestDelta', () => { expect(cwds).toEqual([baseline]); }); + it('does not admit a timed-out NON-Maven command that merely echoes a maven marker', () => { + // R2-2 over-inclusion: the timed-out arm gates on the STRUCTURED Maven + // fact, so an `npm test` that echoes a `[maven-test-failure] ` line + // (this repo's own suites carry literal marker strings failing + // assertions echo) is not admitted and re-executed — a green rerun + // would manufacture netNew failures out of an infrastructure timeout. + const calls: string[] = []; + runWith( + [ + cmd({ + command: 'npm test', + timedOut: true, + exitCode: null, + output: '[maven-test-failure] core/target/x.xml: T#fails', + }), + ], + (command) => { + calls.push(command); + return cmd({ command, output: '' }); + }, + ); + expect(calls).toEqual([]); + }); + + it('admits a timed-out Maven command carrying stdout-only failure evidence', () => { + // R2-2 under-inclusion: a timed-out `./mvnw test` with a failing + // Surefire summary but NO report markers still carries failure evidence + // and must join the disclosure, not drop out to the all-clear note (the + // opposite of build-test's verdict). A Maven command never matches the + // rerun grammar, so it lands in skippedUnrecognised. + const r = runWith( + [ + cmd({ + command: + './mvnw --batch-mode --no-transfer-progress -pl core -am test', + timedOut: true, + exitCode: null, + output: '[ERROR] Tests run: 5, Failures: 2, Errors: 0, Skipped: 0', + maven: { lifecycle: 'test', modules: ['core'], alsoMake: true }, + }), + ], + () => cmd({ output: '' }), + ); + expect(r.note).not.toContain('no PR-side test command failed'); + }); + it('an empty netNew with everything shared is the pre-existing verdict', () => { // The live-run case this exists for: 3 env-sensitive failures that the // model previously had to JUDGE as pre-existing become a measurement. @@ -332,10 +394,75 @@ describe('runTestDelta', () => { expect(ran).toEqual([]); expect(r.entries).toEqual([]); expect(r.netNew).toEqual([]); - expect(r.note).toContain('not the shape'); + expect(r.note).toContain('outside the npm rerun grammar'); expect(r.note).toContain('judge them by the diff'); }); + it('refuses the Maven lifecycle commands the Maven adapter records', () => { + // build-test's test[] can now carry Maven command strings; the rerun + // grammar stays npm-only. Pin the guard so a future widening of + // RERUNNABLE_COMMAND_RE — or a Maven report handed straight to + // `qwen review test-delta --report` — cannot re-execute a Maven + // lifecycle command in the base worktree. + const ran: string[] = []; + const r = runWith( + [ + cmd({ + command: + './mvnw --batch-mode --no-transfer-progress -pl core -am test', + output: '[ERROR] Tests failed', + }), + ], + (command) => { + ran.push(command); + return cmd({ command, output: '' }); + }, + ); + expect(ran).toEqual([]); + expect(r.entries).toEqual([]); + expect(r.note).toContain('outside the npm rerun grammar'); + // The disclosure names the deliberate exclusion, not a grammar accident. + expect(r.note).toContain('Maven lifecycle commands'); + }); + + it('reads the exit-0 Maven verdict flags as failures, not an all-clear', () => { + // The adapter marks these runs ok:false at exit 0 — a fail-never + // setting swallowing failures, a skip setting suppressing the phase, + // a wrapper that never started Maven, evidence the adapter refused to + // certify. Reading "no PR-side test command failed" off the exit + // codes alone would state the opposite of build-test's verdict. The + // commands are still never re-executed (the npm rerun grammar), but + // they join the disclosure instead of the reassuring all-clear. + for (const flag of [ + { swallowedFailure: true }, + { testsSuppressed: true }, + { neverRan: true }, + { evidenceCapped: true }, + // Exit 0 over fresh FAILING reports: no other flag fires for this + // shape, and missing it reported the all-clear over a run build-test + // marked ok:false. + { swallowedReports: true }, + ] as const) { + const ran: string[] = []; + const r = runWith( + [ + cmd({ + command: './mvnw --batch-mode --no-transfer-progress test', + exitCode: 0, + ...flag, + }), + ], + (command) => { + ran.push(command); + return cmd({ command, output: '' }); + }, + ); + expect(ran).toEqual([]); + expect(r.note).not.toContain('no PR-side test command failed'); + expect(r.note).toContain('outside the npm rerun grammar'); + } + }); + it('reruns both shapes build-test actually emits', () => { const ran: string[] = []; runWith( @@ -453,6 +580,50 @@ describe('runTestDelta', () => { expect(r.note).toContain('the whole-command budget shortened'); }); + it('coerces a fractional --timeout at the spawn boundary', () => { + // A decimal --timeout lands as a fractional deadline (60.123s * 1000 + // = 60122.99999999999); spawnSync validates `timeout` as an unsigned + // integer and throws ERR_OUT_OF_RANGE on the raw value — with no + // report at all. Every other test injects the exec seam and bypasses + // the coercion, so this one drives the REAL spawn. + const r = runTestDelta({ + report: writeReport([ + cmd({ command: 'npm test', output: 'FAIL src/a.test.ts' }), + ]), + baseline, + timeout: 60.1234, + }); + // The rerun executed (npm fails fast in the empty base dir) instead + // of throwing out of the whole call. + expect(r.entries).toHaveLength(1); + expect(r.entries[0].base.timedOut).toBe(false); + // The base output is the trim's TEXT — a plain string, never the + // `{text, evidenceDropped}` shape trimOutput returns. Losing the + // `.text` adaptation would serialize a nested object into the report. + expect(typeof r.entries[0].base.output).toBe('string'); + }); + + it('hands spawnSync an integral, positive timeout for a fractional budget', () => { + // The outcome-level probe above catches a reverted coercion as a hard + // crash — the ERR_OUT_OF_RANGE throw propagates out of the whole call, + // so NO report of any shape is produced — but the crash only proves + // spawn rejected the value; pin the spawn OPTIONS directly to prove + // what it received. The input is genuinely fractional (60.1234 * 1000 + // is not integral): 60.123 used to pass even with the coercion + // reverted, because its deadline is exact in JS. + spawnSpy.mockClear(); + runTestDelta({ + report: writeReport([ + cmd({ command: 'npm test', output: 'FAIL src/a.test.ts' }), + ]), + baseline, + timeout: 60.1234, + }); + const opts = spawnSpy.mock.calls[0]?.[1] as { timeout?: number }; + expect(Number.isInteger(opts.timeout)).toBe(true); + expect(opts.timeout).toBeGreaterThanOrEqual(1); + }); + it('refuses an unreadable report and a missing base tree without throwing', () => { expect( runTestDelta({ report: join(dir, 'nope.json'), baseline, timeout: 60 }) diff --git a/packages/cli/src/commands/review/test-delta.ts b/packages/cli/src/commands/review/test-delta.ts index b12d3c36764..2449cfb12aa 100644 --- a/packages/cli/src/commands/review/test-delta.ts +++ b/packages/cli/src/commands/review/test-delta.ts @@ -50,13 +50,19 @@ import { type BuildTestReport, type CommandResult, } from './build-test.js'; +import { + isFailingSurefireSummaryLine, + isGoalFailureLine, + isSourceFailureLine, +} from './lib/maven-toolchain.js'; // eslint-disable-next-line no-control-regex -- ESC is the character under test const ANSI_SGR_RE = /\x1b\[[0-9;]*m/g; /** - * The exact shapes `build-test` emits for a test command — and the only ones - * this command will hand to a shell. + * The exact shapes this command will hand to a shell: build-test's npm test + * commands. Maven lifecycle commands build-test ALSO records are skipped and + * disclosed, never re-executed in the base worktree. * * The report is a FILE this reads and then executes from, with `shell: true`, * in the base worktree. Nothing else in the pipeline re-executes a string it @@ -65,10 +71,10 @@ const ANSI_SGR_RE = /\x1b\[[0-9;]*m/g; * pull request can choose: `packages/x";curl …|sh;"` is a legal path in git * and on Linux, and it round-trips through the report into a shell. * - * Restricting to the emitter's own grammar costs nothing real — `build-test` - * produces `npm test` and `npm test --workspace=""`, both matched here — - * and anything outside it is skipped and disclosed rather than run, which is - * the same treatment every other thing this command cannot do gets. + * Restricting to the npm grammar costs nothing real — `build-test` produces + * `npm test` and `npm test --workspace=""`, both matched here — and + * anything outside it is skipped and disclosed rather than run, which is the + * same treatment every other thing this command cannot do gets. */ const RERUNNABLE_COMMAND_RE = /^npm test(?: --workspace="[\w@./-]+")?$/; @@ -209,7 +215,10 @@ function run(command: string, cwd: string, timeoutMs: number): BaseRunResult { shell: true, cwd, encoding: 'utf8', - timeout: timeoutMs, + // build-test's coercion, deliberately: spawnSync validates `timeout` as + // an unsigned integer, and a fractional --timeout reaches it through + // the same budget arithmetic. + timeout: Math.max(1, Math.round(timeoutMs)), env: buildRunEnv(process.env), maxBuffer: 64 * 1024 * 1024, // build-test's, deliberately: "a build that asks a question is a build that @@ -232,7 +241,7 @@ function run(command: string, cwd: string, timeoutMs: number): BaseRunResult { // is JSON.stringify'd to --out, and the verdict fields sit AFTER it — an // untrimmed megabyte pushes exactly what the command produces past any // reader's truncation. - output: trimOutput(raw), + output: trimOutput(raw).text, }; } @@ -273,9 +282,49 @@ export function runTestDelta(args: TestDeltaArgs): TestDeltaReport { ); } - // Failed for real: a timeout is an infrastructure result and reruns as one. + // Failed for real: a timeout is an infrastructure result and reruns as + // one. Maven's exit-0 verdict flags are failures too — the adapter marks + // the report ok:false with them, and reading "no PR-side test command + // failed" off exit codes alone would state the opposite of build-test's + // verdict for a run that swallowed failures or tested nothing. They are + // still never re-executed (the npm rerun grammar below), but they join + // the disclosure instead of the reassuring all-clear. const failed = (report.test ?? []).filter( - (t) => !t.timedOut && t.exitCode !== 0, + (t) => + (!t.timedOut && + (t.exitCode !== 0 || + t.swallowedFailure === true || + t.testsSuppressed === true || + t.neverRan === true || + t.evidenceCapped === true || + // Exit 0 over fresh failing reports (a testFailureIgnore-style + // setting): no other flag fires for this shape, and without it the + // filter reports the all-clear over a run build-test marked + // ok:false — the exact opposite of that verdict. + t.swallowedReports === true)) || + // A deadline kill does not retroactively excuse the test failures + // Surefire already recorded: build-test marks the run ok:false and its + // note says "treat those as test failures", yet every exit-0 flag + // requires !timedOut, so the entry carries none and this filter would + // drop it — reporting the opposite of build-test's verdict. Gate on + // the STRUCTURED Maven fact (only the Maven adapter sets `maven`), so + // an npm test that merely echoes a marker string is not admitted, and + // broaden to the stdout-only failure shapes a timed-out run can carry + // without the report markers. Route it into the disclosure: a Maven + // command never matches the rerun grammar and lands in + // skippedUnrecognised, named rather than silently lost. + (t.timedOut === true && + t.maven !== undefined && + (/^\[maven-test-failure\] /m.test(t.output ?? '') || + (t.output ?? + '' + .split('\n') + .some( + (line) => + isFailingSurefireSummaryLine(line) || + isSourceFailureLine(line) || + isGoalFailureLine(line), + )))), ); if (failed.length === 0) { return empty( @@ -396,7 +445,7 @@ export function runTestDelta(args: TestDeltaArgs): TestDeltaReport { } if (skippedUnrecognised.length) { parts.push( - `${skippedUnrecognised.length} failed command(s) were not rerun because they are not the shape \`build-test\` emits (${skippedUnrecognised.join(', ')}) — this command executes what the report names, so it executes only that grammar; their failures stay unattributed, judge them by the diff`, + `${skippedUnrecognised.length} failed command(s) were not rerun because they are outside the npm rerun grammar (${skippedUnrecognised.join(', ')}) — build-test also records Maven lifecycle commands, which this command deliberately never re-runs in the base worktree; their failures stay unattributed, judge them by the diff`, ); } if (truncated) { diff --git a/packages/cli/src/commands/review/test-plan.test.ts b/packages/cli/src/commands/review/test-plan.test.ts index 5e2f374f452..c56548a99a5 100644 --- a/packages/cli/src/commands/review/test-plan.test.ts +++ b/packages/cli/src/commands/review/test-plan.test.ts @@ -27,7 +27,8 @@ import { type TestPlanClaim, type TestPlanArgs, } from './test-plan.js'; -import type { BuildTestReport } from './build-test.js'; +import type { BuildTestReport, CommandResult } from './build-test.js'; +import { mavenToolchainAdapter, shellSelector } from './lib/maven-toolchain.js'; describe('extractTestPlanSection', () => { it('finds a `## Test Plan` heading and stops at the next same-level heading', () => { @@ -123,6 +124,16 @@ describe('extractTestPlanSection', () => { }); describe('extractClaims', () => { + it('keeps capitalized legacy-runner prose out of the command claims', () => { + // Case-insensitivity is granted to the Maven alternation alone + // (`MVNW test`): the script adjudicators stay case-sensitive, so + // capitalized legacy-runner spans would only add claims that can never + // settle. + expect(extractClaims('Needs `Node.js`; run `NPM RUN test:unit`')).toEqual( + [], + ); + }); + it('picks commands and paths out of code spans', () => { const claims = extractClaims( 'Ran `npm run build` and `npm test --workspace=packages/cli`.\nAdded `packages/cli/src/a.test.ts`.', @@ -366,10 +377,10 @@ describe('extractClaims', () => { }); describe('observedTestCounts', () => { - const report = (outputs: string[]): BuildTestReport => + const report = (outputs: string[], command = 'npm test'): BuildTestReport => ({ test: outputs.map((output) => ({ - command: 'npm test', + command, exitCode: 0, seconds: 1, timedOut: false, @@ -389,6 +400,259 @@ describe('observedTestCounts', () => { ).toEqual([12]); }); + it('reads fresh Maven report summaries and counts passed tests', () => { + expect( + observedTestCounts( + report( + [ + '[maven-test-report] core (1 failing report(s)): tests=12, failures=3, errors=0, skipped=3\n' + + '[maven-test-report] app (1 report(s)): tests=8, failures=0, errors=0, skipped=1', + ], + './mvnw test', + ), + ), + ).toEqual([13]); + }); + + it('reads rolled-up Maven module summaries too', () => { + // Clean reports roll up per project dir to keep the evidence block + // bounded; the count parser must read the rollup shape the same way. + expect( + observedTestCounts( + report( + [ + '[maven-test-report] core (412 report(s)): tests=8931, failures=0, errors=0, skipped=12', + ], + './mvnw test', + ), + ), + ).toEqual([8919]); + }); + + it('emits the changed-module subtotal beside the -am reactor total', () => { + // `-pl -am` also tests the UPSTREAM closure: a count claim + // about the changed modules must be able to match their subtotal, not + // only the reactor-wide sum — "42 tests pass" for module `core` + // otherwise reads `differs` against a 187 total that includes + // upstream `common`'s 145. + const scoped = { + test: [ + { + command: + './mvnw --batch-mode --no-transfer-progress -pl core -am test', + exitCode: 0, + seconds: 10, + timedOut: false, + maven: { lifecycle: 'test', modules: ['core'], alsoMake: true }, + output: + '[maven-test-report] common (12 report(s)): tests=145, failures=0, errors=0, skipped=0\n' + + '[maven-test-report] core (4 report(s)): tests=42, failures=0, errors=0, skipped=0', + }, + ], + } as unknown as BuildTestReport; + expect(observedTestCounts(scoped)).toEqual([187, 42]); + + // A reactor-wide run carries no module list: the total is the only + // reading. + const reactorWide = { + test: [ + { + command: './mvnw --batch-mode --no-transfer-progress test', + exitCode: 0, + seconds: 10, + timedOut: false, + maven: { lifecycle: 'test', modules: null, alsoMake: false }, + output: + '[maven-test-report] common (12 report(s)): tests=145, failures=0, errors=0, skipped=0\n' + + '[maven-test-report] core (4 report(s)): tests=42, failures=0, errors=0, skipped=0', + }, + ], + } as unknown as BuildTestReport; + expect(observedTestCounts(reactorWide)).toEqual([187]); + }); + + it('counts no tests from an interrupted or infrastructure-classified run', () => { + // An interrupted run's partial counts must not adjudicate a count claim + // — the same exclusion finished() applies to command claims. + const interrupted = { + test: [ + { + command: './mvnw test', + exitCode: null, + seconds: 300, + timedOut: true, + output: + '[maven-test-report] core (43 report(s)): tests=43, failures=0, errors=0, skipped=0', + }, + { + command: 'mvn test', + exitCode: 1, + seconds: 3, + timedOut: false, + infrastructure: true, + output: + '[maven-test-report] core (7 report(s)): tests=7, failures=0, errors=0, skipped=0', + }, + { + // The exitCode===null disjunct pinned ALONE: a spawn-level death + // (OOM kill, maxBuffer overflow) carries timedOut:false, and the + // interrupt fixtures above bundle the two disjuncts together. + command: 'mvn test', + exitCode: null, + seconds: 3, + timedOut: false, + output: + '[maven-test-report] core (9 report(s)): tests=9, failures=0, errors=0, skipped=0', + }, + { + command: 'mvn -fn test', + exitCode: 0, + seconds: 3, + timedOut: false, + swallowedFailure: true, + output: + '[maven-test-report] core (500 report(s)): tests=500, failures=0, errors=0, skipped=0', + }, + { + command: 'mvn test', + exitCode: 0, + seconds: 3, + timedOut: false, + evidenceCapped: true, + output: + '[maven-test-report] core (3 report(s)): tests=1000, failures=0, errors=0, skipped=0', + }, + // The exclusion's other two branches: a suppressed test phase ran + // ZERO tests, and a wrapper that never started measured nothing — + // neither may adjudicate a count claim. + { + command: 'mvn test', + exitCode: 0, + seconds: 3, + timedOut: false, + testsSuppressed: true, + output: + '[maven-test-report] core (2 report(s)): tests=2, failures=0, errors=0, skipped=2', + }, + { + command: './mvnw test', + exitCode: 0, + seconds: 3, + timedOut: false, + neverRan: true, + output: + '[maven-test-report] core (1 report(s)): tests=1, failures=0, errors=0, skipped=0', + }, + // Exit 0 over fresh FAILING reports: build-test marks the run + // ok:false, and its derived pass count must not adjudicate a + // count claim `reproduces` while the command-claim twin rules + // the same run contradicted. + { + command: './mvnw test', + exitCode: 0, + seconds: 3, + timedOut: false, + swallowedReports: true, + output: + '[maven-test-report] core (1 failing report(s)): tests=43, failures=1, errors=0, skipped=0', + }, + ], + } as unknown as BuildTestReport; + expect(observedTestCounts(interrupted)).toEqual([]); + }); + + it('counts no tests from an interrupted npm-shaped run either', () => { + // The mirror of the Maven exclusion: a vitest summary inside an + // INTERRUPTED npm run is a partial count and must not adjudicate. + const interrupted = { + test: [ + { + command: 'npm test', + exitCode: null, + seconds: 300, + timedOut: true, + output: 'Tests 499 passed (499)', + }, + // The null-exit disjunct alone, like its Maven twin above. + { + command: 'npm test', + exitCode: null, + seconds: 3, + timedOut: false, + output: 'Tests 499 passed (499)', + }, + ], + } as unknown as BuildTestReport; + expect(observedTestCounts(interrupted)).toEqual([]); + }); + + it('never counts JS console summaries echoed by a Maven run', () => { + // The mirror of the marker gate: surefire echoes test stdout, and a + // Maven run's own test printing a vitest-shaped summary is not the + // run's count — only the `[maven-test-report]` evidence counts. + expect( + observedTestCounts( + report( + [ + 'Tests 499 passed (499)\n' + + '[maven-test-report] core (1 report(s)): tests=5, failures=0, errors=0, skipped=0', + ], + './mvnw test', + ), + ), + ).toEqual([5]); + }); + + it('counts the omitted totals carried by capped rollup markers', () => { + // The producer caps only the FAILING rollups (clean rollups are + // uncapped), so the fixture must use the producer's real omission + // marker — a fabricated "clean" marker would validate parsing of a + // shape the producer contract forbids. + expect( + observedTestCounts( + report( + [ + '[maven-test-report] mod0 (1 report(s)): tests=1, failures=0, errors=0, skipped=0\n' + + '[maven-test-report] 20 more failing project rollup(s) omitted: tests=20, failures=0, errors=0, skipped=0', + ], + './mvnw test', + ), + ), + ).toEqual([21]); + }); + + it('never counts a Maven report below zero passed tests', () => { + // Surefire does not guarantee tests >= failures + errors + skipped + // (class-level @Disabled and rerunFailingTestsCount reruns both perturb + // it), and the sum spans every report of the command: one negative value + // would silently cancel legitimate counts from its neighbours. + expect( + observedTestCounts( + report( + [ + '[maven-test-report] core (1 report(s)): tests=0, failures=0, errors=0, skipped=1\n' + + '[maven-test-report] app (1 report(s)): tests=8, failures=0, errors=0, skipped=1', + ], + './mvnw test', + ), + ), + ).toEqual([7]); + }); + + it('never counts Maven markers mined from a non-Maven command output', () => { + // The markers are PR test stdout's own to print: a fabricated + // `[maven-test-report]` inside a run that never ran Maven must not + // certify a count claim. + expect( + observedTestCounts( + report([ + 'Tests 10 passed (10)\n' + + '[maven-test-report] x: tests=472, failures=0, errors=0, skipped=0', + ]), + ), + ).toEqual([10]); + }); + it('reads a summary interleaved with ANSI color codes', () => { // What a real color-enabled pipe delivers — the codes sit BETWEEN tokens, // so a token-level regex without the strip finds nothing. From a live @@ -723,6 +987,63 @@ describe('runTestPlan', () => { }); describe('command claims', () => { + /** + * One recorded Maven lifecycle run, in the shape the adapter emits: the + * command line AND the `maven` facts it rendered that line from. + * + * Both come from ONE input here, exactly as they do in the adapter — + * which is the whole reason `test-plan` reads the facts instead of + * parsing the string back. The fixture is deliberately BROADER than the + * adapter, though: the `alsoMake` override can render a narrowed `-pl` + * run WITHOUT `-am`, a shape the adapter never emits (its rendering + * always pairs the two) — that impossible shape exists to pin the + * claim-side `-am` carve-outs against adversarial inputs. Pass + * `command` to render a line the adapter would not (a claim-side + * spelling under test); pass `maven: undefined` for a non-Maven run. + */ + const mavenCmd = ( + opts: { + exe?: string; + lifecycle?: string; + /** null for a reactor-wide run — no `-pl`, no `-am`. */ + modules?: string[] | null; + /** Defaults to true whenever the run is narrowed. */ + alsoMake?: boolean; + /** A config-declared GLOBAL skip (vs a stdout-only module-local one). */ + globalSkip?: boolean; + } & Partial = {}, + ): CommandResult => { + const { + exe = './mvnw', + lifecycle = 'test', + modules = ['core'], + alsoMake = modules !== null, + globalSkip = false, + ...overrides + } = opts; + // Selectors are rendered BY the adapter's own shellSelector: quoting + // each module and joining would emit a line the adapter can never + // produce (it joins first, then wraps the whole selector). A selector + // shellSelector refuses is widened to a reactor-wide run exactly like + // the adapter does — fabricating a `-pl` line it cannot launch would + // pair an impossible (command, facts) shape. + const selector = modules === null ? null : shellSelector(modules); + const widened = modules !== null && selector === null; + const narrowing = + selector === null ? '' : ` -pl ${selector}${alsoMake ? ' -am' : ''}`; + return { + command: `${exe} --batch-mode --no-transfer-progress${narrowing} ${lifecycle}`, + exitCode: 0, + seconds: 3, + timedOut: false, + output: '', + maven: widened + ? { lifecycle, modules: null, alsoMake: false, globalSkip } + : { lifecycle, modules, alsoMake, globalSkip }, + ...overrides, + }; + }; + it('reproduces a script the manifests define', () => { const r = run('## Test Plan\n\nRan `npm run build`'); expect(verdictOf(r.claims, 'npm run build')).toBe('reproduces'); @@ -910,81 +1231,2346 @@ describe('runTestPlan', () => { expect(verdictOf(r.claims, 'make check')).toBe('unchecked'); }); - it('rules a bare command contradicted when ANY scoped run failed', () => { - // build-test records one scoped command per package and does not stop on - // failure; the first match could be the green package that sorted first, - // stating the opposite of the authoritative `ok: false`. + it("matches this review's scoped Maven lifecycle command", () => { + const bt = { + build: [], + test: [mavenCmd({ exitCode: 1, output: 'There are test failures.' })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + expect(claim?.note).toContain('module-scoped'); + }); + + it('reads the Maven lifecycle after a lifecycle-named module selector', () => { const bt = { build: [], test: [ - { - command: 'npm test --workspace="packages/a"', + mavenCmd({ + modules: ['validate'], + exitCode: 1, + output: 'There are test failures.', + }), + ], + } as unknown as BuildTestReport; + + const test = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + expect(verdictOf(test.claims, './mvnw test')).toBe('contradicted'); + + const validate = run('## Test Plan\n\nRan `./mvnw validate`', [], bt); + expect(verdictOf(validate.claims, './mvnw validate')).toBe('unchecked'); + }); + + it('recognizes Maven wrapper command spellings', () => { + for (const command of [ + 'mvn test', + // The spelling Windows `cmd.exe` users type for system Maven, and + // parent-dir wrapper invocations — silently never extracted before. + 'mvn.cmd test', + 'mvnw test', + 'mvnw.cmd test', + './mvnw test', + './mvnw.cmd test', + '.\\mvnw test', + '.\\mvnw.cmd test', + '../mvnw test', + '..\\mvnw test', + // A normal nested-module spelling two levels deep — silently + // never extracted before, and `../../mvnw.cmd` mis-extracted as a + // PATH claim. + '../../mvnw test', + '..\\..\\mvnw test', + '../../mvnw.cmd test', + // The Maven daemon and the debug launcher are Maven too, with the + // platform suffixes their Windows distributions ship. + 'mvnd test', + 'mvnd.cmd test', + 'mvnd.exe test', + 'mvnDebug test', + 'mvnDebug.cmd test', + 'mvnDebug.exe test', + // Bare system Maven from a module directory — same relative hops + // as the wrapper, same rule. + './mvn test', + './mvn.cmd test', + '../mvn test', + '..\\mvn test', + ]) { + const claims = extractClaims(`## Test Plan\n\nRan \`${command}\``); + expect(claims.some((claim) => claim.text === command)).toBe(true); + // The runner token is a command, not a path claim about the tree. + expect( + claims.filter((c) => c.kind === 'path' && c.text === command), + ).toEqual([]); + } + + // The bare deep spelling is a FILENAME claim about the tree: the + // command reading can never settle (no phase to compare against any + // recorded run), and the path reading is the verification the token + // actually is. + const bare = extractClaims('## Test Plan\n\nRan `../../mvnw.cmd`'); + expect( + bare.some((c) => c.kind === 'path' && c.text === '../../mvnw.cmd'), + ).toBe(true); + expect(bare.filter((c) => c.kind === 'command')).toEqual([]); + }); + + it('matches bare Maven wrapper spellings to a scoped lifecycle run', () => { + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const command of [ + 'mvnw test', + 'mvnw.cmd test', + '.\\mvnw.cmd test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('reproduces'); + const claim = r.claims.find((c) => c.text === command); + expect(claim?.note).toContain('module-scoped'); + } + }); + + it('reports a matching Maven timeout as an attempted run', () => { + const bt = { + build: [], + test: [mavenCmd({ exitCode: null, seconds: 120, timedOut: true })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('timed out'); + expect(claim?.note).not.toContain('not run'); + }); + + it('does not rule on a Maven run that ended without an exit code', () => { + // A spawn-level death (OOM kill, outside signal) is infrastructure — + // the adapter says so about the same result; it must not read as a + // contradiction of the author's claim. + const bt = { + build: [], + test: [mavenCmd({ exitCode: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('ended without an exit code'); + expect(claim?.note).not.toContain('not run'); + }); + + it('does not settle a claim whose quoted flag consumes the phase token', () => { + // `"-l"` is the `-l` flag once its quote layer is stripped: `test` + // is its VALUE, not a phase. Matching the raw token let the claim + // settle on a lifecycle it never named. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + const quoted = run( + '## Test Plan\n\nRan `./mvnw verify "-l" test`', + [], + bt, + ); + expect(verdictOf(quoted.claims, './mvnw verify "-l" test')).toBe( + 'unchecked', + ); + + // The unquoted control means the same thing either way. + const unquoted = run( + '## Test Plan\n\nRan `./mvnw verify -l test`', + [], + bt, + ); + expect(verdictOf(unquoted.claims, './mvnw verify -l test')).toBe( + 'unchecked', + ); + }); + + it('detects -am through a quoted flag spelling', () => { + // A quoted `"-am"` is the same flag: missing it discarded the + // failing `-am` run and left the claim unchecked. + const bt = { + build: [], + test: [ + mavenCmd({ exitCode: 1, output: 'There are test failures.' }), + mavenCmd({ modules: ['sibling'], exitCode: 0 }), + ], + } as unknown as BuildTestReport; + + const r = run('## Test Plan\n\nRan `./mvnw -pl core "-am" test`', [], bt); + expect(verdictOf(r.claims, './mvnw -pl core "-am" test')).toBe( + 'contradicted', + ); + }); + + it('does not settle a fail-never claim on a run that can exit non-zero', () => { + // fail-never makes Maven exit 0 over failures it would otherwise die + // on: a run without it recorded exit codes the claimed command + // cannot produce. All three spellings carry the same scope. + const bt = { + build: [], + test: [mavenCmd({ exitCode: 1, output: '[ERROR] COMPILATION ERROR' })], + } as unknown as BuildTestReport; + + for (const command of [ + 'mvn -fn test', + 'mvn --fail-never test', + 'mvn -fail-never test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('unchecked'); + } + + // The unflagged control still contradicts. + const control = run('## Test Plan\n\nRan `mvn test`', [], bt); + expect(verdictOf(control.claims, 'mvn test')).toBe('contradicted'); + }); + + it('reads a quote spanning a flag=value pair as one word', () => { + // `"-settings=my settings.xml"` is one shell word; the whitespace + // split broke it and the fragments slipped past the scope guards. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + + const r = run( + '## Test Plan\n\nRan `./mvnw "-settings=my settings.xml" test`', + [], + bt, + ); + expect( + verdictOf(r.claims, './mvnw "-settings=my settings.xml" test'), + ).toBe('unchecked'); + }); + + it('still contradicts on never-ran and suppressed -am runs', () => { + // A wrapper that never started Maven and a global skip setting + // cannot live in an upstream module: the `-am` exclusion must not + // discard either run. + const neverRanBt = { + build: [], + test: [ + mavenCmd({ exitCode: 0, - seconds: 1, - timedOut: false, - output: '', - }, - { - command: 'npm test --workspace="packages/b"', + output: '[INFO] BUILD SUCCESS', + neverRan: true, + }), + ], + } as unknown as BuildTestReport; + const r = run( + '## Test Plan\n\nRan `./mvnw -pl core test`', + [], + neverRanBt, + ); + const claim = r.claims.find((c) => c.text === './mvnw -pl core test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toContain('Maven never started'); + + const suppressedBt = { + build: [], + test: [ + mavenCmd({ + exitCode: 0, + output: 'Tests are skipped.', + testsSuppressed: true, + // The adapter's own invariant: suppression is one of the + // swallowed-failure shapes, so the two flags always travel + // together on a real result. `globalSkip` marks this a GLOBAL + // (config-declared) skip — the shape that cannot live in an + // upstream module, so the carve-out must keep it. + globalSkip: true, + swallowedFailure: true, + }), + ], + } as unknown as BuildTestReport; + const s = run( + '## Test Plan\n\nRan `./mvnw -pl core test`', + [], + suppressedBt, + ); + const sClaim = s.claims.find((c) => c.text === './mvnw -pl core test'); + expect(sClaim?.verdict).toBe('contradicted'); + expect(sClaim?.observed).toContain('skip setting'); + }); + + it('reads an exit-0 never-ran run under the evidence cap as uncertified', () => { + // The states that fire the cap — a rejected fresh report, a + // truncated sweep — are positive proof the toolchain DID start, + // which defeats the zero-summaries inference `neverRan` rests on. + // A capped never-ran run therefore settles nothing: it reads + // unchecked, never the never-ran contradiction. + const bt = { + build: [], + test: [mavenCmd({ exitCode: 0, neverRan: true, evidenceCapped: true })], + } as unknown as BuildTestReport; + + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw -pl core test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('never read'); + }); + + it('ranks a capped run with definitive failure evidence above a green finished sibling', () => { + // build-test records one scoped run per module, and a bare claim + // matches every one of them — a green finished sibling used to + // shadow a capped run whose own evidence is definitive (a non-zero + // exit, or exit-0 failure markers the cap cannot defeat), and the + // claim read `reproduces` over a run the build-test report marks + // failed. The same capped run alone rules contradicted — the + // sibling must not flip it. + const exit1 = { + build: [], + test: [ + mavenCmd({ + modules: ['core'], exitCode: 1, - seconds: 1, - timedOut: false, - output: 'fail', - }, + evidenceCapped: true, + output: + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails', + }), + mavenCmd({ modules: ['cli'], exitCode: 0 }), ], } as unknown as BuildTestReport; - const r = run('## Test Plan\n\nRan `npm test`', [], bt); - const claim = r.claims.find((c) => c.text === 'npm test'); + const r = run('## Test Plan\n\nRan `./mvnw test`', [], exit1); + const claim = r.claims.find((c) => c.text === './mvnw test'); expect(claim?.verdict).toBe('contradicted'); expect(claim?.observed).toBe('exit 1'); + expect(claim?.note).toContain('non-zero exit is definitive'); + + // The exit-0 twin: fresh-failure markers survive the cap the same + // way — the sibling must not shadow them either. + const exit0 = { + build: [], + test: [ + mavenCmd({ + modules: ['core'], + exitCode: 0, + evidenceCapped: true, + output: + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails', + }), + mavenCmd({ modules: ['cli'], exitCode: 0 }), + ], + } as unknown as BuildTestReport; + const s = run('## Test Plan\n\nRan `./mvnw test`', [], exit0); + const sClaim = s.claims.find((c) => c.text === './mvnw test'); + expect(sClaim?.verdict).toBe('contradicted'); + expect(sClaim?.observed).toContain('fresh Surefire/Failsafe reports'); }); - it('finds a root-only script when the root defines no build/test', () => { - // `readRootPackage` returns null when the root has neither build nor test, - // which used to drop a root-only `lint` and rule a correct claim contradicted. - writeFileSync( - join(dir, 'package.json'), - JSON.stringify({ - workspaces: ['packages/*'], - scripts: { lint: 'eslint .' }, - }), - ); - mkdirSync(join(dir, 'packages/cli'), { recursive: true }); - writeFileSync( - join(dir, 'packages/cli/package.json'), - JSON.stringify({ name: '@x/cli', scripts: { build: 'tsc' } }), - ); - const r = run('## Test Plan\n\nRan `npm run lint`'); - expect(verdictOf(r.claims, 'npm run lint')).toBe('reproduces'); + it('does not settle a claim on an infrastructure-classified Maven run', () => { + // A dependency-resolution failure the same review labels + // 'infrastructure evidence' must not falsify the author's claim. + const bt = { + build: [], + test: [ + mavenCmd({ + exitCode: 1, + output: '[ERROR] Could not resolve dependencies', + infrastructure: true, + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('environmental reasons'); + expect(claim?.note).not.toContain('not run'); }); - }); - describe('count claims', () => { - const withCounts = (...counts: number[]) => - ({ + it('does not reproduce a claim when the matched run recorded fresh test failures', () => { + // surefire testFailureIgnore lets `mvn test` exit 0 over failing tests; + // the adapter flags ok:false on the same result, so the Test Plan must + // not certify it as reproduced. + const bt = { build: [], - test: counts.map((n) => ({ - command: 'npm test', - exitCode: 0, - seconds: 1, - timedOut: false, - output: `Tests ${n} passed (${n})`, - })), - }) as unknown as BuildTestReport; + test: [ + mavenCmd({ + modules: null, + output: + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails', + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toContain('fresh Surefire/Failsafe reports'); + }); - it('reproduces a count a suite in this review reported', () => { - const r = run('## Test Plan\n\n471 tests passed', [], withCounts(471)); - expect(verdictOf(r.claims, '471 tests passed')).toBe('reproduces'); + it('verifies a bare wrapper token as a path claim, not an unsettleable command', () => { + // `./mvnw` alone carries no lifecycle: the command reading can + // never settle, and it used to suppress the path verification the + // token actually is — "added `./mvnw`" is a claim about the tree. + // As a path claim it reproduces when the wrapper is in the tree... + writeFileSync(join(dir, 'mvnw'), '#!/bin/sh\n'); + const present = run('## Test Plan\n\nAdded `./mvnw`'); + expect(verdictOf(present.claims, './mvnw')).toBe('reproduces'); + expect( + present.claims.filter( + (c) => c.kind === 'command' && c.text === './mvnw', + ), + ).toEqual([]); + + // ...and contradicts when the tree does not hold it. + rmSync(join(dir, 'mvnw')); + const absent = run('## Test Plan\n\nAdded `./mvnw`'); + expect(verdictOf(absent.claims, './mvnw')).toBe('contradicted'); }); - it('reports a mismatch as `differs`, NOT as a contradiction', () => { - // The whole point: 471 vs 472 may be two different suites, and this - // command cannot tell. It must never become a blocker. - const r = run('## Test Plan\n\n471 tests passed', [], withCounts(472)); - const claim = r.claims.find((c) => c.text === '471 tests passed'); - expect(claim?.verdict).toBe('differs'); - expect(claim?.observed).toBe('472 passed'); - expect(r.claims.some((c) => c.verdict === 'contradicted')).toBe(false); + it('gives Maven wording to Maven claims whose final token is not a lifecycle', () => { + for (const command of ['mvn test -Dtest=ChangedTest']) { + const r = run(`## Test Plan\n\nRan \`${command}\``); + const claim = r.claims.find((c) => c.text === command); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('Maven command was not run'); + expect(claim?.note).not.toContain('npm script'); + } + + // A bare runner token naming no work is no claim at all: the + // command reading can never settle, and without a `./` or path + // shape there is no filename reading either. + const bare = run('## Test Plan\n\nRan `mvn`'); + expect(bare.claims.filter((c) => c.text === 'mvn')).toEqual([]); + }); + + it('settles a multi-token Maven lifecycle claim on its final phase', () => { + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + const green = run('## Test Plan\n\nRan `./mvnw clean test`', [], bt); + const claim = green.claims.find((c) => c.text === './mvnw clean test'); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toContain('module-scoped'); + + // Flag-only additions do not scope the claim either. + const batch = run('## Test Plan\n\nRan `mvn -B test`', [], bt); + expect(verdictOf(batch.claims, 'mvn -B test')).toBe('reproduces'); + }); + + it('says the full reactor ran when the recorded command did not narrow', () => { + const bt = { + build: [], + test: [mavenCmd({ exe: 'mvn', modules: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `mvn test`', [], bt); + const claim = r.claims.find((c) => c.text === 'mvn test'); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toBe('this review ran it'); + }); + + it('leaves an unobserved Maven command unchecked with Maven wording', () => { + const r = run('## Test Plan\n\nRan `mvn -q verify`'); + const claim = r.claims.find((c) => c.text === 'mvn -q verify'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('Maven command was not run'); + }); + + it('does not match Maven claims with different profiles or project scopes', () => { + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const command of ['./mvnw -Pjdk25 test', './mvnw -pl app test']) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + const claim = r.claims.find((c) => c.text === command); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('Maven command was not run'); + } + }); + + it('settles a -pl claim on a recorded run of the same module scope', () => { + // The review injects its own flags, so exact/prefix matching fails; the + // identical scope and phase still settle the claim. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core -am test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw -pl core -am test'); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toContain('module-scoped'); + }); + + it('contradicts a -pl claim when the same-scope run failed', () => { + const bt = { + build: [], + test: [mavenCmd({ exitCode: 1, output: '[ERROR] Tests failed' })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core -am test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw -pl core -am test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + }); + + it('says a differently scoped Maven run happened instead of nothing', () => { + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl app -am test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw -pl app -am test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('Maven command was not run'); + expect(claim?.note).toContain('different scope or phase'); + }); + + it('does not settle -rf/-N/-f claims from differently scoped runs', () => { + const bt = { + build: [], + test: [mavenCmd({ exitCode: 1, output: '[ERROR] Tests failed' })], + } as unknown as BuildTestReport; + for (const command of [ + 'mvn -rf :core test', + 'mvn -N test', + 'mvn -f other/pom.xml test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('unchecked'); + } + }); + + it('does not settle long-form-scoped claims from differently scoped runs', () => { + // The long forms of -P/-D/-rf/-N scope a claim exactly like the short + // forms; -amd/--also-make-dependents' downstream closure is an + // uncomparable scope too. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const command of [ + './mvnw --non-recursive test', + './mvnw --activate-profiles jdk25 test', + './mvnw --define foo=bar test', + './mvnw --resume-from :core test', + './mvnw -pl core -amd test', + './mvnw -pl core --also-make-dependents test', + './mvnw -pl core --settings ci-settings.xml test', + './mvnw -pl core --global-settings global-settings.xml test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('unchecked'); + } + + // The flip direction too: a FAILED recorded run must not contradict a + // claim whose extra scope that run never exercised. + const failed = { + build: [], + test: [mavenCmd({ exitCode: 1, output: '[ERROR] Tests failed' })], + } as unknown as BuildTestReport; + const flip = run( + '## Test Plan\n\nRan `./mvnw -pl core -amd test`', + [], + failed, + ); + expect(verdictOf(flip.claims, './mvnw -pl core -amd test')).toBe( + 'unchecked', + ); + }); + + it('settles multi-module -pl claims on the same module set, in any order', () => { + const withModules = (modules: string[]) => + ({ + build: [], + test: [mavenCmd({ modules })], + }) as unknown as BuildTestReport; + + // Order independence and cardinality: the adapter records multi-module + // selectors, and the claim names the same set the other way round. + const same = run( + '## Test Plan\n\nRan `./mvnw -pl app,core test`', + [], + withModules(['core', 'app']), + ); + expect(verdictOf(same.claims, './mvnw -pl app,core test')).toBe( + 'reproduces', + ); + + // A recorded run with FEWER modules never tested the extra one. + const fewer = run( + '## Test Plan\n\nRan `./mvnw -pl core,app test`', + [], + withModules(['core']), + ); + expect(verdictOf(fewer.claims, './mvnw -pl core,app test')).toBe( + 'unchecked', + ); + }); + + it('does not contradict a -pl claim on a FAILED -am run of the same module set', () => { + // `-am` pulls the UPSTREAM modules into the run; a failure in one of + // them never falsifies a claim without `-am`, which never tests them. + // The green direction stays settled (pinned above): only the failing + // direction falls through to the conservative unchecked cascade. + const failed = { + build: [], + test: [mavenCmd({ exitCode: 1, output: '[ERROR] Tests failed' })], + } as unknown as BuildTestReport; + + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], failed); + expect(verdictOf(r.claims, './mvnw -pl core test')).toBe('unchecked'); + + // The -am-less claim's other spellings carry the same carve-out. + const longForm = run( + '## Test Plan\n\nRan `./mvnw --projects core test`', + [], + failed, + ); + expect(verdictOf(longForm.claims, './mvnw --projects core test')).toBe( + 'unchecked', + ); + }); + + it('reads -pl= and --projects selector spellings in a claim', () => { + // Claim-side only: a PR author writes any spelling Maven accepts. The + // RUN side is not parsed at all — it reports its module set as data — + // so there is no recorded spelling left to normalize. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const claim of [ + './mvnw -pl=core test', + './mvnw --projects core test', + './mvnw --projects=core test', + ]) { + const r = run(`## Test Plan\n\nRan \`${claim}\``, [], bt); + expect(verdictOf(r.claims, claim)).toBe('reproduces'); + } + }); + + it('settles a test-compile claim on the recorded build-only run', () => { + // --build-only records `test-compile`; disavowing the phase would say + // the review never ran what it did. + const bt = { + build: [mavenCmd({ lifecycle: 'test-compile' })], + test: [], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test-compile`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test-compile'); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toContain('module-scoped'); + }); + + it('does not settle a -pl claim combined with other scopes from a same-module run', () => { + // `-pl core -Pjdk25` is profile-scoped AS WELL AS module-scoped; the + // same-module recorded run never activated that profile. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const command of [ + './mvnw -pl core -Pjdk25 test', + './mvnw -pl core -Dfoo=bar test', + './mvnw -pl core -rf :core test', + './mvnw -pl core -N test', + './mvnw -pl core -f other/pom.xml test', + // The other semantics-altering value flags MAVEN_VALUE_FLAGS + // models: a claim carrying one cannot settle on a run that never + // used it — the adapter never injects any of these. + './mvnw -pl core -s ci-settings.xml test', + './mvnw -pl core -gs global-settings.xml test', + './mvnw -pl core -t jdk11-toolchains.xml test', + './mvnw -pl core -gt global-toolchains.xml test', + './mvnw -pl core -b smart test', + // The attached-value spelling the exact-token match missed. + './mvnw -pl core -amd=app test', + // Offline mode and forced snapshot updates change what resolution + // the run performs; the long spellings carry the same scope. + './mvnw -pl core -o test', + './mvnw -pl core --offline test', + './mvnw -pl core -U test', + './mvnw -pl core --update-snapshots test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('unchecked'); + } + }); + + it('does not settle a claim whose final positional token is not the claimed lifecycle', () => { + // `mvn test deploy` names deploy as its LAST work; settling the + // recognized `test` phase alone would read undisclosed. Trailing + // flag tokens (`-B`) still settle. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + const deploy = run('## Test Plan\n\nRan `mvn test deploy`', [], bt); + expect(verdictOf(deploy.claims, 'mvn test deploy')).toBe('unchecked'); + expect( + deploy.claims.find((c) => c.text === 'mvn test deploy')?.note, + ).toContain('different scope or phase'); + + const site = run('## Test Plan\n\nRan `mvn test site`', [], bt); + expect(verdictOf(site.claims, 'mvn test site')).toBe('unchecked'); + + // Phase reduction still settles when the final token IS the phase. + const clean = run('## Test Plan\n\nRan `mvn clean test`', [], bt); + expect(verdictOf(clean.claims, 'mvn clean test')).toBe('reproduces'); + }); + + it('leaves a coordinate -pl claim unsettleable rather than matching it against a directory run', () => { + // `-pl :core` selects by artifactId — a different NAMESPACE than the + // recorded module directories. artifactId and dir name can disagree, + // and two dirs can share one artifactId, so string-matching the + // reduced coordinate against a dir could settle — or contradict — the + // claim with a DIFFERENT module's run. Unchecked beats wrong. + const green = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl :core test`', [], green); + const claim = r.claims.find((c) => c.text === './mvnw -pl :core test'); + expect(claim?.verdict).toBe('unchecked'); + }); + + it('settles a ./-prefixed -pl claim exactly like the bare spelling', () => { + // Maven treats `-pl ./core` identically to `-pl core`, and the + // recorded modules never carry the prefix — without the + // normalization the natural spelling could never settle or be + // contradicted. + const green = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + const r = run( + '## Test Plan\n\nRan `./mvnw -pl ./core -am test`', + [], + green, + ); + const claim = r.claims.find( + (c) => c.text === './mvnw -pl ./core -am test', + ); + expect(claim?.verdict).toBe('reproduces'); + }); + + it('does not read an -am inside a quoted attached -pl= selector as the flag', () => { + // The space-form twin already consumes the quoted selector; the + // attached form broke at the space and the bare `-am` token inside it + // disabled the upstream-failure carve-out, contradicting a correct + // claim. + const output = + '[maven-test-report] aaa (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] aaa/target/surefire-reports/TEST-A.xml: example.ATest#fails'; + const bt = { + build: [], + test: [ + mavenCmd({ + modules: ['foo -am bar'], + exitCode: 1, + output, + }), + ], + } as unknown as BuildTestReport; + const r = run( + "## Test Plan\n\nRan `./mvnw -pl='foo -am bar' test`", + [], + bt, + ); + const claim = r.claims.find( + (c) => c.text === "./mvnw -pl='foo -am bar' test", + ); + // The failure lives in upstream `aaa`, which the claim (no `-am`) + // never tests — the carve-out applies and the claim stays unchecked. + expect(claim?.verdict).toBe('unchecked'); + }); + + it('discloses the phase reduction when a claim carries unrun work in non-trailing position', () => { + // `mvn deploy test` never ran `deploy` here: settling through the + // trailing in-vocabulary phase must disclose the reduction rather + // than read as if the whole claim ran. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `mvn deploy test`', [], bt); + const claim = r.claims.find((c) => c.text === 'mvn deploy test'); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toContain('final phase'); + expect(claim?.note).toContain('deploy test'); + }); + + it('discloses the phase reduction for every out-of-vocabulary phase', () => { + // MAVEN_UNRUN_WORK_RE covers the whole phase vocabulary outside the + // settlement set — pre-clean, prepare-package, and the integration + // phases included; each must disclose, not silently settle. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + for (const command of [ + 'mvn pre-clean test', + 'mvn post-clean test', + 'mvn prepare-package test', + 'mvn pre-integration-test test', + 'mvn integration-test test', + 'mvn post-integration-test test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + const claim = r.claims.find((c) => c.text === command); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toContain('final phase'); + } + }); + + it('contradicts an -am-excluded claim when the failure survives as a report line inside the claim', () => { + // The 200-line case cap can drop every [maven-test-failure] line of + // the claimed module while its [maven-test-report] line survives with + // failures>0 — that surviving line is in-scope failure evidence, or + // truncation alone would flip the verdict. + const output = + '[maven-test-report] aaa (1 failing report(s)): tests=300, failures=250, errors=0, skipped=0\n' + + Array.from( + { length: 200 }, + (_, i) => + `[maven-test-failure] aaa/target/surefire-reports/TEST-A.xml: example.ATest#f${i}`, + ).join('\n') + + '\n[maven-test-failure] 51 more failing case(s) omitted\n' + + '[maven-test-report] zzz (1 failing report(s)): tests=5, failures=1, errors=0, skipped=0'; + const bt = { + build: [], + test: [ + mavenCmd({ + modules: ['zzz'], + exitCode: 0, + output, + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl zzz test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw -pl zzz test'); + expect(claim?.verdict).toBe('contradicted'); + }); + + it('settles nothing against a run whose evidence the adapter refused to certify', () => { + // An evidenceCapped run is ok:false because its parsed subset is + // partial BY DEFINITION — it must not rule a claim reproduced, and + // its counts must not adjudicate a count claim. + const bt = { + build: [], + test: [ + mavenCmd({ + modules: null, + evidenceCapped: true, + output: + '[maven-test-report] core (3 report(s)): tests=1000, failures=0, errors=0, skipped=0', + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('not certified'); + }); + + it('discloses the reduced form of an interrupted matching run too', () => { + // The interrupted-failure note must not read "this review ran it" + // when the run that recorded the failures was a module-scoped + // reduced form of the claim. + const output = + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails'; + const bt = { + build: [], + test: [mavenCmd({ exitCode: null, timedOut: true, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.note).toContain('module-scoped'); + expect(claim?.note).toContain('interrupted'); + }); + + it('discloses the reduced form in the timeout and spawn-death notes', () => { + // The unchecked cascade phrased every matched run as "this Maven + // command was run by this review" — overstating a module-scoped + // reduced form of the claim exactly as the finished-run branches + // used to. + const timedOutBt = { + build: [], + test: [mavenCmd({ exitCode: null, seconds: 120, timedOut: true })], + } as unknown as BuildTestReport; + const timedOut = run('## Test Plan\n\nRan `./mvnw test`', [], timedOutBt); + const claim = timedOut.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('module-scoped'); + expect(claim?.note).toContain('timed out'); + + const deathBt = { + build: [], + test: [mavenCmd({ exitCode: null })], + } as unknown as BuildTestReport; + const death = run('## Test Plan\n\nRan `./mvnw test`', [], deathBt); + const deathClaim = death.claims.find((c) => c.text === './mvnw test'); + expect(deathClaim?.verdict).toBe('unchecked'); + expect(deathClaim?.note).toContain('module-scoped'); + expect(deathClaim?.note).toContain('ended without an exit code'); + }); + + it('contradicts a claim when the interrupted run recorded fresh failures', () => { + const output = + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails'; + // The deadline kill and the spawn death both keep the captured + // regressions as contradicting evidence. + for (const timedOut of [true, false]) { + const bt = { + build: [], + test: [mavenCmd({ exitCode: null, timedOut, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toContain('interrupted'); + } + }); + + it('does not let a green finished sibling shadow an interrupted run with fresh failures', () => { + // The finished-run ranking used to win whenever ANY finished run + // matched: the claim read `reproduces` off the green sibling while + // the build-test report said ok:false with recorded failures. + const output = + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails'; + const bt = { + build: [], + test: [ + mavenCmd(), + mavenCmd({ exitCode: null, timedOut: true, output }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toContain('interrupted'); + }); + + it('does not contradict a -pl claim on an INTERRUPTED -am run of the same module set', () => { + // The finished twin of this guard is pinned above; the interrupted + // fresh-failure branch must apply the same scope asymmetry — the + // failures may live entirely in upstream modules only `-am` pulled + // in, which the claim never tests. + const output = + '[maven-test-report] upstream (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] upstream/target/surefire-reports/TEST-A.xml: example.ATest#fails'; + for (const timedOut of [true, false]) { + const bt = { + build: [], + test: [mavenCmd({ exitCode: null, timedOut, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], bt); + expect(verdictOf(r.claims, './mvnw -pl core test')).toBe('unchecked'); + } + }); + + it('rules a spawn-level death contradicted for non-Maven claims', () => { + // exitCode null without a deadline kill is a run that never finished; + // the manifest fallback must not certify it as reproduced. (Maven + // claims keep their unchecked cascade, pinned above, and a deadline + // kill keeps falling through to the manifest, pinned as 'does not + // rule on a command killed by the deadline'.) + const bt = { + build: [], + test: [ + { + command: 'npm test --workspace="packages/a"', + exitCode: null, + seconds: 3, + timedOut: false, + output: '', + }, + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `npm test`', [], bt); + const claim = r.claims.find((c) => c.text === 'npm test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('it ended without an exit code'); + }); + + it('does not leak an attached quoted flag value into the positionals', () => { + // The attached `='…'` form carries its quoted value in-token + // for EVERY value flag — the old consumption covered only `-pl=`/ + // `--projects=`, so `-l='a test -B'` leaked the `test` fragment and + // settled a claim that runs no lifecycle work. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const leaked = run("## Test Plan\n\nRan `mvn -l='a test -B'`", [], bt); + expect(verdictOf(leaked.claims, "mvn -l='a test -B'")).toBe('unchecked'); + + // The mirror false negative: the same value beside a real phase used + // to never settle while its space-form twin did. + const settled = run( + "## Test Plan\n\nRan `mvn test -l='build log.txt'`", + [], + bt, + ); + expect(verdictOf(settled.claims, "mvn test -l='build log.txt'")).toBe( + 'reproduces', + ); + }); + + it('does not leak a quoted value whose first word is empty', () => { + // A quoted value starting with a space breaks into a BARE opening + // quote token; the rejoin/consume loops checked the closing quote + // before advancing, so the span was never rejoined and `test` leaked + // into the positionals. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const leaked = run("## Test Plan\n\nRan `mvn -l ' x test -B'`", [], bt); + expect(verdictOf(leaked.claims, "mvn -l ' x test -B'")).toBe('unchecked'); + + const settled = run( + "## Test Plan\n\nRan `mvn test -l ' x verify -e'`", + [], + bt, + ); + expect(verdictOf(settled.claims, "mvn test -l ' x verify -e'")).toBe( + 'reproduces', + ); + }); + + it('does not settle claims on the password-encryption options', () => { + // `-emp`/`-ep` and their long spellings consume their argument and + // perform ZERO lifecycle work; reading the argument as a positional + // phase settled claims that built and tested nothing. All four + // spellings — including the commons-cli single-dash long one — carry + // the same treatment. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + for (const claim of [ + 'mvn -emp test', + 'mvn -ep test', + 'mvn --encrypt-master-password test', + 'mvn -encrypt-master-password test', + ]) { + const r = run(`## Test Plan\n\nRan \`${claim}\``, [], bt); + expect(verdictOf(r.claims, claim)).toBe('unchecked'); + } + }); + + it('does not settle a claim carrying mid-position unknown work', () => { + // Maven dies on 'Unknown lifecycle phase' for a bare positional that + // names no work (`mvn foo test` runs nothing); settling the trailing + // phase anyway read `reproduces` over a command that errored out. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `mvn foo test`', [], bt); + expect(verdictOf(r.claims, 'mvn foo test')).toBe('unchecked'); + }); + + it('extracts ././-prefixed runner spellings as commands', () => { + // A leading `./` before upward hops (or another `./`) used to fall + // out of the runner grammar, so the claim was never extracted. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `././mvnw test`', [], bt); + expect(verdictOf(r.claims, '././mvnw test')).toBe('reproduces'); + }); + + it('extracts case- and .exe-variant runner spellings as commands', () => { + // scoop/Chocolatey installs create an `mvn.exe` shim, and Windows + // authors type `MVNW`/`mvnw.CMD` on a case-insensitive filesystem — + // a span the runner check rejects is a claim never extracted. + const bt = { + build: [], + test: [mavenCmd({ modules: null, exe: 'mvn' })], + } as unknown as BuildTestReport; + const exe = run('## Test Plan\n\nRan `mvn.exe test`', [], bt); + expect(verdictOf(exe.claims, 'mvn.exe test')).toBe('reproduces'); + const upper = run('## Test Plan\n\nRan `MVNW test`', [], bt); + expect(verdictOf(upper.claims, 'MVNW test')).toBe('reproduces'); + }); + + it('does not attribute a src/-named module failure to a root claim', () => { + // The root-project arm keyed on the `/src/` substring: a + // module literally named `src` sits beneath it, and attributing its + // compile failure to a claim naming the root module defeats the + // `-am` carve-out in the accusing direction. Attribution walks to + // the owning pom.xml instead; a failure inside a CLAIMED module + // still contradicts. + writeFileSync(join(dir, 'pom.xml'), ''); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'src', 'pom.xml'), ''); + mkdirSync(join(dir, 'app'), { recursive: true }); + writeFileSync(join(dir, 'app', 'pom.xml'), ''); + const wt = dir.replace(/\\/g, '/'); + + const outside = { + build: [], + test: [ + mavenCmd({ + modules: ['.', 'app'], + exitCode: 1, + output: `[ERROR] ${wt}/src/src/main/java/Foo.java:[10,5] boom`, + }), + ], + } as unknown as BuildTestReport; + const uncheckedRun = run( + '## Test Plan\n\nRan `./mvnw -pl .,app test`', + [], + outside, + ); + expect(verdictOf(uncheckedRun.claims, './mvnw -pl .,app test')).toBe( + 'unchecked', + ); + + const inside = { + build: [], + test: [ + mavenCmd({ + modules: ['.', 'app'], + exitCode: 1, + output: `[ERROR] ${wt}/app/src/main/java/Bar.java:[10,5] boom`, + }), + ], + } as unknown as BuildTestReport; + const contradictedRun = run( + '## Test Plan\n\nRan `./mvnw -pl .,app test`', + [], + inside, + ); + expect(verdictOf(contradictedRun.claims, './mvnw -pl .,app test')).toBe( + 'contradicted', + ); + }); + + it('compares quoted -pl selectors as their module sets', () => { + // A module dir with a space passes the ownership walk, and the CLAIM + // may quote it; the claim parser must rejoin the selector instead of + // collapsing it to its first word. + const recorded = { + build: [], + test: [mavenCmd({ modules: ['my module', 'my other'] })], + } as unknown as BuildTestReport; + + const same = run( + "## Test Plan\n\nRan `./mvnw -pl 'my module,my other' test`", + [], + recorded, + ); + expect( + verdictOf(same.claims, "./mvnw -pl 'my module,my other' test"), + ).toBe('reproduces'); + + // Sharing a first word is NOT the same module set: the recorded run + // never tested `my module` alone. + const different = run( + "## Test Plan\n\nRan `./mvnw -pl 'my module' test`", + [], + recorded, + ); + expect(verdictOf(different.claims, "./mvnw -pl 'my module' test")).toBe( + 'unchecked', + ); + }); + + it('settles adjacent-quote -pl selectors shell-quote joins into one word', () => { + // A selector whose quoting does not open on a token boundary — + // `core","other`'s adjacent quotes — leaked past a whitespace-split + // walker as separate tokens and settled the wrong module set (or + // fell through to a false "was not run by this review"). A shell + // joins them into one word, and the claim parses like a shell. + const recorded = { + build: [], + test: [mavenCmd({ modules: ['core', 'other'] })], + } as unknown as BuildTestReport; + + const adjacent = run( + '## Test Plan\n\nRan `./mvnw -pl core","other test`', + [], + recorded, + ); + expect(verdictOf(adjacent.claims, './mvnw -pl core","other test')).toBe( + 'reproduces', + ); + }); + + it('does not read a maven failure marker out of a non-Maven run', () => { + // Only the Maven adapter emits `[maven-test-failure]`; a green npm + // run whose stdout merely prints the literal (any test can + // console.log it, and this repo's own suites do) must stay + // reproduced — the marker can never be legitimate evidence in a + // non-Maven result. + const bt = { + build: [], + test: [ + { + command: 'npm test', + exitCode: 0, + seconds: 3, + timedOut: false, + output: + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails', + }, + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `npm test`', [], bt); + const claim = r.claims.find((c) => c.text === 'npm test'); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.observed).toBe('exit 0'); + }); + + it('contradicts a space-bearing -pl claim when the same-scope run failed inside it', () => { + // Module dirs with spaces pass the POM gate and shellSelector quotes + // them; the selector rejoin must advance past the flag before + // reading, or the duplicated first word breaks failure attribution + // against real report paths and silently discards the evidence. + const bt = { + build: [], + test: [ + mavenCmd({ + modules: ['my module'], + exitCode: 1, + output: + '[maven-test-report] my module (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] my module/target/surefire-reports/TEST-A.xml: example.ATest#fails', + }), + ], + } as unknown as BuildTestReport; + const r = run( + "## Test Plan\n\nRan `./mvnw -pl 'my module' test`", + [], + bt, + ); + expect(verdictOf(r.claims, "./mvnw -pl 'my module' test")).toBe( + 'contradicted', + ); + }); + + it('does not read -b/-t/-gt flag values as lifecycle phases', () => { + // Maven's other space-separated value flags consume their next + // token: a toolchains/builder FILE named `test` used to read as a + // claimed `test` phase and let a module-scoped test run certify a + // `verify` claim. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + for (const command of [ + 'mvn verify -t test', + 'mvn verify -b test', + 'mvn verify -gt test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('unchecked'); + } + }); + + it('contradicts an -am claim when a same-scope run WITHOUT -am failed', () => { + // The converse of the -am exclusion: a run that never pulled in + // upstream modules and still failed inside the claimed module set + // falsifies the wider claim too. The implementation comment relies + // on this direction, so pin it. + const failed = { + build: [], + test: [ + mavenCmd({ + alsoMake: false, + exitCode: 1, + output: '[ERROR] Tests failed', + }), + ], + } as unknown as BuildTestReport; + + const r = run( + '## Test Plan\n\nRan `./mvnw -pl core -am test`', + [], + failed, + ); + const claim = r.claims.find((c) => c.text === './mvnw -pl core -am test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + + // The long spelling of the claim's also-make flag behaves identically. + const longForm = run( + '## Test Plan\n\nRan `./mvnw -pl core --also-make test`', + [], + failed, + ); + const longClaim = longForm.claims.find( + (c) => c.text === './mvnw -pl core --also-make test', + ); + expect(longClaim?.verdict).toBe('contradicted'); + expect(longClaim?.observed).toBe('exit 1'); + }); + + it('discloses the phase reduction of a multi-phase claim', () => { + // The adapter only ever runs `test`/`test-compile`; settling + // `clean test` on one must not read as if `clean` ran — neither in + // the reactor-wide shape nor the module-scoped one. + const reactorWide = { + build: [], + test: [mavenCmd({ exe: 'mvn', modules: null })], + } as unknown as BuildTestReport; + const wide = run( + '## Test Plan\n\nRan `./mvnw clean test`', + [], + reactorWide, + ); + const wideClaim = wide.claims.find((c) => c.text === './mvnw clean test'); + expect(wideClaim?.verdict).toBe('reproduces'); + expect(wideClaim?.note).toContain('final phase (`test`)'); + expect(wideClaim?.note).toContain('`clean test`'); + + const scoped = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + const narrow = run('## Test Plan\n\nRan `./mvnw clean test`', [], scoped); + expect( + narrow.claims.find((c) => c.text === './mvnw clean test')?.note, + ).toContain('module-scoped form of its final phase'); + }); + + it('discloses the phase reduction of a -pl-scoped multi-phase claim too', () => { + // settledBySameScope settlements of a `-pl` multi-phase claim must + // say the earlier phases did not run, exactly as the unscoped twin. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core clean test`', [], bt); + const claim = r.claims.find( + (c) => c.text === './mvnw -pl core clean test', + ); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toContain('module-scoped form of its final phase'); + expect(claim?.note).toContain('`clean test`'); + }); + + it('does not read a phase-named -pl value as the claimed lifecycle', () => { + // A module dir named `test`: the claimed lifecycle is `verify`, not + // the selector value, so a mere `test` run must not settle it. + const bt = { + build: [], + test: [mavenCmd({ modules: ['test'] })], + } as unknown as BuildTestReport; + const phaseNamed = run( + '## Test Plan\n\nRan `./mvnw verify -pl test`', + [], + bt, + ); + expect(verdictOf(phaseNamed.claims, './mvnw verify -pl test')).toBe( + 'unchecked', + ); + + // Control: the same claim shape with an ordinary module name stays + // unchecked the same way — behavior must not depend on the module + // name being a phase word. + const ordinary = run( + '## Test Plan\n\nRan `./mvnw verify -pl core`', + [], + bt, + ); + expect(verdictOf(ordinary.claims, './mvnw verify -pl core')).toBe( + 'unchecked', + ); + }); + + it('settles a phase-first Maven claim whose flags follow the phase', () => { + // `./mvnw test -pl core` carries tokens after its final phase; the + // claim still settles on the same-scope recorded run instead of + // falling through to a false "different scope or phase" note. + const green = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + const pl = run('## Test Plan\n\nRan `./mvnw test -pl core`', [], green); + const plClaim = pl.claims.find((c) => c.text === './mvnw test -pl core'); + expect(plClaim?.verdict).toBe('reproduces'); + expect(plClaim?.note).toContain('module-scoped'); + + const flag = run('## Test Plan\n\nRan `mvn test -B`', [], green); + expect(verdictOf(flag.claims, 'mvn test -B')).toBe('reproduces'); + }); + + it('keeps a failed -am run contradicting when its failures are inside the claimed set', () => { + // The `-am` carve-out yields when the run's own markers attribute a + // failure to a module the claim DOES test — the common shape, since + // the adapter always records `-am` while author claims omit it. + const output = + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails'; + const failed = { + build: [], + test: [mavenCmd({ exitCode: 1, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], failed); + const claim = r.claims.find((c) => c.text === './mvnw -pl core test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + }); + + it('keeps an ERRORS-only failed -am run contradicting when the errors are inside the claimed set', () => { + // The attribution branch reads failures AND errors off the surviving + // rollup line; an errors-only rollup (no case markers past the cap) + // must contradict exactly like its failures-only twin. + const output = + '[maven-test-report] core (1 failing report(s)): tests=2, failures=0, errors=2, skipped=0'; + const failed = { + build: [], + test: [mavenCmd({ exitCode: 1, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], failed); + const claim = r.claims.find((c) => c.text === './mvnw -pl core test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + }); + + it('contradicts a root-module claim on failures in the root project', () => { + // The root twin of the guard above: `-pl .` claims the root module, + // and a failure marker rooted at `target/` (no module prefix) is + // INSIDE that claim even though the same marker sits outside every + // non-root module set. + const output = + '[maven-test-report] . (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] target/surefire-reports/TEST-Root.xml: example.RootTest#fails'; + const failed = { + build: [], + test: [mavenCmd({ modules: ['.'], exitCode: 1, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl . test`', [], failed); + const claim = r.claims.find((c) => c.text === './mvnw -pl . test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + }); + + it('keeps an interrupted -am run contradicting when its failures are inside the claimed set', () => { + // The interrupted twin of the guard above: a deadline kill does not + // excuse failures Surefire recorded inside the claimed modules. + const output = + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails'; + for (const timedOut of [true, false]) { + const bt = { + build: [], + test: [mavenCmd({ exitCode: null, timedOut, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], bt); + expect(verdictOf(r.claims, './mvnw -pl core test')).toBe( + 'contradicted', + ); + } + }); + + it('does not read a quoted -am inside a -pl selector as the flag', () => { + // A module dir with a space passes the POM entry gate; `-am` inside + // the quoted selector is part of the name, not --also-make, so the + // upstream-failure carve-out still applies. + const failed = { + build: [], + test: [ + mavenCmd({ + modules: ['foo -am bar'], + exitCode: 1, + output: '[ERROR] Tests failed', + }), + ], + } as unknown as BuildTestReport; + const r = run( + "## Test Plan\n\nRan `./mvnw -pl 'foo -am bar' test`", + [], + failed, + ); + expect(verdictOf(r.claims, "./mvnw -pl 'foo -am bar' test")).toBe( + 'unchecked', + ); + }); + + it('settles a claim against the REAL adapter output, not a hand-built one', () => { + // Every other case here feeds a fixture. This one drives the actual + // Maven adapter and settles a claim against what it recorded, so the + // two halves of the contract are pinned together: if the adapter ever + // stops reporting `maven` facts — or reports ones that disagree with + // the command line it rendered — no fixture would notice, but this + // does. + const repo = join(dir, 'reactor'); + mkdirSync(join(repo, 'core'), { recursive: true }); + const pom = (modules: string[] = []) => + `4.0.0e` + + `a1` + + `${modules.map((m) => `${m}`).join('')}`; + writeFileSync(join(repo, 'pom.xml'), pom(['core'])); + writeFileSync(join(repo, 'core', 'pom.xml'), pom()); + + const bt = mavenToolchainAdapter.run({ + root: repo, + changedFiles: ['core/src/main/java/Main.java'], + timeout: 5, + install: false, + exec: (command) => ({ + command, + exitCode: 1, + seconds: 1, + timedOut: false, + output: '[ERROR] Tests failed', + }), + }); + expect(bt.test[0]?.command).toContain('-pl core -am test'); + expect(bt.test[0]?.maven).toEqual({ + lifecycle: 'test', + modules: ['core'], + alsoMake: true, + globalSkip: false, + }); + + const r = run('## Test Plan\n\nRan `./mvnw -pl core -am test`', [], bt); + expect(verdictOf(r.claims, './mvnw -pl core -am test')).toBe( + 'contradicted', + ); + }); + + it('ranks a spawn-level death above a green finished sibling run', () => { + // One green package must not shadow a spawn-dead sibling: the claim + // reads contradicted while the build-test report says ok:false. + const bt = { + build: [], + test: [ + { + command: 'npm test --workspace="packages/a"', + exitCode: 0, + seconds: 1, + timedOut: false, + output: '', + }, + { + command: 'npm test --workspace="packages/b"', + exitCode: null, + seconds: 1, + timedOut: false, + output: '', + }, + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `npm test`', [], bt); + const claim = r.claims.find((c) => c.text === 'npm test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('it ended without an exit code'); + }); + + it('does not reproduce a claim on a run whose exit 0 swallowed failures', () => { + const bt = { + build: [], + test: [ + mavenCmd({ + modules: null, + output: '[ERROR] COMPILATION ERROR :', + swallowedFailure: true, + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toContain('did not fail on'); + }); + + it('words a suppressed test phase as suppression, not recorded failures', () => { + // A skip setting ran ZERO tests: there are no failures to hunt, so + // the contradiction must say the phase never ran — the old wording + // sent the PR after a fail-never setting that was never involved. + const bt = { + build: [], + test: [ + mavenCmd({ + output: '[INFO] Tests are skipped.\n[INFO] BUILD SUCCESS', + swallowedFailure: true, + testsSuppressed: true, + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toContain( + 'skip setting suppressed the test phase', + ); + expect(claim?.note).toContain('nothing was tested'); + expect(claim?.note).not.toContain('recorded failures'); + }); + + it('does not reproduce a claim on a wrapper that never started Maven', () => { + // A stub wrapper's exit 0 verified nothing — the run must contradict + // with its own wording, and never rule the claim reproduced. + const bt = { + build: [], + test: [mavenCmd({ output: '', neverRan: true })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toContain('Maven never started'); + expect(claim?.note).toContain('nothing was built or tested'); + }); + + it('contradicts a capped run on its non-zero exit', () => { + // The cap withholds certification of a PASS; it does not retroactively + // excuse a failure — the non-zero exit is definitive. + const bt = { + build: [], + test: [ + mavenCmd({ + modules: null, + exitCode: 1, + evidenceCapped: true, + output: '[ERROR] Tests failed', + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + expect(claim?.note).toContain('non-zero exit is definitive'); + }); + + it('leaves an exit-0 capped run unsettled', () => { + // Only the non-zero exit is definitive; exit 0 over unread evidence is + // genuinely unknown. + const bt = { + build: [], + test: [ + mavenCmd({ + modules: null, + evidenceCapped: true, + output: '[INFO] BUILD SUCCESS', + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + expect(verdictOf(r.claims, './mvnw test')).toBe('unchecked'); + }); + + it('contradicts an exit-0 capped run on cap-independent failure evidence', () => { + // The cap withholds certification of a PASS; it must not flip the + // verdict on failure evidence the run DID record — markers from + // reports the sweep parsed, or failures a fail-never setting + // swallowed. The finished path rules the identical evidence + // contradicted. + const markers = { + build: [], + test: [ + mavenCmd({ + alsoMake: false, + evidenceCapped: true, + output: + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails', + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], markers); + const claim = r.claims.find((c) => c.text === './mvnw -pl core test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toContain('fresh Surefire/Failsafe reports'); + + const swallowed = { + build: [], + test: [ + mavenCmd({ + alsoMake: false, + evidenceCapped: true, + swallowedFailure: true, + output: '[ERROR] COMPILATION ERROR :', + }), + ], + } as unknown as BuildTestReport; + const s2 = run( + '## Test Plan\n\nRan `./mvnw -pl core test`', + [], + swallowed, + ); + const sClaim = s2.claims.find((c) => c.text === './mvnw -pl core test'); + expect(sClaim?.verdict).toBe('contradicted'); + expect(sClaim?.observed).toContain('did not fail on'); + }); + + it('never carves a CAPPED -am run — the non-zero exit contradicts even on upstream-only markers', () => { + // R2-3: a capped run's markers are incomplete BY CONSTRUCTION — a + // rejected report emits none, and upstream markers do not prove the + // claimed module also passed. So whether the failure lives inside the + // claim is unknowable; the carve-out must not discard the run, and + // the capped cascade rules the non-zero exit definitive. (The earlier + // pin expected `unchecked` here — R2-3 reverses it: discarding the + // run read a definitive failure as "not run".) + const upstreamOnly = { + build: [], + test: [ + mavenCmd({ + exitCode: 1, + evidenceCapped: true, + output: + '[maven-test-report] upstream (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] upstream/target/surefire-reports/TEST-A.xml: example.ATest#fails', + }), + ], + } as unknown as BuildTestReport; + const r = run( + '## Test Plan\n\nRan `./mvnw -pl core test`', + [], + upstreamOnly, + ); + expect(verdictOf(r.claims, './mvnw -pl core test')).toBe('contradicted'); + + // The in-claim marker case contradicts too (unchanged direction). + const insideClaim = { + build: [], + test: [ + mavenCmd({ + exitCode: 1, + evidenceCapped: true, + output: + '[maven-test-report] core (1 failing report(s)): tests=2, failures=1, errors=0, skipped=0\n' + + '[maven-test-failure] core/target/surefire-reports/TEST-A.xml: example.ATest#fails', + }), + ], + } as unknown as BuildTestReport; + const flip = run( + '## Test Plan\n\nRan `./mvnw -pl core test`', + [], + insideClaim, + ); + expect(verdictOf(flip.claims, './mvnw -pl core test')).toBe( + 'contradicted', + ); + }); + + it('does not settle a claim off a capped run classified as infrastructure', () => { + // The capped-definitive ranking arm admits any non-zero exit unless + // guarded: an environmental acquisition failure that also carries + // evidenceCapped (a trim rescue overflow) would otherwise rule the + // claim `contradicted`, laundering the environmental death into a + // definitive ruling. Arms 1/2/4 all exclude infrastructure. + const bt = { + build: [], + test: [ + mavenCmd({ + infrastructure: true, + evidenceCapped: true, + exitCode: 1, + output: '[ERROR] Could not resolve dependencies', + }), + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('environmental'); + }); + + it('reads single-dash long option spellings like their -- twins', () => { + // commons-cli accepts `mvn -projects core` (verified on real Maven + // 3.8.7): normalized before parsing, or the claim bypassed the + // selector grammar and the also-make checks modeled on `--` forms. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const claim of [ + './mvnw -projects core test', + './mvnw -projects=core test', + ]) { + const r = run(`## Test Plan\n\nRan \`${claim}\``, [], bt); + expect(verdictOf(r.claims, claim)).toBe('reproduces'); + } + + // The selector grammar — not just the lifecycle — must be reading + // the spelling: a run scoped to a DIFFERENT module never tested + // `core`, and an unscoped lifecycle settlement would wrongly rule + // this reproduces. + const other = { + build: [], + test: [mavenCmd({ modules: ['cli'] })], + } as unknown as BuildTestReport; + const mismatched = run( + '## Test Plan\n\nRan `./mvnw -projects core test`', + [], + other, + ); + expect(verdictOf(mismatched.claims, './mvnw -projects core test')).toBe( + 'unchecked', + ); + + // The `-also-make` spelling is recognized as upstream closure: the + // finished -am carve-out must NOT apply to it (the claim runs + // upstream too), so an upstream-only failure still contradicts. + const failed = { + build: [], + test: [mavenCmd({ exitCode: 1, output: '[ERROR] Tests failed' })], + } as unknown as BuildTestReport; + const am = run( + '## Test Plan\n\nRan `./mvnw -pl core -also-make test`', + [], + failed, + ); + const amClaim = am.claims.find( + (c) => c.text === './mvnw -pl core -also-make test', + ); + expect(amClaim?.verdict).toBe('contradicted'); + + // The -am-less single-dash spelling keeps the carve-out exactly + // like its `-pl` twin pinned above. + const carve = run( + '## Test Plan\n\nRan `./mvnw -projects core test`', + [], + failed, + ); + expect(verdictOf(carve.claims, './mvnw -projects core test')).toBe( + 'unchecked', + ); + }); + + it('contradicts a -pl claim when the -am run failed compiling inside the claimed module', () => { + // A compile failure writes no Surefire reports, so the marker-based + // attribution cannot see it; the compiler error's own path names the + // module. The carve-out used to discard the failing run and read + // 'this Maven command was not run' over a claim whose own command + // would fail identically. + const output = + `[ERROR] ${join(dir, 'core/src/main/java/Foo.java')}:[10,5] cannot find symbol\n` + + '[INFO] BUILD FAILURE'; + const bt = { + build: [], + test: [mavenCmd({ exitCode: 1, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw -pl core test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + + // Control: the same failure in an UPSTREAM module stays inside the + // carve-out — the claim never compiles it. + const upstream = { + build: [], + test: [ + mavenCmd({ + exitCode: 1, + output: + `[ERROR] ${join(dir, 'upstream/src/main/java/Bar.java')}:[10,5] cannot find symbol\n` + + '[INFO] BUILD FAILURE', + }), + ], + } as unknown as BuildTestReport; + const u = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], upstream); + expect(verdictOf(u.claims, './mvnw -pl core test')).toBe('unchecked'); + }); + + it('contradicts a -pl . claim when the -am run failed compiling the root project', () => { + // failureInsideClaim's root-module arm keys on `/src/`: a + // compile failure in the root project's own sources must defeat the + // `-am` carve-out exactly like a module failure does, or the claim + // stands unchecked on evidence the review's own run contradicts. + const output = + `[ERROR] ${join(dir, 'src/main/java/Foo.java')}:[10,5] cannot find symbol\n` + + '[INFO] BUILD FAILURE'; + const bt = { + build: [], + test: [mavenCmd({ modules: ['.'], exitCode: 1, output })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl . test`', [], bt); + expect(verdictOf(r.claims, './mvnw -pl . test')).toBe('contradicted'); + }); + + it('treats relative mvnd/mvnDebug spellings as command claims', () => { + // The relative wrapper spellings were modeled for mvnw alone; + // `./mvnd test` fell through extraction (no extension, no runner + // match) and could never be ruled. + expect(extractClaims('Run `./mvnd test`').map((c) => c.kind)).toEqual([ + 'command', + ]); + expect( + extractClaims('Run `../mvnDebug test`').map((c) => c.kind), + ).toEqual(['command']); + + // And a recorded green run settles the spelling like its mvnw twin. + const bt = { + build: [], + test: [mavenCmd({ exe: './mvnd', modules: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnd test`', [], bt); + expect(verdictOf(r.claims, './mvnd test')).toBe('reproduces'); + }); + + it('settles a -l/--log-file claim and never reads the log value as a phase', () => { + // `-l` redirects the log; it changes no outcomes, so it is the one + // value flag that does not scope — but its VALUE must be consumed, + // or `mvn verify -l test` read `test` as a claimed phase. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + + const settled = run( + '## Test Plan\n\nRan `mvn test -l build.log`', + [], + bt, + ); + expect(verdictOf(settled.claims, 'mvn test -l build.log')).toBe( + 'reproduces', + ); + + const consumed = run('## Test Plan\n\nRan `mvn verify -l test`', [], bt); + expect(verdictOf(consumed.claims, 'mvn verify -l test')).toBe( + 'unchecked', + ); + }); + + it('does not settle a claim ending on a value flag missing its value', () => { + // Real Maven dies in argument parsing on the dangling form + // (`MissingArgumentException`) and runs zero lifecycle work — the + // claim names a command that cannot execute. `-l` is the one value + // flag whose presence does not scope, so its dangling spelling + // slipped through every settlement gate. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + for (const claim of [ + 'mvn test -l', + 'mvn test --log-file', + 'mvn test -log-file', + ]) { + const r = run(`## Test Plan\n\nRan \`${claim}\``, [], bt); + expect(verdictOf(r.claims, claim)).toBe('unchecked'); + } + // The valued form still settles — only the missing value blocks. + const valued = run('## Test Plan\n\nRan `mvn test -l build.log`', [], bt); + expect(verdictOf(valued.claims, 'mvn test -l build.log')).toBe( + 'reproduces', + ); + }); + + it('does not double-read a -pl that a sibling value flag consumes', () => { + // `mvn -l -pl test`: real Maven's commons-cli hands `-pl` to `-l` + // as its log-file value (or dies in argument parsing when the + // isArgument gate below applies) — the `-pl` token is NOT a + // selector. The double read once yielded lifecycle `test` AND a + // phantom module set `['test']`, settling the claim module-scoped + // against a `-pl test` run and masking every sibling module the + // claimed full-reactor command would have run. + const bt = { + build: [], + test: [mavenCmd({ modules: ['test'] })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `mvn -l -pl test`', [], bt); + expect(verdictOf(r.claims, 'mvn -l -pl test')).toBe('unchecked'); + // The same walker must not read the consumed value as `-am` either. + expect(verdictOf(r.claims, 'mvn -l -pl test')).not.toBe('reproduces'); + }); + + it("does not read an option-like token as a value flag's value", () => { + // commons-cli's isArgument gate: a value flag followed by an + // option-like token is a MISSING VALUE (`MissingArgumentException`) + // — zero lifecycle work runs — not a flag with a dash-prefixed + // value. Without the gate, `-l -am` swallowed `-am` as the log + // file for one walker while another read it as `--also-make`: a + // claim real Maven rejects settled `reproduces`, and its + // failing-run twin contradicted through the `-am` carve-out a run + // the claim could never have executed. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const logThenAm = run('## Test Plan\n\nRan `mvn -l -am test`', [], bt); + expect(verdictOf(logThenAm.claims, 'mvn -l -am test')).toBe('unchecked'); + + const failing = { + build: [], + test: [ + mavenCmd({ + modules: ['core'], + exitCode: 1, + output: '[ERROR] Tests run: 1, Failures: 1', + }), + ], + } as unknown as BuildTestReport; + const carveOut = run( + '## Test Plan\n\nRan `./mvnw -pl core -l -am test`', + [], + failing, + ); + expect(verdictOf(carveOut.claims, './mvnw -pl core -l -am test')).toBe( + 'unchecked', + ); + expect( + carveOut.claims.find((c) => c.text === './mvnw -pl core -l -am test') + ?.note, + ).not.toContain('failed for environmental reasons'); + }); + + it('rules a claim with an unclosed shell substitution instead of aborting', () => { + // An unclosed `${` (or an empty `${}`) makes the shell parser throw + // `Bad substitution`; one malformed span — adversarial or a truncated + // `${` log paste, common in Maven logs — otherwise aborted the + // ENTIRE test-plan step and no claim in the plan was ruled. The + // whitespace-split fallback keeps the claim ruling. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `mvn test ${FOO`', [], bt); + const claim = r.claims.find((c) => c.text === 'mvn test ${FOO'); + expect(claim?.kind).toBe('command'); + expect(claim?.verdict).toBe('unchecked'); + }); + + it('does not settle a claim carrying an option Maven rejects', () => { + // Maven dies on 'Unable to parse command line options' for an option + // it does not have — zero lifecycle work runs, exactly like an + // unknown bare positional — so the claim must not settle. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw test --verbose`', [], bt); + expect(verdictOf(r.claims, './mvnw test --verbose')).toBe('unchecked'); + // A modeled neutral flag still settles. + const modeled = run('## Test Plan\n\nRan `./mvnw -B test`', [], bt); + expect(verdictOf(modeled.claims, './mvnw -B test')).toBe('reproduces'); + }); + + it('does not settle claims carrying the zero-work usage or version options', () => { + // Real Maven prints usage/version and exits 0 with zero lifecycle + // work for them — certifying one would read a command that built and + // tested nothing as run-and-passed. `-V` deliberately stays neutral: + // it prints the version WITHOUT stopping the build. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + for (const flag of ['-h', '--help', '-v', '--version', '-help']) { + const r = run(`## Test Plan\n\nRan \`mvn ${flag} test\``, [], bt); + expect(verdictOf(r.claims, `mvn ${flag} test`)).toBe('unchecked'); + } + const show = run('## Test Plan\n\nRan `mvn -V test`', [], bt); + expect(verdictOf(show.claims, 'mvn -V test')).toBe('reproduces'); + }); + + it('settles default-lifecycle phase claims with the reduction disclosed', () => { + // Maven accepts every default-lifecycle phase as a bare positional; + // the review never runs them explicitly, so the claim settles on its + // final phase with the reduction disclosed instead of reading + // unchecked over work the recorded run DID execute. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `mvn generate-sources test`', [], bt); + const claim = r.claims.find( + (c) => c.text === 'mvn generate-sources test', + ); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toContain('final phase (`test`)'); + }); + + it('discloses the -am asymmetry when a -pl claim settles on an -am run', () => { + // The recorded run resolved inter-module dependencies from the + // reactor; the claim's bare command resolves them from the local + // repository — the note must not read as if the exact command ran. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw -pl core test'); + expect(claim?.verdict).toBe('reproduces'); + expect(claim?.note).toContain( + 'with the upstream closure (`-am`) the claim does not name', + ); + }); + + it('words a capped infrastructure -am run as environmental', () => { + // An infrastructure run cannot contradict — the cascade's + // environmental arm is its only consumer — so the `-am` carve-out + // must not hide a capped one from that arm and force the false + // "different scope or phase" note. + const bt = { + build: [], + test: [mavenCmd({ infrastructure: true, evidenceCapped: true })], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `./mvnw -pl core test`', [], bt); + const claim = r.claims.find((c) => c.text === './mvnw -pl core test'); + expect(claim?.verdict).toBe('unchecked'); + expect(claim?.note).toContain('environmental reasons'); + }); + + it('settles an apostrophe module dir spelled with the quoting dance', () => { + // shellSelector wraps `it's dir` as `'it'\''s dir'`; the claim + // pipeline strips one quote layer before mavenPlModules runs, so the + // space-separated spelling arrives with the dance exposed and no + // outer quote left to detect — the undo must apply regardless, or + // the claim can never settle or be contradicted. + const bt = { + build: [], + test: [mavenCmd({ modules: ["it's dir"], alsoMake: false })], + } as unknown as BuildTestReport; + const r = run( + "## Test Plan\n\nRan `./mvnw -pl 'it'\\''s dir' test`", + [], + bt, + ); + expect(verdictOf(r.claims, "./mvnw -pl 'it'\\''s dir' test")).toBe( + 'reproduces', + ); + }); + + it('attributes a compiler failure through a source path with a space', () => { + // A module dir can carry a space, and the compiler-error path it + // emits does too: the path capture must not stop at the space, or + // the `-am` carve-out keeps discarding a run that failed inside the + // claim and reads it unchecked. + mkdirSync(join(dir, 'my module'), { recursive: true }); + writeFileSync(join(dir, 'my module', 'pom.xml'), ''); + const wt = dir.replace(/\\/g, '/'); + + const bt = { + build: [], + test: [ + mavenCmd({ + modules: ['my module'], + exitCode: 1, + output: `[ERROR] ${wt}/my module/src/Foo.java:[10,5] cannot find symbol`, + }), + ], + } as unknown as BuildTestReport; + const r = run( + "## Test Plan\n\nRan `./mvnw -pl 'my module' test`", + [], + bt, + ); + expect(verdictOf(r.claims, "./mvnw -pl 'my module' test")).toBe( + 'contradicted', + ); + }); + + it('does not settle a claim that repeats the -pl selector', () => { + // Maven ACCUMULATES repeated -pl: `mvn -pl core -pl cli test` builds + // both modules, so reading only the last occurrence let a claim that + // also covered `core` settle (or contradict) on a run scoped to `cli`. + const bt = { + build: [], + test: [mavenCmd({ modules: ['cli'] })], + } as unknown as BuildTestReport; + const command = './mvnw -pl core -pl cli test'; + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('unchecked'); + }); + + it('does not settle attached short-form claims the recorded run never used', () => { + // commons-cli accepts separator-less attached short forms; the + // exact-token matches alone let every one of them bypass the + // conservative treatment. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const command of [ + './mvnw -plcore test', + './mvnw -pl core -fother/pom.xml test', + './mvnw -pl core -rf:core test', + './mvnw -pl core -ssettings.xml test', + './mvnw -pl core -gssettings.xml test', + './mvnw -pl core -ttoolchains.xml test', + './mvnw -pl core -bmybuilder test', + './mvnw -pl core -amdapp test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('unchecked'); + } + }); + + it('does not settle claims whose scope rides a quoted flag token', () => { + // One layer of surrounding quotes must not hide the flag from the + // scope guards or the selector parser. + const bt = { + build: [], + test: [mavenCmd({ modules: null })], + } as unknown as BuildTestReport; + + const pl = run('## Test Plan\n\nRan `mvn "-pl" core test`', [], bt); + expect(verdictOf(pl.claims, 'mvn "-pl" core test')).toBe('unchecked'); + + const prop = run('## Test Plan\n\nRan `mvn "-Dfoo=bar" test`', [], bt); + expect(verdictOf(prop.claims, 'mvn "-Dfoo=bar" test')).toBe('unchecked'); + }); + + it('does not settle parallelism claims on a serial run', () => { + // `-T`/`--threads` change outcomes (modules racing shared state); the + // review only ever runs serial. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const command of [ + './mvnw -pl core -T 1C test', + './mvnw -pl core -T1C test', + './mvnw -pl core --threads=4 test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('unchecked'); + } + }); + + it('settles backslash and trailing-slash -pl spellings on their recorded modules', () => { + // Windows-style selectors name the same module dirs; discarding them + // as unmatched silently wasted the recorded green evidence. + const bt = { + build: [], + test: [mavenCmd()], + } as unknown as BuildTestReport; + + for (const command of [ + './mvnw -pl .\\core test', + './mvnw -pl core/ test', + ]) { + const r = run(`## Test Plan\n\nRan \`${command}\``, [], bt); + expect(verdictOf(r.claims, command)).toBe('reproduces'); + } + }); + + it('does not lift a Maven runner out of its command span', () => { + const plain = run('## Test Plan\n\nRan `./mvnw test`'); + expect( + plain.claims.filter((c) => c.kind === 'path' && c.text === './mvnw'), + ).toEqual([]); + + // A cd-chained runner used to normalize into a false `core/mvnw` path. + const chained = run('## Test Plan\n\nRan `cd core && ./mvnw test`'); + expect( + chained.claims.filter((c) => c.kind === 'path' && /mvnw/.test(c.text)), + ).toEqual([]); + }); + + it('rules a bare command contradicted when ANY scoped run failed', () => { + // build-test records one scoped command per package and does not stop on + // failure; the first match could be the green package that sorted first, + // stating the opposite of the authoritative `ok: false`. + const bt = { + build: [], + test: [ + { + command: 'npm test --workspace="packages/a"', + exitCode: 0, + seconds: 1, + timedOut: false, + output: '', + }, + { + command: 'npm test --workspace="packages/b"', + exitCode: 1, + seconds: 1, + timedOut: false, + output: 'fail', + }, + ], + } as unknown as BuildTestReport; + const r = run('## Test Plan\n\nRan `npm test`', [], bt); + const claim = r.claims.find((c) => c.text === 'npm test'); + expect(claim?.verdict).toBe('contradicted'); + expect(claim?.observed).toBe('exit 1'); + }); + + it('finds a root-only script when the root defines no build/test', () => { + // `readRootPackage` returns null when the root has neither build nor test, + // which used to drop a root-only `lint` and rule a correct claim contradicted. + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ + workspaces: ['packages/*'], + scripts: { lint: 'eslint .' }, + }), + ); + mkdirSync(join(dir, 'packages/cli'), { recursive: true }); + writeFileSync( + join(dir, 'packages/cli/package.json'), + JSON.stringify({ name: '@x/cli', scripts: { build: 'tsc' } }), + ); + const r = run('## Test Plan\n\nRan `npm run lint`'); + expect(verdictOf(r.claims, 'npm run lint')).toBe('reproduces'); + }); + }); + + describe('count claims', () => { + const withCounts = (...counts: number[]) => + ({ + build: [], + test: counts.map((n) => ({ + command: 'npm test', + exitCode: 0, + seconds: 1, + timedOut: false, + output: `Tests ${n} passed (${n})`, + })), + }) as unknown as BuildTestReport; + + it('reproduces a count a suite in this review reported', () => { + const r = run('## Test Plan\n\n471 tests passed', [], withCounts(471)); + expect(verdictOf(r.claims, '471 tests passed')).toBe('reproduces'); + }); + + it('reports a mismatch as `differs`, NOT as a contradiction', () => { + // The whole point: 471 vs 472 may be two different suites, and this + // command cannot tell. It must never become a blocker. + const r = run('## Test Plan\n\n471 tests passed', [], withCounts(472)); + const claim = r.claims.find((c) => c.text === '471 tests passed'); + expect(claim?.verdict).toBe('differs'); + expect(claim?.observed).toBe('472 passed'); + expect(r.claims.some((c) => c.verdict === 'contradicted')).toBe(false); + }); + + it('reproduces a changed-module count on the -am subtotal, not only the reactor total', () => { + // `-pl core -am` also tests the upstream closure: the claim "42 + // tests passed" for `core` matches the changed-module subtotal even + // though the reactor-wide sum is 187 — both readings are emitted, + // and either settles the claim. + const bt = { + build: [], + test: [ + { + command: + './mvnw --batch-mode --no-transfer-progress -pl core -am test', + exitCode: 0, + seconds: 10, + timedOut: false, + maven: { lifecycle: 'test', modules: ['core'], alsoMake: true }, + output: + '[maven-test-report] common (12 report(s)): tests=145, failures=0, errors=0, skipped=0\n' + + '[maven-test-report] core (4 report(s)): tests=42, failures=0, errors=0, skipped=0', + }, + ], + } as unknown as BuildTestReport; + + const r = run('## Test Plan\n\n42 tests passed', [], bt); + expect(verdictOf(r.claims, '42 tests passed')).toBe('reproduces'); + const total = run('## Test Plan\n\n187 tests passed', [], bt); + expect(verdictOf(total.claims, '187 tests passed')).toBe('reproduces'); }); it('is unchecked when no suite reported a count', () => { diff --git a/packages/cli/src/commands/review/test-plan.ts b/packages/cli/src/commands/review/test-plan.ts index f403db06d00..ad0ac510b1f 100644 --- a/packages/cli/src/commands/review/test-plan.ts +++ b/packages/cli/src/commands/review/test-plan.ts @@ -45,9 +45,16 @@ // author can apply. It is the same disclosed-but-not-capping treatment // `script-lint` gives a deferred checker, for the same reason. +import { parse as parseShellQuote } from 'shell-quote'; import type { CommandModule } from 'yargs'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, normalize, resolve } from 'node:path'; +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, normalize, resolve, sep } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { gh, setGhHost } from './lib/gh.js'; import { git } from './lib/git.js'; @@ -57,7 +64,8 @@ import { readWorkspaceGlobs, readWorkspacePackages, } from './lib/workspaces.js'; -import type { BuildTestReport } from './build-test.js'; +import type { BuildTestReport, CommandResult } from './build-test.js'; +import { isSourceFailureLine } from './lib/maven-toolchain.js'; import type { FileMetric } from './lib/report.js'; /** What kind of assertion a claim is, which decides how it can be ruled. */ @@ -194,9 +202,43 @@ export function extractTestPlanSection( return null; } -/** Runners whose presence makes a backticked span a command, not prose. */ -const RUNNER_RE = - /^(npm|npx|yarn|pnpm|bun|make|node|go|cargo|python3?|pytest)\b/; +// `mvn.cmd` is the spelling Windows `cmd.exe` users type for system Maven, +// and scoop/Chocolatey installs create an `mvn.exe` shim — the same suffix +// already granted `mvnd`/`mvnDebug`. The relative wrapper spellings — +// `./mvnw`, `.\mvnw`, and ANY mix of `./` and `../` hops (`../../mvnw` is +// a normal nested-module invocation two levels deep, `././mvnw` and +// `./../mvnw` are ordinary shell spellings) — are command claims exactly +// like the bare runner; without the hops such claims are silently never +// extracted and never ruled. They are modeled for the WHOLE runner +// vocabulary, not `mvnw` alone: `./mvnd test` is a command claim exactly +// like `./mvnw test`, and `./mvn` exactly like `mvn`. +const MAVEN_RUNNER_SOURCE = + 'mvn(?:\\.(?:cmd|exe))?|mvnd(?:\\.(?:cmd|exe))?|mvnDebug(?:\\.(?:cmd|exe))?|mvnw(?:\\.cmd)?' + + '|(?:\\.{1,2}[/\\\\])+(?:mvn(?:\\.(?:cmd|exe))?|mvnd(?:\\.(?:cmd|exe))?|mvnDebug(?:\\.(?:cmd|exe))?|mvnw(?:\\.cmd)?)'; + +/** Runners whose presence makes a backticked span a command, not prose. + * The legacy runners stay case-sensitive: the script adjudicators are, so + * extracting capitalized prose spans (`Node 22`, `NPM RUN test:unit`) only + * added claims that can never settle. The Maven alternation is matched + * case-insensitively through MAVEN_RUNNER_RE: Windows authors type + * `MVNW test` and `mvnw.CMD test` (the filesystem is case-insensitive + * there), and a span the runner check rejects is a claim silently never + * extracted and never ruled. */ +const RUNNER_RE = new RegExp( + '^(?:npm|npx|yarn|pnpm|bun|make|node|go|cargo|python3?|pytest)\\b', +); + +const MAVEN_RUNNER_RE = new RegExp(`^(?:${MAVEN_RUNNER_SOURCE})(?=\\s|$)`, 'i'); + +/** + * True when a tokenized command line names lifecycle work beyond its runner: + * any token after position 0 that is not a flag (flags start with `-`, and + * the neutral flags a claim may carry are all flags too). A runner followed + * only by flags — `./mvnw`, `./mvnw -q` — names no settleable work. + */ +function hasWorkBeyondRunner(tokens: string[]): boolean { + return tokens.slice(1).some((token) => !token.startsWith('-')); +} /** `foo/bar.ts`, `packages/cli/src/x.tsx:42` — a path, not a sentence. */ const PATH_RE = /^[\w.@-]+(?:\/[\w.@-]+)+\/?(?::\d+(?::\d+)?)?$/; @@ -344,7 +386,22 @@ export function extractClaims(section: string): Array<{ // A unified diff pasted into the Test Plan (the template's Evidence // section invites it) is not a set of path claims about the tree. if (/^(?:diff --git|---|\+\+\+|@@)\s/.test(span)) continue; - if (RUNNER_RE.test(span)) push('command', span); + // A bare Maven runner token WITHOUT a lifecycle phase (`./mvnw`) is a + // FILENAME, not a command claim: the command reading can never settle + // (no phase to compare against any recorded run) and it suppressed the + // path verification the same token actually is — "added `./mvnw`" is a + // claim about the tree. Spans naming WORK beyond the runner stay + // commands; a work this review never runs reads `unchecked` there. A + // runner followed only by neutral flags (`./mvnw -q`) names no work, so + // it is not a command claim either — measuring token count, not work, + // once classified it a command that could never settle while suppressing + // the wrapper-existence path claim the span also is. + const spanTokens = span.split(/\s+/); + const mavenCommand = + MAVEN_RUNNER_RE.test(span) && hasWorkBeyondRunner(spanTokens); + if (RUNNER_RE.test(span) || mavenCommand) { + push('command', span); + } if (PATH_RE.test(span)) { if (isPathClaim(span)) push('path', span); continue; @@ -384,6 +441,14 @@ export function extractClaims(section: string): Array<{ .replace(/'[^']*'/g, '') .replace(/"[^"]*"/g, '') .split(/\s+/); + // `./mvnw` is a runner — not a path claim — only when it LEADS a Maven + // command line that names work: as an ARGUMENT of any other command + // (`chmod +x ./mvnw`, `bash ./mvnw`) it unambiguously names a file in + // the tree, and a runner with only neutral flags (`./mvnw -q`) carries + // no settleable work — in both cases the wrapper-existence claim must + // not be silently dropped. + const runnerHead = + MAVEN_RUNNER_RE.test(tokens[0] ?? '') && hasWorkBeyondRunner(tokens); for (let i = 0; i < tokens.length; i++) { // A token following a flag is that flag's VALUE (`--repo owner/repo`, // `-f infra/compose.yml`) — a claim about the tool's argument space, @@ -397,7 +462,7 @@ export function extractClaims(section: string): Array<{ ) continue; const t = tokens[i].replace(/[.,;:)'"]+$/, ''); - if (PATH_RE.test(t) && isPathClaim(t)) { + if (PATH_RE.test(t) && isPathClaim(t) && !(i === 0 && runnerHead)) { push('path', base ? `${base}/${t}` : t); } } @@ -430,6 +495,31 @@ export function observedTestCounts(report: BuildTestReport | null): number[] { if (!report) return []; const counts: number[] = []; for (const cmd of report.test ?? []) { + // The same exclusion family ruleCommand applies to command claims + // (finished() plus the exit-0 failure gate): an interrupted or + // infrastructure-classified run is not a completed suite, and its + // partial counts must not adjudicate a count claim. A fail-never run + // that swallowed failures is the same — the field's contract forbids + // ruling any claim reproduced against it — and so is a run whose + // evidence the adapter refused to certify: part of it was never read. + // An exit-0 run over fresh FAILING reports (swallowedReports) is a + // failed run exactly like its command-claim twin: its derived pass + // count once ruled a count claim `reproduces` while build-test marked + // the same run ok:false. A plain non-zero exit is the commonest failed + // shape — contribute counts only when the run finished green, mirroring + // ruleCommand's green-settlement family. + if ( + cmd.timedOut || + cmd.exitCode === null || + cmd.exitCode !== 0 || + cmd.infrastructure || + cmd.swallowedFailure || + cmd.evidenceCapped || + cmd.testsSuppressed || + cmd.neverRan || + cmd.swallowedReports + ) + continue; // vitest: `Tests 472 passed (472)`. jest: `Tests: 12 passed, 12 total`. let total = 0; let saw = false; @@ -437,6 +527,8 @@ export function observedTestCounts(report: BuildTestReport | null): number[] { // segment forms like `Tests 2 failed | 3 skipped | 40 passed (45)` — // vitest separates with ` | `, jest with `, `. const re = /^\s*Tests:?\s+(?:\d+\s+\w+\s*[,|]\s*)*(\d+)\s+passed/gim; + const mavenRe = + /^\[maven-test-report\]\s+(.+?):\s+tests=(\d+),\s+failures=(\d+),\s+errors=(\d+),\s+skipped=(\d+)$/gim; // Strip ANSI SGR sequences first. A real runner writes its summary through // a color-enabled pipe, so the kept text reads // `Tests\x1b[2m \x1b[22m\x1b[1m3 failed\x1b[22m…` — the codes sit BETWEEN @@ -446,11 +538,61 @@ export function observedTestCounts(report: BuildTestReport | null): number[] { // eslint-disable-next-line no-control-regex -- ESC is the character under test const text = (cmd.output ?? '').replace(/\x1b\[[0-9;]*m/g, ''); let m: RegExpExecArray | null; - while ((m = re.exec(text))) { - total += Number(m[1]); - saw = true; + // Runner-gated in BOTH directions: the JS console-summary regex is + // skipped on Maven runs because surefire echoes test stdout — a Maven + // run's own test printing `Tests 499 passed (499)` is not the run's + // count — and the `[maven-test-report]` markers are only evidence a + // MAVEN run prints. The same text mined from the other toolchain's + // stdout is a fabricated count in either direction; each shape is gated + // behind its own runner. + const isMavenCommand = MAVEN_RUNNER_RE.test(cmd.command); + if (!isMavenCommand) { + while ((m = re.exec(text))) { + total += Number(m[1]); + saw = true; + } + } + // `-pl -am` also tests the UPSTREAM closure, and one rollup + // line per project records it. A count claim about the changed modules + // must be able to match their SUBTOTAL, not only the reactor-wide sum — + // "42 tests pass" for module `core` otherwise reads `differs` against + // a 187 total that includes upstream `common`'s 145, even though the + // `-am` carve-out exists precisely because upstream evidence is out of + // the claim's scope. Collect the per-project lines and emit both + // readings; ruleCount settles on either. + const moduleSet = cmd.maven?.modules ?? null; + let moduleSubtotal = 0; + let sawModuleLine = false; + if (isMavenCommand) { + while ((m = mavenRe.exec(text))) { + // Surefire does not guarantee tests >= failures + errors + skipped + // (class-level @Disabled and rerunFailingTestsCount reruns both perturb + // it), and this sum spans every report of the command: one negative + // value would silently cancel legitimate counts from its neighbours. + const passed = Math.max( + 0, + Number(m[2]) - Number(m[3]) - Number(m[4]) - Number(m[5]), + ); + total += passed; + saw = true; + // The rollup lines append ` (N report(s))` to the project dir; + // strip it to compare against the recorded module names. The + // omitted-rollup marker lines carry no module attribution and + // never match a `-pl` module name, so they stay in the total only. + if (moduleSet !== null) { + const project = m[1].replace( + / \(\d+ (?:failing )?report\(s\)\)$/, + '', + ); + if (moduleSet.includes(project)) { + moduleSubtotal += passed; + sawModuleLine = true; + } + } + } } if (saw) counts.push(total); + if (sawModuleLine && moduleSubtotal !== total) counts.push(moduleSubtotal); } return counts; } @@ -579,6 +721,400 @@ export function npmScriptOf(command: string): string | null { return alias ? alias[1] : null; } +/** Lifecycle phases a Maven command can name. */ +const MAVEN_PHASE_RE = + /^(?:clean|validate|compile|test-compile|test|package|verify|install)$/; + +/** + * Work this review never runs, for reduction DISCLOSURE only — deliberately + * wider than the settlement vocabulary above: a claim carrying `deploy`, + * `site`, or a plugin goal (a positional carrying `:`) in non-trailing + * position settles via its trailing in-vocabulary phase, and the reduction + * must be disclosed rather than read as if the whole claim ran. Trailing + * out-of-vocabulary work is refused separately by claimFinalWork. + */ +const MAVEN_UNRUN_WORK_RE = + /^(?:deploy|site|site-deploy|pre-site|post-site|pre-clean|post-clean|prepare-package|pre-integration-test|integration-test|post-integration-test|initialize|process-resources|process-classes|process-test-classes|generate-sources|process-sources|generate-resources|generate-test-sources|process-test-sources|generate-test-resources|process-test-resources)$/; + +/** + * Boolean flags that change no outcome the review measures — batch mode and + * transfer-progress suppression (the review's own command carries them), + * verbosity and error stack traces, and the version banner that does not + * stop the build (`-v`/`--version` DOES stop it and joins the zero-work + * guards). A claim carrying one settles exactly like the claim without it. + */ +const NEUTRAL_MAVEN_FLAGS = new Set([ + '-B', + '--batch-mode', + '-ntp', + '--no-transfer-progress', + '-q', + '--quiet', + '-e', + '--errors', + '-X', + '--debug', + '-V', + '--show-version', +]); + +/** + * Flags whose space-separated form consumes the NEXT token as their value + * (the attached `=` forms carry it in-token and consume nothing). A module + * dir named `test` handed to one is a flag VALUE, not a lifecycle phase. + */ +const MAVEN_VALUE_FLAGS = new Set([ + '-pl', + '--projects', + '-P', + '--activate-profiles', + '-D', + '--define', + '-rf', + '--resume-from', + '-f', + '--file', + '-s', + '--settings', + '-gs', + '--global-settings', + '-l', + '--log-file', + '-T', + '--threads', + '-b', + '--builder', + '-t', + '--toolchains', + '-gt', + '--global-toolchains', + // The password-encryption options consume their argument and perform + // zero lifecycle work — reading that argument as a positional phase + // settles claims that built and tested nothing. + '-emp', + '--encrypt-master-password', + '-ep', + '--encrypt-password', +]); + +/** + * Long options whose single-dash spelling Maven's commons-cli ALSO accepts + * (`mvn -projects core` — verified on real Maven 3.8.7). They are + * normalized to their `--` spellings before parsing, or they bypass every + * scope/value check modeled on the `--` forms. Exact names only: rewriting + * arbitrary multi-char `-x` tokens would mangle the separator-less attached + * short forms (`-rfcore`), which have their own checks. + */ +const MAVEN_SINGLE_DASH_LONGS = new Set([ + 'projects', + 'also-make', + 'also-make-dependents', + 'activate-profiles', + 'define', + 'resume-from', + 'file', + 'settings', + 'global-settings', + 'log-file', + 'threads', + 'builder', + 'toolchains', + 'global-toolchains', + 'non-recursive', + 'offline', + 'update-snapshots', + 'fail-never', + 'fail-fast', + 'fail-at-end', + 'encrypt-master-password', + 'encrypt-password', + 'batch-mode', + 'help', + 'version', + 'show-version', + // The single-dash long spellings of the neutral flags the `--` set above + // models: without them a claim carrying `-quiet` reads as unknown work and + // never settles, while its `-q` twin settles — the docstring promise that + // a neutral-flag claim settles like the claim without it, broken. + 'quiet', + 'errors', + 'debug', + 'no-transfer-progress', + // The repository-layout flags: their single-dash long spellings start with + // `-l` and must not bypass the scope/value checks modeled on the `--` + // forms via the attached `-l` carve-out. + 'legacy-local-repository', + 'lax-checksums', +]); + +function normalizeMavenSingleDashLongTokens(tokens: string[]): string[] { + return tokens.map((token) => { + const eq = token.indexOf('='); + const head = eq === -1 ? token : token.slice(0, eq); + if ( + head.length > 2 && + head.startsWith('-') && + !head.startsWith('--') && + MAVEN_SINGLE_DASH_LONGS.has(head.slice(1)) + ) { + return `-${token}`; + } + return token; + }); +} + +/** + * shell-quote's parse() reduced to plain strings — the same word-splitting + * a shell performs: quoted selectors arrive as one unquoted word (a quote + * spanning a flag=value pair with a space, adjacent quoting like + * `-pl core","other`, backslash escapes, and the `'\''` apostrophe dance + * all resolve), and control operators and glob patterns survive as their + * literal text so the grammar below treats them like any other unmodeled + * token. `$NAME` references stay literal instead of expanding to empty. + */ +function shellTokens(text: string): string[] { + const literalEnv = (name: string): string => `$${name}`; + // shellQuotePath's `'\''` dance for dirs with an apostrophe is the ONE + // escape sequence claims carry: substitute it before the parse, because + // the parse runs with escaping disabled — backslashes elsewhere are + // Windows path separators (`-pl .\core`), not shell escapes, and must + // survive as literal text for the module-dir normalization below. + const danced = text.replace(/'\\''/g, '\u0001'); + let parsed: ReturnType; + try { + parsed = parseShellQuote(danced, literalEnv, { escape: '\u0000' }); + } catch { + // An unclosed `${` or an empty `${}` makes shell-quote throw + // (`Bad substitution`); one malformed span — adversarial or a truncated + // `${` log paste, common in Maven logs — otherwise aborted the ENTIRE + // test-plan step and no claim in the plan was ruled. Fall back to + // whitespace splitting, the pre-shell-quote behavior: the claim still + // rules (unmodeled work reads `unchecked`) instead of never ruling. + return text.split(/\s+/).filter((token) => token.length > 0); + } + return parsed.flatMap((entry): string[] => { + // A quoted-empty word (`''`/`""`) is a REAL argv slot — bash hands Maven + // `['test', '']` and Maven dies on `Unknown lifecycle phase ""`. Keeping + // it (instead of the old length filter) lets the positional reach the + // unknown-work check and refuse settlement, matching the shell. + // eslint-disable-next-line no-control-regex -- the dance sentinel is the character under test + if (typeof entry === 'string') return [entry.replace(/\u0001/g, "'")]; + if (typeof entry === 'object' && entry !== null) { + if ('pattern' in entry && typeof entry.pattern === 'string') { + return [entry.pattern]; + } + if ('op' in entry && typeof entry.op === 'string') return [entry.op]; + } + return []; + }); +} + +/** + * The tokens of a Maven command line that are not consumed as flag values. + * shellTokens already resolved the quoting. A space-separated value flag + * consumes the next token only when it is not itself option-like — + * commons-cli's isArgument gate; an option-like next token is a missing + * value, not a value — and the attached `=` form carries its + * value in-token. + */ +function mavenPositionalTokens(tokens: string[]): string[] { + const positional: string[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (MAVEN_VALUE_FLAGS.has(token)) { + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith('-')) i += 1; + continue; + } + const eq = token.indexOf('='); + if ( + eq > 0 && + token.startsWith('-') && + MAVEN_VALUE_FLAGS.has(token.slice(0, eq)) + ) { + continue; + } + positional.push(token); + } + return positional; +} + +/** + * True when a value flag's value is missing — the command ends on the flag + * itself (`mvn test -l`), or the next token is itself an option (`mvn + * -l -am test` — `-am` is no log file). Real Maven's + * commons-cli only consumes a next token that is not option-like (its + * isArgument gate) and dies in argument parsing otherwise + * (`MissingArgumentException`): zero lifecycle work runs, and the claim + * names a command that cannot execute. + */ +function mavenDanglingValueFlag(tokens: string[]): boolean { + for (let i = 0; i < tokens.length; i++) { + // The attached `=` form carries its value in-token, so + // only the space-separated spelling can dangle. + if (MAVEN_VALUE_FLAGS.has(tokens[i])) { + const next = tokens[i + 1]; + if (next === undefined || next.startsWith('-')) return true; + i += 1; + } + } + return false; +} + +function mavenLifecycle(tokens: string[]): string | null { + if (!MAVEN_RUNNER_RE.test(tokens[0] ?? '')) return null; + // The LAST phase token that is not a flag value: that reads a phase-first + // spelling (`mvnw test -pl core`) correctly and never mistakes a + // phase-named `-pl` VALUE (`-pl test`) for the command's lifecycle. + // Lowercased so a capitalized claim (`./mvnw TEST`) matches the + // lowercase-only phase vocabulary consistently with the case-insensitive + // runner extraction; the returned lifecycle is the canonical lowercase + // form the settlement comparisons and the adapter's recorded lifecycle + // use. + let lifecycle: string | null = null; + for (const token of mavenPositionalTokens(tokens)) { + const lower = token.toLowerCase(); + if (MAVEN_PHASE_RE.test(lower)) lifecycle = lower; + } + return lifecycle; +} + +const BARE_MAVEN_LIFECYCLE_RE = new RegExp( + `^(?:${MAVEN_RUNNER_SOURCE})\\s+(clean|validate|compile|test-compile|test|package|verify|install)$`, + 'i', +); + +function bareMavenLifecycle(command: string): string | null { + return BARE_MAVEN_LIFECYCLE_RE.exec(command.trim())?.[1] ?? null; +} + +/** True when a command carries `-am`/`--also-make` (upstream closure). */ +function mavenHasAlsoMake(tokens: string[]): boolean { + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + // EVERY space-separated value flag's value is skipped, not just `-pl`'s: + // a selector can carry `-am` inside a module dir name (`-pl 'foo + // -am bar'` — spaces pass the POM entry gate), and a file named `-am` + // handed to `-f` is a value the same way. shellTokens keeps a quoted + // value one token; the isArgument gate consumes a plain-word next token + // and leaves an option-like one unconsumed — the command is dangling + // there anyway (mavenDanglingValueFlag), so the flag is never read. + if (MAVEN_VALUE_FLAGS.has(token)) { + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith('-')) i += 1; + continue; + } + const eq = token.indexOf('='); + if ( + eq > 0 && + token.startsWith('-') && + MAVEN_VALUE_FLAGS.has(token.slice(0, eq)) + ) { + continue; + } + if (token === '-am' || token === '--also-make') return true; + } + return false; +} + +/** The module set of a command's `-pl`/`--projects` selector, sorted. */ +function mavenPlModules(tokens: string[]): string[] | null { + // Maven ACCUMULATES repeated `-pl` (commons-cli `getOptionValues`): + // `mvn -pl m1 -pl m2` builds both modules, so every occurrence joins the + // set — keeping only the last read a claim that covered m1 as scoped to + // m2 alone. + const values: string[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + let raw: string | undefined; + // Advance BEFORE reading, like the sibling token walkers, and with the + // same isArgument gate: an option-like next token is a missing value, + // not a selector. + if (token === '-pl' || token === '--projects') { + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith('-')) { + i += 1; + raw = next; + } + } else if (token.startsWith('-pl=')) raw = token.slice('-pl='.length); + else if (token.startsWith('--projects=')) { + raw = token.slice('--projects='.length); + } else if (MAVEN_VALUE_FLAGS.has(token)) { + // The OTHER space-separated value flags consume their own values: + // `mvn -l -pl test` hands `-pl` to `-l` as its log file, so the `-pl` + // token must not be read a second time as a selector — the double + // read yielded lifecycle `test` AND a phantom module set `['test']`, + // settling the claim as module-scoped when real Maven ran it + // reactor-wide (or died in argument parsing). + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith('-')) i += 1; + continue; + } + if (raw === undefined) continue; + // shellTokens already resolved the quoting — space-bearing selectors, + // adjacent quoting, and the `'\''` apostrophe dance all arrive as one + // plain unquoted word, so the value splits on `,` as-is. + values.push(raw); + } + if (values.length === 0) return null; + const modules = [ + ...new Set( + values + .flatMap((value) => value.split(',')) + .map((module) => { + // Windows backslash selectors (`.\core`) and trailing-slash + // spellings (`core/`) name the same module dir as their POSIX + // twins; normalize them so the claim can settle against the + // recorded dir instead of silently discarding its evidence. + const unquoted = module + .trim() + .replace(/\\/g, '/') + .replace(/\/+$/, ''); + // A `[groupId]:artifactId` coordinate selector names a different + // NAMESPACE than the recorded module directories: artifactId and + // dir name can disagree, and two dirs can share one artifactId, + // so reducing the coordinate and string-matching it against a dir + // could settle — or contradict — the claim with a DIFFERENT + // module's run. Kept raw instead: a coordinate never matches a + // recorded dir (dirs can't carry `:`), so such claims stay + // unsettleable (unchecked) rather than risk a wrong-module + // verdict. + if (unquoted.includes(':')) return unquoted; + // Maven treats `-pl ./core` identically to `-pl core`, and the + // recorded modules come from repo-relative paths that never carry + // the prefix — normalize the claim spelling away, or the claim + // could never settle or be contradicted. A bare `.` (the root + // project) survives the strip as `''` and is restored. + return unquoted.replace(/^\.\/+/, '') || '.'; + }) + .filter((module) => module.length > 0), + ), + ].sort(); + return modules.length > 0 ? modules : null; +} + +/** + * The file a compiler-error line names: every shape `isSourceFailureLine` + * recognizes carries a JVM source path followed by a line/column. + */ +const SOURCE_FAILURE_PATH_RE = + /^\[(?:ERROR|FATAL)\] .*?((?:[A-Za-z]:)?\/.*?\.(?:java|kts?|scala|groovy))(?=:(?:\[|\s?\(|\s?\d))/; + +function sourceFailurePath(line: string): string | null { + return SOURCE_FAILURE_PATH_RE.exec(line.replace(/\\/g, '/'))?.[1] ?? null; +} + +function sameModuleSet(a: string[] | null, b: string[] | null): boolean { + if (a === null || b === null || a.length !== b.length) return false; + // Sorted here rather than assumed: only the CLAIM side comes back sorted + // from mavenPlModules. The run side is the adapter's own module list, and + // a set comparison must not depend on how that list happened to be + // ordered when it was recorded. + const left = [...a].sort(); + const right = [...b].sort(); + return left.every((module, i) => module === right[i]); +} + function ruleCommand( text: string, worktree: string, @@ -586,18 +1122,462 @@ function ruleCommand( ): TestPlanClaim { // A command this review actually ran is settled by its exit code — the // strongest evidence available, and it needs no manifest lookup. + const rawClaimed = text.trim(); + // shell-quote splits the claim like a shell would (see shellTokens): + // quoted flag=value pairs and selectors stay one word, so normalization + // and every scope walker below read the words a shell would hand Maven. + // Maven's commons-cli ALSO accepts single-dash spellings of its long + // options; normalize them to the `--` forms so they cannot bypass the + // grammar below. Both applied to Maven claims only — the comparison + // against recorded commands is unaffected, because the adapter never + // renders those spellings. `claimed` keeps the RAW text for the + // exact/prefix comparison against recorded command lines, which carry + // their own quoting from shellSelector. + const mavenClaim = MAVEN_RUNNER_RE.test(rawClaimed); + const claimTokenList = mavenClaim + ? normalizeMavenSingleDashLongTokens(shellTokens(rawClaimed)) + : rawClaimed.split(/\s+/); + const claimed = rawClaimed; + // A workspace-scoped run (`npm run build --workspace=...`) still settles + // the plan's bare command. Maven scopes before the lifecycle + // (`./mvnw -pl core -am test`), so compare lifecycle phases there — but the + // settling is module-scoped, and the note must not read as if the full + // reactor were verified. Settling follows the claim's FINAL lifecycle when + // the claim carries no scoping of its own (`mvn clean test` settles on + // `test`); a claim naming its own `-pl`/`-P`/`-D` scope keeps its + // conservative treatment, because one scoped run cannot settle a + // differently scoped claim. + const mavenRunnerClaim = MAVEN_RUNNER_RE.test(claimed); + const claimedLifecycle = mavenLifecycle(claimTokenList); + // Maven flags that scope a run to less — or other — than the full reactor. + // A claim carrying one can only be settled by a run of the SAME scope: one + // scoped run cannot settle a differently scoped claim. + const scopesNonPl = (token: string): boolean => + token.startsWith('-P') || + token.startsWith('-D') || + token === '--activate-profiles' || + token.startsWith('--activate-profiles=') || + token === '--define' || + token.startsWith('--define=') || + token === '-rf' || + token.startsWith('-rf=') || + token === '--resume-from' || + token.startsWith('--resume-from=') || + token === '-N' || + token === '--non-recursive' || + token === '-f' || + token.startsWith('-f=') || + token === '--file' || + token.startsWith('--file=') || + token === '-amd' || + // commons-cli accepts attached values too; the exact-token match alone + // let `-amd=` claims bypass the conservative treatment. + token.startsWith('-amd=') || + token === '--also-make-dependents' || + token.startsWith('--also-make-dependents=') || + // The other semantics-altering value flags MAVEN_VALUE_FLAGS models: + // a claim carrying one cannot settle on a run that never used it, + // exactly like the `-D`/`-P` claims of comparable weight above. + token === '-s' || + token.startsWith('-s=') || + token === '--settings' || + token.startsWith('--settings=') || + token === '-gs' || + token.startsWith('-gs=') || + token === '--global-settings' || + token.startsWith('--global-settings=') || + token === '-t' || + token.startsWith('-t=') || + token === '--toolchains' || + token.startsWith('--toolchains=') || + token === '-gt' || + token.startsWith('-gt=') || + token === '--global-toolchains' || + token.startsWith('--global-toolchains=') || + token === '-b' || + token.startsWith('-b=') || + token === '--builder' || + token.startsWith('--builder=') || + // Parallelism changes outcomes (modules racing shared state), and the + // review only ever runs serial: a `-T` claim cannot settle on a serial + // run. `-l`/`--log-file` is the one value flag left out on purpose — + // log redirection changes no outcomes. + token.startsWith('-T') || + token === '--threads' || + token.startsWith('--threads=') || + // Offline mode changes what resolution the run performs (an offline + // build can fail where an online one succeeds), and `-U` forces + // snapshot re-resolution the review never did: a claim carrying one + // cannot settle on a run that never used it. + token === '-o' || + token === '--offline' || + token === '-U' || + token === '--update-snapshots' || + // The repository-layout flags change resolution behavior (legacy local + // repository layout, lax checksums): a claim carrying one cannot settle + // on a run that never used it. They are NOT the neutral `-l` log-file + // family — the modeledMavenOption carve-out below must not swallow them, + // and the single-dash long spellings are normalized above. + token === '-llr' || + token === '--legacy-local-repository' || + token === '--lax-checksums' || + // fail-never makes Maven exit 0 over failures it would otherwise die + // on: a claim carrying it cannot settle on a run that never used it — + // the run's recorded exit codes are ones the claimed command cannot + // produce. The single-dash long spelling is normalized above. + token === '-fn' || + token === '--fail-never' || + // The password-encryption options and the usage/version printers + // perform zero lifecycle work: a claim carrying one cannot settle on a + // run that executed phases, and the attached/commons-cli separator-less + // spellings carry the same scope. `-V`/`--show-version` deliberately + // stays neutral — it prints the version WITHOUT stopping the build. + token.startsWith('-emp') || + token.startsWith('-ep') || + token === '-h' || + token === '--help' || + token === '-v' || + token === '--version' || + token === '--encrypt-master-password' || + token.startsWith('--encrypt-master-password=') || + token === '--encrypt-password' || + token.startsWith('--encrypt-password=') || + // commons-cli also accepts separator-less ATTACHED short forms + // (`-fother/pom.xml`, `-rf:core`, `-ssettings.xml`, `-plcore`); the + // exact-token and `=`-attached matches alone let them bypass the + // conservative treatment. The attached `-pl` form cannot be reduced to + // a module set here, so it keeps the same treatment as the other scopes. + (token.startsWith('-rf') && token !== '-rf') || + (token.startsWith('-f') && token !== '-f') || + (token.startsWith('-s') && token !== '-s') || + (token.startsWith('-gs') && token !== '-gs') || + (token.startsWith('-t') && token !== '-t') || + (token.startsWith('-gt') && token !== '-gt') || + (token.startsWith('-b') && token !== '-b') || + (token.startsWith('-amd') && token !== '-amd') || + // The `-pl=` spelling is still reducible to a module set (the value is + // in-token); only separator-less attached forms (`-plcore`) are not. + (token.startsWith('-pl') && token !== '-pl' && !token.startsWith('-pl=')); + // shellTokens already stripped the quoting: `mvn "-pl" core test` carries + // the same scoping as the unquoted spelling. + const claimTokens = claimTokenList; + // Lifecycle phases the claim names, in order: a multi-phase claim + // (`clean test`) runs phases the recorded single-phase run never did. + // Flag values are excluded: a module dir named `test` handed to `-pl` is + // a selector, not a claimed phase. Out-of-vocabulary WORK counts too + // (`mvn deploy test`, a leading plugin goal): it never ran here, and + // settling the trailing phase without disclosing the reduction would + // overstate the evidence. + const positionalTokens = mavenPositionalTokens(claimTokenList); + // Phase tests lowercase the token so a capitalized claim matches the + // lowercase-only vocabularies consistently with the runner extraction. + const claimPhases = positionalTokens.filter( + (token) => + MAVEN_PHASE_RE.test(token.toLowerCase()) || + MAVEN_UNRUN_WORK_RE.test(token.toLowerCase()) || + (!token.startsWith('-') && token.includes(':')), + ); + // Maven dies on 'Unknown lifecycle phase' for any bare positional outside + // its lifecycle vocabulary (the settlement phases plus the default- + // lifecycle phases MAVEN_UNRUN_WORK_RE models), and on 'Unable to parse + // command line options' for any option it does not have (`mvn foo test` + // and `mvn test --verbose` run no work): the settlement invariant the + // comment above states for trailing and unrun work applies to mid-position + // junk of BOTH kinds, or the claim settles `reproduces` over a command + // that errored out. Position 0 is the runner itself, which names no + // lifecycle work. + // A dash token real Maven accepts: a value flag (the space-separated form + // is consumed before the positionals; the attached spellings carry their + // value in-token), a boolean flag scopesNonPl or mavenHasAlsoMake models, + // or a neutral flag that changes no outcome the review measures. + const modeledMavenOption = (token: string): boolean => + MAVEN_VALUE_FLAGS.has(token) || + scopesNonPl(token) || + token === '-am' || + token === '--also-make' || + NEUTRAL_MAVEN_FLAGS.has(token) || + // The `-l` family's attached spelling (`-lbuild.log`): `-l` is the one + // value flag scopesNonPl leaves out on purpose, so its attached form + // would otherwise read as an unknown option. + (token.startsWith('-l') && token !== '-l'); + const unknownWork = positionalTokens + .slice(1) + .some((token) => + token.startsWith('-') + ? !modeledMavenOption(token) + : !MAVEN_PHASE_RE.test(token.toLowerCase()) && + !MAVEN_UNRUN_WORK_RE.test(token.toLowerCase()) && + !token.includes(':'), + ); + const claimScopesItself = claimTokens.some( + (token) => + token === '-pl' || + token.startsWith('-pl=') || + token === '--projects' || + token.startsWith('--projects=') || + scopesNonPl(token), + ); + // The claim's final positional token names the last work it runs: a + // trailing goal outside the lifecycle vocabulary (`mvn test deploy`, + // `site`, a plugin goal) never ran here, and settling the recognized + // phase alone would read undisclosed — unlike `mvn clean test`, which + // discloses its phase reduction. Trailing flag tokens (`-B`, attached + // `-D…`) name no work of their own. + // Lowercased to match the canonical lowercase lifecycle the settlement + // comparisons use (a capitalized claim's final work still compares equal). + const claimFinalWork = positionalTokens + .filter((token) => !token.startsWith('-')) + .at(-1) + ?.toLowerCase(); + // A value flag missing its value (`mvn test -l`) dies in Maven's argument + // parsing before any lifecycle work — the claim settles nothing, exactly + // like unknown work. + const danglingValueFlag = + mavenClaim && mavenDanglingValueFlag(claimTokenList); + const claimPlModules = mavenPlModules(claimTokenList); + // A claim scoped by `-pl` ALONE can settle on a recorded run with the same + // module set and final lifecycle — that is the SAME scope, and discarding + // the evidence would assert the review never ran what it did. Claims also + // carrying -P/-D/-rf/-N/-f keep the conservative treatment: those scopes + // cannot be compared here. + const claimOnlyPlScoped = + claimPlModules !== null && !claimTokens.some(scopesNonPl); + // The RUN side is never parsed: build-test records what its Maven command + // scopes on `c.maven`, straight from the values it rendered the command + // line from. Only `claimed` — free text a PR author wrote — needs a + // grammar. Keeping one grammar means a claim can never be settled by a + // reading of our own command that the adapter would not recognize. + const settledByLifecycle = (c: CommandResult): boolean => + c.command.trim() !== claimed && + !( + c.command.trim().startsWith(claimed) && + c.command.trim()[claimed.length] === ' ' + ) && + claimedLifecycle !== null && + claimFinalWork === claimedLifecycle && + !claimScopesItself && + !unknownWork && + !danglingValueFlag && + c.maven?.lifecycle === claimedLifecycle; + const settledBySameScope = (c: CommandResult): boolean => + claimOnlyPlScoped && + claimedLifecycle !== null && + claimFinalWork === claimedLifecycle && + !unknownWork && + !danglingValueFlag && + c.maven?.lifecycle === claimedLifecycle && + sameModuleSet(c.maven?.modules ?? null, claimPlModules); + // A run this review itself classified as infrastructure (a timeout, a + // spawn-level death, a Maven acquisition failure) is the same evidence the + // build-test note disavowed as environmental — it must not settle a claim. + // Neither may a run whose evidence the adapter refused to certify: part + // of it was never read. + const finished = (c: CommandResult): boolean => + !c.timedOut && + c.exitCode !== null && + !c.infrastructure && + !c.evidenceCapped; + // The marker is mined from the command's own output — PR test stdout can + // print it too, so it is not tamper-proof (same property as the npm + // console-summary parsing). Runner-gated like the `[maven-test-report]` + // count mining below: only the Maven adapter emits the marker, so the same + // text in a non-Maven command's stdout is fabricated evidence. + const freshTestFailures = (c: CommandResult): boolean => + MAVEN_RUNNER_RE.test(c.command) && + /^\[maven-test-failure\] /m.test(c.output ?? ''); + // A zero exit over fresh failing reports (surefire `testFailureIgnore`), + // or over framed errors a fail-never setting swallowed, is a FAILED run + // for ruling purposes: the Maven adapter marks both ok:false, so the + // claim must not read as reproduced. A run whose evidence was capped is + // ok:false for the opposite reason — it certified NOTHING — so it counts + // as failed here too, never as reproduced: the capped cascade arm rules + // it unchecked or contradicted (never reproduces), and the `-am` + // exclusion reads it as failing so an upstream-only failure still + // cannot contradict a narrower claim. + const ranFailed = (c: CommandResult): boolean => + c.exitCode !== 0 || + freshTestFailures(c) || + c.swallowedFailure === true || + c.evidenceCapped === true || + c.neverRan === true; + // A run's `[maven-test-failure]` markers attribute each failure to its + // report path `/target/...`: when one resolves inside the claimed + // `-pl` set the failure is provably inside the claim's scope, and the + // `-am` carve-outs must not discard it. Mined from the command's own + // output, so it carries the same tamper surface as freshTestFailures. + // The 200-line case cap can drop EVERY `[maven-test-failure]` line of the + // claimed module when an upstream module fails first in path order, so a + // surviving `[maven-test-report]` PROJECT rollup line with non-zero + // failures for the claimed module is in-scope failure evidence too. + const failureInsideClaim = (c: CommandResult): boolean => { + if (claimPlModules === null) return false; + const output = c.output ?? ''; + const lines = output.split('\n'); + // Compile/goal failures inside a claimed module write no Surefire + // reports, so the test-phase markers below cannot attribute them; but a + // compiler error line names the file it failed on, worktree-absolute + // (`[ERROR] /wt/core/src/…/Foo.java:[10,5] …`). Attribute the file to + // the module that OWNS it — its nearest pom.xml-bearing ancestor — not + // to whichever claimed dir the path happens to sit beneath: a module + // literally named `src` (or a `src/core` layout) sits + // beneath `/src/` but is not the root project, and `-am` can + // pull in modules the claim never names. A failure the claim's own + // command would share is one inside a module it DOES name. + const worktreePosix = worktree.split(sep).join('/'); + // The owning module of a worktree-relative file: its nearest pom.xml- + // bearing ancestor. Null when the tree carries no pom.xml at all — + // ownership is unknowable there, and attribution falls back to the + // module-prefix reading. + const owningModuleOf = (rel: string): string | null => { + const segments = rel.split('/'); + for (let depth = segments.length - 1; depth >= 0; depth -= 1) { + const dir = segments.slice(0, depth).join('/'); + // Match the adapter's ownership model: a POM beneath a `src/` tree + // is maven-invoker/archetype test data, not a reactor member, so it + // must not re-own a failure out of the real module — a PR could + // otherwise plant an unreferenced src-nested pom.xml to shift + // attribution and make the carve-out discard an in-claim failure. + if (/(?:^|\/)src\//.test(dir)) continue; + try { + if (statSync(join(worktree, dir, 'pom.xml')).isFile()) { + return dir === '' ? '.' : dir; + } + } catch { + // no pom.xml at this depth + } + } + return null; + }; + if ( + lines.some((line) => { + if (!isSourceFailureLine(line)) return false; + const path = sourceFailurePath(line); + if (path === null || !path.startsWith(`${worktreePosix}/`)) { + return false; + } + const rel = path.slice(worktreePosix.length + 1); + const owner = owningModuleOf(rel); + return claimPlModules.some((module) => + owner !== null + ? owner === module + : module === '.' + ? rel.startsWith('src/') + : rel.startsWith(`${module}/`), + ); + }) + ) { + return true; + } + return claimPlModules.some((module) => { + const prefix = module === '.' ? '' : `${module}/`; + if (output.includes(`[maven-test-failure] ${prefix}target/`)) { + return true; + } + return lines.some( + (line) => + line.startsWith(`[maven-test-report] ${module} (`) && + (/ failures=[1-9]/.test(line) || / errors=[1-9]/.test(line)), + ); + }); + }; + // How a matched run relates to the claim — module-scoped and/or + // phase-reduced — in the wording the notes use. Shared by the + // finished-run ruling below and the interrupted/cascade notes, so the + // same evidence cannot read overstated in one branch merely because + // the run did (or did not) finish. + const runForm = ( + c: CommandResult, + ): { howItRan: string; reduced: boolean } => { + // Reactor-wide recorded runs carry no `-pl`; calling those + // module-scoped would understate what the evidence verified. + const settledReduced = settledByLifecycle(c); + const scoped = + (settledReduced || settledBySameScope(c)) && c.maven?.modules != null; + // A claim without `-am` settling on an `-am` run: the recorded run + // resolved inter-module dependencies from the reactor while the claim's + // bare command resolves them from the local repository — the note must + // not read as if the exact command ran. + const alsoMakeAsymmetry = + scoped && c.maven?.alsoMake === true && !mavenHasAlsoMake(claimTokenList); + const asymmetry = alsoMakeAsymmetry + ? ', with the upstream closure (`-am`) the claim does not name' + : ''; + // A multi-phase claim (`clean test`) settles on its FINAL phase when + // it carries no scoping of its own — the adapter only ever runs + // `test` or `test-compile`, so the note must not read as if the + // earlier phases ran. + const phaseReduced = + (settledReduced || settledBySameScope(c)) && claimPhases.length > 1; + const howItRan = + scoped && phaseReduced + ? `this review ran a module-scoped form of its final phase (\`${claimedLifecycle}\`), ` + + `not the full \`${claimPhases.join(' ')}\` it claims${asymmetry}` + : scoped + ? `this review ran a module-scoped form of it${asymmetry}` + : phaseReduced + ? `this review ran its final phase (\`${claimedLifecycle}\`), ` + + `not the full \`${claimPhases.join(' ')}\` it claims` + : 'this review ran it'; + return { howItRan, reduced: scoped || phaseReduced }; + }; const matches = [ ...(buildTest?.build ?? []), ...(buildTest?.test ?? []), ].filter((c) => { const command = c.command.trim(); - const claimed = text.trim(); - // A workspace-scoped run (`npm run build --workspace=...`) still settles - // the plan's bare command, so match it plus any extra flags — not only an - // exact string. The space guard keeps `build` from matching `build:all`. - return ( - command === claimed || - (command.startsWith(claimed) && command[claimed.length] === ' ') + if ( + !( + command === claimed || + // A bare Maven runner claim (`./mvnw`, `mvn`) carries no lifecycle, so + // prefix-matching it would settle the WHOLE wrapper run from one + // module-scoped run; such claims fall through to the Maven cascade. + (command.startsWith(claimed) && + command[claimed.length] === ' ' && + (!mavenRunnerClaim || bareMavenLifecycle(claimed) !== null)) || + settledByLifecycle(c) || + settledBySameScope(c) + ) + ) { + return false; + } + // An `-am` run also tests the UPSTREAM modules it pulls in, which a + // claim without `-am` never runs: its green exit still settles the + // claim, but its failure may live entirely in modules the claim never + // tests, so it cannot contradict it — one scoped run must not settle a + // differently scoped claim in the failing direction. (The converse IS + // sound: a run WITHOUT `-am` that fails inside the claimed module set + // falsifies an `-am` claim too, so that direction stays settled.) The + // exclusion yields when the run's own markers attribute a failure to a + // module INSIDE the claimed set: that failure is in-scope evidence. + // A CAPPED run is NEVER carved: its markers are incomplete by + // construction (a rejected report emits none, and upstream markers do + // not prove the claimed module also passed), so whether the failure + // lives inside the claim is unknowable — keep it in `matches` and let + // the capped cascade rule the non-zero exit definitive. + // A suppressed run is carved (excluded) only when the suppression is a + // GLOBAL skip — then nothing was tested anywhere. A module-local skip + // seen only in stdout can live in an upstream module while the claimed + // module ran green, so carving it like other failure evidence avoids a + // manufactured "nothing was tested" contradiction. + return !( + settledBySameScope(c) && + c.maven?.alsoMake === true && + !mavenHasAlsoMake(claimTokenList) && + // An infrastructure run cannot contradict — the cascade's + // environmental arm is its only consumer — so the carve-out must not + // hide a capped one from that arm. + !c.infrastructure && + (finished(c) || c.evidenceCapped === true) && + ranFailed(c) && + // A wrapper that never started Maven cannot live in an upstream + // module: it applies to the claim's own command at any scope, so the + // exclusion must not discard it. + !c.neverRan && + // Suppression: only a GLOBAL skip is kept out of the carve-out (nothing + // was tested anywhere); a stdout-only module-local skip is carved. + (!c.testsSuppressed || c.maven?.globalSkip !== true) && + !failureInsideClaim(c) && + c.evidenceCapped !== true ); }); // build-test records one scoped command per package and does not stop on @@ -605,27 +1585,263 @@ function ruleCommand( // scoped run failed, the phase failed, and the bare claim must read // `contradicted` — the first match could be a green package that merely // sorted first, stating the opposite of the authoritative `ok: false`. + // A Maven run interrupted with fresh recorded failures OUT-RANKS a green + // finished sibling: the build-test report says ok:false with the recorded + // markers, and reading `reproduces` off the sibling states the opposite + // of the authoritative evidence. Ranked below a finished FAILED run — + // the stronger evidence — like the spawn-death ranking beside it. + const interruptedWithFailures = mavenRunnerClaim + ? matches.find( + (c) => + (c.timedOut || c.exitCode === null) && + freshTestFailures(c) && + // The same scope asymmetry the `-am` guard above applies to + // finished runs: an interrupted `-am` run's fresh failures may + // live entirely in upstream modules the claim never tests, so it + // cannot contradict the claim either — unless the markers + // attribute a failure to a module inside the claimed set. + !( + settledBySameScope(c) && + c.maven?.alsoMake === true && + !mavenHasAlsoMake(claimTokenList) && + !failureInsideClaim(c) + ), + ) + : undefined; + // A run whose evidence was capped can still carry DEFINITIVE failure + // proof: a non-zero exit (the cap withholds certification of a pass, it + // does not retroactively excuse a failure), or exit-0 failure evidence + // the cap's own cause cannot defeat (markers from reports the sweep DID + // parse, or failures a fail-never setting swallowed). `neverRan` is NOT + // in this set: the states that fire `evidenceCapped` — a fresh report + // rejected, a truncated sweep — are positive proof the toolchain DID + // start, defeating the zero-summaries inference `neverRan` is built on. + // Ranked ABOVE the green finished fallback: one green finished sibling + // matching the same claim otherwise shadows the capped run and reads the + // claim `reproduces` — the exact shadowing this ranking exists to + // forbid. The capped cascade below rules the identical shapes with the + // identical wording when no sibling matches, so the verdict cannot flip + // on which other runs the claim matched. + const cappedDefinitiveRuling = (c: CommandResult): TestPlanClaim | null => { + // A run this review classified as an environmental acquisition failure + // must not settle a claim, capped or not — the same exclusion arms 1/2/4 + // apply via `finished()` or an explicit check. Without this guard the + // non-zero-exit arm below settles such a run `contradicted` whenever it + // also carries `evidenceCapped`, laundering an environmental death into + // a definitive ruling. + if (c.infrastructure === true) return null; + // A never-ran run is not definitive either, capped or not: the cap can + // fire on `rescueOverflow` from a stub wrapper's OWN output, which is + // not proof the toolchain started. Returning a ruling here would + // contradict that with wording describing a run that started; instead + // fall through to the capped cascade, which rules such a run + // `unchecked` with the capped-evidence note (the "Maven never started" + // wording lives only in the finished-run exit-0 arm, unreachable for a + // capped run). + if (c.neverRan === true) return null; + if (c.exitCode !== null && c.exitCode !== 0) { + return { + kind: 'command', + text, + verdict: 'contradicted', + observed: `exit ${c.exitCode}`, + note: + `${runForm(c).howItRan}, and it failed — part of its ` + + 'evidence was never read (rejected or unseen fresh reports, or the ' + + 'trim rescue cap), but the non-zero exit is definitive', + }; + } + if ( + c.exitCode === 0 && + (freshTestFailures(c) || c.swallowedFailure === true) + ) { + // The observed/note split mirrors the finished path's exit-0 arm: + // the cap must not change WHICH failure the evidence records. + const observed = freshTestFailures(c) + ? 'exit 0, but fresh Surefire/Failsafe reports record failures' + : c.testsSuppressed + ? 'exit 0, but a skip setting suppressed the test phase — nothing was tested' + : 'exit 0, but the output records failures the exit code did not fail on'; + const cause = freshTestFailures(c) + ? 'fresh test reports record failures despite the zero exit' + : c.testsSuppressed + ? 'a skip setting suppressed the test phase — nothing was tested' + : 'the run recorded failures despite the zero exit'; + return { + kind: 'command', + text, + verdict: 'contradicted', + observed, + note: + `${runForm(c).howItRan}, and ${cause} — part of its ` + + 'evidence was never read (rejected or unseen fresh reports, or the ' + + 'trim rescue cap), but that withholds ' + + 'certification of a pass, it does not excuse what the run DID record', + }; + } + return null; + }; const ran = - matches.find((c) => !c.timedOut && c.exitCode !== 0) ?? - matches.find((c) => !c.timedOut); + matches.find((c) => finished(c) && ranFailed(c)) ?? + // A spawn-level death (exitCode null, no deadline kill) is a failed run + // for a non-Maven claim, ranked ABOVE any green finished sibling: one + // green package must not shadow the death and read the claim + // `reproduces` while the build-test report says `ok: false`. Maven + // claims rank their interrupted-with-failures run here instead. + (!mavenRunnerClaim + ? matches.find( + (c) => !c.timedOut && c.exitCode === null && !c.infrastructure, + ) + : interruptedWithFailures) ?? + matches.find( + (c) => + !c.infrastructure && + c.evidenceCapped === true && + cappedDefinitiveRuling(c) !== null, + ) ?? + matches.find(finished); if (ran) { + if (ran === interruptedWithFailures) { + return { + kind: 'command', + text, + verdict: 'contradicted', + observed: + 'interrupted, but fresh Surefire/Failsafe reports record failures', + note: `${runForm(ran).howItRan}; it was interrupted, but fresh test reports record failures`, + }; + } + if (ran.evidenceCapped === true) { + // The ranking above only admits capped runs with a definitive ruling. + const definitive = cappedDefinitiveRuling(ran); + if (definitive) return definitive; + } + const form = runForm(ran); + const howItRan = form.howItRan; + if (ran.exitCode === 0 && ranFailed(ran)) { + // The wording splits on what the zero exit actually swallowed: a skip + // setting suppressed the phase (nothing was tested — there are no + // failures to hunt), a wrapper never started Maven (same), or the + // output records failures the exit code did not fail on. + const observed = freshTestFailures(ran) + ? 'exit 0, but fresh Surefire/Failsafe reports record failures' + : ran.testsSuppressed + ? 'exit 0, but a skip setting suppressed the test phase — nothing was tested' + : ran.neverRan + ? 'exit 0, but Maven never started — nothing was built or tested' + : 'exit 0, but the output records failures the exit code did not fail on'; + const note = + `${howItRan}, but ` + + (freshTestFailures(ran) + ? 'fresh test reports record failures despite the zero exit' + : ran.testsSuppressed + ? 'a skip setting suppressed the test phase — nothing was tested' + : ran.neverRan + ? 'the wrapper exited 0 without starting Maven — nothing was built or tested' + : 'the run recorded failures despite the zero exit'); + return { + kind: 'command', + text, + verdict: 'contradicted', + observed, + note, + }; + } return ran.exitCode === 0 ? { kind: 'command', text, verdict: 'reproduces', observed: 'exit 0', - note: 'this review ran it', + note: howItRan, } : { kind: 'command', text, verdict: 'contradicted', - observed: `exit ${ran.exitCode}`, - note: 'this review ran it and it failed', + observed: + ran.exitCode === null + ? 'it ended without an exit code' + : `exit ${ran.exitCode}`, + note: form.reduced + ? `${howItRan}, and that failed` + : 'this review ran it and it failed', }; } + if (mavenRunnerClaim) { + // The interrupted-with-failures ruling is ranked into `ran` above: by + // the time this cascade runs, no such run matched — an interrupted run + // with fresh failures either already contradicted the claim there or + // was excluded by the same `-am` scope asymmetry. + const timedOutRun = matches.find((c) => c.timedOut); + if (timedOutRun) { + return { + kind: 'command', + text, + verdict: 'unchecked', + note: `${runForm(timedOutRun).howItRan}; it timed out`, + }; + } + const spawnDeath = matches.find((c) => c.exitCode === null); + if (spawnDeath) { + return { + kind: 'command', + text, + verdict: 'unchecked', + note: `${runForm(spawnDeath).howItRan}; it ended without an exit code`, + }; + } + const environmental = matches.find((c) => c.infrastructure); + if (environmental) { + return { + kind: 'command', + text, + verdict: 'unchecked', + note: `${runForm(environmental).howItRan}; it failed for environmental reasons`, + }; + } + // A run the adapter refused to certify settles nothing either way: + // name the reason rather than letting the claim fall through to the + // "not run" wording, which would misstate what happened. + const capped = matches.find((c) => c.evidenceCapped); + if (capped) { + // The definitive shapes — a non-zero exit, or exit-0 failure evidence + // the cap cannot defeat — rule through the shared helper, the same + // ruling the ranking above hands them when a sibling matches. + // `neverRan` is deliberately NOT definitive here: the states that + // fire the cap (a rejected fresh report, a truncated sweep) are + // positive proof the toolchain DID start, which defeats the + // zero-summaries inference `neverRan` rests on — a capped never-ran + // run reads unchecked, never contradicted. + const definitive = cappedDefinitiveRuling(capped); + if (definitive) return definitive; + return { + kind: 'command', + text, + verdict: 'unchecked', + note: + `${runForm(capped).howItRan}; part of its evidence was never ` + + 'read (rejected or unseen fresh reports, or the trim rescue cap), so the run was not certified', + }; + } + + // The review may still have run Maven at a different scope or phase — + // say so instead of asserting nothing ran. + const anyMavenRun = [ + ...(buildTest?.build ?? []), + ...(buildTest?.test ?? []), + ].some((c) => c.maven !== undefined); + return { + kind: 'command', + text, + verdict: 'unchecked', + note: anyMavenRun + ? 'this Maven command was not run by this review — the Maven runs it made had a different scope or phase' + : 'this Maven command was not run by this review', + }; + } + const script = npmScriptOf(text); if (!script) { return {