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,550 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import field as dataclass_field
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import tomllib
|
|
10
|
+
from typing import TYPE_CHECKING, Final, Literal
|
|
11
|
+
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
|
|
14
|
+
from sarj_standards._meta import CONFIGS_DIR, __version__
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MANIFEST_NAME: Final = ".sarj-standards.toml"
|
|
22
|
+
MANIFEST_SCHEMA: Final = 3
|
|
23
|
+
Profile = Literal["standard", "application"]
|
|
24
|
+
PROFILES: Final[tuple[Profile, ...]] = ("standard", "application")
|
|
25
|
+
HookManager = Literal["pre-commit", "lefthook", "none"]
|
|
26
|
+
HOOK_MANAGERS: Final[tuple[HookManager, ...]] = ("pre-commit", "lefthook", "none")
|
|
27
|
+
|
|
28
|
+
PEERS_JSON: Final = CONFIGS_DIR / "eslint.peers.json"
|
|
29
|
+
_ESLINT_RULE_KEY: Final = re.compile(
|
|
30
|
+
r'^\s+"(?P<rule>[^"]+)":\s*(?:"(?:off|warn|error)"|\[)',
|
|
31
|
+
re.MULTILINE,
|
|
32
|
+
)
|
|
33
|
+
_SARJ_RULE_ENGINES: Final = frozenset({"python", "sql", "iac", "text"})
|
|
34
|
+
|
|
35
|
+
#: Sibling distributions pinned exactly by `code-standards`.
|
|
36
|
+
LINT_CONFIGS: Final = "code-standards"
|
|
37
|
+
_PYTHON_LINT: Final = "sarj-python-lint"
|
|
38
|
+
SIBLING_PACKAGES: Final = (_PYTHON_LINT, "sarj-sql-lint", "sarj-iac-lint")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def adopted_version() -> str:
|
|
42
|
+
if __version__ != "0.0.0.dev0":
|
|
43
|
+
return __version__
|
|
44
|
+
source_project = CONFIGS_DIR.parents[2] / "pyproject.toml"
|
|
45
|
+
if not source_project.is_file():
|
|
46
|
+
return __version__
|
|
47
|
+
try:
|
|
48
|
+
parsed: object = tomllib.loads(source_project.read_text(encoding="utf-8"))
|
|
49
|
+
except OSError, tomllib.TOMLDecodeError:
|
|
50
|
+
return __version__
|
|
51
|
+
declared = text_field(table_field(as_table(parsed), "project"), "version")
|
|
52
|
+
return __version__ if declared is None else declared
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: Config bundle selected for each detected ecosystem.
|
|
56
|
+
PYTHON_CONFIGS: Final = ("ruff", "pyright")
|
|
57
|
+
TYPESCRIPT_CONFIGS: Final = ("eslint",)
|
|
58
|
+
SHARED_CONFIGS: Final = ("markdownlint", "taplo", "yamllint")
|
|
59
|
+
ALL_CONFIGS: Final = (*PYTHON_CONFIGS, *TYPESCRIPT_CONFIGS, *SHARED_CONFIGS)
|
|
60
|
+
DEFAULT_DURABLE_ARTIFACTS: Final = (
|
|
61
|
+
"**/README.md",
|
|
62
|
+
"docs/**",
|
|
63
|
+
"**/docs/**",
|
|
64
|
+
"architecture/**",
|
|
65
|
+
"**/adr/**",
|
|
66
|
+
".github/**",
|
|
67
|
+
"**/AGENTS.md",
|
|
68
|
+
"**/CLAUDE.md",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ExclusionOverride:
|
|
74
|
+
paths: tuple[str, ...]
|
|
75
|
+
rules: tuple[str, ...]
|
|
76
|
+
reason: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Manifest:
|
|
81
|
+
version: str
|
|
82
|
+
configs: tuple[str, ...]
|
|
83
|
+
python_dest: str
|
|
84
|
+
typescript_dest: str
|
|
85
|
+
profile: Profile = "standard"
|
|
86
|
+
verify_paths: tuple[str, ...] = (".",)
|
|
87
|
+
hook_manager: HookManager = "pre-commit"
|
|
88
|
+
disabled_capabilities: tuple[str, ...] = dataclass_field(default=(), compare=False)
|
|
89
|
+
excluded_paths: tuple[str, ...] = ()
|
|
90
|
+
excluded_rules: tuple[str, ...] = ()
|
|
91
|
+
exclusion_overrides: tuple[ExclusionOverride, ...] = ()
|
|
92
|
+
durable_artifacts: tuple[str, ...] = DEFAULT_DURABLE_ARTIFACTS
|
|
93
|
+
text_excluded_paths: tuple[str, ...] = ()
|
|
94
|
+
doctor_excluded_paths: tuple[str, ...] = ()
|
|
95
|
+
diagnostic_baseline: str | None = None
|
|
96
|
+
ci_bootstrap: tuple[str, ...] = ()
|
|
97
|
+
|
|
98
|
+
def render(self) -> str:
|
|
99
|
+
disabled = tuple(name for name in ALL_CONFIGS if name not in self.configs)
|
|
100
|
+
disabled_text = ", ".join(f'"{name}"' for name in disabled)
|
|
101
|
+
durable_text = ", ".join(json.dumps(value) for value in self.durable_artifacts)
|
|
102
|
+
sections = [
|
|
103
|
+
(
|
|
104
|
+
"# Managed by `code-standards setup`; commit this file.\n"
|
|
105
|
+
f"schema = {MANIFEST_SCHEMA}\n"
|
|
106
|
+
f'bundle = "{self.version}"\n'
|
|
107
|
+
f'profile = "{self.profile}"\n'
|
|
108
|
+
'rule_profile = "all"\n'
|
|
109
|
+
"\n"
|
|
110
|
+
"[capabilities]\n"
|
|
111
|
+
f"disable = [{disabled_text}]\n"
|
|
112
|
+
"\n"
|
|
113
|
+
"[artifacts]\n"
|
|
114
|
+
f"durable = [{durable_text}]\n"
|
|
115
|
+
"\n"
|
|
116
|
+
"[dest]\n"
|
|
117
|
+
f'python = "{self.python_dest}"\n'
|
|
118
|
+
f'typescript = "{self.typescript_dest}"\n'
|
|
119
|
+
"\n"
|
|
120
|
+
"[hooks]\n"
|
|
121
|
+
f'manager = "{self.hook_manager}"\n'
|
|
122
|
+
)
|
|
123
|
+
]
|
|
124
|
+
if self.verify_paths != (".",):
|
|
125
|
+
paths = ", ".join(json.dumps(value) for value in self.verify_paths)
|
|
126
|
+
sections.append(f"\n[verify]\npaths = [{paths}]\n")
|
|
127
|
+
if self.excluded_paths or self.excluded_rules:
|
|
128
|
+
paths = ", ".join(json.dumps(value) for value in self.excluded_paths)
|
|
129
|
+
rules = ", ".join(json.dumps(value) for value in self.excluded_rules)
|
|
130
|
+
sections.append(f"\n[exclude]\npaths = [{paths}]\nrules = [{rules}]\n")
|
|
131
|
+
for override in self.exclusion_overrides:
|
|
132
|
+
paths = ", ".join(json.dumps(value) for value in override.paths)
|
|
133
|
+
rules = ", ".join(json.dumps(value) for value in override.rules)
|
|
134
|
+
sections.append(
|
|
135
|
+
f"\n[[exclude.overrides]]\npaths = [{paths}]\nrules = [{rules}]\nreason = {json.dumps(override.reason)}\n"
|
|
136
|
+
)
|
|
137
|
+
if self.text_excluded_paths:
|
|
138
|
+
paths = ", ".join(json.dumps(value) for value in self.text_excluded_paths)
|
|
139
|
+
sections.append(f"\n[text]\nexclude = [{paths}]\n")
|
|
140
|
+
if self.doctor_excluded_paths:
|
|
141
|
+
paths = ", ".join(json.dumps(value) for value in self.doctor_excluded_paths)
|
|
142
|
+
sections.append(f"\n[doctor]\nexclude = [{paths}]\n")
|
|
143
|
+
if self.diagnostic_baseline is not None:
|
|
144
|
+
sections.append(f"\n[baseline]\ndiagnostics = {json.dumps(self.diagnostic_baseline)}\n")
|
|
145
|
+
if self.ci_bootstrap:
|
|
146
|
+
commands = ", ".join(json.dumps(command) for command in self.ci_bootstrap)
|
|
147
|
+
sections.append(f"\n[ci]\nbootstrap = [{commands}]\n")
|
|
148
|
+
return "".join(sections)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def as_table(value: object) -> dict[str, object]:
|
|
152
|
+
if not isinstance(value, dict):
|
|
153
|
+
return {}
|
|
154
|
+
# Centralize untyped-parser narrowing so downstream tables stay typed.
|
|
155
|
+
entries: dict[str, object] = {}
|
|
156
|
+
for key, item in value.items(): # pyright: ignore[reportUnknownVariableType]
|
|
157
|
+
if isinstance(key, str):
|
|
158
|
+
entries[key] = item # ruff: ignore[manual-dict-comprehension] - pyright needs narrowing
|
|
159
|
+
return entries
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def text_field(table: Mapping[str, object], key: str) -> str | None:
|
|
163
|
+
value = table.get(key)
|
|
164
|
+
return value if isinstance(value, str) else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def list_field(table: Mapping[str, object], key: str) -> list[object]:
|
|
168
|
+
value = table.get(key)
|
|
169
|
+
return value if isinstance(value, list) else [] # pyright: ignore[reportUnknownVariableType] — a narrowed `list` from an untyped parser has Unknown leaves
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def table_field(table: Mapping[str, object], key: str) -> dict[str, object]:
|
|
173
|
+
return as_table(table.get(key))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def default_configs(*, has_python: bool, has_typescript: bool) -> tuple[str, ...]:
|
|
177
|
+
selected: set[str] = set(SHARED_CONFIGS)
|
|
178
|
+
if has_python:
|
|
179
|
+
selected.update(PYTHON_CONFIGS)
|
|
180
|
+
if has_typescript:
|
|
181
|
+
selected.update(TYPESCRIPT_CONFIGS)
|
|
182
|
+
order = (*PYTHON_CONFIGS, *TYPESCRIPT_CONFIGS, *SHARED_CONFIGS)
|
|
183
|
+
return tuple(name for name in order if name in selected)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def manifest_path(root: Path) -> Path:
|
|
187
|
+
return root / MANIFEST_NAME
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load(root: Path) -> Manifest | None: # ruff: ignore[too-many-locals] - one validation boundary keeps manifest errors coherent.
|
|
191
|
+
path = manifest_path(root)
|
|
192
|
+
if not path.is_file():
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
parsed: object = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
196
|
+
except tomllib.TOMLDecodeError as exc:
|
|
197
|
+
msg = f"{path} is not valid TOML: {exc}"
|
|
198
|
+
raise ValueError(msg) from exc
|
|
199
|
+
|
|
200
|
+
data = as_table(parsed)
|
|
201
|
+
raw_schema = data.get("schema")
|
|
202
|
+
if type(raw_schema) is not int or raw_schema != MANIFEST_SCHEMA:
|
|
203
|
+
msg = f"{path} `schema` must equal {MANIFEST_SCHEMA}"
|
|
204
|
+
raise ValueError(msg)
|
|
205
|
+
legacy_fields = tuple(field for field in ("version", "configs", "gradual") if field in data)
|
|
206
|
+
if legacy_fields:
|
|
207
|
+
msg = f"{path} uses removed manifest fields: {', '.join(legacy_fields)}"
|
|
208
|
+
raise ValueError(msg)
|
|
209
|
+
declared = text_field(data, "bundle")
|
|
210
|
+
if data.get("rule_profile", "all") != "all":
|
|
211
|
+
msg = f"{path} `rule_profile` currently supports only: all"
|
|
212
|
+
raise ValueError(msg)
|
|
213
|
+
raw_profile = data.get("profile", "standard")
|
|
214
|
+
capabilities_table = _manifest_table(data, "capabilities")
|
|
215
|
+
disabled = _string_list(capabilities_table, "disable", label="manifest [capabilities].disable")
|
|
216
|
+
unknown_capabilities = sorted(set(disabled) - set(ALL_CONFIGS))
|
|
217
|
+
if unknown_capabilities:
|
|
218
|
+
msg = f"manifest disables unknown capabilities: {', '.join(unknown_capabilities)}"
|
|
219
|
+
raise ValueError(msg)
|
|
220
|
+
names = [name for name in ALL_CONFIGS if name not in disabled]
|
|
221
|
+
if declared is None:
|
|
222
|
+
msg = f"{path} must set a string `bundle` declaration"
|
|
223
|
+
raise TypeError(msg)
|
|
224
|
+
try:
|
|
225
|
+
Version(declared)
|
|
226
|
+
except InvalidVersion as exc:
|
|
227
|
+
msg = f"{path} `bundle` must be a valid PEP 440 version"
|
|
228
|
+
raise ValueError(msg) from exc
|
|
229
|
+
if not isinstance(raw_profile, str) or raw_profile not in PROFILES:
|
|
230
|
+
msg = f"{path} `profile` must be one of: {', '.join(PROFILES)}"
|
|
231
|
+
raise ValueError(msg)
|
|
232
|
+
profile: Profile = raw_profile
|
|
233
|
+
|
|
234
|
+
dest_table = _manifest_table(data, "dest")
|
|
235
|
+
verify_table = _manifest_table(data, "verify")
|
|
236
|
+
hooks_table = _manifest_table(data, "hooks")
|
|
237
|
+
exclude_table = _manifest_table(data, "exclude")
|
|
238
|
+
artifacts_table = _manifest_table(data, "artifacts")
|
|
239
|
+
text_table = _manifest_table(data, "text")
|
|
240
|
+
doctor_table = _manifest_table(data, "doctor")
|
|
241
|
+
baseline_table = _manifest_table(data, "baseline")
|
|
242
|
+
ci_table = _manifest_table(data, "ci")
|
|
243
|
+
raw_hook_manager = hooks_table.get("manager", "pre-commit")
|
|
244
|
+
if not isinstance(raw_hook_manager, str) or raw_hook_manager not in HOOK_MANAGERS:
|
|
245
|
+
msg = f"manifest [hooks].manager must be one of: {', '.join(HOOK_MANAGERS)}"
|
|
246
|
+
raise ValueError(msg)
|
|
247
|
+
hook_manager: HookManager = raw_hook_manager
|
|
248
|
+
return Manifest(
|
|
249
|
+
version=declared,
|
|
250
|
+
configs=tuple(names),
|
|
251
|
+
python_dest=_dest_value(dest_table, "python"),
|
|
252
|
+
typescript_dest=_dest_value(dest_table, "typescript"),
|
|
253
|
+
profile=profile,
|
|
254
|
+
verify_paths=_verify_paths(root, verify_table),
|
|
255
|
+
hook_manager=hook_manager,
|
|
256
|
+
disabled_capabilities=disabled,
|
|
257
|
+
excluded_paths=_path_patterns(root, exclude_table, "paths"),
|
|
258
|
+
excluded_rules=_rule_selectors(exclude_table, "rules"),
|
|
259
|
+
exclusion_overrides=_exclusion_overrides(root, exclude_table),
|
|
260
|
+
durable_artifacts=_string_list(
|
|
261
|
+
artifacts_table,
|
|
262
|
+
"durable",
|
|
263
|
+
label="manifest [artifacts].durable",
|
|
264
|
+
default=DEFAULT_DURABLE_ARTIFACTS,
|
|
265
|
+
),
|
|
266
|
+
text_excluded_paths=_string_list(text_table, "exclude", label="manifest [text].exclude"),
|
|
267
|
+
doctor_excluded_paths=_string_list(doctor_table, "exclude", label="manifest [doctor].exclude"),
|
|
268
|
+
diagnostic_baseline=_relative_file(root, baseline_table, "diagnostics"),
|
|
269
|
+
ci_bootstrap=_ci_bootstrap(ci_table),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _manifest_table(data: Mapping[str, object], key: str) -> dict[str, object]:
|
|
274
|
+
value = data.get(key)
|
|
275
|
+
if value is None:
|
|
276
|
+
return {}
|
|
277
|
+
if not isinstance(value, dict):
|
|
278
|
+
msg = f"manifest [{key}] must be a table"
|
|
279
|
+
raise TypeError(msg)
|
|
280
|
+
return as_table(value) # pyright: ignore[reportUnknownArgumentType] -- runtime dict narrowed above; as_table validates keys
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def load_for_setup(root: Path) -> Manifest | None:
|
|
284
|
+
try:
|
|
285
|
+
return load(root)
|
|
286
|
+
except ValueError:
|
|
287
|
+
legacy = _load_schema_less_manifest(root)
|
|
288
|
+
if legacy is None:
|
|
289
|
+
raise
|
|
290
|
+
return legacy
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _load_schema_less_manifest( # ruff: ignore[too-many-locals] -- validate the complete legacy policy atomically.
|
|
294
|
+
root: Path,
|
|
295
|
+
) -> Manifest | None:
|
|
296
|
+
path = manifest_path(root)
|
|
297
|
+
try:
|
|
298
|
+
parsed: object = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
299
|
+
except OSError, tomllib.TOMLDecodeError:
|
|
300
|
+
return None
|
|
301
|
+
data = as_table(parsed)
|
|
302
|
+
if "schema" in data or "version" not in data or "configs" not in data:
|
|
303
|
+
return None
|
|
304
|
+
declared = text_field(data, "version")
|
|
305
|
+
raw_configs = list_field(data, "configs")
|
|
306
|
+
if declared is None or not all(isinstance(item, str) and item in ALL_CONFIGS for item in raw_configs):
|
|
307
|
+
return None
|
|
308
|
+
try:
|
|
309
|
+
Version(declared)
|
|
310
|
+
except InvalidVersion:
|
|
311
|
+
return None
|
|
312
|
+
raw_profile = data.get("profile", "standard")
|
|
313
|
+
if not isinstance(raw_profile, str) or raw_profile not in PROFILES:
|
|
314
|
+
return None
|
|
315
|
+
profile: Profile = raw_profile
|
|
316
|
+
dest = table_field(data, "dest")
|
|
317
|
+
try:
|
|
318
|
+
python_dest = _dest_value(dest, "python")
|
|
319
|
+
typescript_dest = _dest_value(dest, "typescript")
|
|
320
|
+
except TypeError:
|
|
321
|
+
return None
|
|
322
|
+
gradual_table = table_field(data, "gradual")
|
|
323
|
+
if "python_baseline" in gradual_table:
|
|
324
|
+
_ = _relative_file(root, gradual_table, "python_baseline")
|
|
325
|
+
msg = (
|
|
326
|
+
"cannot losslessly migrate legacy [gradual].python_baseline to the fingerprint-based "
|
|
327
|
+
"[baseline].diagnostics format; preserve the legacy manifest and replace or retire its baseline "
|
|
328
|
+
"before rerunning setup"
|
|
329
|
+
)
|
|
330
|
+
raise ValueError(msg)
|
|
331
|
+
verify_table = table_field(data, "verify")
|
|
332
|
+
hooks_table = table_field(data, "hooks")
|
|
333
|
+
exclude_table = table_field(data, "exclude")
|
|
334
|
+
raw_hook_manager = hooks_table.get("manager", "pre-commit")
|
|
335
|
+
if not isinstance(raw_hook_manager, str) or raw_hook_manager not in HOOK_MANAGERS:
|
|
336
|
+
msg = f"manifest [hooks].manager must be one of: {', '.join(HOOK_MANAGERS)}"
|
|
337
|
+
raise ValueError(msg)
|
|
338
|
+
hook_manager: HookManager = raw_hook_manager
|
|
339
|
+
return Manifest(
|
|
340
|
+
version=declared,
|
|
341
|
+
configs=tuple(dict.fromkeys(item for item in raw_configs if isinstance(item, str))),
|
|
342
|
+
python_dest=python_dest,
|
|
343
|
+
typescript_dest=typescript_dest,
|
|
344
|
+
profile=profile,
|
|
345
|
+
verify_paths=_verify_paths(root, verify_table),
|
|
346
|
+
hook_manager=hook_manager,
|
|
347
|
+
excluded_paths=_path_patterns(root, exclude_table, "paths"),
|
|
348
|
+
excluded_rules=_rule_selectors(exclude_table, "rules"),
|
|
349
|
+
exclusion_overrides=_exclusion_overrides(root, exclude_table),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _applicable_configs(root: Path) -> tuple[str, ...]:
|
|
354
|
+
has_python = any((root / name).is_file() for name in ("pyproject.toml", "uv.lock", "poetry.lock", "pdm.lock"))
|
|
355
|
+
has_typescript = any(
|
|
356
|
+
path.is_file()
|
|
357
|
+
for path in (
|
|
358
|
+
root / "package.json",
|
|
359
|
+
root / "package-lock.json",
|
|
360
|
+
root / "pnpm-lock.yaml",
|
|
361
|
+
root / "yarn.lock",
|
|
362
|
+
root / "bun.lock",
|
|
363
|
+
root / "bun.lockb",
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
return default_configs(has_python=has_python, has_typescript=has_typescript)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _string_list(
|
|
370
|
+
table: Mapping[str, object],
|
|
371
|
+
key: str,
|
|
372
|
+
*,
|
|
373
|
+
label: str,
|
|
374
|
+
default: tuple[str, ...] = (),
|
|
375
|
+
) -> tuple[str, ...]:
|
|
376
|
+
if key not in table:
|
|
377
|
+
return default
|
|
378
|
+
values = list_field(table, key)
|
|
379
|
+
if not all(type(value) is str and value for value in values):
|
|
380
|
+
msg = f"{label} must contain only non-empty strings"
|
|
381
|
+
raise TypeError(msg)
|
|
382
|
+
return tuple(dict.fromkeys(value for value in values if isinstance(value, str)))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _ci_bootstrap(table: Mapping[str, object]) -> tuple[str, ...]:
|
|
386
|
+
commands = _string_list(table, "bootstrap", label="manifest [ci].bootstrap")
|
|
387
|
+
if any(command != command.strip() or "\n" in command or "\r" in command for command in commands):
|
|
388
|
+
msg = "manifest [ci].bootstrap commands must be trimmed single-line strings"
|
|
389
|
+
raise ValueError(msg)
|
|
390
|
+
return commands
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _path_patterns(root: Path, table: Mapping[str, object], key: str) -> tuple[str, ...]:
|
|
394
|
+
patterns = _string_list(table, key, label=f"manifest [exclude].{key}")
|
|
395
|
+
return tuple(validate_excluded_path(root, pattern) for pattern in patterns)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _rule_selectors(table: Mapping[str, object], key: str) -> tuple[str, ...]:
|
|
399
|
+
selectors = _string_list(table, key, label=f"manifest [exclude].{key}")
|
|
400
|
+
return tuple(validate_excluded_rule(selector) for selector in selectors)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def validate_excluded_path(root: Path, pattern: str) -> str:
|
|
404
|
+
normalized = pattern.replace("\\", "/")
|
|
405
|
+
if normalized.startswith(("/", "!")) or ".." in normalized.split("/"):
|
|
406
|
+
msg = f"manifest exclusion pattern must be a repository-relative denylist pattern: {pattern}"
|
|
407
|
+
raise ValueError(msg)
|
|
408
|
+
if normalized in {".", "*", "**", "**/*"}:
|
|
409
|
+
msg = f"manifest exclusion pattern is too broad: {pattern}"
|
|
410
|
+
raise ValueError(msg)
|
|
411
|
+
if normalized in {MANIFEST_NAME, f"**/{MANIFEST_NAME}"}:
|
|
412
|
+
msg = "manifest exclusion cannot hide the Standards manifest"
|
|
413
|
+
raise ValueError(msg)
|
|
414
|
+
_ = root
|
|
415
|
+
return normalized
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def validate_excluded_rule(selector: str) -> str:
|
|
419
|
+
engine, separator, rule = selector.partition(":")
|
|
420
|
+
if (
|
|
421
|
+
not separator
|
|
422
|
+
or engine not in {"ruff", "basedpyright", "eslint", "python", "sql", "iac", "text"}
|
|
423
|
+
or not rule
|
|
424
|
+
or rule != rule.strip()
|
|
425
|
+
):
|
|
426
|
+
msg = f"manifest rule exclusion must use a canonical engine:rule selector: {selector}"
|
|
427
|
+
raise ValueError(msg)
|
|
428
|
+
_validate_known_rule(engine, rule, selector)
|
|
429
|
+
return selector
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _validate_known_rule(engine: str, rule: str, selector: str) -> None:
|
|
433
|
+
from sarj_standards.libs.linting import ( # ruff: ignore[import-outside-top-level] -- avoid a manifest/policy import cycle.
|
|
434
|
+
library_policy,
|
|
435
|
+
)
|
|
436
|
+
from sarj_standards.libs.repository import ( # ruff: ignore[import-outside-top-level] -- avoid a manifest/ledger import cycle.
|
|
437
|
+
ledger,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
shipped = ledger.load()
|
|
441
|
+
if engine in _SARJ_RULE_ENGINES:
|
|
442
|
+
known = frozenset((*shipped.rules.get(engine, ()), *shipped.codes.get(engine, ())))
|
|
443
|
+
if engine == "python":
|
|
444
|
+
known |= frozenset(item.id for item in library_policy.CATALOG)
|
|
445
|
+
if rule not in known:
|
|
446
|
+
msg = f"unknown Standards rule exclusion: {selector}"
|
|
447
|
+
raise ValueError(msg)
|
|
448
|
+
return
|
|
449
|
+
if engine != "eslint":
|
|
450
|
+
return
|
|
451
|
+
if rule.startswith("@sarj/"):
|
|
452
|
+
known = frozenset(f"@sarj/{name}" for name in shipped.rules.get(ledger.ESLINT, ()))
|
|
453
|
+
elif "/" not in rule:
|
|
454
|
+
config = (CONFIGS_DIR / "eslint.strict.mjs").read_text(encoding="utf-8")
|
|
455
|
+
known = frozenset(match.group("rule") for match in _ESLINT_RULE_KEY.finditer(config))
|
|
456
|
+
else:
|
|
457
|
+
return
|
|
458
|
+
if rule not in known:
|
|
459
|
+
msg = f"unknown Standards rule exclusion: {selector}"
|
|
460
|
+
raise ValueError(msg)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _exclusion_overrides(root: Path, table: Mapping[str, object]) -> tuple[ExclusionOverride, ...]:
|
|
464
|
+
values = list_field(table, "overrides")
|
|
465
|
+
overrides: list[ExclusionOverride] = []
|
|
466
|
+
for value in values:
|
|
467
|
+
item = as_table(value)
|
|
468
|
+
paths = _path_patterns(root, item, "paths")
|
|
469
|
+
rules = _rule_selectors(item, "rules")
|
|
470
|
+
reason = text_field(item, "reason")
|
|
471
|
+
if not paths or not rules or reason is None or not reason.strip():
|
|
472
|
+
msg = "each [[exclude.overrides]] entry must set non-empty paths, rules, and reason"
|
|
473
|
+
raise ValueError(msg)
|
|
474
|
+
overrides.append(ExclusionOverride(paths, rules, reason))
|
|
475
|
+
return tuple(overrides)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _dest_value(table: dict[str, object], key: str) -> str:
|
|
479
|
+
if key not in table:
|
|
480
|
+
return "."
|
|
481
|
+
value = table[key]
|
|
482
|
+
if not isinstance(value, str) or not value:
|
|
483
|
+
msg = f"manifest [dest].{key} must be a non-empty string"
|
|
484
|
+
raise TypeError(msg)
|
|
485
|
+
return value
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _verify_paths(root: Path, table: dict[str, object]) -> tuple[str, ...]:
|
|
489
|
+
if "paths" not in table:
|
|
490
|
+
return (".",)
|
|
491
|
+
values = list_field(table, "paths")
|
|
492
|
+
if not values or not all(isinstance(value, str) and value for value in values):
|
|
493
|
+
msg = "manifest [verify].paths must be a non-empty list of non-empty strings"
|
|
494
|
+
raise TypeError(msg)
|
|
495
|
+
paths = tuple(value for value in values if isinstance(value, str))
|
|
496
|
+
for value in paths:
|
|
497
|
+
try:
|
|
498
|
+
(root / value).resolve().relative_to(root.resolve())
|
|
499
|
+
except ValueError as exc:
|
|
500
|
+
msg = f"manifest [verify].paths entry escapes repository root: {value}"
|
|
501
|
+
raise ValueError(msg) from exc
|
|
502
|
+
return paths
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _relative_file(root: Path, table: Mapping[str, object], key: str) -> str | None:
|
|
506
|
+
if key not in table:
|
|
507
|
+
return None
|
|
508
|
+
value = text_field(table, key)
|
|
509
|
+
if value is None or not value.strip():
|
|
510
|
+
msg = f"manifest [baseline].{key} must be a non-empty repository-relative path"
|
|
511
|
+
raise TypeError(msg)
|
|
512
|
+
normalized = value.replace("\\", "/")
|
|
513
|
+
path = Path(normalized)
|
|
514
|
+
if path.is_absolute() or ".." in path.parts or path.suffix != ".json":
|
|
515
|
+
msg = f"manifest [baseline].{key} must be a repository-relative JSON path: {value}"
|
|
516
|
+
raise ValueError(msg)
|
|
517
|
+
try:
|
|
518
|
+
(root / path).resolve().relative_to(root.resolve())
|
|
519
|
+
except ValueError as exc:
|
|
520
|
+
msg = f"manifest [baseline].{key} escapes repository root: {value}"
|
|
521
|
+
raise ValueError(msg) from exc
|
|
522
|
+
return path.as_posix()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def installed_versions() -> dict[str, str]:
|
|
526
|
+
found = {LINT_CONFIGS: adopted_version()}
|
|
527
|
+
for name in SIBLING_PACKAGES:
|
|
528
|
+
try:
|
|
529
|
+
found[name] = version(name)
|
|
530
|
+
except PackageNotFoundError:
|
|
531
|
+
continue
|
|
532
|
+
return found
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def eslint_peers() -> dict[str, str]:
|
|
536
|
+
parsed: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
|
|
537
|
+
PEERS_JSON.read_text(encoding="utf-8")
|
|
538
|
+
)
|
|
539
|
+
table = table_field(as_table(parsed), "peers")
|
|
540
|
+
if not table:
|
|
541
|
+
msg = f"{PEERS_JSON} must contain a `peers` object"
|
|
542
|
+
raise TypeError(msg)
|
|
543
|
+
return {name: pin for name, pin in table.items() if isinstance(pin, str)}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def eslint_overrides() -> dict[str, object]:
|
|
547
|
+
parsed: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
|
|
548
|
+
PEERS_JSON.read_text(encoding="utf-8")
|
|
549
|
+
)
|
|
550
|
+
return table_field(as_table(parsed), "npmOverrides")
|