code-standards 7.0.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.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from typing import TYPE_CHECKING, Literal
|
|
5
|
+
|
|
6
|
+
from . import manifest, transaction
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ExclusionKind = Literal["path", "rule"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Change:
|
|
18
|
+
kind: ExclusionKind
|
|
19
|
+
value: str
|
|
20
|
+
added: bool
|
|
21
|
+
changed: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read(root: Path) -> manifest.Manifest:
|
|
25
|
+
adopted = manifest.load(root.resolve())
|
|
26
|
+
if adopted is None:
|
|
27
|
+
msg = "repository is not adopted; run `code-standards setup` first"
|
|
28
|
+
raise ValueError(msg)
|
|
29
|
+
return adopted
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add(root: Path, kind: ExclusionKind, value: str) -> Change:
|
|
33
|
+
return _change(root, kind, value, add_value=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def remove(root: Path, kind: ExclusionKind, value: str) -> Change:
|
|
37
|
+
return _change(root, kind, value, add_value=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _change(root: Path, kind: ExclusionKind, value: str, *, add_value: bool) -> Change:
|
|
41
|
+
resolved = root.resolve()
|
|
42
|
+
target = manifest.manifest_path(resolved)
|
|
43
|
+
transaction.validate_targets(resolved, (target,))
|
|
44
|
+
expected = target.read_bytes() if target.is_file() else None
|
|
45
|
+
adopted = read(resolved)
|
|
46
|
+
normalized = (
|
|
47
|
+
manifest.validate_excluded_path(resolved, value) if kind == "path" else manifest.validate_excluded_rule(value)
|
|
48
|
+
)
|
|
49
|
+
values = adopted.excluded_paths if kind == "path" else adopted.excluded_rules
|
|
50
|
+
present = normalized in values
|
|
51
|
+
changed = (add_value and not present) or (not add_value and present)
|
|
52
|
+
if not changed:
|
|
53
|
+
return Change(kind, normalized, add_value, changed=False)
|
|
54
|
+
|
|
55
|
+
updated_values = (*values, normalized) if add_value else tuple(item for item in values if item != normalized)
|
|
56
|
+
updated = (
|
|
57
|
+
replace(adopted, excluded_paths=updated_values)
|
|
58
|
+
if kind == "path"
|
|
59
|
+
else replace(adopted, excluded_rules=updated_values)
|
|
60
|
+
)
|
|
61
|
+
transaction.assert_expected(resolved, target, expected)
|
|
62
|
+
transaction.atomic_write_text(resolved, target, updated.render())
|
|
63
|
+
# Treat serialization as a trust boundary too: a successful mutation must
|
|
64
|
+
# always leave a schema-valid manifest behind.
|
|
65
|
+
_ = read(resolved)
|
|
66
|
+
return Change(kind, normalized, add_value, changed=True)
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from typing import TYPE_CHECKING, Final, NamedTuple
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from . import launcher, manifest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .manifest import HookManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
LEFTHOOK_NAMES: Final = ("lefthook.yml", "lefthook.yaml")
|
|
19
|
+
PRECOMMIT_NAMES: Final = (".pre-commit-config.yaml", ".pre-commit-config.yml")
|
|
20
|
+
PRECOMMIT_FILES_PATTERN: Final = (
|
|
21
|
+
r"(?i)(\.py|\.[cm]?[jt]s|\.[jt]sx|\.sql|\.tf|\.tfvars|\.hcl|\.ya?ml|\.toml|\.jsonc|\.mdx?|"
|
|
22
|
+
r"\.(?:bash|cfg|conf|env|ini|properties|sh|tftpl|zsh)|(?:^|/)\.env(?:\..*)?$|"
|
|
23
|
+
r"(?:^|/)requirements(?:/.*|[^/]*\.(?:txt|in))$|"
|
|
24
|
+
r"(?:^|/)(?:Dockerfile(?:\..*)?|Gnumakefile|Justfile|Makefile|package\.json|pyrightconfig\.json))$"
|
|
25
|
+
)
|
|
26
|
+
_COMMANDS_BLOCK: Final = re.compile(r"(?m)^(?P<indent> +)commands:(?P<tail>[^\n]*)$")
|
|
27
|
+
_JOBS_BLOCK: Final = re.compile(r"(?m)^(?P<indent> +)jobs:(?P<tail>[^\n]*)$")
|
|
28
|
+
_MAX_JOB_DEPTH: Final = 64
|
|
29
|
+
_REPO_LINE: Final = re.compile(r"^(?P<indent> *)-\s+repo:\s*(?P<value>[^\r\n]+)(?:\r?\n)?$")
|
|
30
|
+
_OFFICIAL_STANDARDS_REPO: Final = re.compile(
|
|
31
|
+
r"(?i)(?:https?://github\.com/|ssh://git@github\.com/|git@github\.com:)"
|
|
32
|
+
r"sarj-ai/(?:code-)?standards(?:\.git)?/?"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LefthookWrite(NamedTuple):
|
|
37
|
+
path: Path
|
|
38
|
+
contents: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PrecommitRepoBlock(NamedTuple):
|
|
42
|
+
start: int
|
|
43
|
+
end: int
|
|
44
|
+
indent: int
|
|
45
|
+
repository: str | None
|
|
46
|
+
text: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _PrecommitRepoSection(NamedTuple):
|
|
50
|
+
header: int
|
|
51
|
+
section_end: int
|
|
52
|
+
item_indent: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _LefthookEntries(NamedTuple):
|
|
56
|
+
text: str
|
|
57
|
+
layout: str
|
|
58
|
+
entries: Mapping[str, object]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _LocatedBlock(NamedTuple):
|
|
62
|
+
match: re.Match[str]
|
|
63
|
+
section_end: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def precommit_repo_blocks(text: str) -> tuple[PrecommitRepoBlock, ...]:
|
|
67
|
+
lines = text.splitlines(keepends=True)
|
|
68
|
+
section = _precommit_repo_section(lines)
|
|
69
|
+
if section is None:
|
|
70
|
+
return ()
|
|
71
|
+
header, section_end, item_indent = section
|
|
72
|
+
offsets = _line_offsets(lines)
|
|
73
|
+
blocks: list[PrecommitRepoBlock] = []
|
|
74
|
+
for index in range(header + 1, section_end):
|
|
75
|
+
line = lines[index]
|
|
76
|
+
match = _REPO_LINE.match(line)
|
|
77
|
+
if match is None or len(match.group("indent")) != item_indent:
|
|
78
|
+
continue
|
|
79
|
+
end_index = yaml_list_item_end(lines, index, item_indent)
|
|
80
|
+
start = offsets[index]
|
|
81
|
+
end = offsets[end_index] if end_index < len(offsets) else len(text)
|
|
82
|
+
repository = _repository_scalar(match.group("value"))
|
|
83
|
+
blocks.append(PrecommitRepoBlock(start, end, item_indent, repository, text[start:end]))
|
|
84
|
+
return tuple(blocks)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _line_offsets(lines: list[str]) -> list[int]:
|
|
88
|
+
offsets: list[int] = []
|
|
89
|
+
offset = 0
|
|
90
|
+
for line in lines:
|
|
91
|
+
offsets.append(offset)
|
|
92
|
+
offset += len(line)
|
|
93
|
+
return offsets
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _repository_scalar(raw: str) -> str | None:
|
|
97
|
+
try:
|
|
98
|
+
parsed: object = yaml.safe_load(f"value: {raw}\n") # pyright: ignore[reportAny] -- narrowed below.
|
|
99
|
+
value = manifest.as_table(parsed).get("value")
|
|
100
|
+
except yaml.YAMLError:
|
|
101
|
+
return None
|
|
102
|
+
return value if isinstance(value, str) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _precommit_repo_section(lines: list[str]) -> _PrecommitRepoSection | None:
|
|
106
|
+
header = next((index for index, line in enumerate(lines) if re.match(r"^repos:\s*(?:#.*)?(?:\r?\n)?$", line)), None)
|
|
107
|
+
if header is None:
|
|
108
|
+
return None
|
|
109
|
+
section_end = len(lines)
|
|
110
|
+
for index in range(header + 1, len(lines)):
|
|
111
|
+
stripped = lines[index].strip()
|
|
112
|
+
repo_item = _REPO_LINE.match(lines[index]) is not None
|
|
113
|
+
if (
|
|
114
|
+
stripped
|
|
115
|
+
and not stripped.startswith("#")
|
|
116
|
+
and not repo_item
|
|
117
|
+
and len(lines[index]) == len(lines[index].lstrip(" "))
|
|
118
|
+
):
|
|
119
|
+
section_end = index
|
|
120
|
+
break
|
|
121
|
+
item_indent = next(
|
|
122
|
+
(
|
|
123
|
+
len(match.group("indent"))
|
|
124
|
+
for line in lines[header + 1 : section_end]
|
|
125
|
+
if (match := _REPO_LINE.match(line)) is not None
|
|
126
|
+
),
|
|
127
|
+
None,
|
|
128
|
+
)
|
|
129
|
+
return None if item_indent is None else _PrecommitRepoSection(header, section_end, item_indent)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def yaml_list_item_end(lines: list[str], start: int, indent: int) -> int:
|
|
133
|
+
trailing: int | None = None
|
|
134
|
+
index = start + 1
|
|
135
|
+
while index < len(lines):
|
|
136
|
+
stripped = lines[index].lstrip(" ")
|
|
137
|
+
if not stripped.strip() or stripped.startswith("#"):
|
|
138
|
+
trailing = index if trailing is None else trailing
|
|
139
|
+
index += 1
|
|
140
|
+
continue
|
|
141
|
+
candidate_indent = len(lines[index]) - len(stripped)
|
|
142
|
+
if candidate_indent <= indent:
|
|
143
|
+
return trailing if trailing is not None else index
|
|
144
|
+
trailing = None
|
|
145
|
+
index += 1
|
|
146
|
+
return trailing if trailing is not None else len(lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def is_official_standards_repo(repository: str | None) -> bool:
|
|
150
|
+
return repository is not None and _OFFICIAL_STANDARDS_REPO.fullmatch(repository.strip()) is not None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def lefthook_config(root: Path) -> Path | None:
|
|
154
|
+
return next((root / name for name in LEFTHOOK_NAMES if (root / name).is_file()), None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def detect_manager(root: Path) -> HookManager:
|
|
158
|
+
return "lefthook" if lefthook_config(root) is not None else "pre-commit"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def precommit_runs_staged_check(root: Path) -> bool:
|
|
162
|
+
paths = [root / name for name in PRECOMMIT_NAMES if (root / name).is_file()]
|
|
163
|
+
if len(paths) != 1:
|
|
164
|
+
return False
|
|
165
|
+
try:
|
|
166
|
+
parsed: object = yaml.safe_load( # pyright: ignore[reportAny] -- narrowed below.
|
|
167
|
+
paths[0].read_text(encoding="utf-8")
|
|
168
|
+
)
|
|
169
|
+
except OSError, UnicodeError, yaml.YAMLError:
|
|
170
|
+
return False
|
|
171
|
+
candidates: list[dict[str, object]] = []
|
|
172
|
+
for raw_repository in manifest.list_field(manifest.as_table(parsed), "repos"):
|
|
173
|
+
repository = manifest.as_table(raw_repository)
|
|
174
|
+
if repository.get("repo") != "local":
|
|
175
|
+
continue
|
|
176
|
+
candidates.extend(
|
|
177
|
+
hook
|
|
178
|
+
for raw_hook in manifest.list_field(repository, "hooks")
|
|
179
|
+
if (hook := manifest.as_table(raw_hook)).get("id") in {"sarj-standards-check", "sarj-standards-drift"}
|
|
180
|
+
)
|
|
181
|
+
if len(candidates) != 1:
|
|
182
|
+
return False
|
|
183
|
+
candidate = candidates[0]
|
|
184
|
+
return (
|
|
185
|
+
candidate.get("id") == "sarj-standards-check"
|
|
186
|
+
and _runs_staged_check(candidate.get("entry"))
|
|
187
|
+
and candidate.get("language") == "system"
|
|
188
|
+
and "verbose" not in candidate
|
|
189
|
+
and candidate.get("always_run") is True
|
|
190
|
+
and candidate.get("pass_filenames") is True
|
|
191
|
+
and candidate.get("require_serial") is True
|
|
192
|
+
and candidate.get("files") == PRECOMMIT_FILES_PATTERN
|
|
193
|
+
and candidate.get("stages") == ["pre-commit"]
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def lefthook_runs_staged_check(root: Path) -> bool:
|
|
198
|
+
path = lefthook_config(root)
|
|
199
|
+
if path is None:
|
|
200
|
+
return False
|
|
201
|
+
try:
|
|
202
|
+
parsed: object = yaml.safe_load( # pyright: ignore[reportAny] -- narrow the untyped YAML parser boundary below.
|
|
203
|
+
path.read_text(encoding="utf-8")
|
|
204
|
+
)
|
|
205
|
+
except OSError, UnicodeError, yaml.YAMLError:
|
|
206
|
+
return False
|
|
207
|
+
document = manifest.as_table(parsed)
|
|
208
|
+
pre_commit = manifest.as_table(document.get("pre-commit"))
|
|
209
|
+
values = tuple(_lefthook_run_values(pre_commit))
|
|
210
|
+
return values.count(_canonical_lefthook_command(root)) == 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def wire_lefthook_staged_check(root: Path) -> LefthookWrite:
|
|
214
|
+
path = lefthook_config(root)
|
|
215
|
+
if path is None:
|
|
216
|
+
msg = "--hooks lefthook requires lefthook.yml or lefthook.yaml"
|
|
217
|
+
raise ValueError(msg)
|
|
218
|
+
text, layout, entries = _load_lefthook_entries(path)
|
|
219
|
+
staged_values = tuple(value for value in _lefthook_run_values_from_entries(entries) if _runs_staged_check(value))
|
|
220
|
+
if len(staged_values) > 1:
|
|
221
|
+
msg = f"cannot safely wire {path.name}: multiple staged Standards commands are active"
|
|
222
|
+
raise ValueError(msg)
|
|
223
|
+
if staged_values:
|
|
224
|
+
repaired = _replace_lefthook_run(
|
|
225
|
+
text,
|
|
226
|
+
old=staged_values[0],
|
|
227
|
+
new=_canonical_lefthook_command(root),
|
|
228
|
+
)
|
|
229
|
+
if repaired is None:
|
|
230
|
+
msg = f"cannot safely wire {path.name}: staged Standards command is not a scalar run value"
|
|
231
|
+
raise ValueError(msg)
|
|
232
|
+
return LefthookWrite(path, repaired)
|
|
233
|
+
block_match, section_end = _locate_block(path, text, layout)
|
|
234
|
+
name = "sarj-standards" if "sarj-standards" not in entries else "sarj-standards-staged"
|
|
235
|
+
if layout == "commands":
|
|
236
|
+
contents = _insert_staged_command(path, text, block_match, section_end, name)
|
|
237
|
+
else:
|
|
238
|
+
contents = _insert_staged_job(path, text, block_match, section_end, name)
|
|
239
|
+
return LefthookWrite(path, contents)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _load_lefthook_entries(path: Path) -> _LefthookEntries:
|
|
243
|
+
try:
|
|
244
|
+
text = path.read_text(encoding="utf-8")
|
|
245
|
+
parsed: object = yaml.safe_load(text) # pyright: ignore[reportAny] -- narrowed immediately below.
|
|
246
|
+
document = manifest.as_table(parsed)
|
|
247
|
+
pre_commit = manifest.as_table(document.get("pre-commit"))
|
|
248
|
+
except (OSError, UnicodeError, TypeError, yaml.YAMLError) as exc:
|
|
249
|
+
msg = f"cannot safely wire {path.name}: expected valid pre-commit commands or jobs"
|
|
250
|
+
raise ValueError(msg) from exc
|
|
251
|
+
if "commands" in pre_commit:
|
|
252
|
+
return _LefthookEntries(text, "commands", manifest.as_table(pre_commit.get("commands")))
|
|
253
|
+
jobs = manifest.list_field(pre_commit, "jobs")
|
|
254
|
+
if "jobs" in pre_commit:
|
|
255
|
+
names = {
|
|
256
|
+
str(job.get("name")): job for value in jobs if (job := manifest.as_table(value)).get("name") is not None
|
|
257
|
+
}
|
|
258
|
+
return _LefthookEntries(text, "jobs", names)
|
|
259
|
+
msg = f"cannot safely wire {path.name}: expected block-style pre-commit commands or jobs"
|
|
260
|
+
raise ValueError(msg)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _locate_block(path: Path, text: str, layout: str) -> _LocatedBlock:
|
|
264
|
+
pre_commit_match = re.search(r"(?m)^pre-commit:\s*(?:#.*)?$", text)
|
|
265
|
+
if pre_commit_match is None:
|
|
266
|
+
msg = f"cannot safely wire {path.name}: expected a block-style pre-commit.commands mapping"
|
|
267
|
+
raise ValueError(msg)
|
|
268
|
+
section_end_match = re.search(r"(?m)^[^\s#][^:]*:\s*", text[pre_commit_match.end() :])
|
|
269
|
+
section_end = len(text) if section_end_match is None else pre_commit_match.end() + section_end_match.start()
|
|
270
|
+
pattern = _COMMANDS_BLOCK if layout == "commands" else _JOBS_BLOCK
|
|
271
|
+
block_match = pattern.search(text, pre_commit_match.end(), section_end)
|
|
272
|
+
if block_match is None:
|
|
273
|
+
msg = f"cannot safely wire {path.name}: expected block-style pre-commit {layout}"
|
|
274
|
+
raise ValueError(msg)
|
|
275
|
+
return _LocatedBlock(block_match, section_end)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _insert_staged_job(
|
|
279
|
+
path: Path,
|
|
280
|
+
text: str,
|
|
281
|
+
jobs_match: re.Match[str],
|
|
282
|
+
section_end: int,
|
|
283
|
+
name: str,
|
|
284
|
+
) -> str:
|
|
285
|
+
block_indent = len(jobs_match.group("indent"))
|
|
286
|
+
child_indent = " " * (block_indent + 2)
|
|
287
|
+
rendered = f"{child_indent}- name: {name}\n{child_indent} run: {_canonical_lefthook_command(path.parent)}\n"
|
|
288
|
+
tail = jobs_match.group("tail")
|
|
289
|
+
if tail.strip():
|
|
290
|
+
msg = f"cannot safely wire {path.name}: expected block-style pre-commit jobs"
|
|
291
|
+
raise ValueError(msg)
|
|
292
|
+
insertion = jobs_match.end() + (1 if text[jobs_match.end() :].startswith("\n") else 0)
|
|
293
|
+
for line in text[insertion:section_end].splitlines(keepends=True):
|
|
294
|
+
stripped = line.strip()
|
|
295
|
+
if stripped and not stripped.startswith("#") and len(line) - len(line.lstrip(" ")) <= block_indent:
|
|
296
|
+
break
|
|
297
|
+
insertion += len(line)
|
|
298
|
+
separator = "" if insertion > jobs_match.end() else "\n"
|
|
299
|
+
return "".join((text[:insertion], separator, rendered, text[insertion:]))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _insert_staged_command(
|
|
303
|
+
path: Path,
|
|
304
|
+
text: str,
|
|
305
|
+
commands_match: re.Match[str],
|
|
306
|
+
section_end: int,
|
|
307
|
+
name: str,
|
|
308
|
+
) -> str:
|
|
309
|
+
command_indent = len(commands_match.group("indent"))
|
|
310
|
+
child_indent = " " * (command_indent + 2)
|
|
311
|
+
rendered = f"{child_indent}{name}:\n{child_indent} run: {_canonical_lefthook_command(path.parent)}\n"
|
|
312
|
+
absolute_start = commands_match.start()
|
|
313
|
+
absolute_end = commands_match.end()
|
|
314
|
+
tail = commands_match.group("tail")
|
|
315
|
+
if re.fullmatch(r"\s*\{\}\s*(?:#.*)?", tail):
|
|
316
|
+
comment = tail[tail.find("#") :] if "#" in tail else ""
|
|
317
|
+
replacement = f"{commands_match.group('indent')}commands:"
|
|
318
|
+
if comment:
|
|
319
|
+
replacement = f"{replacement} {comment}"
|
|
320
|
+
return "".join((text[:absolute_start], replacement, "\n", rendered, text[absolute_end + 1 :]))
|
|
321
|
+
if tail.strip():
|
|
322
|
+
msg = f"cannot safely wire {path.name}: expected a block-style pre-commit.commands mapping"
|
|
323
|
+
raise ValueError(msg)
|
|
324
|
+
|
|
325
|
+
insertion = absolute_end + (1 if text[absolute_end:].startswith("\n") else 0)
|
|
326
|
+
for line in text[insertion:section_end].splitlines(keepends=True):
|
|
327
|
+
stripped = line.strip()
|
|
328
|
+
if stripped and not stripped.startswith("#") and len(line) - len(line.lstrip(" ")) <= command_indent:
|
|
329
|
+
break
|
|
330
|
+
insertion += len(line)
|
|
331
|
+
separator = "" if insertion > absolute_end else "\n"
|
|
332
|
+
return "".join((text[:insertion], separator, rendered, text[insertion:]))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _runs_staged_check(value: object) -> bool:
|
|
336
|
+
if not isinstance(value, str) or re.search(r"(?:&&|\|\||[;|`]|\$\()", value):
|
|
337
|
+
return False
|
|
338
|
+
migrated, count = launcher.rewrite_legacy_repository_invocations(value)
|
|
339
|
+
if count:
|
|
340
|
+
value = migrated
|
|
341
|
+
try:
|
|
342
|
+
tokens = shlex.split(value)
|
|
343
|
+
except ValueError:
|
|
344
|
+
return False
|
|
345
|
+
prefix = launcher.repository_argv()
|
|
346
|
+
return tuple(tokens[: len(prefix)]) == prefix and tokens[len(prefix) : len(prefix) + 2] == ["check", "--staged"]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _canonical_lefthook_command(root: Path | None = None) -> str:
|
|
350
|
+
if root is not None and (root / "packages" / "standards" / "pyproject.toml").is_file():
|
|
351
|
+
return (
|
|
352
|
+
"uv run --project packages/standards --frozen code-standards check --staged "
|
|
353
|
+
"--trust-repository-code -- {staged_files}"
|
|
354
|
+
)
|
|
355
|
+
return f"{launcher.repository_command()} check --staged --trust-repository-code -- {{staged_files}}"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _lefthook_run_values(value: object, *, depth: int = 0, seen: set[int] | None = None) -> list[str]:
|
|
359
|
+
if depth > _MAX_JOB_DEPTH:
|
|
360
|
+
return []
|
|
361
|
+
visited: set[int] = set() if seen is None else seen
|
|
362
|
+
if isinstance(value, (dict, list)):
|
|
363
|
+
identity = id(value) # pyright: ignore[reportUnknownArgumentType] -- identity is the cycle guard.
|
|
364
|
+
if identity in visited:
|
|
365
|
+
return []
|
|
366
|
+
visited.add(identity)
|
|
367
|
+
table = manifest.as_table(value) # pyright: ignore[reportUnknownArgumentType] -- narrowed parser value.
|
|
368
|
+
if table:
|
|
369
|
+
found: list[str] = [run for run in (table.get("run"),) if isinstance(run, str)]
|
|
370
|
+
for child in table.values():
|
|
371
|
+
found.extend(_lefthook_run_values(child, depth=depth + 1, seen=visited))
|
|
372
|
+
return found
|
|
373
|
+
if isinstance(value, list):
|
|
374
|
+
found = []
|
|
375
|
+
for child in manifest.list_field(
|
|
376
|
+
{"items": value}, # pyright: ignore[reportUnknownArgumentType] -- narrowed parser list.
|
|
377
|
+
"items",
|
|
378
|
+
):
|
|
379
|
+
found.extend(_lefthook_run_values(child, depth=depth + 1, seen=visited))
|
|
380
|
+
return found
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _lefthook_run_values_from_entries(entries: Mapping[str, object]) -> list[str]:
|
|
385
|
+
return _lefthook_run_values(entries)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _replace_lefthook_run(text: str, *, old: str, new: str) -> str | None:
|
|
389
|
+
matches: list[re.Match[str]] = []
|
|
390
|
+
for match in re.finditer(r"(?m)^(?P<prefix>\s*run:\s*)(?P<value>[^\r\n]+)$", text):
|
|
391
|
+
try:
|
|
392
|
+
parsed: object = yaml.safe_load(f"value: {match.group('value')}\n") # pyright: ignore[reportAny]
|
|
393
|
+
value = manifest.as_table(parsed).get("value")
|
|
394
|
+
except yaml.YAMLError:
|
|
395
|
+
continue
|
|
396
|
+
if value == old:
|
|
397
|
+
matches.append(match)
|
|
398
|
+
if len(matches) != 1:
|
|
399
|
+
return None
|
|
400
|
+
match = matches[0]
|
|
401
|
+
return f"{text[: match.start()]}{match.group('prefix')}{new}{text[match.end() :]}"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _jobs_run_staged_check(value: object, seen: set[int] | None = None, *, depth: int = 0) -> bool:
|
|
405
|
+
if not isinstance(value, list):
|
|
406
|
+
return False
|
|
407
|
+
if depth > _MAX_JOB_DEPTH:
|
|
408
|
+
return False
|
|
409
|
+
visited: set[int] = set() if seen is None else seen
|
|
410
|
+
identity = id(value) # pyright: ignore[reportUnknownArgumentType] -- YAML list identity detects alias cycles.
|
|
411
|
+
if identity in visited:
|
|
412
|
+
return False
|
|
413
|
+
visited.add(identity)
|
|
414
|
+
holder: dict[str, object] = {"jobs": value}
|
|
415
|
+
jobs = manifest.list_field(holder, "jobs")
|
|
416
|
+
for raw_job in jobs:
|
|
417
|
+
job = manifest.as_table(raw_job)
|
|
418
|
+
if _runs_staged_check(job.get("run")):
|
|
419
|
+
return True
|
|
420
|
+
group = manifest.as_table(job.get("group"))
|
|
421
|
+
if _jobs_run_staged_check(group.get("jobs"), visited, depth=depth + 1):
|
|
422
|
+
return True
|
|
423
|
+
return False
|