diff --git a/src/lib/linking/resolve.test.ts b/src/lib/linking/resolve.test.ts new file mode 100644 index 0000000..a11e277 --- /dev/null +++ b/src/lib/linking/resolve.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from "vitest"; +import { resolveCommitCard, type LinkCandidate, type LinkableCommit } from "@/lib/linking/resolve"; + +function card(overrides: Partial): LinkCandidate { + return { + id: "card_a", + number: 1, + scopeRule: "", + startDate: "", + created: "2026-01-01 00:00:00.000Z", + archived: false, + ...overrides, + }; +} + +function commit(overrides: Partial): LinkableCommit { + return { + message: "feat(stock): a thing", + scope: "stock", + authoredAt: "2026-06-15 10:00:00.000Z", + isMerge: false, + ...overrides, + }; +} + +describe("resolveCommitCard — explicit references win", () => { + it("links by number regardless of scope", () => { + const cards = [ + card({ id: "by_scope", number: 1, scopeRule: "stock" }), + card({ id: "by_number", number: 42, scopeRule: "quotes" }), + ]; + const result = resolveCommitCard( + commit({ message: "feat(stock): thing [#42]" }), + cards + ); + expect(result).toEqual({ cardId: "by_number", source: "explicit" }); + }); + + it("links by number even before the card's bound", () => { + // An explicit reference is a human statement of intent. Date bounding + // exists to stop SCOPE rules over-reaching, not to overrule a person. + const cards = [card({ id: "late", number: 42, created: "2026-12-01 00:00:00.000Z" })]; + const result = resolveCommitCard(commit({ message: "fix: thing [#42]" }), cards); + expect(result).toEqual({ cardId: "late", source: "explicit" }); + }); + + it("falls through to scope when the number matches no card", () => { + const cards = [card({ id: "scoped", number: 1, scopeRule: "stock" })]; + const result = resolveCommitCard( + commit({ message: "feat(stock): thing [#999]" }), + cards + ); + expect(result).toEqual({ cardId: "scoped", source: "scope" }); + }); +}); + +describe("resolveCommitCard — scope matching", () => { + it("links a commit to the card owning its scope", () => { + const cards = [card({ id: "stock", scopeRule: "stock" })]; + expect(resolveCommitCard(commit({}), cards)).toEqual({ + cardId: "stock", + source: "scope", + }); + }); + + it("does not link when the commit has no scope", () => { + const cards = [card({ id: "stock", scopeRule: "stock" })]; + expect(resolveCommitCard(commit({ scope: "" }), cards)).toEqual({ + cardId: null, + source: "none", + }); + }); + + it("matches case-insensitively", () => { + const cards = [card({ id: "stock", scopeRule: "Stock" })]; + expect(resolveCommitCard(commit({ scope: "stock" }), cards).cardId).toBe("stock"); + }); + + it("ignores cards with no scope rule", () => { + const cards = [card({ id: "plain", scopeRule: "" })]; + expect(resolveCommitCard(commit({}), cards).cardId).toBeNull(); + }); + + it("ignores archived cards", () => { + const cards = [card({ id: "old", scopeRule: "stock", archived: true })]; + expect(resolveCommitCard(commit({}), cards).cardId).toBeNull(); + }); +}); + +describe("resolveCommitCard — date bounding", () => { + it("does not let a card claim commits from before it existed", () => { + // The rot this prevents: a new "Stock pricing v2" card swallowing a year + // of old feat(stock) commits and claiming work it did not do. + const cards = [ + card({ id: "new", scopeRule: "stock", created: "2026-08-01 00:00:00.000Z" }), + ]; + expect( + resolveCommitCard(commit({ authoredAt: "2026-06-15 10:00:00.000Z" }), cards) + .cardId + ).toBeNull(); + }); + + it("uses start_date when set", () => { + const cards = [ + card({ + id: "started", + scopeRule: "stock", + created: "2026-01-01 00:00:00.000Z", + startDate: "2026-08-01 00:00:00.000Z", + }), + ]; + expect( + resolveCommitCard(commit({ authoredAt: "2026-06-15 10:00:00.000Z" }), cards) + .cardId + ).toBeNull(); + }); + + it("falls back to created when start_date is empty", () => { + // Owner-created cards never get a start_date — only the accept flow sets + // one — so a literal start_date-only rule would make them match nothing. + const cards = [ + card({ id: "owner", scopeRule: "stock", startDate: "", created: "2026-01-01 00:00:00.000Z" }), + ]; + expect(resolveCommitCard(commit({}), cards).cardId).toBe("owner"); + }); + + it("includes a commit exactly on the bound", () => { + const cards = [ + card({ id: "exact", scopeRule: "stock", created: "2026-06-15 10:00:00.000Z" }), + ]; + expect(resolveCommitCard(commit({}), cards).cardId).toBe("exact"); + }); +}); + +describe("resolveCommitCard — one commit, one card", () => { + it("gives the commit to the eligible card with the latest bound", () => { + const cards = [ + card({ id: "v1", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }), + card({ id: "v2", number: 2, scopeRule: "stock", created: "2026-05-01 00:00:00.000Z" }), + ]; + expect(resolveCommitCard(commit({}), cards).cardId).toBe("v2"); + }); + + it("leaves an earlier commit with the earlier card", () => { + const cards = [ + card({ id: "v1", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }), + card({ id: "v2", number: 2, scopeRule: "stock", created: "2026-05-01 00:00:00.000Z" }), + ]; + expect( + resolveCommitCard(commit({ authoredAt: "2026-03-01 10:00:00.000Z" }), cards) + .cardId + ).toBe("v1"); + }); + + it("breaks an exact tie by the higher card number, deterministically", () => { + const cards = [ + card({ id: "a", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }), + card({ id: "b", number: 2, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }), + ]; + expect(resolveCommitCard(commit({}), cards).cardId).toBe("b"); + // Order of the input must not change the answer. + expect(resolveCommitCard(commit({}), [...cards].reverse()).cardId).toBe("b"); + }); +}); + +describe("resolveCommitCard — merges", () => { + it("never links a merge commit", () => { + // A merge is not a unit of work; attaching it to a card would inflate that + // card's commit count and drag its session boundaries around. + const cards = [card({ id: "stock", scopeRule: "stock" })]; + expect(resolveCommitCard(commit({ isMerge: true }), cards)).toEqual({ + cardId: null, + source: "none", + }); + }); + + it("does not link a merge even with an explicit reference", () => { + const cards = [card({ id: "stock", number: 42 })]; + expect( + resolveCommitCard( + commit({ isMerge: true, message: "Merge branch 'x' [#42]" }), + cards + ).cardId + ).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/lib/linking/resolve.ts b/src/lib/linking/resolve.ts new file mode 100644 index 0000000..f7f3183 --- /dev/null +++ b/src/lib/linking/resolve.ts @@ -0,0 +1,102 @@ +/** + * Decide which card a commit belongs to. + * + * Pure and database-free, so the precedence chain is verifiable rather than + * asserted. Every rule here exists to stop a specific kind of quiet wrongness — + * see the tests, which name each one. + */ + +import { extractCardReference } from "@/lib/linking/reference"; + +export interface LinkCandidate { + id: string; + number: number; + /** The conventional-commit scope this card claims, or "". */ + scopeRule: string; + /** PocketBase date string, or "" when never started. */ + startDate: string; + created: string; + archived: boolean; +} + +export interface LinkableCommit { + message: string; + /** Parsed conventional-commit scope, or "". */ + scope: string; + authoredAt: string; + isMerge: boolean; +} + +export type LinkSource = "explicit" | "scope" | "none"; + +export interface LinkResolution { + cardId: string | null; + source: LinkSource; +} + +const UNLINKED: LinkResolution = { cardId: null, source: "none" }; + +/** + * The earliest commit a card may claim by scope. + * + * `start_date` when the card was accepted through the Inbox flow, otherwise + * `created`. A literal start_date-only rule would exclude every card the owner + * created directly, which is most of them. + */ +function bound(card: LinkCandidate): string { + return card.startDate || card.created; +} + +/** PocketBase dates compare correctly as strings once normalised to ISO. */ +function comparable(value: string): string { + return (value ?? "").replace(" ", "T"); +} + +export function resolveCommitCard( + commit: LinkableCommit, + candidates: LinkCandidate[] +): LinkResolution { + // A merge is real history but not a unit of work. Linking one would inflate + // a card's commit count and distort its effort estimate. + if (commit.isMerge) return UNLINKED; + + const live = candidates.filter((c) => !c.archived); + + // 1. Explicit reference. Deliberately not date-bounded: bounding exists to + // stop scope rules over-reaching, not to overrule a person who typed a + // card number on purpose. + const referenced = extractCardReference(commit.message); + if (referenced !== null) { + const match = live.find((c) => c.number === referenced); + if (match) return { cardId: match.id, source: "explicit" }; + // Fall through: a stale or mistyped number should still let scope work. + } + + // 2. Scope rule, bounded by the card's start. + if (!commit.scope) return UNLINKED; + + const authored = comparable(commit.authoredAt); + + const eligible = live.filter( + (c) => + c.scopeRule && + c.scopeRule.toLowerCase() === commit.scope.toLowerCase() && + comparable(bound(c)) <= authored + ); + + if (eligible.length === 0) return UNLINKED; + + // One commit, one card. The winner is the eligible card that started most + // recently — so "Stock pricing v2" takes July's commits while the January + // card keeps January's. Ties break on the higher number so the answer never + // depends on the order the candidates arrived in. + const winner = eligible.reduce((best, current) => { + const a = comparable(bound(best)); + const b = comparable(bound(current)); + if (b > a) return current; + if (b < a) return best; + return current.number > best.number ? current : best; + }); + + return { cardId: winner.id, source: "scope" }; +} \ No newline at end of file