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,97 @@
1
+ Metadata-Version: 2.5
2
+ Name: grift-cli
3
+ Version: 0.5.0
4
+ Summary: Deterministic TEP evidence metrics from a single git repository. No LLM.
5
+ Project-URL: Homepage, https://github.com/Cor-Incorporated/grift-cli
6
+ Author: TEP contributors
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: evidence,git,origin,provenance,tep
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.11
19
+ Provides-Extra: dev
20
+ Requires-Dist: build>=1.2; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # grift — AI時代に痩せない証拠
25
+
26
+ **日本語** | [English](README.en.md)
27
+
28
+ Method = **TEP** / Tool = **grift**(CodeScene 型の二層。方法論名は TEP、コマンドは `grift`)。
29
+ Named after the Grift product line.
30
+
31
+ 量は誰でも作れる。残るものと、検証が伴った変更を測る。技量スコアではない。
32
+
33
+ コミット数・行数・「活動量」は、エージェントと生成コードの時代に簡単に膨らむ。grift は git だけから、TEP の帰属(誰の仕事か)と検証層(test co-change ほか)を**決定論的に**出す。LLM は使わない。合成スコアも等級語彙も出さない。
34
+
35
+ ## 5分で始める
36
+
37
+ ```bash
38
+ pipx install grift-cli
39
+ grift analyze ./my-repo --scope repo
40
+ ```
41
+
42
+ - `--scope tenant`(既定): `.tep/identity.toml` に載った人の仕事 = **証拠用**
43
+ - `--scope repo`: bot 以外の全人間コミット = **プロセス観測・参照分布用**
44
+
45
+ 参照分布 v2026.09 は **repo スコープ同士**でのみ照合する。tenant の値を repo 分布に載せない(混ぜたら分布が嘘になる)。
46
+
47
+ ## 実レポート例
48
+
49
+ 自リポで `grift analyze . --format md` を実行する。各数値に単位と provenance(method=TEP・tool=grift・定義版・分析 SHA・`analysis_scope`)が付く。
50
+
51
+ 例(click、tenant スコープ。ゴールデン G1):
52
+
53
+ - test co-change: 0.2664 ratio(73 of 274)
54
+ - corrective rework: 0.0109 ratio(3 of 274)— 観測値。証拠主張ではない
55
+ - path retouch: 0.573 ratio — 観測のみ
56
+
57
+ 母数 20 未満では率も分布位置も出さない(`insufficient_population`。件数の生表示のみ)。
58
+
59
+ ## 指標と限界
60
+
61
+ | 指標 | 何の証拠か | 限界 |
62
+ |---|---|---|
63
+ | origin / 帰属 | 誰の仕事か | identity 未設定は unresolved。Cor 実メールは同梱しない |
64
+ | test co-change | 本番変更にテストが伴うか | テスト基盤なしは not_observed。母数<20 は率も十分位も出さない(`insufficient_population`) |
65
+ | corrective rework | 件名が fix/revert の直近パス回帰(観測) | **証拠主張から降格**。件名規約依存の交絡あり。バグ件数ではない。再挑戦は v0.6 行レベル |
66
+ | path retouch | 同一ファイル再接触 | 観測のみ。証拠主張に使わない |
67
+ | survival (τ=180d) | 行の残り方 | `--survival` のみ。参照分布は **v2027 予定**。今回の公開分布には含めない |
68
+
69
+ 参照位置は「第N十分位」の1行だけ。n<30 の指標は位置を出さない(`reference_too_small`)。v2026.09 の観測 n: test co-change 33 / corrective rework 42(観測)/ survival 0。
70
+
71
+ ## 判別力の現況(repo スコープ・基準 `5f2665e`)
72
+
73
+ 出典: `corpus/DISCRIMINANT-v2026.09.md` Run 2。verdict はファイルから逐語。
74
+
75
+ - `test_cochange A vs C` → `separated`(median_A 0.2379, median_other 0.0306, gap 0.2073, Cliff δ 0.7143, n 18/7)
76
+ - `test_cochange A vs D` → `inconclusive_small_n`(n 18/1。D の narratable 観測が 1)
77
+ - `corrective_rework A vs C` → `fail_tier2`(median_A 0.0522, median_other 0.1063, gap -0.0541, Cliff δ -0.7083, n 18/8)。観測診断のみ。証拠主張には使わない
78
+ - survival: 本コーパスでは未計測。参照分布は v2027 予定
79
+
80
+ WP4 Run 1 の記録(`separated` / `inconclusive_small_n` / `fail_tier2`)は同ファイルに履歴として残してある。
81
+
82
+ ## 倫理
83
+
84
+ - 実在個人のスコアカード・順位表は公開も内部共有も禁止
85
+ - 等級語彙(シニア/ジュニア/素晴らしい)を出さない
86
+ - コーパスの manifest(リポ名+SHA)は再現用に公開してよい。**個別リポ値のリーグテーブルは公開しない**
87
+ - 対照群 E(私的 ground truth)はこのリポに入れない
88
+
89
+ ## 公開状態
90
+
91
+ - 公開リポ: https://github.com/Cor-Incorporated/grift-cli
92
+ - コマンド名: **`grift`**(方法論名 TEP はレポートに残す)
93
+ - PyPI: 配布名 **`grift-cli`**。本公開は別ゲート(未了ならソースから `pipx install .`)
94
+
95
+ ## License
96
+
97
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,26 @@
1
+ tep_cli/__init__.py,sha256=Mt-fwNaHxKqY9wA8r-0TRZTZQxV3bai6Hc1gv5RKBC8,40
2
+ tep_cli/__main__.py,sha256=4Qt60qI-UrGIV7VbBlP0_jIgo8LlBCA3X_oPyXn5KD0,4633
3
+ tep_core/__init__.py,sha256=ZmFlXgmQ-dCTLT8yZRNiD_uN3hpLiv_MJ9ISQiJHgzA,387
4
+ tep_core/activity.py,sha256=_3bTrP0XQPz02-trMAq6d8rlbGb-2EI0UKEzqyCCIYk,1934
5
+ tep_core/analyze.py,sha256=bvbVjE_59Fdz54866ccsmPqU2_68ftrBYyoNOj3ju4A,4652
6
+ tep_core/cochange.py,sha256=-rVdEFeew_kBbUnUfg7meTujj8ZdpMeJTspI_Q-JsVg,3340
7
+ tep_core/core_period.py,sha256=U9U6BGuZj19ZLhHroRCCmrBJrIfGeEuOu-r_T7da3Hk,2601
8
+ tep_core/gitutil.py,sha256=SNuOiLLBMZ97J-yUh2WytfkMlQhdL3GivdHCPoQYqXk,3170
9
+ tep_core/identity.py,sha256=WhGlmGAHM1tdMltACBerfiST1fTXVHEYSjcytepMiOM,3669
10
+ tep_core/lineage.py,sha256=OpXhe_88XFki5hwYVcUb1X8WOnr11f-mZANZfT0n7VY,546
11
+ tep_core/observation.py,sha256=xlzw-FDBdYcy3Ud73_zaEdBokqYCBM6mvy-b8WZRN6Q,771
12
+ tep_core/origin.py,sha256=TCMV5Q-nhFSOT_TiozLeKsy7oqUP8ESO_qnSAacYD0k,4937
13
+ tep_core/paths.py,sha256=xEjpSl5tjZp_qXkNlC0qyt2d1PWoNcK8UK2YZLXsmJ0,1218
14
+ tep_core/reference.py,sha256=QrE_CnojzEEgnRL-zDNHHLVaVOeawmisQKYk81zFSj0,2506
15
+ tep_core/report.py,sha256=FcPia0vtP8qPlh8ov4Eh8hd_QSJ7kn4OE5aNlU0ukwk,8612
16
+ tep_core/rework.py,sha256=tw6hrBkzyHFhtydTiQKwgj7H5y0QbnnC22A95xYkw5E,3980
17
+ tep_core/scope.py,sha256=yha9vm72pqMWgE3i1coZQIxEMM4GtsOqw2TCYc3zYng,1180
18
+ tep_core/survival.py,sha256=VoxOHN7L-BfuMrrllj1rXFhQe4WCzVXu_M7t1xNIJFY,2300
19
+ tep_core/tests_observed.py,sha256=fF8Hyx8beLEbo9WLv3E5XOc2S-ZfwrJCmWgVEL5Q_mI,2694
20
+ tep_core/version.py,sha256=2Nztz_qJZUbxN0wCjdEHN__Dl26yiNzxe-3fk0wf49M,280
21
+ tep_core/data/v2026.09/distributions.json,sha256=x2C184rGVeW4-JE2qnfqgpSRNZ2B5n593eQ0j5tQGw8,1836
22
+ grift_cli-0.5.0.dist-info/METADATA,sha256=45qd6QOIRzd7Dbtkaukoe340QOsOnCXKiwi499llCWY,4999
23
+ grift_cli-0.5.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
24
+ grift_cli-0.5.0.dist-info/entry_points.txt,sha256=T4GYRNy2QNwtd-sIh0cQHd29_jmwqadckbI5UGKTddY,48
25
+ grift_cli-0.5.0.dist-info/licenses/LICENSE,sha256=P6-C9fAmLf584KeKcQoZNLRLCm8TjczQiuW5av6noTE,1073
26
+ grift_cli-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ grift = tep_cli.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TEP contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tep_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Thin CLI wrapper around tep_core."""
tep_cli/__main__.py ADDED
@@ -0,0 +1,146 @@
1
+ """grift CLI (method = TEP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from tep_core.analyze import analyze_repository
11
+ from tep_core.identity import discover_identity
12
+ from tep_core.lineage import Lineage
13
+ from tep_core.report import render_markdown
14
+ from tep_core.version import __version__
15
+
16
+ _EPILOG = """examples:
17
+ grift analyze . --scope repo
18
+ grift analyze ./repo --format md --out ./out
19
+ 詳細: README"""
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="grift",
25
+ description="grift — deterministic TEP evidence from a git repository. No LLM.",
26
+ epilog=_EPILOG,
27
+ formatter_class=argparse.RawDescriptionHelpFormatter,
28
+ )
29
+ parser.add_argument("--version", action="version", version=f"grift {__version__}")
30
+ sub = parser.add_subparsers(dest="command", required=True)
31
+
32
+ analyze = sub.add_parser(
33
+ "analyze",
34
+ help="Analyze one git repository",
35
+ epilog=_EPILOG,
36
+ formatter_class=argparse.RawDescriptionHelpFormatter,
37
+ )
38
+ analyze.add_argument("repo", type=Path, help="Path to a git working tree")
39
+ analyze.add_argument(
40
+ "--identity",
41
+ type=Path,
42
+ default=None,
43
+ help="Path to identity.toml (default: <repo>/.tep/identity.toml)",
44
+ )
45
+ analyze.add_argument(
46
+ "--parent",
47
+ default="",
48
+ help="Upstream parent full name (owner/name). Implies lineage.",
49
+ )
50
+ analyze.add_argument(
51
+ "--fork",
52
+ action="store_true",
53
+ help="Declare the repository as a fork (lineage).",
54
+ )
55
+ analyze.add_argument(
56
+ "--template",
57
+ type=Path,
58
+ default=None,
59
+ help="Path to a template repository (enables template_inherited).",
60
+ )
61
+ analyze.add_argument(
62
+ "--parent-repo",
63
+ type=Path,
64
+ default=None,
65
+ help="Local path to the parent repository (enables derivative classes).",
66
+ )
67
+ analyze.add_argument(
68
+ "--format",
69
+ choices=("md", "json", "both"),
70
+ default="both",
71
+ help="Output format (default: both, markdown first)",
72
+ )
73
+ analyze.add_argument(
74
+ "--out",
75
+ type=Path,
76
+ default=None,
77
+ help="Directory to write report.md and report.json",
78
+ )
79
+ analyze.add_argument(
80
+ "--vendor-scan",
81
+ action="store_true",
82
+ help="Classify generated_or_vendor from commit path names (slower).",
83
+ )
84
+ analyze.add_argument(
85
+ "--include-local-path",
86
+ action="store_true",
87
+ help="Include the absolute local path in report.json (off by default).",
88
+ )
89
+ analyze.add_argument(
90
+ "--survival",
91
+ action="store_true",
92
+ help="Enable EIS-style blame sampling (tau=180d). Slow; git I/O bound.",
93
+ )
94
+ analyze.add_argument(
95
+ "--scope",
96
+ choices=("tenant", "repo"),
97
+ default="tenant",
98
+ help="tenant: identity-matched evidence. repo: all human commits (reference distribution).",
99
+ )
100
+ return parser
101
+
102
+
103
+ def _run_analyze(args: argparse.Namespace) -> int:
104
+ repo: Path = args.repo
105
+ if not (repo / ".git").exists() and not repo.joinpath("HEAD").exists():
106
+ sys.stderr.write(f"not a git repository: {repo}\n")
107
+ return 2
108
+ identity = discover_identity(repo, args.identity)
109
+ lineage = Lineage(is_fork=bool(args.fork), parent=args.parent or None)
110
+ report = analyze_repository(
111
+ repo,
112
+ identity,
113
+ lineage,
114
+ template_provided=args.template is not None,
115
+ parent_repo_provided=args.parent_repo is not None,
116
+ include_files=bool(args.vendor_scan),
117
+ include_local_path=bool(args.include_local_path),
118
+ survival=bool(args.survival),
119
+ scope=str(args.scope),
120
+ )
121
+ markdown = render_markdown(report)
122
+ encoded = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
123
+ if args.out is not None:
124
+ args.out.mkdir(parents=True, exist_ok=True)
125
+ (args.out / "report.md").write_text(markdown, encoding="utf-8")
126
+ (args.out / "report.json").write_text(encoded, encoding="utf-8")
127
+ if args.format in {"md", "both"}:
128
+ sys.stdout.write(markdown)
129
+ if args.format == "both":
130
+ sys.stdout.write("\n")
131
+ if args.format in {"json", "both"}:
132
+ sys.stdout.write(encoded)
133
+ return 0
134
+
135
+
136
+ def main(argv: list[str] | None = None) -> int:
137
+ parser = build_parser()
138
+ args = parser.parse_args(argv)
139
+ if args.command == "analyze":
140
+ return _run_analyze(args)
141
+ parser.error("unknown command")
142
+ return 2
143
+
144
+
145
+ if __name__ == "__main__":
146
+ raise SystemExit(main())
tep_core/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """tep-core: deterministic TEP evidence metrics from a git repository."""
2
+
3
+ from tep_core.analyze import analyze_repository
4
+ from tep_core.identity import load_identity
5
+ from tep_core.lineage import Lineage
6
+ from tep_core.version import DEFINITION_VERSION, __version__
7
+
8
+ __all__ = [
9
+ "DEFINITION_VERSION",
10
+ "Lineage",
11
+ "analyze_repository",
12
+ "load_identity",
13
+ "__version__",
14
+ ]
tep_core/activity.py ADDED
@@ -0,0 +1,55 @@
1
+ """Activity-day metrics (TEP metric-design ruling 1, 2026-08-16)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import statistics
6
+ from collections import Counter
7
+ from datetime import date, timedelta
8
+
9
+ from tep_core.observation import NotObserved, Observed
10
+
11
+ WINDOW_13W_DAYS = 13 * 7
12
+
13
+
14
+ def _parse_day(value: str) -> date:
15
+ year, month, day = (int(part) for part in value.split("-"))
16
+ return date(year, month, day)
17
+
18
+
19
+ def activity_metrics(
20
+ tenant_dates: list[str],
21
+ day_counts: Counter[str],
22
+ *,
23
+ head_date: str | None,
24
+ ) -> dict[str, dict[str, object]]:
25
+ tenant_commits = len(tenant_dates)
26
+ payload: dict[str, dict[str, object]] = {
27
+ "tenant_commits": Observed(tenant_commits, "commits").to_dict(),
28
+ }
29
+ unique_days = sorted(set(tenant_dates))
30
+ active_days = len(unique_days)
31
+ payload["active_days"] = Observed(active_days, "days").to_dict()
32
+
33
+ if active_days == 0:
34
+ payload["commits_per_active_day"] = NotObserved("no_tenant_active_days").to_dict()
35
+ payload["commits_per_active_day_median"] = NotObserved("no_tenant_active_days").to_dict()
36
+ payload["active_days_13w"] = Observed(0, "days").to_dict()
37
+ return payload
38
+
39
+ per_day = tenant_commits / active_days
40
+ payload["commits_per_active_day"] = Observed(round(per_day, 4), "commits/active-day").to_dict()
41
+ daily_values = [day_counts[d] for d in unique_days]
42
+ payload["commits_per_active_day_median"] = Observed(
43
+ statistics.median(daily_values),
44
+ "commits/active-day",
45
+ sample_size=len(daily_values),
46
+ ).to_dict()
47
+
48
+ if head_date:
49
+ head = _parse_day(head_date)
50
+ cut = head - timedelta(days=WINDOW_13W_DAYS)
51
+ in_window = [d for d in unique_days if _parse_day(d) >= cut]
52
+ payload["active_days_13w"] = Observed(len(in_window), "days").to_dict()
53
+ else:
54
+ payload["active_days_13w"] = NotObserved("head_date_unavailable").to_dict()
55
+ return payload
tep_core/analyze.py ADDED
@@ -0,0 +1,123 @@
1
+ """Assemble a provenance-bearing TEP report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from tep_core.activity import activity_metrics
10
+ from tep_core.cochange import test_cochange
11
+ from tep_core.core_period import compute_core_activity_period
12
+ from tep_core.gitutil import GitError, read_commits, repository_identity, rev_parse
13
+ from tep_core.identity import IdentityConfig
14
+ from tep_core.lineage import Lineage
15
+ from tep_core.observation import Observed
16
+ from tep_core.origin import classify_commits
17
+ from tep_core.reference import interpret_metric
18
+ from tep_core.rework import rework_metrics
19
+ from tep_core.scope import DEFAULT_SCOPE
20
+ from tep_core.survival import survival_metrics
21
+ from tep_core.tests_observed import observe_test_frameworks
22
+ from tep_core.version import (
23
+ ACTIVITY_DEFINITION_VERSION,
24
+ DEFINITION_VERSION,
25
+ ORIGIN_DEFINITION_VERSION,
26
+ __version__,
27
+ )
28
+
29
+
30
+ def analyze_repository(
31
+ repo: Path,
32
+ identity: IdentityConfig,
33
+ lineage: Lineage,
34
+ *,
35
+ template_provided: bool = False,
36
+ parent_repo_provided: bool = False,
37
+ include_files: bool = False,
38
+ include_local_path: bool = False,
39
+ survival: bool = False,
40
+ scope: str = DEFAULT_SCOPE,
41
+ ) -> dict[str, Any]:
42
+ repo = repo.resolve()
43
+ commits = read_commits(repo, include_files=False)
44
+ if include_files:
45
+ try:
46
+ named = read_commits(repo, include_files=True)
47
+ files_by_sha = {item.sha: item.files for item in named}
48
+ for commit in commits:
49
+ commit.files = files_by_sha.get(commit.sha, ())
50
+ except GitError:
51
+ pass
52
+ origin = classify_commits(commits, identity, lineage)
53
+ tests = observe_test_frameworks(repo)
54
+ tests_observed = tests.get("kind") == "observed"
55
+ need_paths = include_files or scope == "repo" or origin.counts["tenant_unique"] > 0
56
+ if not include_files and need_paths:
57
+ try:
58
+ named = read_commits(repo, include_files=True)
59
+ files_by_sha = {item.sha: item.files for item in named}
60
+ for commit in commits:
61
+ commit.files = files_by_sha.get(commit.sha, ())
62
+ except GitError:
63
+ pass
64
+ head_sha = rev_parse(repo)
65
+ head_date = commits[0].date if commits else None
66
+ activity = activity_metrics(origin.tenant_dates, origin.tenant_day_counts, head_date=head_date)
67
+ cochange = test_cochange(
68
+ commits,
69
+ origin,
70
+ tests_observed=tests_observed,
71
+ pending_attribution=identity.pending_attribution,
72
+ head_date=head_date,
73
+ scope=scope,
74
+ )
75
+ rework = rework_metrics(
76
+ commits,
77
+ origin,
78
+ pending_attribution=identity.pending_attribution,
79
+ scope=scope,
80
+ )
81
+ corr = rework.get("corrective_rework_rate") if rework.get("kind") == "observed" else rework
82
+ return {
83
+ "schema_version": "tep-report-v1",
84
+ "provenance": {
85
+ "tool_name": "grift",
86
+ "method_name": "TEP",
87
+ "tool_version": __version__,
88
+ "definition_version": DEFINITION_VERSION,
89
+ "origin_definition_version": ORIGIN_DEFINITION_VERSION,
90
+ "activity_definition_version": ACTIVITY_DEFINITION_VERSION,
91
+ "analysis_scope": scope,
92
+ "analyzed_commit_sha": head_sha,
93
+ "analyzed_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
94
+ },
95
+ "repository": repository_identity(repo, include_local_path=include_local_path),
96
+ "identity": {
97
+ "pending_attribution": identity.pending_attribution,
98
+ "actor_count": identity.actor_count,
99
+ },
100
+ "lineage": lineage.to_dict(),
101
+ "origin": origin.origin_observations(
102
+ template_provided=template_provided,
103
+ parent_repo_provided=parent_repo_provided,
104
+ has_upstream_lineage=lineage.has_upstream_lineage,
105
+ ),
106
+ "attribution": {
107
+ "bot": Observed(origin.bot_commits, "commits").to_dict(),
108
+ },
109
+ "activity": activity,
110
+ "core_activity_period": compute_core_activity_period(origin.tenant_dates),
111
+ "test_frameworks": tests,
112
+ "test_cochange": cochange,
113
+ "rework": rework,
114
+ "survival": survival_metrics(repo, enabled=survival),
115
+ "interpretation": {
116
+ "test_cochange": interpret_metric(
117
+ report_scope=scope, metric_id="test_cochange", observation=cochange
118
+ ),
119
+ "corrective_rework": interpret_metric(
120
+ report_scope=scope, metric_id="corrective_rework", observation=corr
121
+ ),
122
+ },
123
+ }
tep_core/cochange.py ADDED
@@ -0,0 +1,102 @@
1
+ """Test co-change rate (definition v1, 2026-08-19 ruling)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, timedelta
6
+
7
+ from tep_core.activity import WINDOW_13W_DAYS
8
+ from tep_core.gitutil import GitCommit
9
+ from tep_core.observation import NotObserved
10
+ from tep_core.origin import OriginResult
11
+ from tep_core.paths import split_paths
12
+ from tep_core.scope import DEFAULT_SCOPE, population_shas
13
+
14
+ COCHANGE_DEFINITION_VERSION = "test-cochange-v1-2026-08-22"
15
+ MIN_POPULATION_FOR_RATE = 20
16
+
17
+
18
+ def _parse_day(value: str) -> date:
19
+ year, month, day = (int(part) for part in value.split("-"))
20
+ return date(year, month, day)
21
+
22
+
23
+ def test_cochange(
24
+ commits: list[GitCommit],
25
+ origin: OriginResult,
26
+ *,
27
+ tests_observed: bool,
28
+ pending_attribution: bool,
29
+ head_date: str | None,
30
+ scope: str = DEFAULT_SCOPE,
31
+ ) -> dict[str, object]:
32
+ """Co-change among production-changing commits in the analysis scope.
33
+
34
+ tenant: identity-matched tenant_unique (evidence).
35
+ repo: all non-bot, non-merge humans (process / reference distribution).
36
+ """
37
+ if not tests_observed:
38
+ return NotObserved("no_test_framework_or_directory").to_dict()
39
+
40
+ shas = population_shas(commits, origin, scope)
41
+ if scope == "tenant" and pending_attribution and not shas:
42
+ return NotObserved("pending_attribution").to_dict()
43
+ if not shas:
44
+ return NotObserved(
45
+ "no_tenant_commits" if scope == "tenant" else "no_human_commits"
46
+ ).to_dict()
47
+ if not any(c.files for c in commits):
48
+ return NotObserved("commit_paths_unavailable").to_dict()
49
+
50
+ pop_all, co_all, test_only, docs_only = _window(commits, shas, start=None)
51
+ payload: dict[str, object] = {
52
+ "kind": "observed",
53
+ "definition_version": COCHANGE_DEFINITION_VERSION,
54
+ "analysis_scope": scope,
55
+ "all_time": _rate_block(pop_all, co_all, test_only, docs_only),
56
+ }
57
+ if head_date:
58
+ cut = _parse_day(head_date) - timedelta(days=WINDOW_13W_DAYS)
59
+ pop_w, co_w, test_w, docs_w = _window(commits, shas, start=cut)
60
+ payload["last_13w"] = _rate_block(pop_w, co_w, test_w, docs_w)
61
+ else:
62
+ payload["last_13w"] = NotObserved("head_date_unavailable").to_dict()
63
+ return payload
64
+
65
+
66
+ def _window(
67
+ commits: list[GitCommit],
68
+ tenant_shas: set[str],
69
+ start: date | None,
70
+ ) -> tuple[int, int, int, int]:
71
+ pop = co = test_only = docs_only = 0
72
+ for commit in commits:
73
+ if commit.sha not in tenant_shas:
74
+ continue
75
+ if start is not None and _parse_day(commit.date) < start:
76
+ continue
77
+ prod, tests, docs = split_paths(commit.files)
78
+ if prod:
79
+ pop += 1
80
+ if tests:
81
+ co += 1
82
+ elif tests:
83
+ test_only += 1
84
+ elif docs:
85
+ docs_only += 1
86
+ return pop, co, test_only, docs_only
87
+
88
+
89
+ def _rate_block(pop: int, co: int, test_only: int, docs_only: int) -> dict[str, object]:
90
+ if pop == 0:
91
+ return NotObserved("no_production_commits").to_dict()
92
+ rate = round(co / pop, 4)
93
+ return {
94
+ "kind": "observed",
95
+ "value": rate,
96
+ "unit": "ratio",
97
+ "population": pop,
98
+ "cochanged": co,
99
+ "test_only": test_only,
100
+ "docs_or_config_excluded": docs_only,
101
+ "narrate_rate": pop >= MIN_POPULATION_FOR_RATE,
102
+ }
@@ -0,0 +1,78 @@
1
+ """Core activity period: minimal contiguous month range covering >=80%."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from datetime import date
7
+
8
+ from tep_core.observation import NotObserved
9
+ from tep_core.version import CORE_ACTIVITY_DEFINITION_VERSION
10
+
11
+ CORE_ACTIVITY_MIN_SHARE = 0.80
12
+
13
+
14
+ def _parse_day(value: str) -> date:
15
+ year, month, day = (int(part) for part in value.split("-"))
16
+ return date(year, month, day)
17
+
18
+
19
+ def _month_key(value: date) -> str:
20
+ return f"{value.year:04d}-{value.month:02d}"
21
+
22
+
23
+ def _add_month(year: int, month: int, delta: int) -> tuple[int, int]:
24
+ total = year * 12 + (month - 1) + delta
25
+ return total // 12, total % 12 + 1
26
+
27
+
28
+ def compute_core_activity_period(commit_dates: list[str]) -> dict[str, object]:
29
+ """Return Observed month-range or NotObserved.
30
+
31
+ Sliding window over calendar months (empty months kept) from the first
32
+ tenant commit month to the last. The shortest window whose share is
33
+ >= 80% wins; ties take the earliest start.
34
+ """
35
+ if not commit_dates:
36
+ return NotObserved("no_tenant_commits").to_dict()
37
+
38
+ months: Counter[str] = Counter()
39
+ for raw in commit_dates:
40
+ months[_month_key(_parse_day(raw))] += 1
41
+
42
+ keys = sorted(months)
43
+ start_y, start_m = (int(p) for p in keys[0].split("-"))
44
+ end_y, end_m = (int(p) for p in keys[-1].split("-"))
45
+ sequence: list[tuple[str, int]] = []
46
+ year, month = start_y, start_m
47
+ while (year, month) <= (end_y, end_m):
48
+ key = f"{year:04d}-{month:02d}"
49
+ sequence.append((key, months.get(key, 0)))
50
+ year, month = _add_month(year, month, 1)
51
+
52
+ total = sum(count for _, count in sequence)
53
+ if total == 0:
54
+ return NotObserved("no_tenant_commits").to_dict()
55
+
56
+ best: tuple[int, int, int, float] | None = None
57
+ for start in range(len(sequence)):
58
+ acc = 0
59
+ for end in range(start, len(sequence)):
60
+ acc += sequence[end][1]
61
+ share = acc / total
62
+ if share >= CORE_ACTIVITY_MIN_SHARE:
63
+ span = end - start
64
+ if best is None or span < best[0] or (span == best[0] and start < best[1]):
65
+ best = (span, start, end, share)
66
+ break
67
+ if best is None:
68
+ return NotObserved("no_tenant_commits").to_dict()
69
+
70
+ _, start_idx, end_idx, share = best
71
+ return {
72
+ "kind": "observed",
73
+ "start": sequence[start_idx][0],
74
+ "end": sequence[end_idx][0],
75
+ "share": round(share, 4),
76
+ "unit": "month-range",
77
+ "definition_version": CORE_ACTIVITY_DEFINITION_VERSION,
78
+ }