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,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import TYPE_CHECKING, Final, Self
|
|
9
|
+
|
|
10
|
+
from pathspec import PathSpec
|
|
11
|
+
from pathspec.pattern import Pattern
|
|
12
|
+
|
|
13
|
+
from sarj_standards._meta import CONFIGS_DIR
|
|
14
|
+
from sarj_standards.libs.adoption.manifest import MANIFEST_NAME, ExclusionOverride, Manifest
|
|
15
|
+
from sarj_standards.libs.rules import RuleSelector
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Iterable, Mapping
|
|
20
|
+
|
|
21
|
+
from sarj_standards.libs.diagnostics import Diagnostic
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_SOURCE_ENGINES: Final[Mapping[str, str]] = MappingProxyType(
|
|
25
|
+
{
|
|
26
|
+
"sarj-python-lint": "python",
|
|
27
|
+
"sarj-sql-lint": "sql",
|
|
28
|
+
"sarj-iac-lint": "iac",
|
|
29
|
+
"sarj-text-lint": "text",
|
|
30
|
+
"sarj-library-policy": "python",
|
|
31
|
+
"ruff": "ruff",
|
|
32
|
+
"basedpyright": "basedpyright",
|
|
33
|
+
"eslint": "eslint",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
_NON_EXCLUDABLE_CODE: Final = "SARJ206"
|
|
37
|
+
_TERRAFORM_TEST_SUFFIXES: Final = (".tftest.hcl", ".tftest.json")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class _Override:
|
|
42
|
+
paths: PathSpec[Pattern]
|
|
43
|
+
rules: frozenset[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Policy:
|
|
48
|
+
root: Path
|
|
49
|
+
excluded_paths: PathSpec[Pattern]
|
|
50
|
+
excluded_rules: frozenset[str]
|
|
51
|
+
overrides: tuple[_Override, ...]
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_manifest(cls, root: Path, manifest: Manifest | None) -> Self:
|
|
55
|
+
resolved = root.resolve()
|
|
56
|
+
if manifest is None:
|
|
57
|
+
return cls(resolved, PathSpec.from_lines("gitignore", ()), frozenset(), ())
|
|
58
|
+
return cls(
|
|
59
|
+
resolved,
|
|
60
|
+
PathSpec.from_lines("gitignore", manifest.excluded_paths),
|
|
61
|
+
frozenset(manifest.excluded_rules),
|
|
62
|
+
tuple(_compile_override(item) for item in manifest.exclusion_overrides),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def corpus_from_manifest(cls, root: Path, manifest: Manifest | None) -> Self:
|
|
67
|
+
policy = cls.from_manifest(root, manifest)
|
|
68
|
+
return cls(policy.root, policy.excluded_paths, frozenset(), ())
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def observe_from_manifest(cls, root: Path, manifest: Manifest | None) -> Self:
|
|
72
|
+
resolved = root.resolve()
|
|
73
|
+
if manifest is None:
|
|
74
|
+
return cls(resolved, PathSpec.from_lines("gitignore", ()), frozenset(), ())
|
|
75
|
+
return cls(
|
|
76
|
+
resolved,
|
|
77
|
+
PathSpec.from_lines("gitignore", manifest.excluded_paths),
|
|
78
|
+
frozenset(manifest.excluded_rules),
|
|
79
|
+
tuple(_compile_override(item) for item in manifest.exclusion_overrides),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def allows_path(self, path: str | Path) -> bool:
|
|
83
|
+
relative = self.relative(path)
|
|
84
|
+
if relative == MANIFEST_NAME:
|
|
85
|
+
return True
|
|
86
|
+
if relative.casefold().endswith(_TERRAFORM_TEST_SUFFIXES):
|
|
87
|
+
return True
|
|
88
|
+
return not self.excluded_paths.match_file(relative)
|
|
89
|
+
|
|
90
|
+
def allows_rule(self, diagnostic: Diagnostic) -> bool:
|
|
91
|
+
if diagnostic.code == _NON_EXCLUDABLE_CODE:
|
|
92
|
+
return True
|
|
93
|
+
engine = _SOURCE_ENGINES.get(diagnostic.source, diagnostic.source)
|
|
94
|
+
selectors = frozenset(
|
|
95
|
+
{
|
|
96
|
+
f"{engine}:{diagnostic.code}",
|
|
97
|
+
f"{engine}:{diagnostic.rule_id}",
|
|
98
|
+
}
|
|
99
|
+
if diagnostic.rule_id is not None
|
|
100
|
+
else {f"{engine}:{diagnostic.code}"}
|
|
101
|
+
)
|
|
102
|
+
if selectors & self.excluded_rules:
|
|
103
|
+
return False
|
|
104
|
+
relative = diagnostic.location.path
|
|
105
|
+
return not any(
|
|
106
|
+
override.paths.match_file(relative) and selectors & override.rules for override in self.overrides
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def filter_paths(self, paths: Iterable[str]) -> tuple[str, ...]:
|
|
110
|
+
return tuple(path for path in paths if self.allows_path(path))
|
|
111
|
+
|
|
112
|
+
def filter_diagnostics(self, diagnostics: Iterable[Diagnostic]) -> tuple[Diagnostic, ...]:
|
|
113
|
+
return tuple(item for item in diagnostics if self.allows_path(item.location.path) and self.allows_rule(item))
|
|
114
|
+
|
|
115
|
+
def relative(self, path: str | Path) -> str:
|
|
116
|
+
candidate = Path(path)
|
|
117
|
+
resolved = (candidate if candidate.is_absolute() else self.root / candidate).resolve()
|
|
118
|
+
try:
|
|
119
|
+
return resolved.relative_to(self.root).as_posix()
|
|
120
|
+
except ValueError as exc:
|
|
121
|
+
msg = f"policy path is outside repository root: {path}"
|
|
122
|
+
raise ValueError(msg) from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _compile_override(value: ExclusionOverride) -> _Override:
|
|
126
|
+
return _Override(PathSpec.from_lines("gitignore", value.paths), frozenset(value.rules))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@lru_cache(maxsize=1)
|
|
130
|
+
def warning_selectors() -> frozenset[RuleSelector]:
|
|
131
|
+
payload: object = json.loads( # pyright: ignore[reportAny]
|
|
132
|
+
(CONFIGS_DIR / "rule-warning-levels.v1.json").read_text(encoding="utf-8")
|
|
133
|
+
)
|
|
134
|
+
if not isinstance(payload, dict):
|
|
135
|
+
msg = "invalid bundled warning-rule lifecycle"
|
|
136
|
+
raise TypeError(msg)
|
|
137
|
+
document: dict[str, object] = payload # pyright: ignore[reportUnknownVariableType]
|
|
138
|
+
if document.get("schemaVersion") != 1:
|
|
139
|
+
msg = "invalid bundled warning-rule lifecycle"
|
|
140
|
+
raise ValueError(msg)
|
|
141
|
+
rules_value = document.get("rules")
|
|
142
|
+
if not isinstance(rules_value, list):
|
|
143
|
+
msg = "invalid bundled warning-rule selectors"
|
|
144
|
+
raise TypeError(msg)
|
|
145
|
+
rules: list[object] = rules_value # pyright: ignore[reportUnknownVariableType]
|
|
146
|
+
if any(not isinstance(value, str) for value in rules):
|
|
147
|
+
msg = "invalid bundled warning-rule selectors"
|
|
148
|
+
raise TypeError(msg)
|
|
149
|
+
return frozenset(RuleSelector.parse(value) for value in rules if isinstance(value, str))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = ["Policy"]
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from itertools import pairwise
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import stat
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from sarj_standards.libs.filesystem import is_link_like
|
|
15
|
+
|
|
16
|
+
from . import textlint
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterable, Sequence
|
|
21
|
+
|
|
22
|
+
from .policy import Policy
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Rule(Protocol):
|
|
26
|
+
"""Registry value shape shared by the three standards linters."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class _CheckerModule(Protocol):
|
|
31
|
+
def main(self, argv: list[str]) -> int: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class _RegistryModule(Protocol):
|
|
36
|
+
REGISTRY: Mapping[str, type[_Rule]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _LoadedTool(NamedTuple):
|
|
40
|
+
checker: Callable[[list[str]], int]
|
|
41
|
+
registry: Mapping[str, type[_Rule]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Tool(StrEnum):
|
|
45
|
+
PYTHON = "python"
|
|
46
|
+
SQL = "sql"
|
|
47
|
+
IAC = "iac"
|
|
48
|
+
TEXT = "text"
|
|
49
|
+
TYPESCRIPT = "typescript"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SUFFIX_TO_TOOL = MappingProxyType(
|
|
53
|
+
{
|
|
54
|
+
".hcl": _Tool.IAC,
|
|
55
|
+
".py": _Tool.PYTHON,
|
|
56
|
+
".sql": _Tool.SQL,
|
|
57
|
+
".tf": _Tool.IAC,
|
|
58
|
+
".tfvars": _Tool.IAC,
|
|
59
|
+
".cjs": _Tool.TYPESCRIPT,
|
|
60
|
+
".cts": _Tool.TYPESCRIPT,
|
|
61
|
+
".js": _Tool.TYPESCRIPT,
|
|
62
|
+
".jsx": _Tool.TYPESCRIPT,
|
|
63
|
+
".mjs": _Tool.TYPESCRIPT,
|
|
64
|
+
".mts": _Tool.TYPESCRIPT,
|
|
65
|
+
".ts": _Tool.TYPESCRIPT,
|
|
66
|
+
".tsx": _Tool.TYPESCRIPT,
|
|
67
|
+
".yaml": _Tool.IAC,
|
|
68
|
+
".yml": _Tool.IAC,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
_TERRAFORM_TEST_SUFFIXES = (".tftest.hcl", ".tftest.json")
|
|
72
|
+
_IGNORED_DIRS = frozenset(
|
|
73
|
+
{
|
|
74
|
+
".build",
|
|
75
|
+
".astro",
|
|
76
|
+
".cache",
|
|
77
|
+
".coverage",
|
|
78
|
+
".gradle",
|
|
79
|
+
".hypothesis",
|
|
80
|
+
".git",
|
|
81
|
+
".mypy_cache",
|
|
82
|
+
".next",
|
|
83
|
+
".open-next",
|
|
84
|
+
".nox",
|
|
85
|
+
".nuxt",
|
|
86
|
+
".output",
|
|
87
|
+
".parcel-cache",
|
|
88
|
+
".playwright-mcp",
|
|
89
|
+
".pnpm-store",
|
|
90
|
+
".pytest_cache",
|
|
91
|
+
".pytype",
|
|
92
|
+
".ruff_cache",
|
|
93
|
+
".svelte-kit",
|
|
94
|
+
".terraform",
|
|
95
|
+
".turbo",
|
|
96
|
+
".uv-cache",
|
|
97
|
+
".venv",
|
|
98
|
+
".vite",
|
|
99
|
+
".wrangler",
|
|
100
|
+
".yarn",
|
|
101
|
+
".tox",
|
|
102
|
+
"__pycache__",
|
|
103
|
+
"__generated__",
|
|
104
|
+
"_backups",
|
|
105
|
+
"dist",
|
|
106
|
+
"build",
|
|
107
|
+
"cache",
|
|
108
|
+
"caches",
|
|
109
|
+
"coverage",
|
|
110
|
+
"htmlcov",
|
|
111
|
+
"generated",
|
|
112
|
+
"node_modules",
|
|
113
|
+
"out",
|
|
114
|
+
"storybook-static",
|
|
115
|
+
"target",
|
|
116
|
+
"vendor",
|
|
117
|
+
"venv",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
_MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024
|
|
121
|
+
_MAX_MARKDOWN_FILE_BYTES = 16 * 1024 * 1024
|
|
122
|
+
_IGNORED_DISCOVERED_FILES = frozenset({".env", ".env.mcp"})
|
|
123
|
+
_SKILL_ARTIFACT_ROOTS = frozenset({".agents", ".claude"})
|
|
124
|
+
_PYTHON_NOISE_RULES = frozenset(
|
|
125
|
+
{
|
|
126
|
+
"docstring-args-restate-signature",
|
|
127
|
+
"docstring-returns-restate-signature",
|
|
128
|
+
"duplicated-override-docstring",
|
|
129
|
+
"no-comment-cruft",
|
|
130
|
+
"no-long-comment",
|
|
131
|
+
"no-restated-comment",
|
|
132
|
+
"no-typed-doc-sections",
|
|
133
|
+
"no-unnecessary-docstring",
|
|
134
|
+
"no-vague-suppression-description",
|
|
135
|
+
"prefer-self-documenting-constant",
|
|
136
|
+
"redundant-class-docstring",
|
|
137
|
+
"redundant-docstring",
|
|
138
|
+
"redundant-module-docstring",
|
|
139
|
+
"restated-test-docstring",
|
|
140
|
+
"test-phase-label-comment",
|
|
141
|
+
"trailing-value-narration",
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
_SQL_NOISE_RULES = frozenset({"no-comment-cruft"})
|
|
145
|
+
_IAC_NOISE_RULES = frozenset({"no-comment-cruft", "no-restated-comment"})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class GroupedPaths:
|
|
150
|
+
python: list[str] = field(default_factory=list)
|
|
151
|
+
sql: list[str] = field(default_factory=list)
|
|
152
|
+
iac: list[str] = field(default_factory=list)
|
|
153
|
+
text: list[str] = field(default_factory=list)
|
|
154
|
+
typescript: list[str] = field(default_factory=list)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def run(
|
|
158
|
+
files: Sequence[str],
|
|
159
|
+
*,
|
|
160
|
+
noise_only: bool = False,
|
|
161
|
+
python_baseline: str | None = None,
|
|
162
|
+
policy: Policy | None = None,
|
|
163
|
+
) -> int:
|
|
164
|
+
grouped = group_paths(files, policy=policy)
|
|
165
|
+
statuses = [
|
|
166
|
+
_run_tool(
|
|
167
|
+
"sarj_python_lint",
|
|
168
|
+
grouped.python,
|
|
169
|
+
selected=_PYTHON_NOISE_RULES if noise_only else None,
|
|
170
|
+
extra_args=("--baseline", python_baseline) if python_baseline is not None else (),
|
|
171
|
+
),
|
|
172
|
+
_run_tool(
|
|
173
|
+
"sarj_sql_lint",
|
|
174
|
+
grouped.sql,
|
|
175
|
+
selected=_SQL_NOISE_RULES if noise_only else None,
|
|
176
|
+
),
|
|
177
|
+
_run_tool(
|
|
178
|
+
"sarj_iac_lint",
|
|
179
|
+
grouped.iac,
|
|
180
|
+
selected=_IAC_NOISE_RULES if noise_only else None,
|
|
181
|
+
),
|
|
182
|
+
]
|
|
183
|
+
if grouped.text:
|
|
184
|
+
statuses.append(textlint.run(grouped.text))
|
|
185
|
+
return max(statuses)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def create_python_baseline(files: Sequence[str], output: str, *, policy: Policy | None = None) -> int:
|
|
189
|
+
grouped = group_paths(files, policy=policy)
|
|
190
|
+
return _run_tool(
|
|
191
|
+
"sarj_python_lint",
|
|
192
|
+
grouped.python,
|
|
193
|
+
selected=None,
|
|
194
|
+
extra_args=("--update-baseline", output),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _run_tool(
|
|
199
|
+
package: str,
|
|
200
|
+
files: Sequence[str],
|
|
201
|
+
*,
|
|
202
|
+
selected: frozenset[str] | None,
|
|
203
|
+
extra_args: Sequence[str] = (),
|
|
204
|
+
) -> int:
|
|
205
|
+
if not files or selected == frozenset():
|
|
206
|
+
return 0
|
|
207
|
+
checker, registry = _load_tool(package)
|
|
208
|
+
if selected is not None:
|
|
209
|
+
registry = _select_rules(registry, selected)
|
|
210
|
+
return _run(checker, registry, files, extra_args=extra_args)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _select_rules(registry: Mapping[str, type[_Rule]], selected: frozenset[str]) -> dict[str, type[_Rule]]:
|
|
214
|
+
return {rule_id: rule for rule_id, rule in registry.items() if rule_id in selected}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _load_tool(
|
|
218
|
+
package: str,
|
|
219
|
+
) -> _LoadedTool:
|
|
220
|
+
checker_module = import_module(f"{package}.__main__")
|
|
221
|
+
registry_module = import_module(f"{package}.rules")
|
|
222
|
+
if not isinstance(checker_module, _CheckerModule) or not isinstance(registry_module, _RegistryModule):
|
|
223
|
+
msg = f"{package} does not expose the expected lint API"
|
|
224
|
+
raise TypeError(msg)
|
|
225
|
+
return _LoadedTool(checker_module.main, registry_module.REGISTRY)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def group_paths(files: Sequence[str], *, policy: Policy | None = None) -> GroupedPaths:
|
|
229
|
+
grouped = GroupedPaths()
|
|
230
|
+
inputs: list[tuple[str, Path, bool]] = []
|
|
231
|
+
for raw_path in files:
|
|
232
|
+
path = Path(raw_path)
|
|
233
|
+
if is_link_like(path):
|
|
234
|
+
msg = f"refusing symlink input: {raw_path}"
|
|
235
|
+
raise ValueError(msg)
|
|
236
|
+
if not path.exists():
|
|
237
|
+
msg = f"input does not exist: {raw_path}"
|
|
238
|
+
raise ValueError(msg)
|
|
239
|
+
if _is_skill_artifact(path):
|
|
240
|
+
continue
|
|
241
|
+
if path.is_file() and _owns_path(path):
|
|
242
|
+
if _is_conventionally_generated(path) or _has_generated_header(path):
|
|
243
|
+
continue
|
|
244
|
+
_validate_source_file(path, raw_path)
|
|
245
|
+
if policy is not None and not policy.allows_path(path):
|
|
246
|
+
continue
|
|
247
|
+
inputs.append((raw_path, path, path.is_dir()))
|
|
248
|
+
|
|
249
|
+
roots = _minimal_roots(path for _raw, path, is_directory in inputs if is_directory)
|
|
250
|
+
walked: set[Path] = set()
|
|
251
|
+
seen: set[Path] = set()
|
|
252
|
+
for raw_path, path, is_directory in inputs:
|
|
253
|
+
if is_directory:
|
|
254
|
+
key = _path_key(path)
|
|
255
|
+
if key in roots and key not in walked:
|
|
256
|
+
_route_directory(grouped, path, seen, policy=policy)
|
|
257
|
+
walked.add(key)
|
|
258
|
+
continue
|
|
259
|
+
_route_unique_path(grouped, path, raw_path, seen)
|
|
260
|
+
return grouped
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def accepts_hook_path(path: Path) -> bool:
|
|
264
|
+
return (
|
|
265
|
+
_owns_path(path)
|
|
266
|
+
and not _is_skill_artifact(path)
|
|
267
|
+
and not _is_conventionally_generated(path)
|
|
268
|
+
and not _has_generated_header(path)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _minimal_roots(paths: Iterable[Path]) -> frozenset[Path]:
|
|
273
|
+
resolved = sorted({_path_key(path) for path in paths}, key=lambda path: (len(path.parts), str(path)))
|
|
274
|
+
roots: list[Path] = []
|
|
275
|
+
for path in resolved:
|
|
276
|
+
if any(path.is_relative_to(root) for root in roots):
|
|
277
|
+
continue
|
|
278
|
+
roots.append(path)
|
|
279
|
+
return frozenset(roots)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _route_directory(grouped: GroupedPaths, path: Path, seen: set[Path], *, policy: Policy | None = None) -> None:
|
|
283
|
+
for root, dir_names, file_names in os.walk(path, topdown=True, followlinks=False):
|
|
284
|
+
base = Path(root)
|
|
285
|
+
dir_names[:] = sorted(
|
|
286
|
+
name
|
|
287
|
+
for name in dir_names
|
|
288
|
+
if name not in _IGNORED_DIRS and not (base.name in _SKILL_ARTIFACT_ROOTS and name == "skills")
|
|
289
|
+
)
|
|
290
|
+
for file_name in sorted(file_names):
|
|
291
|
+
if file_name in _IGNORED_DISCOVERED_FILES:
|
|
292
|
+
continue
|
|
293
|
+
child = Path(root, file_name)
|
|
294
|
+
if not _owns_path(child):
|
|
295
|
+
continue
|
|
296
|
+
if _is_conventionally_generated(child):
|
|
297
|
+
continue
|
|
298
|
+
if policy is not None and not policy.allows_path(child):
|
|
299
|
+
continue
|
|
300
|
+
if not _is_routable_discovered_file(child):
|
|
301
|
+
continue
|
|
302
|
+
_route_unique_path(grouped, child, str(child), seen)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _is_routable_discovered_file(path: Path) -> bool:
|
|
306
|
+
try:
|
|
307
|
+
metadata = path.stat(follow_symlinks=False)
|
|
308
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
309
|
+
return False
|
|
310
|
+
_validate_source_size(path, metadata.st_size)
|
|
311
|
+
except ValueError:
|
|
312
|
+
if _has_generated_header(path):
|
|
313
|
+
return False
|
|
314
|
+
raise
|
|
315
|
+
except OSError:
|
|
316
|
+
return False
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _validate_source_file(path: Path, display: str) -> None:
|
|
321
|
+
try:
|
|
322
|
+
metadata = path.stat(follow_symlinks=False)
|
|
323
|
+
except OSError as exc:
|
|
324
|
+
msg = f"cannot inspect source input: {display}"
|
|
325
|
+
raise ValueError(msg) from exc
|
|
326
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
327
|
+
msg = f"source input is not a regular file: {display}"
|
|
328
|
+
raise ValueError(msg)
|
|
329
|
+
_validate_source_size(path, metadata.st_size)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _validate_source_size(path: Path, size: int) -> None:
|
|
333
|
+
limit = _MAX_MARKDOWN_FILE_BYTES if path.suffix.lower() in {".md", ".mdx"} else _MAX_SOURCE_FILE_BYTES
|
|
334
|
+
if size > limit:
|
|
335
|
+
msg = f"source input exceeds the {limit // (1024 * 1024)} MiB analysis limit: {path}"
|
|
336
|
+
raise ValueError(msg)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _is_conventionally_generated(path: Path) -> bool:
|
|
340
|
+
name = path.name.lower()
|
|
341
|
+
return name.endswith((".gen.ts", ".gen.tsx", ".generated.ts", ".generated.tsx")) or name in {
|
|
342
|
+
"next-env.d.ts",
|
|
343
|
+
"worker-configuration.d.ts",
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _is_skill_artifact(path: Path) -> bool:
|
|
348
|
+
parts = path.parts
|
|
349
|
+
return any(root in _SKILL_ARTIFACT_ROOTS and child == "skills" for root, child in pairwise(parts))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _has_generated_header(path: Path) -> bool:
|
|
353
|
+
try:
|
|
354
|
+
with path.open("rb") as stream:
|
|
355
|
+
lines = stream.read(4096).lower().splitlines()
|
|
356
|
+
except OSError:
|
|
357
|
+
return False
|
|
358
|
+
comments: list[bytes] = []
|
|
359
|
+
in_block = False
|
|
360
|
+
for raw_line in lines[:12]:
|
|
361
|
+
line = raw_line.strip()
|
|
362
|
+
if not line:
|
|
363
|
+
continue
|
|
364
|
+
if in_block:
|
|
365
|
+
comments.append(line)
|
|
366
|
+
in_block = b"*/" not in line and b"-->" not in line
|
|
367
|
+
continue
|
|
368
|
+
if line.startswith((b"#", b"//", b"/*", b"<!--")):
|
|
369
|
+
comments.append(line)
|
|
370
|
+
in_block = (line.startswith(b"/*") and b"*/" not in line) or (
|
|
371
|
+
line.startswith(b"<!--") and b"-->" not in line
|
|
372
|
+
)
|
|
373
|
+
continue
|
|
374
|
+
break
|
|
375
|
+
header = b"\n".join(comments)
|
|
376
|
+
generated = b"auto-generated" in header or b"autogenerated" in header or b"@generated" in header
|
|
377
|
+
hands_off = b"do not edit" in header or b"do not hand-edit" in header or b"do not hand edit" in header
|
|
378
|
+
return generated and hands_off
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _owns_path(path: Path) -> bool:
|
|
382
|
+
return (
|
|
383
|
+
path.name.casefold().endswith(_TERRAFORM_TEST_SUFFIXES)
|
|
384
|
+
or path.suffix.lower() in _SUFFIX_TO_TOOL
|
|
385
|
+
or textlint.is_text_path(path)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _route_unique_path(grouped: GroupedPaths, path: Path, raw_path: str, seen: set[Path]) -> None:
|
|
390
|
+
key = _path_key(path)
|
|
391
|
+
if key in seen:
|
|
392
|
+
return
|
|
393
|
+
seen.add(key)
|
|
394
|
+
_route_path(grouped, path, raw_path)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _path_key(path: Path) -> Path:
|
|
398
|
+
# resolve() performs a filesystem lookup for every discovered file.
|
|
399
|
+
return Path(os.path.abspath(path)) # ruff: ignore[os-path-abspath]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _route_path(grouped: GroupedPaths, path: Path, raw_path: str) -> None:
|
|
403
|
+
tool = (
|
|
404
|
+
_Tool.IAC
|
|
405
|
+
if path.name.casefold().endswith(_TERRAFORM_TEST_SUFFIXES)
|
|
406
|
+
else _SUFFIX_TO_TOOL.get(path.suffix.lower())
|
|
407
|
+
)
|
|
408
|
+
_append_path(grouped, tool, raw_path)
|
|
409
|
+
if textlint.is_text_path(path):
|
|
410
|
+
grouped.text.append(raw_path)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _append_path(grouped: GroupedPaths, tool: _Tool | None, path: str) -> None:
|
|
414
|
+
match tool:
|
|
415
|
+
case _Tool.PYTHON:
|
|
416
|
+
grouped.python.append(path)
|
|
417
|
+
case _Tool.SQL:
|
|
418
|
+
grouped.sql.append(path)
|
|
419
|
+
case _Tool.IAC:
|
|
420
|
+
grouped.iac.append(path)
|
|
421
|
+
case _Tool.TEXT:
|
|
422
|
+
grouped.text.append(path)
|
|
423
|
+
case _Tool.TYPESCRIPT:
|
|
424
|
+
grouped.typescript.append(path)
|
|
425
|
+
case None:
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _run(
|
|
430
|
+
checker: Callable[[list[str]], int],
|
|
431
|
+
registry: Mapping[str, type[_Rule]],
|
|
432
|
+
files: Sequence[str],
|
|
433
|
+
*,
|
|
434
|
+
extra_args: Sequence[str] = (),
|
|
435
|
+
) -> int:
|
|
436
|
+
if not files or not registry:
|
|
437
|
+
return 0
|
|
438
|
+
return checker(["check", *_rule_args(registry), *extra_args, "--", *files])
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _rule_args(registry: Mapping[str, type[_Rule]]) -> list[str]:
|
|
442
|
+
return [part for rule_id in sorted(registry) for part in ("--rule", rule_id)]
|