Skip to content

fix(review): record the shape a dropped file_summaries recovery read - #873

Merged
devops-thiago merged 3 commits into
mainfrom
fix/872-file-summaries-diagnostics
Sep 16, 2026
Merged

devops-thiago merged 3 commits into
mainfrom
fix/872-file-summaries-diagnostics

Conversation

@devops-thiago

Copy link
Copy Markdown
Owner

What type of PR is this?

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

Description

ReviewResponseParser.normalizeFileSummaries recovers summary.file_summaries from several shapes the schema does not accept — a map keyed by path, entries keyed file/description, "path: summary" strings, a single object where the array belongs — and drops what it cannot read. The warning it ends on counted the drops and said nothing about what it had read:

Review response file_summaries did not match the schema — recovered 0 entries and dropped 4; unrecovered entries render as blank walkthrough rows

That is the whole record of a review whose Changed Files table rendered with no summaries. The session row stores the response after this rewrite, so summary.file_summaries reads [] there, and by the time anyone looks the shape that arrived is gone. A count cannot tell an unknown key from a non-textual value, from a level of nesting nobody expected, or from a scalar.

When the recovery saves nothing, the warning now also carries the node type file_summaries arrived as, and the node type and field names of the first dropped entry:

Review response file_summaries did not match the schema — recovered nothing from 2 entries, so the walkthrough renders with no summaries; it arrived as ARRAY, first entry OBJECT with field(s) [file_path, change_summary]

Names only, never values: a name is all it takes to decide whether PATH_KEYS/SUMMARY_KEYS should grow, while a value carries the model's prose about the diff. The model chooses those names, so each one goes through LogSafe.oneLine at the point it is read, and the line is bounded in both directions — at most eight names, each cut at forty characters, with the rest counted (… and 3 more) — since an entry is free to carry a thousand keys or one key a megabyte long.

Three things deliberately do not change:

  • The conforming path. An array of textual {path, summary} entries still returns before any of this and logs nothing.
  • Partial recovery. A recovery that saves some entries keeps the count line it always logged and describes no shape, so one stray entry in an otherwise readable array does not start logging a shape on every review.
  • The recovery itself. No key is added to PATH_KEYS or SUMMARY_KEYS. The point of the change is to capture the evidence; the production response is not available to read, and file_path/change_summary in the test is a stand-in chosen to exercise the code, not an observed shape. Widening the lists on a guess would be the same mistake in a new place.

Verified against the code

Every claim in the issue holds. normalizeFileSummaries does drop unreadable entries and log only counts; PATH_KEYS and SUMMARY_KEYS are as the issue lists them; and the response the session row keeps is written after normalization, so the pre-normalization node is not recoverable from it.

On recording the node on the session row

Left out, on purpose. ReviewSession.aiResponseJson is a single TEXT column holding the whole serialized response, so preserving the original file_summaries means either a second column and a migration or an ad-hoc field inside that JSON, and what it would hold is a whole walkthrough's worth of model prose — the largest free-text field in the response after the findings — written on exactly the rounds that already failed. The question it would answer is "what shape arrived", and the node type plus the first entry's field names answer that in one bounded log line that costs nothing on a healthy review. The javadoc on normalizeFileSummaries states that trade-off so the next reader does not re-open it. If a real case ever needs the values rather than the shape, that is the moment to pay for the column.

Related Issues

Fixes #872

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

New FileSummariesDropDiagnosticsTest captures the LogRecord the parser emits, so the assertions do not depend on which handler is installed. Five tests: an all-dropped array, the map form / a scalar / a single entry (each of the three nodes the description can report on), the sanitizing and bounding of the names, a partly recovered response, and a conforming one.

Red, against the unfixed parser:

[ERROR] Tests run: 5, Failures: 3, Errors: 0, Skipped: 0
[ERROR]   FileSummariesDropDiagnosticsTest.anAllDroppedRecoveryNamesTheNodeTypeAndTheFirstEntrysFieldNames:110 Review response file_summaries did not match the schema — recovered 0 entries and dropped 2; unrecovered entries render as blank walkthrough rows ==> expected: <true> but was: <false>
[ERROR]   FileSummariesDropDiagnosticsTest.aMapFormAScalarAndASingleEntryAreEachDescribed:118 Review response file_summaries did not match the schema — recovered 0 entries and dropped 1; unrecovered entries render as blank walkthrough rows ==> expected: <true> but was: <false>
[ERROR]   FileSummariesDropDiagnosticsTest.theFieldNamesAreSanitizedAndBoundedInCountAndLength:140 Review response file_summaries did not match the schema — recovered 0 entries and dropped 1; unrecovered entries render as blank walkthrough rows ==> expected: <true> but was: <false>

