diff --git a/README.md b/README.md index ab73080d..31bf9a71 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,11 @@ AIDD Framework is a collection of reusable metaprograms, agent orchestration sys /log - log the changes to the activity log /commit - commit the changes to the repository /user-test - generate user testing scripts for post-deploy validation +/split-pr [target PR | target branch] - split an oversized PR into mergeable increments ``` +📖 **[Split PR Skill →](ai/skills/aidd-split-pr/README.md)** + ## 🚀 Quick Start with AIDD CLI ``` @@ -391,6 +394,7 @@ aidd [target-directory] [options] | `-v, --verbose` | Provide detailed output | | `-c, --cursor` | Create `.cursor` symlink for Cursor editor integration | | `-i, --index` | Generate `index.md` files from frontmatter in `ai/` subfolders | +| `churn` | Rank files by hotspot score (LoC × churn × complexity) | | `-h, --help` | Display help information | | `--version` | Show version number | @@ -401,6 +405,13 @@ aidd [target-directory] [options] npx aidd # Current directory npx aidd my-project # Specific directory +# Hotspot analysis +npx aidd churn # Rank files by LoC × churn × complexity (top 20, 90-day window) +npx aidd churn --days 30 # Shorter window +npx aidd churn --top 10 # Tighter list +npx aidd churn --min-loc 100 # Higher LoC threshold +npx aidd churn --json # Machine-readable output + # Preview and force options npx aidd --dry-run # See what would be copied npx aidd --force --verbose # Overwrite with details diff --git a/ai/skills/aidd-split-pr/README.md b/ai/skills/aidd-split-pr/README.md new file mode 100644 index 00000000..3ea4c457 --- /dev/null +++ b/ai/skills/aidd-split-pr/README.md @@ -0,0 +1,17 @@ +# ✂️ aidd-split-pr + +Decompose an oversized PR into smaller, independently-mergeable increments — without losing the work already done on the source branch. + +## Why + +Large PRs are hard to review, risky to merge, and slow to ship. This skill audits an existing branch against a source PR, plans a safe split sequence, and stages each increment from existing work first — writing new code only to fill confirmed gaps. + +## Usage + +``` +/split-pr [target PR | target branch] +``` + +Point it at the PR or branch that needs splitting. It will merge latest main, inventory existing progress, identify modularization opportunities, propose a PR sequence for your approval, then stage each increment. + +See [SKILL.md](./SKILL.md) for the full spec. diff --git a/ai/skills/aidd-split-pr/SKILL.md b/ai/skills/aidd-split-pr/SKILL.md new file mode 100644 index 00000000..879710c7 --- /dev/null +++ b/ai/skills/aidd-split-pr/SKILL.md @@ -0,0 +1,90 @@ +--- +name: aidd-split-pr +description: > + Split a large PR into smaller, mergeable increments without breaking + existing functionality. +compatibility: Requires git and npm. +--- + +# ✂️ aidd-split-pr + +Act as a top-tier software engineer to decompose an oversized PR into +independently-mergeable increments, each leaving CI green. + +Competencies { + merge conflict resolution + code modularization and file-splitting + incremental delivery planning + TDD discipline + PR size management +} + +Constraints { + Do ONE step at a time. Do not skip steps or reorder them. + Ask before resolving any conflict that could change existing behavior. + Prefer extraction over reimplementation: the source branch is the primary + source of truth for implementation. Write new code only to fill confirmed + gaps identified in the audit. + Apply @javascript.mdc, @error-causes.mdc, @tdd.mdc, and @requirements.mdc + throughout. + One specific error-type rule: define CausedError ONCE in a single .d.ts; + never duplicate error type declarations across files. +} + +PRConstraints { + AVOID UNNECESSARY DUPLICATION! + Less is more: every line must serve a justified functional requirement. + Max individual PR size: +1000 LoC. + Reduce test verbosity: assert whole objects, not properties one at a time. +} + +## Step 1 — Merge Latest Main +mergeMain() { + 1. Merge `main` into the branch + 2. Resolve conflicts conservatively — ask before touching anything behavioral +} + +## Step 2 — Audit Existing Progress +auditProgress(sourcePR) => inventory { + 1. Compare branch diff to the source PR + 2. Categorize every change: done | partial | not-started + 3. Share inventory with user before proceeding +} + +## Step 3 — Identify Modularization Opportunities +findSplitPoints(inventory) => splitPlan { + 1. Run `npx aidd churn` to get a ranked hotspot table (LoC × churn × complexity) + 2. Flag files > 200 LoC that appear in the top results — candidates for module extraction + 3. Identify shared mutable state in high-scoring files — propose refactors to eliminate brittle coupling +} + +## Step 4 — Plan the PR Sequence +planPRs(splitPlan) => prSequence { + Each PR must: + - be independently mergeable with CI green + - stay within PRConstraints + - be presented to the user for approval before implementation begins +} + +## Step 5 — Stage Each PR from Existing Work +stagePR(pr, inventory) => stagedPR { + For each change in this PR's scope: + + done | partial => extract from source branch diff; do NOT rewrite + - Cherry-pick, reorganize, or copy the existing implementation + - partial => identify the gap; fill it using TDD (@tdd.mdc) before staging + not-started => confirm with user before writing anything new + + 1. Run /review on staged changes — resolve findings + 2. Run /commit +} + +splitPR = mergeMain |> auditProgress |> findSplitPoints |> planPRs |> stagePR* + +Reference { + Source PR: +} + +Commands { + ✂️ /split-pr [target PR | target branch] - split an oversized PR into mergeable increments +} diff --git a/ai/skills/aidd-split-pr/index.md b/ai/skills/aidd-split-pr/index.md new file mode 100644 index 00000000..73209aae --- /dev/null +++ b/ai/skills/aidd-split-pr/index.md @@ -0,0 +1,19 @@ +# aidd-split-pr + +This index provides an overview of the contents in this directory. + +## Files + +### ✂️ aidd-split-pr + +**File:** `README.md` + +*No description available* + +### ✂️ aidd-split-pr + +**File:** `SKILL.md` + +Split a large PR into smaller, mergeable increments without breaking existing functionality. + + diff --git a/ai/skills/index.md b/ai/skills/index.md index eece29e8..94388d42 100644 --- a/ai/skills/index.md +++ b/ai/skills/index.md @@ -36,6 +36,10 @@ See [`aidd-react/index.md`](./aidd-react/index.md) for contents. See [`aidd-service/index.md`](./aidd-service/index.md) for contents. +### 📁 aidd-split-pr/ + +See [`aidd-split-pr/index.md`](./aidd-split-pr/index.md) for contents. + ### 📁 aidd-structure/ See [`aidd-structure/index.md`](./aidd-structure/index.md) for contents. diff --git a/bin/aidd.js b/bin/aidd.js index d9a00acd..f07acdb5 100755 --- a/bin/aidd.js +++ b/bin/aidd.js @@ -7,6 +7,7 @@ import { fileURLToPath } from "url"; import chalk from "chalk"; import { Command } from "commander"; +import { addChurnCommand } from "../lib/churn-command.js"; import { executeClone, handleCliErrors } from "../lib/cli-core.js"; import { generateAllIndexes } from "../lib/index-generator.js"; @@ -192,4 +193,4 @@ https://paralleldrive.com }; // Execute CLI -createCli().parse(); +addChurnCommand(createCli()).parse(); diff --git a/lib/churn-collector.js b/lib/churn-collector.js new file mode 100644 index 00000000..c80e323e --- /dev/null +++ b/lib/churn-collector.js @@ -0,0 +1,37 @@ +import { execSync } from "child_process"; +import { createError, errorCauses } from "error-causes"; + +const [churnErrors, handleChurnErrors] = errorCauses({ + GitError: { message: "git command failed" }, + NotAGitRepo: { message: "not a git repository" }, +}); + +export { handleChurnErrors, churnErrors }; + +/** + * Returns a Map of filePath -> commit touch count for files changed + * within the given day window. + */ +export const collectChurn = ({ cwd = process.cwd(), days = 90 } = {}) => { + const since = `${days} days ago`; + let output; + + try { + output = execSync( + `git log --since="${since}" --name-only --pretty=format: --diff-filter=ACMR`, + { cwd, encoding: "utf8" }, + ); + } catch (cause) { + const isNotRepo = cause.message?.includes("not a git repository"); + throw createError( + isNotRepo ? churnErrors.NotAGitRepo : churnErrors.GitError, + { cause }, + ); + } + + return output + .split("\n") + .map((f) => f.trim()) + .filter(Boolean) + .reduce((map, file) => map.set(file, (map.get(file) ?? 0) + 1), new Map()); +}; diff --git a/lib/churn-command.js b/lib/churn-command.js new file mode 100644 index 00000000..7e16fb11 --- /dev/null +++ b/lib/churn-command.js @@ -0,0 +1,45 @@ +import chalk from "chalk"; + +import { collectChurn, handleChurnErrors } from "./churn-collector.js"; +import { formatJson, formatTable } from "./churn-formatter.js"; +import { scoreFiles } from "./churn-scorer.js"; +import { collectFileMetrics } from "./file-metrics-collector.js"; + +/** @param {import('commander').Command} program */ +export const addChurnCommand = (program) => { + program + .command("churn") + .description("rank files by hotspot score (LoC × churn × complexity)") + .option("--days ", "git log window in days", "90") + .option("--top ", "max results to show", "20") + .option("--min-loc ", "minimum lines of code to include", "50") + .option("--json", "output raw JSON") + .action(async ({ days, top, minLoc, json }) => { + const cwd = process.cwd(); + try { + const churnMap = collectChurn({ cwd, days: Number(days) }); + const files = [...churnMap.keys()]; + const metricsMap = collectFileMetrics({ cwd, files }); + const results = scoreFiles(churnMap, metricsMap, { + minLoc: Number(minLoc), + top: Number(top), + }); + console.log(json ? formatJson(results) : formatTable(results)); + } catch (err) { + try { + handleChurnErrors({ + GitError: ({ message }) => + console.error(chalk.red(`❌ Git error: ${message}`)), + NotAGitRepo: () => + console.error( + chalk.red("❌ Not a git repository. Run inside a git repo."), + ), + })(err); + } catch { + console.error(chalk.red(`❌ ${err.message}`)); + } + process.exit(1); + } + }); + return program; +}; diff --git a/lib/churn-formatter.js b/lib/churn-formatter.js new file mode 100644 index 00000000..f4c645e2 --- /dev/null +++ b/lib/churn-formatter.js @@ -0,0 +1,46 @@ +import chalk from "chalk"; + +/** + * @typedef {{ file: string, score: number, loc: number, churn: number, complexity: number, gzipRatio: number }} ScoredFile + */ + +const HEADERS = ["Score", "LoC", "Churn", "Cx", "Density", "File"]; + +/** @param {ScoredFile} result */ +const row = ({ score, loc, churn, complexity, gzipRatio, file }) => [ + score.toLocaleString(), + String(loc), + String(churn), + String(complexity), + `${(gzipRatio * 100).toFixed(0)}%`, + file, +]; + +/** @param {string} str @param {number} width */ +const pad = (str, width) => str.padStart(width); + +/** @param {ScoredFile[]} results */ +export const formatTable = (results) => { + if (results.length === 0) { + return chalk.green("✅ No hotspots found above the current thresholds."); + } + + const rows = results.map(row); + const allRows = [HEADERS, ...rows]; + const widths = HEADERS.map((_, i) => + Math.max(...allRows.map((r) => r[i].length)), + ); + + const divider = widths.map((w) => "─".repeat(w)).join(" "); + /** @param {string[]} r */ + const fmt = (r) => r.map((cell, i) => pad(cell, widths[i])).join(" "); + + return [ + chalk.bold(fmt(HEADERS)), + chalk.gray(divider), + ...rows.map((r) => fmt(r)), + ].join("\n"); +}; + +/** @param {ScoredFile[]} results */ +export const formatJson = (results) => JSON.stringify(results, null, 2); diff --git a/lib/churn-formatter.test.js b/lib/churn-formatter.test.js new file mode 100644 index 00000000..e80179f2 --- /dev/null +++ b/lib/churn-formatter.test.js @@ -0,0 +1,63 @@ +import { assert } from "riteway/vitest"; +import { describe, test } from "vitest"; + +import { formatJson, formatTable } from "./churn-formatter.js"; + +const makeResult = (overrides = {}) => ({ + file: "src/foo.ts", + score: 1500, + loc: 100, + churn: 3, + complexity: 5, + gzipRatio: 0.35, + ...overrides, +}); + +describe("formatTable", () => { + test("headers", () => { + const output = formatTable([makeResult()]); + + assert({ + given: "scored results", + should: "include all column headers", + actual: ["Score", "LoC", "Churn", "Cx", "Density", "File"].every((h) => + output.includes(h), + ), + expected: true, + }); + }); + + test("empty state", () => { + assert({ + given: "no results", + should: "return a friendly empty-state message", + actual: formatTable([]).includes("No hotspots"), + expected: true, + }); + }); + + test("gzip density display", () => { + const output = formatTable([makeResult({ gzipRatio: 0.35 })]); + + assert({ + given: "a gzip ratio of 0.35", + should: "display it as a percentage", + actual: output.includes("35%"), + expected: true, + }); + }); +}); + +describe("formatJson", () => { + test("valid JSON", () => { + const results = [makeResult()]; + const parsed = JSON.parse(formatJson(results)); + + assert({ + given: "scored results", + should: "return valid parseable JSON with all fields", + actual: parsed[0], + expected: results[0], + }); + }); +}); diff --git a/lib/churn-scorer.js b/lib/churn-scorer.js new file mode 100644 index 00000000..f9550f71 --- /dev/null +++ b/lib/churn-scorer.js @@ -0,0 +1,35 @@ +/** + * @typedef {{ loc: number, gzipRatio: number, complexity: number }} FileMetrics + * @typedef {{ file: string, score: number, loc: number, churn: number, complexity: number, gzipRatio: number }} ScoredFile + */ + +/** + * Merges churn counts and file metrics into a ranked list of hotspot scores. + * score = loc * churn * complexity (gzipRatio is a supplemental display column) + * + * @param {Map} churnMap + * @param {Map} metricsMap + * @param {{ top?: number, minLoc?: number }} [options] + * @returns {ScoredFile[]} + */ +export const scoreFiles = ( + churnMap, + metricsMap, + { top = 20, minLoc = 50 } = {}, +) => + [...metricsMap.entries()] + .filter(([, { loc }]) => loc >= minLoc) + .map(([file, { loc, gzipRatio, complexity }]) => { + const churn = churnMap.get(file) ?? 0; + return { + churn, + complexity, + file, + gzipRatio, + loc, + score: loc * churn * complexity, + }; + }) + .filter(({ churn }) => churn > 0) + .sort((a, b) => b.score - a.score) + .slice(0, top); diff --git a/lib/churn-scorer.test.js b/lib/churn-scorer.test.js new file mode 100644 index 00000000..3aa77224 --- /dev/null +++ b/lib/churn-scorer.test.js @@ -0,0 +1,98 @@ +import { assert } from "riteway/vitest"; +import { describe, test } from "vitest"; + +import { scoreFiles } from "./churn-scorer.js"; + +/** @param {[string, number][]} entries @returns {Map} */ +const churnMap = (entries) => new Map(entries); + +/** @param {[string, import('./churn-scorer.js').FileMetrics][]} entries */ +const metricsMap = (entries) => new Map(entries); + +describe("scoreFiles", () => { + test("composite score", () => { + const churn = churnMap([["foo.ts", 3]]); + const metrics = metricsMap([ + ["foo.ts", { loc: 100, complexity: 5, gzipRatio: 0.4 }], + ]); + const [result] = scoreFiles(churn, metrics); + + assert({ + given: "loc=100, churn=3, complexity=5", + should: "return score = loc × churn × complexity", + actual: result.score, + expected: 1500, + }); + }); + + test("sorting", () => { + const churn = churnMap([ + ["a.ts", 2], + ["b.ts", 5], + ]); + const metrics = metricsMap([ + ["a.ts", { loc: 200, complexity: 10, gzipRatio: 0.3 }], + ["b.ts", { loc: 100, complexity: 4, gzipRatio: 0.3 }], + ]); + const results = scoreFiles(churn, metrics); + + assert({ + given: "two files with different scores", + should: "return results sorted by score descending", + actual: results.map((r) => r.file), + expected: ["a.ts", "b.ts"], + }); + }); + + test("top filter", () => { + const churn = churnMap([ + ["a.ts", 1], + ["b.ts", 1], + ["c.ts", 1], + ]); + const metrics = metricsMap([ + ["a.ts", { loc: 300, complexity: 3, gzipRatio: 0.3 }], + ["b.ts", { loc: 200, complexity: 3, gzipRatio: 0.3 }], + ["c.ts", { loc: 100, complexity: 3, gzipRatio: 0.3 }], + ]); + + assert({ + given: "top=2", + should: "return at most 2 results", + actual: scoreFiles(churn, metrics, { top: 2 }).length, + expected: 2, + }); + }); + + test("minLoc filter", () => { + const churn = churnMap([ + ["small.ts", 5], + ["big.ts", 5], + ]); + const metrics = metricsMap([ + ["small.ts", { loc: 30, complexity: 10, gzipRatio: 0.3 }], + ["big.ts", { loc: 200, complexity: 10, gzipRatio: 0.3 }], + ]); + + assert({ + given: "minLoc=50 and a file with 30 lines", + should: "exclude the file below the threshold", + actual: scoreFiles(churn, metrics, { minLoc: 50 }).map((r) => r.file), + expected: ["big.ts"], + }); + }); + + test("unchurned files excluded", () => { + const churn = churnMap([]); + const metrics = metricsMap([ + ["untouched.ts", { loc: 500, complexity: 20, gzipRatio: 0.3 }], + ]); + + assert({ + given: "a file with no churn", + should: "exclude it from results", + actual: scoreFiles(churn, metrics).length, + expected: 0, + }); + }); +}); diff --git a/lib/file-metrics-collector.js b/lib/file-metrics-collector.js new file mode 100644 index 00000000..2c8b5700 --- /dev/null +++ b/lib/file-metrics-collector.js @@ -0,0 +1,75 @@ +import { readFileSync } from "fs"; +import path from "path"; +import { gzipSync } from "zlib"; +import { MetricsConfiguration, MetricsParser } from "tsmetrics-core"; + +const JS_TS_EXTENSIONS = new Set([ + ".js", + ".jsx", + ".ts", + ".tsx", + ".mjs", + ".cjs", +]); + +/** + * Cyclomatic complexity of a single function node: + * sum the node's own score + direct children that are NOT function boundaries. + * Nested functions are separate units and get their own M. + * + * @param {{ complexity: number, visible: boolean, children: any[] }} node + * @returns {number} + */ +const functionComplexity = (node) => + (node.complexity ?? 0) + + (node.children ?? []) + .filter((c) => !c.visible) + .reduce((sum, c) => sum + (c.complexity ?? 0), 0); + +/** + * Walk the metrics tree, collect M for every function node (visible=true), + * and return the maximum — the worst offender in the file. + * + * @param {{ complexity: number, visible: boolean, children: any[] }} node + * @returns {number} + */ +const maxFunctionComplexity = (node) => { + const childMax = (node.children ?? []).reduce( + (m, c) => Math.max(m, maxFunctionComplexity(c)), + 1, + ); + return node.visible ? Math.max(functionComplexity(node), childMax) : childMax; +}; + +const measureComplexity = (filePath, src) => { + if (!JS_TS_EXTENSIONS.has(path.extname(filePath))) return 1; + try { + const result = MetricsParser.getMetricsFromText( + filePath, + src, + MetricsConfiguration, + ); + return maxFunctionComplexity(result.metrics); + } catch { + return 1; + } +}; + +/** + * Returns file metrics for each path: { loc, gzipRatio, complexity }. + * Skips files that cannot be read (binary, missing, etc). + */ +export const collectFileMetrics = ({ files, cwd = process.cwd() }) => + files.reduce((map, file) => { + try { + const src = readFileSync(path.resolve(cwd, file), "utf8"); + const loc = src.split("\n").length; + const buf = Buffer.from(src, "utf8"); + const gzipRatio = gzipSync(buf).length / buf.length; + const complexity = measureComplexity(file, src); + map.set(file, { complexity, gzipRatio, loc }); + } catch { + // skip unreadable files silently + } + return map; + }, new Map()); diff --git a/package-lock.json b/package-lock.json index ae7d52d1..74500d96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "error-causes": "^3.0.2", "fs-extra": "^11.1.1", "gray-matter": "^4.0.3", - "js-sha3": "^0.9.3" + "js-sha3": "^0.9.3", + "tsmetrics-core": "^1.4.1" }, "bin": { "aidd": "bin/aidd.js" @@ -6348,17 +6349,6 @@ "destr": "^2.0.3" } }, - "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-dom": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", @@ -7485,6 +7475,15 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsmetrics-core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/tsmetrics-core/-/tsmetrics-core-1.4.1.tgz", + "integrity": "sha512-6G384CDqlIPfAHV8VIA73ky8X/kQOfzGTke+f5B063fjlYo/0p0BAmRLPu+EzBA3qdFeoxFxrvYsdIuTv+ZozQ==", + "license": "MIT", + "peerDependencies": { + "typescript": "^4.9.4" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", diff --git a/package.json b/package.json index 1d6ff4c2..51cafb89 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "error-causes": "^3.0.2", "fs-extra": "^11.1.1", "gray-matter": "^4.0.3", - "js-sha3": "^0.9.3" + "js-sha3": "^0.9.3", + "tsmetrics-core": "^1.4.1" }, "description": "The standard framework for AI Driven Development.", "devDependencies": { diff --git a/plan.md b/plan.md index 7dc8398b..8213236a 100644 --- a/plan.md +++ b/plan.md @@ -2,6 +2,13 @@ ## Current Epics +### 📋 `npx aidd churn` Epic + +**Status**: 📋 PLANNED +**File**: [`tasks/aidd-churn-epic.md`](./tasks/aidd-churn-epic.md) +**Goal**: CLI command that ranks files by composite hotspot score (LoC × churn × complexity + gzip density) to identify prime PR split candidates +**Tasks**: 8 tasks (tsmetrics-core install, churn collector, file metrics collector, composite scorer, churn command, output formatter, tests, skill + README update) + ### 📋 `npx aidd create` Epic **Status**: 📋 PLANNED diff --git a/tasks/aidd-churn-epic.md b/tasks/aidd-churn-epic.md new file mode 100644 index 00000000..880418a6 --- /dev/null +++ b/tasks/aidd-churn-epic.md @@ -0,0 +1,154 @@ +# aidd churn Epic + +**Status**: 🔄 IN PROGRESS +**Goal**: Add `npx aidd churn` — a CLI command that ranks files by composite hotspot score to identify prime PR split candidates + +## Overview + +PRs are hard to scope without knowing where complexity actually lives. Developers need a fast, reproducible way to identify which files are the highest-risk to change — large, frequently touched, and logically complex. `npx aidd churn` produces a ranked table using LoC × git churn × cyclomatic complexity, with gzip density as a supplemental column, so any team can run it in seconds before splitting a branch or opening a review. + +--- + +## Install tsmetrics-core + +Add `tsmetrics-core` as a production dependency for TypeScript-native cyclomatic complexity via AST. + +**Requirements**: +- Given the package is installed, should be importable and return a numeric complexity score for a TypeScript source string + +--- + +## Churn Collector + +`collectChurn({ cwd, days })` — runs `git log` and returns a `Map` for files changed within the window. + +**Requirements**: +- Given a git repo and a day window, should return each touched file with its commit count +- Given a file not touched in the window, should not appear in the result +- Given a path outside a git repo, should throw a structured error + +--- + +## File Metrics Collector + +`collectFileMetrics({ files, cwd })` — reads each file and returns `{ loc, gzipRatio, complexity }` per file. + +**Requirements**: +- Given a source file, should return line count, gzip compression ratio, and cyclomatic complexity +- Given a binary or unreadable file, should skip it gracefully +- Given a non-TypeScript file, should return complexity of 1 + +--- + +## Composite Scorer + +`scoreFiles(metrics)` — pure function that computes `score = loc * churn * complexity`, merges gzip ratio as a display column, and returns results sorted descending. + +**Requirements**: +- Given file metrics with loc, churn, and complexity, should return score = loc × churn × complexity +- Given a list of scored files, should return them sorted by score descending +- Given options `{ top, minLoc }`, should filter results accordingly + +--- + +## Churn Command + +Wire `collectChurn`, `collectFileMetrics`, and `scoreFiles` into a `churn` subcommand on the existing Commander CLI. + +**Requirements**: +- Given `npx aidd churn`, should run against the current directory with 90-day window and top 20 results +- Given `--days`, `--top`, `--min-loc` options, should apply them to the analysis +- Given `--json`, should output raw JSON instead of a table + +--- + +## Output Formatter + +`formatTable(results)` — renders scored results as an aligned CLI table with columns: Score, LoC, Churn, Complexity, Density, File. + +**Requirements**: +- Given scored results, should render a readable aligned table to stdout +- Given `--json` flag, should output a JSON array instead +- Given no results above threshold, should print a friendly empty-state message + +--- + +## Tests + +Unit tests for scorer, collector, and formatter; integration smoke test for the CLI command. + +**Requirements**: +- Given known loc, churn, and complexity values, score should equal their product +- Given a top-N filter, should return at most N results +- Given a minLoc filter, should exclude files below the threshold +- Given `--json`, CLI output should be valid parseable JSON + +--- + +## Update split-pr Skill and README + +Update `aidd-split-pr` Step 3 to reference `npx aidd churn`, and add the command to the CLI reference table in README. + +**Requirements**: +- Given the skill is invoked, should instruct the agent to run `npx aidd churn` for modularization analysis +- Given the README CLI reference table, should list `churn` with its options + +--- + +## Fix Shell Injection in collectChurn ⚠️ HIGH + +Replace `execSync` string interpolation with `spawnSync` args array to eliminate command injection risk. + +**Requirements**: +- Given any value for `days`, should never interpolate user input into a shell string +- Given a non-numeric `days` value, should throw a validation error before calling git + +--- + +## Add Missing Collector Tests 🟠 MEDIUM + +Unit tests for `collectChurn` and `collectFileMetrics` — three epic requirements currently have no coverage. + +**Requirements**: +- Given a git repo, `collectChurn` should return each touched file with its commit count +- Given a path outside a git repo, `collectChurn` should throw a structured `NotAGitRepo` error +- Given a non-TypeScript file, `collectFileMetrics` should return complexity of 1 +- Given an unreadable file, `collectFileMetrics` should skip it and continue + +--- + +## Filter Non-Source Files from Results 🟡 LOW + +`package-lock.json`, `README.md`, and other non-source files dominate scores despite being useless signal. + +**Requirements**: +- Given default options, should exclude JSON, markdown, and lockfiles from results +- Given a `--ext` option, should allow the user to override the included extensions + +--- + +## Validate `--days` Input 🟡 LOW + +`--days abc` silently returns the full git history. Should fail fast with a clear message. + +**Requirements**: +- Given a non-numeric `--days` value, should print an error and exit 1 +- Given a non-positive `--days` value, should print an error and exit 1 + +--- + +## Add churn signal to /review 🟡 LOW + +Add a single line to `review.mdc` instructing the agent to run `npx aidd churn` early and surface high-scoring files in the diff. + +**Requirements**: +- Given a code review is running, should run `npx aidd churn` and flag diff files that rank in the top results + +--- + +## Deduplicate ScoredFile Typedef 🔵 NITPICK + +`ScoredFile` is defined in both `churn-scorer.js` and `churn-formatter.js`. + +**Requirements**: +- Given the typedef exists, should be defined once and referenced by both files