Skip to content

Close ChangelogService's mid-read failure window and cover it with tests - #337

Merged
rdeago merged 6 commits into
Tenacom:mainfrom
rdeago:fix/330-changelog-read-window
Aug 9, 2026
Merged

Close ChangelogService's mid-read failure window and cover it with tests#337
rdeago merged 6 commits into
Tenacom:mainfrom
rdeago:fix/330-changelog-read-window

Conversation

@rdeago

@rdeago rdeago commented Aug 9, 2026

Copy link
Copy Markdown
Member

Proposed changes

ChangelogService opened CHANGELOG.md through UserFile.OpenText, which — as documented — guards the
open only: an I/O failure raised while reading through the returned StreamReader still escaped as an
unhandled-exception stack trace. All three entry points now read the file through UserFile.ReadAllLines,
so a failure at any point of the read is reported as the usual single clean error line, and all three use
one strict UTF-8 encoding instead of two different ones.

The switch was deferred out of #329 because both rewrite methods are line-by-line state machines with no
test coverage, and #330 pairs the refactor with the missing tests. Covering them turned out to need one
design change beyond what the issue anticipated: ChangelogService cannot be constructed from a test at
all, since ServerAdapter has a private protected constructor and VersionService is sealed around
a live Git repository. Rather than build seams to mock the environment, the parsing and rewriting logic
moved into a new ChangelogUpdater that works on lines already read, leaving file access to the service
— the "extract pure logic out of plumbing" route. The state machines are otherwise unchanged: the only
edits are reader.ReadLine() becoming an indexed read with an EOF sentinel, and the section title
arriving as a Func<string>.

The title is a factory rather than a string so that it is still composed only when a section is actually
written. Passing a computed string would have moved the call ahead of the "contains no sections" check,
and a malformed changelog would then report a failure raised while building a title nobody will read.
Composing the title is itself a pure function of a version, a URL and a date, so it moved to
ChangelogUpdater too, where a test can pin its formatting.

Three behavior changes came out of writing the tests and reviewing them:

  • The release.emptyChangelog substitute replaces the whole body of the moved section, blank lines
    included, so it used to come out flush against both the new section's title and the heading below it.
    It is now trimmed and surrounded with blank lines of its own.

  • A release.emptyChangelog that is all whitespace substituted nothing for nothing: bv release
    announced the substitution and then wrote a section with no body. It now fails the release with the
    message that says the substitute is missing.

    Neither of the two gets a "bugs fixed" entry: release.emptyChangelog is introduced in this same
    unreleased version, so no released bv ever had the old behavior, and an entry would offer a reader
    a fix for something they are meeting for the first time a few bullets earlier. The rule is stated
    where the setting is introduced instead — the breaking-change entry now reads "when unset or blank,
    an empty section fails the release".

  • The release date in section titles was formatted with the current culture, so a machine set to th-TH
    or ar-SA wrote a date in a non-Gregorian calendar (2569-04-27 for 2026-04-27), matching neither
    the release tag nor the other section titles. It is now formatted with the invariant culture, pinned by
    a test that runs under th-TH. This bug predates the PR; it was folded in rather than deferred because
    a follow-up would have cost far more than the fix.

Unrelated to the above, one commit sweeps BuildFailedException.ThrowIfNot(<negated condition>) call
sites across the repository into ThrowIf(<condition>). It is committed here because it touches
ChangelogUpdater, which this PR rewrites, and would otherwise conflict with it.

A last commit wraps the declarations and attributes of the files this PR works in that exceed the
style guide's line limits, kept apart from the changes under review as the rule prescribes.

The changelog entry for #329 was extended rather than joined by a new one: both changes land in the same
unreleased version, so between stable releases they are one story. The date bug, being the only change
here that a released bv actually shipped, has an entry of its own.

Checklist of related issues / discussions

Types of changes

This pull request introduces the following types of changes:

  • Bug fix
  • New feature
  • Test addition / update (no changes to non-test code)
  • Refactor (no changes in public API syntax or semantics)
  • Performance improvement (no changes in public API syntax or semantics)
  • Documentation (docs directory) update
  • Dependency addition / update
  • Changes to the build scripts
  • Changes to CI (workflows, bot / app configurations)
  • Changes to repository files (.gitattributes, .gitignore)
  • Other

Breaking changes

This pull request introduces breaking changes:

  • Yes
  • No

Checklist

  • For all types of changes:
  • For code changes only:
    • The project builds on my machine, via the provided build script, with zero warnings
    • I have added tests that prove my feature works / my fix is effective
    • I have added / modified XML documentation according to changes in code
    • I have checked that all the links I added or modified in XML documentation point to their intended destination

