Files
orca/src/shared/workspace-name.ts
T
NeilandClaude 5dd5294294 perf(orcad): keep the server bundle ASCII so V8 stores it at half the size (#31495)
* perf(orcad): keep the server bundle ASCII so V8 stores it one byte per character

129 non-ASCII characters in regex literals (and three in a String.raw plugin
template) made V8 hold the whole ~11 MB orcad-server.js source as a two-byte
string. Write them as \uXXXX escapes and fail the build if any return.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(orcad): keep license comments ASCII and name the module the guard trips on

esbuild gathers legal comments at the end of the bundle; escape that comment so
a dependency's license text cannot fail the ASCII guard, while licenses still
ship with the code. The guard now reports the offset and, from an unminified
rebuild, the module the character came from. A dark-switch build is covered.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-21 04:06:49 -07:00

289 lines
9.2 KiB
TypeScript

import {
collectCompactWorkspaceWords,
foldWorkspaceNameWhitespaceToHyphen
} from './workspace-name-text-scanner'
import { escapeRegex } from './string-utils'
import type { JiraIssueKeyCasing } from './ui-chrome-types'
import { JIRA_ISSUE_KEY_PATTERN } from './jira-issue-url'
function normalizeApostrophes(input: string): string {
return input.replace(/[\u2018\u2019]/g, "'")
}
// Why: contractions and possessives should not become stray `t` / `s` tokens
// in display names or extra hyphen segments in branch-safe workspace seeds.
function removeIntraWordApostrophes(input: string): string {
return normalizeApostrophes(input).replace(/([\p{L}\p{N}])'(?=[\p{L}\p{N}])/gu, '$1')
}
function stripDanglingDisplayApostrophes(input: string): string {
return normalizeApostrophes(input)
.replace(/(^|[^\p{L}\p{N}])'(?=[\p{L}\p{N}])/gu, '$1')
.replace(/([\p{L}\p{N}])'(?=$|[^\p{L}\p{N}])/gu, '$1')
}
export function slugifyForWorkspaceName(input: string): string {
const normalized = removeIntraWordApostrophes(input)
.trim()
.toLowerCase()
.replace(/[\\/]+/g, '-')
return (
foldWorkspaceNameWhitespaceToHyphen(normalized)
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
// Why: git check-ref-format rejects any ref containing `..`, so previews
// must match the main-process sanitizer before workspace creation.
.replace(/\.{2,}/g, '.')
.replace(/^[.-]+|[.-]+$/g, '')
.slice(0, 48)
.replace(/[-._]+$/g, '')
)
}
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
const seed = getLinkedWorkItemTitleSubject(item) || item.title.trim()
return slugifyForWorkspaceName(seed)
}
export type WorkspaceIntentWorkItem = {
type: 'issue' | 'pr' | 'mr'
number: number
title: string
provider?: 'github' | 'gitlab' | 'linear' | 'jira'
linearIdentifier?: string
jiraIdentifier?: string
}
export type WorkspaceIntentName = {
displayName: string
seedName: string
}
export type WorkspaceSeedNameOptions = {
jiraIssueKeyCasing?: JiraIssueKeyCasing
}
// Restore casing only for the leading Jira key in generated seeds.
export function applyJiraIssueKeyCasing(
seedName: string,
jiraIdentifier: string | undefined,
casing: JiraIssueKeyCasing | undefined
): string {
const key = jiraIdentifier?.trim().toUpperCase()
if (casing !== 'preserve' || !key || !JIRA_ISSUE_KEY_PATTERN.test(key)) {
return seedName
}
// Why: slugify only lowercases a key (its characters survive the strip), so re-uppercasing the leading segment is lossless.
const lowerKey = key.toLowerCase()
return seedName === lowerKey || seedName.startsWith(`${lowerKey}-`)
? `${key}${seedName.slice(key.length)}`
: seedName
}
function getLinkedWorkItemTitleSubject(item: { title: string }): string {
return item.title
.trim()
.replace(/^(?:issue|pr|pull request|mr|merge request)\s*[#!]?\d+\s*[:-]\s*/i, '')
.replace(/^#\d+\s*[:-]\s*/, '')
.replace(/\([#!]?\d+\)/g, '')
.replace(/\b#\d+\b/g, '')
.trim()
}
// Why: generated workspace seeds are hyphenated; `issue-123-fix-title`
// must not be reinterpreted as the user explicitly asking to fix a new issue.
const ACTION_LABELS: [RegExp, string][] = [
[/(?:^|[^a-z0-9_-])(?:fix(?:e[sd])?|resolve|repair)(?:$|[^a-z0-9_-])/i, 'Fix'],
[/(?:^|[^a-z0-9_-])(?:debug|diagnose)(?:$|[^a-z0-9_-])/i, 'Debug'],
[/(?:^|[^a-z0-9_-])(?:review|look\s+over|inspect|check|safe|safety)(?:$|[^a-z0-9_-])/i, 'Review'],
[/(?:^|[^a-z0-9_-])(?:implement|build|ship)(?:$|[^a-z0-9_-])/i, 'Implement'],
[/(?:^|[^a-z0-9_-])(?:investigate|understand|triage)(?:$|[^a-z0-9_-])/i, 'Investigate'],
[/(?:^|[^a-z0-9_-])(?:add|create)(?:$|[^a-z0-9_-])/i, 'Add'],
[/(?:^|[^a-z0-9_-])(?:update|change)(?:$|[^a-z0-9_-])/i, 'Update'],
[/(?:^|[^a-z0-9_-])(?:refactor|simplify)(?:$|[^a-z0-9_-])/i, 'Refactor'],
[/(?:^|[^a-z0-9_-])(?:test|verify|validate)(?:$|[^a-z0-9_-])/i, 'Test']
]
const STOP_WORDS = new Set([
'a',
'an',
'and',
'for',
'from',
'in',
'is',
'it',
'of',
'on',
'or',
'the',
'this',
'to',
'with'
])
function detectIntentAction(sourceText: string): string | null {
for (const [pattern, label] of ACTION_LABELS) {
if (pattern.test(sourceText)) {
return label
}
}
return null
}
function titleCaseWord(word: string): string {
const normalized = normalizeApostrophes(word)
if (/^[A-Z]{2,}\d*$/.test(normalized) || /^[A-Z]+-\d+$/i.test(normalized)) {
return normalized.toUpperCase()
}
const acronymPossessive = normalized.match(/^([A-Z]{2,}\d*)'([sS])$/)
if (acronymPossessive) {
return `${acronymPossessive[1].toUpperCase()}'s`
}
const lower = normalized.toLowerCase()
const apostropheParts = lower.split("'")
if (apostropheParts.length === 2 && apostropheParts[0].length === 1 && apostropheParts[1]) {
return `${apostropheParts[0].toUpperCase()}'${apostropheParts[1]}`
}
return lower.charAt(0).toUpperCase() + lower.slice(1)
}
function compactWords(input: string, maxWords = 4): string {
// Why: workspace names can be derived from pasted prompts/URLs; collect only
// the visible words we need instead of splitting the full text.
const words = collectCompactWorkspaceWords(
stripDanglingDisplayApostrophes(input),
maxWords,
STOP_WORDS
)
return words.map(titleCaseWord).join(' ')
}
function compactWorkItemTitle(title: string, item: WorkspaceIntentWorkItem): string {
const identifier = item.linearIdentifier ?? item.jiraIdentifier
let withoutPrefix = title
.trim()
.replace(/^(?:issue|pr|pull request|mr|merge request)\s*[#!]?\d+\s*[:-]\s*/i, '')
.replace(/\([#!]?\d+\)/g, '')
.replace(/^[^:]{1,32}:\s*/, '')
.trim()
if (item.number > 0) {
withoutPrefix = withoutPrefix.replace(new RegExp(`\\b[#!]?${item.number}\\b`, 'g'), '').trim()
}
if (identifier) {
withoutPrefix = withoutPrefix
.replace(new RegExp(`^${escapeRegex(identifier)}\\s*[:-]?\\s*`, 'i'), '')
.trim()
}
return compactWords(withoutPrefix || title, 3)
}
function workItemIdentity(item: WorkspaceIntentWorkItem): string {
if (item.linearIdentifier) {
return item.linearIdentifier.toUpperCase()
}
if (item.jiraIdentifier) {
return item.jiraIdentifier.toUpperCase()
}
if (item.type === 'pr') {
return `PR ${item.number}`
}
if (item.type === 'mr') {
return `MR ${item.number}`
}
return `Issue ${item.number}`
}
export function getLinkedWorkItemWorkspaceName(
item: WorkspaceIntentWorkItem,
options: WorkspaceSeedNameOptions = {}
): WorkspaceIntentName | null {
const identifier = item.linearIdentifier ?? item.jiraIdentifier
let subject = getLinkedWorkItemTitleSubject(item) || item.title.trim()
if (identifier) {
subject = subject
.replace(new RegExp(`^${escapeRegex(identifier)}\\s*[:-]?\\s*`, 'i'), '')
.trim()
}
const displayName = [identifier, subject].filter(Boolean).join(' ') || workItemIdentity(item)
const seedName = applyJiraIssueKeyCasing(
slugifyForWorkspaceName(displayName),
item.provider === 'jira' || (!item.provider && !item.linearIdentifier)
? item.jiraIdentifier
: undefined,
options.jiraIssueKeyCasing
)
if (!seedName) {
return null
}
return { displayName, seedName }
}
function defaultActionForWorkItem(item: WorkspaceIntentWorkItem): string | null {
return item.type === 'pr' || item.type === 'mr' ? 'Review' : null
}
/**
* Resolve the one human intent label that should drive first-create workspace
* identity. The display label and git-safe seed are derived together so the
* folder, branch, and sidebar name do not drift before work has started.
*/
export function getWorkspaceIntentName(args: {
sourceText?: string
workItem?: WorkspaceIntentWorkItem | null
fallbackName?: string
}): WorkspaceIntentName | null {
const sourceText = args.sourceText?.trim() ?? ''
const item = args.workItem ?? null
let displayName = ''
if (item) {
const action = detectIntentAction(sourceText) ?? defaultActionForWorkItem(item)
const identity = workItemIdentity(item)
if (action) {
displayName = `${action} ${identity}`
} else {
const subject = compactWorkItemTitle(item.title, item)
displayName = [identity, subject].filter(Boolean).join(' ')
}
} else if (sourceText) {
const compact = compactWords(sourceText, 5)
displayName = compact
}
if (!displayName && args.fallbackName?.trim()) {
displayName = args.fallbackName.trim()
}
if (!displayName) {
return null
}
const seedName = slugifyForWorkspaceName(displayName)
if (!seedName) {
return null
}
return { displayName, seedName }
}
export function getLinearIssueWorkspaceName(issue: { identifier: string; title: string }): string {
const key = slugifyForWorkspaceName(issue.identifier)
const titleSlug = getLinkedWorkItemSuggestedName(issue)
if (!key) {
return titleSlug
}
let dedupedTitleSlug = titleSlug
if (titleSlug === key) {
dedupedTitleSlug = ''
} else if (titleSlug.startsWith(`${key}-`)) {
dedupedTitleSlug = titleSlug.slice(key.length + 1)
}
return slugifyForWorkspaceName([key, dedupedTitleSlug].filter(Boolean).join('-'))
}
export function resolveWorkspaceCreateName(args: {
draft: string | undefined
fallback: string
}): string {
return args.draft?.trim() || args.fallback
}