From e38a63dbd19e176ddfc51db9f13998021876777c Mon Sep 17 00:00:00 2001 From: glorydavid03023 Date: Wed, 1 Jul 2026 00:32:49 -0500 Subject: [PATCH] fix(mcp): word-boundary linked-issue closing keywords extractLinkedIssues populates the linkedIssues the MCP sends for eligibility, score preview, and gate prediction. Its regex matched the closing keywords without a word boundary, so a keyword embedded in a longer word captured the trailing number: `hotfix 5` -> [5], `prefixes 12` -> [12], `unclosed 9` -> [9]. Anchor the keyword alternatives with `\b`, matching the two canonical server-side extractors (src/db/repositories.ts and src/signals/engine.ts, which both use `\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)`). The bare `#` branch stays boundary-free so `#123` still matches anywhere. Adds a regression test for the embedded-keyword non-matches and the standalone-keyword matches. No issue because issue creation is restricted on this repo; this is a small, self-evident correctness fix in a pure helper with no schema or API change. --- packages/gittensory-mcp/lib/local-branch.js | 8 ++++++-- test/unit/local-branch.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index ca32023d56..95bfb25e1e 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -557,9 +557,13 @@ function assertSourceUploadDisabled() { } } -function extractLinkedIssues(text) { +// Word-boundary the closing keywords (as the server-side extractors in src/db/repositories.ts and +// src/signals/engine.ts already do) so a keyword embedded in a longer word does not spuriously link an +// issue: without \b, `hotfix 5` / `prefixes 12` matched the `fix`/`fixes` substring and captured the +// trailing number. The bare `#` branch stays boundary-free so `#123` still matches anywhere. +export function extractLinkedIssues(text) { const issues = []; - for (const match of String(text).matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|#)\s*#?(\d+)/gi)) issues.push(Number(match[1])); + for (const match of String(text).matchAll(/(?:\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)|#)\s*#?(\d+)/gi)) issues.push(Number(match[1])); return issues.filter((issue) => Number.isInteger(issue) && issue > 0); } diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 0238b24de0..16ad62dff5 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -1737,6 +1737,21 @@ describe("local MCP git metadata collection", () => { ); }); + it("extracts linked issues only from standalone closing keywords, not keyword substrings", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { extractLinkedIssues } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + // Standalone closing keywords (hash optional, as this client-side extractor allows) and bare #refs link. + expect(extractLinkedIssues("fixes #5")).toEqual([5]); + expect(extractLinkedIssues("Closes 12 and resolves #34")).toEqual([12, 34]); + expect(extractLinkedIssues("see #7")).toEqual([7]); + expect(extractLinkedIssues("closes#3")).toEqual([3]); + // Regression: a closing keyword embedded in a longer word must NOT capture a trailing number. + expect(extractLinkedIssues("hotfix 5")).toEqual([]); + expect(extractLinkedIssues("prefixes 12")).toEqual([]); + expect(extractLinkedIssues("unclosed 9")).toEqual([]); + expect(extractLinkedIssues("no references here")).toEqual([]); + }); + it("parses remotes, changed-file stats, linked issues, and refuses source upload mode", async () => { // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { collectLocalBranchMetadata, parseGitRemote } = await import("../../packages/gittensory-mcp/lib/local-branch.js");