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.
Files changed (64) hide show
  1. {gitmole-0.7.1 → gitmole-0.7.2}/PKG-INFO +2 -4
  2. {gitmole-0.7.1 → gitmole-0.7.2}/README.md +1 -3
  3. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/__init__.py +1 -1
  4. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/findings.py +12 -5
  5. gitmole-0.7.2/gitmole/functions.py +182 -0
  6. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/load.py +8 -3
  7. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/render.py +15 -3
  8. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/watch.py +5 -2
  9. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/PKG-INFO +2 -4
  10. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_findings.py +16 -0
  11. gitmole-0.7.2/tests/test_functions.py +187 -0
  12. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_load.py +14 -2
  13. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_render.py +19 -0
  14. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_watch.py +10 -0
  15. gitmole-0.7.1/gitmole/functions.py +0 -90
  16. gitmole-0.7.1/tests/test_functions.py +0 -109
  17. {gitmole-0.7.1 → gitmole-0.7.2}/LICENSE +0 -0
  18. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/__main__.py +0 -0
  19. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/backtest.py +0 -0
  20. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/banner.py +0 -0
  21. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/blame.py +0 -0
  22. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/clean.py +0 -0
  23. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/cli.py +0 -0
  24. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/coupling.py +0 -0
  25. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/deps.py +0 -0
  26. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/duplicates.py +0 -0
  27. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/filetypes.py +0 -0
  28. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/hotspots.py +0 -0
  29. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/identity.py +0 -0
  30. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/knowledge.py +0 -0
  31. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/leaks.py +0 -0
  32. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/loss.py +0 -0
  33. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/maat.py +0 -0
  34. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/run.py +0 -0
  35. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/textfmt.py +0 -0
  36. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole/trend.py +0 -0
  37. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/SOURCES.txt +0 -0
  38. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/dependency_links.txt +0 -0
  39. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/entry_points.txt +0 -0
  40. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/requires.txt +0 -0
  41. {gitmole-0.7.1 → gitmole-0.7.2}/gitmole.egg-info/top_level.txt +0 -0
  42. {gitmole-0.7.1 → gitmole-0.7.2}/pyproject.toml +0 -0
  43. {gitmole-0.7.1 → gitmole-0.7.2}/setup.cfg +0 -0
  44. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_backtest.py +0 -0
  45. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_banner.py +0 -0
  46. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_blame.py +0 -0
  47. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_clean.py +0 -0
  48. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_cli.py +0 -0
  49. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_coupling.py +0 -0
  50. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_deps.py +0 -0
  51. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_duplicates.py +0 -0
  52. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_filetypes.py +0 -0
  53. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_golden.py +0 -0
  54. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_hotspots.py +0 -0
  55. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_identity.py +0 -0
  56. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_knowledge.py +0 -0
  57. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_leaks.py +0 -0
  58. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_loss.py +0 -0
  59. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_maat.py +0 -0
  60. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_packaging.py +0 -0
  61. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_render_examples.py +0 -0
  62. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_run.py +0 -0
  63. {gitmole-0.7.1 → gitmole-0.7.2}/tests/test_textfmt.py +0 -0
  64. {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.1
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; the picture above, as text:
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; the picture above, as text:
55
+ since 2013, at a pinned commit:
58
56
 
59
57
  ```text
60
58
  ╭─ react ──────────────────────────────────────────────────────────────────────────────────────────╮
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.7.1"
3
+ __version__ = "0.7.2"
@@ -442,10 +442,11 @@ def _partial_functions(report: dict) -> str:
442
442
 
443
443
  def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list:
444
444
  """Functions that are both long and complex, in this repository's own source files: test files,
445
- example code, vendored code and generated files (amalgamations included) are left out. A warning
446
- when one sits in a hotspot."""
445
+ example code, vendored code and generated files (amalgamations included) are left out, and so is
446
+ a span the function step marked suspect, since a mis-parse that swallowed the next function is
447
+ long and complex by construction. A warning when one sits in a hotspot."""
447
448
  generated, vendored = _generated(report), filetypes.vendor_dirs(report)
448
- big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines
449
+ big = [f for f in report.get("functions") or [] if f["ccn"] >= min_ccn and f["nloc"] >= min_lines and not f.get("suspect")
449
450
  and not (filetypes.is_test_path(f["file"]) or filetypes.is_sample_path(f["file"]) or filetypes.is_vendored(f["file"], vendored)
450
451
  or f["file"] in generated)]
451
452
  if not big:
@@ -456,7 +457,7 @@ def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list
456
457
  listed = "; ".join(f"{f['function']} ({_place(f)}) complexity {f['ccn']}, {f['nloc']} lines, {f['params']} params" for f in big[:5])
457
458
  more = f" and {len(big) - 5} more" if len(big) > 5 else ""
458
459
  first = big[0]
459
- which = f"the anonymous function at {_place(first)}" if first["function"] == ANONYMOUS else f"{first['function']} in {first['file']}"
460
+ which = f"the anonymous function at {_place(first)}" if _anonymous(first) else f"{first['function']} in {first['file']}"
460
461
  return [_f(sev, "Brain methods",
461
462
  f"{len(big)} function(s) are both long and complex: {listed}{more}.{_partial_functions(report)}",
462
463
  f"Split {which} first, before the next change lands there.")]
@@ -465,9 +466,15 @@ def brain_methods(report: dict, min_ccn: int = 15, min_lines: int = 100) -> list
465
466
  ANONYMOUS = "(anonymous)"
466
467
 
467
468
 
469
+ def _anonymous(f: dict) -> bool:
470
+ """A function lizard could not name: it goes by its start line's text (or "(anonymous)" in an
471
+ older functions.csv), which is not a name to search for."""
472
+ return f.get("anonymous", f["function"] == ANONYMOUS)
473
+
474
+
468
475
  def _place(f: dict) -> str:
469
476
  """Where a function is: its file, or file:line when it has no name to find it by."""
470
- return f"{f['file']}:{f['start']}" if f["function"] == ANONYMOUS else f["file"]
477
+ return f"{f['file']}:{f['start']}" if _anonymous(f) else f["file"]
471
478
 
472
479
 
473
480
  def _generated(report: dict) -> set:
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """Function-level metrics from lizard, over the tracked code files only.
3
+
4
+ Runs as its own process (a pipeline step) and drives lizard through its Python API rather
5
+ than its command line: the file list never touches a shell or a list file, the analysed
6
+ repository is never on sys.path, only files lizard has a reader for are measured, and the
7
+ CSV is streamed so a killed step still leaves what was measured. Duplicated blocks are
8
+ jscpd's job (duplicates.py); lizard's own finder kept a hash node per token and ran to gigabytes."""
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import csv
13
+ import os
14
+ import sys
15
+
16
+ import lizard
17
+
18
+ try:
19
+ from . import blame, filetypes
20
+ except ImportError: # run as a script: the package directory is sys.path[0]
21
+ import blame
22
+ import filetypes
23
+
24
+
25
+ def select_files(repo: str, ignore=(), types_spec: str = None) -> list:
26
+ """Tracked text files lizard can parse. Without --file-types that is every language lizard
27
+ knows (a superset of gitmole's default code list, e.g. Fortran); with it, the intersection."""
28
+ types = filetypes.parse(types_spec)
29
+ files = blame.text_files(repo, ignore) if types_spec is None else blame.code_files(repo, ignore, types)
30
+ return [f for f in files if lizard.get_reader_for(f) is not None]
31
+
32
+
33
+ NAME_CAP = 200 # a deeply nested fixture gives lizard a dotted name of megabytes; nobody reads past this
34
+ LONG_NAME_CAP = 500
35
+
36
+
37
+ def _cut(text: str, cap: int) -> str:
38
+ return text if len(text) <= cap else text[:cap - 1] + "…"
39
+
40
+
41
+ ANONYMOUS = "(anonymous)" # lizard's name for a JavaScript function expression; a Go literal gets ""
42
+ SPARSE_LINES = 40 # a span this long...
43
+ SPARSE_SHARE = 0.25 # ...with under this share of code lines is more likely a mis-parse than a function
44
+
45
+ OPENS_FUNCTION = ("=>", "->", "func", "lambda") # what a line that opens a nameless function holds: "func" covers "function"
46
+ NEARBY = (0, -1, 1, 2, 3, 4, 5)
47
+
48
+
49
+ def nameless(fn) -> bool:
50
+ return fn.name in ("", ANONYMOUS)
51
+
52
+
53
+ def _opener(lines: list, fn) -> int | None:
54
+ """The line near a nameless function's start that opens a function, or None. lizard puts an arrow
55
+ whose body starts on the next line at the body's line, and a callback in a JSX attribute at the
56
+ tag's line, so the start line is tried first, then the line before, then a few lines on."""
57
+ for offset in NEARBY:
58
+ n = fn.start_line + offset
59
+ if 1 <= n <= len(lines) and any(m in lines[n - 1] for m in OPENS_FUNCTION):
60
+ return n
61
+ return None
62
+
63
+
64
+ def label(lines: list, fn) -> str:
65
+ """What to call a function lizard could not name: the line it starts on, whitespace collapsed, in
66
+ any language. `app.post("/api/x", async (req, res) => {` finds the callback; `(anonymous)` does not.
67
+ When a nearby line opens a function and the start line does not, that line is the label."""
68
+ if not nameless(fn) or not 1 <= fn.start_line <= len(lines):
69
+ return ""
70
+ n = _opener(lines, fn) or fn.start_line
71
+ return _cut(" ".join(lines[n - 1].split()), NAME_CAP)
72
+
73
+
74
+ def suspect(lines: list, fn) -> str:
75
+ """Why a span looks like a mis-parse, or "". lizard fails by losing its place (a template literal,
76
+ JSX) and swallowing what follows into one function, so a swallowed span is long with little code
77
+ in it, or holds a line that opens a block at the indentation of the function's own start: a
78
+ sibling that should have ended it. A line that starts by closing a bracket (`}: Props) {`)
79
+ continues the function's own signature, and a bare `{` is the function's own body brace in
80
+ the C styles that put it on its own line; neither counts. It also reads a JSX ternary as a
81
+ nameless function: all code, all deeper than its start, and nothing near the start line opens a
82
+ function."""
83
+ if fn.length >= SPARSE_LINES and fn.nloc < SPARSE_SHARE * fn.length:
84
+ return f"{fn.nloc} of {fn.length} lines are code"
85
+ if nameless(fn) and 1 <= fn.start_line <= len(lines) and _opener(lines, fn) is None:
86
+ return f"nothing opens a function within {max(NEARBY)} lines of line {fn.start_line}"
87
+ span = lines[max(fn.start_line, 1) - 1:fn.end_line]
88
+ if len(span) < 3:
89
+ return ""
90
+ depth = _indent(span[0])
91
+ for number, line in enumerate(span[1:-1], start=fn.start_line + 1):
92
+ text = line.rstrip()
93
+ if text.endswith("{") and _indent(text) <= depth and text.lstrip()[0] not in "{)]}":
94
+ return f"opens a block at line {number} no deeper than its own start"
95
+ return ""
96
+
97
+
98
+ def _indent(line: str) -> int:
99
+ return len(line) - len(line.lstrip())
100
+
101
+
102
+ def csv_row(info, fn, lines: list = ()) -> list:
103
+ """The columns `lizard --csv` prints, then gitmole's own two: a label for nameless functions and
104
+ why the span looks mis-parsed. Names are cut to what a table can show, so one pathological
105
+ fixture cannot make the file unreadable."""
106
+ name = _cut(fn.name, NAME_CAP)
107
+ return [fn.nloc, fn.cyclomatic_complexity, fn.token_count, fn.parameter_count, fn.length,
108
+ f"{name}@{fn.start_line}-{fn.end_line}@{info.filename}", info.filename, name, _cut(fn.long_name, LONG_NAME_CAP), fn.start_line, fn.end_line,
109
+ label(lines, fn), suspect(lines, fn)]
110
+
111
+
112
+ def _lines(path: str) -> list:
113
+ try:
114
+ with open(path, encoding="utf-8", errors="replace") as fh:
115
+ return fh.read().splitlines()
116
+ except OSError:
117
+ return []
118
+
119
+
120
+ def keep_newlines(tokens, reader):
121
+ """lizard's own preprocessing, after splitting whitespace tokens that hold newlines into bare ones.
122
+ lizard 1.24 drops any whitespace token but "\\n" there, and its JSX tokenizer hands it the newline
123
+ before a child element joined with the indentation after it: one line lost per child, so every
124
+ function after a JSX block in a .tsx file reports lines before its own."""
125
+ def split(tokens):
126
+ for t in tokens:
127
+ if t != "\n" and t.isspace() and "\n" in t:
128
+ for _ in range(t.count("\n")):
129
+ yield "\n"
130
+ else:
131
+ yield t
132
+ return lizard.preprocessing(split(tokens), reader)
133
+
134
+
135
+ def extensions() -> list:
136
+ """lizard's metric extensions, with keep_newlines in place of its preprocessing."""
137
+ return [keep_newlines if e is lizard.preprocessing else e for e in lizard.get_extensions([])]
138
+
139
+
140
+ def analyze(files: list, procs: int, exts: list):
141
+ """lizard.analyze_files without its extension bookkeeping: per-file analysis over `procs` workers."""
142
+ return lizard.map_files_to_analyzer(files, lizard.FileAnalyzer(exts), procs)
143
+
144
+
145
+ def measure(repo: str, files: list, out: str, procs: int) -> int:
146
+ """Stream functions.csv while lizard runs. Returns 0, or 1 when lizard gave up on a file (whatever
147
+ was measured by then stays on disk)."""
148
+ exts = extensions()
149
+ rc = 0
150
+ cwd = os.getcwd()
151
+ os.chdir(repo) # lizard opens the paths as given; relative ones keep the CSV repo-relative
152
+ try:
153
+ with open(os.path.join(out, "functions.csv"), "w", encoding="utf-8", newline="") as fh:
154
+ writer = csv.writer(fh, quoting=csv.QUOTE_NONNUMERIC)
155
+ try:
156
+ for info in analyze(files, procs, exts):
157
+ lines = _lines(info.filename) if info.function_list else []
158
+ for fn in info.function_list:
159
+ writer.writerow(csv_row(info, fn, lines))
160
+ fh.flush()
161
+ except Exception as e: # lizard re-raises its parse failures; keep what we have
162
+ print(f"lizard stopped: {e!r}", file=sys.stderr)
163
+ rc = 1
164
+ finally:
165
+ os.chdir(cwd)
166
+ return rc
167
+
168
+
169
+ def main(argv=None) -> int:
170
+ p = argparse.ArgumentParser(description=__doc__)
171
+ p.add_argument("repo")
172
+ p.add_argument("out")
173
+ p.add_argument("--procs", type=int, default=1)
174
+ p.add_argument("--ignore", action="append", default=[])
175
+ p.add_argument("--types", default=None, help="file types spec as for gitmole --file-types")
176
+ args = p.parse_args(argv)
177
+ files = select_files(args.repo, args.ignore, args.types)
178
+ return measure(os.path.abspath(args.repo), files, os.path.abspath(args.out), max(1, args.procs))
179
+
180
+
181
+ if __name__ == "__main__":
182
+ sys.exit(main())
@@ -155,13 +155,18 @@ def _cut(name: str, cap: int = NAME_CAP) -> str:
155
155
 
156
156
 
157
157
  def parse_functions(text: str) -> list:
158
- """lizard --csv rows: nloc, ccn, tokens, params, length, location, file, function, long name, start, end."""
158
+ """lizard --csv rows: nloc, ccn, tokens, params, length, location, file, function, long name, start, end;
159
+ then, from gitmole's own step, a label for a nameless function (its start line) and why the span
160
+ looks mis-parsed. A nameless function goes by its label, or "(anonymous)" in an older file, and
161
+ stays marked anonymous so the report can say where it is."""
159
162
  rows = []
160
163
  for r in csv.reader(io.StringIO(text)):
161
164
  if len(r) < 11:
162
165
  continue
163
- rows.append({"file": _rel(r[6]), "function": _cut(r[7]) or "(anonymous)", "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]),
164
- "start": _num(r[9]), "end": _num(r[10])})
166
+ name, label, suspect = r[7], r[11] if len(r) > 11 else "", r[12] if len(r) > 12 else ""
167
+ anonymous = name in ("", "(anonymous)")
168
+ rows.append({"file": _rel(r[6]), "function": _cut(label if anonymous and label else name) or "(anonymous)", "anonymous": anonymous,
169
+ "ccn": _num(r[1]), "nloc": _num(r[0]), "params": _num(r[3]), "start": _num(r[9]), "end": _num(r[10]), "suspect": suspect})
165
170
  return rows
166
171
 
167
172
 
@@ -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
- rows = [(f["function"], f["file"], f["ccn"], f["nloc"], f["params"]) for f in funcs[:limit]]
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 picture shows; the findings themselves are in the full report."""
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
- out.append(f"{fn['function']}() complexity {fn['ccn']}")
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.1
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; the picture above, as text:
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, "start": 14, "end": 15})
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