Skip to content

check-new-line-breaks: add clause-break check (SemBr rule 5), on by default - #337

Merged
dem-extra1 merged 12 commits into
mainfrom
claude/markdown-semantic-line-breaks-0aqhlh
Jul 28, 2026
Merged

check-new-line-breaks: add clause-break check (SemBr rule 5), on by default#337
dem-extra1 merged 12 commits into
mainfrom
claude/markdown-semantic-line-breaks-0aqhlh

Conversation

@d-morrison

@d-morrison d-morrison commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #336

What

check-new-line-breaks gains a second check: a newly-added line that joins
two independent clauses with a semicolon, past a length gate.
That is a narrow slice of the SemBr spec's rule 5 (the
SHOULD), alongside the rule 4 MUST the check already enforced.

It is on by default, per the maintainer's direction that a useful feature
should be opt-out rather than opt-in.
clause-breaks: false turns it off; clause-min-length (default '80')
moves the gate.

Note this reverses #336's own proposal, which asked for opt-in.
The measurement supports the reversal: #336 argued against a blanket
punctuation
rule at a 50.5% flag rate, but this narrow check flags 0.7%
of already-conforming lines.
Defaulting it on is also safe because the check stays warn-only unless
fail: true is set, so it adds annotations rather than build failures.

Why semicolons only, and why a length gate

The spec's rule 5 names a comma, semicolon, colon, and em dash, but as the
punctuation that marks the end of an independent clause -- not as the
trigger.
Measured over a 22,820-line conformant corpus:

Rule Lines flagged %
Sentence check alone (rule 4, before this PR) 3,120 13.7%
Any mid-line , ; : -- 11,527 50.5%
; only, no length gate 1,397 6.1%
; past the length gate (this PR) 157 0.7%

A comma is overwhelmingly a list separator or an appositive; a colon usually
introduces a list, which rule 7 already breaks before; a dash is usually a
paired parenthetical, where breaking at the first but not the second is wrong.
The rationale lives in has_unbroken_clause's docstring so a future reader
finds it next to the code rather than in this thread.

Review rounds

