diff --git a/review-enrichment/src/analyzers/static-analysis.ts b/review-enrichment/src/analyzers/static-analysis.ts new file mode 100644 index 0000000000..1edbbb8a5b --- /dev/null +++ b/review-enrichment/src/analyzers/static-analysis.ts @@ -0,0 +1,343 @@ +// Static analysis + complexity analyzer (#1477). Scans the ADDED lines of each changed source file for common +// static-defect patterns (eval, debugger, empty-catch, loose equality, console in production code, floating +// promises) and estimates cyclomatic complexity per changed function from decision-point counting. Deterministic, +// pure, no external tools or repo checkout needed — works directly on the patch like the other REES analyzers. +// +// The lint rules are a curated subset that catches real defects without type information (the patch alone is +// enough). This is additive + fail-safe: clean code produces no findings; a timeout/abort degrades to []. +// Language detection gates which rules apply (e.g. `var`/`==` checks are JS/TS-specific). +import type { + EnrichRequest, + StaticLintFinding, + ComplexityFinding, +} from "../types.js"; + +const MAX_LINT_FINDINGS = 25; +const MAX_COMPLEXITY_FINDINGS = 10; +const COMPLEXITY_THRESHOLD = 10; // flag functions with cyclomatic >= this (matches eslint's default) +const MAX_LINE_CHARS = 2000; + +// ── Language detection ──────────────────────────────────────────────────────── + +export type SourceLanguage = + | "typescript" + | "javascript" + | "python" + | "go" + | null; + +export function detectLanguage(path: string): SourceLanguage { + if (/\.(?:tsx?|mts|cts)$/.test(path)) return "typescript"; + if (/\.(?:jsx?|mjs|cjs)$/.test(path)) return "javascript"; + if (/\.py$/i.test(path)) return "python"; + if (/\.go$/i.test(path)) return "go"; + return null; +} + +// ── Lint rules ──────────────────────────────────────────────────────────────── +// Each rule is a flat regex tested against a single added line (after stripping the `+` prefix). The flat +// alternation keeps each linear-time — no nested quantifiers, no backtracking risk on adversarial input. + +type LintRule = { + rule: string; + severity: StaticLintFinding["severity"]; + message: string; + re: RegExp; + languages: Set; +}; + +const LINT_RULES: LintRule[] = [ + { + rule: "no-eval", + severity: "error", + message: + "`eval()` allows arbitrary code execution from attacker-controlled input.", + re: /\beval\s*\(/, + languages: new Set([ + "typescript", + "javascript", + "python", + ] as SourceLanguage[]), + }, + { + rule: "no-debugger", + severity: "error", + message: "`debugger` statement left in production code.", + re: /\bdebugger\b/, + languages: new Set(["typescript", "javascript"] as SourceLanguage[]), + }, + { + rule: "no-console", + severity: "warning", + message: + "`console` call left in production code — remove or route through a logger.", + re: /\bconsole\s*\.\s*(?:log|debug|info|warn|error|trace|dir|table)\s*\(/, + languages: new Set(["typescript", "javascript"] as SourceLanguage[]), + }, + { + rule: "no-empty-catch", + severity: "warning", + message: "Empty catch block silently swallows errors.", + re: /\bcatch\s*\([^)]*\)\s*\{\s*\}/, + languages: new Set(["typescript", "javascript"] as SourceLanguage[]), + }, + { + rule: "eqeqeq", + severity: "warning", + message: + "Use strict equality (`===` / `!==`) instead of loose (`==` / `!=`).", + re: /[^=!<>]==[^=]|[^=!<>]!=[^=]/, + languages: new Set(["typescript", "javascript"] as SourceLanguage[]), + }, + { + rule: "no-unawaited-call", + severity: "warning", + message: + "Standalone call expression — if this returns a Promise, errors are silently lost; add `await` or chain `.catch`.", + re: /(?:^|[{(;,])\s*(?!.*\bawait\b)(?!.*\breturn\b)[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\([^)]*\)\s*(?:;(?:\s|$)|$)/, + languages: new Set(["typescript", "javascript"] as SourceLanguage[]), + }, + { + rule: "no-var", + severity: "warning", + message: "Use `let` or `const` instead of `var`.", + re: /\bvar\s+/, + languages: new Set(["typescript", "javascript"] as SourceLanguage[]), + }, + { + rule: "no-bare-except", + severity: "warning", + message: + "Bare `except:` catches all exceptions including SystemExit/KeyboardInterrupt — catch specific types.", + re: /\bexcept\s*:/, + languages: new Set(["python"] as SourceLanguage[]), + }, +]; + +function* patchLines(patch: string): Generator { + let start = 0; + for (let i = 0; i <= patch.length; i++) { + if (i === patch.length || patch[i] === "\n") { + yield patch.slice(start, i); + start = i + 1; + } + } +} + +type ScanLimits = { + maxFindings?: number; + signal?: AbortSignal; +}; + +/** Scan one file's added lines for static-defect patterns. Pure + deterministic. */ +export function scanPatchForStaticLint( + path: string, + patch: string, + limits: ScanLimits = {}, +): StaticLintFinding[] { + const maxFindings = limits.maxFindings ?? MAX_LINT_FINDINGS; + if (maxFindings <= 0) return []; + const lang = detectLanguage(path); + if (!lang) return []; + const applicableRules = LINT_RULES.filter((r) => r.languages.has(lang)); + if (applicableRules.length === 0) return []; + + const findings: StaticLintFinding[] = []; + let newLine = 0; + for (const line of patchLines(patch)) { + if (limits.signal?.aborted) throw new Error("analyzer_aborted"); + if (line.startsWith("+++") || line.startsWith("---")) continue; + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (hunk) { + newLine = Number(hunk[1]); + continue; + } + if (line.startsWith("+")) { + const body = line.slice(1); + if (body.length <= MAX_LINE_CHARS) { + for (const rule of applicableRules) { + if (rule.re.test(body)) { + findings.push({ + file: path, + line: newLine, + rule: rule.rule, + severity: rule.severity, + message: rule.message, + }); + if (findings.length >= maxFindings) return findings; + break; // one finding per line — the first matching rule + } + } + } + newLine++; + } else if (!line.startsWith("-")) { + newLine++; + } + } + return findings; +} + +// ── Cyclomatic complexity ───────────────────────────────────────────────────── + +const DECISION_RE = /\b(?:if|else\s+if|for|while|case|catch)\b|\?|&&|\|\|/g; +const FUNCTION_DECL_RE = + /(?:function\s+([A-Za-z_$][\w$]*)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|def\s+([A-Za-z_][\w]*)|func\s+([A-Za-z_][\w]*))/; + +/** Count decision points in a code line (the cyclomatic-complexity increment). Pure. */ +export function countDecisions(line: string): number { + const matches = line.match(DECISION_RE); + return matches ? matches.length : 0; +} + +/** Extract the function name from a declaration line, or null if the line isn't a function declaration. Pure. */ +export function extractFunctionName(line: string): string | null { + const m = FUNCTION_DECL_RE.exec(line); + if (!m) return null; + return m[1] ?? m[2] ?? m[3] ?? m[4] ?? null; +} + +interface FunctionAccumulator { + name: string; + cyclomatic: number; + churn: number; + /** The brace depth at the function's opening brace — the function ends when depth returns to this. */ + baseDepth: number; +} + +/** Scan one file's added lines for high-complexity functions. Pure + deterministic. + * Detects function declarations from BOTH added lines AND context lines so that added decision logic + * inside an existing unchanged function is correctly counted. + * Limited to TypeScript/JavaScript — Go/Python function-end tracking (indentation-based for Python) is a + * follow-up; without it, decision points would leak across function boundaries. */ +export function scanPatchForComplexity( + path: string, + patch: string, + limits: ScanLimits = {}, +): ComplexityFinding[] { + const maxFindings = limits.maxFindings ?? MAX_COMPLEXITY_FINDINGS; + if (maxFindings <= 0) return []; + const lang = detectLanguage(path); + if (lang !== "typescript" && lang !== "javascript") return []; + + const findings: ComplexityFinding[] = []; + let current: FunctionAccumulator | null = null; + let braceDepth = 0; + let newLine = 0; + + const flush = () => { + if (current && current.cyclomatic >= COMPLEXITY_THRESHOLD) { + findings.push({ + file: path, + function: current.name, + cyclomatic: current.cyclomatic, + churn: current.churn, + }); + } + current = null; + }; + + for (const line of patchLines(patch)) { + if (limits.signal?.aborted) throw new Error("analyzer_aborted"); + if (line.startsWith("+++") || line.startsWith("---")) continue; + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (hunk) { + flush(); + braceDepth = 0; + newLine = Number(hunk[1]); + continue; + } + const isAdded = line.startsWith("+"); + const isRemoved = line.startsWith("-"); + const body = isAdded ? line.slice(1) : isRemoved ? line.slice(1) : line; + if (body.length > MAX_LINE_CHARS) { + if (isAdded) newLine++; + else if (!isRemoved) newLine++; + continue; + } + + // Detect a new function declaration from EITHER added lines OR context lines (the blocker fix: + // a PR that adds `if`/`for`/`&&` inside an existing function whose declaration is just hunk context + // must still produce a complexity finding for that function). + const fnName = extractFunctionName(body); + if (fnName && !isRemoved) { + flush(); + current = { + name: fnName, + cyclomatic: 1, + churn: 0, + baseDepth: braceDepth, + }; + } + + // Track brace depth from ALL lines (added + context) so function-end detection works. + if (lang === "typescript" || lang === "javascript") { + for (const ch of body) { + if (ch === "{") { + braceDepth++; + } else if (ch === "}") { + braceDepth--; + // A closing brace that returns to or below the function's opening depth ends the function. + if (current && braceDepth <= current.baseDepth) { + flush(); + } + } + } + } + + // Accumulate decision points from ADDED lines only (churn is the new code's complexity contribution). + if (isAdded && current) { + current.cyclomatic += countDecisions(body); + current.churn++; + } + + if (isAdded) newLine++; + else if (!isRemoved) newLine++; + } + flush(); + + findings.sort((a, b) => b.cyclomatic - a.cyclomatic); + return findings.slice(0, maxFindings); +} + +// ── Analyzer entrypoints ────────────────────────────────────────────────────── + +type ScanOptions = { signal?: AbortSignal }; + +/** Analyzer entrypoint: scan every changed source file's added lines for static defects. */ +export async function scanStaticLint( + req: EnrichRequest, + _fetchFn: typeof fetch = fetch, + options: ScanOptions = {}, +): Promise { + const findings: StaticLintFinding[] = []; + for (const file of req.files ?? []) { + if (options.signal?.aborted) throw new Error("analyzer_aborted"); + if (!file.patch) continue; + const lint = scanPatchForStaticLint(file.path, file.patch, { + maxFindings: MAX_LINT_FINDINGS - findings.length, + signal: options.signal, + }); + findings.push(...lint); + if (findings.length >= MAX_LINT_FINDINGS) break; + } + return findings; +} + +/** Analyzer entrypoint: scan every changed source file's added lines for high-complexity functions. */ +export async function scanComplexity( + req: EnrichRequest, + _fetchFn: typeof fetch = fetch, + options: ScanOptions = {}, +): Promise { + const findings: ComplexityFinding[] = []; + for (const file of req.files ?? []) { + if (options.signal?.aborted) throw new Error("analyzer_aborted"); + if (!file.patch) continue; + const complexity = scanPatchForComplexity(file.path, file.patch, { + maxFindings: MAX_COMPLEXITY_FINDINGS - findings.length, + signal: options.signal, + }); + findings.push(...complexity); + } + return findings; +} diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index a30cd8905f..70fb9d557c 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -25,6 +25,7 @@ import { scanCommitSignature } from "./analyzers/commit-signature.js"; import { scanIacMisconfig } from "./analyzers/iac-misconfig.js"; import { scanNativeBuild } from "./analyzers/native-build.js"; import { scanHistory } from "./analyzers/history.js"; +import { scanStaticLint, scanComplexity } from "./analyzers/static-analysis.js"; import { renderBrief } from "./render.js"; import { captureAnalyzerDegradation } from "./sentry.js"; @@ -52,6 +53,8 @@ const ANALYZERS: Record = { iacMisconfig: (req, signal) => scanIacMisconfig(req, signal), nativeBuild: (req, signal) => scanNativeBuild(req, fetch, { signal }), history: (req, signal) => scanHistory(req, fetch, { signal }), + staticLint: (req, signal) => scanStaticLint(req, fetch, { signal }), + complexity: (req, signal) => scanComplexity(req, fetch, { signal }), }; function runWithTimeout( diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 88c4cbdf18..f0e6904614 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -65,9 +65,7 @@ export function renderBrief( (SEVERITY_RANK[b.cve.severity] ?? 4), ); for (const { dep, cve } of flat) { - const fix = cve.fixedIn - ? ` — fixed in ${safeCodeSpan(cve.fixedIn)}` - : ""; + const fix = cve.fixedIn ? ` — fixed in ${safeCodeSpan(cve.fixedIn)}` : ""; lines.push( `- ${safeCodeSpan(`${dep.package}@${dep.to}`)} (${dep.ecosystem}): **${cve.severity}** ${safeCodeSpan(cve.id)} — ${promptText(cve.summary)}${fix}`, ); @@ -86,9 +84,7 @@ export function renderBrief( ); for (const { dep, cve } of flat) { const from = dep.from ? ` from ${safeCodeSpan(dep.from)}` : ""; - const fix = cve.fixedIn - ? ` — fixed in ${safeCodeSpan(cve.fixedIn)}` - : ""; + const fix = cve.fixedIn ? ` — fixed in ${safeCodeSpan(cve.fixedIn)}` : ""; lines.push( `- ${safeCodeSpan(`${dep.file}:${dep.line}`)} resolves transitive ${safeCodeSpan(`${dep.package}@${dep.to}`)} (${dep.ecosystem})${from}: **${cve.severity}** ${safeCodeSpan(cve.id)} — ${promptText(cve.summary)}${fix}`, ); @@ -309,9 +305,7 @@ export function renderBrief( const iacMisconfigs = findings.iacMisconfig ?? []; if (iacMisconfigs.length) { - const explain = ( - kind: (typeof iacMisconfigs)[number]["kind"], - ): string => { + const explain = (kind: (typeof iacMisconfigs)[number]["kind"]): string => { switch (kind) { case "wildcard-cors-credentials": return "allows wildcard CORS together with credentials; browsers can send authenticated cross-origin requests"; @@ -395,6 +389,29 @@ export function renderBrief( } } + const staticLints = findings.staticLint ?? []; + if (staticLints.length) { + lines.push("### Static-defect findings (lint — review before merging)"); + for (const item of staticLints) { + const icon = item.severity === "error" ? "**error**" : "warning"; + lines.push( + `- ${safeCodeSpan(`${item.file}:${item.line}`)} — ${icon} ${safeCodeSpan(item.rule)}: ${promptText(item.message)}`, + ); + } + } + + const complexityFindings = findings.complexity ?? []; + if (complexityFindings.length) { + lines.push( + "### High-complexity functions (cyclomatic — scrutinize logic paths)", + ); + for (const item of complexityFindings) { + lines.push( + `- ${safeCodeSpan(item.file)} ${safeCodeSpan(item.function)} — cyclomatic ${item.cyclomatic}, ${item.churn} added line(s)`, + ); + } + } + if (!lines.length) return { promptSection: "", systemSuffix: "" }; const header = diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 7ba765f7a2..ca75794db6 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -267,6 +267,27 @@ export interface HistoryFinding { partial: boolean; } +/** A static-defect finding from linting added lines in the diff (#1477). Reports the location + rule name + + * severity + message — public-safe by construction (no source content, no variable values). */ +export interface StaticLintFinding { + file: string; + line: number; + rule: string; + severity: "error" | "warning"; + message: string; +} + +/** A cyclomatic-complexity finding for a changed function (#1477). `cyclomatic` is 1 + decision-point count; + * `churn` is the number of added lines in the function. High complexity + high churn = review priority. */ +export interface ComplexityFinding { + file: string; + /** Best-effort function name from the declaration; `anonymous` when it cannot be resolved. */ + function: string; + cyclomatic: number; + /** Number of added lines in the function body (the churn this PR introduces there). */ + churn: number; +} + /** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */ export interface BriefFindings { dependency?: DependencyFinding[]; @@ -287,6 +308,8 @@ export interface BriefFindings { iacMisconfig?: IacMisconfigFinding[]; nativeBuild?: NativeBuildFinding[]; history?: HistoryFinding[]; + staticLint?: StaticLintFinding[]; + complexity?: ComplexityFinding[]; } export type AnalyzerStatus = "ok" | "degraded" | "skipped"; diff --git a/review-enrichment/test/enrichment.test.ts b/review-enrichment/test/enrichment.test.ts index fbeb062211..27ae538eb8 100644 --- a/review-enrichment/test/enrichment.test.ts +++ b/review-enrichment/test/enrichment.test.ts @@ -58,6 +58,15 @@ import { scanPatchForIacMisconfig, scanIacMisconfig, } from "../dist/analyzers/iac-misconfig.js"; +import { + detectLanguage, + scanPatchForStaticLint, + scanPatchForComplexity, + scanStaticLint, + scanComplexity, + countDecisions, + extractFunctionName, +} from "../dist/analyzers/static-analysis.js"; const NOW = new Date("2026-06-26").getTime(); const eolFetch = @@ -189,7 +198,7 @@ test("extractLockfileChanges: package-lock version drift with file line, skippin ' "node_modules/direct": {', '- "version": "1.0.0",', '+ "version": "2.0.0",', - ' },', + " },", ' "node_modules/minimist": {', '- "version": "1.2.8",', '+ "version": "0.0.8",', @@ -602,7 +611,10 @@ test("buildBrief: lockfile-drift analyzer runs and renders OSV findings", async }); assert.equal(brief.analyzerStatus.lockfileDrift, "ok"); assert.equal(brief.findings.lockfileDrift.length, 1); - assert.match(brief.promptSection, /Vulnerable lockfile-only dependency drift/); + assert.match( + brief.promptSection, + /Vulnerable lockfile-only dependency drift/, + ); } finally { globalThis.fetch = realFetch; } @@ -1225,7 +1237,7 @@ test("scanWorkflowPins: flags unpinned third-party actions with YAML-equivalent const patch = [ "@@ -1,0 +1,3 @@", "+ - uses : tj-actions/changed-files@v44", - "+ - \"uses\": third-party/action@main", + '+ - "uses": third-party/action@main', "+ - 'uses' : quoted/action@v1", ].join("\n"); const findings = scanWorkflowPins(".github/workflows/ci.yml", patch); @@ -1593,9 +1605,7 @@ test("extractDependencyChanges: caps manifest files and patch lines", () => { [ { path: "package.json", - patch: ['+ "first": "1.0.0",', '+ "second": "1.0.0",'].join( - "\n", - ), + patch: ['+ "first": "1.0.0",', '+ "second": "1.0.0",'].join("\n"), }, { path: "nested/package.json", patch: '+ "third": "1.0.0",' }, ], @@ -1638,9 +1648,13 @@ test("buildBrief: timeout aborts dependency scan so OSV work stops", async () => fetchCount += 1; signals.push(init.signal); return await new Promise((_resolve, reject) => { - init.signal.addEventListener("abort", () => reject(new Error("aborted")), { - once: true, - }); + init.signal.addEventListener( + "abort", + () => reject(new Error("aborted")), + { + once: true, + }, + ); }); }; @@ -1694,12 +1708,14 @@ test("parseCodeowners: caps repository-controlled size, rule count, and pattern }); test("findOwners: preserves CODEOWNERS anchoring and last-match-wins semantics", () => { - const rules = parseCodeowners([ - "*.ts @global/ts", - "/src/*.ts @root/src", - "docs/ @docs/team", - "src/special.ts @last/match", - ].join("\n")); + const rules = parseCodeowners( + [ + "*.ts @global/ts", + "/src/*.ts @root/src", + "docs/ @docs/team", + "src/special.ts @last/match", + ].join("\n"), + ); assert.deepEqual(findOwners(rules, "nested/file.ts"), ["@global/ts"]); assert.deepEqual(findOwners(rules, "src/file.ts"), ["@root/src"]); @@ -1935,37 +1951,29 @@ test("hasNpmAttestation: returns false on 404 (no attestation)", async () => { }); test("hasNpmAttestation: returns true when attestations array is non-empty", async () => { - const result = await hasNpmAttestation( - "attested-pkg", - "1.0.0", - async () => ({ - ok: true, - status: 200, - json: async () => ({ attestations: [{ predicateType: "slsa" }] }), - }), - ); + const result = await hasNpmAttestation("attested-pkg", "1.0.0", async () => ({ + ok: true, + status: 200, + json: async () => ({ attestations: [{ predicateType: "slsa" }] }), + })); assert.equal(result, true); }); test("hasNpmAttestation: returns false when attestations array is empty", async () => { - const result = await hasNpmAttestation( - "empty-attest", - "1.0.0", - async () => ({ - ok: true, - status: 200, - json: async () => ({ attestations: [] }), - }), - ); + const result = await hasNpmAttestation("empty-attest", "1.0.0", async () => ({ + ok: true, + status: 200, + json: async () => ({ attestations: [] }), + })); assert.equal(result, false); }); test("hasNpmAttestation: returns true (fail-safe) on non-404 registry error", async () => { - const result = await hasNpmAttestation( - "pkg", - "1.0.0", - async () => ({ ok: false, status: 500, json: async () => ({}) }), - ); + const result = await hasNpmAttestation("pkg", "1.0.0", async () => ({ + ok: false, + status: 500, + json: async () => ({}), + })); assert.equal(result, true); }); @@ -1985,7 +1993,11 @@ test("hasNpmAttestation: returns true (fail-safe) when signal is already aborted "1.0.0", async () => { called = true; - return { ok: true, status: 200, json: async () => ({ attestations: [] }) }; + return { + ok: true, + status: 200, + json: async () => ({ attestations: [] }), + }; }, controller.signal, ); @@ -1998,58 +2010,46 @@ test("hasNpmAttestation: returns true (fail-safe) when signal is already aborted // --------------------------------------------------------------------------- test("hasPypiProvenance: returns true when matching file has provenance field", async () => { - const result = await hasPypiProvenance( - "requests", - "2.31.0", - async () => ({ - ok: true, - json: async () => ({ - files: [ - { - filename: "requests-2.31.0-py3-none-any.whl", - provenance: "https://files.pythonhosted.org/.../requests-2.31.0-py3-none-any.whl.provenance", - }, - ], - }), + const result = await hasPypiProvenance("requests", "2.31.0", async () => ({ + ok: true, + json: async () => ({ + files: [ + { + filename: "requests-2.31.0-py3-none-any.whl", + provenance: + "https://files.pythonhosted.org/.../requests-2.31.0-py3-none-any.whl.provenance", + }, + ], }), - ); + })); assert.equal(result, true); }); test("hasPypiProvenance: returns false when matching file lacks provenance field", async () => { - const result = await hasPypiProvenance( - "requests", - "2.31.0", - async () => ({ - ok: true, - json: async () => ({ - files: [{ filename: "requests-2.31.0-py3-none-any.whl" }], - }), + const result = await hasPypiProvenance("requests", "2.31.0", async () => ({ + ok: true, + json: async () => ({ + files: [{ filename: "requests-2.31.0-py3-none-any.whl" }], }), - ); + })); assert.equal(result, false); }); test("hasPypiProvenance: returns true (fail-safe) when no file matches the version", async () => { - const result = await hasPypiProvenance( - "requests", - "2.31.0", - async () => ({ - ok: true, - json: async () => ({ - files: [{ filename: "requests-2.30.0-py3-none-any.whl" }], - }), + const result = await hasPypiProvenance("requests", "2.31.0", async () => ({ + ok: true, + json: async () => ({ + files: [{ filename: "requests-2.30.0-py3-none-any.whl" }], }), - ); + })); assert.equal(result, true); }); test("hasPypiProvenance: returns true (fail-safe) on non-ok response", async () => { - const result = await hasPypiProvenance( - "requests", - "2.31.0", - async () => ({ ok: false, json: async () => ({}) }), - ); + const result = await hasPypiProvenance("requests", "2.31.0", async () => ({ + ok: false, + json: async () => ({}), + })); assert.equal(result, true); }); @@ -2099,63 +2099,96 @@ test("hasPypiProvenance: passes Accept header for PEP 740 simple API", async () // --------------------------------------------------------------------------- test("matchesPypiVersion: matches wheel filename for exact version", () => { - assert.equal(matchesPypiVersion("requests-2.31.0-py3-none-any.whl", "requests", "2.31.0"), true); + assert.equal( + matchesPypiVersion( + "requests-2.31.0-py3-none-any.whl", + "requests", + "2.31.0", + ), + true, + ); }); test("matchesPypiVersion: matches sdist .tar.gz filename for exact version", () => { - assert.equal(matchesPypiVersion("requests-2.31.0.tar.gz", "requests", "2.31.0"), true); + assert.equal( + matchesPypiVersion("requests-2.31.0.tar.gz", "requests", "2.31.0"), + true, + ); }); test("matchesPypiVersion: matches sdist .zip filename for exact version", () => { - assert.equal(matchesPypiVersion("requests-2.31.0.zip", "requests", "2.31.0"), true); + assert.equal( + matchesPypiVersion("requests-2.31.0.zip", "requests", "2.31.0"), + true, + ); }); test("matchesPypiVersion: rejects post-release suffix (version substring of longer version)", () => { // 2.31.0 is a substring of 2.31.0.post1 — must NOT match - assert.equal(matchesPypiVersion("requests-2.31.0.post1-py3-none-any.whl", "requests", "2.31.0"), false); + assert.equal( + matchesPypiVersion( + "requests-2.31.0.post1-py3-none-any.whl", + "requests", + "2.31.0", + ), + false, + ); }); test("matchesPypiVersion: rejects version with shared numeric suffix (prefix overlap)", () => { // 2.31.0 is a substring of 12.31.0 — must NOT match - assert.equal(matchesPypiVersion("requests-12.31.0-py3-none-any.whl", "requests", "2.31.0"), false); + assert.equal( + matchesPypiVersion( + "requests-12.31.0-py3-none-any.whl", + "requests", + "2.31.0", + ), + false, + ); }); test("matchesPypiVersion: matches hyphenated package name normalised to underscore in wheel", () => { // PyPI normalises my-package → my_package in wheel filenames (PEP 503) - assert.equal(matchesPypiVersion("my_package-1.0.0-py3-none-any.whl", "my-package", "1.0.0"), true); + assert.equal( + matchesPypiVersion( + "my_package-1.0.0-py3-none-any.whl", + "my-package", + "1.0.0", + ), + true, + ); }); test("matchesPypiVersion: rejects filename from a different package", () => { - assert.equal(matchesPypiVersion("other-requests-2.31.0-py3-none-any.whl", "requests", "2.31.0"), false); + assert.equal( + matchesPypiVersion( + "other-requests-2.31.0-py3-none-any.whl", + "requests", + "2.31.0", + ), + false, + ); }); test("hasPypiProvenance: returns true (fail-safe) when only post-release file exists for version", async () => { // The API returns requests-2.31.0.post1 files; no file for exact 2.31.0 → can't determine → don't flag - const result = await hasPypiProvenance( - "requests", - "2.31.0", - async () => ({ - ok: true, - json: async () => ({ - files: [{ filename: "requests-2.31.0.post1-py3-none-any.whl" }], - }), + const result = await hasPypiProvenance("requests", "2.31.0", async () => ({ + ok: true, + json: async () => ({ + files: [{ filename: "requests-2.31.0.post1-py3-none-any.whl" }], }), - ); + })); assert.equal(result, true); }); test("hasPypiProvenance: returns true (fail-safe) when only a different version with shared suffix exists", async () => { // 12.31.0 contains "2.31.0" as substring; must not be treated as a match for version 2.31.0 - const result = await hasPypiProvenance( - "requests", - "2.31.0", - async () => ({ - ok: true, - json: async () => ({ - files: [{ filename: "requests-12.31.0-py3-none-any.whl" }], - }), + const result = await hasPypiProvenance("requests", "2.31.0", async () => ({ + ok: true, + json: async () => ({ + files: [{ filename: "requests-12.31.0-py3-none-any.whl" }], }), - ); + })); assert.equal(result, true); }); @@ -2171,12 +2204,14 @@ test("scanProvenance: flags added binary and vendored files, skips modified and files: [ { path: "vendor/lib/util.js", status: "added" }, { path: "native/module.exe", status: "added" }, - { path: "src/app.ts", status: "added" }, // source — null - { path: "native/old.exe", status: "modified" }, // not added — skip - { path: "removed.exe", status: "removed" }, // not added — skip + { path: "src/app.ts", status: "added" }, // source — null + { path: "native/old.exe", status: "modified" }, // not added — skip + { path: "removed.exe", status: "removed" }, // not added — skip ], }, - async () => { throw new Error("should not fetch"); }, + async () => { + throw new Error("should not fetch"); + }, ); assert.equal(findings.length, 2); assert.equal(findings[0].kind, "vendored"); @@ -2202,8 +2237,13 @@ test("scanProvenance: flags npm dep without attestation, skips one with attestat }, async (url) => { const u = String(url); - if (u.includes("no-attest-pkg")) return { ok: false, status: 404, json: async () => ({}) }; - return { ok: true, status: 200, json: async () => ({ attestations: [{ predicateType: "slsa" }] }) }; + if (u.includes("no-attest-pkg")) + return { ok: false, status: 404, json: async () => ({}) }; + return { + ok: true, + status: 200, + json: async () => ({ attestations: [{ predicateType: "slsa" }] }), + }; }, ); assert.equal(findings.length, 1); @@ -2249,7 +2289,11 @@ test("scanProvenance: skips Go ecosystem (no attestation API)", async () => { }, async () => { fetchCalled = true; - return { ok: true, status: 200, json: async () => ({ attestations: [] }) }; + return { + ok: true, + status: 200, + json: async () => ({ attestations: [] }), + }; }, ); assert.equal(findings.length, 0); @@ -2283,7 +2327,9 @@ test("scanProvenance: caps findings at MAX_FINDINGS (binary detection path)", as })); const findings = await scanProvenance( { repoFullName: "o/r", prNumber: 1, files }, - async () => { throw new Error("should not fetch"); }, + async () => { + throw new Error("should not fetch"); + }, ); assert.equal(findings.length, 30); // MAX_FINDINGS }); @@ -2337,7 +2383,9 @@ test("scanProvenance: skips deps that fail isSafeToCheck (overly long name or in test("scanProvenance: handles undefined files gracefully", async () => { const findings = await scanProvenance( { repoFullName: "o/r", prNumber: 1 }, - async () => { throw new Error("should not fetch"); }, + async () => { + throw new Error("should not fetch"); + }, ); assert.deepEqual(findings, []); }); @@ -2564,7 +2612,12 @@ test("scanAssetWeight: small growth is not flagged", async () => { test("renderBrief: renders no-attestation, binary, and vendored sections", () => { const r = renderBrief({ provenance: [ - { kind: "no-attestation", ecosystem: "npm", package: "evil", version: "1.0.0" }, + { + kind: "no-attestation", + ecosystem: "npm", + package: "evil", + version: "1.0.0", + }, { kind: "binary", file: "build/tool.exe" }, { kind: "vendored", file: "vendor/lib/helper.js" }, ], @@ -2586,9 +2639,7 @@ test("renderBrief: empty provenance array produces no provenance section", () => test("renderBrief: provenance escapes control chars and backticks in file paths", () => { const r = renderBrief({ - provenance: [ - { kind: "binary", file: "build/tool`\n### injected" }, - ], + provenance: [{ kind: "binary", file: "build/tool`\n### injected" }], }); assert.doesNotMatch(r.promptSection, /\n### injected/); assert.match(r.promptSection, /binary artifact without source documentation/); @@ -2728,21 +2779,38 @@ test("scanAssetWeight: fail-safe — no token, no binaries, or failed fetch retu treeReply([{ path: "a.png", type: "blob", size: 999999 }]); assert.deepEqual( await scanAssetWeight( - { repoFullName: "o/r", prNumber: 1, headSha: HEAD_SHA, files: [{ path: "a.png", status: "added" }] }, + { + repoFullName: "o/r", + prNumber: 1, + headSha: HEAD_SHA, + files: [{ path: "a.png", status: "added" }], + }, tree, ), [], ); // no token assert.deepEqual( await scanAssetWeight( - { repoFullName: "o/r", prNumber: 1, headSha: HEAD_SHA, githubToken: "t", files: [{ path: "readme.md", status: "added" }] }, + { + repoFullName: "o/r", + prNumber: 1, + headSha: HEAD_SHA, + githubToken: "t", + files: [{ path: "readme.md", status: "added" }], + }, tree, ), [], ); // no binary files assert.deepEqual( await scanAssetWeight( - { repoFullName: "o/r", prNumber: 1, headSha: HEAD_SHA, githubToken: "t", files: [{ path: "a.png", status: "added" }] }, + { + repoFullName: "o/r", + prNumber: 1, + headSha: HEAD_SHA, + githubToken: "t", + files: [{ path: "a.png", status: "added" }], + }, async () => ({ ok: false, json: async () => ({}) }), ), [], @@ -2756,10 +2824,21 @@ test("scanAssetWeight: rejects path-traversal repoFullName + non-SHA refs (no to return treeReply([{ path: "a.png", type: "blob", size: 999999 }]); }; const file = { path: "a.png", status: "added" }; - for (const repoFullName of ["a/b/../../x/y", "../evil", "owner/repo/extra", "o/.."]) { + for (const repoFullName of [ + "a/b/../../x/y", + "../evil", + "owner/repo/extra", + "o/..", + ]) { assert.deepEqual( await scanAssetWeight( - { repoFullName, prNumber: 1, headSha: HEAD_SHA, githubToken: "t", files: [file] }, + { + repoFullName, + prNumber: 1, + headSha: HEAD_SHA, + githubToken: "t", + files: [file], + }, spy, ), [], @@ -2767,18 +2846,33 @@ test("scanAssetWeight: rejects path-traversal repoFullName + non-SHA refs (no to } assert.deepEqual( await scanAssetWeight( - { repoFullName: "o/r", prNumber: 1, headSha: "main", githubToken: "t", files: [file] }, + { + repoFullName: "o/r", + prNumber: 1, + headSha: "main", + githubToken: "t", + files: [file], + }, spy, ), [], ); - assert.equal(fetched, false, "the token-bearing fetch never runs for unsafe input"); + assert.equal( + fetched, + false, + "the token-bearing fetch never runs for unsafe input", + ); }); test("renderBrief: renders the asset-weight block with human-readable sizes", () => { const r = renderBrief({ assetWeight: [ - { path: "img/logo.png", bytes: 2500000, deltaBytes: 2500000, status: "added" }, + { + path: "img/logo.png", + bytes: 2500000, + deltaBytes: 2500000, + status: "added", + }, { path: "v.mp4", bytes: 300000, deltaBytes: 200000, status: "grown" }, ], }); @@ -2835,9 +2929,15 @@ test("detectSecretLog: flags sensitive data into a sink as CODE, not string mess detectSecretLog("logger.info(`token=${apiKey}`);")?.category, "secret", ); - assert.equal(detectSecretLog("log.error(user.password);")?.category, "secret"); + assert.equal( + detectSecretLog("log.error(user.password);")?.category, + "secret", + ); assert.equal(detectSecretLog("console.debug(account.ssn);")?.category, "pii"); - assert.equal(detectSecretLog("console.log(req);")?.category, "request-object"); + assert.equal( + detectSecretLog("console.log(req);")?.category, + "request-object", + ); assert.equal( detectSecretLog("process.stdout.write(session.cookie);")?.sink, "process.stdout.write", @@ -2987,7 +3087,9 @@ test("buildBrief: secret-log analyzer runs (pure, no network)", async () => { test("buildBrief: provenance analyzer fetch failure fails safe", async () => { const realFetch = globalThis.fetch; - globalThis.fetch = async () => { throw new Error("network down"); }; + globalThis.fetch = async () => { + throw new Error("network down"); + }; try { const brief = await buildBrief({ repoFullName: "o/r", @@ -3002,3 +3104,238 @@ test("buildBrief: provenance analyzer fetch failure fails safe", async () => { globalThis.fetch = realFetch; } }); + +// ── static-analysis + complexity analyzer (#1477) ───────────────────────────── + +test("detectLanguage: maps extensions to languages; null for non-source", () => { + assert.equal(detectLanguage("src/app.ts"), "typescript"); + assert.equal(detectLanguage("src/app.tsx"), "typescript"); + assert.equal(detectLanguage("src/app.js"), "javascript"); + assert.equal(detectLanguage("src/app.jsx"), "javascript"); + assert.equal(detectLanguage("src/app.py"), "python"); + assert.equal(detectLanguage("src/app.go"), "go"); + assert.equal(detectLanguage("README.md"), null); + assert.equal(detectLanguage("Dockerfile"), null); +}); + +test("scanPatchForStaticLint: flags eval, debugger, console, empty-catch, == in TS", () => { + const patch = [ + "@@ -1,3 +1,8 @@", + " const ok = true;", + "+eval(userInput);", + "+debugger;", + '+console.log("debug");', + "+try { x() } catch (e) {}", + "+if (x == y) return;", + "+const clean = true;", + ].join("\n"); + const findings = scanPatchForStaticLint("src/app.ts", patch); + const rules = findings.map((f) => f.rule); + assert.ok(rules.includes("no-eval")); + assert.ok(rules.includes("no-debugger")); + assert.ok(rules.includes("no-console")); + assert.ok(rules.includes("no-empty-catch")); + assert.ok(rules.includes("eqeqeq")); + assert.equal(findings.length, 5); + assert.equal(findings[0].line, 2); + assert.equal(findings[0].severity, "error"); +}); + +test("scanPatchForStaticLint: no findings for non-source files or clean code", () => { + assert.deepEqual(scanPatchForStaticLint("README.md", "+some text"), []); + assert.deepEqual( + scanPatchForStaticLint("src/app.ts", "+const x = 1;\n+const y = 2;"), + [], + ); +}); + +test("scanPatchForStaticLint: caps at maxFindings", () => { + const patch = [ + "@@ -1,0 +1,5 @@", + "+console.log(1);", + "+console.log(2);", + "+console.log(3);", + "+console.log(4);", + "+console.log(5);", + ].join("\n"); + const findings = scanPatchForStaticLint("src/app.ts", patch, { + maxFindings: 2, + }); + assert.equal(findings.length, 2); +}); + +test("scanPatchForStaticLint: Python bare-except is flagged", () => { + const patch = "@@ -1,0 +1,3 @@\n+try:\n+except:\n+ pass"; + const findings = scanPatchForStaticLint("src/app.py", patch); + assert.equal(findings.length, 1); + assert.equal(findings[0].rule, "no-bare-except"); +}); + +test("countDecisions + extractFunctionName: helpers are pure", () => { + assert.equal(countDecisions("if (x) {"), 1); + assert.equal(countDecisions("for (let i = 0; i < n; i++) {"), 1); + assert.equal(countDecisions("x && y || z ? a : b"), 3); + assert.equal(countDecisions("const x = 1;"), 0); + assert.equal(extractFunctionName("function foo() {"), "foo"); + assert.equal(extractFunctionName("const bar = async (x) => {"), "bar"); + assert.equal(extractFunctionName("const x = 1;"), null); +}); + +test("scanPatchForComplexity: flags a high-complexity function, skips simple ones", () => { + const patch = [ + "@@ -1,0 +1,20 @@", + "+function complex(a, b, c) {", + "+ if (a && b) {", + "+ for (let i = 0; i < c; i++) {", + "+ if (i || a) {", + "+ while (x) {", + "+ if (a && b || c) {", + "+ }", + "+ }", + "+ }", + "+ }", + "+ }", + "+ return a ? b : c;", + "+}", + "+function simple() {", + "+ return 1;", + "+}", + ].join("\n"); + const findings = scanPatchForComplexity("src/app.ts", patch); + assert.equal(findings.length, 1); + assert.equal(findings[0].function, "complex"); + assert.ok(findings[0].cyclomatic > 10); + assert.ok(findings[0].churn > 0); +}); + +test("scanPatchForComplexity: detects added decisions inside an EXISTING function (context declaration)", () => { + // The function declaration is a CONTEXT line (no +/-); the decision lines are ADDED. + const patch = [ + "@@ -10,3 +10,16 @@", + " function existing() {", + "+ if (a && b) {", + "+ for (let i = 0; i < n; i++) {", + "+ if (x || y) {", + "+ while (z) {", + "+ if (a && b || c) {", + "+ }", + "+ }", + "+ }", + "+ }", + "+ }", + " return result;", + " }", + ].join("\n"); + const findings = scanPatchForComplexity("src/app.ts", patch); + assert.equal(findings.length, 1); + assert.equal(findings[0].function, "existing"); + assert.ok( + findings[0].cyclomatic >= 10, + `expected >= 10, got ${findings[0].cyclomatic}`, + ); + assert.equal(findings[0].churn, 10); // 10 added decision lines +}); + +test("scanPatchForComplexity: else-if is counted as one decision", () => { + assert.equal(countDecisions("else if (x) {"), 1); + assert.equal(countDecisions("if (a) {} else if (b) {}"), 2); +}); + +test("scanPatchForComplexity: no findings for non-source or simple diffs", () => { + assert.deepEqual(scanPatchForComplexity("README.md", "+some text"), []); + assert.deepEqual( + scanPatchForComplexity("src/app.ts", "+const x = 1;\n+const y = 2;"), + [], + ); +}); + +test("scanPatchForComplexity: limited to TS/JS — Python/Go skipped until function-end tracking lands", () => { + const pyPatch = [ + "@@ -1,0 +1,10 @@", + "+def complex(a, b, c):", + "+ if a and b:", + "+ for i in range(c):", + "+ if i or a:", + "+ while x:", + "+ if a and b or c:", + "+ pass", + "+ return a", + ].join("\n"); + assert.deepEqual(scanPatchForComplexity("src/app.py", pyPatch), []); + + const goPatch = [ + "@@ -1,0 +1,10 @@", + "+func complex(a, b, c int) int {", + "+ if a > 0 && b > 0 {", + "+ for i := 0; i < c; i++ {", + "+ if i == 0 || a == 0 {", + "+ }", + "+ }", + "+ }", + "+}", + ].join("\n"); + assert.deepEqual(scanPatchForComplexity("src/app.go", goPatch), []); +}); + +test("renderBrief: renders the static-lint block", () => { + const r = renderBrief({ + staticLint: [ + { + file: "src/app.ts", + line: 5, + rule: "no-eval", + severity: "error", + message: "eval() is dangerous.", + }, + { + file: "src/app.ts", + line: 8, + rule: "no-console", + severity: "warning", + message: "Remove console.", + }, + ], + }); + assert.match(r.promptSection, /Static-defect findings/); + assert.match(r.promptSection, /`src\/app\.ts:5`/); + assert.match(r.promptSection, /\*\*error\*\* `no-eval`/); + assert.match(r.promptSection, /warning `no-console`/); +}); + +test("renderBrief: renders the complexity block", () => { + const r = renderBrief({ + complexity: [ + { file: "src/app.ts", function: "complex", cyclomatic: 15, churn: 12 }, + ], + }); + assert.match(r.promptSection, /High-complexity functions/); + assert.match(r.promptSection, /cyclomatic 15, 12 added line/); +}); + +test("buildBrief: static-lint + complexity analyzers run (pure, no network)", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = async () => ({ ok: true, json: async () => ({}) }); + try { + const brief = await buildBrief({ + repoFullName: "o/r", + prNumber: 1, + analyzers: ["staticLint", "complexity"], + files: [ + { + path: "src/app.ts", + patch: [ + "@@ -1,0 +1,3 @@", + "+eval(x);", + "+debugger;", + '+console.log("x");', + ].join("\n"), + }, + ], + }); + assert.equal(brief.analyzerStatus.staticLint, "ok"); + assert.equal(brief.findings.staticLint.length, 3); + assert.match(brief.promptSection, /Static-defect findings/); + } finally { + globalThis.fetch = realFetch; + } +});