provctl 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
provctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """provctl - provenance-weighted dependency risk gate for Python projects."""
2
+
3
+ __version__ = "0.1.1"
provctl/check.py ADDED
@@ -0,0 +1,392 @@
1
+ """Changed files in, findings out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from provctl import gitio
10
+ from provctl.enrich.pypi import MetadataCache
11
+ from provctl.index import adoption as adoption_mod
12
+ from provctl.index.refresh import is_stale, staleness
13
+ from provctl.index.store import IndexMissingError, IndexStore, NameIndex
14
+ from provctl.model import (
15
+ BandCounts,
16
+ CheckResult,
17
+ Finding,
18
+ Origin,
19
+ Reason,
20
+ Verdict,
21
+ )
22
+ from provctl.paths import decisions_path
23
+ from provctl.policy import verdict as verdict_mod
24
+ from provctl.policy.config import Policy
25
+ from provctl.policy.decisions import DecisionLog
26
+ from provctl.provenance.gitai import GitAiAdapter
27
+ from provctl.provenance.hook import HookProvenanceReader
28
+ from provctl.provenance.model import UNTRACKED, ProvenanceMap
29
+ from provctl.policy.verdict import MetadataSignals
30
+ from provctl.resolve import firstparty, imports as imports_mod, manifests
31
+ from provctl.resolve.distmap import DistributionResolver
32
+
33
+
34
+ @dataclass
35
+ class CheckOptions:
36
+ scope: str = "staged" # staged | worktree | all | range
37
+ rev_range: str | None = None
38
+ use_provenance: bool = True
39
+ enrich: bool = False # Phase 2 metadata lookup (off the hot path)
40
+ include_imports: bool = True
41
+
42
+
43
+ class Checker:
44
+ def __init__(
45
+ self,
46
+ repo: Path,
47
+ policy: Policy,
48
+ store: IndexStore,
49
+ *,
50
+ resolver: DistributionResolver | None = None,
51
+ adoption: adoption_mod.AdoptionSet | None = None,
52
+ metadata_cache: MetadataCache | None = None,
53
+ ):
54
+ self.repo = repo
55
+ self.policy = policy
56
+ self.store = store
57
+ self.resolver = resolver or DistributionResolver(overrides=policy.mapping_overrides)
58
+ self.adoption = adoption if adoption is not None else adoption_mod.load(store)
59
+ self._metadata_cache = metadata_cache
60
+ self._owns_cache = False
61
+ if metadata_cache is None:
62
+ # Missing cache just means no age signal.
63
+ cache_path = store.cache_dir / "metadata.sqlite3"
64
+ if cache_path.exists():
65
+ try:
66
+ self._metadata_cache = MetadataCache(cache_path)
67
+ self._owns_cache = True
68
+ except Exception:
69
+ self._metadata_cache = None
70
+
71
+ def close(self) -> None:
72
+ if self._owns_cache and self._metadata_cache is not None:
73
+ self._metadata_cache.close()
74
+ self._metadata_cache = None
75
+
76
+
77
+ def run(self, options: CheckOptions) -> CheckResult:
78
+ result = CheckResult()
79
+
80
+ changes = self._collect_changes(options, result)
81
+ if changes is None:
82
+ return result
83
+
84
+ index = self.store.open_index()
85
+ meta = self.store.read_meta()
86
+ if meta is not None:
87
+ result.index_project_count = meta.project_count
88
+ age = staleness(meta)
89
+ result.index_age_days = round(age.total_seconds() / 86400, 2) if age else None
90
+ if is_stale(meta):
91
+ result.warnings.append(
92
+ "local PyPI index is more than 7 days old; "
93
+ "run `provctl index refresh` (checks still ran)"
94
+ )
95
+
96
+ try:
97
+ self._probe_index(index, result)
98
+ except IndexMissingError as exc:
99
+ result.warnings.append(str(exc))
100
+ return result
101
+
102
+ manifest_scan = self._scan_manifests(changes, options)
103
+ first_party = self._build_first_party(manifest_scan)
104
+ private_index = self._detect_private_index(manifest_scan)
105
+
106
+ declared = {entry.name for entry in manifest_scan.entries}
107
+
108
+ findings: list[Finding] = []
109
+ findings.extend(
110
+ self._check_manifest_entries(manifest_scan, index, first_party, private_index, changes)
111
+ )
112
+ if options.include_imports:
113
+ findings.extend(
114
+ self._check_imports(changes, options, index, first_party, private_index, declared, result)
115
+ )
116
+
117
+ findings = self._deduplicate(findings)
118
+ findings = self._apply_provenance(findings, options, result)
119
+ findings = self._apply_acknowledgements(findings)
120
+ result.findings = findings
121
+ result.warnings.extend(manifest_scan.parse_errors)
122
+ result.warnings.extend(self.policy.warnings)
123
+ result.bands = self._count_bands(findings)
124
+ index.close()
125
+ return result
126
+
127
+
128
+ def _collect_changes(self, options: CheckOptions, result: CheckResult):
129
+ # Don't let git dump usage text into a commit hook.
130
+ if not gitio.is_git_repo(self.repo):
131
+ result.warnings.append(f"{self.repo} is not a git repository")
132
+ return None
133
+ try:
134
+ if options.scope == "staged":
135
+ return gitio.staged_changes(self.repo)
136
+ if options.scope == "worktree":
137
+ return gitio.worktree_changes(self.repo)
138
+ if options.scope == "range" and options.rev_range:
139
+ return gitio.range_changes(self.repo, options.rev_range)
140
+ return gitio.all_tracked_files(self.repo)
141
+ except gitio.GitError as exc:
142
+ result.warnings.append(f"could not read git changes: {exc}")
143
+ return None
144
+
145
+ @staticmethod
146
+ def _probe_index(index: NameIndex, result: CheckResult) -> None:
147
+ # Open the mmap once so a missing index is one warning, not N.
148
+ index.contains_normalized("pip")
149
+
150
+ def _scan_manifests(self, changes, options: CheckOptions) -> manifests.ManifestScan:
151
+ merged = manifests.ManifestScan()
152
+ staged = options.scope == "staged"
153
+
154
+ for rel in changes.files:
155
+ if not manifests.is_manifest(rel):
156
+ continue
157
+ text = gitio.file_content(self.repo, rel, staged=staged)
158
+ if text is None:
159
+ continue
160
+ sub = manifests.parse_manifest(rel, text)
161
+
162
+ # requirements.txt: only added lines. TOML has no reliable line
163
+ # numbers, so we re-check the whole file.
164
+ if not changes.is_fully_new(rel):
165
+ added = changes.lines_for(rel)
166
+ if added and rel.endswith((".txt", ".in")):
167
+ sub.entries = [e for e in sub.entries if e.lineno in added]
168
+
169
+ merged.entries.extend(sub.entries)
170
+ merged.index_urls.extend(sub.index_urls)
171
+ merged.parse_errors.extend(sub.parse_errors)
172
+ merged.url_requirements |= sub.url_requirements
173
+ merged.workspace_members |= sub.workspace_members
174
+ if sub.project_name and not merged.project_name:
175
+ merged.project_name = sub.project_name
176
+
177
+ # Root pyproject still names first-party packages if it wasn't edited.
178
+ if merged.project_name is None:
179
+ root_pyproject = self.repo / "pyproject.toml"
180
+ if root_pyproject.exists():
181
+ try:
182
+ scan = manifests.parse_pyproject(
183
+ root_pyproject.read_text(encoding="utf-8", errors="replace"),
184
+ "pyproject.toml",
185
+ )
186
+ merged.project_name = scan.project_name
187
+ merged.index_urls.extend(scan.index_urls)
188
+ merged.url_requirements |= scan.url_requirements
189
+ merged.workspace_members |= scan.workspace_members
190
+ except OSError:
191
+ pass
192
+ return merged
193
+
194
+ def _build_first_party(self, scan: manifests.ManifestScan) -> firstparty.FirstPartyIndex:
195
+ return firstparty.discover(
196
+ self.repo,
197
+ project_name=scan.project_name,
198
+ prefixes=self.policy.first_party_prefixes,
199
+ allowlist=self.policy.first_party_allow,
200
+ )
201
+
202
+ def _detect_private_index(self, scan: manifests.ManifestScan) -> bool:
203
+ if self.policy.private_index:
204
+ return True
205
+ if scan.has_private_index:
206
+ return True
207
+ env_urls = manifests.environment_index_urls(dict(os.environ))
208
+ return any(not manifests._is_public_pypi(url) for url in env_urls)
209
+
210
+ def _check_manifest_entries(
211
+ self, scan, index: NameIndex, first_party, private_index: bool, changes
212
+ ) -> list[Finding]:
213
+ findings = []
214
+ seen: set[str] = set()
215
+ for entry in scan.entries:
216
+ if entry.name in seen:
217
+ continue
218
+ seen.add(entry.name)
219
+
220
+ exists = index.contains_normalized(entry.name)
221
+ finding = verdict_mod.evaluate_manifest_entry(
222
+ name=entry.name,
223
+ raw_name=entry.raw_name,
224
+ location=f"{entry.manifest}:{entry.lineno}" if entry.lineno else entry.manifest,
225
+ exists=exists,
226
+ is_first_party=(
227
+ first_party.is_first_party_dist(entry.name)
228
+ or entry.name in scan.workspace_members
229
+ ),
230
+ private_index=private_index,
231
+ is_url_requirement=entry.is_url or entry.name in scan.url_requirements,
232
+ signals=self._signals_for(entry.name) if exists else None,
233
+ policy=self.policy,
234
+ )
235
+ findings.append(finding)
236
+ return findings
237
+
238
+ def _check_imports(
239
+ self, changes, options, index, first_party, private_index, declared, result
240
+ ) -> list[Finding]:
241
+ findings = []
242
+ staged = options.scope == "staged"
243
+ seen: set[str] = set()
244
+ siblings = firstparty.LocalSiblingResolver(self.repo)
245
+
246
+ for rel in changes.files:
247
+ if not imports_mod.is_python_file(rel):
248
+ continue
249
+ text = gitio.file_content(self.repo, rel, staged=staged)
250
+ if text is None:
251
+ continue
252
+ result.scanned_files += 1
253
+
254
+ sites = imports_mod.extract_third_party(text, rel)
255
+ if not changes.is_fully_new(rel):
256
+ added = changes.lines_for(rel)
257
+ if added:
258
+ sites = [s for s in sites if any(n in added for n in s.lines)]
259
+
260
+ for site in sites:
261
+ if site.module in seen:
262
+ continue
263
+ # Sibling modules are first-party; skip the index lookup.
264
+ if siblings.resolves(rel, site.module):
265
+ continue
266
+ seen.add(site.module)
267
+
268
+ resolution = self.resolver.resolve(site.module)
269
+ any_exists = any(
270
+ index.contains_normalized(c) for c in resolution.candidates
271
+ )
272
+ declared_here = any(c in declared for c in resolution.candidates)
273
+
274
+ finding = verdict_mod.evaluate_import(
275
+ module=site.module,
276
+ candidates=resolution.candidates,
277
+ confidence=resolution.confidence.value,
278
+ location=f"{rel}:{site.lineno}",
279
+ any_candidate_exists=any_exists,
280
+ is_first_party=first_party.is_first_party_module(site.module),
281
+ private_index=private_index,
282
+ declared=declared_here,
283
+ )
284
+ if finding is not None:
285
+ findings.append(finding)
286
+ return findings
287
+
288
+ def _signals_for(self, name: str) -> MetadataSignals | None:
289
+ """Age/adoption signals from the local cache only. Miss = no signal."""
290
+ widely_adopted = None
291
+ if self.policy.use_adoption_signal and len(self.adoption):
292
+ widely_adopted = self.adoption.is_widely_adopted(name)
293
+
294
+ age_days = None
295
+ if self._metadata_cache is not None:
296
+ meta = self._metadata_cache.get(name)
297
+ if meta is not None:
298
+ age_days = meta.age_days()
299
+
300
+ return MetadataSignals(widely_adopted=widely_adopted, age_days=age_days)
301
+
302
+ def _load_provenance(self) -> ProvenanceMap:
303
+ """Hook first (per-line, uncommitted); git-ai only as fallback."""
304
+ git_dir = self.repo / ".git"
305
+ hook_map = HookProvenanceReader(git_dir).load()
306
+ if not hook_map.is_empty:
307
+ return hook_map
308
+
309
+ adapter = GitAiAdapter(self.repo)
310
+ if adapter.available:
311
+ return adapter.committed_provenance()
312
+ return ProvenanceMap()
313
+
314
+ def _apply_provenance(
315
+ self, findings: list[Finding], options: CheckOptions, result: CheckResult
316
+ ) -> list[Finding]:
317
+ """Apply provenance only inside the suspicious band."""
318
+ if not options.use_provenance or not self.policy.provenance_enabled:
319
+ result.provenance_source = "disabled"
320
+ for finding in findings:
321
+ finding.baseline_verdict = finding.verdict
322
+ return findings
323
+
324
+ provenance = self._load_provenance()
325
+ result.provenance_source = provenance.source or "none"
326
+
327
+ for finding in findings:
328
+ summary = UNTRACKED
329
+ if finding.location and ":" in finding.location:
330
+ path, _, lineno = finding.location.rpartition(":")
331
+ if lineno.isdigit():
332
+ summary = provenance.summarize(path, int(lineno))
333
+ verdict_mod.apply_provenance(finding, summary, self.policy)
334
+ return findings
335
+
336
+ def _apply_acknowledgements(self, findings: list[Finding]) -> list[Finding]:
337
+ """Drop findings already acked. Fingerprint ignores line numbers."""
338
+ log = DecisionLog(decisions_path(self.repo))
339
+ acknowledged = log.acknowledged_fingerprints()
340
+ if not acknowledged:
341
+ return findings
342
+
343
+ for finding in findings:
344
+ if finding.fingerprint() in acknowledged and finding.verdict is not Verdict.OK:
345
+ finding.verdict = Verdict.INFO
346
+ finding.reason = Reason.ACKNOWLEDGED
347
+ finding.detail = (
348
+ f"previously acknowledged; original finding: {finding.detail}"
349
+ if finding.detail else "previously acknowledged"
350
+ )
351
+ return findings
352
+
353
+ @staticmethod
354
+ def _deduplicate(findings: list[Finding]) -> list[Finding]:
355
+ """Keep the worst finding per (name, origin)."""
356
+ best: dict[tuple[str, str], Finding] = {}
357
+ for finding in findings:
358
+ key = (finding.name, finding.origin.value)
359
+ current = best.get(key)
360
+ if current is None or finding.verdict.rank > current.verdict.rank:
361
+ best[key] = finding
362
+
363
+ # Manifest finding already covers this name.
364
+ manifest_names = {
365
+ f.name for f in best.values() if f.origin is Origin.MANIFEST
366
+ }
367
+ ordered = [
368
+ f for f in best.values()
369
+ if not (f.origin is Origin.IMPORT and f.name in manifest_names)
370
+ ]
371
+ ordered.sort(key=lambda f: (-f.verdict.rank, f.origin.value, f.name))
372
+ return ordered
373
+
374
+ @staticmethod
375
+ def _count_bands(findings: list[Finding]) -> BandCounts:
376
+ bands = BandCounts(total=len(findings))
377
+ for finding in findings:
378
+ if finding.reason is Reason.ABSENT_FROM_INDEX and finding.verdict is Verdict.BLOCK:
379
+ bands.hard_absent += 1
380
+ elif finding.reason is Reason.ABSENT_BUT_FIRST_PARTY:
381
+ bands.first_party_suppressed += 1
382
+ elif finding.reason is Reason.ABSENT_BUT_PRIVATE_INDEX:
383
+ bands.private_index_suppressed += 1
384
+ elif finding.reason is Reason.ABSENT_UNRESOLVABLE_IMPORT:
385
+ bands.unresolvable_imports += 1
386
+ elif finding.reason is Reason.AMBIGUOUS_MAPPING:
387
+ bands.ambiguous_imports += 1
388
+ elif finding.reason is Reason.ACKNOWLEDGED:
389
+ bands.acknowledged += 1
390
+ if finding.provenance_sensitive:
391
+ bands.provenance_sensitive += 1
392
+ return bands