Skip to content

Commit 48015fa

Browse files
committed
Address Copilot review: safe Markdown escaping, matchedText guard, edge-case tests
- Add mdInlineCode() helper using variable-length backtick fence (CommonMark §6.1) so regex patterns containing backticks produce valid inline code spans. Reuse for seg.text in match lines. - buildQueryTitle: use mdInlineCode() for regex queries (fixes embedded backticks) and JSON.stringify() for plain queries (escapes embedded " and converts \n so the heading always stays on a single line). - Fix JSDoc example: '# Results for \`useFlag/i\`' → '# Results for \`/useFlag/i\`'. - buildJsonOutput: guard matchedText behind seg.text non-empty check, matching markdown behaviour and preventing '"matchedText": ""' in JSON output. - Add edge-case tests: plain query with embedded quotes, plain query with newline, regex with embedded backtick, empty seg.text omitted from JSON.
1 parent 9d622dc commit 48015fa

File tree

2 files changed

+80
-4
lines changed

2 files changed

+80
-4
lines changed

src/output.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,29 @@ describe("buildQueryTitle", () => {
299299
'# Results for "useFlag"',
300300
);
301301
});
302+
303+
it("handles a plain query with embedded double quotes without breaking the heading", () => {
304+
const title = buildQueryTitle('from "axios"');
305+
// JSON.stringify escapes the embedded quotes — heading stays on one line
306+
expect(title.startsWith("# Results for ")).toBe(true);
307+
expect(title).not.toContain("\n");
308+
expect(title).toContain("axios");
309+
});
310+
311+
it("handles a plain query with a newline without breaking the heading", () => {
312+
const title = buildQueryTitle("line1\nline2");
313+
// JSON.stringify converts \n to \\n — no actual newline in the heading
314+
expect(title.startsWith("# Results for ")).toBe(true);
315+
expect(title).not.toContain("\n");
316+
});
317+
318+
it("handles a regex query with an embedded backtick without producing malformed inline code", () => {
319+
const title = buildQueryTitle("/foo`bar/i");
320+
expect(title.startsWith("# Results for ")).toBe(true);
321+
expect(title).not.toContain("\n");
322+
// Variable-length fence: at least two consecutive backticks used as delimiter
323+
expect(title).toContain("``");
324+
});
302325
});
303326

304327
describe("buildMarkdownOutput", () => {
@@ -597,6 +620,34 @@ describe("buildJsonOutput — line/col fields", () => {
597620
const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set()));
598621
expect(parsed.results[0].matches[0].matchedText).toBeUndefined();
599622
});
623+
624+
it("omits matchedText when seg.text is an empty string", () => {
625+
// api.ts normalizes missing segment text to "" — matchedText must not be emitted
626+
const groups: RepoGroup[] = [
627+
{
628+
repoFullName: "myorg/repoA",
629+
matches: [
630+
{
631+
path: "src/foo.ts",
632+
repoFullName: "myorg/repoA",
633+
htmlUrl: "https://github.com/myorg/repoA/blob/main/src/foo.ts",
634+
archived: false,
635+
textMatches: [
636+
{
637+
fragment: "some snippet",
638+
matches: [{ text: "", indices: [0, 0], line: 1, col: 1 }],
639+
},
640+
],
641+
},
642+
],
643+
folded: true,
644+
repoSelected: true,
645+
extractSelected: [true],
646+
},
647+
];
648+
const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set()));
649+
expect(parsed.results[0].matches[0].matchedText).toBeUndefined();
650+
});
600651
});
601652

602653
// ─── buildOutput dispatcher ──────────────────────────────────────────────────

src/output.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,21 @@ export function buildReplayDetails(
151151
].join("\n");
152152
}
153153

154+
// ─── Markdown inline-code helper ────────────────────────────────────────────
155+
156+
/**
157+
* Wraps `s` in a Markdown inline-code span using a backtick fence long enough
158+
* to safely contain any backticks already present in `s` (CommonMark §6.1).
159+
* Adds surrounding spaces when `s` starts or ends with a backtick character.
160+
*/
161+
function mdInlineCode(s: string): string {
162+
const runs = [...s.matchAll(/`+/g)].map((m) => m[0].length);
163+
const maxRun = runs.length > 0 ? Math.max(...runs) : 0;
164+
const fence = "`".repeat(maxRun + 1);
165+
const padded = s.startsWith("`") || s.endsWith("`") ? ` ${s} ` : s;
166+
return `${fence}${padded}${fence}`;
167+
}
168+
154169
// ─── Query title ─────────────────────────────────────────────────────────────
155170

156171
/**
@@ -159,11 +174,15 @@ export function buildReplayDetails(
159174
*
160175
* Examples:
161176
* # Results for "useFlag"
162-
* # Results for `useFlag/i`
177+
* # Results for `/useFlag/i`
163178
* # Results for "axios" · including archived · excluding templates
164179
*/
165180
export function buildQueryTitle(query: string, options: ReplayOptions = {}): string {
166-
const queryDisplay = isRegexQuery(query) ? `\`${query}\`` : `"${query}"`;
181+
// JSON.stringify handles embedded double quotes and converts newlines to \n
182+
// so the heading always stays on a single line.
183+
// mdInlineCode uses a variable-length backtick fence to safely display regex
184+
// patterns that may themselves contain backtick characters.
185+
const queryDisplay = isRegexQuery(query) ? mdInlineCode(query) : JSON.stringify(query);
167186
const qualifiers: string[] = [];
168187
if (options.includeArchived) qualifiers.push("including archived");
169188
if (options.excludeTemplates) qualifiers.push("excluding templates");
@@ -232,7 +251,7 @@ export function buildMarkdownOutput(
232251
// absolute line numbers).
233252
const seg = m.textMatches[0]?.matches[0];
234253
if (seg) {
235-
const matchedText = seg.text ? `: \`${seg.text}\`` : "";
254+
const matchedText = seg.text ? `: ${mdInlineCode(seg.text)}` : "";
236255
lines.push(
237256
` - [ ] [${m.path}:${seg.line}:${seg.col}](${m.htmlUrl}#L${seg.line})${matchedText}`,
238257
);
@@ -269,7 +288,13 @@ export function buildJsonOutput(
269288
return {
270289
path: m.path,
271290
url: m.htmlUrl,
272-
...(seg !== undefined ? { line: seg.line, col: seg.col, matchedText: seg.text } : {}),
291+
...(seg !== undefined
292+
? {
293+
line: seg.line,
294+
col: seg.col,
295+
...(seg.text ? { matchedText: seg.text } : {}),
296+
}
297+
: {}),
273298
};
274299
});
275300
return { ...base, matches };

0 commit comments

Comments
 (0)