mirror of https://github.com/garrytan/gstack.git
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:
parent
e0e666454b
commit
29e4c17bfa
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue