driftcheck-py 0.1.48__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 (104) hide show
  1. driftcheck/__init__.py +162 -0
  2. driftcheck/__main__.py +5 -0
  3. driftcheck/baseline.py +314 -0
  4. driftcheck/cli.py +1057 -0
  5. driftcheck/config.py +136 -0
  6. driftcheck/detector.py +904 -0
  7. driftcheck/detectors/__init__.py +694 -0
  8. driftcheck/detectors/a2a.py +276 -0
  9. driftcheck/detectors/actions.py +102 -0
  10. driftcheck/detectors/actions_version_drift.py +56 -0
  11. driftcheck/detectors/bazel.py +95 -0
  12. driftcheck/detectors/bun.py +61 -0
  13. driftcheck/detectors/cargo_features.py +105 -0
  14. driftcheck/detectors/changelog.py +150 -0
  15. driftcheck/detectors/ci_os.py +41 -0
  16. driftcheck/detectors/circleci.py +56 -0
  17. driftcheck/detectors/cmake.py +52 -0
  18. driftcheck/detectors/compose.py +55 -0
  19. driftcheck/detectors/conda.py +46 -0
  20. driftcheck/detectors/count.py +46 -0
  21. driftcheck/detectors/dart.py +73 -0
  22. driftcheck/detectors/deno.py +79 -0
  23. driftcheck/detectors/dependabot.py +73 -0
  24. driftcheck/detectors/devcontainer.py +138 -0
  25. driftcheck/detectors/docker.py +65 -0
  26. driftcheck/detectors/docker_bases.py +84 -0
  27. driftcheck/detectors/docker_multistage.py +88 -0
  28. driftcheck/detectors/dockerfile_instructions.py +68 -0
  29. driftcheck/detectors/dotnet.py +44 -0
  30. driftcheck/detectors/editorconfig.py +128 -0
  31. driftcheck/detectors/elixir.py +50 -0
  32. driftcheck/detectors/engines.py +124 -0
  33. driftcheck/detectors/env_drift.py +246 -0
  34. driftcheck/detectors/external.py +69 -0
  35. driftcheck/detectors/fix.py +160 -0
  36. driftcheck/detectors/frontmatter.py +152 -0
  37. driftcheck/detectors/git_submodule_drift.py +127 -0
  38. driftcheck/detectors/git_tag.py +83 -0
  39. driftcheck/detectors/gitlab.py +56 -0
  40. driftcheck/detectors/go.py +35 -0
  41. driftcheck/detectors/go_replace.py +135 -0
  42. driftcheck/detectors/gradle_catalog.py +77 -0
  43. driftcheck/detectors/helm.py +56 -0
  44. driftcheck/detectors/helm_deps.py +135 -0
  45. driftcheck/detectors/java.py +43 -0
  46. driftcheck/detectors/jenkins.py +141 -0
  47. driftcheck/detectors/julia.py +96 -0
  48. driftcheck/detectors/justfile.py +92 -0
  49. driftcheck/detectors/k8s.py +56 -0
  50. driftcheck/detectors/kotlin.py +66 -0
  51. driftcheck/detectors/kotlin_multiplatform.py +184 -0
  52. driftcheck/detectors/lineending.py +65 -0
  53. driftcheck/detectors/lockfile.py +77 -0
  54. driftcheck/detectors/makefile.py +52 -0
  55. driftcheck/detectors/maven.py +34 -0
  56. driftcheck/detectors/mise.py +89 -0
  57. driftcheck/detectors/nix.py +109 -0
  58. driftcheck/detectors/node.py +41 -0
  59. driftcheck/detectors/npm_workspaces.py +123 -0
  60. driftcheck/detectors/npmrc.py +96 -0
  61. driftcheck/detectors/nvmrc.py +73 -0
  62. driftcheck/detectors/package_lock.py +295 -0
  63. driftcheck/detectors/package_manager.py +87 -0
  64. driftcheck/detectors/package_version.py +100 -0
  65. driftcheck/detectors/php.py +58 -0
  66. driftcheck/detectors/pipfile.py +67 -0
  67. driftcheck/detectors/pnpm.py +72 -0
  68. driftcheck/detectors/poetry.py +124 -0
  69. driftcheck/detectors/pre_commit.py +84 -0
  70. driftcheck/detectors/pyproject_tools.py +124 -0
  71. driftcheck/detectors/python.py +58 -0
  72. driftcheck/detectors/python_freshness.py +222 -0
  73. driftcheck/detectors/python_req.py +292 -0
  74. driftcheck/detectors/python_setup.py +170 -0
  75. driftcheck/detectors/python_version.py +120 -0
  76. driftcheck/detectors/python_version_drift.py +257 -0
  77. driftcheck/detectors/r_lang.py +99 -0
  78. driftcheck/detectors/renovate.py +92 -0
  79. driftcheck/detectors/requirements.py +120 -0
  80. driftcheck/detectors/ruby.py +49 -0
  81. driftcheck/detectors/rust.py +77 -0
  82. driftcheck/detectors/rust_workspace.py +208 -0
  83. driftcheck/detectors/scala.py +72 -0
  84. driftcheck/detectors/swift.py +42 -0
  85. driftcheck/detectors/taskfile.py +152 -0
  86. driftcheck/detectors/terraform.py +52 -0
  87. driftcheck/detectors/terraform_lock.py +133 -0
  88. driftcheck/detectors/tool_versions.py +70 -0
  89. driftcheck/detectors/typosquat.py +218 -0
  90. driftcheck/detectors/uv_lock.py +138 -0
  91. driftcheck/detectors/version_files.py +127 -0
  92. driftcheck/detectors/vscode.py +64 -0
  93. driftcheck/detectors/yarnrc.py +71 -0
  94. driftcheck/doctor.py +434 -0
  95. driftcheck/explain.py +261 -0
  96. driftcheck/git_mode.py +226 -0
  97. driftcheck/plugins.py +235 -0
  98. driftcheck/sarif.py +742 -0
  99. driftcheck_py-0.1.48.dist-info/METADATA +470 -0
  100. driftcheck_py-0.1.48.dist-info/RECORD +104 -0
  101. driftcheck_py-0.1.48.dist-info/WHEEL +5 -0
  102. driftcheck_py-0.1.48.dist-info/entry_points.txt +2 -0
  103. driftcheck_py-0.1.48.dist-info/licenses/LICENSE +2 -0
  104. driftcheck_py-0.1.48.dist-info/top_level.txt +1 -0
