codebase-receipts-cli 1.0.0__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.
- codebase_receipts_cli-1.0.0.dist-info/METADATA +268 -0
- codebase_receipts_cli-1.0.0.dist-info/RECORD +78 -0
- codebase_receipts_cli-1.0.0.dist-info/WHEEL +4 -0
- codebase_receipts_cli-1.0.0.dist-info/entry_points.txt +2 -0
- codebase_receipts_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- receipts/__init__.py +0 -0
- receipts/ama/__init__.py +0 -0
- receipts/ama/interviewer.py +415 -0
- receipts/ats/__init__.py +1 -0
- receipts/ats/comparator.py +98 -0
- receipts/ats/scorer.py +550 -0
- receipts/ats/stats.py +164 -0
- receipts/cli.py +2062 -0
- receipts/config.py +106 -0
- receipts/errors.py +18 -0
- receipts/export.py +120 -0
- receipts/ingest/__init__.py +0 -0
- receipts/ingest/artifact_extractor.py +679 -0
- receipts/ingest/git_source.py +159 -0
- receipts/ingest/manifest.py +114 -0
- receipts/ingest/scanner.py +141 -0
- receipts/ingest/secrets_scanner.py +225 -0
- receipts/interactive/__init__.py +1 -0
- receipts/interactive/repl.py +755 -0
- receipts/ledger/__init__.py +0 -0
- receipts/ledger/pricing_table.py +54 -0
- receipts/ledger/token_ledger.py +226 -0
- receipts/llm/__init__.py +0 -0
- receipts/llm/anthropic_provider.py +95 -0
- receipts/llm/factory.py +124 -0
- receipts/llm/fake_provider.py +79 -0
- receipts/llm/gemini_provider.py +205 -0
- receipts/llm/json_utils.py +46 -0
- receipts/llm/metered.py +62 -0
- receipts/llm/ollama_provider.py +117 -0
- receipts/llm/openai_provider.py +118 -0
- receipts/llm/provider.py +42 -0
- receipts/llm/split.py +78 -0
- receipts/llm/token_estimate.py +35 -0
- receipts/mine/__init__.py +1 -0
- receipts/mine/code_metrics.py +246 -0
- receipts/mine/git_evidence.py +109 -0
- receipts/prep/__init__.py +1 -0
- receipts/prep/dossier.py +313 -0
- receipts/prep/readiness.py +227 -0
- receipts/resume/__init__.py +0 -0
- receipts/resume/claim_extractor.py +338 -0
- receipts/resume/loader.py +35 -0
- receipts/resume/pdf_parser.py +259 -0
- receipts/resume/tex_parser.py +394 -0
- receipts/rewrite/__init__.py +0 -0
- receipts/rewrite/compiler.py +180 -0
- receipts/rewrite/optimizer.py +140 -0
- receipts/rewrite/tex_rewriter.py +227 -0
- receipts/tui/__init__.py +0 -0
- receipts/tui/ama_app.py +454 -0
- receipts/tui/app.py +316 -0
- receipts/tui/dossier_app.py +170 -0
- receipts/tui/hub.py +367 -0
- receipts/tui/ingest_app.py +183 -0
- receipts/tui/rewrite_app.py +463 -0
- receipts/tui/score_app.py +237 -0
- receipts/tui/widgets/__init__.py +0 -0
- receipts/tui/widgets/claims_table.py +50 -0
- receipts/tui/widgets/diff_view.py +38 -0
- receipts/tui/widgets/status_bar.py +38 -0
- receipts/verify/__init__.py +0 -0
- receipts/verify/bm25.py +112 -0
- receipts/verify/enrich.py +128 -0
- receipts/verify/jd_fetch.py +101 -0
- receipts/verify/kb.py +379 -0
- receipts/verify/keyword_gap.py +127 -0
- receipts/verify/query_expand.py +88 -0
- receipts/verify/rerank.py +74 -0
- receipts/verify/rewriter.py +172 -0
- receipts/verify/router.py +211 -0
- receipts/verify/summaries.py +258 -0
- receipts/verify/verifier.py +552 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Local token-count estimation for providers that don't report usage.
|
|
2
|
+
|
|
3
|
+
Uses tiktoken's ``cl100k_base`` as a reasonable cross-model approximation.
|
|
4
|
+
Counts produced here are ESTIMATES and must be labeled as such in the ledger
|
|
5
|
+
(``CompletionResult.tokens_estimated=True``).
|
|
6
|
+
|
|
7
|
+
tiktoken downloads its encoding file on first use, so this module is only
|
|
8
|
+
imported by providers that already require network/daemon access (Ollama) —
|
|
9
|
+
never by the fake provider or the test suite.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
|
|
16
|
+
_CHARS_PER_TOKEN_FALLBACK = 4
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@functools.lru_cache(maxsize=1)
|
|
20
|
+
def _encoding():
|
|
21
|
+
import tiktoken
|
|
22
|
+
|
|
23
|
+
return tiktoken.get_encoding("cl100k_base")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def estimate_tokens(text: str) -> int:
|
|
27
|
+
"""Estimated token count for ``text`` — never a measured value."""
|
|
28
|
+
if not text:
|
|
29
|
+
return 0
|
|
30
|
+
# tiktoken 0.13.x has a known Windows crash (access violation in the Rust
|
|
31
|
+
# backend) on certain inputs when called from a large scan loop. The
|
|
32
|
+
# chars/4 heuristic is accurate enough for the token-budget gate in the
|
|
33
|
+
# artifact extractor, and tiktoken is still used by Ollama for per-call
|
|
34
|
+
# ledger estimates where individual calls are short and isolated.
|
|
35
|
+
return max(1, len(text) // _CHARS_PER_TOKEN_FALLBACK)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Evidence mining — provable facts from code and git history, zero LLM calls."""
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Static code metrics miner — counts things the codebase can PROVE.
|
|
2
|
+
|
|
3
|
+
Zero LLM calls. Walks the scanned file tree (respecting .gitignore) and
|
|
4
|
+
counts concrete, checkable artifacts: API routes, database models,
|
|
5
|
+
migrations, UI components, tests, service modules, and lines of code per
|
|
6
|
+
language. Every count carries file-level provenance so a number in a
|
|
7
|
+
rewritten resume bullet can always be traced back to the files that
|
|
8
|
+
produced it.
|
|
9
|
+
|
|
10
|
+
Regex-based on purpose: it must work identically on Windows (where
|
|
11
|
+
tree-sitter support is spotty) and the patterns only need to be precise
|
|
12
|
+
enough to count, not to parse.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from receipts.ingest import scanner
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Data model
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class MetricCount:
|
|
30
|
+
key: str # machine name, e.g. "api_routes"
|
|
31
|
+
label: str # human name, e.g. "API route definitions"
|
|
32
|
+
count: int
|
|
33
|
+
files: list[str] = field(default_factory=list) # provenance (rel paths)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CodeFacts:
|
|
38
|
+
metrics: list[MetricCount]
|
|
39
|
+
languages: dict[str, int] # language -> lines of code
|
|
40
|
+
total_files: int
|
|
41
|
+
|
|
42
|
+
def metric(self, key: str) -> MetricCount | None:
|
|
43
|
+
for m in self.metrics:
|
|
44
|
+
if m.key == key:
|
|
45
|
+
return m
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def summary_lines(self) -> list[str]:
|
|
49
|
+
"""Human-readable one-liners for CLI/TUI output."""
|
|
50
|
+
lines = [f"{m.label}: {m.count}" for m in self.metrics if m.count > 0]
|
|
51
|
+
if self.languages:
|
|
52
|
+
top = sorted(self.languages.items(), key=lambda kv: -kv[1])[:5]
|
|
53
|
+
lang_str = ", ".join(f"{name} ({loc:,} LOC)" for name, loc in top)
|
|
54
|
+
lines.append(f"Languages: {lang_str}")
|
|
55
|
+
return lines
|
|
56
|
+
|
|
57
|
+
def prompt_facts(self) -> str:
|
|
58
|
+
"""Facts formatted for a rewrite prompt — numbers with provenance."""
|
|
59
|
+
lines: list[str] = []
|
|
60
|
+
for m in self.metrics:
|
|
61
|
+
if m.count == 0:
|
|
62
|
+
continue
|
|
63
|
+
examples = ", ".join(m.files[:3])
|
|
64
|
+
suffix = f" (e.g. {examples})" if examples else ""
|
|
65
|
+
lines.append(f"- {m.count} {m.label.lower()}{suffix}")
|
|
66
|
+
if self.languages:
|
|
67
|
+
top = sorted(self.languages.items(), key=lambda kv: -kv[1])[:5]
|
|
68
|
+
lines.append(
|
|
69
|
+
"- languages by size: "
|
|
70
|
+
+ ", ".join(f"{name} ({loc:,} lines)" for name, loc in top)
|
|
71
|
+
)
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Pattern-based counters
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# FastAPI/Flask decorators and Express-style route registrations.
|
|
80
|
+
_PY_ROUTE_RE = re.compile(
|
|
81
|
+
r"@(?:\w+\.)?(?:app|router|api_router|blueprint|bp)\."
|
|
82
|
+
r"(?:get|post|put|delete|patch|route|websocket)\s*\(",
|
|
83
|
+
re.IGNORECASE,
|
|
84
|
+
)
|
|
85
|
+
_JS_ROUTE_RE = re.compile(
|
|
86
|
+
r"\b(?:app|router)\.(?:get|post|put|delete|patch)\s*\(\s*['\"`]/"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# SQLAlchemy declarative / Django / generic ORM model classes.
|
|
90
|
+
_PY_MODEL_RE = re.compile(
|
|
91
|
+
r"^class\s+\w+\s*\((?:[^)]*\b(?:Base|db\.Model|models\.Model)\b)[^)]*\)\s*:",
|
|
92
|
+
re.MULTILINE,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# React/Vue-style component definitions in .tsx/.jsx files.
|
|
96
|
+
_COMPONENT_RE = re.compile(
|
|
97
|
+
r"(?:export\s+(?:default\s+)?)?(?:function|const)\s+([A-Z]\w+)\s*(?:=|\()"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
_PY_TEST_RE = re.compile(r"^def\s+test_\w+", re.MULTILINE)
|
|
101
|
+
_JS_TEST_RE = re.compile(r"^\s*(?:it|test)\s*\(\s*['\"`]", re.MULTILINE)
|
|
102
|
+
|
|
103
|
+
_EXT_LANGUAGE = {
|
|
104
|
+
".py": "Python",
|
|
105
|
+
".ts": "TypeScript",
|
|
106
|
+
".tsx": "TypeScript",
|
|
107
|
+
".js": "JavaScript",
|
|
108
|
+
".jsx": "JavaScript",
|
|
109
|
+
".java": "Java",
|
|
110
|
+
".kt": "Kotlin",
|
|
111
|
+
".go": "Go",
|
|
112
|
+
".rs": "Rust",
|
|
113
|
+
".rb": "Ruby",
|
|
114
|
+
".sql": "SQL",
|
|
115
|
+
".html": "HTML",
|
|
116
|
+
".css": "CSS",
|
|
117
|
+
".sh": "Shell",
|
|
118
|
+
".yaml": "YAML",
|
|
119
|
+
".yml": "YAML",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _count_matches(pattern: re.Pattern[str], content: str) -> int:
|
|
124
|
+
return len(pattern.findall(content))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _is_migration(rel_path: str) -> bool:
|
|
128
|
+
lowered = rel_path.lower()
|
|
129
|
+
return (
|
|
130
|
+
(
|
|
131
|
+
"alembic/versions/" in lowered
|
|
132
|
+
or "/migrations/" in lowered
|
|
133
|
+
or lowered.startswith("migrations/")
|
|
134
|
+
)
|
|
135
|
+
and lowered.endswith(".py")
|
|
136
|
+
and not lowered.endswith("__init__.py")
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_service_module(rel_path: str) -> bool:
|
|
141
|
+
lowered = rel_path.lower()
|
|
142
|
+
name = lowered.rsplit("/", 1)[-1]
|
|
143
|
+
return name.endswith("_service.py") or (
|
|
144
|
+
"/services/" in lowered and name.endswith(".py") and name != "__init__.py"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Public API
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def mine_code_facts(root: Path) -> CodeFacts:
|
|
154
|
+
"""Mine provable counts from a codebase folder. No network, no LLM."""
|
|
155
|
+
result = scanner.scan(root)
|
|
156
|
+
|
|
157
|
+
routes = 0
|
|
158
|
+
route_files: list[str] = []
|
|
159
|
+
models = 0
|
|
160
|
+
model_files: list[str] = []
|
|
161
|
+
components = 0
|
|
162
|
+
component_files: list[str] = []
|
|
163
|
+
tests = 0
|
|
164
|
+
test_files: list[str] = []
|
|
165
|
+
migration_files: list[str] = []
|
|
166
|
+
service_files: list[str] = []
|
|
167
|
+
languages: dict[str, int] = {}
|
|
168
|
+
|
|
169
|
+
for file in result.text_files:
|
|
170
|
+
rel = file.rel_path
|
|
171
|
+
ext = "." + rel.rsplit(".", 1)[-1].lower() if "." in rel else ""
|
|
172
|
+
|
|
173
|
+
language = _EXT_LANGUAGE.get(ext)
|
|
174
|
+
content: str | None = None
|
|
175
|
+
if language or ext in (".py", ".ts", ".tsx", ".js", ".jsx"):
|
|
176
|
+
content = scanner.read_text(root, rel)
|
|
177
|
+
|
|
178
|
+
if language and content is not None:
|
|
179
|
+
loc = sum(1 for line in content.splitlines() if line.strip())
|
|
180
|
+
languages[language] = languages.get(language, 0) + loc
|
|
181
|
+
|
|
182
|
+
if content is None:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if ext == ".py":
|
|
186
|
+
n = _count_matches(_PY_ROUTE_RE, content)
|
|
187
|
+
if n:
|
|
188
|
+
routes += n
|
|
189
|
+
route_files.append(rel)
|
|
190
|
+
n = _count_matches(_PY_MODEL_RE, content)
|
|
191
|
+
if n:
|
|
192
|
+
models += n
|
|
193
|
+
model_files.append(rel)
|
|
194
|
+
n = _count_matches(_PY_TEST_RE, content)
|
|
195
|
+
if n:
|
|
196
|
+
tests += n
|
|
197
|
+
test_files.append(rel)
|
|
198
|
+
if _is_migration(rel):
|
|
199
|
+
migration_files.append(rel)
|
|
200
|
+
if _is_service_module(rel):
|
|
201
|
+
service_files.append(rel)
|
|
202
|
+
|
|
203
|
+
elif ext in (".ts", ".js"):
|
|
204
|
+
n = _count_matches(_JS_ROUTE_RE, content)
|
|
205
|
+
if n:
|
|
206
|
+
routes += n
|
|
207
|
+
route_files.append(rel)
|
|
208
|
+
n = _count_matches(_JS_TEST_RE, content)
|
|
209
|
+
if n:
|
|
210
|
+
tests += n
|
|
211
|
+
test_files.append(rel)
|
|
212
|
+
|
|
213
|
+
elif ext in (".tsx", ".jsx"):
|
|
214
|
+
n = _count_matches(_COMPONENT_RE, content)
|
|
215
|
+
if n:
|
|
216
|
+
components += n
|
|
217
|
+
component_files.append(rel)
|
|
218
|
+
n = _count_matches(_JS_TEST_RE, content)
|
|
219
|
+
if n:
|
|
220
|
+
tests += n
|
|
221
|
+
test_files.append(rel)
|
|
222
|
+
|
|
223
|
+
metrics = [
|
|
224
|
+
MetricCount("api_routes", "API route definitions", routes, route_files),
|
|
225
|
+
MetricCount("db_models", "Database models", models, model_files),
|
|
226
|
+
MetricCount(
|
|
227
|
+
"migrations",
|
|
228
|
+
"Schema migrations",
|
|
229
|
+
len(migration_files),
|
|
230
|
+
migration_files,
|
|
231
|
+
),
|
|
232
|
+
MetricCount("ui_components", "UI components", components, component_files),
|
|
233
|
+
MetricCount(
|
|
234
|
+
"service_modules",
|
|
235
|
+
"Service modules",
|
|
236
|
+
len(service_files),
|
|
237
|
+
service_files,
|
|
238
|
+
),
|
|
239
|
+
MetricCount("tests", "Test functions", tests, test_files),
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
return CodeFacts(
|
|
243
|
+
metrics=metrics,
|
|
244
|
+
languages=languages,
|
|
245
|
+
total_files=len(result.files),
|
|
246
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Git history evidence — proves what code content can't.
|
|
2
|
+
|
|
3
|
+
Duration claims ("2 years"), collaboration claims ("team of 5"), and
|
|
4
|
+
delivery-volume claims ("shipped 40+ features") are invisible to code
|
|
5
|
+
content but provable from commit history. Zero LLM calls; everything is
|
|
6
|
+
read from the local repository. If the folder isn't a git repo (or is a
|
|
7
|
+
shallow clone with no useful history), the result says so honestly —
|
|
8
|
+
never guess.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_MAX_COMMITS = 5000 # backstop against pathological histories
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class GitEvidence:
|
|
22
|
+
available: bool
|
|
23
|
+
reason: str = "" # why unavailable, when available is False
|
|
24
|
+
first_commit_date: str = "" # ISO date
|
|
25
|
+
last_commit_date: str = ""
|
|
26
|
+
active_months: int = 0
|
|
27
|
+
commit_count: int = 0
|
|
28
|
+
contributor_count: int = 0
|
|
29
|
+
contributors: list[str] = field(default_factory=list)
|
|
30
|
+
merge_count: int = 0
|
|
31
|
+
|
|
32
|
+
def summary_lines(self) -> list[str]:
|
|
33
|
+
if not self.available:
|
|
34
|
+
return [f"Git history: unavailable ({self.reason})"]
|
|
35
|
+
span = (
|
|
36
|
+
f"{self.first_commit_date} to {self.last_commit_date}"
|
|
37
|
+
f" (~{self.active_months} month(s))"
|
|
38
|
+
)
|
|
39
|
+
return [
|
|
40
|
+
f"Git history: {self.commit_count} commit(s) over {span}",
|
|
41
|
+
f"Contributors: {self.contributor_count}",
|
|
42
|
+
f"Merges: {self.merge_count}",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
def prompt_facts(self) -> str:
|
|
46
|
+
"""Facts formatted for a rewrite prompt."""
|
|
47
|
+
if not self.available:
|
|
48
|
+
return ""
|
|
49
|
+
lines = [
|
|
50
|
+
f"- development span: {self.first_commit_date} to"
|
|
51
|
+
f" {self.last_commit_date} (~{self.active_months} months)",
|
|
52
|
+
f"- {self.commit_count} commits by"
|
|
53
|
+
f" {self.contributor_count} contributor(s)",
|
|
54
|
+
]
|
|
55
|
+
if self.merge_count:
|
|
56
|
+
lines.append(f"- {self.merge_count} merge commits")
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def mine_git_evidence(root: Path) -> GitEvidence:
|
|
61
|
+
"""Read commit history from a local repo. Never raises for non-repos."""
|
|
62
|
+
try:
|
|
63
|
+
from git import InvalidGitRepositoryError, NoSuchPathError, Repo
|
|
64
|
+
except ImportError: # pragma: no cover - GitPython is a hard dep
|
|
65
|
+
return GitEvidence(available=False, reason="GitPython not installed")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
repo = Repo(root, search_parent_directories=False)
|
|
69
|
+
except (InvalidGitRepositoryError, NoSuchPathError):
|
|
70
|
+
return GitEvidence(available=False, reason="not a git repository")
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
commits = list(repo.iter_commits(max_count=_MAX_COMMITS))
|
|
74
|
+
except Exception as exc: # empty repo, detached corruption, etc.
|
|
75
|
+
return GitEvidence(available=False, reason=f"no readable history: {exc}")
|
|
76
|
+
|
|
77
|
+
if not commits:
|
|
78
|
+
return GitEvidence(available=False, reason="repository has no commits")
|
|
79
|
+
|
|
80
|
+
# iter_commits yields newest first.
|
|
81
|
+
newest = commits[0]
|
|
82
|
+
oldest = commits[-1]
|
|
83
|
+
|
|
84
|
+
def _date(commit) -> datetime:
|
|
85
|
+
return datetime.fromtimestamp(commit.committed_date, tz=timezone.utc)
|
|
86
|
+
|
|
87
|
+
first_date = _date(oldest)
|
|
88
|
+
last_date = _date(newest)
|
|
89
|
+
span_days = max(0, (last_date - first_date).days)
|
|
90
|
+
active_months = max(1, round(span_days / 30.4))
|
|
91
|
+
|
|
92
|
+
authors: set[str] = set()
|
|
93
|
+
merge_count = 0
|
|
94
|
+
for c in commits:
|
|
95
|
+
name = (c.author.name or "").strip() or (c.author.email or "unknown")
|
|
96
|
+
authors.add(name)
|
|
97
|
+
if len(c.parents) > 1:
|
|
98
|
+
merge_count += 1
|
|
99
|
+
|
|
100
|
+
return GitEvidence(
|
|
101
|
+
available=True,
|
|
102
|
+
first_commit_date=first_date.date().isoformat(),
|
|
103
|
+
last_commit_date=last_date.date().isoformat(),
|
|
104
|
+
active_months=active_months,
|
|
105
|
+
commit_count=len(commits),
|
|
106
|
+
contributor_count=len(authors),
|
|
107
|
+
contributors=sorted(authors),
|
|
108
|
+
merge_count=merge_count,
|
|
109
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Interview-prep artifacts — defense dossier and readiness tracking."""
|