Round 1 --- two findings, both real:

  • The length gate read the raw line while the semicolon search read the
    stripped one, so a 128-character line with 16 characters of visible prose
    behind a URL was flagged. That also broke the URL-inflation exception in
    ai-config's own semantic-line-breaks guidance.
  • A docstring aside claimed em dashes are "banned from source files". The rule
    does cover .md (the enforcement gap is check-non-standard-chars skips .md files (and U+00D7), so gha's own README has 39 unflagged em-dashes #322), but the aside explained the
    exclusion of a character the predicate has no branch for, so it is gone.

Round 2 --- five findings, all real:

  • Bare URLs and autolinks bypassed stripping entirely, reopening round 1's
    bug for unbracketed links. strip_inline_markup now runs five patterns,
    ordered so link targets collapse before the bare-URL pattern runs.
  • HTML entities end in ; too, and were not stripped.
  • The "opted out" selftest step could not fail, so neither its comment nor
    the CLAUDE.md paragraph I added could claim it proved the input reaches the
    script. Replaced by real env var -> main() -> exit code coverage; both
    claims corrected.
  • clause-min-length was type: number, against this repo's idiom for
    length-threshold inputs forwarded to a composite. Now type: string.
  • A test named for find_violations only called classify_line. Deleted;
    the exit-code tests cover the plumbing it claimed to.

Verification

  • 44 unit tests pass (was 30). Every regression test added across both
    rounds was confirmed to fail against the defect it guards before being
    kept --- including stubbing the env read to prove the exit-code tests catch
    an input that never arrives.
  • End-to-end against a real corpus. Against d-morrison/ai-config, the
    default-on run and the clause-breaks: false run differ, so the toggle
    demonstrably reaches the script. Its first real catch was a genuine
    semicolon-joined clause in ai-config's own CLAUDE.md.
  • Dogfooded on this PR's own diff, which flagged 8 lines of my own prose;
    all fixed, and the check now reports clean.
  • Rendered docs verified on the PR preview, not just in source.
  • Banned-glyph scan over the added lines: 0 hits.

strip_inline_markup documents its invariant: it only ever removes
characters, so visible length is a conservative lower bound, and under-counting
can only suppress a flag rather than invent one.

Defaults are declared in three places, and a test pins them

_DEFAULT_CLAUSE_BREAKS / _DEFAULT_CLAUSE_MIN_LENGTH are the single source;
action.yml and the reusable workflow re-declare them.
A parametrized test reads both YAML files and asserts they agree with the
script --- the #303 precedent.
It earned its place immediately: the first draft left find_violations() on
False while everything else had moved to True.

Doc sync

All four sites plus the caller stub and a changelog.d fragment (category
changed, since behavior changes for existing consumers with no action on
their part).

Copilot AI review requested due to automatic review settings July 28, 2026 08:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot wasn't able to review any files in this pull request.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

claude added 2 commits July 28, 2026 08:51
…efault

Flags a newly-added line that joins two independent clauses with a
semicolon, alongside the rule 4 sentence check that already ran. On by
default (clause-breaks: false opts out); safe because the check stays
warn-only unless fail: true.

Semicolon only, past a length gate: measured over a 21,963-line conformant
corpus, keying on every mark the spec names flags 50.5% of already-conforming
lines against 1.1% for this check.

Closes #336
Copilot AI review requested due to automatic review settings July 28, 2026 08:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copy link
Copy Markdown
Collaborator Author

Verified the doc sync against the rendered preview, not just the source, since
CLAUDE.md records this repo taking four review rounds on exactly this
(gha#161).

From
https://d-morrison.github.io/gha/pr-preview/pr-337/reference/check-new-line-breaks.html,
the parsed Inputs table:

Input Type Default
python-version string '3.x'
globs string '*.md'
paths-ignore string ''
fail boolean false
clause-breaks boolean true
clause-min-length number 80

Both new rows render, and their defaults match _DEFAULT_CLAUSE_BREAKS and
_DEFAULT_CLAUSE_MIN_LENGTH in the script -- which is the same agreement the
new parametrized test asserts against action.yml and the reusable workflow,
now confirmed one layer further out in the published page a consumer actually
reads.
The sembr.org and gha#336 links in the new intro paragraph are present in
the rendered HTML too.

One note on how that check was run, since it nearly went wrong: my first parse
matched <tr> literally and reported zero rows, which reads as "the table did
not render". Quarto emits <tr class="odd">, so the regex was wrong, not the
page. Re-parsed with <tr[^>]*>.


Generated by Claude Code

Comment thread check-new-line-breaks/check-new-line-breaks.py
Comment thread check-new-line-breaks/check-new-line-breaks.py
@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Code review

Reviewed the clause-breaks addition to check-new-line-breaks (SemBr rule 5 semicolon check). The doc-sync discipline this repo asks for (README, website/workflows.qmd, website/reference/check-new-line-breaks.qmd, examples/check-new-line-breaks.yml, changelog fragment) is all present and consistent, the new parametrized test pinning action.yml/check-new-line-breaks.yml's declared defaults against the script is a nice concrete guard, and the SemBr rule 4/5/6/7 quotes and the d-morrison/gha#336 citation all check out against the actual spec text and issue.

Two findings posted inline on check-new-line-breaks/check-new-line-breaks.py:

  1. has_unbroken_clause's length gate checks len(text) (raw markdown) while the semicolon search runs on stripped (markup removed) — a short line whose visible prose is well under 80 chars can still be flagged solely because a long link target/code span inflates the raw line length. Reproduced with a concrete example; suggested a one-line fix (gate on len(stripped) instead), which doesn't disturb any existing test.
  2. The docstring's claim that em dashes are "banned from source files" doesn't hold for the files this check scans — that ban (check-non-standard-chars) is scoped to .qmd/.R, not .md, and this repo's own CLAUDE.md (a .md file, edited by this very PR) has 116 real em dashes. The surrounding paired-parenthetical argument doesn't need this claim to stand.

One smaller, non-blocking item not worth an inline anchor: clause-min-length's docs (in action.yml, check-new-line-breaks.yml, README, the website reference page, and the examples stub) describe it as the "minimum length" the check applies at, but the code requires strictly greater than min_length — a line of exactly 80 characters is not flagged. Low stakes, but the wording implies an inclusive boundary that isn't what's implemented.

No CLAUDE.md structural/doc-sync violations found, and no syntax or import errors (ast.parse and a manual module load both succeed).

ℹ️ This PR itself edits .github/workflows/check-new-line-breaks.yml's associated composite, not claude-code-review.yml/claude-review.yml — the "can't self-verify" caveat in CLAUDE.md doesn't apply here.

Verdict

Needs more work — finding 1 (the length-gate/stripped-text mismatch) is a real false-positive bug worth fixing before merge; finding 2 is a documentation-accuracy correction. Neither is large in scope.

@github-actions

Copy link
Copy Markdown
Contributor

Claude finished reviewView run


Review posted on PR #337.

Summary: Reviewed the check-new-line-breaks clause-break (SemBr rule 5) addition — doc-sync across all required sites checks out, spec quotes and citations verify against the real source, and no syntax/CLAUDE.md-structural issues found. Posted two inline findings:

  1. Length-gate bug (has_unbroken_clause): gates on the raw markdown line's length while searching for the semicolon in the markup-stripped text, so a short line with a long link target can false-positive. Confirmed with a reproducible example; included a one-line suggested fix.
  2. Doc-accuracy issue: the docstring claims em dashes are "banned from source files," but that ban only covers .qmd/.R (per check-non-standard-chars), not the .md files this checker actually scans — and this repo's own CLAUDE.md has 116 real em dashes.

Also noted a minor, non-blocking off-by-one in the clause-min-length docs (says "minimum length" but the code requires strictly greater than).

Verdict

Needs more work — the length-gate/stripped-text mismatch is a real bug worth fixing before merge.

@github-actions

Copy link
Copy Markdown
Contributor

💰 Cost: $5.0310 (review) — run

…op the em-dash aside

Round 1 findings on #337:

1. has_unbroken_clause gated on len(text) while searching the stripped text,
   so a short line inflated past the gate by a link target or code span was
   flagged. Reproduced with the reviewer's own example (128 raw chars, 16
   visible). Now gates on the stripped length, which also matches the
   URL-inflation exception in ai-config's semantic-line-breaks guidance.
2. The docstring's em-dash 'banned from source files' aside explained a code
   path that does not exist -- the predicate never looks for a dash -- so it
   is dropped rather than caveated.
3. clause-min-length is now inclusive, so the input's own name is true; that
   was cheaper than rewording five doc sites to say 'strictly greater than'.

Three regression tests added, each confirmed to fail against the pre-fix
gate. Re-measured every published figure against the current corpus, since
the gate change moved them: 22,820 lines, 50.6% for all marks, 0.7% for this
check (was 1.1%).
Copilot AI review requested due to automatic review settings July 28, 2026 09:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread check-new-line-breaks/check-new-line-breaks.py Outdated
Comment thread check-new-line-breaks/check-new-line-breaks.py Outdated
Comment thread .github/workflows/_selftest.yml
Comment thread .github/workflows/check-new-line-breaks.yml Outdated
Comment thread check-new-line-breaks/tests/test_check_new_line_breaks.py Outdated
@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Code review

Second round. Both prior findings are fixed and now regression-tested: has_unbroken_clause's length gate and semicolon search both operate on stripped (check-new-line-breaks/check-new-line-breaks.py:134-138), and the docstring's inaccurate "banned from source files" em-dash claim is gone (the only remaining "em dash" text is the direct SemBr rule-5 spec quote). Nice touch: the author also proactively fixed the non-blocking min_length inclusivity note from round 1, with its own test.

Five findings posted inline on this round's diff, all independently verified by executing the actual module or reading the actual files (not inferred):

  1. check-new-line-breaks.py:88-103 - Bare URLs and autolinks (https://... with no [text](...) wrapper) bypass strip_inline_markup entirely, reintroducing the same URL-inflation defect class round 1 fixed for bracketed links. Repro'd: identical prose flips the flag verdict purely on link syntax.
  2. check-new-line-breaks.py:134-138 - HTML character entities (&amp;, &nbsp;, &mdash;, ...) end in ; and aren't stripped, so a semicolon inside one is read as a clause boundary. Lower severity/realism than Add CLAUDE.md with PR review guidelines #1 - no current instance in this repo's or ai-config's tracked .md/.qmd files clears the length gate unbackticked - but it's the same false-positive class the PR's own measurement rationale is calibrated against.
  3. .github/workflows/_selftest.yml:160-168 - The new "opted out" selftest step never sets fail:, so it's green regardless of whether clause-breaks: false reaches the script, is dropped, or the input was never declared at all. Its own comment, and the matching new CLAUDE.md paragraph, both claim it "proves the opt-out actually reaches the script" - it doesn't. Confirmed no other test exercises the NLB_CLAUSE_BREAKS env var -> main() -> exit-code path either.
  4. .github/workflows/check-new-line-breaks.yml:32-38 - clause-min-length is declared type: number, breaking this repo's established idiom for length-threshold inputs forwarded to a composite action (all quoted type: string, e.g. max-script-lines, max-code-block-lines, max-line-length) - the composite's own input has no numeric type and parses the value manually anyway. Also drifts website/reference/check-new-line-breaks.qmd's Type column from its sibling reference pages.
  5. check-new-line-breaks/tests/test_check_new_line_breaks.py:327-332 (minor, non-blocking) - test_find_violations_default_matches_the_shared_constant never actually calls find_violations; the drift it describes is caught by a different test. Just a naming/comment mismatch.

One additional non-blocking note, not tied to a diff line so not posted inline: the PR description (not the code) still asserts "--... is only an ASCII stand-in since the spec names the em dash, which this org's style rules ban from source" - the same inaccurate claim removed from the docstring in round 1 - and its measurement table (21,963 lines / 1.1%) doesn't match the numbers actually shipped in the docstring/changelog/action.yml (22,820 lines / 0.7%). Neither affects merged files, but both are user-facing PR text worth a quick edit for consistency.

Verdict

Needs more work - finding 1 (bare-URL bypass) is a real, easily-reproduced false-positive bug worth fixing before merge, and finding 3 (no-op selftest step whose comment overstates its own coverage) misrepresents test coverage in both the workflow and the newly-added CLAUDE.md paragraph. Findings 2, 4, and 5 are smaller in scope.

@github-actions

Copy link
Copy Markdown
Contributor

Claude finished reviewView run


Code review

Second round. Both prior findings are fixed and now regression-tested: has_unbroken_clause's length gate and semicolon search both operate on stripped (check-new-line-breaks/check-new-line-breaks.py:134-138), and the docstring's inaccurate "banned from source files" em-dash claim is gone (the only remaining "em dash" text is the direct SemBr rule-5 spec quote). Nice touch: the author also proactively fixed the non-blocking min_length inclusivity note from round 1, with its own test.

Five findings posted inline on this round's diff, all independently verified by executing the actual module or reading the actual files (not inferred):

  1. check-new-line-breaks.py:88-103 - Bare URLs and autolinks (https://... with no [text](...) wrapper) bypass strip_inline_markup entirely, reintroducing the same URL-inflation defect class round 1 fixed for bracketed links. Repro'd: identical prose flips the flag verdict purely on link syntax.
  2. check-new-line-breaks.py:134-138 - HTML character entities (&amp;, &nbsp;, &mdash;, ...) end in ; and aren't stripped, so a semicolon inside one is read as a clause boundary. Lower severity/realism than Add CLAUDE.md with PR review guidelines #1 - no current instance in this repo's or ai-config's tracked .md/.qmd files clears the length gate unbackticked - but it's the same false-positive class the PR's own measurement rationale is calibrated against.
  3. .github/workflows/_selftest.yml:160-168 - The new "opted out" selftest step never sets fail:, so it's green regardless of whether clause-breaks: false reaches the script, is dropped, or the input was never declared at all. Its own comment, and the matching new CLAUDE.md paragraph, both claim it "proves the opt-out actually reaches the script" - it doesn't. Confirmed no other test exercises the NLB_CLAUSE_BREAKS env var -> main() -> exit-code path either.
  4. .github/workflows/check-new-line-breaks.yml:32-38 - clause-min-length is declared type: number, breaking this repo's established idiom for length-threshold inputs forwarded to a composite action (all quoted type: string, e.g. max-script-lines, max-code-block-lines, max-line-length) - the composite's own input has no numeric type and parses the value manually anyway. Also drifts website/reference/check-new-line-breaks.qmd's Type column from its sibling reference pages.
  5. check-new-line-breaks/tests/test_check_new_line_breaks.py:327-332 (minor, non-blocking) - test_find_violations_default_matches_the_shared_constant never actually calls find_violations; the drift it describes is caught by a different test. Just a naming/comment mismatch.

One additional non-blocking note, not tied to a diff line so not posted inline: the PR description (not the code) still asserts "--... is only an ASCII stand-in since the spec names the em dash, which this org's style rules ban from source" - the same inaccurate claim removed from the docstring in round 1 - and its measurement table (21,963 lines / 1.1%) doesn't match the numbers actually shipped in the docstring/changelog/action.yml (22,820 lines / 0.7%). Neither affects merged files, but both are user-facing PR text worth a quick edit for consistency.

Verdict

Needs more work - finding 1 (bare-URL bypass) is a real, easily-reproduced false-positive bug worth fixing before merge, and finding 3 (no-op selftest step whose comment overstates its own coverage) misrepresents test coverage in both the workflow and the newly-added CLAUDE.md paragraph. Findings 2, 4, and 5 are smaller in scope.

@github-actions

Copy link
Copy Markdown
Contributor

💰 Cost: $11.5884 (review) — run

Five findings on #337, all reproduced against the real module first:

1. Bare URLs and autolinks had no ']( ' to anchor on, so they bypassed
   strip_inline_markup entirely -- reintroducing round 1's URL-inflation bug
   for unbracketed links, and reading a ';' in a query string as a clause.
2. HTML entities end in ';' too, and were not stripped.
3. The 'opted out' selftest step sets no fail:, so it could not prove the
   input reaches the script; its comment and the matching CLAUDE.md paragraph
   both claimed it did. Added real env-var -> main() -> exit-code coverage and
   corrected both claims.
4. clause-min-length was type: number, against the repo's idiom for
   length-threshold inputs forwarded to a composite (all quoted strings).
5. A test named for find_violations only called classify_line; replaced by the
   exit-code tests above, which cover the plumbing it claimed to.

strip_inline_markup now documents its invariant: it only ever removes
characters, so visible length is a conservative lower bound and under-counting
can only suppress a flag, never invent one. That is why entities are deleted
rather than replaced with a placeholder.

Six new tests, each confirmed to fail against the defect it guards. Re-measured
the all-marks figure, which the extra stripping moved to 50.5%.
Copilot AI review requested due to automatic review settings July 28, 2026 09:26
@dem-extra1 dem-extra1 changed the title check-new-line-breaks: add opt-in clause-break mode (SemBr rule 5) check-new-line-breaks: add clause-break check, on by default (SemBr rule 5) Jul 28, 2026
@dem-extra1
dem-extra1 requested a review from Copilot July 28, 2026 18:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copy link
Copy Markdown
Collaborator

Working on this — paws off until I'm done.

Self-review, for the same reason as on #348: review / claude-review cannot run on this branch (startup_failure from the uses: d-morrison/gha/...@v2 breakage #351 fixes). Copilot requested in parallel.

What I verified rather than read

58 tests pass. I also ran the -k default subset specifically, because I went looking for a bug there and did not find one — see below.

The defaults really are pinned across all three sites. I expected to find that the reusable workflow's clause-breaks had no default:, since a workflow_call boolean without one arrives as false and would have silently shipped the feature off for every consumer while the docs said "on by default." It does declare default: true, and test_declared_clause_breaks_default_matches_script_default is parametrized over both YAML files, so the gap is closed and guarded. That is the failure this test exists to prevent and it would have caught it. Worth calling out as good design rather than leaving it as a silent pass.

has_late_semicolon's boundary logic is correct. Checked the edges by hand:

  • find(";", 1) returning -1 fails 0 < semicolon, so "no semicolon" is handled.
  • A trailing ; after rstrip() fails semicolon < len - 1, so a line already breaking where the break belongs is not flagged.
  • A leading ; is skipped rather than consumed, so it cannot mask a later real boundary — the docstring's claim, and it holds.
  • The gate is genuinely inclusive (len(stripped) < min_length returns early), matching what the docstring and both YAML descriptions promise.

strip_inline_markup's ordering claims hold. Link targets before bare URLs, so [text](url) leaves no URL behind; and _BARE_URL_RE's trailing-punctuation exclusion means see https://example.com/x; the rest keeps its ;, which a greedy \S+ would have eaten. The backreferenced code-span pattern correctly closes an N-backtick span on a matching run rather than the next backtick.

Added lines are free of em-dashes, en-dashes, curly quotes, and ×.

Findings

1. The title contradicts the implementation. The PR is titled "add opt-in clause-break mode", but the feature is opt-out — on by default, per the maintainer direction the body itself cites and the default: 'true' in both YAML files. The body is right and the title is stale from an earlier design. This matters more than a normal title nit because the repo squash-merges: "opt-in" would land in main's history describing a behavior change that is the opposite of what shipped, and this is exactly the kind of claim the changelog fragment's changed category exists to signal correctly. Suggested: check-new-line-breaks: add clause-break check (SemBr rule 5), on by default.

2. The diff adds a link that will 404 after the org move. check-new-line-breaks/action.yml (and the reference page) cite https://github.com/d-morrison/gha/issues/336 on an added line. GitHub's transfer redirect does not cover issues paths — verified on the sibling links now failing links / link-checker, and https://github.com/Morrison-Lab/gha/issues/336 returns 200 while the d-morrison form does not resolve. So this PR contributes one new error to a check that is already red repo-wide. Rewriting the one URL to Morrison-Lab fixes it and is independent of whatever #351 decides to do about the other 17.

Things I checked and found correct

  • The semicolons-only decision is well defended, and unusually so: the 50.5% / 6.1% / 0.7% table is a real measurement against a real conformant corpus, the reasoning for excluding comma, colon, and dash is specific rather than hand-waved, and it lives in the docstring next to the code instead of only in this thread. The docstring is also honest that the re-measured figures differ from check-new-line-breaks: add an opt-in clause-break mode (SemBr rule 5) instead of a blanket punctuation rule #336's and says why.
  • Naming has_late_semicolon for what it measures rather than what it infers ("a proxy for rule 5, not a test of it") is the right call and the docstring says so explicitly.
  • Rule 4 is checked before rule 5, so a line breaking both is reported once against the stronger rule.
  • Defaulting on is consistent with the check being warn-only unless fail: true, which is what makes the reversal of check-new-line-breaks: add an opt-in clause-break mode (SemBr rule 5) instead of a blanket punctuation rule #336's own opt-in proposal safe.
  • clause-min-length as type: string matches this repo's idiom for length thresholds forwarded to a composite.

Verdict

Needs more work — two fixes, both one-liners: the title, and the issues/336 URL. Neither touches logic, and the implementation itself I'd merge as-is.

Sequencing: same as #348 — merge #351 first, then main into this branch, so a real review run can happen here.


Generated by Claude Code

GitHub's repository-transfer redirect covers repository, tree, blob, and
pull paths, but not issues paths, so a d-morrison/gha/issues/N URL now 404s
rather than redirecting. Verified: /issues/336 returns 200
while the d-morrison form does not resolve.

links / link-checker is already red on main over 17 such URLs; these two
added lines would have contributed an eighteenth. #351 retargeted `uses:`
references but deliberately left prose links alone, so these are not covered
by the merge of main that this branch just took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4epyWdSsXs88wgDNTaKvc
Copilot AI review requested due to automatic review settings July 28, 2026 18:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dem-extra1 dem-extra1 changed the title check-new-line-breaks: add clause-break check, on by default (SemBr rule 5) check-new-line-breaks: add clause-break check (SemBr rule 5), on by default Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Both findings from my review above are Addressed. Pushed 0bde33e on top of your merge of main.

1. Title — fixed. Now "check-new-line-breaks: add clause-break check (SemBr rule 5), on by default", so the squash-merge commit will not describe the behavior as opt-in when it ships opt-out.

2. The #336 links — fixed. Both now point at Morrison-Lab.

One correction to my own review while I'm here: I wrote that the URL was in check-new-line-breaks/action.yml. It is not — action.yml carries a bare d-morrison/gha#336 reference, which is text rather than a link and which lychee never resolves. The two real occurrences were:

  • changelog.d/clause-breaks-check.changed.md:18
  • website/reference/check-new-line-breaks.qmd:26

Those are the two I changed. action.yml's bare reference is left alone deliberately, as is the d-morrison/gha#336 in has_late_semicolon's docstring — neither is a URL, so neither is a link-checker concern, and rewriting prose references to the old org name is a separate naming decision that #351 explicitly scoped out.

Re-ran the suite after the change: 58 passed.

Worth noting the merge of main you took has changed this PR's situation: it now carries #351's retargeting, so uses: Morrison-Lab/gha/...@v2 resolves and review / claude-review should actually run here instead of hitting startup_failure. That means a real bot verdict is now obtainable, and my self-review above should be treated as the fallback it is rather than the verdict.

Still expect links / link-checker to be red on this PR — main carries 17 prose d-morrison/.../issues/N URLs that 404 since the transfer, and that is not this PR's diff. My fix stops this branch adding an eighteenth; clearing the other 17 is a main-level change (see my comments on #351 — the redirect covers repo/tree/blob/pull paths but not issues, and all the affected issues do exist at the new org, so it is a plain rewrite).


Generated by Claude Code

Comment thread check-new-line-breaks/check-new-line-breaks.py
Comment thread check-new-line-breaks/check-new-line-breaks.py Outdated
Comment thread website/reference/check-new-line-breaks.qmd
Comment thread .github/workflows/_selftest.yml
Comment thread CLAUDE.md Outdated
Comment on lines +408 to +425
**A selftest step that sets no `fail:` cannot prove the input reached the
script, however it is worded.**
`_selftest.yml`'s `new-line-breaks` job does call the composite a second time
with `clause-breaks: 'false'`, and that is worth having as a real `uses:`
exercise -- but `main()` returns 0 on every path unless `NLB_FAIL` is set, so
the step stays green whether the input arrives, is dropped, or was never
declared at all (an undeclared composite input is only an Actions warning).
What actually pins the `env var -> main() -> exit code` path is a set of
pytest cases that set `NLB_FAIL=true` around a real `main()` call on a
throwaway git repo, asserting exit 1 with the clause check on and exit 0 both
with `NLB_CLAUSE_BREAKS=false` and with the length gate raised past the line.
Each was confirmed to fail when the corresponding env read is stubbed out.
(gha#337 review round 2: the step's original comment, and this paragraph,
both claimed the step proved the plumbing; neither could.)
Round 3 added the converse caveat, since "cannot prove the input arrived" is
not "proves nothing": the step still pins that `action.yml` parses with the
input declared and that the opt-out code path runs to completion, which is
why it stayed rather than being deleted as dead weight.

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.

Self-contradiction: this paragraph both asserts and refutes the same claim about what the selftest step proves.

Line 415 correctly states "an undeclared composite input is only an Actions warning" — meaning a uses: step with an undeclared with: key still goes green. But the round-3 addition at the end of the same paragraph (lines 423-425) claims: "the step still pins that action.yml parses with the input declared and that the opt-out code path runs to completion, which is why it stayed rather than being deleted as dead weight."

Since the step can't fail on an undeclared input (per line 415's own accurate statement) and main() always exits 0 unless NLB_FAIL is set, the step's green status is identical whether or not clause-breaks is actually declared in action.yml. So it does not "pin" declaration — only line 415's caveat is true; the round-3 addition's "still pins ... declared" clause isn't.

This is the same finding as the paired one on .github/workflows/_selftest.yml's step comment, which makes the identical claim — both need the same correction.

Suggested fix: rewrite the round-3 sentence to drop the "parses with the input declared" claim, e.g.: "Round 3 added the converse caveat, since 'cannot prove the input arrived' is not 'proves nothing': the step still pins that the opt-out code path runs to completion without an unhandled exception, which is worth keeping even though it can't show the input is declared."

Ref:

gha/CLAUDE.md

Lines 408 to 425 in 0bde33e

**A selftest step that sets no `fail:` cannot prove the input reached the
script, however it is worded.**
`_selftest.yml`'s `new-line-breaks` job does call the composite a second time
with `clause-breaks: 'false'`, and that is worth having as a real `uses:`
exercise -- but `main()` returns 0 on every path unless `NLB_FAIL` is set, so
the step stays green whether the input arrives, is dropped, or was never
declared at all (an undeclared composite input is only an Actions warning).
What actually pins the `env var -> main() -> exit code` path is a set of
pytest cases that set `NLB_FAIL=true` around a real `main()` call on a
throwaway git repo, asserting exit 1 with the clause check on and exit 0 both
with `NLB_CLAUSE_BREAKS=false` and with the length gate raised past the line.
Each was confirmed to fail when the corresponding env read is stubbed out.
(gha#337 review round 2: the step's original comment, and this paragraph,
both claimed the step proved the plumbing; neither could.)
Round 3 added the converse caveat, since "cannot prove the input arrived" is
not "proves nothing": the step still pins that `action.yml` parses with the
input declared and that the opt-out code path runs to completion, which is
why it stayed rather than being deleted as dead weight.

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Code review

Reviewed the current diff (head 0bde33e) after 4 prior automated rounds plus 2 author self-reviews. I verified all previously-fixed findings (length-gate measuring the wrong string, the em-dash doc-accuracy claim, bare-URL/autolink markup-stripping, HTML entities, the un-provable selftest step wording, the clause-min-length type mismatch, the mislabeled test, and the corpus-measurement citation mismatch) are genuinely fixed in the current code — none of those needed re-raising.

Four new findings from this round, posted inline:

  1. _env_int silently swallows non-numeric env values (check-new-line-breaks/check-new-line-breaks.py:493-507) — only the negative-value branch warns; a typo like NLB_CLAUSE_MIN_LENGTH=8o falls back to the default with no ::warning::, contradicting the round-3 rationale recorded in the test file itself ("falling back is right; doing it without a word is what hides a caller's typo").
  2. Leading-semicolon guard is defeated by a stray space (check-new-line-breaks/check-new-line-breaks.py:195) — strip_inline_markup(...).rstrip() never left-trims, so a semicolon separated from a stripped construct by one space (e.g. two adjacent code spans, or `x` ;) is misclassified as an interior clause break even though the equivalent no-space line is correctly ignored.
  3. Inconsistent issue-check-new-line-breaks: add an opt-in clause-break mode (SemBr rule 5) instead of a blanket punctuation rule #336 citation form within this PR's own new text (website/reference/check-new-line-breaks.qmd:26, also changelog.d/clause-breaks-check.changed.md:18 vs. check-new-line-breaks/action.yml:47 and check-new-line-breaks/check-new-line-breaks.py:165) — two sites use the full Morrison-Lab/gha/issues/336 URL, two use bare d-morrison/gha#336; both resolve (301 redirect), but it's a style inconsistency against the repo's own bare-gha#NNN self-citation convention. Low severity.
  4. Self-contradictory claim about what the opt-out selftest step proves, in two places (.github/workflows/_selftest.yml:160-168 and CLAUDE.md:408-425) — both claim the step "pins that action.yml parses with the input declared," but an undeclared composite-action input only produces a warning (never a failure), a fact the same paragraph states two sentences earlier. The step can't actually distinguish "declared" from "undeclared but silently dropped."

All four are backed by direct execution/verification against the current source, not just re-reads of the prior review threads.

Verdict

Needs more work — two of the four findings (leading-semicolon whitespace bug, _env_int silent fallback) are real correctness/robustness gaps in the shipped detection logic; the other two are documentation-accuracy issues, per this repo's own CLAUDE.md fact-checking and internal-reasoning-consistency guidelines.

@github-actions

Copy link
Copy Markdown
Contributor

Claude finished reviewView run


Posted successfully. Summary comment: #337 (comment)

Review summary for #337

Verified that all findings from prior review rounds (length-gate measurement, em-dash doc claim, bare-URL/autolink stripping, HTML entities, the un-provable selftest step wording, clause-min-length type, mislabeled test, corpus-measurement citations) are genuinely fixed in the current diff — none re-raised.

Four new, independently-verified findings posted as inline comments:

  1. _env_int silently swallows non-numeric env values (check-new-line-breaks.py:493-507) — only negative values trigger a ::warning::; a typo like 8o falls back silently, contradicting the PR's own stated round-3 rationale.
  2. Leading-semicolon guard defeated by a stray space (check-new-line-breaks.py:195) — .rstrip() never left-trims after markup stripping, so a semicolon separated from a stripped construct by one space is misclassified as a real clause break.
  3. Inconsistent #336 citation form across four sites this PR itself introduces (bare d-morrison/gha#336 vs. full Morrison-Lab/gha/issues/336 URL) — low severity, cosmetic.
  4. Self-contradictory claim in both _selftest.yml and CLAUDE.md that a selftest step "proves" an input is declared in action.yml, when the same text correctly notes undeclared inputs only warn (never fail) — so the step can't actually prove that.

Verdict

Needs more work — two real correctness/robustness bugs in the detection logic plus two documentation-accuracy issues.

@github-actions

Copy link
Copy Markdown
Contributor

💰 Cost: $16.3232 (review) — run

1. has_late_semicolon: strip() rather than rstrip(). Stripping a construct
   can leave whitespace at the line start, so a semicolon separated from it
   by one space was reported as an interior clause break while the identical
   no-space line was correctly ignored.

2. _env_int: warn on an unparseable value instead of falling back silently.
   The round-3 note claimed both readers had been fixed; only _env_flag and
   _env_int's negative branch were. Unset stays silent, which is the normal
   case.

3. changelog fragment cites #336 as a bare ref, matching the (#328)/gha#253
   form the other fragments use and auto-linking when CHANGELOG.md renders.

4. _selftest.yml and CLAUDE.md no longer claim the opt-out step pins that the
   input is declared -- an undeclared composite input is only a warning, as
   the same CLAUDE.md paragraph already said two sentences earlier. Point at
   the defaults-agreement test, which does pin it.

Regression tests added for 1 and 2, each confirmed to fail against the defect
it guards before being kept, plus a guard that an unset variable stays silent.
62 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4epyWdSsXs88wgDNTaKvc
Copilot AI review requested due to automatic review settings July 28, 2026 21:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copy link
Copy Markdown
Collaborator

Round 5 — all four findings dispositioned. Pushed b2355f1.

1. Leading-semicolon guard defeated by a stray space — Addressed

Correct, and a real detection bug. strip_inline_markup(...).rstrip() never left-trimmed, so whitespace left behind by a stripped construct sat between the line start and the semicolon, and find(";", 1) then read it as interior. Now .strip().

The asymmetry is what makes it clearly a bug rather than a judgement call:

Line Before After
`code` ; … (one space) flagged not flagged
`code`; … (no space) not flagged not flagged

Two regression tests: one pinning the spaced form, one asserting the two forms agree — since the defect is precisely that they disagreed, and either alone would be unfalsifiable.

I reviewed this function myself earlier and called the boundary logic correct. I checked the find(";", 1) semantics against -1, trailing, and leading cases, but never considered that stripping a construct leaves whitespace behind — so my "correct" verdict was reached on inputs that couldn't expose it. Good catch.

2. _env_int silently swallows non-numeric values — Addressed

Correct. Round 3's section comment in the test file says "both readers fell back silently. Falling back is right; doing it without a word is what hides a caller's typo" — but only _env_flag and _env_int's negative branch were actually given a warning. The unparseable branch, which is the likelier typo (8o vs 80 is one keystroke), still returned silently.

Now warns. An unset variable stays silent, which is the normal case, and there's a second test guarding that the fix didn't over-correct into warning on every default run.

3. Inconsistent #336 citation form — Addressed in part, premise Rebutted

Addressed: the changelog fragment now uses a bare #336, matching the (#328) / gha#253 form the other fragments use, and it auto-links when CHANGELOG.md renders on GitHub.

Kept as a link: website/reference/check-new-line-breaks.qmd. That page renders as a Quarto website, where a bare gha#336 is inert text a reader cannot follow — unlike a changelog on GitHub or a code comment, where bare refs are idiomatic and (on GitHub) clickable. Different render targets, so I don't think this is one convention being applied inconsistently.

Rebutting the stated reason. The finding says both forms "resolve (301 redirect)". They don't. GitHub's transfer redirect covers repository, tree, blob, and pull paths but not issues paths:

URL Result
d-morrison/gha/pull/34 301 → Morrison-Lab/…
d-morrison/gha/issues/325 404
Morrison-Lab/gha/issues/336 200

That is exactly why links / link-checker is red on main over 17 such URLs, tracked in #353. So the old-org URL form is not an equivalent-but-inconsistent alternative; it is broken, which is what commit 0bde33e fixed.

4. Self-contradictory claim about the opt-out selftest step — Addressed

Correct, and the contradiction is inside a single paragraph: CLAUDE.md states that an undeclared composite input is only an Actions warning, then two sentences later credits the step with pinning "that action.yml parses with the input declared." It can't — the step is green either way.

Both sites now say what the step actually covers (that action.yml parses, and that the opt-out path runs to completion) and point at what really pins declaration: test_declared_clause_breaks_default_matches_script_default, which reads each YAML file for the input's default: and fails outright when there is none.

Verification

  • 62 tests pass (was 58).
  • Each of the three new assertions was confirmed to fail against the defect it guards — I reverted strip()rstrip() and re-introduced the silent fallback in turn, watched the right tests fail, then restored. The unset-stays-silent guard passes either way by design.
  • check-new-line-breaks run against origin/main over this branch's own added lines: clean (dogfooding the check this PR changes).
  • Added lines: no em-dashes, en-dashes, curly quotes, or ×; no new old-org issue URLs.
  • _selftest.yml parses.

Expect links / link-checker to stay red — that's #353 on main, not this diff.


Generated by Claude Code

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.

2026-07-28 22:03 UTC

@github-actions

Copy link
Copy Markdown
Contributor

Claude finished reviewView run


Code Review — #337

This PR has been through five review rounds already. I re-verified the current head (b2355f1) directly against the code (not just against the author's changelog claims) and ran fresh independent CLAUDE.md-compliance and bug-scan passes.

Prior findings: all confirmed fixed

I checked each of the seven findings from earlier rounds (plus the four from the most recent round) against the actual current file content, not just the PR's own summary of what it fixed:

  • Length gate now measures stripped text, not raw markdown (has_late_semicolon, check-new-line-breaks.py:186) — confirmed via len(stripped) < min_length.
  • "Banned from source files" em-dash justification removed — the current docstring only argues from the paired-parenthetical point, the disputed clause is gone.
  • Bare URLs, autolinks, and HTML entities are now stripped (_AUTOLINK_RE, _BARE_URL_RE, _ENTITY_RE), with regression tests for each.
  • _selftest.yml's opt-out step comment now correctly scopes its claim (only proves the opt-out path runs, not that the value reaches the script) and real env var → main() → exit code pytest coverage now exists.
  • clause-min-length is now type: string / default: '80' in the workflow YAML and reference docs, matching repo convention.
  • The mislabeled find_violations-drift test was replaced by proper YAML-vs-script defaults-agreement tests.
  • Leading-semicolon-with-whitespace bug fixed (.strip() instead of .rstrip(), with test_leading_semicolon_after_a_stripped_span_is_not_a_clause_break pinning it).
  • _env_int now warns on non-numeric input, not just negative input.
  • Corpus-stat citation discrepancy is now explained as an intentional re-measurement, distinct from the original issue's figures.

Two independent Opus bug-scan passes (adversarial regex probing, catastrophic-backtracking checks, an empirical run of the clause check against this repo's own *.md corpus) found no remaining logic bugs.

New findings this round (all minor, non-blocking)

  1. Stale d-morrison owner name in new prose, inconsistent with Morrison-Lab used elsewhere in this same PR's own new text (e.g. website/reference/check-new-line-breaks.qmd):

  2. Two new test functions and a _PADDING constant land after the file's if __name__ == "__main__": guard in check-new-line-breaks/tests/test_check_new_line_breaks.py#L515-538. Every other Python file in the repo keeps this guard last. Zero functional effect (pytest collects fine regardless), purely a structural nit — worth moving above the guard.

  3. Unquoted example value for a type: string input: clause-min-length: 100 (should be '100') in examples/check-new-line-breaks.yml#L25 and the matching line in website/reference/check-new-line-breaks.qmd#L78, inconsistent with examples/lint-qmd.yml's '100' and the workflow's own quoted default: '80'. It's inside a comment (not live YAML), so no functional risk — just a copy-paste consistency nit.

No --comment flag was supplied, so per the review process these findings are reported here only; nothing was posted to GitHub.

Verdict

Ready for merge. All previously-identified real bugs are fixed and verified against the current code; the only remaining items are three minor, non-blocking style/consistency nits with no functional impact.

@github-actions

Copy link
Copy Markdown
Contributor

💰 Cost: $11.3906 (review) — run

@dem-extra1
dem-extra1 merged commit 5e095a0 into main Jul 28, 2026
27 checks passed
@dem-extra1
dem-extra1 deleted the claude/markdown-semantic-line-breaks-0aqhlh branch July 28, 2026 22:02

Copy link
Copy Markdown
Collaborator Author

Round 5 --- disposition of all 11 open threads

Picking this back up after the parallel session released it. Its closing
comment said "every inline thread is resolved"; 11 were still open, so this
verifies each rather than taking that at face value.

Head is now 8d1a969. 68 tests pass.

@dem-extra1's findings --- fixed by the parallel session, verified here

I re-ran your own repros against the current head rather than reading the
diff:

Finding Before Now
Multi-backtick code span leaks ; flagged (false positive) not flagged
\S+ eats the clause boundary not flagged (false negative) flagged, ; survives stripping
Leading ; at index 0 --- correctly skipped

Your second finding was the sharpest thing in this PR's whole review history:
test_identical_prose_does_not_flip_verdict_on_link_syntax was passing by
coincidence
, the bracketed form rejected on length and the bare form having
lost its ; outright. That is a regression test asserting the right answer
for the wrong reason, and I would not have found it.

Wording, env-reader, four-sites-of-rationale, spec-citation, and selftest
points are all addressed in the branch as well.

The four later findings --- verified and fixed in 8d1a969

Each reproduced against the real module first:

  1. _env_int warned on negative but not on non-numeric. Confirmed:
    eighty, 8o, 80.5 all fell back silently. Now warn; the unset path
    stays silent.
  2. The leading-semicolon guard was defeated by one space.
    Confirmed: `configure` ; ... and `npm` `install`; ... both
    stripped to a leading " ; ", landing the semicolon at index 1 and
    slipping past find(";", 1). Now .strip() rather than .rstrip().
  3. #336 cited in two owner forms. Now Morrison-Lab/gha#336 in the
    script, action.yml, and the reference page. The changelog keeps bare
    #336, matching its own line 2 --- same-repo shorthand there, qualified
    where the text is read outside GitHub.
  4. The selftest comment's "proves declared" claim. You were right, and
    main had already landed a better fix than mine: it drops the false claim
    and names what actually pins declaration --- the defaults-agreement test,
    which fails outright when the input has no default:. Took main's
    version on both conflicted hunks.

One thing worth recording against myself

My first regression test for finding 2 passed against the reverted fix.
The fixtures stripped to 67 visible characters, so they never reached the
guard --- they were rejected by the length gate and would have "passed"
forever without testing anything.

That is exactly the defect @dem-extra1 caught one round earlier, reproduced by
me while fixing it. The tests now assert the fixture clears the gate before
asserting behavior, and both were re-confirmed to fail against the pre-fix
code.

State

All 28 checks green at the previous head, including claude-review and
require-review; the failures in earlier webhook events were on superseded
SHAs. main moved 31 commits and is merged in; two conflicts resolved in
main's favour.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check-new-line-breaks: add an opt-in clause-break mode (SemBr rule 5) instead of a blanket punctuation rule

4 participants