subedit 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {subedit-0.1.2 → subedit-0.1.4}/.claude-plugin/marketplace.json +2 -1
  2. {subedit-0.1.2 → subedit-0.1.4}/PKG-INFO +4 -2
  3. {subedit-0.1.2 → subedit-0.1.4}/README.md +3 -1
  4. {subedit-0.1.2 → subedit-0.1.4}/docs/rules.md +2 -2
  5. {subedit-0.1.2 → subedit-0.1.4/plugins/subedit}/.claude-plugin/plugin.json +1 -1
  6. {subedit-0.1.2 → subedit-0.1.4/plugins/subedit}/hooks/hooks.json +2 -2
  7. {subedit-0.1.2/hooks → subedit-0.1.4/plugins/subedit/scripts}/run-subedit-hook.sh +1 -1
  8. subedit-0.1.4/plugins/subedit/skills/judging-findings/SKILL.md +44 -0
  9. {subedit-0.1.2 → subedit-0.1.4}/pyproject.toml +1 -1
  10. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/__init__.py +1 -1
  11. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/models/comment.py +93 -11
  12. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/models/prose.py +148 -8
  13. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/rules/layout.py +2 -2
  14. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/rules/rewrite.py +21 -11
  15. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/harnesses/agent_harness.py +7 -0
  16. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/harnesses/claude_code_harness.py +5 -0
  17. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/hook.py +15 -2
  18. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/report.py +2 -1
  19. subedit-0.1.4/src/subedit/vale/styles/subedit/Ellipsis.yml +9 -0
  20. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/vale/styles/subedit/ExternalRef.yml +2 -2
  21. subedit-0.1.4/src/subedit/vale/styles/subedit/MidSentenceColon.yml +9 -0
  22. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/vale/styles/subedit/MidSentenceColonMarkup.yml +3 -2
  23. {subedit-0.1.2 → subedit-0.1.4}/tests/test_fixer.py +2 -1
  24. {subedit-0.1.2 → subedit-0.1.4}/tests/test_hook.py +28 -0
  25. {subedit-0.1.2 → subedit-0.1.4}/tests/test_plugin.py +23 -8
  26. {subedit-0.1.2 → subedit-0.1.4}/tests/test_prose.py +53 -0
  27. {subedit-0.1.2 → subedit-0.1.4}/tests/test_rules_layout.py +52 -2
  28. {subedit-0.1.2 → subedit-0.1.4}/tests/test_rules_rewrite.py +5 -0
  29. {subedit-0.1.2 → subedit-0.1.4}/tests/test_vale_style.py +41 -0
  30. subedit-0.1.4/uv.lock +502 -0
  31. subedit-0.1.2/src/subedit/vale/styles/subedit/Ellipsis.yml +0 -9
  32. subedit-0.1.2/src/subedit/vale/styles/subedit/MidSentenceColon.yml +0 -6
  33. subedit-0.1.2/uv.lock +0 -471
  34. {subedit-0.1.2 → subedit-0.1.4}/.claude/settings.json +0 -0
  35. {subedit-0.1.2 → subedit-0.1.4}/.github/dependabot.yml +0 -0
  36. {subedit-0.1.2 → subedit-0.1.4}/.github/release.yml +0 -0
  37. {subedit-0.1.2 → subedit-0.1.4}/.github/workflows/ci.yml +0 -0
  38. {subedit-0.1.2 → subedit-0.1.4}/.github/workflows/pr.yml +0 -0
  39. {subedit-0.1.2 → subedit-0.1.4}/.github/workflows/release.yml +0 -0
  40. {subedit-0.1.2 → subedit-0.1.4}/.gitignore +0 -0
  41. {subedit-0.1.2 → subedit-0.1.4}/.vale.ini +0 -0
  42. {subedit-0.1.2 → subedit-0.1.4}/LICENSE +0 -0
  43. {subedit-0.1.2 → subedit-0.1.4}/examples/README.md +0 -0
  44. {subedit-0.1.2 → subedit-0.1.4}/examples/probe.py +0 -0
  45. {subedit-0.1.2 → subedit-0.1.4}/examples/subedit.toml +0 -0
  46. {subedit-0.1.2 → subedit-0.1.4}/examples/walkthrough.py +0 -0
  47. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/application/__init__.py +0 -0
  48. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/application/corpus_meter.py +0 -0
  49. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/application/fixer.py +0 -0
  50. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/application/linter.py +0 -0
  51. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/application/report_aggregator.py +0 -0
  52. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/__init__.py +0 -0
  53. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/models/__init__.py +0 -0
  54. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/models/diagnostic.py +0 -0
  55. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/models/document.py +0 -0
  56. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/models/position.py +0 -0
  57. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/models/rule_set.py +0 -0
  58. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/models/text.py +0 -0
  59. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/ports/__init__.py +0 -0
  60. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/ports/external_linter.py +0 -0
  61. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/ports/language_conventions.py +0 -0
  62. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/ports/rule.py +0 -0
  63. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/ports/source_parser.py +0 -0
  64. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/rules/__init__.py +0 -0
  65. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/domain/rules/structure.py +0 -0
  66. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/__init__.py +0 -0
  67. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/config_sources/__init__.py +0 -0
  68. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/config_sources/config_schema.py +0 -0
  69. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/config_sources/toml_config_source.py +0 -0
  70. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/linters/__init__.py +0 -0
  71. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/linters/autocorrect_linter.py +0 -0
  72. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/linters/vale_linter.py +0 -0
  73. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/parsers/__init__.py +0 -0
  74. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/parsers/languages/__init__.py +0 -0
  75. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/parsers/languages/language_profile.py +0 -0
  76. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/parsers/languages/python_profile.py +0 -0
  77. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +0 -0
  78. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/__init__.py +0 -0
  79. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/cli.py +0 -0
  80. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/harnesses/__init__.py +0 -0
  81. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
  82. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/presentation/rule_reference.py +0 -0
  83. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/vale/styles/subedit/DateRef.yml +0 -0
  84. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/vale/styles/subedit/EditHistory.yml +0 -0
  85. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/vale/styles/subedit/EmDash.yml +0 -0
  86. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/vale/styles/subedit/Semicolon.yml +0 -0
  87. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/vale/styles/subedit/SignatureTag.yml +0 -0
  88. {subedit-0.1.2 → subedit-0.1.4}/src/subedit/vale/vale.ini +0 -0
  89. {subedit-0.1.2 → subedit-0.1.4}/subedit.toml +0 -0
  90. {subedit-0.1.2 → subedit-0.1.4}/tests/fixtures/autocorrect/unspaced.py +0 -0
  91. {subedit-0.1.2 → subedit-0.1.4}/tests/fixtures/vale/clean.md +0 -0
  92. {subedit-0.1.2 → subedit-0.1.4}/tests/fixtures/vale/violations.py +0 -0
  93. {subedit-0.1.2 → subedit-0.1.4}/tests/test_cli.py +0 -0
  94. {subedit-0.1.2 → subedit-0.1.4}/tests/test_comment.py +0 -0
  95. {subedit-0.1.2 → subedit-0.1.4}/tests/test_config.py +0 -0
  96. {subedit-0.1.2 → subedit-0.1.4}/tests/test_corpus_meter.py +0 -0
  97. {subedit-0.1.2 → subedit-0.1.4}/tests/test_diagnostic.py +0 -0
  98. {subedit-0.1.2 → subedit-0.1.4}/tests/test_harnesses.py +0 -0
  99. {subedit-0.1.2 → subedit-0.1.4}/tests/test_linter.py +0 -0
  100. {subedit-0.1.2 → subedit-0.1.4}/tests/test_parser.py +0 -0
  101. {subedit-0.1.2 → subedit-0.1.4}/tests/test_parser_ocp.py +0 -0
  102. {subedit-0.1.2 → subedit-0.1.4}/tests/test_parser_oracle.py +0 -0
  103. {subedit-0.1.2 → subedit-0.1.4}/tests/test_position.py +0 -0
  104. {subedit-0.1.2 → subedit-0.1.4}/tests/test_report_aggregator.py +0 -0
  105. {subedit-0.1.2 → subedit-0.1.4}/tests/test_rules_structure.py +0 -0
  106. {subedit-0.1.2 → subedit-0.1.4}/tests/test_schema.py +0 -0
  107. {subedit-0.1.2 → subedit-0.1.4}/tests/test_self_hosting.py +0 -0
  108. {subedit-0.1.2 → subedit-0.1.4}/tests/test_text.py +0 -0
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "mroops",
3
3
  "owner": { "name": "mroops0111" },
