Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Code-point boundary checks for terminal HTTP link scanning.
* Why extracted: keeps hit-testing under max-lines while hosting CJK-aware rules (#10571).
*/

// Why (#10571 review): enumerate glyphs forever misses 〜/〖〗/NBSP; property classes scale.
const NON_ASCII_BODY_TERMINATOR_RE = /[\p{White_Space}\p{P}\p{Sm}]/u

export function isHttpUrlBodyTerminator(code: number): boolean {
if (code <= 0x7f) {
return isAsciiUrlWhitespace(code) || isAsciiUrlBodyDelimiter(code)
}
return isNonAsciiUrlBodyTerminator(code)
}

export function isHttpUrlTrailingPunctuation(code: number): boolean {
if (code <= 0x7f) {
return isAsciiUrlWhitespace(code) || isAsciiUrlTrailingPunctuation(code)
}
// Why: max-length cuts can leave non-ASCII punct at the candidate tail.
return isNonAsciiUrlBodyTerminator(code)
}

/**
* Why (#10571 review): if a terminator is missed, CJK must not fold into the
* authority — WHATWG IDNA would silently retarget the link (spoof surface).
* Paths/queries still accept non-ASCII after `/` `?` `#`.
*/
export function isHttpUrlAuthorityCodeAllowed(code: number): boolean {
return code <= 0x7f
}

function isNonAsciiUrlBodyTerminator(code: number): boolean {
return NON_ASCII_BODY_TERMINATOR_RE.test(String.fromCodePoint(code))
}

function isAsciiUrlWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32
}

function isAsciiUrlBodyDelimiter(code: number): boolean {
return (
code === 0x22 || // "
code === 0x27 || // '
code === 0x21 || // !
code === 0x2a || // *
code === 0x28 || // (
code === 0x29 || // )
code === 0x7b || // {
code === 0x7d || // }
code === 0x7c || // |
code === 0x5c || // \
code === 0x5e || // ^
code === 0x3c || // <
code === 0x3e || // >
code === 0x60 // `
)
}

