diff --git a/bin/gstack-pr-prep-score b/bin/gstack-pr-prep-score new file mode 100755 index 000000000..030bfcb83 --- /dev/null +++ b/bin/gstack-pr-prep-score @@ -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 ), 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 = { + open_pr: 1.0, + open_issue: 0.7, + merged_recent: 0.6, + closed_issue: 0.2, +}; + +function norm(tokens: string[] | undefined): Set { + 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'); +} diff --git a/pr-prep/SKILL.md b/pr-prep/SKILL.md index 65c402c64..809914e46 100644 --- a/pr-prep/SKILL.md +++ b/pr-prep/SKILL.md @@ -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), diff --git a/pr-prep/SKILL.md.tmpl b/pr-prep/SKILL.md.tmpl index a3ad353b2..31a999021 100644 --- a/pr-prep/SKILL.md.tmpl +++ b/pr-prep/SKILL.md.tmpl @@ -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), diff --git a/test/pr-prep-score.test.ts b/test/pr-prep-score.test.ts new file mode 100644 index 000000000..7f0221d0f --- /dev/null +++ b/test/pr-prep-score.test.ts @@ -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'); + }); +}); diff --git a/test/skill-coverage-matrix.ts b/test/skill-coverage-matrix.ts index f734daba8..ff003223a 100644 --- a/test/skill-coverage-matrix.ts +++ b/test/skill-coverage-matrix.ts @@ -43,9 +43,9 @@ export const SKILL_COVERAGE: Record = { 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'],