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,25 +68,25 @@ 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,commitLint
# conflictMarker,debugLeftover,sizeSmell,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
# debugLeftover,sizeSmell
# 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,commitLint
# todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,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,commitLint
# conflictMarker,debugLeftover,sizeSmell,commitLint
# END GENERATED REES ANALYZERS

# Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep
Expand Down
25 changes: 25 additions & 0 deletions apps/gittensory-ui/src/lib/rees-analyzers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,31 @@ 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: "sizeSmell",
title: "Size smells",
category: "quality",
cost: "local",
defaultEnabled: true,
profiles: ["fast", "balanced", "deep"],
requires: ["files"],
limits: {
maxFindings: 25,
maxFileLines: 400,
maxFunctionLines: 60,
maxLineChars: 2000,
},
docs: {
summary:
"Flags maintainability size smells from patch structure: an estimated resulting file length or an added function body span that exceeds configured thresholds.",
looksAt: "Unified-diff hunk headers and added non-test source lines.",
reports:
"File, kind (long-file or big-function), measured size, threshold, and optional function name.",
network: "Pure local analyzer. No external network call.",
notes:
"File length is estimated from hunk headers (the visible patch), not a full checkout. Function detection is structural (`function` / arrow-with-brace) and counts added body lines until brace balance returns to zero.",
},
},
{
name: "commitLint",
title: "Conventional-commit subjects",
Expand Down
28 changes: 28 additions & 0 deletions review-enrichment/analyzer-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,34 @@
"notes": "Distinct from the secrets-in-logs analyzer: this catches plain debug noise regardless of payload. String literals are stripped before matching."
}
},
{
"name": "sizeSmell",
"title": "Size smells",
"category": "quality",
"cost": "local",
"defaultEnabled": true,
"profiles": [
"fast",
"balanced",
"deep"
],
"requires": [
"files"
],
"limits": {
"maxFindings": 25,
"maxFileLines": 400,
"maxFunctionLines": 60,
"maxLineChars": 2000
},
"docs": {
"summary": "Flags maintainability size smells from patch structure: an estimated resulting file length or an added function body span that exceeds configured thresholds.",
"looksAt": "Unified-diff hunk headers and added non-test source lines.",
"reports": "File, kind (long-file or big-function), measured size, threshold, and optional function name.",
"network": "Pure local analyzer. No external network call.",
"notes": "File length is estimated from hunk headers (the visible patch), not a full checkout. Function detection is structural (`function` / arrow-with-brace) and counts added body lines until brace balance returns to zero."
}
},
{
"name": "commitLint",
"title": "Conventional-commit subjects",
Expand Down
33 changes: 33 additions & 0 deletions review-enrichment/src/analyzers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { scanLooseRanges } from "./loose-range.js";
import { scanMagicNumbers } from "./magic-number.js";
import { scanConflictMarkers } from "./conflict-marker.js";
import { scanDebugLeftover } from "./debug-leftover.js";
import { scanSizeSmell } from "./size-smell.js";
import { scanCommitLint } from "./commit-lint.js";
import { scanTerminology } from "./terminology.js";
import { scanTodoMarker } from "./todo-marker.js";
Expand Down Expand Up @@ -1013,6 +1014,38 @@ export const ANALYZER_DESCRIPTORS = [
},
run: (req, { signal }) => scanDebugLeftover(req, signal),
}),
descriptor({
name: "sizeSmell",
title: "Size smells",
category: "quality",
cost: "local",
defaultEnabled: true,
requires: ["files"],
limits: { maxFindings: 25, maxFileLines: 400, maxFunctionLines: 60, maxLineChars: 2000 },
docs: {
summary:
"Flags maintainability size smells from patch structure: an estimated resulting file length or an added function body span that exceeds configured thresholds.",
looksAt: "Unified-diff hunk headers and added non-test source lines.",
reports: "File, kind (long-file or big-function), measured size, threshold, and optional function name.",
network: "Pure local analyzer. No external network call.",
notes:
"File length is estimated from hunk headers (the visible patch), not a full checkout. Function detection is structural (`function` / arrow-with-brace) and counts added body lines until brace balance returns to zero.",
},
render: (findings, helpers) => {
if (!findings.length) return [];
const lines = ["### Size smells (long file or big function added by this PR)"];
for (const item of findings) {
const where = item.line ? `${item.file}:${item.line}` : item.file;
const label =
item.kind === "long-file"
? `estimated ${item.measure} lines (threshold ${item.threshold})`
: `${helpers.safeCodeSpan(item.name ?? "function")} body ${item.measure} added lines (threshold ${item.threshold})`;
lines.push(`- ${helpers.safeCodeSpan(where)} — ${helpers.safeCodeSpan(item.kind)}: ${label}`);
}
return lines;
},
run: (req, { signal }) => scanSizeSmell(req, signal),
}),
descriptor({
name: "commitLint",
title: "Conventional-commit subjects",
Expand Down
166 changes: 166 additions & 0 deletions review-enrichment/src/analyzers/size-smell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Size-smell analyzer (#2019). Flags maintainability smells from patch structure alone: a changed file
// whose estimated resulting length exceeds a threshold, or a newly-added function body whose brace span is
// too long. Pure compute over diff hunks, no network. Skips test paths.
import type { EnrichRequest, SizeSmellFinding } from "../types.js";
import { codeOnly } from "./secret-log.js";
import { isTestPath } from "./test-ratio.js";

export const DEFAULT_MAX_FILE_LINES = 400;
export const DEFAULT_MAX_FUNCTION_LINES = 60;
const MAX_FINDINGS = 25;
const MAX_LINE_CHARS = 2000;

const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;

const FUNCTION_BODY_OPEN_RE =
/\bfunction\s+(\w+)\s*\([^)]*\)\s*\{|\b(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:function\s*)?\([^)]*\)\s*=>\s*\{/;

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

/** Estimate the resulting file length from unified-diff hunk headers. Pure. */
export function estimateFileLengthFromPatch(patch: string): number {
let maxEndLine = 0;
for (const line of patch.split("\n")) {
const hunk = HUNK_RE.exec(line);
if (!hunk) continue;
const newStart = Number(hunk[3]);
const newLen = hunk[4] ? Number(hunk[4]) : 1;
maxEndLine = Math.max(maxEndLine, newStart + newLen - 1);
}
return maxEndLine;
}

function functionNameFromLine(line: string): string | undefined {
const code = codeOnly(line);
const match = FUNCTION_BODY_OPEN_RE.exec(code);
if (!match) return undefined;
return match[1] ?? match[2];
}

function braceDepthDelta(code: string): number {
let depth = 0;
for (const ch of code) {
if (ch === "{") depth++;
else if (ch === "}") depth--;
}
return depth;
}

type PendingFunction = {
name: string;
startLine: number;
bodyLines: number;
depth: number;
};
export function scanPatchForSizeSmell(
path: string,
patch: string,
limits: ScanLimits = {},
): SizeSmellFinding[] {
const maxFileLines = limits.maxFileLines ?? DEFAULT_MAX_FILE_LINES;
const maxFunctionLines = limits.maxFunctionLines ?? DEFAULT_MAX_FUNCTION_LINES;
const maxFindings = limits.maxFindings ?? MAX_FINDINGS;
if (maxFindings <= 0 || isTestPath(path)) return [];

const findings: SizeSmellFinding[] = [];
const fileLength = estimateFileLengthFromPatch(patch);
if (fileLength > maxFileLines) {
findings.push({
file: path,
kind: "long-file",
measure: fileLength,
threshold: maxFileLines,
});
if (findings.length >= maxFindings) return findings;
}

let newLine = 0;
let inHunk = false;
let pending: PendingFunction | null = null;

const flushFunction = () => {
if (!pending) return;
if (pending.bodyLines > maxFunctionLines) {
findings.push({
file: path,
line: pending.startLine,
kind: "big-function",
measure: pending.bodyLines,
threshold: maxFunctionLines,
name: pending.name,
});
}
pending = null;
};

for (const line of patch.split("\n")) {
if (limits.signal?.aborted) throw new Error("analyzer_aborted");
const hunk = HUNK_RE.exec(line);
if (hunk) {
flushFunction();
newLine = Number(hunk[3]);
inHunk = true;
continue;
}
if (!inHunk) continue;

if (line.startsWith("+")) {
const body = line.slice(1);
if (body.length <= MAX_LINE_CHARS) {
const code = codeOnly(body);
if (pending) {
pending.bodyLines++;
pending.depth += braceDepthDelta(code);
if (pending.depth <= 0) flushFunction();
} else {
const name = functionNameFromLine(body);
if (name) {
pending = {
name,
startLine: newLine,
bodyLines: 1,
depth: braceDepthDelta(code),
};
if (pending.depth <= 0) flushFunction();
}
}
}
newLine++;
} else {
flushFunction();
if (!line.startsWith("-") && !line.startsWith("\\")) {
newLine++;
}
}

if (findings.length >= maxFindings) return findings;
}

flushFunction();
return findings;
}

/** Analyzer entrypoint: scan changed non-test files for size smells. */
export async function scanSizeSmell(
req: EnrichRequest,
signal?: AbortSignal,
): Promise<SizeSmellFinding[]> {
const findings: SizeSmellFinding[] = [];
for (const file of req.files ?? []) {
if (signal?.aborted) throw new Error("analyzer_aborted");
if (!file.patch) continue;
for (const finding of scanPatchForSizeSmell(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 @@ -482,6 +482,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("sizeSmell", findings.sizeSmell));
lines.push(...renderDescriptorSection("hardcodedUrl", findings.hardcodedUrl));
lines.push(...renderDescriptorSection("commitLint", findings.commitLint));

Expand Down
12 changes: 12 additions & 0 deletions review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,17 @@ export interface DebugLeftoverFinding {
kind: "debugger" | "console" | "print";
}

/** Maintainability size smell from patch structure (#2019, part of #1499).
* Reports estimated file length or added function body span — never source content. */
export interface SizeSmellFinding {
file: string;
line?: number;
kind: "long-file" | "big-function";
measure: number;
threshold: number;
name?: string;
}

/** 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 @@ -539,6 +550,7 @@ export interface BriefFindings {
magicNumber?: MagicNumberFinding[];
conflictMarker?: ConflictMarkerFinding[];
debugLeftover?: DebugLeftoverFinding[];
sizeSmell?: SizeSmellFinding[];
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 @@ -48,6 +48,7 @@ const EXPECTED_ANALYZERS = [
"magicNumber",
"conflictMarker",
"debugLeftover",
"sizeSmell",
"commitLint",
];

Expand Down
Loading
Loading