From a5e161e3298ddbfe8085c717561e9437e72e9b86 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:59:38 -0700 Subject: [PATCH 1/2] feat(rees): add real before/after complexity-delta analyzer complexity.ts can only score newly-added functions (diff-hunk only) against a fixed threshold, so a PR that meaningfully simplifies an existing gnarly function gets no credit. Add a complexityDelta analyzer that uses the shared reconstructOldContent primitive (#4739) to recover a changed file's pre-PR text, re-runs complexity.ts's own decision-point counting against both versions, and diffs matched functions by name into a structured {file,line,name,before,after,delta} finding. Registered as a separate AnalyzerName (github-light, requires a token/headSha) rather than folded into complexity's existing entry: merging the network fetch into that entry's single requires/cost would gate complexity's free, local, always-on check behind github-token/head-sha (scheduler.ts skips a descriptor's run entirely based on declared requires), regressing it whenever either is unavailable, or mislabel the network-dependent half as cost:local. Part of epic #4737 (PR improvement signal), sub-issue #4740. --- apps/gittensory-ui/src/lib/rees-analyzers.ts | 25 ++ review-enrichment/analyzer-metadata.json | 27 ++ .../src/analyzers/complexity-delta.ts | 178 ++++++++++ review-enrichment/src/analyzers/complexity.ts | 101 +++++- review-enrichment/src/analyzers/registry.ts | 33 ++ review-enrichment/src/render.ts | 1 + review-enrichment/src/types.ts | 18 + .../test/analyzer-registry.test.ts | 1 + .../test/complexity-delta.test.ts | 308 ++++++++++++++++++ review-enrichment/test/complexity.test.ts | 76 +++++ src/review/enrichment-analyzer-names.ts | 1 + 11 files changed, 755 insertions(+), 14 deletions(-) create mode 100644 review-enrichment/src/analyzers/complexity-delta.ts create mode 100644 review-enrichment/test/complexity-delta.test.ts diff --git a/apps/gittensory-ui/src/lib/rees-analyzers.ts b/apps/gittensory-ui/src/lib/rees-analyzers.ts index 664a23e1f1..c933445a1d 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -1102,6 +1102,31 @@ export const REES_ANALYZERS = [ "Diff-hunk approximation, not a whole-function true McCabe count: REES has no full-file content, so this counts if/for/while/case/catch/&&/||/?? token occurrences across the function's ADDED body lines only (1 + count), the same function-boundary detection size-smell.ts (#2019) uses for 'big-function'. A function whose signature line is not part of the diff is not scored. Distinct from deep-nesting (#2030), which measures brace NESTING depth, a readability smell, not decision-point density. Ternary (`? :`) is intentionally excluded — see the analyzer source header for why.", }, }, + { + name: "complexityDelta", + title: "Complexity delta (before/after)", + category: "quality", + cost: "github-light", + defaultEnabled: true, + profiles: ["balanced", "deep"], + requires: ["files", "github-token", "head-sha"], + limits: { + maxFiles: 20, + maxFindings: 25, + }, + docs: { + summary: + "Flags a function whose approximate cyclomatic complexity changed between the pre-PR and head versions of a file -- not just newly-added functions.", + looksAt: + "Changed TS/JS source files, reconstructing the pre-PR file at headSha via the shared before-content primitive and re-running complexity's own decision-point counting on both versions.", + reports: + "File, the function's current line, name, and its before/after/delta approximate complexity.", + network: + "Calls the GitHub API for changed file contents at headSha. Requires headSha and token forwarding for private repos.", + notes: + "Complements complexity (new-function absolute threshold): a function whose signature is unchanged but whose body got simpler shows a negative (improving) delta -- the case the absolute-threshold analyzer alone cannot see. A wholly-added file or an unreconstructable patch degrades to zero findings for that file rather than guessing. A function name that recurs more than once in either version is excluded from matching (ambiguous).", + }, + }, { name: "unsafeAny", title: "Unsafe any (TS)", diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index bc7b3ce29d..e43379ee99 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -1244,6 +1244,33 @@ "notes": "Diff-hunk approximation, not a whole-function true McCabe count: REES has no full-file content, so this counts if/for/while/case/catch/&&/||/?? token occurrences across the function's ADDED body lines only (1 + count), the same function-boundary detection size-smell.ts (#2019) uses for 'big-function'. A function whose signature line is not part of the diff is not scored. Distinct from deep-nesting (#2030), which measures brace NESTING depth, a readability smell, not decision-point density. Ternary (`? :`) is intentionally excluded — see the analyzer source header for why." } }, + { + "name": "complexityDelta", + "title": "Complexity delta (before/after)", + "category": "quality", + "cost": "github-light", + "defaultEnabled": true, + "profiles": [ + "balanced", + "deep" + ], + "requires": [ + "files", + "github-token", + "head-sha" + ], + "limits": { + "maxFiles": 20, + "maxFindings": 25 + }, + "docs": { + "summary": "Flags a function whose approximate cyclomatic complexity changed between the pre-PR and head versions of a file -- not just newly-added functions.", + "looksAt": "Changed TS/JS source files, reconstructing the pre-PR file at headSha via the shared before-content primitive and re-running complexity's own decision-point counting on both versions.", + "reports": "File, the function's current line, name, and its before/after/delta approximate complexity.", + "network": "Calls the GitHub API for changed file contents at headSha. Requires headSha and token forwarding for private repos.", + "notes": "Complements complexity (new-function absolute threshold): a function whose signature is unchanged but whose body got simpler shows a negative (improving) delta -- the case the absolute-threshold analyzer alone cannot see. A wholly-added file or an unreconstructable patch degrades to zero findings for that file rather than guessing. A function name that recurs more than once in either version is excluded from matching (ambiguous)." + } + }, { "name": "unsafeAny", "title": "Unsafe any (TS)", diff --git a/review-enrichment/src/analyzers/complexity-delta.ts b/review-enrichment/src/analyzers/complexity-delta.ts new file mode 100644 index 0000000000..a8536d54ea --- /dev/null +++ b/review-enrichment/src/analyzers/complexity-delta.ts @@ -0,0 +1,178 @@ +// Real before/after complexity-delta analyzer (#4740, part of epic #4737's REES/deterministic-tier phase). +// complexity.ts's own `complexity` analyzer explicitly disclaims being a true before/after delta: it normally +// only sees diff hunks, so it can only score a NEWLY-ADDED function (whose opening line is in the diff) against +// a fixed absolute threshold -- a function whose signature is unchanged but whose BODY was edited gets no score +// at all, so a PR that meaningfully SIMPLIFIES a gnarly existing function gets no credit. This analyzer closes +// that gap using the shared reconstructOldContent primitive (#4739): fetch the changed file's post-PR content at +// headSha (the same authed GitHub contents-API fetch doc-comment-drift.ts/exhaustiveness-drift.ts already +// perform for their own purposes), reverse-apply the patch to recover the pre-PR text, run complexity.ts's OWN +// decision-point counting logic (`scanContentForComplexity` -- reused unchanged, not reimplemented) against BOTH +// versions, match functions by name, and diff the two scores. +// +// Registered as a SEPARATE AnalyzerName (`complexityDelta`) rather than folded into `complexity`'s existing +// entry -- see complexity.ts's header for the full reasoning. Short version: merging this network-dependent, +// before/after logic into `complexity`'s single `requires`/`cost` would either (a) gate `complexity`'s existing +// free, local, always-available absolute-threshold check behind `github-token`/`head-sha`, regressing it +// whenever either is unavailable (scheduler.ts's skipReasonForAnalyzer skips a descriptor's `run` entirely based +// on its DECLARED `requires`, before ever calling it -- this is real scheduling behavior, not just docs), or (b) +// mislabel this genuinely network-costed half as `cost: "local"`, letting it dodge the `github-light` +// concurrency/timeout budget and the `fast` profile's network-free guarantee. Two honestly-classified +// descriptors instead of one dishonest one. +// +// A function whose name recurs more than once in either version (ambiguous -- same rule +// scanContentForComplexity/doc-comment-drift.ts's extractFunctionParams already apply) is excluded from +// matching. A function present only in the NEW version has no "before" to diff against -- that is exactly +// `complexity`'s own job, not this analyzer's. A wholly-added file (reconstructOldContent's `""` return) or an +// unreconstructable patch (`null`) are both "no usable before content" and degrade to zero delta findings for +// that file, never a crash -- checked via plain truthiness, NEVER a strict `=== null` compare (see +// reconstruct-old-content.ts's own doc comment: an empty string is falsy but `!== null`, so a strict-null check +// would wrongly treat a brand-new file's "" as valid before-content). +import type { EnrichRequest, ComplexityDeltaFinding } from "../types.js"; +import { githubHeaders } from "../github-headers.js"; +import { reconstructOldContent } from "./reconstruct-old-content.js"; +import { isJsTsPath, scanContentForComplexity } from "./complexity.js"; +import { DEFAULT_MAX_FINDINGS } from "./limits.js"; + +const GITHUB_API = "https://api.github.com"; +const SLUG_RE = /^[A-Za-z0-9._-]+$/; +const MAX_FILES = 20; +const MAX_FINDINGS = DEFAULT_MAX_FINDINGS; +const MAX_FETCH_BYTES = 1_000_000; + +interface ScanOptions { + signal?: AbortSignal; +} + +async function readBoundedText(resp: Response, signal?: AbortSignal): Promise { + const length = Number(resp.headers.get("content-length")); + if (Number.isFinite(length) && length > MAX_FETCH_BYTES) return null; + if (!resp.body) return null; + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let size = 0; + let text = ""; + try { + while (true) { + if (signal?.aborted) return null; + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_FETCH_BYTES) { + await reader.cancel(); + return null; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +async function fetchFileAtHeadSha( + owner: string, + repo: string, + path: string, + headSha: string, + token: string, + fetchFn: typeof fetch, + signal: AbortSignal | undefined, +): Promise { + try { + const encoded = path.split("/").map(encodeURIComponent).join("/"); + const resp = await fetchFn( + `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encoded}?ref=${encodeURIComponent(headSha)}`, + { headers: githubHeaders(token, { raw: true }), signal }, + ); + if (!resp.ok) return null; + return await readBoundedText(resp, signal); + } catch { + return null; + } +} + +/** Full-file-scan the reconstructed OLD content and the NEW (head) content of one file with + * complexity.ts's shared `scanContentForComplexity`, and diff every function matched (unambiguously) by name in + * both. A function with no change in its measured complexity is not reported -- only a real before/after + * difference is a finding, since a "delta" of zero is nothing for the sibling aggregator (#4742) to act on. Pure. */ +export function matchAndDiffFunctions( + file: string, + oldContent: string, + newContent: string, + limits: { maxFindings?: number } = {}, +): ComplexityDeltaFinding[] { + const maxFindings = limits.maxFindings ?? MAX_FINDINGS; + if (maxFindings <= 0) return []; + + const oldScores = scanContentForComplexity(oldContent); + const newScores = scanContentForComplexity(newContent); + + const findings: ComplexityDeltaFinding[] = []; + for (const [name, after] of newScores) { + const before = oldScores.get(name); + if (!before || before.complexity === after.complexity) continue; + findings.push({ + file, + line: after.line, + name, + before: before.complexity, + after: after.complexity, + delta: after.complexity - before.complexity, + }); + if (findings.length >= maxFindings) break; + } + return findings; +} + +/** Analyzer entrypoint: for each changed JS/TS source file, reconstruct its pre-PR content and diff real + * before/after complexity per function. Fail-safe -- never throws on a missing token/headSha, an + * unreconstructable patch, or a fetch error; each degrades to zero findings for that file rather than a crash + * or a guessed answer. */ +export async function scanComplexityDelta( + req: EnrichRequest, + fetchFn: typeof fetch = fetch, + options: ScanOptions = {}, +): Promise { + const { repoFullName, githubToken, headSha, files = [] } = req; + if (!githubToken || !headSha) return []; + const parts = repoFullName.split("/"); + const owner = parts[0]; + const repo = parts[1]; + if (parts.length !== 2 || !owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return []; + + const sources = files.filter((file) => file.patch && isJsTsPath(file.path)).slice(0, MAX_FILES); + + const findings: ComplexityDeltaFinding[] = []; + for (const file of sources) { + if (options.signal?.aborted) break; + + const headContent = await fetchFileAtHeadSha( + owner, + repo, + file.path, + headSha, + githubToken, + fetchFn, + options.signal, + ); + if (!headContent) continue; + if (options.signal?.aborted) break; // an abort during the fetch should suppress this file's findings too + + // `reconstructOldContent` returns EITHER `null` (patch didn't reverse-apply -- malformed/mismatched) OR `""` + // (patch reverse-applied cleanly but the file is wholly new -- no old-side content at all). Both are "no + // usable before content" and must be treated identically via truthiness; a strict `=== null` check would + // wrongly treat the wholly-new-file "" as valid before-content. + const oldContent = reconstructOldContent(headContent, file.patch!); + if (!oldContent) continue; + + for (const finding of matchAndDiffFunctions(file.path, oldContent, headContent, { + maxFindings: MAX_FINDINGS - findings.length, + })) { + findings.push(finding); + if (findings.length >= MAX_FINDINGS) return findings; + } + } + return findings; +} diff --git a/review-enrichment/src/analyzers/complexity.ts b/review-enrichment/src/analyzers/complexity.ts index 1ac5435085..7e6cf76642 100644 --- a/review-enrichment/src/analyzers/complexity.ts +++ b/review-enrichment/src/analyzers/complexity.ts @@ -1,12 +1,25 @@ -// Approximate cyclomatic-complexity analyzer (#1477). REES has no full-file content -- only diff hunks -- so -// this is deliberately NOT a whole-function true McCabe count (that needs a real parser reading the ENTIRE -// function, including any part outside the diff, and a new AST-parser dependency this service does not carry). -// Instead it approximates: for each newly-added function whose OPENING line is visible in the diff (named -// `function` declarations and arrow functions assigned to const/let/var -- the same structural detection -// size-smell.ts (#2019) already uses for "big-function"), it counts branch/loop/logical-operator tokens across -// the function's ADDED body lines only and reports `1 + that count`, the standard McCabe formula computed on -// the visible slice. A function whose signature line is NOT part of the diff (only its body was edited) is not -// attributed a complexity score, the same accepted scope limit size-smell.ts already carries for "big-function". +// Approximate cyclomatic-complexity analyzer (#1477). The `complexity` AnalyzerName this file registers looks +// only at diff hunks -- so it is deliberately NOT a whole-function true McCabe count (that needs a real parser +// reading the ENTIRE function, including any part outside the diff). Instead it approximates: for each newly-added +// function whose OPENING line is visible in the diff (named `function` declarations and arrow functions assigned +// to const/let/var -- the same structural detection size-smell.ts (#2019) already uses for "big-function"), it +// counts branch/loop/logical-operator tokens across the function's ADDED body lines only and reports +// `1 + that count`, the standard McCabe formula computed on the visible slice. A function whose signature line is +// NOT part of the diff (only its body was edited) is not attributed a score by THIS analyzer -- see +// complexity-delta.ts (#4740, part of epic #4737) for the sibling analyzer that covers exactly that case: using +// the shared reconstructOldContent primitive (#4739) to recover the pre-PR file text, it re-runs this file's OWN +// decision-point counting (via `scanContentForComplexity` below, reused unchanged) against both the reconstructed +// old and current head versions of a file and diffs the two per function. +// +// complexity-delta is a SEPARATE AnalyzerName, not a change to this one's `run`/`cost`/`requires` -- merging its +// network-dependent fetch into this entry's single `requires`/`cost` would either (a) gate this free, local, +// always-on absolute-threshold check behind `github-token`/`head-sha`, regressing it whenever either is +// unavailable (scheduler.ts's skipReasonForAnalyzer skips a descriptor's `run` entirely based on its DECLARED +// `requires`, before `run` is ever invoked -- see brief.ts/scheduler.ts), or (b) mislabel the network-dependent +// half as `cost: "local"`, letting it dodge the `github-light` concurrency/timeout budget it honestly consumes +// and silently including it in the `fast` profile, which is meant to stay network-free. Two honestly-classified +// descriptors instead of one dishonest one; `scanContentForComplexity` is what lets both share one counting +// implementation rather than duplicating it. // // Distinct from deep-nesting.ts (#2030), which measures brace NESTING depth -- a readability smell that // analyzer's own header explicitly disclaims as a complexity metric. This analyzer counts DECISION POINTS @@ -20,10 +33,11 @@ // heuristic rejects. if/for/while/case/catch/&&/||/?? are unambiguous token shapes that cover the bulk of // realistic branching. // -// Pure compute over added diff lines, no network, no new dependency. churn-hotspot (#1513) is not precedent for -// a broader one-time fetch here: it fetches commit METADATA that cannot exist in a diff in any form at all, so a -// fetch is its only option; complexity is partially approximable from the diff text itself, so the cheap -// in-hunk approximation -- not a full-file fetch -- is the right scope for this analyzer. +// Pure compute, no network, no new dependency for THIS file's `complexity` `run`. churn-hotspot (#1513) is not +// precedent for a broader one-time fetch here either: it fetches commit METADATA that cannot exist in a diff in +// any form at all, so a fetch is its only option; `complexity`'s absolute-threshold path is fully approximable +// from the diff text itself, so the cheap in-hunk approximation remains the right scope for THAT AnalyzerName +// specifically -- complexity-delta.ts is where the one-time full-file fetch actually lives. import type { ComplexityFinding, EnrichRequest } from "../types.js"; import { codeOnly } from "./secret-log.js"; import { isTestPath } from "./test-ratio.js"; @@ -56,7 +70,10 @@ const DECISION_RES: RegExp[] = [ /\?\?/g, ]; -function isJsTsPath(path: string): boolean { +/** Exported so complexity-delta.ts (#4740) applies the IDENTICAL JS/TS-and-not-test file filter this analyzer's + * own absolute-threshold path uses -- the two are a matched before/after pair over the same file set, so a + * divergent filter between them would be a real (if subtle) correctness bug, not a harmless style choice. */ +export function isJsTsPath(path: string): boolean { return JS_TS_PATH_RE.test(path) && !isTestPath(path); } @@ -212,3 +229,59 @@ export async function scanComplexity( } return findings; } + +/** One function's approximate complexity from a FULL-file scan, keyed by name. Used only to compare two versions + * of the SAME file (see complexity-delta.ts, #4740) -- `line` is meaningless on its own across versions since a + * function's line number shifts with unrelated edits elsewhere in the file; callers match by NAME, not line. */ +export interface ContentComplexityEntry { + line: number; + complexity: number; +} + +/** Scan an entire file's content -- not just a diff's added lines -- for every function's approximate + * complexity, keyed by name and UNFILTERED by any threshold (a low-complexity function is included too, since a + * before/after diff needs both sides, not just the ones currently over a limit). This is the "run the same + * logic against a full file" counterpart to scanPatchForComplexity: it reuses the exact same function-boundary + * detection (`functionNameFromLine`) and decision-point counting (`countDecisionPoints`, via + * `advancePendingFunction`/`braceDepthDelta`) rather than reimplementing them, so a before/after comparison is + * guaranteed to use identical counting rules on both sides. + * + * A name that recurs more than once (e.g. two top-level declarations sharing a name) is EXCLUDED from the + * result entirely -- the same conservative ambiguity rule doc-comment-drift.ts's `extractFunctionParams` already + * applies: never guess which occurrence a shared name refers to. A nested function (one whose opening line + * appears while another function is already pending) is not tracked as its own entry -- the same accepted + * single-level scope limit scanPatchForComplexity already carries -- its decision points still count toward the + * OUTER pending function, on both sides of the comparison equally. Pure. */ +export function scanContentForComplexity( + content: string, + limits: { maxLineChars?: number } = {}, +): Map { + const maxLineChars = limits.maxLineChars ?? MAX_LINE_CHARS; + const byName = new Map(); + const seen = new Set(); + let pending: PendingFunction | null = null; + + const flush = () => { + if (!pending) return; + const done = pending; + pending = null; + if (seen.has(done.name)) { + byName.delete(done.name); // a second declaration of this name -> ambiguous, exclude it entirely + return; + } + seen.add(done.name); + byName.set(done.name, { line: done.startLine, complexity: done.complexity }); + }; + + const lines = content.split("\n"); + for (let lineNo = 0; lineNo < lines.length; lineNo++) { + const body = lines[lineNo]!; + if (body.length > maxLineChars) continue; + const commented = isBasicCommentLine(body); + const code = codeOnly(body); + pending = advancePendingFunction(pending, body, commented, code, lineNo + 1); + if (pending && pending.depth <= 0) flush(); + } + flush(); + return byName; +} diff --git a/review-enrichment/src/analyzers/registry.ts b/review-enrichment/src/analyzers/registry.ts index 5b96e77152..f5770e99bc 100644 --- a/review-enrichment/src/analyzers/registry.ts +++ b/review-enrichment/src/analyzers/registry.ts @@ -37,6 +37,7 @@ import { scanDeepNesting, DEFAULT_MAX_DEPTH } from "./deep-nesting.js"; import { scanI18nRegression } from "./i18n-regression.js"; import { scanErrorSwallow } from "./error-swallow.js"; import { scanComplexity, DEFAULT_MAX_COMPLEXITY } from "./complexity.js"; +import { scanComplexityDelta } from "./complexity-delta.js"; import { scanFloatingPromise } from "./floating-promise.js"; import { scanSizeSmell } from "./size-smell.js"; import { scanA11yRegression } from "./a11y-regression.js"; @@ -1230,6 +1231,38 @@ export const ANALYZER_DESCRIPTORS = [ }, run: (req, { signal }) => scanComplexity(req, signal), }), + descriptor({ + name: "complexityDelta", + title: "Complexity delta (before/after)", + category: "quality", + cost: "github-light", + defaultEnabled: true, + requires: ["files", "github-token", "head-sha"], + limits: { maxFiles: 20, maxFindings: DEFAULT_MAX_FINDINGS }, + docs: { + summary: + "Flags a function whose approximate cyclomatic complexity changed between the pre-PR and head versions of a file -- not just newly-added functions.", + looksAt: + "Changed TS/JS source files, reconstructing the pre-PR file at headSha via the shared before-content primitive and re-running complexity's own decision-point counting on both versions.", + reports: "File, the function's current line, name, and its before/after/delta approximate complexity.", + network: + "Calls the GitHub API for changed file contents at headSha. Requires headSha and token forwarding for private repos.", + notes: + "Complements complexity (new-function absolute threshold): a function whose signature is unchanged but whose body got simpler shows a negative (improving) delta -- the case the absolute-threshold analyzer alone cannot see. A wholly-added file or an unreconstructable patch degrades to zero findings for that file rather than guessing. A function name that recurs more than once in either version is excluded from matching (ambiguous).", + }, + render: (findings, helpers) => { + if (!findings.length) return []; + const lines = ["### Complexity delta (before vs. after this PR)"]; + for (const item of findings) { + const location = helpers.safeCodeSpan(`${item.file}:${item.line}`); + const name = helpers.safeCodeSpan(item.name); + const sign = item.delta > 0 ? "+" : ""; + lines.push(`- ${location} — ${name}: complexity ${item.before} → ${item.after} (${sign}${item.delta})`); + } + return lines; + }, + run: (req, { signal }) => scanComplexityDelta(req, fetch, { signal }), + }), descriptor({ name: "unsafeAny", title: "Unsafe any (TS)", diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 01d4aabea5..f289fb8025 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -499,6 +499,7 @@ export function renderBrief( lines.push(...renderDescriptorSection("deepNesting", findings.deepNesting)); lines.push(...renderDescriptorSection("errorSwallow", findings.errorSwallow)); lines.push(...renderDescriptorSection("complexity", findings.complexity)); + lines.push(...renderDescriptorSection("complexityDelta", findings.complexityDelta)); lines.push(...renderDescriptorSection("unsafeAny", findings.unsafeAny)); lines.push(...renderDescriptorSection("a11y", findings.a11y)); lines.push(...renderDescriptorSection("i18n", findings.i18n)); diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 7c1d41e80f..f2d4776ffe 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -556,6 +556,23 @@ export interface ComplexityFinding { threshold: number; } +/** A real before/after cyclomatic-complexity delta for a function that existed BEFORE this PR too and whose body + * changed (#4740, part of epic #4737) -- the true-delta counterpart complexity.ts's own header comment + * explicitly disclaims being (that analyzer only scores NEWLY-added functions against a fixed threshold, since + * it normally has no full-file content). Using the shared reconstructOldContent primitive (#4739) to recover the + * pre-PR file text, both the reconstructed OLD and current HEAD versions are scanned with complexity.ts's OWN + * decision-point counting logic (unchanged, just run twice) and matched by function name. A negative `delta` is + * an IMPROVEMENT (the function got simpler); a positive `delta` is a regression. Reports file, the function's + * CURRENT (head) line, name, and both raw scores -- never source content. */ +export interface ComplexityDeltaFinding { + file: string; + line: number; + name: string; + before: number; + after: number; + delta: number; +} + /** Deep control-flow nesting newly added in the diff (#2030, part of #1499). * Reports file, line, measured depth, and threshold — never source content. */ export interface DeepNestingFinding { @@ -718,6 +735,7 @@ export interface BriefFindings { deepNesting?: DeepNestingFinding[]; errorSwallow?: ErrorSwallowFinding[]; complexity?: ComplexityFinding[]; + complexityDelta?: ComplexityDeltaFinding[]; unsafeAny?: UnsafeAnyFinding[]; a11y?: A11yFinding[]; i18n?: I18nFinding[]; diff --git a/review-enrichment/test/analyzer-registry.test.ts b/review-enrichment/test/analyzer-registry.test.ts index 7efc171ae7..794a24f38c 100644 --- a/review-enrichment/test/analyzer-registry.test.ts +++ b/review-enrichment/test/analyzer-registry.test.ts @@ -54,6 +54,7 @@ const EXPECTED_ANALYZERS = [ "deepNesting", "errorSwallow", "complexity", + "complexityDelta", "unsafeAny", "a11y", "i18n", diff --git a/review-enrichment/test/complexity-delta.test.ts b/review-enrichment/test/complexity-delta.test.ts new file mode 100644 index 0000000000..f195ba52b3 --- /dev/null +++ b/review-enrichment/test/complexity-delta.test.ts @@ -0,0 +1,308 @@ +// Units for the real before/after complexity-delta analyzer (#4740, part of epic #4737). Own file (not +// complexity.test.ts) so concurrent analyzer PRs don't collide. Runs against the compiled dist/. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { matchAndDiffFunctions, scanComplexityDelta } from "../dist/analyzers/complexity-delta.js"; +import { renderBrief } from "../dist/render.js"; + +const baseReq = (files) => ({ + repoFullName: "o/r", + prNumber: 1, + headSha: "abc123", + githubToken: "ght", + files, +}); +const fileWith = (content, init) => async () => new Response(content, init); +const status = (code) => async () => new Response("", { status: code }); + +// A function whose signature is UNCHANGED but whose body drops 3 of its 4 `if` checks: reconstructOldContent +// reverse-applies this patch onto HEAD_CONTENT to recover a 4-`if` "before" version (complexity 5), diffed +// against the 1-`if` "after" version (complexity 2) -- the exact "simplifies a gnarly existing function" case +// the current diff-hunk-only `complexity` analyzer cannot see at all (its signature line isn't in this diff). +const HEAD_CONTENT = "export function calc(x) {\n if (a) {}\n return x;\n}\n"; +const CALC_PATCH = [ + "@@ -1,7 +1,4 @@", + " export function calc(x) {", + " if (a) {}", + "- if (b) {}", + "- if (c) {}", + "- if (d) {}", + " return x;", + " }", +].join("\n"); + +test("matchAndDiffFunctions: a function with an unchanged signature but a simplified body shows a negative (improving) delta", () => { + // This is the whole point of #4740: complexity.ts's own diff-hunk-only analyzer cannot see this at all, since + // calc's signature line never appears in a diff -- only its full before/after body content does here. + const oldContent = "function calc(x) {\n if (a) {}\n if (b) {}\n if (c) {}\n if (d) {}\n return x;\n}\n"; + const newContent = "function calc(x) {\n if (a) {}\n return x;\n}\n"; + const findings = matchAndDiffFunctions("src/calc.ts", oldContent, newContent); + assert.deepEqual(findings, [{ file: "src/calc.ts", line: 1, name: "calc", before: 5, after: 2, delta: -3 }]); +}); + +test("matchAndDiffFunctions: a function that gained branches shows a positive (regressing) delta", () => { + const oldContent = "function calc(x) {\n if (a) {}\n return x;\n}\n"; + const newContent = "function calc(x) {\n if (a) {}\n if (b) {}\n if (c) {}\n return x;\n}\n"; + const findings = matchAndDiffFunctions("src/calc.ts", oldContent, newContent); + assert.deepEqual(findings, [{ file: "src/calc.ts", line: 1, name: "calc", before: 2, after: 4, delta: 2 }]); +}); + +test("matchAndDiffFunctions: a function with no complexity change produces no finding", () => { + const content = "function calc(x) {\n if (a) {}\n return x;\n}\n"; + assert.deepEqual(matchAndDiffFunctions("src/calc.ts", content, content), []); +}); + +test("matchAndDiffFunctions: a name declared more than once in the OLD version is excluded from matching (ambiguous)", () => { + const oldContent = "function dup() {\n if (a) {}\n}\nfunction dup() {\n if (b) {}\n if (c) {}\n}\n"; + const newContent = "function dup() {\n if (a) {}\n if (b) {}\n}\n"; + // "dup" is excluded from the OLD scan's map (declared twice, ambiguous) -- treated the same as "genuinely new". + assert.deepEqual(matchAndDiffFunctions("src/x.ts", oldContent, newContent), []); +}); + +test("matchAndDiffFunctions: a function present only in the NEW version has no finding (that is complexity's own job)", () => { + const oldContent = "const unrelated = 1;\n"; + const newContent = "function brandNew() {\n if (a) {}\n}\n"; + assert.deepEqual(matchAndDiffFunctions("src/x.ts", oldContent, newContent), []); +}); + +test("matchAndDiffFunctions: respects a maxFindings cap", () => { + const n = 5; + const oldContent = Array.from({ length: n }, (_, i) => `function fn${i}() {\n if (a) {}\n}`).join("\n"); + const newContent = Array.from({ length: n }, (_, i) => `function fn${i}() {}`).join("\n"); + const findings = matchAndDiffFunctions("src/x.ts", oldContent, newContent, { maxFindings: 2 }); + assert.equal(findings.length, 2); +}); + +test("matchAndDiffFunctions: a non-positive maxFindings yields no findings", () => { + assert.deepEqual( + matchAndDiffFunctions("src/x.ts", "function calc(x) {}\n", "function calc(x) {\n if (a) {}\n}\n", { + maxFindings: 0, + }), + [], + ); +}); + +test("scanComplexityDelta: a function with an unchanged signature but a simplified body shows a negative delta end to end", async () => { + // Drives the REAL entrypoint: fetches head content, reverse-applies the patch via reconstructOldContent, and + // diffs -- not just the pure matchAndDiffFunctions helper above. + const findings = await scanComplexityDelta( + baseReq([{ path: "src/calc.ts", patch: CALC_PATCH }]), + fileWith(HEAD_CONTENT), + ); + assert.equal(findings.length, 1); + assert.deepEqual(findings[0], { file: "src/calc.ts", line: 1, name: "calc", before: 5, after: 2, delta: -3 }); +}); + +test("scanComplexityDelta: requires a github token and a head sha", async () => { + assert.deepEqual( + await scanComplexityDelta( + { repoFullName: "o/r", prNumber: 1, headSha: "x", files: [{ path: "src/a.ts", patch: CALC_PATCH }] }, + fileWith(HEAD_CONTENT), + ), + [], + ); + assert.deepEqual( + await scanComplexityDelta( + { repoFullName: "o/r", prNumber: 1, githubToken: "t", files: [{ path: "src/a.ts", patch: CALC_PATCH }] }, + fileWith(HEAD_CONTENT), + ), + [], + ); +}); + +test("scanComplexityDelta: rejects multi-segment repo slugs without fetching", async () => { + let called = false; + const out = await scanComplexityDelta( + { + repoFullName: "o/r/extra", + prNumber: 1, + headSha: "abc123", + githubToken: "ght", + files: [{ path: "src/a.ts", patch: CALC_PATCH }], + }, + async () => { + called = true; + return fileWith(HEAD_CONTENT)(); + }, + ); + assert.deepEqual(out, []); + assert.equal(called, false); +}); + +test("scanComplexityDelta: skips non-source, test, and patch-less files without fetching", async () => { + let called = false; + const out = await scanComplexityDelta( + baseReq([ + { path: "README.md", patch: CALC_PATCH }, + { path: "src/a.test.ts", patch: CALC_PATCH }, + { path: "src/a.ts" }, // no patch at all + ]), + async () => { + called = true; + return fileWith(HEAD_CONTENT)(); + }, + ); + assert.deepEqual(out, []); + assert.equal(called, false); +}); + +test("scanComplexityDelta: fails safe on a non-ok or throwing fetch", async () => { + assert.deepEqual(await scanComplexityDelta(baseReq([{ path: "src/a.ts", patch: CALC_PATCH }]), status(404)), []); + assert.deepEqual( + await scanComplexityDelta(baseReq([{ path: "src/a.ts", patch: CALC_PATCH }]), async () => { + throw new Error("network"); + }), + [], + ); +}); + +test("scanComplexityDelta: skips oversized file responses before reading the body", async () => { + let bodyAccessed = false; + const out = await scanComplexityDelta(baseReq([{ path: "src/a.ts", patch: CALC_PATCH }]), async () => ({ + ok: true, + headers: new Headers({ "content-length": "1000001" }), + get body() { + bodyAccessed = true; + return new Response(HEAD_CONTENT).body; + }, + })); + assert.deepEqual(out, []); + assert.equal(bodyAccessed, false); +}); + +test("scanComplexityDelta: a response with no body yields no findings", async () => { + const out = await scanComplexityDelta( + baseReq([{ path: "src/a.ts", patch: CALC_PATCH }]), + async () => ({ ok: true, headers: new Headers(), body: null }), + ); + assert.deepEqual(out, []); +}); + +test("scanComplexityDelta: cancels streamed file responses that exceed the byte cap", async () => { + let canceled = false; + const chunk = new Uint8Array(500_001); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }); + const out = await scanComplexityDelta(baseReq([{ path: "src/a.ts", patch: CALC_PATCH }]), async () => new Response(stream)); + assert.deepEqual(out, []); + assert.equal(canceled, true); +}); + +test("scanComplexityDelta: stops on an already-aborted signal", async () => { + const out = await scanComplexityDelta(baseReq([{ path: "src/a.ts", patch: CALC_PATCH }]), fileWith(HEAD_CONTENT), { + signal: AbortSignal.abort(), + }); + assert.deepEqual(out, []); +}); + +test("scanComplexityDelta: an abort that becomes true before the body read begins yields no findings for that file", async () => { + // The signal is still false when the per-file loop's pre-fetch check runs, but flips true INSIDE the fetch + // itself -- readBoundedText's own first internal check (not the outer one) must catch this. + const abortController = new AbortController(); + const out = await scanComplexityDelta( + baseReq([{ path: "src/a.ts", patch: CALC_PATCH }]), + async () => { + abortController.abort(); + return new Response(HEAD_CONTENT); + }, + { signal: abortController.signal }, + ); + assert.deepEqual(out, []); +}); + +test("scanComplexityDelta: an abort that fires only after a file's content is fully read stops further files", async () => { + // The signal flips true DURING the body read's final chunk (after readBoundedText's last internal check already + // passed), so readBoundedText itself returns the content successfully -- the OUTER post-fetch check must still + // catch it and stop before a second file is ever fetched. + const abortController = new AbortController(); + let fetchCalls = 0; + const out = await scanComplexityDelta( + baseReq([ + { path: "src/a.ts", patch: CALC_PATCH }, + { path: "src/b.ts", patch: CALC_PATCH }, + ]), + async () => { + fetchCalls += 1; + let pullCount = 0; + const stream = new ReadableStream({ + pull(controller) { + pullCount += 1; + if (pullCount === 1) { + controller.enqueue(new TextEncoder().encode(HEAD_CONTENT)); + } else { + abortController.abort(); + controller.close(); + } + }, + }); + return new Response(stream); + }, + { signal: abortController.signal }, + ); + assert.deepEqual(out, []); + assert.equal(fetchCalls, 1); +}); + +test("scanComplexityDelta: an unreconstructable (malformed/mismatched) patch degrades to no findings, not a crash", async () => { + // The context line " other" does not match the mocked head content -> reconstructOldContent returns null. + const out = await scanComplexityDelta( + baseReq([{ path: "src/a.ts", patch: "@@ -1,2 +1,2 @@\n-x\n+a\n other" }]), + fileWith("a\nb\n"), + ); + assert.deepEqual(out, []); +}); + +test("scanComplexityDelta: a wholly new file (patch reverse-applies to an empty string) degrades to no findings, not a crash", async () => { + // Distinct from the null case above: the patch reverse-applies CLEANLY but yields zero old-side content (a + // wholly-added file). Both are falsy and must be handled identically via truthiness. + const newFileContent = "export function add(x, y) {\n return x + y;\n}\n"; + const newFilePatch = "@@ -0,0 +1,3 @@\n+export function add(x, y) {\n+ return x + y;\n+}"; + const out = await scanComplexityDelta( + baseReq([{ path: "src/new.ts", patch: newFilePatch }]), + fileWith(newFileContent), + ); + assert.deepEqual(out, []); +}); + +test("scanComplexityDelta: respects the findings cap across files and stops fetching further ones once reached", async () => { + const n = 26; // one more than DEFAULT_MAX_FINDINGS (25), so the 26th function must be dropped + const newLines = []; + const patchBody = []; + for (let i = 0; i < n; i++) { + newLines.push(`function fn${i}() {`, "}"); + patchBody.push(` function fn${i}() {`, "- if (a) {}", " }"); + } + const newContent = `${newLines.join("\n")}\n`; + const patch = [`@@ -1,${n * 3} +1,${n * 2} @@`, ...patchBody].join("\n"); + + let fetchCalls = 0; + const out = await scanComplexityDelta( + baseReq([ + { path: "src/many.ts", patch }, + { path: "src/never-reached.ts", patch }, + ]), + async () => { + fetchCalls += 1; + return new Response(newContent); + }, + ); + assert.equal(out.length, 25); + assert.equal(fetchCalls, 1); // the cap was hit mid-file-1, so file 2 is never fetched +}); + +test("renderBrief emits a public-safe complexity-delta block", () => { + const { promptSection } = renderBrief({ + complexityDelta: [{ file: "src/calc.ts", line: 1, name: "calc", before: 5, after: 2, delta: -3 }], + }); + assert.match(promptSection, /Complexity delta/); + assert.match(promptSection, /src\/calc\.ts:1/); + assert.match(promptSection, /calc/); + assert.match(promptSection, /5.*2.*-3/); +}); diff --git a/review-enrichment/test/complexity.test.ts b/review-enrichment/test/complexity.test.ts index 64762f9ac8..1bd8101840 100644 --- a/review-enrichment/test/complexity.test.ts +++ b/review-enrichment/test/complexity.test.ts @@ -5,7 +5,9 @@ import { countDecisionPoints, DEFAULT_MAX_COMPLEXITY, functionNameFromLine, + isJsTsPath, scanComplexity, + scanContentForComplexity, scanPatchForComplexity, } from "../dist/analyzers/complexity.js"; import { renderBrief } from "../dist/render.js"; @@ -197,3 +199,77 @@ test("scanComplexity: aggregates across files and renders a public-safe brief", assert.match(promptSection, /src\/a\.ts:1/); assert.match(promptSection, /big/); }); + +// scanContentForComplexity (#4740): the full-file-scan counterpart to scanPatchForComplexity, used by +// complexity-delta.ts to score a reconstructed pre-PR file and the current head file with identical logic. +// Own section (not complexity-delta.test.ts) since the function itself is exported from this file. + +test("scanContentForComplexity: scores a function from full file content, not just diff-added lines", () => { + // No patch/hunk involved at all -- this is the capability gap scanPatchForComplexity cannot cover: a function + // whose signature line never appears in any diff. + const content = ["function existing() {", " if (a) {}", " if (b) {}", " return 1;", "}"].join("\n"); + const scores = scanContentForComplexity(content); + assert.deepEqual(scores.get("existing"), { line: 1, complexity: 3 }); +}); + +test("scanContentForComplexity: includes every function regardless of threshold (unfiltered, unlike the diff-hunk pass)", () => { + const ifCount = DEFAULT_MAX_COMPLEXITY + 1; + const content = [ + "function low() {", + " return 1;", + "}", + "function high() {", + ...Array.from({ length: ifCount }, (_, i) => ` if (cond${i}) {}`), + "}", + ].join("\n"); + const scores = scanContentForComplexity(content); + assert.deepEqual(scores.get("low"), { line: 1, complexity: 1 }); + assert.equal(scores.get("high")?.complexity, 1 + ifCount); +}); + +test("scanContentForComplexity: arrow functions are scored the same as named functions", () => { + const content = ["export const run = () => {", " if (a) {}", " if (b) {}", "};"].join("\n"); + assert.deepEqual(scanContentForComplexity(content).get("run"), { line: 1, complexity: 3 }); +}); + +test("scanContentForComplexity: a comment-only line does not inflate complexity", () => { + const content = ["function f() {", " if (a) {}", " // pretend this checks something else too", "}"].join("\n"); + assert.deepEqual(scanContentForComplexity(content).get("f"), { line: 1, complexity: 2 }); +}); + +test("scanContentForComplexity: skips a line beyond the line-length cap without corrupting the pending function", () => { + const overLongLine = ` if (${"a".repeat(2100)}) {}`; // over the default 2000-char cap + const content = ["function f() {", overLongLine, " if (b) {}", "}"].join("\n"); + // The over-long line's "if (" is never counted (skipped entirely); only the short "if (b)" is. + assert.deepEqual(scanContentForComplexity(content).get("f"), { line: 1, complexity: 2 }); +}); + +test("scanContentForComplexity: excludes a name declared more than once (ambiguous match target)", () => { + const content = ["function dup() {", " if (a) {}", "}", "function dup() {", " if (b) {}", " if (c) {}", "}"].join( + "\n", + ); + assert.equal(scanContentForComplexity(content).has("dup"), false); +}); + +test("scanContentForComplexity: a nested function's decision points count toward the outer pending function", () => { + // Same single-level scope limit scanPatchForComplexity already carries: a function opening while another is + // already pending is never tracked as its own entry. + const content = ["function outer() {", " function inner() {", " if (a) {}", " }", " if (b) {}", "}"].join( + "\n", + ); + const scores = scanContentForComplexity(content); + assert.equal(scores.has("inner"), false); + assert.deepEqual(scores.get("outer"), { line: 1, complexity: 3 }); +}); + +test("scanContentForComplexity: empty content yields an empty map", () => { + assert.equal(scanContentForComplexity("").size, 0); +}); + +test("isJsTsPath: matches JS/TS source extensions, excludes test paths and other languages", () => { + assert.equal(isJsTsPath("src/widget.ts"), true); + assert.equal(isJsTsPath("src/widget.tsx"), true); + assert.equal(isJsTsPath("src/widget.mjs"), true); + assert.equal(isJsTsPath("src/widget.py"), false); + assert.equal(isJsTsPath("src/widget.test.ts"), false); +}); diff --git a/src/review/enrichment-analyzer-names.ts b/src/review/enrichment-analyzer-names.ts index e6580b36cf..6e14794977 100644 --- a/src/review/enrichment-analyzer-names.ts +++ b/src/review/enrichment-analyzer-names.ts @@ -47,6 +47,7 @@ export const REES_ANALYZER_NAMES = [ "deepNesting", "errorSwallow", "complexity", + "complexityDelta", "unsafeAny", "a11y", "i18n", From 92b670697bbbf068c4d16b7831b37d69b1becdb2 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:14:20 -0700 Subject: [PATCH 2/2] fix(rees): mirror complexityDelta into the engine-package analyzer-names twin src/review/enrichment-analyzer-names.ts and its hand-duplicated engine-package counterpart must stay in normalized parity; the complexityDelta entry added in #4740 only landed on the main-app copy. --- .env.example | 13 +++++++------ .../src/review/enrichment-analyzer-names.ts | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 5ea3445a87..ee9ea033ed 100644 --- a/.env.example +++ b/.env.example @@ -69,8 +69,8 @@ GITTENSORY_REVIEW_ENRICHMENT=false # churnHotspot,blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch # commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology # todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting -# errorSwallow,complexity,unsafeAny,a11y,i18n,unusedExport,exhaustiveness,flakyTest,commitLint -# apiBreak,deprecatedDep,revertRecurrence,coverageDelta,callerImpact +# errorSwallow,complexity,complexityDelta,unsafeAny,a11y,i18n,unusedExport,exhaustiveness +# flakyTest,commitLint,apiBreak,deprecatedDep,revertRecurrence,coverageDelta,callerImpact # # Profile defaults: # fast: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency @@ -84,16 +84,17 @@ GITTENSORY_REVIEW_ENRICHMENT=false # duplicationDelta,churnHotspot,blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport # staleBranch,commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange # terminology,todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise -# deepNesting,errorSwallow,complexity,unsafeAny,a11y,i18n,unusedExport,exhaustiveness,flakyTest -# commitLint,apiBreak,deprecatedDep,revertRecurrence,coverageDelta,callerImpact +# deepNesting,errorSwallow,complexity,complexityDelta,unsafeAny,a11y,i18n,unusedExport +# exhaustiveness,flakyTest,commitLint,apiBreak,deprecatedDep,revertRecurrence,coverageDelta +# callerImpact # deep: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat # commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication,duplicationDelta # churnHotspot,blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch # commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology # todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting -# errorSwallow,complexity,unsafeAny,a11y,i18n,unusedExport,exhaustiveness,flakyTest,commitLint -# apiBreak,deprecatedDep,revertRecurrence,coverageDelta,callerImpact +# errorSwallow,complexity,complexityDelta,unsafeAny,a11y,i18n,unusedExport,exhaustiveness +# flakyTest,commitLint,apiBreak,deprecatedDep,revertRecurrence,coverageDelta,callerImpact # END GENERATED REES ANALYZERS # Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep diff --git a/packages/gittensory-engine/src/review/enrichment-analyzer-names.ts b/packages/gittensory-engine/src/review/enrichment-analyzer-names.ts index e6580b36cf..6e14794977 100644 --- a/packages/gittensory-engine/src/review/enrichment-analyzer-names.ts +++ b/packages/gittensory-engine/src/review/enrichment-analyzer-names.ts @@ -47,6 +47,7 @@ export const REES_ANALYZER_NAMES = [ "deepNesting", "errorSwallow", "complexity", + "complexityDelta", "unsafeAny", "a11y", "i18n",