Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions apps/gittensory-ui/src/lib/rees-analyzers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<any>` 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",
Expand Down
26 changes: 26 additions & 0 deletions review-enrichment/analyzer-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<any>` 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",
Expand Down
30 changes: 30 additions & 0 deletions review-enrichment/src/analyzers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, `<any>` 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",
Expand Down
102 changes: 102 additions & 0 deletions review-enrichment/src/analyzers/unsafe-any.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Unsafe-`any` analyzer (#2017). Counts and locates explicit `any` type annotations, `<any>` 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 (/<any>/.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<string> {
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<UnsafeAnyFinding[]> {
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;
}
1 change: 1 addition & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
9 changes: 9 additions & 0 deletions review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,14 @@ export interface FloatingPromiseFinding {
call: string;
}

/** An explicit `any` annotation, `<any>` 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 {
Expand Down Expand Up @@ -588,6 +596,7 @@ export interface BriefFindings {
floatingPromise?: FloatingPromiseFinding[];
deepNesting?: DeepNestingFinding[];
errorSwallow?: ErrorSwallowFinding[];
unsafeAny?: UnsafeAnyFinding[];
i18n?: I18nFinding[];
hardcodedUrl?: HardcodedUrlFinding[];
commitLint?: CommitLintFinding[];
Expand Down
1 change: 1 addition & 0 deletions review-enrichment/test/analyzer-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const EXPECTED_ANALYZERS = [
"floatingPromise",
"deepNesting",
"errorSwallow",
"unsafeAny",
"i18n",
"commitLint",
];
Expand Down
85 changes: 85 additions & 0 deletions review-enrichment/test/unsafe-any.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>();"), "assertion");
assert.equal(detectUnsafeAny("type Rows = Array<any>;"), "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/);
});
1 change: 1 addition & 0 deletions src/review/enrichment-analyzer-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const REES_ANALYZER_NAMES = [
"floatingPromise",
"deepNesting",
"errorSwallow",
"unsafeAny",
"i18n",
"commitLint",
] as const;
Expand Down
Loading