subedit 0.1.1__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 (108) hide show
  1. {subedit-0.1.1 → subedit-0.1.2}/.claude-plugin/plugin.json +1 -1
  2. subedit-0.1.2/.vale.ini +13 -0
  3. {subedit-0.1.1 → subedit-0.1.2}/PKG-INFO +10 -2
  4. {subedit-0.1.1 → subedit-0.1.2}/README.md +9 -1
  5. subedit-0.1.2/docs/rules.md +18 -0
  6. {subedit-0.1.1 → subedit-0.1.2}/hooks/run-subedit-hook.sh +1 -1
  7. {subedit-0.1.1 → subedit-0.1.2}/pyproject.toml +1 -1
  8. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/__init__.py +1 -1
  9. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/application/fixer.py +1 -1
  10. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/application/report_aggregator.py +14 -2
  11. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/models/comment.py +18 -7
  12. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/models/diagnostic.py +13 -0
  13. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/models/prose.py +4 -0
  14. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/models/rule_set.py +5 -0
  15. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/ports/external_linter.py +3 -0
  16. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/rules/layout.py +3 -2
  17. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/rules/rewrite.py +10 -4
  18. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/rules/structure.py +3 -1
  19. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/config_sources/toml_config_source.py +7 -3
  20. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/linters/vale_linter.py +16 -0
  21. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/parsers/languages/language_profile.py +1 -0
  22. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/parsers/languages/python_profile.py +3 -0
  23. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/parsers/tree_sitter_parser.py +18 -2
  24. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/cli.py +17 -4
  25. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/report.py +23 -5
  26. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/rule_reference.py +6 -3
  27. subedit-0.1.2/src/subedit/vale/styles/subedit/DateRef.yml +13 -0
  28. subedit-0.1.2/src/subedit/vale/styles/subedit/ExternalRef.yml +15 -0
  29. {subedit-0.1.1/src/subedit/vale/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/MidSentenceColon.yml +2 -2
  30. {subedit-0.1.1/src/subedit/vale/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/MidSentenceColonMarkup.yml +2 -2
  31. subedit-0.1.2/src/subedit/vale/vale.ini +11 -0
  32. {subedit-0.1.1 → subedit-0.1.2}/tests/fixtures/vale/clean.md +7 -0
  33. {subedit-0.1.1 → subedit-0.1.2}/tests/test_cli.py +50 -0
  34. {subedit-0.1.1 → subedit-0.1.2}/tests/test_config.py +9 -1
  35. {subedit-0.1.1 → subedit-0.1.2}/tests/test_fixer.py +29 -0
  36. {subedit-0.1.1 → subedit-0.1.2}/tests/test_hook.py +5 -0
  37. {subedit-0.1.1 → subedit-0.1.2}/tests/test_parser.py +24 -0
  38. {subedit-0.1.1 → subedit-0.1.2}/tests/test_parser_ocp.py +1 -1
  39. {subedit-0.1.1 → subedit-0.1.2}/tests/test_prose.py +5 -0
  40. {subedit-0.1.1 → subedit-0.1.2}/tests/test_report_aggregator.py +25 -1
  41. {subedit-0.1.1 → subedit-0.1.2}/tests/test_schema.py +2 -2
  42. {subedit-0.1.1 → subedit-0.1.2}/tests/test_vale_style.py +20 -2
  43. {subedit-0.1.1 → subedit-0.1.2}/uv.lock +1 -1
  44. subedit-0.1.1/.vale.ini +0 -13
  45. subedit-0.1.1/docs/rules.md +0 -16
  46. subedit-0.1.1/src/subedit/vale/styles/mroops/ExternalRef.yml +0 -7
  47. subedit-0.1.1/src/subedit/vale/vale.ini +0 -11
  48. {subedit-0.1.1 → subedit-0.1.2}/.claude/settings.json +0 -0
  49. {subedit-0.1.1 → subedit-0.1.2}/.claude-plugin/marketplace.json +0 -0
  50. {subedit-0.1.1 → subedit-0.1.2}/.github/dependabot.yml +0 -0
  51. {subedit-0.1.1 → subedit-0.1.2}/.github/release.yml +0 -0
  52. {subedit-0.1.1 → subedit-0.1.2}/.github/workflows/ci.yml +0 -0
  53. {subedit-0.1.1 → subedit-0.1.2}/.github/workflows/pr.yml +0 -0
  54. {subedit-0.1.1 → subedit-0.1.2}/.github/workflows/release.yml +0 -0
  55. {subedit-0.1.1 → subedit-0.1.2}/.gitignore +0 -0
  56. {subedit-0.1.1 → subedit-0.1.2}/LICENSE +0 -0
  57. {subedit-0.1.1 → subedit-0.1.2}/examples/README.md +0 -0
  58. {subedit-0.1.1 → subedit-0.1.2}/examples/probe.py +0 -0
  59. {subedit-0.1.1 → subedit-0.1.2}/examples/subedit.toml +0 -0
  60. {subedit-0.1.1 → subedit-0.1.2}/examples/walkthrough.py +0 -0
  61. {subedit-0.1.1 → subedit-0.1.2}/hooks/hooks.json +0 -0
  62. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/application/__init__.py +0 -0
  63. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/application/corpus_meter.py +0 -0
  64. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/application/linter.py +0 -0
  65. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/__init__.py +0 -0
  66. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/models/__init__.py +0 -0
  67. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/models/document.py +0 -0
  68. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/models/position.py +0 -0
  69. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/models/text.py +0 -0
  70. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/ports/__init__.py +0 -0
  71. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/ports/language_conventions.py +0 -0
  72. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/ports/rule.py +0 -0
  73. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/ports/source_parser.py +0 -0
  74. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/domain/rules/__init__.py +0 -0
  75. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/__init__.py +0 -0
  76. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/config_sources/__init__.py +0 -0
  77. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/config_sources/config_schema.py +0 -0
  78. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/linters/__init__.py +0 -0
  79. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/linters/autocorrect_linter.py +0 -0
  80. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/parsers/__init__.py +0 -0
  81. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/infrastructure/parsers/languages/__init__.py +0 -0
  82. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/__init__.py +0 -0
  83. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/harnesses/__init__.py +0 -0
  84. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/harnesses/agent_harness.py +0 -0
  85. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/harnesses/claude_code_harness.py +0 -0
  86. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/harnesses/codex_harness.py +0 -0
  87. {subedit-0.1.1 → subedit-0.1.2}/src/subedit/presentation/hook.py +0 -0
  88. {subedit-0.1.1/src/subedit/vale/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/EditHistory.yml +0 -0
  89. {subedit-0.1.1/src/subedit/vale/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/Ellipsis.yml +0 -0
  90. {subedit-0.1.1/src/subedit/vale/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/EmDash.yml +0 -0
  91. {subedit-0.1.1/src/subedit/vale/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/Semicolon.yml +0 -0
  92. {subedit-0.1.1/src/subedit/vale/styles/mroops → subedit-0.1.2/src/subedit/vale/styles/subedit}/SignatureTag.yml +0 -0
  93. {subedit-0.1.1 → subedit-0.1.2}/subedit.toml +0 -0
  94. {subedit-0.1.1 → subedit-0.1.2}/tests/fixtures/autocorrect/unspaced.py +0 -0
  95. {subedit-0.1.1 → subedit-0.1.2}/tests/fixtures/vale/violations.py +0 -0
  96. {subedit-0.1.1 → subedit-0.1.2}/tests/test_comment.py +0 -0
  97. {subedit-0.1.1 → subedit-0.1.2}/tests/test_corpus_meter.py +0 -0
  98. {subedit-0.1.1 → subedit-0.1.2}/tests/test_diagnostic.py +0 -0
  99. {subedit-0.1.1 → subedit-0.1.2}/tests/test_harnesses.py +0 -0
  100. {subedit-0.1.1 → subedit-0.1.2}/tests/test_linter.py +0 -0
  101. {subedit-0.1.1 → subedit-0.1.2}/tests/test_parser_oracle.py +0 -0
  102. {subedit-0.1.1 → subedit-0.1.2}/tests/test_plugin.py +0 -0
  103. {subedit-0.1.1 → subedit-0.1.2}/tests/test_position.py +0 -0
  104. {subedit-0.1.1 → subedit-0.1.2}/tests/test_rules_layout.py +0 -0
  105. {subedit-0.1.1 → subedit-0.1.2}/tests/test_rules_rewrite.py +0 -0
  106. {subedit-0.1.1 → subedit-0.1.2}/tests/test_rules_structure.py +0 -0
  107. {subedit-0.1.1 → subedit-0.1.2}/tests/test_self_hosting.py +0 -0
  108. {subedit-0.1.1 → subedit-0.1.2}/tests/test_text.py +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "subedit",
3
3
  "displayName": "subedit",
4
- "version": "0.1.1",
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"
@@ -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.1
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
@@ -163,11 +163,19 @@ block-too-long = { severity = "warning", line = 3, doc-public = 20 }
163
163
 
164
164
  Rule names describe the problem they report. `docs/rules.md` lists every rule, generated from the rules themselves.
165
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
+
166
174
  ## Delegated Tools
167
175
 
168
176
  `check`, `fix`, and the agent hook also run two tools, and fold their findings into the same report.
169
177
 
170
- - **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 `mroops` style that ships with subedit.
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.*`.
171
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.
172
180
 
173
181
  `--no-delegate` skips both for `check` and `fix`.
@@ -129,11 +129,19 @@ 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
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. 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 `mroops` style that ships with subedit.
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.*`.
137
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
147
  `--no-delegate` skips both for `check` and `fix`.
@@ -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. |
@@ -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.1'
7
+ SPEC='subedit[vale]>=0.1.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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "subedit"
3
- version = "0.1.1"
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"},
@@ -1,3 +1,3 @@
1
- __version__ = '0.1.1'
1
+ __version__ = '0.1.2'
2
2
 
3
3
  __all__ = ['__version__']
@@ -25,7 +25,7 @@ class FixResult:
25
25
 
26
26
  @property
27
27
  def unresolved_findings(self) -> tuple[Diagnostic, ...]:
28
- return self.report.needing_author
28
+ return self.report.blocking
29
29
 
30
30
 
31
31
  class Fixer:
@@ -1,4 +1,6 @@
1
- from ..domain.models.diagnostic import Report
1
+ import dataclasses
2
+
3
+ from ..domain.models.diagnostic import Diagnostic, Report
2
4
  from ..domain.models.document import Document
3
5
  from ..domain.models.rule_set import RuleSet
4
6
  from ..domain.ports.external_linter import ExternalLinter
@@ -17,7 +19,17 @@ class ReportAggregator:
17
19
  if not external_linter.is_available():
18
20
  missing_sources.append(external_linter.name)
19
21
  continue
20
- report = report.merged_with(Report(external_linter.run(document)))
22
+ report = report.merged_with(Report(self._configured(external_linter.run(document), rule_set)))
21
23
  if missing_sources:
22
24
  report = report.merged_with(Report(unavailable_sources=tuple(missing_sources)))
23
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
  )
@@ -13,6 +13,8 @@ from ...domain.ports.external_linter import ExternalLinter
13
13
 
14
14
 
15
15
  TIMEOUT_SECONDS = 30
16
+ # The first run of the vale extra downloads the binary, which a slow link can take minutes over.
17
+ PREPARE_TIMEOUT_SECONDS = 900
16
18
  BUNDLED_CONFIG = pathlib.Path(__file__).resolve().parents[2] / 'vale' / 'vale.ini'
17
19
  # Vale exits with this code when its own search finds no config.
18
20
  NO_CONFIG_CODE = 'E100'
@@ -55,6 +57,20 @@ class ValeLinter(ExternalLinter):
55
57
  def is_available(self) -> bool:
56
58
  return shutil.which(self._executable) is not None and _launcher_is_ready()
57
59
 
60
+ def prepare(self) -> None:
61
+ """Let the vale extra finish its download, with progress left on stderr so a waiting command shows why."""
62
+ if _launcher_is_ready() or shutil.which(self._executable) is None:
63
+ return
64
+ try:
65
+ subprocess.run( # noqa: S603
66
+ [self._executable, '--version'],
67
+ stdout=subprocess.DEVNULL,
68
+ check=False,
69
+ timeout=PREPARE_TIMEOUT_SECONDS,
70
+ )
71
+ except (OSError, subprocess.TimeoutExpired):
72
+ return
73
+
58
74
  def run(self, document: Document) -> tuple[Diagnostic, ...]:
59
75
  if not self.is_available():
60
76
  return ()
@@ -10,6 +10,7 @@ from ....domain.ports.language_conventions import LanguageConventions
10
10
  class NodeRole(enum.Enum):
11
11
  ROOT = 'root'
12
12
  DEFINITION = 'definition'
13
+ IMPORT = 'import'
13
14
  COMMENT = 'comment'
14
15
  OTHER = 'other'
15
16
 
@@ -32,6 +32,9 @@ class PythonProfile(LanguageProfile):
32
32
  'function_definition': NodeRole.DEFINITION,
33
33
  'class_definition': NodeRole.DEFINITION,
34
34
  'decorated_definition': NodeRole.DEFINITION,
35
+ 'import_statement': NodeRole.IMPORT,
36
+ 'import_from_statement': NodeRole.IMPORT,
37
+ 'future_import_statement': NodeRole.IMPORT,
35
38
  }
36
39
 
37
40
  def language(self) -> Language:
@@ -150,10 +150,24 @@ class TreeSitterParser(SourceParser):
150
150
  return CommentPlacement.LEADING_DECLARATION
151
151
  parent = node.parent
152
152
  is_root = parent is not None and profile.role_of(parent.type) is NodeRole.ROOT
153
- if is_root and not self._has_code_before(profile, node):
153
+ if is_root and not self._has_code_before(profile, node) and not self._touches_next_code(profile, node):
154
154
  return CommentPlacement.FILE_HEADER
155
155
  return CommentPlacement.INLINE_BODY
156
156
 
157
+ # A comment on the line right above a statement describes that statement, even at the top of a file.
158
+ # Imports are the exception, since a comment over them is a banner rather than a note on one import.
159
+ def _touches_next_code(self, profile: LanguageProfile, node: Node) -> bool:
160
+ candidate = node.next_named_sibling
161
+ return (
162
+ candidate is not None
163
+ and profile.role_of(candidate.type) not in (NodeRole.COMMENT, NodeRole.IMPORT)
164
+ and self._is_adjacent(node, candidate)
165
+ )
166
+
167
+ # A blank line between a comment and what follows sets the comment apart, as a section divider does.
168
+ def _is_adjacent(self, upper: Node, lower: Node) -> bool:
169
+ return lower.start_point[0] == upper.end_point[0] + 1
170
+
157
171
  def _has_code_before(self, profile: LanguageProfile, node: Node) -> bool:
158
172
  parent = node.parent
159
173
  if parent is None:
@@ -164,13 +178,15 @@ class TreeSitterParser(SourceParser):
164
178
  )
165
179
 
166
180
  def _find_next_definition(self, profile: LanguageProfile, node: Node) -> Node | None:
181
+ previous = node
167
182
  candidate = node.next_named_sibling
168
- while candidate is not None:
183
+ while candidate is not None and self._is_adjacent(previous, candidate):
169
184
  role = profile.role_of(candidate.type)
170
185
  if role is NodeRole.DEFINITION:
171
186
  return candidate
172
187
  if role is not NodeRole.COMMENT:
173
188
  return None
189
+ previous = candidate
174
190
  candidate = candidate.next_named_sibling
175
191
  return None
176
192
 
@@ -38,14 +38,14 @@ def check(
38
38
  raise typer.Exit(2)
39
39
 
40
40
  aggregator = _build_report_aggregator(delegate)
41
- finding_count = 0
41
+ has_errors = False
42
42
  for document in documents:
43
43
  report = aggregator.run(document, DEFAULT_CONFIG_SOURCE.resolve(_file_path(document)))
44
- finding_count += len(report.diagnostics)
44
+ has_errors = has_errors or report.has_errors
45
45
  rendered_report = formatter(document, report)
46
46
  if rendered_report:
47
47
  typer.echo(rendered_report)
48
- raise typer.Exit(1 if finding_count else 0)
48
+ raise typer.Exit(1 if has_errors else 0)
49
49
 
50
50
 
51
51
  @app.command()
@@ -71,6 +71,8 @@ def fix(
71
71
  typer.echo(_diff(document, fix_result.document))
72
72
  if fix_result.unresolved_findings:
73
73
  typer.echo(reporters.brief(fix_result.document, fix_result.report))
74
+ if fix_result.report.advisory:
75
+ typer.echo(reporters.advice(fix_result.document, fix_result.report))
74
76
 
75
77
  action_word = 'rewrote' if write else 'would rewrite'
76
78
  typer.echo(
@@ -146,8 +148,19 @@ def _build_report_aggregator(delegate: bool) -> ReportAggregator:
146
148
  return ReportAggregator(_build_linter(), _external_linters(delegate))
147
149
 
148
150
 
151
+ # A command can wait for a download, unlike the hook, and a skipped tool must not read as a clean run.
149
152
  def _external_linters(delegate: bool) -> tuple[ExternalLinter, ...]:
150
- return EXTERNAL_LINTERS if delegate else ()
153
+ if not delegate:
154
+ return ()
155
+ for external_linter in EXTERNAL_LINTERS:
156
+ external_linter.prepare()
157
+ if not external_linter.is_available():
158
+ typer.echo(
159
+ f'{external_linter.name} is not installed, so its checks were skipped. '
160
+ 'Install subedit[vale] for Vale, or pass --no-delegate to skip it on purpose.',
161
+ err=True,
162
+ )
163
+ return EXTERNAL_LINTERS
151
164
 
152
165
 
153
166
  def _file_path(document: Document) -> str:
@@ -16,14 +16,26 @@ def full(document: Document, report: Report) -> str:
16
16
 
17
17
 
18
18
  def brief(document: Document, report: Report) -> str:
19
- if not report.needing_author:
19
+ if not report.blocking:
20
20
  return ''
21
21
  return _rewrite_brief(document, report)
22
22
 
23
23
 
24
+ def advice(document: Document, report: Report) -> str:
25
+ if not report.advisory:
26
+ return ''
27
+ lines = [
28
+ f'{len(report.advisory)} comment {"warning" if len(report.advisory) == 1 else "warnings"} in {_path(document)}, worth a look but not required.'
29
+ ]
30
+ lines.extend(_finding_lines(document, report.advisory))
31
+ return '\n'.join(lines)
32
+
33
+
24
34
  def actionable(document: Document, report: Report) -> str:
25
- if report.needing_author:
35
+ if report.blocking:
26
36
  return _rewrite_brief(document, report)
37
+ if report.advisory:
38
+ return advice(document, report)
27
39
  if report.mechanical:
28
40
  mechanical_count = len(report.mechanical)
29
41
  return (
@@ -46,17 +58,23 @@ def as_json(document: Document, report: Report) -> str:
46
58
 
47
59
 
48
60
  def _rewrite_brief(document: Document, report: Report) -> str:
49
- finding_count = len(report.needing_author)
61
+ finding_count = len(report.blocking)
50
62
  lines = [
51
63
  f'{finding_count} comment {"finding needs" if finding_count == 1 else "findings need"} rewriting in {_path(document)}.',
52
64
  'Each states the fix. Apply it in place, do not add a suppression comment.',
53
65
  '',
54
66
  ]
55
- for diagnostic in report.needing_author:
67
+ lines.extend(_finding_lines(document, report.blocking))
68
+ return '\n'.join(lines)
69
+
70
+
71
+ def _finding_lines(document: Document, diagnostics: tuple[Diagnostic, ...]) -> list[str]:
72
+ lines: list[str] = []
73
+ for diagnostic in diagnostics:
56
74
  position = document.positions.to_position(diagnostic.range.start)
57
75
  lines.append(f'line {position.line + 1} [{diagnostic.rule_id}]')
58
76
  lines.append(f' {diagnostic.message}')
59
- return '\n'.join(lines)
77
+ return lines
60
78
 
61
79
 
62
80
  def _encode(document: Document, diagnostic: Diagnostic) -> dict[str, object]:
@@ -6,16 +6,19 @@ HEADING = '# Rules'
6
6
  PREAMBLE = (
7
7
  'Generated from `RuleMetadata`. Edit the rule, not this file.\n\n'
8
8
  'The fix kind states who resolves a finding. A mechanical kind is repaired without telling the author, '
9
- 'so only a rewrite reaches an agent.'
9
+ 'so only a rewrite reaches an agent.\n\n'
10
+ 'The level says whether a rewrite blocks. An error fails check and stops an agent, '
11
+ 'while a warning is shown as advice. Set either per rule under `[rules]`, '
12
+ 'and the Vale rules the same way by name, as in `"subedit.DateRef" = "off"`.'
10
13
  )
11
14
 
12
15
 
13
16
  def render(rules: tuple[Rule, ...]) -> str:
14
17
  sorted_rules = sorted(rules, key=lambda rule: (rule.metadata.fix_kind.value, rule.metadata.rule_id))
15
- lines = [HEADING, '', PREAMBLE, '', '| Rule | Fix kind | Summary |', '| --- | --- | --- |']
18
+ lines = [HEADING, '', PREAMBLE, '', '| Rule | Fix kind | Level | Summary |', '| --- | --- | --- | --- |']
16
19
  lines.extend(_row(rule.metadata) for rule in sorted_rules)
17
20
  return '\n'.join(lines) + '\n'
18
21
 
19
22
 
20
23
  def _row(metadata: RuleMetadata) -> str:
21
- return f'| `{metadata.rule_id}` | {metadata.fix_kind.value} | {metadata.summary} |'
24
+ return f'| `{metadata.rule_id}` | {metadata.fix_kind.value} | {metadata.default_severity.name.lower()} | {metadata.summary} |'
@@ -0,0 +1,13 @@
1
+ extends: existence
2
+ message: "Date '%s' in a comment. Keep it if it names a version. If it records when something changed, state the current constraint instead."
3
+ level: warning
4
+ nonword: true
5
+ tokens:
6
+ - '\b\d{4}-\d{2}-\d{2}\b'
7
+ - '\b\d{4}/\d{1,2}/\d{1,2}\b'
8
+ - '\b\d{1,2}/\d{1,2}/\d{4}\b'
9
+ - '\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.? \d{1,2},? \d{4}\b'
10
+ - '\b\d{1,2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]* \d{4}\b'
11
+ - '\b(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{4}\b'
12
+ - '\b(?:[Aa]s of|[Ss]ince|[Uu]ntil) (?:Q[1-4] )?\d{4}\b'
13
+ - '\b[Ii]n Q[1-4] \d{4}\b'
@@ -0,0 +1,15 @@
1
+ extends: existence
2
+ message: "External reference '%s' a reader cannot open. Restate the constraint."
3
+ level: error
4
+ nonword: true
5
+ tokens:
6
+ - '[A-Z]{2,}-\d+'
7
+ # Standards and encodings are names a reader can look up, not tickets.
8
+ exceptions:
9
+ - 'ISO-\d+'
10
+ - 'UTF-\d+'
11
+ - 'SHA-\d+'
12
+ - 'AES-\d+'
13
+ - 'CRC-\d+'
14
+ - 'IEEE-\d+'
15
+ - 'ECMA-\d+'