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,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,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
|
+
}
|
tep_core/core_period.py
ADDED
|
@@ -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
|
+
}
|