diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..25d872b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# Copy this file to .env and fill in your values.
+# .env is gitignored — never commit real API keys.
+
+# Optional: enables Claude diff-level analysis of high-risk commits.
+# Get a key at https://console.anthropic.com/
+# Also requires: npm install @anthropic-ai/sdk
+ANTHROPIC_API_KEY=sk-ant-...
diff --git a/.github/workflows/code-metrics.yml b/.github/workflows/code-metrics.yml
index 33980f4..c14c470 100644
--- a/.github/workflows/code-metrics.yml
+++ b/.github/workflows/code-metrics.yml
@@ -30,37 +30,92 @@ jobs:
with:
script: |
const fs = require('fs');
-
+
+ // ---------------------------------------------------------------
+ // Inline helpers (mirrors local-code-metrics.js logic)
+ // ---------------------------------------------------------------
+
+ const CONVENTIONAL_COMMIT_RE = /^(feat|fix|refactor|test|chore|docs|perf|ci|build|revert)(\(.+\))?:/i;
+ function scoreMessageQuality(message) {
+ if (!message) return false;
+ if (CONVENTIONAL_COMMIT_RE.test(message)) return true;
+ return message.split(/\s+/).filter(Boolean).length >= 10;
+ }
+
+ function quantile(sorted, p) {
+ if (sorted.length === 0) return 0;
+ if (sorted.length === 1) return sorted[0];
+ const idx = p * (sorted.length - 1);
+ const lo = Math.floor(idx);
+ const hi = Math.ceil(idx);
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
+ }
+
+ function computeStats(values) {
+ if (values.length === 0) return { p50: 0, p90: 0, p95: 0, mean: 0, stddev: 0 };
+ const sorted = [...values].sort((a, b) => a - b);
+ const mean = values.reduce((s, v) => s + v, 0) / values.length;
+ const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length;
+ return {
+ p50: quantile(sorted, 0.5),
+ p90: quantile(sorted, 0.9),
+ p95: quantile(sorted, 0.95),
+ mean,
+ stddev: Math.sqrt(variance)
+ };
+ }
+
+ function computeVelocityTrend(timestamps) {
+ if (timestamps.length < 2) return 'stable';
+ const ms = timestamps.map(t => new Date(t).getTime()).sort((a, b) => a - b);
+ const spanDays = (ms[ms.length - 1] - ms[0]) / 86400000 || 1;
+ const midMs = (ms[0] + ms[ms.length - 1]) / 2;
+ const halfSpan = spanDays / 2 || 1;
+ const firstRate = ms.filter(t => t <= midMs).length / halfSpan;
+ const secondRate = ms.filter(t => t > midMs).length / halfSpan;
+ if (secondRate > firstRate * 1.25) return 'accelerating';
+ if (secondRate < firstRate * 0.75) return 'decelerating';
+ return 'stable';
+ }
+
+ function classifyDoraArchetype(large, sprawling, testFirst, msgQuality) {
+ if (large < 20 && sprawling < 10 && testFirst > 50 && msgQuality > 60) return 'harmonious-high-achiever';
+ if (sprawling > 25 && large > 30) return 'legacy-bottleneck';
+ if (large > 40 || (testFirst < 30 && large > 20)) return 'foundational-challenges';
+ return 'mixed-signals';
+ }
+
+ // ---------------------------------------------------------------
+ // Main collection
+ // ---------------------------------------------------------------
+
async function collectMetrics() {
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
-
+
console.log(`Repository: ${owner}/${repo}`);
console.log(`Looking for commits since: ${since}`);
-
+
try {
- const repoInfo = await github.rest.repos.get({
- owner,
- repo
- });
+ const repoInfo = await github.rest.repos.get({ owner, repo });
console.log(`Repository access confirmed: ${repoInfo.data.full_name}`);
-
+
const branches = await github.paginate(github.rest.repos.listBranches, {
owner,
repo,
per_page: 100
});
-
- const featureBranches = branches.filter(branch =>
+
+ const featureBranches = branches.filter(branch =>
!['main', 'master'].includes(branch.name.toLowerCase())
);
-
+
console.log(`Found ${branches.length} total branches`);
console.log(`Analyzing ${featureBranches.length} feature branches: ${featureBranches.map(b => b.name).join(', ')}`);
-
+
let allCommits = [];
let branchCommitCounts = {};
-
+
for (const branch of featureBranches) {
try {
console.log(`Fetching commits from branch: ${branch.name}`);
@@ -71,28 +126,27 @@ jobs:
since,
per_page: 100
});
-
+
branchCommitCounts[branch.name] = branchCommits.length;
allCommits = allCommits.concat(branchCommits.map(commit => ({
...commit,
source_branch: branch.name
})));
-
+
await new Promise(resolve => setTimeout(resolve, 200));
-
} catch (error) {
console.log(`Error fetching commits from branch ${branch.name}: ${error.message}`);
branchCommitCounts[branch.name] = 0;
}
}
-
- const uniqueCommits = allCommits.filter((commit, index, self) =>
+
+ const uniqueCommits = allCommits.filter((commit, index, self) =>
index === self.findIndex(c => c.sha === commit.sha)
);
-
+
console.log(`Found ${allCommits.length} total commits, ${uniqueCommits.length} unique commits`);
console.log('Commits per branch:', branchCommitCounts);
-
+
if (uniqueCommits.length === 0) {
console.log('No commits found in feature branches in the last 30 days.');
const emptyMetrics = [];
@@ -101,22 +155,31 @@ jobs:
branches_analyzed: featureBranches.map(b => b.name),
branch_commit_counts: branchCommitCounts,
large_commits_pct: "0.00",
- sprawling_commits_pct: "0.00",
+ sprawling_commits_pct: "0.00",
test_first_pct: "0.00",
avg_files_changed: "0.00",
avg_lines_changed: "0.00",
avg_prod_lines_changed: "0.00",
+ message_quality_pct: "0.00",
+ p50_lines_changed: 0,
+ p90_lines_changed: 0,
+ p95_lines_changed: 0,
+ stddev_lines_changed: 0,
+ velocity_trend: "stable",
+ additions_ratio_median: 0,
+ additions_ratio_p90: 0,
+ dora_archetype: "mixed-signals",
note: "Feature branches only - main/master excluded. Large commit threshold applied to production code only."
};
-
+
fs.writeFileSync('commit_metrics.json', JSON.stringify(emptyMetrics, null, 2));
fs.writeFileSync('metrics_summary.json', JSON.stringify(summary, null, 2));
return;
}
-
+
const metrics = [];
console.log(`Processing ${Math.min(uniqueCommits.length, 50)} commits...`);
-
+
for (const commit of uniqueCommits.slice(0, 50)) {
try {
const detail = await github.rest.repos.getCommit({
@@ -124,7 +187,7 @@ jobs:
repo,
ref: commit.sha
});
-
+
const testFiles = detail.data.files?.filter(f =>
/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
) || [];
@@ -132,11 +195,13 @@ jobs:
const prodFiles = detail.data.files?.filter(f =>
!/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
) || [];
-
+
+ const message = commit.commit.message.split('\n')[0];
+
metrics.push({
sha: commit.sha,
date: commit.commit.committer.date,
- message: commit.commit.message.split('\n')[0],
+ message,
author: commit.commit.author.name,
source_branch: commit.source_branch,
total_additions: detail.data.stats.additions,
@@ -145,54 +210,74 @@ jobs:
test_files_count: testFiles.length,
prod_files_count: prodFiles.length,
test_first_indicator: testFiles.length > 0 && prodFiles.length > 0,
-
- // Calculate production code changes only
prod_additions: prodFiles.reduce((sum, f) => sum + f.additions, 0),
prod_deletions: prodFiles.reduce((sum, f) => sum + f.deletions, 0),
-
- // Base "large commit" on production code only
large_commit: (prodFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0)) > 100,
-
sprawling_commit: (detail.data.files?.length || 0) > 5,
+ message_quality: scoreMessageQuality(message),
commit_type: "feature_branch"
});
-
+
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
console.log(`Error processing commit ${commit.sha}: ${error.message}`);
}
}
-
+
console.log(`Saving metrics for ${metrics.length} commits`);
fs.writeFileSync('commit_metrics.json', JSON.stringify(metrics, null, 2));
-
- // Calculate summary AFTER all commits are processed
+
+ // Statistical distributions
+ const lineSizes = metrics.map(m => m.total_additions + m.total_deletions);
+ const lineStats = computeStats(lineSizes);
+ const fileStats = computeStats(metrics.map(m => m.files_changed));
+ const ratios = metrics.map(m => m.total_additions / (m.total_deletions || 1));
+ const ratioStats = computeStats([...ratios].sort((a, b) => a - b).map((_, i, a) => a[i]));
+ const velocityTrend = computeVelocityTrend(metrics.map(m => m.date));
+
+ const qualityCount = metrics.filter(m => m.message_quality).length;
+ const msgQualityPct = metrics.length > 0
+ ? ((qualityCount / metrics.length) * 100).toFixed(2)
+ : "0.00";
+
+ const largePct = parseFloat((metrics.filter(m => m.large_commit).length / metrics.length * 100).toFixed(2));
+ const sprawlingPct = parseFloat((metrics.filter(m => m.sprawling_commit).length / metrics.length * 100).toFixed(2));
+ const testFirstPct = parseFloat((metrics.filter(m => m.test_first_indicator).length / metrics.length * 100).toFixed(2));
+
const summary = {
total_commits: metrics.length,
filtered_from: uniqueCommits.length,
branches_analyzed: featureBranches.map(b => b.name),
branch_commit_counts: branchCommitCounts,
- large_commits_pct: (metrics.filter(m => m.large_commit).length / metrics.length * 100).toFixed(2),
- sprawling_commits_pct: (metrics.filter(m => m.sprawling_commit).length / metrics.length * 100).toFixed(2),
- test_first_pct: (metrics.filter(m => m.test_first_indicator).length / metrics.length * 100).toFixed(2),
+ large_commits_pct: largePct.toFixed(2),
+ sprawling_commits_pct: sprawlingPct.toFixed(2),
+ test_first_pct: testFirstPct.toFixed(2),
avg_files_changed: (metrics.reduce((sum, m) => sum + m.files_changed, 0) / metrics.length).toFixed(2),
-
- // Use production code lines for average (both total and prod-only metrics)
avg_lines_changed: (metrics.reduce((sum, m) => sum + (m.prod_additions || 0) + (m.prod_deletions || 0), 0) / metrics.length).toFixed(2),
avg_prod_lines_changed: (metrics.reduce((sum, m) => sum + (m.prod_additions || 0) + (m.prod_deletions || 0), 0) / metrics.length).toFixed(2),
-
+ p50_lines_changed: Math.round(lineStats.p50),
+ p90_lines_changed: Math.round(lineStats.p90),
+ p95_lines_changed: Math.round(lineStats.p95),
+ stddev_lines_changed: Math.round(lineStats.stddev),
+ p50_files_changed: Math.round(fileStats.p50),
+ p90_files_changed: Math.round(fileStats.p90),
+ velocity_trend: velocityTrend,
+ additions_ratio_median: parseFloat(ratioStats.p50.toFixed(2)),
+ additions_ratio_p90: parseFloat(ratioStats.p90.toFixed(2)),
+ message_quality_pct: msgQualityPct,
+ dora_archetype: classifyDoraArchetype(largePct, sprawlingPct, testFirstPct, parseFloat(msgQualityPct)),
note: "Feature branches only - main/master excluded. Large commit threshold applied to production code only."
};
-
+
fs.writeFileSync('metrics_summary.json', JSON.stringify(summary, null, 2));
console.log('Summary:', summary);
-
+
} catch (error) {
console.error('Error in collectMetrics:', error);
throw error;
}
}
-
+
collectMetrics();
- name: Upload metrics artifacts
@@ -217,32 +302,68 @@ jobs:
.map(([branch, count]) => `- ${branch}: ${count} commits`)
.join('\n');
+ function statusMark(value, threshold, direction = 'below') {
+ const ok = direction === 'below' ? parseFloat(value) < threshold : parseFloat(value) > threshold;
+ return ok ? 'OK' : 'Warning';
+ }
+
+ const archetypeDescriptions = {
+ 'harmonious-high-achiever': 'Strong foundational practices; AI tools likely amplifying positive outcomes.',
+ 'foundational-challenges': 'Weak testing or batch discipline; AI tools may be accelerating debt accumulation.',
+ 'legacy-bottleneck': 'Architectural scatter combined with large commits; AI making cross-cutting changes worse.',
+ 'mixed-signals': 'Inconsistent patterns across metrics; investigate specific outlier commits.'
+ };
+
+ const archetype = summary.dora_archetype || 'mixed-signals';
+ const archetypeDesc = archetypeDescriptions[archetype] || '';
+
const bodyLines = [
'## Weekly Code Metrics Report',
'',
- '**Period:** Last 30 days',
+ `**Period:** Last 30 days`,
`**Commits Analyzed:** ${summary.total_commits}`,
`**Branches Analyzed:** ${summary.branches_analyzed?.join(', ') || 'N/A'}`,
'',
'### Key Metrics',
'',
- `**Large Commits (>100 production lines):** ${summary.large_commits_pct}%`,
- `**Sprawling Commits (>5 files):** ${summary.sprawling_commits_pct}%`,
- `**Test-First Discipline:** ${summary.test_first_pct}%`,
- `**Avg Files Changed:** ${summary.avg_files_changed}`,
- `**Avg Production Lines Changed:** ${summary.avg_prod_lines_changed}`,
+ '| Metric | Value | Target | Status |',
+ '|--------|-------|--------|--------|',
+ `| Large Commits (>100 prod lines) | ${summary.large_commits_pct}% | <20% | ${statusMark(summary.large_commits_pct, 20)} |`,
+ `| Sprawling Commits (>5 files) | ${summary.sprawling_commits_pct}% | <10% | ${statusMark(summary.sprawling_commits_pct, 10)} |`,
+ `| Test-First Discipline | ${summary.test_first_pct}% | >50% | ${statusMark(summary.test_first_pct, 50, 'above')} |`,
+ `| Message Quality | ${summary.message_quality_pct}% | >60% | ${statusMark(summary.message_quality_pct, 60, 'above')} |`,
+ `| Additions Ratio (median) | ${summary.additions_ratio_median} | <3.0 | ${parseFloat(summary.additions_ratio_median) < 3.0 ? 'OK' : 'Warning'} |`,
+ '',
+ '### Commit Size Distribution',
+ '',
+ '| Percentile | Lines Changed |',
+ '|------------|---------------|',
+ `| p50 (median) | ${summary.p50_lines_changed} |`,
+ `| p90 | ${summary.p90_lines_changed} |`,
+ `| p95 | ${summary.p95_lines_changed} |`,
+ `| Std Dev | ${summary.stddev_lines_changed} |`,
+ '',
+ `**Commit size trend:** ${summary.velocity_trend === 'accelerating' ? 'Accelerating (monitor closely)' : summary.velocity_trend === 'decelerating' ? 'Decelerating' : 'Stable'}`,
+ '',
+ '### DORA Team Archetype',
+ '',
+ `**${archetype}**`,
+ '',
+ archetypeDesc,
'',
'### Branch Activity',
+ '',
branchActivity,
'',
'### Interpretation',
'',
- 'Target: <20% large commits, <10% sprawling commits',
- 'Test-first discipline should trend upward',
- 'Large commits now measured by production code only (excludes test files)',
- 'Consider investigation if metrics worsen significantly',
+ '- Target: <20% large commits, <10% sprawling commits',
+ '- Test-first discipline should remain above 50%',
+ '- Message quality above 60% indicates disciplined commit practices',
+ '- Large commits measured by production code only (excludes test files)',
+ '- p90 > 500 lines warrants investigation of review burden',
'',
- '_Generated by PDCA Code Metrics Workflow_'
+ '_Generated by Code Metrics Workflow_'
];
const body = bodyLines.join('\n');
diff --git a/.github/workflows/pr-metrics.yml b/.github/workflows/pr-metrics.yml
index 9ae0979..bfa045a 100644
--- a/.github/workflows/pr-metrics.yml
+++ b/.github/workflows/pr-metrics.yml
@@ -16,35 +16,57 @@ jobs:
with:
script: |
const pr = context.payload.pull_request;
-
- // Get PR files for size analysis
+
+ // ---------------------------------------------------------------
+ // Inline helpers (mirrors local-code-metrics.js logic)
+ // ---------------------------------------------------------------
+
+ const CONVENTIONAL_COMMIT_RE = /^(feat|fix|refactor|test|chore|docs|perf|ci|build|revert)(\(.+\))?:/i;
+ function scoreMessageQuality(message) {
+ if (!message) return false;
+ if (CONVENTIONAL_COMMIT_RE.test(message)) return true;
+ return message.split(/\s+/).filter(Boolean).length >= 10;
+ }
+
+ function classifyDoraArchetype(large, sprawling, testFirst, msgQuality) {
+ if (large < 20 && sprawling < 10 && testFirst > 50 && msgQuality > 60) return 'harmonious-high-achiever';
+ if (sprawling > 25 && large > 30) return 'legacy-bottleneck';
+ if (large > 40 || (testFirst < 30 && large > 20)) return 'foundational-challenges';
+ return 'mixed-signals';
+ }
+
+ // ---------------------------------------------------------------
+ // Fetch PR data
+ // ---------------------------------------------------------------
+
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100
});
-
- // Get commits in this PR for detailed analysis
+
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100
});
-
+
console.log(`Analyzing ${commits.length} commits and ${files.length} files in PR`);
-
- // Separate test and production files
+
const testFiles = files.filter(f =>
/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
);
-
+
const prodFiles = files.filter(f =>
!/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
);
-
- // Analyze each commit in detail
+
+ // ---------------------------------------------------------------
+ // Per-commit analysis
+ // ---------------------------------------------------------------
+
const commitMetrics = [];
for (const commit of commits) {
try {
@@ -53,7 +75,7 @@ jobs:
repo: context.repo.repo,
ref: commit.sha
});
-
+
const commitTestFiles = detail.data.files?.filter(f =>
/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
) || [];
@@ -61,13 +83,16 @@ jobs:
const commitProdFiles = detail.data.files?.filter(f =>
!/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
) || [];
-
+
const prodChanges = commitProdFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0);
const testChanges = commitTestFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0);
-
+ const additions = detail.data.stats?.additions || 0;
+ const deletions = detail.data.stats?.deletions || 1;
+ const message = commit.commit.message.split('\n')[0];
+
commitMetrics.push({
sha: commit.sha.substring(0, 7),
- message: commit.commit.message.split('\n')[0],
+ message,
author: commit.commit.author.name,
date: new Date(commit.commit.author.date).toLocaleDateString(),
files_changed: detail.data.files?.length || 0,
@@ -81,206 +106,271 @@ jobs:
test_first_indicator: commitTestFiles.length > 0 && commitProdFiles.length > 0,
test_only: commitTestFiles.length > 0 && commitProdFiles.length === 0,
prod_only: commitProdFiles.length > 0 && commitTestFiles.length === 0,
- test_ratio: prodChanges > 0 ? (testChanges / prodChanges) : 0
+ test_ratio: prodChanges > 0 ? (testChanges / prodChanges) : 0,
+ additions_ratio: (additions / (deletions || 1)).toFixed(2),
+ message_quality: scoreMessageQuality(message)
});
-
+
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
console.log(`Error analyzing commit ${commit.sha}: ${error.message}`);
}
}
-
- // Calculate PR-level metrics
+
+ // ---------------------------------------------------------------
+ // PR-level aggregates
+ // ---------------------------------------------------------------
+
const totalAdditions = pr.additions;
const totalDeletions = pr.deletions;
const totalChangedFiles = pr.changed_files;
const totalChanges = totalAdditions + totalDeletions;
-
+
const prodAdditions = prodFiles.reduce((sum, f) => sum + f.additions, 0);
const prodDeletions = prodFiles.reduce((sum, f) => sum + f.deletions, 0);
const prodChanges = prodAdditions + prodDeletions;
const prodFilesCount = prodFiles.length;
-
+
const testAdditions = testFiles.reduce((sum, f) => sum + f.additions, 0);
const testDeletions = testFiles.reduce((sum, f) => sum + f.deletions, 0);
const testChanges = testAdditions + testDeletions;
const testFilesCount = testFiles.length;
-
- // Commit-level analysis
+
const largeCommits = commitMetrics.filter(c => c.large_commit).length;
const sprawlingCommits = commitMetrics.filter(c => c.sprawling_commit).length;
const testFirstCommits = commitMetrics.filter(c => c.test_first_indicator).length;
const testOnlyCommits = commitMetrics.filter(c => c.test_only).length;
const prodOnlyCommits = commitMetrics.filter(c => c.prod_only).length;
-
- const avgCommitSize = commitMetrics.length > 0 ?
- (commitMetrics.reduce((sum, c) => sum + c.prod_changes, 0) / commitMetrics.length) : 0;
-
- const avgFilesPerCommit = commitMetrics.length > 0 ?
- (commitMetrics.reduce((sum, c) => sum + c.files_changed, 0) / commitMetrics.length) : 0;
-
- // Development pattern analysis
- const hasRefactoringPattern = commitMetrics.some(c =>
+ const qualityCommits = commitMetrics.filter(c => c.message_quality).length;
+
+ const avgCommitSize = commitMetrics.length > 0
+ ? (commitMetrics.reduce((sum, c) => sum + c.prod_changes, 0) / commitMetrics.length)
+ : 0;
+
+ const avgFilesPerCommit = commitMetrics.length > 0
+ ? (commitMetrics.reduce((sum, c) => sum + c.files_changed, 0) / commitMetrics.length)
+ : 0;
+
+ const largePct = commitMetrics.length > 0 ? (largeCommits / commitMetrics.length * 100) : 0;
+ const sprawlingPct = commitMetrics.length > 0 ? (sprawlingCommits / commitMetrics.length * 100) : 0;
+ const testFirstPct = commitMetrics.length > 0 ? (testFirstCommits / commitMetrics.length * 100) : 0;
+ const msgQualityPct = commitMetrics.length > 0 ? (qualityCommits / commitMetrics.length * 100) : 0;
+
+ const medianAdditionsRatio = (() => {
+ const ratios = commitMetrics.map(c => parseFloat(c.additions_ratio)).sort((a, b) => a - b);
+ if (ratios.length === 0) return 0;
+ const mid = Math.floor(ratios.length / 2);
+ return ratios.length % 2 !== 0 ? ratios[mid] : (ratios[mid - 1] + ratios[mid]) / 2;
+ })();
+
+ // ---------------------------------------------------------------
+ // Development pattern detection
+ // ---------------------------------------------------------------
+
+ const hasRefactoringPattern = commitMetrics.some(c =>
/refactor|cleanup|reorganize|extract|rename/i.test(c.message)
);
-
- const hasIncrementalPattern = commitMetrics.length > 1 &&
+
+ const hasIncrementalPattern = commitMetrics.length > 1 &&
commitMetrics.every(c => c.prod_changes < 200);
-
- const hasTestDiscipline = commitMetrics.length > 0 &&
+
+ const hasTestDiscipline = commitMetrics.length > 0 &&
(testFirstCommits / commitMetrics.length) > 0.5;
-
- // PR size classification
+
+ // ---------------------------------------------------------------
+ // Size classification
+ // ---------------------------------------------------------------
+
let sizeLabel = 'small';
- let concerns = [];
- let strengths = [];
-
+ const concerns = [];
+ const strengths = [];
+
if (prodChanges > 500) {
sizeLabel = 'extra-large';
- concerns.push(':warning: Very large production changes - consider breaking into smaller PRs');
+ concerns.push('Very large production changes - consider breaking into smaller PRs');
} else if (prodChanges > 200) {
sizeLabel = 'large';
- concerns.push(':straight_ruler: Large production changes - review carefully');
+ concerns.push('Large production changes - review carefully');
} else if (prodChanges > 100) {
sizeLabel = 'medium';
}
-
+
if (prodFilesCount > 10) {
- concerns.push(':file_folder: Many production files changed - possible scope creep');
+ concerns.push('Many production files changed - possible scope creep');
}
-
- // Commit quality assessment
+
if (largeCommits > commitMetrics.length * 0.5) {
- concerns.push(`:package: ${largeCommits}/${commitMetrics.length} commits are large (>100 production lines)`);
+ concerns.push(`${largeCommits}/${commitMetrics.length} commits exceed 100 production lines`);
}
-
+
if (sprawlingCommits > commitMetrics.length * 0.3) {
- concerns.push(`:deciduous_tree: ${sprawlingCommits}/${commitMetrics.length} commits touch many files (>5)`);
+ concerns.push(`${sprawlingCommits}/${commitMetrics.length} commits touch more than 5 files`);
+ }
+
+ if (medianAdditionsRatio > 3.0) {
+ concerns.push(`Median additions ratio ${medianAdditionsRatio.toFixed(2)} exceeds 3.0 - possible batch-acceptance pattern`);
}
-
- // Positive patterns
+
+ if (msgQualityPct < 40) {
+ concerns.push(`Message quality ${msgQualityPct.toFixed(0)}% is below 40% - consider more descriptive commit messages`);
+ }
+
if (hasIncrementalPattern) {
- strengths.push(':building_construction: Incremental development pattern detected');
+ strengths.push('Incremental development pattern detected');
}
-
+
if (hasTestDiscipline) {
- strengths.push(':test_tube: Strong test-first discipline across commits');
+ strengths.push('Strong test-first discipline across commits');
}
-
+
if (hasRefactoringPattern) {
- strengths.push(':recycle: Includes refactoring/cleanup work');
+ strengths.push('Includes refactoring or cleanup work');
}
-
+
if (testOnlyCommits > 0) {
- strengths.push(`:white_check_mark: ${testOnlyCommits} test-only commits (good for TDD)`);
+ strengths.push(`${testOnlyCommits} test-only commits`);
}
-
- // Test adequacy analysis
+
+ if (msgQualityPct >= 60) {
+ strengths.push(`Message quality ${msgQualityPct.toFixed(0)}% meets discipline threshold`);
+ }
+
+ // ---------------------------------------------------------------
+ // Test adequacy
+ // ---------------------------------------------------------------
+
let testAdequacy = 'excellent';
- let testConcerns = [];
- let testPraise = [];
-
+ const testConcerns = [];
+ const testPraise = [];
const testRatio = prodChanges > 0 ? (testChanges / prodChanges) : 0;
-
+
if (prodChanges === 0) {
if (testChanges > 0) {
testAdequacy = 'excellent';
- testPraise.push(':white_check_mark: Test-only changes - refactoring or test improvements');
+ testPraise.push('Test-only changes - refactoring or test improvements');
} else {
testAdequacy = 'n/a';
}
- } else if (prodChanges > 0 && testChanges === 0) {
+ } else if (testChanges === 0) {
testAdequacy = 'poor';
- testConcerns.push(':x: No test changes for production code modifications');
+ testConcerns.push('No test changes for production code modifications');
if (prodOnlyCommits === commitMetrics.length) {
- testConcerns.push(':warning: All commits lack test coverage');
+ testConcerns.push('All commits lack test coverage');
}
} else if (testRatio < 0.3) {
testAdequacy = 'needs-improvement';
- testConcerns.push(':yellow_circle: Low test coverage - consider adding more tests');
+ testConcerns.push('Low test coverage ratio - consider adding more tests');
} else if (testRatio >= 0.3 && testRatio <= 2.0) {
testAdequacy = 'good';
- testPraise.push(':white_check_mark: Good balance of production and test code');
+ testPraise.push('Good balance of production and test code');
} else {
testAdequacy = 'excellent';
- testPraise.push(':star2: Excellent test coverage - comprehensive testing');
+ testPraise.push('Comprehensive test coverage');
}
-
- // Create detailed breakdown
- const breakdown = `
- **Production Code:** ${prodChanges} lines (${prodFilesCount} files)
- **Test Code:** ${testChanges} lines (${testFilesCount} files)
- **Total:** ${totalChanges} lines (${totalChangedFiles} files)
- ${prodChanges > 0 ? `**Test-to-Production Ratio:** ${testRatio.toFixed(2)}:1` : ''}
- `;
-
- const commitAnalysis = `
- ## Commit Analysis
-
- **Total Commits:** ${commitMetrics.length}
- **Average Commit Size:** ${avgCommitSize.toFixed(0)} production lines
- **Average Files per Commit:** ${avgFilesPerCommit.toFixed(1)}
-
- ### Commit Quality Metrics:
- - **Large Commits (>100 prod lines):** ${largeCommits}/${commitMetrics.length} (${(largeCommits/commitMetrics.length*100).toFixed(0)}%)
- - **Sprawling Commits (>5 files):** ${sprawlingCommits}/${commitMetrics.length} (${(sprawlingCommits/commitMetrics.length*100).toFixed(0)}%)
- - **Test-First Discipline:** ${testFirstCommits}/${commitMetrics.length} (${(testFirstCommits/commitMetrics.length*100).toFixed(0)}%)
- - **Test-Only Commits:** ${testOnlyCommits}
- - **Production-Only Commits:** ${prodOnlyCommits}
-
- ### Development Patterns:
- ${strengths.length > 0 ? strengths.map(s => `- ${s}`).join('\n') : '- No notable positive patterns detected'}
-
- ${commitMetrics.length <= 10 ? `
- ### Commit Details:
- ${commitMetrics.map(c => `
- **${c.sha}** by ${c.author} (${c.date})
- \`${c.message}\`
- - Files: ${c.files_changed} (${c.prod_files} prod, ${c.test_files} test)
- - Changes: ${c.prod_changes} prod lines, ${c.test_changes} test lines
- ${c.large_commit ? '- :package: Large commit' : ''}${c.sprawling_commit ? '- :deciduous_tree: Sprawling commit' : ''}${c.test_first_indicator ? '- :test_tube: Test-first' : ''}
- `).join('\n')}` : ''}
- `;
-
- const testSection = testAdequacy !== 'n/a' ? `
- ## Test Coverage Analysis
-
- **Test Adequacy:** ${testAdequacy}
-
- ${testPraise.length > 0 ? '### Strengths:\n' + testPraise.map(p => `- ${p}`).join('\n') + '\n' : ''}
- ${testConcerns.length > 0 ? '### Test Coverage Concerns:\n' + testConcerns.map(c => `- ${c}`).join('\n') + '\n' : ''}
-
- ### Test Coverage Guidelines:
- - **Target ratio:** 0.5-2.0 test lines per production line
- - **Minimum:** Some test changes for any production code modifications
- - **Best practice:** Write tests first (TDD) when adding new features
- ` : '';
-
- const comment = `
- ## Enhanced PR Analysis
-
- **Size:** ${sizeLabel} (based on production code)
-
- ${breakdown}
-
- ${concerns.length > 0 ? '### Size & Quality Concerns:\n' + concerns.map(c => `- ${c}`).join('\n') + '\n' : ':white_check_mark: Good size and commit quality\n'}
-
- ${commitAnalysis}
-
- ${testSection}
-
- ### PDCA Framework Alignment:
- - **Plan:** ${hasIncrementalPattern ? ':white_check_mark: Shows incremental planning' : ':yellow_circle: Consider smaller, planned increments'}
- - **Do:** ${commitMetrics.length > 0 ? ':white_check_mark: Implementation tracked in commits' : ':x: No commit history'}
- - **Check:** ${testAdequacy === 'good' || testAdequacy === 'excellent' ? ':white_check_mark: Good validation through tests' : ':yellow_circle: Needs better validation'}
- - **Act:** ${hasRefactoringPattern ? ':white_check_mark: Shows continuous improvement' : ':information_source: Consider refactoring opportunities'}
-
- *Automated by Enhanced PDCA Framework - analyzing both PR and commit-level metrics*
- `;
-
- github.rest.issues.createComment({
+
+ // ---------------------------------------------------------------
+ // DORA archetype
+ // ---------------------------------------------------------------
+
+ const archetype = commitMetrics.length > 0
+ ? classifyDoraArchetype(largePct, sprawlingPct, testFirstPct, msgQualityPct)
+ : 'mixed-signals';
+
+ const archetypeDescriptions = {
+ 'harmonious-high-achiever': 'Strong batch discipline, test coverage, and message quality. AI tools likely amplifying positive outcomes.',
+ 'foundational-challenges': 'Weak testing or batch discipline detected. Consider strengthening practices before scaling AI usage.',
+ 'legacy-bottleneck': 'High sprawl combined with large commits. AI may be making cross-cutting changes worse.',
+ 'mixed-signals': 'No clear archetype pattern. Review individual metric thresholds.'
+ };
+
+ // ---------------------------------------------------------------
+ // PR comment
+ // ---------------------------------------------------------------
+
+ const breakdown = [
+ `**Production Code:** ${prodChanges} lines (${prodFilesCount} files)`,
+ `**Test Code:** ${testChanges} lines (${testFilesCount} files)`,
+ `**Total:** ${totalChanges} lines (${totalChangedFiles} files)`,
+ prodChanges > 0 ? `**Test-to-Production Ratio:** ${testRatio.toFixed(2)}:1` : ''
+ ].filter(Boolean).join('\n');
+
+ const commitRows = commitMetrics.slice(0, 10).map(c => {
+ const flags = [
+ c.large_commit ? 'large' : '',
+ c.sprawling_commit ? 'sprawling' : '',
+ c.test_first_indicator ? 'test+prod' : '',
+ !c.message_quality ? 'vague-msg' : ''
+ ].filter(Boolean).join(', ');
+ return `**${c.sha}** ${c.author} (${c.date})\n\`${c.message}\`\n${c.prod_changes} prod lines, ${c.files_changed} files${flags ? ` [${flags}]` : ''}`;
+ }).join('\n\n');
+
+ const doraSection = commitMetrics.length > 0 ? [
+ '### DORA Capability Assessment',
+ '',
+ `**Archetype:** ${archetype}`,
+ archetypeDescriptions[archetype],
+ '',
+ '| Capability | Metric | Value | Target |',
+ '|------------|--------|-------|--------|',
+ `| Small Batches | Large commit % | ${largePct.toFixed(0)}% | <20% |`,
+ `| Small Batches | Sprawling commit % | ${sprawlingPct.toFixed(0)}% | <10% |`,
+ `| Version Control | Test-first discipline | ${testFirstPct.toFixed(0)}% | >50% |`,
+ `| Version Control | Message quality | ${msgQualityPct.toFixed(0)}% | >60% |`,
+ `| AI Risk Signal | Additions ratio (median) | ${medianAdditionsRatio.toFixed(2)} | <3.0 |`
+ ].join('\n') : '';
+
+ const commentParts = [
+ '## PR Analysis',
+ '',
+ `**Size:** ${sizeLabel} (based on production code)`,
+ '',
+ breakdown,
+ '',
+ concerns.length > 0
+ ? '### Concerns\n\n' + concerns.map(c => `- ${c}`).join('\n')
+ : 'No size or quality concerns.',
+ '',
+ strengths.length > 0
+ ? '### Strengths\n\n' + strengths.map(s => `- ${s}`).join('\n')
+ : '',
+ '',
+ '## Commit Analysis',
+ '',
+ `**Total Commits:** ${commitMetrics.length}`,
+ `**Average Commit Size:** ${avgCommitSize.toFixed(0)} production lines`,
+ `**Average Files per Commit:** ${avgFilesPerCommit.toFixed(1)}`,
+ '',
+ '| Metric | Value |',
+ '|--------|-------|',
+ `| Large commits (>100 prod lines) | ${largeCommits}/${commitMetrics.length} (${largePct.toFixed(0)}%) |`,
+ `| Sprawling commits (>5 files) | ${sprawlingCommits}/${commitMetrics.length} (${sprawlingPct.toFixed(0)}%) |`,
+ `| Test-first discipline | ${testFirstCommits}/${commitMetrics.length} (${testFirstPct.toFixed(0)}%) |`,
+ `| Message quality | ${qualityCommits}/${commitMetrics.length} (${msgQualityPct.toFixed(0)}%) |`,
+ `| Median additions ratio | ${medianAdditionsRatio.toFixed(2)} |`,
+ `| Test-only commits | ${testOnlyCommits} |`,
+ `| Production-only commits | ${prodOnlyCommits} |`,
+ '',
+ commitMetrics.length <= 10 ? '### Commit Details\n\n' + commitRows : '',
+ '',
+ testAdequacy !== 'n/a' ? [
+ '## Test Coverage',
+ '',
+ `**Test Adequacy:** ${testAdequacy}`,
+ '',
+ testPraise.length > 0 ? testPraise.map(p => `- ${p}`).join('\n') : '',
+ testConcerns.length > 0 ? testConcerns.map(c => `- ${c}`).join('\n') : '',
+ '',
+ '**Target ratio:** 0.5-2.0 test lines per production line'
+ ].join('\n') : '',
+ '',
+ doraSection,
+ '',
+ '_Automated by Code Metrics Workflow_'
+ ].filter(s => s !== '').join('\n');
+
+ await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
- body: comment
- });
\ No newline at end of file
+ body: commentParts
+ });
diff --git a/.gitignore b/.gitignore
index 7decb47..afd32dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,9 +8,14 @@
!.beads/issues.jsonl
!.beads/config.yaml
+# Environment variables — never commit API keys
+.env
+.env.local
+
# Generated output files
local_commit_metrics.json
local_metrics_summary.json
+RETROSPECTIVE*.md
# Test coverage
coverage/
diff --git a/CLAUDE.md b/CLAUDE.md
index d98c781..2e35596 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Purpose
-This toolkit detects **AI code drift** — problematic patterns that emerge when teams adopt AI coding tools. It captures metrics *before* merge squashing destroys the signals, making visible how AI tools actually affect code quality.
+This toolkit detects **AI code drift**: problematic patterns that emerge when teams adopt AI coding tools. It captures metrics *before* merge squashing destroys the signals, making visible how AI tools actually affect code quality.
Key insight: Local analysis reveals 10x higher drift rates than remote analysis because `git merge --squash` and branch deletion destroy granular commit-level signals.
@@ -26,13 +26,13 @@ npm test # run all tests
npm run test:coverage # tests with coverage report (thresholds: 80% lines, 90% functions)
npm run test:watch # watch mode
npx jest __tests__/parseGitLog.test.js # run a single test file
-npm run lint # ESLint (flat config, globals.node required — already configured)
+npm run lint # ESLint (flat config, globals.node required; already configured)
npm run typecheck # tsc --noEmit (checks local-code-metrics.js via @ts-check + tsconfig.json)
```
-All tests mock `child_process` and `fs` — no git repository required to run the suite.
+All tests mock `child_process` and `fs`. No git repository is required to run the suite.
-A pre-commit hook runs lint → typecheck → test automatically. After cloning, activate it with:
+A pre-commit hook runs lint, typecheck, and test automatically. After cloning, activate it with:
```bash
npm install # triggers `prepare`, which sets core.hooksPath to .githooks
@@ -40,13 +40,20 @@ npm install # triggers `prepare`, which sets core.hooksPath to .githooks
## Architecture
-Three components, no shared code between them:
+Three public components with no shared code between them:
-1. **`local-code-metrics.js`** — Standalone Node.js script. Reads local git history via shell commands, classifies files as test vs. production, computes metrics, writes `local_commit_metrics.json` + `local_metrics_summary.json`, and prints a console report with insights.
+1. **`local-code-metrics.js`**: Standalone Node.js script (requires Node ≥18). Orchestration entry point that delegates to focused modules in `lib/`. Reads local git history via shell commands, classifies files as test vs. production, computes metrics, writes `local_commit_metrics.json` + `local_metrics_summary.json` + (optionally) `local_claude_analysis.json`, and prints a console report with insights.
-2. **`.github/workflows/code-metrics.yml`** — Weekly GitHub Actions workflow. Uses the GitHub API to analyze feature branches from the past 30 days. Outputs a JSON artifact and creates a GitHub issue with the summary.
+ The `lib/` directory contains the internal modules (not shared with workflows):
+ - `lib/config.js` — CONFIG object; single source of truth for all thresholds
+ - `lib/git.js` — git shell commands, log parsing, per-commit analysis, diff extraction
+ - `lib/statistics.js` — statistical distributions (p50/p90/p95/stddev), velocity and trend
+ - `lib/metrics.js` — message quality scoring, DORA archetype classification, insights generation
+ - `lib/claude.js` — Anthropic client setup, commit pre-filtering, diff-level API analysis
-3. **`.github/workflows/pr-metrics.yml`** — Per-PR GitHub Actions workflow. Posts a detailed comment on each PR with commit-by-commit analysis, test adequacy, and development pattern detection.
+2. **`.github/workflows/code-metrics.yml`**: Weekly GitHub Actions workflow. Uses the GitHub API to analyze feature branches from the past 30 days. Outputs a JSON artifact and creates a GitHub issue with the summary.
+
+3. **`.github/workflows/pr-metrics.yml`**: Per-PR GitHub Actions workflow. Posts a detailed comment on each PR with commit-by-commit analysis, test adequacy, and development pattern detection.
## Key Metrics and Thresholds
@@ -56,12 +63,48 @@ Three components, no shared code between them:
| Sprawling commit % (>5 files) | <10% |
| Test-to-production ratio | 0.5–2.0:1 |
| Avg files changed per commit | <5 |
+| Commit message quality % | >60% |
+| Additions-to-deletions ratio (median) | <3.0 |
+
+Statistical distributions (p50/p90/p95/stddev) are computed for lines changed and files changed. Commit velocity trend and DORA team archetype are included in the summary.
+
+### DORA Archetype Classification
+
+The summary includes a `dora_archetype` field classifying the repository into one of four team archetypes based on large commit %, sprawling commit %, test-first %, and message quality %:
+
+| Archetype | Signal |
+|-----------|--------|
+| `harmonious-high-achiever` | All four metrics in healthy range |
+| `legacy-bottleneck` | High sprawl (>25%) + high large commits (>30%) |
+| `foundational-challenges` | Large commits >40%, or low test discipline + elevated large commits |
+| `mixed-signals` | No clear archetype threshold breached |
+
+### Claude API Integration (Optional)
+
+Set `ANTHROPIC_API_KEY` to enable diff-level analysis of high-risk commits. When active:
+- Up to 5 commits are selected (large commits with additions > deletions × 3)
+- Each commit is analyzed for AI-generated code patterns and architectural concerns
+- Results are written to `local_claude_analysis.json`
+- Commit metrics are annotated with `ai_confidence`, `risk_score`, `patterns`, and `architectural_concerns`
+
+The script degrades gracefully when the key is absent. No SDK install is required to run.
## Configuration
-Thresholds are configured in the `CONFIG` object at the top of `local-code-metrics.js`. The GitHub workflows have equivalent values hard-coded in their shell/jq logic. When adjusting thresholds, update both places.
+Thresholds are configured in the `CONFIG` object in `lib/config.js`, which is the single source of truth for the local script. Key values:
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `LARGE_COMMIT_THRESHOLD` | 100 | Prod lines changed to flag as large |
+| `SPRAWLING_COMMIT_THRESHOLD` | 5 | Files changed to flag as sprawling |
+| `MESSAGE_QUALITY_MIN_WORDS` | 10 | Word count threshold for non-conventional messages |
+| `AI_ANALYSIS_MAX_COMMITS` | 5 | Max commits sent to Claude per run |
+| `AI_DIFF_MAX_CHARS` | 4000 | Diff truncation limit for Claude API calls |
+| `AI_RISK_ADDITIONS_RATIO` | 3 | Additions/deletions multiplier for Claude pre-filter |
+
+The GitHub workflows have equivalent values hard-coded in their shell/jq logic. When adjusting thresholds, update both places.
-Test file detection uses patterns for JS, Python, Go, Java, and C# — extend `TEST_FILE_PATTERNS` in the script or the equivalent grep patterns in the workflows for other languages.
+Test file detection uses patterns for JS, Python, Go, Java, and C#. Extend `TEST_FILE_PATTERNS` in `lib/config.js` or the equivalent grep patterns in the workflows for other languages.
## Workflow Permissions
diff --git a/README.md b/README.md
index 8d77d0b..6053ae2 100644
--- a/README.md
+++ b/README.md
@@ -10,20 +10,21 @@ Research shows that AI coding tools can lead to increased batch sizes, reduced r
## Tools Included
-### 1. 📊 Weekly AI Code Drift Metrics (GitHub Actions)
-- **File:** `.github/workflows/ai-code-drift-metrics.yml`
+### 1. Weekly AI Code Drift Metrics (GitHub Actions)
+- **File:** `.github/workflows/code-metrics.yml`
- **Purpose:** Automated weekly analysis of feature branches
- **Output:** GitHub issues with trend analysis and artifacts
-### 2. 🚦 Real-time PR Size Analysis (GitHub Actions)
-- **File:** `.github/workflows/pr-size-analysis.yml`
+### 2. Real-time PR Size Analysis (GitHub Actions)
+- **File:** `.github/workflows/pr-metrics.yml`
- **Purpose:** Immediate feedback on every pull request
- **Output:** PR comments with size warnings and recommendations
-### 3. 🔍 Local Repository Analysis (Node.js Script)
-- **File:** `local-ai-drift-analysis.js`
+### 3. Local Repository Analysis (Node.js Script)
+- **File:** `local-code-metrics.js`
- **Purpose:** Immediate analysis of your local development patterns
- **Output:** Console report and JSON files with detailed metrics
+- **Requires:** Node.js >= 18
## Quick Start
@@ -31,23 +32,24 @@ Research shows that AI coding tools can lead to increased batch sizes, reduced r
```bash
# 1. Copy workflows to your repository
mkdir -p .github/workflows
-curl -o .github/workflows/ai-code-drift-metrics.yml https://raw.githubusercontent.com/yourrepo/toolkit/main/ai-code-drift-metrics.yml
-curl -o .github/workflows/pr-size-analysis.yml https://raw.githubusercontent.com/yourrepo/toolkit/main/pr-size-analysis.yml
+# Copy .github/workflows/code-metrics.yml and pr-metrics.yml from this repo
# 2. Ensure feature branches aren't auto-deleted
# Go to repo Settings > General > Pull Requests
# Uncheck "Automatically delete head branches"
# 3. Run manually or wait for weekly schedule
+gh workflow run code-metrics.yml
```
### Option 2: Local Analysis
```bash
-# 1. Download and run the local script
-curl -o local-ai-drift-analysis.js https://raw.githubusercontent.com/yourrepo/toolkit/main/local-ai-drift-analysis.js
-node local-ai-drift-analysis.js
+# 1. Clone and run
+npm install
+node local-code-metrics.js
# 2. Review the generated JSON files and console output
+# Optional: set ANTHROPIC_API_KEY for Claude diff analysis
```
## Key Metrics Tracked
@@ -56,20 +58,21 @@ node local-ai-drift-analysis.js
|--------|--------|---------|
| **Large Commit %** | <20% | Detects batch AI code acceptance |
| **Sprawling Commit %** | <10% | Identifies scattered changes across files |
-| **Test-First Discipline** | Trending ↗ | Monitors TDD practices with AI tools |
+| **Test-First Discipline** | >50% | Monitors TDD practices with AI tools |
+| **Message Quality %** | >60% | Conventional commits or descriptive messages |
+| **Additions/Deletions Ratio (median)** | <3.0 | Flags batch-acceptance pattern |
| **Avg Files Changed** | <5 | Measures development granularity |
-| **Avg Lines Changed** | <100 | Detects wholesale AI code acceptance |
## Real-World Example
**Remote Repository Analysis (misleading):**
- 4 commits over 30 days
-- 0% large commits
+- 0% large commits
- 8 lines average per commit
**Local Repository Analysis (reality):**
- 50 commits across 4 feature branches
-- **46% large commits**
+- **46% large commits**
- **9,053 lines average per commit**
- Clear AI drift patterns hidden by merge squashing
@@ -81,7 +84,7 @@ Customize test file patterns for your language:
```javascript
// In workflows or local script CONFIG
TEST_FILE_PATTERNS: [
- /\.(test|spec)\./i, // JavaScript/TypeScript
+ /\.(test|spec)\./i, // JavaScript/TypeScript
/Tests?\.cs$/i, // C# (FileTests.cs)
/Test\.java$/i, // Java (FileTest.java)
/_test\.py$/i, // Python (file_test.py)
@@ -95,45 +98,52 @@ TEST_FILE_PATTERNS: [
Adjust warning thresholds based on your team:
```javascript
-// Large commit threshold (lines)
-large_commit: (additions + deletions) > 100,
-
-// Sprawling commit threshold (files)
-sprawling_commit: files_changed > 5,
-
-// Analysis period (days)
-ANALYSIS_DAYS: 30,
+LARGE_COMMIT_THRESHOLD: 100, // lines changed
+SPRAWLING_COMMIT_THRESHOLD: 5, // files changed
+ANALYSIS_DAYS: 30, // lookback window
+MESSAGE_QUALITY_MIN_WORDS: 10, // words for non-conventional messages
+AI_RISK_ADDITIONS_RATIO: 3, // additions/deletions multiplier for Claude pre-filter
+AI_ANALYSIS_MAX_COMMITS: 5, // max commits sent to Claude per run
```
## Understanding Results
-### 🟢 Healthy Patterns
+### Healthy Patterns
```
Large commits: <20%
Sprawling commits: <10%
Test-first discipline: >50%
-Avg files changed: <5
-Avg lines changed: <100
+Message quality: >60%
+Additions ratio (median): <3.0
```
-### 🟡 Warning Signs
+### Warning Signs
```
Large commits: 20-40%
Sprawling commits: 10-25%
Test-first discipline: 30-50%
-Avg files changed: 5-10
-Avg lines changed: 100-500
+Additions ratio (median): 3.0-6.0
```
-### 🔴 Critical Issues
+### Critical Issues
```
Large commits: >40%
-Sprawling commits: >25%
+Sprawling commits: >25%
Test-first discipline: <30%
-Avg files changed: >10
-Avg lines changed: >500
+Additions ratio (median): >6.0
```
+## DORA Archetype Classification
+
+The summary includes a `dora_archetype` field:
+
+| Archetype | Signal |
+|-----------|--------|
+| `harmonious-high-achiever` | All metrics in healthy range |
+| `legacy-bottleneck` | High sprawl + high large commits |
+| `foundational-challenges` | Large commits >40% or low test discipline |
+| `mixed-signals` | No clear threshold breached |
+
## Workflow Outputs
### Weekly Metrics Report (GitHub Issue)
@@ -147,50 +157,50 @@ Avg lines changed: >500
### Key Metrics
| Metric | Value | Target | Status |
|--------|-------|--------|---------|
-| Large Commits | 28% | <20% | ⚠️ |
-| Sprawling Commits | 12% | <10% | ⚠️ |
-| Test-First Discipline | 64% | Trending ↗ | ✅ |
+| Large Commits | 28% | <20% | Warning |
+| Sprawling Commits | 12% | <10% | Warning |
+| Test-First Discipline | 64% | >50% | OK |
### Interpretation
-⚠️ **Large commits above 20% threshold** - Consider breaking down AI-generated code
-⚠️ **Sprawling commits above 10% threshold** - Review AI suggestions for scope creep
+**Large commits above 20% threshold** - Consider breaking down AI-generated code
+**Sprawling commits above 10% threshold** - Review AI suggestions for scope creep
```
### PR Size Analysis (PR Comment)
-```markdown
-## 🟠 PR Size Analysis
+```markdown
+## PR Size Analysis
**Size Classification:** large
**Total Changes:** 847 lines (+782, -65)
**Files Changed:** 12
-### ⚠️ Concerns:
-- ⚠️ **Large PR** - May indicate batch acceptance of AI-generated code
-- ⚠️ **Multiple files changed** - Ensure changes are cohesive
+### Concerns:
+- **Large PR** - May indicate batch acceptance of AI-generated code
+- **Multiple files changed** - Ensure changes are cohesive
-### 💡 Recommendations:
+### Recommendations:
- Review carefully for AI-generated patterns that should be broken down
- Consider splitting into focused, single-responsibility PRs
```
### Local Script Output
-```bash
-=== 📊 ANALYSIS RESULTS ===
-
-📈 Total commits analyzed: 50
-📏 Large commits (>100 lines): 46.00%
-📁 Sprawling commits (>5 files): 20.00%
-🧪 Test-first discipline: 58.00%
-📂 Average files changed: 6.42
-📝 Average lines changed: 9,053
-
-=== ⚠️ CONCERNS DETECTED ===
-🚨 Very high large commit rate (46%) - Strong AI drift indicators
-⚠️ High sprawling commit rate (20%) - Watch for scope creep
-
-=== 💡 RECOMMENDATIONS ===
-• Consider breaking AI-generated code into smaller, focused commits
-• Review if AI suggestions are causing scattered changes across files
+```
+=== ANALYSIS RESULTS ===
+
+Total commits analyzed: 50
+Large commits (>100 lines): 46.00%
+Sprawling commits (>5 files): 20.00%
+Test-first discipline: 58.00%
+Average files changed: 6.42
+Average lines changed: 9,053
+
+=== CONCERNS DETECTED ===
+[CRITICAL] Very high large commit rate (46%) - Strong AI drift indicators
+[WARNING] High sprawling commit rate (20%) - Watch for scope creep
+
+=== RECOMMENDATIONS ===
+- Consider breaking AI-generated code into smaller, focused commits
+- Review if AI suggestions are causing scattered changes across files
```
## Prerequisites
@@ -200,20 +210,22 @@ Avg lines changed: >500
- Feature branches preserved after merging (disable auto-delete)
- Repository permissions for creating issues and PR comments
-### For Local Script
-- Node.js (v12 or later)
+### For Local Script
+- Node.js >= 18
- Git repository with local feature branches
- Command line access
+- Optional: `ANTHROPIC_API_KEY` for Claude diff-level analysis
## File Structure
```
your-repo/
├── .github/workflows/
-│ ├── ai-code-drift-metrics.yml # Weekly analysis
-│ └── pr-size-analysis.yml # Real-time PR feedback
-├── local-ai-drift-analysis.js # Local analysis script
-├── local_commit_metrics.json # Generated: detailed data
-└── local_metrics_summary.json # Generated: summary stats
+│ ├── code-metrics.yml # Weekly analysis
+│ └── pr-metrics.yml # Real-time PR feedback
+├── local-code-metrics.js # Local analysis script
+├── local_commit_metrics.json # Generated: detailed data
+├── local_metrics_summary.json # Generated: summary stats
+└── local_claude_analysis.json # Generated: Claude analysis (optional)
```
## Integration Examples
@@ -254,17 +266,17 @@ your-repo/
## Why This Matters
**The Problem:** Teams adopting AI tools often see:
-- ✅ Faster initial coding
-- ❌ Larger, harder-to-review commits
-- ❌ Reduced refactoring discipline
-- ❌ Technical debt accumulation
-- ❌ Net productivity loss over time
+- Faster initial coding
+- Larger, harder-to-review commits
+- Reduced refactoring discipline
+- Technical debt accumulation
+- Net productivity loss over time
**The Solution:** Measure development patterns before they're hidden by workflow processing:
-- 🔍 **Early detection** of problematic AI usage
-- 📊 **Quantified feedback** for development process improvement
-- 🚦 **Real-time prevention** through PR size controls
-- 📈 **Trend analysis** to track team improvement
+- **Early detection** of problematic AI usage
+- **Quantified feedback** for development process improvement
+- **Real-time prevention** through PR size controls
+- **Trend analysis** to track team improvement
## Related Research
@@ -273,9 +285,11 @@ This toolkit implements the methodology described in:
Key findings:
- Merge squashing destroys 90%+ of AI drift signals
-- Local analysis reveals 10x higher drift rates than remote analysis
+- Local analysis reveals 10x higher drift rates than remote analysis
- Teams can maintain quality with proper measurement and discipline
+See also: [metrics-specification.md](metrics-specification.md) for the full technical reference.
+
## License
This work is licensed under [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/).
@@ -284,20 +298,20 @@ You are free to share and adapt this material for any purpose, including commerc
## Contributing
-Improvements welcome! Particularly valuable:
+Improvements welcome. Particularly valuable:
- Additional test file patterns for different languages
-- Enhanced AI pattern detection algorithms
+- Enhanced AI pattern detection algorithms
- Better threshold recommendations for different project types
- Integration examples with other development tools
## Support
-- 📖 **Documentation:** This README covers all common use cases
-- 🐛 **Issues:** Report bugs or request features in the GitHub issues
-- 💬 **Discussions:** Share your results and insights with the community
+- **Documentation:** This README and [metrics-specification.md](metrics-specification.md) cover all common use cases
+- **Issues:** Report bugs or request features in the GitHub issues
+- **Discussions:** Share your results and insights with the community
---
**Attribution:** Based on research by Ken Judy. Please cite when using or adapting these tools.
-**Citation:** Judy, K. (2025). Measuring AI Code Drift: Working with GitHub's Available Metrics to Track LLM Impact on Existing Codebases. https://github.com/stride-nyc/code-quality-metrics
\ No newline at end of file
+**Citation:** Judy, K. (2025). Measuring AI Code Drift: Working with GitHub's Available Metrics to Track LLM Impact on Existing Codebases. https://github.com/stride-nyc/code-quality-metrics
diff --git a/__tests__/claudeAnalysis.test.js b/__tests__/claudeAnalysis.test.js
new file mode 100644
index 0000000..ece2049
--- /dev/null
+++ b/__tests__/claudeAnalysis.test.js
@@ -0,0 +1,215 @@
+'use strict';
+
+jest.mock('@anthropic-ai/sdk', () => ({
+ Anthropic: jest.fn().mockImplementation(() => ({
+ messages: {
+ create: jest.fn().mockResolvedValue({
+ content: [{ type: 'text', text: JSON.stringify({
+ ai_confidence: 75,
+ risk_score: 80,
+ patterns: ['generic variable names'],
+ architectural_concerns: [],
+ summary: 'Test summary'
+ }) }]
+ })
+ }
+ }))
+}), { virtual: true });
+
+jest.mock('child_process');
+jest.mock('fs');
+
+const { execSync } = require('child_process');
+const {
+ getAnthropicClient,
+ selectClaudeCommits,
+ getCommitDiff,
+ analyzeWithClaude,
+ CONFIG,
+} = require('../local-code-metrics');
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ delete process.env.ANTHROPIC_API_KEY;
+});
+
+afterEach(() => {
+ delete process.env.ANTHROPIC_API_KEY;
+});
+
+// ---------------------------------------------------------------------------
+// getAnthropicClient
+// ---------------------------------------------------------------------------
+
+describe('getAnthropicClient', () => {
+ test('returns null when ANTHROPIC_API_KEY is not set', async () => {
+ const client = await getAnthropicClient();
+ expect(client).toBeNull();
+ });
+
+ test('returns a client object when ANTHROPIC_API_KEY is set', async () => {
+ process.env.ANTHROPIC_API_KEY = 'test-key';
+ const client = await getAnthropicClient();
+ expect(client).not.toBeNull();
+ expect(typeof client.messages.create).toBe('function');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getCommitDiff
+// ---------------------------------------------------------------------------
+
+describe('getCommitDiff', () => {
+ test('combines stat and diff output into a single string', () => {
+ execSync
+ .mockReturnValueOnce('src/app.js | 10 ++++') // git show --stat
+ .mockReturnValueOnce('+const x = 1;'); // git show diff
+
+ const result = getCommitDiff('abc123');
+ expect(result).toContain('File Summary');
+ expect(result).toContain('src/app.js | 10 ++++');
+ expect(result).toContain('Diff');
+ expect(result).toContain('+const x = 1;');
+ });
+
+ test('truncates output to AI_DIFF_MAX_CHARS', () => {
+ const bigOutput = 'x'.repeat(CONFIG.AI_DIFF_MAX_CHARS + 1000);
+ execSync
+ .mockReturnValueOnce(bigOutput)
+ .mockReturnValueOnce(bigOutput);
+
+ const result = getCommitDiff('abc123');
+ expect(result.length).toBeLessThanOrEqual(CONFIG.AI_DIFF_MAX_CHARS);
+ });
+
+ test('returns valid string even when git commands return empty', () => {
+ execSync.mockReturnValue('');
+ const result = getCommitDiff('abc123');
+ expect(typeof result).toBe('string');
+ expect(result).toContain('File Summary');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// analyzeWithClaude
+// ---------------------------------------------------------------------------
+
+describe('analyzeWithClaude', () => {
+ const { Anthropic } = require('@anthropic-ai/sdk');
+
+ function makeClient() {
+ return new Anthropic();
+ }
+
+ const COMMIT = {
+ sha: 'abc1234',
+ full_sha: 'abc1234' + '0'.repeat(33),
+ message: 'feat: add thing',
+ author: 'Dev',
+ date: '2024-01-15T10:00:00Z',
+ source_branch: 'feature/x',
+ };
+
+ beforeEach(() => {
+ // getCommitDiff calls execSync twice per commit
+ execSync.mockReturnValue('mock diff content');
+ });
+
+ test('returns result with ai_confidence and risk_score for a qualifying commit', async () => {
+ const client = makeClient();
+ const results = await analyzeWithClaude(client, [COMMIT]);
+ expect(results).toHaveLength(1);
+ expect(results[0].sha).toBe(COMMIT.sha);
+ expect(results[0].ai_confidence).toBe(75);
+ expect(results[0].risk_score).toBe(80);
+ });
+
+ test('returns result with patterns and architectural_concerns arrays', async () => {
+ const client = makeClient();
+ const results = await analyzeWithClaude(client, [COMMIT]);
+ expect(Array.isArray(results[0].patterns)).toBe(true);
+ expect(Array.isArray(results[0].architectural_concerns)).toBe(true);
+ });
+
+ test('records error and continues when API call throws', async () => {
+ const client = makeClient();
+ client.messages.create.mockRejectedValueOnce(new Error('rate limited'));
+
+ const results = await analyzeWithClaude(client, [COMMIT]);
+ expect(results).toHaveLength(1);
+ expect(results[0].error).toMatch(/rate limited/);
+ });
+
+ test('records error when response is not valid JSON', async () => {
+ const client = makeClient();
+ client.messages.create.mockResolvedValueOnce({
+ content: [{ type: 'text', text: 'not json at all' }]
+ });
+
+ const results = await analyzeWithClaude(client, [COMMIT]);
+ expect(results).toHaveLength(1);
+ expect(results[0].error).toBeDefined();
+ });
+
+ test('strips markdown code fences from response before parsing', async () => {
+ const client = makeClient();
+ const payload = { ai_confidence: 50, risk_score: 60, patterns: [], architectural_concerns: [], summary: 'ok' };
+ client.messages.create.mockResolvedValueOnce({
+ content: [{ type: 'text', text: '```json\n' + JSON.stringify(payload) + '\n```' }]
+ });
+
+ const results = await analyzeWithClaude(client, [COMMIT]);
+ expect(results[0].ai_confidence).toBe(50);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// selectClaudeCommits
+// ---------------------------------------------------------------------------
+
+describe('selectClaudeCommits', () => {
+ function makeMetric(overrides) {
+ return {
+ sha: 'abc123',
+ large_commit: false,
+ total_additions: 10,
+ total_deletions: 10,
+ ...overrides
+ };
+ }
+
+ test('returns only large commits where additions exceed deletions * AI_RISK_ADDITIONS_RATIO', () => {
+ const metrics = [
+ makeMetric({ sha: 'aaa', large_commit: true, total_additions: 400, total_deletions: 10 }), // qualifies
+ makeMetric({ sha: 'bbb', large_commit: false, total_additions: 400, total_deletions: 10 }), // not large
+ makeMetric({ sha: 'ccc', large_commit: true, total_additions: 10, total_deletions: 10 }), // ratio too low
+ ];
+ const result = selectClaudeCommits(metrics);
+ expect(result).toHaveLength(1);
+ expect(result[0].sha).toBe('aaa');
+ });
+
+ test('caps results at AI_ANALYSIS_MAX_COMMITS', () => {
+ const metrics = Array.from({ length: CONFIG.AI_ANALYSIS_MAX_COMMITS + 3 }, (_, i) => (
+ makeMetric({ sha: `sha${i}`, large_commit: true, total_additions: 500, total_deletions: 10 })
+ ));
+ const result = selectClaudeCommits(metrics);
+ expect(result).toHaveLength(CONFIG.AI_ANALYSIS_MAX_COMMITS);
+ });
+
+ test('returns empty array when no commits qualify', () => {
+ const metrics = [
+ makeMetric({ sha: 'zzz', large_commit: false, total_additions: 10, total_deletions: 10 }),
+ ];
+ expect(selectClaudeCommits(metrics)).toHaveLength(0);
+ });
+
+ test('sorts by total churn descending before capping', () => {
+ const metrics = [
+ makeMetric({ sha: 'small', large_commit: true, total_additions: 150, total_deletions: 10 }),
+ makeMetric({ sha: 'large', large_commit: true, total_additions: 900, total_deletions: 10 }),
+ ];
+ const result = selectClaudeCommits(metrics);
+ expect(result[0].sha).toBe('large');
+ });
+});
diff --git a/__tests__/collectLocalMetrics.test.js b/__tests__/collectLocalMetrics.test.js
index 322a559..f6277bb 100644
--- a/__tests__/collectLocalMetrics.test.js
+++ b/__tests__/collectLocalMetrics.test.js
@@ -2,9 +2,11 @@
jest.mock('child_process');
jest.mock('fs');
+jest.mock('../lib/claude');
const { execSync } = require('child_process');
const fs = require('fs');
+const claude = require('../lib/claude');
const { collectLocalMetrics } = require('../local-code-metrics');
const FAKE_ROOT = '/fake/repo';
@@ -17,6 +19,8 @@ beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
+ // Default: Claude skipped — overridden only in Claude-active tests
+ claude.getAnthropicClient.mockResolvedValue(null);
});
afterEach(() => {
@@ -130,6 +134,34 @@ describe('collectLocalMetrics — successful run', () => {
expect(typeof summary.avg_lines_changed).toBe('string');
});
+ test('writes local_metrics_summary.json with DORA metric fields', async () => {
+ await collectLocalMetrics();
+
+ const summaryCall = fs.writeFileSync.mock.calls.find(c => c[0].includes('local_metrics_summary'));
+ const summary = JSON.parse(summaryCall[1]);
+ expect(typeof summary.velocity_commits_per_day).toBe('number');
+ expect(['accelerating', 'stable', 'decelerating']).toContain(summary.velocity_trend);
+ expect(typeof summary.additions_ratio_median).toBe('number');
+ expect(typeof summary.additions_ratio_p90).toBe('number');
+ expect(typeof summary.message_quality_pct).toBe('string');
+ expect(['harmonious-high-achiever', 'foundational-challenges', 'legacy-bottleneck', 'mixed-signals'])
+ .toContain(summary.dora_archetype);
+ });
+
+ test('writes local_metrics_summary.json with statistical distribution fields', async () => {
+ await collectLocalMetrics();
+
+ const summaryCall = fs.writeFileSync.mock.calls.find(c => c[0].includes('local_metrics_summary'));
+ const summary = JSON.parse(summaryCall[1]);
+ expect(typeof summary.p50_lines_changed).toBe('number');
+ expect(typeof summary.p90_lines_changed).toBe('number');
+ expect(typeof summary.p95_lines_changed).toBe('number');
+ expect(typeof summary.stddev_lines_changed).toBe('number');
+ expect(typeof summary.p50_files_changed).toBe('number');
+ expect(typeof summary.p90_files_changed).toBe('number');
+ expect(['growing', 'stable', 'shrinking']).toContain(summary.commit_size_trend);
+ });
+
test('logs warnings and recommendations when commits are large', async () => {
// 101 added lines → 100% large commit rate → critical warning + recommendation
const bigNumstat = `101\t0\tsrc/app.js`;
@@ -173,6 +205,14 @@ describe('collectLocalMetrics — successful run', () => {
expect(allLogs).toMatch(/and \d+ more commits/);
});
+ test('logs Claude-skipped message when ANTHROPIC_API_KEY is absent', async () => {
+ delete process.env.ANTHROPIC_API_KEY;
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
+ await collectLocalMetrics();
+ const allLogs = logSpy.mock.calls.flat().join(' ');
+ expect(allLogs).toMatch(/Claude analysis skipped/);
+ });
+
test('deduplicates commits with the same SHA across branches', async () => {
// Two branches both surface the same commit SHA
mockExecSequence(
@@ -191,3 +231,70 @@ describe('collectLocalMetrics — successful run', () => {
expect(written).toHaveLength(1);
});
});
+
+describe('collectLocalMetrics — Claude API active', () => {
+ const SHA = 'a'.repeat(40);
+ const NUMSTAT = `110\t5\tsrc/app.js`; // 115 prod lines → large_commit = true, additions >> deletions
+
+ beforeEach(() => {
+ mockExecSequence(
+ FAKE_ROOT,
+ FAKE_REMOTE,
+ ' feature/x',
+ `${SHA}|2024-01-15T10:00:00Z|Dev|feat: add thing`,
+ NUMSTAT
+ );
+ fs.writeFileSync.mockImplementation(() => {});
+ claude.selectClaudeCommits.mockReturnValue([{
+ sha: SHA.substring(0, 8),
+ full_sha: SHA,
+ message: 'feat: add thing',
+ author: 'Dev',
+ date: '2024-01-15T10:00:00Z',
+ source_branch: 'feature/x'
+ }]);
+ claude.analyzeWithClaude.mockResolvedValue([{
+ sha: SHA.substring(0, 8),
+ ai_confidence: 75,
+ risk_score: 80,
+ patterns: ['generic variable names'],
+ architectural_concerns: [],
+ summary: 'Possible AI-generated code'
+ }]);
+ });
+
+ test('annotates metrics and writes local_claude_analysis.json when Claude returns results', async () => {
+ claude.getAnthropicClient.mockResolvedValue({});
+
+ await collectLocalMetrics();
+
+ // Three files: metrics, summary, claude analysis
+ expect(fs.writeFileSync).toHaveBeenCalledTimes(3);
+
+ const claudeCall = fs.writeFileSync.mock.calls.find(c => c[0].includes('local_claude_analysis'));
+ expect(claudeCall).toBeDefined();
+ const claudeOutput = JSON.parse(claudeCall[1]);
+ expect(claudeOutput.model).toBe('claude-sonnet-4-6');
+ expect(claudeOutput.commits_analyzed).toBe(1);
+ expect(claudeOutput.results).toHaveLength(1);
+
+ // Metric should be annotated with Claude fields
+ const metricsCall = fs.writeFileSync.mock.calls.find(c => c[0].includes('local_commit_metrics'));
+ const metrics = JSON.parse(metricsCall[1]);
+ expect(metrics[0].ai_confidence).toBe(75);
+ expect(metrics[0].risk_score).toBe(80);
+ expect(metrics[0].patterns).toEqual(['generic variable names']);
+ });
+
+ test('logs Claude analysis section to console when metrics are annotated', async () => {
+ claude.getAnthropicClient.mockResolvedValue({});
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
+
+ await collectLocalMetrics();
+
+ const allLogs = logSpy.mock.calls.flat().join(' ');
+ expect(allLogs).toMatch(/CLAUDE AI ANALYSIS/);
+ expect(allLogs).toMatch(/confidence=75%/);
+ expect(allLogs).toMatch(/risk=80%/);
+ });
+});
diff --git a/__tests__/commitVelocity.test.js b/__tests__/commitVelocity.test.js
new file mode 100644
index 0000000..58c6307
--- /dev/null
+++ b/__tests__/commitVelocity.test.js
@@ -0,0 +1,68 @@
+'use strict';
+
+const { computeVelocity } = require('../local-code-metrics');
+
+describe('computeVelocity', () => {
+ it('returns stable trend and zero rate for empty array', () => {
+ const result = computeVelocity([]);
+ expect(result.commits_per_day).toBe(0);
+ expect(result.trend).toBe('stable');
+ });
+
+ it('returns stable trend for single commit', () => {
+ const result = computeVelocity([new Date('2024-01-15T10:00:00Z').toISOString()]);
+ expect(result.commits_per_day).toBeGreaterThanOrEqual(0);
+ expect(result.trend).toBe('stable');
+ });
+
+ it('returns "accelerating" when second half has proportionally more commits', () => {
+ // 10 days: 2 commits in first 5 days, 8 in second 5 days
+ const base = new Date('2024-01-01T00:00:00Z').getTime();
+ const dates = [
+ new Date(base + 1 * 86400000).toISOString(),
+ new Date(base + 2 * 86400000).toISOString(),
+ new Date(base + 6 * 86400000).toISOString(),
+ new Date(base + 7 * 86400000).toISOString(),
+ new Date(base + 8 * 86400000).toISOString(),
+ new Date(base + 9 * 86400000).toISOString(),
+ ];
+ expect(computeVelocity(dates).trend).toBe('accelerating');
+ });
+
+ it('returns "decelerating" when second half has proportionally fewer commits', () => {
+ const base = new Date('2024-01-01T00:00:00Z').getTime();
+ const dates = [
+ new Date(base + 1 * 86400000).toISOString(),
+ new Date(base + 2 * 86400000).toISOString(),
+ new Date(base + 3 * 86400000).toISOString(),
+ new Date(base + 4 * 86400000).toISOString(),
+ new Date(base + 9 * 86400000).toISOString(),
+ ];
+ expect(computeVelocity(dates).trend).toBe('decelerating');
+ });
+
+ it('returns "stable" when commit rate is roughly even across both halves', () => {
+ const base = new Date('2024-01-01T00:00:00Z').getTime();
+ const dates = [
+ new Date(base + 1 * 86400000).toISOString(),
+ new Date(base + 2 * 86400000).toISOString(),
+ new Date(base + 8 * 86400000).toISOString(),
+ new Date(base + 9 * 86400000).toISOString(),
+ ];
+ expect(computeVelocity(dates).trend).toBe('stable');
+ });
+
+ it('returns positive commits_per_day when dates arrive newest-first (git log order)', () => {
+ // git log outputs commits newest-first; velocity must still be positive
+ const base = new Date('2024-01-01T00:00:00Z').getTime();
+ const datesOldestFirst = [
+ new Date(base + 1 * 86400000).toISOString(),
+ new Date(base + 3 * 86400000).toISOString(),
+ new Date(base + 5 * 86400000).toISOString(),
+ new Date(base + 7 * 86400000).toISOString(),
+ ];
+ const datesNewestFirst = [...datesOldestFirst].reverse();
+ const result = computeVelocity(datesNewestFirst);
+ expect(result.commits_per_day).toBeGreaterThan(0);
+ });
+});
diff --git a/__tests__/doraArchetype.test.js b/__tests__/doraArchetype.test.js
new file mode 100644
index 0000000..e6de7ff
--- /dev/null
+++ b/__tests__/doraArchetype.test.js
@@ -0,0 +1,50 @@
+'use strict';
+
+const { classifyDoraArchetype } = require('../local-code-metrics');
+
+describe('classifyDoraArchetype', () => {
+ it('returns "harmonious-high-achiever" for all-healthy metrics', () => {
+ expect(classifyDoraArchetype({
+ large_commits_pct: '10.00',
+ sprawling_commits_pct: '5.00',
+ test_first_pct: '70.00',
+ message_quality_pct: '80.00'
+ })).toBe('harmonious-high-achiever');
+ });
+
+ it('returns "legacy-bottleneck" for high sprawl combined with high large commits', () => {
+ expect(classifyDoraArchetype({
+ large_commits_pct: '35.00',
+ sprawling_commits_pct: '30.00',
+ test_first_pct: '40.00',
+ message_quality_pct: '50.00'
+ })).toBe('legacy-bottleneck');
+ });
+
+ it('returns "foundational-challenges" when large commit rate exceeds 40%', () => {
+ expect(classifyDoraArchetype({
+ large_commits_pct: '45.00',
+ sprawling_commits_pct: '8.00',
+ test_first_pct: '55.00',
+ message_quality_pct: '65.00'
+ })).toBe('foundational-challenges');
+ });
+
+ it('returns "foundational-challenges" for low test discipline with elevated large commits', () => {
+ expect(classifyDoraArchetype({
+ large_commits_pct: '25.00',
+ sprawling_commits_pct: '8.00',
+ test_first_pct: '25.00',
+ message_quality_pct: '50.00'
+ })).toBe('foundational-challenges');
+ });
+
+ it('returns "mixed-signals" when no archetype threshold is clearly breached', () => {
+ expect(classifyDoraArchetype({
+ large_commits_pct: '25.00',
+ sprawling_commits_pct: '12.00',
+ test_first_pct: '40.00',
+ message_quality_pct: '55.00'
+ })).toBe('mixed-signals');
+ });
+});
diff --git a/__tests__/exports.test.js b/__tests__/exports.test.js
index 76a8a85..3418c2e 100644
--- a/__tests__/exports.test.js
+++ b/__tests__/exports.test.js
@@ -29,4 +29,17 @@ describe('module exports', () => {
test('collectLocalMetrics is exported', () => {
expect(typeof mod.collectLocalMetrics).toBe('function');
});
+
+ // D1/D2 additions
+ test('computeStatistics is exported', () => { expect(typeof mod.computeStatistics).toBe('function'); });
+ test('computeVelocity is exported', () => { expect(typeof mod.computeVelocity).toBe('function'); });
+ test('scoreMessageQuality is exported', () => { expect(typeof mod.scoreMessageQuality).toBe('function'); });
+ test('classifyDoraArchetype is exported', () => { expect(typeof mod.classifyDoraArchetype).toBe('function'); });
+
+ // D3 additions
+ test('getAnthropicClient is exported', () => { expect(typeof mod.getAnthropicClient).toBe('function'); });
+ test('selectClaudeCommits is exported', () => { expect(typeof mod.selectClaudeCommits).toBe('function'); });
+ test('getCommitDiff is exported', () => { expect(typeof mod.getCommitDiff).toBe('function'); });
+ test('analyzeWithClaude is exported', () => { expect(typeof mod.analyzeWithClaude).toBe('function'); });
+ test('CLAUDE_SYSTEM_PROMPT is exported', () => { expect(typeof mod.CLAUDE_SYSTEM_PROMPT).toBe('string'); });
});
diff --git a/__tests__/messageQuality.test.js b/__tests__/messageQuality.test.js
new file mode 100644
index 0000000..87f1d4b
--- /dev/null
+++ b/__tests__/messageQuality.test.js
@@ -0,0 +1,36 @@
+'use strict';
+
+const { scoreMessageQuality, CONFIG } = require('../local-code-metrics');
+
+describe('scoreMessageQuality', () => {
+ it('returns false for empty string', () => {
+ expect(scoreMessageQuality('')).toBe(false);
+ });
+
+ it('returns true for conventional commit prefix', () => {
+ expect(scoreMessageQuality('feat: add login')).toBe(true);
+ expect(scoreMessageQuality('fix: resolve null pointer')).toBe(true);
+ expect(scoreMessageQuality('refactor(auth): extract token validation')).toBe(true);
+ });
+
+ it('returns true for long specific message without conventional prefix', () => {
+ const long = 'update the payment processing pipeline to handle declined cards gracefully';
+ expect(long.split(/\s+/).length).toBeGreaterThanOrEqual(CONFIG.MESSAGE_QUALITY_MIN_WORDS);
+ expect(scoreMessageQuality(long)).toBe(true);
+ });
+
+ it('returns false for short vague message without conventional prefix', () => {
+ expect(scoreMessageQuality('fix issue')).toBe(false);
+ expect(scoreMessageQuality('wip')).toBe(false);
+ expect(scoreMessageQuality('update stuff')).toBe(false);
+ });
+
+ it('returns true for conventional prefix regardless of word count', () => {
+ expect(scoreMessageQuality('chore: x')).toBe(true);
+ });
+
+ it('is case-insensitive for conventional prefix', () => {
+ expect(scoreMessageQuality('FEAT: add thing')).toBe(true);
+ expect(scoreMessageQuality('Fix: resolve bug')).toBe(true);
+ });
+});
diff --git a/__tests__/statisticalDistribution.test.js b/__tests__/statisticalDistribution.test.js
new file mode 100644
index 0000000..534e212
--- /dev/null
+++ b/__tests__/statisticalDistribution.test.js
@@ -0,0 +1,75 @@
+'use strict';
+
+const { computeStatistics } = require('../local-code-metrics');
+
+describe('computeStatistics', () => {
+ it('returns zero-value result for empty array', () => {
+ const result = computeStatistics([], []);
+ expect(result.p50).toBe(0);
+ expect(result.p90).toBe(0);
+ expect(result.p95).toBe(0);
+ expect(result.mean).toBe(0);
+ expect(result.stddev).toBe(0);
+ expect(result.trend).toBe('stable');
+ });
+
+ it('returns correct values for single-element array', () => {
+ const result = computeStatistics([100], [Date.now()]);
+ expect(result.p50).toBe(100);
+ expect(result.p90).toBe(100);
+ expect(result.p95).toBe(100);
+ expect(result.mean).toBe(100);
+ expect(result.stddev).toBe(0);
+ expect(result.trend).toBe('stable');
+ });
+
+ it('returns correct percentiles for known 10-element dataset', () => {
+ const sizes = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
+ const now = Date.now();
+ const timestamps = sizes.map((_, i) => now + i * 1000);
+ const result = computeStatistics(sizes, timestamps);
+ expect(result.p50).toBe(55);
+ expect(result.mean).toBe(55);
+ expect(result.p90).toBeGreaterThan(result.p50);
+ expect(result.p95).toBeGreaterThan(result.p90);
+ expect(result.stddev).toBeGreaterThan(0);
+ });
+
+ it('returns "growing" when sizes increase over time', () => {
+ const sizes = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
+ const now = Date.now();
+ const timestamps = sizes.map((_, i) => now + i * 86400000);
+ expect(computeStatistics(sizes, timestamps).trend).toBe('growing');
+ });
+
+ it('returns "shrinking" when sizes decrease over time', () => {
+ const sizes = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10];
+ const now = Date.now();
+ const timestamps = sizes.map((_, i) => now + i * 86400000);
+ expect(computeStatistics(sizes, timestamps).trend).toBe('shrinking');
+ });
+
+ it('returns "stable" when sizes are flat', () => {
+ const sizes = [50, 50, 50, 50, 50, 50];
+ const now = Date.now();
+ const timestamps = sizes.map((_, i) => now + i * 86400000);
+ expect(computeStatistics(sizes, timestamps).trend).toBe('stable');
+ });
+
+ it('marks an extreme value as outlier when it exceeds mean + 2*stddev', () => {
+ const sizes = [10, 10, 10, 10, 10, 10, 10, 10, 10, 1000];
+ const now = Date.now();
+ const timestamps = sizes.map((_, i) => now + i * 1000);
+ const result = computeStatistics(sizes, timestamps);
+ expect(result.isOutlier(1000)).toBe(true);
+ expect(result.isOutlier(10)).toBe(false);
+ });
+
+ it('no values are outliers when distribution is uniform', () => {
+ const sizes = [50, 50, 50, 50];
+ const now = Date.now();
+ const timestamps = sizes.map((_, i) => now + i * 1000);
+ const result = computeStatistics(sizes, timestamps);
+ expect(result.isOutlier(50)).toBe(false);
+ });
+});
diff --git a/ai_drift_metrics_coverage_map.html b/ai_drift_metrics_coverage_map.html
new file mode 100644
index 0000000..291cd3b
--- /dev/null
+++ b/ai_drift_metrics_coverage_map.html
@@ -0,0 +1,279 @@
+
+
+
+
+
+
+
Fully covered by this toolkit
+
Partial / proxy only
+
Gap — needs external tool
+
Out of scope (organizational)
+
+
+
DORA AI Capabilities (7 total)
+
+
+
+
DORA Delivery Metrics
+
+
+
+
Git-Based Signals — fully measured
+
+
+
+
+
+
Analysis layers
+
+
+
Commercial tools for gaps
+
+
+
+
+
diff --git a/lib/claude.js b/lib/claude.js
new file mode 100644
index 0000000..316fb2b
--- /dev/null
+++ b/lib/claude.js
@@ -0,0 +1,101 @@
+// @ts-nocheck
+'use strict';
+
+const { CONFIG } = require('./config');
+const { getCommitDiff } = require('./git');
+
+const CLAUDE_SYSTEM_PROMPT = `You are a code quality analyst specializing in detecting AI-generated code patterns and architectural concerns. Analyze the provided git commit diff and return a JSON assessment.
+
+Detect these AI-generated code patterns:
+- Generic variable names (data, result, item, temp, obj, val, arr, str) without domain context
+- Boilerplate CRUD operations without error handling or domain-specific validation
+- Identically or near-identically structured adjacent functions differing only in variable names
+- Absent domain language — uses generic technical terms instead of business/domain vocabulary
+- Import patterns inconsistent with the rest of the file
+- Missing edge case handling (no null checks, no boundary conditions, no error paths)
+
+Detect these architectural concerns:
+- Code crossing service/module/layer boundaries based on import paths
+- New dependencies on modules not previously used in this area of the codebase
+- Structural patterns inconsistent with the surrounding code's style
+
+Respond ONLY with valid JSON in this exact schema, no other text:
+{
+ "ai_confidence": ,
+ "risk_score": ,
+ "patterns": ["string", ...],
+ "architectural_concerns": ["string", ...],
+ "summary": ""
+}
+
+ai_confidence: likelihood this code was AI-generated without careful human review (0=clearly human-authored, 100=clearly AI-generated)
+risk_score: overall code quality risk for this commit considering size, patterns, and architectural issues`;
+
+/**
+ * Returns an Anthropic client if ANTHROPIC_API_KEY is set and the SDK is available.
+ * Returns null otherwise — callers must check before using.
+ * @returns {Promise