codeguard-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. codeguard/__init__.py +7 -0
  2. codeguard/cli/__init__.py +2 -0
  3. codeguard/cli/_run.py +230 -0
  4. codeguard/cli/commands.py +390 -0
  5. codeguard/cli/formatters.py +422 -0
  6. codeguard/cli/main.py +206 -0
  7. codeguard/config/__init__.py +16 -0
  8. codeguard/config/loader.py +86 -0
  9. codeguard/config/schema.py +172 -0
  10. codeguard/engine/__init__.py +25 -0
  11. codeguard/engine/baseline.py +122 -0
  12. codeguard/engine/context.py +61 -0
  13. codeguard/engine/discovery.py +160 -0
  14. codeguard/engine/finding.py +205 -0
  15. codeguard/engine/fingerprint.py +94 -0
  16. codeguard/engine/gitdiff.py +80 -0
  17. codeguard/engine/policy.py +74 -0
  18. codeguard/engine/registry.py +78 -0
  19. codeguard/engine/rule.py +195 -0
  20. codeguard/engine/runner.py +267 -0
  21. codeguard/engine/suppressions.py +109 -0
  22. codeguard/lang/__init__.py +37 -0
  23. codeguard/lang/base.py +80 -0
  24. codeguard/lang/javascript.py +20 -0
  25. codeguard/lang/node.py +137 -0
  26. codeguard/lang/python_ast.py +29 -0
  27. codeguard/lang/registry.py +38 -0
  28. codeguard/lang/treesitter.py +99 -0
  29. codeguard/lang/typescript.py +24 -0
  30. codeguard/py.typed +1 -0
  31. codeguard/rules/__init__.py +6 -0
  32. codeguard/rules/_jsnodes.py +82 -0
  33. codeguard/rules/_pyimports.py +60 -0
  34. codeguard/rules/javascript/__init__.py +9 -0
  35. codeguard/rules/javascript/cg_sec_101_dynamic_code.py +89 -0
  36. codeguard/rules/javascript/cg_sec_102_child_process.py +58 -0
  37. codeguard/rules/javascript/cg_sec_103_dom_xss.py +67 -0
  38. codeguard/rules/javascript/cg_sec_104_react_dangerous_html.py +54 -0
  39. codeguard/rules/javascript/cg_sec_105_hardcoded_secret.py +73 -0
  40. codeguard/rules/javascript/cg_sec_106_weak_random.py +83 -0
  41. codeguard/rules/meta/__init__.py +55 -0
  42. codeguard/rules/security/__init__.py +8 -0
  43. codeguard/rules/security/cg_sec_001_sql_injection.py +110 -0
  44. codeguard/rules/security/cg_sec_002_hardcoded_secrets.py +184 -0
  45. codeguard/rules/security/cg_sec_003_eval_exec.py +104 -0
  46. codeguard/rules/security/cg_sec_004_unsafe_deserialization.py +156 -0
  47. codeguard/rules/security/cg_sec_005_shell_injection.py +157 -0
  48. codeguard_cli-2.0.0.dist-info/METADATA +210 -0
  49. codeguard_cli-2.0.0.dist-info/RECORD +52 -0
  50. codeguard_cli-2.0.0.dist-info/WHEEL +4 -0
  51. codeguard_cli-2.0.0.dist-info/entry_points.txt +2 -0
  52. codeguard_cli-2.0.0.dist-info/licenses/LICENSE +184 -0
