syntopica-codeality-py 0.2.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.
- codeality_py/__init__.py +1 -0
- codeality_py/assets/codeality-py-ci.yml +51 -0
- codeality_py/assets/codeality-py.toml +19 -0
- codeality_py/assets/importlinter.ini +12 -0
- codeality_py/assets/mypy.ini +7 -0
- codeality_py/assets/pyproject-sections.toml +34 -0
- codeality_py/assets/ruff.toml +51 -0
- codeality_py/baseline/__init__.py +1 -0
- codeality_py/baseline/baseline_file.py +15 -0
- codeality_py/baseline/classified_findings.py +14 -0
- codeality_py/baseline/classify_findings.py +18 -0
- codeality_py/baseline/finding_context.py +19 -0
- codeality_py/baseline/fingerprint.py +21 -0
- codeality_py/baseline/fingerprint_context.py +27 -0
- codeality_py/baseline/read_baseline.py +32 -0
- codeality_py/baseline/write_baseline.py +18 -0
- codeality_py/cli.py +22 -0
- codeality_py/commands/__init__.py +1 -0
- codeality_py/commands/baseline_check_command.py +36 -0
- codeality_py/commands/baseline_command.py +15 -0
- codeality_py/commands/baseline_update_command.py +24 -0
- codeality_py/commands/check_command.py +45 -0
- codeality_py/commands/checked_project.py +26 -0
- codeality_py/commands/gate_command.py +33 -0
- codeality_py/commands/init_command.py +53 -0
- codeality_py/commands/project_option.py +12 -0
- codeality_py/config/__init__.py +1 -0
- codeality_py/config/baseline_config.py +27 -0
- codeality_py/config/check_schema_version.py +16 -0
- codeality_py/config/config_error.py +5 -0
- codeality_py/config/default_generated_globs.py +10 -0
- codeality_py/config/discover_source_roots.py +30 -0
- codeality_py/config/discover_test_roots.py +12 -0
- codeality_py/config/limits.py +11 -0
- codeality_py/config/load_config.py +64 -0
- codeality_py/config/module_role.py +18 -0
- codeality_py/config/override.py +14 -0
- codeality_py/config/parse_overrides.py +32 -0
- codeality_py/config/parse_roles.py +33 -0
- codeality_py/config/parse_rule_codes.py +17 -0
- codeality_py/config/pyproject_layout_hints.py +36 -0
- codeality_py/config/read_config_document.py +17 -0
- codeality_py/config/reject_duplicate_role_claims.py +20 -0
- codeality_py/config/reject_unknown_keys.py +15 -0
- codeality_py/engine/__init__.py +1 -0
- codeality_py/engine/check_project.py +32 -0
- codeality_py/engine/check_result.py +15 -0
- codeality_py/engine/check_source_file.py +25 -0
- codeality_py/engine/fingerprint_findings.py +33 -0
- codeality_py/engine/rule_registry.py +24 -0
- codeality_py/gate/__init__.py +1 -0
- codeality_py/gate/build_stage_result.py +20 -0
- codeality_py/gate/gate_result.py +23 -0
- codeality_py/gate/gate_stages.py +50 -0
- codeality_py/gate/run_gate.py +27 -0
- codeality_py/gate/run_stage.py +52 -0
- codeality_py/gate/stage.py +14 -0
- codeality_py/gate/stage_kind.py +11 -0
- codeality_py/gate/stage_result.py +19 -0
- codeality_py/gate/stage_status.py +12 -0
- codeality_py/init/__init__.py +1 -0
- codeality_py/init/add_missing_rules.py +33 -0
- codeality_py/init/apply_init.py +19 -0
- codeality_py/init/current_branch.py +24 -0
- codeality_py/init/first_party_packages.py +23 -0
- codeality_py/init/has_ruff_table.py +21 -0
- codeality_py/init/managed_asset_files.py +9 -0
- codeality_py/init/managed_file.py +16 -0
- codeality_py/init/merge_pyproject_sections.py +35 -0
- codeality_py/init/merge_quality_group.py +14 -0
- codeality_py/init/merge_ruff_config.py +28 -0
- codeality_py/init/plan_asset_file.py +25 -0
- codeality_py/init/plan_disposition.py +12 -0
- codeality_py/init/plan_init.py +28 -0
- codeality_py/init/plan_pyproject.py +32 -0
- codeality_py/init/plan_ruff.py +21 -0
- codeality_py/init/plan_shadowed_ruff.py +28 -0
- codeality_py/init/python_floor_minor.py +15 -0
- codeality_py/init/read_asset.py +8 -0
- codeality_py/init/read_requires_python.py +16 -0
- codeality_py/init/render_asset.py +55 -0
- codeality_py/init/render_nested_workflow.py +25 -0
- codeality_py/init/repository_root.py +15 -0
- codeality_py/init/workflow_target.py +21 -0
- codeality_py/model/__init__.py +1 -0
- codeality_py/model/finding.py +34 -0
- codeality_py/model/location.py +14 -0
- codeality_py/model/rule_code.py +30 -0
- codeality_py/model/severity.py +10 -0
- codeality_py/parsing/__init__.py +1 -0
- codeality_py/parsing/infrastructure_error.py +5 -0
- codeality_py/parsing/parse_source_file.py +32 -0
- codeality_py/parsing/read_source_text.py +16 -0
- codeality_py/parsing/source_file.py +18 -0
- codeality_py/parsing/syntax_error_finding.py +16 -0
- codeality_py/report/__init__.py +1 -0
- codeality_py/report/exit_code.py +12 -0
- codeality_py/report/finding_document.py +24 -0
- codeality_py/report/findings_summary.py +11 -0
- codeality_py/report/render_gate_report.py +21 -0
- codeality_py/report/render_json_report.py +24 -0
- codeality_py/report/render_text_report.py +16 -0
- codeality_py/report/stage_document.py +16 -0
- codeality_py/roles/__init__.py +1 -0
- codeality_py/roles/assign_module_role.py +36 -0
- codeality_py/roles/compile_glob.py +17 -0
- codeality_py/roles/matches_any_pattern.py +8 -0
- codeality_py/roles/matches_glob.py +14 -0
- codeality_py/roles/test_file_patterns.py +18 -0
- codeality_py/rules/__init__.py +1 -0
- codeality_py/rules/barrel_only_init.py +38 -0
- codeality_py/rules/count_code_lines.py +29 -0
- codeality_py/rules/data_unit_findings.py +16 -0
- codeality_py/rules/docstring_line_spans.py +26 -0
- codeality_py/rules/file_matches_unit.py +33 -0
- codeality_py/rules/grab_bag_names.py +3 -0
- codeality_py/rules/is_allowed_barrel_statement.py +16 -0
- codeality_py/rules/is_column_list.py +15 -0
- codeality_py/rules/is_docstring_statement.py +12 -0
- codeality_py/rules/is_sql_literal.py +15 -0
- codeality_py/rules/is_static_dunder_assignment.py +24 -0
- codeality_py/rules/is_type_checking_block.py +15 -0
- codeality_py/rules/joined_string_text.py +12 -0
- codeality_py/rules/looks_like_sql.py +31 -0
- codeality_py/rules/max_file_lines.py +35 -0
- codeality_py/rules/no_grab_bag_names.py +36 -0
- codeality_py/rules/no_inline_sql.py +52 -0
- codeality_py/rules/one_primary_unit.py +22 -0
- codeality_py/rules/ordinary_unit_findings.py +26 -0
- codeality_py/rules/sql_subject.py +10 -0
- codeality_py/rules/unit_finding.py +23 -0
- codeality_py/suppression/__init__.py +1 -0
- codeality_py/suppression/apply_suppressions.py +31 -0
- codeality_py/suppression/build_suppression.py +15 -0
- codeality_py/suppression/covering_suppression.py +25 -0
- codeality_py/suppression/is_overridden.py +14 -0
- codeality_py/suppression/parse_suppression_line.py +25 -0
- codeality_py/suppression/parse_suppressions.py +17 -0
- codeality_py/suppression/suppression.py +14 -0
- codeality_py/traversal/__init__.py +1 -0
- codeality_py/traversal/collect_source_files.py +17 -0
- codeality_py/traversal/default_excludes.py +25 -0
- codeality_py/traversal/gitignore_matcher.py +44 -0
- codeality_py/traversal/walk_root.py +27 -0
- codeality_py/units/__init__.py +1 -0
- codeality_py/units/coalesce_overloads.py +17 -0
- codeality_py/units/coalesce_singledispatch.py +25 -0
- codeality_py/units/collect_declarations.py +15 -0
- codeality_py/units/count_primary_declarations.py +18 -0
- codeality_py/units/declaration.py +18 -0
- codeality_py/units/declaration_for.py +33 -0
- codeality_py/units/declaration_kind.py +11 -0
- codeality_py/units/decorator_name.py +14 -0
- codeality_py/units/decorator_names.py +12 -0
- codeality_py/units/is_main_guard.py +11 -0
- codeality_py/units/is_private_local_handler.py +13 -0
- codeality_py/units/legacy_type_alias.py +33 -0
- codeality_py/units/literal_string_names.py +15 -0
- codeality_py/units/merge_stray_letters.py +20 -0
- codeality_py/units/named_declaration.py +23 -0
- codeality_py/units/overload_family_length.py +22 -0
- codeality_py/units/resolve_exported_names.py +22 -0
- codeality_py/units/to_snake_case.py +20 -0
- codeality_py/units/top_level_statements.py +20 -0
- syntopica_codeality_py-0.2.0.dist-info/METADATA +96 -0
- syntopica_codeality_py-0.2.0.dist-info/RECORD +169 -0
- syntopica_codeality_py-0.2.0.dist-info/WHEEL +4 -0
- syntopica_codeality_py-0.2.0.dist-info/entry_points.txt +2 -0
- syntopica_codeality_py-0.2.0.dist-info/licenses/LICENSE +21 -0
codeality_py/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Structural linter and config scaffolder for Python projects."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Quality
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
gate:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ['3.11', '3.13']
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
|
23
|
+
with:
|
|
24
|
+
enable-cache: true
|
|
25
|
+
|
|
26
|
+
- name: Check the lockfile is current
|
|
27
|
+
run: uv lock --check
|
|
28
|
+
|
|
29
|
+
# Every extra and every group: the gate audits and tests the environment
|
|
30
|
+
# it runs in, and a group-only install leaves out the extras the tests
|
|
31
|
+
# import.
|
|
32
|
+
- name: Install from the lockfile
|
|
33
|
+
run: uv sync --locked --all-extras --all-groups
|
|
34
|
+
env:
|
|
35
|
+
UV_PYTHON: ${{ matrix.python-version }}
|
|
36
|
+
|
|
37
|
+
- name: Run the quality gate
|
|
38
|
+
run: uv run codeality-py gate --json
|
|
39
|
+
|
|
40
|
+
- name: Type-check with pyrefly, without blocking
|
|
41
|
+
continue-on-error: true
|
|
42
|
+
run: |
|
|
43
|
+
uv run --with pyrefly pyrefly check --output-format json > pyrefly.json || true
|
|
44
|
+
|
|
45
|
+
- name: Upload the pyrefly shadow report
|
|
46
|
+
if: always()
|
|
47
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
48
|
+
with:
|
|
49
|
+
name: pyrefly-shadow-${{ matrix.python-version }}
|
|
50
|
+
path: pyrefly.json
|
|
51
|
+
retention-days: 90
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# codeality-py configuration - https://github.com/syntopica/codeality
|
|
2
|
+
schema-version = 1
|
|
3
|
+
source-roots = ["src"]
|
|
4
|
+
test-roots = ["tests"]
|
|
5
|
+
respect-gitignore = true
|
|
6
|
+
coverage-threshold = 80
|
|
7
|
+
|
|
8
|
+
[limits]
|
|
9
|
+
max-file-lines = 150
|
|
10
|
+
test-max-file-lines = 300
|
|
11
|
+
|
|
12
|
+
# A role states what a file is. Prefer a role over a suppression: it explains
|
|
13
|
+
# the file instead of silencing an alarm.
|
|
14
|
+
[roles]
|
|
15
|
+
data = []
|
|
16
|
+
registry = []
|
|
17
|
+
entrypoint = []
|
|
18
|
+
generated = []
|
|
19
|
+
namespace-init = []
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
; Managed by codeality-py, scaffolded only with --profile app. Declare the real
|
|
2
|
+
; layers of the project; an empty contract enforces nothing.
|
|
3
|
+
[importlinter]
|
|
4
|
+
root_package = CHANGE_ME
|
|
5
|
+
|
|
6
|
+
[importlinter:contract:layers]
|
|
7
|
+
name = Layered architecture
|
|
8
|
+
type = layers
|
|
9
|
+
layers =
|
|
10
|
+
cli
|
|
11
|
+
services
|
|
12
|
+
domain
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Sections codeality-py merges into an existing pyproject.toml. It owns these
|
|
2
|
+
# tables and nothing else; comments and key order elsewhere are preserved.
|
|
3
|
+
|
|
4
|
+
# Without known_first_party, deptry reports every internal import as DEP003.
|
|
5
|
+
[tool.deptry]
|
|
6
|
+
known_first_party = ["CHANGE_ME"]
|
|
7
|
+
|
|
8
|
+
[tool.deptry.per_rule_ignores]
|
|
9
|
+
DEP002 = []
|
|
10
|
+
# Test tooling legitimately lives in a dev group and is imported only by tests.
|
|
11
|
+
DEP004 = ["pytest", "pytest_asyncio", "pytest_cov"]
|
|
12
|
+
|
|
13
|
+
[tool.pytest.ini_options]
|
|
14
|
+
addopts = "-ra"
|
|
15
|
+
testpaths = ["tests"]
|
|
16
|
+
|
|
17
|
+
[tool.coverage.run]
|
|
18
|
+
branch = true
|
|
19
|
+
|
|
20
|
+
[tool.coverage.report]
|
|
21
|
+
show_missing = true
|
|
22
|
+
skip_covered = true
|
|
23
|
+
fail_under = 80
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
quality = [
|
|
27
|
+
"syntopica-codeality-py>=0.1,<1",
|
|
28
|
+
"deptry>=0.20,<1",
|
|
29
|
+
"mypy>=1.11,<2",
|
|
30
|
+
"pip-audit>=2.7,<3",
|
|
31
|
+
"pytest>=8,<10",
|
|
32
|
+
"pytest-cov>=5,<8",
|
|
33
|
+
"ruff>=0.15,<1",
|
|
34
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Managed by codeality-py. Rules are listed explicitly: ALL expands silently on
|
|
2
|
+
# every ruff upgrade, turning a dependency bump into a lint failure.
|
|
3
|
+
line-length = 100
|
|
4
|
+
target-version = "py311"
|
|
5
|
+
|
|
6
|
+
[lint]
|
|
7
|
+
select = [
|
|
8
|
+
"E", # pycodestyle errors
|
|
9
|
+
"W", # pycodestyle warnings
|
|
10
|
+
"F", # pyflakes
|
|
11
|
+
"I", # import sorting
|
|
12
|
+
"N", # pep8-naming
|
|
13
|
+
"UP", # pyupgrade
|
|
14
|
+
"B", # bugbear
|
|
15
|
+
"A", # builtin shadowing
|
|
16
|
+
"C4", # comprehensions
|
|
17
|
+
"DTZ", # naive datetimes
|
|
18
|
+
"T20", # print in library code
|
|
19
|
+
"SIM", # simplifiable code
|
|
20
|
+
"RET", # return control flow
|
|
21
|
+
"ARG", # unused arguments
|
|
22
|
+
"PTH", # pathlib over os.path
|
|
23
|
+
"ERA", # commented-out code
|
|
24
|
+
"PL", # pylint: complexity, branches, statements
|
|
25
|
+
"RUF", # ruff-specific
|
|
26
|
+
"D", # docstrings; replaces interrogate
|
|
27
|
+
# bandit, curated: the full S set is intentionally noisy in tests and in
|
|
28
|
+
# subprocess-heavy code.
|
|
29
|
+
"S102", "S103", "S104", "S105", "S106", "S107", "S108",
|
|
30
|
+
"S301", "S302", "S303", "S304", "S305", "S306",
|
|
31
|
+
"S324", "S501", "S506", "S508", "S509",
|
|
32
|
+
"S608", "S609", "S612",
|
|
33
|
+
]
|
|
34
|
+
ignore = [
|
|
35
|
+
# Conflicts with the formatter; keeping these on fights ruff format forever.
|
|
36
|
+
"E501", "W191", "E111", "E114", "E117", "D206", "D300", "COM812", "ISC001",
|
|
37
|
+
"D203", # incompatible with D211
|
|
38
|
+
"D107", # an __init__ that stores its arguments has nothing to add to the class docstring
|
|
39
|
+
"D213", # incompatible with D212
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[lint.per-file-ignores]
|
|
43
|
+
# **/ so the pattern also reaches nested test roots such as host/tests.
|
|
44
|
+
"**/tests/**" = ["D", "S101", "S105", "S106", "S108", "ARG", "PLR"]
|
|
45
|
+
"**/conftest.py" = ["D"]
|
|
46
|
+
|
|
47
|
+
[lint.pydocstyle]
|
|
48
|
+
convention = "google"
|
|
49
|
+
|
|
50
|
+
[format]
|
|
51
|
+
docstring-code-format = true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Recording and classifying migration debt."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The recorded debt of one project."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
BASELINE_SCHEMA_VERSION = 1
|
|
6
|
+
BASELINE_FILENAME = ".codeality-py-baseline.json"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class BaselineFile:
|
|
11
|
+
"""Fingerprints of the findings a project has agreed to carry."""
|
|
12
|
+
|
|
13
|
+
entries: frozenset[str] = field(default_factory=frozenset)
|
|
14
|
+
schema_version: int = BASELINE_SCHEMA_VERSION
|
|
15
|
+
tool_version: str = ""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""How current findings relate to the recorded baseline."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from codeality_py.model.finding import Finding
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class ClassifiedFindings:
|
|
10
|
+
"""New findings block; known ones are tracked debt; resolved ones are stale."""
|
|
11
|
+
|
|
12
|
+
new: tuple[Finding, ...]
|
|
13
|
+
known: tuple[Finding, ...]
|
|
14
|
+
resolved: tuple[str, ...]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Split current findings into new, known and resolved."""
|
|
2
|
+
|
|
3
|
+
from codeality_py.baseline.baseline_file import BaselineFile
|
|
4
|
+
from codeality_py.baseline.classified_findings import ClassifiedFindings
|
|
5
|
+
from codeality_py.model.finding import Finding
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def classify_findings(findings: tuple[Finding, ...], baseline: BaselineFile) -> ClassifiedFindings:
|
|
9
|
+
"""Return the findings split against the baseline.
|
|
10
|
+
|
|
11
|
+
A baseline entry with no current finding is resolved, and reporting it is
|
|
12
|
+
what stops dead debt being carried forever.
|
|
13
|
+
"""
|
|
14
|
+
new = tuple(finding for finding in findings if finding.fingerprint not in baseline.entries)
|
|
15
|
+
known = tuple(finding for finding in findings if finding.fingerprint in baseline.entries)
|
|
16
|
+
current = {finding.fingerprint for finding in findings}
|
|
17
|
+
resolved = tuple(sorted(baseline.entries - current))
|
|
18
|
+
return ClassifiedFindings(new=new, known=known, resolved=resolved)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Describe what a finding is about, independently of where it sits."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def finding_context(text: str, line: int) -> str:
|
|
9
|
+
"""Return the normalised source of the offending line.
|
|
10
|
+
|
|
11
|
+
The line number itself must not enter a fingerprint - inserting a line
|
|
12
|
+
above a violation would make every finding below it look new. The content
|
|
13
|
+
of the offending line is stable under that edit and still tells two
|
|
14
|
+
different violations in one file apart.
|
|
15
|
+
"""
|
|
16
|
+
lines = text.splitlines()
|
|
17
|
+
if not 1 <= line <= len(lines):
|
|
18
|
+
return ""
|
|
19
|
+
return _WHITESPACE.sub(" ", lines[line - 1]).strip()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Identify a finding by what it is, not by where it sits."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
from codeality_py.model.finding import Finding
|
|
6
|
+
|
|
7
|
+
FINGERPRINT_VERSION = 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fingerprint(finding: Finding, context: str) -> str:
|
|
11
|
+
"""Return a stable identity for this finding.
|
|
12
|
+
|
|
13
|
+
The line number is deliberately excluded: inserting a line above a query
|
|
14
|
+
must not make every finding in the file look new. The context carries the
|
|
15
|
+
offending line's content and its occurrence, so a genuinely new violation
|
|
16
|
+
still gets its own fingerprint.
|
|
17
|
+
"""
|
|
18
|
+
material = "|".join(
|
|
19
|
+
[str(FINGERPRINT_VERSION), finding.code.value, finding.location.path, context]
|
|
20
|
+
)
|
|
21
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Decide what identifies a finding, per rule."""
|
|
2
|
+
|
|
3
|
+
from codeality_py.baseline.finding_context import finding_context
|
|
4
|
+
from codeality_py.model.finding import Finding
|
|
5
|
+
from codeality_py.model.rule_code import RuleCode
|
|
6
|
+
|
|
7
|
+
#: Rules that report once per file. Their anchor is line 1, whose content says
|
|
8
|
+
#: nothing about the violation, and the subject of a size finding is a count
|
|
9
|
+
#: that moves with every edit.
|
|
10
|
+
FILE_LEVEL = frozenset({RuleCode.BPY001, RuleCode.BPY004})
|
|
11
|
+
|
|
12
|
+
#: Rules whose subject names the thing that is wrong and is unique in the file.
|
|
13
|
+
SUBJECT_LEVEL = frozenset({RuleCode.BPY002, RuleCode.BPY003})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fingerprint_context(finding: Finding, text: str) -> str:
|
|
17
|
+
"""Return the text identifying this finding within its file.
|
|
18
|
+
|
|
19
|
+
A file-level rule identifies itself. A naming rule identifies the symbol.
|
|
20
|
+
Everything else can fire many times in one file, so it is identified by the
|
|
21
|
+
offending line's content, which survives edits elsewhere in the file.
|
|
22
|
+
"""
|
|
23
|
+
if finding.code in FILE_LEVEL:
|
|
24
|
+
return ""
|
|
25
|
+
if finding.code in SUBJECT_LEVEL:
|
|
26
|
+
return finding.subject or ""
|
|
27
|
+
return finding_context(text, finding.location.line)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Read the recorded baseline, refusing an incompatible one."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from codeality_py.baseline.baseline_file import BASELINE_SCHEMA_VERSION, BaselineFile
|
|
7
|
+
from codeality_py.config.config_error import ConfigError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_baseline(path: Path) -> BaselineFile:
|
|
11
|
+
"""Return the baseline, or an empty one when the file is absent.
|
|
12
|
+
|
|
13
|
+
An incompatible schema is a configuration error rather than a silent
|
|
14
|
+
reclassification of every finding as new or known.
|
|
15
|
+
"""
|
|
16
|
+
if not path.is_file():
|
|
17
|
+
return BaselineFile()
|
|
18
|
+
try:
|
|
19
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
20
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
21
|
+
raise ConfigError(f"{path.name} could not be read: {error}") from error
|
|
22
|
+
version = document.get("schema_version")
|
|
23
|
+
if version != BASELINE_SCHEMA_VERSION:
|
|
24
|
+
raise ConfigError(
|
|
25
|
+
f"baseline schema_version must be {BASELINE_SCHEMA_VERSION}, found {version!r}; "
|
|
26
|
+
"run 'codeality-py baseline update' to rewrite it"
|
|
27
|
+
)
|
|
28
|
+
return BaselineFile(
|
|
29
|
+
entries=frozenset(document.get("entries", ())),
|
|
30
|
+
schema_version=version,
|
|
31
|
+
tool_version=document.get("tool_version", ""),
|
|
32
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Write the baseline atomically and deterministically."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from codeality_py.baseline.baseline_file import BaselineFile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_baseline(path: Path, baseline: BaselineFile) -> None:
|
|
10
|
+
"""Write the baseline through a temporary file, sorted for a clean diff."""
|
|
11
|
+
document = {
|
|
12
|
+
"schema_version": baseline.schema_version,
|
|
13
|
+
"tool_version": baseline.tool_version,
|
|
14
|
+
"entries": sorted(baseline.entries),
|
|
15
|
+
}
|
|
16
|
+
temporary = path.with_suffix(f"{path.suffix}.tmp")
|
|
17
|
+
temporary.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
|
18
|
+
temporary.replace(path)
|
codeality_py/cli.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Command-line entry point for codeality-py."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from codeality_py.commands.baseline_command import baseline_command
|
|
8
|
+
from codeality_py.commands.check_command import check_command
|
|
9
|
+
from codeality_py.commands.gate_command import gate_command
|
|
10
|
+
from codeality_py.commands.init_command import init_command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group(name="codeality-py")
|
|
14
|
+
@click.version_option(version("syntopica-codeality-py"), prog_name="codeality-py")
|
|
15
|
+
def cli() -> None:
|
|
16
|
+
"""Structural linter and config scaffolder for Python."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
cli.add_command(baseline_command)
|
|
20
|
+
cli.add_command(check_command)
|
|
21
|
+
cli.add_command(gate_command)
|
|
22
|
+
cli.add_command(init_command)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The CLI commands."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Report current findings against the recorded baseline."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from codeality_py.baseline.baseline_file import BASELINE_FILENAME
|
|
9
|
+
from codeality_py.baseline.classify_findings import classify_findings
|
|
10
|
+
from codeality_py.baseline.read_baseline import read_baseline
|
|
11
|
+
from codeality_py.commands.checked_project import checked_project
|
|
12
|
+
from codeality_py.commands.project_option import PROJECT_OPTION
|
|
13
|
+
from codeality_py.report.exit_code import ExitCode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command(name="check")
|
|
17
|
+
@PROJECT_OPTION
|
|
18
|
+
@click.option("--check-stale", is_flag=True, help="Also fail on entries nothing reports any more.")
|
|
19
|
+
def baseline_check_command(project: Path, check_stale: bool) -> None:
|
|
20
|
+
"""Fail on new findings, and on stale debt when asked."""
|
|
21
|
+
root, result = checked_project(project)
|
|
22
|
+
classified = classify_findings(result.findings, read_baseline(root / BASELINE_FILENAME))
|
|
23
|
+
click.echo(
|
|
24
|
+
f"{len(classified.new)} new, {len(classified.known)} known, "
|
|
25
|
+
f"{len(classified.resolved)} resolved"
|
|
26
|
+
)
|
|
27
|
+
for finding in classified.new:
|
|
28
|
+
click.echo(
|
|
29
|
+
f"{finding.location.path}:{finding.location.line}: "
|
|
30
|
+
f"{finding.code.value} {finding.message}"
|
|
31
|
+
)
|
|
32
|
+
sys.exit(
|
|
33
|
+
ExitCode.FINDINGS
|
|
34
|
+
if classified.new or (check_stale and classified.resolved)
|
|
35
|
+
else ExitCode.OK
|
|
36
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The ``baseline`` command group: the only writer of recorded debt."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from codeality_py.commands.baseline_check_command import baseline_check_command
|
|
6
|
+
from codeality_py.commands.baseline_update_command import baseline_update_command
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group(name="baseline")
|
|
10
|
+
def baseline_command() -> None:
|
|
11
|
+
"""Record and inspect migration debt. Only this group ever writes one."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
baseline_command.add_command(baseline_check_command)
|
|
15
|
+
baseline_command.add_command(baseline_update_command)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Record every current finding as accepted debt."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from codeality_py.baseline.baseline_file import BASELINE_FILENAME, BaselineFile
|
|
9
|
+
from codeality_py.baseline.write_baseline import write_baseline
|
|
10
|
+
from codeality_py.commands.checked_project import checked_project
|
|
11
|
+
from codeality_py.commands.project_option import PROJECT_OPTION
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command(name="update")
|
|
15
|
+
@PROJECT_OPTION
|
|
16
|
+
def baseline_update_command(project: Path) -> None:
|
|
17
|
+
"""Write the baseline. This and nothing else records debt."""
|
|
18
|
+
root, result = checked_project(project)
|
|
19
|
+
recorded = BaselineFile(
|
|
20
|
+
entries=frozenset(finding.fingerprint for finding in result.findings),
|
|
21
|
+
tool_version=version("syntopica-codeality-py"),
|
|
22
|
+
)
|
|
23
|
+
write_baseline(root / BASELINE_FILENAME, recorded)
|
|
24
|
+
click.echo(f"recorded {len(recorded.entries)} findings as baseline debt")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The read-only ``check`` command."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from codeality_py.config.config_error import ConfigError
|
|
9
|
+
from codeality_py.config.load_config import load_config
|
|
10
|
+
from codeality_py.engine.check_project import check_project
|
|
11
|
+
from codeality_py.parsing.infrastructure_error import InfrastructureError
|
|
12
|
+
from codeality_py.report.exit_code import ExitCode
|
|
13
|
+
from codeality_py.report.render_json_report import render_json_report
|
|
14
|
+
from codeality_py.report.render_text_report import render_text_report
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command(name="check")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--project",
|
|
20
|
+
"project",
|
|
21
|
+
type=click.Path(file_okay=False, exists=True, path_type=Path),
|
|
22
|
+
default=Path(),
|
|
23
|
+
help="Project root to check. Defaults to the working directory.",
|
|
24
|
+
)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--format",
|
|
27
|
+
"output_format",
|
|
28
|
+
type=click.Choice(["text", "json"]),
|
|
29
|
+
default="text",
|
|
30
|
+
help="Report format. JSON goes to stdout alone.",
|
|
31
|
+
)
|
|
32
|
+
def check_command(project: Path, output_format: str) -> None:
|
|
33
|
+
"""Run the structural rules. This command never writes."""
|
|
34
|
+
try:
|
|
35
|
+
config = load_config(project.resolve())
|
|
36
|
+
result = check_project(config)
|
|
37
|
+
except ConfigError as error:
|
|
38
|
+
click.echo(f"configuration error: {error}", err=True)
|
|
39
|
+
sys.exit(ExitCode.CONFIGURATION)
|
|
40
|
+
except InfrastructureError as error:
|
|
41
|
+
click.echo(f"infrastructure error: {error}", err=True)
|
|
42
|
+
sys.exit(ExitCode.INFRASTRUCTURE)
|
|
43
|
+
rendered = render_json_report(result) if output_format == "json" else render_text_report(result)
|
|
44
|
+
click.echo(rendered)
|
|
45
|
+
sys.exit(ExitCode.FINDINGS if result.findings else ExitCode.OK)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Load a project's configuration and run the checker over it."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from codeality_py.config.config_error import ConfigError
|
|
9
|
+
from codeality_py.config.load_config import load_config
|
|
10
|
+
from codeality_py.engine.check_project import check_project
|
|
11
|
+
from codeality_py.engine.check_result import CheckResult
|
|
12
|
+
from codeality_py.parsing.infrastructure_error import InfrastructureError
|
|
13
|
+
from codeality_py.report.exit_code import ExitCode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def checked_project(project: Path) -> tuple[Path, CheckResult]:
|
|
17
|
+
"""Return the resolved root and its findings, exiting on any failure."""
|
|
18
|
+
root = project.resolve()
|
|
19
|
+
try:
|
|
20
|
+
return root, check_project(load_config(root))
|
|
21
|
+
except ConfigError as error:
|
|
22
|
+
click.echo(f"configuration error: {error}", err=True)
|
|
23
|
+
sys.exit(ExitCode.CONFIGURATION)
|
|
24
|
+
except InfrastructureError as error:
|
|
25
|
+
click.echo(f"infrastructure error: {error}", err=True)
|
|
26
|
+
sys.exit(ExitCode.INFRASTRUCTURE)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The ``gate`` command: the whole quality chain, defined once."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from codeality_py.config.config_error import ConfigError
|
|
9
|
+
from codeality_py.config.load_config import load_config
|
|
10
|
+
from codeality_py.gate.run_gate import run_gate
|
|
11
|
+
from codeality_py.report.exit_code import ExitCode
|
|
12
|
+
from codeality_py.report.render_gate_report import render_gate_report
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(name="gate")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--project",
|
|
18
|
+
type=click.Path(file_okay=False, exists=True, path_type=Path),
|
|
19
|
+
default=Path(),
|
|
20
|
+
help="Project root to gate. Defaults to the working directory.",
|
|
21
|
+
)
|
|
22
|
+
@click.option("--fail-fast", is_flag=True, help="Stop at the first blocking failure.")
|
|
23
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the machine-readable summary.")
|
|
24
|
+
def gate_command(project: Path, fail_fast: bool, as_json: bool) -> None:
|
|
25
|
+
"""Run ruff, mypy, the structural rules, deptry, pip-audit and pytest."""
|
|
26
|
+
try:
|
|
27
|
+
config = load_config(project.resolve())
|
|
28
|
+
except ConfigError as error:
|
|
29
|
+
click.echo(f"configuration error: {error}", err=True)
|
|
30
|
+
sys.exit(ExitCode.CONFIGURATION)
|
|
31
|
+
result = run_gate(config, fail_fast)
|
|
32
|
+
click.echo(render_gate_report(result, as_json))
|
|
33
|
+
sys.exit(result.exit_code())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The ``init`` command: plan by default, merge on request, never shadow."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from codeality_py.init.apply_init import apply_init
|
|
9
|
+
from codeality_py.init.plan_disposition import PlanDisposition
|
|
10
|
+
from codeality_py.init.plan_init import plan_init
|
|
11
|
+
from codeality_py.report.exit_code import ExitCode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command(name="init")
|
|
15
|
+
@click.option(
|
|
16
|
+
"--project",
|
|
17
|
+
type=click.Path(file_okay=False, exists=True, path_type=Path),
|
|
18
|
+
default=Path(),
|
|
19
|
+
help="Project root to scaffold. Defaults to the working directory.",
|
|
20
|
+
)
|
|
21
|
+
@click.option("--check", "check_only", is_flag=True, help="Plan only; exit 2 on any conflict.")
|
|
22
|
+
@click.option("--apply", "should_apply", is_flag=True, help="Write creates and merges.")
|
|
23
|
+
@click.option("--force", is_flag=True, help="Also replace conflicting managed files.")
|
|
24
|
+
@click.option("--ci", "with_ci", is_flag=True, help="Also scaffold the CI workflow.")
|
|
25
|
+
@click.option(
|
|
26
|
+
"--profile",
|
|
27
|
+
type=click.Choice(["lib", "app"]),
|
|
28
|
+
default="lib",
|
|
29
|
+
help="app additionally scaffolds an import-linter contract.",
|
|
30
|
+
)
|
|
31
|
+
def init_command( # noqa: PLR0913, PLR0917 - one parameter per click option
|
|
32
|
+
project: Path,
|
|
33
|
+
check_only: bool,
|
|
34
|
+
should_apply: bool,
|
|
35
|
+
force: bool,
|
|
36
|
+
with_ci: bool,
|
|
37
|
+
profile: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Scaffold the quality configuration by merging, never by shadowing."""
|
|
40
|
+
root = project.resolve()
|
|
41
|
+
plan = plan_init(root, profile, with_ci)
|
|
42
|
+
for managed in plan:
|
|
43
|
+
# A nested project's workflow lives at the repository root, outside it.
|
|
44
|
+
path = managed.path.resolve()
|
|
45
|
+
shown = path.relative_to(root) if path.is_relative_to(root) else path
|
|
46
|
+
click.echo(f"{managed.disposition.value:>9} {shown} ({managed.detail})")
|
|
47
|
+
if should_apply or force:
|
|
48
|
+
written = apply_init(plan, force)
|
|
49
|
+
click.echo(f"wrote {len(written)} files")
|
|
50
|
+
conflicts = [item for item in plan if item.disposition is PlanDisposition.CONFLICT]
|
|
51
|
+
if conflicts and check_only:
|
|
52
|
+
sys.exit(ExitCode.CONFIGURATION)
|
|
53
|
+
sys.exit(ExitCode.OK)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The --project option shared by every command."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
PROJECT_OPTION = click.option(
|
|
8
|
+
"--project",
|
|
9
|
+
type=click.Path(file_okay=False, exists=True, path_type=Path),
|
|
10
|
+
default=Path(),
|
|
11
|
+
help="Project root. Defaults to the working directory.",
|
|
12
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration loading and validation."""
|