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
5 changes: 5 additions & 0 deletions .beads/interactions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@
{"id":"int-3e1630bd","kind":"field_change","created_at":"2026-04-07T12:38:16.042213Z","actor":"Ken Judy","issue_id":"code-quality-metrics-h22","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"lint clean, typecheck clean, 117/117 tests, 96.21% lines / 96.42% functions — all thresholds met"}}
{"id":"int-cee835a3","kind":"field_change","created_at":"2026-04-07T12:38:16.543793Z","actor":"Ken Judy","issue_id":"code-quality-metrics-oga","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All 7 implementation steps complete. Formula fixed, fields renamed, spec updated, tests green."}}
{"id":"int-d7bd06be","kind":"field_change","created_at":"2026-04-07T12:41:46.049872Z","actor":"Ken Judy","issue_id":"code-quality-metrics-zx6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Both workflow files updated: formula fixed (bounded), fields renamed to net_additions_ratio_*, thresholds updated to <0.50, display labels updated. Also cleaned up redundant sort+map in code-metrics.yml ratioStats computation."}}
{"id":"int-8c6635bc","kind":"field_change","created_at":"2026-04-07T13:28:25.173297Z","actor":"Ken Judy","issue_id":"code-quality-metrics-2hj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"isTestFile moved to lib/metrics.js, re-exported from lib/git.js. 119/119 tests pass."}}
{"id":"int-94ca1d4f","kind":"field_change","created_at":"2026-04-07T13:33:11.382272Z","actor":"Ken Judy","issue_id":"code-quality-metrics-bul","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"code-metrics.yml refactored: inline helpers removed, require() added, CONFIG.* used for all thresholds, velocity_commits_per_day added, classifyDoraArchetype signature fixed"}}
{"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"}}
92 changes: 20 additions & 72 deletions .github/workflows/code-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,68 +30,18 @@ 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';
}
// lib/ modules are available after checkout — require() resolves from $GITHUB_WORKSPACE
const { CONFIG } = require('./lib/config');
const { computeStatistics, computeVelocity } = require('./lib/statistics');
const { scoreMessageQuality, classifyDoraArchetype, isTestFile } = require('./lib/metrics');

// ---------------------------------------------------------------
// 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();
const since = new Date(Date.now() - CONFIG.ANALYSIS_DAYS * 24 * 60 * 60 * 1000).toISOString();

console.log(`Repository: ${owner}/${repo}`);
console.log(`Looking for commits since: ${since}`);
Expand Down Expand Up @@ -165,6 +115,7 @@ jobs:
p90_lines_changed: 0,
p95_lines_changed: 0,
stddev_lines_changed: 0,
velocity_commits_per_day: 0,
velocity_trend: "stable",
net_additions_ratio_median: 0,
net_additions_ratio_p90: 0,
Expand All @@ -178,23 +129,18 @@ jobs:
}

const metrics = [];
console.log(`Processing ${Math.min(uniqueCommits.length, 50)} commits...`);
console.log(`Processing ${Math.min(uniqueCommits.length, CONFIG.MAX_COMMITS)} commits...`);

for (const commit of uniqueCommits.slice(0, 50)) {
for (const commit of uniqueCommits.slice(0, CONFIG.MAX_COMMITS)) {
try {
const detail = await github.rest.repos.getCommit({
owner,
repo,
ref: commit.sha
});

const testFiles = detail.data.files?.filter(f =>
/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
) || [];

const prodFiles = detail.data.files?.filter(f =>
!/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
) || [];
const testFiles = detail.data.files?.filter(f => isTestFile(f.filename)) || [];
const prodFiles = detail.data.files?.filter(f => !isTestFile(f.filename)) || [];

const message = commit.commit.message.split('\n')[0];

Expand All @@ -212,8 +158,8 @@ jobs:
test_first_indicator: testFiles.length > 0 && prodFiles.length > 0,
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)) > 100,
sprawling_commit: (detail.data.files?.length || 0) > 5,
large_commit: (prodFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0)) > CONFIG.LARGE_COMMIT_THRESHOLD,
sprawling_commit: (detail.data.files?.length || 0) > CONFIG.SPRAWLING_COMMIT_THRESHOLD,
message_quality: scoreMessageQuality(message),
commit_type: "feature_branch"
});
Expand All @@ -228,15 +174,16 @@ jobs:
fs.writeFileSync('commit_metrics.json', JSON.stringify(metrics, null, 2));

