subedit 0.1.0__tar.gz → 0.1.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 (112) hide show
  1. {subedit-0.1.0 → subedit-0.1.2}/.claude-plugin/plugin.json +1 -1
  2. {subedit-0.1.0 → subedit-0.1.2}/.github/workflows/ci.yml +14 -0
  3. subedit-0.1.2/.vale.ini +13 -0
  4. {subedit-0.1.0 → subedit-0.1.2}/PKG-INFO +20 -8
  5. {subedit-0.1.0 → subedit-0.1.2}/README.md +14 -6
  6. subedit-0.1.2/docs/rules.md +18 -0
  7. subedit-0.1.2/examples/walkthrough.py +71 -0
  8. {subedit-0.1.0 → subedit-0.1.2}/hooks/hooks.json +11 -0
  9. subedit-0.1.2/hooks/run-subedit-hook.sh +27 -0
  10. {subedit-0.1.0 → subedit-0.1.2}/pyproject.toml +7 -2
  11. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/__init__.py +1 -1
  12. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/application/fixer.py +13 -5
  13. subedit-0.1.2/src/subedit/application/report_aggregator.py +35 -0
  14. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/models/comment.py +18 -7
  15. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/models/diagnostic.py +13 -0
  16. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/models/prose.py +4 -0
  17. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/models/rule_set.py +5 -0
  18. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/ports/external_linter.py +3 -0
  19. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/rules/layout.py +3 -2
  20. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/rules/rewrite.py +10 -4
  21. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/rules/structure.py +3 -1
  22. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/config_sources/toml_config_source.py +7 -3
  23. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/linters/autocorrect_linter.py +25 -25
  24. subedit-0.1.2/src/subedit/infrastructure/linters/vale_linter.py +146 -0
  25. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/parsers/languages/language_profile.py +7 -0
  26. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/parsers/languages/python_profile.py +14 -3
  27. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +23 -8
  28. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/cli.py +24 -5
  29. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/hook.py +2 -2
  30. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/report.py +23 -5
  31. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/rule_reference.py +6 -3
  32. subedit-0.1.2/src/subedit/vale/styles/subedit/DateRef.yml +13 -0
  33. subedit-0.1.2/src/subedit/vale/styles/subedit/ExternalRef.yml +15 -0
  34. {subedit-0.1.0/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/MidSentenceColon.yml +2 -2
  35. {subedit-0.1.0/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/MidSentenceColonMarkup.yml +2 -2
  36. subedit-0.1.2/src/subedit/vale/vale.ini +11 -0
  37. subedit-0.1.2/tests/fixtures/autocorrect/unspaced.py +2 -0
  38. {subedit-0.1.0 → subedit-0.1.2}/tests/fixtures/vale/clean.md +7 -0
  39. {subedit-0.1.0 → subedit-0.1.2}/tests/test_cli.py +50 -0
  40. {subedit-0.1.0 → subedit-0.1.2}/tests/test_comment.py +3 -1
  41. {subedit-0.1.0 → subedit-0.1.2}/tests/test_config.py +9 -1
  42. {subedit-0.1.0 → subedit-0.1.2}/tests/test_diagnostic.py +1 -1
  43. {subedit-0.1.0 → subedit-0.1.2}/tests/test_fixer.py +75 -0
  44. {subedit-0.1.0 → subedit-0.1.2}/tests/test_hook.py +5 -0
  45. {subedit-0.1.0 → subedit-0.1.2}/tests/test_parser.py +28 -0
  46. {subedit-0.1.0 → subedit-0.1.2}/tests/test_parser_ocp.py +8 -1
  47. {subedit-0.1.0 → subedit-0.1.2}/tests/test_plugin.py +16 -1
  48. {subedit-0.1.0 → subedit-0.1.2}/tests/test_position.py +5 -5
  49. {subedit-0.1.0 → subedit-0.1.2}/tests/test_prose.py +5 -0
  50. {subedit-0.1.0 → subedit-0.1.2}/tests/test_report_aggregator.py +80 -6
  51. {subedit-0.1.0 → subedit-0.1.2}/tests/test_schema.py +2 -2
  52. {subedit-0.1.0 → subedit-0.1.2}/tests/test_self_hosting.py +9 -9
  53. {subedit-0.1.0 → subedit-0.1.2}/tests/test_text.py +4 -4
  54. subedit-0.1.2/tests/test_vale_style.py +67 -0
  55. {subedit-0.1.0 → subedit-0.1.2}/uv.lock +68 -15
  56. subedit-0.1.0/.vale.ini +0 -13
  57. subedit-0.1.0/docs/rules.md +0 -16
  58. subedit-0.1.0/examples/walkthrough.py +0 -68
  59. subedit-0.1.0/hooks/run-subedit-hook.sh +0 -21
  60. subedit-0.1.0/src/subedit/application/report_aggregator.py +0 -23
  61. subedit-0.1.0/src/subedit/infrastructure/linters/vale_linter.py +0 -87
  62. subedit-0.1.0/styles/mroops/ExternalRef.yml +0 -7
  63. subedit-0.1.0/tests/test_vale_style.py +0 -49
  64. {subedit-0.1.0 → subedit-0.1.2}/.claude/settings.json +0 -0
  65. {subedit-0.1.0 → subedit-0.1.2}/.claude-plugin/marketplace.json +0 -0
  66. {subedit-0.1.0 → subedit-0.1.2}/.github/dependabot.yml +0 -0
  67. {subedit-0.1.0 → subedit-0.1.2}/.github/release.yml +0 -0
  68. {subedit-0.1.0 → subedit-0.1.2}/.github/workflows/pr.yml +0 -0
  69. {subedit-0.1.0 → subedit-0.1.2}/.github/workflows/release.yml +0 -0
  70. {subedit-0.1.0 → subedit-0.1.2}/.gitignore +0 -0
  71. {subedit-0.1.0 → subedit-0.1.2}/LICENSE +0 -0
  72. {subedit-0.1.0 → subedit-0.1.2}/examples/README.md +0 -0
  73. {subedit-0.1.0 → subedit-0.1.2}/examples/probe.py +0 -0
  74. {subedit-0.1.0 → subedit-0.1.2}/examples/subedit.toml +0 -0
  75. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/application/__init__.py +0 -0
  76. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/application/corpus_meter.py +0 -0
  77. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/application/linter.py +0 -0
  78. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/__init__.py +0 -0
  79. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/models/__init__.py +0 -0
  80. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/models/document.py +0 -0
  81. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/models/position.py +0 -0
  82. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/models/text.py +0 -0
  83. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/ports/__init__.py +0 -0
  84. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/ports/language_conventions.py +0 -0
  85. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/ports/rule.py +0 -0
  86. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/ports/source_parser.py +0 -0
  87. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/domain/rules/__init__.py +0 -0
  88. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/__init__.py +0 -0
  89. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/config_sources/__init__.py +0 -0
  90. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/config_sources/config_schema.py +0 -0
  91. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/linters/__init__.py +0 -0
  92. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/parsers/__init__.py +0 -0
  93. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/infrastructure/parsers/languages/__init__.py +0 -0
  94. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/__init__.py +0 -0
  95. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/harnesses/__init__.py +0 -0
  96. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
  97. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
  98. {subedit-0.1.0 → subedit-0.1.2}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
  99. {subedit-0.1.0/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/EditHistory.yml +0 -0
  100. {subedit-0.1.0/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/Ellipsis.yml +0 -0
  101. {subedit-0.1.0/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/EmDash.yml +0 -0
  102. {subedit-0.1.0/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/Semicolon.yml +0 -0
  103. {subedit-0.1.0/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/SignatureTag.yml +0 -0
  104. {subedit-0.1.0 → subedit-0.1.2}/subedit.toml +0 -0
  105. {subedit-0.1.0 → subedit-0.1.2}/tests/fixtures/vale/violations.py +0 -0
  106. {subedit-0.1.0 → subedit-0.1.2}/tests/test_corpus_meter.py +0 -0
  107. {subedit-0.1.0 → subedit-0.1.2}/tests/test_harnesses.py +0 -0
  108. {subedit-0.1.0 → subedit-0.1.2}/tests/test_linter.py +0 -0
  109. {subedit-0.1.0 → subedit-0.1.2}/tests/test_parser_oracle.py +0 -0
  110. {subedit-0.1.0 → subedit-0.1.2}/tests/test_rules_layout.py +0 -0
  111. {subedit-0.1.0 → subedit-0.1.2}/tests/test_rules_rewrite.py +0 -0
  112. {subedit-0.1.0 → subedit-0.1.2}/tests/test_rules_structure.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "subedit",
3
3
  "displayName": "subedit",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "description": "Repairs comment layout silently on write, and returns only what needs rewriting.",
6
6
  "author": {
7
7
  "name": "mroops0111"
@@ -50,6 +50,20 @@ jobs:
50
50
  python-version: ${{ matrix.python-version }}
51
51
  enable-cache: true
52
52
  - run: uv sync --extra dev
53
+
54
+ # Without Vale, every test against it skips and the self-hosting check never runs it.
55
+ # AutoCorrect needs no step, since it arrives as a Python dependency.
56
+ - name: Install Vale
57
+ env:
58
+ VALE_VERSION: 3.18.0
59
+ run: |
60
+ curl -sSfL "https://github.com/errata-ai/vale/releases/download/v${VALE_VERSION}/vale_${VALE_VERSION}_Linux_64-bit.tar.gz" \
61
+ | tar -xz -C "$RUNNER_TEMP" vale
62
+ echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
63
+
64
+ - name: Confirm Vale
65
+ run: vale --version
66
+
53
67
  - run: uv run pytest tests/ -v --tb=short
54
68
 
55
69
  - name: Self-hosting
@@ -0,0 +1,13 @@
1
+ StylesPath = src/subedit/vale/styles
2
+ MinAlertLevel = warning
3
+
4
+ [*.{py,ts,tsx,js,go,rs}]
5
+ BasedOnStyles = subedit
6
+ subedit.MidSentenceColonMarkup = NO
7
+
8
+ [*.md]
9
+ BasedOnStyles = subedit
10
+ subedit.MidSentenceColon = NO
11
+
12
+ [tests/fixtures/vale/*]
13
+ BasedOnStyles =
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: subedit
3
- Version: 0.1.0
3
+ Version: 0.1.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
@@ -18,14 +18,18 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development :: Quality Assurance
20
20
  Requires-Python: >=3.11
21
+ Requires-Dist: autocorrect-py>=2.14.0
21
22
  Requires-Dist: pydantic>=2.13.5
22
- Requires-Dist: tree-sitter-language-pack>=1.17.0
23
+ Requires-Dist: tree-sitter-python>=0.25.0
23
24
  Requires-Dist: tree-sitter>=0.26.0
24
25
  Requires-Dist: typer>=0.27.2
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: basedpyright>=1.40.1; extra == 'dev'
27
28
  Requires-Dist: pytest>=9.0.0; extra == 'dev'
28
29
  Requires-Dist: ruff>=0.16.7; extra == 'dev'
30
+ Requires-Dist: tree-sitter-typescript>=0.23.2; extra == 'dev'
31
+ Provides-Extra: vale
32
+ Requires-Dist: vale>=3.22.0.0; extra == 'vale'
29
33
  Description-Content-Type: text/markdown
30
34
 
31
35
  # subedit
@@ -70,7 +74,7 @@ Install the command line tool from PyPI.
70
74
  uv tool install subedit
71
75
  ```
72
76
 
73
- `pipx install subedit` and `pip install subedit` work as well.
77
+ `pipx install subedit` and `pip install subedit` work as well. Add the `vale` extra, as in `uv tool install 'subedit[vale]'`, to have Vale fetched for you.
74
78
 
75
79
  ## Commands
76
80
 
@@ -105,7 +109,7 @@ Rewriting a sentence needs its meaning, which a deterministic process cannot sup
105
109
 
106
110
  ### Claude Code
107
111
 
108
- The plugin registers the hook. It cannot install the tool, so install that first.
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.
109
113
 
110
114
  ```text
111
115
  /plugin marketplace add mroops0111/subedit
@@ -159,14 +163,22 @@ block-too-long = { severity = "warning", line = 3, doc-public = 20 }
159
163
 
160
164
  Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
161
165
 
166
+ A rule at `error` fails `check` and stops an agent, while one at `warning` is shown as advice and blocks nothing. The same table sets the delegated Vale rules by name.
167
+
168
+ ```toml
169
+ [rules]
170
+ "subedit.MidSentenceColon" = "error"
171
+ "subedit.DateRef" = "off"
172
+ ```
173
+
162
174
  ## Delegated Tools
163
175
 
164
- `subedit check` also runs two tools where they are installed, and folds their findings into its own report.
176
+ `check`, `fix`, and the agent hook also run two tools, and fold their findings into the same report.
165
177
 
166
- - **Vale**: punctuation, banned phrasing, and anything a markup aware prose linter already handles
167
- - **AutoCorrect**: spacing and punctuation width between CJK and Latin text
178
+ - **Vale**: punctuation, banned phrasing, and anything a markup aware prose linter already handles. Vale has no autofix, so its findings come back as rewrites. It runs where it is installed, or where the `vale` extra fetched it. A repository without its own `.vale.ini` gets the style that ships with subedit, whose rules are named `subedit.*`.
179
+ - **AutoCorrect**: spacing and punctuation width between CJK and Latin text. Its corrections are applied silently, like any other mechanical fix. It arrives as a Python dependency, and reads `.autocorrectrc` from the working directory.
168
180
 
169
- `--no-delegate` skips both. `fix` and the agent hook do not run them.
181
+ `--no-delegate` skips both for `check` and `fix`.
170
182
 
171
183
  ## Surfaces
172
184
 
@@ -40,7 +40,7 @@ Install the command line tool from PyPI.
40
40
  uv tool install subedit
41
41
  ```
42
42
 
43
- `pipx install subedit` and `pip install subedit` work as well.
43
+ `pipx install subedit` and `pip install subedit` work as well. Add the `vale` extra, as in `uv tool install 'subedit[vale]'`, to have Vale fetched for you.
44
44
 
45
45
  ## Commands
46
46
 
@@ -75,7 +75,7 @@ Rewriting a sentence needs its meaning, which a deterministic process cannot sup
75
75
 
76
76
  ### Claude Code
77
77
 
78
- The plugin registers the hook. It cannot install the tool, so install that first.
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
80
  ```text
81
81
  /plugin marketplace add mroops0111/subedit
@@ -129,14 +129,22 @@ block-too-long = { severity = "warning", line = 3, doc-public = 20 }
129
129
 
130
130
  Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
131
131
 
132
+ A rule at `error` fails `check` and stops an agent, while one at `warning` is shown as advice and blocks nothing. The same table sets the delegated Vale rules by name.
133
+
134
+ ```toml
135
+ [rules]
136
+ "subedit.MidSentenceColon" = "error"
137
+ "subedit.DateRef" = "off"
138
+ ```
139
+
132
140
  ## Delegated Tools
133
141
 
134
- `subedit check` also runs two tools where they are installed, and folds their findings into its own report.
142
+ `check`, `fix`, and the agent hook also run two tools, and fold their findings into the same report.
135
143
 
136
- - **Vale**: punctuation, banned phrasing, and anything a markup aware prose linter already handles
137
- - **AutoCorrect**: spacing and punctuation width between CJK and Latin text
144
+ - **Vale**: punctuation, banned phrasing, and anything a markup aware prose linter already handles. Vale has no autofix, so its findings come back as rewrites. It runs where it is installed, or where the `vale` extra fetched it. A repository without its own `.vale.ini` gets the style that ships with subedit, whose rules are named `subedit.*`.
145
+ - **AutoCorrect**: spacing and punctuation width between CJK and Latin text. Its corrections are applied silently, like any other mechanical fix. It arrives as a Python dependency, and reads `.autocorrectrc` from the working directory.
138
146
 
139
- `--no-delegate` skips both. `fix` and the agent hook do not run them.
147
+ `--no-delegate` skips both for `check` and `fix`.
140
148
 
141
149
  ## Surfaces
142
150
 
@@ -0,0 +1,18 @@
1
+ # Rules
2
+
3
+ Generated from `RuleMetadata`. Edit the rule, not this file.
4
+
5
+ The fix kind states who resolves a finding. A mechanical kind is repaired without telling the author, so only a rewrite reaches an agent.
6
+
7
+ The level says whether a rewrite blocks. An error fails check and stops an agent, while a warning is shown as advice. Set either per rule under `[rules]`, and the Vale rules the same way by name, as in `"subedit.DateRef" = "off"`.
8
+
9
+ | Rule | Fix kind | Level | Summary |
10
+ | --- | --- | --- | --- |
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. |
13
+ | `block-too-long` | rewrite | warning | A comment block long enough to restate the code has stopped earning its place. |
14
+ | `file-header-comment` | rewrite | warning | A file must not open with a banner comment before its first declaration. |
15
+ | `plain-comment-on-public` | rewrite | error | A public symbol must be documented in the doc comment form its language renders. |
16
+ | `signature-restating-tag` | rewrite | error | A doc comment must not restate what the type signature already carries. |
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. |
@@ -0,0 +1,71 @@
1
+ import sys
2
+
3
+ from subedit.application import Linter, RuleEngine
4
+ from subedit.domain import Document, RuleSet
5
+ from subedit.infrastructure import ALL_RULES, DEFAULT_PARSER
6
+
7
+
8
+ SOURCE = """def build(value):
9
+ # cap the size to the shared limit so the mmap does not
10
+ # blow past it, an unbounded value faults the runner.
11
+ return value
12
+ """
13
+
14
+ WIDTH = int(sys.argv[1]) if len(sys.argv) > 1 else 60
15
+
16
+
17
+ def show(step_number, title, body):
18
+ print(f'\n{"─" * 62}\n{step_number}. {title}\n{"─" * 62}')
19
+ print(body)
20
+
21
+
22
+ show(1, 'Source (just a string)', SOURCE.rstrip())
23
+
24
+ document = Document(uri='file:///demo.py', text=SOURCE, language_id='python')
25
+ group = DEFAULT_PARSER.parse(document)[0]
26
+
27
+ show(
28
+ 2,
29
+ 'The parser turns it into a CommentGroup',
30
+ f"""form = {group.form.value} shape of the comment
31
+ placement = {group.placement.value} where it sits
32
+ attachment = {group.attachment} what it documents
33
+ line_count = {group.line_count}
34
+ widest = {group.longest_line} characters""",
35
+ )
36
+
37
+ show(
38
+ 3,
39
+ 'Each line splits into three parts (the key to measuring width)',
40
+ '\n'.join(
41
+ f' indent={line.indent!r:6s} delimiter={line.delimiter!r:5s} text={line.text!r}' for line in group.lines
42
+ ),
43
+ )
44
+
45
+ show(
46
+ 4,
47
+ 'Prose is the plain text left once the markers are stripped',
48
+ f"""physical_lines = {group.prose().physical_lines}
49
+
50
+ flattened = {group.prose().flattened!r}
51
+
52
+ sentences = {[sentence.text for sentence in group.prose().sentences()]}""",
53
+ )
54
+
55
+ show(5, f'reflow computes the right layout (one sentence per line, width {WIDTH})', group.reflow(WIDTH).render())
56
+
57
+ report = Linter(DEFAULT_PARSER, RuleEngine(ALL_RULES)).run(
58
+ document, RuleSet(enabled_rule_ids=frozenset(rule.metadata.rule_id for rule in ALL_RULES), line_width=WIDTH)
59
+ )
60
+
61
+ show(
62
+ 6,
63
+ 'Each of the 8 rules asks one question, and these answered',
64
+ '\n'.join(
65
+ f' {diagnostic.rule_id:22s} fixed by {"the tool" if diagnostic.is_mechanical else "the author"}'
66
+ for diagnostic in report.diagnostics
67
+ ),
68
+ )
69
+
70
+ print(f'\n → {len(report.mechanical)} mechanical, repaired silently by the tool')
71
+ print(f' → {len(report.needing_author)} need the author, and only these reach an agent')
@@ -12,6 +12,17 @@
12
12
  }
13
13
  ]
14
14
  }
15
+ ],
16
+ "SessionStart": [
17
+ {
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/run-subedit-hook.sh prepare",
22
+ "timeout": 10
23
+ }
24
+ ]
25
+ }
15
26
  ]
16
27
  }
17
28
  }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # Runs subedit from PyPI through uvx, so the plugin needs nothing installed but uv.
3
+ # With "prepare", it warms the same environment in the background instead,
4
+ # because the first run installs subedit and downloads Vale, which no edit should wait on.
5
+ set -uo pipefail
6
+
7
+ SPEC='subedit[vale]>=0.1.2'
8
+ # A bare uvx takes the first Python it finds, which on a stock Mac is too old for subedit.
9
+ PYTHON='>=3.11'
10
+
11
+ if ! command -v uvx >/dev/null 2>&1; then
12
+ cat >&2 <<'MESSAGE'
13
+ subedit needs uv, so comment checks are not running.
14
+
15
+ curl -LsSf https://astral.sh/uv/install.sh | sh
16
+
17
+ Install it, or remove the subedit plugin.
18
+ MESSAGE
19
+ exit 1
20
+ fi
21
+
22
+ if [ "${1:-}" = prepare ]; then
23
+ nohup uvx --python "$PYTHON" --from "$SPEC" vale --version >/dev/null 2>&1 &
24
+ exit 0
25
+ fi
26
+
27
+ exec uvx --python "$PYTHON" --from "$SPEC" subedit-hook
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "subedit"
3
- version = "0.1.0"
3
+ version = "0.1.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"},
@@ -21,9 +21,10 @@ classifiers = [
21
21
  ]
22
22
 
23
23
  dependencies = [
24
+ "autocorrect-py>=2.14.0",
24
25
  "pydantic>=2.13.5",
25
26
  "tree-sitter>=0.26.0",
26
- "tree-sitter-language-pack>=1.17.0",
27
+ "tree-sitter-python>=0.25.0",
27
28
  "typer>=0.27.2",
28
29
  ]
29
30
 
@@ -32,6 +33,10 @@ dev = [
32
33
  "pytest>=9.0.0",
33
34
  "basedpyright>=1.40.1",
34
35
  "ruff>=0.16.7",
36
+ "tree-sitter-typescript>=0.23.2",
37
+ ]
38
+ vale = [
39
+ "vale>=3.22.0.0",
35
40
  ]
36
41
 
37
42
  [project.scripts]
@@ -1,3 +1,3 @@
1
- __version__ = '0.1.0'
1
+ __version__ = '0.1.2'
2
2
 
3
3
  __all__ = ['__version__']
@@ -4,7 +4,9 @@ from ..domain.models.diagnostic import Diagnostic, FixKind, Report
4
4
  from ..domain.models.document import Document
5
5
  from ..domain.models.rule_set import RuleSet
6
6
  from ..domain.models.text import TextEdit, apply_edits
7
+ from ..domain.ports.external_linter import ExternalLinter
7
8
  from .linter import Linter
9
+ from .report_aggregator import ReportAggregator
8
10
 
9
11
 
10
12
  MAX_ROUNDS = 10
@@ -23,19 +25,25 @@ class FixResult:
23
25
 
24
26
  @property
25
27
  def unresolved_findings(self) -> tuple[Diagnostic, ...]:
26
- return self.report.needing_author
28
+ return self.report.blocking
27
29
 
28
30
 
29
31
  class Fixer:
30
- def __init__(self, linter: Linter, max_rounds: int = MAX_ROUNDS) -> None:
31
- self._linter = linter
32
+ def __init__(
33
+ self,
34
+ linter: Linter,
35
+ external_linters: tuple[ExternalLinter, ...] = (),
36
+ max_rounds: int = MAX_ROUNDS,
37
+ ) -> None:
38
+ # External findings join every round, so an edit they carry is applied like a native one.
39
+ self._aggregator = ReportAggregator(linter, external_linters)
32
40
  self._max_rounds = max_rounds
33
41
 
34
42
  def run(self, document: Document, rule_set: RuleSet) -> FixResult:
35
43
  working_document = document
36
44
  applied_edit_count = 0
37
45
  round_count = 0
38
- report = self._linter.run(working_document, rule_set)
46
+ report = self._aggregator.run(working_document, rule_set)
39
47
 
40
48
  while round_count < self._max_rounds:
41
49
  edits = self._next_edits(report)
@@ -44,7 +52,7 @@ class Fixer:
44
52
  working_document = working_document.with_text(apply_edits(working_document.text, edits))
45
53
  applied_edit_count += len(edits)
46
54
  round_count += 1
47
- report = self._linter.run(working_document, rule_set)
55
+ report = self._aggregator.run(working_document, rule_set)
48
56
 
49
57
  return FixResult(
50
58
  document=working_document, report=report, round_count=round_count, applied_edit_count=applied_edit_count
@@ -0,0 +1,35 @@
1
+ import dataclasses
2
+
3
+ from ..domain.models.diagnostic import Diagnostic, Report
4
+ from ..domain.models.document import Document
5
+ from ..domain.models.rule_set import RuleSet
6
+ from ..domain.ports.external_linter import ExternalLinter
7
+ from .linter import Linter
8
+
9
+
10
+ class ReportAggregator:
11
+ def __init__(self, linter: Linter, external_linters: tuple[ExternalLinter, ...] = ()) -> None:
12
+ self._linter = linter
13
+ self._external_linters = external_linters
14
+
15
+ def run(self, document: Document, rule_set: RuleSet) -> Report:
16
+ report = self._linter.run(document, rule_set)
17
+ missing_sources: list[str] = []
18
+ for external_linter in self._external_linters:
19
+ if not external_linter.is_available():
20
+ missing_sources.append(external_linter.name)
21
+ continue
22
+ report = report.merged_with(Report(self._configured(external_linter.run(document), rule_set)))
23
+ if missing_sources:
24
+ report = report.merged_with(Report(unavailable_sources=tuple(missing_sources)))
25
+ return report
26
+
27
+ # The project's config has the last word on a delegated rule, as it does on a native one.
28
+ def _configured(self, diagnostics: tuple[Diagnostic, ...], rule_set: RuleSet) -> tuple[Diagnostic, ...]:
29
+ configured: list[Diagnostic] = []
30
+ for diagnostic in diagnostics:
31
+ if not rule_set.allows_external(diagnostic.rule_id):
32
+ continue
33
+ severity = rule_set.settings_for(diagnostic.rule_id).severity
34
+ configured.append(dataclasses.replace(diagnostic, severity=severity) if severity else diagnostic)
35
+ return tuple(configured)
@@ -97,24 +97,34 @@ class CommentGroup:
97
97
  ]
98
98
  return Prose('\n'.join(rendered_lines))
99
99
 
100
- def reflow(self, width: int) -> 'CommentGroup':
101
- texts = self._reflow_into_texts(width)
100
+ def reflow(self, width: int, stub_floor: int = 0) -> 'CommentGroup':
101
+ # A one line docstring that fits is the summary convention, not a block to lay out.
102
+ if self.form is CommentForm.DOC and self.line_count == 1 and self.longest_line <= width:
103
+ return self
104
+ texts = self._reflow_into_texts(width, stub_floor)
102
105
  if not texts:
103
106
  return self
104
107
  return self._rebuild_lines(texts)
105
108
 
106
- def _reflow_into_texts(self, width: int) -> tuple[str, ...]:
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, ...]:
107
112
  budget = max(20, width - self.lines[0].prefix_width)
108
113
  out: list[str] = []
109
- for paragraph, is_literal in self._split_into_paragraphs():
114
+ for paragraph_index, (paragraph, is_literal) in enumerate(self._split_into_paragraphs()):
110
115
  if out:
111
116
  out.append('')
112
117
  if is_literal:
113
118
  out.extend(paragraph)
114
119
  continue
115
120
  joined_text = ' '.join(line.strip() for line in paragraph if line.strip())
116
- for sentence in Prose(joined_text).sentences():
117
- out.extend(reflow_sentence(sentence.text, budget))
121
+ for sentence_index, sentence in enumerate(Prose(joined_text).sentences()):
122
+ if sentence_index == 1 and paragraph_index == 0 and self.form is CommentForm.DOC:
123
+ out.append('')
124
+ sentence_lines = reflow_sentence(sentence.text, budget)
125
+ if len(sentence_lines) > 1 and min(map(len, sentence_lines)) < stub_floor:
126
+ return ()
127
+ out.extend(sentence_lines)
118
128
  return tuple(out)
119
129
 
120
130
  def _split_into_paragraphs(self) -> tuple[tuple[tuple[str, ...], bool], ...]:
@@ -138,7 +148,8 @@ class CommentGroup:
138
148
  closes_alone = len(self.lines) > 1 and not closing.text.strip()
139
149
  body = [dataclasses.replace(opening, delimiter='', suffix='', text=text, range=opening.range) for text in texts]
140
150
  body[0] = dataclasses.replace(body[0], delimiter=opening.delimiter)
141
- if closes_alone or len(texts) > 1:
151
+ # Text that fits one line closes on that line, as ruff and black format it.
152
+ if len(texts) > 1:
142
153
  body.append(
143
154
  dataclasses.replace(closing, delimiter=closing.delimiter or opening.delimiter, text='', suffix='')
144
155
  )
@@ -96,6 +96,19 @@ class Report:
96
96
  def needing_author(self) -> tuple[Diagnostic, ...]:
97
97
  return tuple(diagnostic for diagnostic in self.diagnostics if diagnostic.needs_author)
98
98
 
99
+ @property
100
+ def blocking(self) -> tuple[Diagnostic, ...]:
101
+ """The findings an author must resolve, since a warning reaches them as advice and blocks nothing."""
102
+ return tuple(diagnostic for diagnostic in self.needing_author if diagnostic.severity is Severity.ERROR)
103
+
104
+ @property
105
+ def advisory(self) -> tuple[Diagnostic, ...]:
106
+ return tuple(diagnostic for diagnostic in self.needing_author if diagnostic.severity is not Severity.ERROR)
107
+
108
+ @property
109
+ def has_errors(self) -> bool:
110
+ return any(diagnostic.severity is Severity.ERROR for diagnostic in self.diagnostics)
111
+
99
112
  @property
100
113
  def is_clean(self) -> bool:
101
114
  return not self.diagnostics
@@ -26,6 +26,7 @@ _FENCE = re.compile(r'^\s*(```|~~~)')
26
26
  _LIST_DELIMITER = re.compile(r'^\s*(?:[0-9]+[.)]|[-*+]|[a-zA-Z][.)])\s')
27
27
  _LITERAL_DELIMITER = '\\b'
28
28
  _CLOSING_MARKS = '"\')]}'
29
+ _APOSTROPHES = ("'", '\u2019')
29
30
 
30
31
  # Only before a number, since "No. 5" abbreviates while a trailing no ends its sentence.
31
32
  NUMBER_ABBREVIATION = 'no'
@@ -161,6 +162,9 @@ class Prose:
161
162
  if offset + 1 < len(text) and text[offset + 1].isdigit():
162
163
  return False
163
164
  trailing_word = _TRAILING_WORD.search(text[:offset])
165
+ # The s of a possessive is no initial, so a sentence closing on a word like API's still ends there.
166
+ if trailing_word and text[: trailing_word.start()].endswith(_APOSTROPHES):
167
+ return self._followed_by_break(text, offset)
164
168
  word = trailing_word.group(1).lower().rstrip('.') if trailing_word else ''
165
169
  if word in ABBREVIATIONS:
166
170
  return False
@@ -19,6 +19,8 @@ class RuleSet:
19
19
  enabled_rule_ids: frozenset[str]
20
20
  settings: typing.Mapping[str, RuleSettings] = dataclasses.field(default_factory=dict)
21
21
  line_width: int = 120
22
+ # A delegated tool's rules are not known up front, so they are listed when switched off instead.
23
+ disabled_external_ids: frozenset[str] = frozenset()
22
24
 
23
25
  def is_enabled(self, rule_id: str) -> bool:
24
26
  return rule_id in self.enabled_rule_ids
@@ -28,3 +30,6 @@ class RuleSet:
28
30
 
29
31
  def severity_for(self, metadata: RuleMetadata) -> Severity:
30
32
  return self.settings_for(metadata.rule_id).severity or metadata.default_severity
33
+
34
+ def allows_external(self, rule_id: str) -> bool:
35
+ return rule_id not in self.disabled_external_ids
@@ -12,3 +12,6 @@ class ExternalLinter(abc.ABC):
12
12
 
13
13
  @abc.abstractmethod
14
14
  def run(self, document: Document) -> tuple[Diagnostic, ...]: ...
15
+
16
+ def prepare(self) -> None: # noqa: B027, an optional hook rather than a required one
17
+ """Fetch what the tool needs, for a caller that can afford to wait. Most tools need nothing."""
@@ -3,6 +3,7 @@ import typing
3
3
  from ..models.comment import CommentGroup
4
4
  from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata
5
5
  from ..ports.rule import Rule, RuleContext
6
+ from .rewrite import stub_floor
6
7
 
7
8
 
8
9
  MID_CLAUSE_BREAK = RuleMetadata(
@@ -22,7 +23,7 @@ class MidClauseBreakRule(Rule):
22
23
  metadata = MID_CLAUSE_BREAK
23
24
 
24
25
  def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
25
- reflowed = group.reflow(context.line_width)
26
+ reflowed = group.reflow(context.line_width, stub_floor(context))
26
27
  if reflowed.render() == group.render():
27
28
  return
28
29
  yield Diagnostic(
@@ -43,7 +44,7 @@ class LineTooLongRule(Rule):
43
44
  width = context.line_width
44
45
  if group.longest_line <= width:
45
46
  return
46
- reflowed = group.reflow(width)
47
+ reflowed = group.reflow(width, stub_floor(context))
47
48
  if reflowed.longest_line > width:
48
49
  return
49
50
  yield Diagnostic(
@@ -1,7 +1,7 @@
1
1
  import typing
2
2
 
3
3
  from ..models.comment import CommentGroup
4
- from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata
4
+ from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata, Severity
5
5
  from ..models.prose import Prose, reflow_sentence
6
6
  from ..ports.rule import Rule, RuleContext
7
7
 
@@ -10,6 +10,7 @@ STUB_FRAGMENT = RuleMetadata(
10
10
  rule_id='stub-fragment',
11
11
  summary='A sentence forced to break must not leave a stub of a line behind.',
12
12
  fix_kind=FixKind.REWRITE,
13
+ default_severity=Severity.WARNING,
13
14
  )
14
15
 
15
16
  UNBREAKABLE_SENTENCE = RuleMetadata(
@@ -35,14 +36,19 @@ def sentence_budget(group: CommentGroup, context: RuleContext) -> int:
35
36
  return max(20, context.line_width - group.lines[0].prefix_width)
36
37
 
37
38
 
39
+ def stub_floor(context: RuleContext) -> int:
40
+ """Reflow reads the same floor, so a fix never lays out a stub that this rule then rejects."""
41
+ if not context.rule_set.is_enabled(STUB_FRAGMENT.rule_id):
42
+ return 0
43
+ return context.settings(STUB_FRAGMENT.rule_id).integer('minimum_characters', DEFAULT_MINIMUM_CHARACTERS)
44
+
45
+
38
46
  class StubFragmentRule(Rule):
39
47
  metadata = STUB_FRAGMENT
40
48
 
41
49
  def check(self, group: CommentGroup, context: RuleContext) -> typing.Iterable[Diagnostic]:
42
50
  budget = sentence_budget(group, context)
43
- minimum_length = context.settings(self.metadata.rule_id).integer(
44
- 'minimum_characters', DEFAULT_MINIMUM_CHARACTERS
45
- )
51
+ minimum_length = stub_floor(context)
46
52
  for sentence in prose_sentences(group):
47
53
  split_pieces = reflow_sentence(sentence, budget)
48
54
  if len(split_pieces) < 2:
@@ -1,7 +1,7 @@
1
1
  import typing
2
2
 
3
3
  from ..models.comment import CommentForm, CommentGroup, CommentPlacement
4
- from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata
4
+ from ..models.diagnostic import Diagnostic, Fix, FixKind, RuleMetadata, Severity
5
5
  from ..ports.language_conventions import LanguageConventions
6
6
  from ..ports.rule import Rule, RuleContext
7
7
 
@@ -10,6 +10,7 @@ FILE_HEADER_COMMENT = RuleMetadata(
10
10
  rule_id='file-header-comment',
11
11
  summary='A file must not open with a banner comment before its first declaration.',
12
12
  fix_kind=FixKind.REWRITE,
13
+ default_severity=Severity.WARNING,
13
14
  )
14
15
 
15
16
  PLAIN_COMMENT_ON_PUBLIC = RuleMetadata(
@@ -104,6 +105,7 @@ BLOCK_TOO_LONG = RuleMetadata(
104
105
  rule_id='block-too-long',
105
106
  summary='A comment block long enough to restate the code has stopped earning its place.',
106
107
  fix_kind=FixKind.REWRITE,
108
+ default_severity=Severity.WARNING,
107
109
  )
108
110
 
109
111
 
@@ -83,13 +83,16 @@ class TomlConfigSource:
83
83
 
84
84
  def _to_rule_set(self, config_file: ConfigFile) -> RuleSet:
85
85
  enabled_ids = set(self._available_rule_ids)
86
+ disabled_external_ids: set[str] = set()
86
87
  settings: dict[str, RuleSettings] = {}
87
88
 
89
+ # A name subedit does not define belongs to a delegated tool, such as subedit.Semicolon from Vale.
88
90
  for rule_id, rule_entry in config_file.rules.items():
89
- if rule_id not in self._available_rule_ids:
90
- continue
91
91
  if rule_entry is False or rule_entry == 'off':
92
- enabled_ids.discard(rule_id)
92
+ if rule_id in self._available_rule_ids:
93
+ enabled_ids.discard(rule_id)
94
+ else:
95
+ disabled_external_ids.add(rule_id)
93
96
  elif isinstance(rule_entry, RuleTable):
94
97
  settings[rule_id] = RuleSettings(severity=rule_entry.resolved_severity, options=rule_entry.options)
95
98
  elif isinstance(rule_entry, str):
@@ -99,4 +102,5 @@ class TomlConfigSource:
99
102
  enabled_rule_ids=frozenset(enabled_ids),
100
103
  settings=settings,
101
104
  line_width=config_file.subedit.line_width,
105
+ disabled_external_ids=frozenset(disabled_external_ids),
102
106
  )