codeimprint 0.5.0a11__py3-none-any.whl
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.
- codeimprint-0.5.0a11.dist-info/METADATA +92 -0
- codeimprint-0.5.0a11.dist-info/RECORD +23 -0
- codeimprint-0.5.0a11.dist-info/WHEEL +4 -0
- codeimprint-0.5.0a11.dist-info/entry_points.txt +2 -0
- codeimprint-0.5.0a11.dist-info/licenses/LICENSE +177 -0
- codeimprint-0.5.0a11.dist-info/licenses/NOTICE +11 -0
- codeimprint_cli/__init__.py +7 -0
- codeimprint_cli/__main__.py +5 -0
- codeimprint_cli/api.py +315 -0
- codeimprint_cli/chainseal.py +175 -0
- codeimprint_cli/cli.py +2128 -0
- codeimprint_cli/config.py +181 -0
- codeimprint_cli/gitrepo.py +148 -0
- codeimprint_cli/imprint_file.py +128 -0
- codeimprint_cli/linecount.py +340 -0
- codeimprint_cli/ownership.py +341 -0
- codeimprint_cli/project.py +129 -0
- codeimprint_cli/receipt.py +679 -0
- codeimprint_cli/records.py +59 -0
- codeimprint_cli/timestamp.py +187 -0
- codeimprint_cli/transparency.py +71 -0
- codeimprint_cli/trust.py +190 -0
- codeimprint_cli/tsa_roots.py +137 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""The line count of a seal (docs/line-count.md): the size a project is priced by.
|
|
2
|
+
|
|
3
|
+
Non-blank lines in the files Git tracks at the sealed commit, without binary, vendored and
|
|
4
|
+
generated files (path and content rules below, then ``linguist-vendored`` and
|
|
5
|
+
``linguist-generated`` from ``.gitattributes``, read by ``git check-attr``). The count is sent
|
|
6
|
+
with the seal and kept in the Imprint's public header, so the digest you sign covers it.
|
|
7
|
+
|
|
8
|
+
This is counting only: nothing here looks at what the code means. It stays fast on large
|
|
9
|
+
repositories: one ``git check-attr`` and one ``git cat-file --batch`` process per count, path
|
|
10
|
+
rules before any content is read, and a per-blob cache in the Git directory (blobs are
|
|
11
|
+
content-addressed, so a count never goes stale).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import threading
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
|
+
|
|
24
|
+
RULES = "lines/1"
|
|
25
|
+
|
|
26
|
+
# -- the rules (the same as the engine's spot-check) -------------------------------------------
|
|
27
|
+
VENDORED_DIRS = frozenset({
|
|
28
|
+
"node_modules", "bower_components", "jspm_packages", "vendor", "vendors", "third_party",
|
|
29
|
+
"third-party", "thirdparty", "Pods", "Carthage", ".yarn", ".pnpm-store", "dist", "build",
|
|
30
|
+
"__pycache__", ".venv", "venv", "site-packages",
|
|
31
|
+
})
|
|
32
|
+
GENERATED_NAMES = frozenset({
|
|
33
|
+
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock",
|
|
34
|
+
"bun.lockb", "deno.lock", "poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock",
|
|
35
|
+
"Cargo.lock", "Gemfile.lock", "composer.lock", "go.sum", "mix.lock", "pubspec.lock",
|
|
36
|
+
"Podfile.lock", "Package.resolved", "packages.lock.json", "flake.lock", "gradle.lockfile",
|
|
37
|
+
"conan.lock", "paket.lock",
|
|
38
|
+
})
|
|
39
|
+
GENERATED_SUFFIXES = (
|
|
40
|
+
".min.js", ".min.css", ".min.mjs", ".min.cjs", ".js.map", ".css.map", ".map",
|
|
41
|
+
"_pb2.py", "_pb2.pyi", "_pb2_grpc.py", ".pb.go", ".pb.cc", ".pb.h", "_pb.js", "_pb.ts",
|
|
42
|
+
"_grpc_pb.js", "_grpc_pb.ts", ".g.dart", ".freezed.dart", ".designer.cs",
|
|
43
|
+
)
|
|
44
|
+
_MINIFIED = re.compile(r"\.min\.[A-Za-z0-9]+$")
|
|
45
|
+
_GENERATED_MARK = re.compile(rb"(?:Code generated .{0,200}DO NOT EDIT|@generated\b)")
|
|
46
|
+
BINARY_SUFFIXES = frozenset({
|
|
47
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".icns", ".webp", ".avif", ".tif",
|
|
48
|
+
".tiff", ".psd", ".pdf", ".zip", ".gz", ".tgz", ".bz2", ".xz", ".zst", ".7z", ".rar",
|
|
49
|
+
".tar", ".jar", ".war", ".ear", ".whl", ".egg", ".exe", ".dll", ".so", ".dylib", ".a",
|
|
50
|
+
".lib", ".o", ".obj", ".class", ".pyc", ".pyo", ".wasm", ".bin", ".dat", ".db",
|
|
51
|
+
".sqlite", ".sqlite3", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".m4a",
|
|
52
|
+
".wav", ".ogg", ".flac", ".mov", ".avi", ".mkv", ".webm", ".iso", ".dmg", ".pkg",
|
|
53
|
+
".deb", ".rpm", ".apk", ".ipa", ".keystore", ".jks", ".p12", ".pfx", ".der",
|
|
54
|
+
})
|
|
55
|
+
BINARY_SNIFF = 8000
|
|
56
|
+
GENERATED_SNIFF = 1024
|
|
57
|
+
MAX_FILE_BYTES = 10 * 1024 * 1024 # larger files are data, not code: not counted
|
|
58
|
+
|
|
59
|
+
# Languages by file name, then by extension (case-insensitive); anything else is "Other".
|
|
60
|
+
FILENAMES = {"Dockerfile": "Dockerfile", "Makefile": "Makefile", "makefile": "Makefile",
|
|
61
|
+
"GNUmakefile": "Makefile", "CMakeLists.txt": "CMake", "Jenkinsfile": "Groovy",
|
|
62
|
+
"Rakefile": "Ruby", "Gemfile": "Ruby", "Vagrantfile": "Ruby", "BUILD": "Starlark",
|
|
63
|
+
"WORKSPACE": "Starlark", "BUILD.bazel": "Starlark"}
|
|
64
|
+
EXTENSIONS = {
|
|
65
|
+
".py": "Python", ".pyi": "Python", ".pyx": "Cython", ".ipynb": "Jupyter Notebook",
|
|
66
|
+
".ts": "TypeScript", ".tsx": "TypeScript", ".mts": "TypeScript", ".cts": "TypeScript",
|
|
67
|
+
".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript", ".cjs": "JavaScript",
|
|
68
|
+
".vue": "Vue", ".svelte": "Svelte", ".astro": "Astro",
|
|
69
|
+
".go": "Go", ".rs": "Rust", ".java": "Java", ".kt": "Kotlin", ".kts": "Kotlin",
|
|
70
|
+
".scala": "Scala", ".groovy": "Groovy", ".gradle": "Groovy", ".clj": "Clojure",
|
|
71
|
+
".c": "C", ".h": "C", ".cc": "C++", ".cpp": "C++", ".cxx": "C++", ".hh": "C++",
|
|
72
|
+
".hpp": "C++", ".hxx": "C++", ".m": "Objective-C", ".mm": "Objective-C++",
|
|
73
|
+
".cs": "C#", ".fs": "F#", ".vb": "Visual Basic", ".swift": "Swift", ".dart": "Dart",
|
|
74
|
+
".rb": "Ruby", ".php": "PHP", ".pl": "Perl", ".pm": "Perl", ".lua": "Lua", ".r": "R",
|
|
75
|
+
".jl": "Julia", ".ex": "Elixir", ".exs": "Elixir", ".erl": "Erlang", ".hs": "Haskell",
|
|
76
|
+
".ml": "OCaml", ".elm": "Elm", ".zig": "Zig", ".nim": "Nim", ".sol": "Solidity",
|
|
77
|
+
".sh": "Shell", ".bash": "Shell", ".zsh": "Shell", ".fish": "Shell", ".ps1": "PowerShell",
|
|
78
|
+
".psm1": "PowerShell", ".bat": "Batchfile", ".cmd": "Batchfile",
|
|
79
|
+
".sql": "SQL", ".graphql": "GraphQL", ".gql": "GraphQL", ".proto": "Protocol Buffers",
|
|
80
|
+
".html": "HTML", ".htm": "HTML", ".css": "CSS", ".scss": "SCSS", ".sass": "Sass",
|
|
81
|
+
".less": "Less", ".md": "Markdown", ".mdx": "MDX", ".rst": "reStructuredText",
|
|
82
|
+
".tex": "TeX", ".json": "JSON", ".jsonc": "JSON", ".yaml": "YAML", ".yml": "YAML",
|
|
83
|
+
".toml": "TOML", ".xml": "XML", ".ini": "INI", ".cfg": "INI", ".tf": "HCL",
|
|
84
|
+
".hcl": "HCL", ".nix": "Nix", ".cmake": "CMake", ".txt": "Text",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def path_excluded(path: str) -> bool:
|
|
89
|
+
"""Vendored or generated by its path alone (``.gitattributes`` can override)."""
|
|
90
|
+
p = PurePosixPath(path)
|
|
91
|
+
if any(part in VENDORED_DIRS for part in p.parts[:-1]):
|
|
92
|
+
return True
|
|
93
|
+
name = p.name
|
|
94
|
+
return (name in GENERATED_NAMES or name.endswith(GENERATED_SUFFIXES)
|
|
95
|
+
or bool(_MINIFIED.search(name)))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def binary_path(path: str) -> bool:
|
|
99
|
+
return PurePosixPath(path).suffix.lower() in BINARY_SUFFIXES
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def nonblank(data: bytes) -> int:
|
|
103
|
+
"""Lines (split on LF) with any byte other than ASCII whitespace (space, tab, CR, VT, FF)."""
|
|
104
|
+
return sum(1 for line in data.split(b"\n") if line.strip())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def measure(data: bytes) -> tuple[int, str]:
|
|
108
|
+
"""(non-blank lines, kind): kind is "text", "binary" or "generated" (a marker in the
|
|
109
|
+
first 1 KiB: Go's "Code generated ... DO NOT EDIT", or @generated)."""
|
|
110
|
+
if len(data) > MAX_FILE_BYTES or b"\0" in data[:BINARY_SNIFF]:
|
|
111
|
+
return 0, "binary"
|
|
112
|
+
if _GENERATED_MARK.search(data[:GENERATED_SNIFF]):
|
|
113
|
+
return nonblank(data), "generated"
|
|
114
|
+
return nonblank(data), "text"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def language(path: str) -> str:
|
|
118
|
+
p = PurePosixPath(path)
|
|
119
|
+
return FILENAMES.get(p.name) or EXTENSIONS.get(p.suffix.lower(), "Other")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# -- the result ------------------------------------------------------------------------------
|
|
123
|
+
@dataclass
|
|
124
|
+
class Count:
|
|
125
|
+
total: int = 0
|
|
126
|
+
files: int = 0
|
|
127
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
128
|
+
|
|
129
|
+
def add(self, path: str, lines: int) -> None:
|
|
130
|
+
self.files += 1
|
|
131
|
+
self.total += lines
|
|
132
|
+
name = language(path)
|
|
133
|
+
self.languages[name] = self.languages.get(name, 0) + lines
|
|
134
|
+
|
|
135
|
+
def payload(self) -> dict:
|
|
136
|
+
"""What is sent with the seal (and kept in the Imprint's public header)."""
|
|
137
|
+
return {"rules": RULES, "total": self.total, "files": self.files,
|
|
138
|
+
"languages": {k: v for k, v in sorted(self.languages.items()) if v}}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def summary(lines: dict) -> str:
|
|
142
|
+
"""'123,456 (Python 80%, TypeScript 20%)': the three largest languages, then the rest."""
|
|
143
|
+
total = int(lines.get("total") or 0)
|
|
144
|
+
if not total:
|
|
145
|
+
return "0"
|
|
146
|
+
ranked = sorted(((n, k) for k, n in (lines.get("languages") or {}).items() if n),
|
|
147
|
+
key=lambda t: (-t[0], t[1]))
|
|
148
|
+
parts = [f"{k} {round(100 * n / total)}%" for n, k in ranked[:3]
|
|
149
|
+
if round(100 * n / total) >= 1]
|
|
150
|
+
rest = total - sum(n for n, _ in ranked[:3])
|
|
151
|
+
if len(ranked) > 3 and rest:
|
|
152
|
+
parts.append(f"other {max(1, round(100 * rest / total))}%")
|
|
153
|
+
return f"{total:,}" + (f" ({', '.join(parts)})" if parts else "")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# -- .gitattributes ---------------------------------------------------------------------------
|
|
157
|
+
_ATTRS = ("linguist-vendored", "linguist-generated")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _attr_value(value: str) -> bool | None:
|
|
161
|
+
"""set/true: excluded; unset/false: counted even if the path rules would exclude it."""
|
|
162
|
+
if value in ("set", "true"):
|
|
163
|
+
return True
|
|
164
|
+
if value in ("unset", "false"):
|
|
165
|
+
return False
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _git_attrs(top: Path, commit: str | None, paths: list[str]) -> dict[str, dict]:
|
|
170
|
+
"""{path: {attr: True/False}} from ``git check-attr`` (at ``commit`` with Git 2.40+,
|
|
171
|
+
else from the index)."""
|
|
172
|
+
if not paths:
|
|
173
|
+
return {}
|
|
174
|
+
data = b"\0".join(p.encode("utf-8", "surrogateescape") for p in paths) + b"\0"
|
|
175
|
+
attempts = ([["--source", commit]] if commit else []) + [["--cached"]]
|
|
176
|
+
out = None
|
|
177
|
+
for extra in attempts:
|
|
178
|
+
try:
|
|
179
|
+
out = subprocess.run(["git", "-C", str(top), "check-attr", "-z", "--stdin", *extra,
|
|
180
|
+
*_ATTRS], input=data, capture_output=True, check=True).stdout
|
|
181
|
+
break
|
|
182
|
+
except subprocess.CalledProcessError:
|
|
183
|
+
continue
|
|
184
|
+
found: dict[str, dict] = {}
|
|
185
|
+
if out is None:
|
|
186
|
+
return found
|
|
187
|
+
parts = out.split(b"\0")
|
|
188
|
+
for i in range(0, len(parts) - 2, 3):
|
|
189
|
+
value = _attr_value(parts[i + 2].decode())
|
|
190
|
+
if value is not None:
|
|
191
|
+
found.setdefault(parts[i].decode("utf-8", "surrogateescape"), {})[
|
|
192
|
+
parts[i + 1].decode()] = value
|
|
193
|
+
return found
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _folder_attrs(root: Path):
|
|
197
|
+
"""``linguist-vendored`` / ``linguist-generated`` from the folder's own .gitattributes
|
|
198
|
+
(outside Git: the top-level file only). Returns path -> {attr: bool}."""
|
|
199
|
+
import pathspec
|
|
200
|
+
|
|
201
|
+
rules = []
|
|
202
|
+
f = root / ".gitattributes"
|
|
203
|
+
if f.is_file():
|
|
204
|
+
for line in f.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
205
|
+
bits = line.strip().split()
|
|
206
|
+
if not bits or bits[0].startswith("#"):
|
|
207
|
+
continue
|
|
208
|
+
attrs = {}
|
|
209
|
+
for a in bits[1:]:
|
|
210
|
+
name, _, val = a.lstrip("-!").partition("=")
|
|
211
|
+
if name in _ATTRS:
|
|
212
|
+
attrs[name] = (False if a.startswith("-") or val == "false" else
|
|
213
|
+
None if a.startswith("!") else True)
|
|
214
|
+
if attrs:
|
|
215
|
+
rules.append((pathspec.GitIgnoreSpec.from_lines([bits[0]]), attrs))
|
|
216
|
+
|
|
217
|
+
def lookup(path: str) -> dict:
|
|
218
|
+
out: dict = {}
|
|
219
|
+
for spec, attrs in rules: # the last matching line wins
|
|
220
|
+
if spec.match_file(path):
|
|
221
|
+
out.update(attrs)
|
|
222
|
+
return {k: v for k, v in out.items() if v is not None}
|
|
223
|
+
return lookup
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _excluded(path: str, attrs: dict) -> bool:
|
|
227
|
+
if any(attrs.get(a) is True for a in _ATTRS):
|
|
228
|
+
return True
|
|
229
|
+
if any(attrs.get(a) is False for a in _ATTRS):
|
|
230
|
+
return binary_path(path) # explicitly counted, unless binary
|
|
231
|
+
return binary_path(path) or path_excluded(path)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _counted(path: str, attrs: dict, lines: int, kind: str) -> bool:
|
|
235
|
+
if kind == "binary":
|
|
236
|
+
return False
|
|
237
|
+
return not (kind == "generated" and attrs.get("linguist-generated") is not False)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# -- counting a commit ----------------------------------------------------------------------
|
|
241
|
+
def _cache_path(top: Path) -> Path | None:
|
|
242
|
+
try:
|
|
243
|
+
out = subprocess.run(["git", "-C", str(top), "rev-parse", "--git-dir"],
|
|
244
|
+
capture_output=True, check=True).stdout.decode().strip()
|
|
245
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
246
|
+
return None
|
|
247
|
+
git_dir = Path(out) if Path(out).is_absolute() else top / out
|
|
248
|
+
return git_dir / "codeimprint-lines.json"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _load_cache(path: Path | None) -> dict:
|
|
252
|
+
try:
|
|
253
|
+
doc = json.loads(path.read_text()) if path else {}
|
|
254
|
+
return doc.get("blobs", {}) if doc.get("rules") == RULES else {}
|
|
255
|
+
except (OSError, ValueError, AttributeError):
|
|
256
|
+
return {}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _save_cache(path: Path | None, blobs: dict) -> None:
|
|
260
|
+
if not path:
|
|
261
|
+
return
|
|
262
|
+
try:
|
|
263
|
+
tmp = path.with_suffix(".tmp")
|
|
264
|
+
tmp.write_text(json.dumps({"rules": RULES, "blobs": blobs}, separators=(",", ":")))
|
|
265
|
+
os.replace(tmp, path)
|
|
266
|
+
except OSError:
|
|
267
|
+
pass # a cache only: never fails a seal
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _stream_blobs(top: Path, blob_ids: list[str]):
|
|
271
|
+
"""(blob, data) for each ID through one ``git cat-file --batch``, written by a thread so
|
|
272
|
+
a large repository never waits on a round trip per file."""
|
|
273
|
+
proc = subprocess.Popen(["git", "-C", str(top), "cat-file", "--batch"],
|
|
274
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
|
275
|
+
|
|
276
|
+
def feed():
|
|
277
|
+
try:
|
|
278
|
+
for blob in blob_ids:
|
|
279
|
+
proc.stdin.write(blob.encode() + b"\n")
|
|
280
|
+
finally:
|
|
281
|
+
proc.stdin.close()
|
|
282
|
+
|
|
283
|
+
writer = threading.Thread(target=feed, daemon=True)
|
|
284
|
+
writer.start()
|
|
285
|
+
try:
|
|
286
|
+
for blob in blob_ids:
|
|
287
|
+
header = proc.stdout.readline().split()
|
|
288
|
+
if len(header) != 3:
|
|
289
|
+
raise RuntimeError(f"blob {blob} is missing from the repository")
|
|
290
|
+
data = proc.stdout.read(int(header[2]))
|
|
291
|
+
proc.stdout.read(1)
|
|
292
|
+
yield blob, data
|
|
293
|
+
finally:
|
|
294
|
+
writer.join()
|
|
295
|
+
proc.stdout.close()
|
|
296
|
+
proc.wait()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def count_commit(top: Path, commit: str, files: list[dict], under: str = "") -> Count:
|
|
300
|
+
"""Count the files of a commit (``gitrepo.ls_tree`` entries, paths relative to ``under``,
|
|
301
|
+
the sealed folder inside the repository)."""
|
|
302
|
+
full = [under + f["path"] for f in files]
|
|
303
|
+
attrs = _git_attrs(top, commit, full)
|
|
304
|
+
candidates = [(f, path) for f, path in zip(files, full)
|
|
305
|
+
if not _excluded(path, attrs.get(path, {}))
|
|
306
|
+
and int(f.get("size") or 0) <= MAX_FILE_BYTES]
|
|
307
|
+
cache_file = _cache_path(top)
|
|
308
|
+
cache = _load_cache(cache_file)
|
|
309
|
+
wanted = sorted({f["blob"] for f, _ in candidates if f["blob"] not in cache})
|
|
310
|
+
for blob, data in _stream_blobs(top, wanted) if wanted else ():
|
|
311
|
+
cache[blob] = list(measure(data))
|
|
312
|
+
count = Count()
|
|
313
|
+
for f, path in candidates:
|
|
314
|
+
lines, kind = cache[f["blob"]]
|
|
315
|
+
if _counted(path, attrs.get(path, {}), lines, kind):
|
|
316
|
+
count.add(path, lines)
|
|
317
|
+
used = {f["blob"] for f in files}
|
|
318
|
+
_save_cache(cache_file, {b: v for b, v in cache.items() if b in used})
|
|
319
|
+
return count
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def count_folder(root: Path, paths: list[str]) -> Count:
|
|
323
|
+
"""Count the files of a folder outside Git (or sealed with --zip): ``paths`` are the
|
|
324
|
+
folder-relative files the client would send, every type (``project.select_files``)."""
|
|
325
|
+
lookup = _folder_attrs(root)
|
|
326
|
+
count = Count()
|
|
327
|
+
for path in paths:
|
|
328
|
+
attrs = lookup(path)
|
|
329
|
+
if _excluded(path, attrs):
|
|
330
|
+
continue
|
|
331
|
+
file = root / path
|
|
332
|
+
try:
|
|
333
|
+
if file.stat().st_size > MAX_FILE_BYTES:
|
|
334
|
+
continue
|
|
335
|
+
lines, kind = measure(file.read_bytes())
|
|
336
|
+
except OSError:
|
|
337
|
+
continue
|
|
338
|
+
if _counted(path, attrs, lines, kind):
|
|
339
|
+
count.add(path, lines)
|
|
340
|
+
return count
|