|
| 1 | +// Breaks a pull request's changed lines down by package and by kind of change (source, tests, docs, |
| 2 | +// dependencies, ...), so reviewers can see at a glance how big a change is and where it lands. |
| 3 | +// |
| 4 | +// Usage: node .github/scripts/pr-stats.ts <base-revision> <head-revision> |
| 5 | +// |
| 6 | +// The script has no dependencies and only reads git history, so it is safe to run against untrusted |
| 7 | +// pull request revisions. Node runs the TypeScript directly (type stripping, Node 22.18+). |
| 8 | + |
| 9 | +import { execFileSync } from 'node:child_process'; |
| 10 | + |
| 11 | +type Area = 'compiler' | 'sdk' | 'utils' | 'website' | 'examples' | 'repository'; |
| 12 | +type Kind = 'source' | 'tests' | 'docs' | 'dependencies' | 'config' | 'version' | 'generated'; |
| 13 | + |
| 14 | +interface FileChange { |
| 15 | + filePath: string; |
| 16 | + area: Area; |
| 17 | + kind: Kind; |
| 18 | + added: number; |
| 19 | + removed: number; |
| 20 | +} |
| 21 | + |
| 22 | +interface Churn { |
| 23 | + added: number; |
| 24 | + removed: number; |
| 25 | +} |
| 26 | + |
| 27 | +const areaRules: { area: Area; prefix: string }[] = [ |
| 28 | + { area: 'compiler', prefix: 'packages/cashc/' }, |
| 29 | + { area: 'sdk', prefix: 'packages/cashscript/' }, |
| 30 | + { area: 'utils', prefix: 'packages/utils/' }, |
| 31 | + { area: 'website', prefix: 'website/' }, |
| 32 | + { area: 'examples', prefix: 'examples/' }, |
| 33 | +]; |
| 34 | + |
| 35 | +// Order matters: the first matching rule wins, anything unmatched is source. |
| 36 | +const kindRules: { kind: Kind; matches: (filePath: string) => boolean }[] = [ |
| 37 | + { |
| 38 | + kind: 'generated', |
| 39 | + matches: (filePath) => baseName(filePath) === 'yarn.lock' || isGeneratedGrammar(filePath), |
| 40 | + }, |
| 41 | + { kind: 'dependencies', matches: (filePath) => baseName(filePath) === 'package.json' }, |
| 42 | + { kind: 'tests', matches: (filePath) => filePath.includes('/test/') || filePath.endsWith('.test.ts') }, |
| 43 | + // Agent instructions are tooling configuration rather than documentation for readers, despite being markdown. |
| 44 | + { |
| 45 | + kind: 'config', |
| 46 | + matches: (filePath) => filePath.startsWith('.github/') |
| 47 | + || baseName(filePath).startsWith('.') |
| 48 | + || /^(tsconfig.*\.json|vitest\.(config|setup)\.ts|lerna\.json|docusaurus\.config\.ts|sidebars\.ts)$/ |
| 49 | + .test(baseName(filePath)) |
| 50 | + || ['AGENTS.md', 'CLAUDE.md'].includes(baseName(filePath)), |
| 51 | + }, |
| 52 | + { |
| 53 | + kind: 'docs', |
| 54 | + matches: (filePath) => /\.mdx?$/.test(filePath) || /^website\/(docs|static)\//.test(filePath), |
| 55 | + }, |
| 56 | +]; |
| 57 | + |
| 58 | +const areaLabels: Record<Area, string> = { |
| 59 | + compiler: 'Compiler (`cashc`)', |
| 60 | + sdk: 'SDK (`cashscript`)', |
| 61 | + utils: 'Utils (`@cashscript/utils`)', |
| 62 | + website: 'Website', |
| 63 | + examples: 'Examples', |
| 64 | + repository: 'Repository', |
| 65 | +}; |
| 66 | + |
| 67 | +const kindLabels: Record<Kind, string> = { |
| 68 | + source: 'Source', |
| 69 | + tests: 'Tests', |
| 70 | + docs: 'Docs', |
| 71 | + dependencies: 'Deps', |
| 72 | + config: 'Config', |
| 73 | + version: 'Version', |
| 74 | + generated: 'Generated', |
| 75 | +}; |
| 76 | + |
| 77 | +const areaOrder: Area[] = ['compiler', 'sdk', 'utils', 'website', 'examples', 'repository']; |
| 78 | +const kindOrder: Kind[] = ['source', 'tests', 'docs', 'dependencies', 'config', 'version', 'generated']; |
| 79 | + |
| 80 | +// Version bumps and generated files are left out of the totals: there is nothing in them to review, and a |
| 81 | +// lock file update would otherwise dwarf everything else. |
| 82 | +const excludedFromTotal: Kind[] = ['version', 'generated']; |
| 83 | + |
| 84 | +// Workspace packages are bumped together on every release. Their version lines count as version bumps |
| 85 | +// rather than dependency changes, and are summarised once instead of listed per package.json. |
| 86 | +const workspacePackages = ['cashc', 'cashscript', '@cashscript/utils']; |
| 87 | +const versionConstantPath = 'packages/cashc/src/index.ts'; |
| 88 | + |
| 89 | +const releaseNotesPath = 'website/docs/releases/release-notes.md'; |
| 90 | +const publishedAreas: Area[] = ['compiler', 'sdk', 'utils']; |
| 91 | +const listedNewFiles = 10; |
| 92 | + |
| 93 | +const [baseRevision, headRevision] = requireRevisions(); |
| 94 | +// Diffing against the merge base keeps commits that landed on the base branch after the pull request |
| 95 | +// was opened out of the numbers. |
| 96 | +const mergeBase = git(['merge-base', baseRevision, headRevision]).trim(); |
| 97 | + |
| 98 | +const lineCounts = git(['diff', '--numstat', '--no-renames', mergeBase, headRevision]); |
| 99 | +console.log(renderReport(parseLineCounts(lineCounts).flatMap(splitVersionLines))); |
| 100 | + |
| 101 | +function renderReport(changes: FileChange[]): string { |
| 102 | + if (changes.length === 0) return 'No line changes in this pull request.'; |
| 103 | + |
| 104 | + return [ |
| 105 | + '### Pull request stats', |
| 106 | + '', |
| 107 | + ...renderTable(changes), |
| 108 | + '', |
| 109 | + ...renderSummary(changes), |
| 110 | + ...renderVersionBump(changes), |
| 111 | + ...renderDependencyChanges(changes), |
| 112 | + ...renderNewFiles(), |
| 113 | + ...renderReleaseNotesReminder(changes), |
| 114 | + ].join('\n'); |
| 115 | +} |
| 116 | + |
| 117 | +function renderTable(changes: FileChange[]): string[] { |
| 118 | + const areas = areaOrder.filter((area) => changes.some((change) => change.area === area)); |
| 119 | + const kinds = kindOrder.filter((kind) => changes.some((change) => change.kind === kind)); |
| 120 | + const reviewableChurn = churnOf(reviewable(changes)); |
| 121 | + |
| 122 | + const header = `| | ${kinds.map((kind) => kindLabels[kind]).join(' | ')} | Total | Net | Share |`; |
| 123 | + const separator = `|---|${kinds.map(() => '---:|').join('')}---:|---:|---:|`; |
| 124 | + const rows = areas.map((area) => { |
| 125 | + const inArea = changes.filter((change) => change.area === area); |
| 126 | + return renderRow(areaLabels[area], inArea, kinds, reviewableChurn); |
| 127 | + }); |
| 128 | + const totalRow = renderRow('**Total**', changes, kinds, reviewableChurn); |
| 129 | + |
| 130 | + return [header, separator, ...rows, totalRow]; |
| 131 | +} |
| 132 | + |
| 133 | +function renderRow(label: string, changes: FileChange[], kinds: Kind[], reviewableChurn: Churn): string { |
| 134 | + const cells = kinds.map((kind) => formatChurn(churnOf(changes.filter((change) => change.kind === kind)))); |
| 135 | + const total = churnOf(reviewable(changes)); |
| 136 | + const share = sizeOf(reviewableChurn) === 0 ? '—' : formatPercentage(sizeOf(total) / sizeOf(reviewableChurn)); |
| 137 | + return `| ${label} | ${cells.join(' | ')} | ${formatChurn(total)} | ${formatNet(total)} | ${share} |`; |
| 138 | +} |
| 139 | + |
| 140 | +function renderSummary(changes: FileChange[]): string[] { |
| 141 | + const reviewableChurn = churnOf(reviewable(changes)); |
| 142 | + const net = sizeOf(reviewableChurn) === 0 ? '' : ` (net ${formatNet(reviewableChurn)})`; |
| 143 | + const lines = [ |
| 144 | + `Reviewable churn: ${sizeOf(reviewableChurn)} lines${net}, version bumps and generated files excluded.`, |
| 145 | + ]; |
| 146 | + |
| 147 | + const sourceChurn = sizeOf(churnOf(changes.filter((change) => change.kind === 'source'))); |
| 148 | + const testChurn = sizeOf(churnOf(changes.filter((change) => change.kind === 'tests'))); |
| 149 | + if (sourceChurn > 0) lines.push(`Test lines per line of source: ${(testChurn / sourceChurn).toFixed(2)}.`); |
| 150 | + |
| 151 | + // Only source files are read line by line, which keeps lock files and fixtures out of the comment share. |
| 152 | + const addedSourceLines = changes |
| 153 | + .filter((change) => change.kind === 'source') |
| 154 | + .flatMap((change) => addedLinesOf(change.filePath)) |
| 155 | + .filter((line) => !isVersionLine(line)); |
| 156 | + const comments = addedSourceLines.filter(isComment).length; |
| 157 | + const nonBlank = addedSourceLines.filter((line) => line.trim() !== '').length; |
| 158 | + if (nonBlank > 0) { |
| 159 | + lines.push(`Comments: ${comments} of ${nonBlank} added source lines, ${formatPercentage(comments / nonBlank)}.`); |
| 160 | + } |
| 161 | + |
| 162 | + return lines; |
| 163 | +} |
| 164 | + |
| 165 | +// A release bumps every package.json at once, which reads best as one line rather than one per file. |
| 166 | +function renderVersionBump(changes: FileChange[]): string[] { |
| 167 | + const bumps = new Map<string, string[]>(); |
| 168 | + |
| 169 | + for (const change of changes.filter((entry) => entry.kind === 'version' && baseName(entry.filePath) === 'package.json')) { |
| 170 | + const before = packageManifest(fileAt(mergeBase, change.filePath)); |
| 171 | + const after = packageManifest(fileAt(headRevision, change.filePath)); |
| 172 | + if (!before.version || !after.version || before.version === after.version) continue; |
| 173 | + |
| 174 | + const bump = `${before.version} → ${after.version}`; |
| 175 | + bumps.set(bump, [...(bumps.get(bump) ?? []), `\`${after.name ?? change.filePath}\``]); |
| 176 | + } |
| 177 | + |
| 178 | + return [...bumps].map(([bump, packages]) => `Version bump: ${bump} in ${packages.join(', ')}.`); |
| 179 | +} |
| 180 | + |
| 181 | +// Which packages the pull request brings in or drops, as opposed to how many lines of package.json moved. |
| 182 | +// A new dependency is the part that warrants a look of its own. |
| 183 | +function renderDependencyChanges(changes: FileChange[]): string[] { |
| 184 | + const lines: string[] = []; |
| 185 | + |
| 186 | + for (const change of changes.filter((entry) => entry.kind === 'dependencies')) { |
| 187 | + const before = declaredDependencies(fileAt(mergeBase, change.filePath)); |
| 188 | + const after = declaredDependencies(fileAt(headRevision, change.filePath)); |
| 189 | + const added = Object.entries(after) |
| 190 | + .filter(([name]) => !(name in before)) |
| 191 | + .map(([name, version]) => `\`${name}@${version}\``); |
| 192 | + const removed = Object.keys(before).filter((name) => !(name in after)).map((name) => `\`${name}\``); |
| 193 | + const updated = Object.entries(after) |
| 194 | + .filter(([name, version]) => name in before && before[name] !== version && !workspacePackages.includes(name)) |
| 195 | + .map(([name, version]) => `\`${name}\` ${before[name]} → ${version}`); |
| 196 | + |
| 197 | + if (added.length > 0) lines.push(`- \`${change.filePath}\` adds ${added.join(', ')}`); |
| 198 | + if (removed.length > 0) lines.push(`- \`${change.filePath}\` removes ${removed.join(', ')}`); |
| 199 | + if (updated.length > 0) lines.push(`- \`${change.filePath}\` updates ${updated.join(', ')}`); |
| 200 | + } |
| 201 | + |
| 202 | + return lines.length === 0 ? [] : ['', 'Dependency changes:', ...lines]; |
| 203 | +} |
| 204 | + |
| 205 | +// Files added by this pull request say how much of it is new surface rather than edits to code that |
| 206 | +// already had reviewers. |
| 207 | +function renderNewFiles(): string[] { |
| 208 | + const newFiles = git(['diff', '--name-status', '--no-renames', mergeBase, headRevision]) |
| 209 | + .split('\n') |
| 210 | + .filter((line) => line.startsWith('A\t')) |
| 211 | + .map((line) => line.slice(2)) |
| 212 | + .filter((filePath) => kindOf(filePath) !== 'generated'); |
| 213 | + if (newFiles.length === 0) return []; |
| 214 | + |
| 215 | + const shown = newFiles.slice(0, listedNewFiles).map((filePath) => `- \`${filePath}\``); |
| 216 | + const hidden = newFiles.length > listedNewFiles ? [`- and ${newFiles.length - listedNewFiles} more`] : []; |
| 217 | + return ['', `New files: ${newFiles.length}`, ...shown, ...hidden]; |
| 218 | +} |
| 219 | + |
| 220 | +// Source changes to a published package usually deserve a line in the release notes. |
| 221 | +function renderReleaseNotesReminder(changes: FileChange[]): string[] { |
| 222 | + const changesPublishedSource = changes |
| 223 | + .some((change) => change.kind === 'source' && publishedAreas.includes(change.area)); |
| 224 | + const touchesReleaseNotes = changes.some((change) => change.filePath === releaseNotesPath); |
| 225 | + if (!changesPublishedSource || touchesReleaseNotes) return []; |
| 226 | + |
| 227 | + return ['', `Package source changed without an update to \`${releaseNotesPath}\`.`]; |
| 228 | +} |
| 229 | + |
| 230 | +function requireRevisions(): [string, string] { |
| 231 | + const [base, head] = process.argv.slice(2); |
| 232 | + if (!base || !head) { |
| 233 | + console.error('Usage: node .github/scripts/pr-stats.ts <base-revision> <head-revision>'); |
| 234 | + process.exit(1); |
| 235 | + } |
| 236 | + return [base, head]; |
| 237 | +} |
| 238 | + |
| 239 | +// Version lines in files that hold a version are split off from the rest of the file's changes, so a release |
| 240 | +// bump does not read as dependency or source changes. |
| 241 | +function splitVersionLines(change: FileChange): FileChange[] { |
| 242 | + if (!isVersionedFile(change.filePath)) return [change]; |
| 243 | + |
| 244 | + const changedLines = changedLinesOf(change.filePath); |
| 245 | + const versionLines = changedLines.filter((line) => isVersionLine(line.slice(1))); |
| 246 | + const otherLines = changedLines.filter((line) => !isVersionLine(line.slice(1))); |
| 247 | + |
| 248 | + return [ |
| 249 | + { ...change, kind: 'version' as Kind, ...countLines(versionLines) }, |
| 250 | + { ...change, ...countLines(otherLines) }, |
| 251 | + ].filter((entry) => sizeOf(entry) > 0); |
| 252 | +} |
| 253 | + |
| 254 | +function isVersionedFile(filePath: string): boolean { |
| 255 | + return ['package.json', 'lerna.json'].includes(baseName(filePath)) || filePath === versionConstantPath; |
| 256 | +} |
| 257 | + |
| 258 | +// A package's own version, a workspace package's version in a dependency list, or the compiler's version constant. |
| 259 | +function isVersionLine(line: string): boolean { |
| 260 | + const workspaceDependency = workspacePackages.some((name) => line.trim().startsWith(`"${name}":`)); |
| 261 | + return /^\s*"version":/.test(line) || /^export const version = /.test(line) || workspaceDependency; |
| 262 | +} |
| 263 | + |
| 264 | +function countLines(changedLines: string[]): Churn { |
| 265 | + return { |
| 266 | + added: changedLines.filter((line) => line.startsWith('+')).length, |
| 267 | + removed: changedLines.filter((line) => line.startsWith('-')).length, |
| 268 | + }; |
| 269 | +} |
| 270 | + |
| 271 | +// Parses the output of `git diff --numstat`, one "<added>\t<removed>\t<path>" line per file. |
| 272 | +// Binary files report "-" for both counts and are left out. |
| 273 | +function parseLineCounts(lineCounts: string): FileChange[] { |
| 274 | + return lineCounts |
| 275 | + .split('\n') |
| 276 | + .filter((line) => line.trim() !== '') |
| 277 | + .map((line) => { |
| 278 | + const [added, removed, filePath] = line.split('\t'); |
| 279 | + return { filePath, area: areaOf(filePath), kind: kindOf(filePath), added: Number(added), removed: Number(removed) }; |
| 280 | + }) |
| 281 | + .filter((change) => Number.isInteger(change.added) && Number.isInteger(change.removed)); |
| 282 | +} |
| 283 | + |
| 284 | +function areaOf(filePath: string): Area { |
| 285 | + return areaRules.find((rule) => filePath.startsWith(rule.prefix))?.area ?? 'repository'; |
| 286 | +} |
| 287 | + |
| 288 | +function kindOf(filePath: string): Kind { |
| 289 | + return kindRules.find((rule) => rule.matches(filePath))?.kind ?? 'source'; |
| 290 | +} |
| 291 | + |
| 292 | +// The ANTLR output next to the grammar is regenerated with `yarn antlr`, only the .g4 file is written by hand. |
| 293 | +function isGeneratedGrammar(filePath: string): boolean { |
| 294 | + return filePath.startsWith('packages/cashc/src/grammar/') && !filePath.endsWith('.g4'); |
| 295 | +} |
| 296 | + |
| 297 | +function baseName(filePath: string): string { |
| 298 | + return filePath.slice(filePath.lastIndexOf('/') + 1); |
| 299 | +} |
| 300 | + |
| 301 | +function reviewable(changes: FileChange[]): FileChange[] { |
| 302 | + return changes.filter((change) => !excludedFromTotal.includes(change.kind)); |
| 303 | +} |
| 304 | + |
| 305 | +function churnOf(changes: FileChange[]): Churn { |
| 306 | + return { |
| 307 | + added: changes.reduce((sum, change) => sum + change.added, 0), |
| 308 | + removed: changes.reduce((sum, change) => sum + change.removed, 0), |
| 309 | + }; |
| 310 | +} |
| 311 | + |
| 312 | +function sizeOf(churn: Churn): number { |
| 313 | + return churn.added + churn.removed; |
| 314 | +} |
| 315 | + |
| 316 | +function formatChurn(churn: Churn): string { |
| 317 | + return sizeOf(churn) === 0 ? '' : `+${churn.added} −${churn.removed}`; |
| 318 | +} |
| 319 | + |
| 320 | +function formatNet(churn: Churn): string { |
| 321 | + const net = churn.added - churn.removed; |
| 322 | + if (sizeOf(churn) === 0) return ''; |
| 323 | + if (net === 0) return '±0'; |
| 324 | + return net > 0 ? `+${net}` : `−${-net}`; |
| 325 | +} |
| 326 | + |
| 327 | +function formatPercentage(fraction: number): string { |
| 328 | + if (fraction > 0 && fraction < 0.01) return '<1%'; |
| 329 | + return `${Math.round(fraction * 100)}%`; |
| 330 | +} |
| 331 | + |
| 332 | +// Lines carrying nothing but a comment, in both TypeScript and CashScript: the line form, and the opening, |
| 333 | +// body and closing of the block form, which this codebase writes with a leading asterisk. |
| 334 | +function isComment(line: string): boolean { |
| 335 | + const trimmed = line.trim(); |
| 336 | + return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'); |
| 337 | +} |
| 338 | + |
| 339 | +function addedLinesOf(filePath: string): string[] { |
| 340 | + return changedLinesOf(filePath).filter((line) => line.startsWith('+')).map((line) => line.slice(1)); |
| 341 | +} |
| 342 | + |
| 343 | +// The added and removed lines of a file, each still carrying its "+" or "-" prefix. Without context lines, |
| 344 | +// everything after the file header is a hunk header or a changed line. |
| 345 | +function changedLinesOf(filePath: string): string[] { |
| 346 | + const diffLines = git(['diff', '-U0', '--no-renames', mergeBase, headRevision, '--', filePath]).split('\n'); |
| 347 | + const firstHunk = diffLines.findIndex((line) => line.startsWith('@@')); |
| 348 | + return firstHunk === -1 ? [] : diffLines.slice(firstHunk).filter((line) => /^[+-]/.test(line)); |
| 349 | +} |
| 350 | + |
| 351 | +interface PackageManifest { |
| 352 | + name?: string; |
| 353 | + version?: string; |
| 354 | + dependencies?: Record<string, string>; |
| 355 | + devDependencies?: Record<string, string>; |
| 356 | + peerDependencies?: Record<string, string>; |
| 357 | +} |
| 358 | + |
| 359 | +function declaredDependencies(content: string): Record<string, string> { |
| 360 | + const manifest = packageManifest(content); |
| 361 | + return { ...manifest.dependencies, ...manifest.devDependencies, ...manifest.peerDependencies }; |
| 362 | +} |
| 363 | + |
| 364 | +// Returns an empty manifest for a missing or invalid package.json. |
| 365 | +function packageManifest(content: string): PackageManifest { |
| 366 | + if (content === '') return {}; |
| 367 | + try { |
| 368 | + return JSON.parse(content) as PackageManifest; |
| 369 | + } catch { |
| 370 | + return {}; |
| 371 | + } |
| 372 | +} |
| 373 | + |
| 374 | +function git(args: string[]): string { |
| 375 | + return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); |
| 376 | +} |
| 377 | + |
| 378 | +// Returns an empty string when the file does not exist at that revision, which is the case on either side |
| 379 | +// of an addition or a deletion. Git reports that on stderr, which is silenced so it does not read as a failure. |
| 380 | +function fileAt(revision: string, filePath: string): string { |
| 381 | + try { |
| 382 | + return execFileSync('git', ['show', `${revision}:${filePath}`], { |
| 383 | + encoding: 'utf8', |
| 384 | + maxBuffer: 64 * 1024 * 1024, |
| 385 | + stdio: ['ignore', 'pipe', 'ignore'], |
| 386 | + }); |
| 387 | + } catch { |
| 388 | + return ''; |
| 389 | + } |
| 390 | +} |
0 commit comments