pyossmtool 0.0.1__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.
Files changed (119) hide show
  1. pyossmtool/__init__.py +3 -0
  2. pyossmtool/bundle/catalog/checks/bandit.scan.yaml +13 -0
  3. pyossmtool/bundle/catalog/checks/codespell.spelling.yaml +12 -0
  4. pyossmtool/bundle/catalog/checks/deadcode.scan.yaml +13 -0
  5. pyossmtool/bundle/catalog/checks/gate.acronym-allowlist.yaml +14 -0
  6. pyossmtool/bundle/catalog/checks/gate.folder-breadth.yaml +14 -0
  7. pyossmtool/bundle/catalog/checks/gate.module-private-vars.yaml +14 -0
  8. pyossmtool/bundle/catalog/checks/gate.module-size.yaml +14 -0
  9. pyossmtool/bundle/catalog/checks/gitleaks.secrets.yaml +21 -0
  10. pyossmtool/bundle/catalog/checks/hadolint.dockerfile.yaml +16 -0
  11. pyossmtool/bundle/catalog/checks/jscpd.duplication.yaml +19 -0
  12. pyossmtool/bundle/catalog/checks/markdownlint.check.yaml +17 -0
  13. pyossmtool/bundle/catalog/checks/mdformat.apply.yaml +10 -0
  14. pyossmtool/bundle/catalog/checks/mdformat.format.yaml +14 -0
  15. pyossmtool/bundle/catalog/checks/mutmut.run.yaml +13 -0
  16. pyossmtool/bundle/catalog/checks/pydeps.cycles.yaml +14 -0
  17. pyossmtool/bundle/catalog/checks/pytest.coverage.yaml +19 -0
  18. pyossmtool/bundle/catalog/checks/pytest.test.yaml +14 -0
  19. pyossmtool/bundle/catalog/checks/radon.cc.yaml +13 -0
  20. pyossmtool/bundle/catalog/checks/radon.mi.yaml +13 -0
  21. pyossmtool/bundle/catalog/checks/ruff.format.apply.yaml +11 -0
  22. pyossmtool/bundle/catalog/checks/ruff.format.yaml +14 -0
  23. pyossmtool/bundle/catalog/checks/ruff.lint.yaml +15 -0
  24. pyossmtool/bundle/catalog/checks/ruff.unused.yaml +17 -0
  25. pyossmtool/bundle/catalog/checks/semgrep.scan.yaml +18 -0
  26. pyossmtool/bundle/catalog/checks/shellcheck.yaml +13 -0
  27. pyossmtool/bundle/catalog/checks/shfmt.apply.yaml +11 -0
  28. pyossmtool/bundle/catalog/checks/shfmt.format.yaml +14 -0
  29. pyossmtool/bundle/catalog/checks/sourcery.review.yaml +14 -0
  30. pyossmtool/bundle/catalog/checks/ty.check.yaml +15 -0
  31. pyossmtool/bundle/catalog/checks/vulture.scan.yaml +16 -0
  32. pyossmtool/bundle/catalog/checks/yamlfmt.apply.yaml +10 -0
  33. pyossmtool/bundle/catalog/checks/yamlfmt.format.yaml +14 -0
  34. pyossmtool/bundle/catalog/checks/yamllint.check.yaml +16 -0
  35. pyossmtool/bundle/catalog/tools/bandit.yaml +20 -0
  36. pyossmtool/bundle/catalog/tools/codespell.yaml +17 -0
  37. pyossmtool/bundle/catalog/tools/deadcode.yaml +13 -0
  38. pyossmtool/bundle/catalog/tools/gitleaks.yaml +9 -0
  39. pyossmtool/bundle/catalog/tools/hadolint.yaml +11 -0
  40. pyossmtool/bundle/catalog/tools/jscpd.yaml +15 -0
  41. pyossmtool/bundle/catalog/tools/markdownlint.yaml +18 -0
  42. pyossmtool/bundle/catalog/tools/mdformat.yaml +13 -0
  43. pyossmtool/bundle/catalog/tools/mutmut.yaml +10 -0
  44. pyossmtool/bundle/catalog/tools/pydeps.yaml +10 -0
  45. pyossmtool/bundle/catalog/tools/pytest.yaml +12 -0
  46. pyossmtool/bundle/catalog/tools/radon.yaml +10 -0
  47. pyossmtool/bundle/catalog/tools/ruff.yaml +26 -0
  48. pyossmtool/bundle/catalog/tools/script.yaml +8 -0
  49. pyossmtool/bundle/catalog/tools/semgrep.yaml +21 -0
  50. pyossmtool/bundle/catalog/tools/shellcheck.yaml +10 -0
  51. pyossmtool/bundle/catalog/tools/shfmt.yaml +13 -0
  52. pyossmtool/bundle/catalog/tools/sourcery.yaml +10 -0
  53. pyossmtool/bundle/catalog/tools/ty.yaml +20 -0
  54. pyossmtool/bundle/catalog/tools/vulture.yaml +13 -0
  55. pyossmtool/bundle/catalog/tools/yamlfmt.yaml +24 -0
  56. pyossmtool/bundle/catalog/tools/yamllint.yaml +24 -0
  57. pyossmtool/bundle/defaults/allowlists/acronyms.yaml +10 -0
  58. pyossmtool/bundle/defaults/allowlists/folder-breadth.txt +1 -0
  59. pyossmtool/bundle/defaults/allowlists/module-private-vars.txt +1 -0
  60. pyossmtool/bundle/defaults/allowlists/module-size.txt +1 -0
  61. pyossmtool/bundle/defaults/configs/bandit.yaml +5 -0
  62. pyossmtool/bundle/defaults/configs/gates/acronym-allowlist.yaml +4 -0
  63. pyossmtool/bundle/defaults/configs/gates/folder-breadth.yaml +12 -0
  64. pyossmtool/bundle/defaults/configs/gates/module-private-vars.yaml +4 -0
  65. pyossmtool/bundle/defaults/configs/gates/module-size.yaml +6 -0
  66. pyossmtool/bundle/defaults/configs/markdownlint.json +8 -0
  67. pyossmtool/bundle/defaults/configs/ruff.toml +8 -0
  68. pyossmtool/bundle/defaults/configs/ty.toml +5 -0
  69. pyossmtool/bundle/defaults/configs/yamlfmt.yaml +4 -0
  70. pyossmtool/bundle/defaults/configs/yamllint.yaml +12 -0
  71. pyossmtool/bundle/defaults/gates/README.md +150 -0
  72. pyossmtool/bundle/defaults/gates/acronym-allowlist.sh +15 -0
  73. pyossmtool/bundle/defaults/gates/folder-breadth.sh +22 -0
  74. pyossmtool/bundle/defaults/gates/lib.sh +116 -0
  75. pyossmtool/bundle/defaults/gates/module-private-vars.sh +77 -0
  76. pyossmtool/bundle/defaults/gates/module-size.sh +60 -0
  77. pyossmtool/bundle/defaults/semgrep/python-quality.yml +19 -0
  78. pyossmtool/bundle/suites/demo.yaml +11 -0
  79. pyossmtool/bundle/suites/extended.yaml +34 -0
  80. pyossmtool/bundle/suites/format.yaml +10 -0
  81. pyossmtool/bundle/suites/formatting.yaml +10 -0
  82. pyossmtool/bundle/suites/policy.yaml +11 -0
  83. pyossmtool/bundle/suites/python-quality.yaml +9 -0
  84. pyossmtool/bundle/suites/reslab-parity.yaml +41 -0
  85. pyossmtool/bundle/suites/standard.yaml +45 -0
  86. pyossmtool/cli.py +233 -0
  87. pyossmtool/config_resolver.py +117 -0
  88. pyossmtool/discovery.py +50 -0
  89. pyossmtool/gate_config.py +119 -0
  90. pyossmtool/gates.py +102 -0
  91. pyossmtool/ignore.py +270 -0
  92. pyossmtool/ignore_materialize.py +184 -0
  93. pyossmtool/installer.py +119 -0
  94. pyossmtool/models.py +279 -0
  95. pyossmtool/parsers/__init__.py +76 -0
  96. pyossmtool/parsers/analysis.py +329 -0
  97. pyossmtool/parsers/base.py +29 -0
  98. pyossmtool/parsers/common.py +57 -0
  99. pyossmtool/parsers/format_text.py +67 -0
  100. pyossmtool/parsers/gates.py +92 -0
  101. pyossmtool/parsers/patterns.py +78 -0
  102. pyossmtool/parsers/prose.py +193 -0
  103. pyossmtool/parsers/ruff.py +103 -0
  104. pyossmtool/parsers/shell.py +83 -0
  105. pyossmtool/policy/__init__.py +0 -0
  106. pyossmtool/policy/acronym_allowlist.py +322 -0
  107. pyossmtool/policy/folder_breadth.py +306 -0
  108. pyossmtool/registry.py +72 -0
  109. pyossmtool/reporter.py +87 -0
  110. pyossmtool/resolver.py +146 -0
  111. pyossmtool/runner.py +482 -0
  112. pyossmtool/runner_script.py +131 -0
  113. pyossmtool/target_expand.py +112 -0
  114. pyossmtool-0.0.1.dist-info/METADATA +94 -0
  115. pyossmtool-0.0.1.dist-info/RECORD +119 -0
  116. pyossmtool-0.0.1.dist-info/WHEEL +5 -0
  117. pyossmtool-0.0.1.dist-info/entry_points.txt +2 -0
  118. pyossmtool-0.0.1.dist-info/licenses/LICENSE +21 -0
  119. pyossmtool-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Module private-vars gate — no leading-underscore module-level names.