function isAsciiUrlTrailingPunctuation(code: number): boolean {
return (
isAsciiUrlBodyDelimiter(code) ||
code === 0x3a || // :
code === 0x2c || // ,
code === 0x2e || // .
code === 0x3f || // ?
code === 0x7e || // ~
code === 0x5b || // [
code === 0x5d // ]
)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
import {
isHttpUrlAuthorityCodeAllowed,
isHttpUrlBodyTerminator,
isHttpUrlTrailingPunctuation
} from './terminal-http-url-boundary'

type ParsedTerminalHttpLink = {
url: string
Expand Down Expand Up @@ -76,14 +81,41 @@ function hasHttpUrlWordBoundary(lineText: string, startIndex: number): boolean {

function findHttpUrlCandidateEnd(lineText: string, startIndex: number): number {
const scanEnd = Math.min(lineText.length, startIndex + TERMINAL_HTTP_URL_MAX_LENGTH + 1)
for (let index = startIndex; index < scanEnd; index += 1) {
if (isHttpUrlBodyTerminator(lineText.charCodeAt(index))) {
// Why: authority ends at first / ? #; non-ASCII there must not reach IDNA.
let inAuthority = true
const authorityStart = httpUrlAuthorityStartIndex(lineText, startIndex)

for (let index = startIndex; index < scanEnd; ) {
const code = lineText.codePointAt(index) ?? 0
const width = code > 0xffff ? 2 : 1

if (inAuthority && index >= authorityStart) {
if (code === 0x2f || code === 0x3f || code === 0x23) {
inAuthority = false
} else if (!isHttpUrlAuthorityCodeAllowed(code)) {
return index
}
}

if (isHttpUrlBodyTerminator(code)) {
return index
}
index += width
}
return scanEnd
}

function httpUrlAuthorityStartIndex(lineText: string, startIndex: number): number {
if (lineText.startsWith('https://', startIndex)) {
return startIndex + 'https://'.length
}
if (lineText.startsWith('http://', startIndex)) {
return startIndex + 'http://'.length
}
// Why: scheme finder only yields http(s); treat body start as authority floor.
return startIndex
}

function trimHttpUrlTrailingPunctuation(
lineText: string,
startIndex: number,
Expand All @@ -96,56 +128,6 @@ function trimHttpUrlTrailingPunctuation(
return endIndex
}

function isHttpUrlBodyTerminator(code: number): boolean {
return (
isAsciiWhitespace(code) ||
code === 0x22 ||
code === 0x27 ||
code === 0x21 ||
code === 0x2a ||
code === 0x28 ||
code === 0x29 ||
code === 0x7b ||
code === 0x7d ||
code === 0x7c ||
code === 0x5c ||
code === 0x5e ||
code === 0x3c ||
code === 0x3e ||
code === 0x60
)
}

function isHttpUrlTrailingPunctuation(code: number): boolean {
return (
isAsciiWhitespace(code) ||
code === 0x22 ||
code === 0x27 ||
code === 0x3a ||
code === 0x2c ||
code === 0x2e ||
code === 0x21 ||
code === 0x3f ||
code === 0x7b ||
code === 0x7d ||
code === 0x7c ||
code === 0x5c ||
code === 0x5e ||
code === 0x7e ||
code === 0x5b ||
code === 0x5d ||
code === 0x28 ||
code === 0x29 ||
code === 0x3c ||
code === 0x3e ||
code === 0x60
)
}

function isAsciiWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32
}

function isAsciiWordCode(code: number): boolean {
return (
(code >= 48 && code <= 57) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,104 @@ describe('extractTerminalHttpLinks', () => {
])
expect(matchAllSpy).not.toHaveBeenCalled()
})

it('terminates URLs on fullwidth parentheses without swallowing CJK annotation text', () => {
// Why (#10571): Claude Code often prints Japanese annotations glued to PRs.
const line = 'PR: https://github.com/stablyai/orca/pull/12345(作成済み・マージ待ち)'
const url = 'https://github.com/stablyai/orca/pull/12345'
expect(extractTerminalHttpLinks(line)).toEqual([
{
url,
startIndex: 'PR: '.length,
endIndex: 'PR: '.length + url.length
}
])
})

it('terminates URLs on CJK corner brackets and ideographic space', () => {
expect(extractTerminalHttpLinks('詳細は「https://example.com/docs」を参照')).toEqual([
{
url: 'https://example.com/docs',
startIndex: '詳細は「'.length,
endIndex: '詳細は「https://example.com/docs'.length
}
])

const ideographicSpaceLine = 'PR: https://example.com/pull/2 (全角スペース区切り)'
const url = 'https://example.com/pull/2'
expect(extractTerminalHttpLinks(ideographicSpaceLine)).toEqual([
{
url,
startIndex: 'PR: '.length,
endIndex: 'PR: '.length + url.length
}
])
})

it('still terminates on half-width spaces and parentheses next to CJK text', () => {
const spaced = 'PR: https://example.com/pull/2 (作成済み)'
const paren = 'PR: https://example.com/pull/2 (作成済み)'
const url = 'https://example.com/pull/2'
expect(extractTerminalHttpLinks(spaced)[0]?.url).toBe(url)
expect(extractTerminalHttpLinks(paren)[0]?.url).toBe(url)
})

it('terminates on CJK punctuation so prose is not IDNA-folded into the host', () => {
// Why (#10571 review): trailing-only trim never saw 、。・ — body must stop first.
const cases: { line: string; end: string }[] = [
{ line: 'https://example.com、それから', end: 'https://example.com' },
{ line: 'https://example.com。次の行', end: 'https://example.com' },
{ line: 'https://example.com・次', end: 'https://example.com' },
{ line: 'https://example.com!注意', end: 'https://example.com' },
{ line: 'https://example.com?詳細', end: 'https://example.com' }
]
for (const { line, end } of cases) {
expect(extractTerminalHttpLinks(line)).toEqual([
{ url: 'https://example.com/', startIndex: 0, endIndex: end.length }
])
}
})

it('guards authority so bare CJK after the host cannot retarget via IDNA', () => {
// Why: defense in depth if a terminator is ever missed — letters aren't \p{P}.
const line = 'https://example.comそれから'
expect(extractTerminalHttpLinks(line)).toEqual([
{
url: 'https://example.com/',
startIndex: 0,
endIndex: 'https://example.com'.length
}
])
})

it('still allows non-ASCII in the path after leaving authority', () => {
const line = 'see https://example.com/ドキュメント'
const links = extractTerminalHttpLinks(line)
expect(links).toHaveLength(1)
expect(links[0]?.url).toBe(new URL('https://example.com/ドキュメント').toString())
expect(links[0]?.endIndex).toBe(line.length)
})

it('terminates on Unicode whitespace beyond ideographic space', () => {
// Why (CodeRabbit): NBSP / EM SPACE / NARROW NO-BREAK SPACE are \p{White_Space}.
for (const ws of ['\u00a0', '\u2003', '\u202f']) {
const line = `https://example.com${ws}next`
expect(extractTerminalHttpLinks(line)).toEqual([
{
url: 'https://example.com/',
startIndex: 0,
endIndex: 'https://example.com'.length
}
])
}
})

it('terminates on property-class punctuation not in the old glyph list', () => {
// Why: 〜 is Pd, ~ is Sm, 〖〗 were missing from the enumerated bracket set.
for (const punct of ['〜', '~', '〖', '〗']) {
const line = `https://example.com${punct}続き`
expect(extractTerminalHttpLinks(line)[0]?.url).toBe('https://example.com/')
expect(extractTerminalHttpLinks(line)[0]?.endIndex).toBe('https://example.com'.length)
}
})
})