authorgravatar for tyler@southboundsoftware.comTyler <tyler@southboundsoftware.com> 2026-08-09 16:16:12-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-09 14:16:12-07:00
log062b4618c229f3e2f13e44efd8dab8c71ad33dae
treedcb30a3a57e1db4af496fe06783f3b709c5a799b
parentdeb901d6381e0c86ef326946c8fb45ee323db708
signature Signed by PGP key B5690EEEBB952194

fix(shared): bound the file-link label so bracket runs stop rescanning (#5782)

Co-authored-by: tsouth89 <tsouth89@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

2 files changed, 36 insertions(+), 1 deletions(-)

packages/shared/src/composerInlineTokens.test.ts+21
...@@ -129,4 +129,25 @@ describe("collectComposerInlineTokens", () => {...@@ -129,4 +129,25 @@ describe("collectComposerInlineTokens", () => {
129 },129 },
130 ]);130 ]);
131 });131 });
132
133 it("still collects a file link whose label is at the length cap", () => {
134 const label = `${"a".repeat(508)}.tsx`;
135 const tokens = collectComposerInlineTokens(`see [${label}](src/${label}) ok`);
136
137 expect(tokens).toHaveLength(1);
138 expect(tokens[0]?.value).toBe(`src/${label}`);
139 });
140
141 it("leaves a file link past the label cap as plain text", () => {
142 const label = `${"a".repeat(509)}.tsx`;
143 expect(collectComposerInlineTokens(`see [${label}](src/${label}) ok`)).toEqual([]);
144 });
145
146 it("stays fast on unterminated bracket runs", () => {
147 // Unbounded, the label body rescanned the rest of the text from every
148 // whitespace: this input took seconds.
149 const started = performance.now();
150 expect(collectComposerInlineTokens(" [[".repeat(40_000))).toEqual([]);
151 expect(performance.now() - started).toBeLessThan(1_000);
152 });
132});153});
packages/shared/src/composerInlineTokens.ts+15-1
...@@ -20,7 +20,21 @@ export interface CollectComposerInlineTokensOptions {...@@ -20,7 +20,21 @@ export interface CollectComposerInlineTokensOptions {
2020
21const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;21const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
22const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;22const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
23const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g;23/**
24 * The label body is bounded rather than `*`. Unbounded, every whitespace in
25 * the composer is a candidate start: the engine scans the rest of the text for
26 * a closing `]`, fails, and rescans from the next whitespace — quadratic on
27 * input like " [[[[[…". A cap makes each attempt constant-bounded.
28 *
29 * Only a basename ever survives the `label !== basename` check below, so this
30 * cannot reject a link a user could meaningfully write; the longest filename
31 * any common filesystem allows is 255.
32 */
33const MAX_FILE_LINK_LABEL_LENGTH = 512;
34const FILE_LINK_TOKEN_REGEX = new RegExp(
35 `(^|\\s)\\[((?:\\\\.|[^\\]\\\\]){0,${MAX_FILE_LINK_LABEL_LENGTH}})\\]\\(([^)\\s]+)\\)(?=\\s)`,
36 "g",
37);
24const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;38const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
25const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;39const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
26// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package.40// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package.