@rdeago rdeago added the enhancement [issue/PR] requests / implements new or improved functionality. label Aug 9, 2026
@github-actions github-actions Bot added area:docs [issue/PR] affects documentation (excluding XML documentation that is part of source code). area:code [issue/PR] affects project code (excluding tests). and removed enhancement [issue/PR] requests / implements new or improved functionality. labels Aug 9, 2026
@rdeago rdeago added the enhancement [issue/PR] requests / implements new or improved functionality. label Aug 9, 2026
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.14184% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.80%. Comparing base (bd0be5d) to head (49e155c).

Files with missing lines Patch % Lines
src/Buildvana.Tool/Services/ChangelogService.cs 0.00% 10 Missing ⚠️
src/Buildvana.Sdk.Tasks/Tasks/ConvertPfxToSnk.cs 0.00% 4 Missing ⚠️
src/Buildvana.Tool/Subcommands/ReleaseCommand.cs 0.00% 4 Missing ⚠️
src/Buildvana.Tool/Services/Git/GitService.cs 0.00% 3 Missing ⚠️
src/Buildvana.Sdk.Tasks/Tasks/GetWinePath.cs 0.00% 2 Missing ⚠️
src/Buildvana.Tool/Services/ChangelogUpdater.cs 98.16% 0 Missing and 2 partials ⚠️
src/Buildvana.Tool/Subcommands/ReleaseSettings.cs 50.00% 2 Missing ⚠️
...verAdapters/Internal/GitHub/GitHubServerAdapter.cs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #337      +/-   ##
==========================================
+ Coverage   54.65%   56.80%   +2.15%     
==========================================
  Files         157      158       +1     
  Lines        4435     4457      +22     
  Branches      786      789       +3     
==========================================
+ Hits         2424     2532     +108     
+ Misses       1912     1825      -87     
- Partials       99      100       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rdeago

rdeago commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

@claude please review this PR.

@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Claude finished @rdeago's task in 4m 45s —— View job


Review of PR #337

  • Gather context (diff, changed files, repo rules)
  • Review ChangelogUpdater extraction for behavior parity
  • Review ChangelogService read-path change
  • Review tests & coverage gaps
  • Check style-guide / file-format compliance
  • Post review

Ric — the extraction is the right call, and I traced both state machines line by line against origin/main: the readLine() → indexed-read-with-EOF-sentinel conversion is faithful, EOF semantics match (File.ReadAllLines and a ReadLine loop agree on trailing newlines, on empty files, and on \r / \n / \r\n), and File.ReadAllLines(path, encoding) passes detectEncodingFromByteOrderMarks: true internally, so BOM handling is unchanged too. The Func<string> title is well argued, and the test that pins it (PrepareForRelease_DoesNotMakeSectionTitleWhenThereAreNoSections) is exactly the one that makes the argument stick. BOMs are present on all three .cs files.

Four things, in priority order.


1. HasUnreleasedChanges leaves the window open in the very command the changelog says is now covered

ChangelogService.cs:161-ish path in ReleaseCommand.cs:161 calls changelog.HasUnreleasedChanges() before changelog.PrepareForRelease() at ReleaseCommand.cs:177. So during bv release, the first thing that touches CHANGELOG.md is still the streaming path, and an IOException raised by reader.ReadLine() inside ChangelogUpdater.HasUnreleasedChanges (src/Buildvana.Tool/Services/ChangelogUpdater.cs:45) still escapes as a raw stack trace — the exact symptom this PR closes.

The changelog entry now reads:

bv release reads the whole of CHANGELOG.md before rewriting it, so a file yanked mid-read (say, by a cloud-sync provider) is reported the same clean way.

For the rewrite that's true; for the read that happens moments earlier in the same command it isn't. A cloud-sync provider yanking the file is precisely as likely during read #1 as during read #2.

The justification in the comment at ChangelogService.cs:60-62 is about how much of the file is parsed, not about I/O cost — and PrepareForRelease reads the whole file microseconds later anyway, so making HasUnreleasedChanges take IReadOnlyList<string> costs one full read of a file that is about to be fully read regardless. That also makes ChangelogUpdater's three methods take the same shape of input, which is worth something on its own.

I'd either close it here too, or narrow the changelog sentence so it doesn't claim the whole command. My preference is the former. Fix this →


2. An empty or whitespace-only release.emptyChangelog now emits three blank lines

ChangelogUpdater.cs:203-209. The new Trim() + surrounding blanks is right for real substitute text, but for "":

"".ReplaceLineEndings("\n").Trim().Split('\n')  →  [""]
result                                          →  ["", "", ""]

Mid-file that renders as three consecutive blank lines between the new section title and the next heading — an MD012 violation written into the user's changelog. origin/main produced one blank line there. It is reachable: schemas/buildvana.schema.json:44-47 declares emptyChangelog as a plain "type": "string" with no minLength, ResolveEmptyChangelog() (ReleaseSettings.cs:135) returns it verbatim, and ReleaseCommand.cs:168-170 only checks is not null.

