gitmole 0.16.0__tar.gz → 0.18.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.16.0 → gitmole-0.18.0}/PKG-INFO +13 -6
- {gitmole-0.16.0 → gitmole-0.18.0}/README.md +12 -5
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/__init__.py +1 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/cli.py +5 -2
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/compare.py +10 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/deps.py +24 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/duplicates.py +44 -2
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/findings.py +82 -2
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/hook.py +2 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/hygiene.py +2 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/leaks.py +3 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/load.py +11 -2
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/maat.py +3 -1
- gitmole-0.18.0/gitmole/provenance.py +241 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/render.py +64 -2
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/run.py +5 -3
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/sarif.py +1 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/signing.py +2 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/watch.py +3 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole.egg-info/PKG-INFO +13 -6
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole.egg-info/SOURCES.txt +2 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_compare.py +2 -2
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_deps.py +14 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_duplicates.py +13 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_findings.py +63 -2
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_golden.py +20 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_leaks.py +1 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_load.py +16 -3
- gitmole-0.18.0/tests/test_provenance.py +128 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_render.py +59 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_run.py +9 -1
- {gitmole-0.16.0 → gitmole-0.18.0}/LICENSE +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/__main__.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/backtest.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/banner.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/blame.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/classify.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/clean.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/coupling.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/evaluate.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/filetypes.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/functions.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/hotspots.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/identity.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/knowledge.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/loss.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/structure.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/szz.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/textfmt.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole/trend.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/pyproject.toml +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/setup.cfg +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_backtest.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_banner.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_blame.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_classify.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_clean.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_cli.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_coupling.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_evaluate.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_filetypes.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_functions.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_hook.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_hotspots.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_hygiene.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_identity.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_knowledge.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_loss.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_maat.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_packaging.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_render_examples.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_sarif.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_signing.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_structure.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_szz.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_textfmt.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_trend.py +0 -0
- {gitmole-0.16.0 → gitmole-0.18.0}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.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
|
|
|
@@ -369,6 +369,9 @@ def _meta_for_run(repo_dir: str, args, estimate, age_ok: bool, plots_ok: bool, p
|
|
|
369
369
|
from . import maat as _maat
|
|
370
370
|
cut = _maat.months_before(meta["last_date"], 6) if meta["last_date"] else None
|
|
371
371
|
first = meta.get("first_date_all") or meta["first_date"] # the backtest reads the whole history, window or not
|
|
372
|
+
year = _maat.months_before(meta["last_date"], 12) if meta["last_date"] else None
|
|
373
|
+
if year and first and first <= year:
|
|
374
|
+
meta["duplicates"]["then"] = year # the rate a year back, when the history reaches it
|
|
372
375
|
if cut and first and first <= _maat.months_before(cut, 6):
|
|
373
376
|
meta["backtest"] = {"status": "planned", "until": cut}
|
|
374
377
|
else:
|
|
@@ -427,7 +430,7 @@ def _analyse(repo_dir: str, out_dir: str, args, ui: Console, planner, estimator)
|
|
|
427
430
|
run.clear_outputs(out_dir)
|
|
428
431
|
steps = planner(repo_dir, out_dir, branch=meta["branch"], age=age_ok, plots=plots_ok, ignore=ignore, types=types_spec, now=args.now,
|
|
429
432
|
since=args.since_date, lizard=lizard_ok, duplicates=duplicates_ok, backtest=cut, ignore_revs=run.ignore_revs_files(repo_dir),
|
|
430
|
-
structure=getattr(args, "structure", False))
|
|
433
|
+
structure=getattr(args, "structure", False), duplicates_then=meta["duplicates"].get("then"))
|
|
431
434
|
run.save_meta(meta, out_dir)
|
|
432
435
|
results = _execute(steps, log_path, repo_dir, args.workers, ui, timeout=args.timeout)
|
|
433
436
|
if _control.cancelled.is_set():
|
|
@@ -589,7 +592,7 @@ def _render(out_dir: str, console: Console, ui: Console, args, err: Console) ->
|
|
|
589
592
|
return 2
|
|
590
593
|
comparison = _compare.compare(before, report, found)
|
|
591
594
|
if args.json:
|
|
592
|
-
_write(
|
|
595
|
+
_write(render.dumps_json(report, found, risk=risk, compare=comparison), args.json, console)
|
|
593
596
|
if args.markdown:
|
|
594
597
|
_write(render.markdown(report, found, full=args.full, risk=risk, base=args.risk, compare=comparison), args.markdown, console)
|
|
595
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
|
|
@@ -6,8 +6,10 @@ jscpd walks the working tree and reports every pair of matching fragments. This
|
|
|
6
6
|
whose two sides are both tracked files inside the analysed types, folds the pairs of one fragment into
|
|
7
7
|
a block with all its places, measures the duplicated share over the kept files, and writes
|
|
8
8
|
duplicates.json. jscpd's own report quotes every fragment, so it is written to a temporary directory
|
|
9
|
-
under OUT and removed before this returns: no source text lands in the output directory.
|
|
10
|
-
|
|
9
|
+
under OUT and removed before this returns: no source text lands in the output directory. With --then
|
|
10
|
+
DATE the tree at the last commit before that date is exported under OUT, measured the same way and
|
|
11
|
+
removed, so the rate has a direction: GitClear's longitudinal data shows duplication is where the
|
|
12
|
+
change is, and one number without the year before it says little. Standalone, like maat.py and blame.py.
|
|
11
13
|
"""
|
|
12
14
|
from __future__ import annotations
|
|
13
15
|
|
|
@@ -118,6 +120,40 @@ def run_jscpd(repo: str, out: str, procs: int, ignore=()) -> tuple:
|
|
|
118
120
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
119
121
|
|
|
120
122
|
|
|
123
|
+
def rev_before(repo: str, date: str):
|
|
124
|
+
out = subprocess.run(["git", "rev-list", "-1", f"--before={date}T00:00:00", "HEAD"], cwd=repo, capture_output=True, text=True)
|
|
125
|
+
return out.stdout.strip() or None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def files_at(repo: str, rev: str, ignore=(), types_spec: str = None) -> list:
|
|
129
|
+
"""The tracked text files of the tree at `rev`, in the analysed types, as select_files lists HEAD's."""
|
|
130
|
+
import fnmatch
|
|
131
|
+
proc = subprocess.run([*filetypes.GIT, "grep", "-I", "--name-only", "-z", "-e", "", rev], cwd=repo, capture_output=True)
|
|
132
|
+
types = filetypes.parse(types_spec)
|
|
133
|
+
paths = sorted(p.decode("utf-8", "surrogateescape").split(":", 1)[1] for p in proc.stdout.split(b"\0") if p)
|
|
134
|
+
return [f for f in paths if filetypes.matches(f, types) and not any(fnmatch.fnmatch(f, g) for g in ignore)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rate_at(repo: str, out: str, rev: str, procs: int, ignore=(), types_spec: str = None):
|
|
138
|
+
"""The duplicated share of the tree at `rev`: the tree exported through a temporary index under
|
|
139
|
+
`out` (as the backtest exports its cut-off), jscpd over it, the same fold and rate as HEAD's."""
|
|
140
|
+
tmp = tempfile.mkdtemp(prefix=".dup-then-", dir=out)
|
|
141
|
+
try:
|
|
142
|
+
tree = os.path.join(tmp, "tree")
|
|
143
|
+
os.makedirs(tree)
|
|
144
|
+
env = dict(os.environ, GIT_INDEX_FILE=os.path.join(tmp, "index"))
|
|
145
|
+
subprocess.run(["git", "read-tree", rev], cwd=repo, env=env, check=True, capture_output=True)
|
|
146
|
+
subprocess.run(["git", "checkout-index", "-a", f"--prefix={tree}/"], cwd=repo, env=env, check=True, capture_output=True)
|
|
147
|
+
files = files_at(repo, rev, ignore, types_spec)
|
|
148
|
+
rc, report = run_jscpd(tree, out, procs, ignore)
|
|
149
|
+
if rc != 0:
|
|
150
|
+
return None
|
|
151
|
+
blocks = fold(report.get("duplicates") or [], set(files))
|
|
152
|
+
return {"files": len(files), "rate": rate(blocks, tree, files)}
|
|
153
|
+
finally:
|
|
154
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
155
|
+
|
|
156
|
+
|
|
121
157
|
def write(result: dict, target: str) -> None:
|
|
122
158
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(target)), prefix=".duplicates-", suffix=".json")
|
|
123
159
|
try:
|
|
@@ -136,6 +172,7 @@ def main(argv=None) -> int:
|
|
|
136
172
|
p.add_argument("--procs", type=int, default=1)
|
|
137
173
|
p.add_argument("--ignore", action="append", default=[])
|
|
138
174
|
p.add_argument("--types", default=None, help="file types spec as for gitmole --file-types")
|
|
175
|
+
p.add_argument("--then", metavar="YYYY-MM-DD", help="also measure the tree at the last commit before this date, for the direction")
|
|
139
176
|
args = p.parse_args(argv)
|
|
140
177
|
repo, out = os.path.abspath(args.repo), os.path.abspath(args.out)
|
|
141
178
|
files = select_files(repo, args.ignore, args.types)
|
|
@@ -145,6 +182,11 @@ def main(argv=None) -> int:
|
|
|
145
182
|
blocks = fold(report.get("duplicates") or [], set(files))
|
|
146
183
|
result = {"tool": "jscpd", "files": len(files), "clones": sum(len(b["places"]) - 1 for b in blocks),
|
|
147
184
|
"rate": rate(blocks, repo, files), "blocks": blocks[:BLOCKS_KEPT]}
|
|
185
|
+
if args.then:
|
|
186
|
+
rev = rev_before(repo, args.then)
|
|
187
|
+
measured = rate_at(repo, out, rev, args.procs, args.ignore, args.types) if rev else None
|
|
188
|
+
if measured:
|
|
189
|
+
result["then"] = {"date": args.then, "rev": rev[:12], **measured}
|
|
148
190
|
write(result, os.path.join(out, "duplicates.json"))
|
|
149
191
|
return 0
|
|
150
192
|
|
|
@@ -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
|
|
|
@@ -1022,9 +1035,76 @@ def unreferenced_files(report: dict) -> list:
|
|
|
1022
1035
|
rule={"id": "unreferenced_files", "ref": "Romano et al., TSE 2020"}, evidence={"count": n, "files": paths[:10]})]
|
|
1023
1036
|
|
|
1024
1037
|
|
|
1038
|
+
def _agents(report: dict) -> dict:
|
|
1039
|
+
return ((report.get("provenance") or {}).get("agents")) or {}
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def agent_approval_disabled(report: dict) -> list:
|
|
1043
|
+
"""A committed agent configuration that turns approval prompts off: every clone that picks the
|
|
1044
|
+
settings up runs the agent's tools without asking."""
|
|
1045
|
+
rows = _agents(report).get("approval_disabled") or []
|
|
1046
|
+
if not rows:
|
|
1047
|
+
return []
|
|
1048
|
+
listed = "; ".join(f"{r['file']} sets {r['setting']}" for r in rows)
|
|
1049
|
+
return [_f("warning", "Agent approval prompts turned off in the repository", f"{listed}. Anyone who opens the clone with that agent runs its tools unasked.",
|
|
1050
|
+
"Move the setting to your personal settings file, which is not committed, and keep the shared one to what everyone should get.",
|
|
1051
|
+
rule={"id": "agent_approval_disabled", "by": "tracked agent settings"}, evidence={"settings": rows})]
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def agent_local_settings(report: dict) -> list:
|
|
1055
|
+
rows = _agents(report).get("local_settings") or []
|
|
1056
|
+
if not rows:
|
|
1057
|
+
return []
|
|
1058
|
+
return [_f("warning", "Personal agent settings tracked", f"{_files_list(rows)} {'is' if len(rows) == 1 else 'are'} tracked; the file is meant for one machine and to stay out of git.",
|
|
1059
|
+
f"git rm --cached {rows[0]} and add it to .gitignore.",
|
|
1060
|
+
rule={"id": "agent_local_settings", "by": "path convention"}, evidence={"files": rows})]
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def mcp_literal_env(report: dict) -> list:
|
|
1064
|
+
"""An MCP server declaration whose environment holds a literal value long enough to be a
|
|
1065
|
+
credential rather than a ${VAR} reference. The key is named, the value never."""
|
|
1066
|
+
rows = [(m["file"], x) for m in _agents(report).get("mcp") or [] for x in m.get("literal_env") or []]
|
|
1067
|
+
if not rows:
|
|
1068
|
+
return []
|
|
1069
|
+
listed = "; ".join(f"{x['server']} sets {x['key']} in {f} to a literal value" for f, x in rows[:5])
|
|
1070
|
+
f0, x0 = rows[0]
|
|
1071
|
+
return [_f("warning", "Literal values in MCP server declarations", f"{listed}. A committed declaration shares whatever it holds.",
|
|
1072
|
+
f"Replace the value of {x0['key']} with ${{{x0['key']}}} and set it in the environment; rotate it if it was a live credential.",
|
|
1073
|
+
rule={"id": "mcp_literal_env", "min_length": 16, "placeholders": "left out"},
|
|
1074
|
+
evidence={"entries": [{"file": f, "server": x["server"], "key": x["key"]} for f, x in rows[:10]]})]
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def agent_instructions_drift(report: dict, min_months: int = 6, min_commits: int = 100) -> list:
|
|
1078
|
+
"""Agent instruction files (AGENTS.md and the like) far behind the code they describe."""
|
|
1079
|
+
last = (report.get("meta") or {}).get("last_date") or ""
|
|
1080
|
+
stale = [r for r in _agents(report).get("instructions") or []
|
|
1081
|
+
if last and _months_apart(r["last"], last) >= min_months and r["commits_behind"] >= min_commits]
|
|
1082
|
+
if not stale:
|
|
1083
|
+
return []
|
|
1084
|
+
listed = "; ".join(f"{r['file']} last changed on {r['last']}, {_months_apart(r['last'], last)} months and {r['commits_behind']:,} commits before the last commit" for r in stale)
|
|
1085
|
+
return [_f("info", "Agent instructions behind the code", f"{listed}.",
|
|
1086
|
+
f"Read {stale[0]['file']} against the tree and update what moved; an agent follows it literally.",
|
|
1087
|
+
rule={"id": "agent_instructions_drift", "min_months": min_months, "min_commits": min_commits},
|
|
1088
|
+
evidence={"files": stale})]
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def signoff_by_co_author(report: dict, min_commits: int = 2) -> list:
|
|
1092
|
+
"""An identity that signs off commits it co-authors but never authors one: the Linux kernel's policy
|
|
1093
|
+
on coding assistants forbids an agent to add Signed-off-by, since the DCO is a human's statement."""
|
|
1094
|
+
rows = [r for r in ((report.get("provenance") or {}).get("trailers") or {}).get("signoff_by_co_author") or [] if r["commits"] >= min_commits]
|
|
1095
|
+
if not rows:
|
|
1096
|
+
return []
|
|
1097
|
+
listed = "; ".join(f"{r['name']} <{r['email']}> signs off {_plural(r['commits'], 'commit')} but never authors one" for r in rows[:3])
|
|
1098
|
+
return [_f("info", "Sign-offs by identities that only co-author", f"{listed}.",
|
|
1099
|
+
"A Signed-off-by line certifies the Developer Certificate of Origin; have a person who authors commits add it.",
|
|
1100
|
+
rule={"id": "signoff_by_co_author", "min_commits": min_commits, "ref": "Linux kernel, Documentation/process/coding-assistants.rst"},
|
|
1101
|
+
evidence={"identities": rows[:10]})]
|
|
1102
|
+
|
|
1103
|
+
|
|
1025
1104
|
RULES = [dormant, secrets_found, credential_files, vulnerable_dependencies, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets,
|
|
1026
1105
|
minor_contributors, reverts, brain_methods, complexity_growth, tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss,
|
|
1027
|
-
sweeping_commits, tangled_commits, hygiene_findings, debt_in_hotspots, deep_nesting, hidden_coupling, unreferenced_files
|
|
1106
|
+
sweeping_commits, tangled_commits, hygiene_findings, debt_in_hotspots, deep_nesting, hidden_coupling, unreferenced_files,
|
|
1107
|
+
agent_approval_disabled, agent_local_settings, mcp_literal_env, agent_instructions_drift, signoff_by_co_author]
|
|
1028
1108
|
|
|
1029
1109
|
|
|
1030
1110
|
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
|
|
@@ -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
|
|
|
@@ -182,6 +182,7 @@ def parse_functions(text: str) -> list:
|
|
|
182
182
|
anonymous = name in ("", "(anonymous)")
|
|
183
183
|
rows.append({"file": _rel(r[6]), "function": textfmt.cut(label if anonymous and label else name, NAME_CAP) or "(anonymous)", "anonymous": anonymous,
|
|
184
184
|
"ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]), "start": _num(r[9]), "end": _num(r[10]), "suspect": suspect})
|
|
185
|
+
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
186
|
return rows
|
|
186
187
|
|
|
187
188
|
|
|
@@ -197,7 +198,10 @@ def parse_duplicates_json(data) -> dict | None:
|
|
|
197
198
|
blocks = [{"lines": _num(b.get("lines")), "places": sorted(tuple(p[:3]) for p in b.get("places") or [] if len(p) >= 3)}
|
|
198
199
|
for b in data.get("blocks") or []]
|
|
199
200
|
rate = data.get("rate")
|
|
200
|
-
|
|
201
|
+
out = {"rate": float(rate) if rate is not None else None, "blocks": blocks, "files": _num(data.get("files"))}
|
|
202
|
+
if isinstance(data.get("then"), dict):
|
|
203
|
+
out["then"] = data["then"] # the rate at the last commit a year before: the direction
|
|
204
|
+
return out
|
|
201
205
|
|
|
202
206
|
|
|
203
207
|
def parse_duplicates(text: str) -> dict:
|
|
@@ -236,6 +240,8 @@ def parse_secrets(text: str) -> list:
|
|
|
236
240
|
value, placeholder = None, False
|
|
237
241
|
out.append({"rule": r.get("RuleID", ""), "file": r.get("File", ""), "commit": r.get("Commit", "")[:7], "line": r.get("StartLine"),
|
|
238
242
|
"fingerprint": r.get("Fingerprint", ""), "value": value, "placeholder": placeholder})
|
|
243
|
+
# betterleaks scans in parallel and does not promise an order; everything downstream reads the rows in this one
|
|
244
|
+
out.sort(key=lambda r: (r["file"], r["commit"], r["line"] or 0, r["rule"], r["fingerprint"]))
|
|
239
245
|
return out
|
|
240
246
|
|
|
241
247
|
|
|
@@ -248,6 +254,8 @@ def parse_dependencies(data) -> dict:
|
|
|
248
254
|
if data["status"] == "scanned":
|
|
249
255
|
out.update({"sources": data.get("sources") or [], "packages": _num(data.get("packages")),
|
|
250
256
|
"vulnerable": data.get("vulnerable") or [], "database_date": data.get("database_date")})
|
|
257
|
+
if data.get("database_digest"):
|
|
258
|
+
out["database_digest"] = data["database_digest"]
|
|
251
259
|
elif data["status"] == "no-database":
|
|
252
260
|
out["download"] = data.get("download") or ""
|
|
253
261
|
return out
|
|
@@ -331,6 +339,7 @@ def load_report(out_dir: str, nested: bool = True) -> dict:
|
|
|
331
339
|
"signing": _read_json(out_dir, "signing.json", {}) or {}, # commit signing coverage; {} before the step or after a killed one
|
|
332
340
|
"hygiene": _read_json(out_dir, "hygiene.json", {}) or {}, # the hygiene checks (hygiene.py); {} before 0.15
|
|
333
341
|
"unreachable": _read_json(out_dir, "unreachable.json", {}) or {},
|
|
334
|
-
"structure": _read_json(out_dir, "structure.json", {}) or {},
|
|
342
|
+
"structure": _read_json(out_dir, "structure.json", {}) or {},
|
|
343
|
+
"provenance": _read_json(out_dir, "provenance.json", {}) or {}, # trailers, cohorts, commit shape, agent files; {} before 0.17 # tree-sitter metrics (structure.py); {} without gitmole[structure] # what the secrets step found outside reachable history
|
|
335
344
|
"backtest": _nested(out_dir) if nested else None,
|
|
336
345
|
}
|
|
@@ -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
|