Skip to content

Release/v0.6.0 - #532

Open
devops-thiago wants to merge 92 commits into
mainfrom
release/v0.6.0
Open

Release/v0.6.0#532
devops-thiago wants to merge 92 commits into
mainfrom
release/v0.6.0

Conversation

@devops-thiago

Copy link
Copy Markdown
Owner

No description provided.

dependabot Bot and others added 30 commits August 8, 2026 07:58
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.16.0 to
11.16.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/mermaid-js/mermaid/releases">mermaid's
releases</a>.</em></p>
<blockquote>
<h2>mermaid@11.16.1</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/mermaid-js/mermaid/pull/8022">#8022</a>
<a
href="https://github.com/mermaid-js/mermaid/commit/12d472c9ed43f94814b110da8d7a9ae6dd5266ed"><code>12d472c</code></a>
Thanks <a
href="https://github.com/aloisklink"><code>@​aloisklink</code></a>! -
fix: handle CSS sibling combinators in compileCSS</p>
</li>
<li>
<p><a
href="https://redirect.github.com/mermaid-js/mermaid/pull/8022">#8022</a>
<a
href="https://github.com/mermaid-js/mermaid/commit/2cd6dcf735533b323507e3e889ffdea870540b43"><code>2cd6dcf</code></a>
Thanks <a
href="https://github.com/aloisklink"><code>@​aloisklink</code></a>! -
fix: increase protections against prototype pollution</p>
<p>User-controlled input already has protections against prototype
pollution.</p>
<p>Fixes: GHSA-c4c3-pg64-4m4v</p>
</li>
<li>
<p><a
href="https://redirect.github.com/mermaid-js/mermaid/pull/8022">#8022</a>
<a
href="https://github.com/mermaid-js/mermaid/commit/99af3fc35ef0a9a9c8c6314521344d67523ddccf"><code>99af3fc</code></a>
Thanks <a
href="https://github.com/aloisklink"><code>@​aloisklink</code></a>! -
fix(architecture): use <code>Map</code>s and <code>Set</code>s to store
groups/services</p>
<p>Services are now rendered in the order they are defined and more
service IDs
are now supported.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/mermaid-js/mermaid/pull/8022">#8022</a>
<a
href="https://github.com/mermaid-js/mermaid/commit/2cd6dcf735533b323507e3e889ffdea870540b43"><code>2cd6dcf</code></a>
Thanks <a
href="https://github.com/aloisklink"><code>@​aloisklink</code></a>! -
deprecate: Deprecate the <code>mermaidAPI.setConfig()</code>
function</p>
<p>Calling this function has no observable effect, as the next time a
<code>render()</code> or <code>parse()</code> is called, the
<code>currentConfig</code> is cleared.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/mermaid-js/mermaid/pull/8022">#8022</a>
<a
href="https://github.com/mermaid-js/mermaid/commit/630aa7e5dd417e1f56bff2a1ce8df2c5ad08d289"><code>630aa7e</code></a>
Thanks <a
href="https://github.com/aloisklink"><code>@​aloisklink</code></a>! -
fix(xychart): support zero-width x-axis ranges</p>
</li>
<li>
<p><a
href="https://redirect.github.com/mermaid-js/mermaid/pull/8022">#8022</a>
<a
href="https://github.com/mermaid-js/mermaid/commit/59b22fad2b3bb04f87a476c84a8a2b24679e607e"><code>59b22fa</code></a>
Thanks <a
href="https://github.com/aloisklink"><code>@​aloisklink</code></a>! -
fix(radar): limit number of ticks to 32</p>
<p>Setting a ticks value higher than this would only show 32 ticks.</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/7ecca0cd7f1658ef74f4e7e91f925724ef403bbf"><code>7ecca0c</code></a>
Version Packages (<a
href="https://redirect.github.com/mermaid-js/mermaid/issues/8023">#8023</a>)</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/95b1b9cdba40e7954aee7d8a265577fd3839710c"><code>95b1b9c</code></a>
docs: change <code>mermaidAPI.setConfig()</code> changeset (<a
href="https://redirect.github.com/mermaid-js/mermaid/issues/8024">#8024</a>)</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/acc69f172b5ad5405ca8de8f4f79a80505092b3f"><code>acc69f1</code></a>
Merge pull request <a
href="https://redirect.github.com/mermaid-js/mermaid/issues/8022">#8022</a>
from mermaid-js/release/11.16.1</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/eba72879e94755433403e571d724ca7a448800af"><code>eba7287</code></a>
docs: point changesets to correct commit hashes</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/12d472c9ed43f94814b110da8d7a9ae6dd5266ed"><code>12d472c</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/2cd6dcf735533b323507e3e889ffdea870540b43"><code>2cd6dcf</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/630aa7e5dd417e1f56bff2a1ce8df2c5ad08d289"><code>630aa7e</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/59b22fad2b3bb04f87a476c84a8a2b24679e607e"><code>59b22fa</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/99af3fc35ef0a9a9c8c6314521344d67523ddccf"><code>99af3fc</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/mermaid-js/mermaid/commit/2337f7ecd629c8e777684a2edff341976230ad67"><code>2337f7e</code></a>
Merge branch 'test/improve-example.html' into release/11.16.1</li>
<li>Additional commits viewable in <a
href="https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.0...mermaid@11.16.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mermaid&package-manager=npm_and_yarn&previous-version=11.16.0&new-version=11.16.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/devops-thiago/ThrillhouseBot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps the actions-minor-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-java](https://github.com/actions/setup-java) | `5.6.0`
| `5.7.0` |
| [docker/login-action](https://github.com/docker/login-action) |
`4.5.2` | `4.6.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) |
`4.37.3` | `4.37.6` |
|
[github/codeql-action/analyze](https://github.com/github/codeql-action)
| `4.37.3` | `4.37.6` |
|
[github/codeql-action/upload-sarif](https://github.com/github/codeql-action)
| `4.37.3` | `4.37.6` |

Updates `actions/setup-java` from 5.6.0 to 5.7.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-java/releases">actions/setup-java's
releases</a>.</em></p>
<blockquote>
<h2>v5.7.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix npm audit failures on releases/v5 by <a
href="https://github.com/brunoborges"><code>@​brunoborges</code></a> in
<a
href="https://redirect.github.com/actions/setup-java/pull/1154">actions/setup-java#1154</a></li>
<li>Backport <a
href="https://redirect.github.com/actions/setup-java/issues/1151">#1151</a>:
Fix missing wrapper cache distributions by <a
href="https://github.com/brunoborges"><code>@​brunoborges</code></a> in
<a
href="https://redirect.github.com/actions/setup-java/pull/1153">actions/setup-java#1153</a></li>
<li>Deprecate legacy Adopt distributions in v5 by <a
href="https://github.com/brunoborges"><code>@​brunoborges</code></a> in
<a
href="https://redirect.github.com/actions/setup-java/pull/1186">actions/setup-java#1186</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-java/compare/v5.6.0...v5.7.0">https://github.com/actions/setup-java/compare/v5.6.0...v5.7.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-java/commit/b6effb05e454b25005698d916606bdc6ffcbf961"><code>b6effb0</code></a>
Deprecate legacy Adopt distributions in v5 (<a
href="https://redirect.github.com/actions/setup-java/issues/1186">#1186</a>)</li>
<li><a
href="https://github.com/actions/setup-java/commit/e498d2a66a953492f322542257b22125c989b422"><code>e498d2a</code></a>
Backport <a
href="https://redirect.github.com/actions/setup-java/issues/1151">#1151</a>:
Fix missing wrapper cache distributions (<a
href="https://redirect.github.com/actions/setup-java/issues/1153">#1153</a>)</li>
<li><a
href="https://github.com/actions/setup-java/commit/6a3384db745932178632d0e22b2bd28cad1678e6"><code>6a3384d</code></a>
Fix npm audit failures on releases/v5 (<a
href="https://redirect.github.com/actions/setup-java/issues/1154">#1154</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/setup-java/compare/03ad4de0992f5dab5e18fcb136590ce7c4a0ac95...b6effb05e454b25005698d916606bdc6ffcbf961">compare
view</a></li>
</ul>
</details>
<br />

Updates `docker/login-action` from 4.5.2 to 4.6.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.6.0</h2>
<ul>
<li>Harden buildx scoped config path handling by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1059">docker/login-action#1059</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> and
<code>@​aws-sdk/client-ecr-public</code> to 3.1095.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1051">docker/login-action#1051</a></li>
<li>Bump js-yaml from 5.2.1 to 5.2.2 in <a
href="https://redirect.github.com/docker/login-action/pull/1057">docker/login-action#1057</a></li>
<li>Bump postcss from 8.5.10 to 8.5.22 in <a
href="https://redirect.github.com/docker/login-action/pull/1056">docker/login-action#1056</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.5.2...v4.6.0">https://github.com/docker/login-action/compare/v4.5.2...v4.6.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/dbcb813823bdd20940b903addbd779551569679f"><code>dbcb813</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1051">#1051</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependen...</li>
<li><a
href="https://github.com/docker/login-action/commit/5bcb015ee6ec720ecdeaef2dc1164122e9b209fc"><code>5bcb015</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/b30b2f2d3196c1714318ba0c3c3bec211d949752"><code>b30b2f2</code></a>
build(deps): bump the aws-sdk-dependencies group across 1 directory with
2 up...</li>
<li><a
href="https://github.com/docker/login-action/commit/9087f1e6d666fe0292409e3c819680c18526e108"><code>9087f1e</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1057">#1057</a>
from docker/dependabot/npm_and_yarn/js-yaml-5.2.2</li>
<li><a
href="https://github.com/docker/login-action/commit/0009830ea169ca16c24c0ea4cac1c325bfa3aee4"><code>0009830</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/23255232d3e43c8f0052d9a0dba82a515a88ce92"><code>2325523</code></a>
build(deps): bump js-yaml from 5.2.1 to 5.2.2</li>
<li><a
href="https://github.com/docker/login-action/commit/4ec1d4a769e8b05a89a7396551dc38b329211688"><code>4ec1d4a</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1056">#1056</a>
from docker/dependabot/npm_and_yarn/postcss-8.5.22</li>
<li><a
href="https://github.com/docker/login-action/commit/5fc99ba47bca274c5a499688f71c7ea79c0ea1b3"><code>5fc99ba</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1053">#1053</a>
from docker/dependabot/github_actions/aws-actions/co...</li>
<li><a
href="https://github.com/docker/login-action/commit/e512bd59d16c53d79ea5c0f0e345fe554453c4bb"><code>e512bd5</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1052">#1052</a>
from docker/dependabot/github_actions/codeql-actions...</li>
<li><a
href="https://github.com/docker/login-action/commit/a146c91b8f371700d323bae808af7cbdc2766ed5"><code>a146c91</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1059">#1059</a>
from crazy-max/harden-buildx-scope-paths</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/371161bbe7024a29a25c5e19bfcbc0804fe9ad2c...dbcb813823bdd20940b903addbd779551569679f">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/init` from 4.37.3 to 4.37.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.6</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>v4.37.5</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>v4.37.4</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/5595ccaf912efad79be6eef63a5619ff05969be3"><code>5595cca</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4071">#4071</a>
from github/update-v4.37.6-6a9359a1b</li>
<li><a
href="https://github.com/github/codeql-action/commit/ec9c75796a7f2cee5af0c5ffa0b81dc3bb58754b"><code>ec9c757</code></a>
Add change note for PR 4070</li>
<li><a
href="https://github.com/github/codeql-action/commit/45c8742e17cbd668814137f95e605d925b8722a2"><code>45c8742</code></a>
Update changelog for v4.37.6</li>
<li><a
href="https://github.com/github/codeql-action/commit/6a9359a1bd054c53cae7bb737bd8d796cfbf3014"><code>6a9359a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4070">#4070</a>
from github/mbg/remote-address/change-file-default</li>
<li><a
href="https://github.com/github/codeql-action/commit/065cdc0394d424981db720df63ebc570e41b775f"><code>065cdc0</code></a>
Change <code>DEFAULT_CONFIG_FILE_NAME</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/f99dd5aeee9cf92e92d0c700cb0aa7afd7bbf431"><code>f99dd5a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4066">#4066</a>
from github/dependabot/npm_and_yarn/js-yaml-5.2.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/1804b211a343d69a6584d26fb3a68a8fe6ca39d4"><code>1804b21</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4068">#4068</a>
from github/mergeback/v4.37.5-to-main-d1ba80a1</li>
<li><a
href="https://github.com/github/codeql-action/commit/3020a2f46286abb1704269b22ada83bd0e81c64f"><code>3020a2f</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/93c3a5a40b7affbf8ea6a480767ed0db8e8d3c5c"><code>93c3a5a</code></a>
Update changelog and version after v4.37.5</li>
<li><a
href="https://github.com/github/codeql-action/commit/d1ba80a13dd99fba24a470575428917156a28b43"><code>d1ba80a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4067">#4067</a>
from github/update-v4.37.5-1cd4d01d5</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...5595ccaf912efad79be6eef63a5619ff05969be3">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.6</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>v4.37.5</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>v4.37.4</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/5595ccaf912efad79be6eef63a5619ff05969be3"><code>5595cca</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4071">#4071</a>
from github/update-v4.37.6-6a9359a1b</li>
<li><a
href="https://github.com/github/codeql-action/commit/ec9c75796a7f2cee5af0c5ffa0b81dc3bb58754b"><code>ec9c757</code></a>
Add change note for PR 4070</li>
<li><a
href="https://github.com/github/codeql-action/commit/45c8742e17cbd668814137f95e605d925b8722a2"><code>45c8742</code></a>
Update changelog for v4.37.6</li>
<li><a
href="https://github.com/github/codeql-action/commit/6a9359a1bd054c53cae7bb737bd8d796cfbf3014"><code>6a9359a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4070">#4070</a>
from github/mbg/remote-address/change-file-default</li>
<li><a
href="https://github.com/github/codeql-action/commit/065cdc0394d424981db720df63ebc570e41b775f"><code>065cdc0</code></a>
Change <code>DEFAULT_CONFIG_FILE_NAME</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/f99dd5aeee9cf92e92d0c700cb0aa7afd7bbf431"><code>f99dd5a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4066">#4066</a>
from github/dependabot/npm_and_yarn/js-yaml-5.2.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/1804b211a343d69a6584d26fb3a68a8fe6ca39d4"><code>1804b21</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4068">#4068</a>
from github/mergeback/v4.37.5-to-main-d1ba80a1</li>
<li><a
href="https://github.com/github/codeql-action/commit/3020a2f46286abb1704269b22ada83bd0e81c64f"><code>3020a2f</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/93c3a5a40b7affbf8ea6a480767ed0db8e8d3c5c"><code>93c3a5a</code></a>
Update changelog and version after v4.37.5</li>
<li><a
href="https://github.com/github/codeql-action/commit/d1ba80a13dd99fba24a470575428917156a28b43"><code>d1ba80a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4067">#4067</a>
from github/update-v4.37.5-1cd4d01d5</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...5595ccaf912efad79be6eef63a5619ff05969be3">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/upload-sarif` from 4.37.3 to 4.37.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/upload-sarif's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.6</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>v4.37.5</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>v4.37.4</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/upload-sarif's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/5595ccaf912efad79be6eef63a5619ff05969be3"><code>5595cca</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4071">#4071</a>
from github/update-v4.37.6-6a9359a1b</li>
<li><a
href="https://github.com/github/codeql-action/commit/ec9c75796a7f2cee5af0c5ffa0b81dc3bb58754b"><code>ec9c757</code></a>
Add change note for PR 4070</li>
<li><a
href="https://github.com/github/codeql-action/commit/45c8742e17cbd668814137f95e605d925b8722a2"><code>45c8742</code></a>
Update changelog for v4.37.6</li>
<li><a
href="https://github.com/github/codeql-action/commit/6a9359a1bd054c53cae7bb737bd8d796cfbf3014"><code>6a9359a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4070">#4070</a>
from github/mbg/remote-address/change-file-default</li>
<li><a
href="https://github.com/github/codeql-action/commit/065cdc0394d424981db720df63ebc570e41b775f"><code>065cdc0</code></a>
Change <code>DEFAULT_CONFIG_FILE_NAME</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/f99dd5aeee9cf92e92d0c700cb0aa7afd7bbf431"><code>f99dd5a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4066">#4066</a>
from github/dependabot/npm_and_yarn/js-yaml-5.2.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/1804b211a343d69a6584d26fb3a68a8fe6ca39d4"><code>1804b21</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4068">#4068</a>
from github/mergeback/v4.37.5-to-main-d1ba80a1</li>
<li><a
href="https://github.com/github/codeql-action/commit/3020a2f46286abb1704269b22ada83bd0e81c64f"><code>3020a2f</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/93c3a5a40b7affbf8ea6a480767ed0db8e8d3c5c"><code>93c3a5a</code></a>
Update changelog and version after v4.37.5</li>
<li><a
href="https://github.com/github/codeql-action/commit/d1ba80a13dd99fba24a470575428917156a28b43"><code>d1ba80a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4067">#4067</a>
from github/update-v4.37.5-1cd4d01d5</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...5595ccaf912efad79be6eef63a5619ff05969be3">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps the quarkus group with 2 updates:
[io.quarkus.platform:quarkus-bom](https://github.com/quarkusio/quarkus-platform)
and
[io.quarkus.platform:quarkus-maven-plugin](https://github.com/quarkusio/quarkus-platform).

Updates `io.quarkus.platform:quarkus-bom` from 3.37.4 to 3.38.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/1b2e5cf4dc9142bb0964f5a76a8a632895c0818f"><code>1b2e5cf</code></a>
[maven-release-plugin] prepare release 3.38.0</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/be439b29eee2763416636b243cf997cb9e06ba89"><code>be439b2</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/1982">#1982</a>
from lwitkowski/onboard-quarkus-http-problem</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/2cfd10d65abaae061692d5adfd368cd39a25a2e4"><code>2cfd10d</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2092">#2092</a>
from quarkusio/update-automation/main-langchain4j-1....</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/31f41bba3a6db9a4e37fb995d09e1428899448cd"><code>31f41bb</code></a>
Include Quarkus HTTP Problem tests in the Platform</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/65b6d9da200dc9cbea5a0ddd89eb598bd1944ce5"><code>65b6d9d</code></a>
Add quarkus-http-problem</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/9b167b743e760128f0a0b09280e44ac28c584132"><code>9b167b7</code></a>
Update LangChain4j to 1.12.1</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/0e6e01bb67a150c8cfb1a19fd18f02c15b3228e9"><code>0e6e01b</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2084">#2084</a>
from beikov/bp-1.6.19</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/5f088ebefa56c9de53e54e1e1b7f88d70d75afdc"><code>5f088eb</code></a>
Upgrade to Blaze-Persistence 1.6.19</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/7d3fbdff98aa861ca730725468fea3ad7d3db7c4"><code>7d3fbdf</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2091">#2091</a>
from gsmet/aws-resync</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/b5b477d02c1e5ead75453cc501c9823b092b7b9d"><code>b5b477d</code></a>
Resync AWS update</li>
<li>Additional commits viewable in <a
href="https://github.com/quarkusio/quarkus-platform/compare/3.37.4...3.38.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `io.quarkus.platform:quarkus-maven-plugin` from 3.37.4 to 3.38.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/1b2e5cf4dc9142bb0964f5a76a8a632895c0818f"><code>1b2e5cf</code></a>
[maven-release-plugin] prepare release 3.38.0</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/be439b29eee2763416636b243cf997cb9e06ba89"><code>be439b2</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/1982">#1982</a>
from lwitkowski/onboard-quarkus-http-problem</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/2cfd10d65abaae061692d5adfd368cd39a25a2e4"><code>2cfd10d</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2092">#2092</a>
from quarkusio/update-automation/main-langchain4j-1....</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/31f41bba3a6db9a4e37fb995d09e1428899448cd"><code>31f41bb</code></a>
Include Quarkus HTTP Problem tests in the Platform</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/65b6d9da200dc9cbea5a0ddd89eb598bd1944ce5"><code>65b6d9d</code></a>
Add quarkus-http-problem</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/9b167b743e760128f0a0b09280e44ac28c584132"><code>9b167b7</code></a>
Update LangChain4j to 1.12.1</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/0e6e01bb67a150c8cfb1a19fd18f02c15b3228e9"><code>0e6e01b</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2084">#2084</a>
from beikov/bp-1.6.19</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/5f088ebefa56c9de53e54e1e1b7f88d70d75afdc"><code>5f088eb</code></a>
Upgrade to Blaze-Persistence 1.6.19</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/7d3fbdff98aa861ca730725468fea3ad7d3db7c4"><code>7d3fbdf</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2091">#2091</a>
from gsmet/aws-resync</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/b5b477d02c1e5ead75453cc501c9823b092b7b9d"><code>b5b477d</code></a>
Resync AWS update</li>
<li>Additional commits viewable in <a
href="https://github.com/quarkusio/quarkus-platform/compare/3.37.4...3.38.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `io.quarkus.platform:quarkus-maven-plugin` from 3.37.4 to 3.38.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/1b2e5cf4dc9142bb0964f5a76a8a632895c0818f"><code>1b2e5cf</code></a>
[maven-release-plugin] prepare release 3.38.0</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/be439b29eee2763416636b243cf997cb9e06ba89"><code>be439b2</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/1982">#1982</a>
from lwitkowski/onboard-quarkus-http-problem</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/2cfd10d65abaae061692d5adfd368cd39a25a2e4"><code>2cfd10d</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2092">#2092</a>
from quarkusio/update-automation/main-langchain4j-1....</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/31f41bba3a6db9a4e37fb995d09e1428899448cd"><code>31f41bb</code></a>
Include Quarkus HTTP Problem tests in the Platform</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/65b6d9da200dc9cbea5a0ddd89eb598bd1944ce5"><code>65b6d9d</code></a>
Add quarkus-http-problem</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/9b167b743e760128f0a0b09280e44ac28c584132"><code>9b167b7</code></a>
Update LangChain4j to 1.12.1</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/0e6e01bb67a150c8cfb1a19fd18f02c15b3228e9"><code>0e6e01b</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2084">#2084</a>
from beikov/bp-1.6.19</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/5f088ebefa56c9de53e54e1e1b7f88d70d75afdc"><code>5f088eb</code></a>
Upgrade to Blaze-Persistence 1.6.19</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/7d3fbdff98aa861ca730725468fea3ad7d3db7c4"><code>7d3fbdf</code></a>
Merge pull request <a
href="https://redirect.github.com/quarkusio/quarkus-platform/issues/2091">#2091</a>
from gsmet/aws-resync</li>
<li><a
href="https://github.com/quarkusio/quarkus-platform/commit/b5b477d02c1e5ead75453cc501c9823b092b7b9d"><code>b5b477d</code></a>
Resync AWS update</li>
<li>Additional commits viewable in <a
href="https://github.com/quarkusio/quarkus-platform/compare/3.37.4...3.38.0">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps the maven-minor-patch group with 1 update:
[io.quarkiverse.langchain4j:quarkus-langchain4j-bom](https://github.com/quarkiverse/quarkus-langchain4j).

Updates `io.quarkiverse.langchain4j:quarkus-langchain4j-bom` from 1.12.0
to 1.12.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/quarkiverse/quarkus-langchain4j/releases">io.quarkiverse.langchain4j:quarkus-langchain4j-bom's
releases</a>.</em></p>
<blockquote>
<h2>1.12.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Fixes numeric value check by <a
href="https://github.com/jmartisk"><code>@​jmartisk</code></a> in <a
href="https://redirect.github.com/quarkiverse/quarkus-langchain4j/pull/2709">quarkiverse/quarkus-langchain4j#2709</a></li>
<li>Release 1.12.2 by <a
href="https://github.com/jmartisk"><code>@​jmartisk</code></a> in <a
href="https://redirect.github.com/quarkiverse/quarkus-langchain4j/pull/2711">quarkiverse/quarkus-langchain4j#2711</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/quarkiverse/quarkus-langchain4j/compare/1.12.1...1.12.2">https://github.com/quarkiverse/quarkus-langchain4j/compare/1.12.1...1.12.2</a></p>
<h2>1.12.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Release 1.12.1 by <a
href="https://github.com/jmartisk"><code>@​jmartisk</code></a> in <a
href="https://redirect.github.com/quarkiverse/quarkus-langchain4j/pull/2701">quarkiverse/quarkus-langchain4j#2701</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/quarkiverse/quarkus-langchain4j/compare/1.12.0...1.12.1">https://github.com/quarkiverse/quarkus-langchain4j/compare/1.12.0...1.12.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/245cfa4e219926345f548b8b47cf6de681c94670"><code>245cfa4</code></a>
[maven-release-plugin] prepare release 1.12.2</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/8c7f1502eb4a171eae9d9556f8a95e974ef8f40c"><code>8c7f150</code></a>
Update the latest release version 1.12.2 in documentation</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/16e9587d780f03f8bcb102a6e43a56d34c91fe1f"><code>16e9587</code></a>
Update README to 1.12.2</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/2dcce62b9d909e577d652cdaa7fb65fb19d34be1"><code>2dcce62</code></a>
Update dependencies in samples</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/3aa5229681c498574bcfc30e9ea437ad2aa30950"><code>3aa5229</code></a>
Release 1.12.2</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/15c187e69dfcce67a32da3570af0d5ce2cbaa357"><code>15c187e</code></a>
Fixes numeric value check</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/ad444c88b4443c8cc4b09d43c7e1383858388d3a"><code>ad444c8</code></a>
[maven-release-plugin] prepare for next development iteration</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/66965c56bd90e846f3d3c142208d8fede7e5aade"><code>66965c5</code></a>
[maven-release-plugin] prepare release 1.12.1</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/2c71f881a678771f8a2928efd9d112b5cb7b17a9"><code>2c71f88</code></a>
Update the latest release version 1.12.1 in documentation</li>
<li><a
href="https://github.com/quarkiverse/quarkus-langchain4j/commit/8d814cc486392b8caae7f5893bb2a63c2db7439f"><code>8d814cc</code></a>
Update README to 1.12.1</li>
<li>Additional commits viewable in <a
href="https://github.com/quarkiverse/quarkus-langchain4j/compare/1.12.0...1.12.2">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Thiago Gonzaga <2332561+devops-thiago@users.noreply.github.com>
…efault (#449)

## What type of PR is this?

- [ ] 🐛 Bug fix
- [x] ✨ Feature
- [x] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

`thrillhousebot.review.ignored-files` is app-wide, so a single
deployment reviewing many repositories has to pick one list for all of
them. This lets a repository declare ignore globs of its own, **unioned
with** (never replacing) the global default.

### Where per-repo structured settings live — and why

**Decision: a dedicated `.github/thrillhousebot.yml`** (with
`.github/thrillhousebot.yaml` as an alternate name), *not* frontmatter
in the existing `.github/thrillhousebot.md`.

- The instructions fallback chain deliberately reaches into files owned
by other tools (`.github/copilot-instructions.md`, `CLAUDE.md`,
`AGENTS.md`, `AGENT.md`). Frontmatter would mean writing ThrillhouseBot
config into whichever of those happened to win — and which one wins
varies per repository.
- The instructions file's whole content is handed to the model as
untrusted prose. Frontmatter would either leak config noise into the
prompt or require stripping it first, changing the prompt bytes for
every repository that already has an instructions file.
- A dedicated file is structured, versionable, and independent of the
prompt path.

This is a substrate for #33 (path-scoped review instructions), which
will need per-repo structured settings from the same place. The seam is:

- `RepoSettings` — the settings record. #33 adds a component here.
- `RepoSettingsParser` — YAML text → `RepoSettings`. #33 adds a reader
for its own key under the same `review:` map.
- `RepoSettingsResolver` — fetch + two-name chain + per-repo TTL cache +
fail-soft. #33 needs no changes here.

Note that #33 is about review *rules* per path, whereas this controls
what is sent to the model at all; they stay distinct.

### Shape

```yaml
review:
  ignored-files:
    - "docs/generated/**"
    - "**/*.snap"
    - "testdata/**"
```

**Precedence: global ∪ per-repo.** A file is skipped if it matches
*either* list. A repository can take more files out of review scope,
never put back a file the deployment excludes.

### Implementation notes

- **One glob matcher, not two.** The existing `GlobMatcher` /
`compileGlobMatchers` / `**`-suffix behaviour in `ReviewDiffFormatter`
is now wrapped in an `IgnoreGlobs` value type that both the global list
and the per-repo list compile through, so a repository can never get
different matching semantics than the deployment default. `union()` is
the additive operation.
- **The glob is still walked once per review.** `ReviewDiffFormatter` is
`@ApplicationScoped` with its patterns fixed at construction, so
per-repo patterns could not be baked in. Instead the effective
`IgnoreGlobs` is resolved once in `ReviewContextLoader.load(...)` and
threaded into the single `reviewableFiles(...)` call (and the base
comparison), preserving the existing compute-once property.
- **Cache**: `RepoSettingsResolver` mirrors `InstructionsResolver` —
5-minute TTL, 1-minute negative cache, size-triggered sweep,
`LongSupplier` clock for tests.
- **Fail-soft at every layer**: feature off → `EMPTY`; file absent →
next name, then `EMPTY`; transport error → `EMPTY`; undecodable content
→ `EMPTY`; malformed YAML or unexpected shape → `EMPTY`
(`RepoSettingsParser` never throws); uncompilable glob → dropped by the
existing `compileGlobMatchers` catch; and
`SoftLoaders.repoSettings(...)` is the outer guarantee.
- **Untrusted input hardening**: the parser reads a generic tree (no
reflection, no POJO binding) and bounds the document with snakeyaml
loader limits (code-point, nesting depth, alias expansion, duplicate
keys), plus caps on pattern count and pattern length.
- **New config key** `thrillhousebot.review.repo-config-enabled`
(default `true`) is the operator kill switch, following the
`add-docs-enabled` precedent. Documented in `README.md` and
`.env.example`.
- **No new dependency**: `jackson-dataformat-yaml` was already on the
compile classpath via `quarkus-smallrye-openapi` and version-managed by
the existing `jackson-bom` import; it is now declared explicitly in
`pom.xml` because it is used directly. No version was added or bumped.

### Deliberately out of scope

The on-demand commands (`/describe`, `/changelog`, `/add-docs`,
maintainer replies) still use the global list only — they build their
diffs through separate call paths, and `MaintainerReplyService` has no
default branch in its task to resolve the config with. Extending them is
mechanical now that the seam exists
(`diffFormatter.ignoreGlobs(settings.ignoredFiles())` plus the
`reviewableFiles(files, globs)` overload) and is best done as a
follow-up rather than widening this diff.

## Related Issues

Fixes #51

Related: #33 (path-scoped review instructions) will build on
`RepoSettings` / `RepoSettingsParser` / `RepoSettingsResolver`.

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

New tests: `RepoSettingsResolverTest` (17 cases: parsing, `.yml`→`.yaml`
fallback, caps, fail-soft, TTL / negative cache / sweep, disabled flag),
`ReviewDiffFormatterTest$PerRepoIgnorePatterns` (5 cases), and 3
end-to-end `load(...)` cases in `ReviewContextLoaderTest`.

Each new behaviour was validated red/green. With the tests in the tree,
the production behaviour was neutralized while keeping the API (so the
tests still compiled and ran, rather than failing to build):
`ReviewDiffFormatter.ignoreGlobs(...)` reduced to `return globalGlobs;`,
`ReviewContextLoader.load` reverted to
`diffFormatter.reviewableFiles(files)`, and
`RepoSettingsResolver.resolve` short-circuited to `RepoSettings.EMPTY`.

Red phase — actual failures produced:

```
[ERROR] ReviewDiffFormatterTest.repoDeclaredPatternTakesEffect
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

[ERROR] ReviewDiffFormatterTest.effectiveSetIsTheUnionOfGlobalAndPerRepoPatterns
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

[ERROR] ReviewDiffFormatterTest.malformedRepoPatternIsDroppedWithoutFailingTheReview
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

[ERROR] ReviewDiffFormatterTest.perRepoPatternsAlsoScopeTheBaseComparison
org.opentest4j.AssertionFailedError:
  (expected "(test/fixtures/big.json skipped: matches ignored pattern" in the rendered comparison)

[ERROR] ReviewContextLoaderTest.perRepoIgnorePatternsNarrowTheReviewableFileSet
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>

[ERROR] RepoSettingsResolverTest.readsIgnoredFilesFromTheYmlFile
org.opentest4j.AssertionFailedError: expected: <[docs/generated/**, **/*.snap]> but was: <[]>

[ERROR] RepoSettingsResolverTest.fallsBackToTheYamlExtension
org.opentest4j.AssertionFailedError: expected: <[vendored/**]> but was: <[]>

[ERROR] RepoSettingsResolverTest.acceptsACommaSeparatedScalarLikeTheEnvVarForm
org.opentest4j.AssertionFailedError: expected: <[docs/generated/**, **/*.snap]> but was: <[]>

[ERROR] RepoSettingsResolverTest.capsHowManyPatternsARepositoryMayContribute
org.opentest4j.AssertionFailedError: expected: <200> but was: <0>

[ERROR] RepoSettingsResolverTest.dropsBlankAndOverLongPatterns
org.opentest4j.AssertionFailedError: expected: <[kept/**]> but was: <[]>

[ERROR] RepoSettingsResolverTest.cachesPerRepositoryAndRefetchesAfterTheTtl
org.opentest4j.AssertionFailedError: expected: <[gen/**]> but was: <[]>

[ERROR] RepoSettingsResolverTest.cachesAreKeyedPerRepository
org.opentest4j.AssertionFailedError: expected: <[gen/**]> but was: <[]>

[ERROR] RepoSettingsResolverTest.negativeResultIsCachedBrieflyThenRetried
Wanted but not invoked:
prClient.getFileContent("Bearer test-jwt", "application/vnd.github+json", "test-owner",
    "test-repo", ".github/thrillhousebot.yml", "main");
Actually, there were zero interactions with this mock.

[ERROR] RepoSettingsResolverTest.sweepDropsExpiredEntriesOnceTheCacheIsLarge
org.opentest4j.AssertionFailedError: expected: <1000> but was: <0>
```

Two of the new tests pass in both phases **by design** — they are the
"global-only behaviour is unchanged" regression guards
(`repoThatDeclaresNothingKeepsGlobalOnlyBehavior` and
`repoWithNoDeclaredPatternsKeepsEveryFileTheGlobalListAllows`); they
must hold before and after.

With the production change restored, all of the above pass. Gates run
locally:

- `./mvnw -B spotless:apply` — clean
- `./mvnw -B clean compile spotbugs:check spotless:check` — BUILD
SUCCESS, `BugInstance size is 0`
- `./mvnw -B clean test` — BUILD SUCCESS, `Tests run: 1902, Failures: 0,
Errors: 0, Skipped: 0`

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

The native build only runs on `main`, not on PRs. Residual risk there is
low — `snakeyaml` and `jackson-dataformat-yaml` are already exercised at
runtime in the native image by `quarkus-smallrye-openapi`, and this code
path uses `readTree` with no reflective POJO binding — but it is worth a
glance on the first `main` build after merge.
## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [ ] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

Dogfooding turned up a summary that described a full multi-collaborator
decompose (~1.6k LOC, 7 new collaborators) as "extracts CI-status
evaluation" — the prose named one class while the diff was the whole
refactor.

**Diagnosis.** On a large PR the summary comes from a separate model
call (`AiReviewService.summarize` → `PrReviewer.summarizeStream`, driven
by `PrReviewPrompts.SUMMARY_SYSTEM` / `SUMMARY_USER`), and that call
never sees the diff. The PR title and description were already reaching
it through the `prContext` slot, so the gap was not the metadata — it
was that:

1. `SUMMARY_SYSTEM` told the model to derive `pr_purpose` "from the
changed files and findings" and never mentioned the author's stated
intent or the size of the change; and
2. nothing in the prompt stated how big the change actually is. The only
breadth signal was a changed-file list that `clampOverview` may truncate
on exactly the large PRs where this matters.

**Fix.**

- `FindingPipeline` now renders a deterministic PR-scope block at the
head of the changed-files section: `PR scope (whole pull request): N
files changed, +A -D`, then a per-directory breakdown (`-
src/main/java/...: 3 files (+460 -900)`), capped at 10 directories with
a rollup for the rest. The totals are GitHub's authoritative PR totals
(`ctx.prTotals()`, the same numbers the rendered Changes Overview
reports since #298), falling back to the diff-derived counts when the
fetch failed. It sits ahead of the per-file rows so budget clamping can
only drop the tail, the same reason the pure-rename rollup leads.
- `SUMMARY_SYSTEM` now requires `pr_purpose` to describe the WHOLE
change set, grounded in the PR title/description *and* the scope totals,
and explicitly forbids presenting one extracted class, one file, or the
one component carrying findings as if it were the whole PR. It also
states that a summary narrower than the stated PR scope is wrong and
that few/no findings never means the change was small.
- `description_gaps` now covers the qualitative case this issue is
about: a description whose scope is narrower than the change itself.
- `SUMMARY_USER` renames the file-list section to `## PR scope and
changed files (computed from the diff — authoritative)` and states the
purpose must account for all of it.

Everything new is data derived from the diff and file list; the
untrusted prose (title/body) keeps going through the existing escaped
`prContext` slot.

**Deliberately not implemented** (both optional in the issue):

- *The "mentions fewer files than changedFiles.size()" guard.*
`pr_purpose` is prose that the prompt explicitly asks to "describe
behavior, not a file listing", so a well-written summary mentions no
file paths at all. A mention-count threshold would append "(partial —
see walkthrough)" to correct summaries, which erodes trust in the other
direction. The deterministic effort went into making the scope
authoritative and unclampable instead.
- *A "Description vs PR metadata" pass in
`FindingVerifierPrompts.SYSTEM`.* That verifier audits individual
candidate findings and runs per batch, before the summary exists — it
has no summary to check. The equivalent signal is instead encoded where
the summary is produced (`description_gaps`, already rendered by
`PrSummaryGenerator.appendDescriptionGaps` as "⚠️ Description vs.
Implementation"), with no extra AI call.

No new config keys, so no README/`.env.example` changes.

## Related Issues

Fixes #335

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Red/green validated per test: with the production change stashed (`git
stash push -- src/main/java`) and the tests kept, all seven new
assertions fail; with it restored they pass.

Red phase (`./mvnw -B test
-Dtest='FindingPipelineTest,PrReviewPromptsContentTest'` → `Tests run:
47, Failures: 7`):

```
FindingPipelineTest.summaryOverviewStatesTheWholePrScopeForAMultiFileRefactor
  src/main/java/app/review/Orchestrator.java (modified, +40 -900)
  ... ==> expected: <true> but was: <false>
FindingPipelineTest.summaryOverviewScopeUsesGitHubsAuthoritativeTotalsWhenAvailable
  a.java (modified, +3 -0) ... ==> expected: <true> but was: <false>
FindingPipelineTest.summaryOverviewScopeStaysSingularForASingleFilePr
  src/main/java/app/Tiny.java (modified, +3 -1) ==> expected: <true> but was: <false>
FindingPipelineTest.summaryOverviewRollsUpDirectoriesBeyondTheCap
  pkg0/File.java (modified, +1 -0) ... ==> expected: <true> but was: <false>
PrReviewPromptsContentTest.summaryPromptGroundsThePurposeInTheWholeChangeSet
  pr_purpose must be scoped to the whole change set, not one file (#335)
  — missing marker: "what the WHOLE change set does" ==> expected: <true> but was: <false>
PrReviewPromptsContentTest.summaryPromptRejectsASummaryNarrowerThanThePrScope
  a summary whose scope is a subset of the diff's must be called out as wrong (#335)
  — missing marker: "scope is narrower than the stated PR scope is wrong"
PrReviewPromptsContentTest.summaryUserPromptFramesTheFileListAsAuthoritativeScope
  the summary user prompt must present the scope block as authoritative
  — missing marker: "## PR scope and changed files (computed from the diff — authoritative)"
```

(The assertion message on the pipeline tests is the captured
`SummaryInputs.changedFiles()` value, i.e. the file list with no scope
header.)

Green phase: same command, `Tests run: 47, Failures: 0`.

Coverage of the acceptance criteria:

- *Summary reflects multi-file refactors* —
`summaryOverviewStatesTheWholePrScopeForAMultiFileRefactor` drives the
multi-file fixture (5 files over 3 directories, title/body announcing
the full scope) through the map-reduce path and asserts the summary call
receives the totals, the per-directory breakdown, and that they precede
the per-file rows. The prompt-side half is pinned by the
`PrReviewPromptsContentTest` cases.
- *Regression test with a multi-file fixture* — the same test, plus
`summaryOverviewScopeUsesGitHubsAuthoritativeTotalsWhenAvailable`
(GitHub totals win over diff-derived counts) and
`summaryOverviewRollsUpDirectoriesBeyondTheCap` (the breakdown stays
bounded).
- *No regression on small single-purpose PRs* —
`summaryOverviewScopeStaysSingularForASingleFilePr` asserts a one-file
PR renders singular scope text with no multi-file or multi-directory
language, and the single-call review path (which is what small PRs use)
is untouched.

Also run: `./mvnw -B spotless:apply`, `./mvnw -B clean compile
spotbugs:check spotless:check` (BugInstance size is 0, BUILD SUCCESS),
`./mvnw -B clean test` (full suite: `Tests run: 1884, Failures: 0,
Errors: 0, Skipped: 0`).

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

The block the summary call now receives ahead of the file list, for a
decompose-shaped PR:

```
PR scope (whole pull request): 5 files changed, +664 -902
Directories touched: 3
- src/main/java/app/review: 3 files (+460 -900)
- src/test/java/app/review: 1 file (+200 -0)
- (repository root): 1 file (+4 -2)
src/main/java/app/review/Orchestrator.java (modified, +40 -900)
...
```

## Additional Notes

`summaryFindingsJsonIsClampedToThePerCallBudget` calibrates a per-call
budget against the exact fixed prompt sections, so it needed its
expected overview updated for the new scope block and slightly longer
finding descriptions to keep the findings share dominant; its assertions
are unchanged.
## What type of PR is this?

- [ ] 🐛 Bug fix
- [x] ✨ Feature
- [x] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [x] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

When a maintainer replies to decline a finding, the follow-up analysis
recorded it
`justified` and the bot moved on — it never checked whether the stated
rebuttal
actually holds. A dismissal was treated as ground truth rather than a
claim, so a
**correct** finding could be closed by an **incorrect** rebuttal, and
the rebuttal
often names the very mechanism that makes the bug real.

This PR makes a decline a claim to verify. Two layers, both deliberately
conservative:

**1. Prompt rule** (`PrReviewPrompts.SYSTEM`, `previous_findings_status`
contract).
Before marking a prior finding `justified`, trace the reply's stated
reason against
the code in the provided material. When that material *plainly
contradicts* the
premise, keep the finding `unresolved` and quote the contradicting line
in the note —
never re-raise it as a new finding. The rule also spells out the
conservative side:
override only at high confidence, on evidence quotable from the provided
material;
style, intent, accepted risk, priority, or any premise whose supporting
code is not in
context keep the decline.

**2. Deterministic post-processing** (`FollowUpAnalyzer.recheckDeclines`
+
`RebuttalContradiction`), in the same shape as the existing
`supersedeVanished` /
`addUnreportedVanished` status rewriters, so the guarantee does not rest
on the model
obeying prose. It detects exactly one high-precision family — the
dogfood one — and
all three legs must hold:

1. the prior finding is about concurrency (race, check-then-act,
thread-safety, atomicity);
2. the maintainer's reply asserts concurrency is impossible
("single-threaded", "runs
serially", "only ever called from …"), judged on the reply with fenced
blocks and
blockquotes stripped, so quoted material is never read as the
maintainer's assertion;
3. the reviewed diff contains a concurrent-dispatch construct
(`newVirtualThreadPerTaskExecutor`,
`newCachedThreadPool`, `executor.submit/execute`,
`CompletableFuture.runAsync`,
   `new Thread(...)`, `@Async`, `parallelStream()`).

The status is then rewritten `justified` → `unresolved` with a one-line
note quoting both
the claim and the contradicting line. Anything else — every rebuttal
about style, intent,
accepted risk, or priority, and every premise not refutable from code
text — matches nothing
and keeps the decline.

Safety properties:

- **One push-back, then defer.** The re-check only fires while the
thread carries a *single*
maintainer reply. A second reply is the maintainer answering the
push-back and always wins,
so the bot can never keep re-opening the same finding round after round.
- The re-opened finding re-enters the ordinary `unresolved` path: it
holds approval
(`APPROVE` → `COMMENT`) exactly like any other unresolved prior finding
and is **never**
re-posted as a new inline comment, so nobody answers the same comment
twice.
- `REVIEW_DECLINE_RECHECK_ENABLED=false` disables the step outright,
making a maintainer
  reply final.

This is also the gate that makes durable maintainer-feedback memory
safe: only declines that
survive this re-check are sound enough to persist as learnings.

**Known limitation (stated honestly).** The deterministic step can only
refute a rebuttal whose
contradicting code is inside the material the review call saw. In the
dogfood PR the executor
producer itself was an unchanged file and would not have been visible —
but the same PR's
`CommentCommandService` change *was* in the diff and contains
`executor.execute(() -> execute(ctx))`,
which is the evidence the regression test uses. When the mechanism lives
entirely outside the
diff, only the prompt rule can catch it, and only when the model has
that context.

### Files

| File | Change |
|---|---|
| `review/RebuttalContradiction.java` | New. Deterministic
claim/evidence matcher; returns a quoted claim + quoted code line, or
nothing. |
| `review/FollowUpAnalyzer.java` | New `recheckDeclines(...)` status
rewriter, plus the thread/reply lookup and the enabled flag. |
| `review/VerdictBuilder.java` | Wires the re-check into `build(...)`
after `addUnreportedVanished`; lazily supplies the reviewed diff (budget
batches, else `ctx.diff()`). |
| `review/ai/PrReviewPrompts.java` | Prompt rule in the
`previous_findings_status` contract. |
| `config/ThrillhouseConfig.java`, `application.properties` | New
`thrillhousebot.review.decline-recheck-enabled` (default `true`). |
| `README.md`, `.env.example` | Config table row, `.env.example` entry,
and a "Re-checking declines" section. |

## Related Issues

Fixes #169

## How Has This Been Tested?

New tests: `RebuttalContradictionTest` (9 cases), 5 new cases in
`FollowUpAnalyzerTest`, and an
end-to-end wiring case in `VerdictBuilderTest`. Both directions are
covered, and each was
validated red/green by neutralizing only the production code.

**(a) A code-contradicted rebuttal must not be recorded justified.**
Neutralized by early-returning
the statuses unchanged from `recheckDeclines` and returning
`Optional.empty()` from
`RebuttalContradiction.find`:

```
FollowUpAnalyzerTest.recheckShouldReopenDeclineWhoseAsyncAfterAckPremiseTheReviewedCodeContradicts
  org.opentest4j.AssertionFailedError: a decline the reviewed code contradicts must not be
  recorded justified ==> expected: "unresolved" but was: "justified"

RebuttalContradictionTest.shouldContradictAsyncAfterAckRebuttalWhenTheCodeDispatchesConcurrently
  org.opentest4j.AssertionFailedError: the async-after-ack rebuttal is refuted by
  executor.execute(...) in the reviewed code ==> expected: "true" but was: "false"
```

**(b) A style/intent rebuttal must still be recorded justified.**
Neutralized in the other
direction, by widening the claim pattern so the re-check over-fires:

```
FollowUpAnalyzerTest.recheckShouldKeepDeclineThatRestsOnStyleOrIntent
  org.opentest4j.AssertionFailedError: a rebuttal that is not refutable from the code must be
  respected ==> expected: "justified" but was: "unresolved"

RebuttalContradictionTest.shouldRespectRebuttalsThatAreNotRefutableFromCode (x4)
  org.opentest4j.AssertionFailedError: style / intent / accepted-risk rebuttals must keep the
  decline ==> expected: "true" but was: "false"

RebuttalContradictionTest.shouldIgnoreClaimsThatAppearOnlyInQuotedMarkdown
  org.opentest4j.AssertionFailedError: a blockquote or fenced block is quoted material, not the
  maintainer's own assertion ==> expected: "true" but was: "false"
```

**(c) The escape hatch and the config flag.** Neutralized by relaxing
the single-reply guard to
`humanReplies.isEmpty()` and by dropping the flag check:

```
FollowUpAnalyzerTest.recheckShouldDeferOnceTheMaintainerHasAnsweredTwice
  org.opentest4j.AssertionFailedError: a second maintainer reply answers the push-back and always
  wins ==> expected: "justified" but was: "unresolved"

FollowUpAnalyzerTest.recheckShouldBeDisabledByConfig
  org.opentest4j.AssertionFailedError: expected: "justified" but was: "unresolved"
```

**(d) The wiring.** Removing the `recheckDeclines` call from
`VerdictBuilder.build`:

```
VerdictBuilderTest.declinedPriorFindingTheReviewedCodeContradictsStaysOpenAndHoldsApprove
  org.opentest4j.AssertionFailedError: expected: "1" but was: "0"
```

(The assertion values above are quoted for markdown's sake; the runner
prints them in angle
brackets.) Every one passes again with the production code restored. The
regression case is
derived from the dogfood scenario: the `PrPauseService.pause()` race
finding, declined with
"only ever called from the /pause command path, which runs
asynchronously on the review executor
after the webhook has returned 200", against a diff containing
`executor.execute(() -> execute(ctx));`.

Format / lint / suite:

- `./mvnw -B spotless:apply` — clean
- `./mvnw -B clean compile spotbugs:check spotless:check` — BUILD
SUCCESS, `BugInstance size is 0`
- `./mvnw -B clean test` — **Tests run: 1892, Failures: 0, Errors: 0,
Skipped: 0**

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

- **Why both a prompt rule and a deterministic step.** The prompt rule
generalizes (it can weigh
any premise the model can trace, including ones no regex will ever
encode) but is unverifiable
and unenforceable. The deterministic step covers one narrow family with
genuinely load-bearing
tests and, crucially, runs *after* the model — so it also catches the
case where the model itself
accepted the bad rebuttal, which is exactly what happened in the dogfood
PR. Neither alone is
  sufficient.
- Only the concurrency family is implemented deterministically. "The
caller already guards X" is in
the prompt rule but not the deterministic step on purpose: proving a
guard is *absent* from a
partial diff is inference from missing evidence, which is the unsafe
direction here.
- The contradiction note lands on `PreviousFindingStatus.note`, which is
persisted and shown in the
dashboard but not yet rendered in the summary markdown — the same as the
existing superseded note.
  Surfacing notes in the summary table felt like separate scope.
- Issue #38 (durable maintainer-feedback memory) is deliberately **not**
implemented here.
## What type of PR is this?

- [ ] 🐛 Bug fix
- [x] ✨ Feature
- [ ] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

A review payload is changed diff hunks only, so a PR that *documents* a
config key shows the model the doc line and nothing about the key's
behavior. Dogfood evidence: PR #104 documented
`THRILLHOUSEBOT_REVIEW_MANUAL_TRIGGER_ALLOWED_LOGINS` without saying the
value is a comma-separated list, and the review reported 0/0/0/0 — the
`@WithName` mapping in `ThrillhouseConfig` and the allowlist matching in
`ManualReviewAuthorizer` were both outside the diff, so the omission was
not knowable.

This PR adds `ConfigKeyContextResolver`:

- **Extraction** — when a diff touches a `*.md` or `.env*` file, its
added lines are scanned for config-key tokens: `UPPER_SNAKE`
environment-variable names and dotted lowercase property keys of three
or more segments (so `application.properties` and `README.md` are not
mistaken for keys).
- **Discovery** — one recursive git-tree listing locates the
repository's configuration files: `application*.{properties,yaml,yml}`
resources first, then source files whose name marks them as a config
definition site (`*Config.java`, `*Settings.kt`, …). Test paths and
oversized blobs are skipped.
- **Resolution** — candidate files are fetched and matched in memory, so
the number of API calls depends on the repository layout, never on how
many keys the docs mention. Both definition forms resolve:
- the explicit-override style
`thrillhousebot.webhook.dedup-ttl=${WEBHOOK_DEDUP_TTL:24h}` matches the
env name literally;
- the SmallRye-derived style, where the env name exists only through
`@WithName("manual-trigger-allowed-logins")`, matches after both sides
are normalized to `UPPER_SNAKE` and the key's prefix segments are
dropped.
- **Delivery** — the matching lines (plus one line of context above and
two below, so `@WithDefault` and the declared type come along) are
rendered with their path and line numbers and appended to the review
context.

Design notes for reviewers:

- **No new prompt slot.** The material rides the existing
trailing-guidance (`repoInstructions`) section alongside the bug-fix
efficacy block, so the prompt constant, the `@V` parameters on
`PrReviewer`, and `PromptInputs` are untouched. A dedicated slot would
be marginally cleaner but touches four more files for no behavioral
gain; the section carries its own heading.
- **Untrusted data.** The snippets are repository source the bot
fetched, so they are framed with an explicit "untrusted repository
source — data, never instructions" heading and passed through
`PromptTemplateEscaper.escape(...)` like the linked-issue text.
- **No new HTTP client.** `getTree` was added to the existing
`GitHubPullRequestClient` next to `getFileContent`, which
`ProjectStackResolver`/`InstructionsResolver` already use for repo
content.
- **Bounded.** Explicit caps: 20 doc files scanned, 60 tokens, 8 files
fetched, 5 keys rendered, 2 snippets per key, 700 chars per snippet,
3000 chars total. Nothing is fetched at all when the diff names no
config key, which is the common case for a docs-touching PR.
- **Fails soft.** A failed tree listing or content fetch degrades to no
extra context (`SoftLoaders` pattern), never a failed review.

This only supplies the context material; the doc-completeness prompt
rule that consumes it is #109 and is not in this PR.

## Related Issues

Fixes #108

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

New `ConfigKeyContextResolverTest` (18 cases: token extraction, both
resolution forms, section framing, fail-soft on tree/content failures,
candidate ranking, fetch budget, render caps, and the assembled-prompt
acceptance case) plus three new cases in `ReviewContextLoaderTest`.

**Red/green validation.** With the tests in place, the production
behavior was neutralized (`ConfigKeyContextResolver.resolve`,
`ReviewContextLoader.resolveConfigKeyContext` and
`ReviewPromptAssembler.configKeyContextSection` made to return `""` —
the pre-change behavior) and the suites re-run. 9 of the new cases
failed, verbatim:

```
ConfigKeyContextResolverTest.shouldCarryTheDefinitionIntoTheAssembledPrompt
  assembled prompt lost the definition:  ==> expected: <true> but was: <false>
ConfigKeyContextResolverTest.shouldResolveDerivedEnvVarToItsWithNameMapping
  key heading missing from:  ==> expected: <true> but was: <false>
ConfigKeyContextResolverTest.shouldResolveExplicitEnvOverrideInApplicationProperties
  explicit override missing from:  ==> expected: <true> but was: <false>
ConfigKeyContextResolverTest.shouldResolvePropertyKeyTokens
  property definition missing from:  ==> expected: <true> but was: <false>
ConfigKeyContextResolverTest.shouldFrameTheSectionAsUntrustedData
  expected: <true> but was: <false>
ConfigKeyContextResolverTest.shouldSkipAFileWhoseContentCannotBeRead
  a failed fetch must not lose the other definition:  ==> expected: <true> but was: <false>
ConfigKeyContextResolverTest.shouldCapRenderedKeysAndTotalCharacters
  rendered key count is not capped:  ==> expected: <5> but was: <0>
ConfigKeyContextResolverTest.shouldNotFetchMoreFilesThanTheBudgetAllows
  (Mockito) wanted 8 invocations of getFileContent, but was 0
ReviewContextLoaderTest.shouldResolveAtThePrHeadSha
  expected: <### definitions> but was: <>
```

With the production change restored, all 69 cases in those two classes
pass.

Full gates on Java 25:

- `./mvnw -B spotless:apply` — clean
- `./mvnw -B clean compile spotbugs:check spotless:check` — `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` — **Tests run: 1898, Failures: 0, Errors: 0,
Skipped: 0**

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

**No new config key.** The caps are compile-time constants rather than
configuration, so there is nothing to add to the README table or
`.env.example` — hence the unticked documentation box. The resolver is
unconditional like `ProjectStackResolver` and the linked-issue fetch; if
a kill switch is wanted for the extra GitHub calls it is a small
follow-up.

**Cost.** A PR whose docs name no config key costs zero extra API calls.
A PR that does costs one tree listing plus at most 8 content fetches,
and stops early once every token has resolved (typically 2 fetches for
this repo: `application.properties` and `ThrillhouseConfig.java`).
Results are not cached — the material is read at the PR head SHA so a
key added by the same PR resolves against the PR's own tree, which makes
a per-repo cache mostly cold; a per-SHA cache is a reasonable follow-up
if re-review latency matters.

**Deliberately out of scope.** Issue #109 (the doc-completeness prompt
rule) and #55 (general cross-file context) are not touched. No CHANGELOG
entry was added to avoid conflicting with the other v0.6.0 wave
branches.
…452)

## What type of PR is this?

- [ ] 🐛 Bug fix
- [x] ✨ Feature
- [x] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

PR-Agent exposes `/improve`, a dedicated pass that proposes broad,
committable improvements across an entire PR. ThrillhouseBot only
attached inline `suggestion` blocks to findings produced by the review
pipeline, so there was no way to ask for an "improve this change set"
pass.

This adds `/improve` (and the `@thrillhousebot improve` mention form) as
an on-demand, write-gated command:

- **Whole-PR pass, token-budgeted.** `PrImprovementService` plans
**batches over the reviewable file list** with `DiffBudgetPlanner`, the
way the review path has worked since #53, and makes one model call per
batch. It extends `AbstractPrSuggestionGenerator` for the PR title/body
and the resolved repository instructions (`.github/thrillhousebot.md`
and the fallback chain), with the same fail-soft degradation the other
on-request commands use.
- **Committable suggestions.** Every improvement whose quoted
`suggestion_old` anchors cleanly onto the diff is posted as an inline ``
```suggestion `` block on the lines it replaces. A single-line
replacement must land on the exact reported line *and* reproduce that
line's text **including its leading indentation**; a multi-line
replacement is anchored by its verbatim range so it overwrites the whole
span rather than only its first line.
- **Copy-paste fallback.** Improvements that cannot be pinned to the
diff (or that GitHub rejects) are surfaced as copy-paste blocks in the
run's summary comment instead of being dropped.
- **Coverage disclosure.** The summary discloses the **plan's** omitted
and clipped files — genuinely over-budget ones — and **names** them, via
a new `ReviewResult.truncationDisclosure(int, TruncationDetail)`
overload mirroring the existing `truncationNotice(int,
TruncationDetail)`. The on-demand surface therefore upholds the same
"reported by name, never silently dropped" contract as the review banner
(same standard as #296).
- **Gating.** Write access is enforced by `ManualReviewAuthorizer`, the
command is refused with the paused notice while a PR is paused, both
suggestion kinds count against `max-review-comments`, and the whole
command sits behind a new kill switch.

`AbstractPrSuggestionGenerator.Inputs` now also carries the head SHA and
the ignore-filtered file list — both already fetched by `loadInputs` and
simply discarded before — so a command can anchor its output back onto
the diff without a second round of API calls.

### How a run is planned

```
loadInputs(...)                       diff + title + body + instructions + reviewable files
  └─ respectPerRepoIgnores(...)       #449 globs applied on top of the global set  ──┐
       └─ planBatches(reviewable)                                                    │
            ├─ max-input-tokens <= 0  → one uncapped batch                           │
            └─ otherwise              → DiffBudgetPlanner.plan(                      │
                                            reviewable,                              │
                                            sharedPromptOverhead(inputs),            │
                                            perCallInputBudget(),                    │
                                            maxBatches())                            │
  └─ generate(...)  one assistant call per batch, merged and deduped by file:line    │
  └─ post(...)      DiffLineResolver over the SAME effective file list ──────────────┘
```

- **Batches come from the file list, not the diff string.**
`DiffBudgetPlanner` orders files highest-impact-first and packs them
First-Fit-Decreasing into bins that each fit the per-call input budget.
A file too large for one bin is hunk-clipped; one that still does not
fit is reported by name.
- **What bounds coverage now is `max-ai-calls`, not `max-diff-lines`.**
The line cap no longer gates what the model sees. Unlike a review,
`/improve` makes no final summary call — its summary comment is
assembled locally — so the whole `max-ai-calls` allowance goes to
batches (a review reserves one). Files that never get a batch are named
in the summary.
- **Shared prompt overhead is this command's own.**
`sharedPromptOverhead(...)` is built from
`PrImproveAssistantPrompts.SYSTEM` + `PrSuggestionPrompts.USER` (the
actual `@UserMessage` on `PrImproveAssistant.improve`) + the fence
scaffolding + the escaped title/body/instructions — exactly the non-diff
text `generateOne(...)` sends, and nothing from `PrReviewPrompts`.
Sizing batches against the review path's prompts would let every
"in-budget" batch overshoot the real input limit.
- **Anchoring stays whole-PR.** The `DiffLineResolver` is built once
from the effective reviewable file list — never from a batch — so an
improvement produced by batch 3 still anchors to its correct absolute
line.
- **Merging and dedupe.** Results are merged across batches and deduped
by file and line, so two batches can never propose the same line twice.
- **Partial failure is survivable.** A batch whose call or parse fails
is skipped and disclosed rather than failing the run; only an
all-batches failure posts the failure notice.

### Why batching, not `max-diff-lines`

The first version of this command took the pre-#53 route: a single call
over the diff string that `ReviewDiffFormatter` caps at
`max-diff-lines`. For a command whose entire value proposition is
covering the **whole** change set, silently shrinking to the first N
lines is the wrong failure mode — and it is not hypothetical. On a
change set with the cap set low, the model received literally this and
nothing else:

```
## Overview: 2 files (+7 -0)

(diff truncated at 4 lines — 2 files omitted)
```

Batching sizes by **tokens over the file list**, so a long diff only
loses coverage once it exceeds the whole budget. Truncation becomes the
rare genuinely-over-budget fallback rather than the design.

**Cost.** One model call per batch, capped at `max-ai-calls` (default 6)
— so an `/improve` costs at most what one review costs, and typically
far less, since most PRs fit in a single batch. It is not free, though:
a large PR that used to cost one call can now cost up to six. Lowering
`max-ai-calls` lowers the ceiling for both commands.

### The seam #457 builds on

#457 moves `/describe`, `/changelog` and `/add-docs` onto the same
batching. The seam is the pair of methods in `PrImprovementService`:

```java
// budget → batches, over an already-ignore-filtered file list
private DiffBudgetPlanner.BudgetPlan planBatches(List<FileDiff> reviewable, Inputs inputs)
// the non-diff text this command's own calls repeat, so the planner can subtract it
private String sharedPromptOverhead(Inputs inputs)
```

Everything else those commands need is already shared:
`AbstractPrSuggestionGenerator.Inputs` carries the ignore-filtered
`reviewableFiles`, `DiffBudgetPlanner.plan(reviewable, overhead,
perCallInputBudget(), maxBatches)` is package-visible, and
`ReviewResult.truncationDisclosure(int, TruncationDetail)` renders the
disclosure. So each command needs only its own overhead string. The
natural refactor is to lift `planBatches` and `sharedPromptOverhead`
into `AbstractPrSuggestionGenerator`, parameterised by the caller's
prompt constants; `respectPerRepoIgnores(...)` should move up with them
(it is wired only into `/improve` here to avoid changing the other three
commands' behaviour in this PR).

### Respecting per-repo ignore patterns

Now that every file is in scope, the per-repo ignore patterns from #449
are applied on top of the deployment-wide set. While the pass stopped at
`max-diff-lines`, a repo-ignored file beyond the cap was excluded by
accident; without this it would newly receive committable suggestions
against generated or vendored code a repository explicitly asked the bot
to leave alone. Per-repo patterns are strictly additive, so the
already-filtered list is narrowed again — no extra diff fetch — and it
fails soft to the global set exactly like the review path.

The same effective list also backs the line map, so an ignored file
cannot be reached by a hallucinated path either: a suggestion naming one
no longer resolves, and degrades to a copy-paste note instead of a
one-click commit.

### Why the anchoring is stricter than `/add-docs`

`/add-docs` only ever *inserts* a doc comment above a declaration, so a
quote that matches loosely is harmless. `/improve` *rewrites* the line,
and committing a suggestion replaces the anchored range with
`suggestion_new` verbatim. A model that re-indented the code it quoted
has almost certainly re-indented its replacement too, so a loose match
would silently reflow the line — and in an indentation-sensitive
language, change what the code means. `/improve` therefore requires an
exact match (trailing whitespace excepted) and fails closed when the
line text cannot be read: an unverifiable line must not be rewritten on
the author's behalf. Anything that does not anchor becomes a copy-paste
block a human applies deliberately. The prompt asks for character-exact
indentation in `suggestion_old` so this path stays the common one rather
than the fallback.

### Configuration

New key `thrillhousebot.review.improve-enabled` /
`REVIEW_IMPROVE_ENABLED`, documented in `README.md` and `.env.example`.

It **defaults to `true`**, matching the `add-docs-enabled` precedent
rather than the usual "new flag defaults to current behaviour" rule: the
command never runs automatically, requires write access, and only spends
AI budget when a maintainer explicitly asks for it. Flagging that here
explicitly in case you would rather ship it opt-in — it is a one-line
change to `@WithDefault`.

No new key was needed for batching: it reuses `REVIEW_MAX_INPUT_TOKENS`,
`REVIEW_TOKEN_SAFETY_MARGIN`, `REVIEW_OUTPUT_BUFFER_TOKENS` and
`REVIEW_MAX_AI_CALLS`. Their README rows now say so, and
`REVIEW_MAX_DIFF_LINES` no longer lists `/improve` among the single-call
line-capped renders.

### Documentation

The docs site's current-version pages `include:` the README sections, so
the README edits flow into them automatically; the `0.1.0`–`0.4.0` trees
are frozen archives and are untouched.
`website/src/content/docs/index.md` keeps its own hand-maintained
command teaser, which listed every on-demand generation command except
the new one, so it is updated too. `npm ci && npm run build` in
`website/` passes with *"All internal links are valid."*

`docs/COMPARISON.md` was left untouched — it compares licensing,
hosting, model and cost dimensions and carries no command matrix, so
there is nothing there to update.

## Related Issues

Fixes #316

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

`PrImprovementServiceTest` (35 tests), `ImprovementParserTest` (6),
`ImprovementResponseTest` (12), plus additions to
`SuggestionFormatterTest`, `TriggerDetectorTest`,
`CommentCommandServiceTest` and `WebhookControllerTest`. Full suite:
**2112 tests, 0 failures, 0 errors**.

Every behaviour was validated by neutralizing only the production change
— never the test — and confirming a real assertion failure.

### Round 1 — the command itself

**1. Command detection** — removed the `IMPROVE` entry from
`TriggerDetector.buildPatterns()`:

```
TriggerDetectorTest.shouldDetectEachSlashCommand:62 expected: IMPROVE but was: NONE
TriggerDetectorTest.shouldDetectEachMentionCommand:76 expected: IMPROVE but was: NONE
TriggerDetectorTest.shouldDetectImproveOutsideAQuotedMention:168 expected: IMPROVE but was: NONE
```

**2. Command routing** — removed the `IMPROVE` switch arm and the
help-table row from `CommentCommandService`:

```
CommentCommandServiceTest.improveDelegatesToImprovementServiceWhenAuthorized
Wanted but not invoked:
improvementService.handle(
    ImproveTask[owner=owner, repo=repo, prNumber=7, defaultBranch=main, installationId=12345],
    "token"
);
Actually, there were zero interactions with this mock.
```

**3. Generation / committable suggestions** — made
`PrImprovementService.postInline` return `false` unconditionally:

```
PrImprovementServiceTest.postsCommittableSuggestionForAnImprovement
Wanted but not invoked:
reviewClient.createPullRequestComment(any, any, "owner", "repo", 7,
    Capturing argument: CreatePullRequestCommentRequest);
Actually, there were zero interactions with this mock.
```
(also red: `anchorsAMultiLineImprovementAcrossItsWholeRange`,
`capsThePerRunCommentCount`)

### Round 2 — the indentation fix

Reverting the comparison to the original `.strip()` form, keeping the
test:

```
PrImprovementServiceTest.doesNotRewriteALineWhoseQuoteDropsTheLeadingIndentation
org.mockito.exceptions.verification.NeverWantedButInvoked:

reviewClient.createPullRequestComment(
    any,
    any,
    any,
    any,
    any integer,
    any
);
Never wanted here:
```

That is the defect itself: with `.strip()` the mis-indented rewrite
**was** posted as a one-click commit. Green once the fix is restored.
`anchorsAQuoteThatReproducesTheLeadingIndentation` and
`anchorsDespiteInsignificantTrailingWhitespaceInTheDiff` cover the other
side, so the stricter rule cannot silently swallow correct suggestions.

### Round 3 — coverage

`codecov/patch` was red at 85.33% with 33 uncovered lines, all of them
fail-soft and degradation paths — the ones that matter most for work
running off the webhook ACK thread. Now covered: malformed and empty
model responses, `isPostable()` for every missing field, a PR with a
blank or absent head ref, a formatter yielding no reviewable file list,
a blank or absent rendered diff, a multi-line replacement whose range
cannot be resolved, a reported line that only snaps to a neighbour, the
per-run cap swallowing every improvement, and a summary post that
throws. Each new test was proven red first, e.g. with `isPostable()`
stubbed to `return true`:

```
ImprovementResponseTest.anImprovementMissingRequiredDataIsNotPostable:76 null file ==> expected: false but was: true
ImprovementResponseTest.anImprovementMissingRequiredDataIsNotPostable:76 blank file ==> expected: false but was: true
ImprovementResponseTest.anImprovementMissingRequiredDataIsNotPostable:76 zero line ==> expected: false but was: true
```

### Round 4 — token-budgeted batching, mutation-tested per test

Each batching test was mutation-tested individually: one minimal change
to production code, `./mvnw -B test -Dtest=PrImprovementServiceTest`,
then restore. Verbatim failures:

**`coversFilesThatTheLineCapWouldHaveDroppedEntirely`** — sent
`inputs.diff()` (the line-capped string) instead of the batch text:

```
org.opentest4j.AssertionFailedError:
[[THRILLHOUSEBOT-UNTRUSTED-DATA-3cc17bcc8c94fe51adb1bbe6ec3fcee4]]
## Overview: 2 files (+7 -0)


(diff truncated at 4 lines — 2 files omitted)

[[THRILLHOUSEBOT-UNTRUSTED-DATA-3cc17bcc8c94fe51adb1bbe6ec3fcee4]] ==> expected: <true> but was: <false>
```

That is the whole case for this change: under the old design the model
was handed a truncation notice and **zero** file content. The same
mutation also reddened
`sendsOneUncappedBatchWhenTokenBudgetingIsDisabled` and
`leavesFilesTheRepositoryAskedTheBotToIgnoreOutOfScope`.

**`splitsAnOversizedChangeSetAcrossBatchesAndDedupesTheResults`** —
dropped the `seen.add(dedupeKey(improvement))` guard:

```
org.mockito.exceptions.verification.TooManyActualInvocations:
reviewClient.createPullRequestComment(<any>, <any>, <any>, <any>, <any integer>, <any>);
Wanted 1 time:
But was 2 times:
-> at PrImprovementService.postInline(PrImprovementService.java:397)
-> at PrImprovementService.postInline(PrImprovementService.java:397)
```

**`neverSpendsMoreModelCallsThanMaxAiCalls`** — `maxBatches()` returning
`Integer.MAX_VALUE`:

```
org.mockito.exceptions.verification.TooManyActualInvocations:
improveAssistant.improve(<any>, <any>, <any>, <any>);
Wanted 1 time:
But was 2 times:
```

The same mutation also reddened
`namesTheFilesLeftUncoveredWhenTheBatchBudgetRunsOut` — with unbounded
batches the summary says "partially analyzed" instead of naming the
files no batch ever reached.

**`disclosesFilesTheTokenBudgetCouldNotCoverByName`** — disclosure
passed only a count, dropping the `TruncationDetail`:

```
org.opentest4j.AssertionFailedError:
✨ ThrillhouseBot has no improvements to suggest for the changes in this PR.

> ⚠️ **Large PR — partial coverage.** 2 file(s) were omitted because the diff exceeded the size budget, so this covers only part of the diff. ==> expected: <true> but was: <false>
```

**`sendsOneUncappedBatchWhenTokenBudgetingIsDisabled`** — the
budgeting-off path planning with a 1-token diff budget instead of an
uncapped one:

```
Wanted but not invoked:
improveAssistant.improve(<Capturing argument: String>, <any>, <any>, <any>);
Actually, there were zero interactions with this mock.
```

**`keepsImprovementsFromTheBatchesThatSucceededWhenOneBatchFails`** —
`if (failed == plan.batches().size())` weakened to `if (failed > 0)`:

```
Wanted but not invoked:
reviewClient.createPullRequestComment(<any>, <any>, <any>, <any>, <any integer>, <any>);
Actually, there were zero interactions with this mock.
```

**`leavesFilesTheRepositoryAskedTheBotToIgnoreOutOfScope`** —
`respectPerRepoIgnores` short-circuited to the global list:

```
org.opentest4j.AssertionFailedError:
[[THRILLHOUSEBOT-UNTRUSTED-DATA-293487720f3452d4b6d88f0c5795f471]]
## Overview: 2 files (+7 -0)

### src/Foo.java (modified, +4 -0)
...
### src/Other.java (modified, +3 -0)
...
==> expected: <false> but was: <true>
```


**`doesNotDiscloseTruncationForTheLineCapWhenTheBudgetCoveredEverything`**
— re-appended the old
`ReviewResult.truncationDisclosure(inputs.omittedFiles())`:

```
org.opentest4j.AssertionFailedError:
## ✨ ThrillhouseBot — suggested improvements

Proposed **1** committable improvement(s) inline on the changed lines.

---
*Nothing was committed — review each suggestion and commit the ones you want. Re-run with `/improve`.*

> ⚠️ **Large PR — partial coverage.** 48 file(s) were omitted because the diff exceeded the size budget, so this covers only part of the diff. ==> expected: <false> but was: <true>
```

That is the false partial-coverage claim the test exists to forbid: 48
files the *line cap* dropped, against a plan that batched every file
within budget.

**`toleratesAFormatterThatYieldsNoReviewableFileList`** — removed the
empty-plan early return:

```
org.opentest4j.AssertionFailedError: expected: <✨ ThrillhouseBot found no reviewable changes to improve in this PR.> but was: <✨ ThrillhouseBot could not generate improvements for this PR. Please try `/improve` again.>
```

**One mutation survived, and it should have.** Narrowing the
`activeModel.maxInputTokens() <= 0` guard in `planBatches` to `< 0`
leaves the whole class green. That is correct rather than a hole: with
budgeting off, `perCallInputBudget()` already returns
`Integer.MAX_VALUE`, so the general path also produces one uncapped
batch. The branch exists to skip the BPE pass entirely and to mark the
plan `budgeted=false`, mirroring the identical guard in
`DiffBudgetPlanner.plan(reviewable, PromptInputs)`; it is kept for
parity with the review path, not as behaviour the tests need to pin. The
*behaviour* it protects is pinned — the same test dies under both the
`plan(reviewable, 1, 1)` mutation above and the line-capped-string
mutation.

Batch budgets in these tests are derived from the **real** prompt
overhead via `TokenCounter` rather than hardcoded, so editing a prompt
cannot silently turn them into no-ops by making every file overflow.

Two earlier tests changed meaning rather than being deleted, since the
design change invalidated their premise: the old "disclosure comes from
the line cap" test is now
`doesNotDiscloseTruncationForTheLineCapWhenTheBudgetCoveredEverything` —
a formatter reporting 48 line-omitted files against a plan that covered
every file must produce **no** warning, since a false partial-coverage
claim is its own defect. And a formatter yielding no reviewable files
now reports "no reviewable changes" instead of falling through to the
model, because there is nothing to plan batches over.

### Round 5 — cross-batch anchoring and ignore scope

Two gaps the batching commit left untested, closed here.

**`anchorsAnImprovementFromALaterBatchToItsAbsoluteLine`** — a
three-file PR, one file per batch, where the only improvement comes out
of the *last* batch and names a file whose hunk starts at line 120.
Committing a rewrite to the wrong line is the exact failure the stricter
anchoring exists to prevent, so it needs a multi-batch fixture rather
than an argument. The per-call budget is computed from the real rendered
sections (`oneFilePerBatchBudget`), so the split is deterministic and
nothing is clipped. Building the line map from the first batch instead
of the whole PR:

```
PrImprovementServiceTest.anchorsAnImprovementFromALaterBatchToItsAbsoluteLine
Wanted but not invoked:
reviewClient.createPullRequestComment(<any>, <any>, "owner", "repo", 7,
    <Capturing argument: CreatePullRequestCommentRequest>);
Actually, there were zero interactions with this mock.
```

(The same mutation also reddens
`coversFilesThatTheLineCapWouldHaveDroppedEntirely`, which anchors onto
the second file of a single batch.)

**`neverCommitsASuggestionToAFileTheRepositoryAskedTheBotToIgnore`** —
this one found a real, if narrow, hole. The batches were planned over
the ignore-filtered list, but `DiffLineResolver` was still built from
`inputs.reviewableFiles()`, the pre-per-repo-ignore list. A model naming
an ignored file therefore still resolved against it and got a
**committable** suggestion posted onto code the repository asked the bot
to leave alone. Fixed by computing the effective list once in
`handle(...)` and threading it into both the planner and the resolver —
the compute-once discipline `ReviewContextLoader` already uses. With the
resolver reverted to `inputs.reviewableFiles()`:

````
PrImprovementServiceTest.neverCommitsASuggestionToAFileTheRepositoryAskedTheBotToIgnore
org.mockito.exceptions.verification.NeverWantedButInvoked:

reviewClient.createPullRequestComment(<any>, <any>, <any>, <any>, <any integer>, <any>);
Never wanted here:
But invoked here:
-> at PrImprovementService.postInline(PrImprovementService.java:405) with arguments:
   [..., CreatePullRequestCommentRequest[commitId=headsha1234567, body=**✨ Improvement — Bound the retry loop** `error-handling`
   ...
   ```suggestion
   while (retries++ < 3) { call(); }
   ```
   , path=src/Other.java, line=2, side=RIGHT, startLine=null, startSide=null]]
````

`src/Other.java` is the file the repository asked the bot to ignore, and
that is a one-click commit against it.

### Coverage

Local JaCoCo on `PrImprovementService`: **every line covered**, and
exactly one partial branch out of 78 — the `current != null` arm of
`quotesCurrentLine`. It is the same guard Codecov has been flagging
since Round 3; it only moved down the file as batching was added (line
309 → 445), so it is not a new gap. Nothing in the batching or anchoring
code is uncovered. It is unreachable by construction: `postInline` only
calls it after `resolveRightSideLine` returned the exact line, and
`DiffLineResolver.appendRightSide` does `lineText.put(...)`
unconditionally for every line it adds to `lines`. It is kept as
fail-closed defence against a future divergence rather than deleted to
buy a percentage point. `DiffBudgetPlanner` is 100% line and 100%
branch.

### ThrillhouseBot's own findings on this PR

Dogfooding, so all three are recorded with a verdict.

**🟡 MEDIUM — "Lenient whitespace comparison may allow un-anchorable
suggestions" (`quotesCurrentLine`): conclusion right, reasoning wrong.
Fixed, and hardened beyond the proposal.**

The stated mechanism is not correct: GitHub does not validate
`suggestion_old` at all. Committing a suggestion replaces the anchored
line range with the block body verbatim, so it cannot "fail to apply" —
`suggestion_old` is purely this codebase's own anchor.

The conclusion is right for a different reason, and it is a real defect:
`DiffLineResolver` stores right-side text as `rawLine.substring(1)`, so
the diff marker is gone but **the indentation is preserved**. Comparing
with `.strip()` therefore matched an un-indented quote, and the
committable suggestion we posted carried the model's un-indented
replacement — committing it reflowed the line.

The suggested patch kept `current == null ||`, i.e. fail **open**. Under
a verbatim-commit contract "cannot verify this line" must mean "do not
rewrite it", so the fix inverts that too:

```java
return current != null
    && current.stripTrailing().equals(improvement.suggestionOld().stripTrailing());
```

**🔵 LOW — "Missing newline after opening code fence in
formatImprovementBlock": wrong. Patch not applied; it would have
introduced the defect it describes.**

`CODE_FENCE_CLOSE` was `"\n```\n"` — it already carries a newline on
both sides, and is used as the opening delimiter as well as the closing
one. Applying the suggested patch verbatim and running the byte-exact
rendering test (newlines shown escaped, since the values contain fences
themselves):

````
SuggestionFormatterTest.shouldRenderImprovementBlockFencesByteExactly
expected: **T**\n\n```\nline one\nline two\n```\n
but was:  **T**\n\n```\n\nline one\nline two\n\n```\n
````

— a blank line as the first *and* last line inside every code block, in
`/add-docs` notes as well as `/improve` blocks.

The finding reasoned from the constant's **name** rather than its value,
and the name was genuinely misleading, so the underlying issue is fixed
instead: `CODE_FENCE_CLOSE` is renamed to `CODE_FENCE` with the value
and rendered output unchanged, and both renderings are now locked by
byte-exact assertions.

**🔵 LOW — "Verify that file list retrieval paginates to avoid silent
truncation" (`SoftLoaders.files`): false positive — but a well-formed
one.**

The concern was that `SoftLoaders.files` might take GitHub's 30-per-page
default and silently drop everything beyond the first page, which under
batching would matter more than before. It does not: `SoftLoaders.files`
calls `GitHubPullRequestClient.getPullRequestFiles`, which walks pages
of `FILES_PER_PAGE = 100` up to `MAX_FILE_PAGES = 30` — 3000 files,
which is GitHub's own cap on the files endpoint. No change made.

Worth recording how this one was raised, because it is the shape a
low-confidence finding *should* have: the code it flagged is outside
this diff, so it hedged to LOW, said explicitly that the file was "not
shown in this diff", asked for verification rather than asserting a bug,
and was routed to **"Things to double-check"** in the summary instead of
an inline thread. That is the correct handling for a hypothesis about
unseen code — the check cost a minute and the alternative (staying
silent about a plausible whole-PR-coverage bug) would have been worse.

### Other

Quoted-input safety per `.github/thrillhousebot.md`: `/improve` inside
fenced code blocks, blockquotes and inline code is asserted **not** to
trigger the command, while a genuine `/improve` alongside a quoted one
still fires.

All four SonarCloud issues raised against this PR are resolved (S5976 —
three structurally identical tests merged into one parameterized test;
two S6126 — text blocks, each verified byte-identical by comparing the
resulting `String` values, not by the tests still passing; S135 — the
loop's `continue` removed by inverting the guard).

Local gates: `./mvnw -B spotless:apply` clean, `./mvnw -B clean compile
spotbugs:check spotless:check` reports `BugInstance size is 0`, `./mvnw
-B clean test` reports **Tests run: 2112, Failures: 0, Errors: 0,
Skipped: 0**.

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

The prompt the model actually received under the old single-call design,
on a two-file change set with the line cap set to 4 — captured verbatim
from the assertion failure when the batching change is reverted:

```
[[THRILLHOUSEBOT-UNTRUSTED-DATA-3cc17bcc8c94fe51adb1bbe6ec3fcee4]]
## Overview: 2 files (+7 -0)


(diff truncated at 4 lines — 2 files omitted)

[[THRILLHOUSEBOT-UNTRUSTED-DATA-3cc17bcc8c94fe51adb1bbe6ec3fcee4]]
```

## Additional Notes

`PrDescriptionGeneratorTest` and `ChangelogEntryGeneratorTest` each
needed a two-line stub update: `loadInputs` now calls the two-argument
`buildDiffStringWithStats(files, reviewableFiles)` overload (the
one-argument form computed the reviewable list internally and threw it
away), so the mocked formatter stubs had to match the new arity. No
assertions changed.

Known follow-ups, deliberately out of scope:

- **#457** — see [The seam #457 builds on](#the-seam-457-builds-on)
above for the two methods to lift and what is already shared.
- The multi-line anchoring path still matches on stripped lines via
`DiffLineResolver.resolveSuggestionRange`, which is shared with the
review/finding path. Applying the same indentation strictness there
would change behaviour for other commands and belongs in its own change.
- An improvement naming a repo-ignored file still renders as a
copy-paste block in the summary (it just cannot be committed with one
click). The block contains only model-authored text and the path it
invented, never content from the ignored file, so this is the same
treatment any unanchorable suggestion gets.
## What type of PR is this?

- [ ] 🐛 Bug fix
- [x] ✨ Feature
- [x] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [x] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

Adds `/generate-tests`, an on-request command that asks the model to
propose unit tests
for the code the PR changed — a way to close coverage gaps surfaced
during review without
leaving the PR.

**How a proposal is presented.** A generated test is normally a whole
new file. GitHub's
committable `suggestion` block replaces an anchored line range on an
inline review comment,
so a new file has nothing to anchor to; forcing one in would produce a
broken commit when
applied. Each proposed file is therefore rendered through
`SuggestionFormatter` as a
copy-paste block headed by the exact repository path it belongs at — the
same
"show the draft, don't commit it" shape `/add-docs` already falls back
to when a
declaration can't be pinned to a hunk. Nothing is committed and no file
is edited.

Everything in that comment is model output, so the rendering is hardened
against a
prompt-injected diff: the fence is widened past the longest backtick run
in the test
source, the language tag is dropped unless it looks like a language tag,
and every
model-supplied prose field — the path, the "covers" note, and the
trailing "not covered"
notes — is flattened to a single line through one shared rule (with the
path's backticks
removed), so none of them can break out of the structure around it.

**Gating and failure behaviour.** The handler runs
`ManualReviewAuthorizer` then
`PrPauseService`, in the same order as the other on-request commands,
behind the
`REVIEW_GENERATE_TESTS_ENABLED` flag. Every load fails soft: no diff, an
assistant error,
or an unparseable reply all degrade to posting nothing rather than a
noisy error on the PR.
When the model judges nothing testable, the bot says so instead of
staying silent — the
maintainer asked explicitly. At most 5 files are rendered per comment,
with a line naming
how many were held back. When the diff was over budget the comment
carries the shared
partial-coverage disclosure, on the "nothing to test" outcome too, so
that verdict can
never read as a verdict on the whole PR.

Files:

- `review/UnitTestGenerator.java` — loads the diff/PR
context/instructions/project stack,
calls the assistant, renders the comment. Extends
`AbstractPrSuggestionGenerator` and
does not touch its diff loading, so it inherits token-budgeted batching
when that lands.
- `review/ai/UnitTestAssistant.java`, `UnitTestAssistantPrompts.java` —
the LangChain4j
service and its prompts; the diff, PR body, stack and repo instructions
are escaped and
  framed as untrusted data.
- `review/ai/UnitTestGenerationParser.java`,
`UnitTestGenerationResponse.java` — JSON
  parsing, null-entry tolerance, and the postable-proposal filter.
- `review/SuggestionFormatter.java` — `formatGeneratedTestFile(...)`
plus the fence,
  language-tag and single-line hardening.
- `webhook/CommentCommand.java`, `TriggerDetector.java`,
`CommentCommandService.java` —
the new command, its slash and mention patterns, the handler and the
`/help` row.
- `config/ThrillhouseConfig.java`, `application.properties`,
`.env.example`, `README.md`,
`docs/ARCHITECTURE.md`, `website/src/content/docs/index.md` — the flag
and its docs.

The flag defaults to `true`, matching `REVIEW_ADD_DOCS_ENABLED`: the
command never runs
automatically, only when a write-access holder asks for it, so the flag
is the operator's
kill switch for the AI budget rather than an opt-in.

## Related Issues

Fixes #36

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

`./mvnw -B clean test spotless:check spotbugs:check` — 2196 tests green,
`BugInstance size is 0`, spotless clean. JaCoCo reports 100% line
**and** branch coverage
on all four new classes and on `SuggestionFormatter`, `TriggerDetector`
and
`CommentCommandService`.

`cd website && npm ci && npm run build` (the `docs.yml` build job) — 66
pages,
"All internal links are valid". The rendered `/commands/`,
`/configuration/` and index
pages all carry the new command and key.

Every new test was mutation-proven: the production code was neutralized
one behaviour at
a time and the test had to fail. Verbatim failures below.

### Command routing and quoted-input safety

| Mutation (production code) | Verbatim failure |
|---|---|
| `patterns.put(CommentCommand.GENERATE_TESTS, ...)` removed |
`TriggerDetectorTest.shouldDetectEachSlashCommand:62 expected:
<GENERATE_TESTS> but was: <NONE>` and `shouldDetectEachMentionCommand:76
expected: <GENERATE_TESTS> but was: <NONE>` |
| `FENCED_CODE` stripping disabled |
`shouldNotDetectGenerateTestsInsideQuotedContext:161 expected: <NONE>
but was: <GENERATE_TESTS>` |
| `~~~` dropped from `FENCED_CODE` |
`shouldNotDetectGenerateTestsInsideQuotedContext:172 expected: <NONE>
but was: <GENERATE_TESTS>` |
| `BLOCKQUOTE_LINE` stripping disabled |
`shouldNotDetectGenerateTestsInsideQuotedContext:170 expected: <NONE>
but was: <GENERATE_TESTS>` |
| `INLINE_CODE` stripping disabled |
`shouldNotDetectGenerateTestsInsideQuotedContext:166 expected: <NONE>
but was: <GENERATE_TESTS>` |
| whole comment discarded whenever it contains any quoted context |
`shouldStillDetectGenerateTestsAlongsideAQuotedOne:184 expected:
<GENERATE_TESTS> but was: <NONE>` |
| `GENERATE_TESTS` excluded from the webhook's command routing |
`WebhookControllerTest.shouldRouteGenerateTestsCommandToCommandService:880
Wanted but not invoked: commentCommandService.handle(...) Actually,
there were zero interactions with this mock.` |

The inline-code assertion originally in this PR (`` run
`/generate-tests` to propose ``)
**survived** the inline-code mutation: an unpadded span already fails
the slash pattern's
whitespace boundary, so it never exercised the stripping. It was
replaced with a padded
span and the mention form, both of which do depend on it — the row above
is the failure
from the hardened version.

### Gating

| Mutation | Verbatim failure |
|---|---|
| `generateTestsEnabled()` gate removed |
`CommentCommandServiceTest.generateTestsIgnoredWhenDisabled:376 No
interactions wanted here ... But found these interactions on mock
'authorizer'` |
| `authorized(ctx)` gate removed |
`generateTestsIgnoredWhenUnauthorized:364 No interactions wanted here
... But found these interactions on mock 'testGenerator'` |
| `prPauseService.isPaused(...)` gate removed |
`generateTestsPostsPausedNoticeWhenPaused:354 Wanted but not invoked:
commentClient.createComment(...) Actually, there were zero interactions
with this mock.` |
| `case GENERATE_TESTS ->` removed from the switch |
`generateTestsPostsTheGeneratedSuggestion:333 Wanted but not invoked:
commentClient.createComment(...)` |
| `suggestion == null` guard removed |
`generateTestsPostsNothingWhenGeneratorReturnsNull:344 ... But invoked
here: ... CreateCommentRequest[body=null]` |
| `/generate-tests` row removed from `HELP_TEXT` |
`helpListsTheGenerateTestsCommand:384 expected: <true> but was: <false>`
|

### Generation flow

| Mutation | Verbatim failure |
|---|---|
| header dropped from the rendered comment |
`UnitTestGeneratorTest.rendersEachProposedTestFileAsACopyPasteBlock:113
... ==> expected: <true> but was: <false>` |
| `MAX_TEST_FILES` cap removed | `capsTheNumberOfRenderedTestFiles:180
... expected: <true> but was: <false>` (Foo5/Foo6 rendered) |
| "nothing warrants a test" message suppressed |
`reportsThatNothingWarrantsATestInsteadOfStayingSilent:194 expected:
<true> but was: <false>` |
| model's coverage notes dropped |
`reportsThatNothingWarrantsATestInsteadOfStayingSilent:195 ... expected:
<true> but was: <false>` |
| partial-coverage disclosure not appended |
`appendsPartialCoverageDisclosureWhenTheDiffWasTruncated:225 expected:
<\n\n> ⚠️ **Large PR — partial coverage.** 48 file(s) were omitted ...>
but was: <>` and
`disclosesPartialCoverageEvenWhenNoTestsWereProposed:243 expected:
<true> but was: <false>` |
| disclosure appended unconditionally |
`appendsNoDisclosureWhenNothingWasOmitted:255 ... expected: <true> but
was: <false>` |
| no-diff path returns `""` instead of `null` |
`returnsNullWhenThereIsNoDiff:263 expected: <null> but was: <>` |
| unparseable-reply path returns `""` instead of `null` |
`returnsNullWhenTheResponseIsNotUsableJson:284 expected: <null> but was:
<>` |
| assistant failure rethrown instead of degrading |
`returnsNullWhenTheAssistantThrows:274->generate:101 » Runtime model
down` |
| PR-details load no longer fails soft |
`stillGeneratesWhenPrDetailsFetchFails:329->generate:101 » Runtime 404`
|
| project-stack load no longer fails soft |
`stillGeneratesWhenTheProjectStackCannotBeResolved:316->generate:101 »
Runtime github down` |
| diff escaped instead of fenced |
`fencesTheDiffAndPassesTheProjectStackToTheAssistant:302 expected:
<true> but was: <false>` |
| project stack not passed to the assistant |
`fencesTheDiffAndPassesTheProjectStackToTheAssistant:305 expected:
<pom.xml: junit> but was: <>` |
| `{{projectStack}}` removed from the user prompt |
`AiServicePromptRenderingTest.unitTestPromptIncludesEveryContextVariable:133
projectStack missing ==> expected: <true> but was: <false>` |
| `@UserMessage` moved from the method to a parameter |
`AiServiceUserMessagePlacementTest.unitTestAssistantPutsUserMessageOnTheMethod:51
UnitTestAssistant.generate must declare @Usermessage on the method so
the template is rendered ==> expected: <true> but was: <false>` |

### Rendering and parsing

| Mutation | Verbatim failure |
|---|---|
| path heading dropped |
`SuggestionFormatterTest.shouldFormatGeneratedTestFileAsAPathHeadedCodeBlock:187
... expected: <true> but was: <false>` |
| path not flattened / backticks kept |
`shouldKeepAModelSuppliedPathInsideItsHeadingCodeSpan:225 ... expected:
<true> but was: <false>` (the injected `## Injected` heading escaped the
code span) |
| `covers` note not flattened | `shouldFlattenAMultiLineCoversNote:234
... expected: <true> but was: <false>` |
| `covers` line dropped entirely |
`shouldFormatGeneratedTestFileAsAPathHeadedCodeBlock:188 ... expected:
<true> but was: <false>` |
| null path rendered literally |
`shouldTolerateAMissingPathCoversAndCode:214 ### \`null\` ... expected:
<true> but was: <false>` |
| null code rendered literally |
`shouldTolerateAMissingPathCoversAndCode:215 ... expected: <false> but
was: <true>` |
| fence never widened past backtick runs |
`shouldWidenTheFencePastBacktickRunsInTheTestSource:198 ... expected:
<true> but was: <false>` and
`UnitTestGeneratorTest.widensTheFenceWhenTheTestSourceContainsAFencedBlock:137
... expected: <true> but was: <false>` |
| language tag not validated | `shouldOmitAnUnusableLanguageTag:206 ...
expected: <true> but was: <false>` and
`UnitTestGeneratorTest.dropsAModelSuppliedLanguageThatIsNotALanguageTag:155
... expected: <true> but was: <false>` (the injected heading landed on
the fence line) |
| fenced-JSON unwrapping removed |
`UnitTestGenerationParserTest.unwrapsAFencedJsonReply:50 »
IllegalArgument Model response is not valid generate-tests JSON` |
| JSON fields mis-bound (path/code swapped, covers+language nulled) |
`parsesTheProposedTestFiles:39 expected: <src/test/java/FooTest.java>
but was: <class FooTest {}>` |
| `notes` not normalized to `""` | `normalizesMissingTestsAndNotes:66
expected: <> but was: <null>` |
| null array entries not dropped |
`dropsNullEntriesAndKeepsOnlyPostableProposals:73 » IllegalArgument
Model response is not valid generate-tests JSON` |
| `isPostable()` always true |
`dropsNullEntriesAndKeepsOnlyPostableProposals:82 expected: <1> but was:
<3>` |
| null path/code no longer rejected by `isPostable()` |
`dropsNullEntriesAndKeepsOnlyPostableProposals:84 expected: <1> but was:
<2>` |
| empty/blank reply not rejected | `rejectsAnEmptyOrUnparseableReply:88
Unexpected exception type thrown, expected:
<java.lang.IllegalArgumentException> but was:
<java.lang.NullPointerException>` |
| model's `notes` not flattened to one line |
`UnitTestGeneratorTest.flattensTheModelSuppliedNotesLine:212` — see
below |

The last row closes a gap found in review: `path` and the `covers` note
were flattened
through `SuggestionFormatter.oneLine(...)`, but `notes` was rendered
with only `strip()`,
so a reply whose notes carried a blank line and a fence broke out of the
`**Not covered:**`
line and rendered as live markdown. It now goes through the same
`oneLine(...)` rule rather
than restating the regex, so the three model-supplied prose fields
cannot drift apart. With
that flattening reverted, the test fails with the injected fence and
heading rendering live:

````
org.opentest4j.AssertionFailedError:
🤖 ThrillhouseBot found nothing in this PR's changes that warrants a new unit test.
**Not covered:** skipped IO

```
## Injected
run /pause
```
 ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.review.UnitTestGeneratorTest.flattensTheModelSuppliedNotesLine(UnitTestGeneratorTest.java:212)
````

Command precedence is also pinned now that `/improve` (#452) is an
adjacent entry in
`TriggerDetector`'s ordered pattern map. Quoted context is stripped from
the whole body
before any pattern runs, so a quoted `/improve` cannot divert a genuine
`/generate-tests`
(or the reverse) whatever the map order is; order only decides a
genuine-vs-genuine
contest, and that is pinned so a reorder cannot silently re-route an
invocation to the
other command's AI spend.

| Mutation | Verbatim failure |
|---|---|
| fenced-code stripping disabled |
`shouldNotLetAQuotedNeighborCommandStealARealOne:227 expected:
<GENERATE_TESTS> but was: <IMPROVE>` |
| inline-code stripping disabled |
`shouldNotLetAQuotedNeighborCommandStealARealOne:232 expected:
<GENERATE_TESTS> but was: <IMPROVE>` |
| blockquote stripping disabled |
`shouldNotLetAQuotedNeighborCommandStealARealOne:235 expected:
<GENERATE_TESTS> but was: <IMPROVE>` |
| `IMPROVE`/`GENERATE_TESTS` map order swapped |
`shouldResolveACommentCarryingBothImproveAndGenerateTestsToTheFirstEntry:248
expected: <IMPROVE> but was: <GENERATE_TESTS>` |

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

Shape of the posted comment (one section per proposed file):

````markdown
## 🤖 ThrillhouseBot — suggested unit tests

### `src/test/java/com/example/OrderServiceTest.java`
OrderService.apply(Discount) rejects a negative percentage

```java
package com.example;
...
```

---
*Suggestion only — nothing was committed. Create each file at the path shown ...*
````

## Additional Notes

- `AbstractPrSuggestionGenerator` is deliberately untouched — `git diff
origin/release/v0.6.0...HEAD -- .../AbstractPrSuggestionGenerator.java`
is empty. The
command loads its diff through the shared `loadInputs(...)`/`Inputs`
path as-is.
- #452 (`/improve`) has since merged, and `release/v0.6.0` is merged
into this branch in
`ad07735`. Both commands register a new comment command, so the enum,
the ordered pattern
map, the command switch, the `/help` table, the config key and every
README/`.env`/docs
listing collided additively; both sides are kept, with `/generate-tests`
ordered after
`/improve` everywhere. No behaviour of this command changed in the
merge; the only
adjustment was to a test, because `loadInputs(...)` now resolves the
reviewable file list
before rendering the diff, so `UnitTestGeneratorTest` stubs the
two-argument
  `ReviewDiffFormatter.buildDiffStringWithStats(files, reviewable)`.
- **Pending #463.** This command still calls `inputs.omittedFiles()` at
`UnitTestGenerator.java:133` for its partial-coverage disclosure. #463
lifts the batching
seam into `AbstractPrSuggestionGenerator` and removes that field; once
it merges this
command adopts the seam — `disclosure(plan)`, `planBatches(...)` with
this command's own
prompts, per-batch text rather than the whole diff, and the
per-repo-ignore-filtered file
list as the authoritative one downstream. That call site is a known,
tracked follow-up
rather than an oversight; `PrDescriptionGenerator` and
`ChangelogEntryGenerator` read the
  same field and migrate with it.
- Known limitation, shared with the other on-request commands: the
comment body is not
length-capped against GitHub's 65,536-character limit. Five whole test
files could in
principle exceed it; the request then fails soft (logged, nothing
posted). Worth a
follow-up issue that caps all of the generated comments, rather than
solving it for one
  command here.
…463)

## What type of PR is this?

- [ ] 🐛 Bug fix
- [x] ✨ Feature
- [x] 📝 Documentation
- [x] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

`max-diff-lines` predates token budgeting and had become a second,
cruder ceiling sitting in front of it. The review path stopped using it
when map-reduce (#53) landed, and `/improve` was moved onto that in #316
— but `/describe` and `/changelog` still shrank a large PR to the first
`max-diff-lines` of its rendered diff. A description was therefore
written from a partial diff, and a CHANGELOG entry drafted from one,
with whole files never reaching the model at all.

**The seam.** The three methods `/improve` proved — batch planning, the
shared prompt overhead, and the per-repo ignore re-filter — are lifted
into `AbstractPrSuggestionGenerator`, parameterised by each command's
own prompt constants. `/improve` is refactored onto the lifted versions
rather than keeping a private copy. Every on-request suggestion command
now plans batches over the reviewable **file list** under the per-call
token budget and makes one call per batch. One call:

```java
var plan = planBatches(reviewable, inputs, ownSystemPrompt, ownUserPrompt, reservedCalls);
```

**The reduce step is per-command, because the reductions genuinely
differ.** Batching is only the map step:

| Command | Reduce | Extra AI call? |
|---|---|---|
| `/describe` | Per-batch partial descriptions **synthesized** into one
coherent title + description | Yes — reserved, spent only when >1 batch
|
| `/changelog` | Per-batch candidate entries **merged** into one entry |
Yes — reserved, spent only when >1 candidate |
| `/improve` | Local union of per-batch suggestions, deduped by
`file:line` | No |
| `/generate-tests` | Local union of per-batch test files, deduped by
path | No |

Nothing is concatenated. Stapling `/describe`'s partials together
repeats the overview once per part and reads as several pull requests;
for `/changelog`, a deterministic merge could unify headings and drop
identical bullets, but the duplicates that actually arise are *not*
identical — two batches that saw different files of one feature describe
that change in two different sentences, which only a reader that
understands them can collapse. Both reduce calls are reserved out of
`max-ai-calls` up front, the same way the review path reserves one for
its summary, so a run never exceeds the ceiling of one review; a
single-batch PR still costs exactly one call.

**Also in this change**

- The shared overhead is assembled from each command's **own** prompts.
Sizing a batch against another command's prompts would let an
"in-budget" batch overshoot the real input limit.
- Coverage disclosure now comes from `BudgetPlan.omittedFiles()` /
`clippedFiles()` — files **named**, not counted — keeping #296's
wording. `Inputs.omittedFiles` (the line-cap count) is gone, so nothing
can reach for the wrong number.
- Coverage on a huge PR is bounded by `max-ai-calls`, not the file list.
Files that never got a batch are named. When *no* file fit any batch at
all, every command says so and names the files rather than going silent
— a misconfigured budget must not look like a bot that ignored the
command. An empty plan that omitted *nothing*, because the repository
ignores every changed file, is the opposite case and posts nothing.
- Per-repo ignore patterns (#449) are applied on top of the global set
for every command, and the filtered list stays authoritative for
everything downstream — batches *and* the line map alike. That is the
bug #452's audit found; the lifted method carries the invariant in its
javadoc.
- `max-input-tokens <= 0` keeps budgeting off as a single uncapped batch
rather than regressing to the line-capped string.
- A batch whose model call fails is skipped rather than failing the run,
and the shortfall is disclosed.
- Sizing callers reach the prompts through `systemPrompt()` /
`userPrompt()` accessors: a reference to a `static final String` is
inlined into the caller's class file at compile time, and a third copy
of a multi-kilobyte prompt trips SpotBugs'
`HSC_HUGE_SHARED_STRING_CONSTANT`. The accessors are deliberately *not*
named `system()` / `user()` — differing from the constant only by
capitalization reads as a typo at the call site.

**`/add-docs` is deliberately out of scope.** It does not extend
`AbstractPrSuggestionGenerator`; it orders its loading around a hard
head-SHA precondition (every output is an inline suggestion, so no head
SHA means nothing postable and a distinct user-facing message), and it
feeds its assistant a different input set — project stack, a combined
`PromptSections.prContext(...)` block, and a pre-rendered instructions
section built from `ResolvedInstructions` rather than the content string
the shared `Inputs` carries. Folding it in therefore means changing the
shared `Inputs` contract at the same time as first lifting the seam, on
the command that posts committable edits. It is worth doing and should
be tracked separately; `/add-docs` remains line-capped and the README
now says so precisely. Note it also still has **no per-repo ignore
filter at all**, which is worth carrying into that follow-up.

**Path-scoped instructions (#460) are not part of what a command batch
carries.** `PathScopedInstructions` is resolved only by
`ReviewContextLoader` and rendered only into `ReviewPromptAssembler`'s
trailing-guidance slot, so it reaches the review prompt and nothing
else. The `repoInstructions` slot of the batched commands is fed solely
by `InstructionsResolver.resolve(...).content()` — the global
instructions file — which `sharedPromptOverhead(...)` already counts in
full. No batch is mis-sized by the scoped rules.

## `/generate-tests` is migrated onto the seam in this PR

#461 merged before this one, so `/generate-tests` landed on
`release/v0.6.0` still line-capped and still reading
`Inputs.omittedFiles`, which this PR removes. The migration #461's agent
was going to perform *after* this merged is therefore done **here** —
there is no "later", because without it base does not compile.
`UnitTestGenerator` now:

1. resolves its effective file list once via
`respectPerRepoIgnores(...)` and plans from it,
2. plans token-budgeted batches and sends `batch.text()` per batch,
3. discloses coverage from `disclosure(plan)`,
4. reserves **0** calls — its reduce is a local union, so the whole
`max-ai-calls` allowance buys batches.

**Two things this surfaced that are worth reading closely.**

**The shared overhead was not sufficient for this command, and using it
unchanged would have been a real bug.** `sharedPromptOverhead(...)`
counts system + user + fence + title + body + instructions.
`/generate-tests` also sends the resolved **project stack** on every
call — dependency manifests, kilobytes, not a rounding error — so the
estimate would have undercounted every batch by the size of the stack
and let "in-budget" batches overshoot the model's real input limit. That
is precisely the failure the overhead exists to prevent.
`planBatches(...)` therefore gains a six-argument form taking the
command's own extra per-call sections, and `/generate-tests` declares
the stack there. A future adopter with its own extra section must do the
same rather than reach for the five-argument form.

**It uses its own prompt templates, not the shared ones.**
`UnitTestAssistant` is annotated with `UnitTestAssistantPrompts.SYSTEM`
/ `UnitTestAssistantPrompts.USER` — it does **not** share
`PrSuggestionPrompts.USER`, because its user template carries the
project-stack section. Sizing its batches against the shared user
template would measure the wrong prompt. `UnitTestAssistantPrompts` had
no accessors (it is new from #461), so `systemPrompt()` / `userPrompt()`
are added to it — SpotBugs failed the build without them, exactly as the
accessor javadoc predicts.

**Why dedupe by path rather than merge.** Batches partition the file
list, so two batches usually propose disjoint test paths. When they do
collide, each proposal's `code` is a *complete* compilable file —
package, imports and fixtures included, posted verbatim to paste at that
path — so two of them at one path are alternatives, not additions.
Rendering both would invite pasting the second over the first and
silently losing the first's cases, and merging them properly would need
a model call for a rare collision. The first wins and the rest are
counted in a disclosure line, so the maintainer can re-run for the
others.

## Related Issues

Fixes #457

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Every new behavior was validated red/green: the test was written, the
production change was mutated to neutralize exactly that behavior, the
test was confirmed to **fail**, and the mutation was reverted to confirm
it passes.

**One mutation initially stayed green and the test was rewritten.**
`proposesTestsForFilesThatTheLineCapWouldHaveDroppedEntirely` first
asserted only that each batch *contained* its file — which is also true
when every call is handed the whole-PR diff, the very behavior being
replaced. Strengthened to assert the partition (batch 1 contains `Foo`
and **not** `Other`, batch 2 the reverse), it goes red properly:

```
[ERROR] UnitTestGeneratorTest.proposesTestsForFilesThatTheLineCapWouldHaveDroppedEntirely:439 [[THRILLHOUSEBOT-UNTRUSTED-DATA-3d24804df8d0a8a72a18bb0d9f6a121f]]
```

**The project stack is counted in the budget.** Mutation: use the
five-argument `planBatches(...)`, leaving the stack out of the overhead.
With a 20k-character stack no file can honestly fit, so the correct run
makes no call at all; the mutant ships batches that overshoot:

```
[ERROR] UnitTestGeneratorTest.countsTheProjectStackInTheBudgetSoBatchesAreNotOversized:496
No interactions wanted here:
```

**Per-repo ignores stay authoritative.** Mutation: plan from
`inputs.reviewableFiles()` instead of the filtered list.

```
[ERROR] UnitTestGeneratorTest.leavesFilesTheRepositoryAskedTheBotToIgnoreOutOfScope:520 [[THRILLHOUSEBOT-UNTRUSTED-DATA-5c207a4fc3e69a526faf47a8f7df5769]]
[ERROR] UnitTestGeneratorTest.staysSilentWhenEveryChangedFileIsOutOfScope:574
```

**Same-path proposals are deduped.** Mutation: drop the `seenPaths`
guard.

```
[ERROR] UnitTestGeneratorTest.keepsOneProposalPerPathAndSaysHowManyWereLeftOut:479 ## 🤖 ThrillhouseBot — suggested unit tests
```

**Disclosure comes from the budget plan.** Mutation: `disclosure(plan)`
returns `""` and the empty-plan branch returns `null`.

```
[ERROR] UnitTestGeneratorTest.disclosesPartialCoverageEvenWhenNoTestsWereProposed:321 🧪 ThrillhouseBot found nothing in this PR's changes that warrants a new unit test. ==> expected: <true> but was: <false>
[ERROR] UnitTestGeneratorTest.namesTheFilesLeftUncoveredWhenTheBatchBudgetRunsOut:303 ## 🤖 ThrillhouseBot — suggested unit tests
[ERROR] UnitTestGeneratorTest.namesTheFilesWhenTheBudgetCouldNotCoverASingleOne:555 expected: not <null>
```

Earlier rounds for `/describe`, `/changelog` and `/improve` (batch text
vs. line-capped render, synthesis vs. concatenation, reserved reduce
call, per-repo ignores, disclosure, budgeting-disabled, nothing-covered,
merge declines) all went red as recorded before; the `<= 0` guard
mutation remains the one that does not, because `max-input-tokens=0`
reaches `Integer.MAX_VALUE` down the fall-through path and yields the
same single batch.

Build results, on the merge of `release/v0.6.0` at `ad36d22` (#458,
#460, #464, #459, #461):

```
./mvnw -B spotless:apply                                   # clean
./mvnw -B clean compile spotbugs:check spotless:check      # BUILD SUCCESS
./mvnw -B clean test                                       # Tests run: 2226, Failures: 0, Errors: 0, Skipped: 0
cd website && npm run build                                # "All internal links are valid."
```

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Notes for anything still in flight

`Inputs.omittedFiles` is gone. It carried how many files the
`max-diff-lines` render dropped; once a command plans its own
token-budgeted batches that number describes a render nothing sends to a
model, so it is not merely redundant but wrong. Nothing on
`release/v0.6.0` or in this tree still reads it.

Any command extending `AbstractPrSuggestionGenerator` that is still in
flight needs the same four steps `/generate-tests` just took:

1. `disclosure(plan)` rather than a line-cap count.
2. `planBatches(reviewable, inputs, <its own system prompt>, <its own
user prompt>, reservedCalls)` — its **own** prompt constants, and the
six-argument form if it repeats a section the shared overhead does not
know about. Check the merged tree for whether the class exposes
`systemPrompt()` / `userPrompt()` accessors or only constants; adding
them is required if a sizing reference would inline a third copy.
3. `batch.text()` per batch, never `inputs.diff()`.
4. `respectPerRepoIgnores(target, COMMAND, inputs.reviewableFiles())`,
with that same list used for anything that anchors onto the diff.

## Additional Notes

**Operator-visible cost change.** `/describe` and `/changelog` on a PR
that needs more than one batch now cost one more model call than the
batches alone, reserved out of `REVIEW_MAX_AI_CALLS`, so the ceiling per
run is unchanged. `/improve` and `/generate-tests` reserve nothing.
Documented in the config table, the "AI call budget" section, the
command prose, and the Known limitations bullet.

**The ignore filter is authoritative for the line resolver, not just the
planner.** `/improve` threads the resolved list into both
`planBatches(...)` and `post(...)`, where the resolver is built as `new
DiffLineResolver(diffFormatter().patchesByReviewableFiles(reviewable))`
— never from `inputs.reviewableFiles()`.
`PrImprovementServiceTest.neverCommitsASuggestionToAFileTheRepositoryAskedTheBotToIgnore`
pins it. `/describe`, `/changelog` and `/generate-tests` build no line
map (a proposed test file is a new file with no diff line to anchor to),
so they cannot exercise it, but the rule is stated in
`respectPerRepoIgnores(...)`'s javadoc for future adopters.

**New prompts.**
`PrDescribeAssistantPrompts.SYNTHESIS_SYSTEM`/`SYNTHESIS_USER` and
`ChangelogAssistantPrompts.MERGE_SYSTEM`/`MERGE_USER`, with matching
`synthesize(...)` / `merge(...)` methods on the assistants. Both user
templates mirror `PrSuggestionPrompts.USER` — same context sections,
same random-fence untrusted-data block — with the partials/candidates in
place of the diff. `AiServicePromptRenderingTest` drives both through
the real rendering pipeline and asserts every `@V` reaches the message.

**No new config keys.**
…omplete (#109) (#465)

## What type of PR is this?

- [x] ✨ Feature

## Description

A documented config key whose description omits the facts an operator
actually needs — value type, list/separator semantics, units, allowed
values, default — was unreportable by construction:

- `PrReviewPrompts.SYSTEM` calls "documentation-vs-code wording" a
cosmetic phrasing nitpick under the `"low"` severity rule, and its
documentation dimensions target **contradictions** only (doc-vs-code,
comment-vs-code), never **omissions** in `.md` / `.env` files.
- `FindingVerifierPrompts.SYSTEM` compounds it: a claim like "this list
is comma-separated per the `Optional<List<String>>` binding" reads
exactly like the remembered-framework-behavior class capped at
`"medium"`, and it quotes a definition line the diff does not contain,
which is a standing rejection ground.

Dogfood evidence — **PR #104**: the entry for
`THRILLHOUSEBOT_REVIEW_MANUAL_TRIGGER_ALLOWED_LOGINS` never said the
value is comma-separated, and the bot returned "No issues found".

**#108 (merged) closed the evidence half**: `ConfigKeyContextResolver`
resolves the keys a `*.md` / `.env*` diff names to their definition
sites and renders them into the review context under `### Config key
definitions from the repository`. This PR is the matching prompt half,
in the generator+verifier "claim-class" shape established by #97 and
#107 — a generator-only change fails, because the verifier re-applies
the framework-behavior cap.

**Generator (`PrReviewPrompts.SYSTEM`)**

- New review dimension **10. CONFIG KEY DOCUMENTATION COMPLETENESS**,
gated on the key's definition being present in the `Config key
definitions from the repository` section that #108 actually renders.
Reportable: an omitted **type**, **list/separator semantics**,
**units/duration format**, **allowed values**, or **default**. Risk
`"low"`, or `"medium"` when a plausible reading of the doc as written
produces a broken configuration.
- Per the issue thread's follow-up (the PR #164 /
`MANUAL_TRIGGER_AUTH_TIMEOUT` instance), the same dimension covers the
starkest form: a key the diff **adds** while also changing a doc/config
file that lists sibling keys without listing the new one. When the diff
changes no doc/config file at all, the model cannot see whether docs
exist elsewhere and must say nothing.
- The `"low"` severity bullet now carves this out explicitly — an
omitted type/separator/unit/allowed-value/default is a correctness gap
for whoever sets the key, **not** a phrasing nitpick; prose style, tone
and ordering remain nitpicks.
- A new self-check keeps it narrow: the claim must quote the documented
line **and** the definition line that establishes the omitted fact, and
name which fact is missing. A claim that only rewords the documentation,
whose missing fact the quoted definition does not establish, or about a
key whose definition is not in the provided material, is invalid.

**Verifier (`FindingVerifierPrompts.SYSTEM`)**

- New claim-class paragraph, alongside the existing bug-fix-efficacy /
mock-fidelity / heuristic-limitation ones: the definition is repository
material the review pass was handed from **outside the diff**, so the
finding is neither rejected for quoting an unshown line nor demoted as
remembered framework behavior — an `Optional<List<String>>` `@WithName`
mapping *is* evidence, not a recollection. The PR #104 miss is embedded
as the inline regression example.
- The same paragraph keeps the door shut: reject when the documentation
already states the fact, when the finding names no format-critical fact
(wording, tone, ordering, a missing example), or when it quotes no
definition at all.
- Severity calibration exempts a definition-backed claim from the
"unverifiable framework behavior → at most medium" cap.

### What is now reportable that was not

Exactly one thing: a config key documented in the diff whose description
omits its **type, list/comma separator, units, allowed values, or
default**, where the key's **definition is in the provided material**
and the documentation does not state that fact anywhere in the changed
material — plus the degenerate case of a key added with no entry beside
its siblings in a doc file the same diff touches. Everything else about
documentation prose — wording, tone, ordering, table formatting, missing
examples, and any fact the definition does not establish — remains
excluded, in both prompts.

## ⚠️ Known limitation: the verifier is not given the config-key context

**`FindingVerifier.verify(...)` receives `findings`, `diff`,
`projectStack` and `previousFindings` — and no `configKeyContext`.** The
section #108 builds is threaded only through `ReviewContextLoader` →
`ReviewPromptAssembler` → the **generator** call. The verifier never
sees it.

The practical consequence, stated plainly:

- **Docs-only PR (the PR #104 shape).** The key's definition is in the
generator's material but *not* the verifier's. The best available
verdict is a **downgrade to a confidence-`"low"` verification request,
not a confirmation**. The prompt says exactly that, so the finding
survives to the summary instead of being dropped — which is the actual
behavioral change here — but it will not post as a confirmed inline
finding.
- **PR that adds the key and documents it together** (what this repo's
own guidance requires). The definition is in the diff, so the verifier
can confirm outright and the rule works end to end.

**Full parity needs `configKeyContext` threaded through
`FindingVerifier` → `FindingVerificationService` → `FindingPipeline`**
(a new `@V` slot on the AI-service interface, a matching section in
`FindingVerifierPrompts.USER`, an extra parameter on
`FindingVerificationService.verify`, and the value carried to both
`FindingPipeline` call sites). That is production plumbing, deliberately
out of scope for a prompt-scoped issue whose stated files are the two
prompt constants — but it is the follow-up this change wants, and it is
why the true-positive eval case below accepts two verdicts rather than
one.

## Related Issues

Fixes #109

Depends on #108 (merged) for the implementation evidence the rule refers
to.

## How Has This Been Tested?

- [x] Unit tests

Five new coarse content anchors in `PrReviewPromptsContentTest` (35
tests in the class), following the convention #117 and #335 used there:

- `generatorPromptReportsIncompleteConfigKeyDocumentation`
- `generatorPromptCarvesConfigDocGapsOutOfThePhrasingNitpickExclusion`
- `generatorPromptKeepsTheConfigDocClaimNarrowAndEvidenced`
- `verifierPromptDoesNotDemoteConfigDocGapsAsFrameworkBehavior`
- `verifierPromptStillRejectsDocumentationPhrasingNitpicks`

Red/green validation — with the two prompt files reverted (`git stash
push -- src/main/java`) and the tests kept, all five fail:

```
[ERROR] Tests run: 30, Failures: 5, Errors: 0, Skipped: 0 -- in PrReviewPromptsContentTest
generatorPromptReportsIncompleteConfigKeyDocumentation
  the config-key documentation-completeness dimension must exist (#109) — missing marker:
  "CONFIG KEY DOCUMENTATION COMPLETENESS" ==> expected: <true> but was: <false>
generatorPromptCarvesConfigDocGapsOutOfThePhrasingNitpickExclusion
  the low-severity nitpick exclusion must carve out config-key documentation gaps (#109) —
  missing marker: "a config-key documentation gap under dimension 10" ==> expected: <true> but was: <false>
generatorPromptKeepsTheConfigDocClaimNarrowAndEvidenced
  the dimension must exclude prose-style omissions from the carve-out — missing marker:
  "wording, tone, ordering, table formatting" ==> expected: <true> but was: <false>
verifierPromptDoesNotDemoteConfigDocGapsAsFrameworkBehavior
  verifier must judge config-doc-completeness findings on their own terms (#109) — missing marker:
  "A config-key documentation-completeness finding" ==> expected: <true> but was: <false>
verifierPromptStillRejectsDocumentationPhrasingNitpicks
  the verifier carve-out must not reopen the door to documentation phrasing nitpicks — missing
  marker: "no format-critical fact (wording, tone, ordering, a missing example" ==> expected: <true> but was: <false>
```

With the prompt change restored, all five pass. These anchors prove the
guidance is present and guard against silent reversion; they prove
nothing about model behavior.

Local gates (Java 25):

- `./mvnw -B spotless:apply` + `./mvnw -B clean compile spotbugs:check
spotless:check` — BUILD SUCCESS
- `./mvnw -B clean test` — **2201 tests, 0 failures, 0 errors**

### 🚨 The eval-corpus cases in this PR have NOT been executed

Two new eval-corpus **verifier** cases derived from the PR #104 miss are
added, and `EvalCorpusTest` validates their well-formedness in every
build — but that is a fixture check, not a behavioral one:

- `evalcorpus/pr104-config-key-doc-incomplete-true-positive` — the
incomplete README/`.env` entry, with a candidate finding quoting the
`@WithName` `Optional<List<String>>` definition. `expectedVerdicts:
["confirmed", "downgraded"]` — the labelled property is **"must not be
dropped"**, and two verdicts are accepted precisely because of the
verifier-plumbing gap above.
- `evalcorpus/pr104-config-doc-phrasing-nitpick-false-positive` — a
wording/ordering nitpick over the same diff. `expectedVerdicts:
["rejected"]`, the negative control for "the carve-out did not reopen
the nitpick door".

**Neither case has been run.** `PromptEvalTest` is tagged `eval` and
needs a live provider key; **no provider key exists in the environment
this was developed in**, so both labels are **expected outcomes I
reasoned to, not measured results**. Nothing in this PR demonstrates
that a model actually acts on the new guidance, and no false-positive
rate has been measured.

**`PromptEvalTest -Peval` should be run against a live provider before
this ships:**

```bash
QUARKUS_LANGCHAIN4J_OPENAI_API_KEY=... ./mvnw test -Peval -Dtest=PromptEvalTest
```

**If the true-positive case comes back `rejected`, the verifier-plumbing
gap above is the first thing to look at** — the verifier cannot see the
definition on a docs-only diff, so a rejection there is the expected
symptom of that missing context rather than a prompt-wording problem.

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] My changes generate no new warnings or errors

## Additional Notes

No production logic changed — prompt text, its deterministic content
anchors, and eval fixtures only.

No CHANGELOG entry, matching #117 and #108 in this milestone: the wave
branches all edit the same `[Unreleased]` block and the entry is better
written once at release assembly.
## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [x] 📝 Documentation
- [x] 🔧 Refactor
- [ ] 🚀 Performance
- [x] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

`/add-docs` was the last on-request generator that had not moved onto
the shared
`AbstractPrSuggestionGenerator` seam, and it had drifted behind the
other four in two
user-visible ways.

**Per-repo ignore globs were not honoured — on the one command that
writes.**
`DocGenerationService` filtered the changed files through the
single-argument
`diffFormatter.reviewableFiles(files)`, which resolves to the
deployment-wide ignore list
only. The globs a maintainer declares in `.github/thrillhousebot.yml`
(#51) were never
asked for. Because this command posts committable ```` ```suggestion
```` blocks, a path a
repository had explicitly told the bot to leave alone could still come
back as a one-click
commit button. The file list now goes through
`respectPerRepoIgnores(...)`, and that one
list feeds both the batch plan and the `DiffLineResolver` line map, so
an out-of-scope file
can reach neither a model call nor an anchor.

**Still line-capped instead of token-budgeted.** The prompt was built
with
`buildDiffStringWithStats(...)`, which drops whole files at the
`max-diff-lines` boundary
and appends a truncation footnote. Coverage is now planned as
token-budgeted batches over
the whole change set through `planBatches(...)` (#457), matching how
`UnitTestGenerator`
consumes a `BudgetPlan`: one call per batch, the per-batch docs merged
locally (so no
reduce call is reserved and the whole `max-ai-calls` allowance buys
batches), uncovered
files named through `disclosure(plan)` rather than counted, and failed
batches disclosed
through `batchFailureNote(...)`. The line-cap truncation note is gone
with the line cap.

The post-time guards are unchanged: the exact-line diff-anchor check,
the "replacement must
retain the declaration line" guard, and the per-PR comment cap now
simply run over the docs
merged from every batch. Repository-settings resolution goes through
`SoftLoaders`, so a
failure there degrades to the previous (global-list) behaviour instead
of failing the
command — the contract `/add-docs` already had.

Files touched:

- `review/DocGenerationService.java` — extends
`AbstractPrSuggestionGenerator`; takes
`RepoSettingsResolver`, `DiffBudgetPlanner` and `ActiveModelSettings`;
filters through
`respectPerRepoIgnores`; plans and generates per batch; merges and
dedupes docs by
`file:line`; drops the `omittedFiles` line-cap disclosure in favour of
the plan's; adds a
`NOT_COVERED` message so "nothing needs documentation" is never a
verdict on code the
  model never read.
- `review/ai/DocGeneratorPrompts.java` — `systemPrompt()` /
`userPrompt()` accessors so the
batch planner can size the overhead without inlining a second copy of
the prompt
constants (SpotBugs `HSC_HUGE_SHARED_STRING_CONSTANT`), mirroring the
other prompt classes.
- `review/DocGenerationServiceTest.java` — new coverage for the ignore
filter, the batching,
the guards across batches and the soft-fail path; the two tests that
pinned the old
  line-cap render re-pointed at the plan.
- `README.md` — `/add-docs` moved from the `REVIEW_MAX_DIFF_LINES` list
to the batched-command
lists, and the "Known limitations" note that it was the one unbatched
command removed.

`DocGenerationService` keeps its own PR/file loading rather than calling
`loadInputs(...)`:
it distinguishes "the PR could not be loaded" from "the PR has nothing
reviewable to
document" in what it posts back, and it needs the head SHA before doing
any work at all.
That is documented on the class.

## Related Issues

Fixes #468

## How Has This Been Tested?

Unit tests, validated red/green. A blanket `git stash push --
src/main/java` does not
compile here — the constructor gains three parameters, so the test class
would not build —
so each behaviour was neutralised surgically and the test re-run.
Verbatim red-phase output:

**1. Per-repo ignore filter** — reverted
`respectPerRepoIgnores(task.target(), COMMAND,
diffFormatter().reviewableFiles(files))` to the old
`diffFormatter().reviewableFiles(files)`:

````
DocGenerationServiceTest.postsNoSuggestionForAFileOnlyThePerRepoIgnorePatternExcludes
org.mockito.exceptions.verification.NeverWantedButInvoked:

reviewClient.createPullRequestComment(
    <any>, <any>, <any>, <any>, <any integer>, <any>
);
Never wanted here:
-> at ...DocGenerationServiceTest.postsNoSuggestionForAFileOnlyThePerRepoIgnorePatternExcludes
But invoked here:
-> at ...DocGenerationService.postInline(DocGenerationService.java:452) with arguments:
   [token gh-abc, application/vnd.github+json, owner, repo, 7,
    CreatePullRequestCommentRequest[commitId=headsha1234567, body=**📝 Documentation for `hop(int)`**

```suggestion
/** Hops. */
public int hop(int n) {
```
, path=src/Other.java, line=1, side=RIGHT, startLine=null, startSide=null]]
````

```
DocGenerationServiceTest.reportsNoFilesWhenEveryChangedFileIsOutOfScopeForTheRepository
org.opentest4j.AssertionFailedError: expected: <📝 ThrillhouseBot found no reviewable changed
files to document in this PR.> but was: <📝 ThrillhouseBot could not generate documentation
for this PR. Please try `/add-docs` again.>
```

`src/Other.java` is reviewable under the global list (empty in that
test) and excluded only
by the pattern the repository declared, so the failure is specifically
about per-repo globs
— and it shows the exact harm: a committable suggestion handed out on an
ignored path.

**2. Global ignore behaviour unchanged** — kept `respectPerRepoIgnores`
but dropped the
global filter feeding it (`respectPerRepoIgnores(task.target(), COMMAND,
files)`):

````
DocGenerationServiceTest.keepsTheGlobalIgnoreListWorkingForARepositoryWithNoOwnSettings
org.opentest4j.AssertionFailedError:
...
### docs/README.md (modified, +1 -0)
```diff
@@
+hi
```
... ==> expected: <false> but was: <true>
````

**3. Batching replaces the line cap** — restored the old whole-PR
line-capped render as the
text sent to the model:

````
DocGenerationServiceTest.documentsFilesThatTheLineCapWouldHaveDroppedEntirely
org.opentest4j.AssertionFailedError:
## Overview: 2 files (+9 -0)

### src/Foo.java (modified, +6 -0)
```diff
@@ -0,0 +1,6 @@
(patch truncated — 6 lines omitted)
```

(diff truncated at 8 lines — 1 files omitted)
 ==> expected: <true> but was: <false>
````

```
DocGenerationServiceTest.plansOneBatchPerSliceOfTheChangeSetRatherThanOneCallOverTheWholeRender
org.opentest4j.AssertionFailedError: ... ==> expected: <false> but was: <true>
```

```
DocGenerationServiceTest.keepsTheDocsFromTheBatchesThatSucceededWhenOneBatchFails
Wanted but not invoked:
commentClient.createComment(...);
Actually, there were zero interactions with this mock.
```

The first is the acceptance criterion verbatim: under the line cap
`src/Other.java` is
dropped entirely ("1 files omitted") and its declaration never reaches
the model.

**4. Plan-sourced disclosure, budget message, planner failure** —
dropped `disclosure(plan)`
from the summary, replaced `NOT_COVERED` with `NOTHING_TO_DOCUMENT`, and
let a planner
failure fall through to the outer catch:

```
DocGenerationServiceTest.appendsPartialCoverageDisclosureNamingTheFilesNoBatchCouldCover
org.opentest4j.AssertionFailedError: 📝 ThrillhouseBot added **1** documentation
suggestion(s) for changed symbols. Review each one and commit the suggestions you want to
keep. ==> expected: <true> but was: <false>
```

```
DocGenerationServiceTest.reportsTheBudgetRatherThanAVerdictWhenNoFileFitsABatch
org.opentest4j.AssertionFailedError: 📝 ThrillhouseBot found no changed symbols that need
documentation in this PR. ==> expected: <true> but was: <false>
```

```
DocGenerationServiceTest.reportsFailureWhenBatchPlanningThrows
Wanted but not invoked:
commentClient.createComment(<any>, <any>, "owner", "repo", 7, <Capturing argument>);
Actually, there were zero interactions with this mock.
```

**5. Post-time guards** — the comment cap (`if (suggestions + notes >=
cap)`) and the
declaration-retention guard (`if (!preservesExistingCode(doc))`) each
stubbed out:

```
DocGenerationServiceTest.keepsTheCommentCapOverTheDocsMergedFromEveryBatch
org.mockito.exceptions.verification.TooManyActualInvocations:
reviewClient.createPullRequestComment(...);
Wanted 1 time: ... But was 2 times:
-> at ...DocGenerationService.postInline(DocGenerationService.java:453)
-> at ...DocGenerationService.postInline(DocGenerationService.java:453)
```

```
DocGenerationServiceTest.capsAtMaxReviewComments
org.mockito.exceptions.verification.TooManyActualInvocations: ... Wanted 1 time ... But was 2 times
```

````
DocGenerationServiceTest.disclosesCapDropEvenWhenNothingWasPosted
org.mockito.exceptions.verification.NeverWantedButInvoked: ... But invoked here:
-> at ...DocGenerationService.postInline(...) with arguments: [... body=**📝 Documentation for `bar`**

```suggestion
/** a */
public int bar(int x) {
```
, path=src/Foo.java, line=1, ...]]
````

```
DocGenerationServiceTest.keepsTheGuardsAcrossBatchesSoOnlyTheAnchorableDocIsPosted
org.mockito.exceptions.verification.TooManyActualInvocations
```

**6. Diff anchor guard** — stubbed out the exact-line anchor check:

````
DocGenerationServiceTest.doesNotPostSuggestionThatCannotAnchorCleanly(String, String)[1]
"declaration line is not in the diff"
org.mockito.exceptions.verification.NeverWantedButInvoked: ... But invoked here:
-> at ...DocGenerationService.postInline(...) with arguments: [... body=**📝 Documentation for `ghost`**

```suggestion
/** x */
whatever
```
...]]
````

**7. Soft-fail of the new resolution path** — replaced the
`SoftLoaders.repoSettings(...)`
call in `AbstractPrSuggestionGenerator.respectPerRepoIgnores` with a
direct
`repoSettingsResolver.resolve(...)`:

```
DocGenerationServiceTest.continuesWithTheGlobalIgnoreListWhenRepositorySettingsCannotBeResolved
Wanted but not invoked:
reviewClient.createPullRequestComment(<any>, <any>, <any>, <any>, <any integer>, <any>);
Actually, there were zero interactions with this mock.
```

**8. Cross-batch merge by `file:line`** — replaced the guarded merge in
`generateEachBatch`
with an unconditional `seen.add(doc.file().strip() + ":" + doc.line());
merged.add(doc);`, so
a declaration two batches both documented is added twice:

```
DocGenerationServiceTest.postsOneCommentWhenTwoBatchesBothDocumentTheSameDeclaration
org.mockito.exceptions.verification.TooManyActualInvocations:

reviewClient.createPullRequestComment(
    <any>,
    <any>,
    <any>,
    <any>,
    <any integer>,
    <any>
);
Wanted 1 time:
-> at ...DocGenerationServiceTest.postsOneCommentWhenTwoBatchesBothDocumentTheSameDeclaration
But was 2 times:
-> at ...DocGenerationService.postInline(DocGenerationService.java:452)
-> at ...DocGenerationService.postInline(DocGenerationService.java:452)
```

Both batches return a doc for `src/Foo.java:1` — the collision the merge
exists for, and the
harm it prevents: two inline comments on one declaration line.

**9. Per-batch parse guard, partial run** — deleted the `try`/`catch`
around `parser.parse(raw)`
in `generateOne`, leaving a bare `return parser.parse(raw);`. One batch
replies with prose, the
other with usable JSON; without the guard the `IllegalArgumentException`
escapes to `handle`'s
outer catch and nothing at all is posted:

```
DocGenerationServiceTest.skipsABatchWhoseResponseWillNotParseAndKeepsTheOthers
Wanted but not invoked:
reviewClient.createPullRequestComment(
    <any>,
    <any>,
    "owner",
    "repo",
    7,
    <Capturing argument: CreatePullRequestCommentRequest>
);
-> at ...DocGenerationServiceTest.capturedInlineComment(DocGenerationServiceTest.java:190)
Actually, there were zero interactions with this mock.
```

**10. Per-batch parse guard, total failure** — same neutralisation, with
no batch parsing at
all. The maintainer must still get the failure notice rather than
silence:

```
DocGenerationServiceTest.reportsFailureWhenNoBatchResponseWillParse
Wanted but not invoked:
commentClient.createComment(
    <any>,
    <any>,
    "owner",
    "repo",
    7,
    <Capturing argument: CreateCommentRequest>
);
-> at ...DocGenerationServiceTest.postedSummary(DocGenerationServiceTest.java:183)
Actually, there were zero interactions with this mock.
```

Every production change was restored after each step and the class
re-run green
(`DocGenerationServiceTest`: 45 tests, 0 failures).

Sections 8–10 were added after the first CI run: `codecov/patch`
reported 95.06% on the patch,
and the two gaps were exactly the branches those tests now pin — the
cross-batch dedupe branch
at `DocGenerationService.java:292` (the duplicate-drop side was never
taken) and the per-batch
parse-failure catch at `DocGenerationService.java:322-324`. Patch
coverage is now **100%
(81/81 lines, 0 missing, 0 partial)**, with no `codecov.yml` or
threshold changes.

Build:

- `./mvnw -B spotless:apply` — clean
- `./mvnw -B clean compile spotbugs:check spotless:check` — `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` — **Tests run: 2327, Failures: 0, Errors: 0,
Skipped: 0**

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

n/a

## Additional Notes

- `DocPostOutcome.skippedByCap` is **kept** — despite the shared name,
it is the per-PR
*comment* cap the acceptance criteria require, not the line cap. The
line cap lived in
`GeneratedDocs.omittedFiles` /
`ReviewResult.truncationDisclosure(omittedFiles)`, and that
is what was removed. `truncationDisclosure(int)` itself stays:
`FollowUpDeltaSummary` and
  the review path still use it.
- Docs from two batches are deduplicated by `file:line`. Batches
partition the file list so
a collision is unusual, but a model can quote a file it saw named in
another batch's
context, and two inline comments on one declaration line would be noise.
- Non-postable docs are still merged rather than filtered during the
reduce, so the summary
keeps distinguishing "the model returned nothing"
(`NOTHING_TO_DOCUMENT`) from "it returned
  something that could not be placed" (`COULD_NOT_PLACE`).
- `REVIEW_MAX_INPUT_TOKENS`'s README row still lists only review,
`/improve`, `/describe` and
`/changelog`; it already omitted `/generate-tests` before this PR, so
correcting it is left
  as a separate docs fix rather than widened here.
…ss a zero-finding round (#470)

## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [ ] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

When a follow-up review round produced **zero** findings, the next
round's `## Previous Review Findings` section was filled with the
previous bot review's **body text verbatim** — including the bot's own
`"No new issues in this revision, but N previous finding(s) remain
unresolved …"` boilerplate — while the real prior finding vanished from
tracking. The unresolved count then drifted upward every round and
`VerdictBuilder` demoted APPROVE → COMMENT indefinitely.

Three defects, all in the context-construction path:

1. **The review-body fallback fired on the wrong condition.**
`FollowUpAnalyzer.buildPreviousFindingsContext(...)` selected it
whenever the structured rendering came out empty, which is true both
when there is genuinely no persisted AI response (its documented
purpose) *and* when a persisted round legitimately found nothing. The
caller now passes that fact explicitly (`previousResponsePersisted`)
instead of it being inferred from the shape of the output.

2. **The fallback offered the bot's own prose as findings.** Even in the
legitimate no-persisted-response case, a review body the bot generated
about its own verdict carries no finding, so presenting it under *"the
following issues were flagged … determine if it is resolved, unresolved,
or justified"* is a category error. The fallback now discards a body it
recognizes as self-authored, matched against the producers' own
constants (the unresolved-previous sentence, the clean-review message,
the two CI-hold notices, the partial-review banner) so the recognizer
cannot drift from the text it recognizes. A body it did not generate is
still passed through.

3. **The open set was re-derived from the latest round rather than
carried.** A zero-finding round exposes no ids, so treating it as "the
previous round" evicted the still-open finding from the prompt, from
`previous_findings_status`, and from every id-keyed consumer. The prior
round a review reports on is now the newest persisted round that
actually *raised* findings, and the deterministic backstop pairs each
round's `previous_findings_status` with that same round instead of
blindly with the round before it — which is why the count could never
come back down (the ids never mapped, so nothing ever closed).

**Depth chosen.** The issue's suggested fix (3) — carry-forward — is
implemented in the form that does not destabilise the id space the #169
decline re-check depends on: the effective previous round's list is
carried **whole**, so every id stays exactly the 1-based position the
finding had when it was posted, which is the index its inline comment's
hidden `thrillhousebot:finding=N` marker carries. Filtering closed
findings out of the carried list (the other reading of "carry forward")
would renumber the survivors and silently break marker-based thread
matching, so it is deliberately not done. Accumulating findings across
rounds that *each* raised some remains the backstop's job, unchanged.

### Files

- `FollowUpAnalyzer.java` — explicit `previousResponsePersisted` flag on
the context builder; `effectivePreviousFindings` /
`effectivePreviousRoundIndex` (skip zero-finding rounds);
`isPersistedResponse` (tells a parsed empty round from the blank
stand-in without a second parse); `isSelfAuthoredStatusBody` guard on
the review-body fallback; backstop replay now pairs a status block with
the newest earlier round that raised findings.
- `ReviewContextLoader.java` — resolves that round once, and derives the
rendered context, the id space (`previousFindingsList()`), the raw JSON
the supersede pass re-reads, and the older-rounds slice from it, so the
three cannot drift apart.
- `ReviewResult.java` — `isUnresolvedPreviousMessage(...)` plus shared
lead-in constants for the CI-hold and partial-review bodies, so the
guard above matches text the producers own.
- `ReviewPublisher.java` — `noIssuesBody` now builds from those shared
constants (text unchanged, byte for byte).

## Related Issues

Fixes #455

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Eleven new tests, each validated red → green. Because the tests
reference the new API, plain `git stash push -- src/main/java` yields a
compile failure rather than an assertion; the production change was
therefore neutralized *behaviourally* instead — the four changed
decisions reverted to their old form with the new signatures kept — so
every test fails on its assertion. Verbatim red-phase output (`?` is the
console rendering the em dash / emoji):

```
FollowUpAnalyzerTest.previousFindingsContextShouldNeverCarryTheBotsOwnStatusBody:1837 a persisted round that legitimately found nothing must render no previous findings ==> expected: <> but was: <No new issues in this revision, but 1 previous finding(s) remain unresolved ? fix them, or reply on their review thread (where one exists) with why they are deferred.>
FollowUpAnalyzerTest.reviewBodyFallbackShouldDropEveryBotGeneratedBodyAndKeepTheRest:1853 expected: <> but was: <Everything's coming up Thrillhouse! ?

No issues found in this PR.>
ReviewContextLoaderTest.findingFromRoundOneSurvivesAZeroFindingRoundNumberedAndInTheIdSpace the zero-finding round evicted the still-open finding from the id space ==> expected: <1> but was: <0>
ReviewContextLoaderTest.zeroFindingRoundWithNothingToCarryLeavesTheIdSpaceEmptyAndTheSectionAbsent an absent previous-findings section is what suppresses the prompt block entirely ==> expected: <> but was: <No new issues in this revision, but 1 previous finding(s) remain unresolved ? fix them, or reply on their review thread (where one exists) with why they are deferred.>
ReviewContextLoaderTest.carriedFindingsKeepTheIdsTheirOwnRoundGaveThem expected: <{2=.../RepoSettingsParser.java, 1=.../RepoSettingsResolver.java}> but was: <{}>
VerdictBuilderTest.unresolvedCountAcrossAZeroFindingRoundStaysAtTheOneRealFinding:925 the unresolved count must equal the number of distinct real findings still open ==> expected: <1> but was: <2>
VerdictBuilderTest.resolvedPriorFindingNoLongerPhantomHoldsApproveAfterAZeroFindingRound:949 expected: <0> but was: <1>
```

Restoring the production change turns all seven green.

Four further tests landed in `a763f28` to close the `codecov/patch` gap,
covering the absent-input branches of the new helpers (plus one added
assertion on the existing `reviewBodyFallback…` test). Seven mutations
in total, each neutralizing one guard or conjunct in the production
method and nothing else:

```
dropped `if (priorAiResponses == null) return -1;`
FollowUpAnalyzerTest.effectivePreviousRoundHelpersShouldTreatAbsentRoundsAsNoPreviousRound:1893 ? NullPointer Cannot invoke "java.util.List.size()" because "priorAiResponses" is null

dropped `response != null &&` from the round-selection loop
FollowUpAnalyzerTest.effectivePreviousRoundHelpersShouldTreatAbsentRoundsAsNoPreviousRound:1901 ? NullPointer Cannot invoke "dev.thiagogonzaga.thrillhousebot.review.ai.ReviewResponse.findings()" because "response" is null

dropped `response != null &&` from isPersistedResponse
FollowUpAnalyzerTest.isPersistedResponseShouldSeparateAStoredRoundFromAMissingOne:1914 expected: <false> but was: <true>

isPersistedResponse: reference identity `response != EMPTY_RESPONSE` weakened to `!response.equals(EMPTY_RESPONSE)`
FollowUpAnalyzerTest.isPersistedResponseShouldSeparateAStoredRoundFromAMissingOne:1920 a round that legitimately found nothing did persist a response ==> expected: <true> but was: <false>

dropped `if (text == null) return false;` from isUnresolvedPreviousMessage
ReviewResultTest.isUnresolvedPreviousMessageShouldRejectTextThatOnlyResemblesIt:215 ? NullPointer Cannot invoke "String.strip()" because "text" is null

dropped `&& stripped.endsWith(UNRESOLVED_PREVIOUS_SUFFIX)`
ReviewResultTest.isUnresolvedPreviousMessageShouldRejectTextThatOnlyResemblesIt:216 a human review opening with the same words carries a real finding and must be kept ==> expected: <false> but was: <true>
FollowUpAnalyzerTest.reviewBodyFallbackShouldDropEveryBotGeneratedBodyAndKeepTheRest:1879 a body that only opens like the generated sentence carries a real finding and is kept ==> expected: <No new issues in this revision, but the null check on line 12 is still wrong.> but was: <>

dropped `.strip()` from isUnresolvedPreviousMessage
ReviewResultTest.isUnresolvedPreviousMessageShouldMatchTheGeneratedSentenceForAnyCount:202 a stored review body keeps its surrounding whitespace ==> expected: <true> but was: <false>
```

The `equals()` mutation is the one worth keeping pinned: it is the
obvious-looking simplification, and it silently puts the review-body
fallback back into the exact path this PR removes it from, because a
round that legitimately found nothing is *equal* to the blank stand-in
without being the same object.

The three regression tests the issue names:

- **Round N raises a finding, round N+1 raises none → round N+2 still
carries it, numbered, count stays at 1** —
`ReviewContextLoaderTest.findingFromRoundOneSurvivesAZeroFindingRoundNumberedAndInTheIdSpace`
(drives the real `load(...)` with the real `FollowUpAnalyzer`, asserting
the section actually handed to the model) plus
`VerdictBuilderTest.unresolvedCountAcrossAZeroFindingRoundStaysAtTheOneRealFinding`.
- **A body matching the `unresolvedPreviousMessage` shape never appears
in `{{previousFindings}}`** —
`FollowUpAnalyzerTest.previousFindingsContextShouldNeverCarryTheBotsOwnStatusBody`,
covering both the zero-finding path and the legitimate fallback path;
`reviewBodyFallbackShouldDropEveryBotGeneratedBodyAndKeepTheRest`
extends it to every body the bot generates and pins that a body it did
not generate is preserved.
- **A PR whose only prior finding was resolved returns to APPROVE** —
`VerdictBuilderTest.resolvedPriorFindingNoLongerPhantomHoldsApproveAfterAZeroFindingRound`.

The two #169 pins:

-
`ReviewContextLoaderTest.zeroFindingRoundWithNothingToCarryLeavesTheIdSpaceEmptyAndTheSectionAbsent`
— with nothing to carry, `previous` stays empty and no pseudo-finding
fabricated from a review body ever enters the id space `recheckDeclines`
uses.
-
`ReviewContextLoaderTest.carriedFindingsKeepTheIdsTheirOwnRoundGaveThem`
— a real prior finding keeps the id its own round gave it across a
zero-finding round, so `previous_findings_status` id 2 still names the
same finding it always did.

Build:

- `./mvnw -B spotless:apply` — clean
- `./mvnw -B clean compile spotbugs:check spotless:check` — `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` — 2325 tests, 0 failures, 0 errors

Coverage: `codecov/patch` went 91.49% → 100% (47/47 lines, 0 misses, 0
partials). What was uncovered was the absent-input handling on the three
new helpers — the `null` list and `null` slot guards in
`effectivePreviousRoundIndex`, the `null` guard in
`isPersistedResponse`, and the `null` guard plus the `endsWith` half of
`isUnresolvedPreviousMessage`. All are now covered by the four tests
above. No `codecov.yml`, `pom.xml`, or workflow file was changed.

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

Round 3 of PR #449, before this change (verbatim from the deployed app
log):

```
## Previous Review Findings
The following issues were flagged in the previous review.
For each, determine if it is resolved, unresolved, or justified.
No new issues in this revision, but 1 previous finding(s) remain unresolved — fix them, or reply on their review thread (where one exists) with why they are deferred.
```

After this change the same round renders round 1's real finding,
numbered `1.`, with its original id.

## Additional Notes

- No new configuration keys, so nothing to document in `README.md`.
- The user-facing review text is unchanged: the CI-hold and
partial-review sentences moved into shared constants byte for byte, and
existing assertions on them still pass.
- Deliberately out of scope, as the issue directs: the latent
`Set.copyOf(plan.omittedFiles()).contains(file.filename())` NPE at
`FindingPipeline.java:563`. It is untouched and still open.
- Also deliberately not attempted: accumulating open findings across
rounds that each raised findings. That case is already covered by the
deterministic backstop, and widening the numbered prompt list to span
rounds would renumber findings away from their inline-comment markers.
- **A judgement call worth disagreeing with, if you do.** The
absent-input guards the four coverage tests pin are structurally
unreachable through the production path as it stands:
`parsePreviousResponses` ends in `List.copyOf`, and `ReviewContext`'s
compact constructor does too, so neither a `null` list nor a `null`
element can actually reach `effectivePreviousRoundIndex` or
`isPersistedResponse` today. I kept them and tested them rather than
deleting them, on two grounds: they are `public static` methods rather
than private helpers, and null-tolerance is the established convention
of this class — `parsePreviousResponses(null)`, `toStatuses(null)` and
`formatAnsweredEarlier(null)` all behave the same way and are already
tested. Each guard now carries a javadoc sentence stating the contract,
so the tests pin documented behaviour rather than an accident. The
alternative — deleting the guards and letting the callers' `List.copyOf`
be the only defence — is a defensible reading, and it is a small change
in each of the three spots if preferred.
…eview (#472)

## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [ ] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

Two spots in the review path fed a possibly-`null` value into an
immutable collection that rejects `null`. Both run **after the 200
ack**, on the async review thread, so the failure was not a bad review —
it was *no* review, with nothing surfaced on the PR explaining why.

**1. A persisted response body of the JSON literal `null`.**
`FollowUpAnalyzer.parseResponse` guarded the Java null and the blank
string and caught `JsonProcessingException`, but `"null"` is
syntactically valid JSON: Jackson returns Java `null` and throws
nothing, so the parse-failure fallback was never reached. The caller put
that null straight into `List.copyOf`, which rejects null elements — a
stored session row containing those four characters failed every
subsequent review of that PR, permanently, until the row was edited. It
now degrades to `EMPTY_RESPONSE`, which is exactly what the `catch`
directly above it already promised for unreadable prior state.

**2. `Set.copyOf(...).contains(<possibly-null filename>)`.**
`GitHubPullRequestClient.FileDiff` is a Jackson-deserialized record with
no compact constructor validating `filename`, and it deliberately
tolerates nulls in its siblings (`patch` is null for binary files,
`previousFilename` for non-renames). Meanwhile
`ReviewDiffFormatter.IgnoreGlobs.matches` opens with an explicit
`filename == null` guard. The two disagreed about whether a null
filename was possible, and that disagreement was the actual defect.

This takes the side the codebase already established — **a null filename
is tolerated and means "not matched"** — rather than adding validation
to `FileDiff`. Validating at construction is the other reading, but it
converts a silent null into a hard failure at parse time for *every*
file the API returns, which is a much wider behavior change than the
defect calls for. The contract now lives in one place,
`ReviewDiffFormatter.namesContain`, next to the glob matcher that
already stated it.

Three call sites move onto it:

| Site | Lookup |
| --- | --- |
| `FindingPipeline.changedFilesOverview` | `omitted.contains(...)` —
reported in the issue |
| `FindingPipeline.changedFilesOverview` | `clipped.contains(...)` —
reported in the issue |
| `VerdictBuilder.build` |
`Set.copyOf(truncation.omittedFileNames()).contains(...)` — **a third
instance, not in the issue**, in the same review path and equally
null-hostile |

Two things worth flagging that the issue does not mention:

- **`plan.omittedFiles()` / `plan.clippedFiles()` cannot themselves
contain null.** `DiffBudgetPlanner.BudgetPlan`'s compact constructor
already runs `List.copyOf` on both, so `Set.copyOf(...)` in
`FindingPipeline` can only ever fail on the `contains(null)` argument,
never on the collection's contents. No extra guard is needed there.
- **`directoryOf` needed the same guard.** The directory breakdown that
builds the scope header runs *ahead* of the per-file rows, so it
dereferenced the unnamed file first and the `contains` guard alone would
have been unreachable. Its comment claimed "a null path is not guarded
here: the per-file loop above already dereferences the same name against
an immutable set" — that had the ordering backwards. A file with no path
carries no directory component either, so it lands in the existing
`(repository root)` bucket.

## Related Issues

Fixes #471

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Four tests, each validated red/green by neutralizing **only** the
production behavior it covers (surgically, one at a time — a blanket
revert would have produced compile errors, which prove nothing) and
confirming the failure, then restoring and confirming green.

**1.
`FollowUpAnalyzerTest.persistedResponseOfTheJsonLiteralNullDegradesToTheEmptyResponse`**

Mutation: `parseResponse` returns `mapper.readValue(...)` directly
again, without the null check.

```
java.lang.NullPointerException
	at java.base/java.util.Objects.requireNonNull(Objects.java:220)
	at java.base/java.util.ImmutableCollections$List12.<init>(ImmutableCollections.java:590)
	at java.base/java.util.List.of(List.java:1168)
	at java.base/java.util.ImmutableCollections.listCopy(ImmutableCollections.java:191)
	at java.base/java.util.List.copyOf(List.java:1191)
	at dev.thiagogonzaga.thrillhousebot.review.FollowUpAnalyzer.parsePreviousResponses(FollowUpAnalyzer.java:1243)
	at dev.thiagogonzaga.thrillhousebot.review.FollowUpAnalyzerTest.persistedResponseOfTheJsonLiteralNullDegradesToTheEmptyResponse(FollowUpAnalyzerTest.java:757)
```

That is the exact `List.copyOf` line named in the issue. The test also
pins `previousFindingFilesById("null")`, which reads the same response
and would otherwise dereference the null.

**2.
`ReviewDiffFormatterTest.GlobMatching.shouldTreatANullFilenameAsAbsentFromAnImmutableNameSet`**

Mutation: `namesContain` drops its `filename != null &&` and delegates
straight to `names.contains(filename)`.

```
java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because "o" is null
	at java.base/java.util.ImmutableCollections$Set12.contains(ImmutableCollections.java:1036)
	at dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatter.namesContain(ReviewDiffFormatter.java:175)
	at dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest$GlobMatching.shouldTreatANullFilenameAsAbsentFromAnImmutableNameSet(ReviewDiffFormatterTest.java:761)
```

Covers all three arms: null, present, and absent-but-named.

**3.
`FindingPipelineTest.anUnnamedReviewableFileIsNeitherOmittedNorClippedInTheSummaryOverview`**

Same mutation as (2), driven through the real multi-call pipeline:

```
java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because "o" is null
	at java.base/java.util.ImmutableCollections$Set12.contains(ImmutableCollections.java:1036)
	at dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatter.namesContain(ReviewDiffFormatter.java:175)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.changedFilesOverview(FindingPipeline.java:563)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.runMultiCall(FindingPipeline.java:235)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.run(FindingPipeline.java:113)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.anUnnamedReviewableFileIsNeitherOmittedNorClippedInTheSummaryOverview(FindingPipelineTest.java:682)
```

The same test also pins the `directoryOf` guard. Mutation: restore
`path.lastIndexOf('/')` without the null check.

```
java.lang.NullPointerException: Cannot invoke "String.lastIndexOf(int)" because "path" is null
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.directoryOf(FindingPipeline.java:670)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.appendDirectoryBreakdown(FindingPipeline.java:636)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.changeScopeSummary(FindingPipeline.java:621)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.changedFilesOverview(FindingPipeline.java:559)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.runMultiCall(FindingPipeline.java:235)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.run(FindingPipeline.java:113)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.anUnnamedReviewableFileIsNeitherOmittedNorClippedInTheSummaryOverview(FindingPipelineTest.java:682)
```

Those two stack traces are the concrete evidence for the ordering claim
above: the breakdown is reached first.

**4.
`VerdictBuilderTest.anUnnamedFileIsNotTreatedAsOmittedFromTheWalkthroughRows`**
— the third instance.

Same mutation as (2):

```
java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because "o" is null
	at java.base/java.util.ImmutableCollections$Set12.contains(ImmutableCollections.java:1036)
	at dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatter.namesContain(ReviewDiffFormatter.java:175)
	at dev.thiagogonzaga.thrillhousebot.review.VerdictBuilder.lambda$build$0(VerdictBuilder.java:138)
	at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:196)
```

It asserts the named omitted file is dropped from the walkthrough rows
while the unnamed one keeps its row — the null case and the ordinary
case in the same assertion.

Build gates:

```
./mvnw -B spotless:apply                                   # clean
./mvnw -B clean compile spotbugs:check spotless:check      # BugInstance size is 0, BUILD SUCCESS
./mvnw -B clean test                                       # Tests run: 2342, Failures: 0, Errors: 0, Skipped: 0
```

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

N/A — see the red-phase stack traces above.

## Additional Notes

No config keys, no schema changes, no user-visible behavior change on
today's inputs: GitHub always sends a filename, and no production
session row holds a literal `null`. Every path this touches is the one
that previously threw.

**Left for a follow-up rather than folded in.** `DiffBudgetPlanner` has
two more sites that a null filename would still break, both *upstream*
of the code changed here, and both needing a semantic decision this
defect does not license:

- `renderAndSize` sorts with `.thenComparing(FileDiff::filename)`, which
throws once two files tie on `additions + deletions`. Deciding where an
unnamed file sorts is a design call, not a null guard.
- `omitted.add(s.file().filename())` feeds `BudgetPlan`'s `List.copyOf`,
which rejects the null. Filtering it out would silently shrink
`omittedFiles().size()` — the count that holds APPROVE in
`VerdictBuilder` — so an unreviewed file could stop withholding
approval. That trade needs its own issue.

Neither blocks this fix: a review can reach the sites changed here
without passing through either (the sort does no comparisons on a
single-file batch, and a file that fits its budget is never added to
`omitted`). Happy to file them if you'd like.
…aps (#489)

## What type of PR is this?

- [x] 🐛 Bug fix

## Description

Two coverage-bookkeeping defects let a review claim coverage it did not
actually have. Both feed `BudgetPlan.truncated()` and the APPROVE hold,
so they are fixed together on one surface.

**P2 — a patch-less changed file was silently counted as fully
reviewed.** GitHub returns `patch == null` for binary files and for text
diffs too large to display, while still reporting non-zero
`additions`/`deletions`. Such a file survived `reviewableFiles`
(`isPureRename` needs a zero change count) and was packed into a batch
as a bare `### … (modified, +N -M)` header with no ```` ```diff ````
body — so APPROVE was not held, a model "resolved" claim for it was
trusted, and the overview showed it as a normal reviewed file.
`DiffBudgetPlanner` now detects a reviewable file whose `patch()` is
null/blank while it has real changes and routes it to `omittedFiles`
instead of packing it. It then flows correctly: `omittedFiles` non-empty
⇒ `truncated()` true ⇒ APPROVE held; excluded from `batch.files()` ⇒ no
"resolved" claim trusted; listed under omitted in the overview.

**Lead#3 — a persistent single-batch failure discarded every successful
batch.** In `FindingPipeline.runMultiCall`, a batch that failed its
synchronous retry did `throw new IllegalStateException("Parallel batch
review failed", e)`, which bubbled to
`ReviewOrchestrator.handleReviewFailure` and reported the *whole* review
as failed — throwing away every already-succeeded batch's findings. It
now soft-fails like the on-request generators
(`DocGenerationService.generateEachBatch`, `PrImprovementService`): the
failed batch's files are recorded as uncovered on the shared
`BudgetPlan`, the successful batches keep their findings, and the
summary discloses the shortfall.

To carry the runtime failure from the review pass to the verdict (the
orchestrator builds one `BudgetPlan` and passes the same instance to
both `run()` and `VerdictBuilder.build()`), `BudgetPlan` gained a live
`runtimeUncoveredFiles` accumulator, written only through the
package-private `recordUncoveredFiles(...)`, with its record accessor
overridden to return a defensive copy (no mutable backing escapes —
SpotBugs-clean). New `effectiveOmittedFiles()`/`effectiveClippedFiles()`
fold it into the planned omissions, and a clipped file whose batch then
failed is reported once as omitted (never also as "partially analyzed"),
so the two findings never double-count.

## Related Issues

Refs audit a3-P2, a3-Lead#3. N/A (no tracked issue numbers).

## How Has This Been Tested?

- [x] Unit tests

Red/green per finding (verbatim red-phase failures captured against the
unfixed code):

**P2** — new
`DiffBudgetPlannerTest.aPatchlessChangedFileIsOmittedByNameNotSilentlyReviewed`
(and `aBlankPatchChangedFileIsOmittedWhileRealDiffsAreStillPacked`). A
`FileDiff("src/Huge.java","modified",4000,10,4010,null)` must land in
`plan.omittedFiles()`, make `truncated()` true, and never appear in a
batch's `files()`. Red (fix reverted):

```
[ERROR] DiffBudgetPlannerTest.aPatchlessChangedFileIsOmittedByNameNotSilentlyReviewed:258 expected: <[src/Huge.java]> but was: <[]>
[ERROR] DiffBudgetPlannerTest.aBlankPatchChangedFileIsOmittedWhileRealDiffsAreStillPacked:273 expected: <[src/Blob.bin]> but was: <[]>
```

The patch-less file was silently packed (`omittedFiles` empty). Green
after the fix.

**Lead#3** — new
`FindingPipelineTest.multiCallSoftFailsAPersistentlyFailingBatchAndKeepsTheSuccessfulOnes`
drives `runMultiCall` with batch 1 stubbed to fail all retries; it
asserts batch 2's findings survive, a summary is produced, and `a.java`
is disclosed as uncovered. Red (soft-fail reverted to the old throw):

```
[ERROR] FindingPipelineTest.multiCallSoftFailsAPersistentlyFailingBatchAndKeepsTheSuccessfulOnes -- Time elapsed: 6.817 s <<< ERROR!
java.lang.IllegalStateException: Parallel batch review failed
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.runMultiCall(FindingPipeline.java:212)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipeline.run(FindingPipeline.java:114)
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.multiCallSoftFailsAPersistentlyFailingBatchAndKeepsTheSuccessfulOnes(FindingPipelineTest.java:218)
Caused by: dev.thiagogonzaga.thrillhousebot.review.ai.AiReviewException: batch blew up
```

The whole review threw and lost the successful batch. Green after the
fix.

**APPROVE-hold chain (verdict level)** — new
`VerdictBuilderTest.aFailedBatchsRuntimeUncoveredFilesHoldApprovalAndAreDisclosedAsOmitted`
proves a plan carrying a runtime-uncovered file downgrades APPROVE →
COMMENT and names the file ("omitted entirely (failed.java)");
`aRuntimeUncoveredFileThatWasAlsoClippedIsCountedOnceAsOmitted` proves
no double-count.

**Pinned-defect test replaced:** the old
`multiCallPropagatesBatchFailureAsIllegalStateException` asserted the
exact behavior being fixed (whole review throws on a persistent batch
failure). It was rewritten to assert the corrected soft-fail behavior.

Gates (JDK 25): `spotless:apply` clean; `clean compile spotbugs:check
spotless:check` → SpotBugs "No errors/warnings found", BUILD SUCCESS;
full suite `./mvnw -B test` → **2398 tests, 0 failures, 0 errors, 0
skipped**.

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly (no user-facing
config/message defaults changed)
- [x] My changes generate no new warnings or errors

## Additional Notes

- Files touched are limited to the assigned set: `DiffBudgetPlanner`,
`FindingPipeline`, `VerdictBuilder`, and their tests.
`ReviewDiffFormatter` needed no patch-null render change.
`ReviewOrchestrator` was not modified — the runtime coverage gap reaches
the verdict through the shared `BudgetPlan` instance.
- Minor known cosmetic gap (not fixed here, would touch the non-owned
`ReviewResult`): the verdict's coarse coverage banner phrases every
omission as "exceeded the review budget", which reads slightly off for a
failed-batch file. The detailed summary overview (owned) discloses those
accurately ("not reviewed — the review call for it did not complete;
treated as uncovered").
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.12 to
3.4.13.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.13</h2>
<ul>
<li>Fixed an issue with hook removal during <code>IN_PLACE</code>
sanitization, thanks <a
href="https://github.com/koyokr"><code>@​koyokr</code></a></li>
<li>Fixed an issue with hooks potentially bypassing the clone guard,
thanks <a
href="https://github.com/AkshayjainG"><code>@​AkshayjainG</code></a></li>
<li>Fixed an issue with DOM clobbering via <code>ownerDocument</code>
during <code>IN_PLACE</code>, thanks <a
href="https://github.com/AkshayjainG"><code>@​AkshayjainG</code></a></li>
<li>Bumped several dependencies where possible</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/3067f774676975de12306effd6db6ad7a9a8c17f"><code>3067f77</code></a>
release: 3.4.13 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1562">#1562</a>)</li>
<li>See full diff in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dompurify&package-manager=npm_and_yarn&previous-version=3.4.12&new-version=3.4.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/devops-thiago/ThrillhouseBot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
)

## What type of PR is this?

- [x] ✨ Feature
- [x] 📝 Documentation

## Description

Two independent AI-configuration changes, one commit each.

### 1. `xhigh` and `max` reasoning-effort tiers

`AI_REASONING_EFFORT` was validated against a closed allow-list that
stopped at `high`. Newer reasoning models expose two tiers above it, so
an operator asking for either got a hard **startup failure** on a value
the provider would have accepted — the boot validator, not the provider,
was the thing rejecting it.

`ReasoningConfig.ALLOWED_EFFORTS` now reads `none, low, medium, high,
xhigh, max`, still in ascending cost/quality order. Nothing else in the
path changes: the effort already rides the OpenAI-compatible wire
verbatim via `ChatModelCustomizers.reasoningEffort(...)`, so a provider
that does not recognize a tier rejects the call rather than silently
downgrading it — the same contract `high` has always had. Validation
stays active while reasoning is disabled, so a typo is still caught at
boot.

### 2. Real caps for `deepseek-v4-flash`

`deepseek-v4-flash` carried only an **empty binding stub** in the models
map. An empty stub exists to make the env var bindable, not to supply a
value, so a deployment naming that model fell through to
`ModelSettings.DEFAULT_MAX_INPUT_TOKENS` — 128 000 tokens, an eighth of
the model's real window. Every review was budgeted far below what the
model could take, silently.

It now ships real values:

```properties
thrillhousebot.ai.models.deepseek-v4-flash.max-input-tokens=1000000
thrillhousebot.ai.models.deepseek-v4-flash.max-output-tokens=384000
```

Both remain overridable per deployment through the usual
`THRILLHOUSEBOT_AI_MODELS_DEEPSEEK_V4_FLASH_*` env vars — the entry
simply stops being a stub and starts being a default. It is the only
non-empty entry in that block, so a comment says why.

**One thing a reviewer should weigh.** `max-output-tokens` is not
budgeting metadata: `ChatModelCustomizers` maps it to
`builder.maxTokens(...)`, so `max_tokens=384000` now goes out on
**every** chat call for this model. Providers that validate `input +
max_tokens` against the context window will reject a call where the two
overshoot — an operator running `REVIEW_MAX_INPUT_TOKENS` near the 1 M
window has to keep the sum inside it. The properties comment says so at
the point of change. The token budgeter is unaffected; it reserves
output space through `review.output-buffer-tokens`, which is a separate
knob.

The shipped pricing for the model — `0.00014` / `0.00028` per 1K, i.e.
**$0.14 / $0.28 per 1M** — was already correct and is unchanged here; it
was simply untested, so it is now pinned (see below).

Docs for the reasoning tiers updated in `README.md`, `.env.example`, and
the `application.properties` comment. The website configuration page
includes the README section, so it picks the change up with no separate
edit; the versioned `website/src/content/docs/0.4.0/` snapshot and the
historical `CHANGELOG` entry are deliberately left alone — they describe
what shipped then.

## Related Issues

N/A — both are operator-reported gaps found while configuring a
reasoning model.

## How Has This Been Tested?

- [x] Unit tests

### Reasoning tiers — red/green on `StartupConfigValidatorTest`

**Red**, with
`acceptsEveryReasoningEffortCaseInsensitivelyWithWhitespace` extended by
`"XHigh"` and `" max "` (mixed case + whitespace, to pin normalization
on the new tiers too):

```
[ERROR] StartupConfigValidatorTest.acceptsEveryReasoningEffortCaseInsensitivelyWithWhitespace:566 ? ConfigValidation ThrillhouseBot cannot start ? required configuration is missing or invalid:
  - AI_REASONING_EFFORT must be one of none, low, medium, high (thrillhousebot.ai.reasoning.effort): XHigh
```

**Green** after the one-line `ALLOWED_EFFORTS` change.

One existing test had to change, and it is worth attention:
`failsFastWhenReasoningEffortIsInvalid` used **`max`** as its invalid
sample, which is now a valid tier. It uses `maximum` instead — still not
a tier — so the test keeps proving the same thing (an unrecognized value
fails fast) rather than being weakened; its message assertion tracks the
widened list. Its red phase:

```
[ERROR] StartupConfigValidatorTest.failsFastWhenReasoningEffortIsInvalid:516 ...
  - AI_REASONING_EFFORT must be one of none, low, medium, high (thrillhousebot.ai.reasoning.effort): maximum
  ==> expected: <true> but was: <false>
```

`rejectsInvalidReasoningEffortEvenWhileReasoningIsDisabled` (`hgih`) was
already unaffected and still passes.

### Model caps — red/green on `AiPricingConfigTest`

That test is a `@QuarkusTest` reading the real `application.properties`,
so it asserts against what actually ships.
`shouldShipDeepSeekV4FlashContextAndOutputCaps` run with the properties
change stashed away:

```
[ERROR] AiPricingConfigTest.shouldShipDeepSeekV4FlashContextAndOutputCaps:91 ? NoSuchElement No value present
```

— the empty stub resolving to an absent `Optional`, which is precisely
the bug. Green with the change applied.

To be straight about it: the companion
`shouldPriceDeepSeekV4FlashAtItsPublishedRate` is a **characterization
test, not a red/green one**. The rates were already correct in
`application.properties`; that test passes before and after and exists
to stop a future edit from moving them undetected.

### Gates, on the final tree

- `./mvnw -B spotless:apply` / `spotless:check` — clean
- `./mvnw -B clean compile spotbugs:check` — `BugInstance size is 0`,
BUILD SUCCESS
- `./mvnw -B clean test` — **Tests run: 2426, Failures: 0, Errors: 0,
Skipped: 0**

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

The reasoning change is purely widening — every previously valid value
stays valid and the default (`low`) is unchanged, so no deployment is
affected unless it opts into a new tier. Whether a given endpoint
honours `xhigh`/`max` is the provider's business; the bot passes the
value through and surfaces the provider's own error if it does not.

The model-caps change **is** a behavior change for anyone already
running `deepseek-v4-flash`: their effective input budget rises from the
128 000 fallback to `min(REVIEW_MAX_INPUT_TOKENS, 1000000)`, and calls
start carrying an explicit `max_tokens`. That is the intended
correction, but it is not a no-op, and it is the reason the `max_tokens`
caveat above is worth reading before deploying.
@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 2
  • Previous findings resolved: 0
  • Previous findings still open: 16

* it is not the existing {@code /resolve} command (which resolves GitHub review threads) under a
* second name — {@code TriggerDetector}'s {@code resolve} pattern ends in a word boundary and
* therefore does not fire on {@code resolved}.
*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM — Clear directive regex hardcodes @thrillhousebot instead of configured bot logins (medium confidence — verify before acting)

The CLEAR_DIRECTIVE pattern is hardcoded to '@thrillhousebot resolved' (Pattern.compile("@thrillhousebot\s+resolved\b(?!\s*\?)", Pattern.CASE_INSENSITIVE)). The repository convention (project instructions) states bot login(s) are configurable via thrillhousebot.github.bot-logins and that bot identity must be derived from BotIdentity/TriggerDetector, never a hardcoded literal. On a deployment using a different bot login, a maintainer's '@ resolved : — <title>' comment will not match isClearDirective, so the ack is not posted and the threadless-finding clear (#548) silently fails — and because mention detection is driven by the configured login, such comments may not even reach this code. The regex should be built from the configured bot identities rather than a fixed literal.

}
return String.join(" and ", parts) + " because the diff exceeded the review budget";
// The spend-ceiling class carries its own reason: these files fit the diff budget fine — the
// review ran out of tokens to pay for their calls — so the budget wording would misdirect the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 LOW — Patchless files are disclosed as exceeding the review budget

DiffBudgetPlanner now classifies a changed file with no/blank patch (binary or oversized text diff) as a coverage gap and omits it by name: '... there is nothing for the model to read — so packing it would count the file as fully reviewed ... Omit it by name like an unclippable file'. However, the only rendered reason for omitted files on the verdict/disclosure surfaces is 'because the diff exceeded the review budget' / '(omitted — exceeded the review call budget; not analyzed)'. That reason is false for patchless files — no budget increase would make a binary or huge-display-truncated file reviewable. Users are pointed at the wrong knob. Add a distinct 'no patch provided' omission class (or disclose patchless files with a neutral reason) so the reason matches the cause.

@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 1
  • Previous findings resolved: 0
  • Previous findings still open: 17

Comment on lines +986 to +989
var reviewable = ReviewDiffFormatter.namesOf(ctx.reviewableFiles());
var rows = new ArrayList<String>();
for (var file : ctx.files()) {
if (reviewable.contains(file.filename())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM — withheldMaterialNotice does a linear contains() per changed file — O(n^2) per review

withheldMaterialNotice builds var reviewable = ReviewDiffFormatter.namesOf(ctx.reviewableFiles()); (a List) and then, inside for (var file : ctx.files()), tests reviewable.contains(file.filename()). Both collections are sized by the PR's changed-file list, so the membership test inside the loop is the canonical dimension-5 quadratic shape: O(files × reviewable). A PR at GitHub's file-listing cap (~3000 files) costs millions of list scans on every review, on the ack-thread path. The fix is a hashed membership collection; Set.copyOf(...) is unsafe here because a FileDiff can carry a null filename (the codebase guards that elsewhere via ReviewDiffFormatter.namesContain), so a HashSet is the right replacement — null-tolerant and O(1) lookup.

Suggested change
var reviewable = ReviewDiffFormatter.namesOf(ctx.reviewableFiles());
var rows = new ArrayList<String>();
for (var file : ctx.files()) {
if (reviewable.contains(file.filename())) {
var reviewable = new java.util.HashSet<>(ReviewDiffFormatter.namesOf(ctx.reviewableFiles()));
var rows = new ArrayList<String>();
for (var file : ctx.files()) {
if (reviewable.contains(file.filename())) {

devops-thiago and others added 5 commits August 12, 2026 08:04
…595)

## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [ ] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

`AiReviewService`'s streaming completion handler built its truncation
with the **active** model's remedy and `conciseModelImplicated = false`,
and `summarize()` — whose call runs on the `concise` named model —
re-marked the flag after the throw:

```java
throw e.implicatingConciseModel();   // "same message, same partial body"
```

So a cut summary carried `conciseModelImplicated = true` alongside a
message telling the operator to raise `max-output-tokens`, the very knob
that flag exists to say does **not** cap this call. On that lane the two
never agreed — the red output below is the exception stating both at
once.

#542 gave the blocking path one source for both: `AiResponses.ModelLane`
carries the remedy text *and* decides the flag. The streaming path now
uses the same source:

- each entry point declares the binding its call runs on — `review` and
`reviewBatch` → `ACTIVE`, `summarize` → `CONCISE`;
- the lane travels through `runWithRetries` → `streamOnce` → the
completion handler, which asks it for the truncation
(`lane.truncation(detail, partialBody)`) instead of writing remedy text
of its own;
- `ModelLane#truncation` appends the remedy and sets the flag together,
so no caller can supply one without the other. `textOrThrowOnTruncation`
goes through it too, which is why the blocking lanes' message text is
byte-for-byte what it was.

`summarize()`'s after-the-fact re-marking is deleted. Nothing patches
the flag any more, so nothing can patch it out of step with the words —
which is the actual fix; the wrong remedy string was only the symptom.

**Behaviour that does not change:** #495's no-retry contract (the
truncation is still raised at the same point and still refused a retry),
the buffered `partialBody` that #500's salvage runs on,
`conciseModelImplicated` as seen by every consumer that branches on it
(`ReviewOrchestrator#truncationCheckSummary`, `ReviewPublisher`), and
every blocking lane's message.

## Related Issues

Fixes #581

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Red first, on unfixed main code (`AiResponses.java` and
`AiReviewService.java` reverted to their `fda4bc7` state). The failure
message is the defect itself — the flag assertion above it passes, then
the message contradicts it:

```
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.AiReviewServiceTest.aTruncatedSummarysMessageNamesTheSameCapItsConciseFlagDoes -- Time elapsed: 0.012 s <<< FAILURE!
org.opentest4j.AssertionFailedError: the message must name the cap its own flag points at: Model stopped at its response-length cap (finish_reason=length) after 30 characters, so the response is incomplete. Raise the active model's max-output-tokens, or leave it unset to use the provider default. ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.review.ai.AiReviewServiceTest.aTruncatedSummarysMessageNamesTheSameCapItsConciseFlagDoes(AiReviewServiceTest.java:1201)
```

The sibling test `aTruncatedReviewKeepsTheActiveModelsRemedy` passes
before and after by design: it pins the half that was already right, so
the fix cannot be "make everything say concise".

Green with the fix, and the gates re-run after the rebase onto
`1a2f22b`:

| gate | result |
|---|---|
| `./mvnw -B spotless:apply` then `./mvnw -B clean compile
spotbugs:check spotless:check` | `BugInstance size is 0` — BUILD SUCCESS
|
| `./mvnw -B clean test` | `Tests run: 2795, Failures: 0, Errors: 0,
Skipped: 0` — BUILD SUCCESS |
| jacoco ∩ `git diff -U0 1a2f22b...HEAD` | **0 uncovered lines, 0
uncovered branches** (15 trackable changed main lines; both `ModelLane`
arms exercised) |

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

`AiResponseTruncatedException#implicatingConciseModel()` is left in
place: with `summarize()` no longer calling it, it has no production
caller and cannot produce a contradiction, but removing it (or making it
rewrite the remedy, the issue's other option) means editing
`AiResponseTruncatedException.java` and the two tests that still build a
concise truncation through it — outside this change's file scope, so it
is flagged rather than done here.

Rebased onto `1a2f22b` after #592 merged. The predicted conflict was the
one `throw` in `textOrThrowOnTruncation` and nothing else, resolved as
this body said it should be — the lane-built truncation **and**
`result.content()` as the partial body:

```java
throw lane.truncation(
    what + " stopped at the model's response-length cap (finish_reason=length), so the response is incomplete.",
    result.content());
```

Verified by mutation rather than by reading the diff, since a resolution
that silently drops the #580 side would still compile and still pass
everything that predates #592. Putting `null` back in the resolved code
turns #592's own tests red:

```
[ERROR] AiResponsesTest.carriesTheCutBodyOnTheFailureSoTheLaneCanSalvageIt <<< FAILURE!
org.opentest4j.AssertionFailedError: the paid, cut body must travel with the failure ==> expected: <{"verdicts":[{"id":1,"verdict":"valid"},{"id":2,"verd> but was: <null>
[ERROR] AiResponsesTest.theCarriedBodyIsWhatTheSalvagerRecoversTheCompletedElementsFrom <<< FAILURE!
org.opentest4j.AssertionFailedError: the verdict that closed before the cut is recoverable ==> expected: <1> but was: <0>
```

`ModelLane#truncation`'s javadoc picked up the merged reality in the
same resolution: the partial body is now the cut text on *both* paths —
`Result#content()` blocking, the buffered stream streaming — rather than
"null when the lane does not buffer one".
…mment (#596)

## What type of PR is this?

- [x] 🐛 Bug fix

## Description

One observation was being published on up to three surfaces of the same
summary comment — as an
inline finding, again as a "Description vs. Implementation" bullet, and
again as its own clause in a
Changed Files walkthrough row — with nothing tying the copies together.
Readers infer severity from
repetition, so a low-value note raised three times outranked the
severest finding in the same
comment (on ThrillhouseBot-test #23 it outranked a SQL injection), and
the duplicate emissions
inflated the apparent finding count.

The renderer already holds the full set it is about to publish across
all three surfaces, so it now
collapses each claim to its most specific surface before rendering
anything:

- **inline finding > description-gap bullet > walkthrough row.** A
description gap that restates a
finding is dropped; so is a second gap that only rephrases an earlier
one. All findings are
considered, not just the five that reach "Key Findings" — on
ThrillhouseBot-test #22 the duplicated
  claim was 8th by severity and never appeared in that list.
- **Walkthrough rows are preserved.** A row summarising a file that also
carries an inline finding is
normal and useful, so the row's *first* clause is never touched; only
the clauses appended after it
that restate an already-published claim are removed. A row therefore
always keeps a real
description of its file, and a single-clause note is returned untouched.

Two texts state the same claim when they share a contiguous run of three
content words, or when
their content words overlap by half of the shorter side. Polarity does
not gate that test in
general — it holds a pair back only when the two say the same things
with opposite polarity, one
negating and every content word of one side present in the other
(containment in either direction).
Such a pair scores as a perfect match while asserting opposite things,
because a negator contributes
a single token and no similarity score can separate "the value is
sanitized" from "the value is not
sanitized"; that is a contradiction to surface, never a duplicate to
delete. Two texts that disagree
on polarity but each name something the other leaves out are still
judged on their content, so a gap
quoting the PR's affirmative promise still collapses onto the finding
reporting the absence.
Contracted negations ("isn't", "won't") are rewritten before tokenizing,
since content words are
split on non-alphanumeric runs and would otherwise tear "isn't" into
"isn" and "t". The overlap arm
requires five content words on the shorter side — below that the
coefficient is noise, and keeping
both copies is the safe direction.

Under-firing is deliberately the safe direction throughout: a missed
duplicate is the status quo,
while a false collapse silently deletes a claim the model made.

The regression material is the corpus itself — the fixtures in the new
tests are the verbatim text
ThrillhouseBot published on devops-thiago/ThrillhouseBot-test #23 (Java)
and #22 (C).

## Related Issues

Fixes #588

## How Has This Been Tested?

- [x] Unit tests

Red/green proof. With the renderer change reverted
(`SummarySurfaceDeduplicator` removed,
`PrSummaryGenerator` restored), the new tests fail exactly as claimed —
the flagship case shows the
same claim on all three surfaces at once:

```
[ERROR] Tests run: 60, Failures: 3, Errors: 0, Skipped: 0, Time elapsed: 1.261 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.PrSummaryGeneratorTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.PrSummaryGeneratorTest.restatedDescriptionGapAndWalkthroughClauseCollapseToTheInlineFinding -- Time elapsed: 0 s <<< FAILURE!
org.opentest4j.AssertionFailedError:
## 🤖 ThrillhouseBot PR Summary

### ⚠️ Description vs. Implementation
The PR description does not fully match the change:
- The runnable entry point passes a hardcoded empty task list to `dispatchDueTasks`, so the shipped service cannot actually dispatch any task.
- PR says TaskRunRepository persists run history, but the added class only contains searchRunsByTaskName; no insert/update/upsert write path is present in the diff.

### Changes Overview
- **Files changed:** 1
- **Lines added:** +30
- **Lines removed:** 0

### Changed Files
| File | Change | Summary |
|------|--------|---------|
| `src/main/java/com/thrillhouse/scheduler/Main.java` | Added | Added one-shot entry point; hardcodes an empty task list and reads only registry URL env var |

### Risk Assessment
| Risk | Count |
|------|-------|
| 🔴 Critical | 0 |
| 🟠 High | 1 |
| 🟡 Medium | 0 |
| 🔵 Low | 0 |

### Key Findings
- **HIGH:** Main passes a hardcoded empty task list, so nothing is ever dispatched (`src/main/java/com/thrillhouse/scheduler/Main.java:22`)

---
*Automated review by ThrillhouseBot. Reply with `/review` to re-run.*
 ==> expected: <false> but was: <true>
	at org.junit.jupiter.api.Assertions.assertFalse(Assertions.java:266)
	at dev.thiagogonzaga.thrillhousebot.review.PrSummaryGeneratorTest.restatedDescriptionGapAndWalkthroughClauseCollapseToTheInlineFinding(PrSummaryGeneratorTest.java:1261)

[ERROR] PrSummaryGeneratorTest.restatedGapCollapsesEvenWhenTheFindingIsNotAKeyFinding:1329 ==> expected: <false> but was: <true>
[ERROR] PrSummaryGeneratorTest.descriptionGapsSectionDisappearsWhenEveryBulletRestatesAFinding:1378 ==> expected: <false> but was: <true>
```

With the fix in place all three pass, and the full suite is green.

Gates:

- `./mvnw -B spotless:apply` then `./mvnw -B clean compile
spotbugs:check spotless:check` — `BugInstance size is 0`, BUILD SUCCESS.
- `./mvnw -B clean test` — BUILD SUCCESS, no failures, no errors.
- Coverage: jacoco ∩ `git diff -U0 fda4bc7...HEAD` shows zero uncovered
lines and zero uncovered
  branches across the changed main code.

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

The dedupe pass is deliberately conservative. Two known duplicates in
the corpus survive it: a
walkthrough clause of only four content words ("query is vulnerable to
SQL injection" against the
finding "SQL injection in task-name search query") falls under the
minimum-token guard, and a
clause phrased entirely differently from its finding ("degraded flag is
true whenever any dispatch
was attempted") scores below the overlap threshold. Both are the
tolerable failure direction:
publishing a claim twice is the current behaviour, while a wrong
collapse would delete it.

Review follow-up (da65366): the bot's own review of this PR caught a
false-collapse route through
the stop list — "no"/"not"/"does" were dropped as function words, so a
negated paraphrase tokenized
identically to the finding it contradicts and was deleted. Fixed by
tracking polarity separately,
with three regression tests; see the resolved thread for the red output
and why removing the
negators from the stop list alone would not have been sufficient.

Review follow-up (fdd9602): the re-review found the same false-collapse
still reachable through
contracted negations — content words split on non-alphanumeric runs, so
"doesn't" tore into "doesn"
and "t" and the sentence read as affirmative. Contractions are now
rewritten to a bare "not" before
the split, irregular stems included, so no stray content word is left
behind. The same review caught
the class javadoc overclaiming that collapsing requires polarity
agreement; the behaviour is
deliberate and test-pinned, so the documentation was corrected rather
than the code.

Review follow-up (this round): a third review pass found both javadocs
still describing the polarity
override as mutual containment ("neither names anything the other leaves
out") while the code fires
on containment in either direction. The one-directional behaviour is
correct — a short negated claim
lying wholly inside a longer affirmative one is exactly the
contradiction that must be surfaced, and
tightening to mutual containment would reintroduce the silent deletion —
so both javadocs were
corrected to state the condition the code implements, documentation
only.

Review follow-up (6bcb075): a fourth pass found the `stem()` javadoc
claiming it makes "the plural,
past and third-person forms of one word collide", which overstates it —
stripping trailing s/e/d
leaves "verifies" as "verifi" while "verify" keeps its y, so y→i
inflections never meet. The
stemmer is unchanged: missing a y→i collapse lets a duplicate survive,
the under-firing direction
this class prefers, while widening it would merge more words and risk
deleting a claim. The comment
now states the rule it implements and names the uncaught case,
documentation only.

---------

Co-authored-by: Thiago Gonzaga <thiago@ensina.tech>
…produced (#597)

## What type of PR is this?

- [x] ✨ Feature

## Description

#568 made a throttled post survivable; it does not stop the burst being
produced. In the run behind
it, 56 commands across 8 PRs created content faster than GitHub would
accept it and drew **29
rejections** — every one a wasted round trip, a spent retry attempt, and
a dispatcher slot held
while a doomed call backed off.

`GitHubWritePacer` spaces content-creating calls out instead, so the bot
stays inside GitHub's
secondary-rate-limit envelope rather than discovering it by rejection.

**Where it sits.** On the same seam as the backoff —
`GitHubWriteRetry#call` — which is exactly the
set GitHub counts as content creation: `createComment`, `updateComment`,
`createReview`,
`createPullRequestComment`, `replyToReviewComment`. It is therefore
process-wide and shared across
reviews and commands rather than per review, which is what the issue
asks for: the bot produces a
burst without anyone doing anything unusual.
`ReviewPublisher#tryPostInlineComment` issues one
content-creating call per inline finding, and several PRs are reviewed
concurrently.

**How a slot is claimed.** Each caller atomically claims the next free
instant and advances a shared
cursor by one interval, then waits until the instant it claimed.
Claiming is a single atomic update
with no lock held across the HTTP call, so a slow request never blocks
the queue behind it, and
callers go out in the order they arrived instead of in a thundering herd
when the interval elapses.

**Both numbers are knobs rather than constants**, as the issue asks:

| key | env | default | meaning |
| --- | --- | --- | --- |
| `thrillhousebot.github.write-min-interval` |
`GITHUB_WRITE_MIN_INTERVAL` | `1s` | spacing between two
content-creating requests; `0` disables pacing entirely |
| `thrillhousebot.github.write-max-wait` | `GITHUB_WRITE_MAX_WAIT` |
`60s` | ceiling on how long one caller waits for its slot |

The default is GitHub's published guidance: no more than one
content-creating request per second.

**Why there is a ceiling.** A wait holds the per-PR serialization slot
in the dispatcher — the same
reason #568's backoff is bounded twice over. Past the ceiling the call
goes out unpaced and the
bounded backoff handles a refusal, which is exactly where the bot is
today, rather than parking a
finished command for minutes.

**Why pacing can never cost a payload.** A pacing wait that is
interrupted proceeds with the call
rather than failing it. The content on its way out has already been paid
for, so the worst this
limiter is ever allowed to do is let a burst through.

**How this composes with #568 and #578.** The pacer is the preventative
form: it keeps the burst
from being produced. The backoff remains the fallback for throttling
pacing cannot prevent — another
instance of the App, or a repo busy for reasons the bot did not cause —
and #578's dropped-post
notice is the last resort when even that runs out. Every attempt is
paced, repeats included, so a
repeat queues rather than jumping the limiter.

## Related Issues

Fixes #579

## How Has This Been Tested?

- [x] Unit tests
- [x] Integration tests
- [ ] Manual testing

`GitHubWritePacerTest` drives the clock by hand and records the waiting
instead of serving it, so
what is pinned is the arithmetic of the shared cursor: the lone write
that is not delayed at all, a
burst handed out one interval apart, a queue that drained charging
nothing, the ceiling clamping a
long queue, `0` disabling pacing, an interrupted wait sending the call
anyway, and the knob falling
back to the guidance when unset. `GitHubWriteRetryTest` gains a case
showing a throttle repeat is
paced too.

`GitHubWritePacingTest` is a `@QuarkusTest` driving a real REST client
against a loopback GitHub —
the only thing that can show the limiter is on the path a comment
actually takes, and that it holds
when the burst is genuinely concurrent. It asserts on when the requests
*arrive at GitHub*, not on
anything the bot recorded about itself.

### Red/green proof

`GitHubWritePacingTest` compiles unchanged against the base commit, so
it was run there:

```
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 3.075 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.github.GitHubWritePacingTest
[ERROR] dev.thiagogonzaga.thrillhousebot.github.GitHubWritePacingTest.aConcurrentBurstOfCommentsReachesGitHubSpacedOutRatherThanAllAtOnce -- Time elapsed: 0.586 s <<< FAILURE!
org.opentest4j.AssertionFailedError: content-creating calls reached GitHub [0, 0, 0]ms apart; its envelope is one per second, and a burst tighter than that is exactly what it answers with 403 ==> expected: <true> but was: <false>
	at org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:199)
	at dev.thiagogonzaga.thrillhousebot.github.GitHubWritePacingTest.aConcurrentBurstOfCommentsReachesGitHubSpacedOutRatherThanAllAtOnce(GitHubWritePacingTest.java:128)
```

`[0, 0, 0]ms apart` is the defect verbatim: four concurrent posts all
reach GitHub inside the same
millisecond, which is the burst it answers with 403. With the fix they
arrive a second apart.

### Gates

- `./mvnw -B spotless:apply` → BUILD SUCCESS
- `./mvnw -B clean compile spotbugs:check spotless:check` → `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` → **Tests run: 2779, Failures: 0, Errors: 0,
Skipped: 0**
- JaCoCo ∩ `git diff -U0 fda4bc7...HEAD` → **0 uncovered lines, 0
uncovered branches** across the 38 trackable changed main-code lines
(`GitHubWritePacer` 32, `GitHubWriteRetry` 6)

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

The limiter is quiet by design — it logs at debug, because a slot wait
is normal operation rather
than an incident:

```
DEBUG [GitHubWritePacer] Pacing a comment on owner/repo #3 — waiting 1000ms for a content-creation slot
```

The line that used to appear instead is the 403 the wait prevents.

## Additional Notes

`ThrillhouseConfig.GitHubConfig` gains the two knobs. That is not
optional: `@ConfigMapping(prefix =
"thrillhousebot")` validates its whole namespace, so an undeclared
`thrillhousebot.github.*` key
fails startup outright —

```
io.smallrye.config.ConfigValidationException: Configuration validation failed:
	SRCFG00050: thrillhousebot.github.write-min-interval ... does not map to any root
```

— and the declaration is the namespace's schema. The limiter itself
reads the key directly, because
it lives on the REST clients' `default`-method write path where there is
no CDI.

The follow-up for #578 (announcing a dropped post on the PR) touches a
disjoint set of files —
`GitHubCommentClient`, `GitHubReviewClient` and a new class — so the two
merge cleanly in either
order.


## Review follow-up

Both of the review's lower-confidence findings were checked against the
code rather than argued
with. Both turned out to be right about the *evidence* being thin and
wrong about there being a
defect, so **nothing under `src/main` changed** and two tests were added
instead.

**"Verify all five content-creating methods dispatch through
`GitHubWriteRetry.call`."** They do —
all five, and nothing else does:

```
$ grep -n "GitHubWriteRetry.DEFAULT.call" src/main/java/dev/thiagogonzaga/thrillhousebot/github/*.java
GitHubCommentClient.java:58    createComment
GitHubCommentClient.java:136   updateComment
GitHubReviewClient.java:56     createReview
GitHubReviewClient.java:172    createPullRequestComment
GitHubReviewClient.java:202    replyToReviewComment
```

Five hits, five methods, no other call sites — and `call` paces
unconditionally, so reaching it *is*
being paced. The finding is right that only one of the five was
demonstrated end to end, and that is
worth closing because it is this PR's central claim.
`GitHubWritePacingTest.everyContentCreatingCallIsPacedAndNotJustTheConversationComment`
now fires all five at once against the loopback GitHub and asserts every
arrival is spaced. On the
base commit it fails exactly as the finding predicts an unpaced path
would:

```
org.opentest4j.AssertionFailedError: content-creating calls reached GitHub [1, 2, 0, 0]ms apart; its envelope is one per second, and a burst tighter than that is exactly what it answers with 403 ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.github.GitHubWritePacingTest.everyContentCreatingCallIsPacedAndNotJustTheConversationComment(GitHubWritePacingTest.java:199)
```

**"Documented '0 disables pacing' value never exercised through the
config converter."** It survives
it. Measured against the real SmallRye converter rather than reasoned
about:

```
PROBE raw=0    -> PT0S
PROBE raw=0s   -> PT0S
PROBE raw=0S   -> PT0S
PROBE raw=PT0S -> PT0S
```

So a bare `0` converts to `Duration.ZERO`, the documentation is
accurate, and the startup-validation
failure the finding feared does not occur — no wording changed. The gap
it identifies is real
though: that promise runs through the converter, while the only
zero-interval test handed
`Duration.ZERO` straight to the constructor.
`GitHubWritePacerTest.theDocumentedBareZeroSurvivesTheConfigConverterAndReallyDisablesPacing`
now sets the property, converts it, and paces a burst with the result,
because the failure mode
would otherwise stay invisible until an operator reached for the knob
during an incident.
#598)

## What type of PR is this?

- [x] ✨ Feature

## Description

The backoff from #568 is bounded, so a persistently throttled reply is
still dropped once the
attempts are spent. Today that is logged and nothing else: from the pull
request the command simply
never answered, which is indistinguishable from the silent-decline class
#538 fixed. The user cannot
tell whether the bot ignored them, crashed, or lost the post — and
nothing says the right move is to
run it again.

**The catch the issue names.** The obvious remedy — reply "this was
throttled, please re-run" — is
itself a `createComment`: the exact call being throttled, sent at the
exact moment GitHub is refusing
it. Giving it its own budget just spends more attempts on the same
refusal.

**So the notice is never posted on its own.** `GitHubLostWrites` holds
it, and the *next* content the
bot successfully lands on that pull request carries it up front:

> [!WARNING]
> **An earlier reply on this pull request was never posted.** GitHub was
rate-limiting the bot and
> the retries ran out, so work it had already finished was thrown away.
If you were waiting on an
> answer, run the command again.

That costs no additional content-creating request — which is the whole
point of #579 — cannot be
throttled separately from the post it travels with, and appears on the
pull request the loss
happened on rather than in a log the user cannot read. Of the three
options the issue weighs, this
is the "persist the pending notice and post it on the next successful
interaction" one; it also
needs no change in `review/` or `webhook/`, so every posting path
inherits it at the client boundary
instead of each fail-soft handler having to learn about throttling.

**Which calls do what.** `createComment` and `createReview` both land in
the conversation, so they
carry a notice. An inline comment and a thread reply are anchored to a
diff line and are a poor place
to announce an unrelated loss, so they leave a notice behind without
carrying one — losing an inline
finding is still a loss the PR should hear about. `updateComment` does
neither: it identifies its
target by comment id, so this layer cannot tell which pull request it
belongs to.

**What it deliberately does not claim.**

- *Only a throttle counts.* A permission 403 or a 422 is a defect to
fix, not a command to re-run,
  and announcing those would put a warning on every single comment.
- *The wording does not name the command.* This layer sees a comment on
a pull request, not the
`/describe` or `/improve` behind it. An honest "if you were waiting on
an answer, run it again"
  beats a guess.
- *A notice is cleared only once the post carrying it has landed*, so a
post that is itself dropped
does not take the notice with it — and a loss that arrives while a
notice is in flight is still
  announced next time.
- *It cannot promise delivery* on a pull request the bot never writes to
again. That case is no worse
  than today's log-only behaviour.

**Bounded, and quiet when stale.** A notice is forgotten after six
hours, because "a reply was
dropped" glued onto a comment days later is noise rather than a signal,
and the registry holds at
most 200 pull requests so a sustained outage cannot grow it without end.

**Why prepending is safe.** `ReviewContextLoader.isBotSummaryComment`
matches the heading on any
line, not at the start of the body — precisely because the truncation
banner already precedes it —
and no code in the repository matches a comment body with `startsWith`.

**How this composes with #568 and #579.** #579's pacer keeps the burst
from being produced, #568's
backoff absorbs the throttling pacing cannot prevent, and this is the
last resort for when even that
runs out. The three are strictly ordered by cost, and this one is the
only one the user ever sees.

## Related Issues

Fixes #578

## How Has This Been Tested?

- [x] Unit tests
- [x] Integration tests
- [ ] Manual testing

`GitHubLostWritesTest` drives the carrier directly on a hand-held clock:
a quiet PR carrying nothing,
a dropped reply announced on the next comment, the notice said once
rather than on everything
afterwards, a notice kept when the post meant to deliver it is itself
dropped, a loss that lands
while a notice is in flight, scoping to the PR that lost the post, a
permission refusal staying
silent, the six-hour staleness cut-off, the registry cap (including a PR
already holding a notice
still counting at capacity) and an expired notice making room for a new
one.

`GitHubDroppedCommentNoticeTest` is a `@QuarkusTest` driving a real REST
client against a loopback
GitHub. It asserts on **the body GitHub receives**, because that is the
only thing the user ever
sees; the log line the retry already writes is exactly what the issue
says is not enough.

### Red/green proof

`GitHubDroppedCommentNoticeTest` compiles unchanged against the base
commit, so it was run there:

```
2026-08-12 00:39:08,950 WARN  [dev.thiagogonzaga.thrillhousebot.github.GitHubWriteRetry] (main) GitHub still throttling a comment on owner/repo #5882 after 3 attempts — the generated content is lost and the command has to be re-run. status=403 retry-after=0 x-ratelimit-remaining=0 body={"message":"You have exceeded a secondary rate limit. Please wait a few minutes before you try again.","documentation_url":"https://docs.github.com/rest"}
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.392 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.github.GitHubDroppedCommentNoticeTest
[ERROR] dev.thiagogonzaga.thrillhousebot.github.GitHubDroppedCommentNoticeTest.aReplyGitHubThrewAwayIsAnnouncedOnTheNextCommentThatLands -- Time elapsed: 0.452 s <<< FAILURE!
org.opentest4j.AssertionFailedError: {"body":"the answer to /describe"} ==> expected: <true> but was: <false>
	at org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:232)
	at dev.thiagogonzaga.thrillhousebot.github.GitHubDroppedCommentNoticeTest.aReplyGitHubThrewAwayIsAnnouncedOnTheNextCommentThatLands(GitHubDroppedCommentNoticeTest.java:135)
```

The assertion message is the whole defect in one line: the very next
comment GitHub receives is
`{"body":"the answer to /describe"}` and nothing else. The log knows a
reply was thrown away and the
pull request never hears about it.

### Gates

- `./mvnw -B spotless:apply` → BUILD SUCCESS
- `./mvnw -B clean compile spotbugs:check spotless:check` → `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` → **Tests run: 2787, Failures: 0, Errors: 0,
Skipped: 0**
- JaCoCo ∩ `git diff -U0 fda4bc7...HEAD` → **0 uncovered lines, 0
uncovered branches** across the 79
trackable changed main-code lines (`GitHubLostWrites` 62,
`GitHubReviewClient` 13,
  `GitHubCommentClient` 4)

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

The loss still says so in the log, and now says what will happen about
it:

```
WARN [GitHubWriteRetry] GitHub still throttling a comment on owner/repo #14 after 3 attempts — the generated content is lost and the command has to be re-run. status=403 retry-after=0 x-ratelimit-remaining=0 body={"message":"You have exceeded a secondary rate limit. ..."}
WARN [GitHubLostWrites] Lost a throttled post on owner/repo #14 — the next comment the bot lands there will say so (1 now pending)
```

## Additional Notes

Every file touched is inside `dev.thiagogonzaga.thrillhousebot.github`;
no caller signature changed
and no fail-soft handler in `review/` or `webhook/` needed to move.

The companion PR for #579 (pacing content-creating calls) touches a
disjoint set of files —
`GitHubWriteRetry`, a new limiter, `ThrillhouseConfig` and
`application.properties` — so the two
merge cleanly in either order. Once both land, the notice becomes rarer:
the pacer stops most of the
bursts that produce the throttling in the first place.


## Review follow-up

**"Concurrent carrying posts can clear a notice that arrived in
flight."** Correct, reachable, and
fixed — this is the one direction the feature cannot afford to fail in.

The original `settle` subtracted each carrier's snapshot from one shared
count. With two posts on
the same pull request in flight, both having read `pending = 1`, and a
third post thrown away
between their reads and their completions:

| step | shared count | what happened |
| --- | --- | --- |
| both carriers read | 1 | each carries `notice(1)` |
| a third post is dropped | 2 | nobody has carried this one |
| carrier A settles (`carried = 1`) | 1 | `2 > 1`, so subtract |
| carrier B settles (`carried = 1`) | **0** | `1 > 1` is false, so the
entry is **removed** |

The second loss is retired having been announced by nobody, and the user
is never told their content
was dropped — precisely the silence this PR exists to remove. I had
noted the *over*-announcing side
of this overlap as benign and accepted it; the review is right that the
clearing side is the opposite
direction and is not acceptable.

**The fix** replaces the outstanding count with two monotonic
watermarks, `lost` and `announced`
(`pending = lost - announced`). A delivered post advances `announced`
towards the `lost` value it
actually carried, and the watermark never moves backwards, so a second
carrier holding the same
snapshot finds `announced >= carried.lost` and leaves the entry alone.
Replaying the table above:
carrier B advances `announced` to 1, carrier A is a no-op, and `pending`
stays 1 — the third loss is
still waiting and rides the next comment. The overlap can now only ever
repeat a notice, never drop
one, and repeating is the harmless direction.

No locking was added: serializing `carrying` per target would hold a
lock across an HTTP call that
may itself back off for up to a minute.

### Red/green proof


`GitHubLostWritesTest.aLossThatLandsWhileTwoPostsCarryTheSameNoticeIsStillAnnouncedAfterwards`
nests one `carrying` inside another so the inner post runs entirely
between the outer post's read
and its completion — the interleaving described above,
deterministically. Against this PR's previous
head (`b0973e7`), where the other 14 cases in the class still pass:

```
[ERROR] Tests run: 15, Failures: 1, Errors: 0, Skipped: 0 -- in dev.thiagogonzaga.thrillhousebot.github.GitHubLostWritesTest
org.opentest4j.AssertionFailedError: the loss recorded between the two overlapping posts was retired without ever being announced; the next comment carried: "" ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.github.GitHubLostWritesTest.aLossThatLandsWhileTwoPostsCarryTheSameNoticeIsStillAnnouncedAfterwards(GitHubLostWritesTest.java:181)
```

`the next comment carried: ""` is the defect verbatim: the pull request
says nothing at all.


### Second round: "Announced (zero-pending) entries are never removed
and can exhaust the registry cap"

Correct, and a regression the watermark fix above introduced. Before it,
`settle` removed the entry
the moment nothing was outstanding; after it, a delivered entry sat at
`pending == 0` with `lost ==
announced > 0` and only the TTL could retire it.

**What evicted an entry, before this round.** Two paths, both TTL:

```
snapshot() :169   pending.remove(target, loss)          — expired, on the read path
remember() :194   pending.values().removeIf(expired)    — expired, on the write path
announce() :184   computeIfPresent(...)                 — never removed anything
```

**So the TTL does not cover it.** It bounds growth — the map never
exceeds the cap and a settled
entry is swept six hours after its *last loss*, since `announce`
preserves `loss.at()`. What it does
not do is free the slot in the meantime. Once `DEFAULT_MAX_TARGETS` pull
requests have lost a post
inside one six-hour window, every slot can be a settled entry, and the
next pull request to lose one
takes the `pending.size() >= maxTargets && !pending.containsKey(target)`
branch and is only logged —
the silence this PR removes, arriving from the other end, and with
nothing outstanding to protect
the slots it was denied.

**The fix** drops an entry as soon as its last outstanding notice is
delivered, so a slot is held
only while a pull request is genuinely still owed one — which is what
`DEFAULT_MAX_TARGETS` claims
to bound.

**The guard the finding asked for is real, and it is load-bearing.**
Both watermarks restart when a
later loss recreates an entry, so a carrier still in flight from the
previous run holds a snapshot
whose `lost` can equal the new run's and would retire a loss it never
carried. Each run of losses
now carries an `id`, and a delivery only counts when `loss.id() ==
carried.id()`. This is not
defensive coding: deleting that one condition and leaving everything
else in place makes
`aCarrierLeftOverFromASettledEntryCannotRetireALaterLoss` fail.

### Red/green proof


`GitHubLostWritesTest.aRegistryFullOfAlreadyDeliveredNoticesStillHasRoomForANewLoss`
fills both
slots of the two-target fixture, has both notices delivered, then loses
a post on a third pull
request. Against the previous head (`c942633`), with the other 16 cases
in the class still passing:

```
[ERROR] Tests run: 17, Failures: 1, Errors: 0, Skipped: 0 -- in dev.thiagogonzaga.thrillhousebot.github.GitHubLostWritesTest
org.opentest4j.AssertionFailedError: the registry was full of already-delivered notices, so the new loss was only logged; the next comment carried: "" ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.github.GitHubLostWritesTest.aRegistryFullOfAlreadyDeliveredNoticesStillHasRoomForANewLoss(GitHubLostWritesTest.java:240)
```
…ng it (#602)

## What type of PR is this?

- [x] 🐛 Bug fix

## Description

Mock fidelity scored **2 YES / 6 PARTIAL** across the round-3 corpus,
and
"PARTIAL" meant one specific thing every time:

> the exact reasoning appears, in prose, inside the body of a
*different*
> finding — and is never emitted as a finding of its own.

React #26 stated the defect verbatim inside the producer/consumer
finding
("both fixture scores are below threshold, so it can't distinguish
correct
filtering from push-everything"). Angular #25 quoted the spec file's own
concession into a walkthrough row, reused it as supporting evidence
inside
finding 5, and still never raised it. C# #24 noticed it three separate
times,
inside findings 1, 2 and 4.

So the model has already judged the defect worth writing down — this is
not the
reporting-threshold problem that #545's rebalancing fixed for the
performance
and comment-contradiction dimensions. The failure is that a supporting
observation made while building finding A never gets promoted into its
own
finding B, even when it is a different defect, in a different file, on a
different dimension.

### What changed

`PrReviewPrompts.SYSTEM`, in the block that already governs emission
("Severity
is not confidence, and neither one is a reason to stay silent"), gains
the
promotion rule and the sweep:

- Every defect gets its own finding, on the dimension it belongs to.
Stating a
  second, different defect inside another finding's description, in a
`summary.file_summaries` row, or in a `description_gaps` entry is
**not**
reporting it — those surfaces carry no severity, no anchor line and no
review
  thread, so a defect that appears only there reaches nobody.
- The rule is explicitly disambiguated from the neighbouring "Report
each
underlying defect exactly once", which forbids restating *one* defect at
several lines rather than filing a *second* one. Reading the two
together is
  a plausible reason the model treats a bury as compliance.
- A closing sweep: re-read each finding description, each
`file_summaries` line
and each `description_gaps` entry for statements describing a defect no
  finding covers, and promote each into its own finding at the risk and
confidence its own dimension prescribes. Framed as promotion of material
already written, not new analysis — which is what the issue's option 1
asks
  for and what makes it cheap.

Dimension 8 and the injected `MOCK_FIDELITY_REQUEST` block repeat it at
the
point the contradiction is actually noticed: while building the finding
it was
evidence for.

## Related Issues

Fixes #587

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Prompt-text change: exempt from red/green proof per the round's
standards, and
covered by the deterministic anchor test the repo already uses for this
file.

`PrReviewPromptsContentTest.aDefectOnAnotherDimensionMustBePromotedOutOfTheFindingItSupports`
pins the seven markers (five in `SYSTEM`, one in dimension 8, one in
`MOCK_FIDELITY_REQUEST`) so a future edit cannot silently revert the
guidance.

Whether the model *acts* on it is the corpus's job, and the issue states
the
regression test it already provides: the corpus plants exactly one
defect per
dimension per PR, so a dimension whose reasoning appears in the response
but
not in the finding list is mechanically detectable.

### Gates

```
./mvnw -B spotless:apply                                    OK
./mvnw -B clean compile spotbugs:check spotless:check       BugInstance size is 0 / BUILD SUCCESS
./mvnw -B clean test                                        Tests run: 2769, Failures: 0, Errors: 0, Skipped: 0
```

Coverage — jacoco ∩ `git diff -U0 fda4bc7...HEAD`:

```
src/main/java/.../review/ai/PrReviewPrompts.java: 29 changed lines, 0 instrumented,
0 uncovered lines, 0 lines with uncovered branches
RESULT: CLEAN
```

All 29 changed main lines sit inside compile-time `String` constants, so
they
carry no bytecode of their own; the class's own initialization is
exercised by
the content test.

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

The issue notes that the performance dimension regressed the same way in
three
languages and may share this root cause. The rule added here is written
per
*defect*, not per dimension, so it covers that case too — and the sweep
names
dimensions 4, 5 and 8 as the ones most often lost, since their evidence
is
exactly what gets cited in support of something else.

Co-authored-by: Thiago Gonzaga <thiago@ensina.tech>
@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 1
  • Previous findings resolved: 0
  • Previous findings still open: 16

Comment thread .env.example
Comment on lines +89 to +92
# Optional: line cap on single-call diff renders (/add-docs, replies, base comparison,
# budgeting-disabled review). Token-budgeted reviews and the batched commands — /improve,
# /describe, /changelog, /generate-tests — ignore it (DiffBudgetPlanner owns coverage by
# tokens); 0 = off

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 LOW — .env.example still lists /add-docs as line-capped though the command now batches by tokens

The updated .env.example comment reads: "# Optional: line cap on single-call diff renders (/add-docs, replies, base comparison," / "# budgeting-disabled review). Token-budgeted reviews and the batched commands — /improve," / "# /describe, /changelog, /generate-tests — ignore it (DiffBudgetPlanner owns coverage by" — i.e. it still names /add-docs as one of the renders the REVIEW_MAX_DIFF_LINES line cap applies to. The README updated in the same PR says the opposite: "Token-budgeted reviews and the batched commands — /improve, /describe, /changelog, /generate-tests, /add-docs — ignore it (the planner owns coverage by tokens)". The implementation agrees with the README: DocGenerationService now extends AbstractPrSuggestionGenerator, plans batches through planBatches, and no longer calls buildDiffStringWithStats for the model (its comment says "The whole-PR render is deliberately absent"), so /add-docs is token-budgeted like its siblings and never consults the line cap. This is the "comment left describing the behavior this very change replaced" class: the .env.example statement is false after this PR. Risk low — no configuration value breaks, but an operator tuning /add-docs coverage against the .env.example reference gets the wrong mechanism.

Suggested change
# Optional: line cap on single-call diff renders (/add-docs, replies, base comparison,
# budgeting-disabled review). Token-budgeted reviews and the batched commands — /improve,
# /describe, /changelog, /generate-tests — ignore it (DiffBudgetPlanner owns coverage by
# tokens); 0 = off
# Optional: line cap on single-call diff renders (replies, base comparison,
# budgeting-disabled review). Token-budgeted reviews and the batched commands — /improve,
# /describe, /changelog, /generate-tests, /add-docs — ignore it (DiffBudgetPlanner owns
# coverage by tokens); 0 = off

devops-thiago and others added 12 commits August 12, 2026 10:11
)

## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [ ] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

`ReviewDiffFormatter.truncateSection(section, 1)` — reached when
`DiffBudgetPlanner.clipToBudget`
squeezes a section down to `maxLines == 1` — emitted only the bare
`(patch truncated)` notice. The
`### <path> (…)` header is part of the truncated content, so it
disappeared too: the batch prompt
kept the file's slot but lost its name. The model was asked to review a
change it cannot identify,
and any instruction keyed to the path ("report findings against the file
they occur in") had nothing
to bind to. This is most likely on very large PRs, where budgets are
tightest, sections clip hardest,
and a silently anonymous file is least likely to be noticed among many.

The fix treats the header as structural rather than clippable content. A
one-line clip now emits the
header itself, with the truncation notice folded into the header's own
parenthetical:

    ### src/App.java (modified, +9 -2, patch truncated)

Folding rather than appending matters: the notice is placed inside the
existing parenthetical so the
line still reads as `### <path> (…)`, and consumers that scope by file
keep parsing the path —
`FindingQuoteValidator.indexDiff` cuts the path at the *last* `" ("`,
which a trailing
`… (modified, +9 -2) (patch truncated)` would have broken. A header
without a parenthetical gets
` (patch truncated)` appended (still parseable), and a first line that
is not a `### ` header keeps
the old bare notice.

The worst case is now a named file with no visible patch instead of an
unnamed one. The clip stays
within its one-line budget, so the line accounting in
`formatWithLineBudget` and the monotonic
re-clip loop in `clipToBudget` are unaffected.

## Related Issues

Fixes #603

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Four new tests in `ReviewDiffFormatterTest`: the one-line clip names the
file and stays one line;
the emitted header still parses back to the path; a header with no
parenthetical still names the
file; and an end-to-end `buildDiffString` render whose line budget
squeezes the last section to a
one-line clip still names it. The existing one-line test was renamed to
state what it now pins —
the headerless fallback.

### Red proof (tests against unfixed `ReviewDiffFormatter`)

```
[ERROR] Tests run: 16, Failures: 4, Errors: 0, Skipped: 0, Time elapsed: 0.020 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest$TruncationHelpers
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest.shouldKeepFileHeaderParseableAsSectionHeaderWhenBudgetIsOneLine -- Time elapsed: 0.005 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <### src/App.java (modified, +9 -2, patch truncated)> but was: <(patch truncated)>
	at dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest$TruncationHelpers.shouldKeepFileHeaderParseableAsSectionHeaderWhenBudgetIsOneLine(ReviewDiffFormatterTest.java:393)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest.shouldKeepFileHeaderWhenBudgetIsOneLine -- Time elapsed: 0.001 s <<< FAILURE!
org.opentest4j.AssertionFailedError: clipped section must still name the file ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest$TruncationHelpers.shouldKeepFileHeaderWhenBudgetIsOneLine(ReviewDiffFormatterTest.java:379)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest.shouldKeepFileNamedWhenPlannerClipsSectionToOneLine -- Time elapsed: 0.001 s <<< FAILURE!
org.opentest4j.AssertionFailedError: the clipped file must still be named ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest$TruncationHelpers.shouldKeepFileNamedWhenPlannerClipsSectionToOneLine(ReviewDiffFormatterTest.java:418)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest.shouldNameFileWhenHeaderHasNoParentheticalAndBudgetIsOneLine -- Time elapsed: 0 s <<< FAILURE!
org.opentest4j.AssertionFailedError:
expected: <### src/App.java (patch truncated)
> but was: <(patch truncated)
>
	at dev.thiagogonzaga.thrillhousebot.review.ReviewDiffFormatterTest$TruncationHelpers.shouldNameFileWhenHeaderHasNoParentheticalAndBudgetIsOneLine(ReviewDiffFormatterTest.java:404)
```

With the fix applied all four pass.

### Gates

- `./mvnw -B spotless:apply` → BUILD SUCCESS
- `./mvnw -B clean compile spotbugs:check spotless:check` → `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` → Tests run: 2851, Failures: 0, Errors: 0,
Skipped: 0
- JaCoCo ∩ `git diff -U0 469539e...HEAD` on changed main code → 0
uncovered lines, 0 uncovered
  branches (both sides of each new condition are exercised)

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

N/A

## Additional Notes

Behavior for every other budget is unchanged: at `maxLines >= 2` the
header already survived through
`truncatePlain` / `truncateWithoutFence`. Only the `maxLines == 1`
degradation changes.
…lauses (#610)

## What type of PR is this?

- [x] 🐛 Bug fix
- [x] 🔒 Security

## Description

The deterministic severity floor for an unmitigated injection sink
(#594, for #570) did not fire on
the round-4 acceptance case: its own `MITIGATION_ASSERTED` defeater
matched a **conditional
hypothetical the finding immediately rejects**.

Round 4 planted the same stored-XSS class in Angular and React. The
Angular half published
`CRITICAL`; the React half argued its own severity was critical and
published `MEDIUM`:

> No sanitization, escaping, or validation of body is visible anywhere
in the provided material.
> This is the stored-XSS defect class ... If the feedback API sanitizes
body on write, the exploit
> is neutralized — verify that layer — but a sanitizer you cannot see is
not a sanitizer, so
> severity stays at critical while confidence is medium.

Running the two defeaters against that text: `MITIGATION_ASSERTED`
matches `API sanitizes` (second
alternation, `\w+\s+(sanitizes|escapes|...)`) out of the conditional
clause; `SINK_DENIED` correctly
does not match. So the floor was defeated by a hypothesis the very next
clause rejects.

The phrasing is not incidental. #575's review prompt REQUIRES a
demonstrated-sink finding whose
mitigating layer was not shown to **name the exact layer to verify** —
the instruction manufactures
the wording the defeater reads as a mitigation. Meanwhile #594's
defeaters exist because the floor
was demonstrably over-firing on findings that *deny* the sink ("so no
SQL injection is possible").
Both directions are real, and the fix has to keep both.

### Fix: option 1 (clause-scoping), and why not 2 or 3

The trigger (`INJECTION_SINK` + `NO_MITIGATION`) is still read on the
whole finding. The two
**defeaters** now run on what the finding *asserts*: conditional clauses
are removed first, matching
`if|unless|whether|in case|assuming|provided that|should` from the
marker to the end of that clause
(next comma, colon, semicolon, dash, or sentence end). This is the same
narrowing the hedging scan
already needed in #594 for the same reason — a token regex cannot carry
mood.

Scoped to the **clause**, not the sentence carrying it, because
over-firing is the dangerous
direction: `..., but React escapes it at render` asserts the mitigation
*outside* its conditional and
must still defeat the floor. A whole-sentence exclusion would have lost
that.

### Second commit: both edges of the clause scan were wrong

Review found two defects in the first commit, pulling in opposite
directions. Both reproduce, both
are fixed, both are pinned by a test.

**Under-firing — the clause ended at the first comma.** A protasis
carries its own commas, so
`If the feedback API, per its own contract, always sanitizes the body on
write, ...` had only
`If the feedback API` removed; `always sanitizes` stayed in the asserted
text, `MITIGATION_ASSERTED`
matched, and the floor stayed suppressed. That is #608 itself, reached
through a sentence one comma
away from the one the issue names. The clause now ends at a real
boundary — strong punctuation
(`; : . ! ?`, newline, en/em dash) or a coordinator that opens the
consequent
(`but`, `so`, `then`, `however`, `therefore`, `otherwise`) — and no
longer at a bare comma.

**Over-firing — plain `should` was treated as a conditional marker.**
`It should be noted that the
API sanitizes body on write.` is an assertion, and reading it as a
hypothesis removed the whole
sentence from the defeater's view, so a finding that explicitly says the
value IS sanitized got
floored to high. `should` is dropped. It cost nothing to drop: only
inverted `Should the API
sanitize ...` is a hypothesis, and its bare infinitive matches none of
`MITIGATION_ASSERTED`'s verb
forms, so the marker never bought a single correct exclusion.

### Third commit: the span mechanism is retired

A third review pass found a third leaking shape — a coordinator sitting
*inside* the protasis, so
`If the API sanitizes nothing on write but silently escapes the body on
read, ...` truncates at
`but` and `silently escapes` reads as asserted. Testing that fix turned
up a fourth shape nobody had
reported: a postposed conditional, `The API sanitizes the body only if
the request is trusted`,
where the marker comes **after** the verb it governs, so no span
starting at the marker can ever
cover it.

That is three passes, four shapes, one hole. The pattern is not bad luck
about which stop tokens
were chosen — it is structural. A protasis is a syntactic constituent,
and **no lexical token marks
where it ends**: commas occur inside it, coordinators occur inside it,
dashes occur inside it, and
in the postposed form it does not start where the marker is. Any
span-delimiting rule is a guess at
a boundary that is not lexically marked, so for every terminator there
exists a sentence that puts
that terminator inside the protasis. The shape space is open, and every
leak lands on the
**under-firing** side — the direction #570 has failed on for three
rounds.

So the span is retired rather than refined again, which is the same move
#596 made when it stopped
tuning a similarity score that could not carry negation.

**What replaces it:** the unit of judgement is the sentence. A sentence
containing a conditional
marker is not read for defeaters at all. `CONDITIONAL_CLAUSE` (a
tempered-greedy span regex) becomes
`CONDITIONAL_MARKER` (a plain word alternation), and the scan reuses
`CLAUSE_BOUNDARY`, the sentence
split the hedging scan in this same class already uses. Net effect on
the diff: less code, one fewer
regex construct, no span arithmetic.

**Why the shape space is now closed, not merely smaller.** The boundary
question is gone rather than
answered. A hypothesis and its marker always occupy the same sentence —
that is what a conditional
*is*, in every English word order, fronted or postposed — so excluding
the whole sentence cannot
leak a hypothetical verb into asserted text regardless of what the
sentence contains. There is no
terminator left for a sentence shape to sit inside. The postposed case
is closed by construction
rather than by a fifth rule, which is the concrete evidence that this is
a different mechanism and
not a smaller edge: it was fixed without being enumerated.

**The one remaining error, pinned rather than buried.** A mitigation
asserted in the *same sentence*
as a conditional (`If it were stored as plain text this would be moot,
but React escapes it at
render`) is read as hypothetical, and the floor lifts a finding it
should have left alone. This is
the only way the rule can be wrong, and it is now an explicit
characterization test
(`liftsAFindingThatAssertsItsMitigationInsideAHypotheticalSentence`)
rather than an undocumented
edge. It is accepted deliberately:

- it errs toward the safe direction — over-firing, not under-firing;
- it still requires the finding to name a sink **and** claim nothing
sanitizes it before the floor
  can fire at all;
- only risk moves; confidence, placement and every other signal stay
where the model put them;
- a mitigation stated in its own sentence still defeats the floor, which
is how findings state one
  (`stillHonoursAMitigationAssertedInItsOwnSentence`);
- and every token-level attempt to keep this case reopened an under-fire
instead — the coordinator
carve-out from the second commit was exactly that attempt, and it is
what the third review pass
  broke.

## Related Issues

Fixes #608

## How Has This Been Tested?

- [x] Unit tests

Eight tests added to `FindingVerificationServiceTest`, and #594's two
over-firing tests
(`doesNotFloorAFindingThatDeniesTheSinkItNames`,
`doesNotFloorAFindingThatDescribesTheMitigationAsPresent`) stay green
unchanged.

**Red output on unfixed code** (`./mvnw -B test
-Dtest=FindingVerificationServiceTest` at
`469539e` with only the tests applied):

```
[ERROR] Tests run: 46, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 1.439 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.ratesTheConditionallyHedgedHalfOfThePlantedPairLikeItsTwin -- Time elapsed: 0.007 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <high> but was: <medium>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1199)
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.ratesTheConditionallyHedgedHalfOfThePlantedPairLikeItsTwin(FindingVerificationServiceTest.java:981)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.floorsAnInjectionSinkFindingWhoseOnlySanitizerMentionIsAConditionalItRejects -- Time elapsed: 0.001 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <high> but was: <medium>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1199)
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.floorsAnInjectionSinkFindingWhoseOnlySanitizerMentionIsAConditionalItRejects(FindingVerificationServiceTest.java:962)
```

Both are the exact published-vs-argued contradiction the issue measures:
the finding text is the
round-4 comment verbatim, rated `medium`, and the floor did not lift it.

**Red output for the second commit's two defects** (same command at
`f55d7d7`'s tests over the
first commit's implementation):

```
[ERROR] Tests run: 48, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 2.098 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.floorsAnInjectionSinkFindingWhoseConditionalCarriesItsOwnCommas -- Time elapsed: 0.012 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <high> but was: <medium>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1199)
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.floorsAnInjectionSinkFindingWhoseConditionalCarriesItsOwnCommas(FindingVerificationServiceTest.java:1032)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.doesNotReadAnAssertiveShouldFrameAsAConditional -- Time elapsed: 0.019 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <...risk=low...> but was: <...risk=high...>
	at org.junit.jupiter.api.Assertions.assertSame(Assertions.java:2962)
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.doesNotReadAnAssertiveShouldFrameAsAConditional(FindingVerificationServiceTest.java:1057)
```

The two failures are the two directions: the comma case published
`medium` where the floor should
have lifted it, and the `should` case was lifted to `high` on a finding
that asserts a mitigation.

**Red output for the third commit** (the three new tests over the second
commit's implementation):

```
[ERROR] Tests run: 51, Failures: 3, Errors: 0, Skipped: 0, Time elapsed: 1.289 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.floorsAnInjectionSinkFindingWhoseConditionalMarkerFollowsTheVerbItGoverns -- Time elapsed: 0.007 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <high> but was: <medium>
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.floorsAnInjectionSinkFindingWhoseConditionalMarkerFollowsTheVerbItGoverns(FindingVerificationServiceTest.java:1055)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.floorsAnInjectionSinkFindingWhoseConditionalContainsACoordinator -- Time elapsed: 0.002 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <high> but was: <medium>
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.floorsAnInjectionSinkFindingWhoseConditionalContainsACoordinator(FindingVerificationServiceTest.java:1031)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.liftsAFindingThatAssertsItsMitigationInsideAHypotheticalSentence -- Time elapsed: 0.002 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <high> but was: <low>
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.liftsAFindingThatAssertsItsMitigationInsideAHypotheticalSentence(FindingVerificationServiceTest.java:1082)
```

The first two are the two leaking shapes, both publishing `medium` where
the floor should have
lifted them. The third is the accepted cost, red because it pins the new
behaviour.

Gates:

- `./mvnw -B clean compile spotbugs:check spotless:check` — `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` — `Tests run: 2855, Failures: 0, Errors: 0,
Skipped: 0`
- jacoco ∩ `git diff -U0 469539e...HEAD` — 0 uncovered lines, 0
uncovered branches in changed main code

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

Behaviour is unchanged for any finding with no conditional marker in its
title or description, which
is every existing test case bar the new ones.

---------

Co-authored-by: Thiago Gonzaga <thiago@ensina.tech>
… first (#611)

## What type of PR is this?

- [x] 🐛 Bug fix

## Description

#573 gave the review call the disclosure it was missing: every path this
pull request
changes that the call was not shown, with the reason (pure rename,
ignore list, budget).
The rule that reads it stops one sentence too early.

On this repository's own PR #564 — README.md, pom.xml,
application.properties, with
`**/pom.xml` on the shipped default
`THRILLHOUSEBOT_REVIEW_IGNORED_FILES` — the review
posted **two** false statements from that one withheld file:

> The PR title and description state that quarkus-jdbc-h2 is moved to
`provided` scope in pom.xml, but the diff contains no pom.xml change;
only README.md and application.properties comments/docs are present.
> The README and application.properties now claim the H2 driver is a
`provided` dependency that is not packaged into the runtime image, but
nothing in the diff implements or verifies that claim.

The pom hunk was present and correct. The first sentence is what the
existing rule
forbids. The second is the one that does the damage: it is about files
the call **could**
see, and it says their documentation is unsupported — a conclusion
reachable only from
the first error. One withheld file, two wrong claims, on the PR's entire
substantive
change, and it was the sole reason that PR sat at REQUEST_CHANGES.

### The fix

Prompt text only, in three places:

- **The withheld-material self-check** gains the derived case. Material
of yours that
describes behavior whose implementation would live in a withheld path is
not unbacked,
unverified, unimplemented, aspirational, premature, or "documented but
not done". It
names the paths ignore lists actually withhold — `pom.xml`,
`build.gradle`,
`package.json`, `go.mod`, `Cargo.toml`, `requirements.txt`, lockfiles —
so the model
can recognize the shape, and moves the check **ahead** of the sentence:
read the
withheld list for a path that would carry the change *before* asserting
the diff does
not contain one. When the truthful statement is "the file that settles
this was never
  shown to me", there is no finding and no gap.
- **`SYSTEM`'s `description_gaps` spec**: a documentation or
configuration change you can
see is not a gap because the code implementing it sits on the omitted
list.
- **`SUMMARY_SYSTEM`'s `description_gaps` spec**: the same guard on the
other surface that
  emits the field.

Nothing else changes. The material half is already shipped (#573), the
ignore list is
untouched, and no plumbing is edited.

### Scope — what this does not do

`FindingPipeline.changedFilesOverview` builds the **summary** call's
changed-file list
from `ctx.reviewableFiles()`, so an ignore-listed path is still absent
from that list
entirely on a multi-call review: the summary prompt's guard has nothing
to match there.
PR #564 is small enough to go through the single budgeted review call,
which does carry
the notice, so this PR fixes the reported instance — but the
summary-overview gap is real
and lives in `FindingPipeline`, outside this PR's lane. `/describe`
reproduces the same
false claim on its own prompt surface and is likewise untouched.

## Related Issues

Fixes #566

## How Has This Been Tested?

- [x] Unit tests

Two `PrReviewPromptsContentTest` changes: a new
`aClaimBuiltOnAWithheldPathIsRefusedAlongWithTheClaimThatNamesIt`
pinning the derived-claim
rule, the named manifests, the check-before-you-assert ordering and the
write-nothing
outcome; and two anchors added to
`bothPromptsKeepWithheldPathsOutOfDescriptionGaps` for
the guard on both `description_gaps` surfaces.

### Red proof

The round's standards exempt prompt-text changes, but the new pins do
fail on the unfixed
prompt. With `PrReviewPrompts.java` restored to `469539e` (copied aside,
not stashed):

```
[ERROR] Tests run: 51, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.101 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.ai.PrReviewPromptsContentTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.PrReviewPromptsContentTest.bothPromptsKeepWithheldPathsOutOfDescriptionGaps -- Time elapsed: 0.006 s <<< FAILURE!
org.opentest4j.AssertionFailedError: the review call's description_gaps must refuse the derived claim too (#566) — missing marker: "is not a gap either because the code implementing it sits" ==> expected: <true> but was: <false>

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.PrReviewPromptsContentTest.aClaimBuiltOnAWithheldPathIsRefusedAlongWithTheClaimThatNamesIt -- Time elapsed: 0.001 s <<< FAILURE!
org.opentest4j.AssertionFailedError: the withheld-material rule must extend past the sentence that names the path (#566) — missing marker: "The same rule governs every claim BUILT on a withheld path" ==> expected: <true> but was: <false>
```

### Gates

| Gate | Result |
|---|---|
| `spotless:apply` + `clean compile spotbugs:check spotless:check` |
BUILD SUCCESS, `BugInstance size is 0` |
| `clean test` | `Tests run: 2848, Failures: 0, Errors: 0, Skipped: 0` |
| jacoco ∩ `git diff -U0 469539e...HEAD` (main code) | 0 uncovered
lines, 0 uncovered branches — the three changed hunks are text-block
content inside a constant, and JaCoCo instruments no line in any of them
|

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

No `README.md` or `docs/**` file is touched, so the Docs workflow is not
in play.
The regression case is preserved: re-running the review over PR #564's
diff should
produce neither sentence.

Co-authored-by: Thiago Gonzaga <thiago@ensina.tech>
… defect (#612)

## What type of PR is this?

- [x] 🐛 Bug fix

## Description

Re-scoped to **performance only** (comment-contradiction is holding;
mock fidelity moved
to #587).

#545 took the performance dimension from 0/4 to 7/8 in round 2. Round 3
rebuilt the corpus
and missed it in three of eight — c #22, zig #20, react #26 — and all
three failed
identically: **the review examined the exact function closely, reported
a different real
defect in it, and never mentioned complexity.** Nothing was overlooked,
so raising a
threshold or re-arguing "the shape is the evidence" has nothing to bite
on.

Reading the three planted defects shows the second half of the problem.
Dimension 5
described "a linear membership test inside a loop" and "a nested loop
pair" — and not one
of the three is spelled that way:

| lang | the planted shape | why the description missed it |
|---|---|---|
| zig #20 | `enqueueBatch` loops over `jobs` and calls
`containsJob(id)`, whose body scans `self.pending.items` | the inner
scan is in a **helper**, a different function; neither function alone
contains two loops |
| c #22 | `rotator_write` is called once per log message and calls
`already_seen`, which walks every message ever written | the outer level
is a **per-item entry point**, not a loop statement — there is no `for`
around the call in the diff |
| react #26 | `merged.filter((result, i) => merged.findIndex(r => r.id
=== result.id) === i)` | the two levels are **two chained higher-order
calls** over one array — and this is the idiomatic JS dedupe one-liner,
which reads as correct rather than as quadratic |

A reader looking for two nested `for` statements finds none of them.

### The fix

Prompt text only, in three places:

- **Dimension 5 gains shapes (e)-(g)**, introduced as "the two levels
are usually NOT one
  loop nested inside another in the same function": the scan behind a
`contains…/has…/already…/find…/lookup…` helper the diff also shows; the
per-item entry
point scanning an accumulator earlier calls appended to ("handling n
items costs O(n^2)
even though no line in the diff shows two loops"); and two chained
higher-order calls
over the same collection, with the dedupe-by-id one-liner named outright
and
  "being the idiomatic spelling is not a bound".
- **The emission rules gain the trigger**, next to the existing
promotion sweep and doing
what that sweep cannot. The sweep only rescues a defect already written
down somewhere;
in these three misses the complexity was never written down at all. So:
*finding a defect
in a function does not finish that function* — anchoring a finding
there, on any
dimension, obliges one more question about what that code costs before
moving on, and
"the bug you already found is not a reason its cost is acceptable". A
second bullet
covers the c case's near-miss specifically: when you discuss a dedupe
accumulator for its
scope, lifetime, correctness or unbounded growth, say in the same pass
which lookup it
uses — "grows without bound" and "is scanned linearly per item" are two
defects on two
  dimensions, and the round-3 review filed the framing without either.
- **The quote-both-levels self-check** now says the two quoted lines
need not sit in the
same function, so a helper's scanning line plus its call site is
admissible evidence.
  Without this the new shapes would be enumerated and then invalidated.

Precision guidance is untouched: the fixed-and-small-bound exclusion,
the comment
justification, the already-hashed lookup check and the "state the cost
in words unless one
n drives both levels" rule all stand. Round 3's precision (5 arguable
false positives
against 15 verified true extras, every decline trap refused) is what
those protect.

## Related Issues

Fixes #537

## How Has This Been Tested?

- [x] Unit tests

Two new `PrReviewPromptsContentTest` cases:
`dimensionFiveEnumeratesTheNonNestedSpellingsOfAQuadratic` (the three
disguised shapes, the
idiom carve-out, and the self-check's cross-function admission) and
`aFindingOnAnotherDimensionMustNotEndTheExaminationOfTheFunction` (the
trigger, its framing
as a wrong-dimension rather than did-not-look failure, and the
accumulator split). The
existing #537 pins from #545 are left intact.

### Red proof

The round's standards exempt prompt-text changes, but the new pins do
fail on the unfixed
prompt. With `PrReviewPrompts.java` restored to `469539e` (copied aside,
not stashed):

```
[ERROR] Tests run: 52, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.122 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.ai.PrReviewPromptsContentTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.PrReviewPromptsContentTest.dimensionFiveEnumeratesTheNonNestedSpellingsOfAQuadratic -- Time elapsed: 0.014 s <<< FAILURE!
org.opentest4j.AssertionFailedError: the disguised forms must be introduced as the ones that go unreported (#537) — missing marker: "The two levels are usually NOT one loop nested inside another" ==> expected: <true> but was: <false>

[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.PrReviewPromptsContentTest.aFindingOnAnotherDimensionMustNotEndTheExaminationOfTheFunction -- Time elapsed: 0.001 s <<< FAILURE!
org.opentest4j.AssertionFailedError: one filed defect must not close the function to a second one (#537) — missing marker: "Finding a defect in a function does not finish that function" ==> expected: <true> but was: <false>
```

### Gates

| Gate | Result |
|---|---|
| `spotless:apply` + `clean compile spotbugs:check spotless:check` |
BUILD SUCCESS, `BugInstance size is 0` |
| `clean test` | `Tests run: 2849, Failures: 0, Errors: 0, Skipped: 0` |
| jacoco ∩ `git diff -U0 469539e...HEAD` (main code) | 0 uncovered
lines, 0 uncovered branches — the three changed hunks are text-block
content inside a constant, and JaCoCo instruments no line in any of them
|

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

This issue is only settleable by re-measurement, not by a tree check —
the corpus has to be
re-run against a deployed image carrying this prompt, with the three
round-3 misses (c #22,
zig #20, react #26) as the specific cases to watch. No `README.md` or
`docs/**` file is
touched, so the Docs workflow is not in play.

Co-authored-by: Thiago Gonzaga <thiago@ensina.tech>
…613)

## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [ ] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

`FindingQuoteValidator` was deleting correct findings whose quoted code
**is** in the diff, on files
the PR adds in full.

### What the investigation ruled out

The issue asked whether added files reach the matcher at all. They do.
`ReviewDiffFormatter` emits a
wholly-new file as an ordinary `### path (added, +N -0)` section with
its full patch inside a
` ```diff ` fence, `indexDiff` indexes it like any other section, and a
byte-exact quote of one of
its lines validates. `byteExactQuoteOfAWhollyAddedFileIsKept` pins that
and passes both before and
after this change.

### The actual mechanism

`matchQuote` tests each `suggestion_old` line for **exact equality**
(after `strip()`) against the
set of normalized diff lines. It is line-anchored, not
whitespace-normalized across line boundaries.
So when the model rewraps a construct the source splits across lines,
*every* quoted line fails and
the verdict is `NONE` — which deleted the entire finding.

Both round-4 corpus losses are exactly that:

* Go, `internal/store/store.go:32` — the source splits `s.db.Exec(...)`
over four lines with the SQL
literal on its own line and Go's mandatory trailing comma before the
newline. Joined onto one line
by the model, that comma disappears and no quoted line equals a diff
line.
* Python, `src/cert_monitor/alerts.py:14` — the source uses implicit
concatenation, so the f-string
is two physical lines each with its own `f"` prefix. Collapsed into one
literal, the intervening
  `" f"` disappears and again nothing matches.

Normalizing whitespace would not have saved either one: both rewraps
*change tokens*, not just
spacing, so no containment test over compacted text finds them. Absence
of an exact match simply is
not evidence of a phantom, and on an added file — where the whole file
is in the diff and the quoted
code is most certainly present — acting on it as if it were destroyed
true findings, one of them the
corpus's planted producer→consumer defect.

### The fix

An unmatched quote now draws the same conservative demotion a partial
quote already did: the
untrusted suggestion block is removed and confidence is capped at
`"low"`. The finding still
surfaces, carrying its low-confidence disclaimer, and
`BlockingStrictness` still keeps it from
blocking.

This follows the issue's guidance that under-firing is the far safer
direction here — an unanchored
true finding is useful, a silently deleted one is not. The validator now
deletes nothing at all, so
the disclosure gap the issue raises ("nothing in the posted review says
a finding was removed") is
closed by there being nothing to disclose; the summary keeps counting
the finding.

The misattribution case (a quote whose text belongs to a different file
in the diff) moves the same
way, from deletion to demotion, for the same reason: file scoping still
refuses to let another
file's text validate the quote, it just costs the finding its suggestion
rather than its life.

The three near-identical demotion arms in `validate` collapse into one
`demotionReason` switch, so
every verdict shares a single keep-and-demote path and a future verdict
cannot silently suppress
anything. The four log messages are byte-identical to before except the
former drop line, which now
reads as a demotion.

## Related Issues

Fixes #607

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Three new tests in `FindingQuoteValidatorTest`, two of them built from
the **verbatim patches** of
the corpus PRs (fetched from `ThrillhouseBot-test` #27 and #28):

* `byteExactQuoteOfAWhollyAddedFileIsKept` — added files are indexed
(rules out the indexing
  hypothesis; green before and after).
* `rewrappedQuoteOfAWhollyAddedFileIsKeptDemoted` — instance 1, the Go
SQL literal.
* `collapsedFStringQuoteOfAWhollyAddedFileIsKeptDemoted` — instance 2,
the Python f-string.

### Red/green proof

Both reproductions fail on unfixed code with the finding gone, and with
the issue's log line naming
the very files it names:

```
INFO  [dev.thiagogonzaga.thrillhousebot.review.FindingQuoteValidator] (main) Dropping finding 'Title' (internal/store/store.go:2) — the code it quotes does not appear in the diff
INFO  [dev.thiagogonzaga.thrillhousebot.review.FindingQuoteValidator] (main) Dropping finding 'Title' (src/cert_monitor/alerts.py:2) — the code it quotes does not appear in the diff

[ERROR] Tests run: 69, Failures: 3, Errors: 0, Skipped: 0, Time elapsed: 0.239 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.FindingQuoteValidatorTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.FindingQuoteValidatorTest.rewrappedQuoteOfAWhollyAddedFileIsKeptDemoted -- Time elapsed: 0.006 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <1> but was: <0>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
	at dev.thiagogonzaga.thrillhousebot.review.FindingQuoteValidatorTest.rewrappedQuoteOfAWhollyAddedFileIsKeptDemoted(FindingQuoteValidatorTest.java:1074)

[ERROR] dev.thiagogonzaga.thrillhousebot.review.FindingQuoteValidatorTest.collapsedFStringQuoteOfAWhollyAddedFileIsKeptDemoted -- Time elapsed: 0.001 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <1> but was: <0>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
	at dev.thiagogonzaga.thrillhousebot.review.FindingQuoteValidatorTest.collapsedFStringQuoteOfAWhollyAddedFileIsKeptDemoted(FindingQuoteValidatorTest.java:1099)
```

`byteExactQuoteOfAWhollyAddedFileIsKept` is green in that same red run —
the added file *was*
indexed; only the exact-equality match failed.

### Gates

| Gate | Result |
| --- | --- |
| `./mvnw -B spotless:apply` + `spotless:check` | clean |
| `./mvnw -B clean compile spotbugs:check` | `BugInstance size is 0` |
| `./mvnw -B clean test` | `Tests run: 2850, Failures: 0, Errors: 0,
Skipped: 0` |
| jacoco ∩ `git diff -U0 469539e...HEAD` | 48 changed main lines, **0
uncovered lines, 0 uncovered branches** |

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

After the fix, the same input logs a demotion rather than a deletion:

```
Finding 'cert_checks table is never created' (internal/store/store.go:32) quotes code that does not appear in the diff — dropping its suggestion and capping confidence
```

## Additional Notes

Behavioral note for reviewers: `FindingQuoteValidator` no longer removes
any finding. Phantom-quote
findings survive as low-confidence, suggestion-less notes rather than
disappearing.
`FrameworkFalsePositiveFilter` is a separate guard and still filters on
its own criteria.
…out of the verifier's downgrade pressure (#614)

## What type of PR is this?

- [x] 🐛 Bug fix
- [x] 🔒 Security

## Description

Two verifier-prompt carve-outs, both in `FindingVerifierPrompts.SYSTEM`,
both driven by prompt text
rather than by `FindingVerificationService` logic — which is why they
ship together.

### #605 — no carve-out for a demonstrated injection sink

`SYSTEM` already carves config/IaC, mock-fidelity, heuristic and
producer→consumer findings out of
its own downgrade grounds. It had nothing for an injection sink whose
tainted value and sink call
are both in the diff — and the prompt names "routing and rendering
semantics" as remembered
framework behaviour capped at `medium` risk / `low` confidence, which is
exactly how
`dangerouslySetInnerHTML` and `bypassSecurityTrustHtml` read. So the
verifier spent a downgrade on a
defect the material in front of it demonstrates.

The new paragraph follows the shape of the existing carve-outs: it
states what the class is, says it
is **not** remembered framework behaviour, names the rejection ground
and the severity cap it
exempts, puts the uncertainty about an unshown sanitizing layer on
**confidence and never on risk**,
and keeps an explicit rejection path so it is not a blanket exemption —
a sink the material shows is
neutralized, a sink fed a literal, or a sink not in the diff is still
rejected. The severity
calibration paragraph gained the matching half.

The deterministic floor from #594 already makes the published outcome
correct, so this is not
user-visible today. It is still worth fixing at the source: the floor is
a backstop over a decision
that is still wrong, and any future path that does not run through the
floor inherits the bug.

### #589 — standard-library semantics treated as unestablished

The first rejection ground ("remembered external framework or library
behavior ... that the provided
diff and project stack do not support") was applied to core language
semantics. Round-3 logs, verbatim:

> Verifier rejected finding 'logLatestTimestamp crashes with
UnsupportedOperationException on empty
> uniqueEvents': The crash claim depends on Scala 2.13 List.max behavior
on empty lists, which is
> external library behavior not established by the diff or project
stack.

That was the PR's planted functional defect. The reviewer found it,
named it and pointed at the
right line; the verifier deleted it before publication, and round-3
scoring recorded the dimension as
"not mentioned anywhere" — measured recall understating the reviewer
because a true positive was
destroyed downstream.

The ground now excludes documented semantics of the language and its own
standard library, and says
what to judge such a finding on instead (whether the material shows the
triggering case can reach
the call). It explicitly **keeps** the ground for repo state not shown,
unshown callers and unshown
configuration — the C# #24 half of the issue, which the issue itself
calls defensible, still
rejects. The severity calibration paragraph gained the matching half so
the medium/low cap does not
re-impose what the rejection ground no longer does.

## Related Issues

Fixes #605
Fixes #589

## How Has This Been Tested?

- [x] Unit tests

Prompt-text changes, so there is no red/green proof to give — nothing
executable changed. Per the
repo's convention for this class of change, the guard is a content test:
new
`FindingVerifierPromptsContentTest` pins each carve-out by the markers
that carry its meaning — the
rejection ground it exempts, the direction it must not reopen (a
neutralized sink and a non-attacker
value stay rejectable; repo state, unshown callers and unshown
configuration stay rejection
grounds), and the severity-cap exemptions. It is a new file rather than
an addition to
`PrReviewPromptsContentTest` so the verifier prompt's guards live with
the prompt they guard.

Gates:

- `./mvnw -B clean compile spotbugs:check spotless:check` — `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` — `Tests run: 2852, Failures: 0, Errors: 0,
Skipped: 0`
- jacoco ∩ `git diff -U0 469539e...HEAD` — 0 uncovered lines, 0
uncovered branches in changed main code

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

Both carve-outs are additive to the prompt; no existing ground or cap
was removed, and every
existing `PrReviewPromptsContentTest` assertion on
`FindingVerifierPrompts.SYSTEM` still holds.

Co-authored-by: Thiago Gonzaga <thiago@ensina.tech>
…put budget (#615)

## What type of PR is this?

- [x] 🐛 Bug fix

## Description

`previousFindings` is the one prompt section that grows monotonically.
Every review round appends the
previous round's findings, their prose and their whole comment threads,
and on a long-lived PR whose
head keeps advancing nothing ever retires them. It is also **shared**
overhead — repeated verbatim in
every batch call — so `DiffBudgetPlanner` counting it in
`sharedOverhead` only tells it the prompt is
large: adding batches multiplies the block instead of dividing it, and
the planner's only lever
(shrinking the diff budget) starves the review of the code it exists to
read. On this repo's own
release PR (#532) the block reached ~437K tokens against 57–74K for a
normal review, and before #584
it made that PR unreviewable outright.

### What was chosen, and why

**The bound is a share of the per-call input budget (25%), not a
character cap.** The block is charged
against the whole per-call budget because every call carries it; a
quarter is deliberately generous so
the bound bites on accumulation, not on an ordinary follow-up round.

**It degrades by condensation, not truncation.** Dropping the oldest
findings silently would make the
follow-up pass forget what it already reported — the mechanism behind
several resolved/unresolved
bugs. What that pass actually needs is *identity and location*: the id
it must report a status for,
the `file:line` it must look at, the title it matches on. So every entry
keeps its own line
(`3. [HIGH] path/File.java:42 — Title`) and loses only its continuation
lines — description, quoted
code, thread replies. No finding disappears and no id shifts. The
deterministic resolution machinery
is unaffected either way: the approve backstop and `unresolvedFindings`
run off
`ctx.previousFindingsList()` (structured objects), never off this prompt
text.

**Only if the condensed block still does not fit are entries dropped,
and then from the tail** — so
the numbered findings whose ids `previous_findings_status` is keyed to
outlive the advisory
"answered in earlier rounds" list that follows them. That is real
forgetting, so it degrades the safe
way: a finding the model cannot report on is not counted resolved, it is
held open by the approve
backstop (`unreportedUnresolvedStatuses`), so approval is withheld
rather than granted.

**The elision is disclosed** the way the existing overhead shortfall is
— a `WARN` naming the before
size, the share and the counts — and additionally **in-band to the
model**, which is the consumer of
this block: a trailing notice says the entries are condensed, forbids
the one dangerous inference
("never report a finding you cannot see as resolved"), and restates what
the block's own
"answered in earlier rounds" header used to say, since that header is a
detail line and condenses
away with the rest. Saying it in our own notice keeps the meaning
without making the bound depend on
matching another class's prose.

Deliberately **not** added: a coverage-style disclosure on the posted
review. Condensation is not a
coverage gap — no file goes unread, no finding loses its identity, and
`truncated()` / the APPROVE
gate are untouched — so listing it beside "files omitted by name" would
misdescribe it. (Surfacing
the *drop* tier in the posted review would mean touching
`VerdictBuilder` and `ReviewResult`; it is
unreachable at any sane budget — 61 entries fit in ~300 tokens — and the
backstop already holds
approval when it fires.)

**Fence safety:** the notice is appended *outside* the CSPRNG fence so
it reads as instruction, not as
more untrusted data, and both fence lines are carried across the cut so
the untrusted region can never
end up unterminated with our instruction swallowed inside it.

`ReviewOrchestrator` applies the bound once, before anything is sized or
sent, so the plan's overhead
estimate and the text the calls actually carry are the same string;
`plan(...)` bounds again when it
sizes the overhead, and the operation is idempotent.

## Related Issues

Fixes #583

## How Has This Been Tested?

- [x] Unit tests

**Red**, against `469539e` with
`anAccumulatedPreviousFindingsBlockNoLongerStarvesTheDiffBudget` in
place and no production change (300 accumulated findings, 20K-token
per-call budget, two small files):

```
2026-08-12 09:32:39,357 WARN  [dev.thiagogonzaga.thrillhousebot.review.DiffBudgetPlanner] (main) Shared prompt overhead (69587 tokens) consumes the whole input budget (20000 tokens); batching with a minimal diff budget — most files will be omitted by name
2026-08-12 09:32:39,364 WARN  [dev.thiagogonzaga.thrillhousebot.review.DiffBudgetPlanner] (main) File section exceeds the 1-token budget even after clipping; omitting the file by name
2026-08-12 09:32:39,365 WARN  [dev.thiagogonzaga.thrillhousebot.review.DiffBudgetPlanner] (main) File section exceeds the 1-token budget even after clipping; omitting the file by name
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.989 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.DiffBudgetPlannerTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.DiffBudgetPlannerTest.anAccumulatedPreviousFindingsBlockNoLongerStarvesTheDiffBudget -- Time elapsed: 0.111 s <<< FAILURE!
org.opentest4j.AssertionFailedError: the diff budget must survive the previous findings ==> expected: <true> but was: <false>
	at org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:232)
	at dev.thiagogonzaga.thrillhousebot.review.DiffBudgetPlannerTest.anAccumulatedPreviousFindingsBlockNoLongerStarvesTheDiffBudget(DiffBudgetPlannerTest.java:650)
```

Every file omitted by name, exactly as #532 behaved. Green with the fix;
the same scenario now logs
`Previous-findings context (60406 tokens) exceeds its 5000-token share
of the input budget; condensed
301 finding(s) to id, location and title` and packs both files.

Nine further tests cover: condensation keeping every id/location/title
while dropping prose and
replies; the fence staying balanced with the disclosure outside it; the
tail drop and its disclosure;
a cap too small to hold even one entry; pass-through and idempotence;
unfenced and
only-looks-fenced blocks; absent/blank blocks; and `max-input-tokens=0`
leaving the block uncapped.

Gates on `c0f42e2`:

- `./mvnw -B clean compile spotbugs:check spotless:check` → `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` → `Tests run: 2857, Failures: 0, Errors: 0,
Skipped: 0`
- jacoco ∩ `git diff -U0 469539e...HEAD` → 82 trackable changed main
lines, zero uncovered lines,
  zero uncovered branches

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

One line lands outside `DiffBudgetPlanner`: `ReviewOrchestrator` has to
adopt the bounded inputs, or
the planner would bound a copy nobody sends. Everything else — the
share, the condenser, the cut, the
disclosure — is in the planner and its test.

The block still grows with the number of open findings, just at ~1 line
each instead of a paragraph
plus a comment thread; the cap is what makes it bounded, and the cap is
what the review can afford.
…#616)

## What type of PR is this?

- [x] ✨ Feature

## Description

`/generate-tests` could not see the review's findings, so it
rediscovered defects instead of being told about them. The findings are
already persisted behind
`ReviewSessionPersistence.findAllPriorAiResponseJsons`, but only the
review path ever read them — the suggestion path never touched them.
#590 fixed the prompt half of #571 (reason about intended behaviour,
never pin current behaviour); this is the structural half it left out of
lane.

Wiring, in the order the issue lists it:

1. **`UnitTestGenerator` loads the prior findings for the PR.** It reads
every completed prior round for `owner/repo` + PR number (nothing to
exclude — no review session is in progress on this path), parses them
through `FollowUpAnalyzer`, and renders the newest round that actually
raised findings, exactly as the review path picks it. Findings a later
round already closed are skipped — the section is presented to the model
as behaviour that is wrong *today* — and their id slots are skipped
rather than renumbered, so the ids still match the ones the review
posted. The load fails soft: a database problem degrades to no section
rather than losing the command.

2. **A new `@V("priorFindings")` on `UnitTestAssistant.generate`,**
rendered by a `{{#if priorFindings}}` section in the user template. The
system prompt frames the findings as known-wrong behaviour that must
never become the expected value, and asks for a test aimed at the exact
file and line the review flagged. They also join the blanket
untrusted-data statement.

3. **The section is part of the per-call overhead `planBatches`
subtracts.** `planBatches` lives in `AbstractPrSuggestionGenerator` (not
`DiffBudgetPlanner`) and already takes an `extraPerCallSections`
argument for the project stack. Rather than concatenating the findings
at that one call site, the two extra sections are now built once into an
`ExtraSections` value that both the planner and every batch call read,
so the estimate cannot drift from what is actually sent. The findings
section is capped at 10 findings (the remainder is counted, not dropped)
so a review that raised thirty of them cannot eat the diff budget it
shares.

## Related Issues

Fixes #606

## How Has This Been Tested?

- [x] Unit tests

### Red/green proof

Point 3 is the part that breaks things, so it is verified by a test, not
by inspection. Reverting **only** the overhead accounting
(`extras.all()` → `extras.projectStack()` in the `planBatches` call,
everything else in place) fails two tests:

```
Tests run: 32, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 4.835 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.UnitTestGeneratorTest
dev.thiagogonzaga.thrillhousebot.review.UnitTestGeneratorTest.keepsEveryBatchCallWithinThePerCallInputBudgetWithTheFindingsCounted -- Time elapsed: 0.138 s <<< FAILURE!
org.opentest4j.AssertionFailedError: batch call 0 sent 2932 tokens against a 2791-token budget ==> expected: <true> but was: <false>
	at org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:232)
	at dev.thiagogonzaga.thrillhousebot.review.UnitTestGeneratorTest.keepsEveryBatchCallWithinThePerCallInputBudgetWithTheFindingsCounted(UnitTestGeneratorTest.java:778)

dev.thiagogonzaga.thrillhousebot.review.UnitTestGeneratorTest.countsThePriorFindingsInTheBudgetSoBatchesAreNotOversized -- Time elapsed: 0.141 s <<< FAILURE!
org.mockito.exceptions.verification.NoInteractionsWanted:

No interactions wanted here:
-> at dev.thiagogonzaga.thrillhousebot.review.UnitTestGeneratorTest.countsThePriorFindingsInTheBudgetSoBatchesAreNotOversized(UnitTestGeneratorTest.java:718)
But found these interactions on mock 'testAssistant':
-> at dev.thiagogonzaga.thrillhousebot.review.UnitTestGenerator.lambda$generateOne$0(UnitTestGenerator.java:383)
-> at dev.thiagogonzaga.thrillhousebot.review.UnitTestGenerator.lambda$generateOne$0(UnitTestGenerator.java:383)
```

The first failure is the one the issue warned about, measured on what
the calls actually carry rather than on the plan: system prompt + user
template + every section + the batch's diff is estimated per call and
asserted against the per-call input budget. With the findings left out
of the overhead the planner hands a batch the room the findings are
already spending, and the assembled request is 141 tokens over the
limit. With the fix in place the same call measures 2621 tokens against
a 2797-token budget, so the invariant holds with real margin rather than
by a rounding accident. The second failure is the coarser shape of the
same bug, mirroring the existing project-stack test: a findings section
far larger than the room left for diff text must leave no file
affordable, and instead two over-budget calls go out.

### Other coverage

New tests in `UnitTestGeneratorTest`: the findings reach the assistant
rendered as `1. [CRITICAL] src/Foo.java:42 — <title>` with the
description; no section at all when the PR has no prior round; a finding
a later round resolved is left out while the surviving ids keep their
numbers; a finding with no file, or with a file but no line, writes no
fabricated location; the cap renders 10 and counts the rest; a
persistence failure still generates. `AiServicePromptRenderingTest` pins
that the template actually renders the new `@V` (a section the planner
pays for but the template drops would be the worst of both), and
`UnitTestAssistantPromptsContentTest` pins the new guidance.

### Gates

- `./mvnw -B spotless:apply` then `./mvnw -B clean compile
spotbugs:check spotless:check` — BugInstance size is 0, BUILD SUCCESS
- `./mvnw -B clean test` — Tests run: 2857, Failures: 0, Errors: 0,
Skipped: 0
- jacoco ∩ `git diff -U0 469539e...HEAD` over changed main code — 54
trackable changed lines, zero uncovered lines and zero uncovered
branches

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

Value beyond #571, as the issue puts it: a generator that knows the
findings can target a test at the sink the review flagged rather than
hoping to stumble across it. `DiffBudgetPlanner.java` was not touched —
`planBatches` and its overhead assembly live in
`AbstractPrSuggestionGenerator`, which already exposed the
`extraPerCallSections` hook this needed.

---

## Follow-up: the bot's own review of this PR (9e82261)

Both LOW findings were judged on the merits and both were right.

**Absent finding line rendered a fabricated location.**
`ReviewResponse.Finding.line` is a primitive `int`, so an absent line
arrived as 0 and the prompt carried `src/Foo.java:0` — with the file
absent too, a bare `:0`. That points at a line that exists nowhere and
costs tokens on every finding without one. The location is now written
only when the finding has a file, and the `:line` only when the line is
real. The test (renamed `writesNoLocationForAFindingThatHasNone`) now
exercises all three shapes — no file, file without line, file with line
— which is the fixture gap the finding named. Red on the unfixed
rendering:

```
org.opentest4j.AssertionFailedError:
1. [] :7 —
2. [HIGH] src/Foo.java:0 — No line
3. [LOW] src/Bar.java:12 — Located
   d
 ==> expected: <true> but was: <false>
```

**Fail-soft guard covered only the fetch.** The `try` wrapped
`findAllPriorAiResponseJsons` but not the deserialization of the rounds
it returns — which runs on JSON written by earlier versions of the bot.
Nothing in `FollowUpAnalyzer` looks able to throw today (`parseResponse`
catches `JsonProcessingException`, and a null element in `findings` /
`previous_findings_status` is rejected by `List.copyOf` inside the
record constructor, which Jackson wraps into a
`ValueInstantiationException` — a `JsonProcessingException`), but that
guarantee rests on a third-party library's wrapping behaviour in a class
outside this change. The point of the guard is that `/generate-tests`
still works when its context cannot be read, so it now spans the whole
load rather than the fetch alone. Red with the guard narrowed back:

```
java.lang.IllegalStateException: unreadable stored round
	at dev.thiagogonzaga.thrillhousebot.review.UnitTestGeneratorTest$1.parsePreviousResponses(UnitTestGeneratorTest.java:725)
	at dev.thiagogonzaga.thrillhousebot.review.UnitTestGenerator.priorFindings(UnitTestGenerator.java:269)
	at dev.thiagogonzaga.thrillhousebot.review.UnitTestGenerator.generate(UnitTestGenerator.java:197)
```

Gates re-run on 9e82261: BugInstance size is 0, spotless clean, `Tests
run: 2857, Failures: 0, Errors: 0, Skipped: 0`, and the jacoco ∩ diff
intersection (from
`/Users/thiago/repos/fix-606/target/jacoco-quarkus.exec`) still reports
zero uncovered lines and zero uncovered branches over 54 trackable
changed lines.
…gth cap (#617)

## What type of PR is this?

- [x] 🐛 Bug fix

## Description

`FindingVerificationService` discarded the whole verifier response on
the length-stop path, even
though everything needed to salvage it was already in place:

- #592 changed `AiResponses.textOrThrowOnTruncation` to pass
`result.content()` instead of `null`, so
the paid-for, cut text now travels on the truncation as its partial
body;
- #546 already added `salvageArray(body, "verdicts", …)` and the service
already runs it on the
*other* lane that reaches a cut body — the one where the provider
reports no
  `finish_reason=length` at all and the cut surfaces as a parse failure.

Only the length-stop lane still threw everything away. A verification
call cut mid-JSON discarded
every verdict it had already paid for, including the complete ones, so a
body carrying nine closed
verdicts and a tenth cut off contributed nothing.

This wires the two together. `salvageTruncatedVerdicts` runs the same
salvage over
`AiResponseTruncatedException.partialBody()` and applies what closed;
the id-coverage count both
lanes log is now one helper rather than two copies.

Nothing else about the lane changes. The truncation is still not
retried, the fail-open contract is
untouched, and a candidate whose verdict fell on the far side of the cut
simply has no verdict —
`apply` keeps such a finding exactly as it stands, so a missing verdict
never rejects or downgrades
anything. A truncation with nothing recoverable (no partial body at all,
or a cut before the first
verdict closed) keeps every unverified finding exactly as before.

## Related Issues

Fixes #599

## How Has This Been Tested?

- [x] Unit tests

**Red output on unfixed code** (`./mvnw -B test
-Dtest=FindingVerificationServiceTest` at `469539e`
with only the tests applied):

```
[ERROR] Tests run: 45, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 2.000 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.appliesTheVerdictsThatClosedBeforeTheResponseLengthCapCutTheBody -- Time elapsed: 0.036 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <2> but was: <3>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
	at dev.thiagogonzaga.thrillhousebot.review.ai.FindingVerificationServiceTest.appliesTheVerdictsThatClosedBeforeTheResponseLengthCapCutTheBody(FindingVerificationServiceTest.java:388)
```

Three findings in, three findings out: the `rejected` verdict that had
closed before the cut was
discarded along with everything else, so nothing was applied. Green, the
rejected candidate is
dropped, the downgraded one is lowered, and the candidate past the cut
survives untouched.

The second test pins the unchanged half of the contract from both
directions — a cut landing inside
the first verdict, and a truncation carrying no partial body at all — so
the fail-open path is not
quietly narrowed by the salvage.

Gates:

- `./mvnw -B clean compile spotbugs:check spotless:check` — `BugInstance
size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` — `Tests run: 2849, Failures: 0, Errors: 0,
Skipped: 0`
- jacoco ∩ `git diff -U0 469539e...HEAD` — 0 uncovered lines, 0
uncovered branches in changed main code

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

Scope is `FindingVerificationService` only, as the issue asks — neither
the salvage helper nor the
truncation type needed a change.

Co-authored-by: Thiago Gonzaga <thiago@ensina.tech>
…under token budgeting (#618)

## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [ ] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

Three of the defects grouped in #486, all inside `FindingPipeline`.

### 1. The heuristic failure-mode dimension was dead under the shipped
default (P3)

`ReviewPromptAssembler.assemble` gates `HEURISTIC_FAILURE_MODES_REQUEST`
on `ctx.diff()`, and
`ReviewContextLoader.load` sets `diff` to `""` whenever
`activeModel.maxInputTokens() > 0` — which
`thrillhousebot.review.max-input-tokens` defaults to `48000`. So on the
shipped configuration the
detector was always handed an empty string, always answered "no
heuristic code", and the whole
review dimension (#123 / #420) was never appended to a single review
call. Nothing failed; the
dimension just silently contributed nothing.

The section is now decided in `FindingPipeline`, which is the first
point that holds the material
the call actually receives — the plan's batch text — and it is decided
**per batch**, so only the
batches whose own slice introduces a decision rule pay for the guidance.
This is the same fallback
`VerdictBuilder.reviewedCode` already applies for the decline recheck
("with token budgeting on,
`ctx.diff()` is empty and the planned batches are authoritative"); the
prompt-assembly path was the
one still reading the empty slot.

Two supporting details:

- **The failure is loud now.** A batch whose text is blank logs a WARN
naming the dimension it
could not evaluate, instead of being indistinguishable from a PR that
genuinely introduces no
heuristic code. That silence is what let the gap survive from before the
feature existed.
- **File scoping is restored.** `HeuristicCodeDetector` scopes files by
the unified-diff
`+++ b/path` header, which the bot's rendered `### path (status, +a -d)`
sections do not carry —
so with the raw section text the detector's test-file exclusion (a
fixture regex is not new
production logic) would stop applying and its JS/TS regex-literal signal
would never fire. The
batch's own headers are translated before the scan; everything else
passes through verbatim, so
the detector reads exactly the added lines the model was given, clipping
included.

Sizing note: the planner sized the shared overhead before this section
existed, so a batch that
triggers it runs ~700 tokens over the planned overhead. That is the same
shape as the
withheld-material notice this class already prepends after planning, and
it is comfortably inside
the token safety margin (10% of 48000 ≈ 4800 tokens by default).

**Audit of the rest of the `combineSections` chain**, as the issue asks:
`heuristicFailureModesSection`
is the only `ctx.diff()`-gated section. The others are driven by
`repoLabels`, config, `relatedTests`
(derived from `reviewableFiles`, not the diff), `prDescription` +
`linkedIssuesContext`,
`configKeyContext`, `patchCoverage` and the instruction resolvers — none
reads `ctx.diff()`. The
`fencedDiff` slot itself does, and the pipeline already replaces it per
batch.

### 2. `clampOverview` dropped the head it exists to protect, and
miscounted (P4)

All three sub-issues, fixed as one: the overview is now split at the
seam the clamp acts on — a
header block (pure-rename rollup + PR-scope totals, rendered first
precisely so clamping can only
take the tail) and the per-file rows.

- The packing loop can no longer break at zero listed lines and emit a
bare rollup note in the
header's place. A budget too small to hold the header withholds the
overview outright.
- The rollup note now counts files, because rows are all it can drop.
Previously it counted lines,
so the scope header, "Directories touched:" and the per-directory rows
inflated it — a two-file
  PR was reported as "(+5 more changed files)".
- The `overviewBudget <= 0` guard became `overviewBudget - noteReserve <
headerTokens`, so the
"overview withheld" message is no longer bypassed when the budget is
positive but below what the
  note and header need.

### 3. `unwrapParallelFailure` was dead code (P8, first half)

No production caller — the real retry path inlines the throw. Deleted,
along with the two tests
that were keeping it alive.

### Explicitly not in this PR

The remaining parts of #486 live in files held by other agents in this
round, so they are untouched
here rather than silently dropped: `/improve`'s budget-exhaustion
message (P5, `PrImprovementService`),
the `max-ai-calls` and `max-diff-lines` javadoc drift (P6 / P8's second
half, `ThrillhouseConfig`),
and the unbounded legacy render at `max-input-tokens <= 0` (P7,
`DiffBudgetPlanner`).

One further gap found while verifying P3 and **not** fixed here: on the
non-default
`max-input-tokens <= 0` path the assembler still feeds the detector
`ctx.diff()`, which is rendered
in the same header-less `### path` form — so file scoping does not apply
there either. Closing that
needs `ReviewPromptAssembler` or `HeuristicCodeDetector`, both outside
this PR's scope.

## Related Issues

Partially fixes #486 (P3, P4, and P8's dead-code half).

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [ ] Manual testing

New tests in `FindingPipelineTest`, failing on the unfixed code exactly
as claimed:

```
[ERROR] Tests run: 67, Failures: 4, Errors: 0, Skipped: 0, Time elapsed: 6.119 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest
[ERROR] dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.anOverviewTooSmallForItsScopeHeaderIsWithheldNotReducedToABareRollup -- Time elapsed: 0.108 s <<< FAILURE!
org.opentest4j.AssertionFailedError:
(+5 more changed files — overview truncated to fit the summary budget)
 ==> expected: <true> but was: <false>

[ERROR] dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.theHeuristicDimensionIsScopedToTheBatchThatIntroducesTheRule -- Time elapsed: 0.062 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>

[ERROR] dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.aBatchWithNoTextSaysTheHeuristicDimensionCouldNotBeEvaluated -- Time elapsed: 0.064 s <<< FAILURE!
org.opentest4j.AssertionFailedError: the skipped dimension must be stated, not silent: [] ==> expected: <true> but was: <false>

[ERROR] dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.aBudgetedBatchThatIntroducesHeuristicCodeCarriesTheFailureModeDimension -- Time elapsed: 0.055 s <<< FAILURE!
org.opentest4j.AssertionFailedError: repo rules ==> expected: <true> but was: <false>
```

The first failure is the P4 proof in one line: the PR under test has two
changed files, and the
clamp announced five — the head was dropped and the note counted lines.

Gates: `spotless:apply`, `clean compile spotbugs:check spotless:check`
(BugInstance size is 0),
`clean test` (full suite green).

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Screenshots / Logs

N/A

## Additional Notes

Scope is deliberately limited to `FindingPipeline` and its tests; the
other #486 groups are listed
above so the issue can be closed only once they are all covered.
#619)

## What type of PR is this?

- [x] 🐛 Bug fix
- [ ] ✨ Feature
- [x] 📝 Documentation
- [ ] 🔧 Refactor
- [ ] 🚀 Performance
- [ ] ✅ Test
- [ ] 🔒 Security
- [ ] 📦 Dependency update
- [ ] 🏗️ CI/CD

## Description

The shipped `deepseek-v4-flash` entry described a contract the model is
not on. It claimed 1,000,000
input tokens with 384,000 output on a **separate** budget; the provider
counts the completion against
the same 1,048,576-token context as the prompt and refuses the call:

```
This model's maximum context length is 1048576 tokens. However, you requested 2446275 tokens
(2062275 in the messages, 384000 in the completion).
```

That pair cannot fit the context even with a perfectly budgeted prompt —
1,000,000 + 384,000 is over
the limit before a single diff line is packed.

**1. The defaults now describe the provider's actual behaviour.**

```properties
thrillhousebot.ai.models.deepseek-v4-flash.context-tokens=1048576
thrillhousebot.ai.models.deepseek-v4-flash.max-input-tokens=900000
thrillhousebot.ai.models.deepseek-v4-flash.max-output-tokens=8192
```

`separate-output-budget` is gone (shared is the default and the correct
contract). `max-input-tokens`
keeps 148,576 tokens of window clear for the response — the same
headroom as the values a deployment
is demonstrably running today (900000 in / 96000 out = 996,000 of
1,048,576). The shipped
`max-output-tokens` matches the shipped `REVIEW_OUTPUT_BUFFER_TOKENS`
reservation (8192), so the
number the budgeter holds back and the number the provider is licensed
to generate agree by
construction; raising one means raising the other, which is exactly what
the shared-window rules
enforce. The old 384000 response allowance is not replaced with another
guess: on a shared window it
would spend 37% of every call's context on the completion.

**2. A `max-input + max-output > context` pair is now refused at boot**,
following the
`StartupConfigValidator` fail-fast pattern:

- **Per entry** (`validateWindowFits`, alongside the other per-model
rules): a shared-window model
that declares `context-tokens` must fit `max-input-tokens +
max-output-tokens` inside it. Checked
for *every* configured entry, not only the active one — the #502 lesson:
a bad pair in the shipped
  table is otherwise invisible until someone points `AI_MODEL` at it.
- **Per deployment** (`validateActiveModelWindow`): the same ceiling on
the values that will actually
be sent — the effective input budget (`REVIEW_MAX_INPUT_TOKENS` clamped
by the model cap) plus the
largest response cap any lane may request, i.e. `max-output-tokens`
**or**
`REVIEW_CONCISE_MAX_OUTPUT_TOKENS`, since the summary/verifier/reply
calls are charged to the same
window. This is the rule an environment can trip without touching the
shipped table.

Both are skipped for a `separate-output-budget` model (its completion is
not drawn from the window)
and for a model that declares no `context-tokens`, so existing
configurations are unaffected. A
non-positive `context-tokens` is rejected on its own terms and does not
produce a second, derived
complaint.

Worth being explicit about the limit of this: the validator closes the
*shared-window* hole the issue
asks for, but it could not have caught the shipped mistake by itself.
`separate-output-budget=true`
is a factual claim about the provider, and claiming it wrongly is
precisely what switches every guard
off. Only a runtime signal — the provider's own length rejection — can
falsify it, which is part 3.

**3. The 5x retry of a context-length rejection is deliberately NOT in
this PR.** It is a different
file lane (the AI call/retry path, same class as #495 and #508 — a
deterministically invalid request
that fails identically on every attempt), it has its own red/green
proof, and mixing a retry-policy
change into a config correction would make both harder to review and to
revert. It should be a
separate PR against the same milestone; this PR removes the
misconfiguration that triggered it, not
the burn itself.

Docs: README's per-model section (inlined into the docs site),
`.env.example`, and the properties
comments now state the shared-window arithmetic and warn that marking a
shared-window model
`separate` turns off every guard.

## Related Issues

Fixes #562

## How Has This Been Tested?

- [x] Unit tests
- [ ] Integration tests
- [x] Manual testing

Full suite: `Tests run: 2861, Failures: 0, Errors: 0, Skipped: 0` —
BUILD SUCCESS.
Docs site (`cd website && npm ci && npm run build`): 66 pages built,
"All internal links are valid."

### Red proof

Against the unfixed code (fix reverted, new tests kept):

Shipped defaults restored to `max-input-tokens=1000000` /
`max-output-tokens=384000` /
`separate-output-budget=true`:

```
[ERROR] dev.thiagogonzaga.thrillhousebot.config.AiPricingConfigTest.shouldShipDeepSeekV4FlashCapsThatFitItsSharedContextWindow -- Time elapsed: 0.014 s <<< FAILURE!
org.opentest4j.AssertionFailedError: this model's completion is spent out of its context window, so it must stay on the shared contract where the buffer is reserved and the caps are held to context-tokens ==> expected: <Optional.empty> but was: <Optional[true]>
	at dev.thiagogonzaga.thrillhousebot.config.AiPricingConfigTest.shouldShipDeepSeekV4FlashCapsThatFitItsSharedContextWindow(AiPricingConfigTest.java:97)
```

Validator rules removed (both call sites), shipped defaults also
reverted:

```
[ERROR] Tests run: 12, Failures: 3, Errors: 0, Skipped: 0, Time elapsed: 0.037 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest$SharedContextWindow
[ERROR] dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest.failsFastWhenTheEffectiveInputBudgetOverrunsAWindowSmallerThanTheDefaultCap -- Time elapsed: 0.003 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Expected dev.thiagogonzaga.thrillhousebot.config.ConfigValidationException to be thrown, but nothing was thrown.
	at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3234)
	at dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest.assertFailsValidation(StartupConfigValidatorTest.java:240)
	at dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest$SharedContextWindow.failsFastWhenTheEffectiveInputBudgetOverrunsAWindowSmallerThanTheDefaultCap(StartupConfigValidatorTest.java:652)

[ERROR] dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest.failsFastWhenAModelsOwnCapsCannotBothFitItsContextWindow -- Time elapsed: 0.002 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Expected dev.thiagogonzaga.thrillhousebot.config.ConfigValidationException to be thrown, but nothing was thrown.
	at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3234)
	at dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest.assertFailsValidation(StartupConfigValidatorTest.java:240)
	at dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest$SharedContextWindow.failsFastWhenAModelsOwnCapsCannotBothFitItsContextWindow(StartupConfigValidatorTest.java:569)

[ERROR] dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest.countsTheConciseCapAsTheResponseTermWhenItIsTheLargerOne -- Time elapsed: 0.002 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Expected dev.thiagogonzaga.thrillhousebot.config.ConfigValidationException to be thrown, but nothing was thrown.
	at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3234)
	at dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest.assertFailsValidation(StartupConfigValidatorTest.java:240)
	at dev.thiagogonzaga.thrillhousebot.config.StartupConfigValidatorTest$SharedContextWindow.countsTheConciseCapAsTheResponseTermWhenItIsTheLargerOne(StartupConfigValidatorTest.java:674)
```

"Nothing was thrown" is the defect stated exactly: a configuration that
cannot possibly work booted
cleanly. Note that the existing guard test
`everyShippedModelBootsUnderTheShippedConciseCap` passes
in **both** states — booting was never the problem, which is why the
ceiling rule was needed.

The new `context-tokens` accessor has to exist for the new tests to
compile, so "unfixed" above means
the config key present with the rules and shipped values reverted; there
is no way to compile a test
for a key against a tree that does not declare it.

### Gates

- `./mvnw -B spotless:apply` → clean
- `./mvnw -B clean compile spotbugs:check spotless:check` → `BugInstance
size is 0`, `Error size is 0`, BUILD SUCCESS
- `./mvnw -B clean test` → 2861 tests, 0 failures, 0 errors
- Coverage: jacoco ∩ `git diff -U0 469539e...HEAD` over changed main
code (`StartupConfigValidator`,
`ThrillhouseConfig`) → 35 executable changed lines, **zero uncovered
lines and zero uncovered
  branches**

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

**Operator-facing change.** A deployment that overrode the flash caps to
the old shape needs to
re-check them: with `separate-output-budget` gone,
`REVIEW_OUTPUT_BUFFER_TOKENS` must cover
`max-output-tokens` again, and `REVIEW_MAX_INPUT_TOKENS` + the largest
response cap must fit
1,048,576. The proven combination (900000 / 96000 / 65536, with the
buffer raised to 96000) boots and
is covered by a test.

This PR does not touch defect 2 from the issue (the token estimate
running ~2.2–2.3x under the
provider's count). That needs measurement first and lives in the
budgeter/tokenizer lane.
## What type of PR is this?

- [x] 📝 Documentation
- [x] 🐛 Bug fix

## Description

`./mvnw quarkus:dev` refuses to boot on a fresh clone:
`StartupConfigValidator` hard-requires
`GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`, `GITHUB_WEBHOOK_SECRET` and
`AI_API_KEY`. That refusal is
correct and stays. What was missing is discoverability — the README
dev-mode section never named
the `.env.example` → `.env` step, and never said the private key has to
be a real generated PEM.

**README dev-mode section** now states the `cp .env.example .env` step,
the PEM requirement, and a
one-line command for a throwaway key:

```bash
openssl genrsa -traditional 2048 | awk '{printf "%s\\n", $0}'
```

`-traditional` is load-bearing. `RsaPrivateKeyParser` only strips PKCS#1
armor
(`BEGIN RSA PRIVATE KEY`), and OpenSSL 3's `genrsa` writes PKCS#8 by
default, so a key generated
without the flag is rejected at boot exactly like placeholder text.
Verified directly against the
parser:

```
pkcs8.pem     -> REJECTED: Could not parse RSA private key: Illegal base64 character 2d
key (pkcs1)   -> OK
```

**`.env.example` shipped a value that cannot work.** The private key was
double-quoted; the
surrounding quotes are read as part of the value in dev mode and boot
fails with
`Illegal base64 character 22` (`"`). The example is now the unquoted
single-line form with `\n`
escapes, which was verified to work on both paths — `quarkus:dev`, and
the docker compose
`env_file` path (`docker compose run` shows the literal `\n` reaching
the container, which the
parser handles).

**Validator message.** The refusal is where the reader actually is, so
it now names the remedy:
every `GITHUB_PRIVATE_KEY` problem carries the accepted format, the
single-line spelling and the
openssl command, and the closing line says to copy `.env.example` to
`.env` rather than merely
pointing at the file. The real refusal now reads:

```
ThrillhouseBot cannot start — required configuration is missing or invalid:
  - GITHUB_PRIVATE_KEY is set but is not a valid PEM RSA private key (thrillhousebot.github.private-key): Could not parse RSA private key: Illegal base64 character 2e. Use the PKCS#1 PEM GitHub downloads when you generate an App private key, on one line with \n escapes and no surrounding quotes; for a throwaway local key run: openssl genrsa -traditional 2048
Copy .env.example to .env in the project root (both dev mode and docker compose read it), set the values above, and restart. Dashboard OAuth (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET) is optional.
```

## Related Issues

Fixes #593

## How Has This Been Tested?

- [x] Unit tests
- [x] Manual testing

**Red/green proof.** Three new tests in `StartupConfigValidatorTest`,
verbatim red output on the
unfixed validator:

```
[ERROR] StartupConfigValidatorTest.missingPrivateKeyNamesHowToObtainOne:295 ThrillhouseBot cannot start — required configuration is missing or invalid:
  - GITHUB_PRIVATE_KEY is required but is not set (thrillhousebot.github.private-key)
Set the values above (see .env.example) and restart. Dashboard OAuth (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET) is optional. ==> expected: <true> but was: <false>
[ERROR] StartupConfigValidatorTest.malformedPrivateKeyNamesHowToObtainOne:306 ThrillhouseBot cannot start — required configuration is missing or invalid:
  - GITHUB_PRIVATE_KEY is set but is not a valid PEM RSA private key (thrillhousebot.github.private-key): Could not parse RSA private key: Illegal base64 character 2d
Set the values above (see .env.example) and restart. Dashboard OAuth (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET) is optional. ==> expected: <true> but was: <false>
[ERROR] StartupConfigValidatorTest.refusalNamesCopyingEnvExampleAsTheFirstStep:315 ThrillhouseBot cannot start — required configuration is missing or invalid:
  - AI_API_KEY is required but is not set (quarkus.langchain4j.openai.api-key)
Set the values above (see .env.example) and restart. Dashboard OAuth (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET) is optional. ==> expected: <true> but was: <false>
[ERROR] Tests run: 68, Failures: 3, Errors: 0, Skipped: 0
```

**End-to-end check of the instructions themselves.** Followed the new
README text from scratch on a
clean worktree (JDK 25): `cp .env.example .env`, generated the key with
the documented openssl
command, pasted it unquoted, filled the other three values:

```
INFO [dev.thiagogonzaga...StartupConfigValidator] Configuration validated: GitHub App id, private key, webhook secret, and AI API key are present.
INFO [io.quarkus] thrillhousebot 0.5.1-SNAPSHOT on JVM (powered by Quarkus 3.38.0) started in 9.435s. Listening on: http://localhost:8080
```

**Gates**

- `./mvnw -B spotless:apply` then `./mvnw -B clean compile
spotbugs:check spotless:check` — BUILD
  SUCCESS, `BugInstance size is 0`
- `./mvnw -B clean test` — `Tests run: 2850, Failures: 0, Errors: 0,
Skipped: 0`
- `cd website && npm ci && npm run build` (run from this branch's
worktree) — 66 pages built,
`All internal links are valid.` The only intra-README link added sits in
the Development section,
which no docs page inlines (`include:` covers only `#features`,
`#providers`, `#commands`,
  `#configuration`, `#repository-configuration`, `#pr-labels`).
- Coverage: jacoco ∩ `git diff -U0` over changed main lines — zero
uncovered lines, zero uncovered
  branches.

## Checklist

- [x] My code follows the project's coding standards
- [x] I have performed a self-review of my own code
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the documentation accordingly
- [x] My changes generate no new warnings or errors

## Additional Notes

Behaviour is unchanged — the validator still refuses to boot on exactly
the same conditions; only
its wording gained the remedy. The `.env.example` quoting fix is the one
change with a functional
effect, and it makes a previously broken example work on both the
dev-mode and docker compose
paths.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request java Pull requests that update java code testing Test coverage and test quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant