subedit 0.3.0__tar.gz → 0.3.2__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 (113) hide show
  1. {subedit-0.3.0 → subedit-0.3.2}/PKG-INFO +6 -2
  2. {subedit-0.3.0 → subedit-0.3.2}/README.md +5 -1
  3. {subedit-0.3.0 → subedit-0.3.2}/plugins/subedit/.claude-plugin/plugin.json +1 -1
  4. {subedit-0.3.0 → subedit-0.3.2}/plugins/subedit/scripts/run-subedit-hook.sh +23 -1
  5. {subedit-0.3.0 → subedit-0.3.2}/pyproject.toml +1 -1
  6. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/__init__.py +1 -1
  7. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/comment.py +25 -2
  8. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/prose.py +19 -6
  9. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/rules/layout.py +3 -2
  10. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/config_sources/toml_config_source.py +4 -0
  11. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/parsers/languages/language_profile.py +6 -0
  12. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/parsers/languages/python_profile.py +1 -0
  13. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/parsers/languages/typescript_profile.py +5 -1
  14. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +4 -1
  15. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/hook.py +2 -1
  16. {subedit-0.3.0 → subedit-0.3.2}/tests/test_fixer.py +2 -2
  17. {subedit-0.3.0 → subedit-0.3.2}/tests/test_hook.py +19 -0
  18. {subedit-0.3.0 → subedit-0.3.2}/tests/test_language_parity.py +63 -0
  19. {subedit-0.3.0 → subedit-0.3.2}/tests/test_plugin.py +32 -4
  20. {subedit-0.3.0 → subedit-0.3.2}/tests/test_prose.py +16 -0
  21. {subedit-0.3.0 → subedit-0.3.2}/tests/test_rules_layout.py +21 -4
  22. {subedit-0.3.0 → subedit-0.3.2}/uv.lock +1 -1
  23. {subedit-0.3.0 → subedit-0.3.2}/.claude/settings.json +0 -0
  24. {subedit-0.3.0 → subedit-0.3.2}/.claude-plugin/marketplace.json +0 -0
  25. {subedit-0.3.0 → subedit-0.3.2}/.github/dependabot.yml +0 -0
  26. {subedit-0.3.0 → subedit-0.3.2}/.github/release.yml +0 -0
  27. {subedit-0.3.0 → subedit-0.3.2}/.github/workflows/ci.yml +0 -0
  28. {subedit-0.3.0 → subedit-0.3.2}/.github/workflows/pr.yml +0 -0
  29. {subedit-0.3.0 → subedit-0.3.2}/.github/workflows/release.yml +0 -0
  30. {subedit-0.3.0 → subedit-0.3.2}/.gitignore +0 -0
  31. {subedit-0.3.0 → subedit-0.3.2}/.vale.ini +0 -0
  32. {subedit-0.3.0 → subedit-0.3.2}/LICENSE +0 -0
  33. {subedit-0.3.0 → subedit-0.3.2}/docs/rules.md +0 -0
  34. {subedit-0.3.0 → subedit-0.3.2}/examples/README.md +0 -0
  35. {subedit-0.3.0 → subedit-0.3.2}/examples/probe.py +0 -0
  36. {subedit-0.3.0 → subedit-0.3.2}/examples/subedit.toml +0 -0
  37. {subedit-0.3.0 → subedit-0.3.2}/examples/walkthrough.py +0 -0
  38. {subedit-0.3.0 → subedit-0.3.2}/plugins/subedit/hooks/hooks.json +0 -0
  39. {subedit-0.3.0 → subedit-0.3.2}/plugins/subedit/skills/judging-findings/SKILL.md +0 -0
  40. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/application/__init__.py +0 -0
  41. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/application/corpus_meter.py +0 -0
  42. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/application/fixer.py +0 -0
  43. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/application/linter.py +0 -0
  44. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/application/report_aggregator.py +0 -0
  45. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/__init__.py +0 -0
  46. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/__init__.py +0 -0
  47. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/comment_layout.py +0 -0
  48. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/diagnostic.py +0 -0
  49. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/document.py +0 -0
  50. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/position.py +0 -0
  51. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/prose_map.py +0 -0
  52. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/rule_set.py +0 -0
  53. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/models/text.py +0 -0
  54. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/ports/__init__.py +0 -0
  55. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/ports/external_linter.py +0 -0
  56. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/ports/language_conventions.py +0 -0
  57. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/ports/rule.py +0 -0
  58. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/ports/source_parser.py +0 -0
  59. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/rules/__init__.py +0 -0
  60. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/rules/rewrite.py +0 -0
  61. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/domain/rules/structure.py +0 -0
  62. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/__init__.py +0 -0
  63. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/config_sources/__init__.py +0 -0
  64. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/config_sources/config_schema.py +0 -0
  65. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/config_sources/formatter_width.py +0 -0
  66. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/linters/__init__.py +0 -0
  67. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/linters/autocorrect_linter.py +0 -0
  68. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/linters/source_path.py +0 -0
  69. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/linters/vale_linter.py +0 -0
  70. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/parsers/__init__.py +0 -0
  71. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/parsers/languages/__init__.py +0 -0
  72. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/infrastructure/parsers/languages/code_line.py +0 -0
  73. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/__init__.py +0 -0
  74. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/cli.py +0 -0
  75. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/harnesses/__init__.py +0 -0
  76. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
  77. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
  78. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
  79. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/report.py +0 -0
  80. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/presentation/rule_reference.py +0 -0
  81. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/DateRef.yml +0 -0
  82. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/EditHistory.yml +0 -0
  83. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/Ellipsis.yml +0 -0
  84. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/EmDash.yml +0 -0
  85. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/ExternalRef.yml +0 -0
  86. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/MidSentenceColon.yml +0 -0
  87. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/MidSentenceColonMarkup.yml +0 -0
  88. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/Semicolon.yml +0 -0
  89. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/styles/subedit/SignatureTag.yml +0 -0
  90. {subedit-0.3.0 → subedit-0.3.2}/src/subedit/vale/vale.ini +0 -0
  91. {subedit-0.3.0 → subedit-0.3.2}/subedit.toml +0 -0
  92. {subedit-0.3.0 → subedit-0.3.2}/tests/fixtures/autocorrect/unspaced.py +0 -0
  93. {subedit-0.3.0 → subedit-0.3.2}/tests/fixtures/vale/clean.md +0 -0
  94. {subedit-0.3.0 → subedit-0.3.2}/tests/fixtures/vale/violations.py +0 -0
  95. {subedit-0.3.0 → subedit-0.3.2}/tests/test_cli.py +0 -0
  96. {subedit-0.3.0 → subedit-0.3.2}/tests/test_comment.py +0 -0
  97. {subedit-0.3.0 → subedit-0.3.2}/tests/test_config.py +0 -0
  98. {subedit-0.3.0 → subedit-0.3.2}/tests/test_corpus_meter.py +0 -0
  99. {subedit-0.3.0 → subedit-0.3.2}/tests/test_diagnostic.py +0 -0
  100. {subedit-0.3.0 → subedit-0.3.2}/tests/test_harnesses.py +0 -0
  101. {subedit-0.3.0 → subedit-0.3.2}/tests/test_linter.py +0 -0
  102. {subedit-0.3.0 → subedit-0.3.2}/tests/test_parser.py +0 -0
  103. {subedit-0.3.0 → subedit-0.3.2}/tests/test_parser_ocp.py +0 -0
  104. {subedit-0.3.0 → subedit-0.3.2}/tests/test_parser_oracle.py +0 -0
  105. {subedit-0.3.0 → subedit-0.3.2}/tests/test_position.py +0 -0
  106. {subedit-0.3.0 → subedit-0.3.2}/tests/test_report_aggregator.py +0 -0
  107. {subedit-0.3.0 → subedit-0.3.2}/tests/test_rules_rewrite.py +0 -0
  108. {subedit-0.3.0 → subedit-0.3.2}/tests/test_rules_structure.py +0 -0
  109. {subedit-0.3.0 → subedit-0.3.2}/tests/test_schema.py +0 -0
  110. {subedit-0.3.0 → subedit-0.3.2}/tests/test_self_hosting.py +0 -0
  111. {subedit-0.3.0 → subedit-0.3.2}/tests/test_text.py +0 -0
  112. {subedit-0.3.0 → subedit-0.3.2}/tests/test_typescript.py +0 -0
  113. {subedit-0.3.0 → subedit-0.3.2}/tests/test_vale_style.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: subedit
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -38,7 +38,7 @@ Your house style, enforced in the places prose hides inside a codebase.
38
38
 
39
39
  A subeditor is the person at a publication who makes every piece of copy match its house style. `subedit` does that job for the prose in your code.
40
40
 
41
- Linters check your code. Almost nothing checks the sentences you write around it. `subedit` reads the comments and docstrings in your source, lays them out one sentence per line, and only tells you about the sentences a tool cannot fix for you.
41
+ Linters check your code. Almost nothing checks the sentences you write around it. `subedit` reads the comments and docstrings in your source, lays them out so no sentence breaks mid clause, and only tells you about the sentences a tool cannot fix for you.
42
42
 
43
43
  ## Example
44
44
 
@@ -56,6 +56,8 @@ An editor wraps a comment at whatever column it reaches, so sentences break mid
56
56
  # Retry once, then give up.
57
57
  ```
58
58
 
59
+ Short sentences may share a line while it fits, as in `# Retry once. Then give up.` A line of them that overflows is split, one sentence a line.
60
+
59
61
  A sentence that runs past the width with no comma has nowhere legal to break, and only its author knows where the meaning divides. That one comes back to you, or to the agent that wrote it.
60
62
 
61
63
  ```text
@@ -107,6 +109,8 @@ Rewriting a sentence needs its meaning, which a deterministic process cannot sup
107
109
 
108
110
  `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
111
 
112
+ The hook acts only in a project that opts in, with a `subedit.toml` or a `[tool.subedit]` table in `pyproject.toml`, in the edited file's directory or a parent. An empty table is enough. Elsewhere it exits 0 and changes nothing, so a plugin installed for every session stays out of projects that never asked for it. `check` and `fix` run anywhere, with the defaults when no config is found.
113
+
110
114
  ### Claude Code
