gitmole 0.7.1__tar.gz → 0.7.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitmole-0.7.1 → gitmole-0.7.2}/PKG-INFO +2 -4
- {gitmole-0.7.1 → gitmole-0.7.2}/README.md +1 -3
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/__init__.py +1 -1
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/findings.py +12 -5
- gitmole-0.7.2/gitmole/functions.py +182 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/load.py +8 -3
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/render.py +15 -3
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/watch.py +5 -2
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/PKG-INFO +2 -4
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_findings.py +16 -0
- gitmole-0.7.2/tests/test_functions.py +187 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_load.py +14 -2
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_render.py +19 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_watch.py +10 -0
- gitmole-0.7.1/gitmole/functions.py +0 -90
- gitmole-0.7.1/tests/test_functions.py +0 -109
- {gitmole-0.7.1 → gitmole-0.7.2}/LICENSE +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/__main__.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/backtest.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/banner.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/blame.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/clean.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/cli.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/coupling.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/deps.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/duplicates.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/filetypes.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/hotspots.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/identity.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/knowledge.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/leaks.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/loss.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/maat.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/run.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/textfmt.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/trend.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/SOURCES.txt +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/requires.txt +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/pyproject.toml +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/setup.cfg +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_backtest.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_banner.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_blame.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_clean.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_cli.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_coupling.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_deps.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_duplicates.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_filetypes.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_golden.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_hotspots.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_identity.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_knowledge.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_leaks.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_loss.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_maat.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_packaging.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_render_examples.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_run.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_textfmt.py +0 -0
- {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_trend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -26,8 +26,6 @@ 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
|
-
<img src="https://raw.githubusercontent.com/antvinni/gitmole/main/docs/report.svg" width="912" alt="gitmole on react: the summary, and the watch list of files where the next bug is likely, with a backtest">
|
|
30
|
-
|
|
31
29
|
Free. Any Stack. Local. Offline. Deterministic. Fast.
|
|
32
30
|
|
|
33
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.
|
|
@@ -74,7 +72,7 @@ blocks on secrets in source files and still posts the report. Every option:
|
|
|
74
72
|
## What you get
|
|
75
73
|
|
|
76
74
|
The opening of the report for [react](https://github.com/facebook/react), 35,263 commits
|
|
77
|
-
since 2013, at a pinned commit
|
|
75
|
+
since 2013, at a pinned commit:
|
|
78
76
|
|
|
79
77
|
```text
|
|
80
78
|
╭─ react ──────────────────────────────────────────────────────────────────────────────────────────╮
|
|
@@ -6,8 +6,6 @@ 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
|
-
<img src="https://raw.githubusercontent.com/antvinni/gitmole/main/docs/report.svg" width="912" alt="gitmole on react: the summary, and the watch list of files where the next bug is likely, with a backtest">
|
|
10
|
-
|
|
11
9
|
Free. Any Stack. Local. Offline. Deterministic. Fast.
|
|
12
10
|
|
|
13
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.
|
|
@@ -54,7 +52,7 @@ blocks on secrets in source files and still posts the report. Every option:
|
|
|
54
52
|
## What you get
|
|
55
53
|
|
|
56
54
|
The opening of the report for [react](https://github.com/facebook/react), 35,263 commits
|
|
57
|
-
since 2013, at a pinned commit
|
|
55
|
+
since 2013, at a pinned commit:
|
|
58
56
|
|
|
59
57
|
```text
|
|
60
58
|
╭─ react ──────────────────────────────────────────────────────────────────────────────────────────╮
|
|
@@ -442,10 +442,11 @@ def _partial_functions(report: dict) -> str:
|
|
|
442
442
|
|
|
443
443
|
def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list:
|
|
444
444
|
"""Functions that are both long and complex, in this repository's own source files: test files,
|
|
445
|
-
example code, vendored code and generated files (amalgamations included) are left out
|
|
446
|
-
|
|
445
|
+
example code, vendored code and generated files (amalgamations included) are left out, and so is
|
|
446
|
+
a span the function step marked suspect, since a mis-parse that swallowed the next function is
|
|
447
|
+
long and complex by construction. A warning when one sits in a hotspot."""
|
|
447
448
|
generated, vendored = _generated(report), filetypes.vendor_dirs(report)
|
|
448
|
-
big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines
|
|
449
|
+
big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines and not f.get("suspect")
|
|
449
450
|
and not (filetypes.is_test_path(f["file"]) or filetypes.is_sample_path(f["file"]) or filetypes.is_vendored(f["file"], vendored)
|
|
450
451
|
or f["file"] in generated)]
|
|
451
452
|
if not big:
|
|
@@ -456,7 +457,7 @@ def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list
|
|
|
456
457
|
listed = "; ".join(f"{f['function']} ({_place(f)}) complexity {f['ccn']}, {f['nloc']} lines, {f['params']} params" for f in big[:5])
|
|
457
458
|
more = f" and {len(big) - 5} more" if len(big) > 5 else ""
|
|
458
459
|
first = big[0]
|
|
459
|
-
which = f"the anonymous function at {_place(first)}" if first
|
|
460
|
+
which = f"the anonymous function at {_place(first)}" if _anonymous(first) else f"{first['function']} in {first['file']}"
|
|
460
461
|
return [_f(sev, "Brain methods",
|
|
461
462
|
f"{len(big)} function(s) are both long and complex: {listed}{more}.{_partial_functions(report)}",
|
|
462
463
|
f"Split {which} first, before the next change lands there.")]
|
|
@@ -465,9 +466,15 @@ def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list
|
|
|
465
466
|
ANONYMOUS = "(anonymous)"
|
|
466
467
|
|
|
467
468
|
|
|
469
|
+
def _anonymous(f: dict) -> bool:
|
|
470
|
+
"""A function lizard could not name: it goes by its start line's text (or "(anonymous)" in an
|
|
471
|
+
older functions.csv), which is not a name to search for."""
|
|
472
|
+
return f.get("anonymous", f["function"] == ANONYMOUS)
|
|
473
|
+
|
|
474
|
+
|
|
468
475
|
def _place(f: dict) -> str:
|
|
469
476
|
"""Where a function is: its file, or file:line when it has no name to find it by."""
|
|
470
|
-
return f"{f['file']}:{f['start']}" if f
|
|
477
|
+
return f"{f['file']}:{f['start']}" if _anonymous(f) else f["file"]
|
|
471
478
|
|
|
472
479
|
|
|
473
480
|
def _generated(report: dict) -> set:
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Function-level metrics from lizard, over the tracked code files only.
|
|
3
|
+
|
|
4
|
+
Runs as its own process (a pipeline step) and drives lizard through its Python API rather
|
|
5
|
+
than its command line: the file list never touches a shell or a list file, the analysed
|
|
6
|
+
repository is never on sys.path, only files lizard has a reader for are measured, and the
|
|
7
|
+
CSV is streamed so a killed step still leaves what was measured. Duplicated blocks are
|
|
8
|
+
jscpd's job (duplicates.py); lizard's own finder kept a hash node per token and ran to gigabytes."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import csv
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import lizard
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from . import blame, filetypes
|
|
20
|
+
except ImportError: # run as a script: the package directory is sys.path[0]
|
|
21
|
+
import blame
|
|
22
|
+
import filetypes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def select_files(repo: str, ignore=(), types_spec: str = None) -> list:
|
|
26
|
+
"""Tracked text files lizard can parse. Without --file-types that is every language lizard
|
|
27
|
+
knows (a superset of gitmole's default code list, e.g. Fortran); with it, the intersection."""
|
|
28
|
+
types = filetypes.parse(types_spec)
|
|
29
|
+
files = blame.text_files(repo, ignore) if types_spec is None else blame.code_files(repo, ignore, types)
|
|
30
|
+
return [f for f in files if lizard.get_reader_for(f) is not None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
NAME_CAP = 200 # a deeply nested fixture gives lizard a dotted name of megabytes; nobody reads past this
|
|
34
|
+
LONG_NAME_CAP = 500
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cut(text: str, cap: int) -> str:
|
|
38
|
+
return text if len(text) <= cap else text[:cap - 1] + "…"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ANONYMOUS = "(anonymous)" # lizard's name for a JavaScript function expression; a Go literal gets ""
|
|
42
|
+
SPARSE_LINES = 40 # a span this long...
|
|
43
|
+
SPARSE_SHARE = 0.25 # ...with under this share of code lines is more likely a mis-parse than a function
|
|
44
|
+
|
|
45
|
+
OPENS_FUNCTION = ("=>", "->", "func", "lambda") # what a line that opens a nameless function holds: "func" covers "function"
|
|
46
|
+
NEARBY = (0, -1, 1, 2, 3, 4, 5)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def nameless(fn) -> bool:
|
|
50
|
+
return fn.name in ("", ANONYMOUS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _opener(lines: list, fn) -> int | None:
|
|
54
|
+
"""The line near a nameless function's start that opens a function, or None. lizard puts an arrow
|
|
55
|
+
whose body starts on the next line at the body's line, and a callback in a JSX attribute at the
|
|
56
|
+
tag's line, so the start line is tried first, then the line before, then a few lines on."""
|
|
57
|
+
for offset in NEARBY:
|
|
58
|
+
n = fn.start_line + offset
|
|
59
|
+
if 1 <= n <= len(lines) and any(m in lines[n - 1] for m in OPENS_FUNCTION):
|
|
60
|
+
return n
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def label(lines: list, fn) -> str:
|
|
65
|
+
"""What to call a function lizard could not name: the line it starts on, whitespace collapsed, in
|
|
66
|
+
any language. `app.post("/api/x", async (req, res) => {` finds the callback; `(anonymous)` does not.
|
|
67
|
+
When a nearby line opens a function and the start line does not, that line is the label."""
|
|
68
|
+
if not nameless(fn) or not 1 <= fn.start_line <= len(lines):
|
|
69
|
+
return ""
|
|
70
|
+
n = _opener(lines, fn) or fn.start_line
|
|
71
|
+
return _cut(" ".join(lines[n - 1].split()), NAME_CAP)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def suspect(lines: list, fn) -> str:
|
|
75
|
+
"""Why a span looks like a mis-parse, or "". lizard fails by losing its place (a template literal,
|
|
76
|
+
JSX) and swallowing what follows into one function, so a swallowed span is long with little code
|
|
77
|
+
in it, or holds a line that opens a block at the indentation of the function's own start: a
|
|
78
|
+
sibling that should have ended it. A line that starts by closing a bracket (`}: Props) {`)
|
|
79
|
+
continues the function's own signature, and a bare `{` is the function's own body brace in
|
|
80
|
+
the C styles that put it on its own line; neither counts. It also reads a JSX ternary as a
|
|
81
|
+
nameless function: all code, all deeper than its start, and nothing near the start line opens a
|
|
82
|
+
function."""
|
|
83
|
+
if fn.length >= SPARSE_LINES and fn.nloc < SPARSE_SHARE * fn.length:
|
|
84
|
+
return f"{fn.nloc} of {fn.length} lines are code"
|
|
85
|
+
if nameless(fn) and 1 <= fn.start_line <= len(lines) and _opener(lines, fn) is None:
|
|
86
|
+
return f"nothing opens a function within {max(NEARBY)} lines of line {fn.start_line}"
|
|
87
|
+
span = lines[max(fn.start_line, 1) - 1:fn.end_line]
|
|
88
|
+
if len(span) < 3:
|
|
89
|
+
return ""
|
|
90
|
+
depth = _indent(span[0])
|
|
91
|
+
for number, line in enumerate(span[1:-1], start=fn.start_line + 1):
|
|
92
|
+
text = line.rstrip()
|
|
93
|
+
if text.endswith("{") and _indent(text) <= depth and text.lstrip()[0] not in "{)]}":
|
|
94
|
+
return f"opens a block at line {number} no deeper than its own start"
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _indent(line: str) -> int:
|
|
99
|
+
return len(line) - len(line.lstrip())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def csv_row(info, fn, lines: list = ()) -> list:
|
|
103
|
+
"""The columns `lizard --csv` prints, then gitmole's own two: a label for nameless functions and
|
|
104
|
+
why the span looks mis-parsed. Names are cut to what a table can show, so one pathological
|
|
105
|
+
fixture cannot make the file unreadable."""
|
|
106
|
+
name = _cut(fn.name, NAME_CAP)
|
|
107
|
+
return [fn.nloc, fn.cyclomatic_complexity, fn.token_count, fn.parameter_count, fn.length,
|
|
108
|
+
f"{name}@{fn.start_line}-{fn.end_line}@{info.filename}", info.filename, name, _cut(fn.long_name, LONG_NAME_CAP), fn.start_line, fn.end_line,
|
|
109
|
+
label(lines, fn), suspect(lines, fn)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _lines(path: str) -> list:
|
|
113
|
+
try:
|
|
114
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
115
|
+
return fh.read().splitlines()
|
|
116
|
+
except OSError:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def keep_newlines(tokens, reader):
|
|
121
|
+
"""lizard's own preprocessing, after splitting whitespace tokens that hold newlines into bare ones.
|
|
122
|
+
lizard 1.24 drops any whitespace token but "\\n" there, and its JSX tokenizer hands it the newline
|
|
123
|
+
before a child element joined with the indentation after it: one line lost per child, so every
|
|
124
|
+
function after a JSX block in a .tsx file reports lines before its own."""
|
|
125
|
+
def split(tokens):
|
|
126
|
+
for t in tokens:
|
|
127
|
+
if t != "\n" and t.isspace() and "\n" in t:
|
|
128
|
+
for _ in range(t.count("\n")):
|
|
129
|
+
yield "\n"
|
|
130
|
+
else:
|
|
131
|
+
yield t
|
|
132
|
+
return lizard.preprocessing(split(tokens), reader)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extensions() -> list:
|
|
136
|
+
"""lizard's metric extensions, with keep_newlines in place of its preprocessing."""
|
|
137
|
+
return [keep_newlines if e is lizard.preprocessing else e for e in lizard.get_extensions([])]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def analyze(files: list, procs: int, exts: list):
|
|
141
|
+
"""lizard.analyze_files without its extension bookkeeping: per-file analysis over `procs` workers."""
|
|
142
|
+
return lizard.map_files_to_analyzer(files, lizard.FileAnalyzer(exts), procs)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def measure(repo: str, files: list, out: str, procs: int) -> int:
|
|
146
|
+
"""Stream functions.csv while lizard runs. Returns 0, or 1 when lizard gave up on a file (whatever
|
|
147
|
+
was measured by then stays on disk)."""
|
|
148
|
+
exts = extensions()
|
|
149
|
+
rc = 0
|
|
150
|
+
cwd = os.getcwd()
|
|
151
|
+
os.chdir(repo) # lizard opens the paths as given; relative ones keep the CSV repo-relative
|
|
152
|
+
try:
|
|
153
|
+
with open(os.path.join(out, "functions.csv"), "w", encoding="utf-8", newline="") as fh:
|
|
154
|
+
writer = csv.writer(fh, quoting=csv.QUOTE_NONNUMERIC)
|
|
155
|
+
try:
|
|
156
|
+
for info in analyze(files, procs, exts):
|
|
157
|
+
lines = _lines(info.filename) if info.function_list else []
|
|
158
|
+
for fn in info.function_list:
|
|
159
|
+
writer.writerow(csv_row(info, fn, lines))
|
|
160
|
+
fh.flush()
|
|
161
|
+
except Exception as e: # lizard re-raises its parse failures; keep what we have
|
|
162
|
+
print(f"lizard stopped: {e!r}", file=sys.stderr)
|
|
163
|
+
rc = 1
|
|
164
|
+
finally:
|
|
165
|
+
os.chdir(cwd)
|
|
166
|
+
return rc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv=None) -> int:
|
|
170
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
171
|
+
p.add_argument("repo")
|
|
172
|
+
p.add_argument("out")
|
|
173
|
+
p.add_argument("--procs", type=int, default=1)
|
|
174
|
+
p.add_argument("--ignore", action="append", default=[])
|
|
175
|
+
p.add_argument("--types", default=None, help="file types spec as for gitmole --file-types")
|
|
176
|
+
args = p.parse_args(argv)
|
|
177
|
+
files = select_files(args.repo, args.ignore, args.types)
|
|
178
|
+
return measure(os.path.abspath(args.repo), files, os.path.abspath(args.out), max(1, args.procs))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main())
|
|
@@ -155,13 +155,18 @@ def _cut(name: str, cap: int = NAME_CAP) -> str:
|
|
|
155
155
|
|
|
156
156
|
|
|
157
157
|
def parse_functions(text: str) -> list:
|
|
158
|
-
"""lizard --csv rows: nloc, ccn, tokens, params, length, location, file, function, long name, start, end
|
|
158
|
+
"""lizard --csv rows: nloc, ccn, tokens, params, length, location, file, function, long name, start, end;
|
|
159
|
+
then, from gitmole's own step, a label for a nameless function (its start line) and why the span
|
|
160
|
+
looks mis-parsed. A nameless function goes by its label, or "(anonymous)" in an older file, and
|
|
161
|
+
stays marked anonymous so the report can say where it is."""
|
|
159
162
|
rows = []
|
|
160
163
|
for r in csv.reader(io.StringIO(text)):
|
|
161
164
|
if len(r) < 11:
|
|
162
165
|
continue
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
name, label, suspect = r[7], r[11] if len(r) > 11 else "", r[12] if len(r) > 12 else ""
|
|
167
|
+
anonymous = name in ("", "(anonymous)")
|
|
168
|
+
rows.append({"file": _rel(r[6]), "function": _cut(label if anonymous and label else name) or "(anonymous)", "anonymous": anonymous,
|
|
169
|
+
"ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]), "start": _num(r[9]), "end": _num(r[10]), "suspect": suspect})
|
|
165
170
|
return rows
|
|
166
171
|
|
|
167
172
|
|
|
@@ -515,7 +515,10 @@ def functions_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
515
515
|
funcs, generated_note = _hide_generated(funcs, lambda f: f["file"], report, full, noun="function in a generated file", plural="functions in generated files")
|
|
516
516
|
hidden_note = _join_hidden(hidden_note, vendor_note, sample_note, generated_note)
|
|
517
517
|
limit = _limit("Complex functions", full)
|
|
518
|
-
|
|
518
|
+
shown = funcs[:limit]
|
|
519
|
+
rows = [(f["function"], _where(f), f"{f['ccn']}{SUSPECT_MARK}" if f.get("suspect") else f["ccn"], f["nloc"], f["params"]) for f in shown]
|
|
520
|
+
suspects = sum(1 for f in shown if f.get("suspect"))
|
|
521
|
+
suspect_note = f"{SUSPECT_MARK} marks {suspects} span{'s' if suspects != 1 else ''} lizard may have mis-parsed" if suspects else None
|
|
519
522
|
columns = [("function", {"overflow": "fold"}), ("file", PATH), ("ccn", RIGHT), ("lines", RIGHT), ("params", RIGHT)]
|
|
520
523
|
if full is not True:
|
|
521
524
|
rows = _shorten(rows, width, columns, path_columns=(1,))
|
|
@@ -533,10 +536,19 @@ def functions_section(report: dict, full: bool = True, width=None) -> dict:
|
|
|
533
536
|
f"nothing over complexity {CCN_FLOOR} in source files {counted}")
|
|
534
537
|
else:
|
|
535
538
|
note = None
|
|
536
|
-
caption = "; ".join(c for c in (_more(len(funcs), limit), None if note else hidden_note, partial) if c) or None
|
|
539
|
+
caption = "; ".join(c for c in (_more(len(funcs), limit), None if note else hidden_note, partial, suspect_note) if c) or None
|
|
537
540
|
return _section("Complex functions", columns, rows, note=note, caption=caption)
|
|
538
541
|
|
|
539
542
|
|
|
543
|
+
SUSPECT_MARK = "?"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _where(f: dict) -> str:
|
|
547
|
+
"""A named function is found by its name in its file; a nameless one goes by its start line's text,
|
|
548
|
+
so the row says which line."""
|
|
549
|
+
return f"{f['file']}:{f['start']}" if f.get("anonymous") else f["file"]
|
|
550
|
+
|
|
551
|
+
|
|
540
552
|
def knowledge_section(report: dict, full: bool = True, width=None) -> dict:
|
|
541
553
|
"""Ownership by area of the tree: who wrote most of each directory, gone owners marked."""
|
|
542
554
|
months = report["meta"].get("gone_months", loss.DEFAULT_MONTHS)
|
|
@@ -811,7 +823,7 @@ def report(report: dict, findings: list, console: Console, full: bool = False, r
|
|
|
811
823
|
|
|
812
824
|
def excerpt(report: dict, findings: list, console: Console, full: bool = False) -> None:
|
|
813
825
|
"""The report's opening on its own: the header, whose tally counts the findings, and the watch
|
|
814
|
-
list. What the README's
|
|
826
|
+
list. What the README's text block shows; the findings themselves are in the full report."""
|
|
815
827
|
console.print(header(report, findings))
|
|
816
828
|
print_section(console, watch_section(report, full=full, width=console.width))
|
|
817
829
|
|
|
@@ -50,9 +50,11 @@ def _companions(report: dict) -> dict:
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
def _worst_function(report: dict) -> dict:
|
|
53
|
+
"""The most complex function per file, passing over spans the function step marked as likely
|
|
54
|
+
mis-parsed: a swallowed span's complexity is not the file's."""
|
|
53
55
|
worst = {}
|
|
54
56
|
for f in report.get("functions") or []:
|
|
55
|
-
if f["file"] not in worst or f["ccn"] > worst[f["file"]]["ccn"]:
|
|
57
|
+
if not f.get("suspect") and (f["file"] not in worst or f["ccn"] > worst[f["file"]]["ccn"]):
|
|
56
58
|
worst[f["file"]] = f
|
|
57
59
|
return worst
|
|
58
60
|
|
|
@@ -120,7 +122,8 @@ def _reasons(r: dict) -> list:
|
|
|
120
122
|
out.append(f"{r['owner']} wrote {round(100 * r['owner_share'])}% of it")
|
|
121
123
|
fn = r["function"]
|
|
122
124
|
if fn and fn["ccn"] >= CCN_FLOOR:
|
|
123
|
-
|
|
125
|
+
named = f"the function at line {fn['start']}" if fn.get("anonymous") else f"{fn['function']}()"
|
|
126
|
+
out.append(f"{named} complexity {fn['ccn']}")
|
|
124
127
|
if r["companions"]:
|
|
125
128
|
other, degree = r["companions"][0]
|
|
126
129
|
more = len(r["companions"]) - 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -26,8 +26,6 @@ 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
|
-
<img src="https://raw.githubusercontent.com/antvinni/gitmole/main/docs/report.svg" width="912" alt="gitmole on react: the summary, and the watch list of files where the next bug is likely, with a backtest">
|
|
30
|
-
|
|
31
29
|
Free. Any Stack. Local. Offline. Deterministic. Fast.
|
|
32
30
|
|
|
33
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.
|
|
@@ -74,7 +72,7 @@ blocks on secrets in source files and still posts the report. Every option:
|
|
|
74
72
|
## What you get
|
|
75
73
|
|
|
76
74
|
The opening of the report for [react](https://github.com/facebook/react), 35,263 commits
|
|
77
|
-
since 2013, at a pinned commit
|
|
75
|
+
since 2013, at a pinned commit:
|
|
78
76
|
|
|
79
77
|
```text
|
|
80
78
|
╭─ react ──────────────────────────────────────────────────────────────────────────────────────────╮
|
|
@@ -474,6 +474,22 @@ class BrainMethods(unittest.TestCase):
|
|
|
474
474
|
self.assertIn("(anonymous) (completions.go:316) complexity 47, 136 lines, 1 params", f[0]["detail"])
|
|
475
475
|
self.assertEqual(f[0]["advice"], "Split the anonymous function at completions.go:316 first, before the next change lands there.")
|
|
476
476
|
|
|
477
|
+
def test_a_labelled_nameless_function_is_listed_by_its_label_and_placed_by_its_line(self):
|
|
478
|
+
fns = [{"file": "server/routes.ts", "function": 'app.post("/api/x", async (req, res) => {', "anonymous": True,
|
|
479
|
+
"ccn": 47, "nloc": 136, "params": 1, "start": 316, "end": 585, "suspect": ""}]
|
|
480
|
+
f = findings.brain_methods(report(functions=fns))
|
|
481
|
+
self.assertIn('app.post("/api/x", async (req, res) => { (server/routes.ts:316) complexity 47, 136 lines, 1 params', f[0]["detail"])
|
|
482
|
+
self.assertEqual(f[0]["advice"], "Split the anonymous function at server/routes.ts:316 first, before the next change lands there.")
|
|
483
|
+
|
|
484
|
+
def test_a_suspect_span_is_not_a_brain_method(self):
|
|
485
|
+
fns = [{"file": "core/parser.py", "function": "parse", "ccn": 41, "nloc": 220, "params": 9, "start": 10, "end": 300,
|
|
486
|
+
"suspect": "opens a block at line 120 no deeper than its own start"},
|
|
487
|
+
{"file": "core/util.py", "function": "tidy", "ccn": 16, "nloc": 120, "params": 2, "start": 1, "end": 130, "suspect": ""}]
|
|
488
|
+
f = findings.brain_methods(report(functions=fns))
|
|
489
|
+
self.assertNotIn("parse", f[0]["detail"], "a span lizard may have mis-parsed is not advice")
|
|
490
|
+
self.assertEqual(f[0]["advice"], "Split tidy in core/util.py first, before the next change lands there.")
|
|
491
|
+
self.assertEqual(findings.brain_methods(report(functions=fns[:1])), [])
|
|
492
|
+
|
|
477
493
|
def test_generated_files_are_not_brain_methods(self):
|
|
478
494
|
fns = [{"file": "lib/config-validator.js", "function": "validate10", "ccn": 373, "nloc": 1150, "params": 5, "start": 1, "end": 1150},
|
|
479
495
|
{"file": "lib/reply.js", "function": "onSendEnd", "ccn": 34, "nloc": 180, "params": 2, "start": 1, "end": 180}]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from gitmole import functions
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_repo(d, extra=()):
|
|
10
|
+
def git(*args):
|
|
11
|
+
e = dict(os.environ, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null",
|
|
12
|
+
GIT_AUTHOR_NAME="Ann", GIT_AUTHOR_EMAIL="a@x", GIT_COMMITTER_NAME="Ann", GIT_COMMITTER_EMAIL="a@x")
|
|
13
|
+
subprocess.run(["git", *args], cwd=d, check=True, capture_output=True, env=e)
|
|
14
|
+
git("init", "-q")
|
|
15
|
+
os.makedirs(os.path.join(d, "vendor"))
|
|
16
|
+
files = {
|
|
17
|
+
"app.py": "def tracked(a, b):\n if a:\n return b\n return a\n",
|
|
18
|
+
"vendor/lib.py": "def vendored():\n return 1\n",
|
|
19
|
+
"notes.md": "def not_code():\n pass\n",
|
|
20
|
+
"page.js": "function scripted(a) {\n if (a) { return 1; }\n return 0;\n}\n",
|
|
21
|
+
"run.sh": "shelled() {\n if [ -n \"$1\" ]; then echo hi; fi\n}\n",
|
|
22
|
+
"sim.f90": "subroutine fortran_sub(x)\n integer :: x\n if (x > 0) then\n x = 1\n end if\nend subroutine\n",
|
|
23
|
+
}
|
|
24
|
+
files.update(extra)
|
|
25
|
+
for name, text in files.items():
|
|
26
|
+
with open(os.path.join(d, name), "w") as fh:
|
|
27
|
+
fh.write(text)
|
|
28
|
+
git("add", "-A")
|
|
29
|
+
git("commit", "-q", "-m", "one")
|
|
30
|
+
with open(os.path.join(d, "untracked.py"), "w") as fh:
|
|
31
|
+
fh.write("def untracked():\n return 2\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(d, *args):
|
|
35
|
+
out = os.path.join(d, "out")
|
|
36
|
+
os.makedirs(out, exist_ok=True)
|
|
37
|
+
rc = functions.main([d, out, "--procs", "1", *args])
|
|
38
|
+
with open(os.path.join(out, "functions.csv")) as fh:
|
|
39
|
+
csv = fh.read()
|
|
40
|
+
return rc, csv, sorted(os.listdir(out))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FunctionsScript(unittest.TestCase):
|
|
44
|
+
def test_measures_tracked_files_lizard_can_read(self):
|
|
45
|
+
with tempfile.TemporaryDirectory() as d:
|
|
46
|
+
make_repo(d)
|
|
47
|
+
rc, csv, written = run(d, "--ignore", "vendor/**")
|
|
48
|
+
self.assertEqual(rc, 0)
|
|
49
|
+
self.assertIn('"tracked"', csv)
|
|
50
|
+
self.assertIn('"fortran_sub"', csv, "every language lizard knows, not only gitmole's default code list")
|
|
51
|
+
self.assertNotIn("untracked", csv, "not tracked by git")
|
|
52
|
+
self.assertNotIn("vendored", csv, "--ignore applies")
|
|
53
|
+
self.assertNotIn("not_code", csv, "markdown is not code")
|
|
54
|
+
self.assertNotIn("shelled", csv, "no lizard reader for shell: no guessing with the C-like fallback")
|
|
55
|
+
self.assertEqual(written, ["functions.csv"], "duplicates are jscpd's step, not lizard's")
|
|
56
|
+
|
|
57
|
+
def test_a_huge_nested_name_is_cut_before_it_is_written(self):
|
|
58
|
+
from types import SimpleNamespace
|
|
59
|
+
name = ".".join("a" for _ in range(100_000))
|
|
60
|
+
fn = SimpleNamespace(nloc=5, cyclomatic_complexity=3, token_count=40, parameter_count=1, length=5, name=name,
|
|
61
|
+
long_name=name + "( )", start_line=1, end_line=5)
|
|
62
|
+
row = functions.csv_row(SimpleNamespace(filename="a.py"), fn)
|
|
63
|
+
self.assertEqual(len(row[7]), functions.NAME_CAP)
|
|
64
|
+
self.assertTrue(row[7].endswith("…"))
|
|
65
|
+
self.assertLessEqual(len(row[8]), functions.LONG_NAME_CAP)
|
|
66
|
+
self.assertLessEqual(len(row[5]), functions.NAME_CAP + len("@1-5@a.py"))
|
|
67
|
+
|
|
68
|
+
def test_csv_is_lizards_own_layout_then_the_label_and_suspect_columns(self):
|
|
69
|
+
with tempfile.TemporaryDirectory() as d:
|
|
70
|
+
make_repo(d)
|
|
71
|
+
rc, csv, _ = run(d, "--types", "py", "--ignore", "vendor/**")
|
|
72
|
+
self.assertEqual(csv, '4,2,14,2,4,"tracked@1-4@app.py","app.py","tracked","tracked( a , b )",1,4,"",""\n')
|
|
73
|
+
|
|
74
|
+
def test_a_nameless_function_is_labelled_by_its_start_line(self):
|
|
75
|
+
route = 'app.post("/api/actions/:id/assign", async (req, res) => {\n if (!req.params.id) { return res.status(400).send(); }\n res.send(req.params.id);\n});\n'
|
|
76
|
+
literal = "package main\n\nfunc main() {\n\tf := func(x int) int {\n\t\tif x > 0 {\n\t\t\treturn 1\n\t\t}\n\t\treturn 0\n\t}\n\t_ = f\n}\n"
|
|
77
|
+
with tempfile.TemporaryDirectory() as d:
|
|
78
|
+
make_repo(d, extra={"routes.js": route, "lit.go": literal})
|
|
79
|
+
rc, csv, _ = run(d, "--types", "js,go")
|
|
80
|
+
self.assertIn('"(anonymous)","(anonymous)",1,4,"app.post(""/api/actions/:id/assign"", async (req, res) => {",""', csv)
|
|
81
|
+
self.assertIn('""," x int",4,9,"f := func(x int) int {",""', csv, "a Go literal has an empty name and the same kind of label")
|
|
82
|
+
self.assertIn('"main","main",3,11,"",""', csv, "a named function needs no label")
|
|
83
|
+
|
|
84
|
+
def test_the_label_is_the_nearest_line_that_opens_a_function_when_lizard_is_a_line_off(self):
|
|
85
|
+
# lizard puts an arrow whose body starts on the next line at the body's line, and a callback in a
|
|
86
|
+
# JSX attribute at the tag's line: the label looks a line back and a few lines on for the `=>`
|
|
87
|
+
src = ("const xs = items.filter((m) =>\n prev.includes(m)\n);\n"
|
|
88
|
+
"function A() {\n return (\n <Input\n onChange={(e) => {\n set(e.target.value);\n }}\n />\n );\n}\n")
|
|
89
|
+
with tempfile.TemporaryDirectory() as d:
|
|
90
|
+
make_repo(d, extra={"near.tsx": src})
|
|
91
|
+
rc, csv, _ = run(d, "--types", "tsx")
|
|
92
|
+
self.assertIn('"(anonymous)","(anonymous)",2,2,"const xs = items.filter((m) =>",""', csv)
|
|
93
|
+
self.assertIn('"(anonymous)","(anonymous)",6,8,"onChange={(e) => {",""', csv)
|
|
94
|
+
|
|
95
|
+
def test_a_span_that_swallows_a_sibling_is_marked_suspect(self):
|
|
96
|
+
# lizard loses its place in a template literal and folds the next function into `tpl`
|
|
97
|
+
src = ("const tpl = (name) => `\n<html>\n <body>\n ${name ? `<h1>${name}</h1>` : \"\"}\n </body>\n</html>`;\n\n"
|
|
98
|
+
"function after(a) {\n if (a) { return 1; }\n return 0;\n}\n")
|
|
99
|
+
with tempfile.TemporaryDirectory() as d:
|
|
100
|
+
make_repo(d, extra={"tpl.js": src})
|
|
101
|
+
rc, csv, _ = run(d, "--types", "js")
|
|
102
|
+
self.assertNotIn('"after"', csv, "the fixture only holds if lizard still swallows the sibling")
|
|
103
|
+
self.assertIn('"tpl","tpl ( name )",1,9,"","opens a block at line 8 no deeper than its own start"', csv)
|
|
104
|
+
self.assertIn('"scripted","scripted ( a )",1,4,"",""', csv, "a closing line is not a sibling")
|
|
105
|
+
|
|
106
|
+
def test_a_signature_that_closes_its_parameter_list_on_a_later_line_is_not_a_sibling(self):
|
|
107
|
+
src = ("function Panel({\n title,\n onClose,\n}: PanelProps) {\n if (!title) { return null; }\n return onClose;\n}\n"
|
|
108
|
+
"class Store {\n async summary(filters?: {\n siteId?: string;\n }): Promise<any> {\n if (filters) { return 1; }\n return 0;\n }\n}\n")
|
|
109
|
+
with tempfile.TemporaryDirectory() as d:
|
|
110
|
+
make_repo(d, extra={"sig.ts": src})
|
|
111
|
+
rc, csv, _ = run(d, "--types", "ts")
|
|
112
|
+
self.assertIn('"Panel","Panel ( title , onClose , PanelProps )",1,7,"",""', csv)
|
|
113
|
+
self.assertIn('"summary","summary ( filters siteId )",9,14,"",""', csv)
|
|
114
|
+
|
|
115
|
+
def test_a_c_function_with_its_brace_on_its_own_line_and_a_goto_label_is_not_a_sibling(self):
|
|
116
|
+
# curl's style: the body brace alone at column 0, labels at column 0
|
|
117
|
+
src = "static int opt_bool(int a)\n{\n if(a) {\n return 1;\n }\nout:\n return 0;\n}\n"
|
|
118
|
+
with tempfile.TemporaryDirectory() as d:
|
|
119
|
+
make_repo(d, extra={"tool.c": src})
|
|
120
|
+
rc, csv, _ = run(d, "--types", "c")
|
|
121
|
+
self.assertIn('"opt_bool","opt_bool( int a)",1,8,"",""', csv)
|
|
122
|
+
|
|
123
|
+
def test_jsx_children_on_their_own_lines_do_not_shift_the_line_numbers(self):
|
|
124
|
+
# lizard 1.24 merges the newline before a JSX child with its indentation into one whitespace token,
|
|
125
|
+
# and its preprocessing drops whitespace tokens other than a bare newline: one line lost per child
|
|
126
|
+
src = ('function A() {\n return (\n <div className="grid">\n <div className="lg">\n </div>\n'
|
|
127
|
+
' <Skeleton className="h-48" />\n </div>\n );\n}\nfunction B() {\n return 1;\n}\n')
|
|
128
|
+
with tempfile.TemporaryDirectory() as d:
|
|
129
|
+
make_repo(d, extra={"page.tsx": src})
|
|
130
|
+
rc, csv, _ = run(d, "--types", "tsx")
|
|
131
|
+
self.assertIn('"B","B ( )",10,12,"",""', csv)
|
|
132
|
+
self.assertIn('"A","A ( )",1,9,"",""', csv)
|
|
133
|
+
|
|
134
|
+
def test_a_nameless_span_where_nothing_opens_a_function_is_marked_suspect(self):
|
|
135
|
+
# lizard reports a JSX ternary as a function: all code, all deeper than its start, so only the
|
|
136
|
+
# missing `=>` or `function` near its start line gives it away
|
|
137
|
+
src = "function A() {\n return (\n <div>\n {isLoading ? (\n <p>x</p>\n ) : (\n <p>y</p>\n )}\n </div>\n );\n}\n"
|
|
138
|
+
with tempfile.TemporaryDirectory() as d:
|
|
139
|
+
make_repo(d, extra={"ternary.tsx": src})
|
|
140
|
+
rc, csv, _ = run(d, "--types", "tsx")
|
|
141
|
+
self.assertIn('"(anonymous)","(anonymous) ( x )",4,8,"{isLoading ? (","nothing opens a function within 5 lines of line 4"', csv)
|
|
142
|
+
self.assertIn('"A","A ( )",1,11,"",""', csv)
|
|
143
|
+
|
|
144
|
+
def test_a_long_span_that_is_mostly_not_code_is_marked_suspect(self):
|
|
145
|
+
body = "".join(f" // note {i}\n" for i in range(40))
|
|
146
|
+
src = "function sparse(a) {\n" + body + " if (a) { return 1; }\n return 0;\n}\n"
|
|
147
|
+
with tempfile.TemporaryDirectory() as d:
|
|
148
|
+
make_repo(d, extra={"sparse.js": src})
|
|
149
|
+
rc, csv, _ = run(d, "--types", "js")
|
|
150
|
+
self.assertIn('"sparse","sparse ( a )",1,44,"","4 of 44 lines are code"', csv)
|
|
151
|
+
|
|
152
|
+
def test_file_types_restrict_what_is_measured(self):
|
|
153
|
+
with tempfile.TemporaryDirectory() as d:
|
|
154
|
+
make_repo(d)
|
|
155
|
+
rc, csv, _ = run(d, "--types", "js")
|
|
156
|
+
self.assertEqual(rc, 0)
|
|
157
|
+
self.assertIn("scripted", csv)
|
|
158
|
+
self.assertNotIn("tracked", csv)
|
|
159
|
+
self.assertNotIn("fortran", csv)
|
|
160
|
+
|
|
161
|
+
def test_no_code_files_still_writes_an_empty_csv(self):
|
|
162
|
+
with tempfile.TemporaryDirectory() as d:
|
|
163
|
+
make_repo(d)
|
|
164
|
+
rc, csv, _ = run(d, "--ignore", "*.py", "--ignore", "*.js", "--ignore", "*.f90")
|
|
165
|
+
self.assertEqual(rc, 0)
|
|
166
|
+
self.assertEqual(csv, "")
|
|
167
|
+
|
|
168
|
+
def test_a_lizard_module_in_the_analysed_repo_is_data_not_code_to_run(self):
|
|
169
|
+
bomb = "import sys\nsys.stderr.write('REPO LIZARD RAN')\nsys.exit(7)\n"
|
|
170
|
+
with tempfile.TemporaryDirectory() as d:
|
|
171
|
+
make_repo(d, extra={"lizard.py": bomb, "lizard_ext/__init__.py": bomb} if False else {"lizard.py": bomb})
|
|
172
|
+
rc, csv, _ = run(d, "--types", "py", "--ignore", "vendor/**")
|
|
173
|
+
self.assertEqual(rc, 0)
|
|
174
|
+
self.assertIn('"tracked"', csv)
|
|
175
|
+
self.assertNotIn("REPO LIZARD RAN", csv)
|
|
176
|
+
|
|
177
|
+
def test_a_file_lizard_cannot_read_does_not_stop_the_files_after_it(self):
|
|
178
|
+
with tempfile.TemporaryDirectory() as d:
|
|
179
|
+
make_repo(d, extra={"b_gone.py": "def gone():\n return 3\n", "c_after.py": "def after(a):\n return a\n"})
|
|
180
|
+
os.remove(os.path.join(d, "b_gone.py")) # still in the index, no longer on disk
|
|
181
|
+
rc, csv, _ = run(d, "--types", "py", "--ignore", "vendor/**")
|
|
182
|
+
self.assertEqual(rc, 0)
|
|
183
|
+
self.assertIn('"after"', csv, "files after the unreadable one are still measured")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
unittest.main()
|
|
@@ -131,7 +131,8 @@ class ParseFunctions(unittest.TestCase):
|
|
|
131
131
|
|
|
132
132
|
def test_rows_with_clean_paths(self):
|
|
133
133
|
rows = load.parse_functions(self.CSV)
|
|
134
|
-
self.assertEqual(rows[0], {"file": "gitmole/findings.py", "function": "_f", "ccn": 1, "nloc": 2, "params": 3,
|
|
134
|
+
self.assertEqual(rows[0], {"file": "gitmole/findings.py", "function": "_f", "anonymous": False, "ccn": 1, "nloc": 2, "params": 3,
|
|
135
|
+
"start": 14, "end": 15, "suspect": ""})
|
|
135
136
|
self.assertEqual(rows[1]["file"], "src/parser.py")
|
|
136
137
|
self.assertEqual((rows[1]["ccn"], rows[1]["nloc"], rows[1]["params"]), (41, 120, 9))
|
|
137
138
|
|
|
@@ -154,7 +155,18 @@ class ParseFunctions(unittest.TestCase):
|
|
|
154
155
|
def test_a_nameless_function_is_called_anonymous(self):
|
|
155
156
|
# lizard names Go function literals with an empty string where it names JavaScript's "(anonymous)"
|
|
156
157
|
rows = load.parse_functions('136,47,926,1,270,"@316-585@completions.go","completions.go",""," c * Command",316,585\n')
|
|
157
|
-
self.assertEqual((rows[0]["function"], rows[0]["start"]), ("(anonymous)", 316))
|
|
158
|
+
self.assertEqual((rows[0]["function"], rows[0]["start"], rows[0]["anonymous"], rows[0]["suspect"]), ("(anonymous)", 316, True, ""))
|
|
159
|
+
|
|
160
|
+
def test_a_nameless_function_goes_by_its_label_and_stays_marked_anonymous(self):
|
|
161
|
+
rows = load.parse_functions('136,47,926,1,270,"@316-585@completions.go","completions.go",""," c * Command",316,585,"Run: func(c *Command) {",""\n'
|
|
162
|
+
'4,2,36,0,4,"(anonymous)@1-4@routes.js","routes.js","(anonymous)","(anonymous)",1,4,"app.post(""/api/x"", async (req, res) => {",""\n'
|
|
163
|
+
'4,2,14,2,4,"tracked@1-4@app.py","app.py","tracked","tracked( a , b )",1,4,"",""\n')
|
|
164
|
+
self.assertEqual([(r["function"], r["anonymous"]) for r in rows],
|
|
165
|
+
[("Run: func(c *Command) {", True), ('app.post("/api/x", async (req, res) => {', True), ("tracked", False)])
|
|
166
|
+
|
|
167
|
+
def test_a_suspect_span_carries_its_reason(self):
|
|
168
|
+
rows = load.parse_functions('9,1,21,1,9,"tpl@1-9@tpl.js","tpl.js","tpl","tpl ( name )",1,9,"","opens a block at line 8 no deeper than its own start"\n')
|
|
169
|
+
self.assertEqual(rows[0]["suspect"], "opens a block at line 8 no deeper than its own start")
|
|
158
170
|
|
|
159
171
|
def test_a_row_cut_short_by_a_killed_step_does_not_abort_the_report(self):
|
|
160
172
|
rows = load.parse_functions(self.CSV + '5,3,40,1,5,"g@1-5@a.py","a.py","g","g( )",1,\n')
|
|
@@ -341,6 +341,25 @@ class Report(unittest.TestCase):
|
|
|
341
341
|
full_text = rendered(r, [], full=True)
|
|
342
342
|
self.assertIn("tests/test_a.py", full_text[full_text.index("Complex functions"):])
|
|
343
343
|
|
|
344
|
+
def test_a_nameless_function_shows_its_label_and_its_file_with_the_line(self):
|
|
345
|
+
r = sample_report()
|
|
346
|
+
r["functions"].append({"file": "server/routes.ts", "function": 'app.post("/api/x", async (req, res) => {', "anonymous": True,
|
|
347
|
+
"ccn": 25, "nloc": 60, "params": 0, "start": 1162, "end": 1240, "suspect": ""})
|
|
348
|
+
fn = _section_text(rendered(r, [], width=200), "Complex functions")
|
|
349
|
+
self.assertIn('app.post("/api/x", async (req, res) => {', fn)
|
|
350
|
+
self.assertIn("server/routes.ts:1162", fn)
|
|
351
|
+
self.assertNotIn("static/js/app.js:", fn, "a named function is found by its name; the row shows the file alone")
|
|
352
|
+
|
|
353
|
+
def test_a_suspect_span_is_marked_and_the_caption_says_what_the_mark_means(self):
|
|
354
|
+
r = sample_report()
|
|
355
|
+
r["functions"].append({"file": "lib/tpl.js", "function": "tpl", "anonymous": False, "ccn": 30, "nloc": 9, "params": 1, "start": 1, "end": 9,
|
|
356
|
+
"suspect": "opens a block at line 8 no deeper than its own start"})
|
|
357
|
+
fn = _section_text(rendered(r, [], width=200), "Complex functions")
|
|
358
|
+
self.assertIn("30?", fn)
|
|
359
|
+
self.assertIn("? marks 1 span lizard may have mis-parsed", fn)
|
|
360
|
+
plain = _section_text(rendered(sample_report(), [], width=200), "Complex functions")
|
|
361
|
+
self.assertNotIn("?", plain)
|
|
362
|
+
|
|
344
363
|
def test_default_complex_functions_hide_vendored_code_and_say_so(self):
|
|
345
364
|
r = sample_report()
|
|
346
365
|
r["functions"].append({"file": "vendor/github.com/x/y.go", "function": "validate", "ccn": 179, "nloc": 424, "params": 3, "start": 1, "end": 424})
|
|
@@ -47,6 +47,16 @@ class Risks(unittest.TestCase):
|
|
|
47
47
|
self.assertEqual(by["core/util.py"]["reasons"], ["changed 30 times", "fixed twice", "Ann wrote 95% of it"])
|
|
48
48
|
self.assertEqual(by["web/index.html"]["reasons"], ["changed 60 times"])
|
|
49
49
|
|
|
50
|
+
def test_a_nameless_function_is_named_by_its_line_and_a_suspect_span_is_passed_over(self):
|
|
51
|
+
r = report()
|
|
52
|
+
r["functions"] = [{"file": "core/parser.py", "function": 'app.post("/api/x", async (req, res) => {', "anonymous": True,
|
|
53
|
+
"ccn": 125, "nloc": 500, "params": 0, "start": 1162, "end": 1891, "suspect": "opens a block at line 1214 no deeper than its own start"},
|
|
54
|
+
{"file": "core/parser.py", "function": 'router.get("/x", (req, res) => {', "anonymous": True,
|
|
55
|
+
"ccn": 41, "nloc": 220, "params": 0, "start": 10, "end": 300, "suspect": ""}]
|
|
56
|
+
top = watch.risks(r)[0]
|
|
57
|
+
self.assertIn("the function at line 10 complexity 41", top["reasons"], "a label is not a name to put () after; a suspect span is not this file's complexity")
|
|
58
|
+
self.assertNotIn("complexity 125", " ".join(top["reasons"]))
|
|
59
|
+
|
|
50
60
|
def test_leaves_out_tests_deleted_files_and_one_offs(self):
|
|
51
61
|
files = [r["file"] for r in watch.risks(report())]
|
|
52
62
|
self.assertNotIn("tests/test_parser.py", files)
|
|
@@ -1,90 +0,0 @@
|
|
|
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
|
-
def csv_row(info, fn) -> list:
|
|
42
|
-
"""The columns `lizard --csv` prints, so the loader does not care which produced the file. Names
|
|
43
|
-
are cut to what a table can show, so one pathological fixture cannot make the file unreadable."""
|
|
44
|
-
name = _cut(fn.name, NAME_CAP)
|
|
45
|
-
return [fn.nloc, fn.cyclomatic_complexity, fn.token_count, fn.parameter_count, fn.length,
|
|
46
|
-
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]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def analyze(files: list, procs: int, exts: list):
|
|
50
|
-
"""lizard.analyze_files without its extension bookkeeping: per-file analysis over `procs` workers."""
|
|
51
|
-
return lizard.map_files_to_analyzer(files, lizard.FileAnalyzer(exts), procs)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def measure(repo: str, files: list, out: str, procs: int) -> int:
|
|
55
|
-
"""Stream functions.csv while lizard runs. Returns 0, or 1 when lizard gave up on a file (whatever
|
|
56
|
-
was measured by then stays on disk)."""
|
|
57
|
-
exts = lizard.get_extensions([]) # lizard's metric extensions
|
|
58
|
-
rc = 0
|
|
59
|
-
cwd = os.getcwd()
|
|
60
|
-
os.chdir(repo) # lizard opens the paths as given; relative ones keep the CSV repo-relative
|
|
61
|
-
try:
|
|
62
|
-
with open(os.path.join(out, "functions.csv"), "w", encoding="utf-8", newline="") as fh:
|
|
63
|
-
writer = csv.writer(fh, quoting=csv.QUOTE_NONNUMERIC)
|
|
64
|
-
try:
|
|
65
|
-
for info in analyze(files, procs, exts):
|
|
66
|
-
for fn in info.function_list:
|
|
67
|
-
writer.writerow(csv_row(info, fn))
|
|
68
|
-
fh.flush()
|
|
69
|
-
except Exception as e: # lizard re-raises its parse failures; keep what we have
|
|
70
|
-
print(f"lizard stopped: {e!r}", file=sys.stderr)
|
|
71
|
-
rc = 1
|
|
72
|
-
finally:
|
|
73
|
-
os.chdir(cwd)
|
|
74
|
-
return rc
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def main(argv=None) -> int:
|
|
78
|
-
p = argparse.ArgumentParser(description=__doc__)
|
|
79
|
-
p.add_argument("repo")
|
|
80
|
-
p.add_argument("out")
|
|
81
|
-
p.add_argument("--procs", type=int, default=1)
|
|
82
|
-
p.add_argument("--ignore", action="append", default=[])
|
|
83
|
-
p.add_argument("--types", default=None, help="file types spec as for gitmole --file-types")
|
|
84
|
-
args = p.parse_args(argv)
|
|
85
|
-
files = select_files(args.repo, args.ignore, args.types)
|
|
86
|
-
return measure(os.path.abspath(args.repo), files, os.path.abspath(args.out), max(1, args.procs))
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if __name__ == "__main__":
|
|
90
|
-
sys.exit(main())
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import subprocess
|
|
3
|
-
import tempfile
|
|
4
|
-
import unittest
|
|
5
|
-
|
|
6
|
-
from gitmole import functions
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def make_repo(d, extra=()):
|
|
10
|
-
def git(*args):
|
|
11
|
-
e = dict(os.environ, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null",
|
|
12
|
-
GIT_AUTHOR_NAME="Ann", GIT_AUTHOR_EMAIL="a@x", GIT_COMMITTER_NAME="Ann", GIT_COMMITTER_EMAIL="a@x")
|
|
13
|
-
subprocess.run(["git", *args], cwd=d, check=True, capture_output=True, env=e)
|
|
14
|
-
git("init", "-q")
|
|
15
|
-
os.makedirs(os.path.join(d, "vendor"))
|
|
16
|
-
files = {
|
|
17
|
-
"app.py": "def tracked(a, b):\n if a:\n return b\n return a\n",
|
|
18
|
-
"vendor/lib.py": "def vendored():\n return 1\n",
|
|
19
|
-
"notes.md": "def not_code():\n pass\n",
|
|
20
|
-
"page.js": "function scripted(a) {\n if (a) { return 1; }\n return 0;\n}\n",
|
|
21
|
-
"run.sh": "shelled() {\n if [ -n \"$1\" ]; then echo hi; fi\n}\n",
|
|
22
|
-
"sim.f90": "subroutine fortran_sub(x)\n integer :: x\n if (x > 0) then\n x = 1\n end if\nend subroutine\n",
|
|
23
|
-
}
|
|
24
|
-
files.update(extra)
|
|
25
|
-
for name, text in files.items():
|
|
26
|
-
with open(os.path.join(d, name), "w") as fh:
|
|
27
|
-
fh.write(text)
|
|
28
|
-
git("add", "-A")
|
|
29
|
-
git("commit", "-q", "-m", "one")
|
|
30
|
-
with open(os.path.join(d, "untracked.py"), "w") as fh:
|
|
31
|
-
fh.write("def untracked():\n return 2\n")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def run(d, *args):
|
|
35
|
-
out = os.path.join(d, "out")
|
|
36
|
-
os.makedirs(out, exist_ok=True)
|
|
37
|
-
rc = functions.main([d, out, "--procs", "1", *args])
|
|
38
|
-
with open(os.path.join(out, "functions.csv")) as fh:
|
|
39
|
-
csv = fh.read()
|
|
40
|
-
return rc, csv, sorted(os.listdir(out))
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class FunctionsScript(unittest.TestCase):
|
|
44
|
-
def test_measures_tracked_files_lizard_can_read(self):
|
|
45
|
-
with tempfile.TemporaryDirectory() as d:
|
|
46
|
-
make_repo(d)
|
|
47
|
-
rc, csv, written = run(d, "--ignore", "vendor/**")
|
|
48
|
-
self.assertEqual(rc, 0)
|
|
49
|
-
self.assertIn('"tracked"', csv)
|
|
50
|
-
self.assertIn('"fortran_sub"', csv, "every language lizard knows, not only gitmole's default code list")
|
|
51
|
-
self.assertNotIn("untracked", csv, "not tracked by git")
|
|
52
|
-
self.assertNotIn("vendored", csv, "--ignore applies")
|
|
53
|
-
self.assertNotIn("not_code", csv, "markdown is not code")
|
|
54
|
-
self.assertNotIn("shelled", csv, "no lizard reader for shell: no guessing with the C-like fallback")
|
|
55
|
-
self.assertEqual(written, ["functions.csv"], "duplicates are jscpd's step, not lizard's")
|
|
56
|
-
|
|
57
|
-
def test_a_huge_nested_name_is_cut_before_it_is_written(self):
|
|
58
|
-
from types import SimpleNamespace
|
|
59
|
-
name = ".".join("a" for _ in range(100_000))
|
|
60
|
-
fn = SimpleNamespace(nloc=5, cyclomatic_complexity=3, token_count=40, parameter_count=1, length=5, name=name,
|
|
61
|
-
long_name=name + "( )", start_line=1, end_line=5)
|
|
62
|
-
row = functions.csv_row(SimpleNamespace(filename="a.py"), fn)
|
|
63
|
-
self.assertEqual(len(row[7]), functions.NAME_CAP)
|
|
64
|
-
self.assertTrue(row[7].endswith("…"))
|
|
65
|
-
self.assertLessEqual(len(row[8]), functions.LONG_NAME_CAP)
|
|
66
|
-
self.assertLessEqual(len(row[5]), functions.NAME_CAP + len("@1-5@a.py"))
|
|
67
|
-
|
|
68
|
-
def test_csv_matches_lizards_own_layout(self):
|
|
69
|
-
with tempfile.TemporaryDirectory() as d:
|
|
70
|
-
make_repo(d)
|
|
71
|
-
rc, csv, _ = run(d, "--types", "py", "--ignore", "vendor/**")
|
|
72
|
-
self.assertEqual(csv, '4,2,14,2,4,"tracked@1-4@app.py","app.py","tracked","tracked( a , b )",1,4\n')
|
|
73
|
-
|
|
74
|
-
def test_file_types_restrict_what_is_measured(self):
|
|
75
|
-
with tempfile.TemporaryDirectory() as d:
|
|
76
|
-
make_repo(d)
|
|
77
|
-
rc, csv, _ = run(d, "--types", "js")
|
|
78
|
-
self.assertEqual(rc, 0)
|
|
79
|
-
self.assertIn("scripted", csv)
|
|
80
|
-
self.assertNotIn("tracked", csv)
|
|
81
|
-
self.assertNotIn("fortran", csv)
|
|
82
|
-
|
|
83
|
-
def test_no_code_files_still_writes_an_empty_csv(self):
|
|
84
|
-
with tempfile.TemporaryDirectory() as d:
|
|
85
|
-
make_repo(d)
|
|
86
|
-
rc, csv, _ = run(d, "--ignore", "*.py", "--ignore", "*.js", "--ignore", "*.f90")
|
|
87
|
-
self.assertEqual(rc, 0)
|
|
88
|
-
self.assertEqual(csv, "")
|
|
89
|
-
|
|
90
|
-
def test_a_lizard_module_in_the_analysed_repo_is_data_not_code_to_run(self):
|
|
91
|
-
bomb = "import sys\nsys.stderr.write('REPO LIZARD RAN')\nsys.exit(7)\n"
|
|
92
|
-
with tempfile.TemporaryDirectory() as d:
|
|
93
|
-
make_repo(d, extra={"lizard.py": bomb, "lizard_ext/__init__.py": bomb} if False else {"lizard.py": bomb})
|
|
94
|
-
rc, csv, _ = run(d, "--types", "py", "--ignore", "vendor/**")
|
|
95
|
-
self.assertEqual(rc, 0)
|
|
96
|
-
self.assertIn('"tracked"', csv)
|
|
97
|
-
self.assertNotIn("REPO LIZARD RAN", csv)
|
|
98
|
-
|
|
99
|
-
def test_a_file_lizard_cannot_read_does_not_stop_the_files_after_it(self):
|
|
100
|
-
with tempfile.TemporaryDirectory() as d:
|
|
101
|
-
make_repo(d, extra={"b_gone.py": "def gone():\n return 3\n", "c_after.py": "def after(a):\n return a\n"})
|
|
102
|
-
os.remove(os.path.join(d, "b_gone.py")) # still in the index, no longer on disk
|
|
103
|
-
rc, csv, _ = run(d, "--types", "py", "--ignore", "vendor/**")
|
|
104
|
-
self.assertEqual(rc, 0)
|
|
105
|
-
self.assertIn('"after"', csv, "files after the unreadable one are still measured")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if __name__ == "__main__":
|
|
109
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|