check-opencode-annotations.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. import { describe, expect, test } from "bun:test"
  2. import path from "node:path"
  3. const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"])
  4. function isExempt(file: string) {
  5. const norm = file.replaceAll("\\", "/").toLowerCase()
  6. return norm.split("/").some((part) => part.includes("kilocode"))
  7. }
  8. function isSource(file: string) {
  9. return SOURCE_EXTS.has(path.extname(file))
  10. }
  11. const MARKER_PREFIX = /(?:\/\/|\{?\s*\/\*)\s*kilocode_change\b/
  12. function hasMarker(line: string) {
  13. return MARKER_PREFIX.test(line)
  14. }
  15. function coveredLines(text: string): Set<number> {
  16. const lines = text.split(/\r?\n/)
  17. const covered = new Set<number>()
  18. const first = lines.find((x) => x.trim() !== "")
  19. if (first?.match(/(?:\/\/|\{?\s*\/\*)\s*kilocode_change\s*-\s*new\s*file\b/)) {
  20. for (let i = 1; i <= lines.length; i++) covered.add(i)
  21. return covered
  22. }
  23. let block = false
  24. for (let i = 0; i < lines.length; i++) {
  25. const n = i + 1
  26. const line = lines[i] ?? ""
  27. if (line.match(/(?:\/\/|\{?\s*\/\*)\s*kilocode_change\s+start\b/)) {
  28. block = true
  29. covered.add(n)
  30. continue
  31. }
  32. if (line.match(/(?:\/\/|\{?\s*\/\*)\s*kilocode_change\s+end\b/)) {
  33. covered.add(n)
  34. block = false
  35. continue
  36. }
  37. if (block) {
  38. covered.add(n)
  39. continue
  40. }
  41. if (hasMarker(line)) covered.add(n)
  42. }
  43. return covered
  44. }
  45. function checkLine(line: string, covered: Set<number>, n: number): boolean {
  46. const trim = line.trim()
  47. if (!trim) return true
  48. if (hasMarker(trim)) return true
  49. return covered.has(n)
  50. }
  51. // ─── hasMarker tests ──────────────────────────────────────────────────────────
  52. describe("hasMarker", () => {
  53. const cases: Array<[string, boolean]> = [
  54. // JS-style inline
  55. ["// kilocode_change", true],
  56. [" // kilocode_change", true],
  57. ["const x = 1 // kilocode_change", true],
  58. ["// kilocode_change start", true],
  59. ["// kilocode_change end", true],
  60. ["// kilocode_change - new file", true],
  61. ["// kilocode_change", true],
  62. ["// kilocode_change ", true],
  63. // JSX-style inline
  64. ["{/* kilocode_change */}", true],
  65. [" {/* kilocode_change */}", true],
  66. ["{/* kilocode_change start */}", true],
  67. ["{/* kilocode_change end */}", true],
  68. ["{/* kilocode_change - new file */}", true],
  69. ["{/* kilocode_change - KiloNews added */}", true],
  70. ["{/* kilocode_change */}", true],
  71. ["{/* kilocode_change */}", true],
  72. // bare /* */ style
  73. ["/* kilocode_change */", true],
  74. [" /* kilocode_change */", true],
  75. ["/* kilocode_change start */", true],
  76. ["/* kilocode_change end */", true],
  77. // Non-markers
  78. ["const x = 1", false],
  79. ["<text fg={color}>{label}</text>", false],
  80. ["// some other comment", false],
  81. ["{/* just a comment */}", false],
  82. ["/* something else */", false],
  83. // typo variants — should NOT match (missing word boundary)
  84. ["// kilocode_changes", false],
  85. ["// kilocode_changelog", false],
  86. ["/* kilocode_change_log */", false],
  87. ["{/* kilocode_changes */}", false],
  88. ["// kilocode_changeable", false],
  89. ["", false],
  90. [" ", false],
  91. ]
  92. test.each(cases)("input %j → %j", (input, expected) => {
  93. expect(hasMarker(input)).toBe(expected)
  94. })
  95. })
  96. // ─── isExempt tests ───────────────────────────────────────────────────────────
  97. describe("isExempt", () => {
  98. const cases: Array<[string, boolean]> = [
  99. // exempt — "kilocode" in path
  100. ["packages/opencode/src/kilocode/foo.ts", true],
  101. ["packages/opencode/test/kilocode/bar.test.ts", true],
  102. ["packages/opencode/src/some/kilocode/deep/path.ts", true],
  103. ["packages/opencode/src/kilocode/deep/nested/file.tsx", true],
  104. // exempt — "kilocode" in filename
  105. ["packages/opencode/src/foo/kilocode.ts", true],
  106. ["packages/opencode/src/bar/kilocode.test.ts", true],
  107. ["packages/opencode/src/file.kilocode.ts", true],
  108. // exempt — case-insensitive
  109. ["packages/opencode/src/KiloCode/foo.ts", true],
  110. ["packages/opencode/src/KILOCODE/bar.ts", true],
  111. // NOT exempt
  112. ["packages/opencode/src/index.ts", false],
  113. ["packages/opencode/src/cli/cmd/tui/routes/home.tsx", false],
  114. ["packages/opencode/src/cli/cmd/tui/routes/session/index.tsx", false],
  115. ["packages/opencode/src/tool/registry.ts", false],
  116. ["packages/opencode/src/config/config.ts", false],
  117. ["packages/opencode/src/indexing/search-service.ts", false],
  118. // kilocode_change is not the same as kilocode
  119. ["packages/opencode/src/check-opencode-annotations.ts", false],
  120. ]
  121. test.each(cases)("%j → exempt=%j", (file, expected) => {
  122. expect(isExempt(file)).toBe(expected)
  123. })
  124. })
  125. // ─── isSource tests ───────────────────────────────────────────────────────────
  126. describe("isSource", () => {
  127. const cases: Array<[string, boolean]> = [
  128. ["foo.ts", true],
  129. ["foo.tsx", true],
  130. ["foo/bar.tsx", true],
  131. ["foo.js", true],
  132. ["foo.jsx", true],
  133. [".json", false],
  134. [".md", false],
  135. [".txt", false],
  136. ["Makefile", false],
  137. ["foo.go", false],
  138. ["foo.rs", false],
  139. ]
  140. test.each(cases)("%j → isSource=%j", (file, expected) => {
  141. expect(isSource(file)).toBe(expected)
  142. })
  143. })
  144. // ─── coveredLines tests ───────────────────────────────────────────────────────
  145. describe("coveredLines", () => {
  146. test("empty file", () => {
  147. const covered = coveredLines("")
  148. expect(covered.size).toBe(0)
  149. })
  150. test("file with only whitespace", () => {
  151. const covered = coveredLines(" \n\n \n")
  152. expect(covered.size).toBe(0)
  153. })
  154. test("whole-file JS annotation", () => {
  155. const covered = coveredLines("// kilocode_change - new file\nexport const x = 1\nexport const y = 2")
  156. expect(covered).toEqual(new Set([1, 2, 3]))
  157. })
  158. test("whole-file JSX annotation", () => {
  159. const covered = coveredLines("{/* kilocode_change - new file */}\nexport const x = 1\nexport const y = 2")
  160. expect(covered).toEqual(new Set([1, 2, 3]))
  161. })
  162. test("JS block markers", () => {
  163. const text = [
  164. "const a = 1",
  165. "// kilocode_change start",
  166. "const b = 2",
  167. "const c = 3",
  168. "// kilocode_change end",
  169. "const d = 4",
  170. ].join("\n")
  171. const covered = coveredLines(text)
  172. expect(covered).toEqual(new Set([2, 3, 4, 5])) // block markers + content
  173. })
  174. test("JSX block markers", () => {
  175. const text = [
  176. "const a = 1",
  177. "{/* kilocode_change start */}",
  178. "const b = 2",
  179. "const c = 3",
  180. "{/* kilocode_change end */}",
  181. "const d = 4",
  182. ].join("\n")
  183. const covered = coveredLines(text)
  184. expect(covered).toEqual(new Set([2, 3, 4, 5]))
  185. })
  186. test("mixed JS and JSX block markers (nested)", () => {
  187. const text = [
  188. "// kilocode_change start",
  189. "{/* kilocode_change start */}",
  190. "const b = 2",
  191. "{/* kilocode_change end */}",
  192. "// kilocode_change end",
  193. ].join("\n")
  194. const covered = coveredLines(text)
  195. expect(covered).toEqual(new Set([1, 2, 3, 4, 5]))
  196. })
  197. test("bare /* */ block markers", () => {
  198. const text = ["/* kilocode_change start */", "const b = 2", "/* kilocode_change end */"].join("\n")
  199. const covered = coveredLines(text)
  200. expect(covered).toEqual(new Set([1, 2, 3]))
  201. })
  202. test("inline JS marker covers only that line", () => {
  203. const text = ["const a = 1", "const b = 2 // kilocode_change", "const c = 3"].join("\n")
  204. const covered = coveredLines(text)
  205. expect(covered).toEqual(new Set([2]))
  206. })
  207. test("inline JSX marker covers only that line", () => {
  208. const text = ["const a = 1", "{/* kilocode_change */}", "const c = 3"].join("\n")
  209. const covered = coveredLines(text)
  210. expect(covered).toEqual(new Set([2]))
  211. })
  212. test("inline JS marker with code on same line", () => {
  213. const text = "const url = Flag.KILO_MODELS_URL || 'https://models.dev' // kilocode_change\n"
  214. const covered = coveredLines(text)
  215. expect(covered).toEqual(new Set([1]))
  216. })
  217. test("JSX block marker with descriptive suffix", () => {
  218. const text = [
  219. "{/* kilocode_change start - Kilo-specific error display */}",
  220. "<ErrorDisplay />",
  221. "{/* kilocode_change end */}",
  222. ].join("\n")
  223. const covered = coveredLines(text)
  224. expect(covered).toEqual(new Set([1, 2, 3]))
  225. })
  226. test("multiple independent blocks", () => {
  227. const text = [
  228. "// kilocode_change start",
  229. "const a = 1",
  230. "// kilocode_change end",
  231. "const b = 2",
  232. "{/* kilocode_change start */}",
  233. "const c = 3",
  234. "{/* kilocode_change end */}",
  235. "const d = 4",
  236. ].join("\n")
  237. const covered = coveredLines(text)
  238. expect(covered).toEqual(new Set([1, 2, 3, 5, 6, 7]))
  239. })
  240. test("marker line with extra text after marker is still covered", () => {
  241. const text = [
  242. "const a = 1",
  243. "// kilocode_change start - this is kilo specific",
  244. "const b = 2",
  245. "// kilocode_change end",
  246. ].join("\n")
  247. const covered = coveredLines(text)
  248. expect(covered).toEqual(new Set([2, 3, 4]))
  249. })
  250. test("nested block — inner block ends, outer continues", () => {
  251. const text = [
  252. "// kilocode_change start",
  253. "{/* kilocode_change start */}",
  254. "const b = 2",
  255. "{/* kilocode_change end */}",
  256. "const c = 3",
  257. "// kilocode_change end",
  258. ].join("\n")
  259. const covered = coveredLines(text)
  260. // Line 1: start, block=true
  261. // Line 2: inner start, block=true (covered by block)
  262. // Line 3: covered by block
  263. // Line 4: inner end, block=false, covered by end marker
  264. // Line 5: NOT covered (block is false, no inline marker)
  265. // Line 6: outer end, block already false, covered by end marker
  266. expect(covered).toEqual(new Set([1, 2, 3, 4, 6]))
  267. })
  268. test("whitespace before marker is handled", () => {
  269. const text = [" {/* kilocode_change start */}", " const b = 2", " {/* kilocode_change end */}"].join("\n")
  270. const covered = coveredLines(text)
  271. expect(covered).toEqual(new Set([1, 2, 3]))
  272. })
  273. })
  274. // ─── checkLine integration tests ──────────────────────────────────────────────
  275. // Simulates what the main loop does for each added line
  276. describe("checkLine (main loop simulation)", () => {
  277. function check(text: string, addedLines: number[]): string[] {
  278. const covered = coveredLines(text)
  279. const lines = text.split(/\r?\n/)
  280. const violations: string[] = []
  281. for (const n of addedLines) {
  282. const line = lines[n - 1] ?? ""
  283. const trim = line.trim()
  284. if (!trim) continue
  285. if (hasMarker(trim)) continue
  286. if (!covered.has(n)) violations.push(`line ${n}: ${trim}`)
  287. }
  288. return violations
  289. }
  290. test("covered line reports no violation", () => {
  291. const text = ["// kilocode_change start", "const kilo = 1", "// kilocode_change end"].join("\n")
  292. expect(check(text, [2])).toEqual([])
  293. })
  294. test("uncovered line reports violation", () => {
  295. const text = ["const uncovered = 1", "const also_uncovered = 2"].join("\n")
  296. expect(check(text, [1, 2])).toEqual(["line 1: const uncovered = 1", "line 2: const also_uncovered = 2"])
  297. })
  298. test("empty lines are skipped", () => {
  299. const text = ["const x = 1", "", " ", "", "const y = 2"].join("\n")
  300. expect(check(text, [1, 2, 3, 4, 5])).toEqual(["line 1: const x = 1", "line 5: const y = 2"])
  301. })
  302. test("marker lines are skipped even if uncovered", () => {
  303. // This shouldn't normally happen, but the loop should skip it
  304. const text = ["{/* kilocode_change */}", "{/* kilocode_change start */}"].join("\n")
  305. expect(check(text, [1, 2])).toEqual([])
  306. })
  307. test("real-world TSX home.tsx pattern", () => {
  308. const text = [
  309. '<box width="100%" maxWidth={75}>',
  310. " {/* kilocode_change start */}",
  311. " <Show when={indexingOn()}>",
  312. " <text fg={indexingColor()}>{indexingLabel()}</text>",
  313. " </Show>",
  314. " {/* kilocode_change end */}",
  315. "</box>",
  316. ].join("\n")
  317. // Only the first and last lines (opening/closing box) should be uncovered
  318. expect(check(text, [1, 7])).toEqual([`line 1: <box width="100%" maxWidth={75}>`, `line 7: </box>`])
  319. // Middle lines are covered
  320. expect(check(text, [2, 3, 4, 5, 6])).toEqual([])
  321. })
  322. test("real-world TSX session index.tsx pattern", () => {
  323. const text = [
  324. "const foo = 1",
  325. "{/* kilocode_change start */}",
  326. '<Match when={props.part.tool === "semantic_search"}>',
  327. "<SemanticSearch {...toolprops} />",
  328. "</Match>",
  329. "{/* kilocode_change end */}",
  330. "const bar = 2",
  331. ].join("\n")
  332. // Lines 1 and 7 are uncovered (not in any block)
  333. expect(check(text, [1, 7])).toEqual(["line 1: const foo = 1", "line 7: const bar = 2"])
  334. // Lines 2-6 are covered
  335. expect(check(text, [2, 3, 4, 5, 6])).toEqual([])
  336. })
  337. test("real-world TSX sidebar.tsx pattern", () => {
  338. const text = [
  339. "<box>",
  340. " {/* kilocode_change start */}",
  341. " <SessionTree />",
  342. " {/* kilocode_change end */}",
  343. "</box>",
  344. " {/* kilocode_change start */}",
  345. " <div>other content</div>",
  346. " {/* kilocode_change end */}",
  347. ].join("\n")
  348. expect(check(text, [1, 5])).toEqual(["line 1: <box>", "line 5: </box>"])
  349. expect(check(text, [2, 3, 4, 6, 7, 8])).toEqual([])
  350. })
  351. test("real-world TSX permission.tsx inline pattern", () => {
  352. const text = [
  353. "{/* kilocode_change */}",
  354. "<PermissionDeniedCard />",
  355. "{/* kilocode_change */}",
  356. "<AnotherKiloComponent />",
  357. ].join("\n")
  358. expect(check(text, [2, 4])).toEqual(["line 2: <PermissionDeniedCard />", "line 4: <AnotherKiloComponent />"])
  359. expect(check(text, [1, 3])).toEqual([])
  360. })
  361. test("JS-style session/index.tsx pattern (from existing codebase)", () => {
  362. const text = ["const foo = 1", "<Toast />", "{/* kilocode_change */}", "<Footer />", "</box>"].join("\n")
  363. // Line 2 (<Toast />) is NOT covered — it's between <Toast /> and the marker
  364. expect(check(text, [2, 4])).toEqual(["line 2: <Toast />", "line 4: <Footer />"])
  365. expect(check(text, [3])).toEqual([])
  366. })
  367. test("whole-file annotated file — no violations even for unmarked lines", () => {
  368. const text = [
  369. "// kilocode_change - new file",
  370. "export const kiloFeature = true",
  371. "export const alsoKilo = 123",
  372. "export const notMarked = 'oops'",
  373. ].join("\n")
  374. expect(check(text, [2, 3, 4])).toEqual([])
  375. })
  376. })
  377. // ─── Regex edge cases ─────────────────────────────────────────────────────────
  378. describe("MARKER_PREFIX regex edge cases", () => {
  379. test("handles { followed immediately by /*", () => {
  380. expect(hasMarker("{/* kilocode_change */}")).toBe(true)
  381. })
  382. test("handles { followed by whitespace then /*", () => {
  383. expect(hasMarker("{ /* kilocode_change */}")).toBe(true)
  384. })
  385. test("handles just /* with no brace", () => {
  386. expect(hasMarker("/* kilocode_change */")).toBe(true)
  387. })
  388. test("handles // with no spaces", () => {
  389. expect(hasMarker("//kilocode_change")).toBe(true)
  390. })
  391. test("handles // with lots of spaces", () => {
  392. expect(hasMarker("// kilocode_change")).toBe(true)
  393. })
  394. test("does not match {/* without kilocode_change", () => {
  395. expect(hasMarker("{/* some other comment */}")).toBe(false)
  396. })
  397. test("does not match /* without kilocode_change", () => {
  398. expect(hasMarker("/* just a comment */")).toBe(false)
  399. })
  400. test("does not match kilocode_changes (word boundary)", () => {
  401. expect(hasMarker("// kilocode_changes")).toBe(false)
  402. expect(hasMarker("// kilocode_changelog")).toBe(false)
  403. expect(hasMarker("{/* kilocode_changes */}")).toBe(false)
  404. expect(hasMarker("// kilocode_changeable")).toBe(false)
  405. })
  406. })
  407. // ─── isExempt — Windows paths ─────────────────────────────────────────────────
  408. describe("isExempt — Windows backslash paths", () => {
  409. test("Windows paths with backslashes", () => {
  410. expect(isExempt("packages\\opencode\\src\\kilocode\\foo.ts")).toBe(true)
  411. expect(isExempt("packages\\opencode\\test\\kilocode\\bar.test.ts")).toBe(true)
  412. expect(isExempt("packages\\opencode\\src\\index.ts")).toBe(false)
  413. })
  414. })
  415. // ─── coveredLines — additional patterns ───────────────────────────────────────
  416. describe("coveredLines — additional patterns", () => {
  417. test("block with descriptive suffix is still recognized", () => {
  418. const text = [
  419. "{/* kilocode_change start - Kilo-specific indexing display */}",
  420. "<IndexingStatus />",
  421. "{/* kilocode_change end */}",
  422. ].join("\n")
  423. const covered = coveredLines(text)
  424. expect(covered).toEqual(new Set([1, 2, 3]))
  425. })
  426. test("empty file content", () => {
  427. const covered = coveredLines("// kilocode_change start\n \n// kilocode_change end")
  428. expect(covered).toEqual(new Set([1, 2, 3]))
  429. })
  430. test("multiple separate JS inline markers", () => {
  431. const text = [
  432. "const a = 1 // kilocode_change",
  433. "const b = 2",
  434. "const c = 3 // kilocode_change",
  435. "const d = 4",
  436. ].join("\n")
  437. const covered = coveredLines(text)
  438. expect(covered).toEqual(new Set([1, 3]))
  439. })
  440. test("consecutive block markers (no content)", () => {
  441. const text = ["// kilocode_change start", "// kilocode_change end"].join("\n")
  442. const covered = coveredLines(text)
  443. expect(covered).toEqual(new Set([1, 2]))
  444. })
  445. test("block immediately followed by another start", () => {
  446. const text = [
  447. "// kilocode_change start",
  448. "const a = 1",
  449. "// kilocode_change end",
  450. "{/* kilocode_change start */}",
  451. "const b = 2",
  452. "{/* kilocode_change end */}",
  453. ].join("\n")
  454. const covered = coveredLines(text)
  455. expect(covered).toEqual(new Set([1, 2, 3, 4, 5, 6]))
  456. })
  457. test("trailing empty line after block end is not covered", () => {
  458. const text = "// kilocode_change start\nconst a = 1\n// kilocode_change end\n\n"
  459. const covered = coveredLines(text)
  460. // Block ends at line 3; trailing empty line 4 is outside the block
  461. expect(covered).toEqual(new Set([1, 2, 3]))
  462. })
  463. })
  464. // ─── checkLine — additional patterns ─────────────────────────────────────────
  465. describe("checkLine — additional patterns", () => {
  466. function check(text: string, addedLines: number[]): string[] {
  467. const covered = coveredLines(text)
  468. const lines = text.split(/\r?\n/)
  469. const violations: string[] = []
  470. for (const n of addedLines) {
  471. const line = lines[n - 1] ?? ""
  472. const trim = line.trim()
  473. if (!trim) continue
  474. if (hasMarker(trim)) continue
  475. if (!covered.has(n)) violations.push(`line ${n}: ${trim}`)
  476. }
  477. return violations
  478. }
  479. test("real-world dialog-status.tsx pattern — multiple inline blocks", () => {
  480. // Based on actual file: packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
  481. const text = [
  482. "{/* kilocode_change start */}",
  483. "<KiloDialog>",
  484. "{/* kilocode_change end */}",
  485. "const normal = 1",
  486. " {/* kilocode_change start */}",
  487. " <KiloDialog />",
  488. " {/* kilocode_change end */}",
  489. ].join("\n")
  490. // Lines 4 is uncovered
  491. expect(check(text, [4])).toEqual(["line 4: const normal = 1"])
  492. // Lines 1-3 and 5-7 are covered
  493. expect(check(text, [1, 2, 3, 5, 6, 7])).toEqual([])
  494. })
  495. test("real-world TUI routes — line between marker and code should be uncovered", () => {
  496. // A common mistake: putting code on a different line from the marker
  497. const text = ["{/* kilocode_change start */}", "", "<KiloIndexing />", "", "{/* kilocode_change end */}"].join("\n")
  498. // Empty lines (2, 4) are skipped
  499. expect(check(text, [3])).toEqual([])
  500. // All non-empty lines (1, 3, 5) are covered
  501. expect(check(text, [1, 3, 5])).toEqual([])
  502. })
  503. test("end marker on same line as content is covered", () => {
  504. const text = "const a = 1\n{/* kilocode_change end */} // block already closed, still covered\n"
  505. const covered = coveredLines(text)
  506. expect(covered).toEqual(new Set([2]))
  507. })
  508. test("end marker closes block correctly", () => {
  509. const text = [
  510. "// kilocode_change start",
  511. "const a = 1",
  512. "// kilocode_change end",
  513. "const b = 2", // uncovered
  514. ].join("\n")
  515. expect(check(text, [1, 2, 3, 4])).toEqual(["line 4: const b = 2"])
  516. })
  517. })