111
115
 
112
116
  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.
@@ -4,7 +4,7 @@ Your house style, enforced in the places prose hides inside a codebase.
4
4
 
5
5
  A subeditor is the person at a publication who makes every piece of copy match its house style. `subedit` does that job for the prose in your code.
6
6
 
7
- Linters check your code. Almost nothing checks the sentences you write around it. `subedit` reads the comments and docstrings in your source, lays them out one sentence per line, and only tells you about the sentences a tool cannot fix for you.
7
+ Linters check your code. Almost nothing checks the sentences you write around it. `subedit` reads the comments and docstrings in your source, lays them out so no sentence breaks mid clause, and only tells you about the sentences a tool cannot fix for you.
8
8
 
9
9
  ## Example
10
10
 
@@ -22,6 +22,8 @@ An editor wraps a comment at whatever column it reaches, so sentences break mid
22
22
  # Retry once, then give up.
23
23
  ```
24
24
 
25
+ Short sentences may share a line while it fits, as in `# Retry once. Then give up.` A line of them that overflows is split, one sentence a line.
26
+
25
27
  A sentence that runs past the width with no comma has nowhere legal to break, and only its author knows where the meaning divides. That one comes back to you, or to the agent that wrote it.
26
28
 
27
29
  ```text
@@ -73,6 +75,8 @@ Rewriting a sentence needs its meaning, which a deterministic process cannot sup
73
75
 
74
76
  `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
77
 
78
+ The hook acts only in a project that opts in, with a `subedit.toml` or a `[tool.subedit]` table in `pyproject.toml`, in the edited file's directory or a parent. An empty table is enough. Elsewhere it exits 0 and changes nothing, so a plugin installed for every session stays out of projects that never asked for it. `check` and `fix` run anywhere, with the defaults when no config is found.
79
+
76
80
  ### Claude Code
77
81
 
78
82
  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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "subedit",
3
3
  "displayName": "subedit",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "description": "Repairs comment layout silently on write, and returns only what needs rewriting.",
