subedit 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- subedit/application/__init__.py +16 -0
- subedit/application/corpus_meter.py +87 -0
- subedit/application/fixer.py +68 -0
- subedit/application/linter.py +55 -0
- subedit/application/report_aggregator.py +23 -0
- subedit/domain/__init__.py +63 -0
- subedit/domain/models/__init__.py +36 -0
- subedit/domain/models/comment.py +168 -0
- subedit/domain/models/diagnostic.py +123 -0
- subedit/domain/models/document.py +23 -0
- subedit/domain/models/position.py +108 -0
- subedit/domain/models/prose.py +215 -0
- subedit/domain/models/rule_set.py +30 -0
- subedit/domain/models/text.py +23 -0
- subedit/domain/ports/__init__.py +14 -0
- subedit/domain/ports/external_linter.py +14 -0
- subedit/domain/ports/language_conventions.py +25 -0
- subedit/domain/ports/rule.py +28 -0
- subedit/domain/ports/source_parser.py +12 -0
- subedit/domain/rules/__init__.py +15 -0
- subedit/domain/rules/layout.py +57 -0
- subedit/domain/rules/rewrite.py +83 -0
- subedit/domain/rules/structure.py +141 -0
- subedit/infrastructure/__init__.py +47 -0
- subedit/infrastructure/config_sources/__init__.py +11 -0
- subedit/infrastructure/config_sources/config_schema.py +43 -0
- subedit/infrastructure/config_sources/toml_config_source.py +102 -0
- subedit/infrastructure/linters/__init__.py +5 -0
- subedit/infrastructure/linters/autocorrect_linter.py +71 -0
- subedit/infrastructure/linters/vale_linter.py +87 -0
- subedit/infrastructure/parsers/__init__.py +12 -0
- subedit/infrastructure/parsers/languages/__init__.py +11 -0
- subedit/infrastructure/parsers/languages/language_profile.py +26 -0
- subedit/infrastructure/parsers/languages/python_profile.py +71 -0
- subedit/infrastructure/parsers/tree_sitter_parser.py +219 -0
- subedit/presentation/__init__.py +0 -0
- subedit/presentation/cli.py +193 -0
- subedit/presentation/harnesses/__init__.py +40 -0
- subedit/presentation/harnesses/agent_harness.py +43 -0
- subedit/presentation/harnesses/claude_code_harness.py +37 -0
- subedit/presentation/harnesses/codex_harness.py +43 -0
- subedit/presentation/hook.py +108 -0
- subedit/presentation/report.py +79 -0
- subedit/presentation/rule_reference.py +21 -0
- subedit-0.1.0.dist-info/METADATA +193 -0
- subedit-0.1.0.dist-info/RECORD +50 -0
- subedit-0.1.0.dist-info/WHEEL +4 -0
- subedit-0.1.0.dist-info/entry_points.txt +3 -0
- subedit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from tree_sitter import Node, Query, QueryCursor
|
|
2
|
+
from tree_sitter_language_pack import get_language, get_parser
|
|
3
|
+
|
|
4
|
+
from ...domain.models.comment import CommentForm, CommentGroup, CommentLine, CommentPlacement, SymbolRef
|
|
5
|
+
from ...domain.models.document import Document
|
|
6
|
+
from ...domain.models.position import SourceRange
|
|
7
|
+
from ...domain.ports.source_parser import SourceParser
|
|
8
|
+
from .languages import LanguageProfile, NodeRole
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Every profile must spell its captures this way, and the type system cannot check it.
|
|
12
|
+
# A query that names them otherwise yields nothing, silently.
|
|
13
|
+
COMMENT_CAPTURE = 'comment'
|
|
14
|
+
DOC_CAPTURE = 'doc'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TreeSitterParser(SourceParser):
|
|
18
|
+
def __init__(self, profiles: tuple[LanguageProfile, ...]) -> None:
|
|
19
|
+
self._profiles = {profile.language_id: profile for profile in profiles}
|
|
20
|
+
|
|
21
|
+
def supports(self, language_id: str) -> bool:
|
|
22
|
+
return language_id in self._profiles
|
|
23
|
+
|
|
24
|
+
def parse(self, document: Document) -> tuple[CommentGroup, ...]:
|
|
25
|
+
"""Turn source text into the comment groups the rules read.
|
|
26
|
+
|
|
27
|
+
1. Look up the profile. An unknown language yields nothing rather than raising.
|
|
28
|
+
2. Encode to bytes, because every offset this package stores is a byte offset.
|
|
29
|
+
3. Parse with tree-sitter and run the profile query over the tree.
|
|
30
|
+
4. Build one group per doc comment, since each already arrives as a single node.
|
|
31
|
+
5. Group adjacent line comments first,
|
|
32
|
+
because three consecutive hashes are three nodes sharing one sentence.
|
|
33
|
+
6. Sort by position, so a caller reading the result walks the file in order.
|
|
34
|
+
"""
|
|
35
|
+
profile = self._profiles.get(document.language_id)
|
|
36
|
+
if profile is None:
|
|
37
|
+
return ()
|
|
38
|
+
source_bytes = document.text.encode('utf-8')
|
|
39
|
+
tree = get_parser(document.language_id).parse(source_bytes) # pyright: ignore[reportArgumentType]
|
|
40
|
+
grouped_captures = self._run_query(profile, document.language_id, tree.root_node)
|
|
41
|
+
comment_groups = [self._build_doc_group(profile, document, node) for node in grouped_captures[DOC_CAPTURE]]
|
|
42
|
+
comment_groups.extend(
|
|
43
|
+
self._build_line_group(profile, document, group)
|
|
44
|
+
for group in self._group_adjacent_comments(profile, document, grouped_captures[COMMENT_CAPTURE])
|
|
45
|
+
)
|
|
46
|
+
return tuple(sorted(comment_groups, key=lambda group: group.range.start))
|
|
47
|
+
|
|
48
|
+
def _run_query(self, profile: LanguageProfile, language_id: str, root: Node) -> dict[str, list[Node]]:
|
|
49
|
+
query = Query(get_language(language_id), profile.query()) # pyright: ignore[reportArgumentType]
|
|
50
|
+
captures_by_name = QueryCursor(query).captures(root)
|
|
51
|
+
# A node can match several patterns at once, so the same byte offset arrives twice.
|
|
52
|
+
grouped_captures: dict[str, list[Node]] = {COMMENT_CAPTURE: [], DOC_CAPTURE: []}
|
|
53
|
+
for name, nodes in captures_by_name.items():
|
|
54
|
+
grouped_captures.setdefault(name, []).extend(nodes)
|
|
55
|
+
for name, nodes in grouped_captures.items():
|
|
56
|
+
seen_offsets: set[int] = set()
|
|
57
|
+
unique_nodes = [
|
|
58
|
+
node for node in nodes if not (node.start_byte in seen_offsets or seen_offsets.add(node.start_byte))
|
|
59
|
+
]
|
|
60
|
+
grouped_captures[name] = sorted(unique_nodes, key=lambda node: node.start_byte)
|
|
61
|
+
return grouped_captures
|
|
62
|
+
|
|
63
|
+
def _group_adjacent_comments(
|
|
64
|
+
self, profile: LanguageProfile, document: Document, nodes: list[Node]
|
|
65
|
+
) -> list[list[Node]]:
|
|
66
|
+
groups: list[list[Node]] = []
|
|
67
|
+
for node in nodes:
|
|
68
|
+
if groups and self._joins_previous_group(profile, document, groups[-1][-1], node):
|
|
69
|
+
groups[-1].append(node)
|
|
70
|
+
else:
|
|
71
|
+
groups.append([node])
|
|
72
|
+
return groups
|
|
73
|
+
|
|
74
|
+
# Same column, next line, and neither one trailing.
|
|
75
|
+
# A trailing comment describes the code to its left, so it never joins what sits below it.
|
|
76
|
+
def _joins_previous_group(self, profile: LanguageProfile, document: Document, previous: Node, node: Node) -> bool:
|
|
77
|
+
if previous.start_point[0] != node.start_point[0] - 1:
|
|
78
|
+
return False
|
|
79
|
+
if self._is_trailing_comment(document, previous) or self._is_trailing_comment(document, node):
|
|
80
|
+
return False
|
|
81
|
+
return previous.start_point[1] == node.start_point[1]
|
|
82
|
+
|
|
83
|
+
def _is_trailing_comment(self, document: Document, node: Node) -> bool:
|
|
84
|
+
line = document.positions.line_text(node.start_point[0])
|
|
85
|
+
return bool(line[: node.start_point[1]].strip())
|
|
86
|
+
|
|
87
|
+
def _build_line_group(self, profile: LanguageProfile, document: Document, nodes: list[Node]) -> CommentGroup:
|
|
88
|
+
lines = tuple(self._build_comment_line(profile, document, node, CommentForm.LINE) for node in nodes)
|
|
89
|
+
placement = self._classify_placement(profile, document, nodes[-1], is_doc=False)
|
|
90
|
+
return CommentGroup(
|
|
91
|
+
range=SourceRange(lines[0].range.start, lines[-1].range.end),
|
|
92
|
+
lines=lines,
|
|
93
|
+
form=CommentForm.LINE,
|
|
94
|
+
placement=placement,
|
|
95
|
+
attachment=self._resolve_attachment(profile, nodes[-1], is_doc=False)
|
|
96
|
+
if placement is CommentPlacement.LEADING_DECLARATION
|
|
97
|
+
else None,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _build_doc_group(self, profile: LanguageProfile, document: Document, node: Node) -> CommentGroup:
|
|
101
|
+
lines: list[CommentLine] = []
|
|
102
|
+
for line_number in range(node.start_point[0], node.end_point[0] + 1):
|
|
103
|
+
marker_split = profile.split_delimiter(document.positions.line_text(line_number), CommentForm.DOC)
|
|
104
|
+
lines.append(
|
|
105
|
+
CommentLine(
|
|
106
|
+
range=document.positions.line_range(line_number),
|
|
107
|
+
indent=marker_split.indent,
|
|
108
|
+
delimiter=marker_split.delimiter,
|
|
109
|
+
text=marker_split.text,
|
|
110
|
+
suffix=marker_split.suffix,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return CommentGroup(
|
|
114
|
+
range=SourceRange(lines[0].range.start, lines[-1].range.end),
|
|
115
|
+
lines=tuple(lines),
|
|
116
|
+
form=CommentForm.DOC,
|
|
117
|
+
placement=self._classify_placement(profile, document, node, is_doc=True),
|
|
118
|
+
attachment=self._resolve_attachment(profile, node, is_doc=True),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Width is measured across indent, delimiter, and text together.
|
|
122
|
+
# Vale measures the extracted content alone, which is why a deeply indented comment passes there.
|
|
123
|
+
def _build_comment_line(
|
|
124
|
+
self, profile: LanguageProfile, document: Document, node: Node, form: CommentForm
|
|
125
|
+
) -> CommentLine:
|
|
126
|
+
line_number, column = node.start_point
|
|
127
|
+
line_text = document.positions.line_text(line_number)
|
|
128
|
+
marker_split = profile.split_delimiter(line_text[column:], form)
|
|
129
|
+
return CommentLine(
|
|
130
|
+
range=document.positions.line_range(line_number),
|
|
131
|
+
indent=line_text[:column] + marker_split.indent,
|
|
132
|
+
delimiter=marker_split.delimiter,
|
|
133
|
+
text=marker_split.text,
|
|
134
|
+
suffix=marker_split.suffix,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _classify_placement(
|
|
138
|
+
self,
|
|
139
|
+
profile: LanguageProfile,
|
|
140
|
+
document: Document,
|
|
141
|
+
node: Node,
|
|
142
|
+
*,
|
|
143
|
+
is_doc: bool,
|
|
144
|
+
) -> CommentPlacement:
|
|
145
|
+
if is_doc:
|
|
146
|
+
owner = self._find_owning_definition(profile, node)
|
|
147
|
+
return CommentPlacement.FILE_HEADER if owner is None else CommentPlacement.LEADING_DECLARATION
|
|
148
|
+
if self._is_trailing_comment(document, node):
|
|
149
|
+
return CommentPlacement.TRAILING
|
|
150
|
+
if self._find_next_definition(profile, node) is not None:
|
|
151
|
+
return CommentPlacement.LEADING_DECLARATION
|
|
152
|
+
parent = node.parent
|
|
153
|
+
is_root = parent is not None and profile.role_of(parent.type) is NodeRole.ROOT
|
|
154
|
+
if is_root and not self._has_code_before(profile, node):
|
|
155
|
+
return CommentPlacement.FILE_HEADER
|
|
156
|
+
return CommentPlacement.INLINE_BODY
|
|
157
|
+
|
|
158
|
+
def _has_code_before(self, profile: LanguageProfile, node: Node) -> bool:
|
|
159
|
+
parent = node.parent
|
|
160
|
+
if parent is None:
|
|
161
|
+
return False
|
|
162
|
+
return any(
|
|
163
|
+
sibling.start_byte < node.start_byte and profile.role_of(sibling.type) is not NodeRole.COMMENT
|
|
164
|
+
for sibling in parent.named_children
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _find_next_definition(self, profile: LanguageProfile, node: Node) -> Node | None:
|
|
168
|
+
candidate = node.next_named_sibling
|
|
169
|
+
while candidate is not None:
|
|
170
|
+
role = profile.role_of(candidate.type)
|
|
171
|
+
if role is NodeRole.DEFINITION:
|
|
172
|
+
return candidate
|
|
173
|
+
if role is not NodeRole.COMMENT:
|
|
174
|
+
return None
|
|
175
|
+
candidate = candidate.next_named_sibling
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def _find_owning_definition(self, profile: LanguageProfile, node: Node) -> Node | None:
|
|
179
|
+
ancestor = node.parent
|
|
180
|
+
while ancestor is not None:
|
|
181
|
+
role = profile.role_of(ancestor.type)
|
|
182
|
+
if role is NodeRole.DEFINITION:
|
|
183
|
+
return ancestor
|
|
184
|
+
if role is NodeRole.ROOT:
|
|
185
|
+
return None
|
|
186
|
+
ancestor = ancestor.parent
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
# Only a leading declaration documents a symbol, and CommentGroup rejects any other pairing.
|
|
190
|
+
def _resolve_attachment(self, profile: LanguageProfile, node: Node, *, is_doc: bool) -> SymbolRef | None:
|
|
191
|
+
definition_node = (
|
|
192
|
+
self._find_owning_definition(profile, node) if is_doc else self._find_next_definition(profile, node)
|
|
193
|
+
)
|
|
194
|
+
if definition_node is None:
|
|
195
|
+
return None
|
|
196
|
+
declaration = self._find_named_declaration(profile, definition_node)
|
|
197
|
+
if declaration is None:
|
|
198
|
+
return None
|
|
199
|
+
name_node = declaration.child_by_field_name('name')
|
|
200
|
+
if name_node is None or name_node.text is None:
|
|
201
|
+
return None
|
|
202
|
+
name = name_node.text.decode('utf-8')
|
|
203
|
+
return SymbolRef(
|
|
204
|
+
name=name,
|
|
205
|
+
kind=profile.symbol_kind(declaration.type),
|
|
206
|
+
visibility=profile.visibility_of(name),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# A decorated definition wraps the real one and carries no name field of its own,
|
|
210
|
+
# so the search descends until it reaches whichever node actually names something.
|
|
211
|
+
def _find_named_declaration(self, profile: LanguageProfile, node: Node) -> Node | None:
|
|
212
|
+
if node.child_by_field_name('name') is not None:
|
|
213
|
+
return node
|
|
214
|
+
for child_node in node.named_children:
|
|
215
|
+
if profile.role_of(child_node.type) is NodeRole.DEFINITION:
|
|
216
|
+
named_declaration = self._find_named_declaration(profile, child_node)
|
|
217
|
+
if named_declaration is not None:
|
|
218
|
+
return named_declaration
|
|
219
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import pathlib
|
|
3
|
+
import sys
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from .. import __version__
|
|
9
|
+
from ..application import CorpusMeter, CorpusStatistics, Fixer, Linter, ReportAggregator, RuleEngine
|
|
10
|
+
from ..domain.models.document import Document
|
|
11
|
+
from ..infrastructure import ALL_RULES, DEFAULT_CONFIG_SOURCE, DEFAULT_PARSER, EXTERNAL_LINTERS, PYTHON
|
|
12
|
+
from . import report as reporters
|
|
13
|
+
from . import rule_reference
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(add_completion=False, help='A linter and formatter for the prose inside code comments.')
|
|
17
|
+
|
|
18
|
+
FORMATTERS = {
|
|
19
|
+
'full': reporters.full,
|
|
20
|
+
'actionable': reporters.actionable,
|
|
21
|
+
'json': reporters.as_json,
|
|
22
|
+
}
|
|
23
|
+
PERCENTILES = (0.5, 0.75, 0.9, 0.95, 0.99)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def check(
|
|
28
|
+
paths: list[pathlib.Path] = typer.Argument(..., help='Files or directories to check.'),
|
|
29
|
+
output: str = typer.Option('full', '--output', help='full, actionable, or json.'),
|
|
30
|
+
delegate: bool = typer.Option(True, '--delegate/--no-delegate', help='Also run Vale and AutoCorrect.'),
|
|
31
|
+
) -> None:
|
|
32
|
+
documents = _load(tuple(paths))
|
|
33
|
+
_require(documents)
|
|
34
|
+
formatter = FORMATTERS.get(output)
|
|
35
|
+
if formatter is None:
|
|
36
|
+
typer.echo(f'Unknown output format {output!r}. Choose full, actionable, or json.', err=True)
|
|
37
|
+
raise typer.Exit(2)
|
|
38
|
+
|
|
39
|
+
aggregator = _build_report_aggregator(delegate)
|
|
40
|
+
finding_count = 0
|
|
41
|
+
for document in documents:
|
|
42
|
+
report = aggregator.run(document, DEFAULT_CONFIG_SOURCE.resolve(_file_path(document)))
|
|
43
|
+
finding_count += len(report.diagnostics)
|
|
44
|
+
rendered_report = formatter(document, report)
|
|
45
|
+
if rendered_report:
|
|
46
|
+
typer.echo(rendered_report)
|
|
47
|
+
raise typer.Exit(1 if finding_count else 0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def fix(
|
|
52
|
+
paths: list[pathlib.Path] = typer.Argument(..., help='Files or directories to fix.'),
|
|
53
|
+
write: bool = typer.Option(False, '--write', help='Write changes instead of printing a diff.'),
|
|
54
|
+
) -> None:
|
|
55
|
+
documents = _load(tuple(paths))
|
|
56
|
+
_require(documents)
|
|
57
|
+
|
|
58
|
+
fixer = Fixer(_build_linter())
|
|
59
|
+
changed_file_count = 0
|
|
60
|
+
unresolved_finding_count = 0
|
|
61
|
+
for document in documents:
|
|
62
|
+
fix_result = fixer.run(document, DEFAULT_CONFIG_SOURCE.resolve(_file_path(document)))
|
|
63
|
+
unresolved_finding_count += len(fix_result.unresolved_findings)
|
|
64
|
+
if fix_result.has_changes:
|
|
65
|
+
changed_file_count += 1
|
|
66
|
+
if write:
|
|
67
|
+
pathlib.Path(_file_path(document)).write_text(fix_result.document.text, encoding='utf-8')
|
|
68
|
+
else:
|
|
69
|
+
typer.echo(_diff(document, fix_result.document))
|
|
70
|
+
if fix_result.unresolved_findings:
|
|
71
|
+
typer.echo(reporters.brief(fix_result.document, fix_result.report))
|
|
72
|
+
|
|
73
|
+
action_word = 'rewrote' if write else 'would rewrite'
|
|
74
|
+
typer.echo(
|
|
75
|
+
f'{action_word} {changed_file_count} of {len(documents)} files, {unresolved_finding_count} findings need an author.',
|
|
76
|
+
err=True,
|
|
77
|
+
)
|
|
78
|
+
raise typer.Exit(1 if unresolved_finding_count else 0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command()
|
|
82
|
+
def stats(paths: list[pathlib.Path] = typer.Argument(..., help='Files or directories to measure.')) -> None:
|
|
83
|
+
documents = _load(tuple(paths))
|
|
84
|
+
_require(documents)
|
|
85
|
+
typer.echo(_render(CorpusMeter(DEFAULT_PARSER).run(documents)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def rules(
|
|
90
|
+
destination: pathlib.Path = typer.Option(None, '--write', help='Write the generated table to this path.'),
|
|
91
|
+
) -> None:
|
|
92
|
+
rule_table = rule_reference.render(ALL_RULES)
|
|
93
|
+
if destination is None:
|
|
94
|
+
typer.echo(rule_table, nl=False)
|
|
95
|
+
return
|
|
96
|
+
destination.write_text(rule_table, encoding='utf-8')
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def version() -> None:
|
|
101
|
+
typer.echo(f'subedit {__version__}')
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
app()
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _load(paths: tuple[pathlib.Path, ...]) -> tuple[Document, ...]:
|
|
110
|
+
source_paths: list[pathlib.Path] = []
|
|
111
|
+
for path in paths:
|
|
112
|
+
if path.is_dir():
|
|
113
|
+
source_paths.extend(
|
|
114
|
+
sorted(candidate for candidate in path.rglob('*') if candidate.suffix in PYTHON.extensions)
|
|
115
|
+
)
|
|
116
|
+
elif path.suffix in PYTHON.extensions:
|
|
117
|
+
source_paths.append(path)
|
|
118
|
+
documents: list[Document] = []
|
|
119
|
+
for source_path in source_paths:
|
|
120
|
+
root = DEFAULT_CONFIG_SOURCE.root_for(str(source_path))
|
|
121
|
+
if DEFAULT_CONFIG_SOURCE.is_excluded(
|
|
122
|
+
source_path, root, DEFAULT_CONFIG_SOURCE.exclude_patterns(str(source_path))
|
|
123
|
+
):
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
text = source_path.read_text(encoding='utf-8')
|
|
127
|
+
except (OSError, UnicodeDecodeError):
|
|
128
|
+
continue
|
|
129
|
+
documents.append(Document(uri=source_path.resolve().as_uri(), text=text, language_id=PYTHON.language_id))
|
|
130
|
+
return tuple(documents)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _require(documents: tuple[Document, ...]) -> None:
|
|
134
|
+
if not documents:
|
|
135
|
+
typer.echo('No readable source files found.', err=True)
|
|
136
|
+
raise typer.Exit(1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_linter() -> Linter:
|
|
140
|
+
return Linter(DEFAULT_PARSER, RuleEngine(ALL_RULES))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _build_report_aggregator(delegate: bool) -> ReportAggregator:
|
|
144
|
+
return ReportAggregator(_build_linter(), EXTERNAL_LINTERS if delegate else ())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _file_path(document: Document) -> str:
|
|
148
|
+
return document.uri.removeprefix('file://')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _diff(before: Document, after: Document) -> str:
|
|
152
|
+
path = _file_path(before)
|
|
153
|
+
return ''.join(
|
|
154
|
+
difflib.unified_diff(
|
|
155
|
+
before.text.splitlines(keepends=True),
|
|
156
|
+
after.text.splitlines(keepends=True),
|
|
157
|
+
fromfile=path,
|
|
158
|
+
tofile=path,
|
|
159
|
+
)
|
|
160
|
+
).rstrip()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _render(statistics: CorpusStatistics) -> str:
|
|
164
|
+
heading = (
|
|
165
|
+
f'{"group":24s} {"n":>6s} {"med":>5s} '
|
|
166
|
+
+ ' '.join(f'p{int(fraction * 100):<3d}' for fraction in PERCENTILES)
|
|
167
|
+
+ ' max'
|
|
168
|
+
)
|
|
169
|
+
lines = [
|
|
170
|
+
f'{statistics.document_count} documents, {statistics.group_count} comment blocks',
|
|
171
|
+
'',
|
|
172
|
+
'block line counts',
|
|
173
|
+
heading,
|
|
174
|
+
]
|
|
175
|
+
lines.extend(_row(distribution.label, distribution) for distribution in statistics.line_counts)
|
|
176
|
+
for distribution in (statistics.physical_widths, statistics.sentence_lengths):
|
|
177
|
+
lines.extend(['', distribution.label, _row('', distribution)])
|
|
178
|
+
lines.extend(['', 'sentences over width with no comma to break at'])
|
|
179
|
+
for width, sentence_count in statistics.unbreakable_counts_by_width:
|
|
180
|
+
percentage = (
|
|
181
|
+
sentence_count / statistics.sentence_lengths.count * 100 if statistics.sentence_lengths.count else 0.0
|
|
182
|
+
)
|
|
183
|
+
lines.append(f' width {width:3d} {sentence_count:5d} sentences {percentage:5.2f}%')
|
|
184
|
+
return '\n'.join(lines)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _row(label: str, distribution: typing.Any) -> str:
|
|
188
|
+
cells = ' '.join(f'{distribution.percentile(fraction):<4d}' for fraction in PERCENTILES)
|
|
189
|
+
return f'{label:24s} {distribution.count:6d} {distribution.median:5d} {cells} {distribution.maximum:4d}'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == '__main__':
|
|
193
|
+
sys.exit(main())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
from ...infrastructure import PYTHON
|
|
4
|
+
from .agent_harness import BLOCK_EXIT, AgentHarness, HarnessMetadata, Payload
|
|
5
|
+
from .claude_code_harness import ClaudeCodeHarness
|
|
6
|
+
from .codex_harness import CodexHarness
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
EXTENSIONS = frozenset(PYTHON.extensions)
|
|
10
|
+
|
|
11
|
+
# Each agent names its edit tools differently, so a payload identifies its own sender.
|
|
12
|
+
# One project can run both agents at once, and neither needs to know the other exists.
|
|
13
|
+
ALL_HARNESSES: tuple[AgentHarness, ...] = (
|
|
14
|
+
ClaudeCodeHarness(EXTENSIONS),
|
|
15
|
+
CodexHarness(EXTENSIONS),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve(payload: Payload) -> tuple[AgentHarness, tuple[pathlib.Path, ...]] | None:
|
|
20
|
+
"""Hand the payload to each harness until one recognises it.
|
|
21
|
+
|
|
22
|
+
A harness that declines returns None, so declining and finding nothing stay distinct.
|
|
23
|
+
"""
|
|
24
|
+
for harness in ALL_HARNESSES:
|
|
25
|
+
edited_paths = harness.edited_files(payload)
|
|
26
|
+
if edited_paths is not None:
|
|
27
|
+
return harness, edited_paths
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
'ALL_HARNESSES',
|
|
33
|
+
'BLOCK_EXIT',
|
|
34
|
+
'AgentHarness',
|
|
35
|
+
'ClaudeCodeHarness',
|
|
36
|
+
'CodexHarness',
|
|
37
|
+
'HarnessMetadata',
|
|
38
|
+
'Payload',
|
|
39
|
+
'resolve',
|
|
40
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import dataclasses
|
|
3
|
+
import pathlib
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Both Claude Code and Codex block by exiting two and writing the reason to stderr,
|
|
8
|
+
# so the exit contract is shared and only the payload shape differs.
|
|
9
|
+
BLOCK_EXIT = 2
|
|
10
|
+
|
|
11
|
+
Payload = typing.Mapping[str, object]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
15
|
+
class HarnessMetadata:
|
|
16
|
+
name: str
|
|
17
|
+
summary: str
|
|
18
|
+
example: Payload
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if not self.name or not self.summary:
|
|
22
|
+
raise ValueError('A harness names itself and says what it recognises')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentHarness(abc.ABC):
|
|
26
|
+
metadata: HarnessMetadata
|
|
27
|
+
|
|
28
|
+
@abc.abstractmethod
|
|
29
|
+
def edited_files(self, payload: Payload) -> tuple[pathlib.Path, ...] | None:
|
|
30
|
+
"""Return the files this payload edited, or None when it came from another agent.
|
|
31
|
+
|
|
32
|
+
An empty tuple and None mean different things.
|
|
33
|
+
Empty says the payload was ours and touched nothing we check.
|
|
34
|
+
None says it was never ours to read.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def existing_source_files(
|
|
40
|
+
candidates: typing.Iterable[str | pathlib.Path], extensions: frozenset[str]
|
|
41
|
+
) -> tuple[pathlib.Path, ...]:
|
|
42
|
+
paths = [pathlib.Path(candidate) for candidate in candidates]
|
|
43
|
+
return tuple(dict.fromkeys(path for path in paths if path.suffix in extensions and path.is_file()))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from .agent_harness import AgentHarness, HarnessMetadata, Payload, existing_source_files
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
EDIT_TOOLS = frozenset({'Edit', 'Write', 'MultiEdit', 'NotebookEdit'})
|
|
8
|
+
PATH_FIELDS = ('file_path', 'notebook_path')
|
|
9
|
+
|
|
10
|
+
CLAUDE_CODE = HarnessMetadata(
|
|
11
|
+
name='claude-code',
|
|
12
|
+
summary='Claude Code names the edited file in tool_input.',
|
|
13
|
+
example={'tool_name': 'Edit', 'tool_input': {'file_path': 'src/module.py'}},
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClaudeCodeHarness(AgentHarness):
|
|
18
|
+
metadata = CLAUDE_CODE
|
|
19
|
+
|
|
20
|
+
def __init__(self, extensions: frozenset[str]) -> None:
|
|
21
|
+
self._extensions = extensions
|
|
22
|
+
|
|
23
|
+
def edited_files(self, payload: Payload) -> tuple[pathlib.Path, ...] | None:
|
|
24
|
+
tool_name = payload.get('tool_name')
|
|
25
|
+
if not isinstance(tool_name, str) or tool_name not in EDIT_TOOLS:
|
|
26
|
+
return None
|
|
27
|
+
tool_input = self._tool_input(payload)
|
|
28
|
+
path_values = [tool_input.get(field) for field in PATH_FIELDS]
|
|
29
|
+
if not any(path_value is not None for path_value in path_values):
|
|
30
|
+
return None
|
|
31
|
+
return existing_source_files(
|
|
32
|
+
[path_value for path_value in path_values if isinstance(path_value, str) and path_value], self._extensions
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _tool_input(self, payload: Payload) -> typing.Mapping[str, object]:
|
|
36
|
+
tool_input = payload.get('tool_input')
|
|
37
|
+
return tool_input if isinstance(tool_input, dict) else {}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import re
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from .agent_harness import AgentHarness, HarnessMetadata, Payload, existing_source_files
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
EDIT_TOOLS = frozenset({'apply_patch'})
|
|
9
|
+
|
|
10
|
+
CODEX = HarnessMetadata(
|
|
11
|
+
name='codex',
|
|
12
|
+
summary='Codex sends a patch, and the path is read from its header.',
|
|
13
|
+
example={'tool_name': 'apply_patch', 'tool_input': {'command': '*** Update File: src/module.py'}},
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Codex carries no path field.
|
|
18
|
+
# The patch body names each file in its header, so the path is read back out of the diff.
|
|
19
|
+
_PATCH_HEADER = re.compile(r'^(?:\*\*\*\s+(?:Update|Add|Delete) File:|---|\+\+\+)\s+(.+?)\s*$', re.MULTILINE)
|
|
20
|
+
_STRIP_PREFIX = re.compile(r'^[ab]/')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CodexHarness(AgentHarness):
|
|
24
|
+
metadata = CODEX
|
|
25
|
+
|
|
26
|
+
def __init__(self, extensions: frozenset[str]) -> None:
|
|
27
|
+
self._extensions = extensions
|
|
28
|
+
|
|
29
|
+
def edited_files(self, payload: Payload) -> tuple[pathlib.Path, ...] | None:
|
|
30
|
+
tool_name = payload.get('tool_name')
|
|
31
|
+
if not isinstance(tool_name, str) or tool_name not in EDIT_TOOLS:
|
|
32
|
+
return None
|
|
33
|
+
command = self._tool_input(payload).get('command')
|
|
34
|
+
if not isinstance(command, str):
|
|
35
|
+
return None
|
|
36
|
+
patched_paths = [_STRIP_PREFIX.sub('', header.group(1)) for header in _PATCH_HEADER.finditer(command)]
|
|
37
|
+
return existing_source_files(
|
|
38
|
+
[patched_path for patched_path in patched_paths if patched_path != '/dev/null'], self._extensions
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def _tool_input(self, payload: Payload) -> typing.Mapping[str, object]:
|
|
42
|
+
tool_input = payload.get('tool_input')
|
|
43
|
+
return tool_input if isinstance(tool_input, dict) else {}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import json
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from ..application import Fixer, Linter, RuleEngine
|
|
7
|
+
from ..domain.models.document import Document
|
|
8
|
+
from ..infrastructure import ALL_RULES, DEFAULT_CONFIG_SOURCE, DEFAULT_PARSER, PYTHON
|
|
9
|
+
from . import report as reporters
|
|
10
|
+
from .harnesses import ALL_HARNESSES, BLOCK_EXIT, Payload, resolve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
HELP_FLAGS = frozenset({'--help', '-h', '--harnesses'})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
17
|
+
class HookResult:
|
|
18
|
+
exit_code: int
|
|
19
|
+
stderr: str = ''
|
|
20
|
+
repaired_paths: tuple[str, ...] = ()
|
|
21
|
+
harness_name: str = ''
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_blocking(self) -> bool:
|
|
25
|
+
return self.exit_code == BLOCK_EXIT
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(payload: Payload, *, write: bool = True) -> HookResult:
|
|
29
|
+
recognition = resolve(payload)
|
|
30
|
+
if recognition is None:
|
|
31
|
+
return HookResult(exit_code=0)
|
|
32
|
+
harness, edited_paths = recognition
|
|
33
|
+
if not edited_paths:
|
|
34
|
+
return HookResult(exit_code=0, harness_name=harness.metadata.name)
|
|
35
|
+
|
|
36
|
+
fixer = Fixer(Linter(DEFAULT_PARSER, RuleEngine(ALL_RULES)))
|
|
37
|
+
author_messages: list[str] = []
|
|
38
|
+
repaired_paths: list[str] = []
|
|
39
|
+
|
|
40
|
+
for source_path in edited_paths:
|
|
41
|
+
try:
|
|
42
|
+
source_text = source_path.read_text(encoding='utf-8')
|
|
43
|
+
except (OSError, UnicodeDecodeError):
|
|
44
|
+
continue
|
|
45
|
+
document = Document(uri=source_path.resolve().as_uri(), text=source_text, language_id=PYTHON.language_id)
|
|
46
|
+
fix_result = fixer.run(document, DEFAULT_CONFIG_SOURCE.resolve(str(source_path)))
|
|
47
|
+
if fix_result.has_changes and write:
|
|
48
|
+
source_path.write_text(fix_result.document.text, encoding='utf-8')
|
|
49
|
+
repaired_paths.append(str(source_path))
|
|
50
|
+
brief_report = reporters.brief(fix_result.document, fix_result.report)
|
|
51
|
+
if brief_report:
|
|
52
|
+
author_messages.append(brief_report)
|
|
53
|
+
|
|
54
|
+
if not author_messages:
|
|
55
|
+
return HookResult(exit_code=0, repaired_paths=tuple(repaired_paths), harness_name=harness.metadata.name)
|
|
56
|
+
return HookResult(
|
|
57
|
+
exit_code=BLOCK_EXIT,
|
|
58
|
+
stderr='\n\n'.join(author_messages),
|
|
59
|
+
repaired_paths=tuple(repaired_paths),
|
|
60
|
+
harness_name=harness.metadata.name,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def edited_files(payload: Payload) -> tuple[pathlib.Path, ...]:
|
|
65
|
+
recognition = resolve(payload)
|
|
66
|
+
if recognition is None:
|
|
67
|
+
return ()
|
|
68
|
+
_, edited_paths = recognition
|
|
69
|
+
return edited_paths
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def describe_harnesses() -> str:
|
|
73
|
+
"""Say which payload shapes reach this hook, so a caller can check its own.
|
|
74
|
+
|
|
75
|
+
Unrecognised payloads exit quietly, since an agent sends many that are none of our business.
|
|
76
|
+
That silence is why the shapes have to be askable for.
|
|
77
|
+
"""
|
|
78
|
+
help_lines = ['subedit-hook reads one agent payload on stdin.', '']
|
|
79
|
+
for harness in ALL_HARNESSES:
|
|
80
|
+
metadata = harness.metadata
|
|
81
|
+
help_lines.extend([metadata.name, f' {metadata.summary}', f' {json.dumps(metadata.example)}', ''])
|
|
82
|
+
help_lines.extend(
|
|
83
|
+
[
|
|
84
|
+
'A payload no harness recognises exits 0 and changes nothing.',
|
|
85
|
+
'Outside an agent, use subedit fix --write and subedit check instead.',
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
return '\n'.join(help_lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main() -> int:
|
|
92
|
+
if HELP_FLAGS & set(sys.argv[1:]):
|
|
93
|
+
sys.stdout.write(describe_harnesses() + '\n')
|
|
94
|
+
return 0
|
|
95
|
+
try:
|
|
96
|
+
payload = json.load(sys.stdin)
|
|
97
|
+
except (json.JSONDecodeError, ValueError):
|
|
98
|
+
return 0
|
|
99
|
+
if not isinstance(payload, dict):
|
|
100
|
+
return 0
|
|
101
|
+
hook_result = run(payload)
|
|
102
|
+
if hook_result.stderr:
|
|
103
|
+
sys.stderr.write(hook_result.stderr + '\n')
|
|
104
|
+
return hook_result.exit_code
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == '__main__':
|
|
108
|
+
sys.exit(main())
|