Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@
{"id":"int-5369ba6b","kind":"field_change","created_at":"2026-04-07T13:33:11.931574Z","actor":"Ken Judy","issue_id":"code-quality-metrics-a6p","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"pr-metrics.yml refactored: inline helpers removed, require() added, isTestFile() replaces 4x regex, CONFIG.* thresholds, classifyDoraArchetype signature fixed"}}
{"id":"int-7a66b645","kind":"field_change","created_at":"2026-04-07T13:34:14.142186Z","actor":"Ken Judy","issue_id":"code-quality-metrics-ac0","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"CLAUDE.md updated: architecture section reflects shared lib/, config section notes single source of truth, 'update both places' warning removed, net additions ratio threshold updated"}}
{"id":"int-6b197fa6","kind":"field_change","created_at":"2026-04-07T13:34:39.195467Z","actor":"Ken Judy","issue_id":"code-quality-metrics-22h","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"lint clean, typecheck clean, 119/119, 96.21%/96.42% coverage — all thresholds met"}}
{"id":"int-bfbb744e","kind":"field_change","created_at":"2026-04-07T14:19:17.960966Z","actor":"Ken Judy","issue_id":"code-quality-metrics-136","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-ec619666","kind":"field_change","created_at":"2026-04-07T14:19:18.535756Z","actor":"Ken Judy","issue_id":"code-quality-metrics-0jd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-4c8966f4","kind":"field_change","created_at":"2026-04-07T14:19:19.087859Z","actor":"Ken Judy","issue_id":"code-quality-metrics-9ji","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
{"id":"int-d3f400f4","kind":"field_change","created_at":"2026-04-07T15:03:20.883507Z","actor":"Ken Judy","issue_id":"code-quality-metrics-4j6","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}}
{"id":"int-0f17aa50","kind":"field_change","created_at":"2026-04-07T15:07:12.425052Z","actor":"Ken Judy","issue_id":"code-quality-metrics-g0a","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}}
{"id":"int-3ad9c87f","kind":"field_change","created_at":"2026-04-07T15:11:38.627913Z","actor":"Ken Judy","issue_id":"code-quality-metrics-84l","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}}
{"id":"int-8359fc75","kind":"field_change","created_at":"2026-04-07T15:14:28.801592Z","actor":"Ken Judy","issue_id":"code-quality-metrics-yuf","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}}
{"id":"int-67abf2c2","kind":"field_change","created_at":"2026-04-07T15:19:31.62249Z","actor":"Ken Judy","issue_id":"code-quality-metrics-38c","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}}
{"id":"int-1177d69a","kind":"field_change","created_at":"2026-04-07T15:23:05.721325Z","actor":"Ken Judy","issue_id":"code-quality-metrics-moo","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}}
{"id":"int-ac0d33c7","kind":"field_change","created_at":"2026-04-07T15:23:06.286044Z","actor":"Ken Judy","issue_id":"code-quality-metrics-cek","extra":{"field":"status","new_value":"closed","old_value":"in_progress"}}
20 changes: 15 additions & 5 deletions .github/workflows/code-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ jobs:
branch_commit_counts: branchCommitCounts,
large_commits_pct: "0.00",
sprawling_commits_pct: "0.00",
test_first_pct: "0.00",
test_coverage_rate: "0.00",
test_isolation_rate: "0.00",
uncovered_prod_rate: "0.00",
avg_files_changed: "0.00",
avg_lines_changed: "0.00",
avg_prod_lines_changed: "0.00",
Expand Down Expand Up @@ -156,6 +158,8 @@ jobs:
test_files_count: testFiles.length,
prod_files_count: prodFiles.length,
test_first_indicator: testFiles.length > 0 && prodFiles.length > 0,
test_only_commit: testFiles.length > 0 && prodFiles.length === 0,
uncovered_prod_commit: testFiles.length === 0 && prodFiles.length > 0 && (prodFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0)) > CONFIG.LARGE_COMMIT_THRESHOLD,
prod_additions: prodFiles.reduce((sum, f) => sum + f.additions, 0),
prod_deletions: prodFiles.reduce((sum, f) => sum + f.deletions, 0),
large_commit: (prodFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0)) > CONFIG.LARGE_COMMIT_THRESHOLD,
Expand Down Expand Up @@ -192,7 +196,9 @@ jobs:

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 testCoveragePct = parseFloat((metrics.filter(m => m.test_first_indicator).length / metrics.length * 100).toFixed(2));
const testIsolationPct = parseFloat((metrics.filter(m => m.test_only_commit).length / metrics.length * 100).toFixed(2));
const uncoveredProdPct = parseFloat((metrics.filter(m => m.uncovered_prod_commit).length / metrics.length * 100).toFixed(2));

const summary = {
total_commits: metrics.length,
Expand All @@ -201,7 +207,9 @@ jobs:
branch_commit_counts: branchCommitCounts,
large_commits_pct: largePct.toFixed(2),
sprawling_commits_pct: sprawlingPct.toFixed(2),
test_first_pct: testFirstPct.toFixed(2),
test_coverage_rate: testCoveragePct.toFixed(2),
test_isolation_rate: testIsolationPct.toFixed(2),
uncovered_prod_rate: uncoveredProdPct.toFixed(2),
avg_files_changed: (metrics.reduce((sum, m) => sum + m.files_changed, 0) / metrics.length).toFixed(2),
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),
Expand All @@ -216,7 +224,7 @@ jobs:
net_additions_ratio_median: parseFloat(ratioStats.p50.toFixed(2)),
net_additions_ratio_p90: parseFloat(ratioStats.p90.toFixed(2)),
message_quality_pct: msgQualityPct,
dora_archetype: classifyDoraArchetype({ large_commits_pct: largePct.toFixed(2), sprawling_commits_pct: sprawlingPct.toFixed(2), test_first_pct: testFirstPct.toFixed(2), message_quality_pct: msgQualityPct }),
dora_archetype: classifyDoraArchetype({ large_commits_pct: largePct.toFixed(2), sprawling_commits_pct: sprawlingPct.toFixed(2), test_coverage_rate: testCoveragePct.toFixed(2), uncovered_prod_rate: uncoveredProdPct.toFixed(2), message_quality_pct: msgQualityPct }),
note: "Feature branches only - main/master excluded. Large commit threshold applied to production code only."
};

Expand Down Expand Up @@ -281,7 +289,9 @@ jobs:
'|--------|-------|--------|--------|',
`| 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')} |`,
`| Test Coverage (test+prod commits) | ${summary.test_coverage_rate}% | >50% | ${statusMark(summary.test_coverage_rate, 50, 'above')} |`,
`| Test Isolation (test-only commits) | ${summary.test_isolation_rate}% | >10% | ${statusMark(summary.test_isolation_rate, 10, 'above')} |`,
`| Uncovered Prod (large, no tests) | ${summary.uncovered_prod_rate}% | <10% | ${statusMark(summary.uncovered_prod_rate, 10)} |`,
`| Message Quality | ${summary.message_quality_pct}% | >60% | ${statusMark(summary.message_quality_pct, 60, 'above')} |`,
`| Net Additions Ratio (median) | ${summary.net_additions_ratio_median} | <0.50 | ${parseFloat(summary.net_additions_ratio_median) < 0.50 ? 'OK' : 'Warning'} |`,
'',
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/pr-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
jobs:
analyze-pr:
runs-on: ubuntu-latest
if: github.actor != 'dependabot[bot]'
steps:
- name: Checkout with full history
uses: actions/checkout@v4
Expand Down Expand Up @@ -81,6 +82,8 @@ jobs:
large_commit: prodChanges > CONFIG.LARGE_COMMIT_THRESHOLD,
sprawling_commit: (detail.data.files?.length || 0) > CONFIG.SPRAWLING_COMMIT_THRESHOLD,
test_first_indicator: commitTestFiles.length > 0 && commitProdFiles.length > 0,
test_only_commit: commitTestFiles.length > 0 && commitProdFiles.length === 0,
uncovered_prod_commit: commitTestFiles.length === 0 && commitProdFiles.length > 0 && prodChanges > CONFIG.LARGE_COMMIT_THRESHOLD,
test_only: commitTestFiles.length > 0 && commitProdFiles.length === 0,
prod_only: commitProdFiles.length > 0 && commitTestFiles.length === 0,
test_ratio: prodChanges > 0 ? (testChanges / prodChanges) : 0,
Expand Down Expand Up @@ -131,6 +134,8 @@ jobs:
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 testCoveragePct = testFirstPct;
const uncoveredProdPct = commitMetrics.length > 0 ? (commitMetrics.filter(c => c.uncovered_prod_commit).length / commitMetrics.length * 100) : 0;
const msgQualityPct = commitMetrics.length > 0 ? (qualityCommits / commitMetrics.length * 100) : 0;

const medianAdditionsRatio = (() => {
Expand Down Expand Up @@ -250,7 +255,7 @@ jobs:
// ---------------------------------------------------------------

const archetype = commitMetrics.length > 0
? classifyDoraArchetype({ large_commits_pct: largePct.toFixed(2), sprawling_commits_pct: sprawlingPct.toFixed(2), test_first_pct: testFirstPct.toFixed(2), message_quality_pct: msgQualityPct.toFixed(2) })
? classifyDoraArchetype({ large_commits_pct: largePct.toFixed(2), sprawling_commits_pct: sprawlingPct.toFixed(2), test_coverage_rate: testCoveragePct.toFixed(2), uncovered_prod_rate: uncoveredProdPct.toFixed(2), message_quality_pct: msgQualityPct.toFixed(2) })
: 'mixed-signals';

const archetypeDescriptions = {
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ For more details, see README.md and docs/QUICKSTART.md.

Run these after each implementation step, not only at the end of a session:

**When renaming a field that appears in another file's JSDoc `@param` type**, update the JSDoc in the same step as the rename — do not defer to a later step. A transient typecheck failure between steps is a process violation. Run `npm run typecheck` immediately after any rename before proceeding.

```bash
npm test # all tests must pass before moving to next step
npm run lint # lint must be clean before moving to next step
Expand Down
49 changes: 49 additions & 0 deletions __tests__/analyzeCommit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,53 @@ describe('analyzeCommit', () => {
execSync.mockReturnValue(numstatLine(5, 2, 'src/app.js'));
expect(analyzeCommit(MOCK_SHA, MOCK_BRANCH).source_branch).toBe(MOCK_BRANCH);
});

// --- test_only_commit ---
test('sets test_only_commit true when only test files changed', () => {
execSync.mockReturnValue(numstatLine(10, 2, 'src/app.test.js'));
const result = analyzeCommit(MOCK_SHA, MOCK_BRANCH);
expect(result.test_only_commit).toBe(true);
});

test('sets test_only_commit false when both test and prod files changed', () => {
execSync.mockReturnValue([
numstatLine(10, 2, 'src/app.js'),
numstatLine(5, 1, 'src/app.test.js')
].join('\n'));
const result = analyzeCommit(MOCK_SHA, MOCK_BRANCH);
expect(result.test_only_commit).toBe(false);
});

test('sets test_only_commit false when only prod files changed', () => {
execSync.mockReturnValue(numstatLine(20, 5, 'src/app.js'));
expect(analyzeCommit(MOCK_SHA, MOCK_BRANCH).test_only_commit).toBe(false);
});

// --- uncovered_prod_commit ---
test('sets uncovered_prod_commit true when only large prod commit with no tests', () => {
const lines = CONFIG.LARGE_COMMIT_THRESHOLD + 1;
execSync.mockReturnValue(numstatLine(lines, 0, 'src/app.js'));
const result = analyzeCommit(MOCK_SHA, MOCK_BRANCH);
expect(result.uncovered_prod_commit).toBe(true);
});

test('sets uncovered_prod_commit false when prod-only commit is not large', () => {
execSync.mockReturnValue(numstatLine(10, 0, 'src/app.js'));
expect(analyzeCommit(MOCK_SHA, MOCK_BRANCH).uncovered_prod_commit).toBe(false);
});

test('sets uncovered_prod_commit false when large commit includes test files', () => {
const lines = CONFIG.LARGE_COMMIT_THRESHOLD + 1;
execSync.mockReturnValue([
numstatLine(lines, 0, 'src/app.js'),
numstatLine(5, 0, 'src/app.test.js')
].join('\n'));
expect(analyzeCommit(MOCK_SHA, MOCK_BRANCH).uncovered_prod_commit).toBe(false);
});

test('sets uncovered_prod_commit false when large commit is test-only', () => {
const lines = CONFIG.LARGE_COMMIT_THRESHOLD + 1;
execSync.mockReturnValue(numstatLine(lines, 0, 'src/app.test.js'));
expect(analyzeCommit(MOCK_SHA, MOCK_BRANCH).uncovered_prod_commit).toBe(false);
});
});
11 changes: 11 additions & 0 deletions __tests__/collectLocalMetrics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,17 @@ describe('collectLocalMetrics — successful run', () => {
.toContain(summary.dora_archetype);
});

test('writes local_metrics_summary.json with three-way test classification rates', 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.test_coverage_rate).toBe('string');
expect(typeof summary.test_isolation_rate).toBe('string');
expect(typeof summary.uncovered_prod_rate).toBe('string');
expect(summary.test_first_pct).toBeUndefined();
});

test('writes local_metrics_summary.json with statistical distribution fields', async () => {
await collectLocalMetrics();

Expand Down
27 changes: 21 additions & 6 deletions __tests__/doraArchetype.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,28 @@ describe('classifyDoraArchetype', () => {
expect(classifyDoraArchetype({
large_commits_pct: '10.00',
sprawling_commits_pct: '5.00',
test_first_pct: '70.00',
test_coverage_rate: '70.00',
uncovered_prod_rate: '5.00',
message_quality_pct: '80.00'
})).toBe('harmonious-high-achiever');
});

it('returns "mixed-signals" (not harmonious) when uncovered_prod_rate is at or above 10', () => {
expect(classifyDoraArchetype({
large_commits_pct: '10.00',
sprawling_commits_pct: '5.00',
test_coverage_rate: '70.00',
uncovered_prod_rate: '10.00',
message_quality_pct: '80.00'
})).not.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',
test_coverage_rate: '40.00',
uncovered_prod_rate: '5.00',
message_quality_pct: '50.00'
})).toBe('legacy-bottleneck');
});
Expand All @@ -25,16 +37,18 @@ describe('classifyDoraArchetype', () => {
expect(classifyDoraArchetype({
large_commits_pct: '45.00',
sprawling_commits_pct: '8.00',
test_first_pct: '55.00',
test_coverage_rate: '55.00',
uncovered_prod_rate: '5.00',
message_quality_pct: '65.00'
})).toBe('foundational-challenges');
});

it('returns "foundational-challenges" for low test discipline with elevated large commits', () => {
it('returns "foundational-challenges" when uncovered_prod_rate exceeds 20%', () => {
expect(classifyDoraArchetype({
large_commits_pct: '25.00',
sprawling_commits_pct: '8.00',
test_first_pct: '25.00',
test_coverage_rate: '40.00',
uncovered_prod_rate: '25.00',
message_quality_pct: '50.00'
})).toBe('foundational-challenges');
});
Expand All @@ -43,7 +57,8 @@ describe('classifyDoraArchetype', () => {
expect(classifyDoraArchetype({
large_commits_pct: '25.00',
sprawling_commits_pct: '12.00',
test_first_pct: '40.00',
test_coverage_rate: '40.00',
uncovered_prod_rate: '5.00',
message_quality_pct: '55.00'
})).toBe('mixed-signals');
});
Expand Down
Loading
Loading