test(pr-prep): extract + unit-test the collision scorer

The Step 4 bucketing (title/file Jaccard, state weighting, EXACT_DUP /
OVERLAP / SIBLING / CLEAN precedence) lived only as inline bash in the
skill, so it had no behavioral coverage. Extract it into a pure,
deterministic CLI, `bin/gstack-pr-prep-score`, and pin every bucket
threshold in `test/pr-prep-score.test.ts` (13 cases, free, gate-tier).
The skill's Step 4 now points at the scorer as the canonical
implementation rather than re-deriving the thresholds inline, and the
coverage matrix gates pr-prep on the new behavioral test. This is the
v0.2.0 extraction the skill flagged, scoped to the scoring core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin D. Smith 2026-06-11 00:28:56 +10:00
parent e0e666454b
commit 29e4c17bfa
5 changed files with 287 additions and 2 deletions

153
bin/gstack-pr-prep-score Executable file
View File

@ -0,0 +1,153 @@
#!/usr/bin/env bun
/**
* gstack-pr-prep-score — pure collision scorer for the /pr-prep audit.
*
* Extracts the Step 4 bucketing rules from pr-prep/SKILL.md into one
* deterministic, unit-tested place (the v0.2.0 seed the skill flags). The
* skill's inline bash remains the spec; this is the canonical implementation
* it points to. No network, no git — pure function of its JSON input.
*
* Input (stdin or --file <path>), one commit's candidate set:
* {
* "commitKeywords": ["reindex", "cli", "fix"],
* "changedFiles": ["src/reindex.ts"],
* "candidates": [
* { "state": "open_pr", "titleKeywords": [...], "changedFiles": [...] },
* { "state": "open_issue", "titleKeywords": [...] },
* { "state": "merged_recent", "titleKeywords": [...] },
* { "state": "closed_issue", "titleKeywords": [...] }
* ]
* }
*
* Output (stdout): { "bucket", "topScore", "openIssueCount", "reasons": [...] }
* Buckets: EXACT_DUP | OVERLAP | SIBLING | CLEAN (precedence in that order).
* Exit 0 always; the bucket is the signal (the skill maps EXACT_DUP -> exit 1).
*/
export type CandidateState = 'open_pr' | 'open_issue' | 'merged_recent' | 'closed_issue';
export interface Candidate {
state: CandidateState;
titleKeywords?: string[];
changedFiles?: string[];
ref?: string; // e.g. "#1358" — passed through into reasons
}
export interface ScoreInput {
commitKeywords?: string[];
changedFiles?: string[];
candidates?: Candidate[];
}
export type Bucket = 'EXACT_DUP' | 'OVERLAP' | 'SIBLING' | 'CLEAN';
export interface ScoreResult {
bucket: Bucket;
topScore: number;
openIssueCount: number;
reasons: string[];
}
// State weighting from SKILL.md Step 4.
const STATE_WEIGHT: Record<CandidateState, number> = {
open_pr: 1.0,
open_issue: 0.7,
merged_recent: 0.6,
closed_issue: 0.2,
};
function norm(tokens: string[] | undefined): Set<string> {
return new Set((tokens ?? []).map((t) => t.toLowerCase().trim()).filter(Boolean));
}
export function jaccard(a: string[] | undefined, b: string[] | undefined): number {
const sa = norm(a);
const sb = norm(b);
if (sa.size === 0 || sb.size === 0) return 0;
let inter = 0;
for (const x of sa) if (sb.has(x)) inter++;
const union = sa.size + sb.size - inter;
return union === 0 ? 0 : inter / union;
}
export function score(input: ScoreInput): ScoreResult {
const candidates = input.candidates ?? [];
const reasons: string[] = [];
let topScore = 0;
let exactDup = false;
let overlap = false;
let sibling = false;
let openIssueCount = 0;
let hasOpenPr = false;
let onlyClosed = candidates.length > 0;
for (const c of candidates) {
const titleJ = jaccard(input.commitKeywords, c.titleKeywords);
// File overlap only meaningful for open PRs (we can read their diff).
const fileJ =
c.state === 'open_pr' ? jaccard(input.changedFiles, c.changedFiles) : 0;
const overlapJ = Math.max(titleJ, fileJ);
const s = overlapJ * STATE_WEIGHT[c.state];
if (s > topScore) topScore = s;
if (c.state !== 'closed_issue') onlyClosed = false;
if (c.state === 'open_issue') openIssueCount++;
if (c.state === 'open_pr') hasOpenPr = true;
const ref = c.ref ? `${c.ref} ` : '';
// EXACT_DUP: any OPEN PR with title Jaccard >=0.6 OR file overlap >=0.6.
if (c.state === 'open_pr' && (titleJ >= 0.6 || fileJ >= 0.6)) {
exactDup = true;
reasons.push(
`${ref}EXACT_DUP: open PR titleJ=${titleJ.toFixed(2)} fileJ=${fileJ.toFixed(2)}`,
);
continue;
}
// OVERLAP: any OPEN PR/issue with score >=0.3.
if ((c.state === 'open_pr' || c.state === 'open_issue') && s >= 0.3) {
overlap = true;
reasons.push(`${ref}OVERLAP: open ${c.state} score=${s.toFixed(2)}`);
continue;
}
// SIBLING: merged-recently with any overlap.
if (c.state === 'merged_recent' && overlapJ > 0) {
sibling = true;
reasons.push(`${ref}SIBLING: merged-recent overlap=${overlapJ.toFixed(2)}`);
}
}
// OVERLAP also triggers on >=3 OPEN issues even when none individually scores high.
if (openIssueCount >= 3) {
overlap = true;
reasons.push(`OVERLAP: ${openIssueCount} open issues`);
}
// SIBLING: OPEN issues present but no OPEN PR (and not already EXACT_DUP/OVERLAP).
if (openIssueCount > 0 && !hasOpenPr) sibling = true;
let bucket: Bucket;
if (exactDup) bucket = 'EXACT_DUP';
else if (overlap) bucket = 'OVERLAP';
else if (sibling) bucket = 'SIBLING';
else bucket = 'CLEAN';
if (bucket === 'CLEAN' && reasons.length === 0) {
reasons.push(onlyClosed ? 'only closed issues' : 'no hits above threshold');
}
return { bucket, topScore: Number(topScore.toFixed(4)), openIssueCount, reasons };
}
// CLI entry — skip when imported by the test.
if (import.meta.main) {
const fileArg = process.argv.indexOf('--file');
const raw =
fileArg !== -1
? await Bun.file(process.argv[fileArg + 1]).text()
: await Bun.stdin.text();
let input: ScoreInput;
try {
input = JSON.parse(raw || '{}');
} catch {
console.error('gstack-pr-prep-score: input is not valid JSON');
process.exit(2);
}
process.stdout.write(JSON.stringify(score(input)) + '\n');
}