3
+ set -euo pipefail
4
+
5
+ # shellcheck source=/dev/null
6
+ source "$(pyossmtool gates lib-path)"
7
+
8
+ gate_init "module-private-vars"
9
+
10
+ BASE_BRANCH="${GATE_BASE_BRANCH:-main}"
11
+ ALLOWLIST="${GATE_ALLOWLIST_FILE:-}"
12
+ IFS=' ' read -r -a SCAN_ROOTS <<<"${GATE_SCAN_ROOTS:-src/}"
13
+
14
+ ASSIGN_PATTERN='^_[^_][A-Za-z0-9_]*[[:space:]]*[:=]'
15
+ DEF_PATTERN='^(async[[:space:]]+)?def[[:space:]]+_[^_][A-Za-z0-9_]*[[:space:]]*[\(:]'
16
+ CLASS_PATTERN='^class[[:space:]]+_[^_][A-Za-z0-9_]*[[:space:]]*[\(:]'
17
+
18
+ is_allowlisted() {
19
+ local rel="$1"
20
+ [[ -n ${ALLOWLIST} && -f ${ALLOWLIST} ]] || return 1
21
+ grep -v '^[[:space:]]*#' "${ALLOWLIST}" | grep -v '^[[:space:]]*$' | grep -Fxq "${rel}"
22
+ }
23
+
24
+ is_strict_file() {
25
+ local rel="$1"
26
+ local strict
27
+ for strict in "${STRICT_FILES[@]-}"; do
28
+ [[ ${rel} == "${strict}" ]] && return 0
29
+ done
30
+ return 1
31
+ }
32
+
33
+ report_matches() {
34
+ local rel="$1"
35
+ local label="$2"
36
+ local matches="$3"
37
+ if [[ -n ${matches} ]]; then
38
+ while IFS= read -r line; do
39
+ [[ -n ${line} ]] || continue
40
+ line_no="${line%%:*}"
41
+ gate_fail "private-${label}" "${rel}:${line}" "${rel}" "${line_no}"
42
+ done <<<"${matches}"
43
+ fi
44
+ }
45
+
46
+ STRICT_FILES=()
47
+ if git rev-parse --verify "${BASE_BRANCH}" >/dev/null 2>&1; then
48
+ MERGE_BASE="$(git merge-base HEAD "${BASE_BRANCH}")"
49
+ new_file_globs=()
50
+ for scan_root in "${SCAN_ROOTS[@]}"; do
51
+ new_file_globs+=("${scan_root}" "${scan_root}/**")
52
+ done
53
+ mapfile -t STRICT_FILES < <(git diff --name-only --diff-filter=A "${MERGE_BASE}...HEAD" -- "${new_file_globs[@]}" | grep '\.py$' || true)
54
+ fi
55
+
56
+ for scan_dir in "${SCAN_ROOTS[@]}"; do
57
+ [[ -d ${scan_dir} ]] || continue
58
+ while IFS= read -r -d '' file; do
59
+ rel="${file#./}"
60
+ if gate_path_ignored "${rel}"; then
61
+ continue
62
+ fi
63
+ if is_allowlisted "${rel}"; then
64
+ continue
65
+ fi
66
+ assign_matches="$(grep -nE "${ASSIGN_PATTERN}" "${file}" || true)"
67
+ report_matches "${rel}" "assignment" "${assign_matches}"
68
+ if is_strict_file "${rel}"; then
69
+ def_matches="$(grep -nE "${DEF_PATTERN}" "${file}" || true)"
70
+ class_matches="$(grep -nE "${CLASS_PATTERN}" "${file}" || true)"
71
+ report_matches "${rel}" "function" "${def_matches}"
72
+ report_matches "${rel}" "class" "${class_matches}"
73
+ fi
74
+ done < <(find "${scan_dir}" -name '*.py' -print0)
75
+ done
76
+
77
+ gate_finish
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ # Module size gate — portfolio and new-file line caps.
3
+ set -euo pipefail
4
+
5
+ # shellcheck source=/dev/null
6
+ source "$(pyossmtool gates lib-path)"
7
+
8
+ gate_init "module-size"
9
+
10
+ PORTFOLIO_MAX="${GATE_PORTFOLIO_MAX_LINES:-1000}"
11
+ NEW_FILE_MAX="${GATE_NEW_FILE_MAX_LINES:-500}"
12
+ BASE_BRANCH="${GATE_BASE_BRANCH:-main}"
13
+ ALLOWLIST="${GATE_ALLOWLIST_FILE:-}"
14
+ IFS=' ' read -r -a SCAN_ROOTS <<<"${GATE_SCAN_ROOTS:-src/}"
15
+
16
+ count_non_blank_lines() {
17
+ local file="$1"
18
+ grep -cve '^[[:space:]]*$' "${file}" || true
19
+ }
20
+
21
+ is_allowlisted() {
22
+ local rel="$1"
23
+ [[ -n ${ALLOWLIST} && -f ${ALLOWLIST} ]] || return 1
24
+ grep -v '^[[:space:]]*#' "${ALLOWLIST}" | grep -v '^[[:space:]]*$' | grep -Fxq "${rel}"
25
+ }
26
+
27
+ for scan_root in "${SCAN_ROOTS[@]}"; do
28
+ [[ -d ${scan_root} ]] || continue
29
+ while IFS= read -r -d '' file; do
30
+ rel="${file#./}"
31
+ if gate_path_ignored "${rel}"; then
32
+ continue
33
+ fi
34
+ loc="$(count_non_blank_lines "${file}")"
35
+ if ((loc > PORTFOLIO_MAX)) && ! is_allowlisted "${rel}"; then
36
+ gate_fail "module-size" "${rel} has ${loc} lines (portfolio cap ${PORTFOLIO_MAX})" "${rel}" "1"
37
+ fi
38
+ done < <(find "${scan_root}" -name '*.py' -print0)
39
+ done
40
+
41
+ if git rev-parse --verify "${BASE_BRANCH}" >/dev/null 2>&1; then
42
+ MERGE_BASE="$(git merge-base HEAD "${BASE_BRANCH}")"
43
+ new_file_globs=()
44
+ for scan_root in "${SCAN_ROOTS[@]}"; do
45
+ new_file_globs+=("${scan_root}" "${scan_root}/**")
46
+ done
47
+ mapfile -t new_files < <(git diff --name-only --diff-filter=A "${MERGE_BASE}...HEAD" -- "${new_file_globs[@]}" | grep '\.py$' || true)
48
+ for rel in "${new_files[@]}"; do
49
+ [[ -f ${rel} ]] || continue
50
+ if gate_path_ignored "${rel}"; then
51
+ continue
52
+ fi
53
+ loc="$(count_non_blank_lines "${rel}")"
54
+ if ((loc > NEW_FILE_MAX)) && ! is_allowlisted "${rel}"; then
55
+ gate_fail "module-size" "${rel} has ${loc} lines (new-file cap ${NEW_FILE_MAX})" "${rel}" "1"
56
+ fi
57
+ done
58
+ fi
59
+
60
+ gate_finish
@@ -0,0 +1,19 @@
1
+ rules:
2
+ - id: no-eval
3
+ patterns:
4
+ - pattern: eval(...)
5
+ message: Avoid eval() — use safer alternatives.
6
+ languages: [python]
7
+ severity: ERROR
8
+ - id: no-exec
9
+ patterns:
10
+ - pattern: exec(...)
11
+ message: Avoid exec() — use safer alternatives.
12
+ languages: [python]
13
+ severity: ERROR
14
+ - id: no-hardcoded-temp-password
15
+ patterns:
16
+ - pattern: password = "..."
17
+ message: Possible hardcoded password.
18
+ languages: [python]
19
+ severity: WARNING
@@ -0,0 +1,11 @@
1
+ id: demo
2
+ name: Demo Suite
3
+ description: Internal demo suite for sample_files fixtures (development only).
4
+ env: auto
5
+ target: sample_files/
6
+ checks:
7
+ - id: ruff.lint
8
+ - id: ruff.format
9
+ - id: ty.check
10
+ - id: shellcheck
11
+ - id: bandit.scan
@@ -0,0 +1,34 @@
1
+ id: extended
2
+ name: Extended Quality Suite
3
+ description: Standard suite plus slow or optional checks (mutation testing, sourcery
4
+ review).
5
+ env: auto
6
+ target: .
7
+ checks:
8
+ - id: ruff.format
9
+ - id: ruff.lint
10
+ - id: ruff.unused
11
+ - id: ty.check
12
+ - id: bandit.scan
13
+ - id: gitleaks.secrets
14
+ - id: semgrep.scan
15
+ - id: deadcode.scan
16
+ - id: vulture.scan
17
+ - id: pydeps.cycles
18
+ - id: radon.cc
19
+ - id: radon.mi
20
+ - id: jscpd.duplication
21
+ - id: shfmt.format
22
+ - id: shellcheck
23
+ - id: mdformat.format
24
+ - id: markdownlint.check
25
+ - id: yamlfmt.format
26
+ - id: yamllint.check
27
+ - id: codespell.spelling
28
+ - id: pytest.test
29
+ target: tests/
30
+ - id: pytest.coverage
31
+ target: tests/
32
+ - id: hadolint.dockerfile
33
+ - id: mutmut.run
34
+ - id: sourcery.review
@@ -0,0 +1,10 @@
1
+ id: format
2
+ name: Format Apply
3
+ description: Apply formatters across Python, shell, Markdown, and YAML.
4
+ env: auto
5
+ target: .
6
+ checks:
7
+ - id: ruff.format.apply
8
+ - id: shfmt.apply
9
+ - id: mdformat.apply
10
+ - id: yamlfmt.apply
@@ -0,0 +1,10 @@
1
+ id: formatting
2
+ name: Formatting
3
+ description: All format-only checks across Python, shell, Markdown, and YAML.
4
+ env: auto
5
+ target: .
6
+ checks:
7
+ - id: ruff.format
8
+ - id: shfmt.format
9
+ - id: mdformat.format
10
+ - id: yamlfmt.format
@@ -0,0 +1,11 @@
1
+ id: policy
2
+ name: Policy Gates
3
+ description: Structural policy checks (module size, private vars, folder breadth,
4
+ acronyms).
5
+ env: auto
6
+ target: .
7
+ checks:
8
+ - id: gate.module-size
9
+ - id: gate.module-private-vars
10
+ - id: gate.folder-breadth
11
+ - id: gate.acronym-allowlist
@@ -0,0 +1,9 @@
1
+ id: python-quality
2
+ name: Python Quality
3
+ description: Core Python lint, format, and type checks.
4
+ env: auto
5
+ target: .
6
+ checks:
7
+ - id: ruff.format
8
+ - id: ruff.lint
9
+ - id: ty.check
@@ -0,0 +1,41 @@
1
+ id: reslab-parity
2
+ name: ResLab Parity Reference
3
+ description: "Alias for the standard suite \u2014 full reslab-equivalent CLI tool\
4
+ \ coverage."
5
+ env: auto
6
+ target: .
7
+ ignore-profile:
8
+ - .gitignore
9
+ - .cursorignore
10
+ ignore-paths:
11
+ - .pyossmtool/
12
+ - venv/
13
+ - .venv/
14
+ - reports/
15
+ - sample_files/
16
+ checks:
17
+ - id: ruff.format
18
+ - id: ruff.lint
19
+ - id: ruff.unused
20
+ - id: ty.check
21
+ - id: shfmt.format
22
+ - id: shellcheck
23
+ - id: bandit.scan
24
+ - id: gitleaks.secrets
25
+ - id: semgrep.scan
26
+ - id: deadcode.scan
27
+ - id: vulture.scan
28
+ - id: pydeps.cycles
29
+ - id: radon.cc
30
+ - id: radon.mi
31
+ - id: jscpd.duplication
32
+ - id: mdformat.format
33
+ - id: markdownlint.check
34
+ - id: yamlfmt.format
35
+ - id: yamllint.check
36
+ - id: codespell.spelling
37
+ - id: pytest.test
38
+ target: tests/
39
+ - id: pytest.coverage
40
+ target: tests/
41
+ - id: hadolint.dockerfile
@@ -0,0 +1,45 @@
1
+ id: standard
2
+ name: Standard Quality Suite
3
+ description: Full portable quality gate set for new Python projects (matches reslab
4
+ tool coverage).
5
+ env: auto
6
+ target: .
7
+ ignore-profile:
8
+ - .gitignore
9
+ - .cursorignore
10
+ ignore-paths:
11
+ - .pyossmtool/
12
+ - venv/
13
+ - .venv/
14
+ - reports/
15
+ - sample_files/
16
+ checks:
17
+ - id: ruff.format
18
+ - id: ruff.lint
19
+ - id: ruff.unused
20
+ - id: ty.check
21
+ - id: bandit.scan
22
+ - id: gitleaks.secrets
23
+ - id: semgrep.scan
24
+ - id: deadcode.scan
25
+ - id: vulture.scan
26
+ - id: pydeps.cycles
27
+ - id: radon.cc
28
+ - id: radon.mi
29
+ - id: jscpd.duplication
30
+ - id: shfmt.format
31
+ - id: shellcheck
32
+ - id: mdformat.format
33
+ - id: markdownlint.check
34
+ - id: yamlfmt.format
35
+ - id: yamllint.check
36
+ - id: codespell.spelling
37
+ - id: pytest.test
38
+ target: tests/
39
+ - id: pytest.coverage
40
+ target: tests/
41
+ - id: hadolint.dockerfile
42
+ - id: gate.module-size
43
+ - id: gate.module-private-vars
44
+ - id: gate.folder-breadth
45
+ - id: gate.acronym-allowlist
pyossmtool/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ """pyossmtool command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from pyossmtool.gates import lib_path, scaffold_gate
11
+ from pyossmtool.installer import Installer
12
+ from pyossmtool.models import CheckMode, FailureReport
13
+ from pyossmtool.registry import Registry
14
+ from pyossmtool.runner import Runner
15
+
16
+ app = typer.Typer(help="Quality-gate orchestrator for AI-assisted development")
17
+ list_app = typer.Typer(help="List catalog entries")
18
+ gate_app = typer.Typer(help="Shell script gate framework")
19
+ gates_app = typer.Typer(help="Gate library utilities")
20
+ app.add_typer(list_app, name="list")
21
+ app.add_typer(gate_app, name="gate")
22
+ app.add_typer(gates_app, name="gates")
23
+
24
+
25
+ def _project_root() -> Path:
26
+ return Path.cwd()
27
+
28
+
29
+ def _registry() -> Registry:
30
+ return Registry(project_root=_project_root())
31
+
32
+
33
+ @list_app.command("tools")
34
+ def list_tools() -> None:
35
+ registry = _registry()
36
+ for tool in registry.tools.values():
37
+ typer.echo(f"{tool.id}\t{tool.name}\t({tool.install.method.value})")
38
+
39
+
40
+ @list_app.command("checks")
41
+ def list_checks() -> None:
42
+ registry = _registry()
43
+ for check in registry.checks.values():
44
+ origin = "project" if check.tool == "script" else "bundled"
45
+ typer.echo(f"{check.id}\t{check.tool}\t{check.name}\t{check.mode.value}\t({origin})")
46
+
47
+
48
+ @list_app.command("suites")
49
+ def list_suites() -> None:
50
+ registry = _registry()
51
+ for suite in registry.suites.values():
52
+ typer.echo(f"{suite.id}\t{suite.name}")
53
+
54
+
55
+ @gates_app.command("lib-path")
56
+ def gates_lib_path() -> None:
57
+ """Print absolute path to defaults/gates/lib.sh for sourcing in scripts."""
58
+ typer.echo(str(lib_path()))
59
+
60
+
61
+ @gate_app.command("init")
62
+ def gate_init(
63
+ name: str = typer.Argument(..., help="Gate name (e.g. module-size)"),
64
+ description: str = typer.Option("Project script gate", "--description", "-d"),
65
+ ) -> None:
66
+ """Scaffold a script gate under .pyossmtool/gates/ and register it in the catalog."""
67
+ project_root = _project_root()
68
+ try:
69
+ script_path, catalog_path = scaffold_gate(project_root, name, description)
70
+ except FileExistsError as exc:
71
+ typer.echo(f"SETUP {exc}", err=True)
72
+ raise typer.Exit(code=2) from exc
73
+
74
+ typer.echo(f"Created gate script: {script_path.relative_to(project_root)}")
75
+ typer.echo(f"Created catalog check: {catalog_path.relative_to(project_root)}")
76
+ typer.echo("")
77
+ typer.echo("Add the check to pyossmtool.yaml:")
78
+ typer.echo(f" - id: {catalog_path.stem}")
79
+ typer.echo("")
80
+ typer.echo("Run it:")
81
+ typer.echo(f" pyossmtool check --check {catalog_path.stem} --target .")
82
+
83
+
84
+ @app.command("schema")
85
+ def export_schema() -> None:
86
+ typer.echo(json.dumps(FailureReport.model_json_schema(), indent=2))
87
+
88
+
89
+ @app.command("install")
90
+ def install(
91
+ suite: str = typer.Option("demo", "--suite", help="Suite whose tool dependencies to install"),
92
+ ) -> None:
93
+ registry = _registry()
94
+ installer = Installer(registry, _project_root())
95
+ try:
96
+ installer.install_suite(suite)
97
+ except Exception as exc:
98
+ typer.echo(f"SETUP {exc}", err=True)
99
+ raise typer.Exit(code=2) from exc
100
+
101
+
102
+ @app.command("check")
103
+ def check_cmd(
104
+ suite: str | None = typer.Option(None, "--suite", help="Suite id to run"),
105
+ check: str | None = typer.Option(None, "--check", help="Single check id to run"),
106
+ target: str | None = typer.Option(None, "--target", help="Target path for single check"),
107
+ fail_fast: bool = typer.Option(False, "--fail-fast", help="Stop at first failure"),
108
+ verbose: bool = typer.Option(False, "--verbose", help="Print commands and keep raw artifacts on success"),
109
+ ) -> None:
110
+ """Report-only quality checks (no file writes from formatters)."""
111
+ raise typer.Exit(code=_execute(CheckMode.CHECK, suite, check, target, fail_fast, verbose))
112
+
113
+
114
+ @app.command("format")
115
+ def format_cmd(
116
+ suite: str | None = typer.Option("format", "--suite", help="Suite id to run"),
117
+ check: str | None = typer.Option(None, "--check", help="Single check id to run"),
118
+ target: str | None = typer.Option(None, "--target", help="Target path for single check"),
119
+ fail_fast: bool = typer.Option(False, "--fail-fast", help="Stop at first failure"),
120
+ verbose: bool = typer.Option(False, "--verbose", help="Print commands and keep raw artifacts on success"),
121
+ ) -> None:
122
+ """Apply formatters / autofix checks that write to the tree."""
123
+ raise typer.Exit(code=_execute(CheckMode.FORMAT, suite, check, target, fail_fast, verbose))
124
+
125
+
126
+ def _execute(
127
+ mode: CheckMode,
128
+ suite: str | None,
129
+ check: str | None,
130
+ target: str | None,
131
+ fail_fast: bool,
132
+ verbose: bool,
133
+ ) -> int:
134
+ registry = _registry()
135
+ project_root = _project_root()
136
+ project_config = registry.load_project_config(project_root)
137
+ runner = Runner(registry, project_root, verbose=verbose)
138
+
139
+ if check:
140
+ return _run_single_check(runner, check, target, suite, project_config, mode)
141
+
142
+ suite_id = suite or (project_config.suite if project_config else None)
143
+ if not suite_id:
144
+ typer.echo("SETUP provide --suite or create pyossmtool.yaml", err=True)
145
+ return 2
146
+
147
+ try:
148
+ suite_result = runner.run_suite(
149
+ suite_id,
150
+ project_config=project_config,
151
+ fail_fast=fail_fast,
152
+ mode=mode,
153
+ )
154
+ except KeyError as exc:
155
+ typer.echo(f"SETUP {exc}", err=True)
156
+ return 2
157
+ return _emit_suite_result(suite_result)
158
+
159
+
160
+ def _run_single_check(
161
+ runner: Runner,
162
+ check: str,
163
+ target: str | None,
164
+ suite: str | None,
165
+ project_config,
166
+ mode: CheckMode,
167
+ ) -> int:
168
+ if not target:
169
+ typer.echo("SETUP --target is required with --check", err=True)
170
+ return 2
171
+ check_def = runner.registry.get_check(check)
172
+ if check_def.mode != mode:
173
+ typer.echo(
174
+ f"SETUP check '{check}' has mode={check_def.mode.value}; use pyossmtool {check_def.mode.value}",
175
+ err=True,
176
+ )
177
+ return 2
178
+ result = runner.run_check(
179
+ check,
180
+ target=target,
181
+ suite_id=suite or (project_config.suite if project_config else None),
182
+ project_config=project_config,
183
+ )
184
+ return _exit_for_check_result(result)
185
+
186
+
187
+ def _exit_for_check_result(result) -> int:
188
+ if result.error:
189
+ typer.echo(f"SETUP {result.error}", err=True)
190
+ return 2
191
+ if result.passed:
192
+ return 0
193
+ typer.echo(f"FAIL {result.check_id} -> {result.report_path}", err=True)
194
+ return 1
195
+
196
+
197
+ def _emit_suite_result(suite_result) -> int:
198
+ if suite_result.passed:
199
+ return 0
200
+ for result in suite_result.results:
201
+ _emit_failed_check(result)
202
+ return 1
203
+
204
+
205
+ def _emit_failed_check(result) -> None:
206
+ if result.passed:
207
+ return
208
+ if result.error:
209
+ typer.echo(f"SETUP {result.check_id}: {result.error}", err=True)
210
+ return
211
+ if result.report_path:
212
+ typer.echo(f"FAIL {result.check_id} -> {result.report_path}", err=True)
213
+
214
+
215
+ def main() -> None:
216
+ app()
217
+
218
+
219
+ _TYPER_COMMANDS = (
220
+ list_tools,
221
+ list_checks,
222
+ list_suites,
223
+ gates_lib_path,
224
+ gate_init,
225
+ export_schema,
226
+ install,
227
+ check_cmd,
228
+ format_cmd,
229
+ )
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()
@@ -0,0 +1,117 @@
1
+ """Resolve tool configuration: repo-native > pyossmtool.yaml paths > bundled defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ from pyossmtool.models import (
9
+ ConfigMode,
10
+ ConfigSpec,
11
+ ProjectConfig,
12
+ ToolConfigSpec,
13
+ ToolDef,
14
+ )
15
+ from pyossmtool.registry import BUNDLE_ROOT
16
+
17
+ DEFAULTS_DIR = BUNDLE_ROOT / "defaults" / "configs"
18
+
19
+
20
+ class ConfigResolver:
21
+ def __init__(self, project_root: Path) -> None:
22
+ self.project_root = project_root.resolve()
23
+ self.defaults_dir = DEFAULTS_DIR
24
+
25
+ def extra_argv(self, tool: ToolDef, project_config: ProjectConfig | None) -> list[str]:
26
+ config_path = self._selected_config_path(tool, project_config)
27
+ if config_path is None:
28
+ return []
29
+ return self._config_argv(tool, config_path)
30
+
31
+ def resolve_config_path(self, tool: ToolDef, project_config: ProjectConfig | None) -> Path | None:
32
+ path = self._selected_config_path(tool, project_config)
33
+ return path.resolve() if path and path.exists() else None
34
+
35
+ def _selected_config_path(self, tool: ToolDef, project_config: ProjectConfig | None) -> Path | None:
36
+ spec = project_config.configs if project_config else ConfigSpec()
37
+ if spec.mode == ConfigMode.PATHS:
38
+ return self._paths_mode_config(tool.id, spec)
39
+ if tool.config is None:
40
+ return None
41
+ if spec.mode == ConfigMode.BUNDLED:
42
+ return self._bundled_mode_config(tool.config)
43
+ return self._auto_mode_config(tool.config)
44
+
45
+ def _paths_mode_config(self, tool_id: str, spec: ConfigSpec) -> Path | None:
46
+ path = spec.paths.get(tool_id)
47
+ return (self.project_root / path).resolve() if path else None
48
+
49
+ def _bundled_mode_config(self, config: ToolConfigSpec) -> Path | None:
50
+ bundled = self._bundled_path(config)
51
+ return bundled.resolve() if bundled and bundled.exists() else None
52
+
53
+ def _auto_mode_config(self, config: ToolConfigSpec) -> Path | None:
54
+ if config.use_pyproject_as_path and self._pyproject_has_table(*config.pyproject):
55
+ return self.project_root / "pyproject.toml"
56
+ if self._repo_has_config(config):
57
+ return None
58
+ return self._bundled_mode_config(config)
59
+
60
+ def _config_argv(self, tool: ToolDef, config_path: Path) -> list[str]:
61
+ if not config_path.exists() or tool.config is None:
62
+ return []
63
+ if not tool.config.should_pass_flag() or not tool.config.flag:
64
+ return []
65
+ return [tool.config.flag, str(config_path)]
66
+
67
+ def _repo_has_config(self, config: ToolConfigSpec) -> bool:
68
+ if self._repo_has_files(config):
69
+ return True
70
+ if self._repo_has_dirs(config):
71
+ return True
72
+ return bool(config.pyproject) and self._pyproject_has_table(*config.pyproject)
73
+
74
+ def _repo_has_files(self, config: ToolConfigSpec) -> bool:
75
+ return any((self.project_root / name).exists() for name in config.repo_files)
76
+
77
+ def _repo_has_dirs(self, config: ToolConfigSpec) -> bool:
78
+ return any(self._nonempty_dir(name) for name in config.repo_dirs)
79
+
80
+ def _nonempty_dir(self, rel: str) -> bool:
81
+ candidate = self.project_root / rel
82
+ return candidate.is_dir() and any(candidate.iterdir())
83
+
84
+ def _bundled_path(self, config: ToolConfigSpec) -> Path | None:
85
+ if config.bundled_path:
86
+ path = BUNDLE_ROOT / config.bundled_path
87
+ return path if path.exists() else None
88
+ if config.bundled:
89
+ path = self.defaults_dir / config.bundled
90
+ return path if path.exists() else None
91
+ return None
92
+
93
+ def _pyproject_has_table(self, *keys: str) -> bool:
94
+ if not keys:
95
+ return False
96
+ data = self._load_pyproject()
97
+ if data is None:
98
+ return False
99
+ return _dict_has_path(data, keys)
100
+
101
+ def _load_pyproject(self) -> dict | None:
102
+ pyproject = self.project_root / "pyproject.toml"
103
+ if not pyproject.exists():
104
+ return None
105
+ try:
106
+ return tomllib.loads(pyproject.read_text(encoding="utf-8"))
107
+ except tomllib.TOMLDecodeError:
108
+ return None
109
+
110
+
111
+ def _dict_has_path(data: dict, keys: tuple[str, ...]) -> bool:
112
+ node: object = data
113
+ for key in keys:
114
+ if not isinstance(node, dict) or key not in node:
115
+ return False
116
+ node = node[key]
117
+ return True