From 9cf015933582acc40213fe33035df74e7e96eddc Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 3 Apr 2026 20:30:09 +0300 Subject: [PATCH] fix(triage): slugify agent names for labels + pre-create missing labels (#784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: Watch triage derived labels from display names ('Steve Rogers' → 'squad:steve rogers') instead of directory slugs ('squad:steve-rogers'). Fix: Added shared slugify() utility, applied across triage.ts, templates, and label sync workflows. Bug 2: gh issue edit --add-label fails with exit code 1 when label doesn't exist. Watch swallowed the error silently. Fix: Added ensureTag() to PlatformAdapter + pre-create all squad:{member} labels at watch startup. 12 new tests: slugify utility (10) + triage label verification (2). All 41 tests pass. Build clean. Closes #784 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/fix-triage-label-slug.md | 8 ++++ .github/workflows/squad-triage.yml | 6 ++- .github/workflows/sync-squad-labels.yml | 4 +- .squad-templates/ralph-triage.js | 4 +- .squad-templates/workflows/squad-triage.yml | 6 ++- .../workflows/sync-squad-labels.yml | 4 +- index.js | 4 +- .../squad-cli/src/cli/commands/watch/index.ts | 13 ++++++ packages/squad-cli/templates/ralph-triage.js | 4 +- .../templates/workflows/squad-triage.yml | 6 ++- .../templates/workflows/sync-squad-labels.yml | 4 +- packages/squad-sdk/src/platform/github.ts | 11 +++++ packages/squad-sdk/src/platform/types.ts | 3 ++ packages/squad-sdk/src/ralph/triage.ts | 5 ++- packages/squad-sdk/src/utils/slugify.ts | 15 +++++++ packages/squad-sdk/templates/ralph-triage.js | 4 +- .../templates/workflows/squad-triage.yml | 6 ++- .../templates/workflows/sync-squad-labels.yml | 4 +- templates/ralph-triage.js | 4 +- templates/workflows/squad-triage.yml | 6 ++- templates/workflows/sync-squad-labels.yml | 4 +- test/ralph-triage.test.ts | 28 ++++++++++++ test/slugify.test.ts | 45 +++++++++++++++++++ 23 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 .changeset/fix-triage-label-slug.md create mode 100644 packages/squad-sdk/src/utils/slugify.ts create mode 100644 test/slugify.test.ts diff --git a/.changeset/fix-triage-label-slug.md b/.changeset/fix-triage-label-slug.md new file mode 100644 index 000000000..60fb4ec81 --- /dev/null +++ b/.changeset/fix-triage-label-slug.md @@ -0,0 +1,8 @@ +--- +'@bradygaster/squad-sdk': patch +'@bradygaster/squad-cli': patch +--- + +Fix triage label slug derivation: use `slugify()` instead of `toLowerCase()` so multi-word agent names produce valid GitHub labels (e.g. "Steve Rogers" → `squad:steve-rogers` instead of `squad:steve rogers`). + +Pre-create all `squad:{member}` labels at watch startup via `ensureTag()` so `gh issue edit --add-label` never fails on missing labels. diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index 613d3da02..ecc946645 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -135,9 +135,11 @@ jobs: return; } + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Build triage context const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` + `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` ).join('\n'); // Determine best assignee based on issue content and routing @@ -214,7 +216,7 @@ jobs: } const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; + const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; // Add the member-specific label await github.rest.issues.addLabels({ diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml index fbcfd9cc2..699fc680f 100644 --- a/.github/workflows/sync-squad-labels.yml +++ b/.github/workflows/sync-squad-labels.yml @@ -103,6 +103,8 @@ jobs: { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } ]; + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Ensure the base "squad" triage label exists const labels = [ { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } @@ -110,7 +112,7 @@ jobs: for (const member of members) { labels.push({ - name: `squad:${member.name.toLowerCase()}`, + name: `squad:${slugify(member.name)}`, color: MEMBER_COLOR, description: `Assigned to ${member.name} (${member.role})` }); diff --git a/.squad-templates/ralph-triage.js b/.squad-templates/ralph-triage.js index cf3023961..0f533ee48 100644 --- a/.squad-templates/ralph-triage.js +++ b/.squad-templates/ralph-triage.js @@ -55,6 +55,8 @@ function normalizeEol(content) { return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } +function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + function parseRoutingRules(routingMd) { const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i); if (!table) return []; @@ -124,7 +126,7 @@ function parseRoster(teamMd) { members.push({ name, role, - label: `squad:${name.toLowerCase()}`, + label: `squad:${slugify(name)}`, }); } diff --git a/.squad-templates/workflows/squad-triage.yml b/.squad-templates/workflows/squad-triage.yml index a58be9b29..d118a2813 100644 --- a/.squad-templates/workflows/squad-triage.yml +++ b/.squad-templates/workflows/squad-triage.yml @@ -110,9 +110,11 @@ jobs: return; } + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Build triage context const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` + `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` ).join('\n'); // Determine best assignee based on issue content and routing @@ -189,7 +191,7 @@ jobs: } const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; + const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; // Add the member-specific label await github.rest.issues.addLabels({ diff --git a/.squad-templates/workflows/sync-squad-labels.yml b/.squad-templates/workflows/sync-squad-labels.yml index fbcfd9cc2..699fc680f 100644 --- a/.squad-templates/workflows/sync-squad-labels.yml +++ b/.squad-templates/workflows/sync-squad-labels.yml @@ -103,6 +103,8 @@ jobs: { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } ]; + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Ensure the base "squad" triage label exists const labels = [ { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } @@ -110,7 +112,7 @@ jobs: for (const member of members) { labels.push({ - name: `squad:${member.name.toLowerCase()}`, + name: `squad:${slugify(member.name)}`, color: MEMBER_COLOR, description: `Assigned to ${member.name} (${member.role})` }); diff --git a/index.js b/index.js index 66646fb78..3b0a6193c 100644 --- a/index.js +++ b/index.js @@ -272,6 +272,8 @@ if (cmd === 'watch') { const content = fs.readFileSync(teamMd, 'utf8'); + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Parse members from roster function parseMembers(text) { const lines = text.split('\n'); @@ -283,7 +285,7 @@ if (cmd === 'watch') { if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { const cells = line.split('|').map(c => c.trim()).filter(Boolean); if (cells.length >= 2 && !['Scribe', 'Ralph'].includes(cells[0])) { - members.push({ name: cells[0], role: cells[1], label: `squad:${cells[0].toLowerCase()}` }); + members.push({ name: cells[0], role: cells[1], label: `squad:${slugify(cells[0])}` }); } } } diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index 5f1bad662..ea4e53204 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -662,6 +662,19 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig fatal('No squad members found in team.md'); } + // Pre-create squad member labels so addTag never fails on missing labels + if (adapter.ensureTag) { + for (const member of roster) { + try { + await adapter.ensureTag(member.label, { color: 'd4c5f9', description: `Squad triage: ${member.name}` }); + } catch { /* best-effort — continue if label creation fails */ } + } + try { + await adapter.ensureTag('squad:copilot', { color: 'd4c5f9', description: 'Squad triage: Copilot coding agent' }); + } catch { /* best-effort */ } + console.log(`${DIM}Labels: ensured ${roster.length + 1} squad labels exist${RESET}`); + } + const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot'); const autoAssign = content.includes(''); const monitorSessionId = 'ralph-watch'; diff --git a/packages/squad-cli/templates/ralph-triage.js b/packages/squad-cli/templates/ralph-triage.js index cf3023961..0f533ee48 100644 --- a/packages/squad-cli/templates/ralph-triage.js +++ b/packages/squad-cli/templates/ralph-triage.js @@ -55,6 +55,8 @@ function normalizeEol(content) { return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } +function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + function parseRoutingRules(routingMd) { const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i); if (!table) return []; @@ -124,7 +126,7 @@ function parseRoster(teamMd) { members.push({ name, role, - label: `squad:${name.toLowerCase()}`, + label: `squad:${slugify(name)}`, }); } diff --git a/packages/squad-cli/templates/workflows/squad-triage.yml b/packages/squad-cli/templates/workflows/squad-triage.yml index a58be9b29..d118a2813 100644 --- a/packages/squad-cli/templates/workflows/squad-triage.yml +++ b/packages/squad-cli/templates/workflows/squad-triage.yml @@ -110,9 +110,11 @@ jobs: return; } + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Build triage context const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` + `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` ).join('\n'); // Determine best assignee based on issue content and routing @@ -189,7 +191,7 @@ jobs: } const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; + const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; // Add the member-specific label await github.rest.issues.addLabels({ diff --git a/packages/squad-cli/templates/workflows/sync-squad-labels.yml b/packages/squad-cli/templates/workflows/sync-squad-labels.yml index fbcfd9cc2..699fc680f 100644 --- a/packages/squad-cli/templates/workflows/sync-squad-labels.yml +++ b/packages/squad-cli/templates/workflows/sync-squad-labels.yml @@ -103,6 +103,8 @@ jobs: { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } ]; + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Ensure the base "squad" triage label exists const labels = [ { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } @@ -110,7 +112,7 @@ jobs: for (const member of members) { labels.push({ - name: `squad:${member.name.toLowerCase()}`, + name: `squad:${slugify(member.name)}`, color: MEMBER_COLOR, description: `Assigned to ${member.name} (${member.role})` }); diff --git a/packages/squad-sdk/src/platform/github.ts b/packages/squad-sdk/src/platform/github.ts index f4ac6dca7..c3efb6424 100644 --- a/packages/squad-sdk/src/platform/github.ts +++ b/packages/squad-sdk/src/platform/github.ts @@ -128,6 +128,17 @@ export class GitHubAdapter implements PlatformAdapter { this.gh(['issue', 'edit', String(workItemId), '--repo', this.repoFlag, '--add-label', tag]); } + async ensureTag(tag: string, options?: { color?: string; description?: string }): Promise { + const args = ['label', 'create', tag, '--repo', this.repoFlag, '--force']; + if (options?.color) args.push('--color', options.color); + if (options?.description) args.push('--description', options.description); + try { + this.gh(args); + } catch { + // Label already exists or creation failed — continue either way + } + } + async removeTag(workItemId: number, tag: string): Promise { this.gh(['issue', 'edit', String(workItemId), '--repo', this.repoFlag, '--remove-label', tag]); } diff --git a/packages/squad-sdk/src/platform/types.ts b/packages/squad-sdk/src/platform/types.ts index 1bc6ca639..a28a8e4c5 100644 --- a/packages/squad-sdk/src/platform/types.ts +++ b/packages/squad-sdk/src/platform/types.ts @@ -50,6 +50,9 @@ export interface PlatformAdapter { removeTag(workItemId: number, tag: string): Promise; addComment(workItemId: number, comment: string): Promise; + /** Ensure a tag/label exists (creates it if missing). No-op on platforms with auto-created tags. */ + ensureTag?(tag: string, options?: { color?: string; description?: string }): Promise; + // Pull Requests listPullRequests(options: { status?: string; limit?: number }): Promise; createPullRequest(options: { diff --git a/packages/squad-sdk/src/ralph/triage.ts b/packages/squad-sdk/src/ralph/triage.ts index f207f13b8..e9b826a44 100644 --- a/packages/squad-sdk/src/ralph/triage.ts +++ b/packages/squad-sdk/src/ralph/triage.ts @@ -1,4 +1,7 @@ import { normalizeEol } from '../utils/normalize-eol.js'; +import { slugify } from '../utils/slugify.js'; + +export { slugify } from '../utils/slugify.js'; /** Parsed routing rule from routing.md */ export interface RoutingRule { @@ -118,7 +121,7 @@ export function parseRoster(teamMd: string): TeamMember[] { members.push({ name, role, - label: `squad:${name.toLowerCase()}`, + label: `squad:${slugify(name)}`, }); } diff --git a/packages/squad-sdk/src/utils/slugify.ts b/packages/squad-sdk/src/utils/slugify.ts new file mode 100644 index 000000000..ad60a54e4 --- /dev/null +++ b/packages/squad-sdk/src/utils/slugify.ts @@ -0,0 +1,15 @@ +/** + * Convert a display name to a URL/label-safe slug. + * + * "Steve Rogers" → "steve-rogers" + * "Tony Stark (Iron Man)" → "tony-stark-iron-man" + * "Doctor Strange (Stephen Strange)" → "doctor-strange-stephen-strange" + * "Thor" → "thor" + * "already-slugified" → "already-slugified" + */ +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} diff --git a/packages/squad-sdk/templates/ralph-triage.js b/packages/squad-sdk/templates/ralph-triage.js index cf3023961..0f533ee48 100644 --- a/packages/squad-sdk/templates/ralph-triage.js +++ b/packages/squad-sdk/templates/ralph-triage.js @@ -55,6 +55,8 @@ function normalizeEol(content) { return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } +function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + function parseRoutingRules(routingMd) { const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i); if (!table) return []; @@ -124,7 +126,7 @@ function parseRoster(teamMd) { members.push({ name, role, - label: `squad:${name.toLowerCase()}`, + label: `squad:${slugify(name)}`, }); } diff --git a/packages/squad-sdk/templates/workflows/squad-triage.yml b/packages/squad-sdk/templates/workflows/squad-triage.yml index a58be9b29..d118a2813 100644 --- a/packages/squad-sdk/templates/workflows/squad-triage.yml +++ b/packages/squad-sdk/templates/workflows/squad-triage.yml @@ -110,9 +110,11 @@ jobs: return; } + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Build triage context const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` + `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` ).join('\n'); // Determine best assignee based on issue content and routing @@ -189,7 +191,7 @@ jobs: } const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; + const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; // Add the member-specific label await github.rest.issues.addLabels({ diff --git a/packages/squad-sdk/templates/workflows/sync-squad-labels.yml b/packages/squad-sdk/templates/workflows/sync-squad-labels.yml index fbcfd9cc2..699fc680f 100644 --- a/packages/squad-sdk/templates/workflows/sync-squad-labels.yml +++ b/packages/squad-sdk/templates/workflows/sync-squad-labels.yml @@ -103,6 +103,8 @@ jobs: { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } ]; + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Ensure the base "squad" triage label exists const labels = [ { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } @@ -110,7 +112,7 @@ jobs: for (const member of members) { labels.push({ - name: `squad:${member.name.toLowerCase()}`, + name: `squad:${slugify(member.name)}`, color: MEMBER_COLOR, description: `Assigned to ${member.name} (${member.role})` }); diff --git a/templates/ralph-triage.js b/templates/ralph-triage.js index cf3023961..0f533ee48 100644 --- a/templates/ralph-triage.js +++ b/templates/ralph-triage.js @@ -55,6 +55,8 @@ function normalizeEol(content) { return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } +function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + function parseRoutingRules(routingMd) { const table = parseTableSection(routingMd, /^##\s*work\s*type\s*(?:→|->)\s*agent\b/i); if (!table) return []; @@ -124,7 +126,7 @@ function parseRoster(teamMd) { members.push({ name, role, - label: `squad:${name.toLowerCase()}`, + label: `squad:${slugify(name)}`, }); } diff --git a/templates/workflows/squad-triage.yml b/templates/workflows/squad-triage.yml index a58be9b29..d118a2813 100644 --- a/templates/workflows/squad-triage.yml +++ b/templates/workflows/squad-triage.yml @@ -110,9 +110,11 @@ jobs: return; } + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Build triage context const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` + `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` ).join('\n'); // Determine best assignee based on issue content and routing @@ -189,7 +191,7 @@ jobs: } const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; + const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; // Add the member-specific label await github.rest.issues.addLabels({ diff --git a/templates/workflows/sync-squad-labels.yml b/templates/workflows/sync-squad-labels.yml index fbcfd9cc2..699fc680f 100644 --- a/templates/workflows/sync-squad-labels.yml +++ b/templates/workflows/sync-squad-labels.yml @@ -103,6 +103,8 @@ jobs: { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } ]; + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Ensure the base "squad" triage label exists const labels = [ { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } @@ -110,7 +112,7 @@ jobs: for (const member of members) { labels.push({ - name: `squad:${member.name.toLowerCase()}`, + name: `squad:${slugify(member.name)}`, color: MEMBER_COLOR, description: `Assigned to ${member.name} (${member.role})` }); diff --git a/test/ralph-triage.test.ts b/test/ralph-triage.test.ts index 2bf366083..925f4393e 100644 --- a/test/ralph-triage.test.ts +++ b/test/ralph-triage.test.ts @@ -51,6 +51,34 @@ describe('ralph triage parser helpers', () => { expect(roster).toEqual([{ name: 'Alpha', role: 'Developer', label: 'squad:alpha' }]); }); + it('slugifies multi-word names into labels', () => { + const multiWord = [ + '## Members', + '', + '| Name | Role |', + '|------|------|', + '| Steve Rogers | Lead |', + '| Tony Stark | Engineer |', + ].join('\n'); + + const roster = parseRoster(multiWord); + expect(roster[0]!.label).toBe('squad:steve-rogers'); + expect(roster[1]!.label).toBe('squad:tony-stark'); + }); + + it('slugifies names with parentheses into labels', () => { + const withParens = [ + '## Members', + '', + '| Name | Role |', + '|------|------|', + '| Tony Stark (Iron Man) | Engineer |', + ].join('\n'); + + const roster = parseRoster(withParens); + expect(roster[0]!.label).toBe('squad:tony-stark-iron-man'); + }); + it('filters out Scribe and Ralph', () => { const roster = parseRoster(TEAM_MD); expect(roster.some((member) => member.name === 'Scribe')).toBe(false); diff --git a/test/slugify.test.ts b/test/slugify.test.ts new file mode 100644 index 000000000..bf54c4c16 --- /dev/null +++ b/test/slugify.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { slugify } from '../packages/squad-sdk/src/utils/slugify.js'; + +describe('slugify', () => { + it('slugifies "Steve Rogers" to "steve-rogers"', () => { + expect(slugify('Steve Rogers')).toBe('steve-rogers'); + }); + + it('slugifies "Tony Stark (Iron Man)" to "tony-stark-iron-man"', () => { + expect(slugify('Tony Stark (Iron Man)')).toBe('tony-stark-iron-man'); + }); + + it('slugifies "Natasha Romanoff (Black Widow)" correctly', () => { + expect(slugify('Natasha Romanoff (Black Widow)')).toBe('natasha-romanoff-black-widow'); + }); + + it('slugifies single-word names', () => { + expect(slugify('Thor')).toBe('thor'); + }); + + it('slugifies "Doctor Strange (Stephen Strange)"', () => { + expect(slugify('Doctor Strange (Stephen Strange)')).toBe('doctor-strange-stephen-strange'); + }); + + it('handles names that are already slugified', () => { + expect(slugify('steve-rogers')).toBe('steve-rogers'); + }); + + it('handles Scribe and Ralph (exempt names)', () => { + expect(slugify('Scribe')).toBe('scribe'); + expect(slugify('Ralph')).toBe('ralph'); + }); + + it('handles leading/trailing special characters', () => { + expect(slugify(' --Alpha-- ')).toBe('alpha'); + }); + + it('collapses consecutive non-alphanumeric characters', () => { + expect(slugify('a!!!b...c')).toBe('a-b-c'); + }); + + it('returns empty string for empty input', () => { + expect(slugify('')).toBe(''); + }); +});