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.
Potentially problematic release.
This version of grift-cli might be problematic. Click here for more details.
- 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
tep_core/report.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Markdown rendering. Units required. No grade vocabulary. No person scorecards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# P0-d-2: first-party merges are not narrated as upstream_sync.
|
|
8
|
+
_ORIGIN_ORDER = (
|
|
9
|
+
"tenant_unique",
|
|
10
|
+
"tenant_merge_or_sync",
|
|
11
|
+
"upstream_sync",
|
|
12
|
+
"inherited_upstream",
|
|
13
|
+
"unresolved",
|
|
14
|
+
"ambiguous_origin",
|
|
15
|
+
"tenant_derivative",
|
|
16
|
+
"external_upstream_contribution",
|
|
17
|
+
"template_inherited",
|
|
18
|
+
"generated_or_vendor",
|
|
19
|
+
"bot",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_DISPLAY_NAME = {
|
|
23
|
+
"tenant_merge_or_sync": "merge commits (PR flow)",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_SKIP_NOT_OBSERVED = frozenset({"upstream_sync", "tenant_merge_or_sync"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fmt_obs(obs: dict[str, Any], *, fallback: str = "not observed") -> str:
|
|
30
|
+
if obs.get("kind") == "not_observed":
|
|
31
|
+
return f"not observed ({obs.get('reason', 'unspecified')})"
|
|
32
|
+
value = obs.get("value")
|
|
33
|
+
unit = obs.get("unit", "")
|
|
34
|
+
sample = obs.get("sample_size")
|
|
35
|
+
if sample is not None:
|
|
36
|
+
return f"{value} {unit} (n={sample} days)"
|
|
37
|
+
return f"{value} {unit}".strip() if unit else fallback
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_markdown(report: dict[str, Any]) -> str:
|
|
41
|
+
prov = report["provenance"]
|
|
42
|
+
identity = report["identity"]
|
|
43
|
+
lineage = report["lineage"]
|
|
44
|
+
origin = report["origin"]
|
|
45
|
+
activity = report["activity"]
|
|
46
|
+
core = report["core_activity_period"]
|
|
47
|
+
tests = report["test_frameworks"]
|
|
48
|
+
repo = report.get("repository") or {}
|
|
49
|
+
|
|
50
|
+
lines: list[str] = [
|
|
51
|
+
"# TEP analysis",
|
|
52
|
+
"",
|
|
53
|
+
"## Provenance",
|
|
54
|
+
f"- method: {prov.get('method_name') or 'TEP'}",
|
|
55
|
+
f"- tool: {prov['tool_name']} {prov['tool_version']}",
|
|
56
|
+
f"- definition version: {prov['definition_version']}",
|
|
57
|
+
f"- analyzed commit SHA: `{prov['analyzed_commit_sha']}`",
|
|
58
|
+
f"- analyzed at: {prov['analyzed_at']}",
|
|
59
|
+
"",
|
|
60
|
+
"## Repository",
|
|
61
|
+
f"- name: {repo.get('name') or 'unknown'}",
|
|
62
|
+
f"- remote: {repo.get('remote') or 'none'}",
|
|
63
|
+
"",
|
|
64
|
+
"## Lineage",
|
|
65
|
+
f"- is_fork: {str(lineage['is_fork']).lower()}",
|
|
66
|
+
f"- parent: {lineage['parent'] or 'none'}",
|
|
67
|
+
f"- has_upstream_lineage: {str(lineage['has_upstream_lineage']).lower()}",
|
|
68
|
+
"",
|
|
69
|
+
"## Identity",
|
|
70
|
+
(
|
|
71
|
+
f"- pending_attribution: {str(identity['pending_attribution']).lower()} "
|
|
72
|
+
f"({identity['actor_count']} configured actors)"
|
|
73
|
+
),
|
|
74
|
+
"",
|
|
75
|
+
"## Origin (commits)",
|
|
76
|
+
]
|
|
77
|
+
for name in _ORIGIN_ORDER:
|
|
78
|
+
obs = origin.get(name)
|
|
79
|
+
if not obs:
|
|
80
|
+
continue
|
|
81
|
+
if obs.get("kind") == "not_observed" and name in _SKIP_NOT_OBSERVED:
|
|
82
|
+
continue
|
|
83
|
+
if (
|
|
84
|
+
name == "inherited_upstream"
|
|
85
|
+
and not lineage.get("has_upstream_lineage")
|
|
86
|
+
and obs.get("kind") == "observed"
|
|
87
|
+
and obs.get("value") == 0
|
|
88
|
+
):
|
|
89
|
+
continue
|
|
90
|
+
label = _DISPLAY_NAME.get(name, name)
|
|
91
|
+
lines.append(f"- {label}: {_fmt_obs(obs)}")
|
|
92
|
+
lines += [
|
|
93
|
+
"",
|
|
94
|
+
"## Activity (tenant_unique commits)",
|
|
95
|
+
f"- tenant commits: {_fmt_obs(activity['tenant_commits'])}",
|
|
96
|
+
f"- active days: {_fmt_obs(activity['active_days'])}",
|
|
97
|
+
f"- commits per active day: {_fmt_obs(activity['commits_per_active_day'])}",
|
|
98
|
+
(
|
|
99
|
+
"- commits per active day (median): "
|
|
100
|
+
f"{_fmt_obs(activity['commits_per_active_day_median'])}"
|
|
101
|
+
),
|
|
102
|
+
f"- active days (13 weeks): {_fmt_obs(activity['active_days_13w'])}",
|
|
103
|
+
"",
|
|
104
|
+
"## Core activity period",
|
|
105
|
+
]
|
|
106
|
+
if core.get("kind") == "not_observed":
|
|
107
|
+
lines.append(f"- not observed ({core.get('reason')})")
|
|
108
|
+
else:
|
|
109
|
+
share_pct = round(float(core["share"]) * 100, 1)
|
|
110
|
+
lines.append(
|
|
111
|
+
f"- {core['start']} to {core['end']} "
|
|
112
|
+
f"({share_pct} percent of tenant_unique commits; {core['unit']}; "
|
|
113
|
+
f"definition {core['definition_version']})"
|
|
114
|
+
)
|
|
115
|
+
if activity["commits_per_active_day_median"].get("sample_size", 99) < 5:
|
|
116
|
+
lines.append("- comparison narrative omitted: median sample size is below 5 days")
|
|
117
|
+
lines += ["", "## Test frameworks"]
|
|
118
|
+
if tests.get("kind") == "not_observed":
|
|
119
|
+
lines.append(f"- not observed ({tests.get('reason')})")
|
|
120
|
+
else:
|
|
121
|
+
names = ", ".join(tests.get("names") or []) or "none named"
|
|
122
|
+
lines.append(f"- observed: {names} (boolean {tests.get('value')})")
|
|
123
|
+
lines += ["", "## Test co-change", f"- {_fmt_metric_block(report.get('test_cochange'))}"]
|
|
124
|
+
interp = report.get("interpretation") or {}
|
|
125
|
+
lines += _fmt_interp_line("co-change", interp.get("test_cochange"))
|
|
126
|
+
lines += ["", "## Rework", *_fmt_rework_lines(report.get("rework"))]
|
|
127
|
+
lines += _fmt_interp_line("corrective rework", interp.get("corrective_rework"))
|
|
128
|
+
lines += ["", "## Survival (tau=180 days)", f"- {_fmt_metric_block(report.get('survival'))}"]
|
|
129
|
+
lines.append("")
|
|
130
|
+
return "\n".join(lines)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _fmt_metric_block(obs: dict[str, Any] | None) -> str:
|
|
134
|
+
if not obs:
|
|
135
|
+
return "not observed (missing)"
|
|
136
|
+
if obs.get("kind") == "not_observed":
|
|
137
|
+
return f"not observed ({obs.get('reason')})"
|
|
138
|
+
if "all_time" in obs:
|
|
139
|
+
block = obs["all_time"]
|
|
140
|
+
if block.get("kind") == "not_observed":
|
|
141
|
+
return f"all-time not observed ({block.get('reason')})"
|
|
142
|
+
rate = block.get("value")
|
|
143
|
+
pop = block.get("population")
|
|
144
|
+
unit = block.get("unit", "ratio")
|
|
145
|
+
narrate = block.get("narrate_rate", True)
|
|
146
|
+
if not narrate:
|
|
147
|
+
return (
|
|
148
|
+
"all-time not narrated (insufficient_population; "
|
|
149
|
+
f"{pop}コミット中{block.get('cochanged')}件)"
|
|
150
|
+
)
|
|
151
|
+
return f"all-time {rate} {unit} (population {pop} commits)"
|
|
152
|
+
if "corrective_rework_rate" in obs:
|
|
153
|
+
return _fmt_rework_lines(obs)[0].lstrip("- ")
|
|
154
|
+
if "survival_index" in obs:
|
|
155
|
+
return (
|
|
156
|
+
f"survival index {obs['survival_index']} {obs.get('unit')} "
|
|
157
|
+
f"(tau {obs.get('tau_days')} days; {obs.get('files_sampled')} files; "
|
|
158
|
+
f"{obs.get('lines_sampled')} lines)"
|
|
159
|
+
)
|
|
160
|
+
return "observed"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _fmt_rework_lines(obs: dict[str, Any] | None) -> list[str]:
|
|
164
|
+
if not obs:
|
|
165
|
+
return ["- not observed (missing)"]
|
|
166
|
+
if obs.get("kind") == "not_observed":
|
|
167
|
+
return [f"- not observed ({obs.get('reason')})"]
|
|
168
|
+
corr = obs.get("corrective_rework_rate") or {}
|
|
169
|
+
touch = obs.get("path_retouch_rate") or {}
|
|
170
|
+
window = obs.get("window_days")
|
|
171
|
+
lines: list[str] = []
|
|
172
|
+
if corr.get("kind") == "observed":
|
|
173
|
+
if corr.get("narrate_rate", True):
|
|
174
|
+
lines.append(
|
|
175
|
+
f"- corrective rework (observational, subject-convention dependent; "
|
|
176
|
+
f"not an evidence claim): {corr.get('value')} {corr.get('unit')} "
|
|
177
|
+
f"({corr.get('corrective_commits')} of {corr.get('population')} "
|
|
178
|
+
f"commits; {window} day window)"
|
|
179
|
+
)
|
|
180
|
+
lines.append(
|
|
181
|
+
"- limit: corrective rework is fresh-work stability "
|
|
182
|
+
"(recent paths needing an immediate fix/revert); "
|
|
183
|
+
"it is not a bug count"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
lines.append(
|
|
187
|
+
"- corrective rework (observational, subject-convention dependent; "
|
|
188
|
+
"not an evidence claim): not narrated (insufficient_population; "
|
|
189
|
+
f"{corr.get('population')}コミット中{corr.get('corrective_commits')}件)"
|
|
190
|
+
)
|
|
191
|
+
lines.append(
|
|
192
|
+
"- limit: corrective rework is fresh-work stability "
|
|
193
|
+
"(recent paths needing an immediate fix/revert); "
|
|
194
|
+
"it is not a bug count"
|
|
195
|
+
)
|
|
196
|
+
touch_pop = touch.get("population")
|
|
197
|
+
if isinstance(touch_pop, int) and touch_pop < 20:
|
|
198
|
+
lines.append(
|
|
199
|
+
"- path retouch (observational, not an evidence claim): "
|
|
200
|
+
"not narrated (insufficient_population; "
|
|
201
|
+
f"{touch_pop}コミット中{touch.get('retouch_commits')}件)"
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
lines.append(
|
|
205
|
+
"- path retouch (observational, not an evidence claim): "
|
|
206
|
+
f"{touch.get('value')} {touch.get('unit')} "
|
|
207
|
+
f"({touch.get('retouch_commits')} of {touch.get('population')} commits)"
|
|
208
|
+
)
|
|
209
|
+
return lines or ["- observed"]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _fmt_interp_line(label: str, obs: dict[str, Any] | None) -> list[str]:
|
|
213
|
+
if not obs or obs.get("kind") != "observed":
|
|
214
|
+
return []
|
|
215
|
+
return [
|
|
216
|
+
f"- {label} {obs.get('value')} ({obs.get('analysis_scope')} scope) — "
|
|
217
|
+
f"reference corpus {obs.get('reference_version')} (n={obs.get('n')}) "
|
|
218
|
+
f"decile {obs.get('decile')}"
|
|
219
|
+
]
|
tep_core/rework.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Rework metrics among production commits in the analysis scope (H-4 / WP1b-1).
|
|
2
|
+
|
|
3
|
+
path_retouch_rate and corrective_rework_rate are observational only.
|
|
4
|
+
corrective_rework_rate depends on commit-subject conventions (fix/revert) and
|
|
5
|
+
must not be used as an evidence claim (measurement confounding: conventional
|
|
6
|
+
commit culture inflates detection). Line-level rework is deferred to v0.6.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import date
|
|
13
|
+
|
|
14
|
+
from tep_core.gitutil import GitCommit
|
|
15
|
+
from tep_core.observation import NotObserved
|
|
16
|
+
from tep_core.origin import OriginResult
|
|
17
|
+
from tep_core.paths import split_paths
|
|
18
|
+
from tep_core.scope import DEFAULT_SCOPE, population_shas
|
|
19
|
+
|
|
20
|
+
REWORK_DEFINITION_VERSION = "rework-v1.1-2026-08-22"
|
|
21
|
+
REWORK_WINDOW_DAYS = 21
|
|
22
|
+
|
|
23
|
+
# Conventional-commit and common prose: fix / hotfix / bug / revert.
|
|
24
|
+
_CORRECTIVE_SUBJECT_RE = re.compile(
|
|
25
|
+
r"^(revert\b|fix(\b|\(|:|!)|hotfix\b|bugfix\b|bug(\b|\(|:)|fixes\s+#)",
|
|
26
|
+
re.I,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_day(value: str) -> date:
|
|
31
|
+
year, month, day = (int(part) for part in value.split("-"))
|
|
32
|
+
return date(year, month, day)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_corrective_subject(subject: str) -> bool:
|
|
36
|
+
return bool(_CORRECTIVE_SUBJECT_RE.search(subject) or "This reverts commit" in subject)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rework_metrics(
|
|
40
|
+
commits: list[GitCommit],
|
|
41
|
+
origin: OriginResult,
|
|
42
|
+
*,
|
|
43
|
+
pending_attribution: bool,
|
|
44
|
+
scope: str = DEFAULT_SCOPE,
|
|
45
|
+
) -> dict[str, object]:
|
|
46
|
+
shas = population_shas(commits, origin, scope)
|
|
47
|
+
if scope == "tenant" and pending_attribution and not shas:
|
|
48
|
+
return NotObserved("pending_attribution").to_dict()
|
|
49
|
+
if not shas:
|
|
50
|
+
return NotObserved(
|
|
51
|
+
"no_tenant_commits" if scope == "tenant" else "no_human_commits"
|
|
52
|
+
).to_dict()
|
|
53
|
+
if not any(c.files for c in commits):
|
|
54
|
+
return NotObserved("commit_paths_unavailable").to_dict()
|
|
55
|
+
|
|
56
|
+
prod_commits = [c for c in commits if c.sha in shas and split_paths(c.files)[0]]
|
|
57
|
+
if not prod_commits:
|
|
58
|
+
return NotObserved("no_production_commits").to_dict()
|
|
59
|
+
|
|
60
|
+
chrono = sorted(prod_commits, key=lambda c: c.date)
|
|
61
|
+
last_touch: dict[str, date] = {}
|
|
62
|
+
retouch = 0
|
|
63
|
+
corrective = 0
|
|
64
|
+
for commit in chrono:
|
|
65
|
+
prod, _, _ = split_paths(commit.files)
|
|
66
|
+
day = _parse_day(commit.date)
|
|
67
|
+
recent_path = False
|
|
68
|
+
for path in prod:
|
|
69
|
+
prev = last_touch.get(path)
|
|
70
|
+
if prev is not None and (day - prev).days <= REWORK_WINDOW_DAYS:
|
|
71
|
+
recent_path = True
|
|
72
|
+
last_touch[path] = day
|
|
73
|
+
if recent_path:
|
|
74
|
+
retouch += 1
|
|
75
|
+
if is_corrective_subject(commit.subject):
|
|
76
|
+
corrective += 1
|
|
77
|
+
|
|
78
|
+
reverts = sum(
|
|
79
|
+
1
|
|
80
|
+
for c in commits
|
|
81
|
+
if c.sha in shas
|
|
82
|
+
and is_corrective_subject(c.subject)
|
|
83
|
+
and (c.subject.lower().startswith("revert") or "This reverts commit" in c.subject)
|
|
84
|
+
)
|
|
85
|
+
population = len(prod_commits)
|
|
86
|
+
return {
|
|
87
|
+
"kind": "observed",
|
|
88
|
+
"definition_version": REWORK_DEFINITION_VERSION,
|
|
89
|
+
"analysis_scope": scope,
|
|
90
|
+
"window_days": REWORK_WINDOW_DAYS,
|
|
91
|
+
"evidence_claim": None,
|
|
92
|
+
"corrective_rework_rate": {
|
|
93
|
+
"kind": "observed",
|
|
94
|
+
"value": round(corrective / population, 4),
|
|
95
|
+
"unit": "ratio",
|
|
96
|
+
"corrective_commits": corrective,
|
|
97
|
+
"population": population,
|
|
98
|
+
"narrate_rate": population >= 20,
|
|
99
|
+
"evidence_claim": False,
|
|
100
|
+
},
|
|
101
|
+
"path_retouch_rate": {
|
|
102
|
+
"kind": "observed",
|
|
103
|
+
"value": round(retouch / population, 4),
|
|
104
|
+
"unit": "ratio",
|
|
105
|
+
"retouch_commits": retouch,
|
|
106
|
+
"population": population,
|
|
107
|
+
"evidence_claim": False,
|
|
108
|
+
},
|
|
109
|
+
"revert_rate": {
|
|
110
|
+
"kind": "observed",
|
|
111
|
+
"value": round(reverts / max(len(shas), 1), 4),
|
|
112
|
+
"unit": "ratio",
|
|
113
|
+
"reverts": reverts,
|
|
114
|
+
"population": len(shas),
|
|
115
|
+
},
|
|
116
|
+
"line_rework": NotObserved("deferred_to_v0.6").to_dict(),
|
|
117
|
+
}
|
tep_core/scope.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Analysis scope: tenant (evidence) vs repo (process / reference distribution)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tep_core.gitutil import GitCommit
|
|
6
|
+
from tep_core.origin import OriginResult
|
|
7
|
+
|
|
8
|
+
SCOPES = ("tenant", "repo")
|
|
9
|
+
DEFAULT_SCOPE = "tenant"
|
|
10
|
+
TENANT_ORIGIN = frozenset({"tenant_unique"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScopeMismatchError(ValueError):
|
|
14
|
+
"""Comparing values computed under different analysis scopes is forbidden."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def require_same_scope(left: str, right: str) -> None:
|
|
18
|
+
if left != right:
|
|
19
|
+
raise ScopeMismatchError(
|
|
20
|
+
f"scope mismatch: {left!r} vs {right!r} — reference lookup is same-scope only"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def population_shas(
|
|
25
|
+
commits: list[GitCommit],
|
|
26
|
+
origin: OriginResult,
|
|
27
|
+
scope: str,
|
|
28
|
+
) -> set[str]:
|
|
29
|
+
"""SHAs eligible for co-change/rework. Merge and bot are always excluded."""
|
|
30
|
+
if scope not in SCOPES:
|
|
31
|
+
raise ValueError(f"unknown scope: {scope}")
|
|
32
|
+
if scope == "tenant":
|
|
33
|
+
return {sha for sha, klass in origin.classes_by_sha.items() if klass in TENANT_ORIGIN}
|
|
34
|
+
return {
|
|
35
|
+
commit.sha
|
|
36
|
+
for commit in commits
|
|
37
|
+
if origin.classes_by_sha.get(commit.sha) != "bot" and not commit.is_merge
|
|
38
|
+
}
|
tep_core/survival.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Repo-level time-decayed survival (EIS-style, tau=180d). No person scorecards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from tep_core.gitutil import GitError, _run_git
|
|
10
|
+
from tep_core.observation import NotObserved
|
|
11
|
+
|
|
12
|
+
SURVIVAL_DEFINITION_VERSION = "survival-v1-2026-08-22"
|
|
13
|
+
TAU_DAYS = 180
|
|
14
|
+
TAU_SECONDS = TAU_DAYS * 86400
|
|
15
|
+
SRC_EXT = (".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".vue", ".svelte", ".astro", ".swift")
|
|
16
|
+
EXCLUDE = re.compile(r"node_modules/|vendor/|dist/|build/|\.min\.|generated")
|
|
17
|
+
DEFAULT_MAX_FILES = 40
|
|
18
|
+
BLAME_TIMEOUT = 12
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def survival_metrics(
|
|
22
|
+
repo: Path,
|
|
23
|
+
*,
|
|
24
|
+
enabled: bool,
|
|
25
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
26
|
+
) -> dict[str, object]:
|
|
27
|
+
if not enabled:
|
|
28
|
+
return NotObserved("survival_scan_disabled").to_dict()
|
|
29
|
+
try:
|
|
30
|
+
listed = _run_git(repo, ["ls-files"]).splitlines()
|
|
31
|
+
head_ts = int(_run_git(repo, ["log", "-1", "--format=%at"]).strip() or "0")
|
|
32
|
+
except GitError:
|
|
33
|
+
return NotObserved("git_unavailable").to_dict()
|
|
34
|
+
|
|
35
|
+
files = [f for f in listed if f.endswith(SRC_EXT) and not EXCLUDE.search(f)]
|
|
36
|
+
if not files:
|
|
37
|
+
return NotObserved("no_source_files").to_dict()
|
|
38
|
+
|
|
39
|
+
step = max(1, len(files) // max_files)
|
|
40
|
+
sample = files[::step][:max_files]
|
|
41
|
+
weights = 0.0
|
|
42
|
+
lines = 0
|
|
43
|
+
scanned = 0
|
|
44
|
+
for path in sample:
|
|
45
|
+
try:
|
|
46
|
+
out = _run_git(
|
|
47
|
+
repo,
|
|
48
|
+
["blame", "--line-porcelain", "HEAD", "--", path],
|
|
49
|
+
timeout=BLAME_TIMEOUT,
|
|
50
|
+
)
|
|
51
|
+
except GitError:
|
|
52
|
+
continue
|
|
53
|
+
if not out:
|
|
54
|
+
continue
|
|
55
|
+
scanned += 1
|
|
56
|
+
for line in out.splitlines():
|
|
57
|
+
if line.startswith("author-time "):
|
|
58
|
+
ts = int(line.split()[1])
|
|
59
|
+
age = max(0, head_ts - ts)
|
|
60
|
+
weights += math.exp(-age / TAU_SECONDS)
|
|
61
|
+
lines += 1
|
|
62
|
+
if lines == 0 or scanned == 0:
|
|
63
|
+
return NotObserved("blame_unavailable").to_dict()
|
|
64
|
+
return {
|
|
65
|
+
"kind": "observed",
|
|
66
|
+
"definition_version": SURVIVAL_DEFINITION_VERSION,
|
|
67
|
+
"tau_days": TAU_DAYS,
|
|
68
|
+
"files_sampled": scanned,
|
|
69
|
+
"source_files": len(files),
|
|
70
|
+
"lines_sampled": lines,
|
|
71
|
+
"survival_index": round(weights / lines, 4),
|
|
72
|
+
"unit": "dimensionless",
|
|
73
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Test-framework observation at HEAD. not_observed != 0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from tep_core.observation import NotObserved
|
|
10
|
+
|
|
11
|
+
FRAMEWORK_MARKERS = (
|
|
12
|
+
"vitest",
|
|
13
|
+
"jest",
|
|
14
|
+
"playwright",
|
|
15
|
+
"cypress",
|
|
16
|
+
"mocha",
|
|
17
|
+
"jasmine",
|
|
18
|
+
"pytest",
|
|
19
|
+
"unittest",
|
|
20
|
+
"nyc",
|
|
21
|
+
"testing-library",
|
|
22
|
+
"ava",
|
|
23
|
+
"karma",
|
|
24
|
+
"selenium",
|
|
25
|
+
"testify",
|
|
26
|
+
"ginkgo",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
TEST_DIRECTORIES = (
|
|
30
|
+
"tests",
|
|
31
|
+
"test",
|
|
32
|
+
"__tests__",
|
|
33
|
+
"spec",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_QUOTED_TOKEN = re.compile(r"[\"']([A-Za-z0-9_.-]+)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _package_json_names(root: Path) -> set[str]:
|
|
40
|
+
path = root / "package.json"
|
|
41
|
+
if not path.is_file():
|
|
42
|
+
return set()
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
return set()
|
|
47
|
+
keys = {
|
|
48
|
+
**(data.get("dependencies") or {}),
|
|
49
|
+
**(data.get("devDependencies") or {}),
|
|
50
|
+
}
|
|
51
|
+
return {str(name).lower() for name in keys}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _quoted_tokens(text: str) -> set[str]:
|
|
55
|
+
return {match.group(1).lower() for match in _QUOTED_TOKEN.finditer(text)}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _manifest_names(root: Path) -> set[str]:
|
|
59
|
+
names: set[str] = set()
|
|
60
|
+
names |= _package_json_names(root)
|
|
61
|
+
for rel in ("pyproject.toml", "tox.ini", "setup.cfg", "Pipfile", "go.mod", "Gemfile"):
|
|
62
|
+
path = root / rel
|
|
63
|
+
if path.is_file():
|
|
64
|
+
names |= _quoted_tokens(path.read_text(encoding="utf-8", errors="replace"))
|
|
65
|
+
for req in root.glob("requirements*.txt"):
|
|
66
|
+
names |= _quoted_tokens(req.read_text(encoding="utf-8", errors="replace"))
|
|
67
|
+
for line in req.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
68
|
+
token = re.split(r"[<>=!~;\s]", line.strip(), maxsplit=1)[0].lower()
|
|
69
|
+
if token:
|
|
70
|
+
names.add(token)
|
|
71
|
+
return names
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _matched_markers(package_names: set[str]) -> list[str]:
|
|
75
|
+
found: list[str] = []
|
|
76
|
+
for marker in FRAMEWORK_MARKERS:
|
|
77
|
+
if any(marker in pkg for pkg in package_names):
|
|
78
|
+
found.append(marker)
|
|
79
|
+
return found
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _test_directories(root: Path) -> list[str]:
|
|
83
|
+
found: list[str] = []
|
|
84
|
+
for name in TEST_DIRECTORIES:
|
|
85
|
+
candidate = root / name
|
|
86
|
+
if candidate.is_dir():
|
|
87
|
+
found.append(name)
|
|
88
|
+
return found
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def observe_test_frameworks(root: Path) -> dict[str, object]:
|
|
92
|
+
names = _matched_markers(_manifest_names(root))
|
|
93
|
+
directories = _test_directories(root)
|
|
94
|
+
if not names and not directories:
|
|
95
|
+
return NotObserved("no_test_framework_or_directory").to_dict()
|
|
96
|
+
return {
|
|
97
|
+
"kind": "observed",
|
|
98
|
+
"value": True,
|
|
99
|
+
"names": names,
|
|
100
|
+
"directories": directories,
|
|
101
|
+
"unit": "boolean",
|
|
102
|
+
}
|
tep_core/version.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
2
|
+
DEFINITION_VERSION = "tep-v0.5.0-2026-08-22"
|
|
3
|
+
ORIGIN_DEFINITION_VERSION = "origin-v1.1-2026-08-22"
|
|
4
|
+
ACTIVITY_DEFINITION_VERSION = "activity-v1-2026-08-16"
|
|
5
|
+
CORE_ACTIVITY_DEFINITION_VERSION = "core-activity-v1-2026-08-16"
|
|
6
|
+
IDENTITY_SCHEMA_VERSION = "identity-v1"
|