// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:7607-7962. import { preview } from "./auth-and-safety"; import { commandError, isGitHubError, runnerDisposition } from "./client"; import { issueListPaginationSummary, listIssueComments, listIssues } from "./issue-read"; import { ISSUE_SCAN_MAX_FINDINGS, MIN_SAFE_BODY_SCAN_CHARS } from "./types"; import type { EscapeBodyKind, EscapeCleanupSuggestion, EscapeFindingClassification, EscapeFindingSeverity, EscapeMatchFinding, EscapePatternDefinition, EscapeScanEntry, GitHubCommandResult } from "./types"; export function escapeSnippet(text: string, index: number, radius = 80): string { const start = Math.max(0, index - radius); const end = Math.min(text.length, index + radius + 40); return text.slice(start, end).replace(/\n/g, "\\n"); } export function lineColumnAt(text: string, index: number): { lineNumber: number; column: number } { let lineNumber = 1; let column = 1; for (let cursor = 0; cursor < index && cursor < text.length; cursor += 1) { if (text[cursor] === "\n") { lineNumber += 1; column = 1; } else { column += 1; } } return { lineNumber, column }; } export function lineTextAt(text: string, lineNumber: number): string { const lines = text.split(/\r?\n/); return lines[lineNumber - 1] ?? ""; } export function isInsideFencedCodeBlock(text: string, index: number): boolean { const lines = text.slice(0, index).split(/\r?\n/); let openFence = false; for (const line of lines) { const trimmed = line.trimStart(); if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) openFence = !openFence; } return openFence; } export function isInsideInlineCode(text: string, index: number): boolean { const { lineNumber, column } = lineColumnAt(text, index); const line = lineTextAt(text, lineNumber); const before = line.slice(0, Math.max(0, column - 1)); const after = line.slice(Math.max(0, column - 1)); return (before.match(/`/g)?.length ?? 0) % 2 === 1 && (after.match(/`/g)?.length ?? 0) > 0; } export function isExplanatoryLiteralBackslashN(text: string, index: number): boolean { if (isInsideFencedCodeBlock(text, index) || isInsideInlineCode(text, index)) return true; const window = text.slice(Math.max(0, index - 90), Math.min(text.length, index + 90)).toLowerCase(); return [ "字面量", "说明", "示例", "举例", "提到", "引用", "文本", "字符串", "escape", "literal", "example", "mention", "quoted", "反斜杠", ].some((keyword) => window.includes(keyword)); } export function cleanupEscapedText(text: string): string { return text .replace(/\\r\\n/g, "\n") .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") .replace(/\\x0a/gi, "\n") .replace(/\\x1b/gi, "") .replace(/\\u001b/gi, "") .replace(/\\033/g, "") .replace(/\\`/g, "`"); } export function escapeBodyId(bodyKind: EscapeBodyKind, issueNumber: number, issueId: number, commentId?: number): string { return bodyKind === "issue-body" ? `issue:${issueNumber}:body:${issueId}` : `issue:${issueNumber}:comment:${commentId ?? "unknown"}`; } export function textFindingPatterns(): EscapePatternDefinition[] { return [ { kind: "literal-backslash-n", pattern: /\\n/g, description: "literal backslash-n", }, { kind: "literal-backslash-t", pattern: /\\t/g, description: "literal backslash-t", }, { kind: "escaped-backtick", pattern: /\\`/g, description: "escaped backtick", }, { kind: "shell-escaped-newline", pattern: /\\r\\n|\\012|\\x0a/gi, description: "shell escaped newline", }, { kind: "ansi-escape-literal", pattern: /\\x1b|\\u001b|\\033/gi, description: "ANSI escape literal", }, ]; } export function bodyRiskFindings(kind: EscapeBodyKind, issueNumber: number, issueId: number, body: string | null, url: string, commentId?: number): EscapeMatchFinding[] { const bodyChars = body === null ? null : body.length; const bodyTrimmedChars = body === null ? null : body.trim().length; const location = { type: kind === "issue-body" ? "issue" as const : "comment" as const, bodyKind: kind, bodyId: escapeBodyId(kind, issueNumber, issueId, commentId), issueNumber, issueId, ...(commentId === undefined ? {} : { commentId }), url, bodyChars, bodyTrimmedChars, }; if (body === null) { return [{ ...location, kind: "null-body", classification: "risk", severity: "high", reason: "GitHub returned a null body", snippet: "", lineNumber: 0, column: 0, match: "", }]; } const trimmed = body.trim(); if (trimmed.toLowerCase() === "null") { return [{ ...location, kind: "literal-null-body", classification: "risk", severity: "high", reason: "body text is the literal string null", snippet: escapeSnippet(body, 0), lineNumber: 1, column: 1, match: "null", }]; } if (trimmed.length === 0) { return [{ ...location, kind: "blank-body", classification: "risk", severity: "medium", reason: "body is blank after trimming", snippet: "", lineNumber: 0, column: 0, match: "", }]; } if (trimmed.length < MIN_SAFE_BODY_SCAN_CHARS) { return [{ ...location, kind: "short-body", classification: "risk", severity: "medium", reason: `body is shorter than the safe guard threshold (${MIN_SAFE_BODY_SCAN_CHARS} chars)`, snippet: escapeSnippet(body, 0), lineNumber: 1, column: 1, match: "", }]; } return []; } export function scanText(text: string, patterns: EscapePatternDefinition[], issueNumber: number, issueId: number, bodyKind: EscapeBodyKind, url: string, commentId?: number): EscapeMatchFinding[] { const findings: EscapeMatchFinding[] = []; const counts = new Map(); for (const item of patterns) { item.pattern.lastIndex = 0; const matches = text.match(item.pattern); counts.set(item.kind, matches === null ? 0 : matches.length); } for (const item of patterns) { item.pattern.lastIndex = 0; let match = item.pattern.exec(text); while (match !== null) { const { lineNumber, column } = lineColumnAt(text, match.index); const explanatory = item.kind === "literal-backslash-n" && counts.get(item.kind) === 1 && isExplanatoryLiteralBackslashN(text, match.index); const classification: EscapeFindingClassification = explanatory ? "explanatory-mention" : "suspected-pollution"; const severity: EscapeFindingSeverity = explanatory ? "low" : item.kind === "ansi-escape-literal" ? "high" : "medium"; findings.push({ type: bodyKind === "issue-body" ? "issue" : "comment", bodyKind, bodyId: escapeBodyId(bodyKind, issueNumber, issueId, commentId), issueNumber, issueId, ...(commentId === undefined ? {} : { commentId }), url, kind: item.kind, classification, severity, reason: explanatory ? "literal backslash-n appears in explanatory context" : `matched ${item.description}`, snippet: escapeSnippet(text, match.index), lineNumber, column, match: match[0], bodyChars: text.length, bodyTrimmedChars: text.trim().length, ...(classification === "suspected-pollution" ? { cleanupPreview: { before: escapeSnippet(text, match.index), after: escapeSnippet(cleanupEscapedText(text), match.index) } } : {}), }); if (findings.length >= ISSUE_SCAN_MAX_FINDINGS) return findings; match = item.pattern.exec(text); } } return findings; } export function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupSuggestion[] { const groups = new Map(); for (const finding of findings) { if (finding.type === "comment-scan-error") continue; if (finding.classification !== "suspected-pollution" && finding.kind !== "short-body" && finding.kind !== "null-body" && finding.kind !== "blank-body" && finding.kind !== "literal-null-body") continue; const locatorKey = `${finding.bodyKind}:${finding.issueNumber}:${finding.issueId}:${finding.commentId ?? ""}`; const list = groups.get(locatorKey); if (list === undefined) groups.set(locatorKey, [finding]); else list.push(finding); } const suggestions: EscapeCleanupSuggestion[] = []; for (const group of groups.values()) { const first = group[0]; const suspected = group.some((finding) => finding.classification === "suspected-pollution"); const bodyRisk = group.some((finding) => finding.classification === "risk"); suggestions.push({ type: first.bodyKind, bodyId: first.bodyId, issueNumber: first.issueNumber, issueId: first.issueId, ...(first.commentId === undefined ? {} : { commentId: first.commentId }), url: first.url, classification: suspected ? "suspected-pollution" : "risk", action: first.bodyKind === "issue-body" ? (suspected ? "rewrite-issue-body-with-body-stdin" : "review-body-length") : "review-comment-manually", reason: suspected ? "suspected shell escape pollution should be rewritten from heredoc/stdin" : bodyRisk ? "body length/null risk should be reviewed before any write" : "no cleanup needed", findings: group.map((finding) => ({ kind: finding.kind, classification: finding.classification, severity: finding.severity, lineNumber: finding.lineNumber, column: finding.column, snippet: finding.snippet, match: finding.match, })), ...(first.bodyKind === "issue-body" ? { plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-stdin --dry-run <<'EOF'\n\nEOF`, bodyInputHint: "pass cleaned Markdown through --body-stdin with a quoted heredoc; --body-file is only for reusable files", preview: { before: first.cleanupPreview?.before ?? first.snippet, after: first.cleanupPreview?.after ?? first.snippet, }, } : { preview: { before: first.cleanupPreview?.before ?? first.snippet, after: first.cleanupPreview?.after ?? first.snippet, }, }), }); } return suggestions; } export async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise { const issues = await listIssues(token, repo, "all", limit); if (isGitHubError(issues)) return commandError(commandName, repo, issues); const issueOnly = issues.items; const patterns = textFindingPatterns(); const findings: EscapeScanEntry[] = []; let scannedComments = 0; for (const issue of issueOnly) { findings.push(...bodyRiskFindings("issue-body", issue.number, issue.id, issue.body, issue.html_url)); findings.push(...scanText(issue.body ?? "", patterns, issue.number, issue.id, "issue-body", issue.html_url)); const comments = await listIssueComments(token, repo, issue.number); if (isGitHubError(comments)) { findings.push({ type: "comment-scan-error", bodyKind: "comment-body", bodyId: escapeBodyId("comment-body", issue.number, issue.id), issueNumber: issue.number, issueId: issue.id, url: issue.html_url, degradedReason: comments.degradedReason, runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason), details: comments, }); continue; } for (const comment of comments) { scannedComments += 1; findings.push(...bodyRiskFindings("comment-body", issue.number, issue.id, comment.body, comment.html_url, comment.id)); findings.push(...scanText(comment.body ?? "", patterns, issue.number, issue.id, "comment-body", comment.html_url, comment.id)); } } const cleanupSuggestions = summarizeCleanupSuggestion(findings); const summary = findings.reduce((acc, finding) => { if (finding.type === "comment-scan-error") { acc.commentScanErrors += 1; return acc; } if (finding.classification === "suspected-pollution") acc.suspectedPollution += 1; if (finding.classification === "explanatory-mention") acc.explanatoryMention += 1; if (finding.classification === "risk") acc.risk += 1; if (finding.kind === "null-body" || finding.kind === "literal-null-body" || finding.kind === "blank-body" || finding.kind === "short-body") acc.bodyRisks += 1; return acc; }, { suspectedPollution: 0, explanatoryMention: 0, risk: 0, bodyRisks: 0, commentScanErrors: 0 }); return { ok: true, command: commandName, repo, dryRun, planned: true, scannedIssues: issueOnly.length, rawIssues: issues.rawCount, pagination: issueListPaginationSummary(issues), scannedComments, findings, cleanupSuggestions, summary: { findings: findings.length, suggestions: cleanupSuggestions.length, ...summary, }, note: dryRun ? "Dry-run cleanup planning only; no issue or comment content was modified." : "Read-only scan; no issue or comment content was modified.", }; }