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.
- driftcheck/__init__.py +162 -0
- driftcheck/__main__.py +5 -0
- driftcheck/baseline.py +314 -0
- driftcheck/cli.py +1057 -0
- driftcheck/config.py +136 -0
- driftcheck/detector.py +904 -0
- driftcheck/detectors/__init__.py +694 -0
- driftcheck/detectors/a2a.py +276 -0
- driftcheck/detectors/actions.py +102 -0
- driftcheck/detectors/actions_version_drift.py +56 -0
- driftcheck/detectors/bazel.py +95 -0
- driftcheck/detectors/bun.py +61 -0
- driftcheck/detectors/cargo_features.py +105 -0
- driftcheck/detectors/changelog.py +150 -0
- driftcheck/detectors/ci_os.py +41 -0
- driftcheck/detectors/circleci.py +56 -0
- driftcheck/detectors/cmake.py +52 -0
- driftcheck/detectors/compose.py +55 -0
- driftcheck/detectors/conda.py +46 -0
- driftcheck/detectors/count.py +46 -0
- driftcheck/detectors/dart.py +73 -0
- driftcheck/detectors/deno.py +79 -0
- driftcheck/detectors/dependabot.py +73 -0
- driftcheck/detectors/devcontainer.py +138 -0
- driftcheck/detectors/docker.py +65 -0
- driftcheck/detectors/docker_bases.py +84 -0
- driftcheck/detectors/docker_multistage.py +88 -0
- driftcheck/detectors/dockerfile_instructions.py +68 -0
- driftcheck/detectors/dotnet.py +44 -0
- driftcheck/detectors/editorconfig.py +128 -0
- driftcheck/detectors/elixir.py +50 -0
- driftcheck/detectors/engines.py +124 -0
- driftcheck/detectors/env_drift.py +246 -0
- driftcheck/detectors/external.py +69 -0
- driftcheck/detectors/fix.py +160 -0
- driftcheck/detectors/frontmatter.py +152 -0
- driftcheck/detectors/git_submodule_drift.py +127 -0
- driftcheck/detectors/git_tag.py +83 -0
- driftcheck/detectors/gitlab.py +56 -0
- driftcheck/detectors/go.py +35 -0
- driftcheck/detectors/go_replace.py +135 -0
- driftcheck/detectors/gradle_catalog.py +77 -0
- driftcheck/detectors/helm.py +56 -0
- driftcheck/detectors/helm_deps.py +135 -0
- driftcheck/detectors/java.py +43 -0
- driftcheck/detectors/jenkins.py +141 -0
- driftcheck/detectors/julia.py +96 -0
- driftcheck/detectors/justfile.py +92 -0
- driftcheck/detectors/k8s.py +56 -0
- driftcheck/detectors/kotlin.py +66 -0
- driftcheck/detectors/kotlin_multiplatform.py +184 -0
- driftcheck/detectors/lineending.py +65 -0
- driftcheck/detectors/lockfile.py +77 -0
- driftcheck/detectors/makefile.py +52 -0
- driftcheck/detectors/maven.py +34 -0
- driftcheck/detectors/mise.py +89 -0
- driftcheck/detectors/nix.py +109 -0
- driftcheck/detectors/node.py +41 -0
- driftcheck/detectors/npm_workspaces.py +123 -0
- driftcheck/detectors/npmrc.py +96 -0
- driftcheck/detectors/nvmrc.py +73 -0
- driftcheck/detectors/package_lock.py +295 -0
- driftcheck/detectors/package_manager.py +87 -0
- driftcheck/detectors/package_version.py +100 -0
- driftcheck/detectors/php.py +58 -0
- driftcheck/detectors/pipfile.py +67 -0
- driftcheck/detectors/pnpm.py +72 -0
- driftcheck/detectors/poetry.py +124 -0
- driftcheck/detectors/pre_commit.py +84 -0
- driftcheck/detectors/pyproject_tools.py +124 -0
- driftcheck/detectors/python.py +58 -0
- driftcheck/detectors/python_freshness.py +222 -0
- driftcheck/detectors/python_req.py +292 -0
- driftcheck/detectors/python_setup.py +170 -0
- driftcheck/detectors/python_version.py +120 -0
- driftcheck/detectors/python_version_drift.py +257 -0
- driftcheck/detectors/r_lang.py +99 -0
- driftcheck/detectors/renovate.py +92 -0
- driftcheck/detectors/requirements.py +120 -0
- driftcheck/detectors/ruby.py +49 -0
- driftcheck/detectors/rust.py +77 -0
- driftcheck/detectors/rust_workspace.py +208 -0
- driftcheck/detectors/scala.py +72 -0
- driftcheck/detectors/swift.py +42 -0
- driftcheck/detectors/taskfile.py +152 -0
- driftcheck/detectors/terraform.py +52 -0
- driftcheck/detectors/terraform_lock.py +133 -0
- driftcheck/detectors/tool_versions.py +70 -0
- driftcheck/detectors/typosquat.py +218 -0
- driftcheck/detectors/uv_lock.py +138 -0
- driftcheck/detectors/version_files.py +127 -0
- driftcheck/detectors/vscode.py +64 -0
- driftcheck/detectors/yarnrc.py +71 -0
- driftcheck/doctor.py +434 -0
- driftcheck/explain.py +261 -0
- driftcheck/git_mode.py +226 -0
- driftcheck/plugins.py +235 -0
- driftcheck/sarif.py +742 -0
- driftcheck_py-0.1.48.dist-info/METADATA +470 -0
- driftcheck_py-0.1.48.dist-info/RECORD +104 -0
- driftcheck_py-0.1.48.dist-info/WHEEL +5 -0
- driftcheck_py-0.1.48.dist-info/entry_points.txt +2 -0
- driftcheck_py-0.1.48.dist-info/licenses/LICENSE +2 -0
- 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
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")
|