6
6
  "author": {
7
7
  "name": "mroops0111"
@@ -4,10 +4,32 @@
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.3.0'
7
+ SPEC='subedit[vale]>=0.3.2'
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
 
11
+ # The plugin reaches every session, so it acts only in a project that configures subedit,
12
+ # with a subedit.toml or a [tool.subedit] table in pyproject.toml, here or in a parent directory.
13
+ adopts_subedit() {
14
+ local directory="$1"
15
+ while :; do
16
+ if [ -f "$directory/subedit.toml" ]; then
17
+ return 0
18
+ fi
19
+ if [ -f "$directory/pyproject.toml" ] && grep -q '^\[tool\.subedit[].]' "$directory/pyproject.toml"; then
20
+ return 0
21
+ fi
22
+ if [ "$directory" = / ] || [ -z "$directory" ]; then
23
+ return 1
24
+ fi
25
+ directory=$(dirname "$directory")
26
+ done
27
+ }
28
+
29
+ if ! adopts_subedit "${CLAUDE_PROJECT_DIR:-$PWD}"; then
30
+ exit 0
31
+ fi
32
+
11
33
  if ! command -v uvx >/dev/null 2>&1; then
12
34
  cat >&2 <<'MESSAGE'
13
35
  subedit needs uv, so comment checks are not running.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "subedit"
3
- version = "0.3.0"
3
+ version = "0.3.2"
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.3.0'
1
+ __version__ = '0.3.2'
2
2
 
3
3
  __all__ = ['__version__']
@@ -137,11 +137,21 @@ class CommentGroup:
137
137
  out.extend(paragraph)
138
138
  continue
139
139
  lines = tuple(line.strip() for line in paragraph if line.strip())
140
- for sentence_index, sentence in enumerate(Prose(' '.join(lines)).sentences()):
140
+ sentences = Prose(' '.join(lines)).sentences()
141
+ shared_until = 0
142
+ for sentence_index, sentence in enumerate(sentences):
143
+ if sentence.offset < shared_until:
144
+ continue
141
145
  if sentence_index == 1 and paragraph_index == 0 and self.has_summary_line:
142
146
  out.append('')
143
147
  first_budget = self.text_budget(width, first_line=not out) - (reserve if not out else 0)
144
- if self.has_summary_line and paragraph_index == 0 and sentence_index == 0:
148
+ is_summary = self.has_summary_line and paragraph_index == 0 and sentence_index == 0
149
+ shared_line = None if is_summary else _shared_line(lines, sentences[sentence_index:])
150
+ if shared_line is not None and len(shared_line) <= first_budget:
151
+ out.append(shared_line)
152
+ shared_until = sentence.offset + len(shared_line)
153
+ continue
154
+ if is_summary:
145
155
  best_lines: tuple[str, ...] = (sentence.text,)
146
156
  else:
147
157
  best_lines = reflow_sentence(sentence.text, body_budget, stub_floor, first_width=first_budget)
@@ -192,6 +202,19 @@ class CommentGroup:
192
202
  return TextEdit(self.range, self.render())
193
203
 
194
204
 
205
+ # A line the author filled with several short sentences, each whole, reads as well as one sentence a line,
206
+ # so it stays while it fits.
207
+ def _shared_line(lines: tuple[str, ...], sentences: tuple[Sentence, ...]) -> str | None:
208
+ start = 0
209
+ for line in lines:
210
+ if start == sentences[0].offset:
211
+ end = start + len(line)
212
+ ending_here = [sentence for sentence in sentences[1:] if sentence.end == end]
213
+ return line if ending_here and sentences[0].end < end else None
214
+ start += len(line) + 1
215
+ return None
216
+
217
+
195
218
  # The lines a sentence already occupies, when it has them to itself.
196
219
  def _given_lines(lines: tuple[str, ...], sentence: Sentence) -> tuple[str, ...] | None:
197
220
  covering: list[str] = []
@@ -21,6 +21,8 @@ ABBREVIATIONS = frozenset(
21
21
  )
22
22
 
23
23
  _SENTENCE_END = re.compile(r'[.!?]')
24
+ # A colon ends a lead-in as a period ends a sentence, so the line after either opens something new.
25
+ _ENDS_A_SENTENCE = re.compile(r'[.!?:]["\')\]]*\s*$')
24
26
  _TRAILING_WORD = re.compile(r'([A-Za-z][A-Za-z.]*)$')
25
27
  _URL = re.compile(r'\b(?:https?://|www\.)\S+')
26
28
  # A span opened by two backticks, as reStructuredText writes code, closes on two.
@@ -30,6 +32,7 @@ _FENCE = re.compile(r'^\s*(```|~~~)')
30
32
  _LIST_DELIMITER = re.compile(r'^\s*(?:[0-9]+[.)]|[-*+]|[a-zA-Z][.)])\s')
31
33
  _LITERAL_DELIMITER = '\\b'
32
34
  _CLOSING_MARKS = '"\')]}'
35
+ _OPENING_MARKS = '"\'([{'
33
36
  _APOSTROPHES = ("'", '\u2019')
34
37
 
35
38
  # Only before a number, since "No. 5" abbreviates while a trailing no ends its sentence.
@@ -107,7 +110,8 @@ class Prose:
107
110
  current_lines = []
108
111
  segment_is_literal = next_is_literal
109
112
 
110
- for line in self.physical_lines:
113
+ lines = self.physical_lines
114
+ for previous_line, line in zip(('', *lines), lines, strict=False):
111
115
  stripped_line = line.strip()
112
116
  inside_tags = inside_tags or self.markup.opens_tag_section(line)
113
117
  if inside_tags:
@@ -133,7 +137,7 @@ class Prose:
133
137
  continue
134
138
  if self._is_list_item(line):
135
139
  inside_list = True
136
- elif not self._continues_list_item(line, inside_list):
140
+ elif not self._continues_list_item(line, previous_line, inside_list):
137
141
  inside_list = False
138
142
  # The indented body under a commented-out opening brace is code too.
139
143
  inside_code = self.markup.is_code(line) or (inside_code and line[:1].isspace())
@@ -173,10 +177,10 @@ class Prose:
173
177
  def _is_indented(self, line: str) -> bool:
174
178
  return line.startswith((' ', '\t'))
175
179
 
176
- # A wrapped list item is indented under its marker rather than to a fixed width,
180
+ # A wrapped list item is indented under its marker, or left at the margin while its sentence runs on,
177
181
  # so the continuation belongs to the item instead of opening a paragraph.
178
- def _continues_list_item(self, line: str, inside_list: bool) -> bool:
179
- return inside_list and line[:1].isspace()
182
+ def _continues_list_item(self, line: str, previous_line: str, inside_list: bool) -> bool:
183
+ return inside_list and (line[:1].isspace() or not _ENDS_A_SENTENCE.search(previous_line))
180
184
 
181
185
  def _is_list_item(self, line: str) -> bool:
182
186
  return bool(_LIST_DELIMITER.match(line))
@@ -237,10 +241,19 @@ class Prose:
237
241
  return False
238
242
  if word == NUMBER_ABBREVIATION and text[offset + 1 :].lstrip()[:1].isdigit():
239
243
  return False
240
- if trailing_word and len(trailing_word.group(1).replace('.', '')) == 1:
244
+ if (
245
+ trailing_word
246
+ and len(trailing_word.group(1).replace('.', '')) == 1
247
+ and self._stands_alone(text, trailing_word)
248
+ ):
241
249
  return False
242
250
  return self._followed_by_break(text, offset)
243
251
 
252
+ # An initial stands as a word of its own, so a letter closing Ctrl+C or a plural such as `Event`s is none.
253
+ def _stands_alone(self, text: str, trailing_word: re.Match[str]) -> bool:
254
+ before = text[: trailing_word.start()]
255
+ return not before or before[-1].isspace() or before[-1] in _OPENING_MARKS
256
+
244
257
  def _followed_by_break(self, text: str, offset: int) -> bool:
245
258
  rest = text[offset + 1 :]
246
259
  if not rest:
@@ -30,8 +30,9 @@ class MidClauseBreakRule(Rule):
30
30
  rule_id=self.metadata.rule_id,
31
31
  range=group.range,
32
32
  message=(
33
- 'Comment layout differs from one sentence per line. '
34
- 'A sentence that fits takes one line, and a longer one breaks only at a comma ending a clause.'
33
+ 'Comment layout breaks a sentence where it need not. '
34
+ 'A sentence that fits takes one line, and a longer one breaks only at a comma ending a clause. '
35
+ 'Short sentences may share a line that fits.'
35
36
  ),
36
37
  fix=Fix.reflow(reflowed.as_edit()),
37
38
  )