// Statistical distributions
const timestamps = metrics.map(m => new Date(m.date).getTime());
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 lineStats = computeStatistics(lineSizes, timestamps);
const fileStats = computeStatistics(metrics.map(m => m.files_changed), timestamps);
const ratios = metrics.map(m => {
const t = m.total_additions + m.total_deletions;
return t === 0 ? 0 : (m.total_additions - m.total_deletions) / t;
});
const ratioStats = computeStats(ratios);
const velocityTrend = computeVelocityTrend(metrics.map(m => m.date));
const ratioStats = computeStatistics(ratios, timestamps);
const velocity = computeVelocity(metrics.map(m => m.date));

const qualityCount = metrics.filter(m => m.message_quality).length;
const msgQualityPct = metrics.length > 0
Expand Down Expand Up @@ -264,11 +211,12 @@ jobs:
stddev_lines_changed: Math.round(lineStats.stddev),
p50_files_changed: Math.round(fileStats.p50),
p90_files_changed: Math.round(fileStats.p90),
velocity_trend: velocityTrend,
velocity_commits_per_day: velocity.commits_per_day,
velocity_trend: velocity.trend,
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(largePct, sprawlingPct, testFirstPct, parseFloat(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 }),
note: "Feature branches only - main/master excluded. Large commit threshold applied to production code only."
};

Expand Down
44 changes: 10 additions & 34 deletions .github/workflows/pr-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,9 @@ jobs:
script: |
const pr = context.payload.pull_request;

// ---------------------------------------------------------------
// 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';
}
// lib/ modules are available after checkout — require() resolves from $GITHUB_WORKSPACE
const { CONFIG } = require('./lib/config');
const { scoreMessageQuality, classifyDoraArchetype, isTestFile } = require('./lib/metrics');

// ---------------------------------------------------------------
// Fetch PR data
Expand All @@ -55,13 +41,8 @@ jobs:

console.log(`Analyzing ${commits.length} commits and ${files.length} files in PR`);

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)
);
const testFiles = files.filter(f => isTestFile(f.filename));
const prodFiles = files.filter(f => !isTestFile(f.filename));

// ---------------------------------------------------------------
// Per-commit analysis
Expand All @@ -76,13 +57,8 @@ jobs:
ref: commit.sha
});

const commitTestFiles = detail.data.files?.filter(f =>
/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
) || [];

const commitProdFiles = detail.data.files?.filter(f =>
!/\.(test|spec)\.|Tests?\.cs$|Test\.cs$|__tests__|\/tests?\//i.test(f.filename)
) || [];
const commitTestFiles = detail.data.files?.filter(f => isTestFile(f.filename)) || [];
const commitProdFiles = detail.data.files?.filter(f => !isTestFile(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);
Expand All @@ -102,8 +78,8 @@ jobs:
prod_changes: prodChanges,
test_changes: testChanges,
total_changes: detail.data.stats.additions + detail.data.stats.deletions,
large_commit: prodChanges > 100,
sprawling_commit: (detail.data.files?.length || 0) > 5,
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: commitTestFiles.length > 0 && commitProdFiles.length === 0,
prod_only: commitProdFiles.length > 0 && commitTestFiles.length === 0,
Expand Down Expand Up @@ -274,7 +250,7 @@ jobs:
// ---------------------------------------------------------------

const archetype = commitMetrics.length > 0
? classifyDoraArchetype(largePct, sprawlingPct, testFirstPct, msgQualityPct)
? 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) })
: 'mixed-signals';

const archetypeDescriptions = {
Expand Down
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ npm run lint # lint must be clean before moving to next step

When introducing new tooling (new lint rules, type checking, config changes), run the gate immediately after setup — before writing any tests or code — to catch configuration gaps early.

**When modifying `.github/workflows/` or `lib/`**, run a workflow smoke test:

```bash
gh workflow run code-metrics.yml --ref <your-branch>
gh run watch # wait for completion
gh run download <run-id> --dir /tmp/smoke && cat /tmp/smoke/*/metrics_summary.json | python3 -m json.tool
```

Verify:
- The artifact contains `velocity_commits_per_day` (numeric, not absent)
- No `Cannot find module` errors in the log
- Confirm the run's commit SHA matches your branch HEAD: `gh run view <run-id>`

A green run ≠ new code ran. The artifact is the proof.

## Landing the Plane (Session Completion)

**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
Expand Down
Loading
Loading