gitmole 0.7.0__tar.gz → 0.7.2__tar.gz
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.
- {gitmole-0.7.0 → gitmole-0.7.2}/PKG-INFO +58 -42
- {gitmole-0.7.0 → gitmole-0.7.2}/README.md +57 -41
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/__init__.py +1 -1
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/banner.py +7 -4
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/cli.py +3 -3
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/findings.py +12 -5
- gitmole-0.7.2/gitmole/functions.py +182 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/load.py +8 -3
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/render.py +21 -2
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/watch.py +5 -2
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/PKG-INFO +58 -42
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/SOURCES.txt +1 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_banner.py +22 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_cli.py +14 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_findings.py +16 -0
- gitmole-0.7.2/tests/test_functions.py +187 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_load.py +14 -2
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_render.py +45 -0
- gitmole-0.7.2/tests/test_render_examples.py +106 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_watch.py +10 -0
- gitmole-0.7.0/gitmole/functions.py +0 -90
- gitmole-0.7.0/tests/test_functions.py +0 -109
- {gitmole-0.7.0 → gitmole-0.7.2}/LICENSE +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/__main__.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/backtest.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/blame.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/clean.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/coupling.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/deps.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/duplicates.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/filetypes.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/hotspots.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/identity.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/knowledge.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/leaks.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/loss.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/maat.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/run.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/textfmt.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/trend.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/pyproject.toml +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/setup.cfg +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_backtest.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_blame.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_clean.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_coupling.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_deps.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_duplicates.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_filetypes.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_golden.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_hotspots.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_identity.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_knowledge.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_leaks.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_loss.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_maat.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_packaging.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_run.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_textfmt.py +0 -0
- {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_trend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -26,14 +26,13 @@ A toolkit for digging into any cloned git repository: who works on it,
|
|
|
26
26
|
where the risk is, how old the code is, whether the repo itself is healthy,
|
|
27
27
|
and whether anything sensitive was ever committed.
|
|
28
28
|
|
|
29
|
-
Any
|
|
29
|
+
Free. Any Stack. Local. Offline. Deterministic. Fast.
|
|
30
30
|
|
|
31
|
-
- **Free.** MIT licence, no paid tier, no account. The tools it runs are open source too.
|
|
31
|
+
- **Free.** MIT licence, no paid tier, no account, no token. A local clone needs no credentials, and a public `owner/repo` is cloned with plain git. Your `gh` login is only used for private repositories and for `owner/*`, and only when you ask for them. The tools it runs are open source too.
|
|
32
32
|
- **Any stack.** It reads what every repository has: the git log, git blame and the files themselves.
|
|
33
|
-
- **Offline.** Everything runs against a clone on your machine. Nothing is uploaded, nothing phones home; the vulnerability database is a copy you download once.
|
|
34
|
-
- **No
|
|
35
|
-
- **
|
|
36
|
-
- **Light.** A 4,400-commit repository takes under thirty seconds. A few thousand lines of Python plus two libraries.
|
|
33
|
+
- **Local & Offline.** Everything runs against a clone on your machine. Nothing is uploaded, nothing phones home; the vulnerability database is a copy you download once.
|
|
34
|
+
- **Deterministic.** No AI at runtime. Every finding is a plain rule over counts you can recompute by hand. The same clone gives the same report every time.
|
|
35
|
+
- **Fast.** A 4,400-commit repository takes under thirty seconds.
|
|
37
36
|
|
|
38
37
|
## Install
|
|
39
38
|
|
|
@@ -72,47 +71,64 @@ blocks on secrets in source files and still posts the report. Every option:
|
|
|
72
71
|
|
|
73
72
|
## What you get
|
|
74
73
|
|
|
75
|
-
|
|
74
|
+
The opening of the report for [react](https://github.com/facebook/react), 35,263 commits
|
|
75
|
+
since 2013, at a pinned commit:
|
|
76
76
|
|
|
77
77
|
```text
|
|
78
|
-
╭─
|
|
79
|
-
│
|
|
80
|
-
│
|
|
81
|
-
│ most commits on Wed at
|
|
82
|
-
│
|
|
83
|
-
|
|
84
|
-
╭─ Findings (4) ───────────────────────────────────────────────────────────────────────────────────╮
|
|
85
|
-
│ ▲ Bus factor of one │
|
|
86
|
-
│ vinni wrote 100% of the code that survives today │
|
|
87
|
-
│ ↳ Pair someone with vinni on gitmole/ first; it is 100% theirs. │
|
|
88
|
-
│ ▲ Hotspots getting more complex │
|
|
89
|
-
│ 4 of the 10 top source hotspots grew by 25% or more in a year: gitmole/render.py (+194%), │
|
|
90
|
-
│ gitmole/findings.py (+360%), gitmole/cli.py (+64%), gitmole/run.py (+34%) │
|
|
91
|
-
│ ↳ Split gitmole/render.py before the next change; its complexity grew 194% in a year. │
|
|
92
|
-
│ ▲ Knowledge islands │
|
|
93
|
-
│ 1 area(s) with at least 200 lines were written almost entirely by one person: gitmole/ (vinni │
|
|
94
|
-
│ 100%). That is 98% of all lines added │
|
|
95
|
-
│ ↳ Pair someone with vinni on gitmole/ first; it is the largest at 7,607 lines. │
|
|
96
|
-
│ ● Bug magnets │
|
|
97
|
-
│ 5 file(s) were fixed 3+ times in the last six months: gitmole/load.py (4 recent, 4 total); │
|
|
98
|
-
│ gitmole/cli.py (3 recent, 3 total); gitmole/findings.py (3 recent, 3 total); gitmole/render.py │
|
|
99
|
-
│ (3 recent, 3 total); gitmole/run.py (3 recent, 3 total) │
|
|
100
|
-
│ ↳ Review gitmole/load.py and gitmole/cli.py before the next release; expect the next bug │
|
|
101
|
-
│ there. │
|
|
102
|
-
│ ✔ No secrets in history │
|
|
103
|
-
│ betterleaks scanned every commit on every branch; 28 placeholder-shaped hits left out │
|
|
78
|
+
╭─ react ──────────────────────────────────────────────────────────────────────────────────────────╮
|
|
79
|
+
│ 35263 commits · 2013-05-28 → 2026-09-16 · 1880 identities · branch main │
|
|
80
|
+
│ 681,078 lines in 4781 files · JavaScript, TypeScript, Rust, CSS │
|
|
81
|
+
│ most commits on Wed at 16:00 · 13% of commits are fixes · 1% of commits are reverts · 19% │
|
|
82
|
+
│ of surviving code from 2026 │
|
|
83
|
+
│ 1 critical, 6 warnings, 8 notes │
|
|
104
84
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
85
|
+
|
|
86
|
+
◎ Watch list
|
|
87
|
+
file why
|
|
88
|
+
──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
compiler/packages/babel-plugin-react-compiler/s changed 331 times · fixed twice in six months ·
|
|
90
|
+
rc/Inference/InferMutationAliasingEffects.ts Joe Savona wrote 98% of it ·
|
|
91
|
+
findNonMutatedDestructureSpreads() complexity 39
|
|
92
|
+
packages/react-server/src/ReactFlightServer.js changed 377 times · fixed once in six months ·
|
|
93
|
+
renderModelDestructive() complexity 544
|
|
94
|
+
packages/shared/forks/ReactFeatureFlags.www.js changed 583 times · fixed once in six months ·
|
|
95
|
+
changes with
|
|
96
|
+
packages/shared/forks/ReactFeatureFlags.test-ren
|
|
97
|
+
derer.www.js (77%) and 5 others
|
|
98
|
+
packages/shared/ReactFeatureFlags.js changed 575 times · fixed 6 times · changes with
|
|
99
|
+
packages/shared/forks/ReactFeatureFlags.test-ren
|
|
100
|
+
derer.js (80%) and 4 others
|
|
101
|
+
packages/react-reconciler/src/ReactFiberWorkLoo changed 342 times · fixed 4 times in six months
|
|
102
|
+
p.js · flushSpawnedWork() complexity 48
|
|
103
|
+
ranked by churn × recent fixes × complexity × single ownership
|
|
104
|
+
6 months ago this list would have named 6 of the 211 files fixed since (a random 15 of the 1979
|
|
105
|
+
files that had changed more than once would name 0.3)
|
|
105
106
|
```
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
likely,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
and
|
|
108
|
+
The watch list is the point: the five files where the next bug is most
|
|
109
|
+
likely, the reasons in words, and a backtest that says how the same list,
|
|
110
|
+
drawn six months earlier, would have done against the fixes that followed.
|
|
111
|
+
Between the header and that list the full report puts its findings, 15 for
|
|
112
|
+
react (1 critical, 6 warnings, 8 notes); below it, tables for people, the
|
|
113
|
+
knowledge map, the timeline, hotspots with their complexity trend, change
|
|
114
|
+
coupling, complex functions and repo health. Every section is explained in
|
|
114
115
|
[docs/output.md](https://github.com/antvinni/gitmole/blob/main/docs/output.md).
|
|
115
116
|
|
|
117
|
+
Reports on repositories you know, each at a pinned commit with a fixed
|
|
118
|
+
reference date, published as gitmole wrote them; the repo-health numbers
|
|
119
|
+
come from git-sizer over the whole clone, so a fresh clone can differ there:
|
|
120
|
+
|
|
121
|
+
| Repository | Commits | Lines | Watch list backtest |
|
|
122
|
+
|---|---:|---:|---|
|
|
123
|
+
| [curl](https://github.com/antvinni/gitmole/blob/main/docs/examples/curl.md) | 39,894 | 247,179 | named 15 of the 239 files fixed in the next six months; a random pick would name 5.0 |
|
|
124
|
+
| [django](https://github.com/antvinni/gitmole/blob/main/docs/examples/django.md) | 52,832 | 431,749 | named 13 of the 213 files fixed in the next six months; a random pick would name 2.8 |
|
|
125
|
+
| [react](https://github.com/antvinni/gitmole/blob/main/docs/examples/react.md) | 35,263 | 681,078 | named 6 of the 211 files fixed in the next six months; a random pick would name 0.3 |
|
|
126
|
+
| [kubernetes](https://github.com/antvinni/gitmole/blob/main/docs/examples/kubernetes.md) | 161,803 | 4,180,715 | named 7 of the 338 files fixed in the next six months; a random pick would name 0.4 |
|
|
127
|
+
|
|
128
|
+
kubernetes's code-age step was skipped on gitmole's default time budget and
|
|
129
|
+
its report says so; the duplicates step was skipped on the memory budget too
|
|
130
|
+
and produced no section.
|
|
131
|
+
|
|
116
132
|
## The tool set
|
|
117
133
|
|
|
118
134
|
One tool per question; together they cover what a single command can tell
|
|
@@ -138,7 +154,7 @@ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/bl
|
|
|
138
154
|
- [Install](https://github.com/antvinni/gitmole/blob/main/docs/install.md): macOS, Linux, pipx, the check, pinned releases.
|
|
139
155
|
- [Command line](https://github.com/antvinni/gitmole/blob/main/docs/cli.md): every option, portfolio mode, exports and CI gates, big repositories.
|
|
140
156
|
- [The report and the output files](https://github.com/antvinni/gitmole/blob/main/docs/output.md): what each section and each file means.
|
|
141
|
-
- [
|
|
157
|
+
- [Example reports](https://github.com/antvinni/gitmole/tree/main/docs/examples): curl, django, react and kubernetes at pinned commits, regenerated by `bin/render-examples`.
|
|
142
158
|
- [Why these tools](https://github.com/antvinni/gitmole/blob/main/docs/tools.md): the rationale, what was left out, licences.
|
|
143
159
|
- [Development](https://github.com/antvinni/gitmole/blob/main/docs/development.md): setup, tests, releases, code layout.
|
|
144
160
|
- [Contributing](https://github.com/antvinni/gitmole/blob/main/CONTRIBUTING.md): bugs, ideas, pull requests, security reports.
|
|
@@ -6,14 +6,13 @@ A toolkit for digging into any cloned git repository: who works on it,
|
|
|
6
6
|
where the risk is, how old the code is, whether the repo itself is healthy,
|
|
7
7
|
and whether anything sensitive was ever committed.
|
|
8
8
|
|
|
9
|
-
Any
|
|
9
|
+
Free. Any Stack. Local. Offline. Deterministic. Fast.
|
|
10
10
|
|
|
11
|
-
- **Free.** MIT licence, no paid tier, no account. The tools it runs are open source too.
|
|
11
|
+
- **Free.** MIT licence, no paid tier, no account, no token. A local clone needs no credentials, and a public `owner/repo` is cloned with plain git. Your `gh` login is only used for private repositories and for `owner/*`, and only when you ask for them. The tools it runs are open source too.
|
|
12
12
|
- **Any stack.** It reads what every repository has: the git log, git blame and the files themselves.
|
|
13
|
-
- **Offline.** Everything runs against a clone on your machine. Nothing is uploaded, nothing phones home; the vulnerability database is a copy you download once.
|
|
14
|
-
- **No
|
|
15
|
-
- **
|
|
16
|
-
- **Light.** A 4,400-commit repository takes under thirty seconds. A few thousand lines of Python plus two libraries.
|
|
13
|
+
- **Local & Offline.** Everything runs against a clone on your machine. Nothing is uploaded, nothing phones home; the vulnerability database is a copy you download once.
|
|
14
|
+
- **Deterministic.** No AI at runtime. Every finding is a plain rule over counts you can recompute by hand. The same clone gives the same report every time.
|
|
15
|
+
- **Fast.** A 4,400-commit repository takes under thirty seconds.
|
|
17
16
|
|
|
18
17
|
## Install
|
|
19
18
|
|
|
@@ -52,47 +51,64 @@ blocks on secrets in source files and still posts the report. Every option:
|
|
|
52
51
|
|
|
53
52
|
## What you get
|
|
54
53
|
|
|
55
|
-
|
|
54
|
+
The opening of the report for [react](https://github.com/facebook/react), 35,263 commits
|
|
55
|
+
since 2013, at a pinned commit:
|
|
56
56
|
|
|
57
57
|
```text
|
|
58
|
-
╭─
|
|
59
|
-
│
|
|
60
|
-
│
|
|
61
|
-
│ most commits on Wed at
|
|
62
|
-
│
|
|
63
|
-
|
|
64
|
-
╭─ Findings (4) ───────────────────────────────────────────────────────────────────────────────────╮
|
|
65
|
-
│ ▲ Bus factor of one │
|
|
66
|
-
│ vinni wrote 100% of the code that survives today │
|
|
67
|
-
│ ↳ Pair someone with vinni on gitmole/ first; it is 100% theirs. │
|
|
68
|
-
│ ▲ Hotspots getting more complex │
|
|
69
|
-
│ 4 of the 10 top source hotspots grew by 25% or more in a year: gitmole/render.py (+194%), │
|
|
70
|
-
│ gitmole/findings.py (+360%), gitmole/cli.py (+64%), gitmole/run.py (+34%) │
|
|
71
|
-
│ ↳ Split gitmole/render.py before the next change; its complexity grew 194% in a year. │
|
|
72
|
-
│ ▲ Knowledge islands │
|
|
73
|
-
│ 1 area(s) with at least 200 lines were written almost entirely by one person: gitmole/ (vinni │
|
|
74
|
-
│ 100%). That is 98% of all lines added │
|
|
75
|
-
│ ↳ Pair someone with vinni on gitmole/ first; it is the largest at 7,607 lines. │
|
|
76
|
-
│ ● Bug magnets │
|
|
77
|
-
│ 5 file(s) were fixed 3+ times in the last six months: gitmole/load.py (4 recent, 4 total); │
|
|
78
|
-
│ gitmole/cli.py (3 recent, 3 total); gitmole/findings.py (3 recent, 3 total); gitmole/render.py │
|
|
79
|
-
│ (3 recent, 3 total); gitmole/run.py (3 recent, 3 total) │
|
|
80
|
-
│ ↳ Review gitmole/load.py and gitmole/cli.py before the next release; expect the next bug │
|
|
81
|
-
│ there. │
|
|
82
|
-
│ ✔ No secrets in history │
|
|
83
|
-
│ betterleaks scanned every commit on every branch; 28 placeholder-shaped hits left out │
|
|
58
|
+
╭─ react ──────────────────────────────────────────────────────────────────────────────────────────╮
|
|
59
|
+
│ 35263 commits · 2013-05-28 → 2026-09-16 · 1880 identities · branch main │
|
|
60
|
+
│ 681,078 lines in 4781 files · JavaScript, TypeScript, Rust, CSS │
|
|
61
|
+
│ most commits on Wed at 16:00 · 13% of commits are fixes · 1% of commits are reverts · 19% │
|
|
62
|
+
│ of surviving code from 2026 │
|
|
63
|
+
│ 1 critical, 6 warnings, 8 notes │
|
|
84
64
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
65
|
+
|
|
66
|
+
◎ Watch list
|
|
67
|
+
file why
|
|
68
|
+
──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
compiler/packages/babel-plugin-react-compiler/s changed 331 times · fixed twice in six months ·
|
|
70
|
+
rc/Inference/InferMutationAliasingEffects.ts Joe Savona wrote 98% of it ·
|
|
71
|
+
findNonMutatedDestructureSpreads() complexity 39
|
|
72
|
+
packages/react-server/src/ReactFlightServer.js changed 377 times · fixed once in six months ·
|
|
73
|
+
renderModelDestructive() complexity 544
|
|
74
|
+
packages/shared/forks/ReactFeatureFlags.www.js changed 583 times · fixed once in six months ·
|
|
75
|
+
changes with
|
|
76
|
+
packages/shared/forks/ReactFeatureFlags.test-ren
|
|
77
|
+
derer.www.js (77%) and 5 others
|
|
78
|
+
packages/shared/ReactFeatureFlags.js changed 575 times · fixed 6 times · changes with
|
|
79
|
+
packages/shared/forks/ReactFeatureFlags.test-ren
|
|
80
|
+
derer.js (80%) and 4 others
|
|
81
|
+
packages/react-reconciler/src/ReactFiberWorkLoo changed 342 times · fixed 4 times in six months
|
|
82
|
+
p.js · flushSpawnedWork() complexity 48
|
|
83
|
+
ranked by churn × recent fixes × complexity × single ownership
|
|
84
|
+
6 months ago this list would have named 6 of the 211 files fixed since (a random 15 of the 1979
|
|
85
|
+
files that had changed more than once would name 0.3)
|
|
85
86
|
```
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
likely,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
and
|
|
88
|
+
The watch list is the point: the five files where the next bug is most
|
|
89
|
+
likely, the reasons in words, and a backtest that says how the same list,
|
|
90
|
+
drawn six months earlier, would have done against the fixes that followed.
|
|
91
|
+
Between the header and that list the full report puts its findings, 15 for
|
|
92
|
+
react (1 critical, 6 warnings, 8 notes); below it, tables for people, the
|
|
93
|
+
knowledge map, the timeline, hotspots with their complexity trend, change
|
|
94
|
+
coupling, complex functions and repo health. Every section is explained in
|
|
94
95
|
[docs/output.md](https://github.com/antvinni/gitmole/blob/main/docs/output.md).
|
|
95
96
|
|
|
97
|
+
Reports on repositories you know, each at a pinned commit with a fixed
|
|
98
|
+
reference date, published as gitmole wrote them; the repo-health numbers
|
|
99
|
+
come from git-sizer over the whole clone, so a fresh clone can differ there:
|
|
100
|
+
|
|
101
|
+
| Repository | Commits | Lines | Watch list backtest |
|
|
102
|
+
|---|---:|---:|---|
|
|
103
|
+
| [curl](https://github.com/antvinni/gitmole/blob/main/docs/examples/curl.md) | 39,894 | 247,179 | named 15 of the 239 files fixed in the next six months; a random pick would name 5.0 |
|
|
104
|
+
| [django](https://github.com/antvinni/gitmole/blob/main/docs/examples/django.md) | 52,832 | 431,749 | named 13 of the 213 files fixed in the next six months; a random pick would name 2.8 |
|
|
105
|
+
| [react](https://github.com/antvinni/gitmole/blob/main/docs/examples/react.md) | 35,263 | 681,078 | named 6 of the 211 files fixed in the next six months; a random pick would name 0.3 |
|
|
106
|
+
| [kubernetes](https://github.com/antvinni/gitmole/blob/main/docs/examples/kubernetes.md) | 161,803 | 4,180,715 | named 7 of the 338 files fixed in the next six months; a random pick would name 0.4 |
|
|
107
|
+
|
|
108
|
+
kubernetes's code-age step was skipped on gitmole's default time budget and
|
|
109
|
+
its report says so; the duplicates step was skipped on the memory budget too
|
|
110
|
+
and produced no section.
|
|
111
|
+
|
|
96
112
|
## The tool set
|
|
97
113
|
|
|
98
114
|
One tool per question; together they cover what a single command can tell
|
|
@@ -118,7 +134,7 @@ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/bl
|
|
|
118
134
|
- [Install](https://github.com/antvinni/gitmole/blob/main/docs/install.md): macOS, Linux, pipx, the check, pinned releases.
|
|
119
135
|
- [Command line](https://github.com/antvinni/gitmole/blob/main/docs/cli.md): every option, portfolio mode, exports and CI gates, big repositories.
|
|
120
136
|
- [The report and the output files](https://github.com/antvinni/gitmole/blob/main/docs/output.md): what each section and each file means.
|
|
121
|
-
- [
|
|
137
|
+
- [Example reports](https://github.com/antvinni/gitmole/tree/main/docs/examples): curl, django, react and kubernetes at pinned commits, regenerated by `bin/render-examples`.
|
|
122
138
|
- [Why these tools](https://github.com/antvinni/gitmole/blob/main/docs/tools.md): the rationale, what was left out, licences.
|
|
123
139
|
- [Development](https://github.com/antvinni/gitmole/blob/main/docs/development.md): setup, tests, releases, code layout.
|
|
124
140
|
- [Contributing](https://github.com/antvinni/gitmole/blob/main/CONTRIBUTING.md): bugs, ideas, pull requests, security reports.
|
|
@@ -83,8 +83,9 @@ def _sprite_rows(look: int = 0) -> list:
|
|
|
83
83
|
return rows
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def neon(offset: int = 0, look: int = 0) -> Text:
|
|
87
|
-
"""The banner with the palette rotated down by `offset` rows, and the mole beside it.
|
|
86
|
+
def neon(offset: int = 0, look: int = 0, version: str = None) -> Text:
|
|
87
|
+
"""The banner with the palette rotated down by `offset` rows, and the mole beside it.
|
|
88
|
+
With `version`, a dim `v1.2.3` row underneath: the report's reproducibility stamp."""
|
|
88
89
|
text = Text()
|
|
89
90
|
sprite = _sprite_rows(look)
|
|
90
91
|
for i, row in enumerate(ART.split("\n")):
|
|
@@ -93,15 +94,17 @@ def neon(offset: int = 0, look: int = 0) -> Text:
|
|
|
93
94
|
text.append(" " * GAP)
|
|
94
95
|
text.append_text(sprite[i])
|
|
95
96
|
text.append("\n")
|
|
97
|
+
if version:
|
|
98
|
+
text.append(f"v{version}\n", style="dim")
|
|
96
99
|
return text
|
|
97
100
|
|
|
98
101
|
|
|
99
102
|
LOOK_EVERY = 5 # frames per glance; at 10 fps the eyes move every half second
|
|
100
103
|
|
|
101
104
|
|
|
102
|
-
def frames():
|
|
105
|
+
def frames(version: str = None):
|
|
103
106
|
"""Endless generator of banner frames: gradient flowing down, eyes glancing side to side."""
|
|
104
107
|
n = 0
|
|
105
108
|
while True:
|
|
106
|
-
yield neon(offset=n % len(NEON), look=(n // LOOK_EVERY) % 2)
|
|
109
|
+
yield neon(offset=n % len(NEON), look=(n // LOOK_EVERY) % 2, version=version)
|
|
107
110
|
n += 1
|
|
@@ -171,7 +171,7 @@ def _no_run(args, console, ui, err) -> int:
|
|
|
171
171
|
err.print(f"[red]no gitmole output found in {out_dir}[/red] (expected meta.json)")
|
|
172
172
|
return 2
|
|
173
173
|
if ui.is_terminal:
|
|
174
|
-
ui.print(banner.neon())
|
|
174
|
+
ui.print(banner.neon(version=__version__))
|
|
175
175
|
return _render(out_dir, console, ui, args, err)
|
|
176
176
|
|
|
177
177
|
|
|
@@ -437,7 +437,7 @@ def _execute(steps, log_path, repo_dir, workers, console, timeout=None) -> dict:
|
|
|
437
437
|
active, lock = set(), threading.Lock()
|
|
438
438
|
started = time.monotonic()
|
|
439
439
|
spinner = Spinner("dots", style="cyan")
|
|
440
|
-
frame = banner.frames()
|
|
440
|
+
frame = banner.frames(version=__version__)
|
|
441
441
|
|
|
442
442
|
def label() -> str:
|
|
443
443
|
with lock:
|
|
@@ -466,7 +466,7 @@ def _execute(steps, log_path, repo_dir, workers, console, timeout=None) -> dict:
|
|
|
466
466
|
while worker.is_alive():
|
|
467
467
|
live.update(view())
|
|
468
468
|
worker.join(0.1)
|
|
469
|
-
live.update(Group(banner.neon(), Text("")) if console.is_terminal else Text(""))
|
|
469
|
+
live.update(Group(banner.neon(version=__version__), Text("")) if console.is_terminal else Text(""))
|
|
470
470
|
console.print(f"[dim]{len(steps)} steps in {time.monotonic() - started:.1f}s[/dim]\n")
|
|
471
471
|
return results
|
|
472
472
|
|
|
@@ -442,10 +442,11 @@ def _partial_functions(report: dict) -> str:
|
|
|
442
442
|
|
|
443
443
|
def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list:
|
|
444
444
|
"""Functions that are both long and complex, in this repository's own source files: test files,
|
|
445
|
-
example code, vendored code and generated files (amalgamations included) are left out
|
|
446
|
-
|
|
445
|
+
example code, vendored code and generated files (amalgamations included) are left out, and so is
|
|
446
|
+
a span the function step marked suspect, since a mis-parse that swallowed the next function is
|
|
447
|
+
long and complex by construction. A warning when one sits in a hotspot."""
|
|
447
448
|
generated, vendored = _generated(report), filetypes.vendor_dirs(report)
|
|
448
|
-
big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines
|
|
449
|
+
big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines and not f.get("suspect")
|
|
449
450
|
and not (filetypes.is_test_path(f["file"]) or filetypes.is_sample_path(f["file"]) or filetypes.is_vendored(f["file"], vendored)
|
|
450
451
|
or f["file"] in generated)]
|
|
451
452
|
if not big:
|
|
@@ -456,7 +457,7 @@ def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list
|
|
|
456
457
|
listed = "; ".join(f"{f['function']} ({_place(f)}) complexity {f['ccn']}, {f['nloc']} lines, {f['params']} params" for f in big[:5])
|
|
457
458
|
more = f" and {len(big) - 5} more" if len(big) > 5 else ""
|
|
458
459
|
first = big[0]
|
|
459
|
-
which = f"the anonymous function at {_place(first)}" if first
|
|
460
|
+
which = f"the anonymous function at {_place(first)}" if _anonymous(first) else f"{first['function']} in {first['file']}"
|
|
460
461
|
return [_f(sev, "Brain methods",
|
|
461
462
|
f"{len(big)} function(s) are both long and complex: {listed}{more}.{_partial_functions(report)}",
|
|
462
463
|
f"Split {which} first, before the next change lands there.")]
|
|
@@ -465,9 +466,15 @@ def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list
|
|
|
465
466
|
ANONYMOUS = "(anonymous)"
|
|
466
467
|
|
|
467
468
|
|
|
469
|
+
def _anonymous(f: dict) -> bool:
|
|
470
|
+
"""A function lizard could not name: it goes by its start line's text (or "(anonymous)" in an
|
|
471
|
+
older functions.csv), which is not a name to search for."""
|
|
472
|
+
return f.get("anonymous", f["function"] == ANONYMOUS)
|
|
473
|
+
|
|
474
|
+
|
|
468
475
|
def _place(f: dict) -> str:
|
|
469
476
|
"""Where a function is: its file, or file:line when it has no name to find it by."""
|
|
470
|
-
return f"{f['file']}:{f['start']}" if f
|
|
477
|
+
return f"{f['file']}:{f['start']}" if _anonymous(f) else f["file"]
|
|
471
478
|
|
|
472
479
|
|
|
473
480
|
def _generated(report: dict) -> set:
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Function-level metrics from lizard, over the tracked code files only.
|
|
3
|
+
|
|
4
|
+
Runs as its own process (a pipeline step) and drives lizard through its Python API rather
|
|
5
|
+
than its command line: the file list never touches a shell or a list file, the analysed
|
|
6
|
+
repository is never on sys.path, only files lizard has a reader for are measured, and the
|
|
7
|
+
CSV is streamed so a killed step still leaves what was measured. Duplicated blocks are
|
|
8
|
+
jscpd's job (duplicates.py); lizard's own finder kept a hash node per token and ran to gigabytes."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import csv
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import lizard
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from . import blame, filetypes
|
|
20
|
+
except ImportError: # run as a script: the package directory is sys.path[0]
|
|
21
|
+
import blame
|
|
22
|
+
import filetypes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def select_files(repo: str, ignore=(), types_spec: str = None) -> list:
|
|
26
|
+
"""Tracked text files lizard can parse. Without --file-types that is every language lizard
|
|
27
|
+
knows (a superset of gitmole's default code list, e.g. Fortran); with it, the intersection."""
|
|
28
|
+
types = filetypes.parse(types_spec)
|
|
29
|
+
files = blame.text_files(repo, ignore) if types_spec is None else blame.code_files(repo, ignore, types)
|
|
30
|
+
return [f for f in files if lizard.get_reader_for(f) is not None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
NAME_CAP = 200 # a deeply nested fixture gives lizard a dotted name of megabytes; nobody reads past this
|
|
34
|
+
LONG_NAME_CAP = 500
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cut(text: str, cap: int) -> str:
|
|
38
|
+
return text if len(text) <= cap else text[:cap - 1] + "…"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ANONYMOUS = "(anonymous)" # lizard's name for a JavaScript function expression; a Go literal gets ""
|
|
42
|
+
SPARSE_LINES = 40 # a span this long...
|
|
43
|
+
SPARSE_SHARE = 0.25 # ...with under this share of code lines is more likely a mis-parse than a function
|
|
44
|
+
|
|
45
|
+
OPENS_FUNCTION = ("=>", "->", "func", "lambda") # what a line that opens a nameless function holds: "func" covers "function"
|
|
46
|
+
NEARBY = (0, -1, 1, 2, 3, 4, 5)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def nameless(fn) -> bool:
|
|
50
|
+
return fn.name in ("", ANONYMOUS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _opener(lines: list, fn) -> int | None:
|
|
54
|
+
"""The line near a nameless function's start that opens a function, or None. lizard puts an arrow
|
|
55
|
+
whose body starts on the next line at the body's line, and a callback in a JSX attribute at the
|
|
56
|
+
tag's line, so the start line is tried first, then the line before, then a few lines on."""
|
|
57
|
+
for offset in NEARBY:
|
|
58
|
+
n = fn.start_line + offset
|
|
59
|
+
if 1 <= n <= len(lines) and any(m in lines[n - 1] for m in OPENS_FUNCTION):
|
|
60
|
+
return n
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def label(lines: list, fn) -> str:
|
|
65
|
+
"""What to call a function lizard could not name: the line it starts on, whitespace collapsed, in
|
|
66
|
+
any language. `app.post("/api/x", async (req, res) => {` finds the callback; `(anonymous)` does not.
|
|
67
|
+
When a nearby line opens a function and the start line does not, that line is the label."""
|
|
68
|
+
if not nameless(fn) or not 1 <= fn.start_line <= len(lines):
|
|
69
|
+
return ""
|
|
70
|
+
n = _opener(lines, fn) or fn.start_line
|
|
71
|
+
return _cut(" ".join(lines[n - 1].split()), NAME_CAP)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def suspect(lines: list, fn) -> str:
|
|
75
|
+
"""Why a span looks like a mis-parse, or "". lizard fails by losing its place (a template literal,
|
|
76
|
+
JSX) and swallowing what follows into one function, so a swallowed span is long with little code
|
|
77
|
+
in it, or holds a line that opens a block at the indentation of the function's own start: a
|
|
78
|
+
sibling that should have ended it. A line that starts by closing a bracket (`}: Props) {`)
|
|
79
|
+
continues the function's own signature, and a bare `{` is the function's own body brace in
|
|
80
|
+
the C styles that put it on its own line; neither counts. It also reads a JSX ternary as a
|
|
81
|
+
nameless function: all code, all deeper than its start, and nothing near the start line opens a
|
|
82
|
+
function."""
|
|
83
|
+
if fn.length >= SPARSE_LINES and fn.nloc < SPARSE_SHARE * fn.length:
|
|
84
|
+
return f"{fn.nloc} of {fn.length} lines are code"
|
|
85
|
+
if nameless(fn) and 1 <= fn.start_line <= len(lines) and _opener(lines, fn) is None:
|
|
86
|
+
return f"nothing opens a function within {max(NEARBY)} lines of line {fn.start_line}"
|
|
87
|
+
span = lines[max(fn.start_line, 1) - 1:fn.end_line]
|
|
88
|
+
if len(span) < 3:
|
|
89
|
+
return ""
|
|
90
|
+
depth = _indent(span[0])
|
|
91
|
+
for number, line in enumerate(span[1:-1], start=fn.start_line + 1):
|
|
92
|
+
text = line.rstrip()
|
|
93
|
+
if text.endswith("{") and _indent(text) <= depth and text.lstrip()[0] not in "{)]}":
|
|
94
|
+
return f"opens a block at line {number} no deeper than its own start"
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _indent(line: str) -> int:
|
|
99
|
+
return len(line) - len(line.lstrip())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def csv_row(info, fn, lines: list = ()) -> list:
|
|
103
|
+
"""The columns `lizard --csv` prints, then gitmole's own two: a label for nameless functions and
|
|
104
|
+
why the span looks mis-parsed. Names are cut to what a table can show, so one pathological
|
|
105
|
+
fixture cannot make the file unreadable."""
|
|
106
|
+
name = _cut(fn.name, NAME_CAP)
|
|
107
|
+
return [fn.nloc, fn.cyclomatic_complexity, fn.token_count, fn.parameter_count, fn.length,
|
|
108
|
+
f"{name}@{fn.start_line}-{fn.end_line}@{info.filename}", info.filename, name, _cut(fn.long_name, LONG_NAME_CAP), fn.start_line, fn.end_line,
|
|
109
|
+
label(lines, fn), suspect(lines, fn)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _lines(path: str) -> list:
|
|
113
|
+
try:
|
|
114
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
115
|
+
return fh.read().splitlines()
|
|
116
|
+
except OSError:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def keep_newlines(tokens, reader):
|
|
121
|
+
"""lizard's own preprocessing, after splitting whitespace tokens that hold newlines into bare ones.
|
|
122
|
+
lizard 1.24 drops any whitespace token but "\\n" there, and its JSX tokenizer hands it the newline
|
|
123
|
+
before a child element joined with the indentation after it: one line lost per child, so every
|
|
124
|
+
function after a JSX block in a .tsx file reports lines before its own."""
|
|
125
|
+
def split(tokens):
|
|
126
|
+
for t in tokens:
|
|
127
|
+
if t != "\n" and t.isspace() and "\n" in t:
|
|
128
|
+
for _ in range(t.count("\n")):
|
|
129
|
+
yield "\n"
|
|
130
|
+
else:
|
|
131
|
+
yield t
|
|
132
|
+
return lizard.preprocessing(split(tokens), reader)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extensions() -> list:
|
|
136
|
+
"""lizard's metric extensions, with keep_newlines in place of its preprocessing."""
|
|
137
|
+
return [keep_newlines if e is lizard.preprocessing else e for e in lizard.get_extensions([])]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def analyze(files: list, procs: int, exts: list):
|
|
141
|
+
"""lizard.analyze_files without its extension bookkeeping: per-file analysis over `procs` workers."""
|
|
142
|
+
return lizard.map_files_to_analyzer(files, lizard.FileAnalyzer(exts), procs)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def measure(repo: str, files: list, out: str, procs: int) -> int:
|
|
146
|
+
"""Stream functions.csv while lizard runs. Returns 0, or 1 when lizard gave up on a file (whatever
|
|
147
|
+
was measured by then stays on disk)."""
|
|
148
|
+
exts = extensions()
|
|
149
|
+
rc = 0
|
|
150
|
+
cwd = os.getcwd()
|
|
151
|
+
os.chdir(repo) # lizard opens the paths as given; relative ones keep the CSV repo-relative
|
|
152
|
+
try:
|
|
153
|
+
with open(os.path.join(out, "functions.csv"), "w", encoding="utf-8", newline="") as fh:
|
|
154
|
+
writer = csv.writer(fh, quoting=csv.QUOTE_NONNUMERIC)
|
|
155
|
+
try:
|
|
156
|
+
for info in analyze(files, procs, exts):
|
|
157
|
+
lines = _lines(info.filename) if info.function_list else []
|
|
158
|
+
for fn in info.function_list:
|
|
159
|
+
writer.writerow(csv_row(info, fn, lines))
|
|
160
|
+
fh.flush()
|
|
161
|
+
except Exception as e: # lizard re-raises its parse failures; keep what we have
|
|
162
|
+
print(f"lizard stopped: {e!r}", file=sys.stderr)
|
|
163
|
+
rc = 1
|
|
164
|
+
finally:
|
|
165
|
+
os.chdir(cwd)
|
|
166
|
+
return rc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv=None) -> int:
|
|
170
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
171
|
+
p.add_argument("repo")
|
|
172
|
+
p.add_argument("out")
|
|
173
|
+
p.add_argument("--procs", type=int, default=1)
|
|
174
|
+
p.add_argument("--ignore", action="append", default=[])
|
|
175
|
+
p.add_argument("--types", default=None, help="file types spec as for gitmole --file-types")
|
|
176
|
+
args = p.parse_args(argv)
|
|
177
|
+
files = select_files(args.repo, args.ignore, args.types)
|
|
178
|
+
return measure(os.path.abspath(args.repo), files, os.path.abspath(args.out), max(1, args.procs))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main())
|
|
@@ -155,13 +155,18 @@ def _cut(name: str, cap: int = NAME_CAP) -> str:
|
|
|
155
155
|
|
|
156
156
|
|
|
157
157
|
def parse_functions(text: str) -> list:
|
|
158
|
-
"""lizard --csv rows: nloc, ccn, tokens, params, length, location, file, function, long name, start, end
|
|
158
|
+
"""lizard --csv rows: nloc, ccn, tokens, params, length, location, file, function, long name, start, end;
|
|
159
|
+
then, from gitmole's own step, a label for a nameless function (its start line) and why the span
|
|
160
|
+
looks mis-parsed. A nameless function goes by its label, or "(anonymous)" in an older file, and
|
|
161
|
+
stays marked anonymous so the report can say where it is."""
|
|
159
162
|
rows = []
|
|
160
163
|
for r in csv.reader(io.StringIO(text)):
|
|
161
164
|
if len(r) < 11:
|
|
162
165
|
continue
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
name, label, suspect = r[7], r[11] if len(r) > 11 else "", r[12] if len(r) > 12 else ""
|
|
167
|
+
anonymous = name in ("", "(anonymous)")
|
|
168
|
+
rows.append({"file": _rel(r[6]), "function": _cut(label if anonymous and label else name) or "(anonymous)", "anonymous": anonymous,
|
|
169
|
+
"ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]), "start": _num(r[9]), "end": _num(r[10]), "suspect": suspect})
|
|
165
170
|
return rows
|
|
166
171
|
|
|
167
172
|
|