@@ -47,6 +47,10 @@ class TomlConfigSource:
47
47
  relative = target.resolve().as_posix()
48
48
  return any(fnmatch.fnmatch(relative, pattern) for pattern in patterns)
49
49
 
50
+ def is_adopted(self, path: str) -> bool:
51
+ """Whether a subedit.toml or a pyproject.toml with [tool.subedit] covers the path, as a project opts in."""
52
+ return self._locate(pathlib.Path(path)) is not None
53
+
50
54
  def root_for(self, path: str) -> pathlib.Path:
51
55
  config_path = self._locate(pathlib.Path(path))
52
56
  return config_path.parent if config_path else pathlib.Path.cwd()
@@ -1,5 +1,6 @@
1
1
  import abc
2
2
  import enum
3
+ import re
3
4
 
4
5
  from tree_sitter import Language, Node
5
6
 
@@ -47,6 +48,11 @@ class LanguageProfile(LanguageConventions, GrammarAdapter):
47
48
  width_settings: tuple[WidthSetting, ...] = ()
48
49
  # Directories a package manager fills with code nobody in the project wrote.
49
50
  vendor_directories: frozenset[str] = frozenset()
51
+ # A comment a tool reads, such as eslint-disable or noqa, which the query sets apart from prose.
52
+ directive: re.Pattern[str] | None = None
53
+
54
+ def is_directive(self, node: Node) -> bool:
55
+ return self.directive is not None and bool(self.directive.match((node.text or b'').decode('utf-8')))
50
56
 
51
57
  def imports_for_effect(self, node: Node) -> bool:
52
58
  """Whether an import binds no name and runs only for its side effect, which a comment over it explains."""
@@ -76,6 +76,7 @@ class PythonProfile(LanguageProfile):
76
76
  signature_tags = ('Args:', 'Returns:', 'Raises:', 'Yields:', ':param', ':return', ':returns', ':rtype')
77
77
  canonical_extension = '.py'
78
78
  doc_layout = QUOTED_DOC_LAYOUT
79
+ directive = re.compile(_DIRECTIVE)
79
80
  doc_markup = ProseMarkup(tag_section=_SECTION, example_tag='Example', code_line=_CODE_LINE)
80
81
  line_markup = ProseMarkup(code_line=_CODE_LINE)
81
82
  vendor_directories = frozenset({'venv', 'site-packages', '__pypackages__'})
@@ -20,7 +20,10 @@ _DOC_CLOSING = '*/'
20
20
  # A plain /* */ block is usually a license header or a lint directive, so only // and /** */ are prose.
21
21
  # A directive such as eslint-disable-next-line, or a triple-slash reference, is read by a tool rather than a person.
22
22
  # Three stars open a banner rather than a doc comment.
