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.
Files changed (64) hide show
  1. {gitmole-0.7.0 → gitmole-0.7.2}/PKG-INFO +58 -42
  2. {gitmole-0.7.0 → gitmole-0.7.2}/README.md +57 -41
  3. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/__init__.py +1 -1
  4. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/banner.py +7 -4
  5. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/cli.py +3 -3
  6. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/findings.py +12 -5
  7. gitmole-0.7.2/gitmole/functions.py +182 -0
  8. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/load.py +8 -3
  9. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/render.py +21 -2
  10. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/watch.py +5 -2
  11. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/PKG-INFO +58 -42
  12. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/SOURCES.txt +1 -0
  13. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_banner.py +22 -0
  14. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_cli.py +14 -0
  15. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_findings.py +16 -0
  16. gitmole-0.7.2/tests/test_functions.py +187 -0
  17. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_load.py +14 -2
  18. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_render.py +45 -0
  19. gitmole-0.7.2/tests/test_render_examples.py +106 -0
  20. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_watch.py +10 -0
  21. gitmole-0.7.0/gitmole/functions.py +0 -90
  22. gitmole-0.7.0/tests/test_functions.py +0 -109
  23. {gitmole-0.7.0 → gitmole-0.7.2}/LICENSE +0 -0
  24. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/__main__.py +0 -0
  25. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/backtest.py +0 -0
  26. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/blame.py +0 -0
  27. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/clean.py +0 -0
  28. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/coupling.py +0 -0
  29. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/deps.py +0 -0
  30. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/duplicates.py +0 -0
  31. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/filetypes.py +0 -0
  32. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/hotspots.py +0 -0
  33. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/identity.py +0 -0
  34. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/knowledge.py +0 -0
  35. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/leaks.py +0 -0
  36. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/loss.py +0 -0
  37. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/maat.py +0 -0
  38. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/run.py +0 -0
  39. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/textfmt.py +0 -0
  40. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole/trend.py +0 -0
  41. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/dependency_links.txt +0 -0
  42. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/entry_points.txt +0 -0
  43. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/requires.txt +0 -0
  44. {gitmole-0.7.0 → gitmole-0.7.2}/gitmole.egg-info/top_level.txt +0 -0
  45. {gitmole-0.7.0 → gitmole-0.7.2}/pyproject.toml +0 -0
  46. {gitmole-0.7.0 → gitmole-0.7.2}/setup.cfg +0 -0
  47. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_backtest.py +0 -0
  48. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_blame.py +0 -0
  49. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_clean.py +0 -0
  50. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_coupling.py +0 -0
  51. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_deps.py +0 -0
  52. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_duplicates.py +0 -0
  53. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_filetypes.py +0 -0
  54. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_golden.py +0 -0
  55. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_hotspots.py +0 -0
  56. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_identity.py +0 -0
  57. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_knowledge.py +0 -0
  58. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_leaks.py +0 -0
  59. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_loss.py +0 -0
  60. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_maat.py +0 -0
  61. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_packaging.py +0 -0
  62. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_run.py +0 -0
  63. {gitmole-0.7.0 → gitmole-0.7.2}/tests/test_textfmt.py +0 -0
  64. {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.0
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 stack. Free. Offline. No token. No AI. Light.
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 token.** A local clone needs no credentials. The optional `owner/repo` shortcut uses the `gh` login you already have, and you ask for it.
35
- - **No AI.** Every finding is a plain rule over counts you can recompute by hand. The same clone gives the same report every time.
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
- Running `gitmole .` inside this repository:
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
- ╭─ gitmole ────────────────────────────────────────────────────────────────────────────────────────╮
79
- 185 commits · 2026-09-15 → 2026-09-17 · 1 identity · branch main
80
- 9,475 lines in 52 files · Python, Ruby
81
- │ most commits on Wed at 20:00 · 4% of commits are fixes · 100% of surviving code from 2026
82
- 3 warnings, 1 note
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
- Below that: a watch list of the five files where the next bug is most
108
- likely, with the reasons in words and a backtest of how the list would have
109
- done; then tables for people, the knowledge map, the timeline, hotspots with
110
- their complexity trend, change coupling, complex functions and repo health.
111
- The full report is in
112
- [docs/example.md](https://github.com/antvinni/gitmole/blob/main/docs/example.md),
113
- and every section is explained in
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
- - [Full example report](https://github.com/antvinni/gitmole/blob/main/docs/example.md): the whole `gitmole .` output for this repository.
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 stack. Free. Offline. No token. No AI. Light.
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 token.** A local clone needs no credentials. The optional `owner/repo` shortcut uses the `gh` login you already have, and you ask for it.
15
- - **No AI.** Every finding is a plain rule over counts you can recompute by hand. The same clone gives the same report every time.
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
- Running `gitmole .` inside this repository:
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
- ╭─ gitmole ────────────────────────────────────────────────────────────────────────────────────────╮
59
- 185 commits · 2026-09-15 → 2026-09-17 · 1 identity · branch main
60
- 9,475 lines in 52 files · Python, Ruby
61
- │ most commits on Wed at 20:00 · 4% of commits are fixes · 100% of surviving code from 2026
62
- 3 warnings, 1 note
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
- Below that: a watch list of the five files where the next bug is most
88
- likely, with the reasons in words and a backtest of how the list would have
89
- done; then tables for people, the knowledge map, the timeline, hotspots with
90
- their complexity trend, change coupling, complex functions and repo health.
91
- The full report is in
92
- [docs/example.md](https://github.com/antvinni/gitmole/blob/main/docs/example.md),
93
- and every section is explained in
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
- - [Full example report](https://github.com/antvinni/gitmole/blob/main/docs/example.md): the whole `gitmole .` output for this repository.
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.
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.2"
@@ -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. A warning
446
- when one sits in a hotspot."""
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["function"] == ANONYMOUS else f"{first['function']} in {first['file']}"
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["function"] == ANONYMOUS else f["file"]
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
- rows.append({"file": _rel(r[6]), "function": _cut(r[7]) or "(anonymous)", "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]),
164
- "start": _num(r[9]), "end": _num(r[10])})
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