Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -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 |

Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions ai/skills/aidd-split-pr/README.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 90 additions & 0 deletions ai/skills/aidd-split-pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -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: <Source PR>
}

Commands {
✂️ /split-pr [target PR | target branch] - split an oversized PR into mergeable increments
}
19 changes: 19 additions & 0 deletions ai/skills/aidd-split-pr/index.md
Original file line number Diff line number Diff line change
@@ -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.


4 changes: 4 additions & 0 deletions ai/skills/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion bin/aidd.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -192,4 +193,4 @@ https://paralleldrive.com
};

// Execute CLI
createCli().parse();
addChurnCommand(createCli()).parse();
37 changes: 37 additions & 0 deletions lib/churn-collector.js
Original file line number Diff line number Diff line change
@@ -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" },
);
Comment on lines +15 to +23
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collectChurn builds a shell command string and interpolates days directly into --since="${since}". Since collectChurn is exported, any caller passing untrusted/non-numeric input could potentially cause shell injection or at least produce invalid git arguments. Validate/coerce days to a finite positive integer inside collectChurn and consider using execFileSync (args array) to avoid invoking a shell.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell injection via days parameter in execSync

Medium Severity

The days parameter is interpolated directly into a shell command string passed to execSync. Because the value is placed inside double quotes in the shell (--since="${since}"), command substitution like $(...) would be executed. The immediate CLI caller converts to Number() which mitigates this, but collectChurn is an exported function that accepts arbitrary input without validation, leaving the injection surface open for any future caller.

Additional Locations (1)

Fix in Cursor Fix in Web

} 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());
};
Comment on lines +15 to +37
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no unit tests covering collectChurn yet (e.g., parsing git log output, the day window behavior, and structured errors when not in a git repo). Given this is core to the new churn feature and the repo already has extensive lib-level tests, please add coverage for these scenarios.

Copilot uses AI. Check for mistakes.
45 changes: 45 additions & 0 deletions lib/churn-command.js
Original file line number Diff line number Diff line change
@@ -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 <n>", "git log window in days", "90")
.option("--top <n>", "max results to show", "20")
.option("--min-loc <n>", "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) });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-numeric --days silently produces wrong results

Medium Severity

When a user passes a non-numeric value like --days abc, Number("abc") evaluates to NaN. This produces the git argument --since="NaN days ago", which git silently treats as an invalid date and returns the entire commit history instead of the intended time window. The user receives a plausible-looking but incorrect hotspot ranking with no error or warning.

Additional Locations (1)

Fix in Cursor Fix in Web

const files = [...churnMap.keys()];
const metricsMap = collectFileMetrics({ cwd, files });
const results = scoreFiles(churnMap, metricsMap, {
minLoc: Number(minLoc),
top: Number(top),
Comment on lines +13 to +25
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--days, --top, and --min-loc are converted with Number(...) but never validated. Invalid inputs become NaN (e.g. --min-loc foo), which can yield confusing empty output or odd git args. Prefer Commander’s option coercion (parseInt) plus explicit validation (finite, positive) and a clear error message before running the analysis.

Suggested change
.option("--days <n>", "git log window in days", "90")
.option("--top <n>", "max results to show", "20")
.option("--min-loc <n>", "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),
.option(
"--days <n>",
"git log window in days",
(value) => Number.parseInt(value, 10),
90,
)
.option(
"--top <n>",
"max results to show",
(value) => Number.parseInt(value, 10),
20,
)
.option(
"--min-loc <n>",
"minimum lines of code to include",
(value) => Number.parseInt(value, 10),
50,
)
.option("--json", "output raw JSON")
.action(async ({ days, top, minLoc, json }) => {
const ensurePositiveInteger = (value, name) => {
if (
!Number.isFinite(value) ||
!Number.isInteger(value) ||
value <= 0
) {
console.error(
chalk.red(
`❌ Invalid value for --${name}: ${String(
value,
)}. Expected a positive integer.`,
),
);
process.exit(1);
}
};
ensurePositiveInteger(days, "days");
ensurePositiveInteger(top, "top");
ensurePositiveInteger(minLoc, "min-loc");
const cwd = process.cwd();
try {
const churnMap = collectChurn({ cwd, days });
const files = [...churnMap.keys()];
const metricsMap = collectFileMetrics({ cwd, files });
const results = scoreFiles(churnMap, metricsMap, {
minLoc,
top,

Copilot uses AI. Check for mistakes.
});
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);
}
});
Comment on lines +9 to +43
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no integration/smoke test for the new churn CLI subcommand yet (e.g., npx aidd churn --json produces parseable JSON and exits 0). Since the command wires together git parsing + file IO + formatting, adding at least a minimal CLI-level test would help prevent regressions.

Copilot uses AI. Check for mistakes.
return program;
};
46 changes: 46 additions & 0 deletions lib/churn-formatter.js
Original file line number Diff line number Diff line change
@@ -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);
63 changes: 63 additions & 0 deletions lib/churn-formatter.test.js
Original file line number Diff line number Diff line change
@@ -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],
});
});
});
Loading
Loading