4
+ "description": "Plugins built alongside subedit.",
4
5
  "plugins": [
5
6
  {
6
7
  "name": "subedit",
7
- "source": { "source": "github", "repo": "mroops0111/subedit" },
8
+ "source": "./plugins/subedit",
8
9
  "description": "Repairs comment layout silently on write, and returns only what needs rewriting."
9
10
  }
10
11
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: subedit
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A linter and formatter for the prose inside code comments, across languages.
5
5
  Project-URL: Homepage, https://github.com/mroops0111/subedit
6
6
  Project-URL: Repository, https://github.com/mroops0111/subedit
@@ -105,12 +105,14 @@ Rewriting a sentence needs its meaning, which a deterministic process cannot sup
105
105
 
106
106
  ## Agent Hook
107
107
 
108
- `subedit-hook` runs after every file an agent edits. It repairs the mechanical findings in place, and exits 2 only when a sentence needs rewriting, which hands the message back to the model.
108
+ `subedit-hook` runs after every file an agent edits. It repairs the mechanical findings in place, and exits 2 only when a sentence needs rewriting, which hands the message back to the model. In Claude Code a warning reaches the model too, as context beside the tool result, without blocking the edit.
109
109
 
110
110
  ### Claude Code
111
111
 
112
112
  The plugin needs only [uv](https://docs.astral.sh/uv/). It runs subedit from PyPI with the `vale` extra, and warms that environment in the background when a session starts, so no edit waits on the first download.
113
113
 
114
+ It also ships the `subedit:judging-findings` skill, which gives the model the criteria for every finding a tool cannot fix, such as when a spec identifier stays and a ticket number goes. Criteria in your `CLAUDE.md`, `AGENTS.md`, or a skill of your own come first.
115
+
114
116
  ```text
115
117
  /plugin marketplace add mroops0111/subedit
116
118
  /plugin install subedit@mroops
@@ -71,12 +71,14 @@ Rewriting a sentence needs its meaning, which a deterministic process cannot sup
71
71
 
72
72
  ## Agent Hook
73
73
 
74
- `subedit-hook` runs after every file an agent edits. It repairs the mechanical findings in place, and exits 2 only when a sentence needs rewriting, which hands the message back to the model.
74
+ `subedit-hook` runs after every file an agent edits. It repairs the mechanical findings in place, and exits 2 only when a sentence needs rewriting, which hands the message back to the model. In Claude Code a warning reaches the model too, as context beside the tool result, without blocking the edit.
75
75
 
76
76
  ### Claude Code
77
77
 
78
78
  The plugin needs only [uv](https://docs.astral.sh/uv/). It runs subedit from PyPI with the `vale` extra, and warms that environment in the background when a session starts, so no edit waits on the first download.
79
79
 
80
+ It also ships the `subedit:judging-findings` skill, which gives the model the criteria for every finding a tool cannot fix, such as when a spec identifier stays and a ticket number goes. Criteria in your `CLAUDE.md`, `AGENTS.md`, or a skill of your own come first.
81
+
80
82
  ```text
81
83
  /plugin marketplace add mroops0111/subedit
82
84
  /plugin install subedit@mroops
@@ -9,10 +9,10 @@ The level says whether a rewrite blocks. An error fails check and stops an agent
9
9
  | Rule | Fix kind | Level | Summary |
10
10
  | --- | --- | --- | --- |
11
11
  | `line-too-long` | reflow | error | A physical comment line must fit the configured width, counting indent and delimiter. |
12
- | `mid-clause-break` | reflow | error | A comment block must be laid out one sentence per line, splitting only at commas. |
12
+ | `mid-clause-break` | reflow | error | A sentence sits on one line when it fits, and otherwise breaks at clause commas, on as few lines as it can. |
13
13
  | `block-too-long` | rewrite | warning | A comment block long enough to restate the code has stopped earning its place. |
14
14
  | `file-header-comment` | rewrite | warning | A file must not open with a banner comment before its first declaration. |
15
15
  | `plain-comment-on-public` | rewrite | error | A public symbol must be documented in the doc comment form its language renders. |
16
16
  | `signature-restating-tag` | rewrite | error | A doc comment must not restate what the type signature already carries. |
17
17
  | `stub-fragment` | rewrite | warning | A sentence forced to break must not leave a stub of a line behind. |
18
- | `unbreakable-sentence` | rewrite | error | A sentence over the width must carry a comma so it has somewhere legal to break. |
18
+ | `unbreakable-sentence` | rewrite | error | A sentence over the width must carry a clause comma wherever it has to break. |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "subedit",
3
3
  "displayName": "subedit",
4
- "version": "0.1.2",
4
+ "version": "0.1.4",
5
5
  "description": "Repairs comment layout silently on write, and returns only what needs rewriting.",
6
6
  "author": {
7
7
  "name": "mroops0111"
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-subedit-hook.sh",
10
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/run-subedit-hook.sh",
11
11
  "timeout": 30
12
12
  }
13
13
  ]
@@ -18,7 +18,7 @@
18
18
  "hooks": [
19
19
  {
20
20
  "type": "command",
21
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-subedit-hook.sh prepare",
21
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/run-subedit-hook.sh prepare",
22
22
  "timeout": 10
23
23
  }
24
24
  ]
@@ -4,7 +4,7 @@
4
4
  # because the first run installs subedit and downloads Vale, which no edit should wait on.
5
5
  set -uo pipefail
6
6
 
7
- SPEC='subedit[vale]>=0.1.2'
7
+ SPEC='subedit[vale]>=0.1.4'
8
8
  # A bare uvx takes the first Python it finds, which on a stock Mac is too old for subedit.
9
9
  PYTHON='>=3.11'
10
10
 
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: judging-findings
3
+ description: Decide what to do with a subedit finding that no tool can fix, such as subedit.ExternalRef, subedit.DateRef, subedit.MidSentenceColon, block-too-long, or unbreakable-sentence. Use when a subedit hook, `subedit check`, or `subedit fix` reports a finding that needs rewriting or a warning that is a judgment call.
4
+ ---
5
+
6
+ # Judge subedit Findings
7
+
8
+ subedit repairs comment layout on its own. What reaches you is either an error, which blocks until the text is rewritten, or a warning, which asks for a judgment and blocks nothing. For each finding, read the flagged comment, apply the criteria below, and either rewrite the text or leave it as it is.
9
+
10
+ ## Project Criteria Come First
11
+
12
+ A project or its people can judge differently. Look for their criteria before applying these, and follow theirs where the two disagree:
13
+
14
+ - `CLAUDE.md` or `AGENTS.md` in the repository, or the user's own instructions.
15
+ - A project or personal skill that covers subedit findings.
16
+ - `[tool.subedit.rules]` in `pyproject.toml`, or `[rules]` in `subedit.toml`. A rule set to `"off"` there is never reported. A rule a project wants left alone for good belongs there, not in a suppression comment.
17
+
18
+ ## Rewriting
19
+
20
+ Rewrite in place, keep the meaning, and keep the comment to the constraint the code cannot show. Never add a suppression comment, and never reword a sentence only to dodge a pattern while keeping what the rule objects to.
21
+
22
+ ## Warnings
23
+
24
+ A warning passes when the text meets its criterion. Leave such text exactly as it is, and say so in one line if anyone asks.
25
+
26
+ | Rule | Keep the text when | Rewrite it when |
27
+ | --- | --- | --- |
28
+ | `subedit.ExternalRef` | The identifier is public and resolvable by anyone, such as an RFC, an MCP SEP, a PEP, a CVE, or an ISO standard. | It is a private ticket, such as a Jira key or an internal issue number. State the constraint the ticket recorded instead. |
29
+ | `subedit.DateRef` | The date names a version or a spec revision, such as the dated revision an MCP specification is named by. | It records when something happened or changed. State the constraint that holds now. |
30
+ | `subedit.MidSentenceColon` | The colon introduces a list, an indented block, or a label such as `Note:`. | The sentence runs on after the colon. Split it into two sentences, or join the clauses with a comma and a conjunction. |
31
+ | `block-too-long` | Every sentence states a constraint the code cannot show. | Some sentences narrate what the code does. Cut them, or move the explanation into a name. |
32
+ | `file-header-comment` | It states a constraint about the whole module that no single declaration owns. | It is a banner or a summary of the file. Delete it, or move the constraint to the declaration it applies to. |
33
+ | `stub-fragment` | Never. A stub reads as a broken clause. | Always. Rewrite the sentence to fit one line, or split it into two complete sentences. |
34
+
35
+ ## Errors
36
+
37
+ An error always needs a rewrite. The message states the fix, and these notes cover what it leaves out.
38
+
39
+ - `unbreakable-sentence`: a line can break only at a comma that ends a clause, and a comma inside brackets or a code span does not count. Add a comma at a clause boundary, or split the sentence in two.
40
+ - `plain-comment-on-public`: move the text into the docstring.
41
+ - `signature-restating-tag` and `subedit.SignatureTag`: delete the tag, since the signature already says it.
42
+ - `subedit.EditHistory`: state the current behavior, not its history.
43
+ - `subedit.Semicolon`, `subedit.EmDash`: split into two sentences, or use a comma or parentheses.
44
+ - `subedit.Ellipsis`: name two or three concrete examples after "such as", as in "a transport failure, such as a connect, timeout, or DNS error". Dropping the trailing word and joining the rest with "or" turns a sample into the whole set, which narrows what the sentence claims.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "subedit"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "A linter and formatter for the prose inside code comments, across languages."
5
5
  authors = [
6
6
  {name = "Yun-Tai Yang"},
@@ -1,3 +1,3 @@
1
- __version__ = '0.1.2'
1
+ __version__ = '0.1.4'
2
2
 
3
3
  __all__ = ['__version__']
@@ -1,8 +1,9 @@
1
1
  import dataclasses
2
2
  import enum
3
+ import itertools
3
4
 
4
5
  from .position import SourceRange
5
- from .prose import Prose, reflow_sentence
6
+ from .prose import Prose, Sentence, break_cost, comma_breaks, reflow_sentence
6
7
  from .text import TextEdit
7
8
 
8
9
 
@@ -102,14 +103,25 @@ class CommentGroup:
102
103
  if self.form is CommentForm.DOC and self.line_count == 1 and self.longest_line <= width:
103
104
  return self
104
105
  texts = self._reflow_into_texts(width, stub_floor)
106
+ # Text that ends up on one line closes there too, so the closing quotes need room beside it.
107
+ closing_width = len(self._closing_quotes())
108
+ if len(texts) == 1 and len(texts[0]) > self.text_budget(width, first_line=True) - closing_width:
109
+ texts = self._reflow_into_texts(width, stub_floor, closing_width)
105
110
  if not texts:
106
111
  return self
107
112
  return self._rebuild_lines(texts)
108
113
 
109
- # A sentence whose split strands a stub needs its author,
110
- # so the block keeps the layout they gave it rather than one its own check rejects.
111
- def _reflow_into_texts(self, width: int, stub_floor: int) -> tuple[str, ...]:
112
- budget = max(20, width - self.lines[0].prefix_width)
114
+ def text_budget(self, width: int, *, first_line: bool) -> int:
115
+ """The characters of text a line holds, which on a docstring's opening line also cede the quotes."""
116
+ opening = self.lines[0]
117
+ quoted = first_line or self.form is not CommentForm.DOC
118
+ return max(20, width - (opening.prefix_width if quoted else len(opening.indent)))
119
+
120
+ # A sentence takes as few lines as fit, and the author's layout stays when it breaks no worse than the best one.
121
+ # A sentence whose new layout would strand a stub or still overflow needs its author,
122
+ # so it keeps the layout they gave it rather than one its own check rejects.
123
+ def _reflow_into_texts(self, width: int, stub_floor: int, reserve: int = 0) -> tuple[str, ...]:
124
+ body_budget = self.text_budget(width, first_line=False)
113
125
  out: list[str] = []
114
126
  for paragraph_index, (paragraph, is_literal) in enumerate(self._split_into_paragraphs()):
115
127
  if out:
@@ -117,14 +129,23 @@ class CommentGroup:
117
129
  if is_literal:
118
130
  out.extend(paragraph)
119
131
  continue
120
- joined_text = ' '.join(line.strip() for line in paragraph if line.strip())
121
- for sentence_index, sentence in enumerate(Prose(joined_text).sentences()):
132
+ lines = tuple(line.strip() for line in paragraph if line.strip())
133
+ for sentence_index, sentence in enumerate(Prose(' '.join(lines)).sentences()):
122
134
  if sentence_index == 1 and paragraph_index == 0 and self.form is CommentForm.DOC:
123
135
  out.append('')
124
- sentence_lines = reflow_sentence(sentence.text, budget)
125
- if len(sentence_lines) > 1 and min(map(len, sentence_lines)) < stub_floor:
136
+ first_budget = self.text_budget(width, first_line=not out) - (reserve if not out else 0)
137
+ best_lines = reflow_sentence(sentence.text, body_budget, stub_floor, first_width=first_budget)
138
+ given_lines = _given_lines(lines, sentence)
139
+ if given_lines and _keeps_given(given_lines, best_lines, (first_budget, body_budget), stub_floor):
140
+ out.extend(given_lines)
141
+ elif _is_legal(best_lines, (first_budget, body_budget), stub_floor):
142
+ out.extend(best_lines)
143
+ elif given_lines:
144
+ out.extend(given_lines)
145
+ elif _strands_stub(best_lines, stub_floor):
126
146
  return ()
127
- out.extend(sentence_lines)
147
+ else:
148
+ out.extend(best_lines)
128
149
  return tuple(out)
129
150
 
130
151
  def _split_into_paragraphs(self) -> tuple[tuple[tuple[str, ...], bool], ...]:
@@ -156,9 +177,14 @@ class CommentGroup:
156
177
  if not closes_alone:
157
178
  body[-1] = dataclasses.replace(body[-1], delimiter=opening.delimiter.strip())
158
179
  else:
159
- body[0] = dataclasses.replace(body[0], suffix=closing.suffix or opening.delimiter.strip())
180
+ body[0] = dataclasses.replace(body[0], suffix=self._closing_quotes())
160
181
  return dataclasses.replace(self, lines=tuple(body))
161
182
 
183
+ def _closing_quotes(self) -> str:
184
+ if self.form is not CommentForm.DOC:
185
+ return ''
186
+ return self.lines[-1].suffix or self.lines[0].delimiter.strip()
187
+
162
188
  def with_texts(self, texts: tuple[str, ...]) -> 'CommentGroup':
163
189
  if not texts:
164
190
  raise ValueError('A comment block needs at least one line')
@@ -177,3 +203,59 @@ class CommentGroup:
177
203
 
178
204
  def as_edit(self) -> TextEdit:
179
205
  return TextEdit(self.range, self.render())
206
+
207
+
208
+ # The lines a sentence already occupies, when it has them to itself.
209
+ def _given_lines(lines: tuple[str, ...], sentence: Sentence) -> tuple[str, ...] | None:
210
+ covering: list[str] = []
211
+ start = 0
212
+ for line in lines:
213
+ end = start + len(line)
214
+ if start < sentence.end and end > sentence.offset:
215
+ if not covering and start != sentence.offset:
216
+ return None
217
+ covering.append(line)
218
+ if end == sentence.end:
219
+ return tuple(covering)
220
+ if end > sentence.end:
221
+ return None
222
+ start = end + 1
223
+ return None
224
+
225
+
226
+ def _keeps_given(
227
+ given_lines: tuple[str, ...], best_lines: tuple[str, ...], budgets: tuple[int, int], stub_floor: int
228
+ ) -> bool:
229
+ # A colon ending a line in mid sentence is a rewrite finding, so its layout waits for that rewrite.
230
+ if _breaks_at_a_colon(given_lines):
231
+ return _fits(given_lines, budgets)
232
+ return (
233
+ len(given_lines) == len(best_lines)
234
+ and _is_legal(given_lines, budgets, stub_floor)
235
+ and break_cost(given_lines) <= break_cost(best_lines)
236
+ )
237
+
238
+
239
+ def _breaks_at_a_colon(lines: tuple[str, ...]) -> bool:
240
+ return any(line.endswith(':') for line in lines[:-1])
241
+
242
+
243
+ def _fits(lines: tuple[str, ...], budgets: tuple[int, int]) -> bool:
244
+ first_budget, body_budget = budgets
245
+ return all(len(line) <= (first_budget if index == 0 else body_budget) for index, line in enumerate(lines))
246
+
247
+
248
+ def _is_legal(lines: tuple[str, ...], budgets: tuple[int, int], stub_floor: int) -> bool:
249
+ if not _fits(lines, budgets):
250
+ return False
251
+ if len(lines) == 1:
252
+ return True
253
+ if _strands_stub(lines, stub_floor):
254
+ return False
255
+ breaks = set(comma_breaks(' '.join(lines)))
256
+ line_ends = itertools.accumulate(len(line) + 1 for line in lines[:-1])
257
+ return all(line_end - 1 in breaks for line_end in line_ends)
258
+
259
+
260
+ def _strands_stub(lines: tuple[str, ...], stub_floor: int) -> bool:
261
+ return len(lines) > 1 and min(map(len, lines)) < stub_floor
@@ -1,4 +1,5 @@
1
1
  import dataclasses
2
+ import itertools
2
3
  import re
3
4
 
4
5
 
@@ -21,7 +22,9 @@ ABBREVIATIONS = frozenset(
21
22
  _SENTENCE_END = re.compile(r'[.!?]')
22
23
  _TRAILING_WORD = re.compile(r'([A-Za-z][A-Za-z.]*)$')
23
24
  _URL = re.compile(r'\b(?:https?://|www\.)\S+')
24
- _CODE_SPAN = re.compile(r'`[^`]*`')
25
+ # A span opened by two backticks, as reStructuredText writes code, closes on two.
26
+ _CODE_RUN = re.compile(r'(`+).+?\1')
27
+ _BRACKET_PAIRS = {'(': ')', '[': ']', '{': '}'}
25
28
  _FENCE = re.compile(r'^\s*(```|~~~)')
26
29
  _LIST_DELIMITER = re.compile(r'^\s*(?:[0-9]+[.)]|[-*+]|[a-zA-Z][.)])\s')
27
30
  _LITERAL_DELIMITER = '\\b'
@@ -148,9 +151,10 @@ class Prose:
148
151
  found_sentences.append(Sentence(text[start:].strip(), start))
149
152
  return tuple(sentence for sentence in found_sentences if sentence.text)
150
153
 
154
+ # A period inside brackets, as in an ellipsis closing a list of examples, ends no sentence outside them.
151
155
  def _find_protected_spans(self, text: str) -> tuple[tuple[int, int], ...]:
152
156
  spans = [(url.start(), url.end()) for url in _URL.finditer(text)]
153
- spans.extend((code_span.start(), code_span.end()) for code_span in _CODE_SPAN.finditer(text))
157
+ spans.extend(_enclosed_spans(text))
154
158
  return tuple(spans)
155
159
 
156
160
  def _is_protected(self, offset: int, spans: tuple[tuple[int, int], ...]) -> bool:
@@ -183,27 +187,62 @@ class Prose:
183
187
  return not rest or rest[0] == ' '
184
188
 
185
189
 
186
- def reflow_sentence(sentence: str, width: int) -> tuple[str, ...]:
187
- if len(sentence) <= width:
190
+ def reflow_sentence(sentence: str, width: int, stub_floor: int = 0, first_width: int | None = None) -> tuple[str, ...]:
191
+ """Lay a sentence out on as few lines as fit, breaking only at clause commas.
192
+
193
+ Among layouts with that fewest count, one that leaves no stub wins, then one whose breaks split no list,
194
+ then one whose breaks each open a clause, then the one with the longest opening line.
195
+ A sentence with a clause too long for any line falls back to filling each line greedily,
196
+ which leaves the overflow where a rewrite finds it.
197
+ """
198
+ opening_width = width if first_width is None else first_width
199
+ if len(sentence) <= opening_width:
188
200
  return (sentence,)
189
201
  pieces = _comma_pieces(sentence)
190
202
  if len(pieces) == 1:
191
203
  return (sentence,)
192
- return _pack(pieces, width)
204
+ return _best_layout(pieces, width, opening_width, stub_floor) or _pack(pieces, width)
193
205
 
194
206
 
195
207
  def _comma_pieces(sentence: str) -> tuple[str, ...]:
196
208
  pieces: list[str] = []
197
209
  start = 0
198
- for comma in re.finditer(r',\s+', sentence):
199
- pieces.append(sentence[start : comma.end()].rstrip())
200
- start = comma.end()
210
+ for offset in comma_breaks(sentence):
211
+ pieces.append(sentence[start:offset].strip())
212
+ start = offset
201
213
  tail = sentence[start:].strip()
202
214
  if tail:
203
215
  pieces.append(tail)
204
216
  return tuple(pieces) if pieces else (sentence,)
205
217
 
206
218
 
219
+ def comma_breaks(sentence: str) -> tuple[int, ...]:
220
+ """Offsets just past each comma that ends a clause, which are the only places a sentence may break.
221
+
222
+ A comma inside brackets or a code span separates items of one phrase, so a break there splits the phrase.
223
+ """
224
+ enclosed = _enclosed_spans(sentence)
225
+ return tuple(
226
+ comma.start() + 1
227
+ for comma in re.finditer(r',\s+', sentence)
228
+ if not any(start < comma.start() < end for start, end in enclosed)
229
+ )
230
+
231
+
232
+ # Only a matched pair encloses anything, so a stray bracket such as the one in "1)" never swallows the sentence.
233
+ def _enclosed_spans(sentence: str) -> tuple[tuple[int, int], ...]:
234
+ spans = [(code_span.start(), code_span.end()) for code_span in _CODE_RUN.finditer(sentence)]
235
+ open_brackets: list[tuple[str, int]] = []
236
+ for offset, character in enumerate(sentence):
237
+ if any(start <= offset < end for start, end in spans):
238
+ continue
239
+ if character in _BRACKET_PAIRS:
240
+ open_brackets.append((_BRACKET_PAIRS[character], offset))
241
+ elif open_brackets and character == open_brackets[-1][0]:
242
+ spans.append((open_brackets.pop()[1], offset + 1))
243
+ return tuple(spans)
244
+
245
+
207
246
  def _pack(pieces: tuple[str, ...], width: int) -> tuple[str, ...]:
208
247
  lines: list[str] = []
209
248
  current_line = ''
@@ -217,3 +256,104 @@ def _pack(pieces: tuple[str, ...], width: int) -> tuple[str, ...]:
217
256
  if current_line:
218
257
  lines.append(current_line)
219
258
  return tuple(lines)
259
+
260
+
261
+ # Words that open a clause, so a comma before one is where a sentence divides in meaning.
262
+ # As is left out, since it opens an aside such as "as this once did" as often as a clause.
263
+ CLAUSE_OPENERS = frozenset(
264
+ {
265
+ 'although',
266
+ 'and',
267
+ 'because',
268
+ 'but',
269
+ 'nor',
270
+ 'or',
271
+ 'otherwise',
272
+ 'since',
273
+ 'so',
274
+ 'then',
275
+ 'though',
276
+ 'unless',
277
+ 'until',
278
+ 'whereas',
279
+ 'which',
280
+ 'while',
281
+ 'yet',
282
+ }
283
+ )
284
+ # A piece this short between two commas is an item in a list, such as "base URL,", not a clause.
285
+ LIST_ITEM_WORDS = 3
286
+ LIST_CLOSERS = frozenset({'and', 'or'})
287
+ ARTICLES = frozenset({'a', 'an', 'the'})
288
+
289
+
290
+ def _best_layout(pieces: tuple[str, ...], width: int, opening_width: int, stub_floor: int) -> tuple[str, ...] | None:
291
+ """Pick break points by dynamic programming over the pieces, scoring each layout from the end backwards."""
292
+ count = len(pieces)
293
+ finished: tuple[tuple[int, int, int, int], tuple[str, ...]] = ((0, 0, 0, 0), ())
294
+ best: list[tuple[tuple[int, int, int, int], tuple[str, ...]] | None] = [None] * count
295
+ best.append(finished)
296
+ for start in reversed(range(count)):
297
+ line_width = opening_width if start == 0 else width
298
+ for stop in range(start + 1, count + 1):
299
+ line = ' '.join(pieces[start:stop])
300
+ if len(line) > line_width:
301
+ break
302
+ rest = best[stop]
303
+ if rest is None:
304
+ continue
305
+ (rest_lines, rest_stubs, rest_lists, rest_asides), rest_layout = rest
306
+ is_stub = (start, stop) != (0, count) and len(line) < stub_floor
307
+ splits_list = stop < count and _splits_a_list(pieces, stop)
308
+ opens_aside = stop < count and not _opens_a_clause(pieces[stop])
309
+ score = (rest_lines + 1, rest_stubs + is_stub, rest_lists + splits_list, rest_asides + opens_aside)
310
+ candidate = (score, (line, *rest_layout))
311
+ current = best[start]
312
+ # On a tie the longer line wins, as filling greedily would choose.
313
+ if current is None or score <= current[0]:
314
+ best[start] = candidate
315
+ found = best[0]
316
+ return None if found is None else found[1]
317
+
318
+
319
+ def _splits_a_list(pieces: tuple[str, ...], index: int) -> bool:
320
+ """Whether a break before ``pieces[index]`` falls beside an item of a list rather than between clauses."""
321
+ return _is_list_item(pieces, index - 1) or _is_list_item(pieces, index)
322
+
323
+
324
+ # A short piece is an item, such as "base URL,", when the run goes on with another short piece or closes on and or or.
325
+ # One standing alone, such as "returns ``None``,", or one opening a clause, such as "then optional params,", is not.
326
+ def _is_list_item(pieces: tuple[str, ...], index: int) -> bool:
327
+ if not 0 < index < len(pieces) - 1 or not _is_short(pieces[index]) or _opens_a_clause(pieces[index]):
328
+ return False
329
+ following = pieces[index + 1].split()
330
+ if _is_appositive(pieces[index], following):
331
+ return False
332
+ closes_run = bool(following) and following[0].lower() in LIST_CLOSERS
333
+ return closes_run or _is_short(pieces[index + 1]) or (index >= 2 and _is_short(pieces[index - 1]))
334
+
335
+
336
+ # "``owner``, a ``Person``," renames what came before it,
337
+ # unless the next piece runs on in parallel, as in "a cat, a dog".
338
+ def _is_appositive(piece: str, following: list[str]) -> bool:
339
+ words = piece.split()
340
+ if not words or words[0].lower() not in ARTICLES:
341
+ return False
342
+ parallel = following[1:] if following and following[0].lower() in LIST_CLOSERS else following
343
+ return not parallel or parallel[0].lower() not in ARTICLES
344
+
345
+
346
+ def _is_short(piece: str) -> bool:
347
+ return len(piece.split()) <= LIST_ITEM_WORDS
348
+
349
+
350
+ def _opens_a_clause(piece: str) -> bool:
351
+ words = piece.split()
352
+ return bool(words) and words[0].lower() in CLAUSE_OPENERS
353
+
354
+
355
+ def break_cost(lines: tuple[str, ...]) -> int:
356
+ """Count the breaks in a laid out sentence that split a list."""
357
+ pieces = _comma_pieces(' '.join(lines))
358
+ piece_ends = itertools.accumulate(len(_comma_pieces(line)) for line in lines[:-1])
359
+ return sum(_splits_a_list(pieces, piece_end) for piece_end in piece_ends)
@@ -8,7 +8,7 @@ from .rewrite import stub_floor
8
8
 
9
9
  MID_CLAUSE_BREAK = RuleMetadata(
10
10
  rule_id='mid-clause-break',
11
- summary='A comment block must be laid out one sentence per line, splitting only at commas.',
11
+ summary='A sentence sits on one line when it fits, and otherwise breaks at clause commas, on as few lines as it can.',
12
12
  fix_kind=FixKind.REFLOW,
13
13
  )
14
14
 
@@ -31,7 +31,7 @@ class MidClauseBreakRule(Rule):
31
31
  range=group.range,
32
32
  message=(
33
33
  'Comment layout differs from one sentence per line. '
34
- 'Break only at a period or a comma, never mid clause.'
34
+ 'A sentence that fits takes one line, and a longer one breaks only at a comma ending a clause.'
35
35
  ),
36
36
  fix=Fix.reflow(reflowed.as_edit()),
37
37
  )
@@ -15,12 +15,13 @@ STUB_FRAGMENT = RuleMetadata(
15
15
 
16
16
  UNBREAKABLE_SENTENCE = RuleMetadata(
17
17
  rule_id='unbreakable-sentence',
18
- summary='A sentence over the width must carry a comma so it has somewhere legal to break.',
18
+ summary='A sentence over the width must carry a clause comma wherever it has to break.',
19
19
  fix_kind=FixKind.REWRITE,
20
20
  )
21
21
 
22
22
 
23
23
  DEFAULT_MINIMUM_CHARACTERS = 24
24
+ BRACKET_NOTE = 'A comma inside brackets or a code span separates items, so it is no place to break. '
24
25
 
25
26
 
26
27
  def prose_sentences(group: CommentGroup) -> tuple[str, ...]:
@@ -32,8 +33,12 @@ def prose_sentences(group: CommentGroup) -> tuple[str, ...]:
32
33
  )
33
34
 
34
35
 
35
- def sentence_budget(group: CommentGroup, context: RuleContext) -> int:
36
- return max(20, context.line_width - group.lines[0].prefix_width)
36
+ def budgeted_sentences(group: CommentGroup, context: RuleContext) -> tuple[tuple[str, int], ...]:
37
+ """Pair each sentence with the budget reflow lays it out in, so no rule judges a layout reflow would not make."""
38
+ return tuple(
39
+ (sentence, group.text_budget(context.line_width, first_line=index == 0))
40
+ for index, sentence in enumerate(prose_sentences(group))
41
+ )
37
42
 
38
43
 
39
44
  def stub_floor(context: RuleContext) -> int:
@@ -47,10 +52,9 @@ class StubFragmentRule(Rule):
47
52
  metadata = STUB_FRAGMENT
48
53
 
49
54
  def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
50
- budget = sentence_budget(group, context)
51
55
  minimum_length = stub_floor(context)
52
- for sentence in prose_sentences(group):
53
- split_pieces = reflow_sentence(sentence, budget)
56
+ for sentence, budget in budgeted_sentences(group, context):
57
+ split_pieces = reflow_sentence(sentence, budget, minimum_length)
54
58
  if len(split_pieces) < 2:
55
59
  continue
56
60
  shortest_piece = min(split_pieces, key=len)
@@ -73,17 +77,23 @@ class UnbreakableSentenceRule(Rule):
73
77
  metadata = UNBREAKABLE_SENTENCE
74
78
 
75
79
  def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
76
- budget = max(20, context.line_width - group.lines[0].prefix_width)
77
- for sentence in prose_sentences(group):
78
- if len(sentence) <= budget or ',' in sentence:
80
+ for sentence, budget in budgeted_sentences(group, context):
81
+ longest_line = max(reflow_sentence(sentence, budget), key=len)
82
+ if len(longest_line) <= budget:
79
83
  continue
84
+ if longest_line == sentence:
85
+ cause = 'contains no comma that ends a clause, so there is no legal place to break it'
86
+ else:
87
+ cause = f'still leaves a line of {len(longest_line)} characters when broken at every clause comma'
80
88
  yield Diagnostic(
81
89
  rule_id=self.metadata.rule_id,
82
90
  range=group.range,
83
91
  message=(
84
92
  f'This sentence runs to {len(sentence)} characters against a budget of {budget} characters '
85
- 'and contains no comma, so there is no legal place to break it. '
86
- 'Add a comma at a clause boundary, or split it into two sentences.'
93
+ f'and {cause}. '
94
+ f'{BRACKET_NOTE if "," in sentence else ""}'
95
+ 'Add a comma at a clause boundary, or split it into two sentences. '
96
+ f'The part that does not fit is "{longest_line}".'
87
97
  ),
88
98
  fix=Fix.rewrite(),
89
99
  )
@@ -35,6 +35,13 @@ class AgentHarness(abc.ABC):
35
35
  """
36
36
  ...
37
37
 
38
+ def advisory_output(self, advice: str) -> str:
39
+ """Stdout that puts advice in front of the model without blocking, or nothing where the agent has no channel.
40
+
41
+ Exit two is the only shared way to reach the model, and it blocks, so a warning needs one of its own.
42
+ """
43
+ return ''
44
+
38
45
 
39
46
  def existing_source_files(
40
47
  candidates: typing.Iterable[str | pathlib.Path], extensions: frozenset[str]
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import pathlib
2
3
  import typing
3
4
 
@@ -35,3 +36,7 @@ class ClaudeCodeHarness(AgentHarness):
35
36
  def _tool_input(self, payload: Payload) -> typing.Mapping[str, object]:
36
37
  tool_input = payload.get('tool_input')
37
38
  return tool_input if isinstance(tool_input, dict) else {}
39
+
40
+ def advisory_output(self, advice: str) -> str:
41
+ # Claude Code reads stdout as JSON on exit zero, and adds this field beside the tool result.
42
+ return json.dumps({'hookSpecificOutput': {'hookEventName': 'PostToolUse', 'additionalContext': advice}})