Skip to content
Closed
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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,25 @@ GITTENSORY_REVIEW_ENRICHMENT=false
# nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity
# ciCheckSignals,undocumentedExport,staleBranch,commitHygiene,pendingReviewRequests,testRatio
# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover
# commitLint
# unsafeAny,commitLint
#
# Profile defaults:
# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,hardcodedUrl
# actionPin,eol,redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild,testRatio
# migrationSafety,looseRange,terminology,todoMarker,magicNumber,conflictMarker,debugLeftover
# unsafeAny
# balanced (default): dependency,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,commitLint
# conflictMarker,debugLeftover,unsafeAny,commitLint
# deep: dependency,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,commitLint
# conflictMarker,debugLeftover,unsafeAny,commitLint
# END GENERATED REES ANALYZERS

# Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep
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 @@ -933,6 +933,28 @@ export const REES_ANALYZERS = [
"Distinct from the secrets-in-logs analyzer: this catches plain debug noise regardless of payload. String literals are stripped before matching.",
},
},
{
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 explicit `any` type annotations, `as any` casts, and `<any>` assertions newly added in TypeScript files.",
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 full-line comments are ignored; inline `//` and `/* */` comments are stripped before matching.",
},
},
{
name: "commitLint",
title: "Conventional-commit subjects",
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 @@ -1052,6 +1052,32 @@
"notes": "Distinct from the secrets-in-logs analyzer: this catches plain debug noise regardless of payload. String literals are stripped before matching."
}
},
{
"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 explicit `any` type annotations, `as any` casts, and `<any>` assertions newly added in TypeScript files.",
"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 full-line comments are ignored; inline `//` and `/* */` comments are stripped before matching."
}
},
{
"name": "commitLint",
"title": "Conventional-commit subjects",
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 @@ -36,6 +36,7 @@ import { scanTerminology } from "./terminology.js";
import { scanTodoMarker } from "./todo-marker.js";
import { scanTyposquat } from "./typosquat.js";
import { scanUndocumentedExport } from "./undocumented-export.js";
import { scanUnsafeAny } from "./unsafe-any.js";
import type {
AnalyzerDescriptor,
AnalyzerFn,
Expand Down Expand Up @@ -977,6 +978,35 @@ export const ANALYZER_DESCRIPTORS = [
},
run: (req, { signal }) => scanDebugLeftover(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 explicit `any` type annotations, `as any` casts, and `<any>` assertions newly added in TypeScript files.",
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 full-line comments are ignored; inline `//` and `/* */` comments are stripped before matching.",
},
render: (findings, helpers) => {
if (!findings.length) return [];
const lines = ["### Unsafe `any` usage (type-safety erosion 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: "commitLint",
title: "Conventional-commit subjects",
Expand Down
137 changes: 137 additions & 0 deletions review-enrichment/src/analyzers/unsafe-any.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Unsafe-`any` counter analyzer (#2017). Flags explicit `any` type annotations, `as any` casts, and `<any>`
// assertions newly introduced in TypeScript diffs — a type-safety erosion signal for the reviewer. Pure compute
// over added lines in .ts/.tsx files, no network. Structural regex only (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_EXTS = new Set(["ts", "tsx", "mts", "cts"]);

const CAST_RE = /\bas\s+any\b/;
const ASSERTION_RE = /<\s*any\s*>/;
const ANNOTATION_RE = /:\s*any\b(?!\s*[,}])/;

function isTypeScriptPath(path: string): boolean {
const ext = /\.([^.]+)$/.exec(path)?.[1]?.toLowerCase();
return Boolean(ext && TS_EXTS.has(ext) && !isTestPath(path));
}

function stripComments(code: string): string {
const noBlock = code.replace(/\/\*[\s\S]*?\*\//g, " ");
const slash = noBlock.indexOf("//");
return slash >= 0 ? noBlock.slice(0, slash) : noBlock;
}

function isFullLineComment(line: string): boolean {
const trimmed = line.trimStart();
if (trimmed.startsWith("//")) return true;
return /^\s*\/\*[\s\S]*\*\/\s*$/.test(line);
}

type BlockCommentState = { open: boolean };

/** Remove block-comment spans from one added line, tracking multi-line comment state. Pure aside from state. */
export function stripBlockCommentsFromAddedLine(line: string, state: BlockCommentState): string | null {
let scanLine = line;
if (state.open) {
const end = scanLine.indexOf("*/");
if (end < 0) return null;
state.open = false;
scanLine = scanLine.slice(end + 2);
}
while (true) {
const start = scanLine.indexOf("/*");
if (start < 0) break;
const end = scanLine.indexOf("*/", start + 2);
if (end < 0) {
scanLine = scanLine.slice(0, start);
state.open = true;
break;
}
scanLine = `${scanLine.slice(0, start)} ${scanLine.slice(end + 2)}`;
}
return scanLine;
}

/** Classify one added TS line for an unsafe-`any` pattern, or null. Pure. */
export function detectUnsafeAny(line: string): UnsafeAnyFinding["kind"] | null {
if (isFullLineComment(line)) return null;
const code = stripComments(codeOnly(line));
if (CAST_RE.test(code)) return "cast";
if (ASSERTION_RE.test(code)) return "assertion";
if (ANNOTATION_RE.test(code)) return "annotation";
return null;
}

type ScanLimits = {
maxFindings?: number;
signal?: AbortSignal;
};

/** Scan one file patch's added lines for unsafe `any`, 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 || !isTypeScriptPath(path)) return [];
const findings: UnsafeAnyFinding[] = [];
let newLine = 0;
let inHunk = false;
const blockComment: BlockCommentState = { open: 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;
blockComment.open = false;
continue;
}
if (!inHunk) continue;
if (line.startsWith("+")) {
const body = line.slice(1);
if (body.length <= MAX_LINE_CHARS) {
const scanLine = stripBlockCommentsFromAddedLine(body, blockComment);
if (scanLine !== null) {
const kind = detectUnsafeAny(scanLine);
if (kind) {
findings.push({ file: path, line: newLine, kind });
if (findings.length >= maxFindings) return findings;
}
}
}
newLine++;
} else {
blockComment.open = false;
if (!line.startsWith("-") && !line.startsWith("\\")) {
newLine++;
}
}
}
return findings;
}

/** Analyzer entrypoint: scan every changed TS file's added lines for unsafe `any`. */
export async function scanUnsafeAny(
req: EnrichRequest,
signal?: AbortSignal,
): Promise<UnsafeAnyFinding[]> {
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;
}
1 change: 1 addition & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ export function renderBrief(
lines.push(...renderDescriptorSection("magicNumber", findings.magicNumber));
lines.push(...renderDescriptorSection("conflictMarker", findings.conflictMarker));
lines.push(...renderDescriptorSection("debugLeftover", findings.debugLeftover));
lines.push(...renderDescriptorSection("unsafeAny", findings.unsafeAny));
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 @@ -473,6 +473,14 @@ export interface DebugLeftoverFinding {
kind: "debugger" | "console" | "print";
}

/** Explicit `any` type erosion newly introduced in TypeScript source (#2017, part of #1499).
* Reports location and kind only — never the 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 @@ -528,6 +536,7 @@ export interface BriefFindings {
magicNumber?: MagicNumberFinding[];
conflictMarker?: ConflictMarkerFinding[];
debugLeftover?: DebugLeftoverFinding[];
unsafeAny?: UnsafeAnyFinding[];
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 @@ -47,6 +47,7 @@ const EXPECTED_ANALYZERS = [
"magicNumber",
"conflictMarker",
"debugLeftover",
"unsafeAny",
"commitLint",
];

Expand Down
Loading
Loading