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,50 @@
1
+ """Discover check targets from tool file globs under a scan root.
2
+
3
+ Empty ``ToolDef.files`` (and no check-level ``include``) means the tool accepts a
4
+ directory root — pass ``target`` through unchanged. Non-empty globs expand to
5
+ matching files under ``project_root / target``, filtered by the effective
6
+ gitignore matcher. An empty match set means the check should be skipped
7
+ (silent pass).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from pyossmtool.ignore import EffectiveIgnores
15
+ from pyossmtool.models import CheckDef, ToolDef
16
+ from pyossmtool.target_expand import expand_include_globs
17
+
18
+
19
+ def effective_file_globs(check: CheckDef, tool: ToolDef) -> list[str]:
20
+ """Check-level include narrows; otherwise use tool files."""
21
+ if check.include:
22
+ return list(check.include)
23
+ return list(tool.files)
24
+
25
+
26
+ def argv_targets_for_check(
27
+ *,
28
+ project_root: Path,
29
+ check: CheckDef,
30
+ tool: ToolDef,
31
+ target: str,
32
+ ignores: EffectiveIgnores | None = None,
33
+ ) -> list[str] | None:
34
+ globs = effective_file_globs(check, tool)
35
+ if not globs:
36
+ return [target]
37
+ root = (project_root / target).resolve()
38
+ matches = expand_include_globs(root, globs)
39
+ if ignores is not None:
40
+ matches = [rel for rel in matches if not _is_ignored(target, rel, ignores)]
41
+ if not matches:
42
+ return None
43
+ return [str(Path(target) / rel) for rel in matches]
44
+
45
+
46
+ def _is_ignored(target: str, rel: str, ignores: EffectiveIgnores) -> bool:
47
+ repo_relative = str(Path(target) / rel).replace("\\", "/")
48
+ if repo_relative.startswith("./"):
49
+ repo_relative = repo_relative[2:]
50
+ return ignores.is_ignored(repo_relative)
@@ -0,0 +1,119 @@
1
+ """Load gate policy configuration (paths mode > project file > bundled)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from pyossmtool.models import CheckDef, ConfigMode, GateConfigSpec, ProjectConfig
11
+ from pyossmtool.registry import BUNDLE_ROOT
12
+
13
+ BUNDLED_CONFIGS = BUNDLE_ROOT / "defaults" / "configs"
14
+ BUNDLED_ALLOWLISTS = BUNDLE_ROOT / "defaults" / "allowlists"
15
+
16
+
17
+ def resolve_gate_config_path(
18
+ check: CheckDef,
19
+ project_root: Path,
20
+ project_config: ProjectConfig | None,
21
+ ) -> Path | None:
22
+ override = _paths_mode_override(check.id, project_root, project_config)
23
+ if override is not None:
24
+ return override
25
+
26
+ spec = check.config
27
+ if spec is None:
28
+ return None
29
+
30
+ if spec.project_file:
31
+ project_path = project_root / spec.project_file
32
+ if project_path.exists():
33
+ return project_path.resolve()
34
+
35
+ return _bundled_config_path(spec)
36
+
37
+
38
+ def load_gate_config(
39
+ check: CheckDef,
40
+ project_root: Path,
41
+ project_config: ProjectConfig | None,
42
+ ) -> tuple[Path | None, dict[str, Any]]:
43
+ path = resolve_gate_config_path(check, project_root, project_config)
44
+ if path is None or not path.exists():
45
+ return path, {}
46
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
47
+ if not isinstance(data, dict):
48
+ raise ValueError(f"Gate config must be a mapping: {path}")
49
+ config = dict(data)
50
+ _resolve_allowlist_path(check, project_root, config)
51
+ return path, config
52
+
53
+
54
+ def _paths_mode_override(check_id: str, project_root: Path, project_config: ProjectConfig | None) -> Path | None:
55
+ spec = project_config.configs if project_config else None
56
+ if not spec or spec.mode != ConfigMode.PATHS:
57
+ return None
58
+ override = spec.paths.get(check_id)
59
+ return (project_root / override).resolve() if override else None
60
+
61
+
62
+ def _bundled_config_path(spec: GateConfigSpec) -> Path | None:
63
+ if not spec.bundled:
64
+ return None
65
+ path = BUNDLED_CONFIGS / spec.bundled
66
+ return path.resolve() if path.exists() else None
67
+
68
+
69
+ def _resolve_allowlist_path(check: CheckDef, project_root: Path, config: dict[str, Any]) -> None:
70
+ raw = config.get("allowlist_file")
71
+ if not raw:
72
+ return
73
+ path = _allowlist_path(project_root, raw)
74
+ if path.exists():
75
+ config["allowlist_file"] = str(path.resolve())
76
+ return
77
+ bundled = _bundled_allowlist(check)
78
+ config["allowlist_file"] = str(bundled.resolve() if bundled else path.resolve())
79
+
80
+
81
+ def _allowlist_path(project_root: Path, raw: Any) -> Path:
82
+ path = Path(str(raw))
83
+ if path.is_absolute():
84
+ return path
85
+ return project_root / path
86
+
87
+
88
+ def _bundled_allowlist(check: CheckDef) -> Path | None:
89
+ if check.config is None or not check.config.allowlist_bundled:
90
+ return None
91
+ bundled = BUNDLED_ALLOWLISTS / check.config.allowlist_bundled
92
+ return bundled if bundled.exists() else None
93
+
94
+
95
+ def gate_env_from_config(config: dict[str, Any], project_root: Path) -> dict[str, str]:
96
+ env: dict[str, str] = {}
97
+ for key, value in config.items():
98
+ if value is None:
99
+ continue
100
+ env[f"GATE_{key.upper()}"] = _gate_env_value(value)
101
+ allowlist = config.get("allowlist_file")
102
+ if allowlist:
103
+ env["GATE_ALLOWLIST_FILE"] = str(_allowlist_env_path(allowlist, project_root))
104
+ return env
105
+
106
+
107
+ def _allowlist_env_path(allowlist: Any, project_root: Path) -> Path:
108
+ path = Path(str(allowlist))
109
+ if not path.is_absolute():
110
+ path = project_root / path
111
+ return path.resolve()
112
+
113
+
114
+ def _gate_env_value(value: Any) -> str:
115
+ if isinstance(value, list):
116
+ return " ".join(str(item) for item in value)
117
+ if isinstance(value, bool):
118
+ return "1" if value else "0"
119
+ return str(value)
pyossmtool/gates.py ADDED
@@ -0,0 +1,102 @@
1
+ """Script gate scaffolding and library path resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import stat
7
+ from pathlib import Path
8
+
9
+ from pyossmtool.registry import BUNDLE_ROOT
10
+
11
+ GATES_LIB = BUNDLE_ROOT / "defaults" / "gates" / "lib.sh"
12
+ PROJECT_GATES_DIR = ".pyossmtool/gates"
13
+ PROJECT_CHECKS_DIR = ".pyossmtool/catalog/checks"
14
+
15
+
16
+ def lib_path() -> Path:
17
+ return GATES_LIB
18
+
19
+
20
+ def default_report_path(check_id: str) -> str:
21
+ return f".pyossmtool/reports/{check_id}.json"
22
+
23
+
24
+ def gate_check_id(name: str) -> str:
25
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
26
+ return f"gate.{slug}"
27
+
28
+
29
+ def gate_script_path(name: str) -> str:
30
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
31
+ return f"{PROJECT_GATES_DIR}/{slug}.sh"
32
+
33
+
34
+ def gate_catalog_path(name: str) -> Path:
35
+ return Path(PROJECT_CHECKS_DIR) / f"{gate_check_id(name)}.yaml"
36
+
37
+
38
+ def render_gate_script(name: str, description: str) -> str:
39
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
40
+ return f"""#!/usr/bin/env bash
41
+ # {description}
42
+ set -euo pipefail
43
+
44
+ # shellcheck source=/dev/null
45
+ source "$(pyossmtool gates lib-path)"
46
+
47
+ gate_init "{slug}"
48
+
49
+ # Example: fail when a scan root is missing
50
+ if [[ ! -d "${{PYOSSMTOOL_TARGET:-.}}" ]]; then
51
+ \tgate_fail "missing-target" "Scan target not found: ${{PYOSSMTOOL_TARGET:-.}}"
52
+ fi
53
+
54
+ # Add your policy checks here, e.g.:
55
+ # gate_fail "module-size" "src/foo.py exceeds line limit" "src/foo.py" "1"
56
+
57
+ gate_finish
58
+ """
59
+
60
+
61
+ def render_gate_catalog(name: str, description: str) -> str:
62
+ check_id = gate_check_id(name)
63
+ script = gate_script_path(name)
64
+ report = default_report_path(check_id)
65
+ title = name.replace("-", " ").title()
66
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
67
+ return f"""id: {check_id}
68
+ tool: script
69
+ name: {title}
70
+ description: {description}
71
+ script: {script}
72
+ parser: gate_json
73
+ output_file: {report}
74
+ argv: []
75
+ config:
76
+ bundled: gates/{slug}.yaml
77
+ project_file: .pyossmtool/configs/gates/{check_id}.yaml
78
+ success:
79
+ exit_codes: [0]
80
+ remediation:
81
+ suggested_commands:
82
+ - "./{script}"
83
+ """
84
+
85
+
86
+ def scaffold_gate(project_root: Path, name: str, description: str) -> tuple[Path, Path]:
87
+ script_rel = gate_script_path(name)
88
+ script_path = project_root / script_rel
89
+ catalog_path = project_root / gate_catalog_path(name)
90
+
91
+ script_path.parent.mkdir(parents=True, exist_ok=True)
92
+ catalog_path.parent.mkdir(parents=True, exist_ok=True)
93
+
94
+ if script_path.exists():
95
+ raise FileExistsError(f"Gate script already exists: {script_path}")
96
+ if catalog_path.exists():
97
+ raise FileExistsError(f"Gate catalog entry already exists: {catalog_path}")
98
+
99
+ script_path.write_text(render_gate_script(name, description), encoding="utf-8")
100
+ script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
101
+ catalog_path.write_text(render_gate_catalog(name, description), encoding="utf-8")
102
+ return script_path, catalog_path
pyossmtool/ignore.py ADDED
@@ -0,0 +1,270 @@
1
+ """Resolve and apply unified ignore profiles and paths for quality checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ import pathspec
9
+ import yaml
10
+
11
+ from pyossmtool.models import CheckDef, IgnoreSpec, ProjectConfig, SuiteCheckRef, SuiteDef, ToolDef
12
+
13
+
14
+ @dataclass
15
+ class ToolIgnoreMaterial:
16
+ """Tool-specific ignore materialization output."""
17
+
18
+ argv: list[str] = field(default_factory=list)
19
+ config_path: Path | None = None
20
+ skip_file: Path | None = None
21
+ post_subcommand: bool = False
22
+
23
+
24
+ @dataclass
25
+ class EffectiveIgnores:
26
+ """Merged ignore patterns for a single check run."""
27
+
28
+ profile_files: list[str] = field(default_factory=list)
29
+ path_patterns: list[str] = field(default_factory=list)
30
+ _matcher: pathspec.PathSpec | None = field(default=None, repr=False)
31
+
32
+ def matcher(self) -> pathspec.PathSpec:
33
+ if self._matcher is None:
34
+ patterns = list(self.path_patterns)
35
+ for profile in self.profile_files:
36
+ patterns.extend(_load_profile_patterns(Path(profile)))
37
+ self._matcher = pathspec.PathSpec.from_lines("gitignore", patterns)
38
+ return self._matcher
39
+
40
+ def is_ignored(self, repo_relative_path: str) -> bool:
41
+ normalized = _normalize_repo_path(repo_relative_path)
42
+ if not normalized:
43
+ return False
44
+ return self.matcher().match_file(normalized)
45
+
46
+
47
+ def merge_ignore_specs(*specs: IgnoreSpec | None) -> IgnoreSpec:
48
+ profiles: list[str] = []
49
+ paths: list[str] = []
50
+ seen_profiles: set[str] = set()
51
+ seen_paths: set[str] = set()
52
+ for spec in specs:
53
+ if spec is None:
54
+ continue
55
+ _extend_unique(profiles, seen_profiles, spec.ignore_profile)
56
+ _extend_unique(paths, seen_paths, spec.ignore_paths)
57
+ return IgnoreSpec(ignore_profile=profiles, ignore_paths=paths)
58
+
59
+
60
+ def _extend_unique(target: list[str], seen: set[str], values: list[str]) -> None:
61
+ for item in values:
62
+ if item in seen:
63
+ continue
64
+ seen.add(item)
65
+ target.append(item)
66
+
67
+
68
+ def resolve_effective_ignores(
69
+ project_root: Path,
70
+ *,
71
+ suite: SuiteDef | None = None,
72
+ project_config: ProjectConfig | None = None,
73
+ check_ref: SuiteCheckRef | None = None,
74
+ check: CheckDef | None = None,
75
+ bundled_patterns: list[str] | None = None,
76
+ ) -> EffectiveIgnores:
77
+ merged = merge_ignore_specs(
78
+ IgnoreSpec(ignore_profile=[".gitignore"], ignore_paths=bundled_patterns or []),
79
+ _ignore_spec_from(suite),
80
+ _ignore_spec_from(project_config),
81
+ _ignore_spec_from(check),
82
+ _ignore_spec_from(check_ref),
83
+ )
84
+ return _effective_from_merged(merged, project_root)
85
+
86
+
87
+ def _ignore_spec_from(source) -> IgnoreSpec:
88
+ if source is None:
89
+ return IgnoreSpec()
90
+ return IgnoreSpec(ignore_profile=source.ignore_profile, ignore_paths=source.ignore_paths)
91
+
92
+
93
+ def _effective_from_merged(merged: IgnoreSpec, project_root: Path) -> EffectiveIgnores:
94
+ profile_files = [
95
+ str((project_root / rel).resolve()) for rel in merged.ignore_profile if (project_root / rel).resolve().is_file()
96
+ ]
97
+ path_patterns = [_normalize_pattern(pattern) for pattern in merged.ignore_paths]
98
+ return EffectiveIgnores(profile_files=profile_files, path_patterns=path_patterns)
99
+
100
+
101
+ def filter_findings(findings: list, ignores: EffectiveIgnores) -> list:
102
+
103
+ return [finding for finding in findings if _keep_finding(finding, ignores)]
104
+
105
+
106
+ def _keep_finding(finding, ignores: EffectiveIgnores) -> bool:
107
+ from pyossmtool.models import Finding
108
+
109
+ if not isinstance(finding, Finding):
110
+ return True
111
+ if not finding.location or not finding.location.file:
112
+ return True
113
+ return not ignores.is_ignored(finding.location.file)
114
+
115
+
116
+ def materialize_for_tool(
117
+ tool: ToolDef,
118
+ ignores: EffectiveIgnores,
119
+ *,
120
+ project_root: Path,
121
+ check_id: str,
122
+ base_config_path: Path | None = None,
123
+ ) -> ToolIgnoreMaterial:
124
+ from pyossmtool.ignore_materialize import materialize_for_tool as _materialize
125
+
126
+ return _materialize(
127
+ tool,
128
+ ignores,
129
+ project_root=project_root,
130
+ check_id=check_id,
131
+ base_config_path=base_config_path,
132
+ )
133
+
134
+
135
+ def ignore_env(ignores: EffectiveIgnores, project_root: Path) -> dict[str, str]:
136
+ profiles = [str((project_root / rel).resolve()) for rel in _profile_rel_paths(ignores, project_root)]
137
+ env: dict[str, str] = {}
138
+ if profiles:
139
+ env["PYOSSMTOOL_IGNORE_PROFILES"] = "\n".join(profiles)
140
+ if ignores.path_patterns:
141
+ env["PYOSSMTOOL_IGNORE_PATHS"] = "\n".join(ignores.path_patterns)
142
+ return env
143
+
144
+
145
+ def _profile_rel_paths(ignores: EffectiveIgnores, project_root: Path) -> list[str]:
146
+ rels: list[str] = []
147
+ for path in ignores.profile_files:
148
+ try:
149
+ rels.append(str(Path(path).resolve().relative_to(project_root.resolve())))
150
+ except ValueError:
151
+ rels.append(path)
152
+ return rels
153
+
154
+
155
+ def _all_patterns(ignores: EffectiveIgnores) -> list[str]:
156
+ patterns = list(ignores.path_patterns)
157
+ for profile in ignores.profile_files:
158
+ patterns.extend(_load_profile_patterns(Path(profile)))
159
+ deduped: list[str] = []
160
+ seen: set[str] = set()
161
+ for pattern in patterns:
162
+ normalized = _normalize_pattern(pattern)
163
+ if normalized and normalized not in seen:
164
+ seen.add(normalized)
165
+ deduped.append(normalized)
166
+ return deduped
167
+
168
+
169
+ def _load_profile_patterns(path: Path) -> list[str]:
170
+ if not path.is_file():
171
+ return []
172
+ return [
173
+ pattern
174
+ for line in path.read_text(encoding="utf-8").splitlines()
175
+ if (pattern := _profile_line_pattern(line)) is not None
176
+ ]
177
+
178
+
179
+ def _profile_line_pattern(line: str) -> str | None:
180
+ stripped = line.strip()
181
+ if not stripped or stripped.startswith("#") or stripped.startswith("!"):
182
+ return None
183
+ return stripped
184
+
185
+
186
+ def _normalize_pattern(pattern: str) -> str:
187
+ pattern = pattern.strip().replace("\\", "/")
188
+ if pattern.startswith("./"):
189
+ pattern = pattern[2:]
190
+ return pattern.rstrip("/") if not pattern.endswith("/") else pattern
191
+
192
+
193
+ def _normalize_repo_path(path: str) -> str:
194
+ normalized = path.strip().replace("\\", "/")
195
+ if normalized.startswith("./"):
196
+ normalized = normalized[2:]
197
+ return normalized
198
+
199
+
200
+ def _patterns_as_yaml_block(patterns: list[str]) -> str:
201
+ return "\n".join(patterns) + ("\n" if patterns else "")
202
+
203
+
204
+ def _merge_yaml_config(base_path: Path | None, overlay: dict) -> dict:
205
+ merged = _load_yaml_dict(base_path)
206
+ for key, value in overlay.items():
207
+ merged[key] = _merge_yaml_value(merged, key, value)
208
+ return merged
209
+
210
+
211
+ def _load_yaml_dict(base_path: Path | None) -> dict:
212
+ if not base_path or not base_path.exists():
213
+ return {}
214
+ loaded = yaml.safe_load(base_path.read_text(encoding="utf-8")) or {}
215
+ return loaded if isinstance(loaded, dict) else {}
216
+
217
+
218
+ def _merge_yaml_value(merged: dict, key: str, value) -> object:
219
+ if key == "ignore" and "ignore" in merged:
220
+ return _merge_ignore_block(merged["ignore"], value)
221
+ if key == "exclude" and "exclude" in merged:
222
+ return _merge_exclude_list(merged["exclude"], value)
223
+ return value
224
+
225
+
226
+ def _merge_ignore_block(existing_value, value) -> str:
227
+ existing = str(existing_value).strip().splitlines()
228
+ new_lines = str(value).strip().splitlines()
229
+ return _patterns_as_yaml_block(list(dict.fromkeys([*existing, *new_lines])))
230
+
231
+
232
+ def _merge_exclude_list(existing_value, value) -> list:
233
+ existing = list(existing_value) if isinstance(existing_value, list) else []
234
+ new_items = value if isinstance(value, list) else [value]
235
+ return list(dict.fromkeys([*existing, *new_items]))
236
+
237
+
238
+ def bundled_tool_ignore_patterns(tool_id: str) -> list[str]:
239
+ """Static bundled defaults merged before suite/project ignores."""
240
+ if tool_id == "deadcode":
241
+ return ["**/models.py"]
242
+ return []
243
+
244
+
245
+ def ignores_from_env(project_root: Path | None = None) -> EffectiveIgnores:
246
+ """Build ignores from PYOSSMTOOL_IGNORE_* environment variables (script gates)."""
247
+ import os
248
+
249
+ root = (project_root or Path.cwd()).resolve()
250
+ profile_files = _resolve_env_profiles(root, os.environ.get("PYOSSMTOOL_IGNORE_PROFILES", ""))
251
+ path_patterns = _env_path_patterns(os.environ.get("PYOSSMTOOL_IGNORE_PATHS", ""))
252
+ return EffectiveIgnores(profile_files=profile_files, path_patterns=path_patterns)
253
+
254
+
255
+ def _env_path_patterns(text: str) -> list[str]:
256
+ return [_normalize_pattern(item) for item in text.splitlines() if item.strip()]
257
+
258
+
259
+ def _resolve_env_profiles(root: Path, text: str) -> list[str]:
260
+ resolved: list[str] = []
261
+ for item in text.splitlines():
262
+ profile = item.strip()
263
+ if not profile:
264
+ continue
265
+ path = Path(profile)
266
+ if not path.is_absolute():
267
+ path = root / path
268
+ if path.is_file():
269
+ resolved.append(str(path.resolve()))
270
+ return resolved