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.

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"