From bf761b6e81120ab83337bf1fe70c09c942ce82af Mon Sep 17 00:00:00 2001 From: bohdansolovie Date: Sun, 5 Jul 2026 10:19:59 +0200 Subject: [PATCH 1/3] feat(enrichment): add debug-leftover analyzer for console.log and debugger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2015 — flags plain debug leftovers in non-test source separately from secret-log. Co-authored-by: Cursor --- .env.example | 9 +- apps/gittensory-ui/src/lib/rees-analyzers.ts | 22 +++++ review-enrichment/analyzer-metadata.json | 26 ++++++ .../src/analyzers/debug-leftover.ts | 86 ++++++++++++++++++ review-enrichment/src/analyzers/registry.ts | 30 +++++++ review-enrichment/src/render.ts | 1 + review-enrichment/src/types.ts | 9 ++ .../test/analyzer-registry.test.ts | 1 + review-enrichment/test/debug-leftover.test.ts | 88 +++++++++++++++++++ 9 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 review-enrichment/src/analyzers/debug-leftover.ts create mode 100644 review-enrichment/test/debug-leftover.test.ts diff --git a/.env.example b/.env.example index 24836986fa..f45e296386 100644 --- a/.env.example +++ b/.env.example @@ -67,23 +67,24 @@ GITTENSORY_REVIEW_ENRICHMENT=false # provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig,nativeBuild # history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity,ciCheckSignals # undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio,migrationSafety -# looseRange,terminology,todoMarker,magicNumber,conflictMarker,commitLint +# looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover,commitLint # # Profile defaults: # fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol # redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild,testRatio,migrationSafety -# looseRange,terminology,todoMarker,magicNumber,conflictMarker +# looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover # balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency # actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature # iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink # approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene # pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber -# conflictMarker,commitLint +# conflictMarker,debugLeftover,commitLint # deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol # redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig # nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity # ciCheckSignals,undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio -# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,commitLint +# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover +# commitLint # END GENERATED REES ANALYZERS # Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep diff --git a/apps/gittensory-ui/src/lib/rees-analyzers.ts b/apps/gittensory-ui/src/lib/rees-analyzers.ts index e2f514346d..2f982b6675 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -886,6 +886,28 @@ export const REES_ANALYZERS = [ "Structural: an exactly-seven-character marker run at column 0. The ambiguous `=======` separator is not flagged in Markdown/AsciiDoc files, where it is a legitimate section rule.", }, }, + { + name: "debugLeftover", + title: "Debug leftovers", + category: "quality", + cost: "local", + defaultEnabled: true, + profiles: ["fast", "balanced", "deep"], + requires: ["files"], + limits: { + maxFindings: 25, + maxLineChars: 2000, + }, + docs: { + summary: + "Flags debugging leftovers a PR adds in non-test source — `debugger;`, bare console sinks, or `print()` calls.", + looksAt: "Added lines in changed non-test source files.", + reports: "File, line, and kind: debugger, console, or print.", + network: "Pure local analyzer. No external network call.", + notes: + "Distinct from the secrets-in-logs analyzer: this catches plain debug noise regardless of payload. String literals are stripped before matching.", + }, + }, { name: "commitLint", title: "Conventional-commit subjects", diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index df8b580a6c..d86d778f5d 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -999,6 +999,32 @@ "notes": "Structural: an exactly-seven-character marker run at column 0. The ambiguous `=======` separator is not flagged in Markdown/AsciiDoc files, where it is a legitimate section rule." } }, + { + "name": "debugLeftover", + "title": "Debug leftovers", + "category": "quality", + "cost": "local", + "defaultEnabled": true, + "profiles": [ + "fast", + "balanced", + "deep" + ], + "requires": [ + "files" + ], + "limits": { + "maxFindings": 25, + "maxLineChars": 2000 + }, + "docs": { + "summary": "Flags debugging leftovers a PR adds in non-test source — `debugger;`, bare console sinks, or `print()` calls.", + "looksAt": "Added lines in changed non-test source files.", + "reports": "File, line, and kind: debugger, console, or print.", + "network": "Pure local analyzer. No external network call.", + "notes": "Distinct from the secrets-in-logs analyzer: this catches plain debug noise regardless of payload. String literals are stripped before matching." + } + }, { "name": "commitLint", "title": "Conventional-commit subjects", diff --git a/review-enrichment/src/analyzers/debug-leftover.ts b/review-enrichment/src/analyzers/debug-leftover.ts new file mode 100644 index 0000000000..a0c634f726 --- /dev/null +++ b/review-enrichment/src/analyzers/debug-leftover.ts @@ -0,0 +1,86 @@ +// Debug-leftover analyzer (#2015). Flags debugging leftovers introduced in the diff — `debugger;` statements +// and bare `console.*` / `print()` calls added to non-test source files. Distinct from the secret-log analyzer +// (which only fires on sensitive-value sinks); this catches plain debug noise regardless of payload. Pure compute, +// no network. String-literal content is stripped before matching so a `"console.log('hi')"` inside a string is +// not flagged. Line-cited via hunk headers, mirroring the sibling local analyzers. +import type { DebugLeftoverFinding, EnrichRequest } from "../types.js"; +import { codeOnly } from "./secret-log.js"; +import { isTestPath } from "./test-ratio.js"; + +const MAX_FINDINGS = 25; +const MAX_LINE_CHARS = 2000; + +const DEBUGGER_RE = /\bdebugger\s*;/; +const CONSOLE_RE = /\bconsole\s*\.\s*(?:log|debug|info|warn|error|trace|dir|table)\s*\(/; +const PRINT_RE = /\bprint\s*\(/; + +/** Classify one added line for a debug leftover, or null. Pure. */ +export function detectDebugLeftover(line: string): DebugLeftoverFinding["kind"] | null { + const code = codeOnly(line); + if (DEBUGGER_RE.test(code)) return "debugger"; + if (CONSOLE_RE.test(code)) return "console"; + if (PRINT_RE.test(code)) return "print"; + return null; +} + +type ScanLimits = { + maxFindings?: number; + signal?: AbortSignal; +}; + +/** Scan one file patch's added lines for debug leftovers, line-cited via hunk headers. Pure. */ +export function scanPatchForDebugLeftover( + path: string, + patch: string, + limits: ScanLimits = {}, +): DebugLeftoverFinding[] { + const maxFindings = limits.maxFindings ?? MAX_FINDINGS; + if (maxFindings <= 0 || isTestPath(path)) return []; + const findings: DebugLeftoverFinding[] = []; + let newLine = 0; + let inHunk = false; + for (const line of patch.split("\n")) { + if (limits.signal?.aborted) throw new Error("analyzer_aborted"); + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (hunk) { + newLine = Number(hunk[1]); + inHunk = true; + continue; + } + if (!inHunk) continue; + if (line.startsWith("+")) { + const body = line.slice(1); + if (body.length <= MAX_LINE_CHARS) { + const kind = detectDebugLeftover(body); + if (kind) { + findings.push({ file: path, line: newLine, kind }); + if (findings.length >= maxFindings) return findings; + } + } + newLine++; + } else if (!line.startsWith("-") && !line.startsWith("\\")) { + newLine++; + } + } + return findings; +} + +/** Analyzer entrypoint: scan every changed non-test file's added lines for debug leftovers. */ +export async function scanDebugLeftover( + req: EnrichRequest, + signal?: AbortSignal, +): Promise { + const findings: DebugLeftoverFinding[] = []; + for (const file of req.files ?? []) { + if (signal?.aborted) throw new Error("analyzer_aborted"); + if (!file.patch) continue; + for (const finding of scanPatchForDebugLeftover(file.path, file.patch, { + maxFindings: MAX_FINDINGS - findings.length, + signal, + })) { + findings.push(finding); + if (findings.length >= MAX_FINDINGS) return findings; + } + } + return findings; +} diff --git a/review-enrichment/src/analyzers/registry.ts b/review-enrichment/src/analyzers/registry.ts index 76da147c3f..97a513a55c 100644 --- a/review-enrichment/src/analyzers/registry.ts +++ b/review-enrichment/src/analyzers/registry.ts @@ -29,6 +29,7 @@ import { scanMigrationSafety } from "./migration-safety.js"; import { scanLooseRanges } from "./loose-range.js"; import { scanMagicNumbers } from "./magic-number.js"; import { scanConflictMarkers } from "./conflict-marker.js"; +import { scanDebugLeftover } from "./debug-leftover.js"; import { scanCommitLint } from "./commit-lint.js"; import { scanTerminology } from "./terminology.js"; import { scanTodoMarker } from "./todo-marker.js"; @@ -917,6 +918,35 @@ export const ANALYZER_DESCRIPTORS = [ }, run: (req) => scanConflictMarkers(req), }), + descriptor({ + name: "debugLeftover", + title: "Debug leftovers", + category: "quality", + cost: "local", + defaultEnabled: true, + requires: ["files"], + limits: { maxFindings: 25, maxLineChars: 2000 }, + docs: { + summary: + "Flags debugging leftovers a PR adds in non-test source — `debugger;`, bare console sinks, or `print()` calls.", + looksAt: "Added lines in changed non-test source files.", + reports: "File, line, and kind: debugger, console, or print.", + network: "Pure local analyzer. No external network call.", + notes: + "Distinct from the secrets-in-logs analyzer: this catches plain debug noise regardless of payload. String literals are stripped before matching.", + }, + render: (findings, helpers) => { + if (!findings.length) return []; + const lines = ["### Debug leftovers (debugger / console / print added by this PR)"]; + for (const item of findings) { + lines.push( + `- ${helpers.safeCodeSpan(`${item.file}:${item.line}`)} — ${helpers.safeCodeSpan(item.kind)}`, + ); + } + return lines; + }, + run: (req, { signal }) => scanDebugLeftover(req, signal), + }), descriptor({ name: "commitLint", title: "Conventional-commit subjects", diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 5d418ae81c..89177996e2 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -480,6 +480,7 @@ export function renderBrief( lines.push(...renderDescriptorSection("todoMarker", findings.todoMarker)); lines.push(...renderDescriptorSection("magicNumber", findings.magicNumber)); lines.push(...renderDescriptorSection("conflictMarker", findings.conflictMarker)); + lines.push(...renderDescriptorSection("debugLeftover", findings.debugLeftover)); lines.push(...renderDescriptorSection("commitLint", findings.commitLint)); if (!lines.length) return { promptSection: "", systemSuffix: "" }; diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 7bfef1b21f..1d8f0ddcd1 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -465,6 +465,14 @@ export interface ConflictMarkerFinding { marker: "<<<<<<<" | "|||||||" | "=======" | ">>>>>>>"; } +/** A debugging leftover a PR added in non-test source — `debugger;`, a bare console sink, or a `print()` call + * (#2015, part of #1499). Distinct from secret-log (sensitive payloads); reports location + kind only. */ +export interface DebugLeftoverFinding { + file: string; + line: number; + kind: "debugger" | "console" | "print"; +} + /** A PR commit subject that does not conform to the Conventional Commits spec (#2021, part of #1499). Reports a * short SHA prefix, the subject, and the failing reason — never author/email. */ export interface CommitLintFinding { @@ -510,6 +518,7 @@ export interface BriefFindings { todoMarker?: TodoMarkerFinding[]; magicNumber?: MagicNumberFinding[]; conflictMarker?: ConflictMarkerFinding[]; + debugLeftover?: DebugLeftoverFinding[]; commitLint?: CommitLintFinding[]; } diff --git a/review-enrichment/test/analyzer-registry.test.ts b/review-enrichment/test/analyzer-registry.test.ts index edd5354d4d..73979e059e 100644 --- a/review-enrichment/test/analyzer-registry.test.ts +++ b/review-enrichment/test/analyzer-registry.test.ts @@ -45,6 +45,7 @@ const EXPECTED_ANALYZERS = [ "todoMarker", "magicNumber", "conflictMarker", + "debugLeftover", "commitLint", ]; diff --git a/review-enrichment/test/debug-leftover.test.ts b/review-enrichment/test/debug-leftover.test.ts new file mode 100644 index 0000000000..bb41b00c38 --- /dev/null +++ b/review-enrichment/test/debug-leftover.test.ts @@ -0,0 +1,88 @@ +// Units for the debug-leftover analyzer (#2015). Own file (not enrichment.test.ts) so concurrent analyzer PRs +// don't collide. No network — pure, stateless per-line detection. Runs against the compiled dist/. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + detectDebugLeftover, + scanDebugLeftover, + scanPatchForDebugLeftover, +} from "../dist/analyzers/debug-leftover.js"; +import { renderBrief } from "../dist/render.js"; + +const patchOf = (lines: string[]) => + `@@ -1,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}`; + +test("detectDebugLeftover: recognizes debugger, console sinks, and print()", () => { + assert.equal(detectDebugLeftover(" debugger;"), "debugger"); + assert.equal(detectDebugLeftover("console.log('hi')"), "console"); + assert.equal(detectDebugLeftover(" console.debug(state)"), "console"); + assert.equal(detectDebugLeftover("print('debug')"), "print"); +}); + +test("detectDebugLeftover: a console call inside a string literal is not flagged", () => { + assert.equal(detectDebugLeftover('const s = "console.log(\\"nope\\")"'), null); + assert.equal(detectDebugLeftover("log(`hint: console.log(here)`);"), null); +}); + +test("detectDebugLeftover: debugger inside a string is not flagged", () => { + assert.equal(detectDebugLeftover('const msg = "debugger;"'), null); +}); + +test("scanPatchForDebugLeftover: flags added lines with correct locations", () => { + const findings = scanPatchForDebugLeftover( + "src/widget.ts", + patchOf(["function f() {", " debugger;", " console.log('x');", " return g();", "}"]), + ); + assert.deepEqual(findings, [ + { file: "src/widget.ts", line: 2, kind: "debugger" }, + { file: "src/widget.ts", line: 3, kind: "console" }, + ]); +}); + +test("scanPatchForDebugLeftover: only ADDED lines are scanned", () => { + const patch = [ + "@@ -10,2 +10,2 @@", + " function f() {", + "- console.log('old');", + "+ print('new')", + ].join("\n"); + assert.deepEqual(scanPatchForDebugLeftover("pkg/widget.py", patch), [ + { file: "pkg/widget.py", line: 11, kind: "print" }, + ]); +}); + +test("scanPatchForDebugLeftover: skips test/spec files", () => { + assert.deepEqual( + scanPatchForDebugLeftover("src/widget.test.ts", patchOf(["console.log('in test')"])), + [], + ); + assert.deepEqual( + scanPatchForDebugLeftover("tests/widget.spec.js", patchOf(["debugger;"])), + [], + ); +}); + +test("scanPatchForDebugLeftover: respects the findings cap", () => { + const lines = Array.from({ length: 30 }, (_, i) => `console.log(${i});`); + assert.equal(scanPatchForDebugLeftover("src/a.ts", patchOf(lines), { maxFindings: 3 }).length, 3); +}); + +test("scanDebugLeftover: aggregates across files and renders in the brief", async () => { + const findings = await scanDebugLeftover({ + files: [ + { path: "src/a.ts", patch: patchOf(["debugger;"]) }, + { path: "lib/b.py", patch: patchOf(["print('x')"]) }, + ], + }); + assert.deepEqual(findings, [ + { file: "src/a.ts", line: 1, kind: "debugger" }, + { file: "lib/b.py", line: 1, kind: "print" }, + ]); + + const { promptSection } = renderBrief({ + debugLeftover: findings, + }); + assert.match(promptSection, /Debug leftovers/); + assert.match(promptSection, /src\/a\.ts:1/); + assert.match(promptSection, /lib\/b\.py:1/); +}); From f4174246c72bfb90182a5c3db6111162e2cefc35 Mon Sep 17 00:00:00 2001 From: bohdansolovie Date: Sun, 5 Jul 2026 10:34:36 +0200 Subject: [PATCH 2/3] fix(enrichment): restrict print() debug detection to Python paths Avoid false positives on method calls like document.print(). Co-authored-by: Cursor --- review-enrichment/src/analyzers/debug-leftover.ts | 10 +++++++--- review-enrichment/test/debug-leftover.test.ts | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/review-enrichment/src/analyzers/debug-leftover.ts b/review-enrichment/src/analyzers/debug-leftover.ts index a0c634f726..93c2e8bb7f 100644 --- a/review-enrichment/src/analyzers/debug-leftover.ts +++ b/review-enrichment/src/analyzers/debug-leftover.ts @@ -15,11 +15,15 @@ const CONSOLE_RE = /\bconsole\s*\.\s*(?:log|debug|info|warn|error|trace|dir|tabl const PRINT_RE = /\bprint\s*\(/; /** Classify one added line for a debug leftover, or null. Pure. */ -export function detectDebugLeftover(line: string): DebugLeftoverFinding["kind"] | null { +export function detectDebugLeftover( + line: string, + path?: string, +): DebugLeftoverFinding["kind"] | null { const code = codeOnly(line); if (DEBUGGER_RE.test(code)) return "debugger"; if (CONSOLE_RE.test(code)) return "console"; - if (PRINT_RE.test(code)) return "print"; + // Python-only: `\bprint` after a dot would false-positive on `document.print()` / `obj.print()`. + if (path && /\.pyi?$/i.test(path) && PRINT_RE.test(code)) return "print"; return null; } @@ -51,7 +55,7 @@ export function scanPatchForDebugLeftover( if (line.startsWith("+")) { const body = line.slice(1); if (body.length <= MAX_LINE_CHARS) { - const kind = detectDebugLeftover(body); + const kind = detectDebugLeftover(body, path); if (kind) { findings.push({ file: path, line: newLine, kind }); if (findings.length >= maxFindings) return findings; diff --git a/review-enrichment/test/debug-leftover.test.ts b/review-enrichment/test/debug-leftover.test.ts index bb41b00c38..3700170783 100644 --- a/review-enrichment/test/debug-leftover.test.ts +++ b/review-enrichment/test/debug-leftover.test.ts @@ -16,7 +16,13 @@ test("detectDebugLeftover: recognizes debugger, console sinks, and print()", () assert.equal(detectDebugLeftover(" debugger;"), "debugger"); assert.equal(detectDebugLeftover("console.log('hi')"), "console"); assert.equal(detectDebugLeftover(" console.debug(state)"), "console"); - assert.equal(detectDebugLeftover("print('debug')"), "print"); + assert.equal(detectDebugLeftover("print('debug')", "lib/b.py"), "print"); +}); + +test("detectDebugLeftover: print() is Python-only and does not match method calls like document.print()", () => { + assert.equal(detectDebugLeftover("document.print()"), null); + assert.equal(detectDebugLeftover("printer.print('x')"), null); + assert.equal(detectDebugLeftover("print('debug')", "src/widget.ts"), null); }); test("detectDebugLeftover: a console call inside a string literal is not flagged", () => { From ddd647f86a985ec6810ce5a15a8a6f65efe0649f Mon Sep 17 00:00:00 2001 From: bohdansolovie Date: Sun, 5 Jul 2026 10:40:14 +0200 Subject: [PATCH 3/3] fix(enrichment): require non-method print() calls in Python debug scan Use (? --- review-enrichment/src/analyzers/debug-leftover.ts | 2 +- review-enrichment/test/debug-leftover.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/review-enrichment/src/analyzers/debug-leftover.ts b/review-enrichment/src/analyzers/debug-leftover.ts index 93c2e8bb7f..b8c8154ca7 100644 --- a/review-enrichment/src/analyzers/debug-leftover.ts +++ b/review-enrichment/src/analyzers/debug-leftover.ts @@ -12,7 +12,7 @@ const MAX_LINE_CHARS = 2000; const DEBUGGER_RE = /\bdebugger\s*;/; const CONSOLE_RE = /\bconsole\s*\.\s*(?:log|debug|info|warn|error|trace|dir|table)\s*\(/; -const PRINT_RE = /\bprint\s*\(/; +const PRINT_RE = /(? {