The two guard tests — the partly recovered response and the conforming one — passed before the fix and still pass, which is what makes them guards.

Green, with the fix:

[INFO] BugInstance size is 0
[INFO] BUILD SUCCESS

[INFO] Tests run: 4042, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Patch coverage on the src/main diff against origin/main, line and branch, from target/site/jacoco/jacoco.xml:

GAPS: none

Checklist

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

Additional Notes

The sanitizing test drives the real forgery characters through the parser: a field name carrying U+000A and a bidi override (U+202E) comes out of the line as one two, collapsed rather than deleted, so neither can forge a record boundary or reorder what an operator reads. LogSafeInvariantTest derives its untrusted accessors from the response records and so does not reach a name read off a JsonNode; the name is wrapped where it is read, which is where that class's javadoc says the wrap belongs.

A CHANGELOG.md entry is added under ## [Unreleased]### Fixed.

summary.file_summaries arrives in several shapes the schema does not
accept, and the recovery drops what it cannot read while logging only
counts. A production review dropped all four of its entries, so the
Changed Files table rendered with no summaries and nothing recorded
what had arrived: the session row stores the response after the
rewrite, so its file_summaries reads [] there.

When the recovery saves nothing, the warning now also carries the node
type file_summaries arrived as, and the node type and field names of
the first dropped entry, which is what tells an unknown key apart from
a non-textual value or a level of nesting nobody expected. Those names
are the model's text, so each goes through LogSafe, at most eight are
listed, each is cut at forty characters and the rest are counted. No
value is logged, and the node stays out of the session row.

A conforming array is still returned untouched and in silence, and a
recovery that saves some entries keeps the count line it always logged,
so one stray entry does not start logging a shape on every review. No
key is added to PATH_KEYS or SUMMARY_KEYS: the shape has to be observed
before the lists grow.
@devops-thiago devops-thiago added this to the v0.6.9 milestone Sep 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

When the walkthrough recovery of summary.file_summaries recovers nothing, the parser's warning now also names the node type file_summaries arrived as and the node type plus field names of the first dropped entry — each name routed through LogSafe.oneLine and bounded to eight names of forty characters with the remainder counted — so a blank Changed Files table can be diagnosed after the fact without logging any model prose.

Description vs. Implementation

No mismatch found between the PR description and the change.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
  A["normalizeFileSummaries(root)"] --> B{"nothing recovered from file_summaries?"}
  B -- "no" --> C["warn recovered n / dropped m (unchanged count line)"]
  B -- "yes" --> D["firstEntry(fileSummaries): element 0, single entry, or first property value"]
  D --> E["describeEntry: node type + field names"]
  E --> F["first 8 property names via LogSafe.oneLine, each cut at 40 chars, remainder counted"]
  F --> G["warn: it arrived as NODETYPE, first entry ... and N more"]
  C --> H["summary.set file_summaries to normalized"]
  G --> H
Loading

Changes Overview

  • Files changed: 3
  • Lines added: +262
  • Lines removed: -4

Changed Files

File Change Summary
CHANGELOG.md Modified Adds an Unreleased/Fixed entry describing the shape-recording drop diagnostic and the deliberately unchanged recovery and session-row behavior
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/ReviewResponseParser.java Modified Splits the drop warning: empty recovery adds arrived-as node type and sanitize/bounded field names of the first dropped entry; partial recovery keeps the old count line
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ai/FileSummariesDropDiagnosticsTest.java Added Five LogRecord-capturing tests covering all-dropped, map/scalar/single-entry shapes, name sanitizer bounds, partial recovery, and the silent conforming path

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 0
🔵 Low 1

Things to double-check

1 lower-confidence finding
  • LOW: Sanitize test embeds a raw U+000A inside a JSON string, which a plain ObjectMapper rejects by default (src/test/java/dev/thiagogonzaga/thrillhousebot/review/ai/FileSummariesDropDiagnosticsTest.java:131) (low confidence — verify before acting)

⚠️ Required CI Checks Status

Some required checks are still pending or have failed:

Check Type Status Detail
test check-run ⏳ Pending -