23
- _DIRECTIVE = '^(///|//[ \t]*(eslint|@ts-|prettier-ignore|biome-ignore|istanbul |c8 |#region|#endregion))'
23
+ _DIRECTIVE = (
24
+ '^(///|//[ \t]*(eslint|@ts-|prettier-ignore|biome-ignore|istanbul |c8 |#region|#endregion)'
25
+ '|/[*][ \t]*(eslint|prettier-ignore|biome-ignore|istanbul |c8 ))'
26
+ )
24
27
  QUERY = f"""
25
28
  ((comment) @comment (#match? @comment "^//") (#not-match? @comment "{_DIRECTIVE}"))
26
29
  ((comment) @directive (#match? @directive "{_DIRECTIVE}"))
@@ -63,6 +66,7 @@ class TypeScriptProfile(LanguageProfile):
63
66
  doc_delimiter = '/**'
64
67
  doc_layout = FramedDocLayout(opening='/**', prefix=' * ', closing_line=' */')
65
68
  doc_leads_declaration = True
69
+ directive = re.compile(_DIRECTIVE)
66
70
  width_settings = (
67
71
  *(
68
72
  WidthSetting(name, ('printWidth',))
@@ -181,12 +181,15 @@ class TreeSitterParser(SourceParser):
181
181
  # A comment on the line right above a statement describes that statement, even at the top of a file.
182
182
  # Imports are the exception, since a comment over them is a banner rather than a note on one import,
183
183
  # unless the import runs only for its effect, such as patching a library, which only a comment explains.
184
+ # A directive such as eslint-disable governs what follows it, so the comment over it explains the directive.
184
185
  def _touches_next_code(self, profile: LanguageProfile, node: Node) -> bool:
185
186
  candidate = node.next_named_sibling
186
187
  if candidate is None or not self._is_adjacent(node, candidate):
187
188
  return False
188
189
  role = profile.role_of(candidate.type)
189
- return role is not NodeRole.COMMENT and (role is not NodeRole.IMPORT or profile.imports_for_effect(candidate))
190
+ if role is NodeRole.COMMENT:
191
+ return profile.is_directive(candidate)
192
+ return role is not NodeRole.IMPORT or profile.imports_for_effect(candidate)
190
193
 
191
194
  # A blank line between a comment and what follows sets the comment apart, as a section divider does.
192
195
  def _is_adjacent(self, upper: Node, lower: Node) -> bool:
@@ -45,7 +45,8 @@ def run(payload: Payload, *, write: bool = True) -> HookResult:
45
45
  except (OSError, UnicodeDecodeError):
46
46
  continue
47
47
  language = language_for(source_path)
48
- if language is None:
48
+ # A plugin installed once reaches every project, so only one that configures subedit opts in.
49
+ if language is None or not DEFAULT_CONFIG_SOURCE.is_adopted(str(source_path)):
49
50
  continue
50
51
  document = Document(uri=source_path.resolve().as_uri(), text=source_text, language_id=language.language_id)
51
52
  fix_result = fixer.run(document, DEFAULT_CONFIG_SOURCE.resolve(str(source_path)))
@@ -116,13 +116,13 @@ def test_targeted_edits_run_before_reflow() -> None:
116
116
  fix=Fix.reflow(reflowed.as_edit()),
117
117
  )
118
118
 
119
- source = 'def f():\n # one. two.\n pass\n'
119
+ source = 'def f():\n # one\n # two.\n pass\n'
120
120
  document = Document(uri='file:///a.py', text=source, language_id='python')
121
121
  outcome = Fixer(Linter(DEFAULT_PARSER, RuleEngine((ReflowRule(), TargetedRule())))).run(
122
122
  document, RuleSet(enabled_rule_ids=frozenset({'targeted', 'reflowing'}), line_width=60)
123
123
  )
124
124
  assert '!' in outcome.document.text
125
- assert outcome.document.text.count(' # ') == 2
125
+ assert outcome.document.text.count(' # ') == 1
126
126
 
127
127
 
128
128
  def test_overlapping_edits_are_deferred_rather_than_dropped() -> None:
@@ -31,6 +31,25 @@ def seed(tmp_path: pathlib.Path, body: str, name: str = 'probe.py') -> pathlib.P
31
31
  return target
32
32
 
33
33
 
34
+ def test_a_project_without_subedit_config_is_left_alone(tmp_path: pathlib.Path) -> None:
35
+ body = MIS_WRAPPED + 'def g():\n # this one carries no comma and runs on' + ' and on' * 16 + '.\n pass\n'
36
+ target = tmp_path / 'probe.py'
37
+ target.write_text(body, encoding='utf-8')
38
+
39
+ hook_result = hook.run(payload(target))
40
+
41
+ assert hook_result.exit_code == 0
42
+ assert target.read_text(encoding='utf-8') == body
43
+
44
+
45
+ def test_a_tool_subedit_table_in_pyproject_opts_in(tmp_path: pathlib.Path) -> None:
46
+ (tmp_path / 'pyproject.toml').write_text('[tool.subedit]\nline-width = 74\n', encoding='utf-8')
47
+ target = tmp_path / 'probe.py'
48
+ target.write_text(UNBREAKABLE, encoding='utf-8')
49
+
50
+ assert hook.run(payload(target), write=False).is_blocking
51
+
52
+
34
53
  def test_a_non_edit_tool_is_ignored(tmp_path: pathlib.Path) -> None:
35
54
  target = seed(tmp_path, MIS_WRAPPED)
36
55
  assert hook.run(payload(target, tool='Read')).exit_code == 0
@@ -124,3 +124,66 @@ def test_one_line_of_code_is_code_in_both_languages(line: str) -> None:
124
124
  )
125
125
  def test_a_block_set_two_spaces_in_is_left_as_laid_out(source: str, language_id: str) -> None:
126
126
  assert fixed(source, language_id) == source
127
+
128
+
129
+ @pytest.mark.parametrize(
130
+ ('source', 'language_id'),
131
+ [
132
+ (
133
+ '// Fixture strings hold literal placeholders the gateway expands.\n'
134
+ '/* eslint-disable no-template-curly-in-string */\nimport { describe } from "vitest";\n',
135
+ 'typescript',
136
+ ),
137
+ (
138
+ '# Fixture strings hold literal placeholders the gateway expands.\n'
139
+ '# pylint: disable=consider-using-f-string\nimport os\n',
140
+ 'python',
141
+ ),
142
+ ],
143
+ )
144
+ def test_a_comment_explaining_a_file_wide_directive_is_no_file_header(source: str, language_id: str) -> None:
145
+ suffix = '.py' if language_id == 'python' else '.ts'
146
+ document = Document(uri=f'file:///a{suffix}', text=source, language_id=language_id)
147
+ report = Linter(DEFAULT_PARSER, RuleEngine(ALL_RULES)).run(document, RuleSet(enabled_rule_ids=ENABLED))
148
+ assert 'file-header-comment' not in [item.rule_id for item in report.diagnostics]
149
+
150
+
151
+ @pytest.mark.parametrize(
152
+ ('source', 'language_id'),
153
+ [
154
+ (
155
+ '/**\n * End-to-end flow.\n *\n * 1. Workspace scaffold, writes PRODUCT.md, registers workspace,\n'
156
+ " * binds the active plugin's validators.\n * 2. Proposal submission, which the reviewer approves.\n */\n"
157
+ 'export function flow(): void {}\n',
158
+ 'typescript',
159
+ ),
160
+ (
161
+ 'def flow():\n """End-to-end flow.\n\n - **Graph**: reuses the workspace `GraphCanvas`,\n'
162
+ ' so the layout matches the main view.\n - **List**: shows every proposal.\n """\n',
163
+ 'python',
164
+ ),
165
+ ],
166
+ )
167
+ def test_a_list_item_whose_sentence_runs_on_at_the_margin_stays_one_item(source: str, language_id: str) -> None:
168
+ assert fixed(source, language_id) == source
169
+
170
+
171
+ @pytest.mark.parametrize(
172
+ ('source', 'language_id', 'marker'),
173
+ [
174
+ (
175
+ 'def f():\n """Do it."""\n # Surface errors without stack traces. Reserve stacks for bugs.\n return 1\n',
176
+ 'python',
177
+ '#',
178
+ ),
179
+ (
180
+ '/** Do it. */\nexport function f() {\n'
181
+ ' // Surface errors without stack traces. Reserve stacks for bugs.\n return 1;\n}\n',
182
+ 'typescript',
183
+ '//',
184
+ ),
185
+ ],
186
+ )
187
+ def test_short_sentences_may_share_a_line_that_fits(source: str, language_id: str, marker: str) -> None:
188
+ assert fixed(source, language_id) == source
189
+ assert f'{marker} Surface errors without stack traces.\n' in fixed(source, language_id, width=60)
@@ -58,20 +58,48 @@ def test_the_wrapper_is_valid_shell() -> None:
58
58
  subprocess.run(['bash', '-n', str(WRAPPER)], check=True, capture_output=True)
