Files
Kilo-Org_kilocode/script/upstream/utils/markers.ts
T
Mark IJbema c1accfa588 fix(upstream): stop finder hanging on big assets
Replace the subprocess-based line diff in classifyDrift with an
in-process multiset diff (approxDiff) so concurrent classifications
don't deadlock on big text files like sprite.svg via Bun $.quiet()
pipe-buffer stalls.

Also filter non-code assets (SVG, PNG, fonts, archives, lock files,
etc.) before classification so they don't bloat the report or stress
git subprocesses. Switch kilo-only path excludes to glob pathspecs so
every packages/kilo-*/ and **/kilocode/** dir is excluded without
maintaining a hand list.

Rename the 'whitespace-only' bucket to 'cosmetic-only' — with the
multiset diff it also catches line reordering, so the old label was
misleading.
2026-05-05 14:17:33 +02:00

449 lines
13 KiB
TypeScript

#!/usr/bin/env bun
/**
* Shared kilocode_change marker helpers used by both the marker fixer and the
* reset-candidate classifier. The logic here was originally inlined in
* fix-kilocode-markers.ts.
*/
import { $ } from "bun"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
export interface Text {
lines: string[]
eol: string
final: boolean
}
export interface Clean {
text: Text
marks: Marks
}
export interface Diff {
lines: Set<number>
deleted: number
}
export interface Range {
start: number
end: number
}
export interface Block extends Range {
before: string
after: string
}
export interface Marks {
inline: Map<number, string>
starts: Map<number, string>
ends: Map<number, string>
blocks: Block[]
file?: string
}
export type Style = "slash" | "hash" | "jsx" | "block"
export const standalone = [
/^\s*\/\/\s*kilocode_change\b.*$/,
/^\s*#\s*kilocode_change\b.*$/,
/^\s*\{?\s*\/\*\s*kilocode_change\b.*\*\/\}?\s*$/,
]
export const start = /\bkilocode_change\s+start\b/
export const end = /\bkilocode_change\s+end\b/
export const freshmark = /\bkilocode_change\s*-\s*new\s*file\b/
export const unsupported = new Set([".json", ".jsonc", ".lock", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"])
export const styles = new Map<string, Style>([
[".ts", "slash"],
[".tsx", "slash"],
[".js", "slash"],
[".jsx", "slash"],
[".css", "block"],
[".yml", "hash"],
[".yaml", "hash"],
[".toml", "hash"],
[".sh", "hash"],
[".bash", "hash"],
[".zsh", "hash"],
])
export const exempt = ["script/upstream/"]
export function ext(file: string) {
return path.extname(file).toLowerCase()
}
export function supported(file: string, text: string) {
const kind = ext(file)
if (unsupported.has(kind)) return false
if (styles.has(kind)) return true
return !kind && text.startsWith("#!")
}
export function annotates(file: string) {
return !exempt.some((scope) => file.startsWith(scope))
}
export function binary(data: Uint8Array) {
return data.includes(0)
}
export function split(text: string): Text {
const eol = text.includes("\r\n") ? "\r\n" : "\n"
const final = text.endsWith("\n")
const body = final ? text.slice(0, text.endsWith("\r\n") ? -2 : -1) : text
return { lines: body ? body.split(/\r?\n/) : [], eol, final }
}
export function join(text: Text) {
return text.lines.join(text.eol) + (text.final ? text.eol : "")
}
function strip(file: string, line: string): { line: string | null; mark?: string } {
if (standalone.some((item) => item.test(line))) return { line: null }
if (style(file) === "hash") return comment(line, [/^#\s*kilocode_change\b/])
return comment(line, [/^\{\/\*\s*kilocode_change\b/, /^\/\*\s*kilocode_change\b/, /^\/\/\s*kilocode_change\b/])
}
function comment(line: string, tokens: RegExp[]) {
let quote = ""
let escape = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (!char) continue
if (quote) {
if (escape) {
escape = false
continue
}
if (char === "\\") {
escape = true
continue
}
if (char === quote) quote = ""
continue
}
if (char === '"' || char === "'" || char === "`") {
quote = char
continue
}
const rest = line.slice(i)
if (tokens.some((item) => item.test(rest))) {
const next = line.slice(0, i).trimEnd()
return { line: next, mark: line.slice(next.length) }
}
}
return { line }
}
export function clean(file: string, text: string): Clean {
const parsed = split(text)
const marks: Marks = { inline: new Map(), starts: new Map(), ends: new Map(), blocks: [] }
const lines: string[] = []
const opens: { before: string; start?: number }[] = []
for (const line of parsed.lines) {
if (standalone.some((item) => item.test(line))) {
if (freshmark.test(line)) marks.file = line
if (start.test(line)) {
opens.push({ before: line })
continue
}
if (end.test(line)) {
const open = opens.pop()
const last = lines.length - 1
if (open?.start !== undefined && last >= open.start) {
marks.ends.set(last, line)
marks.blocks.push({ start: open.start, end: last, before: open.before, after: line })
}
if (!open && last >= 0) marks.ends.set(last, line)
continue
}
continue
}
const next = strip(file, line)
if (next.line === null) continue
const index = lines.length
lines.push(next.line)
for (const open of opens) {
if (open.start !== undefined) continue
open.start = index
marks.starts.set(index, open.before)
}
if (next.mark) marks.inline.set(index, next.mark)
}
return { text: { ...parsed, lines }, marks }
}
export function style(file: string): Style {
const kind = ext(file)
return styles.get(kind) ?? "hash"
}
function context(file: string, text: Text, range: Range): Style {
const base = style(file)
if (![".tsx", ".jsx"].includes(ext(file))) return base
if (tag(text.lines, range.start)) return "block"
if (child(text.lines, range.start)) return "jsx"
return base
}
function nearby(lines: string[], start: number, step: number) {
for (let i = start; i >= 0 && i < lines.length; i += step) {
const line = lines[i]?.trim()
if (line) return line
}
return ""
}
function tag(lines: string[], start: number) {
const current = lines[start]?.trim() ?? ""
if (!current) return false
if (/^[A-Za-z_$][\w$.:/-]*(=|\s*=)/.test(current)) return true
for (let i = start - 1; i >= Math.max(0, start - 20); i--) {
const line = lines[i]?.trim() ?? ""
if (!line) continue
if (line.includes(">")) return false
if (/^<\/?[A-Za-z]/.test(line)) return true
}
return false
}
function child(lines: string[], start: number) {
const current = lines[start]?.trim() ?? ""
const prev = nearby(lines, start - 1, -1)
const next = nearby(lines, start + 1, 1)
if (prev.endsWith(">") && !prev.endsWith("=>")) return true
if (next.startsWith("</")) return true
if (current.startsWith("</")) return true
if (current.startsWith("<") && prev && !prev.endsWith("(") && !prev.endsWith("return (")) return true
return false
}
function block(mode: Style, pad: string) {
if (mode === "hash") return { start: `${pad}# kilocode_change start`, end: `${pad}# kilocode_change end` }
if (mode === "jsx") return { start: `${pad}{/* kilocode_change start */}`, end: `${pad}{/* kilocode_change end */}` }
if (mode === "block") return { start: `${pad}/* kilocode_change start */`, end: `${pad}/* kilocode_change end */` }
return { start: `${pad}// kilocode_change start`, end: `${pad}// kilocode_change end` }
}
function note(mode: Style) {
if (mode === "hash") return " # kilocode_change"
if (mode === "jsx") return " {/* kilocode_change */}"
if (mode === "block") return " /* kilocode_change */"
return " // kilocode_change"
}
function indent(line: string) {
return line.match(/^\s*/)?.[0] ?? ""
}
function inline(file: string, _lines: string[], _range: Range, mode: Style) {
if (mode === "hash") return true
if (mode === "block" || mode === "jsx") return false
if (![".tsx", ".jsx"].includes(ext(file))) return true
return true
}
function merge(items: Range[]) {
return [...items]
.sort((a, b) => a.start - b.start)
.reduce<Range[]>((acc, item) => {
const prev = acc.at(-1)
if (prev && item.start <= prev.end + 1) {
prev.end = Math.max(prev.end, item.end)
return acc
}
acc.push({ ...item })
return acc
}, [])
}
export function ranges(nums: Set<number>): Range[] {
const sorted = [...nums].sort((a, b) => a - b)
return merge(
sorted.reduce<Range[]>((acc, num) => {
const prev = acc.at(-1)
if (prev && num === prev.end + 1) {
prev.end = num
return acc
}
acc.push({ start: num, end: num })
return acc
}, []),
)
}
function expand(found: Range[], marks: Marks) {
return merge(
found.map((range) => {
const next = { ...range }
for (const block of marks.blocks) {
if (next.end < block.start || next.start > block.end) continue
next.start = Math.min(next.start, block.start)
next.end = Math.max(next.end, block.end)
}
return next
}),
)
}
function boundary(line: string | undefined, kind: RegExp) {
if (!line) return false
return standalone.some((item) => item.test(line)) && kind.test(line)
}
function gap(lines: string[], index: number) {
const next = lines.slice(index).findIndex((line) => line.trim() !== "")
return next === -1 ? -1 : index + next
}
function collapse(lines: string[]): string[] {
const index = lines.findIndex((line, pos) => {
if (!boundary(line, end)) return false
const next = gap(lines, pos + 1)
return next !== -1 && boundary(lines[next], start)
})
if (index === -1) return lines
const next = gap(lines, index + 1)
return collapse(lines.filter((_, pos) => pos !== index && pos !== next))
}
function saved(marks: Marks, range: Range) {
return marks.blocks.find((block) => block.start === range.start && block.end === range.end)
}
export function annotate(file: string, clean: Clean, found: Range[]) {
const text = clean.text
const marks = clean.marks
const lines = [...text.lines]
for (const range of expand(found, marks).reverse()) {
const mode = context(file, text, range)
const prior = saved(marks, range)
const before = prior?.before ?? marks.starts.get(range.start)
const after = prior?.after ?? marks.ends.get(range.end)
if (!before && !after && range.start === range.end && inline(file, text.lines, range, mode)) {
lines[range.start] = `${lines[range.start]}${marks.inline.get(range.start) ?? note(mode)}`
continue
}
const pad = indent(text.lines[range.start] ?? "")
const fallback = block(mode, pad)
const pair = {
start: before ?? fallback.start,
end: after ?? fallback.end,
}
lines.splice(range.end + 1, 0, pair.end)
lines.splice(range.start, 0, pair.start)
}
return join({ ...text, lines: collapse(lines) })
}
export function fresh(file: string, clean: Clean) {
const lines = [...clean.text.lines]
const mode = style(file)
const line = clean.marks.file ?? (mode === "hash" ? "# kilocode_change - new file" : "// kilocode_change - new file")
const at = lines[0]?.startsWith("#!") ? 1 : 0
lines.splice(at, 0, line)
return join({ ...clean.text, lines })
}
function patch(out: string): Diff {
const lines = new Set<number>()
const state = { next: 0, deleted: 0, added: 0, removed: 0 }
const flush = () => {
if (state.removed > 0 && state.added === 0) state.deleted += state.removed
state.added = 0
state.removed = 0
}
for (const line of out.split("\n")) {
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
if (hunk) {
flush()
state.next = Number(hunk[1]) - 1
continue
}
if (line.startsWith("+++") || line.startsWith("---")) continue
if (line.startsWith("+")) {
if (line.slice(1).trim()) lines.add(state.next)
state.added++
state.next++
continue
}
if (line.startsWith("-")) {
state.removed++
continue
}
if (line.startsWith(" ")) state.next++
}
flush()
return { lines, deleted: state.deleted }
}
export async function changed(base: Text, head: Text, opts?: { ignoreWhitespace?: boolean }): Promise<Diff> {
const dir = await mkdtemp(path.join(tmpdir(), "kilo-markers-"))
const left = path.join(dir, "upstream")
const right = path.join(dir, "current")
try {
await Bun.write(left, join({ ...base, eol: "\n" }))
await Bun.write(right, join({ ...head, eol: "\n" }))
const result = opts?.ignoreWhitespace
? await $`git diff --no-index --no-ext-diff -w --unified=0 -- ${left} ${right}`.quiet().nothrow()
: await $`git diff --no-index --no-ext-diff --unified=0 -- ${left} ${right}`.quiet().nothrow()
if (result.exitCode === 0) return { lines: new Set(), deleted: 0 }
if (result.exitCode === 1) return patch(result.stdout.toString())
throw new Error(result.stderr.toString())
} finally {
await rm(dir, { recursive: true, force: true })
}
}
/**
* Pure in-process line-diff used by bulk classifiers. Returns the number of
* non-matching lines between two texts using a multiset approach (moving a line
* around doesn't count as drift). Whitespace can optionally be ignored.
*
* Unlike `changed()`, this spawns no subprocesses so it is safe to run
* concurrently without risking pipe-buffer deadlocks on large inputs.
*/
export function approxDiff(base: string, head: string, opts?: { ignoreWhitespace?: boolean }): number {
if (base === head) return 0
const norm = opts?.ignoreWhitespace ? (line: string) => line.replace(/\s+/g, " ").trim() : (line: string) => line
const counts = new Map<string, number>()
for (const line of base.split(/\r?\n/)) {
const key = norm(line)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
for (const line of head.split(/\r?\n/)) {
const key = norm(line)
counts.set(key, (counts.get(key) ?? 0) - 1)
}
let total = 0
for (const v of counts.values()) total += Math.abs(v)
return total
}