From 1db7dcd9bf9fd44916ec0613e0509e121e84682d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 1 Aug 2026 08:24:05 -0400 Subject: [PATCH] fix(#2849): strip trailing hyphen after 60-char slug truncation (#2967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#2849): add failing regression for trailing-hyphen-after-truncation The strip ran before .substring(0, 60), so a cut landing on a separator produced a slug ending in '-'. Four cases: the exact issue repro (59 a's + space + tail), a boundary landing before a separator, leading-hyphen survival, and a long-Cyrillic transliteration+truncation case. * fix(#2849): strip trailing hyphen after 60-char truncation generateSlugInternal ran the ^-+|-+$ hyphen strip BEFORE .substring(0, 60), so a title whose 60-character cut landed on a separator yielded a slug ending in '-' — the very thing the strip step exists to prevent. Reorder so the strip runs after truncation. Truncation cannot introduce a leading hyphen, so the full ^-+|-+$ pass last is equivalent for leading hyphens and fixes the trailing-hyphen-after-truncation case. Latin-script output for titles ≤ 60 chars is byte-identical; only titles whose truncation boundary lands on a separator change (from broken to clean). * test(#2849): add all-separator collapses-to-empty boundary case Surfaced by isolated adversarial review: pin the contract that input which is entirely separators ('!!!', '!'.repeat(70)) reduces to '' — not null, not a stray hyphen — both short and past the 60-char truncation. * chore(#2849): add changeset fragment pr:0 placeholder; will backfill the real PR number after the PR exists. * chore(#2849): backfill changeset PR number 2967 --------- Co-authored-by: sim --- .changeset/sunny-voles-climb.md | 5 ++++ src/core-utils.cts | 7 ++++- tests/core-utils.test.cjs | 52 +++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 .changeset/sunny-voles-climb.md diff --git a/.changeset/sunny-voles-climb.md b/.changeset/sunny-voles-climb.md new file mode 100644 index 000000000..b880c9465 --- /dev/null +++ b/.changeset/sunny-voles-climb.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2967 +--- +**Slug no longer ends with a trailing hyphen when truncated** — long titles whose 60-character cut landed on a word separator produced a slug ending in `-`, which then leaked into phase directory and branch names. The trailing-hyphen strip now runs after truncation. (#2849) diff --git a/src/core-utils.cts b/src/core-utils.cts index 114a96e90..ae5a64c05 100644 --- a/src/core-utils.cts +++ b/src/core-utils.cts @@ -95,7 +95,12 @@ function pathExistsInternal(cwd: string, targetPath: string): boolean { function generateSlugInternal(text: string | null | undefined): string | null { if (!text) return null; - return transliterateForSlug(text).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60); + // #2849: strip leading/trailing hyphens AFTER truncation, not only before. + // .substring(0, 60) can land on a separator, re-introducing a trailing hyphen + // the strip step exists to prevent. Truncation cannot add a leading hyphen, so + // running the full ^-+|-+$ pass last is equivalent for leading hyphens and + // fixes the trailing-hyphen-after-truncation case. + return transliterateForSlug(text).replace(/[^a-z0-9]+/g, '-').substring(0, 60).replace(/^-+|-+$/g, ''); } // ─── Transliteration (#2848) ───────────────────────────────────────────────── diff --git a/tests/core-utils.test.cjs b/tests/core-utils.test.cjs index 3d416d1b5..4a0753f3d 100644 --- a/tests/core-utils.test.cjs +++ b/tests/core-utils.test.cjs @@ -203,6 +203,58 @@ describe('generateSlugInternal', () => { assert.ok(result !== null && result.length <= 60); }); + // ─── #2849: trailing hyphen must not survive 60-char truncation. ─────────── + // The strip ran before .substring(0, 60), so a cut landing on a separator + // produced a slug ending in `-`. The strip must run after truncation. + + test('#2849 — trailing hyphen is stripped after 60-char truncation', () => { + // 59 a's + space + "tail" → "aaaa…aaa-tail" (64 chars). Truncating at 60 + // lands on the separator → "aaaa…aaa-" (ends in `-`) without the fix. + const slug = coreUtils.generateSlugInternal('a'.repeat(59) + ' tail'); + assert.ok(slug !== null, 'slug must not be null'); + assert.ok(!slug.endsWith('-'), `slug must not end with a hyphen; got: ${JSON.stringify(slug)}`); + assert.ok(slug.length <= 60, `slug must be at most 60 chars; got length ${slug?.length}`); + // The tail word is truncated away — the slug is the 59 a's with no separator. + assert.strictEqual(slug, 'a'.repeat(59)); + }); + + test('#2849 — truncation landing before a separator keeps a clean boundary', () => { + // 58 a's + space + "b" = 60 chars exactly. Truncation keeps all 60 → "aaa…aa-b". + const slug = coreUtils.generateSlugInternal('a'.repeat(58) + ' b'); + assert.ok(slug !== null); + assert.ok(!slug.endsWith('-'), `slug must not end with a hyphen; got: ${JSON.stringify(slug)}`); + assert.strictEqual(slug?.length, 60); + assert.strictEqual(slug, 'a'.repeat(58) + '-b'); + }); + + test('#2849 — leading hyphens are still stripped after the truncation reorder', () => { + // Leading punctuation becomes a hyphen, then is stripped. Truncation runs + // after the strip; the leading-hyphen guarantee must survive the reorder. + const slug = coreUtils.generateSlugInternal('!!!' + 'a'.repeat(60)); + assert.ok(slug !== null); + assert.ok(!slug.startsWith('-'), `slug must not start with a hyphen; got: ${JSON.stringify(slug)}`); + assert.ok(!slug.endsWith('-'), `slug must not end with a hyphen; got: ${JSON.stringify(slug)}`); + assert.ok((slug?.length ?? 0) <= 60); + }); + + test('#2849 — long Cyrillic transliterates and truncates without a trailing hyphen', () => { + // Transliteration expands Cyrillic; the result can exceed 60 chars and land + // on a separator when truncated. The post-truncation strip must still fire. + const slug = coreUtils.generateSlugInternal('Объект день '.repeat(10).trim()); + assert.ok(slug !== null); + assert.ok(!slug.includes('Объект'), 'non-ASCII must be transliterated away'); + assert.ok(/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug), `slug must be ASCII-only and well-formed; got: ${slug}`); + assert.ok(!slug.endsWith('-'), `slug must not end with a hyphen; got: ${JSON.stringify(slug)}`); + assert.ok((slug?.length ?? 0) <= 60); + }); + + test('#2849 — all-separator input collapses to empty, not a stray hyphen', () => { + // Input that is entirely separators must reduce to '' (not null, not '-'), + // both short and when truncated past 60 chars. + assert.strictEqual(coreUtils.generateSlugInternal('!!!'), ''); + assert.strictEqual(coreUtils.generateSlugInternal('!'.repeat(70)), ''); + }); + test('unicode characters are replaced with hyphens', () => { const result = coreUtils.generateSlugInternal('中文phase'); assert.ok(typeof result === 'string');