cbp-action 0.1.7__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.
cbp/__init__.py ADDED
File without changes
@@ -0,0 +1,142 @@
1
+ """Release-artifact and Python environment integrity checks."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import hashlib
6
+ import re
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ from .config import GateConfig
11
+
12
+ SKIP_PARTS = {".git", ".venv", "__pycache__", "node_modules", "wheelhouse", "qa-temp", "build"}
13
+ SKIP_PREFIXES = ("quality/", "checks/", "tests/", "tools/audit_", "tools/red_team", "tools/package_release", "tools/prune_", "tools/restructure_", "tools/rewrite_")
14
+ BAD_NAMES = {"netstat", "netstat.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "cmd", "cmd.exe"}
15
+
16
+
17
+ def artifact_findings(cfg: GateConfig) -> list[str]:
18
+ """Verify configured artifacts inside an optional package ZIP."""
19
+ required = getattr(cfg, "required_artifacts", ()) or ()
20
+ if not getattr(cfg, "package_zip", None):
21
+ return []
22
+ if not required:
23
+ return ["package_zip is configured but required_artifacts is empty"]
24
+ zip_path = Path(cfg.package_zip)
25
+ if not zip_path.is_file():
26
+ return [f"package zip missing: {zip_path}"]
27
+ findings: list[str] = []
28
+ try:
29
+ with zipfile.ZipFile(zip_path) as archive:
30
+ names = set(archive.namelist())
31
+ manifest: dict[str, str] = {}
32
+ if "update-manifest.json" in names:
33
+ import json
34
+ data = json.loads(archive.read("update-manifest.json").decode("utf-8"))
35
+ manifest = {str(item["path"]): str(item["sha256"]) for item in data.get("files", [])}
36
+ for item in required:
37
+ path = str(item.get("path")) if isinstance(item, dict) else str(item)
38
+ expected = str(item.get("sha256", "")) if isinstance(item, dict) else manifest.get(path, "")
39
+ if path not in names:
40
+ findings.append(f"required artifact missing from zip: {path}")
41
+ continue
42
+ if expected:
43
+ digest = hashlib.sha256(archive.read(path)).hexdigest()
44
+ if digest.lower() != expected.lower():
45
+ findings.append(f"required artifact hash mismatch: {path}")
46
+ except (OSError, zipfile.BadZipFile, ValueError) as exc:
47
+ return [f"cannot inspect package zip: {exc}"]
48
+ return findings
49
+
50
+
51
+ def _is_absolute_command(value: str) -> bool:
52
+ return value.startswith(("\\", "/")) or (len(value) > 2 and value[1] == ":")
53
+
54
+
55
+ PATH_ESCAPE = None # line-based check below avoids regex escaping ambiguity
56
+ ACTION_REF = re.compile(r"uses:\s*([^\s#]+)")
57
+ FULL_SHA = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$")
58
+
59
+
60
+ def path_escape_findings(cfg: GateConfig) -> list[str]:
61
+ """Reject hard-coded Windows paths whose backslash becomes an escape."""
62
+ findings: list[str] = []
63
+ for path in list(cfg.root.rglob("*.py")) + list(cfg.root.rglob("*.ps1")):
64
+ if SKIP_PARTS & set(path.parts):
65
+ continue
66
+ for line_no, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
67
+ if "C:\\" in line and any(token in line for token in ("\t", "\n", "\r")):
68
+ findings.append(f"{path.relative_to(cfg.root).as_posix()}:{line_no}: ambiguous Windows path escape; use os.path.join or escaped slashes")
69
+ return findings
70
+
71
+
72
+ def python_env_findings(cfg: GateConfig) -> list[str]:
73
+ """Reject obvious PATH-dependent subprocess calls and missing sys.path guards."""
74
+ findings: list[str] = []
75
+ for path in cfg.root.rglob("*.py"):
76
+ rel = path.relative_to(cfg.root).as_posix()
77
+ if SKIP_PARTS & set(path.parts) or rel.startswith(SKIP_PREFIXES):
78
+ continue
79
+ try:
80
+ tree = ast.parse(path.read_text(encoding="utf-8"))
81
+ except (OSError, SyntaxError):
82
+ continue
83
+ for node in ast.walk(tree):
84
+ if not isinstance(node, ast.Call) or not node.args:
85
+ continue
86
+ func = node.func
87
+ name = ""
88
+ if isinstance(func, ast.Attribute):
89
+ name = func.attr
90
+ if name not in {"run", "Popen", "call", "check_output", "system"}:
91
+ continue
92
+ first = node.args[0]
93
+ command = ""
94
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
95
+ command = first.value
96
+ elif isinstance(first, (ast.List, ast.Tuple)) and first.elts:
97
+ head = first.elts[0]
98
+ if isinstance(head, ast.Constant) and isinstance(head.value, str):
99
+ command = head.value
100
+ if command:
101
+ token = command.strip().split()[0] if command.strip() else ""
102
+ base = Path(token).name.lower()
103
+ if base in BAD_NAMES and not _is_absolute_command(token):
104
+ findings.append(f"{path.relative_to(cfg.root).as_posix()}:{node.lineno}: naked external command {token!r}")
105
+ for name in ("tools/broker.py", "webui/run.py"):
106
+ path = cfg.root / name
107
+ if path.is_file() and "sys.path.insert" not in path.read_text(encoding="utf-8"):
108
+ findings.append(f"{name}: missing sys.path.insert bootstrap")
109
+ for path in list(cfg.root.rglob("*.bat")) + list(cfg.root.rglob("*.ps1")):
110
+ if SKIP_PARTS & set(path.parts):
111
+ continue
112
+ text = path.read_text(encoding="utf-8", errors="replace")
113
+ for line in text.splitlines():
114
+ if "PYTHONPATH" in line and "=" in line:
115
+ if "tools" not in line:
116
+ findings.append(f"{path.relative_to(cfg.root).as_posix()}: PYTHONPATH must include tools/")
117
+ break
118
+ return findings
119
+
120
+
121
+ def action_pin_findings(cfg: GateConfig) -> list[str]:
122
+ """Require every GitHub Action use to be pinned to a full commit SHA."""
123
+ findings: list[str] = []
124
+ for path in list(cfg.root.rglob("*.yml")) + list(cfg.root.rglob("*.yaml")):
125
+ rel = path.relative_to(cfg.root).as_posix()
126
+ if SKIP_PARTS & set(path.parts) or rel.endswith("usage.yml") or rel.startswith(("examples/", "docs/")) or "/examples/" in rel or "/docs/" in rel:
127
+ continue
128
+ for line_no, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
129
+ match = ACTION_REF.search(line)
130
+ if not match:
131
+ continue
132
+ ref = match.group(1).strip("'\"")
133
+ if ref.startswith("./") or ref.startswith("docker://"):
134
+ continue
135
+ if not FULL_SHA.fullmatch(ref):
136
+ findings.append(f"{path.relative_to(cfg.root).as_posix()}:{line_no}: GitHub Action is not pinned to a full SHA: {ref}")
137
+ return findings
138
+
139
+
140
+ def check(cfg: GateConfig) -> list[str]:
141
+ """Run both release-integrity checks."""
142
+ return artifact_findings(cfg) + python_env_findings(cfg) + path_escape_findings(cfg) + action_pin_findings(cfg)
cbp/baseline.py ADDED
@@ -0,0 +1,49 @@
1
+ """Baseline fingerprints for pre-existing findings."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+
8
+ SCHEMA = "cbp-baseline/v1"
9
+
10
+
11
+ def fingerprint(gate: str, line: str) -> str:
12
+ return hashlib.sha256((gate + "\0" + line.strip()).encode("utf-8")).hexdigest()
13
+
14
+
15
+ def load(path: Path | str | None) -> set[str]:
16
+ if not path:
17
+ return set()
18
+ p = Path(path)
19
+ if not p.is_file():
20
+ return set()
21
+ data = json.loads(p.read_text(encoding="utf-8"))
22
+ return {str(item["fingerprint"]) for item in data.get("entries", []) if isinstance(item, dict) and item.get("fingerprint")}
23
+
24
+
25
+ def write(path: Path, gate: str, lines: list[str]) -> int:
26
+ entries = [{"gate": gate, "fingerprint": fingerprint(gate, line), "line": line} for line in lines]
27
+ data = {"schema": SCHEMA, "version": 1, "entries": entries}
28
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
29
+ return len(entries)
30
+
31
+
32
+ def filter_lines(cfg, gate: str, lines: list[str]) -> list[str]:
33
+ configured = getattr(cfg, "baseline", "")
34
+ path = cfg.path(configured) if configured else cfg.root / "cbp-baseline.json"
35
+ allowed = load(path)
36
+ return [line for line in lines if fingerprint(gate, line) not in allowed]
37
+
38
+
39
+ def write_entries(path: Path, entries: list[tuple[str, str]]) -> int:
40
+ data = {
41
+ "schema": SCHEMA,
42
+ "version": 1,
43
+ "entries": [
44
+ {"gate": gate, "fingerprint": fingerprint(gate, line), "line": line}
45
+ for gate, line in entries
46
+ ],
47
+ }
48
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + chr(10), encoding="utf-8")
49
+ return len(data["entries"])
cbp/binary_gate.py ADDED
@@ -0,0 +1,65 @@
1
+ """Generic large-binary provenance gate."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ from .config import GateConfig
10
+
11
+ SUFFIXES = (".exe", ".dll", ".bin", ".so", ".dylib", ".whl", ".tar.gz")
12
+ SUPPORTED_SCOPES = {"self-contained-runtime", "runtime-pack", "runtime-only"}
13
+
14
+
15
+ def info_size(archive, name):
16
+ """Return a ZIP member's uncompressed size."""
17
+ return archive.getinfo(name).file_size
18
+
19
+
20
+ def _candidates(cfg: GateConfig):
21
+ package = cfg.path(cfg.package_zip) if cfg.package_zip else None
22
+ if package and package.is_file():
23
+ with zipfile.ZipFile(package) as archive:
24
+ return [(name, info_size(archive, name), archive.read(name)) for name in archive.namelist() if name.lower().endswith(SUFFIXES) and info_size(archive, name) > cfg.binary_limit_mb * 1024 * 1024]
25
+ result = []
26
+ for path in cfg.root.rglob("*"):
27
+ if path.is_file() and path.name.lower().endswith(SUFFIXES) and path.stat().st_size > cfg.binary_limit_mb * 1024 * 1024:
28
+ text = path.as_posix()
29
+ if (
30
+ ".git" not in path.parts
31
+ and ".venv" not in path.parts
32
+ and "wheelhouse" not in path.parts
33
+ and "build/bin" not in text
34
+ and "build/obj" not in text
35
+ and "build/publish" not in text
36
+ ):
37
+ result.append((path.name, path.stat().st_size, path.read_bytes()))
38
+ return result
39
+
40
+
41
+ def run(cfg: GateConfig) -> tuple[bool, str]:
42
+ """Require provenance and SBOM records for large release binaries."""
43
+ candidates = _candidates(cfg)
44
+ for name, size, data in candidates:
45
+ artifacts = cfg.path(cfg.binary_artifacts)
46
+ build = artifacts / (Path(name).name + ".build.json")
47
+ sbom = artifacts / (Path(name).name + ".sbom.json")
48
+ if not build.is_file() or not sbom.is_file():
49
+ return False, f"{name} lacks build/SBOM provenance"
50
+ sbom_record = json.loads(sbom.read_text(encoding="utf-8"))
51
+ scope = sbom_record.get("scope")
52
+ if scope is not None and scope not in SUPPORTED_SCOPES:
53
+ return False, f"{name} has unsupported SBOM scope {scope!r}"
54
+ if sbom_record.get("binary_sha256") and sbom_record["binary_sha256"].upper() != hashlib.sha256(data).hexdigest().upper():
55
+ return False, f"{name} SBOM binary hash mismatch"
56
+ if "binary_size" in sbom_record and int(sbom_record["binary_size"]) != size:
57
+ return False, f"{name} SBOM binary size mismatch"
58
+ record = json.loads(build.read_text(encoding="utf-8"))
59
+ if record.get("sha256", "").upper() != hashlib.sha256(data).hexdigest().upper():
60
+ return False, f"{name} provenance hash mismatch"
61
+ if int(record.get("size_bytes", -1)) != size:
62
+ return False, f"{name} provenance size mismatch"
63
+ if not candidates:
64
+ return True, "N/A (no binaries found)"
65
+ return True, f"binaries={len(candidates)}"
cbp/claims_gate.py ADDED
@@ -0,0 +1,85 @@
1
+ """Generic release-claim reconciliation gate."""
2
+ from __future__ import annotations
3
+
4
+ RULE_ID = "CBP-CLM-001"
5
+
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from .config import GateConfig
11
+
12
+ TOKEN = re.compile(r"(?m)^QUALITY_COUNTS:\s*(\d+)/(\d+)\s*$")
13
+ GATE_CLAIM = re.compile(r"\b(?:all|across|through)\s+([A-Za-z]+|\d+)\s+gates?\b", re.IGNORECASE)
14
+ WORDS = {
15
+ word: number
16
+ for number, word in enumerate(
17
+ ("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten")
18
+ )
19
+ }
20
+
21
+
22
+ def _gate_number(text: str, total: int, *, required: bool) -> str:
23
+ match = GATE_CLAIM.search(text)
24
+ if not match:
25
+ return "release body must state the gate count" if required else ""
26
+ token = match.group(1).lower()
27
+ number = int(token) if token.isdigit() else WORDS.get(token)
28
+ if number is None:
29
+ return f"unknown gate count claim: {match.group(0)}"
30
+ if number != total:
31
+ return f"notes claim {token} gates but manifest has {total}"
32
+ return ""
33
+
34
+
35
+ def _body_problem(path: Path, expected: str, total: int) -> str:
36
+ if not path.is_file():
37
+ return f"release body missing: {path}"
38
+ text = path.read_text(encoding="utf-8")
39
+ tokens = TOKEN.findall(text)
40
+ if len(tokens) != 1:
41
+ return "release body needs exactly one QUALITY_COUNTS line"
42
+ actual = f"{tokens[0][0]}/{tokens[0][1]}"
43
+ if actual != expected:
44
+ return f"release body {actual} != manifest {expected}"
45
+ problem = _gate_number(text, total, required=True)
46
+ if problem:
47
+ return "release body " + problem
48
+ if "First public release" in text:
49
+ return "release body must not claim First public release"
50
+ return ""
51
+
52
+
53
+ def run(cfg: GateConfig) -> tuple[bool, str]:
54
+ """Match release-note quality claims to the machine manifest."""
55
+ if not cfg.claims_manifest or not cfg.claims_notes:
56
+ return False, "claims_manifest and claims_notes must be configured"
57
+ manifest = cfg.path(cfg.claims_manifest)
58
+ notes = cfg.path(cfg.claims_notes)
59
+ if not manifest.is_file():
60
+ return False, (
61
+ "quality-manifest.json missing. "
62
+ "Run `python tools/dogfood_manifest.py --root .` first to generate a local manifest."
63
+ )
64
+ if not notes.is_file():
65
+ return False, "release notes missing. Run the dogfood command first to generate them."
66
+ data = json.loads(manifest.read_text(encoding="utf-8"))
67
+ counts = data.get("counts", {})
68
+ expected = f"{counts.get('passed', -1)}/{counts.get('total', -1)}"
69
+ text = notes.read_text(encoding="utf-8")
70
+ tokens = TOKEN.findall(text)
71
+ if len(tokens) != 1:
72
+ return False, "release notes need exactly one QUALITY_COUNTS line"
73
+ actual = f"{tokens[0][0]}/{tokens[0][1]}"
74
+ if actual != expected:
75
+ return False, f"notes {actual} != manifest {expected}"
76
+ total = int(counts.get("total", -1))
77
+ gate_problem = _gate_number(text, total, required=False)
78
+ if gate_problem:
79
+ return False, gate_problem
80
+ body = getattr(cfg, "release_body", "")
81
+ if body:
82
+ body_problem = _body_problem(cfg.path(body), expected, total)
83
+ if body_problem:
84
+ return False, body_problem
85
+ return True, f"claims={actual}"
cbp/cli.py ADDED
@@ -0,0 +1,148 @@
1
+ """Standalone CBP CLI."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import baseline, binary_gate, suppression, claims_gate, comment_gate, coverage_gate, custom_rules, dependency_gate, docs_gate, encoding_gate, sarif, upgrade_gate, verify_release
11
+ from .config import load_config
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ """Run enabled CBP gates and return a process exit code."""
16
+ parser = argparse.ArgumentParser(prog="cbp")
17
+ parser.add_argument("--root", default=".")
18
+ parser.add_argument("--config", default="")
19
+ parser.add_argument("--mode", choices=("audit", "warn", "enforce"), default="enforce")
20
+ parser.add_argument("--coverage-threshold", type=float, default=None)
21
+ parser.add_argument("--policy-coverage-threshold", type=float, default=None)
22
+ parser.add_argument("--coverage-json", default="")
23
+ parser.add_argument("--coverage-allowlist", default="")
24
+ parser.add_argument("--coverage-signature", default="")
25
+ parser.add_argument("--require-coverage-signature", type=lambda x: x.lower() == "true", default=None)
26
+ parser.add_argument("--dependency-lock", default="")
27
+ parser.add_argument("--install-command", default="")
28
+ parser.add_argument("--installer-core", dest="install_command", default=argparse.SUPPRESS)
29
+ parser.add_argument("--docs-manifest", default="")
30
+ parser.add_argument("--binary-artifacts", default="")
31
+ parser.add_argument("--package-zip", default="")
32
+ parser.add_argument("--claims-manifest", default="")
33
+ parser.add_argument("--claims-notes", default="")
34
+ parser.add_argument("--release-body", default="")
35
+ parser.add_argument("--manifest", default="")
36
+ parser.add_argument("--sarif-output", default="")
37
+ parser.add_argument("--baseline", default="")
38
+ parser.add_argument("--generate-baseline", default="")
39
+ parser.add_argument("--verify-release", default="")
40
+ parser.add_argument("--repository", default="")
41
+ parser.add_argument("--enable-coverage", type=lambda x: x.lower() == "true", default=None)
42
+ parser.add_argument("--enable-encoding", type=lambda x: x.lower() == "true", default=None)
43
+ parser.add_argument("--enable-dependency", type=lambda x: x.lower() == "true", default=None)
44
+ parser.add_argument("--enable-comments", type=lambda x: x.lower() == "true", default=None)
45
+ parser.add_argument("--enable-docs", type=lambda x: x.lower() == "true", default=None)
46
+ parser.add_argument("--enable-binary", type=lambda x: x.lower() == "true", default=None)
47
+ parser.add_argument("--enable-claims", type=lambda x: x.lower() == "true", default=None)
48
+ parser.add_argument("--enable-custom", type=lambda x: x.lower() == "true", default=None)
49
+ parser.add_argument("--enable-upgrade", type=lambda x: x.lower() == "true", default=None)
50
+ args = parser.parse_args(argv)
51
+ if args.verify_release:
52
+ repository = args.repository or os.environ.get("GITHUB_REPOSITORY", "")
53
+ if not repository:
54
+ print("VERIFY_RELEASE_FAILED: --repository or GITHUB_REPOSITORY is required")
55
+ return 1
56
+ body = Path(args.release_body) if args.release_body else Path(args.root) / "docs" / "RELEASE_BODY.md"
57
+ ok, detail = verify_release.verify(repository, args.verify_release, body, verify_release.token_from_env())
58
+ print(("RELEASE_BODY_OK " if ok else "") + detail)
59
+ return 0 if ok else 1
60
+ config_path = Path(args.config) if args.config else Path(args.root) / "cbp.json"
61
+ if not args.config and not config_path.is_file():
62
+ config_path = Path(args.root) / ".cbp" / "config.json"
63
+ overrides = {}
64
+ if args.coverage_threshold is not None:
65
+ overrides["coverage_threshold"] = args.coverage_threshold
66
+ if args.policy_coverage_threshold is not None:
67
+ overrides["policy_threshold"] = args.policy_coverage_threshold
68
+ for key, value in {
69
+ "coverage_json": args.coverage_json, "coverage_allowlist": args.coverage_allowlist, "coverage_signature": args.coverage_signature,
70
+ "dependency_lock": args.dependency_lock, "install_command": args.install_command,
71
+ "docs_manifest": args.docs_manifest, "binary_artifacts": args.binary_artifacts,
72
+ "package_zip": args.package_zip, "claims_manifest": args.claims_manifest,
73
+ "claims_notes": args.claims_notes, "release_body": args.release_body, "baseline": args.baseline,
74
+ }.items():
75
+ if value:
76
+ overrides[key] = value
77
+ if args.manifest:
78
+ overrides["claims_manifest"] = args.manifest
79
+ for key in ("enable_coverage", "require_coverage_signature", "enable_encoding", "enable_dependency", "enable_comments", "enable_docs", "enable_binary", "enable_claims", "enable_custom", "enable_upgrade"):
80
+ value = getattr(args, key)
81
+ if value is not None:
82
+ overrides[key] = value
83
+ cfg = load_config(Path(args.root), config_path, overrides)
84
+ checks = []
85
+ if cfg.enable_encoding:
86
+ checks.append(("encoding", encoding_gate.run))
87
+ if cfg.enable_coverage:
88
+ checks.append(("coverage", coverage_gate.run))
89
+ if cfg.enable_dependency:
90
+ checks.append(("dependency", dependency_gate.run))
91
+ if cfg.enable_comments:
92
+ checks.append(("comments", comment_gate.run))
93
+ if cfg.enable_docs:
94
+ checks.append(("docs", docs_gate.run))
95
+ if cfg.enable_claims:
96
+ checks.append(("claims", claims_gate.run))
97
+ if cfg.enable_custom:
98
+ checks.append(("custom", custom_rules.run))
99
+ if cfg.enable_upgrade:
100
+ checks.append(("upgrade", upgrade_gate.run))
101
+ if cfg.enable_binary:
102
+ checks.append(("binary", binary_gate.run))
103
+ ok = True
104
+ gate_results = []
105
+ for name, check in checks:
106
+ passed, detail = check(cfg)
107
+ filtered = suppression.filter_text(cfg.root, detail)
108
+ if filtered != detail:
109
+ passed = passed or not filtered.strip()
110
+ detail = filtered or "inline-suppressed"
111
+ gate_results.append((name, passed, detail))
112
+ print(f"{name}: {'PASS' if passed else 'FAIL'} {detail}")
113
+ ok = ok and passed
114
+ if not checks:
115
+ ok = False
116
+ baseline_path = Path(args.generate_baseline) if args.generate_baseline else Path(args.baseline) if args.baseline else Path(args.root) / "cbp-baseline.json"
117
+ if args.generate_baseline:
118
+ entries = []
119
+ for gate, passed, detail in gate_results:
120
+ if not passed:
121
+ entries.extend((gate, line) for line in detail.splitlines() if line.strip())
122
+ baseline.write_entries(baseline_path, entries)
123
+ print(f"BASELINE_WRITTEN path={baseline_path} entries={len(entries)}")
124
+ return 0
125
+ allowed_baseline = baseline.load(baseline_path)
126
+ if allowed_baseline:
127
+ adjusted = []
128
+ for gate, passed, detail in gate_results:
129
+ kept = [line for line in detail.splitlines() if baseline.fingerprint(gate, line) not in allowed_baseline]
130
+ if not passed and not any(line.strip() for line in kept):
131
+ passed = True
132
+ adjusted.append((gate, passed, chr(10).join(kept) if kept else "baseline-suppressed"))
133
+ gate_results = adjusted
134
+ ok = all(passed for _, passed, _ in gate_results)
135
+ if args.sarif_output:
136
+ try:
137
+ target = Path(args.sarif_output)
138
+ document = sarif.build(gate_results, Path(args.root))
139
+ target.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
140
+ except OSError as exc:
141
+ print(f"sarif: FAIL cannot write {args.sarif_output}: {exc}")
142
+ ok = False
143
+ print("CBP_RESULT=" + ("PASS" if ok else "FAIL") + " gates_ran=" + str(len(checks)))
144
+ return 0 if ok or args.mode in {"audit", "warn"} else 1
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
cbp/comment_gate.py ADDED
@@ -0,0 +1,69 @@
1
+ """Generic AST/token comment gate."""
2
+ from __future__ import annotations
3
+
4
+ RULE_ID = "CBP-CMT-001"
5
+
6
+ import ast
7
+ import re
8
+ import tokenize
9
+
10
+ from .config import GateConfig
11
+ from .baseline import filter_lines
12
+
13
+ SLOP = re.compile(r"#\s*(This is|It is|This function|This method|This class)\b", re.IGNORECASE)
14
+ SUPPRESS = re.compile(r"cbp:\s*ignore=(CBP-[A-Z]+-\d{3})", re.IGNORECASE)
15
+ GENERIC = ("caller-supplied value", "function result, or none", "helper/type")
16
+
17
+
18
+ def _suppressed(lines: list[str], lineno: int, rule: str) -> bool:
19
+ if lineno < 1 or lineno > len(lines):
20
+ return False
21
+ line = lines[lineno - 1]
22
+ match = SUPPRESS.search(line)
23
+ return bool(match and match.group(1).upper() == rule.upper())
24
+
25
+
26
+ def _check(path, findings):
27
+ text = path.read_text(encoding="utf-8")
28
+ lines = text.splitlines()
29
+ tree = ast.parse(text)
30
+ for node in ast.walk(tree):
31
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
32
+ continue
33
+ name = getattr(node, "name", "")
34
+ if name.startswith("_"):
35
+ continue
36
+ doc = ast.get_docstring(node) or ""
37
+ if len(doc.strip()) < 20:
38
+ if not _suppressed(lines, node.lineno, RULE_ID):
39
+ findings.append(f"{path.name}:{node.lineno}: public {name} lacks substantive docstring")
40
+ continue
41
+ if any(phrase in doc.casefold() for phrase in GENERIC):
42
+ if not _suppressed(lines, node.lineno, RULE_ID):
43
+ findings.append(f"{path.name}:{node.lineno}: public {name} docstring is generic boilerplate")
44
+ args = [a.arg for a in node.args.args if a.arg not in {"self", "cls"}] if hasattr(node, "args") else []
45
+ if "Args:" in doc or "Parameters:" in doc:
46
+ for arg in args:
47
+ if arg not in doc and not _suppressed(lines, node.lineno, RULE_ID):
48
+ findings.append(f"{path.name}:{node.lineno}: docstring misses {arg}")
49
+ with path.open("r", encoding="utf-8") as handle:
50
+ for token in tokenize.generate_tokens(handle.readline):
51
+ if token.type == tokenize.COMMENT and SLOP.search(token.string):
52
+ if not _suppressed(lines, token.start[0], RULE_ID):
53
+ findings.append(f"{path.name}:{token.start[0]}: slop comment {token.string.strip()}")
54
+
55
+
56
+ def run(cfg: GateConfig) -> tuple[bool, str]:
57
+ """Reject missing docstrings, argument mismatches, and slop comments."""
58
+ findings: list[str] = []
59
+ for relative in cfg.comment_files:
60
+ path = cfg.path(relative)
61
+ if path.is_file():
62
+ try:
63
+ _check(path, findings)
64
+ except SyntaxError as exc:
65
+ findings.append(f"{path.name}: parse failed: {exc}")
66
+ findings = filter_lines(cfg, "comments", findings)
67
+ if findings:
68
+ return False, "\n".join(findings[:80]) + f"\nfindings={len(findings)}"
69
+ return True, f"comments={len(cfg.comment_files)} files (balanced against baseline)"