codeguard/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CodeGuard — static analysis for Python, focused on AI-code failure modes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ __version__ = "2.0.0"
7
+ __all__ = ["__version__"]
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """CodeGuard CLI package."""
codeguard/cli/_run.py ADDED
@@ -0,0 +1,230 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Shared implementation behind ``codeguard scan`` and ``codeguard ci``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import click
12
+ from rich.console import Console
13
+
14
+ from codeguard import __version__
15
+ from codeguard.cli import formatters as fmt
16
+ from codeguard.config import ConfigError, find_config, load_config
17
+ from codeguard.engine.baseline import Baseline, apply_baseline
18
+ from codeguard.engine.discovery import DiscoveryConfig, discover
19
+ from codeguard.engine.finding import Finding
20
+ from codeguard.engine.gitdiff import changed_files, default_base, is_git_repo
21
+ from codeguard.engine.policy import apply_config, gating_findings
22
+ from codeguard.engine.registry import REGISTRY
23
+ from codeguard.engine.runner import AnalysisRunner
24
+
25
+ EXIT_OK = 0
26
+ EXIT_FINDINGS = 1
27
+ EXIT_USAGE = 2
28
+ EXIT_CONFIG = 3
29
+ EXIT_INTERNAL = 4
30
+
31
+ SEVERITIES = ["critical", "high", "medium", "low", "info"]
32
+ FORMATS = ["human", "json", "json-legacy", "sarif", "github", "rdjson", "junit"]
33
+
34
+
35
+ def console(no_color: bool) -> Console:
36
+ return Console(stderr=True, no_color=no_color)
37
+
38
+
39
+ @dataclass
40
+ class RunOptions:
41
+ paths: tuple[Path, ...]
42
+ config_path: Path | None = None
43
+ output_format: str | None = None
44
+ rule_ids: tuple[str, ...] = ()
45
+ excludes: tuple[str, ...] = ()
46
+ includes: tuple[str, ...] = ()
47
+ fail_on: str | None = None
48
+ exit_zero: bool = False
49
+ min_severity: str | None = None
50
+ show_suppressed: bool = False
51
+ no_gitignore: bool = False
52
+ jobs: int | None = None
53
+ quiet: bool = False
54
+ no_color: bool = False
55
+ stdin_filename: str = "stdin.py"
56
+ output: Path | None = None
57
+ baseline_path: Path | None = None
58
+ now: str | None = None # pin the date for `until=` suppression expiry
59
+ diff_ref: str | None = None
60
+ diff_auto: bool = False # `ci` -- pick a base branch if diff_ref not given
61
+ sarif_out: Path | None = None # `ci` -- also write SARIF here
62
+
63
+
64
+ def execute(opt: RunOptions) -> int:
65
+ """Run a scan and return the process exit code."""
66
+ err = console(opt.no_color)
67
+
68
+ first = Path(opt.paths[0]) if opt.paths and str(opt.paths[0]) != "-" else Path.cwd()
69
+ scan_root = first if first.is_dir() else first.parent
70
+
71
+ try:
72
+ cfg_file = opt.config_path or find_config(scan_root)
73
+ config = load_config(cfg_file)
74
+ except ConfigError as exc:
75
+ err.print(f"[bold red]Config error:[/bold red] {exc}")
76
+ return EXIT_CONFIG
77
+
78
+ root = Path(config.source_dir) if config.source_dir else scan_root
79
+
80
+ output_format = (opt.output_format or config.output).lower()
81
+ fail_on = opt.fail_on
82
+ if opt.min_severity and not fail_on:
83
+ err.print("[yellow]warning:[/yellow] --severity is deprecated; use --fail-on.")
84
+ fail_on = opt.min_severity
85
+ threshold = (fail_on or config.fail_on).lower()
86
+
87
+ n_jobs = opt.jobs if opt.jobs is not None else config.jobs
88
+ if n_jobs == 0:
89
+ n_jobs = os.cpu_count() or 1
90
+
91
+ selected = list(opt.rule_ids) or config.enable or None
92
+ if selected is not None:
93
+ bad = [r for r in selected if r not in REGISTRY]
94
+ if bad:
95
+ err.print(f"[bold red]Error:[/bold red] unknown rule ID(s): {', '.join(bad)}.")
96
+ return EXIT_USAGE
97
+ disabled = set(config.disable)
98
+ active_ids = [
99
+ r.id
100
+ for r in REGISTRY.all()
101
+ if (selected is None or r.id in selected) and r.id not in disabled
102
+ ]
103
+ now = None
104
+ if opt.now:
105
+ from datetime import date
106
+
107
+ try:
108
+ now = date.fromisoformat(opt.now)
109
+ except ValueError:
110
+ err.print(f"[bold red]Error:[/bold red] --now must be YYYY-MM-DD, got {opt.now!r}")
111
+ return EXIT_USAGE
112
+ runner = AnalysisRunner(rule_ids=active_ids, now=now)
113
+
114
+ disc = DiscoveryConfig(
115
+ include=[*config.include, *opt.includes],
116
+ exclude=[*config.exclude, *opt.excludes],
117
+ respect_gitignore=config.gitignore and not opt.no_gitignore,
118
+ )
119
+
120
+ # --- collect --------------------------------------------------------
121
+ diff_ref = opt.diff_ref
122
+ if diff_ref is None and opt.diff_auto:
123
+ if not is_git_repo(root):
124
+ err.print("[bold red]Error:[/bold red] not a git repository; `ci` needs one.")
125
+ return EXIT_USAGE
126
+ diff_ref = default_base(root)
127
+ if diff_ref is None:
128
+ err.print("[yellow]warning:[/yellow] no base branch found; scanning all files.")
129
+
130
+ try:
131
+ findings = _collect(opt, runner, disc, n_jobs, root, diff_ref, err)
132
+ except SystemExit as exc: # _collect signalled a usage error
133
+ return int(exc.code or EXIT_USAGE)
134
+ except Exception as exc:
135
+ err.print(f"[bold red]Internal error:[/bold red] {exc}")
136
+ return EXIT_INTERNAL
137
+
138
+ findings = apply_config(findings, config, root=str(root))
139
+
140
+ # --- baseline ------------------------------------------------------
141
+ baseline_path = opt.baseline_path
142
+ if baseline_path is None and config.baseline:
143
+ candidate = root / config.baseline
144
+ if candidate.is_file():
145
+ baseline_path = candidate
146
+ if baseline_path is not None:
147
+ try:
148
+ baseline = Baseline.load(baseline_path)
149
+ except ValueError as exc:
150
+ err.print(f"[bold red]Config error:[/bold red] {exc}")
151
+ return EXIT_CONFIG
152
+ findings = apply_baseline(findings, baseline)
153
+
154
+ findings.sort(key=lambda f: (f.location.file, f.location.line, f.location.col, f.rule_id))
155
+
156
+ # --- render -------------------------------------------------------
157
+ text = _render(output_format, findings, opt, err)
158
+
159
+ if opt.sarif_out is not None:
160
+ opt.sarif_out.write_text(
161
+ fmt.format_sarif(findings, tool_version=__version__), encoding="utf-8"
162
+ )
163
+ if not opt.quiet:
164
+ err.print(f"SARIF written to [bold]{opt.sarif_out}[/bold]")
165
+
166
+ if opt.output:
167
+ opt.output.write_text(text, encoding="utf-8")
168
+ if not opt.quiet:
169
+ err.print(f"Results written to [bold]{opt.output}[/bold]")
170
+ else:
171
+ click.echo(text, nl=False)
172
+
173
+ # --- exit code --------------------------------------------------
174
+ if opt.exit_zero:
175
+ return EXIT_OK
176
+ return EXIT_FINDINGS if gating_findings(findings, threshold) else EXIT_OK
177
+
178
+
179
+ def _collect(
180
+ opt: RunOptions,
181
+ runner: AnalysisRunner,
182
+ disc: DiscoveryConfig,
183
+ jobs: int,
184
+ root: Path,
185
+ diff_ref: str | None,
186
+ err: Console,
187
+ ) -> list[Finding]:
188
+ targets = list(opt.paths) or [Path(".")]
189
+
190
+ if any(str(p) == "-" for p in targets):
191
+ source = sys.stdin.read()
192
+ try:
193
+ return runner.run(source, filename=opt.stdin_filename)
194
+ except SyntaxError as exc:
195
+ err.print(f"[yellow]warning:[/yellow] stdin: syntax error on line {exc.lineno}")
196
+ return []
197
+
198
+ for p in targets:
199
+ if not p.exists():
200
+ err.print(f"[bold red]Error:[/bold red] path does not exist: {p}")
201
+ raise SystemExit(EXIT_USAGE)
202
+
203
+ files = discover(targets, disc, root=root)
204
+
205
+ if diff_ref is not None:
206
+ changed = {p.resolve() for p in changed_files(diff_ref, root=root)}
207
+ files = [f for f in files if f.resolve() in changed]
208
+
209
+ return runner.run_files(files, jobs=jobs)
210
+
211
+
212
+ def _render(output_format: str, findings: list[Finding], opt: RunOptions, err: Console) -> str:
213
+ ss = opt.show_suppressed
214
+ if output_format == "human":
215
+ return fmt.format_human(findings, show_suppressed=ss)
216
+ if output_format == "json":
217
+ return fmt.format_json(findings, show_suppressed=ss, tool_version=__version__)
218
+ if output_format == "json-legacy":
219
+ if not opt.quiet:
220
+ err.print("[yellow]warning:[/yellow] --format json-legacy is deprecated.")
221
+ return fmt.format_json_legacy(findings, show_suppressed=ss)
222
+ if output_format == "sarif":
223
+ return fmt.format_sarif(findings, tool_version=__version__)
224
+ if output_format == "github":
225
+ return fmt.format_github(findings, show_suppressed=ss)
226
+ if output_format == "rdjson":
227
+ return fmt.format_rdjson(findings, show_suppressed=ss, tool_version=__version__)
228
+ if output_format == "junit":
229
+ return fmt.format_junit(findings, show_suppressed=ss)
230
+ raise click.BadParameter(f"unknown format: {output_format}")
@@ -0,0 +1,390 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Auxiliary CLI commands: list-rules, explain, validate, init."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ import codeguard.rules # noqa: F401 -- register built-in rules
13
+ from codeguard import __version__
14
+ from codeguard.cli.formatters import finding_help_uri
15
+ from codeguard.config import ConfigError, find_config, load_config
16
+ from codeguard.engine.finding import Finding
17
+ from codeguard.engine.registry import REGISTRY
18
+
19
+ _STARTER_TOML = """\
20
+ # CodeGuard configuration -- https://mevichitra.github.io/codeguard/configuration/
21
+ # (In pyproject.toml, use the [tool.codeguard] table instead of [codeguard].)
22
+
23
+ [codeguard]
24
+ exclude = ["**/*.min.js", "tests/fixtures/**"]
25
+ fail_on = "high" # exit 1 only on findings at or above this severity
26
+ gitignore = true
27
+
28
+ [codeguard.rules]
29
+ disable = []
30
+ """
31
+
32
+
33
+ def register(group: click.Group) -> None:
34
+ group.add_command(_list_rules)
35
+ group.add_command(_explain)
36
+ group.add_command(_validate)
37
+ group.add_command(_init)
38
+ group.add_command(_baseline)
39
+ group.add_command(_suppressions)
40
+
41
+
42
+ @click.command("list-rules")
43
+ @click.option("--format", "fmt", type=click.Choice(["table", "json"]), default="table")
44
+ @click.option("--language", "language", default=None, help="Filter by language.")
45
+ @click.option("--category", "category", default=None, help="Filter by category.")
46
+ @click.option("--severity", "severity", default=None, help="Filter by severity.")
47
+ def _list_rules(fmt: str, language: str | None, category: str | None, severity: str | None) -> None:
48
+ """List every registered rule."""
49
+ rows: list[dict[str, object]] = []
50
+ for rule in REGISTRY.all():
51
+ langs = sorted(lang.value for lang in rule.languages)
52
+ if language and language.lower() not in langs:
53
+ continue
54
+ if category and rule.category.value != category.lower():
55
+ continue
56
+ if severity and rule.severity.value != severity.lower():
57
+ continue
58
+ rows.append(
59
+ {
60
+ "id": rule.id,
61
+ "title": rule.title,
62
+ "severity": rule.severity.value,
63
+ "category": rule.category.value,
64
+ "languages": langs,
65
+ "cwe": rule.cwe,
66
+ }
67
+ )
68
+
69
+ if fmt == "json":
70
+ click.echo(json.dumps(rows, indent=2))
71
+ return
72
+
73
+ if not rows:
74
+ click.echo("No rules match.")
75
+ return
76
+ width = max(len(str(row["id"])) for row in rows)
77
+ for row in rows:
78
+ langs_str = ",".join(row["languages"]) # type: ignore[arg-type]
79
+ click.echo(f"{row['id']:<{width}} {row['severity']:<8} {langs_str:<20} {row['title']}")
80
+
81
+
82
+ @click.command("explain")
83
+ @click.argument("rule_id")
84
+ def _explain(rule_id: str) -> None:
85
+ """Show the full description of a rule."""
86
+ rule = REGISTRY.get(rule_id)
87
+ if rule is None:
88
+ click.echo(f"Unknown rule: {rule_id}", err=True)
89
+ sys.exit(2)
90
+ langs = ", ".join(sorted(lang.value for lang in rule.languages))
91
+ click.echo(f"{rule.id} -- {rule.title}\n")
92
+ click.echo(f"Severity: {rule.severity.value}")
93
+ click.echo(f"Category: {rule.category.value}")
94
+ click.echo(f"Languages: {langs}")
95
+ if rule.cwe:
96
+ click.echo(f"CWE: {rule.cwe}")
97
+ if rule.owasp:
98
+ click.echo(f"OWASP: {rule.owasp}")
99
+ click.echo(f"Docs: {rule.help_uri or finding_help_uri(rule.id)}\n")
100
+ click.echo(rule.description)
101
+
102
+
103
+ @click.command("validate")
104
+ @click.option(
105
+ "--config",
106
+ "config_path",
107
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
108
+ default=None,
109
+ help="Config file to validate (default: discovered).",
110
+ )
111
+ def _validate(config_path: Path | None) -> None:
112
+ """Validate a codeguard.toml (or pyproject [tool.codeguard])."""
113
+ path = config_path or find_config()
114
+ if path is None:
115
+ click.echo("No config file found. Nothing to validate.")
116
+ return
117
+ try:
118
+ cfg = load_config(path)
119
+ except ConfigError as exc:
120
+ click.echo(f"Invalid: {exc}", err=True)
121
+ sys.exit(3)
122
+
123
+ unknown = [
124
+ rid
125
+ for rid in {*cfg.enable, *cfg.disable, *cfg.severity_remap, *cfg.rules}
126
+ if rid not in REGISTRY
127
+ ]
128
+ if unknown:
129
+ click.echo(f"Invalid: {path}: unknown rule ID(s): {', '.join(sorted(unknown))}", err=True)
130
+ sys.exit(3)
131
+ conflict = set(cfg.enable) & set(cfg.disable)
132
+ if conflict:
133
+ click.echo(
134
+ f"Invalid: {path}: rule(s) both enabled and disabled: {', '.join(sorted(conflict))}",
135
+ err=True,
136
+ )
137
+ sys.exit(3)
138
+
139
+ click.echo(f"OK: {path}")
140
+
141
+
142
+ @click.command("init")
143
+ @click.option("--force", is_flag=True, help="Overwrite an existing codeguard.toml.")
144
+ def _init(force: bool) -> None:
145
+ """Write a starter codeguard.toml in the current directory."""
146
+ target = Path("codeguard.toml")
147
+ if target.exists() and not force:
148
+ click.echo("codeguard.toml already exists (use --force to overwrite).", err=True)
149
+ sys.exit(2)
150
+ target.write_text(_STARTER_TOML, encoding="utf-8")
151
+ click.echo(f"Wrote {target}")
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # baseline
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ def _scan_for_baseline(paths: tuple[Path, ...], config_path: Path | None) -> list[Finding]:
160
+ """Full scan (all rules, config applied) used to build/refresh a baseline."""
161
+ from codeguard.config import load_config
162
+ from codeguard.engine.discovery import DiscoveryConfig, discover
163
+ from codeguard.engine.policy import apply_config
164
+ from codeguard.engine.runner import AnalysisRunner
165
+
166
+ targets = [Path(p) for p in paths] or [Path(".")]
167
+ first = targets[0]
168
+ root = first if first.is_dir() else first.parent
169
+
170
+ cfg_file = config_path or find_config(root)
171
+ config = load_config(cfg_file)
172
+ cfg_root = Path(config.source_dir) if config.source_dir else root
173
+
174
+ disc = DiscoveryConfig(
175
+ include=list(config.include),
176
+ exclude=list(config.exclude),
177
+ respect_gitignore=config.gitignore,
178
+ )
179
+ findings = AnalysisRunner().run_files(discover(targets, disc, root=cfg_root))
180
+ findings = apply_config(findings, config, root=str(cfg_root))
181
+ return [f for f in findings if not f.suppressed]
182
+
183
+
184
+ @click.group("baseline")
185
+ def _baseline() -> None:
186
+ """Create and maintain a baseline file (findings that must not fail CI)."""
187
+
188
+
189
+ @_baseline.command("create")
190
+ @click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
191
+ @click.option(
192
+ "--config",
193
+ "config_path",
194
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
195
+ default=None,
196
+ )
197
+ @click.option(
198
+ "--output",
199
+ "-o",
200
+ type=click.Path(dir_okay=False, path_type=Path),
201
+ default=Path(".codeguard-baseline.json"),
202
+ show_default=True,
203
+ )
204
+ def _baseline_create(paths: tuple[Path, ...], config_path: Path | None, output: Path) -> None:
205
+ """Snapshot every current finding into a new baseline file."""
206
+ from codeguard.engine.baseline import Baseline
207
+
208
+ findings = _scan_for_baseline(paths, config_path)
209
+ Baseline.from_findings(findings, tool_version=__version__).save(output)
210
+ click.echo(f"Wrote {output} ({len(findings)} finding(s) baselined)")
211
+
212
+
213
+ @_baseline.command("update")
214
+ @click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
215
+ @click.option(
216
+ "--config",
217
+ "config_path",
218
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
219
+ default=None,
220
+ )
221
+ @click.option(
222
+ "--baseline",
223
+ "-b",
224
+ "baseline_file",
225
+ type=click.Path(dir_okay=False, path_type=Path),
226
+ default=Path(".codeguard-baseline.json"),
227
+ show_default=True,
228
+ )
229
+ def _baseline_update(
230
+ paths: tuple[Path, ...], config_path: Path | None, baseline_file: Path
231
+ ) -> None:
232
+ """Add newly-appeared findings to the baseline (keeps existing entries)."""
233
+ from codeguard.engine.baseline import Baseline
234
+
235
+ if not baseline_file.exists():
236
+ click.echo(f"No baseline at {baseline_file}; run `baseline create` first.", err=True)
237
+ sys.exit(2)
238
+ findings = _scan_for_baseline(paths, config_path)
239
+ before = len(Baseline.load(baseline_file))
240
+ updated = Baseline.load(baseline_file).updated_with(findings)
241
+ updated.save(baseline_file)
242
+ click.echo(f"Updated {baseline_file} (+{len(updated) - before} new entry/entries)")
243
+
244
+
245
+ @_baseline.command("prune")
246
+ @click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
247
+ @click.option(
248
+ "--config",
249
+ "config_path",
250
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
251
+ default=None,
252
+ )
253
+ @click.option(
254
+ "--baseline",
255
+ "-b",
256
+ "baseline_file",
257
+ type=click.Path(dir_okay=False, path_type=Path),
258
+ default=Path(".codeguard-baseline.json"),
259
+ show_default=True,
260
+ )
261
+ def _baseline_prune(paths: tuple[Path, ...], config_path: Path | None, baseline_file: Path) -> None:
262
+ """Drop baseline entries whose finding no longer occurs."""
263
+ from codeguard.engine.baseline import Baseline
264
+
265
+ if not baseline_file.exists():
266
+ click.echo(f"No baseline at {baseline_file}.", err=True)
267
+ sys.exit(2)
268
+ live = {f.fingerprint for f in _scan_for_baseline(paths, config_path) if f.fingerprint}
269
+ before = len(Baseline.load(baseline_file))
270
+ pruned = Baseline.load(baseline_file).pruned(live)
271
+ pruned.save(baseline_file)
272
+ click.echo(f"Pruned {baseline_file} (-{before - len(pruned)} stale entry/entries)")
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # suppressions
277
+ # ---------------------------------------------------------------------------
278
+
279
+
280
+ @click.group("suppressions")
281
+ def _suppressions() -> None:
282
+ """Inspect `# codeguard: ignore[...]` comments across the codebase."""
283
+
284
+
285
+ @_suppressions.command("list")
286
+ @click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
287
+ @click.option(
288
+ "--config",
289
+ "config_path",
290
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
291
+ default=None,
292
+ )
293
+ @click.option("--expired", is_flag=True, help="Show only expired suppressions (exit 1 if any).")
294
+ @click.option("--unused", is_flag=True, help="Show only suppressions that suppress nothing.")
295
+ @click.option("--format", "fmt", type=click.Choice(["table", "json"]), default="table")
296
+ @click.option(
297
+ "--now",
298
+ "now_str",
299
+ metavar="YYYY-MM-DD",
300
+ default=None,
301
+ help="Date used to evaluate `until=` (default: today).",
302
+ )
303
+ def _suppressions_list(
304
+ paths: tuple[Path, ...],
305
+ config_path: Path | None,
306
+ expired: bool,
307
+ unused: bool,
308
+ fmt: str,
309
+ now_str: str | None,
310
+ ) -> None:
311
+ """List every suppression comment with its status (active / expired / unused)."""
312
+ import json as _json
313
+ from datetime import date
314
+
315
+ from codeguard.config import load_config
316
+ from codeguard.engine.discovery import DiscoveryConfig, discover
317
+ from codeguard.engine.runner import AnalysisRunner
318
+ from codeguard.engine.suppressions import SuppressionSet
319
+ from codeguard.lang.registry import language_for_path
320
+
321
+ today = date.fromisoformat(now_str) if now_str else date.today()
322
+
323
+ targets = [Path(p) for p in paths] or [Path(".")]
324
+ first = targets[0]
325
+ root = first if first.is_dir() else first.parent
326
+ config = load_config(config_path or find_config(root))
327
+ cfg_root = Path(config.source_dir) if config.source_dir else root
328
+ disc = DiscoveryConfig(
329
+ include=list(config.include),
330
+ exclude=list(config.exclude),
331
+ respect_gitignore=config.gitignore,
332
+ )
333
+ files = discover(targets, disc, root=cfg_root)
334
+ runner = AnalysisRunner()
335
+
336
+ rows: list[dict[str, object]] = []
337
+ for path in files:
338
+ if language_for_path(path) is None:
339
+ continue
340
+ source = path.read_text(encoding="utf-8", errors="replace")
341
+ suppset = SuppressionSet.parse(source)
342
+ if not suppset.all():
343
+ continue
344
+ try:
345
+ findings = runner.run(source, filename=str(path), now=today)
346
+ except SyntaxError:
347
+ findings = []
348
+ suppressed_at = {(f.rule_id, f.location.line) for f in findings if f.suppressed}
349
+ suppressed_rules = {f.rule_id for f in findings if f.suppressed}
350
+
351
+ for supp in suppset.all():
352
+ if supp.is_expired(today):
353
+ status = "expired"
354
+ elif supp.file_level:
355
+ status = "active" if supp.rule_ids & suppressed_rules else "unused"
356
+ else:
357
+ status = (
358
+ "active"
359
+ if any((rid, supp.line) in suppressed_at for rid in supp.rule_ids)
360
+ else "unused"
361
+ )
362
+ rows.append(
363
+ {
364
+ "file": str(path),
365
+ "line": supp.line,
366
+ "rules": sorted(supp.rule_ids),
367
+ "scope": "file" if supp.file_level else "line",
368
+ "reason": supp.reason,
369
+ "until": supp.until.isoformat() if supp.until else None,
370
+ "status": status,
371
+ }
372
+ )
373
+
374
+ if expired:
375
+ rows = [r for r in rows if r["status"] == "expired"]
376
+ if unused:
377
+ rows = [r for r in rows if r["status"] == "unused"]
378
+
379
+ if fmt == "json":
380
+ click.echo(_json.dumps(rows, indent=2))
381
+ elif not rows:
382
+ click.echo("No suppressions." if not (expired or unused) else "None.")
383
+ else:
384
+ for r in rows:
385
+ rules = ",".join(r["rules"]) # type: ignore[arg-type]
386
+ reason = r["reason"] or "(no reason)"
387
+ click.echo(f"{r['file']}:{r['line']} {r['status']:<8} {rules:<24} {reason}")
388
+
389
+ if expired and rows:
390
+ sys.exit(1)