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.
- grift_cli-0.5.0.dist-info/METADATA +97 -0
- grift_cli-0.5.0.dist-info/RECORD +26 -0
- grift_cli-0.5.0.dist-info/WHEEL +4 -0
- grift_cli-0.5.0.dist-info/entry_points.txt +2 -0
- grift_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- tep_cli/__init__.py +1 -0
- tep_cli/__main__.py +146 -0
- tep_core/__init__.py +14 -0
- tep_core/activity.py +55 -0
- tep_core/analyze.py +123 -0
- tep_core/cochange.py +102 -0
- tep_core/core_period.py +78 -0
- tep_core/data/v2026.09/distributions.json +110 -0
- tep_core/gitutil.py +112 -0
- tep_core/identity.py +109 -0
- tep_core/lineage.py +22 -0
- tep_core/observation.py +34 -0
- tep_core/origin.py +158 -0
- tep_core/paths.py +48 -0
- tep_core/reference.py +76 -0
- tep_core/report.py +219 -0
- tep_core/rework.py +117 -0
- tep_core/scope.py +38 -0
- tep_core/survival.py +73 -0
- tep_core/tests_observed.py +102 -0
- tep_core/version.py +6 -0
|
@@ -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
|
+
}
|
tep_core/observation.py
ADDED
|
@@ -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
|
+
}
|