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 @@ -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
Expand Down
23 changes: 23 additions & 0 deletions apps/gittensory-ui/src/lib/rees-analyzers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<any>` 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);
26 changes: 26 additions & 0 deletions review-enrichment/analyzer-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<any>` 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."
}
}
]
}
40 changes: 40 additions & 0 deletions review-enrichment/src/analyzers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 `<any>` 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 `<any>` 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(
Expand Down
123 changes: 123 additions & 0 deletions review-enrichment/src/analyzers/unsafe-any.ts
Original file line number Diff line number Diff line change
@@ -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 `<any>` 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 `<any>`, but any single-level angle group that contains an `any` token, so
// `Record<string, any>`, `Map<string, any>`, `Promise<any[]>`, and `Array<any>` 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<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 @@ -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: "" };

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 @@ -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 `<any>`
* 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[];
Expand Down Expand Up @@ -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
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 @@ -44,6 +44,7 @@ const EXPECTED_ANALYZERS = [
"terminology",
"todoMarker",
"magicNumber",
"unsafeAny",
];

test("analyzer descriptors cover the runtime registry in stable order", () => {
Expand Down
Loading
Loading