diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc2e6e3c64..c7bd0fe430 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -280,7 +280,6 @@ jobs: marker-guard: name: Marker Guard runs-on: ubuntu-latest - if: github.event_name == 'pull_request' timeout-minutes: 5 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -300,9 +299,20 @@ jobs: run: bun install working-directory: script/upstream + - name: Run marker parser tests + run: bun test + working-directory: script/upstream + - name: Check for missing altimate_change markers run: | - if [[ "${{ github.head_ref }}" == merge-upstream-* ]] || [[ "${{ github.head_ref }}" == upstream/merge-* ]]; then + if [[ "${{ github.event_name }}" == "push" ]]; then + if [[ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ]]; then + echo "Initial push (zero-SHA) — skipping marker check" + exit 0 + fi + echo "Push to main — running marker check against pre-push state" + bun run script/upstream/analyze.ts --markers --base "${{ github.event.before }}" --strict + elif [[ "${{ github.head_ref }}" == merge-upstream-* ]] || [[ "${{ github.head_ref }}" == upstream/merge-* ]]; then echo "Upstream merge PR detected — running marker check in non-strict mode" bun run script/upstream/analyze.ts --markers --base ${{ github.event.pull_request.base.ref }} else diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 54a0e47aa2..865553899f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -15,7 +15,9 @@ import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" import { useLocal } from "../context/local" +// altimate_change start — upgrade indicator import import { UpgradeIndicator } from "../component/upgrade-indicator" +// altimate_change end // TODO: what is the best way to do this? let once = false @@ -153,7 +155,9 @@ export function Home() { + {/* altimate_change start — upgrade indicator in home footer */} {Installation.VERSION}} /> + {/* altimate_change end */} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 98cb87ebbe..c7225214c0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -8,7 +8,9 @@ import { useRoute } from "../../context/route" // altimate_change start - yolo mode visual indicator import { Flag } from "@/flag/flag" // altimate_change end +// altimate_change start — upgrade indicator import import { UpgradeIndicator } from "../../component/upgrade-indicator" +// altimate_change end export function Footer() { const { theme } = useTheme() @@ -96,7 +98,9 @@ export function Footer() { /status + {/* altimate_change start — upgrade indicator in session footer */} + {/* altimate_change end */} ) diff --git a/script/upstream/analyze.test.ts b/script/upstream/analyze.test.ts new file mode 100644 index 0000000000..ae4ae72fce --- /dev/null +++ b/script/upstream/analyze.test.ts @@ -0,0 +1,290 @@ +import { describe, test, expect } from "bun:test" +import { parseDiffForMarkerWarnings } from "./analyze" + +// Helper to create a unified diff string from lines +function makeDiff(hunks: string): string { + return `diff --git a/file.ts b/file.ts +index abc1234..def5678 100644 +--- a/file.ts ++++ b/file.ts +${hunks}` +} + +describe("parseDiffForMarkerWarnings", () => { + test("returns no warnings for empty diff", () => { + expect(parseDiffForMarkerWarnings("file.ts", "")).toEqual([]) + expect(parseDiffForMarkerWarnings("file.ts", " \n ")).toEqual([]) + }) + + test("added code inside added markers — no warning", () => { + const diff = makeDiff( + `@@ -10,3 +10,5 @@ + const existing = true ++// altimate_change start — new feature ++const custom = true ++// altimate_change end + const more = true`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("added code without markers — warning", () => { + const diff = makeDiff( + `@@ -10,3 +10,4 @@ + const existing = true ++const unmarked = true + const more = true`, + ) + const warnings = parseDiffForMarkerWarnings("file.ts", diff) + expect(warnings).toHaveLength(1) + expect(warnings[0].file).toBe("file.ts") + expect(warnings[0].context).toContain("unmarked") + }) + + test("REGRESSION: added code inside existing (context) markers — no warning", () => { + // This is the exact bug that caused the upgrade indicator leak. + // Markers are context lines (already committed), new code is added inside. + const diff = makeDiff( + `@@ -10,4 +10,5 @@ + const existing = true + // altimate_change start — existing feature ++const newCodeInsideExistingBlock = true + // altimate_change end + const more = true`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("REGRESSION: context marker end followed by added code — warning", () => { + // New code added AFTER an existing marker block should be flagged. + const diff = makeDiff( + `@@ -10,4 +10,5 @@ + // altimate_change start — block A + const blockA = true + // altimate_change end ++const outsideBlock = true + const existing = true`, + ) + const warnings = parseDiffForMarkerWarnings("file.ts", diff) + expect(warnings).toHaveLength(1) + expect(warnings[0].context).toContain("outsideBlock") + }) + + test("REGRESSION: context marker start, added code, context marker end — no warning", () => { + // Entire marker block is pre-existing (context), only the inner code is new. + const diff = makeDiff( + `@@ -8,4 +8,5 @@ + // altimate_change start - yolo mode visual indicator + import { Flag } from "@/flag/flag" + // altimate_change end ++import { UpgradeIndicator } from "../../component/upgrade-indicator" + const next = true`, + ) + // The import line is skipped by the "import " heuristic, so no warning + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("REGRESSION: non-import code inside existing context markers — no warning", () => { + // Verifies the fix works independently of the import heuristic + const diff = makeDiff( + `@@ -10,4 +10,5 @@ + const existing = true + // altimate_change start — custom feature ++const customCode = doSomething() + // altimate_change end + const more = true`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("REGRESSION: JSX comment markers on context lines — no warning", () => { + // JSX uses {/* altimate_change start ... */} syntax + const diff = makeDiff( + `@@ -95,4 +95,5 @@ + {/* altimate_change start — upgrade indicator */} ++ + {/* altimate_change end */} + `, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("multiple hunks — marker state resets at hunk boundary", () => { + // Each hunk starts fresh, marker state should NOT carry across hunks + // since different parts of the file may have different marker context. + const diff = makeDiff( + `@@ -5,3 +5,4 @@ + // altimate_change start — block 1 ++const inBlock = true + // altimate_change end +@@ -50,3 +51,4 @@ + const existing = true ++const unmarkedInSecondHunk = true + const more = true`, + ) + const warnings = parseDiffForMarkerWarnings("file.ts", diff) + expect(warnings).toHaveLength(1) + expect(warnings[0].context).toContain("unmarkedInSecondHunk") + }) + + test("marker state from hunk 1 does not leak into hunk 2", () => { + // If hunk 1 ends inside a marker block (start without end in context), + // hunk 2 should NOT inherit that state. + const diff = makeDiff( + `@@ -5,3 +5,4 @@ + // altimate_change start — block 1 ++const inBlock = true + const moreInBlock = true +@@ -80,3 +81,4 @@ + const unrelated = true ++const shouldBeWarned = true + const end = true`, + ) + const warnings = parseDiffForMarkerWarnings("file.ts", diff) + expect(warnings).toHaveLength(1) + expect(warnings[0].context).toContain("shouldBeWarned") + }) + + test("import lines are skipped even without markers", () => { + const diff = makeDiff( + `@@ -1,3 +1,4 @@ + import { existing } from "./existing" ++import { NewThing } from "./new-thing" + const x = 1`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("export lines are skipped even without markers", () => { + const diff = makeDiff( + `@@ -1,3 +1,4 @@ + const x = 1 ++export { x } + const y = 2`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("comment-only lines are skipped (not TODOs)", () => { + const diff = makeDiff( + `@@ -1,3 +1,4 @@ + const x = 1 ++// this is a harmless comment + const y = 2`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("TODO comments are NOT skipped", () => { + const diff = makeDiff( + `@@ -1,3 +1,4 @@ + const x = 1 ++// TODO: implement custom feature + const y = 2`, + ) + const warnings = parseDiffForMarkerWarnings("file.ts", diff) + expect(warnings).toHaveLength(1) + }) + + test("empty added lines are skipped", () => { + const diff = makeDiff( + `@@ -1,3 +1,4 @@ + const x = 1 ++ + const y = 2`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("deleted lines don't affect marker state or line numbers", () => { + const diff = makeDiff( + `@@ -5,5 +5,5 @@ + // altimate_change start — feature +-const oldCode = true ++const newCode = true + // altimate_change end + const next = true`, + ) + expect(parseDiffForMarkerWarnings("file.ts", diff)).toEqual([]) + }) + + test("line number in warning matches diff hunk position", () => { + const diff = makeDiff( + `@@ -42,3 +42,4 @@ + const existing = true ++const unmarked = true + const more = true`, + ) + const warnings = parseDiffForMarkerWarnings("file.ts", diff) + expect(warnings).toHaveLength(1) + expect(warnings[0].line).toBe(43) + }) + + test("context truncated to 80 chars in warning", () => { + const longLine = "x".repeat(120) + const diff = makeDiff( + `@@ -1,3 +1,4 @@ + const a = 1 ++const ${longLine} = true + const b = 2`, + ) + const warnings = parseDiffForMarkerWarnings("file.ts", diff) + expect(warnings).toHaveLength(1) + expect(warnings[0].context.length).toBeLessThanOrEqual(80) + }) + + test("only first unmarked line is reported per file", () => { + const diff = makeDiff( + `@@ -1,3 +1,5 @@ + const a = 1 ++const first = true ++const second = true + const b = 2`, + ) + const warnings = parseDiffForMarkerWarnings("file.ts", diff) + expect(warnings).toHaveLength(1) + expect(warnings[0].context).toContain("first") + }) + + test("real-world scenario: upgrade indicator in footer.tsx", () => { + // Simulates the exact diff that leaked: UpgradeIndicator added to + // session footer without markers, adjacent to existing yolo marker block. + const diff = makeDiff( + `@@ -8,4 +8,6 @@ + // altimate_change start - yolo mode visual indicator + import { Flag } from "@/flag/flag" + // altimate_change end ++// altimate_change start — upgrade indicator import ++import { UpgradeIndicator } from "../../component/upgrade-indicator" ++// altimate_change end + +@@ -96,4 +98,6 @@ + ++ {/* altimate_change start — upgrade indicator in session footer */} ++ ++ {/* altimate_change end */} + `, + ) + expect(parseDiffForMarkerWarnings("footer.tsx", diff)).toEqual([]) + }) + + test("real-world scenario: unmarked upgrade indicator would be caught", () => { + // Same scenario but WITHOUT markers — should flag + const diff = makeDiff( + `@@ -8,4 +8,5 @@ + // altimate_change start - yolo mode visual indicator + import { Flag } from "@/flag/flag" + // altimate_change end ++import { UpgradeIndicator } from "../../component/upgrade-indicator" + +@@ -96,4 +97,5 @@ + ++ + `, + ) + const warnings = parseDiffForMarkerWarnings("footer.tsx", diff) + // import is skipped by heuristic, but is flagged + expect(warnings).toHaveLength(1) + expect(warnings[0].context).toContain("UpgradeIndicator") + }) +}) diff --git a/script/upstream/analyze.ts b/script/upstream/analyze.ts index f205a9989e..efbc435a38 100644 --- a/script/upstream/analyze.ts +++ b/script/upstream/analyze.ts @@ -606,22 +606,9 @@ function isUpstreamShared(file: string, config: MergeConfig): boolean { return upstreamFiles.has(file) } -function checkFileForMarkers(file: string, base?: string): MarkerWarning[] { - const { execSync } = require("child_process") - const root = repoRoot() +// altimate_change start — exported for unit testing +export function parseDiffForMarkerWarnings(file: string, diffOutput: string): MarkerWarning[] { const warnings: MarkerWarning[] = [] - - const diffCmd = base - ? `git diff -U5 ${base}...HEAD -- "${file}"` - : `git diff -U5 HEAD -- "${file}"` - - let diffOutput: string - try { - diffOutput = execSync(diffCmd, { cwd: root, encoding: "utf-8" }) - } catch { - return warnings - } - if (!diffOutput.trim()) return warnings const lines = diffOutput.split("\n") @@ -635,10 +622,27 @@ function checkFileForMarkers(file: string, base?: string): MarkerWarning[] { const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)/) if (hunkMatch) { currentLine = parseInt(hunkMatch[1]) - 1 + // Reset marker state at hunk boundaries — hunks are discontinuous + // regions of the file, so marker blocks from one region don't apply + // to another. + inMarkerBlock = false continue } - if (line.startsWith("+") && !line.startsWith("+++")) { + // altimate_change start — fix: track marker state from context lines, not just added lines + if (line.startsWith("-") && !line.startsWith("---")) { + // deleted line, don't increment + continue + } + + // Both added (+) and context ( ) lines appear in the final file, + // so both must update marker state. Without this, existing markers + // on context lines are invisible and new code next to them looks + // unmarked — causing false positives. + const isAdded = line.startsWith("+") && !line.startsWith("+++") + const isContext = !line.startsWith("+") && !line.startsWith("-") && !line.startsWith("\\") + + if (isAdded || isContext) { currentLine++ const content = line.slice(1).trim() @@ -646,6 +650,9 @@ function checkFileForMarkers(file: string, base?: string): MarkerWarning[] { if (content.includes("altimate_change end")) { inMarkerBlock = false; continue } if (content.includes("altimate_change")) continue + // Only flag added lines as violations — context lines are pre-existing + if (!isAdded) continue + if (!content) continue if (content.startsWith("//") && !content.includes("TODO")) continue if (content.startsWith("import ")) continue @@ -658,11 +665,8 @@ function checkFileForMarkers(file: string, base?: string): MarkerWarning[] { newCodeContext = content } } - } else if (line.startsWith("-")) { - // deleted line, don't increment - } else { - currentLine++ } + // altimate_change end } if (hasNewCode) { @@ -676,6 +680,25 @@ function checkFileForMarkers(file: string, base?: string): MarkerWarning[] { return warnings } +// altimate_change end + +function checkFileForMarkers(file: string, base?: string): MarkerWarning[] { + const { execSync } = require("child_process") + const root = repoRoot() + + const diffCmd = base + ? `git diff -U5 ${base}...HEAD -- "${file}"` + : `git diff -U5 HEAD -- "${file}"` + + let diffOutput: string + try { + diffOutput = execSync(diffCmd, { cwd: root, encoding: "utf-8" }) + } catch { + return [] + } + + return parseDiffForMarkerWarnings(file, diffOutput) +} function runMarkerCheck(config: MergeConfig, base?: string, strict?: boolean): number { const { execSync } = require("child_process") @@ -804,7 +827,11 @@ async function main(): Promise { } } -main().catch((e) => { - logger.error(`Analysis failed: ${e.message || e}`) - process.exit(2) -}) +// altimate_change start — guard CLI execution when imported as a module (e.g., by tests) +if (import.meta.main) { + main().catch((e) => { + logger.error(`Analysis failed: ${e.message || e}`) + process.exit(2) + }) +} +// altimate_change end