grift-cli 0.5.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.
@@ -0,0 +1,110 @@
1
+ {
2
+ "version": "v2026.09",
3
+ "analysis_scope": "repo",
4
+ "definition_version": "tep-v0.5.0-2026-08-22",
5
+ "tool_version": "0.5.0",
6
+ "pin_list_hash": "2a55849fd379283d",
7
+ "measured_at": "2026-08-21T17:00:53Z",
8
+ "ok_repos": 47,
9
+ "metrics": {
10
+ "test_cochange": {
11
+ "n": 33,
12
+ "min": 0.0,
13
+ "q1": 0.1181,
14
+ "median": 0.2328,
15
+ "q3": 0.3019,
16
+ "max": 0.5078,
17
+ "values": [
18
+ 0.0,
19
+ 0.0026,
20
+ 0.0071,
21
+ 0.0123,
22
+ 0.0306,
23
+ 0.0369,
24
+ 0.08,
25
+ 0.1113,
26
+ 0.1249,
27
+ 0.1274,
28
+ 0.1372,
29
+ 0.1471,
30
+ 0.1682,
31
+ 0.1993,
32
+ 0.1995,
33
+ 0.2143,
34
+ 0.2328,
35
+ 0.2331,
36
+ 0.2363,
37
+ 0.2395,
38
+ 0.2528,
39
+ 0.2588,
40
+ 0.2739,
41
+ 0.2766,
42
+ 0.2859,
43
+ 0.3178,
44
+ 0.3285,
45
+ 0.4154,
46
+ 0.4257,
47
+ 0.4438,
48
+ 0.4754,
49
+ 0.4964,
50
+ 0.5078
51
+ ]
52
+ },
53
+ "corrective_rework": {
54
+ "n": 42,
55
+ "min": 0.0,
56
+ "q1": 0.0329,
57
+ "median": 0.0614,
58
+ "q3": 0.1031,
59
+ "max": 0.3846,
60
+ "values": [
61
+ 0.0,
62
+ 0.0,
63
+ 0.0,
64
+ 0.0,
65
+ 0.0,
66
+ 0.0,
67
+ 0.0013,
68
+ 0.0142,
69
+ 0.0271,
70
+ 0.0289,
71
+ 0.0342,
72
+ 0.0419,
73
+ 0.047,
74
+ 0.0477,
75
+ 0.0491,
76
+ 0.0491,
77
+ 0.0528,
78
+ 0.0554,
79
+ 0.0571,
80
+ 0.0581,
81
+ 0.0594,
82
+ 0.0633,
83
+ 0.0649,
84
+ 0.0667,
85
+ 0.0672,
86
+ 0.0721,
87
+ 0.073,
88
+ 0.073,
89
+ 0.0852,
90
+ 0.0926,
91
+ 0.0975,
92
+ 0.102,
93
+ 0.1063,
94
+ 0.1106,
95
+ 0.1337,
96
+ 0.1338,
97
+ 0.1499,
98
+ 0.1546,
99
+ 0.1717,
100
+ 0.1951,
101
+ 0.1971,
102
+ 0.3846
103
+ ]
104
+ },
105
+ "survival_index": {
106
+ "n": 0,
107
+ "values": []
108
+ }
109
+ }
110
+ }
tep_core/gitutil.py ADDED
@@ -0,0 +1,112 @@
1
+ """Git subprocess helpers. Standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ _GIT_TIMEOUT_SECONDS = 120
10
+
11
+
12
+ class GitError(RuntimeError):
13
+ pass
14
+
15
+
16
+ @dataclass
17
+ class GitCommit:
18
+ sha: str
19
+ author_email: str
20
+ parents: tuple[str, ...]
21
+ date: str # YYYY-MM-DD
22
+ subject: str
23
+ files: tuple[str, ...] = field(default_factory=tuple)
24
+
25
+ @property
26
+ def is_merge(self) -> bool:
27
+ return len(self.parents) > 1
28
+
29
+
30
+ def _run_git(repo: Path, args: list[str], timeout: int = _GIT_TIMEOUT_SECONDS) -> str:
31
+ try:
32
+ result = subprocess.run( # noqa: S603
33
+ ["git", "-c", "safe.directory=*", "-C", str(repo), *args],
34
+ capture_output=True,
35
+ text=True,
36
+ encoding="utf-8",
37
+ errors="replace",
38
+ timeout=timeout,
39
+ )
40
+ except subprocess.TimeoutExpired as exc:
41
+ raise GitError(f"git {' '.join(args)} timed out") from exc
42
+ if result.returncode != 0:
43
+ raise GitError(result.stderr.strip() or f"git {' '.join(args)} failed")
44
+ return result.stdout
45
+
46
+
47
+ def rev_parse(repo: Path, rev: str = "HEAD") -> str:
48
+ return _run_git(repo, ["rev-parse", rev]).strip()
49
+
50
+
51
+ def remote_url(repo: Path, name: str = "origin") -> str | None:
52
+ try:
53
+ url = _run_git(repo, ["remote", "get-url", name]).strip()
54
+ except GitError:
55
+ return None
56
+ return url or None
57
+
58
+
59
+ def repository_identity(repo: Path, *, include_local_path: bool) -> dict[str, object]:
60
+ payload: dict[str, object] = {
61
+ "name": repo.name,
62
+ "remote": remote_url(repo),
63
+ }
64
+ if include_local_path:
65
+ payload["path"] = str(repo.resolve())
66
+ return payload
67
+
68
+
69
+ def read_commits(repo: Path, *, include_files: bool = False) -> list[GitCommit]:
70
+ """Read commit metadata. File names are optional (vendor heuristic)."""
71
+ fmt = "%H%x1f%ae%x1f%P%x1f%ad%x1f%s"
72
+ args = ["log", f"--format={fmt}", "--date=short"]
73
+ if include_files:
74
+ args.append("--name-only")
75
+ raw = _run_git(repo, args)
76
+ return _parse_log(raw, include_files=include_files)
77
+
78
+
79
+ def _parse_log(raw: str, *, include_files: bool) -> list[GitCommit]:
80
+ commits: list[GitCommit] = []
81
+ current: GitCommit | None = None
82
+ files: list[str] = []
83
+
84
+ def flush() -> None:
85
+ nonlocal current, files
86
+ if current is None:
87
+ return
88
+ current.files = tuple(files)
89
+ commits.append(current)
90
+ current = None
91
+ files = []
92
+
93
+ for line in raw.splitlines():
94
+ if "\x1f" in line:
95
+ flush()
96
+ parts = line.split("\x1f")
97
+ if len(parts) != 5:
98
+ continue
99
+ sha, email, parents, date, subject = parts
100
+ current = GitCommit(
101
+ sha=sha,
102
+ author_email=email,
103
+ parents=tuple(p for p in parents.split() if p),
104
+ date=date,
105
+ subject=subject,
106
+ )
107
+ files = []
108
+ continue
109
+ if include_files and current is not None and line.strip():
110
+ files.append(line.strip())
111
+ flush()
112
+ return commits
tep_core/identity.py ADDED
@@ -0,0 +1,109 @@
1
+ """Load `.tep/identity.toml` (identity-v1).
2
+
3
+ Vocabulary is shared with Grift actor_attributions:
4
+ canonical_id, emails, github_login, attribution_state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ import tomllib
14
+
15
+ from tep_core.version import IDENTITY_SCHEMA_VERSION
16
+
17
+ ATTRIBUTION_STATES = frozenset({"verified", "claimed", "inferred", "unresolved", "external", "bot"})
18
+ TENANT_STATES = frozenset({"verified", "claimed", "inferred"})
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Actor:
23
+ canonical_id: str
24
+ emails: tuple[str, ...]
25
+ github_login: str | None
26
+ attribution_state: str
27
+
28
+
29
+ @dataclass
30
+ class IdentityConfig:
31
+ schema_version: str = IDENTITY_SCHEMA_VERSION
32
+ email_patterns: tuple[re.Pattern[str], ...] = ()
33
+ actors: tuple[Actor, ...] = ()
34
+ source_path: Path | None = None
35
+ pending_attribution: bool = True
36
+ _email_to_actor: dict[str, Actor] = field(default_factory=dict, init=False, repr=False)
37
+
38
+ def __post_init__(self) -> None:
39
+ mapping: dict[str, Actor] = {}
40
+ for actor in self.actors:
41
+ for email in actor.emails:
42
+ mapping[email.lower()] = actor
43
+ self._email_to_actor = mapping
44
+
45
+ @property
46
+ def actor_count(self) -> int:
47
+ return len(self.actors)
48
+
49
+ def actor_for_email(self, email: str) -> Actor | None:
50
+ return self._email_to_actor.get(email.lower())
51
+
52
+ def is_tenant_email(self, email: str) -> bool:
53
+ actor = self.actor_for_email(email)
54
+ if actor is not None and actor.attribution_state in TENANT_STATES:
55
+ return True
56
+ return any(pattern.search(email) for pattern in self.email_patterns)
57
+
58
+ def state_for_email(self, email: str) -> str:
59
+ actor = self.actor_for_email(email)
60
+ if actor is not None:
61
+ return actor.attribution_state
62
+ if any(pattern.search(email) for pattern in self.email_patterns):
63
+ return "inferred"
64
+ return "unresolved"
65
+
66
+
67
+ def empty_identity() -> IdentityConfig:
68
+ return IdentityConfig(pending_attribution=True)
69
+
70
+
71
+ def load_identity(path: Path | None) -> IdentityConfig:
72
+ if path is None or not path.is_file():
73
+ return empty_identity()
74
+ with path.open("rb") as handle:
75
+ data = tomllib.load(handle)
76
+ raw_patterns = list(data.get("tenant", {}).get("email_patterns", []) or [])
77
+ patterns = tuple(re.compile(p, re.I) for p in raw_patterns)
78
+ actors: list[Actor] = []
79
+ for row in data.get("actors", []) or []:
80
+ state = str(row.get("attribution_state", "unresolved"))
81
+ if state not in ATTRIBUTION_STATES:
82
+ raise ValueError(f"unknown attribution_state: {state}")
83
+ emails = tuple(str(e) for e in (row.get("emails") or []) if e)
84
+ actors.append(
85
+ Actor(
86
+ canonical_id=str(row.get("canonical_id") or ""),
87
+ emails=emails,
88
+ github_login=row.get("github_login"),
89
+ attribution_state=state,
90
+ )
91
+ )
92
+ pending = not patterns and not actors
93
+ return IdentityConfig(
94
+ schema_version=str(data.get("schema_version") or IDENTITY_SCHEMA_VERSION),
95
+ email_patterns=patterns,
96
+ actors=tuple(actors),
97
+ source_path=path,
98
+ pending_attribution=pending,
99
+ )
100
+
101
+
102
+ def discover_identity(repo: Path, explicit: Path | None) -> IdentityConfig:
103
+ if explicit is not None:
104
+ return load_identity(explicit)
105
+ candidates = [repo / ".tep" / "identity.toml", Path.cwd() / ".tep" / "identity.toml"]
106
+ for candidate in candidates:
107
+ if candidate.is_file():
108
+ return load_identity(candidate)
109
+ return empty_identity()
tep_core/lineage.py ADDED
@@ -0,0 +1,22 @@
1
+ """Lineage: has_upstream_lineage = is_fork OR bool(parent)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Lineage:
10
+ is_fork: bool = False
11
+ parent: str | None = None
12
+
13
+ @property
14
+ def has_upstream_lineage(self) -> bool:
15
+ return self.is_fork or bool(self.parent)
16
+
17
+ def to_dict(self) -> dict[str, object]:
18
+ return {
19
+ "is_fork": self.is_fork,
20
+ "parent": self.parent,
21
+ "has_upstream_lineage": self.has_upstream_lineage,
22
+ }
@@ -0,0 +1,34 @@
1
+ """Observed vs not_observed. Zero is a legal observed value."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Observed:
11
+ value: Any
12
+ unit: str
13
+ sample_size: int | None = None
14
+
15
+ def to_dict(self) -> dict[str, Any]:
16
+ payload: dict[str, Any] = {
17
+ "kind": "observed",
18
+ "value": self.value,
19
+ "unit": self.unit,
20
+ }
21
+ if self.sample_size is not None:
22
+ payload["sample_size"] = self.sample_size
23
+ return payload
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class NotObserved:
28
+ reason: str
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return {"kind": "not_observed", "reason": self.reason}
32
+
33
+
34
+ Observation = Observed | NotObserved
tep_core/origin.py ADDED
@@ -0,0 +1,158 @@
1
+ """Origin classifier (revised taxonomy: SO2 8-class + unresolved/bot + P0-d-2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from dataclasses import dataclass, field
8
+
9
+ from tep_core.gitutil import GitCommit
10
+ from tep_core.identity import IdentityConfig
11
+ from tep_core.lineage import Lineage
12
+ from tep_core.observation import NotObserved, Observed
13
+
14
+ # Grift migration 000073 + 000075 vocabulary.
15
+ ORIGIN_CLASSES = (
16
+ "inherited_upstream",
17
+ "upstream_sync",
18
+ "tenant_unique",
19
+ "tenant_merge_or_sync",
20
+ "tenant_derivative",
21
+ "external_upstream_contribution",
22
+ "template_inherited",
23
+ "generated_or_vendor",
24
+ "ambiguous_origin",
25
+ "unresolved",
26
+ "bot",
27
+ )
28
+
29
+ ALWAYS_OBSERVED = frozenset(
30
+ {
31
+ "tenant_unique",
32
+ "inherited_upstream",
33
+ "generated_or_vendor",
34
+ "ambiguous_origin",
35
+ "unresolved",
36
+ "bot",
37
+ }
38
+ )
39
+
40
+ BOT_EMAIL_RE = re.compile(
41
+ r"\[bot\]@|^action@github\.com$|^github-actions|^dependabot|^renovate",
42
+ re.I,
43
+ )
44
+
45
+ _VENDOR_PREFIXES = (
46
+ "vendor/",
47
+ "node_modules/",
48
+ "third_party/",
49
+ "third-party/",
50
+ )
51
+ _VENDOR_SUFFIXES = (
52
+ ".min.js",
53
+ ".min.css",
54
+ ".pb.go",
55
+ )
56
+
57
+
58
+ def is_bot_email(email: str) -> bool:
59
+ return BOT_EMAIL_RE.search(email) is not None
60
+
61
+
62
+ def is_undecidable_email(email: str) -> bool:
63
+ """True when origin cannot be attributed even with more identity config."""
64
+ return not email.strip()
65
+
66
+
67
+ def is_vendor_path(path: str) -> bool:
68
+ lowered = path.replace("\\", "/").lstrip("./")
69
+ if any(lowered.startswith(prefix) for prefix in _VENDOR_PREFIXES):
70
+ return True
71
+ return any(lowered.endswith(suffix) for suffix in _VENDOR_SUFFIXES)
72
+
73
+
74
+ def is_vendor_only(commit: GitCommit) -> bool:
75
+ if not commit.files:
76
+ return False
77
+ return all(is_vendor_path(path) for path in commit.files)
78
+
79
+
80
+ @dataclass
81
+ class OriginResult:
82
+ counts: Counter[str] = field(default_factory=Counter)
83
+ bot_commits: int = 0
84
+ tenant_dates: list[str] = field(default_factory=list)
85
+ tenant_day_counts: Counter[str] = field(default_factory=Counter)
86
+ classes_by_sha: dict[str, str] = field(default_factory=dict)
87
+
88
+ def origin_observations(
89
+ self,
90
+ *,
91
+ template_provided: bool,
92
+ parent_repo_provided: bool,
93
+ has_upstream_lineage: bool,
94
+ ) -> dict[str, dict[str, object]]:
95
+ payload: dict[str, dict[str, object]] = {}
96
+ for name in ORIGIN_CLASSES:
97
+ if name == "upstream_sync" and not has_upstream_lineage:
98
+ payload[name] = NotObserved("no_upstream_lineage").to_dict()
99
+ elif name == "tenant_merge_or_sync" and has_upstream_lineage:
100
+ payload[name] = NotObserved("lineage_present").to_dict()
101
+ elif name in ALWAYS_OBSERVED or name in {
102
+ "upstream_sync",
103
+ "tenant_merge_or_sync",
104
+ }:
105
+ payload[name] = Observed(self.counts[name], "commits").to_dict()
106
+ elif name == "template_inherited" and not template_provided:
107
+ payload[name] = NotObserved("template_not_provided").to_dict()
108
+ elif (
109
+ name
110
+ in {
111
+ "tenant_derivative",
112
+ "external_upstream_contribution",
113
+ }
114
+ and not parent_repo_provided
115
+ ):
116
+ payload[name] = NotObserved("parent_repo_not_provided").to_dict()
117
+ else:
118
+ payload[name] = Observed(self.counts[name], "commits").to_dict()
119
+ return payload
120
+
121
+
122
+ def classify_commits(
123
+ commits: list[GitCommit],
124
+ identity: IdentityConfig,
125
+ lineage: Lineage,
126
+ ) -> OriginResult:
127
+ result = OriginResult()
128
+ has_lineage = lineage.has_upstream_lineage
129
+
130
+ for commit in commits:
131
+ email = commit.author_email
132
+ actor = identity.actor_for_email(email)
133
+ if is_bot_email(email) or (actor is not None and actor.attribution_state == "bot"):
134
+ result.bot_commits += 1
135
+ result.counts["bot"] += 1
136
+ result.classes_by_sha[commit.sha] = "bot"
137
+ continue
138
+ if is_vendor_only(commit):
139
+ result.counts["generated_or_vendor"] += 1
140
+ result.classes_by_sha[commit.sha] = "generated_or_vendor"
141
+ continue
142
+ tenant = identity.is_tenant_email(email)
143
+ if tenant:
144
+ if commit.is_merge:
145
+ klass = "upstream_sync" if has_lineage else "tenant_merge_or_sync"
146
+ else:
147
+ klass = "tenant_unique"
148
+ result.tenant_dates.append(commit.date)
149
+ result.tenant_day_counts[commit.date] += 1
150
+ elif has_lineage:
151
+ klass = "inherited_upstream"
152
+ elif is_undecidable_email(email):
153
+ klass = "ambiguous_origin"
154
+ else:
155
+ klass = "unresolved"
156
+ result.counts[klass] += 1
157
+ result.classes_by_sha[commit.sha] = klass
158
+ return result
tep_core/paths.py ADDED
@@ -0,0 +1,48 @@
1
+ """Path roles for test co-change and rework (clean-room)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ TEST_PATH_RE = re.compile(
8
+ r"(^|/)(tests|test|__tests__|spec)/"
9
+ r"|(^|/)(test_[^/]+|_test)\.py$"
10
+ r"|_test\.go$"
11
+ r"|\.spec\.(ts|tsx|js|jsx)$"
12
+ r"|\.test\.(ts|tsx|js|jsx)$"
13
+ r"|_spec\.rb$",
14
+ re.I,
15
+ )
16
+
17
+ DOC_OR_CONFIG_RE = re.compile(
18
+ r"\.(md|rst)$"
19
+ r"|(^|/)(docs|documentation|\.github)(/|$)"
20
+ r"|(^|/)(LICENSE|CHANGELOG|CONTRIBUTING)",
21
+ re.I,
22
+ )
23
+
24
+
25
+ def normalize(path: str) -> str:
26
+ return path.replace("\\", "/").lstrip("./")
27
+
28
+
29
+ def is_test_path(path: str) -> bool:
30
+ return bool(TEST_PATH_RE.search(normalize(path)))
31
+
32
+
33
+ def is_doc_or_config_path(path: str) -> bool:
34
+ p = normalize(path)
35
+ if is_test_path(p):
36
+ return False
37
+ return bool(DOC_OR_CONFIG_RE.search(p))
38
+
39
+
40
+ def is_prod_path(path: str) -> bool:
41
+ return not is_test_path(path) and not is_doc_or_config_path(path)
42
+
43
+
44
+ def split_paths(files: tuple[str, ...] | list[str]) -> tuple[list[str], list[str], list[str]]:
45
+ prod = [f for f in files if is_prod_path(f)]
46
+ tests = [f for f in files if is_test_path(f)]
47
+ docs = [f for f in files if is_doc_or_config_path(f)]
48
+ return prod, tests, docs
tep_core/reference.py ADDED
@@ -0,0 +1,76 @@
1
+ """Reference distribution lookup. Same-scope only. n<30 suppresses position."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ from tep_core.observation import NotObserved
10
+ from tep_core.scope import require_same_scope
11
+
12
+ REFERENCE_VERSION = "v2026.09"
13
+ MIN_N = 30
14
+ _DATA = Path(__file__).resolve().parent / "data" / "v2026.09" / "distributions.json"
15
+
16
+
17
+ @lru_cache(maxsize=1)
18
+ def load_distributions() -> dict:
19
+ if not _DATA.is_file():
20
+ return {
21
+ "version": REFERENCE_VERSION,
22
+ "analysis_scope": "repo",
23
+ "metrics": {},
24
+ }
25
+ return json.loads(_DATA.read_text(encoding="utf-8"))
26
+
27
+
28
+ def _quantile_index(sorted_values: list[float], value: float) -> int:
29
+ """1-based decile in 1..10 (ceil of 10 * fraction at-or-below)."""
30
+ if not sorted_values:
31
+ return 1
32
+ below = sum(1 for item in sorted_values if item <= value)
33
+ frac = below / len(sorted_values)
34
+ decile = int(frac * 10)
35
+ if decile < 1:
36
+ return 1
37
+ if decile > 10:
38
+ return 10
39
+ return decile
40
+
41
+
42
+ def interpret_metric(
43
+ *,
44
+ report_scope: str,
45
+ metric_id: str,
46
+ observation: dict,
47
+ ) -> dict[str, object]:
48
+ dist = load_distributions()
49
+ if report_scope != "repo":
50
+ return NotObserved("scope_is_tenant").to_dict()
51
+ require_same_scope(report_scope, str(dist.get("analysis_scope") or "repo"))
52
+ if observation.get("kind") != "observed":
53
+ return NotObserved("metric_not_observed").to_dict()
54
+ inner = observation.get("all_time") or observation
55
+ if inner.get("kind") != "observed" or inner.get("value") is None:
56
+ return NotObserved("metric_not_observed").to_dict()
57
+ population = inner.get("population")
58
+ if inner.get("narrate_rate") is False or (isinstance(population, int) and population < 20):
59
+ return NotObserved("insufficient_population").to_dict()
60
+ block = (dist.get("metrics") or {}).get(metric_id) or {}
61
+ values = list(block.get("values") or [])
62
+ n = int(block.get("n") or len(values))
63
+ if n < MIN_N:
64
+ return NotObserved("reference_too_small").to_dict()
65
+ value = float(inner["value"])
66
+ decile = _quantile_index(sorted(values), value)
67
+ return {
68
+ "kind": "observed",
69
+ "reference_version": dist.get("version", REFERENCE_VERSION),
70
+ "analysis_scope": "repo",
71
+ "n": n,
72
+ "decile": decile,
73
+ "value": value,
74
+ "unit": inner.get("unit", "ratio"),
75
+ "metric_id": metric_id,
76
+ }