driftcheck/__init__.py ADDED
@@ -0,0 +1,162 @@
1
+ """driftcheck — detect version drift between docs and toolchain."""
2
+ __version__ = "0.1.47"
3
+
4
+ from .sarif import to_sarif
5
+ from .detectors.actions import find_actions_node_drift
6
+ from .detectors.bazel import find_bazel_drift
7
+ from .detectors.bun import find_bun_drift
8
+ from .detectors.ci_os import find_ci_os_drift
9
+ from .detectors.circleci import find_circleci_drift
10
+ from .detectors.cmake import find_cmake_drift
11
+ from .detectors.env_drift import find_compose_override_drift
12
+ from .detectors.conda import find_conda_drift
13
+ from .detectors.count import find_count_drift
14
+ from .detectors.dart import find_dart_drift
15
+ from .detectors.deno import find_deno_drift
16
+ from .detectors.dependabot import find_dependabot_drift
17
+ from .detectors.devcontainer import find_devcontainer_drift
18
+ from .detectors.compose import find_docker_compose_drift
19
+ from .detectors.docker import find_docker_drift
20
+ from .detectors.dockerfile_instructions import find_dockerfile_instruction_drift
21
+ from .detectors.docker_bases import find_dockerfile_bases_drift
22
+ from .detectors.docker_multistage import find_dockerfile_multistage_drift
23
+ from .detectors.dotnet import find_dotnet_drift
24
+ from .detectors.editorconfig import find_editorconfig_drift
25
+ from .detectors.elixir import find_elixir_drift
26
+ from .detectors.engines import find_engines_drift
27
+ from .detectors.env_drift import find_env_drift
28
+ from .detectors.external import find_external_resource_drift
29
+ from .detectors.actions import find_gh_actions_version_drift
30
+ from .detectors.actions_version_drift import find_actions_version_drift
31
+ from .detectors.git_tag import find_git_tag_drift
32
+ from .detectors.gitlab import find_gitlab_drift
33
+ from .detectors.go import find_go_drift
34
+ from .detectors.a2a import (
35
+ parse_agent_card,
36
+ extract_card_spec_version,
37
+ extract_card_capabilities,
38
+ extract_card_endpoints,
39
+ find_a2a_drift,
40
+ is_agent_card_file,
41
+ )
42
+ from .detectors.gradle_catalog import find_gradle_catalog_drift
43
+ from .detectors.helm import find_helm_drift
44
+ from .detectors.env_drift import find_helm_values_drift
45
+ from .detectors.java import find_java_drift
46
+ from .detectors.jenkins import find_jenkins_drift
47
+ from .detectors.k8s import find_k8s_drift
48
+ from .detectors.kotlin import find_kotlin_drift
49
+ from .detectors.lineending import find_lineending_drift
50
+ from .detectors.lockfile import find_lockfile_drift
51
+ from .detectors.makefile import find_makefile_drift
52
+ from .detectors.maven import find_maven_drift
53
+ from .detectors.mise import find_mise_drift
54
+ from .detectors.nix import find_nix_drift
55
+ from .detectors.node import find_node_drift
56
+ from .detectors.npmrc import find_npmrc_drift
57
+ from .detectors.nvmrc import find_nvmrc_drift
58
+ from .detectors.package_manager import find_package_manager_drift
59
+ from .detectors.php import find_php_drift
60
+ from .detectors.pipfile import find_pipfile_drift
61
+ from .detectors.pnpm import find_pnpm_workspace_drift
62
+ from .detectors.poetry import find_poetry_drift
63
+ from .detectors.pre_commit import find_pre_commit_drift
64
+ from .detectors.python import find_python_drift
65
+ from .detectors.python_version import find_python_version_file_drift
66
+ from .detectors.renovate import find_renovate_drift
67
+ from .detectors.git_submodule_drift import find_git_submodule_drift
68
+ from .detectors.requirements import find_requirements_drift
69
+ from .detectors.npm_workspaces import find_npm_workspace_drift
70
+ from .detectors.julia import find_julia_drift
71
+ from .detectors.cargo_features import find_cargo_feature_drift
72
+ from .detectors.rust_workspace import find_rust_workspace_drift
73
+ from .detectors.ruby import find_ruby_drift
74
+ from .detectors.rust import find_rust_drift
75
+ from .detectors.swift import find_swift_drift
76
+ from .detectors.taskfile import find_taskfile_drift
77
+ from .detectors.terraform import find_terraform_drift
78
+ from .detectors.tool_versions import find_tool_versions_drift
79
+ from .detectors.typosquat import find_typosquat_drift
80
+ from .detectors.version_files import find_version_file_drift
81
+ from .detectors.vscode import find_vscode_extensions_drift
82
+ from .detectors.yarnrc import find_yarnrc_drift
83
+
84
+ __all__ = [
85
+ "to_sarif",
86
+ "find_actions_node_drift",
87
+ "find_bazel_drift",
88
+ "find_bun_drift",
89
+ "find_ci_os_drift",
90
+ "find_circleci_drift",
91
+ "find_cmake_drift",
92
+ "find_compose_override_drift",
93
+ "find_conda_drift",
94
+ "find_count_drift",
95
+ "find_dart_drift",
96
+ "find_deno_drift",
97
+ "find_dependabot_drift",
98
+ "find_devcontainer_drift",
99
+ "find_docker_compose_drift",
100
+ "find_docker_drift",
101
+ "find_dockerfile_instruction_drift",
102
+ "find_dockerfile_bases_drift",
103
+ "find_dockerfile_multistage_drift",
104
+ "find_dotnet_drift",
105
+ "find_editorconfig_drift",
106
+ "find_elixir_drift",
107
+ "find_engines_drift",
108
+ "find_env_drift",
109
+ "find_external_resource_drift",
110
+ "find_gh_actions_version_drift",
111
+ "find_actions_version_drift",
112
+ "find_git_tag_drift",
113
+ "find_gitlab_drift",
114
+ "find_go_drift",
115
+ "find_gradle_catalog_drift",
116
+ "find_helm_drift",
117
+ "find_helm_values_drift",
118
+ "find_java_drift",
119
+ "find_jenkins_drift",
120
+ "find_k8s_drift",
121
+ "find_kotlin_drift",
122
+ "find_lineending_drift",
123
+ "find_lockfile_drift",
124
+ "find_makefile_drift",
125
+ "find_maven_drift",
126
+ "find_mise_drift",
127
+ "find_nix_drift",
128
+ "find_node_drift",
129
+ "find_npmrc_drift",
130
+ "find_nvmrc_drift",
131
+ "find_package_manager_drift",
132
+ "find_php_drift",
133
+ "find_pipfile_drift",
134
+ "find_pnpm_workspace_drift",
135
+ "find_poetry_drift",
136
+ "find_pre_commit_drift",
137
+ "find_python_drift",
138
+ "find_python_version_file_drift",
139
+ "find_python_dep_freshness",
140
+ "find_renovate_drift",
141
+ "find_requirements_drift",
142
+ "find_npm_workspace_drift",
143
+ "find_a2a_drift",
144
+ "parse_agent_card",
145
+ "extract_card_spec_version",
146
+ "extract_card_capabilities",
147
+ "extract_card_endpoints",
148
+ "is_agent_card_file",
149
+ "find_julia_drift",
150
+ "find_cargo_feature_drift",
151
+ "find_rust_workspace_drift",
152
+ "find_ruby_drift",
153
+ "find_rust_drift",
154
+ "find_swift_drift",
155
+ "find_taskfile_drift",
156
+ "find_terraform_drift",
157
+ "find_tool_versions_drift",
158
+ "find_typosquat_drift",
159
+ "find_version_file_drift",
160
+ "find_vscode_extensions_drift",
161
+ "find_yarnrc_drift",
162
+ ]
driftcheck/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m driftcheck`."""
2
+ from driftcheck.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
driftcheck/baseline.py ADDED
@@ -0,0 +1,314 @@
1
+ """Baseline mode for driftcheck.
2
+
3
+ Enables incremental drift detection by accepting the current state as "known good"
4
+ and only reporting NEW drifts that appear after the baseline is set.
5
+
6
+ File format: .driftcheck-baseline.json in repo root.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .detector import scan_repo
17
+
18
+ BASELINE_FILENAME = ".driftcheck-baseline.json"
19
+ BASELINE_SCHEMA_VERSION = 1
20
+
21
+
22
+ def _get_commit_sha(root: Path) -> str:
23
+ """Get current git commit SHA, or 'unknown' if not in a git repo."""
24
+ try:
25
+ result = subprocess.run(
26
+ ["git", "rev-parse", "HEAD"],
27
+ cwd=root,
28
+ capture_output=True,
29
+ text=True,
30
+ timeout=10,
31
+ )
32
+ if result.returncode == 0:
33
+ return result.stdout.strip()
34
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
35
+ pass
36
+ return "unknown"
37
+
38
+
39
+ def _make_drift_key(detector: str, drift: dict) -> tuple[str, str, str]:
40
+ """Create a dedup key for a drift entry.
41
+
42
+ Key is (detector, file, type) where type is derived from the drift's
43
+ content to distinguish different drift types on the same file.
44
+ """
45
+ file = drift.get("file", "")
46
+ # Derive a type key from the drift's distinguishing features
47
+ if "doc_version" in drift:
48
+ type_key = f"doc={drift['doc_version']}"
49
+ elif "detail" in drift:
50
+ type_key = f"detail={drift['detail'][:80]}"
51
+ elif "runner" in drift:
52
+ type_key = f"runner={drift['runner']}"
53
+ elif "action" in drift:
54
+ type_key = f"action={drift['action']}@{drift.get('current', '')}"
55
+ elif "image" in drift:
56
+ type_key = f"image={drift['image']}"
57
+ else:
58
+ # Fallback: use sorted keys to create a stable fingerprint
59
+ type_key = str(sorted(drift.items()))
60
+ return (detector, file, type_key)
61
+
62
+
63
+ def _result_to_baseline_entries(result: dict) -> list[dict]:
64
+ """Convert scan_repo result to a list of baseline drift entries."""
65
+ entries = []
66
+ for key, drifts in result.items():
67
+ if not isinstance(drifts, list):
68
+ continue
69
+ if key in ("_skipped_symlinks",):
70
+ continue
71
+ for drift in drifts:
72
+ if not isinstance(drift, dict):
73
+ continue
74
+ detector, file, type_key = _make_drift_key(key, drift)
75
+ entries.append({
76
+ "detector": key,
77
+ "file": file,
78
+ "type": type_key,
79
+ "first_seen": datetime.now(timezone.utc).isoformat(),
80
+ # Store original drift data for reference
81
+ "drift": drift,
82
+ })
83
+ return entries
84
+
85
+
86
+ def create_baseline(root: Path, result: dict | None = None) -> dict:
87
+ """Create a baseline from the current repository state.
88
+
89
+ Args:
90
+ root: repo root path
91
+ result: pre-computed scan result (if None, will scan)
92
+
93
+ Returns:
94
+ The baseline dict that was written
95
+ """
96
+ if result is None:
97
+ result = scan_repo(root)
98
+
99
+ commit_sha = _get_commit_sha(root)
100
+ entries = _result_to_baseline_entries(result)
101
+
102
+ baseline = {
103
+ "version": BASELINE_SCHEMA_VERSION,
104
+ "created_at": datetime.now(timezone.utc).isoformat(),
105
+ "updated_at": datetime.now(timezone.utc).isoformat(),
106
+ "commit_sha": commit_sha,
107
+ "total_entries": len(entries),
108
+ "drifts": entries,
109
+ }
110
+
111
+ baseline_path = root / BASELINE_FILENAME
112
+ baseline_path.write_text(
113
+ json.dumps(baseline, indent=2, default=str),
114
+ encoding="utf-8",
115
+ )
116
+
117
+ # Add to .gitignore if not already there
118
+ _add_to_gitignore(root)
119
+
120
+ return baseline
121
+
122
+
123
+ def load_baseline(root: Path) -> dict | None:
124
+ """Load baseline from disk.
125
+
126
+ Returns:
127
+ The baseline dict, or None if no baseline exists or schema mismatch.
128
+ """
129
+ baseline_path = root / BASELINE_FILENAME
130
+ if not baseline_path.exists():
131
+ return None
132
+
133
+ try:
134
+ text = baseline_path.read_text(encoding="utf-8")
135
+ baseline = json.loads(text)
136
+ except (OSError, json.JSONDecodeError):
137
+ return None
138
+
139
+ if baseline.get("version") != BASELINE_SCHEMA_VERSION:
140
+ return None
141
+
142
+ return baseline
143
+
144
+
145
+ def update_baseline(root: Path, result: dict | None = None) -> dict:
146
+ """Update the baseline to the current state.
147
+
148
+ Preserves first_seen timestamps for drifts that existed before.
149
+ """
150
+ old_baseline = load_baseline(root)
151
+ old_first_seen: dict[tuple, str] = {}
152
+
153
+ if old_baseline:
154
+ for entry in old_baseline.get("drifts", []):
155
+ key = (entry.get("detector", ""), entry.get("file", ""), entry.get("type", ""))
156
+ old_first_seen[key] = entry.get("first_seen", "")
157
+
158
+ if result is None:
159
+ result = scan_repo(root)
160
+
161
+ commit_sha = _get_commit_sha(root)
162
+ entries = _result_to_baseline_entries(result)
163
+
164
+ # Preserve first_seen for drifts that existed before
165
+ now = datetime.now(timezone.utc).isoformat()
166
+ for entry in entries:
167
+ key = (entry["detector"], entry["file"], entry["type"])
168
+ if key in old_first_seen:
169
+ entry["first_seen"] = old_first_seen[key]
170
+ else:
171
+ entry["first_seen"] = now
172
+
173
+ baseline = {
174
+ "version": BASELINE_SCHEMA_VERSION,
175
+ "created_at": old_baseline.get("created_at", now) if old_baseline else now,
176
+ "updated_at": now,
177
+ "commit_sha": commit_sha,
178
+ "total_entries": len(entries),
179
+ "drifts": entries,
180
+ }
181
+
182
+ baseline_path = root / BASELINE_FILENAME
183
+ baseline_path.write_text(
184
+ json.dumps(baseline, indent=2, default=str),
185
+ encoding="utf-8",
186
+ )
187
+
188
+ return baseline
189
+
190
+
191
+ def reset_baseline(root: Path) -> bool:
192
+ """Remove the baseline file.
193
+
194
+ Returns:
195
+ True if a baseline was removed, False if none existed.
196
+ """
197
+ baseline_path = root / BASELINE_FILENAME
198
+ if baseline_path.exists():
199
+ baseline_path.unlink()
200
+ return True
201
+ return False
202
+
203
+
204
+ def compare_against_baseline(
205
+ root: Path, result: dict, baseline: dict | None = None
206
+ ) -> dict:
207
+ """Compare current scan results against the baseline.
208
+
209
+ Args:
210
+ root: repo root path
211
+ result: current scan_repo() result
212
+ baseline: pre-loaded baseline (if None, will load from disk)
213
+
214
+ Returns:
215
+ Dict with:
216
+ - new_drifts: drifts not in baseline (failures)
217
+ - pre_existing_drifts: drifts in baseline (warnings)
218
+ - baseline_info: metadata about the baseline
219
+ """
220
+ if baseline is None:
221
+ baseline = load_baseline(root)
222
+
223
+ if baseline is None:
224
+ # No baseline: all drifts are "new"
225
+ all_drifts = {}
226
+ for key, drifts in result.items():
227
+ if isinstance(drifts, list) and key != "_skipped_symlinks":
228
+ all_drifts[key] = drifts
229
+ return {
230
+ "new_drifts": all_drifts,
231
+ "pre_existing_drifts": {},
232
+ "baseline_info": None,
233
+ }
234
+
235
+ # Build set of baseline drift keys
236
+ baseline_keys: set[tuple] = set()
237
+ for entry in baseline.get("drifts", []):
238
+ key = (entry.get("detector", ""), entry.get("file", ""), entry.get("type", ""))
239
+ baseline_keys.add(key)
240
+
241
+ new_drifts: dict[str, list] = {}
242
+ pre_existing_drifts: dict[str, list] = {}
243
+
244
+ for key, drifts in result.items():
245
+ if not isinstance(drifts, list) or key == "_skipped_symlinks":
246
+ continue
247
+ for drift in drifts:
248
+ if not isinstance(drift, dict):
249
+ continue
250
+ _, _, type_key = _make_drift_key(key, drift)
251
+ drift_key = (key, drift.get("file", ""), type_key)
252
+ if drift_key in baseline_keys:
253
+ pre_existing_drifts.setdefault(key, []).append(drift)
254
+ else:
255
+ new_drifts.setdefault(key, []).append(drift)
256
+
257
+ return {
258
+ "new_drifts": new_drifts,
259
+ "pre_existing_drifts": pre_existing_drifts,
260
+ "baseline_info": {
261
+ "created_at": baseline.get("created_at"),
262
+ "updated_at": baseline.get("updated_at"),
263
+ "commit_sha": baseline.get("commit_sha"),
264
+ "total_entries": baseline.get("total_entries"),
265
+ },
266
+ }
267
+
268
+
269
+ def show_baseline(root: Path) -> str:
270
+ """Return a human-readable summary of the baseline."""
271
+ baseline = load_baseline(root)
272
+ if baseline is None:
273
+ return "No baseline found. Run 'driftcheck --baseline' to create one."
274
+
275
+ lines = [
276
+ f"Baseline created: {baseline.get('created_at', 'unknown')}",
277
+ f"Last updated: {baseline.get('updated_at', 'unknown')}",
278
+ f"Commit SHA: {baseline.get('commit_sha', 'unknown')}",
279
+ f"Total drifts: {baseline.get('total_entries', 0)}",
280
+ "",
281
+ ]
282
+
283
+ # Group by detector
284
+ by_detector: dict[str, list] = {}
285
+ for entry in baseline.get("drifts", []):
286
+ detector = entry.get("detector", "unknown")
287
+ by_detector.setdefault(detector, []).append(entry)
288
+
289
+ for detector, entries in sorted(by_detector.items()):
290
+ lines.append(f" {detector}: {len(entries)} drift(s)")
291
+ for e in entries[:3]: # Show first 3 per detector
292
+ lines.append(f" - {e.get('file', '?')}: {e.get('type', '')[:60]}")
293
+ if len(entries) > 3:
294
+ lines.append(f" ... and {len(entries) - 3} more")
295
+
296
+ return "\n".join(lines)
297
+
298
+
299
+ def _add_to_gitignore(root: Path) -> None:
300
+ """Add the baseline file to .gitignore if not already tracked."""
301
+ gitignore_path = root / ".gitignore"
302
+ entry = BASELINE_FILENAME
303
+
304
+ if gitignore_path.exists():
305
+ content = gitignore_path.read_text(encoding="utf-8")
306
+ if entry in content:
307
+ return
308
+ # Append with newline if file doesn't end with one
309
+ if content and not content.endswith("\n"):
310
+ content += "\n"
311
+ content += entry + "\n"
312
+ gitignore_path.write_text(content, encoding="utf-8")
313
+ else:
314
+ gitignore_path.write_text(entry + "\n", encoding="utf-8")