check-opencode-annotations.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. #!/usr/bin/env bun
  2. /**
  3. * Verifies that every Kilo-specific change in shared packages/opencode/ files
  4. * is annotated with a kilocode_change marker.
  5. *
  6. * Usage:
  7. * bun run script/check-opencode-annotations.ts # diff against origin/main
  8. * bun run script/check-opencode-annotations.ts --base <ref> # diff against <ref>
  9. *
  10. * A line is "covered" if it:
  11. * - contains a kilocode_change marker comment (inline annotation)
  12. * - falls inside a kilocode_change start/end block (block annotation)
  13. * - is in a file whose first non-empty line is (whole-file annotation)
  14. * // kilocode_change - new file
  15. * - is empty / whitespace-only (skipped)
  16. * - is itself a marker line (auto-covered)
  17. *
  18. * Both JS (//) and JSX ({/ * ... * /}) comment styles are recognized.
  19. *
  20. * Exempt paths (no markers needed — entirely Kilo-specific):
  21. * - packages/opencode/src/kilocode/**
  22. * - packages/opencode/test/kilocode/**
  23. * - Any path containing "kilocode" in directory or filename
  24. * - Any path with a directory starting with "kilo-" (e.g. kilo-sessions/)
  25. */
  26. import { spawnSync } from "node:child_process"
  27. import { readFileSync } from "node:fs"
  28. import path from "node:path"
  29. const ROOT = path.resolve(import.meta.dir, "..")
  30. const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"])
  31. const args = process.argv.slice(2)
  32. const baseIdx = args.indexOf("--base")
  33. const base = baseIdx !== -1 ? args[baseIdx + 1] : "origin/main"
  34. function run(cmd: string, args: string[]) {
  35. const result = spawnSync(cmd, args, { cwd: ROOT, encoding: "utf8" })
  36. if (result.status !== 0) {
  37. const msg = result.stderr?.trim() || result.stdout?.trim() || "unknown error"
  38. console.error(`Command failed: ${cmd} ${args.join(" ")}\n${msg}`)
  39. process.exit(1)
  40. }
  41. return result.stdout?.trim() ?? ""
  42. }
  43. function changedFiles() {
  44. const out = run("git", ["diff", "--name-only", "--diff-filter=AMRT", `${base}...HEAD`, "--", "packages/opencode"])
  45. return out ? out.split("\n").filter(Boolean) : []
  46. }
  47. function isUpstreamMerge() {
  48. const out = run("git", ["log", "--format=%P%x09%s", `${base}..HEAD`])
  49. return out.split("\n").some((line) => {
  50. const [parents = "", subject = ""] = line.split("\t")
  51. if (!parents.includes(" ")) return false
  52. const s = subject.toLowerCase()
  53. return s.startsWith("merge: upstream ") || s.startsWith("resolve merge conflict")
  54. })
  55. }
  56. function isExempt(file: string) {
  57. const norm = file.replaceAll("\\", "/").toLowerCase()
  58. return norm.split("/").some((part) => part.includes("kilocode") || part.startsWith("kilo-"))
  59. }
  60. function isSource(file: string) {
  61. return SOURCE_EXTS.has(path.extname(file))
  62. }
  63. function addedLines(file: string): Set<number> {
  64. const diff = run("git", ["diff", "--unified=0", "--diff-filter=AMRT", `${base}...HEAD`, "--", file])
  65. const out = new Set<number>()
  66. for (const line of diff.split("\n")) {
  67. const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
  68. if (!m) continue
  69. const start = Number(m[1])
  70. const count = m[2] !== undefined ? Number(m[2]) : 1
  71. for (let i = 0; i < count; i++) out.add(start + i)
  72. }
  73. return out
  74. }
  75. // Matches the start of a kilocode_change marker in both JS (//) and JSX ({/* */}) comments
  76. const MARKER_PREFIX = /(?:\/\/|\{?\s*\/\*)\s*kilocode_change\b/
  77. function hasMarker(line: string) {
  78. return MARKER_PREFIX.test(line)
  79. }
  80. function coveredLines(text: string): { lines: string[]; covered: Set<number> } {
  81. const lines = text.split(/\r?\n/)
  82. const covered = new Set<number>()
  83. // Whole-file annotation: first non-empty line is "// kilocode_change - new file"
  84. const first = lines.find((x) => x.trim() !== "")
  85. if (first?.match(/(?:\/\/|\{?\s*\/\*)\s*kilocode_change\s*-\s*new\s*file\b/)) {
  86. for (let i = 1; i <= lines.length; i++) covered.add(i)
  87. return { lines, covered }
  88. }
  89. let block = false
  90. for (let i = 0; i < lines.length; i++) {
  91. const n = i + 1
  92. const line = lines[i] ?? ""
  93. if (line.match(/(?:\/\/|\{?\s*\/\*)\s*kilocode_change\s+start\b/)) {
  94. block = true
  95. covered.add(n)
  96. continue
  97. }
  98. if (line.match(/(?:\/\/|\{?\s*\/\*)\s*kilocode_change\s+end\b/)) {
  99. covered.add(n)
  100. block = false
  101. continue
  102. }
  103. if (block) {
  104. covered.add(n)
  105. continue
  106. }
  107. if (hasMarker(line)) covered.add(n)
  108. }
  109. return { lines, covered }
  110. }
  111. // --- main ---
  112. if (isUpstreamMerge()) {
  113. console.log("Skipping opencode annotation check — upstream opencode merge detected.")
  114. process.exit(0)
  115. }
  116. const files = changedFiles().filter((f) => !isExempt(f) && isSource(f))
  117. if (files.length === 0) {
  118. console.log("No shared opencode source files changed — nothing to check.")
  119. process.exit(0)
  120. }
  121. const violations: string[] = []
  122. for (const file of files) {
  123. const nums = addedLines(file)
  124. if (nums.size === 0) continue
  125. const abs = path.join(ROOT, file)
  126. const text = readFileSync(abs, "utf8")
  127. const { lines, covered } = coveredLines(text)
  128. for (const n of nums) {
  129. const line = lines[n - 1] ?? ""
  130. const trim = line.trim()
  131. if (!trim) continue
  132. if (hasMarker(trim)) continue
  133. if (!covered.has(n)) violations.push(` ${file}:${n}: ${trim}`)
  134. }
  135. }
  136. if (violations.length === 0) {
  137. console.log("All shared opencode changes are annotated with kilocode_change markers.")
  138. process.exit(0)
  139. }
  140. console.error(
  141. [
  142. "Unannotated Kilo changes found in shared opencode files:",
  143. "",
  144. ...violations,
  145. "",
  146. "Every Kilo-specific change in packages/opencode/ must be annotated.",
  147. "",
  148. "Inline (single line):",
  149. " const url = Flag.KILO_MODELS_URL || 'https://models.dev' // kilocode_change",
  150. "",
  151. "Block (multiple lines):",
  152. " // kilocode_change start",
  153. " ...",
  154. " // kilocode_change end",
  155. "",
  156. "JSX/TSX (inside JSX templates):",
  157. " {/* kilocode_change */}",
  158. " {/* kilocode_change start */}",
  159. " ...",
  160. " {/* kilocode_change end */}",
  161. "",
  162. "New file:",
  163. " // kilocode_change - new file",
  164. "",
  165. "Exempt paths (no markers needed):",
  166. " - packages/opencode/src/kilocode/**",
  167. " - packages/opencode/test/kilocode/**",
  168. " - Any path containing 'kilocode' in the directory or filename",
  169. " - Any directory starting with 'kilo-' (e.g. kilo-sessions/)",
  170. "",
  171. "See AGENTS.md for details.",
  172. ].join("\n"),
  173. )
  174. process.exit(1)