59
59
 
60
60
 
61
- def test_the_wrapper_says_what_to_install_when_nothing_is_found() -> None:
62
- completed = subprocess.run(
63
- ['bash', str(WRAPPER)],
61
+ def run_wrapper(project: pathlib.Path, *arguments: str) -> subprocess.CompletedProcess[str]:
62
+ return subprocess.run(
63
+ ['bash', str(WRAPPER), *arguments],
64
64
  check=False,
65
65
  capture_output=True,
66
66
  text=True,
67
- env={'PATH': '/usr/bin:/bin'},
67
+ env={'PATH': '/usr/bin:/bin', 'CLAUDE_PROJECT_DIR': str(project)},
68
68
  input='',
69
69
  )
70
70
 
71
+
72
+ def test_the_wrapper_says_what_to_install_when_nothing_is_found(tmp_path: pathlib.Path) -> None:
73
+ (tmp_path / 'subedit.toml').write_text('[subedit]\n', encoding='utf-8')
74
+
75
+ completed = run_wrapper(tmp_path)
76
+
71
77
  assert completed.returncode == 1
72
78
  assert 'astral.sh/uv' in completed.stderr, 'a missing command must say how to get it'
73
79
 
74
80
 
81
+ @pytest.mark.parametrize('arguments', [(), ('prepare',)])
82
+ def test_the_wrapper_stays_out_of_a_project_without_subedit_config(
83
+ tmp_path: pathlib.Path, arguments: tuple[str, ...]
84
+ ) -> None:
85
+ (tmp_path / 'pyproject.toml').write_text('[tool.ruff]\nline-length = 100\n', encoding='utf-8')
86
+
87
+ completed = run_wrapper(tmp_path, *arguments)
88
+
89
+ assert (completed.returncode, completed.stderr) == (0, '')
90
+
91
+
92
+ @pytest.mark.parametrize('config', [('subedit.toml', '[subedit]\n'), ('pyproject.toml', '[tool.subedit.rules]\n')])
93
+ def test_the_wrapper_acts_in_a_subdirectory_of_a_configured_project(
94
+ tmp_path: pathlib.Path, config: tuple[str, str]
95
+ ) -> None:
96
+ name, text = config
97
+ (tmp_path / name).write_text(text, encoding='utf-8')
98
+ (tmp_path / 'packages').mkdir()
99
+
100
+ assert 'astral.sh/uv' in run_wrapper(tmp_path / 'packages').stderr
101
+
102
+
75
103
  def test_the_wrapper_runs_the_released_version() -> None:
76
104
  pyproject = (ROOT / 'pyproject.toml').read_text(encoding='utf-8')
77
105
  version = next(line for line in pyproject.splitlines() if line.startswith('version')).split('"')[1]
@@ -237,3 +237,19 @@ def test_an_appositive_is_no_list_item() -> None:
237
237
  def test_parallel_items_led_by_articles_still_form_a_list() -> None:
238
238
  lines = ('It feeds a cat, a dog,', 'and a bird every morning before the long walk.')
239
239
  assert break_cost(lines) == 1
240
+
241
+
242
+ @pytest.mark.parametrize(
243
+ 'text',
244
+ [
245
+ 'Process-local pub/sub of `WorkspaceEvent`s. Subscribers register per workspace.',
246
+ 'Stop both on Ctrl+C. Standalone, it runs the server alone.',
247
+ 'Retry on HTTP 5xx-A. Then give up.',
248
+ ],
249
+ )
250
+ def test_a_letter_closing_a_longer_token_is_no_initial(text: str) -> None:
251
+ assert len(Prose(text).sentences()) == 2
252
+
253
+
254
+ def test_an_initial_in_brackets_still_holds_its_sentence() -> None:
255
+ assert len(Prose('named after (J. Smith) originally').sentences()) == 1
@@ -32,10 +32,27 @@ def test_a_mid_clause_break_is_reported_and_reflowed() -> None:
32
32
  assert '# cap the size to the limit so the mmap does not blow past it,\n' in fixed(source, width=70)
33
33
 
34
34
 
35
- def test_reflow_splits_one_sentence_per_line() -> None:
36
- source = 'def f():\n # first one. second one. third one.\n pass\n'
37
- result = fixed(source)
38
- assert result.count(' # ') == 3
35
+ def test_short_sentences_sharing_a_line_that_fits_stay_together() -> None:
36
+ assert lint('def f():\n # first one. second one. third one.\n pass\n').is_clean
37
+
38
+
39
+ def test_sentences_sharing_a_line_that_overflows_take_one_line_each() -> None:
40
+ source = 'def f():\n # the first sentence runs on. the second one too. the third one ends here.\n pass\n'
41
+ assert fixed(source, width=40).count(' # ') == 3
42
+
43
+
44
+ def test_sentences_on_lines_of_their_own_are_not_joined() -> None:
45
+ assert lint('def f():\n # first one.\n # second one.\n pass\n').is_clean
46
+
47
+
48
+ def test_a_sentence_running_on_from_a_shared_line_takes_its_own_line() -> None:
49
+ source = 'def f():\n # first one. second one\n # runs on.\n pass\n'
50
+ assert fixed(source) == 'def f():\n # first one.\n # second one runs on.\n pass\n'
51
+
52
+
53
+ def test_a_docstring_summary_keeps_its_line_to_itself() -> None:
54
+ source = 'def f():\n """Do it. Then more.\n\n Body.\n """\n'
55
+ assert '"""Do it.\n\n Then more.' in fixed(source)
39
56
 
40
57
 
41
58
  def test_a_long_sentence_splits_at_commas_only() -> None:
@@ -349,7 +349,7 @@ wheels = [
349
349
 
350
350
  [[package]]
351
351
  name = "subedit"
352
- version = "0.3.0"
352
+ version = "0.3.2"
353
353
  source = { editable = "." }
354
354
  dependencies = [
355
355
  { name = "autocorrect-py" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes