From fbc6e4972ab6e1624ff1dcf3ece54e82de5e19e4 Mon Sep 17 00:00:00 2001 From: jony376 Date: Sun, 5 Jul 2026 10:13:00 -0700 Subject: [PATCH] feat(enrichment): add unsafe-any TS counter analyzer Register a local REES analyzer that flags newly-added : any, as any, and patterns in TypeScript diffs with line-cited findings. Closes #2017 Co-authored-by: Cursor --- .env.example | 8 +- apps/gittensory-ui/src/lib/rees-analyzers.ts | 22 ++++ review-enrichment/analyzer-metadata.json | 26 +++++ review-enrichment/src/analyzers/registry.ts | 30 ++++++ review-enrichment/src/analyzers/unsafe-any.ts | 102 ++++++++++++++++++ review-enrichment/src/render.ts | 1 + review-enrichment/src/types.ts | 9 ++ .../test/analyzer-registry.test.ts | 1 + review-enrichment/test/unsafe-any.test.ts | 85 +++++++++++++++ src/review/enrichment-analyzer-names.ts | 1 + 10 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 review-enrichment/src/analyzers/unsafe-any.ts create mode 100644 review-enrichment/test/unsafe-any.test.ts diff --git a/.env.example b/.env.example index ce12184819..9536558737 100644 --- a/.env.example +++ b/.env.example @@ -68,27 +68,27 @@ GITTENSORY_REVIEW_ENRICHMENT=false # commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot # blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene # pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber -# conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,i18n +# conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,i18n # commitLint # # Profile defaults: # fast: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,actionPin,eol,redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild # testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker -# debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,i18n +# debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,i18n # balanced (default): dependency,dependencyDiff,lockfileDrift,secret,license,installScript # heavyDependency,hardcodedUrl,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,debugLeftover,sizeSmell,floatingPromise,deepNesting -# errorSwallow,i18n,commitLint +# errorSwallow,unsafeAny,i18n,commitLint # deep: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,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,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,i18n +# conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,i18n # commitLint # END GENERATED REES ANALYZERS diff --git a/apps/gittensory-ui/src/lib/rees-analyzers.ts b/apps/gittensory-ui/src/lib/rees-analyzers.ts index 5cced472cc..9620f8fdf9 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -1049,6 +1049,28 @@ export const REES_ANALYZERS = [ "Multiline catch bodies are collected with brace balance. Catches that log, rethrow, or reference the binding are not flagged. Brace counting is character-level (string literals are not stripped).", }, }, + { + name: "unsafeAny", + title: "Unsafe any (TS)", + category: "quality", + cost: "local", + defaultEnabled: true, + profiles: ["fast", "balanced", "deep"], + requires: ["files"], + limits: { + maxFindings: 25, + maxLineChars: 2000, + }, + docs: { + summary: + "Counts and locates explicit `any` annotations, `` assertions, and `as any` casts newly introduced in TypeScript diffs.", + looksAt: "Added lines in changed non-test .ts/.tsx/.mts/.cts source files.", + reports: "File, line, and kind: annotation, cast, or assertion.", + network: "Pure local analyzer. No external network call.", + notes: + "Structural regex only (no type-checker). String literals and comment lines are skipped; findings are capped.", + }, + }, { name: "i18n", title: "i18n regressions", diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index 2cb2ec931b..e06196056c 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -1187,6 +1187,32 @@ "notes": "Multiline catch bodies are collected with brace balance. Catches that log, rethrow, or reference the binding are not flagged. Brace counting is character-level (string literals are not stripped)." } }, + { + "name": "unsafeAny", + "title": "Unsafe any (TS)", + "category": "quality", + "cost": "local", + "defaultEnabled": true, + "profiles": [ + "fast", + "balanced", + "deep" + ], + "requires": [ + "files" + ], + "limits": { + "maxFindings": 25, + "maxLineChars": 2000 + }, + "docs": { + "summary": "Counts and locates explicit `any` annotations, `` assertions, and `as any` casts newly introduced in TypeScript diffs.", + "looksAt": "Added lines in changed non-test .ts/.tsx/.mts/.cts source files.", + "reports": "File, line, and kind: annotation, cast, or assertion.", + "network": "Pure local analyzer. No external network call.", + "notes": "Structural regex only (no type-checker). String literals and comment lines are skipped; findings are capped." + } + }, { "name": "i18n", "title": "i18n regressions", diff --git a/review-enrichment/src/analyzers/registry.ts b/review-enrichment/src/analyzers/registry.ts index e871b13901..2f64a03cb5 100644 --- a/review-enrichment/src/analyzers/registry.ts +++ b/review-enrichment/src/analyzers/registry.ts @@ -38,6 +38,7 @@ import { scanErrorSwallow } from "./error-swallow.js"; import { scanFloatingPromise } from "./floating-promise.js"; import { scanSizeSmell } from "./size-smell.js"; import { scanCommitLint } from "./commit-lint.js"; +import { scanUnsafeAny } from "./unsafe-any.js"; import { scanTerminology } from "./terminology.js"; import { scanTodoMarker } from "./todo-marker.js"; import { scanTyposquat } from "./typosquat.js"; @@ -1137,6 +1138,35 @@ export const ANALYZER_DESCRIPTORS = [ }, run: (req, { signal }) => scanErrorSwallow(req, signal), }), + descriptor({ + name: "unsafeAny", + title: "Unsafe any (TS)", + category: "quality", + cost: "local", + defaultEnabled: true, + requires: ["files"], + limits: { maxFindings: 25, maxLineChars: 2000 }, + docs: { + summary: + "Counts and locates explicit `any` annotations, `` assertions, and `as any` casts newly introduced in TypeScript diffs.", + looksAt: "Added lines in changed non-test .ts/.tsx/.mts/.cts source files.", + reports: "File, line, and kind: annotation, cast, or assertion.", + network: "Pure local analyzer. No external network call.", + notes: + "Structural regex only (no type-checker). String literals and comment lines are skipped; findings are capped.", + }, + render: (findings, helpers) => { + if (!findings.length) return []; + const lines = ["### Unsafe any (explicit any introduced 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 }) => scanUnsafeAny(req, signal), + }), descriptor({ name: "i18n", title: "i18n regressions", diff --git a/review-enrichment/src/analyzers/unsafe-any.ts b/review-enrichment/src/analyzers/unsafe-any.ts new file mode 100644 index 0000000000..25c392b0db --- /dev/null +++ b/review-enrichment/src/analyzers/unsafe-any.ts @@ -0,0 +1,102 @@ +// Unsafe-`any` analyzer (#2017). Counts and locates explicit `any` type annotations, `` assertions, and +// `as any` casts newly introduced in TypeScript diffs — a type-safety erosion signal for the reviewer. Pure +// compute over added lines in .ts/.tsx/.mts/.cts files only; structural regex (no type-checker), fail-safe. +import type { EnrichRequest, UnsafeAnyFinding } 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 TS_PATH_RE = /\.(?:tsx?|mts|cts)$/i; + +function isTsPath(path: string): boolean { + return TS_PATH_RE.test(path) && !isTestPath(path); +} + +function isCommentLine(line: string): boolean { + const trimmed = line.trimStart(); + return /^(?:\/\/|\/\*|\*)/.test(trimmed); +} + +/** Classify one added line for an unsafe `any` pattern, or null. Pure. */ +export function detectUnsafeAny(line: string): UnsafeAnyFinding["kind"] | null { + if (isCommentLine(line) || line.length > MAX_LINE_CHARS) return null; + const code = codeOnly(line); + if (/\bas any\b/.test(code)) return "cast"; + if (//.test(code)) return "assertion"; + if (/:\s*any\b/.test(code)) return "annotation"; + return null; +} + +type ScanLimits = { + maxFindings?: number; + signal?: AbortSignal; +}; + +function* patchLines(patch: string): Generator { + let start = 0; + while (start <= patch.length) { + const end = patch.indexOf("\n", start); + if (end === -1) { + yield patch.slice(start); + return; + } + yield patch.slice(start, end); + start = end + 1; + } +} + +/** Scan one file patch's added lines for unsafe `any` usage, line-cited via hunk headers. Pure. */ +export function scanPatchForUnsafeAny( + path: string, + patch: string, + limits: ScanLimits = {}, +): UnsafeAnyFinding[] { + const maxFindings = limits.maxFindings ?? MAX_FINDINGS; + if (maxFindings <= 0 || !isTsPath(path)) return []; + const findings: UnsafeAnyFinding[] = []; + let newLine = 0; + let inHunk = false; + for (const line of patchLines(patch)) { + if (limits.signal?.aborted) return findings; + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (hunk) { + newLine = Number(hunk[1]); + inHunk = true; + continue; + } + if (!inHunk) continue; + if (line.startsWith("+")) { + const kind = detectUnsafeAny(line.slice(1)); + 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 file's added lines for unsafe `any` usage. */ +export async function scanUnsafeAny( + req: EnrichRequest, + signal?: AbortSignal, +): Promise { + const findings: UnsafeAnyFinding[] = []; + for (const file of req.files ?? []) { + if (signal?.aborted) return findings; + if (!file.patch) continue; + for (const finding of scanPatchForUnsafeAny(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/render.ts b/review-enrichment/src/render.ts index 04fc842ae6..5039c10652 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -490,6 +490,7 @@ export function renderBrief( lines.push(...renderDescriptorSection("floatingPromise", findings.floatingPromise)); lines.push(...renderDescriptorSection("deepNesting", findings.deepNesting)); lines.push(...renderDescriptorSection("errorSwallow", findings.errorSwallow)); + lines.push(...renderDescriptorSection("unsafeAny", findings.unsafeAny)); lines.push(...renderDescriptorSection("i18n", findings.i18n)); lines.push(...renderDescriptorSection("hardcodedUrl", findings.hardcodedUrl)); lines.push(...renderDescriptorSection("commitLint", findings.commitLint)); diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 0f417f40b7..c2183577be 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -528,6 +528,14 @@ export interface FloatingPromiseFinding { call: string; } +/** An explicit `any` annotation, `` assertion, or `as any` cast newly added in a TS diff (#2017, part of #1499). + * Reports file, line, and kind only — never surrounding code. */ +export interface UnsafeAnyFinding { + file: string; + line: number; + kind: "annotation" | "cast" | "assertion"; +} + /** An absolute HTTP(S) URL or raw IP:port endpoint hardcoded in non-test, non-config source (#2027, part of #1499). * Reports location, kind, and a redacted/truncated host — never full paths or query strings. */ export interface HardcodedUrlFinding { @@ -588,6 +596,7 @@ export interface BriefFindings { floatingPromise?: FloatingPromiseFinding[]; deepNesting?: DeepNestingFinding[]; errorSwallow?: ErrorSwallowFinding[]; + unsafeAny?: UnsafeAnyFinding[]; i18n?: I18nFinding[]; hardcodedUrl?: HardcodedUrlFinding[]; commitLint?: CommitLintFinding[]; diff --git a/review-enrichment/test/analyzer-registry.test.ts b/review-enrichment/test/analyzer-registry.test.ts index bc19bc4ccd..9db05f6470 100644 --- a/review-enrichment/test/analyzer-registry.test.ts +++ b/review-enrichment/test/analyzer-registry.test.ts @@ -52,6 +52,7 @@ const EXPECTED_ANALYZERS = [ "floatingPromise", "deepNesting", "errorSwallow", + "unsafeAny", "i18n", "commitLint", ]; diff --git a/review-enrichment/test/unsafe-any.test.ts b/review-enrichment/test/unsafe-any.test.ts new file mode 100644 index 0000000000..fd10bd6480 --- /dev/null +++ b/review-enrichment/test/unsafe-any.test.ts @@ -0,0 +1,85 @@ +// Units for the unsafe-any analyzer (#2017). Own file so concurrent analyzer PRs don't collide. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + detectUnsafeAny, + scanPatchForUnsafeAny, + scanUnsafeAny, +} from "../dist/analyzers/unsafe-any.js"; +import { renderBrief } from "../dist/render.js"; + +const patchOf = (lines: string[]) => + `@@ -1,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}`; + +test("detectUnsafeAny: classifies annotation, cast, and assertion patterns", () => { + assert.equal(detectUnsafeAny("const value: any = input;"), "annotation"); + assert.equal(detectUnsafeAny("function run(payload: any) {}"), "annotation"); + assert.equal(detectUnsafeAny("return data as any;"), "cast"); + assert.equal(detectUnsafeAny("const rows = (items as any).map(fn);"), "cast"); + assert.equal(detectUnsafeAny("const rows = get();"), "assertion"); + assert.equal(detectUnsafeAny("type Rows = Array;"), "assertion"); +}); + +test("detectUnsafeAny: skips comments and string-literal false positives", () => { + assert.equal(detectUnsafeAny("// const value: any = input;"), null); + assert.equal(detectUnsafeAny('const msg = ": any in prose";'), null); + assert.equal(detectUnsafeAny('console.log("as any is bad");'), null); +}); + +test("scanPatchForUnsafeAny: flags added lines with correct locations", () => { + const findings = scanPatchForUnsafeAny( + "src/worker.ts", + patchOf([ + "export function parse(input: string) {", + " const payload: any = JSON.parse(input);", + " return payload as any;", + "}", + ]), + ); + assert.deepEqual(findings, [ + { file: "src/worker.ts", line: 2, kind: "annotation" }, + { file: "src/worker.ts", line: 3, kind: "cast" }, + ]); +}); + +test("scanPatchForUnsafeAny: skips test files and non-TS paths", () => { + assert.deepEqual( + scanPatchForUnsafeAny("src/worker.test.ts", patchOf(["const x: any = 1;"])), + [], + ); + assert.deepEqual( + scanPatchForUnsafeAny("lib/worker.py", patchOf(["x: any = 1"])), + [], + ); +}); + +test("scanPatchForUnsafeAny: caps findings at maxFindings", () => { + const findings = scanPatchForUnsafeAny( + "src/worker.ts", + patchOf(["const a: any = 1;", "const b: any = 2;", "const c: any = 3;"]), + { maxFindings: 2 }, + ); + assert.equal(findings.length, 2); +}); + +test("scanUnsafeAny: aggregates across files", async () => { + const findings = await scanUnsafeAny({ + repoFullName: "owner/repo", + prNumber: 1, + files: [ + { path: "src/a.ts", patch: patchOf(["const x: any = 1;"]) }, + { path: "src/b.ts", patch: patchOf(["return value as any;"]) }, + ], + }); + assert.equal(findings.length, 2); + assert.equal(findings[0]?.kind, "annotation"); + assert.equal(findings[1]?.kind, "cast"); +}); + +test("renderBrief: includes unsafeAny findings via descriptor render", () => { + const findings = [{ file: "src/a.ts", line: 4, kind: "cast" as const }]; + const { promptSection } = renderBrief({ unsafeAny: findings }); + assert.match(promptSection, /Unsafe any/); + assert.match(promptSection, /src\/a.ts:4/); + assert.match(promptSection, /cast/); +}); diff --git a/src/review/enrichment-analyzer-names.ts b/src/review/enrichment-analyzer-names.ts index cfa8969f3f..7b22b22aff 100644 --- a/src/review/enrichment-analyzer-names.ts +++ b/src/review/enrichment-analyzer-names.ts @@ -46,6 +46,7 @@ export const REES_ANALYZER_NAMES = [ "floatingPromise", "deepNesting", "errorSwallow", + "unsafeAny", "i18n", "commitLint", ] as const;