diff --git a/.env.example b/.env.example index 115b0b9d8d..0581de5926 100644 --- a/.env.example +++ b/.env.example @@ -67,22 +67,23 @@ 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 +# looseRange,terminology,todoMarker,magicNumber,unsafeAny # # Profile defaults: # fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol # redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild,testRatio,migrationSafety -# looseRange,terminology,todoMarker,magicNumber +# looseRange,terminology,todoMarker,magicNumber,unsafeAny # 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 +# unsafeAny # 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 +# migrationSafety,looseRange,terminology,todoMarker,magicNumber,unsafeAny # 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 d622de7867..0dc2d40c62 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -865,6 +865,29 @@ export const REES_ANALYZERS = [ "Precision-first: common values such as 0, 1, -1, 2, 100, 1000, and powers of ten are silent.", }, }, + { + name: "unsafeAny", + title: "Unsafe `any` usage", + category: "quality", + cost: "local", + defaultEnabled: true, + profiles: ["fast", "balanced", "deep"], + requires: ["files"], + limits: { + maxFindings: 25, + maxLineChars: 2000, + }, + docs: { + summary: + "Counts and locates explicit `any` usage a PR adds in TypeScript: `: any` annotations, `as any` casts, and `` assertions.", + looksAt: + "Added lines in .ts/.tsx/.mts/.cts files (string literals and same-line comments blanked first).", + reports: "File, line, and the `any`-usage kind — never line content.", + network: "Pure local analyzer. No external network call.", + notes: + "Structural regex only, no type-checker; a cheap best-effort string/comment strip keeps a mention of `any` in prose or a string out, so the signal reflects real type-safety erosion.", + }, + }, ] as const satisfies readonly ReesAnalyzerDoc[]; export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name); diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index fd2fc254ce..bb1d342fed 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -973,6 +973,32 @@ "network": "Pure local analyzer. No external network call.", "notes": "Precision-first: common values such as 0, 1, -1, 2, 100, 1000, and powers of ten are silent." } + }, + { + "name": "unsafeAny", + "title": "Unsafe `any` usage", + "category": "quality", + "cost": "local", + "defaultEnabled": true, + "profiles": [ + "fast", + "balanced", + "deep" + ], + "requires": [ + "files" + ], + "limits": { + "maxFindings": 25, + "maxLineChars": 2000 + }, + "docs": { + "summary": "Counts and locates explicit `any` usage a PR adds in TypeScript: `: any` annotations, `as any` casts, and `` assertions.", + "looksAt": "Added lines in .ts/.tsx/.mts/.cts files (string literals and same-line comments blanked first).", + "reports": "File, line, and the `any`-usage kind — never line content.", + "network": "Pure local analyzer. No external network call.", + "notes": "Structural regex only, no type-checker; a cheap best-effort string/comment strip keeps a mention of `any` in prose or a string out, so the signal reflects real type-safety erosion." + } } ] } diff --git a/review-enrichment/src/analyzers/registry.ts b/review-enrichment/src/analyzers/registry.ts index 516784b8f3..7745c684ec 100644 --- a/review-enrichment/src/analyzers/registry.ts +++ b/review-enrichment/src/analyzers/registry.ts @@ -28,6 +28,7 @@ import { scanTestRatio } from "./test-ratio.js"; import { scanMigrationSafety } from "./migration-safety.js"; import { scanLooseRanges } from "./loose-range.js"; import { scanMagicNumbers } from "./magic-number.js"; +import { scanUnsafeAny } from "./unsafe-any.js"; import { scanTerminology } from "./terminology.js"; import { scanTodoMarker } from "./todo-marker.js"; import { scanTyposquat } from "./typosquat.js"; @@ -886,6 +887,45 @@ export const ANALYZER_DESCRIPTORS = [ }, run: (req, { signal }) => scanMagicNumbers(req, signal), }), + descriptor({ + name: "unsafeAny", + title: "Unsafe `any` usage", + category: "quality", + cost: "local", + defaultEnabled: true, + requires: ["files"], + limits: { maxFindings: 25, maxLineChars: 2000 }, + docs: { + summary: + "Counts and locates explicit `any` usage a PR adds in TypeScript: `: any` annotations, `as any` casts, and `` assertions.", + looksAt: "Added lines in .ts/.tsx/.mts/.cts files (string literals and same-line comments blanked first).", + reports: "File, line, and the `any`-usage kind — never line content.", + network: "Pure local analyzer. No external network call.", + notes: + "Structural regex only, no type-checker; a cheap best-effort string/comment strip keeps a mention of `any` in prose or a string out, so the signal reflects real type-safety erosion.", + }, + render: (findings, helpers) => { + if (!findings.length) return []; + const explain = (kind: (typeof findings)[number]["kind"]): string => { + switch (kind) { + case "annotation": + return "a `: any` type annotation opts the value out of type checking"; + case "cast": + return "an `as any` cast discards the checked type"; + case "assertion": + return "an `` assertion/type-argument erases the type"; + } + }; + const lines = ["### Unsafe `any` usage (type-safety erosion)"]; + for (const item of findings) { + lines.push( + `- ${helpers.safeCodeSpan(`${item.file}:${item.line}`)} — ${explain(item.kind)}`, + ); + } + return lines; + }, + run: (req, { signal }) => scanUnsafeAny(req, signal), + }), ] as const satisfies readonly AnyAnalyzerDescriptor[]; export const ANALYZER_NAMES = ANALYZER_DESCRIPTORS.map( diff --git a/review-enrichment/src/analyzers/unsafe-any.ts b/review-enrichment/src/analyzers/unsafe-any.ts new file mode 100644 index 0000000000..9a3cea1cfb --- /dev/null +++ b/review-enrichment/src/analyzers/unsafe-any.ts @@ -0,0 +1,123 @@ +// Unsafe-`any` counter (#2017). Counts and locates explicit `any` type usage a PR ADDS in TypeScript — a type +// annotation (`: any`), an `as any` cast, or an `` assertion/type-argument — a type-safety-erosion signal a +// reviewer can weigh. Structural regex only (no type-checker), pure, no network. Detection is gated to .ts/.tsx +// (and the .mts/.cts module variants), so JS or prose can't false-positive. String literals and same-line +// comments are blanked before matching (a cheap, best-effort avoidance per the issue's "where cheaply +// detectable"). KNOWN LIMITATION: cross-line block-comment state is NOT tracked, so a `: any`/`as any` on a +// continuation line of a multi-line `/* … */` comment that does not begin with `*` can still be counted — a +// rare, accepted false positive of the cheap-strip approach, not a correctness guarantee. +// Line-cited via hunk headers, mirroring the sibling local analyzers (redos.ts). +import type { EnrichRequest, UnsafeAnyFinding } from "../types.js"; +import { codeOnly } from "./secret-log.js"; + +const MAX_FINDINGS = 25; +const MAX_LINE_CHARS = 2000; + +const TS_EXTS = new Set(["ts", "tsx", "mts", "cts"]); + +// `\bany\b` in each shape keeps `anyOf`/`anything`/`Company` out. `annotation` is a `: any` type position; +// `cast` is an `as any`; `assertion` is an explicit `any` INSIDE an angle-bracket type-argument / assertion list +// — not just the bare ``, but any single-level angle group that contains an `any` token, so +// `Record`, `Map`, `Promise`, and `Array` are all surfaced (a nested +// generic still matches on its innermost `<…>` group, which the `[^<>]` class stops at). +const ANNOTATION_RE = /:\s*any\b/; +const CAST_RE = /\bas\s+any\b/; +const ASSERTION_RE = /<[^<>]*\bany\b[^<>]*>/; + +/** The lowercased final path extension, or null. */ +function extOf(path: string): string | null { + const base = path.split("/").pop() ?? path; + const dot = base.lastIndexOf("."); + return dot > 0 ? base.slice(dot + 1).toLowerCase() : null; +} + +/** Blank string literals (via codeOnly) then strip same-line block/line comments, so only real code is matched. + * Cheap and single-line by design — cross-line comment state is not tracked. Pure. */ +function toCode(line: string): string { + return codeOnly(line) + .replace(/\/\*.*?\*\//g, " ") + .replace(/\/\/.*$/, ""); +} + +/** The explicit-`any` kinds present on one line, de-duplicated by kind. A line that is a JSDoc/comment + * continuation (`* … : any …`) is skipped so documentation prose is never counted. Pure. */ +export function detectUnsafeAny(line: string): UnsafeAnyFinding["kind"][] { + const code = toCode(line); + // A JSDoc/block-comment continuation line — `* @param`, a bare `*`, or the closing `*/`. Match ONLY those + // shapes (a `*` followed by a space, end-of-line, or `/`), NOT a generator method like `*load(): any {}` + // whose `*` is immediately followed by an identifier — otherwise that method's `: any` is silently missed. + const trimmed = code.trimStart(); + if (trimmed === "*" || trimmed.startsWith("* ") || trimmed.startsWith("*/")) return []; + const kinds: UnsafeAnyFinding["kind"][] = []; + if (CAST_RE.test(code)) kinds.push("cast"); + if (ASSERTION_RE.test(code)) kinds.push("assertion"); + if (ANNOTATION_RE.test(code)) kinds.push("annotation"); + return kinds; +} + +type ScanLimits = { + maxFindings?: number; + signal?: AbortSignal; +}; + +/** Scan one TS file patch's added lines for explicit `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) return []; + const ext = extOf(path); + if (!ext || !TS_EXTS.has(ext)) return []; + + const findings: UnsafeAnyFinding[] = []; + 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; + } + // Skip pre-hunk preamble; inside a hunk `+++x`/`+++ x` is added content, not a header. + if (!inHunk) continue; + if (line.startsWith("+")) { + const body = line.slice(1); + if (body.length <= MAX_LINE_CHARS) { + for (const kind of detectUnsafeAny(body)) { + findings.push({ file: path, line: newLine, kind }); + if (findings.length >= maxFindings) return findings; + } + } + newLine++; + } else if (!line.startsWith("-") && !line.startsWith("\\")) { + // A `\ No newline at end of file` marker is not a new-file line — do not advance the cursor + // (same class as the redos / actions-pin fix). + newLine++; + } + } + return findings; +} + +/** Analyzer entrypoint: scan every changed .ts/.tsx file's added lines for explicit `any` usage. */ +export async function scanUnsafeAny( + req: EnrichRequest, + signal?: AbortSignal, +): Promise { + const findings: UnsafeAnyFinding[] = []; + for (const file of req.files ?? []) { + if (signal?.aborted) throw new Error("analyzer_aborted"); + 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 df05642f19..f5e28a0f33 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -459,6 +459,7 @@ export function renderBrief( lines.push(...renderDescriptorSection("terminology", findings.terminology)); lines.push(...renderDescriptorSection("todoMarker", findings.todoMarker)); lines.push(...renderDescriptorSection("magicNumber", findings.magicNumber)); + lines.push(...renderDescriptorSection("unsafeAny", findings.unsafeAny)); if (!lines.length) return { promptSection: "", systemSuffix: "" }; diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 175aecb10c..8d40e2ea2d 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -447,6 +447,14 @@ export interface MagicNumberFinding { value: string; } +/** An explicit `any` usage a PR added in TypeScript — a `: any` annotation, an `as any` cast, or an `` + * assertion/type-argument (#2017, part of #1499). A type-safety-erosion signal; reports the location + kind. */ +export interface UnsafeAnyFinding { + file: string; + line: number; + kind: "annotation" | "cast" | "assertion"; +} + /** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */ export interface BriefFindings { dependency?: DependencyFinding[]; @@ -483,6 +491,7 @@ export interface BriefFindings { terminology?: TerminologyFinding[]; todoMarker?: TodoMarkerFinding[]; magicNumber?: MagicNumberFinding[]; + unsafeAny?: UnsafeAnyFinding[]; } /** A JSDoc/TSDoc block whose `@param` tags name parameters the adjacent function no longer declares — a diff --git a/review-enrichment/test/analyzer-registry.test.ts b/review-enrichment/test/analyzer-registry.test.ts index 0b90e20549..d7fdc7c13e 100644 --- a/review-enrichment/test/analyzer-registry.test.ts +++ b/review-enrichment/test/analyzer-registry.test.ts @@ -44,6 +44,7 @@ const EXPECTED_ANALYZERS = [ "terminology", "todoMarker", "magicNumber", + "unsafeAny", ]; test("analyzer descriptors cover the runtime registry in stable order", () => { diff --git a/review-enrichment/test/unsafe-any.test.ts b/review-enrichment/test/unsafe-any.test.ts new file mode 100644 index 0000000000..24754a0cff --- /dev/null +++ b/review-enrichment/test/unsafe-any.test.ts @@ -0,0 +1,134 @@ +// Units for the unsafe-`any` counter analyzer (#2017). 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 { + detectUnsafeAny, + scanPatchForUnsafeAny, + scanUnsafeAny, +} from "../dist/analyzers/unsafe-any.js"; +import { renderBrief } from "../dist/render.js"; + +const patchOf = (lines) => `@@ -1,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}`; + +test("detectUnsafeAny: distinguishes annotation, cast, and assertion", () => { + assert.deepEqual(detectUnsafeAny("function f(x: any) {"), ["annotation"]); + assert.deepEqual(detectUnsafeAny("const y = x as any;"), ["cast"]); + assert.deepEqual(detectUnsafeAny("const z = x;"), ["assertion"]); + assert.deepEqual(detectUnsafeAny("const w: Array = [];"), ["assertion"]); +}); + +test("detectUnsafeAny: an `any` inside a multi-argument or compound generic type argument is an assertion finding", () => { + // Regression: the type-argument matcher must see explicit `any` inside a `<…>` list that has other tokens, + // not only the bare `` token. + assert.deepEqual(detectUnsafeAny("const x: Record = {};"), ["assertion"]); + assert.deepEqual(detectUnsafeAny("let p: Promise = load();"), ["assertion"]); + assert.deepEqual(detectUnsafeAny("const m: Map = new Map();"), ["assertion"]); + assert.deepEqual(detectUnsafeAny("function f(): Map> { return x; }"), ["assertion"]); + // A generic with no `any` argument is not flagged. + assert.deepEqual(detectUnsafeAny("const r: Record = {};"), []); +}); + +test("detectUnsafeAny: multiple distinct kinds on one line are each reported (deduped by kind)", () => { + // cast + assertion + annotation, deduped so a repeated kind is not double-counted on the same line. + const kinds = detectUnsafeAny("let a: any = (x as any) as any; const b = c;"); + assert.deepEqual([...kinds].sort(), ["annotation", "assertion", "cast"]); +}); + +test("detectUnsafeAny: word-boundary — anyOf/anything/Company are not matched", () => { + assert.deepEqual(detectUnsafeAny("const o: anyOf = pick();"), []); + assert.deepEqual(detectUnsafeAny("let s = 'has anything';"), []); + assert.deepEqual(detectUnsafeAny("class Company {}"), []); +}); + +test("detectUnsafeAny: `any` inside a string literal or comment is not counted", () => { + assert.deepEqual(detectUnsafeAny('const msg = "cast as any here";'), []); + assert.deepEqual(detectUnsafeAny("const n = 1; // treat as any value"), []); + assert.deepEqual(detectUnsafeAny("doStuff(); /* returns : any */"), []); + assert.deepEqual(detectUnsafeAny(" * @param p : any description"), []); // JSDoc continuation +}); + +test("detectUnsafeAny: real code with a trailing comment is still counted", () => { + assert.deepEqual(detectUnsafeAny("const v: any = load(); // TODO type this"), ["annotation"]); +}); + +test("detectUnsafeAny: a generator method starting with `*` is code, not a JSDoc line — its `: any` is counted", () => { + // Regression: the JSDoc guard must skip only `* `/`*`/`*/` continuation shapes, never a `*name(): any` method. + assert.deepEqual(detectUnsafeAny(" *load(): any {}"), ["annotation"]); + assert.deepEqual(detectUnsafeAny(" *items(): Generator {}"), ["assertion"]); + // …while a genuine JSDoc continuation line is still skipped. + assert.deepEqual(detectUnsafeAny(" * @returns {any} the value"), []); + assert.deepEqual(detectUnsafeAny(" */"), []); +}); + +test("scanPatchForUnsafeAny: only scans TS-family files", () => { + assert.deepEqual(scanPatchForUnsafeAny("src/a.ts", patchOf(["let x: any;"])), [ + { file: "src/a.ts", line: 1, kind: "annotation" }, + ]); + assert.deepEqual(scanPatchForUnsafeAny("src/a.tsx", patchOf(["let x: any;"])), [ + { file: "src/a.tsx", line: 1, kind: "annotation" }, + ]); + // A .js/.py/.md file is not TypeScript — not scanned. + assert.deepEqual(scanPatchForUnsafeAny("src/a.js", patchOf(["let x = y; // as any"])), []); + assert.deepEqual(scanPatchForUnsafeAny("docs/notes.md", patchOf(["use `as any` sparingly"])), []); +}); + +test("scanPatchForUnsafeAny: flags kinds on added lines with correct locations", () => { + const findings = scanPatchForUnsafeAny( + "src/svc.ts", + patchOf(["function f(p: any) {", " return p as any;", " const clean: string = p;", "}"]), + ); + assert.deepEqual(findings, [ + { file: "src/svc.ts", line: 1, kind: "annotation" }, + { file: "src/svc.ts", line: 2, kind: "cast" }, + ]); +}); + +test("scanPatchForUnsafeAny: only ADDED lines are scanned; new-file line numbers stay correct", () => { + const patch = [ + "@@ -10,2 +10,2 @@", + " function f() {", // context line 10 + "- let x: any;", // removed, does not advance + "+ let x: any;", // new-file line 11 + ].join("\n"); + assert.deepEqual(scanPatchForUnsafeAny("src/a.ts", patch), [ + { file: "src/a.ts", line: 11, kind: "annotation" }, + ]); +}); + +test("scanPatchForUnsafeAny: enforces the maxFindings cap", () => { + const lines = Array.from({ length: 30 }, () => "let x: any;"); + const findings = scanPatchForUnsafeAny("src/a.ts", patchOf(lines), { maxFindings: 5 }); + assert.equal(findings.length, 5); + assert.deepEqual(scanPatchForUnsafeAny("src/a.ts", patchOf(lines), { maxFindings: 0 }), []); +}); + +test("scanUnsafeAny: scans every changed TS file and honors the global cap", async () => { + const anyLines = Array.from({ length: 30 }, () => "let x: any;"); + const findings = await scanUnsafeAny({ + repoFullName: "octo/repo", + prNumber: 1, + files: [ + { path: "src/a.js", patch: patchOf(["let x = 1;"]) }, // skipped (not TS) + { path: "src/b.ts", patch: patchOf(anyLines) }, + ], + }); + assert.equal(findings.length, 25); + assert.ok(findings.every((f) => f.file === "src/b.ts")); +}); + +test("scanUnsafeAny: no files yields no findings", async () => { + assert.deepEqual(await scanUnsafeAny({ repoFullName: "octo/repo", prNumber: 1 }), []); +}); + +test("renderBrief: unsafe-any findings render location and a public-safe explanation", () => { + const { promptSection } = renderBrief({ + unsafeAny: [ + { file: "src/svc.ts", line: 1, kind: "annotation" }, + { file: "src/svc.ts", line: 2, kind: "cast" }, + ], + }); + assert.match(promptSection, /Unsafe .any. usage/); + assert.match(promptSection, /src\/svc\.ts:1/); + assert.match(promptSection, /type annotation/); +}); diff --git a/src/review/enrichment-analyzer-names.ts b/src/review/enrichment-analyzer-names.ts index 3690d8de16..3605252f8c 100644 --- a/src/review/enrichment-analyzer-names.ts +++ b/src/review/enrichment-analyzer-names.ts @@ -38,6 +38,7 @@ export const REES_ANALYZER_NAMES = [ "terminology", "todoMarker", "magicNumber", + "unsafeAny", ] as const; export type ReesAnalyzerName = (typeof REES_ANALYZER_NAMES)[number]; diff --git a/test/unit/enrichment-wire.test.ts b/test/unit/enrichment-wire.test.ts index 71ea8a995f..dd95ac568f 100644 --- a/test/unit/enrichment-wire.test.ts +++ b/test/unit/enrichment-wire.test.ts @@ -626,7 +626,7 @@ describe("resolveReesAnalyzers", () => { resolveReesAnalyzers( env({ REES_ANALYZERS: - "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", + "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,unsafeAny", }), ), ).toEqual([ @@ -663,6 +663,7 @@ describe("resolveReesAnalyzers", () => { "looseRange", "terminology", "todoMarker", + "unsafeAny", ]); });