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