gitmole 0.17.0__tar.gz → 0.19.0__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.17.0 → gitmole-0.19.0}/PKG-INFO +13 -6
- {gitmole-0.17.0 → gitmole-0.19.0}/README.md +12 -5
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/__init__.py +1 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/cli.py +1 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/compare.py +10 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/deps.py +24 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/findings.py +123 -2
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/hook.py +2 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/hygiene.py +2 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/knowledge.py +34 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/leaks.py +3 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/load.py +19 -2
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/maat.py +103 -2
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/render.py +49 -2
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/sarif.py +1 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/signing.py +2 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/watch.py +25 -1
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole.egg-info/PKG-INFO +13 -6
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_compare.py +2 -2
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_deps.py +14 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_findings.py +78 -2
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_golden.py +20 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_leaks.py +1 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_load.py +10 -3
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_maat.py +53 -2
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_render.py +45 -5
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_watch.py +15 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/LICENSE +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/__main__.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/backtest.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/banner.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/blame.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/classify.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/clean.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/coupling.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/duplicates.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/evaluate.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/filetypes.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/functions.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/hotspots.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/identity.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/loss.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/provenance.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/run.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/structure.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/szz.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/textfmt.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole/trend.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole.egg-info/SOURCES.txt +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/pyproject.toml +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/setup.cfg +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_backtest.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_banner.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_blame.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_classify.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_clean.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_cli.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_coupling.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_duplicates.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_evaluate.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_filetypes.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_functions.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_hook.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_hotspots.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_hygiene.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_identity.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_knowledge.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_loss.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_packaging.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_provenance.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_render_examples.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_run.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_sarif.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_signing.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_structure.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_szz.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_textfmt.py +0 -0
- {gitmole-0.17.0 → gitmole-0.19.0}/tests/test_trend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
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
|
|
@@ -44,7 +44,7 @@ Free. Any Stack. Local. Offline. Deterministic. Fast.
|
|
|
44
44
|
- **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.
|
|
45
45
|
- **Any stack.** It reads what every repository has: the git log, git blame and the files themselves.
|
|
46
46
|
- **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.
|
|
47
|
-
- **Deterministic.** No AI at runtime. Every finding is a plain rule over counts you can recompute by hand. The JSON export carries each finding's rule
|
|
47
|
+
- **Deterministic.** No AI at runtime. Every finding is a plain rule over counts you can recompute by hand. The JSON export carries each finding's rule, the numbers it fired on and, where a rule rests on a paper, the citation. The same commit gives the same bytes: gitmole's own CI runs it twice on every commit, compares the exports and attests the report.
|
|
48
48
|
|
|
49
49
|
## Install
|
|
50
50
|
|
|
@@ -73,12 +73,17 @@ gitmole . --markdown report.md # the same report as a Markdown document
|
|
|
73
73
|
gitmole . --json report.json # every table, the watch list and the findings
|
|
74
74
|
gitmole . --fail-on warning # exit 3 if any finding is a warning or worse
|
|
75
75
|
gitmole . --risk main --risk-threshold 10 # exit 3 if the files changed since main hold over 10% of the risk
|
|
76
|
+
gitmole . --sarif gitmole.sarif # the findings for GitHub code scanning or GitLab
|
|
77
|
+
gitmole . --compare last.json # what changed since an earlier --json export
|
|
78
|
+
gitmole analysis-repo --no-run --hook # a coding agent's edit hook: history's view of the files it just touched
|
|
76
79
|
gitmole . --since 2y --full # the current team, every row and column
|
|
77
80
|
gitmole --clean # list what gitmole left behind, delete on a yes
|
|
78
81
|
```
|
|
79
82
|
|
|
80
83
|
A CI job that runs `gitmole . --fail-on critical --markdown - >> "$GITHUB_STEP_SUMMARY"`
|
|
81
|
-
blocks on secrets in source files and still posts the report.
|
|
84
|
+
blocks on secrets in source files and still posts the report. The same
|
|
85
|
+
scoring wires into Claude Code, Cursor, Gemini CLI and pre-commit as a hook
|
|
86
|
+
that exits 2 over a threshold. Every option:
|
|
82
87
|
[docs/cli.md](https://github.com/antvinni/gitmole/blob/main/docs/cli.md).
|
|
83
88
|
|
|
84
89
|
## What you get
|
|
@@ -87,9 +92,9 @@ Reports on repositories you know, each at a pinned commit, published as gitmole
|
|
|
87
92
|
|
|
88
93
|
| Repository | Commit | Commits | Lines | gitmole run |
|
|
89
94
|
|---|---|---:|---:|---:|
|
|
90
|
-
| [curl](https://github.com/antvinni/gitmole/blob/main/docs/examples/curl.md) | [`540ee5b5`](https://github.com/curl/curl/commit/540ee5b560cc6e775e11317048a13cc7e355bf91) | 39,758 | 247,179 |
|
|
91
|
-
| [django](https://github.com/antvinni/gitmole/blob/main/docs/examples/django.md) | [`8cbdd4a8`](https://github.com/django/django/commit/8cbdd4a814397f81adf0129288f32b615bd1f94f) | 34,933 | 431,749 |
|
|
92
|
-
| [react](https://github.com/antvinni/gitmole/blob/main/docs/examples/react.md) | [`2b19aecd`](https://github.com/facebook/react/commit/2b19aecd0e9111b774fad0fad9862e50bcb5bc8a) | 21,703 | 681,078 |
|
|
95
|
+
| [curl](https://github.com/antvinni/gitmole/blob/main/docs/examples/curl.md) | [`540ee5b5`](https://github.com/curl/curl/commit/540ee5b560cc6e775e11317048a13cc7e355bf91) | 39,758 | 247,179 | 57 s |
|
|
96
|
+
| [django](https://github.com/antvinni/gitmole/blob/main/docs/examples/django.md) | [`8cbdd4a8`](https://github.com/django/django/commit/8cbdd4a814397f81adf0129288f32b615bd1f94f) | 34,933 | 431,749 | 132 s |
|
|
97
|
+
| [react](https://github.com/antvinni/gitmole/blob/main/docs/examples/react.md) | [`2b19aecd`](https://github.com/facebook/react/commit/2b19aecd0e9111b774fad0fad9862e50bcb5bc8a) | 21,703 | 681,078 | 151 s |
|
|
93
98
|
|
|
94
99
|
Run times are one `gitmole CLONE` with every default step, on a MacBook Pro (M4, 16 GB).
|
|
95
100
|
|
|
@@ -110,6 +115,7 @@ you about a clone.
|
|
|
110
115
|
| Which blocks of code appear more than once | [jscpd](https://github.com/kucherenko/jscpd) | brew |
|
|
111
116
|
| Have secrets ever been committed | [betterleaks](https://github.com/betterleaks/betterleaks) | brew |
|
|
112
117
|
| Do the dependencies have known vulnerabilities | [osv-scanner](https://github.com/google/osv-scanner), offline against a local copy of the OSV database | brew, plus a one-time database download |
|
|
118
|
+
| How deeply nested is the code, what did the authors flag, what imports what | [tree-sitter](https://github.com/tree-sitter/py-tree-sitter) grammars for eleven languages | pip, opt-in with `gitmole[structure]` |
|
|
113
119
|
|
|
114
120
|
Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/blob/main/docs/tools.md).
|
|
115
121
|
|
|
@@ -121,6 +127,7 @@ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/bl
|
|
|
121
127
|
- [Example reports](https://github.com/antvinni/gitmole/tree/main/docs/examples): curl, django and react at pinned commits, regenerated by `bin/render-examples`.
|
|
122
128
|
- [Validation](https://github.com/antvinni/gitmole/blob/main/docs/validation.md): the watch list against other ways of ranking the same files at six cut-offs on three repositories.
|
|
123
129
|
- [Why these tools](https://github.com/antvinni/gitmole/blob/main/docs/tools.md): the rationale, what was left out, licences.
|
|
130
|
+
- [References](https://github.com/antvinni/gitmole/blob/main/docs/references.md): the research and tools gitmole's rules are built on.
|
|
124
131
|
- [Development](https://github.com/antvinni/gitmole/blob/main/docs/development.md): setup, tests, releases, code layout.
|
|
125
132
|
- [Contributing](https://github.com/antvinni/gitmole/blob/main/CONTRIBUTING.md): bugs, ideas, pull requests, security reports.
|
|
126
133
|
|
|
@@ -11,7 +11,7 @@ Free. Any Stack. Local. Offline. Deterministic. Fast.
|
|
|
11
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
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 JSON export carries each finding's rule
|
|
14
|
+
- **Deterministic.** No AI at runtime. Every finding is a plain rule over counts you can recompute by hand. The JSON export carries each finding's rule, the numbers it fired on and, where a rule rests on a paper, the citation. The same commit gives the same bytes: gitmole's own CI runs it twice on every commit, compares the exports and attests the report.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -40,12 +40,17 @@ gitmole . --markdown report.md # the same report as a Markdown document
|
|
|
40
40
|
gitmole . --json report.json # every table, the watch list and the findings
|
|
41
41
|
gitmole . --fail-on warning # exit 3 if any finding is a warning or worse
|
|
42
42
|
gitmole . --risk main --risk-threshold 10 # exit 3 if the files changed since main hold over 10% of the risk
|
|
43
|
+
gitmole . --sarif gitmole.sarif # the findings for GitHub code scanning or GitLab
|
|
44
|
+
gitmole . --compare last.json # what changed since an earlier --json export
|
|
45
|
+
gitmole analysis-repo --no-run --hook # a coding agent's edit hook: history's view of the files it just touched
|
|
43
46
|
gitmole . --since 2y --full # the current team, every row and column
|
|
44
47
|
gitmole --clean # list what gitmole left behind, delete on a yes
|
|
45
48
|
```
|
|
46
49
|
|
|
47
50
|
A CI job that runs `gitmole . --fail-on critical --markdown - >> "$GITHUB_STEP_SUMMARY"`
|
|
48
|
-
blocks on secrets in source files and still posts the report.
|
|
51
|
+
blocks on secrets in source files and still posts the report. The same
|
|
52
|
+
scoring wires into Claude Code, Cursor, Gemini CLI and pre-commit as a hook
|
|
53
|
+
that exits 2 over a threshold. Every option:
|
|
49
54
|
[docs/cli.md](https://github.com/antvinni/gitmole/blob/main/docs/cli.md).
|
|
50
55
|
|
|
51
56
|
## What you get
|
|
@@ -54,9 +59,9 @@ Reports on repositories you know, each at a pinned commit, published as gitmole
|
|
|
54
59
|
|
|
55
60
|
| Repository | Commit | Commits | Lines | gitmole run |
|
|
56
61
|
|---|---|---:|---:|---:|
|
|
57
|
-
| [curl](https://github.com/antvinni/gitmole/blob/main/docs/examples/curl.md) | [`540ee5b5`](https://github.com/curl/curl/commit/540ee5b560cc6e775e11317048a13cc7e355bf91) | 39,758 | 247,179 |
|
|
58
|
-
| [django](https://github.com/antvinni/gitmole/blob/main/docs/examples/django.md) | [`8cbdd4a8`](https://github.com/django/django/commit/8cbdd4a814397f81adf0129288f32b615bd1f94f) | 34,933 | 431,749 |
|
|
59
|
-
| [react](https://github.com/antvinni/gitmole/blob/main/docs/examples/react.md) | [`2b19aecd`](https://github.com/facebook/react/commit/2b19aecd0e9111b774fad0fad9862e50bcb5bc8a) | 21,703 | 681,078 |
|
|
62
|
+
| [curl](https://github.com/antvinni/gitmole/blob/main/docs/examples/curl.md) | [`540ee5b5`](https://github.com/curl/curl/commit/540ee5b560cc6e775e11317048a13cc7e355bf91) | 39,758 | 247,179 | 57 s |
|
|
63
|
+
| [django](https://github.com/antvinni/gitmole/blob/main/docs/examples/django.md) | [`8cbdd4a8`](https://github.com/django/django/commit/8cbdd4a814397f81adf0129288f32b615bd1f94f) | 34,933 | 431,749 | 132 s |
|
|
64
|
+
| [react](https://github.com/antvinni/gitmole/blob/main/docs/examples/react.md) | [`2b19aecd`](https://github.com/facebook/react/commit/2b19aecd0e9111b774fad0fad9862e50bcb5bc8a) | 21,703 | 681,078 | 151 s |
|
|
60
65
|
|
|
61
66
|
Run times are one `gitmole CLONE` with every default step, on a MacBook Pro (M4, 16 GB).
|
|
62
67
|
|
|
@@ -77,6 +82,7 @@ you about a clone.
|
|
|
77
82
|
| Which blocks of code appear more than once | [jscpd](https://github.com/kucherenko/jscpd) | brew |
|
|
78
83
|
| Have secrets ever been committed | [betterleaks](https://github.com/betterleaks/betterleaks) | brew |
|
|
79
84
|
| Do the dependencies have known vulnerabilities | [osv-scanner](https://github.com/google/osv-scanner), offline against a local copy of the OSV database | brew, plus a one-time database download |
|
|
85
|
+
| How deeply nested is the code, what did the authors flag, what imports what | [tree-sitter](https://github.com/tree-sitter/py-tree-sitter) grammars for eleven languages | pip, opt-in with `gitmole[structure]` |
|
|
80
86
|
|
|
81
87
|
Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/blob/main/docs/tools.md).
|
|
82
88
|
|
|
@@ -88,6 +94,7 @@ Why these and not others: [docs/tools.md](https://github.com/antvinni/gitmole/bl
|
|
|
88
94
|
- [Example reports](https://github.com/antvinni/gitmole/tree/main/docs/examples): curl, django and react at pinned commits, regenerated by `bin/render-examples`.
|
|
89
95
|
- [Validation](https://github.com/antvinni/gitmole/blob/main/docs/validation.md): the watch list against other ways of ranking the same files at six cut-offs on three repositories.
|
|
90
96
|
- [Why these tools](https://github.com/antvinni/gitmole/blob/main/docs/tools.md): the rationale, what was left out, licences.
|
|
97
|
+
- [References](https://github.com/antvinni/gitmole/blob/main/docs/references.md): the research and tools gitmole's rules are built on.
|
|
91
98
|
- [Development](https://github.com/antvinni/gitmole/blob/main/docs/development.md): setup, tests, releases, code layout.
|
|
92
99
|
- [Contributing](https://github.com/antvinni/gitmole/blob/main/CONTRIBUTING.md): bugs, ideas, pull requests, security reports.
|
|
93
100
|
|
|
@@ -592,7 +592,7 @@ def _render(out_dir: str, console: Console, ui: Console, args, err: Console) ->
|
|
|
592
592
|
return 2
|
|
593
593
|
comparison = _compare.compare(before, report, found)
|
|
594
594
|
if args.json:
|
|
595
|
-
_write(
|
|
595
|
+
_write(render.dumps_json(report, found, risk=risk, compare=comparison), args.json, console)
|
|
596
596
|
if args.markdown:
|
|
597
597
|
_write(render.markdown(report, found, full=args.full, risk=risk, base=args.risk, compare=comparison), args.markdown, console)
|
|
598
598
|
if args.sarif:
|
|
@@ -65,4 +65,13 @@ def compare(before: dict, report: dict, found: list, top: int = watch.WATCH_TOP)
|
|
|
65
65
|
"watch_entered": [f for f in after_watch if f not in before_watch], "watch_left": [f for f in before_watch if f not in after_watch],
|
|
66
66
|
"tally": {"before": _tally(before.get("findings") or []), "after": _tally(found)},
|
|
67
67
|
"before": {"commit": (meta_b.get("run") or {}).get("commit"), "date": meta_b.get("last_date"),
|
|
68
|
-
"options_differ": _options_differ(meta_b, meta_a)}}
|
|
68
|
+
"options_differ": _options_differ(meta_b, meta_a), "database": _database_changed(before, report)}}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _database_changed(before: dict, report: dict):
|
|
72
|
+
"""{before, after} dates when the two runs scanned against different snapshots of the OSV database,
|
|
73
|
+
else None: a new advisory changes the vulnerable-dependency finding without any change to the code."""
|
|
74
|
+
b, a = before.get("dependencies") or {}, report.get("dependencies") or {}
|
|
75
|
+
if b.get("database_digest") and a.get("database_digest") and b["database_digest"] != a["database_digest"]:
|
|
76
|
+
return {"before": b.get("database_date"), "after": a.get("database_date")}
|
|
77
|
+
return None
|
|
@@ -58,6 +58,28 @@ def database_date(base: str = None) -> str | None:
|
|
|
58
58
|
return dt.datetime.fromtimestamp(newest).date().isoformat() if newest else None
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def database_digest(base: str = None) -> str | None:
|
|
62
|
+
"""A digest of the local database snapshot: every file under the cache directories, by path, size
|
|
63
|
+
and modification time. Two runs against the same snapshot agree; a refresh changes it, which is how
|
|
64
|
+
--compare can say a dependency finding moved because the database did. None when there is none."""
|
|
65
|
+
import hashlib
|
|
66
|
+
base = cache_dir() if base is None else base
|
|
67
|
+
entries = []
|
|
68
|
+
for name in CACHE_DIRS:
|
|
69
|
+
root = os.path.join(base, name)
|
|
70
|
+
for dirpath, _, files in os.walk(root):
|
|
71
|
+
for f in files:
|
|
72
|
+
full = os.path.join(dirpath, f)
|
|
73
|
+
try:
|
|
74
|
+
st = os.stat(full)
|
|
75
|
+
except OSError:
|
|
76
|
+
continue
|
|
77
|
+
entries.append(f"{os.path.relpath(full, base)}\0{st.st_size}\0{int(st.st_mtime)}")
|
|
78
|
+
if not entries:
|
|
79
|
+
return None
|
|
80
|
+
return hashlib.sha256("\n".join(sorted(entries)).encode("utf-8", "surrogateescape")).hexdigest()[:16]
|
|
81
|
+
|
|
82
|
+
|
|
61
83
|
def _key(version: str) -> tuple:
|
|
62
84
|
return tuple(int(n) for n in _NUMBER.findall(version or ""))
|
|
63
85
|
|
|
@@ -94,7 +116,7 @@ def _score(groups: list, vulns: list) -> float | None:
|
|
|
94
116
|
|
|
95
117
|
|
|
96
118
|
_WORDS = {"CRITICAL": 9.5, "HIGH": 8.0, "MODERATE": 5.5, "MEDIUM": 5.5, "LOW": 2.0}
|
|
97
|
-
MALICIOUS_PREFIX = "MAL-" # OpenSSF malicious-packages records, in the same OSV database; they carry no CVSS
|
|
119
|
+
MALICIOUS_PREFIX = "MAL-" # OpenSSF malicious-packages records (ossf/malicious-packages), in the same OSV database; they carry no CVSS
|
|
98
120
|
|
|
99
121
|
|
|
100
122
|
def is_malicious(vulns: list) -> bool:
|
|
@@ -185,6 +207,7 @@ def main(argv=None) -> int:
|
|
|
185
207
|
return 1
|
|
186
208
|
result = summarise(data, os.getcwd())
|
|
187
209
|
result["database_date"] = database_date()
|
|
210
|
+
result["database_digest"] = database_digest()
|
|
188
211
|
else:
|
|
189
212
|
print(f"deps.py: osv-scanner exited {proc.returncode}; no report written", file=sys.stderr)
|
|
190
213
|
return proc.returncode
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Heuristics that turn a loaded report into a short list of flagged findings.
|
|
1
|
+
"""Heuristics that turn a loaded report into a short list of flagged findings. A rule that rests on a paper
|
|
2
|
+
carries the short citation in its rule dict's `ref` (see REFS); docs/references.md has the full entries."""
|
|
2
3
|
from __future__ import annotations
|
|
3
4
|
|
|
4
5
|
import re
|
|
@@ -11,11 +12,23 @@ PLACEHOLDER_NAMES = {"your name", "unknown", "root", "user"}
|
|
|
11
12
|
PLACEHOLDER_EMAIL = re.compile(r"(@example\.(com|org|net)$|^you@|^user@|^root@|@localhost$)")
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
# The paper a rule rests on, as the short citation the rule dict carries in `ref`; the full entries are in
|
|
16
|
+
# docs/references.md. A rule that is gitmole's own heuristic has none.
|
|
17
|
+
REFS = {"minor_contributors": "Bird et al., FSE 2011", "tangled_commits": "Herzig and Zeller, MSR 2013",
|
|
18
|
+
"brain_methods": "Lanza and Marinescu, 2006", "tight_coupling": "Gall, Hajek and Jazayeri, ICSM 1998",
|
|
19
|
+
"hotspot_dominance": "Tornhill, Your Code as a Crime Scene, 2024", "trojan_source": "Boucher and Anderson, USENIX Security 2023",
|
|
20
|
+
"debt_in_hotspots": "Maldonado and Shihab, MTD 2015", "hidden_coupling": "Ajienka and Capiluppi, JSS 2017",
|
|
21
|
+
"unreferenced_files": "Romano et al., TSE 2020", "signoff_by_co_author": "Linux kernel, Documentation/process/coding-assistants.rst",
|
|
22
|
+
"deep_nesting": "SonarSource cognitive complexity; CodeScene code health"}
|
|
23
|
+
|
|
24
|
+
|
|
14
25
|
def _f(severity: str, title: str, statement: str, advice: str, rule: dict, evidence: dict) -> dict:
|
|
15
26
|
"""A finding: the facts, then the next step. `detail` is the two joined for anyone reading the
|
|
16
27
|
JSON; `advice` says which part is the step so the report can show it on its own line. `rule` is
|
|
17
28
|
the rule's id and the thresholds it fired on, `evidence` the numbers they were compared with:
|
|
18
29
|
between them a reader of the JSON can check the finding without reading this file."""
|
|
30
|
+
if rule.get("id") in REFS and "ref" not in rule:
|
|
31
|
+
rule = {**rule, "ref": REFS[rule["id"]]}
|
|
19
32
|
return {"severity": severity, "title": title, "detail": f"{statement.rstrip()} {advice}", "advice": advice,
|
|
20
33
|
"rule": rule, "evidence": evidence}
|
|
21
34
|
|
|
@@ -1088,10 +1101,118 @@ def signoff_by_co_author(report: dict, min_commits: int = 2) -> list:
|
|
|
1088
1101
|
evidence={"identities": rows[:10]})]
|
|
1089
1102
|
|
|
1090
1103
|
|
|
1104
|
+
def _pool_files(report: dict) -> list:
|
|
1105
|
+
"""The source files still in the tree that no classifier reason sets aside."""
|
|
1106
|
+
from . import classify
|
|
1107
|
+
cls = classify.Classifier(report)
|
|
1108
|
+
return sorted(f for f in _tree(report) if cls.reason(f) is None)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _authors_of(report: dict, files: list, key: str = "is_author") -> dict:
|
|
1112
|
+
wanted = set(files)
|
|
1113
|
+
out = {f: set() for f in files}
|
|
1114
|
+
for r in report.get("doa") or []:
|
|
1115
|
+
if r["entity"] in wanted and r.get(key):
|
|
1116
|
+
out[r["entity"]].add(r["author"])
|
|
1117
|
+
return out
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def truck_factor(report: dict, min_files: int = 20, area_files: int = 10) -> list:
|
|
1121
|
+
"""Avelino et al.'s truck factor over the degree of authorship: how many people have to leave before
|
|
1122
|
+
more than half the source files have no author. One is a warning, two a note. Changes rather than
|
|
1123
|
+
lines, and a creator's bonus, so it can disagree with the surviving-code share, which the bus-factor
|
|
1124
|
+
finding reads; the finding says so when it does. Also per area, and with knowledge halving every
|
|
1125
|
+
five months."""
|
|
1126
|
+
if not report.get("doa"):
|
|
1127
|
+
return []
|
|
1128
|
+
files = _pool_files(report)
|
|
1129
|
+
authored = {f: a for f, a in _authors_of(report, files).items()}
|
|
1130
|
+
if len(files) < min_files:
|
|
1131
|
+
return []
|
|
1132
|
+
tf, removed, share = knowledge.truck_factor(authored)
|
|
1133
|
+
tf_d, removed_d, _ = knowledge.truck_factor(_authors_of(report, files, "is_author_decayed"))
|
|
1134
|
+
depth = knowledge.depth_for(files)
|
|
1135
|
+
areas = {}
|
|
1136
|
+
for f in files:
|
|
1137
|
+
areas.setdefault(knowledge._area(f, depth), []).append(f)
|
|
1138
|
+
lone = []
|
|
1139
|
+
for area, fs in sorted(areas.items()):
|
|
1140
|
+
if len(fs) >= area_files and area != knowledge.ROOT:
|
|
1141
|
+
n, who, _ = knowledge.truck_factor({f: authored[f] for f in fs})
|
|
1142
|
+
if n == 1:
|
|
1143
|
+
lone.append((area, who[0]))
|
|
1144
|
+
if tf > 2 and not lone:
|
|
1145
|
+
return []
|
|
1146
|
+
orphans = round(share * len(files))
|
|
1147
|
+
statement = (f"Truck factor {tf}: without {textfmt.join_and(removed)}, {orphans} of the {len(files)} source files ({_pct(orphans, len(files))}) "
|
|
1148
|
+
f"have no author left.")
|
|
1149
|
+
if tf_d != tf:
|
|
1150
|
+
statement += f" With knowledge halving every five months it is {tf_d} ({textfmt.join_and(removed_d)})."
|
|
1151
|
+
if lone:
|
|
1152
|
+
statement += " Areas with a truck factor of one: " + ", ".join(f"{a} ({w})" for a, w in lone[:5]) + (f" and {len(lone) - 5} more" if len(lone) > 5 else "") + "."
|
|
1153
|
+
shares = report.get("theseus_authors") or {}
|
|
1154
|
+
if shares and removed:
|
|
1155
|
+
top, lines = max(shares.items(), key=lambda kv: kv[1])
|
|
1156
|
+
if top != removed[0]:
|
|
1157
|
+
statement += f" The surviving code's largest share is {top}'s ({_pct(lines, sum(shares.values()))}), which the bus-factor finding reads."
|
|
1158
|
+
first_area = next((a for a, w in lone if w == removed[0]), lone[0][0] if lone else None)
|
|
1159
|
+
advice = f"Pair someone with {removed[0]}" + (f" on {first_area}" if first_area else "") + " first; they author most of what would be left without an author."
|
|
1160
|
+
return [_f("warning" if tf == 1 else "info", "Truck factor", statement, advice,
|
|
1161
|
+
rule={"id": "truck_factor", "doa_author_share": 0.75, "doa_floor": 3.293, "orphan_share": 0.5, "decay_months": 5,
|
|
1162
|
+
"ref": "Avelino et al., ICPC 2016"},
|
|
1163
|
+
evidence={"truck_factor": tf, "removed": removed, "truck_factor_decayed": tf_d, "removed_decayed": removed_d,
|
|
1164
|
+
"files": len(files), "orphaned": orphans, "areas": [{"area": a, "author": w} for a, w in lone[:10]]})]
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def authors_gone(report: dict, min_files: int = 5) -> list:
|
|
1168
|
+
"""Files whose every author by degree of authorship has stopped committing, while others still
|
|
1169
|
+
change them: "creator left, editors remain", knowledge the blame share cannot show."""
|
|
1170
|
+
if not report.get("doa"):
|
|
1171
|
+
return []
|
|
1172
|
+
months = report["meta"].get("gone_months", loss.DEFAULT_MONTHS)
|
|
1173
|
+
gone = {g["name"] for g in loss.gone(report, months)}
|
|
1174
|
+
fresh = {a["entity"] for a in report.get("age") or [] if a["age-months"] < 12}
|
|
1175
|
+
files = [f for f in _pool_files(report) if f in fresh]
|
|
1176
|
+
authored = _authors_of(report, files)
|
|
1177
|
+
left = [(f, sorted(a)) for f, a in authored.items() if a and a <= gone]
|
|
1178
|
+
if len(left) < min_files:
|
|
1179
|
+
return []
|
|
1180
|
+
listed = "; ".join(f"{f} ({textfmt.join_and(a)})" for f, a in left[:5]) + (f" and {len(left) - 5} more" if len(left) > 5 else "")
|
|
1181
|
+
return [_f("info", "Files whose authors have left", f"{len(left)} source files changed in the last year have no author still committing: {listed}.",
|
|
1182
|
+
f"Make the people who edit {left[0][0]} its authors: review its design with them and write down what only {left[0][1][0]} knew.",
|
|
1183
|
+
rule={"id": "authors_gone", "gone_months": months, "min_files": min_files, "ref": "Avelino et al., ICPC 2016"},
|
|
1184
|
+
evidence={"count": len(left), "files": [{"file": f, "authors": a} for f, a in left[:10]]})]
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def component_coupling(report: dict, min_degree: int = 30) -> list:
|
|
1188
|
+
"""Components (top-level directories, or the level below a lone src/) that change together in a
|
|
1189
|
+
large share of their changes: coupling at the level of the architecture, where two files in one
|
|
1190
|
+
directory is only a layout."""
|
|
1191
|
+
rows = report.get("components") or []
|
|
1192
|
+
if not rows:
|
|
1193
|
+
return []
|
|
1194
|
+
depth = knowledge.depth_for(list(_tree(report)) or [r["entity"] + "x" for r in rows])
|
|
1195
|
+
|
|
1196
|
+
def aside(c):
|
|
1197
|
+
probe = c + "x.py"
|
|
1198
|
+
return filetypes.is_test_path(probe) or filetypes.is_sample_path(probe) or filetypes.is_doc_path(probe) or filetypes.is_vendor_path(probe)
|
|
1199
|
+
pairs = [r for r in rows if r["depth"] == depth and r["degree"] >= min_degree and not aside(r["entity"]) and not aside(r["coupled"])]
|
|
1200
|
+
if not pairs:
|
|
1201
|
+
return []
|
|
1202
|
+
listed = "; ".join(f"{p['entity']} and {p['coupled']} change together in {p['degree']}% of their changes ({p['shared']} shared)" for p in pairs[:3])
|
|
1203
|
+
more = f" ({len(pairs) - 3} more pairs)" if len(pairs) > 3 else ""
|
|
1204
|
+
first = pairs[0]
|
|
1205
|
+
return [_f("info", "Components that change together", f"{listed}{more}.",
|
|
1206
|
+
f"Look at what {first['entity']} and {first['coupled']} share: a change that keeps landing in both is an interface nobody named.",
|
|
1207
|
+
rule={"id": "component_coupling", "min_degree": min_degree, "depth": depth, "ref": "Tornhill, Your Code as a Crime Scene, 2024"},
|
|
1208
|
+
evidence={"pairs": [{"a": p["entity"], "b": p["coupled"], "degree": p["degree"], "shared": p["shared"]} for p in pairs[:10]]})]
|
|
1209
|
+
|
|
1210
|
+
|
|
1091
1211
|
RULES = [dormant, secrets_found, credential_files, vulnerable_dependencies, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets,
|
|
1092
1212
|
minor_contributors, reverts, brain_methods, complexity_growth, tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss,
|
|
1093
1213
|
sweeping_commits, tangled_commits, hygiene_findings, debt_in_hotspots, deep_nesting, hidden_coupling, unreferenced_files,
|
|
1094
|
-
agent_approval_disabled, agent_local_settings, mcp_literal_env, agent_instructions_drift, signoff_by_co_author
|
|
1214
|
+
agent_approval_disabled, agent_local_settings, mcp_literal_env, agent_instructions_drift, signoff_by_co_author,
|
|
1215
|
+
truck_factor, authors_gone, component_coupling]
|
|
1095
1216
|
|
|
1096
1217
|
|
|
1097
1218
|
def evaluate(report: dict) -> list:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""The agent-hook gate: `gitmole OUT_DIR --no-run --hook [--risk-threshold N] [-- FILE...]
|
|
1
|
+
"""The agent-hook gate: `gitmole OUT_DIR --no-run --hook [--risk-threshold N] [-- FILE...]`, after Codacy's
|
|
2
|
+
argument for deterministic findings before inference and Zimmermann et al.'s co-change recommendations.
|
|
2
3
|
|
|
3
4
|
Deterministic findings should run before inference, so the model reasons over a short list rather
|
|
4
5
|
than rediscovering known issues. A linter says what is wrong in the diff; gitmole says what history
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Repository hygiene from the clone alone: the checks OpenSSF Scorecard and the OSPS Baseline make
|
|
2
|
-
through the GitHub API, done with file and git reads
|
|
2
|
+
through the GitHub API, done with file and git reads; Trojan Source after Boucher and Anderson (USENIX
|
|
3
|
+
Security 2023).
|
|
3
4
|
|
|
4
5
|
Runs as a pipeline step, `python -m gitmole.hygiene OUT_DIR`, from inside the repository, and writes
|
|
5
6
|
hygiene.json; findings.py turns it into findings. Each check keys on a convention of the ecosystem (a
|
|
@@ -78,3 +78,37 @@ def islands(areas_list: list, min_lines: int = 200, min_share: float = 0.9) -> l
|
|
|
78
78
|
if n / a["lines"] >= min_share:
|
|
79
79
|
out.append({"area": a["area"], "owner": owner, "share": round(100 * n / a["lines"]), "lines": a["lines"]})
|
|
80
80
|
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def truck_factor(authors_of: dict, orphan_share: float = 0.5) -> tuple:
|
|
84
|
+
"""Avelino et al.'s truck factor: remove the person who authors the most files, again and again, until
|
|
85
|
+
more than half the files have no author left. (the number removed, their names in order, the share
|
|
86
|
+
orphaned at the end). authors_of: {file: set of names}."""
|
|
87
|
+
files = list(authors_of)
|
|
88
|
+
if not files:
|
|
89
|
+
return 0, [], 0.0
|
|
90
|
+
remaining = {f: set(a) for f, a in authors_of.items()}
|
|
91
|
+
removed = []
|
|
92
|
+
|
|
93
|
+
def orphaned():
|
|
94
|
+
return sum(1 for a in remaining.values() if not a) / len(files)
|
|
95
|
+
while orphaned() <= orphan_share:
|
|
96
|
+
counts = {}
|
|
97
|
+
for a in remaining.values():
|
|
98
|
+
for who in a:
|
|
99
|
+
counts[who] = counts.get(who, 0) + 1
|
|
100
|
+
if not counts:
|
|
101
|
+
break
|
|
102
|
+
top = min(counts, key=lambda w: (-counts[w], w))
|
|
103
|
+
removed.append(top)
|
|
104
|
+
for a in remaining.values():
|
|
105
|
+
a.discard(top)
|
|
106
|
+
return len(removed), removed, orphaned()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def depth_for(paths: list, dominant: float = 0.8) -> int:
|
|
110
|
+
"""1 for top-level directories, 2 when one top-level directory holds `dominant` of the files (a lone
|
|
111
|
+
src/), as the knowledge map chooses."""
|
|
112
|
+
from collections import Counter
|
|
113
|
+
tops = Counter(_area(p, 1) for p in paths)
|
|
114
|
+
return 2 if tops and tops.most_common(1)[0][1] >= dominant * len(paths) and tops.most_common(1)[0][0] != ROOT else 1
|
|
@@ -228,6 +228,9 @@ def scan_unreachable(repo: str, out_dir: str, found: dict) -> list:
|
|
|
228
228
|
sha = os.path.basename(r.get("File") or "")
|
|
229
229
|
r["File"] = f"(unreachable blob {sha[:12]})"
|
|
230
230
|
r["Commit"] = ""
|
|
231
|
+
# betterleaks' own fingerprint names the scratch path the blob was written to; this one names the blob,
|
|
232
|
+
# so it is the same in every run and can go into .betterleaksignore
|
|
233
|
+
r["Fingerprint"] = f"unreachable:{sha}:{r.get('RuleID', '')}:{r.get('StartLine', '')}"
|
|
231
234
|
return rows
|
|
232
235
|
|
|
233
236
|
|
|
@@ -82,7 +82,9 @@ def parse_scc(text: str, types=None) -> dict:
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
NUMERIC_COLUMNS = {"n-revs", "degree", "average-revs", "n-authors", "age-months", "added", "deleted", "n-fixes", "recent-fixes", "tiny-revs",
|
|
85
|
-
"minor", "soc", "partners", "n-sets", "with-tests", "periods"
|
|
85
|
+
"minor", "soc", "partners", "n-sets", "with-tests", "periods", "fa", "dl", "ac", "is_author", "is_author_decayed", "late",
|
|
86
|
+
"depth", "shared"}
|
|
87
|
+
FLOAT_COLUMNS = {"doa", "doa_decayed", "hcm"}
|
|
86
88
|
|
|
87
89
|
|
|
88
90
|
def parse_maat_csv(text: str) -> list:
|
|
@@ -91,10 +93,17 @@ def parse_maat_csv(text: str) -> list:
|
|
|
91
93
|
return []
|
|
92
94
|
out = []
|
|
93
95
|
for row in csv.DictReader(io.StringIO(text)):
|
|
94
|
-
out.append({k: (_num(v) if k in NUMERIC_COLUMNS else v) for k, v in row.items()})
|
|
96
|
+
out.append({k: (_num(v) if k in NUMERIC_COLUMNS else _float(v) if k in FLOAT_COLUMNS else v) for k, v in row.items()})
|
|
95
97
|
return out
|
|
96
98
|
|
|
97
99
|
|
|
100
|
+
def _float(v):
|
|
101
|
+
try:
|
|
102
|
+
return float(v)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return 0.0
|
|
105
|
+
|
|
106
|
+
|
|
98
107
|
def _num(v):
|
|
99
108
|
"""An int for a numeric cell; 0 for a missing, empty or garbage one (a row cut short by a killed step)."""
|
|
100
109
|
try:
|
|
@@ -182,6 +191,7 @@ def parse_functions(text: str) -> list:
|
|
|
182
191
|
anonymous = name in ("", "(anonymous)")
|
|
183
192
|
rows.append({"file": _rel(r[6]), "function": textfmt.cut(label if anonymous and label else name, NAME_CAP) or "(anonymous)", "anonymous": anonymous,
|
|
184
193
|
"ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]), "start": _num(r[9]), "end": _num(r[10]), "suspect": suspect})
|
|
194
|
+
rows.sort(key=lambda f: (f["file"], f["start"], f["end"], f["function"])) # the function step works in parallel; the order is this one
|
|
185
195
|
return rows
|
|
186
196
|
|
|
187
197
|
|
|
@@ -239,6 +249,8 @@ def parse_secrets(text: str) -> list:
|
|
|
239
249
|
value, placeholder = None, False
|
|
240
250
|
out.append({"rule": r.get("RuleID", ""), "file": r.get("File", ""), "commit": r.get("Commit", "")[:7], "line": r.get("StartLine"),
|
|
241
251
|
"fingerprint": r.get("Fingerprint", ""), "value": value, "placeholder": placeholder})
|
|
252
|
+
# betterleaks scans in parallel and does not promise an order; everything downstream reads the rows in this one
|
|
253
|
+
out.sort(key=lambda r: (r["file"], r["commit"], r["line"] or 0, r["rule"], r["fingerprint"]))
|
|
242
254
|
return out
|
|
243
255
|
|
|
244
256
|
|
|
@@ -251,6 +263,8 @@ def parse_dependencies(data) -> dict:
|
|
|
251
263
|
if data["status"] == "scanned":
|
|
252
264
|
out.update({"sources": data.get("sources") or [], "packages": _num(data.get("packages")),
|
|
253
265
|
"vulnerable": data.get("vulnerable") or [], "database_date": data.get("database_date")})
|
|
266
|
+
if data.get("database_digest"):
|
|
267
|
+
out["database_digest"] = data["database_digest"]
|
|
254
268
|
elif data["status"] == "no-database":
|
|
255
269
|
out["download"] = data.get("download") or ""
|
|
256
270
|
return out
|
|
@@ -313,6 +327,9 @@ def load_report(out_dir: str, nested: bool = True) -> dict:
|
|
|
313
327
|
"soc": parse_maat_csv(_read(out_dir, "maat-soc.csv")), # sum of coupling; empty for an output directory from before 0.11
|
|
314
328
|
"tests": parse_maat_csv(_read(out_dir, "maat-tests.csv")), # test co-change per production file; empty before 0.12
|
|
315
329
|
"entropy": parse_maat_csv(_read(out_dir, "maat-entropy.csv")), # Hassan's change entropy per file; empty before 0.13
|
|
330
|
+
"doa": parse_maat_csv(_read(out_dir, "maat-doa.csv")), # degree of authorship per file and person; empty before 0.19
|
|
331
|
+
"latenight": parse_maat_csv(_read(out_dir, "maat-latenight.csv")),
|
|
332
|
+
"components": parse_maat_csv(_read(out_dir, "maat-components.csv")),
|
|
316
333
|
"authors": parse_maat_csv(_read(out_dir, "maat-authors.csv")),
|
|
317
334
|
"age": parse_maat_csv(_read(out_dir, "maat-age.csv")),
|
|
318
335
|
"ownership": ownership,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Change analysis over a git log export, in the layout code-maat produced
|
|
2
|
+
"""Change analysis over a git log export, in the layout code-maat produced: revisions, coupling and sum of
|
|
3
|
+
coupling after Adam Tornhill's code-maat, minor contributors after Bird et al. (FSE 2011), change entropy
|
|
4
|
+
after Hassan (ICSE 2009), oversized fixes after Herzig and Zeller (MSR 2013); see docs/references.md.
|
|
3
5
|
|
|
4
6
|
Standalone on purpose: gitmole runs it as a pipeline step with
|
|
5
7
|
`python3 maat.py LOG OUT_DIR [--aliases META_JSON]` and it must not need the
|
|
@@ -379,6 +381,102 @@ def entropy(commits: list, now: str = None, decay: float = ENTROPY_DECAY) -> lis
|
|
|
379
381
|
return rows
|
|
380
382
|
|
|
381
383
|
|
|
384
|
+
DOA_DECAY_MONTHS = 5 # JetBrains' Bus Factor Explorer: knowledge halves every five months
|
|
385
|
+
DOA_AUTHOR_SHARE = 0.75
|
|
386
|
+
DOA_FLOOR = 3.293
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _doa(fa: int, dl: float, ac: float) -> float:
|
|
390
|
+
"""Avelino et al.'s degree of authorship: a creator's bonus, the author's own changes, and a
|
|
391
|
+
logarithmic dilution by everyone else's."""
|
|
392
|
+
return 3.293 + 1.098 * fa + 0.164 * dl - 0.321 * math.log(1 + ac)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def doa(commits: list, now: str = None) -> list:
|
|
396
|
+
"""Per file and person: created it (the first commit that added lines to it; a pure move creates
|
|
397
|
+
nothing), their changes, others' changes, the degree of authorship, and whether they count as an
|
|
398
|
+
author of it (DOA at least three quarters of the file's highest and at least 3.293), undecayed and
|
|
399
|
+
with knowledge halving every five months. Changes, not lines, so a reformat transfers nothing."""
|
|
400
|
+
now = dt.date.fromisoformat(now or dt.date.today().isoformat())
|
|
401
|
+
changes, decayed, first = defaultdict(Counter), defaultdict(Counter), {}
|
|
402
|
+
for c in commits:
|
|
403
|
+
weight = 0.5 ** (max(0, (now - dt.date.fromisoformat(c["date"])).days) / 30.44 / DOA_DECAY_MONTHS)
|
|
404
|
+
for p, added, _ in c["files"]:
|
|
405
|
+
for who in people(c):
|
|
406
|
+
changes[p][who] += 1
|
|
407
|
+
decayed[p][who] += weight
|
|
408
|
+
if added > 0 and (p not in first or (c["date"], c.get("time", "")) < first[p][0]):
|
|
409
|
+
first[p] = ((c["date"], c.get("time", "")), c["author"])
|
|
410
|
+
rows = []
|
|
411
|
+
for p, per in changes.items():
|
|
412
|
+
total, total_d = sum(per.values()), sum(decayed[p].values())
|
|
413
|
+
creator = first.get(p, (None, None))[1]
|
|
414
|
+
scores = {}
|
|
415
|
+
for who, n in per.items():
|
|
416
|
+
fa = int(who == creator)
|
|
417
|
+
scores[who] = (fa, n, total - n, _doa(fa, n, total - n), _doa(fa, decayed[p][who], total_d - decayed[p][who]))
|
|
418
|
+
top, top_d = max(s[3] for s in scores.values()), max(s[4] for s in scores.values())
|
|
419
|
+
for who, (fa, n, others, value, value_d) in sorted(scores.items()):
|
|
420
|
+
rows.append({"entity": p, "author": who, "fa": fa, "dl": n, "ac": others, "doa": round(value, 4), "doa_decayed": round(value_d, 4),
|
|
421
|
+
"is_author": int(value >= DOA_FLOOR and value >= DOA_AUTHOR_SHARE * top),
|
|
422
|
+
"is_author_decayed": int(value_d >= DOA_FLOOR and value_d >= DOA_AUTHOR_SHARE * top_d)})
|
|
423
|
+
rows.sort(key=lambda r: (r["entity"], r["author"]))
|
|
424
|
+
return rows
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _local_hour(stamp: str):
|
|
428
|
+
try:
|
|
429
|
+
return dt.datetime.fromisoformat(stamp[:-1] + "+00:00" if stamp.endswith("Z") else stamp).hour if len(stamp) > 10 else None
|
|
430
|
+
except ValueError:
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
LATE_HOURS = range(0, 4) # Eyolfson, Tan and Lam: commits between midnight and 4 am, in the author's own time, were buggier
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def latenight(commits: list) -> list:
|
|
438
|
+
"""Per file: its revisions, and how many were committed between midnight and 4 am in the author's
|
|
439
|
+
own offset. A reason beside a file, never a rank: the effect is far weaker than churn or ownership."""
|
|
440
|
+
revs, late = Counter(), Counter()
|
|
441
|
+
for c in commits:
|
|
442
|
+
hour = _local_hour(c.get("time") or "")
|
|
443
|
+
for p, _, _ in c["files"]:
|
|
444
|
+
revs[p] += 1
|
|
445
|
+
late[p] += hour is not None and hour in LATE_HOURS
|
|
446
|
+
rows = [{"entity": p, "n-revs": n, "late": late[p]} for p, n in revs.items()]
|
|
447
|
+
rows.sort(key=lambda r: (-r["late"], r["entity"]))
|
|
448
|
+
return rows
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def component(path: str, depth: int) -> str:
|
|
452
|
+
dirs = path.split("/")[:-1]
|
|
453
|
+
return "/".join(dirs[:depth]) + "/" if dirs else "(root files)"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def components(commits: list, min_shared: int = 10, min_degree: int = 20, max_components: int = 10) -> list:
|
|
457
|
+
"""Coupling between components, the files truncated to their first one and two directories, over
|
|
458
|
+
the logical changes: two files in one directory changing together is a layout, `auth/` and
|
|
459
|
+
`billing/` changing together 40% of the time is architecture. A change that spans more than
|
|
460
|
+
`max_components` components is a sweep and couples nothing."""
|
|
461
|
+
out = []
|
|
462
|
+
for depth in (1, 2):
|
|
463
|
+
revs, shared = Counter(), Counter()
|
|
464
|
+
for c in changesets(commits):
|
|
465
|
+
comps = sorted({component(p, depth) for p, _, _ in c["files"]} - {"(root files)"})
|
|
466
|
+
if not comps or len(comps) > max_components:
|
|
467
|
+
continue
|
|
468
|
+
revs.update(comps)
|
|
469
|
+
for a, b in itertools.combinations(comps, 2):
|
|
470
|
+
shared[(a, b)] += 1
|
|
471
|
+
for (a, b), n in shared.items():
|
|
472
|
+
avg = (revs[a] + revs[b]) / 2
|
|
473
|
+
degree = int(math.floor(100 * n / avg + 0.5))
|
|
474
|
+
if n >= min_shared and degree >= min_degree:
|
|
475
|
+
out.append({"depth": depth, "entity": a, "coupled": b, "degree": degree, "shared": n, "average-revs": int(math.floor(avg + 0.5))})
|
|
476
|
+
out.sort(key=lambda r: (r["depth"], -r["degree"], -r["shared"], r["entity"], r["coupled"]))
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
|
|
382
480
|
RECENT_MONTHS = 6
|
|
383
481
|
OVERSIZED_PERCENTILE = 0.99 # a fix changing more lines than this share of the history's commits credits nothing
|
|
384
482
|
OVERSIZED_FLOOR = 500 # ...and never under this many lines, so a small repository's percentile does not bite
|
|
@@ -557,8 +655,11 @@ ANALYSES = {
|
|
|
557
655
|
"entity-ownership": (entity_ownership, ["entity", "author", "added", "deleted"]),
|
|
558
656
|
"fixes": (fixes, ["entity", "n-fixes", "last-fix", "recent-fixes"]),
|
|
559
657
|
"entropy": (entropy, ["entity", "periods", "hcm"]),
|
|
658
|
+
"doa": (doa, ["entity", "author", "fa", "dl", "ac", "doa", "doa_decayed", "is_author", "is_author_decayed"]),
|
|
659
|
+
"latenight": (latenight, ["entity", "n-revs", "late"]),
|
|
660
|
+
"components": (components, ["depth", "entity", "coupled", "degree", "shared", "average-revs"]),
|
|
560
661
|
}
|
|
561
|
-
NEEDS_NOW = {"age", "fixes", "entropy"}
|
|
662
|
+
NEEDS_NOW = {"age", "fixes", "entropy", "doa"}
|
|
562
663
|
|
|
563
664
|
|
|
564
665
|
def aliases_from_meta(path: str) -> dict:
|