View File

@ -975,6 +975,16 @@ Final severity bucket per commit:
| **SIBLING** | OPEN issues but no PR; or merged-recently with overlap |
| **CLEAN** | No hits, or only old closed issues |
This bucketing is implemented deterministically in `bin/gstack-pr-prep-score`
(pure function, unit-tested in `test/pr-prep-score.test.ts`) — the canonical
scorer. Pipe each commit's candidate set through it as JSON rather than
re-deriving the thresholds inline:
```bash
echo "$CANDIDATE_JSON" | ~/.claude/skills/gstack/bin/gstack-pr-prep-score
# -> {"bucket":"EXACT_DUP","topScore":1,"openIssueCount":0,"reasons":[...]}
```
## Step 4.4: Second-opinion review via codex (CLEAN commits only)
For each commit bucketed CLEAN (i.e. not duplicating upstream work),

View File

@ -169,6 +169,16 @@ Final severity bucket per commit:
| **SIBLING** | OPEN issues but no PR; or merged-recently with overlap |
| **CLEAN** | No hits, or only old closed issues |
This bucketing is implemented deterministically in `bin/gstack-pr-prep-score`
(pure function, unit-tested in `test/pr-prep-score.test.ts`) — the canonical
scorer. Pipe each commit's candidate set through it as JSON rather than
re-deriving the thresholds inline:
```bash
echo "$CANDIDATE_JSON" | ~/.claude/skills/gstack/bin/gstack-pr-prep-score
# -> {"bucket":"EXACT_DUP","topScore":1,"openIssueCount":0,"reasons":[...]}
```
## Step 4.4: Second-opinion review via codex (CLEAN commits only)
For each commit bucketed CLEAN (i.e. not duplicating upstream work),

112
test/pr-prep-score.test.ts Normal file
View File