Automated review by ThrillhouseBot. Reply with /review to re-run.

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ThrillhouseBot noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):

  • LOW: Sanitize test embeds a raw U+000A inside a JSON string, which a plain ObjectMapper rejects by default (src/test/java/dev/thiagogonzaga/thrillhousebot/review/ai/FileSummariesDropDiagnosticsTest.java:131)
    The test builds the forged field name as: var forged = "one" + ch(0x0A) + ch(0x202E) + "two"; and then embeds it verbatim into the raw document at: var entry = new StringBuilder("{"" + longName + "": 1, "" + forged + "": 1"); — so the JSON text contains a literal U+000A character inside a quoted string value. RFC 8259 forbids unescaped control characters (U+0000–U+001F) inside JSON strings, and Jackson's default JsonReadFeature.ALLOW_UNQUOTED_CONTROL_CHARS is disabled, so com.fasterxml.jackson.databind.node readTree on such input throws "Illegal unquoted character (#10)". The parser instance under test is plain — private final ReviewResponseParser parser = new ReviewResponseParser(new ObjectMapper()); — so unless ReviewResponseParser.parse internally deserializes with a mapper that enables ALLOW_UNQUOTED_CONTROL_CHARS (its read path is not in the provided material), the raw U+000A is rejected at JSON-syntax level before normalizeFileSummaries' recovery or LogSafe.oneLine ever sees it: the test either errors (if parse propagates) or the captured line is a parse-failure record and the assertions on the truncated/bounded names fail. Note that the author-supplied red and green outputs show this input parsing cleanly, which conflicts with Jackson's documented default behavior; I cannot fully reconcile that from the diff, so treat this as a verification request — CI will show whether the run stays green and only the parse path (not the mapper configuration) settles which behavior is real. Even in the lenient case the intent survives unchanged: the escape-sequence form below produces the identical in-memory field name (onetwo) after Jackson unescapes it, so the sanitizer's newline-collapsing is what is actually exercised rather than the JSON parser's tolerance.

@thrillhousebot thrillhousebot Bot added bug Something isn't working java Pull requests that update java code labels Sep 16, 2026
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@devops-thiago

Copy link
Copy Markdown
Owner Author

/review

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues in this revision, but 1 previous finding(s) remain unresolved — fix them, or reply on their review thread with why they are deferred. A finding listed only under "Things to double-check" has no thread: clear it by commenting @thrillhousebot resolved path/to/File.java:42 — <the finding's title> on this PR.

The sanitizing test embeds U+000A directly in the JSON document rather
than as an escape, which reads as invalid JSON until one knows that
extractJson escapes control characters inside string literals before the
document is read. Say so at the line, so the deliberate choice is not
mistaken for a test that only happens to pass.
@devops-thiago

Copy link
Copy Markdown
Owner Author

@thrillhousebot resolved src/test/java/dev/thiagogonzaga/thrillhousebot/review/ai/FileSummariesDropDiagnosticsTest.java:131 — Sanitize test embeds a raw U+000A inside a JSON string, which a plain ObjectMapper rejects by default

Refuted: the mapper never sees that character raw. ReviewResponseParser.parse reads the document as readDocuments(extractJson(raw)) (ReviewResponseParser.java:110), and extractJson ends on return escapeControlCharsInStrings(trimmed) (ReviewResponseParser.java:693), which escapes U+0000–U+001F inside string literals and leaves control characters outside them alone. That pass exists because models emit raw tabs and newlines inside string fields and strict parsing would fail the whole review; the test is deliberately taking the same route a model's own stray newline takes, and the name reaches the recovery as onetwo either way. The escape-sequence form would produce the identical in-memory name while skipping that step, which is why the raw form is the one worth testing. U+202E is a format character, not a control one, so it passes through untouched to LogSafe as intended. The full suite is green at 4042 tests locally and in CI. A comment at the line now says this, in 78455f9, so the choice is not read again as a test that only happens to pass.

@thrillhousebot

Copy link
Copy Markdown
Contributor

The next review will close every previous finding this comment names by its path:line and title; anything it does not name stays open.

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check test is pending

thrillhousebot[bot]
thrillhousebot Bot previously approved these changes Sep 16, 2026

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Required CI is now green for 78455f9, so the approval the earlier review held back is posted. The code was not re-reviewed: that review found no issues, and only the CI gate held its approval.

Choosing between the two warning lines inside normalizeFileSummaries put
its cognitive complexity at 19, over the 15 the analysis allows. The
choice is its own decision and reads as one: which line a recovery earns
depends only on whether it saved anything, not on which shape it walked.
Behaviour is unchanged.

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check test is pending

@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Required CI is now green for 1e1732b, so the approval the earlier review held back is posted. The code was not re-reviewed: that review found no issues, and only the CI gate held its approval.

@devops-thiago
devops-thiago merged commit 21344e1 into main Sep 16, 2026
18 checks passed
@devops-thiago
devops-thiago deleted the fix/872-file-summaries-diagnostics branch September 16, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working java Pull requests that update java code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A dropped file_summaries recovery leaves a blank walkthrough and no record of the shape that arrived

1 participant