Skill Tracer
Skills › gstack › gstack › garry-output-comparison.ts

garry-output-comparison.ts

434 lines · scripts/garry-output-comparison.ts at 3f79d81

1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/**
3 * 2013 vs 2026 output throughput comparison.
4 *
5 * Rationale: the README hero used to brag "600,000+ lines of production code" as
6 * a proxy for productivity. After Louise de Sadeleer's review
7 * (https://x.com/LouiseDSadeleer/status/2045139351227478199) called out LOC as
8 * a vanity metric when AI writes most of the code, we replaced it with a real
9 * pro-rata multiple on logical code change: non-blank, non-comment lines added
10 * across authored commits in public repos, computed for 2013 and 2026.
11 *
12 * Algorithm (per Codex Pass 2 review in PLAN_TUNING_V1):
13 * 1. For each year (2013, 2026), enumerate authored commits. Author filter
14 * comes from --email CLI flags (repeatable), the GSTACK_AUTHOR_EMAILS env
15 * var (comma-separated), or falls back to `git config user.email`.
16 * 2. For each commit, git diff <commit>^ <commit> produces a unified diff.
17 * 3. Extract ADDED lines from the diff. Classify as "logical" by filtering
18 * out blank lines + single-line comments (per-language regex; imperfect
19 * but honest — better than raw LOC).
20 * 4. Sum per year. Report raw additions + logical additions + per-language
21 * breakdown + caveats. Caveats matter: public repos only, commit-style drift,
22 * private work exclusion.
23 *
24 * Requires: scc (for classification when available; falls back to regex).
25 * Run: bun run scripts/garry-output-comparison.ts [--repo-root <path>] [--email <addr>...]
26 * GSTACK_AUTHOR_EMAILS=a@x.com,b@y.com bun run scripts/garry-output-comparison.ts
27 * Output: docs/throughput-2013-vs-2026.json
28 */
29import * as fs from 'fs';
30import * as path from 'path';
31import { execSync } from 'child_process';
33function resolveAuthorEmails(argv: string[]): string[] {
34 const fromArgs: string[] = [];
35 for (let i = 0; i < argv.length; i++) {
36 if (argv[i] === '--email' && argv[i + 1]) {
37 fromArgs.push(argv[i + 1]);
38 i++;
39 }
40 }
41 if (fromArgs.length > 0) return fromArgs;
43 const envVar = process.env.GSTACK_AUTHOR_EMAILS;
44 if (envVar && envVar.trim()) {
45 return envVar.split(',').map(s => s.trim()).filter(Boolean);
46 }
48 try {
49 const gitEmail = execSync('git config user.email', {
50 encoding: 'utf-8',
51 stdio: ['ignore', 'pipe', 'ignore'],
52 }).trim();
53 if (gitEmail) return [gitEmail];
54 } catch {
55 // fall through
56 }
58 process.stderr.write(
59 'No author email configured. Pass --email <addr> (repeatable), ' +
60 'set GSTACK_AUTHOR_EMAILS=a@x.com,b@y.com, or configure git user.email.\n'
61 );
62 process.exit(1);
65const TARGET_YEARS = [2013, 2026];
67// Repos to skip entirely because they're not real shipping work (demos, spikes,
68// vendored imports, throwaway experiments). When the script is pointed at one
69// of these, it emits a stderr note and exits without writing a per-repo JSON.
70// Add more via PR with a one-line rationale.
71const EXCLUDED_REPOS: Record<string, string> = {
72 'tax-app': 'demo app for an upcoming YC channel video, not production shipping work',
73};
75type PerYearResult = {
76 year: number;
77 active: boolean;
78 commits: number;
79 files_touched: number;
80 raw_lines_added: number;
81 logical_lines_added: number;
82 active_weeks: number;
83 days_elapsed: number; // 365 for past years; day-of-year for current year
84 is_partial: boolean; // true for current year (2026 today), false for past
85 per_day_rate: { // per calendar day (incl. non-active days)
86 logical: number;
87 raw: number;
88 commits: number;
89 };
90 annualized_projection: { // per_day_rate × 365 — what the year looks like if pace holds
91 logical: number;
92 raw: number;
93 commits: number;
94 };
95 per_language: Record<string, { commits: number; logical_added: number }>;
96 caveats: string[];
97};
99type Output = {
100 computed_at: string;
101 scc_available: boolean;
102 years: PerYearResult[];
103 multiples: {
104 // TO-DATE: raw totals. Compares full 2013 year vs (possibly partial) 2026.
105 // Answers: "How much has been produced so far?"
106 to_date: {
107 logical_lines_added: number | null;
108 raw_lines_added: number | null;
109 commits: number | null;
110 files_touched: number | null;
111 };
112 // RUN RATE: per-day pace, apples-to-apples regardless of calendar coverage.
113 // Answers: "What's the pace at, normalized for time elapsed?"
114 run_rate: {
115 logical_per_day: number | null;
116 raw_per_day: number | null;
117 commits_per_day: number | null;
118 };
119 // Deprecated: kept for backwards-compat with older consumers reading the JSON.
120 // Aliases `to_date.logical_lines_added` — will be removed in a future version.
121 logical_lines_added: number | null;
122 };
123 caveats_global: string[];
124 version: number;
125};
127function hasScc(): boolean {
128 try {
129 execSync('command -v scc', { stdio: 'ignore' });
130 return true;
131 } catch {
132 return false;
133 }
136function printSccHint(): void {
137 const hint = [
138 '',
139 'scc is required for language classification of added lines.',
140 'Run: bash scripts/setup-scc.sh',
141 ' (macOS: brew install scc)',
142 ' (Linux: apt install scc, or download from github.com/boyter/scc/releases)',
143 ' (Windows: github.com/boyter/scc/releases)',
144 '',
145 ].join('\n');
146 process.stderr.write(hint);
149/**
150 * Crude per-language comment-line filter. Used only when scc is unavailable.
151 * This is a honest approximation — it excludes obvious comment markers but
152 * won't catch block comments, docstrings, or language-specific subtleties.
153 * The output JSON flags this as an approximation via the `scc_available` field.
154 */
155function isLogicalLine(line: string): boolean {
156 const trimmed = line.replace(/^\+/, '').trim();
157 if (trimmed === '') return false;
158 if (trimmed.startsWith('//')) return false; // JS/TS/Go/Rust/etc
159 if (trimmed.startsWith('#')) return false; // Python/Ruby/shell
160 if (trimmed.startsWith('--')) return false; // SQL/Haskell/Lua
161 if (trimmed.startsWith(';')) return false; // Lisp/Clojure
162 if (trimmed.startsWith('/*')) return false; // C-style block start
163 if (trimmed.startsWith('*') && trimmed.length < 80) return false; // C-style block middle
164 if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) return false; // Python docstrings
165 return true;
168function enumerateCommits(year: number, repoPath: string, authorEmails: string[]): string[] {
169 const since = `${year}-01-01`;
170 const until = `${year}-12-31`;
171 const authorFlags = authorEmails.map(e => `--author=${e}`).join(' ');
172 try {
173 const cmd = `git -C "${repoPath}" log --since=${since} --until=${until} ${authorFlags} --pretty=format:'%H' 2>/dev/null`;
174 const out = execSync(cmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
175 return out.split('\n').filter(l => /^[0-9a-f]{40}$/.test(l.trim()));
176 } catch {
177 return [];
178 }
181function analyzeCommit(commit: string, repoPath: string, sccAvailable: boolean): {
182 raw: number; logical: number; filesTouched: number; perLang: Record<string, number>;
183} {
184 // Use --no-renames to avoid double-counting R100 renames
185 let diff = '';
186 try {
187 diff = execSync(
188 `git -C "${repoPath}" show --no-renames --format= --unified=0 ${commit}`,
189 { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 50 * 1024 * 1024 }
190 );
191 } catch {
192 return { raw: 0, logical: 0, filesTouched: 0, perLang: {} };
193 }
195 const lines = diff.split('\n');
196 let raw = 0;
197 let logical = 0;
198 const files = new Set<string>();
199 const perLang: Record<string, number> = {};
200 let currentFile = '';
201 let currentExt = '';
203 for (const line of lines) {
204 if (line.startsWith('+++ b/')) {
205 currentFile = line.slice('+++ b/'.length).trim();
206 if (currentFile && currentFile !== '/dev/null') {
207 files.add(currentFile);
208 currentExt = path.extname(currentFile).slice(1) || 'other';
209 }
210 continue;
211 }
212 if (line.startsWith('+') && !line.startsWith('+++')) {
213 raw += 1;
214 if (isLogicalLine(line)) {
215 logical += 1;
216 perLang[currentExt] = (perLang[currentExt] || 0) + 1;
217 }
218 }
219 }
221 return { raw, logical, filesTouched: files.size, perLang };
222 // Note: sccAvailable is currently unused — in a future version we could pipe
223 // added lines through `scc --stdin` for better per-language SLOC. For now the
224 // regex fallback is what ships; the output flags this honestly.
225 void sccAvailable;
228/**
229 * Days elapsed in the given year as of `now`. For past years returns 365
230 * (366 for leap years). For the current year returns the day-of-year
231 * through `now`. For future years returns 0.
232 */
233function daysElapsed(year: number, now: Date = new Date()): number {
234 const currentYear = now.getUTCFullYear();
235 if (year < currentYear) {
236 const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
237 return isLeap ? 366 : 365;
238 }
239 if (year > currentYear) return 0;
240 // Current year: days since Jan 1 inclusive
241 const jan1 = new Date(Date.UTC(year, 0, 1));
242 const diffMs = now.getTime() - jan1.getTime();
243 return Math.max(1, Math.floor(diffMs / (24 * 60 * 60 * 1000)) + 1);
246function analyzeRepo(repoPath: string, year: number, authorEmails: string[], sccAvailable: boolean, now: Date = new Date()): PerYearResult {
247 const commits = enumerateCommits(year, repoPath, authorEmails);
248 const perLang: Record<string, { commits: number; logical_added: number }> = {};
249 let rawTotal = 0;
250 let logicalTotal = 0;
251 let filesTotal = 0;
252 const weeks = new Set<string>();
254 for (const commit of commits) {
255 const r = analyzeCommit(commit, repoPath, sccAvailable);
256 rawTotal += r.raw;
257 logicalTotal += r.logical;
258 filesTotal += r.filesTouched;
259 for (const [ext, count] of Object.entries(r.perLang)) {
260 if (!perLang[ext]) perLang[ext] = { commits: 0, logical_added: 0 };
261 perLang[ext].logical_added += count;
262 perLang[ext].commits += 1;
263 }
264 // Bucket commit into ISO week
265 try {
266 const dateStr = execSync(
267 `git -C "${repoPath}" show --format=%cI --no-patch ${commit}`,
268 { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }
269 ).trim();
270 if (dateStr) {
271 const d = new Date(dateStr);
272 const weekStart = new Date(d);
273 weekStart.setDate(d.getDate() - d.getDay());
274 weeks.add(weekStart.toISOString().slice(0, 10));
275 }
276 } catch {
277 // ignore
278 }
279 }
281 const days = daysElapsed(year, now);
282 const isPartial = year === now.getUTCFullYear();
283 const perDayLogical = days > 0 ? logicalTotal / days : 0;
284 const perDayRaw = days > 0 ? rawTotal / days : 0;
285 const perDayCommits = days > 0 ? commits.length / days : 0;
287 return {
288 year,
289 active: commits.length > 0,
290 commits: commits.length,
291 files_touched: filesTotal,
292 raw_lines_added: rawTotal,
293 logical_lines_added: logicalTotal,
294 active_weeks: weeks.size,
295 days_elapsed: days,
296 is_partial: isPartial,
297 per_day_rate: {
298 logical: +perDayLogical.toFixed(2),
299 raw: +perDayRaw.toFixed(2),
300 commits: +perDayCommits.toFixed(3),
301 },
302 annualized_projection: {
303 logical: Math.round(perDayLogical * 365),
304 raw: Math.round(perDayRaw * 365),
305 commits: Math.round(perDayCommits * 365),
306 },
307 per_language: perLang,
308 caveats: commits.length === 0
309 ? [`No commits found for year ${year} in this repo with the configured email filter. If private work existed in this era, it is excluded.`]
310 : (isPartial ? [`Year ${year} is partial (day ${days} of 365). Run-rate multiple extrapolates current pace.`] : []),
311 };
314function main() {
315 const args = process.argv.slice(2);
316 const repoRootIdx = args.indexOf('--repo-root');
317 const repoRoot = repoRootIdx >= 0 && args[repoRootIdx + 1]
318 ? args[repoRootIdx + 1]
319 : process.cwd();
321 // Check exclusion list — skip with stderr note if repo basename matches.
322 // Also delete any stale output JSON so aggregation loops don't pick up
323 // numbers from a pre-exclusion run.
324 const repoBasename = path.basename(path.resolve(repoRoot));
325 if (EXCLUDED_REPOS[repoBasename]) {
326 const staleOutput = path.join(repoRoot, 'docs', 'throughput-2013-vs-2026.json');
327 if (fs.existsSync(staleOutput)) fs.unlinkSync(staleOutput);
328 process.stderr.write(
329 `Skipping ${repoBasename}: ${EXCLUDED_REPOS[repoBasename]}\n` +
330 `(add/remove in EXCLUDED_REPOS at the top of this script)\n`
331 );
332 process.exit(0);
333 }
335 const sccAvailable = hasScc();
336 if (!sccAvailable) {
337 printSccHint();
338 process.stderr.write('Continuing with regex-based logical-line classification (an approximation).\n\n');
339 }
341 const authorEmails = resolveAuthorEmails(args);
343 // For V1, we analyze the single repo at repoRoot. Future work: enumerate
344 // public repos via GitHub API + clone each into a cache dir.
345 const now = new Date();
346 const years = TARGET_YEARS.map(y => analyzeRepo(repoRoot, y, authorEmails, sccAvailable, now));
348 const y2013 = years.find(y => y.year === 2013);
349 const y2026 = years.find(y => y.year === 2026);
351 // Both multiples live in the same output — they measure different things:
352 //
353 // to_date = raw totals. "How much did 2026 produce so far?"
354 // (mixes full-year 2013 vs partial 2026; honest about volume)
355 // run_rate = per-day pace. "What's the throughput rate, normalized?"
356 // (apples-to-apples regardless of how much of 2026 has elapsed)
357 const toDate = {
358 logical_lines_added: (y2013?.active && y2013.logical_lines_added > 0 && y2026?.active)
359 ? +(y2026.logical_lines_added / y2013.logical_lines_added).toFixed(1)
360 : null,
361 raw_lines_added: (y2013?.active && y2013.raw_lines_added > 0 && y2026?.active)
362 ? +(y2026.raw_lines_added / y2013.raw_lines_added).toFixed(1)
363 : null,
364 commits: (y2013?.active && y2013.commits > 0 && y2026?.active)
365 ? +(y2026.commits / y2013.commits).toFixed(1)
366 : null,
367 files_touched: (y2013?.active && y2013.files_touched > 0 && y2026?.active)
368 ? +(y2026.files_touched / y2013.files_touched).toFixed(1)
369 : null,
370 };
372 const runRate = {
373 logical_per_day: (y2013?.per_day_rate.logical && y2013.per_day_rate.logical > 0 && y2026?.active)
374 ? +(y2026.per_day_rate.logical / y2013.per_day_rate.logical).toFixed(1)
375 : null,
376 raw_per_day: (y2013?.per_day_rate.raw && y2013.per_day_rate.raw > 0 && y2026?.active)
377 ? +(y2026.per_day_rate.raw / y2013.per_day_rate.raw).toFixed(1)
378 : null,
379 commits_per_day: (y2013?.per_day_rate.commits && y2013.per_day_rate.commits > 0 && y2026?.active)
380 ? +(y2026.per_day_rate.commits / y2013.per_day_rate.commits).toFixed(1)
381 : null,
382 };
384 const multiples = {
385 to_date: toDate,
386 run_rate: runRate,
387 // Back-compat alias — older consumers read `multiples.logical_lines_added`.
388 logical_lines_added: toDate.logical_lines_added,
389 };
391 const output: Output = {
392 computed_at: new Date().toISOString(),
393 scc_available: sccAvailable,
394 years,
395 multiples,
396 caveats_global: [
397 'Public repos only. Private work at both eras is excluded to make the comparison apples-to-apples.',
398 '2013 and 2026 may differ in commit-style: 2013 tends toward monolithic commits, 2026 tends toward smaller AI-assisted commits. Multiples reflect this drift.',
399 sccAvailable
400 ? 'Logical-line classification uses scc-aware regex (approximate).'
401 : 'Logical-line classification uses a crude regex fallback (scc not installed). Exclude blank lines + single-line comments; does not catch block comments or docstrings. Approximate.',
402 'This script analyzes a single repo at a time. Full 2013-vs-2026 picture requires running against every public repo with commits in both years and summing results (future work).',
403 'Authorship attribution relies on commit email matching. Supply historical aliases via --email flags or GSTACK_AUTHOR_EMAILS.',
404 ],
405 version: 1,
406 };
408 const outDir = path.join(repoRoot, 'docs');
409 const outPath = path.join(outDir, 'throughput-2013-vs-2026.json');
410 fs.mkdirSync(outDir, { recursive: true });
411 fs.writeFileSync(outPath, JSON.stringify(output, null, 2) + '\n');
413 process.stderr.write(`Wrote ${outPath}\n`);
414 process.stderr.write(
415 `2013: ${y2013?.logical_lines_added ?? 'n/a'} logical added (${y2013?.days_elapsed ?? '?'}d) | ` +
416 `2026: ${y2026?.logical_lines_added ?? 'n/a'} logical added (${y2026?.days_elapsed ?? '?'}d, ${y2026?.is_partial ? 'partial' : 'full'})\n`
417 );
418 if (toDate.logical_lines_added !== null) {
419 process.stderr.write(`TO-DATE multiple (raw volume): ${toDate.logical_lines_added}× logical, ${toDate.raw_lines_added}× raw\n`);
420 }
421 if (runRate.logical_per_day !== null) {
422 process.stderr.write(
423 `RUN-RATE multiple (per-day pace): ${runRate.logical_per_day}× logical/day, ${runRate.commits_per_day}× commits/day\n` +
424 ` 2013 pace: ${y2013?.per_day_rate.logical.toFixed(1) ?? '?'} logical/day | ` +
425 `2026 pace: ${y2026?.per_day_rate.logical.toFixed(1) ?? '?'} logical/day | ` +
426 `2026 annualized: ${y2026?.annualized_projection.logical.toLocaleString() ?? '?'} logical/year projected\n`
427 );
428 }
429 if (toDate.logical_lines_added === null && runRate.logical_per_day === null) {
430 process.stderr.write(`No multiple computable (one or both years inactive in this repo).\n`);
431 }
434main();