@ -0,0 +1,112 @@
/**
* Behavioral test for the /pr-prep collision scorer (bin/gstack-pr-prep-score).
*
* Pins the Step 4 bucketing contract from pr-prep/SKILL.md: title/file Jaccard,
* state weighting, and the EXACT_DUP / OVERLAP / SIBLING / CLEAN precedence.
* Pure function, deterministic, free gate-tier.
*/
import { describe, test, expect } from 'bun:test';
import { score, jaccard, type ScoreInput } from '../bin/gstack-pr-prep-score';
describe('pr-prep scorer: jaccard', () => {
test('identical sets = 1.0', () => {
expect(jaccard(['a', 'b'], ['a', 'b'])).toBe(1);
});
test('disjoint sets = 0', () => {
expect(jaccard(['a'], ['b'])).toBe(0);
});
test('half overlap', () => {
// {a,b} vs {b,c}: inter=1, union=3
expect(jaccard(['a', 'b'], ['b', 'c'])).toBeCloseTo(1 / 3, 5);
});
test('case-insensitive, empty-safe', () => {
expect(jaccard(['Foo'], ['foo'])).toBe(1);
expect(jaccard([], ['a'])).toBe(0);
expect(jaccard(undefined, ['a'])).toBe(0);
});
});
describe('pr-prep scorer: buckets', () => {
test('open PR with high title Jaccard -> EXACT_DUP', () => {
const input: ScoreInput = {
commitKeywords: ['reindex', 'cli', 'only', 'fix'],
candidates: [{ state: 'open_pr', ref: '#913', titleKeywords: ['reindex', 'cli', 'only', 'fix'] }],
};
expect(score(input).bucket).toBe('EXACT_DUP');
});
test('open PR with high file overlap -> EXACT_DUP even on weak title', () => {
const input: ScoreInput = {
commitKeywords: ['unrelated', 'words'],
changedFiles: ['src/reindex.ts', 'src/cli.ts'],
candidates: [
{ state: 'open_pr', ref: '#913', titleKeywords: ['nothing', 'matches'], changedFiles: ['src/reindex.ts', 'src/cli.ts'] },
],
};
expect(score(input).bucket).toBe('EXACT_DUP');
});
test('open PR with mid score (>=0.3, <0.6) -> OVERLAP', () => {
// titleJ {a,b,c,d} vs {a,b,e,f}: inter=2 union=6 = 0.333; openPR weight 1.0 -> 0.333
const input: ScoreInput = {
commitKeywords: ['a', 'b', 'c', 'd'],
candidates: [{ state: 'open_pr', ref: '#1', titleKeywords: ['a', 'b', 'e', 'f'] }],
};
const r = score(input);
expect(r.bucket).toBe('OVERLAP');
expect(r.topScore).toBeGreaterThanOrEqual(0.3);
expect(r.topScore).toBeLessThan(0.6);
});
test('three open issues -> OVERLAP even when each scores low', () => {
const weak = (ref: string) => ({ state: 'open_issue' as const, ref, titleKeywords: ['zzz'] });
const input: ScoreInput = {
commitKeywords: ['a', 'b'],
candidates: [weak('#1'), weak('#2'), weak('#3')],
};
const r = score(input);
expect(r.bucket).toBe('OVERLAP');
expect(r.openIssueCount).toBe(3);
});
test('single low-score open issue, no PR -> SIBLING', () => {
const input: ScoreInput = {
commitKeywords: ['a', 'b'],
candidates: [{ state: 'open_issue', ref: '#5', titleKeywords: ['zzz'] }],
};
expect(score(input).bucket).toBe('SIBLING');
});
test('merged-recently with overlap -> SIBLING', () => {
const input: ScoreInput = {
commitKeywords: ['a', 'b'],
candidates: [{ state: 'merged_recent', ref: '#9', titleKeywords: ['a', 'b'] }],
};
expect(score(input).bucket).toBe('SIBLING');
});
test('only closed issues -> CLEAN', () => {
const input: ScoreInput = {
commitKeywords: ['a', 'b'],
candidates: [{ state: 'closed_issue', ref: '#7', titleKeywords: ['a', 'b'] }],
};
const r = score(input);
expect(r.bucket).toBe('CLEAN');
expect(r.reasons.join(' ')).toContain('only closed issues');
});
test('no candidates -> CLEAN', () => {
expect(score({ commitKeywords: ['a'], candidates: [] }).bucket).toBe('CLEAN');
});
test('precedence: EXACT_DUP wins over a co-present overlapping issue', () => {
const input: ScoreInput = {
commitKeywords: ['a', 'b', 'c'],
candidates: [
{ state: 'open_issue', ref: '#1', titleKeywords: ['a', 'b', 'c'] },
{ state: 'open_pr', ref: '#2', titleKeywords: ['a', 'b', 'c'] },
],
};
expect(score(input).bucket).toBe('EXACT_DUP');
});
});

View File

@ -43,9 +43,9 @@ export const SKILL_COVERAGE: Record<string, SkillCoverage> = {
periodic: ['test/skill-e2e-workflow.test.ts'],
},
'pr-prep': {
gate: ['test/skill-coverage-floor.test.ts'],
gate: ['test/pr-prep-score.test.ts', 'test/skill-coverage-floor.test.ts'],
periodic: [],
rationale: 'Pre-PR upstream-duplicate audit; structural floor covers it until a behavioral E2E lands.',
rationale: 'Behavioral test pins the Step 4 collision-scorer bucketing; structural floor covers the rest.',
},
review: {
gate: ['test/skill-e2e-review.test.ts', 'test/skill-coverage-floor.test.ts'],