Cheapest fix is to trim once into a local and treat an empty result as "no substitute"; adding "minLength": 1 to the schema would stop it one layer earlier, though the schema isn't load-bearing at runtime. Fix this →


3. PrepareForRelease_UsesLineFeedAsLineSeparator doesn't exercise what it looks like it does

tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs:281-289. The substitute carries \r\n, but the unreleased section contains "- Something new.", so result.All(string.IsNullOrWhiteSpace) is false and the substitute is discarded before its line endings are ever normalized. DoesNotContain("\r") then passes because no input line contained \r in the first place — the assertion can't fail for the reason the test name suggests.

The CRLF-normalization path is genuinely covered by PrepareForRelease_SubstitutesConfiguredTextForEmptySection (line 172), so nothing is untested; this test is just claiming a guarantee it doesn't check. Either drop the unused emptyChangelogSubstitute argument, or empty the section so it's actually used. Fix this →


4. The class remarks contradict one of the three methods

ChangelogUpdater.cs:20-23:

The methods of this class work on changelog contents already read, leaving file access to ChangelogService.

HasUnreleasedChanges takes a live TextReader over an open file, so it doesn't. If finding 1 is taken, this fixes itself; if not, the remarks need to say "with the exception of…". Fix this →


Nits (take or leave)

  • == null / != null in a new file. ChangelogUpdater.cs uses them at lines 46, 48, 52, 101, 105, 116, 144, 244, 248, 257, 269. csharp-style-guide.md asks for is null / is not null. They're verbatim moves so the churn argument is real, but the file is new, and the null-state analysis that BuildFailedException.ThrowIfNot relies on works identically either way.
  • Encoding asymmetry. HasUnreleasedChanges reads with Encoding.UTF8 (replacement characters on malformed input) while the two rewrite methods use new UTF8Encoding(false, true) (throws). Pre-existing, but it means a corrupt changelog is silently tolerated by the check and then fails the rewrite. Folding it into finding 1 would let one encoding serve all three.
  • Untested guarantee. UpdateNewSectionTitle's doc promises the title factory is called "at most once, and only if a section heading is actually replaced", but only PrepareForRelease has the corresponding test. A five-line twin would close the asymmetry.
  • Pre-existing, out of scope: MakeSectionTitle (ChangelogService.cs:103) formats DateTime.Now with yyyy-MM-dd through the current culture, so on a machine defaulting to a non-Gregorian calendar (th-TH, ar-SA) the year written into the changelog is wrong. The line moved in this diff but didn't change; worth its own issue rather than this PR.

What I verified, and what I couldn't

Verified by reading: state-machine parity against origin/main, EOF/BOM/line-ending equivalence of the read-path swap, every expected string in the test file recomputed by hand against the new implementation (all eight PrepareForRelease expectations match), BOM presence, test-project conventions (global namespace, internal sealed, TUnit assertions — all consistent with the neighbours).

I could not build or run the tests — dotnet build is not permitted in this environment, so I have no independent confirmation of the zero-warning claim or of the test run. Everything above is from reading the code.

Net: the design change is sound and the tests are a real improvement over the zero coverage these state machines had. Finding 1 is the one I'd want settled before merge, since it decides whether the changelog entry is accurate as written.
fix/330-changelog-read-window

@rdeago

rdeago commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Thanks — findings 1 and 2 were both right, and 1 was right about the changelog entry too.

1. Mid-read window in HasUnreleasedChanges. Taken. ChangelogUpdater.HasUnreleasedChanges now takes IReadOnlyList<string> like its siblings, and ChangelogService reads through UserFile.ReadAllLines. You were right that the "only needs a prefix" justification was about parsing, not I/O, and that ReleaseCommand reads the whole file moments later regardless — it is in fact the only caller, so the streaming path bought nothing at all. The repeated lineIndex < lines.Count ? … is now a GetLineOrNull helper shared by the three state machines.

Two things came along with it, as you predicted: the class remarks (finding 4) are now true as written, and all three methods share one FileEncoding field. That last one is a real behavior change, so, stating it plainly: a changelog containing invalid UTF-8 now fails the check with a clean Could not read from CHANGELOG.md: … instead of being read with replacement characters and failing at the rewrite. That is the intended reading — a changelog that can't be read faithfully can't be rewritten faithfully — and it falls under the existing #329 entry.

2. Empty substitute. Taken, plus one step further. The substitute is trimmed once into a local and an empty result is treated as no substitute. Beyond that, ReleaseSettings.ResolveEmptyChangelog() now returns null for a whitespace-only value, so emptyChangelog: " " fails the release with the existing actionable message rather than quietly producing a body-less section — the tool was otherwise reporting "substituting the configured text" while substituting nothing.

No minLength in the schema: the code now handles the case honestly, and the schema isn't load-bearing at runtime, so the rule would only exist in two places with one of them unable to enforce it.

3. Vacuous LF test. Taken — the unreleased section is now empty, so the CRLF-bearing substitute is actually exercised. (The DoesNotContain("\r") assertion wasn't entirely idle, since it also pins writer.NewLine, but the substitute argument was dead weight and the test name over-promised.)

4. Resolved by 1.

Nits. is null/is not null throughout ChangelogUpdater; encoding asymmetry folded into 1; added the UpdateNewSectionTitle laziness twin. And the DateTime.Now culture bug is fixed here rather than deferred — MakeSectionTitle goes through string.Create(CultureInfo.InvariantCulture, …), and it got its own bullet under Bugs fixed in this release, since it's a distinct user-visible defect. It stays untested: the format lives inside ChangelogService, the very type this PR concluded can't be instantiated in a test, and putting a seam behind a one-line date format to assert it would be the coverage theater the repo rules rule out.

Sanity check on the new commit: dotnet bv pack clean (0 warnings, 0 errors, all test projects run), inspectcode --severity=WARNING at 0 results.

@claude please take another look.

@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Claude finished @rdeago's task in 6m 9s —— View job


Review of PR #337 (round 2)

  • Gather context (new commit diff since round 1)
  • Verify finding 1 fix (HasUnreleasedChanges read path + shared encoding)
  • Verify finding 2 fix (empty substitute + ResolveEmptyChangelog)
  • Verify findings 3, 4 and nits
  • Review the new MakeSectionTitle culture fix
  • Post review

Ric — all four are properly closed, and I re-traced the parts that moved rather than taking the summary on trust.

1 — mid-read window. ChangelogUpdater.HasUnreleasedChanges now takes IReadOnlyList<string> (ChangelogUpdater.cs:33) and ChangelogService.cs:71 reads through UserFile.ReadAllLines. ReleaseCommand.cs:161 is indeed the only caller, so nothing else paid for the streaming path. GetLineOrNull (ChangelogUpdater.cs:286) is index-for-ReadLine faithful in all three machines: post-increment placement is unchanged, and every null pivot (:113, :141, :249, :258, :270) still fires at the same point in the sequence.

Your invalid-UTF-8 claim holds, and for a reason worth recording since it isn't obvious: DecoderFallbackException derives from ArgumentException, and ExceptionExtensions.cs:40 already admits (ArgumentException and not ArgumentNullException) into IsIORelatedException, so UserFile.Read wraps it — the check really does fail with Could not read from CHANGELOG.md: … rather than a stack trace. It isn't an accidental hole in the guard.

2 — empty substitute. ChangelogUpdater.cs:201-210 trims once into a local and falls through on an empty result; I re-derived PrepareForRelease_IgnoresWhitespaceOnlySubstitute by hand and the single blank line between the two headings is what comes out. ResolveEmptyChangelog filtering whitespace (ReleaseSettings.cs:139-143) is the better half of the fix — ReleaseCommand.cs:172 was otherwise logging "substituting the configured text" while substituting nothing. Agreed on skipping minLength; see the one-word nit below instead.

3 — LF test. Now genuinely exercises the substitute: the section body is a single blank line, result.All(string.IsNullOrWhiteSpace) is true, the CRLF-bearing substitute is normalized, and DoesNotContain("\r") can now fail for the reason the name gives.

4 — true as written now.

Culture fix. string.Create(CultureInfo.InvariantCulture, …) at ChangelogService.cs:105-107 is the right shape, and MakeSectionTitle is the only culture-formatted date that reaches file contents anywhere in src/ — I grepped for the siblings and there are none, so the changelog bullet's scope is exactly right. The 2569 example checks out: custom specifiers go through the culture's Calendar, and th-TH defaults to ThaiBuddhistCalendar.

Nothing blocking. Four leftovers, all small.


a. ChangelogUpdater.cs:75 breaks the 120-column parameter-list rule

public static string PrepareForRelease(IReadOnlyList<string> lines, Func<string> makeSectionTitle, string? emptyChangelogSubstitute)

129 characters with indentation, and csharp-style-guide.md is unambiguous for declarations: past 120, every parameter goes on its own line. This is a new signature (on main the method took only emptyChangelogSubstitute), so it isn't a verbatim move — I missed it in round 1. Its siblings are fine: HasUnreleasedChanges and UpdateNewSectionTitle both fit.

GetLineOrNull at :286 is 126 characters too, but there the overflow is the expression body, not the parameter list, and wrapping the two parameters would read worse than wrapping after =>. Your call whether the rule reaches it; if it doesn't, the rule could say so. Fix this →

b. The schema description no longer describes the behavior

schemas/buildvana.schema.json:45 still says "When omitted, an empty changelog fails the release." — now a blank value does too. This isn't the minLength you declined: the description is documentation, not a second enforcement point, and it's what a user sees in editor IntelliSense. One word (omitted or blank) keeps it honest without duplicating the rule. Fix this →

c. The strict encoding is best-effort for a changelog that carries a BOM

File.ReadAllLines(path, encoding) passes detectEncodingFromByteOrderMarks: true, and detection replaces the reader's encoding — your own UserFileTests.cs:461-476 pins exactly that for a UTF-16 BOM. My recollection of the BCL is that the UTF-8 BOM branch substitutes the static Encoding.UTF8, whose fallback is replacement rather than throw; if so, a BOM'd CHANGELOG.md with invalid bytes still decodes to U+FFFD instead of failing, and WriteAllText then persists the replacement characters (a strict encoder is happy to write U+FFFD).

I could not verify that branch here — no network and no dotnet — so treat it as a caveat to check rather than a finding. It only bites a changelog Buildvana didn't write, and the outcome is what main already did, so I wouldn't act on it beyond perhaps not over-promising in the comment at ChangelogService.cs:31-33.

d. The culture fix could be pinned without a seam

You're right that mocking the environment for a date format would be theater — but there's a third option that's the same move this PR already made twice: MakeSectionTitle is a pure function of a version string, a URL and a date. As ChangelogUpdater.MakeSectionTitle(string version, string releaseUrl, DateTime date) it becomes testable with no seam and no mock, and a [NotInParallel] test that sets CultureInfo.CurrentCulture to th-TH and asserts 2026-01-01 pins the fix — which a test in the host culture cannot, since both the old and new code agree under en-US.

The cost is real: the format leaves the service that owns _version and _server, so the composition ("## " + …) sits in one place and the title text in another. Fine either way; the argument for "untestable" is weaker than for the rest of ChangelogService, and this one is a bug you just fixed. Fix this →


What I verified, and what I couldn't

Read and re-derived by hand: GetLineOrNull parity against main's ReadLine loops in all three machines; the expected string of every PrepareForRelease test, including the two that changed and the one that's new; the DecoderFallbackExceptionIsIORelatedExceptionBuildFailedException chain that backs your encoding claim; that ReleaseCommand.cs:161 is the sole HasUnreleasedChanges caller; that UserFile.OpenText is still used (SelfReferenceUpdater.cs:137) and so isn't now dead; that MakeSectionTitle is the only culture-formatted date reaching file contents; that release.emptyChangelog is indeed still inside ## Unreleased changes (line 28, well above the 1.1.10 heading at line 117), so it needs no entry of its own; long-line scan of all four touched source files.

Not verified: I still cannot build or run tests here — dotnet is not permitted in this environment — so the clean bv pack and the zero inspectcode results are your report, not my confirmation. Nor could I check the BCL source for item (c).

Net: I'd merge this. The read path is now uniform across the three entry points, the whitespace substitute is handled at both layers with the honest failure rather than a quiet one, and the culture bug was worth pulling forward rather than deferring.
fix/330-changelog-read-window

@rdeago
rdeago force-pushed the fix/330-changelog-read-window branch from 5dcb69d to c587a7d Compare August 9, 2026 14:57
@rdeago

rdeago commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Round 2 addressed. The branch was rebased onto main and force-pushed, so the commit range has moved.

a — the 120-column rule. PrepareForRelease is wrapped. The declaration was 136 characters, not 129.

Your question about GetLineOrNull turned out to be the useful part of the finding, so it got answered in the rule rather than in the code. Ric's ruling: the 120-character threshold applies to declarations only — the section is titled "Long parameter lists" — so invocation lines like BuildFailedException.ThrowIf(...) were never in scope. What was missing was any limit at all for every other line, which is what left both of us guessing. .claude/rules/csharp-style-guide.md now sets a general limit of 140 characters for code and comment lines, and narrows the 120-character rule to declarations. That commit is on main, below this branch.

The limit applies to new and modified lines always, and to a whole file when you are actually working in it, as a separate commit. A mechanical sweep touching many files with a line or two each does not count — otherwise the ThrowIf sweep would have owed 25 unrelated wraps, and a rule that makes cleanups expensive stops producing cleanups. Under that rule this PR owed three lines, all in files it works in: the 162-character class doc in ChangelogService and two lines in ReleaseSettings.

b — the schema description. Fixed, but not where you pointed. schemas/buildvana.schema.json is generated from [Description] on ReleaseConfig.EmptyChangelog, and tools/generate-config-schema.cs runs in check mode in CI: editing only the schema would have made the build fail as stale. Both now read "When omitted or blank", and --check reports the schema up to date.

c — the BOM caveat. You were right, and it is now verified rather than recalled. A probe writing EF BB BF C3 28 and reading it back through File.ReadAllLines(path, new UTF8Encoding(false, true)):

no BOM:    THREW DecoderFallbackException: Unable to translate bytes [C3] at index 0
UTF-8 BOM: NO THROW -> "?(" [FFFD 0028]

A detected BOM does replace the strict encoding with one whose fallback substitutes U+FFFD, so a BOM'd changelog with invalid bytes is read and rewritten with replacement characters. No behavior change here — that is what main does too — but the comment at ChangelogService.cs:31 now says so instead of promising a guarantee the code does not make.

d — pinning the culture fix. Taken. ChangelogUpdater.MakeSectionTitle(string version, Uri releaseUrl, DateTime date) is pure, needs no seam, and sits next to the "## " + makeSectionTitle() that consumes it; the service passes DateTime.Now. The parameter is Uri, not string: ServerAdapter.GetReleaseUrl returns Uri, and the old code relied on interpolation calling ToString(). A [NotInParallel] test sets CultureInfo.CurrentCulture to th-TH and asserts 2026-01-01 — verified failing calendars first: th-TH gives ThaiBuddhistCalendar2569-01-01, ar-SA gives UmAlQuraCalendar1447-07-12. My "coverage theater" objection in round 2 was aimed at mocking a clock or a culture provider; this needs neither, so it was the wrong objection.

One thing you did not flag, and should have caught me on. Making a whitespace-only release.emptyChangelog fail the release is a user-visible behavior change, and it had no changelog entry. It has one now.

The ThrowIfNotThrowIf sweep that arrived on this branch is now described in the PR body, with why it is committed here.

Sanity check on the rebased branch: dotnet bv pack exit 0, 0 warnings / 0 errors, 749 tests passed; inspectcode --swea --severity=WARNINGTotal results: 0; schema check up to date.

@claude please take another look.

@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Claude finished @rdeago's task in 6m 59s —— View job


Review of PR #337 (round 3)

  • Gather context (rebased branch, new style-guide rule on main)
  • Verify (a) declaration wrap + the new 140-char rule as applied here
  • Verify (b) schema description + generator round-trip
  • Verify (c) BOM caveat comment
  • Verify (d) MakeSectionTitle extraction + th-TH test
  • Verify the new changelog entry
  • Post review

Ric — all four closed, and the two you went past the finding on (the generated schema, the culture pin) are the better calls. Details of what I re-derived rather than took on trust:

a. PrepareForRelease is wrapped one-per-line with the closing paren on the last parameter (ChangelogUpdater.cs:75-78). GetLineOrNull is now split after the declaration (:307-308), so neither line is in question any more. Swept the four files this PR works in for the new 140 limit: ChangelogService.cs, ChangelogUpdater.cs, ReleaseSettings.cs, ChangelogUpdaterTests.cs are all clean — the class doc is wrapped at ChangelogService.cs:20-22, and the longest survivors are ChangelogUpdater.cs:193 (126) and ChangelogService.cs:106 (121). The >140 lines still in ReleaseCommand.cs, GitService.cs and GitHubServerAdapter.cs are all unmodified context; every line the sweep touched is well inside. The carve-out reads right to me — and I'd rather have the rule than my guess at it.

While there: the sweep is complete. No ThrowIfNot(!…), ThrowIfNot(… is not null) or equivalent survives anywhere in src/; the eleven remaining ThrowIfNot sites all take genuinely positive conditions (TryParse, TryGetPropertyValue, version comparisons). Null-state analysis survives the flip because ThrowIf carries [DoesNotReturnIf(true)] (BuildFailedException.cs:127), the mirror of ThrowIfNot's [DoesNotReturnIf(false)] at :141.

b. Right, and I should have looked for the generator before pointing at the artifact. ReleaseConfig.cs:37 and schemas/buildvana.schema.json:45 now carry byte-identical text, so --check has nothing to complain about.

c. Your probe settles it, and ChangelogService.cs:31-36 now documents the limit instead of the intent: strict on reads, best-effort when a BOM is present, unchanged from main either way. That's the honest version.

d. The extraction lands where I'd hoped, and the Uri parameter is better than the string I suggested — string.Create(IFormatProvider, …) formats a non-IFormattable through ToString(), which is exactly what the old interpolation did, so the URL text is unchanged. One thing worth recording, since it's what would silently neuter the test later: the repo sets InvariantGlobalization nowhere, so new CultureInfo("th-TH") really does get ICU data and ThaiBuddhistCalendar. If that property is ever switched on for the tool or the test project, MakeSectionTitle_UsesInvariantCalendar_WhateverTheCurrentCulture keeps passing while pinning nothing.

Three leftovers, none blocking.


1. The new changelog entry documents a fix to a feature that has never shipped

CHANGELOG.md:108 — "A release.emptyChangelog consisting only of whitespace no longer counts as a substitute … bv release used to announce that it was substituting the configured text, then write a release section with no body at all."

But release.emptyChangelog is introduced in this same unreleased version: CHANGELOG.md:90 is the breaking change that removes --unstable-changelog / --require-changelog and replaces them with release.changelogUpdates + release.emptyChangelog. No released bv has the setting, so "used to" refers to a state only this branch's predecessors were ever in. Once the section becomes 2.1's release notes, a reader gets a bug fix for behavior they never had, for a setting they're meeting for the first time four bullets earlier.

That's the same ground on which the blank-line trimming was excused from an entry in round 1 ("release.emptyChangelog has not shipped yet, so this needs no changelog entry of its own"). Both are defensible, but they should agree — and the version that costs the reader least is to state the rule where the feature is introduced: :90 already says "when unset, an empty section fails the release", which one word makes complete and correct.

So my honest answer to "you should have caught me" is the opposite direction from the one you expected: the gap isn't a missing entry, it's two entries deciding the same question differently. Fix this →

2. One declaration in a file this PR works in is over the 120-character threshold

ReleaseSettings.cs:82public static ReleaseSettings Parse(IReadOnlyList<string> options, BuildvanaConfig config, DotNetSettings dotNetSettings) is 125 characters with indentation. Pre-existing and unmodified, so it's only owed if the "bring the whole file within the limit" clause carries the stricter declaration threshold along with the 140-character one.

The rule can be read either way: the sweep clause says "within the limit" (singular, in the 140 section), while "Declarations are held to a stricter limit" sits two lines above it. You swept this file for 140 already, so the question is live rather than academic — and it'll come up again on the next file someone tidies. Either wrap it, or say in the rule that whole-file cleanup means the 140 limit only. Fix this →

3. A typo rode along into the new file

ChangelogUpdater.cs:107: "Copy everything up to an including the first section heading" — "an" for "and". Verbatim from ChangelogService.cs:128 on main, so it's not new, but the line is in a file that didn't exist last week. Fix this →


What I verified, and what I couldn't

Read and re-derived: that main's MakeSectionTitle was a plain interpolated string (ChangelogService.cs:303 on main), i.e. current-culture, so the bug was real and the fix changes only the calendar and not the URL text; that the write encoding is unchanged from main (new UTF8Encoding(false, true) there too) and only HasUnreleasedChanges' read encoding moved; that no negated ThrowIfNot survives in src/; that every line the sweep modified is far under 140 and the four worked-in files are clean at 140; that the [Description] attribute and the generated schema carry identical text; that InvariantGlobalization/PredefinedCulturesOnly appear nowhere in the repo; that the three new changelog bullets sit under ## Unreleased changes (line 10, next ## at 118); BOMs on all four touched .cs files.

Not verified: I still cannot build or run tests here, so the clean bv pack, the 749 passing tests, the zero inspectcode results and the schema --check are your report, not my confirmation.

Net: ship it. Item 1 is a changelog wording call, not a code one, and items 2–3 are a line and a letter.
fix/330-changelog-read-window

rdeago and others added 6 commits August 9, 2026 17:22
CHANGELOG.md was opened through UserFile.OpenText, which guards the open only:
an I/O failure raised while reading through the returned StreamReader escaped as
an unhandled-exception stack trace. PrepareForRelease and UpdateNewSectionTitle
consume the file to EOF and rewrite it whole, so streaming buys them nothing;
they now read through UserFile.ReadAllLines, and a failure at any point is
reported as the usual single clean error line. HasUnreleasedChanges keeps
streaming, and says why: it stops at the first line of content it finds.

The parsing and rewriting state machines move to a new ChangelogUpdater that
works on lines already read, leaving file access to the service. This is what
makes them testable at all: ChangelogService cannot be constructed from a test,
as ServerAdapter has a private protected constructor and VersionService is
sealed around a Git repository. The new type takes the section title as a
factory rather than a string, so it is still composed only when a section is
actually written, and a malformed changelog reports its own failure rather than
one raised while building a title nobody will read.

Also trim the release.emptyChangelog substitute and surround it with blank
lines. It replaces the whole body of the moved section, blank lines included, so
it used to come out flush against both the new section title and the heading
below it. The setting has not shipped yet, hence no changelog entry of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bv release` checks the "Unreleased changes" section before rewriting the
changelog, so the streaming read left in `HasUnreleasedChanges` was still
the first thing to touch `CHANGELOG.md`: a mid-read failure there escaped
as an unhandled exception, the very symptom the previous commit closed for
the rewrite. `ChangelogUpdater.HasUnreleasedChanges` now takes the lines of
the changelog like its siblings, and `ChangelogService` reads them in one
guarded call, with the same encoding used to rewrite the file.

Also, in the same area:

- an all-whitespace `release.emptyChangelog` no longer writes three blank
  lines into the changelog: it is treated as no substitute at all, both
  where the substitute is applied and where the setting is resolved, so
  the release fails with the message that names the missing setting;
- the release date in changelog section titles is formatted with the
  invariant culture, instead of whatever calendar the machine prescribes;
- `PrepareForRelease_UsesLineFeedAsLineSeparator` empties the unreleased
  section, so that the CRLF-bearing substitute it passes is actually used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This shouldn't need an explanation.
Left as is where it made sense, which means a very few places.
MakeSectionTitle is a pure function of a version, a URL and a date, so
it moves to ChangelogUpdater, next to the only place that composes a
section heading from it. A test can then pin the invariant formatting
under th-TH, where a culture-formatted date reads 2569-01-01.

Also from the round-2 review:

- A blank release.emptyChangelog fails the release, so say so in the
  configuration model's description, whence the JSON schema is
  generated, and in the changelog.
- The strict UTF-8 decoding of CHANGELOG.md is bypassed when the file
  carries a byte order mark: the file APIs detect it, and a detected
  BOM replaces the encoding with one that substitutes U+FFFD. Say that,
  instead of promising a guarantee the code does not make.
- Bring the touched files within the 140-character line limit, and wrap
  the parameter list of PrepareForRelease, which exceeds the stricter
  120-character limit for declarations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
release.emptyChangelog ships in this same unreleased version, so a "bugs
fixed" entry about a blank one describes a state no released bv was ever
in. The breaking-change entry that introduces the setting says instead
that an unset or blank value fails the release, which is the whole rule
in one place, and the round-2 entry goes away. This also settles the way
the blank-line trimming was settled in round 1, rather than the opposite
way.

Also from the round-3 review:

- Say in MakeSectionTitle_UsesInvariantCalendar_WhateverTheCurrentCulture
  that it needs ICU data: under InvariantGlobalization, th-TH would fall
  back to the Gregorian calendar and the test would pin nothing.
- Fix a typo carried over verbatim from ChangelogService.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whole-file line-length cleanup covers the stricter limit that
declarations are held to, so ReleaseSettings.Parse gets its parameter
list wrapped, and the two Description attributes of ReleaseConfig that
were over the general limit are split. The attribute arguments stay
constant expressions, so the generated schema is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rdeago
rdeago force-pushed the fix/330-changelog-read-window branch from c587a7d to 49e155c Compare August 9, 2026 15:29
@rdeago

rdeago commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

All three taken. Branch rebased again (the style-guide clarification below is a new commit on main), so the range has moved once more.

1 — the changelog entry. You are right, and the direction you took it is the one I should have taken myself: the inconsistency was mine, between excusing the blank-line trimming in round 1 and adding an entry for the whitespace fix in round 2. release.emptyChangelog ships in this same version, so neither change is a bug any reader ever met. The bullet is gone, and the breaking-change entry that introduces the setting now reads "when unset or blank, an empty section fails the release" — the whole rule, stated once, where the setting is introduced. The PR description says so too, so the two excuses no longer disagree.

2 — ReleaseSettings.Parse. Ambiguity conceded, and resolved in the rule rather than in this file alone: whole-file cleanup now explicitly carries the declaration limit. "A file brought within the general limit but left with over-long declarations is a file that still owes a second sweep" — which is the whole argument for not splitting the two thresholds.

That widened this PR's debt by one file, since a sweep that ignores declarations is not the sweep the rule now describes: ReleaseConfig.cs, which this PR edits one [Description] in, had two attribute lines at 158 and 166 characters. Both are split; the arguments stay constant expressions, so --check still reports the schema up to date. Parse and those two are the last over-limit lines in the files this PR works in, and they are in their own commit, apart from the changes under review.

3 — typo. Fixed.

On the InvariantGlobalization trap: worth recording in the test rather than in a review thread, so the comment above MakeSectionTitle_UsesInvariantCalendar_WhateverTheCurrentCulture now says the test needs ICU data to bite, and what silently happens to it if that property is ever switched on. Whoever flips it meets the warning at the point of failure.

Sanity check on the rebased branch: dotnet bv pack exit 0, 0 warnings / 0 errors, 749 tests passed; inspectcode --swea --severity=WARNINGTotal results: 0; schema check up to date.

Thanks for three rounds of it — the two calls you went past a finding to make (the generated schema, this round's changelog reasoning) were both better than the finding.

@rdeago
rdeago merged commit ab9aaaa into Tenacom:main Aug 9, 2026
8 checks passed
@rdeago
rdeago deleted the fix/330-changelog-read-window branch August 9, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:code [issue/PR] affects project code (excluding tests). area:docs [issue/PR] affects documentation (excluding XML documentation that is part of source code). enhancement [issue/PR] requests / implements new or improved functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Close ChangelogService's mid-read failure window and cover it with tests

1 participant