diff --git a/.env.example b/.env.example index c9b3042c9f..b93bf11d81 100644 --- a/.env.example +++ b/.env.example @@ -66,10 +66,10 @@ GITTENSORY_REVIEW_ENRICHMENT=false # dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat # commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot -# blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene -# pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber -# conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,a11y -# i18n,commitLint +# blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,unusedExport,staleBranch +# commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology +# todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting +# errorSwallow,unsafeAny,a11y,i18n,commitLint # # Profile defaults: # fast: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency @@ -79,17 +79,17 @@ GITTENSORY_REVIEW_ENRICHMENT=false # balanced (default): dependency,dependencyDiff,lockfileDrift,secret,license,installScript # heavyDependency,hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight # typosquat,commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication -# churnHotspot,blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch -# commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology -# todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting -# errorSwallow,unsafeAny,a11y,i18n,commitLint +# churnHotspot,blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,unusedExport +# staleBranch,commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange +# terminology,todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise +# deepNesting,errorSwallow,unsafeAny,a11y,i18n,commitLint # deep: dependency,dependencyDiff,lockfileDrift,secret,license,installScript,heavyDependency # hardcodedUrl,actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat # commitSignature,iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot -# blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch,commitHygiene -# pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology,todoMarker,magicNumber -# conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting,errorSwallow,unsafeAny,a11y -# i18n,commitLint +# blameLink,approvalIntegrity,ciCheckSignals,undocumentedExport,unusedExport,staleBranch +# commitHygiene,pendingReviewRequests,testRatio,migrationSafety,looseRange,terminology +# todoMarker,magicNumber,conflictMarker,debugLeftover,sizeSmell,floatingPromise,deepNesting +# errorSwallow,unsafeAny,a11y,i18n,commitLint # END GENERATED REES ANALYZERS # Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep diff --git a/apps/gittensory-ui/src/lib/rees-analyzers.ts b/apps/gittensory-ui/src/lib/rees-analyzers.ts index 960b4ab37f..d4133dfb1c 100644 --- a/apps/gittensory-ui/src/lib/rees-analyzers.ts +++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts @@ -706,6 +706,32 @@ export const REES_ANALYZERS = [ "Conservative: re-export lists (`export { x }`) and `export *` are ignored; a preceding `//` line (except tool directives like `eslint-disable`) or a real JSDoc `/**` block counts as documented (a plain `/* … */` block does not).", }, }, + { + name: "unusedExport", + title: "Unused exports (dead-on-arrival)", + category: "quality", + cost: "github-light", + defaultEnabled: true, + profiles: ["balanced", "deep"], + requires: ["files", "github-token", "head-sha"], + limits: { + maxSymbols: 20, + maxSearches: 15, + maxFindings: 25, + }, + docs: { + summary: + "Flags exports newly added by the PR that have zero non-declaration references anywhere in the repo.", + looksAt: + "Direct `export const/let/var/function/class/interface/type/enum` declarations added in changed source files, cross-checked via GitHub Code Search at headSha.", + reports: + "File, line, and symbol name of each unreferenced added export — never file contents.", + network: + "One bounded GitHub code-search call per candidate symbol (repo-scoped). Requires GitHub token forwarding for private repos.", + notes: + "Conservative: re-export lists and `export *` are ignored; a search error or cap hit skips that symbol rather than flagging it. Changed/removed exports with callers are out of scope (#1509).", + }, + }, { name: "staleBranch", title: "Stale branch signal", diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json index eb783794b9..5c420d4b45 100644 --- a/review-enrichment/analyzer-metadata.json +++ b/review-enrichment/analyzer-metadata.json @@ -796,6 +796,34 @@ "notes": "Conservative: re-export lists (`export { x }`) and `export *` are ignored; a preceding `//` line (except tool directives like `eslint-disable`) or a real JSDoc `/**` block counts as documented (a plain `/* … */` block does not)." } }, + { + "name": "unusedExport", + "title": "Unused exports (dead-on-arrival)", + "category": "quality", + "cost": "github-light", + "defaultEnabled": true, + "profiles": [ + "balanced", + "deep" + ], + "requires": [ + "files", + "github-token", + "head-sha" + ], + "limits": { + "maxSymbols": 20, + "maxSearches": 15, + "maxFindings": 25 + }, + "docs": { + "summary": "Flags exports newly added by the PR that have zero non-declaration references anywhere in the repo.", + "looksAt": "Direct `export const/let/var/function/class/interface/type/enum` declarations added in changed source files, cross-checked via GitHub Code Search at headSha.", + "reports": "File, line, and symbol name of each unreferenced added export — never file contents.", + "network": "One bounded GitHub code-search call per candidate symbol (repo-scoped). Requires GitHub token forwarding for private repos.", + "notes": "Conservative: re-export lists and `export *` are ignored; a search error or cap hit skips that symbol rather than flagging it. Changed/removed exports with callers are out of scope (#1509)." + } + }, { "name": "staleBranch", "title": "Stale branch signal", diff --git a/review-enrichment/src/analyzers/registry.ts b/review-enrichment/src/analyzers/registry.ts index 24bb153e14..dcea3fdb1e 100644 --- a/review-enrichment/src/analyzers/registry.ts +++ b/review-enrichment/src/analyzers/registry.ts @@ -44,6 +44,7 @@ import { scanTerminology } from "./terminology.js"; import { scanTodoMarker } from "./todo-marker.js"; import { scanTyposquat } from "./typosquat.js"; import { scanUndocumentedExport } from "./undocumented-export.js"; +import { scanUnusedExport } from "./unused-export.js"; import type { AnalyzerDescriptor, AnalyzerFn, @@ -671,6 +672,38 @@ export const ANALYZER_DESCRIPTORS = [ }, run: (req, { signal }) => scanUndocumentedExport(req, fetch, { signal }), }), + descriptor({ + name: "unusedExport", + title: "Unused exports (dead-on-arrival)", + category: "quality", + cost: "github-light", + defaultEnabled: true, + requires: ["files", "github-token", "head-sha"], + limits: { maxSymbols: 20, maxSearches: 15, maxFindings: 25 }, + docs: { + summary: + "Flags exports newly added by the PR that have zero non-declaration references anywhere in the repo.", + looksAt: + "Direct `export const/let/var/function/class/interface/type/enum` declarations added in changed source files, cross-checked via GitHub Code Search at headSha.", + reports: "File, line, and symbol name of each unreferenced added export — never file contents.", + network: + "One bounded GitHub code-search call per candidate symbol (repo-scoped). Requires GitHub token forwarding for private repos.", + notes: + "Conservative: re-export lists and `export *` are ignored; a search error or cap hit skips that symbol rather than flagging it. Changed/removed exports with callers are out of scope (#1509).", + }, + render: (findings, helpers) => { + if (!findings.length) return []; + const lines = ["### Unused exports (newly added exports with no references)"]; + for (const item of findings) { + lines.push( + `- ${helpers.safeCodeSpan(`${item.file}:${item.line}`)} exports ${helpers.safeCodeSpan(item.symbol)} with no references found in the repo`, + ); + } + return lines; + }, + run: (req, { signal, analysis, diagnostics }) => + scanUnusedExport(req, fetch, { signal, analysis, diagnostics }), + }), descriptor({ name: "staleBranch", title: "Stale branch signal", diff --git a/review-enrichment/src/analyzers/unused-export.ts b/review-enrichment/src/analyzers/unused-export.ts new file mode 100644 index 0000000000..9fe7252448 --- /dev/null +++ b/review-enrichment/src/analyzers/unused-export.ts @@ -0,0 +1,177 @@ +// Unused-export / dead-on-arrival scan (#2025, part of #1499). Flags exports NEWLY ADDED by the PR that have +// zero non-declaration references anywhere in the repo — net-new public surface nobody calls yet. Parses added +// top-level `export` declarations from the diff, then resolves each symbol via GitHub Code Search (repo-scoped, +// injected fetch). Deliberately conservative + fail-safe: strict maxSymbols + maxSearches caps; a missing +// token/head-sha, an unresolvable repo slug, or any search error yields no finding for that symbol rather than +// an error. Scope is strictly net-new exports only — changed/removed exports with live callers belong in #1509. +import type { AnalyzerDiagnostics, EnrichRequest, UnusedExportFinding } from "../types.js"; +import type { AnalysisContext } from "../analysis-context.js"; +import { boundedFetchJson } from "../external-fetch.js"; +import { exportedSymbols, parseAddedExports } from "./undocumented-export.js"; + +const GITHUB_API = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const MAX_SYMBOLS = 20; +const MAX_SEARCHES = 15; +const MAX_FINDINGS = 25; +const SEARCH_PER_PAGE = 5; +const MAX_SEARCH_JSON_BYTES = 256 * 1024; +const SLUG_RE = /^[A-Za-z0-9._-]+$/; +const SKIP_RE = /(?:\.d\.ts$|\.min\.|\.test\.|\.spec\.|__tests__\/|(?:^|\/)(?:dist|build|vendor)\/)/; + +interface ScanOptions { + signal?: AbortSignal; + analysis?: Pick; + diagnostics?: AnalyzerDiagnostics; + maxSymbols?: number; + maxSearches?: number; +} + +interface AddedExport { + file: string; + line: number; + symbol: string; +} + +interface CodeSearchItem { + path?: string; + text_matches?: Array<{ fragment?: string }>; +} + +interface CodeSearchResponse { + total_count?: number; + items?: CodeSearchItem[]; +} + +function githubHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + "User-Agent": "gittensory-review-enrichment", + }; +} + +/** Collect added direct export declarations across changed files, bounded by maxSymbols. Pure. */ +export function collectAddedExports( + files: NonNullable, + maxSymbols = MAX_SYMBOLS, +): AddedExport[] { + const out: AddedExport[] = []; + for (const file of files) { + if (!file.patch || SKIP_RE.test(file.path)) continue; + for (const { symbol, newLine } of parseAddedExports(file.patch)) { + out.push({ file: file.path, line: newLine, symbol }); + if (out.length >= maxSymbols) return out; + } + } + return out; +} + +/** True when every text-match fragment in a same-file search hit looks like the export declaration itself (not a + * use site). Pure — conservative: an ambiguous fragment is treated as a reference so we don't false-flag. */ +export function fragmentsLookLikeExportDeclaration(symbol: string, fragments: string[]): boolean { + if (!fragments.length) return false; + for (const fragment of fragments) { + for (const line of fragment.split("\n")) { + if (!exportedSymbols(line).includes(symbol)) return false; + } + } + return true; +} + +/** Decide whether a bounded code-search response shows any non-declaration reference to `symbol`. Pure. */ +export function symbolHasNonDeclarationReference( + symbol: string, + declFile: string, + search: CodeSearchResponse, +): boolean { + const total = search.total_count ?? 0; + const items = search.items ?? []; + if (total === 0) return false; + for (const item of items) { + if (!item.path) continue; + if (item.path !== declFile) return true; + const fragments = (item.text_matches ?? []).map((m) => m.fragment ?? "").filter(Boolean); + if (!fragmentsLookLikeExportDeclaration(symbol, fragments)) return true; + } + // Every returned hit is the declaration in the declaring file. If GitHub reports more hits than we fetched, + // assume at least one is a real reference — conservative, avoids false positives on popular tokens. + if (total > items.length) return true; + return total > 1; +} + +async function fetchCodeSearch( + query: string, + token: string, + fetchImpl: typeof fetch, + options: ScanOptions, +): Promise { + const url = `${GITHUB_API}/search/code?q=${encodeURIComponent(query)}&per_page=${SEARCH_PER_PAGE}`; + const fetchOptions = { + endpointCategory: "github-code-search", + headers: githubHeaders(token), + signal: options.signal, + fetchImpl, + diagnostics: options.diagnostics, + phase: "unused-export", + subcall: "github-code-search", + maxBytes: MAX_SEARCH_JSON_BYTES, + maxCallsPerCategory: options.maxSearches ?? MAX_SEARCHES, + }; + const response = options.analysis + ? await options.analysis.fetchJson(url, fetchOptions) + : await boundedFetchJson(url, fetchOptions); + return response.ok ? response.data : null; +} + +/** Build a repo-scoped code-search query for `symbol`. Pure. */ +export function codeSearchQuery(owner: string, repo: string, symbol: string): string { + return `repo:${owner}/${repo} ${symbol}`; +} + +/** Analyzer entrypoint: flag newly-added exports with zero non-declaration references. Fail-safe. */ +export async function scanUnusedExport( + 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, repo] = parts; + if (parts.length !== 2 || !owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return []; + + const maxSymbols = options.maxSymbols ?? MAX_SYMBOLS; + const maxSearches = options.maxSearches ?? MAX_SEARCHES; + const candidates = collectAddedExports(files, maxSymbols); + if (!candidates.length) return []; + + const findings: UnusedExportFinding[] = []; + let searches = 0; + + for (const { file, line, symbol } of candidates) { + if (options.signal?.aborted) break; + if (searches >= maxSearches) break; + + let search: CodeSearchResponse | null = null; + try { + search = await fetchCodeSearch(codeSearchQuery(owner, repo, symbol), githubToken, fetchFn, { + ...options, + maxSearches, + }); + } catch { + search = null; + } + searches += 1; + if (!search) continue; + if (options.signal?.aborted) break; + + if (!symbolHasNonDeclarationReference(symbol, file, search)) { + findings.push({ file, line, symbol }); + if (findings.length >= MAX_FINDINGS) return findings; + } + } + return findings; +} diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 63b97b26b5..9e9b669cb0 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -475,6 +475,7 @@ export function renderBrief( lines.push(...renderDescriptorSection("approvalIntegrity", findings.approvalIntegrity)); lines.push(...renderDescriptorSection("ciCheckSignals", findings.ciCheckSignals)); lines.push(...renderDescriptorSection("undocumentedExport", findings.undocumentedExport)); + lines.push(...renderDescriptorSection("unusedExport", findings.unusedExport)); lines.push(...renderDescriptorSection("staleBranch", findings.staleBranch)); lines.push(...renderDescriptorSection("commitHygiene", findings.commitHygiene)); lines.push(...renderDescriptorSection("pendingReviewRequests", findings.pendingReviewRequests)); diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 1fb604edf1..4229e33c64 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -360,6 +360,14 @@ export interface UndocumentedExportFinding { symbol: string; } +/** An export newly ADDED by the PR that has zero non-declaration references anywhere in the repo — dead-on-arrival + * surface. Reports the symbol + its line only, never file contents. (#2025) */ +export interface UnusedExportFinding { + file: string; + line: number; + symbol: string; +} + /** A review/approval integrity signal, read from structured PR-reviews API fields only (state, commit_id, * user.login, submitted_at) — never diff/file content. `stale-approval`: the reviewer's latest APPROVED review * predates the PR's current head commit. `self-approval`: the PR author approved their own PR. @@ -593,6 +601,7 @@ export interface BriefFindings { approvalIntegrity?: ApprovalIntegrityFinding[]; ciCheckSignals?: CiCheckSignalFinding[]; undocumentedExport?: UndocumentedExportFinding[]; + unusedExport?: UnusedExportFinding[]; staleBranch?: StaleBranchFinding[]; commitHygiene?: CommitHygieneFinding[]; pendingReviewRequests?: PendingReviewRequestFinding[]; diff --git a/review-enrichment/test/analyzer-registry.test.ts b/review-enrichment/test/analyzer-registry.test.ts index 133ab89d9a..2f67666461 100644 --- a/review-enrichment/test/analyzer-registry.test.ts +++ b/review-enrichment/test/analyzer-registry.test.ts @@ -37,6 +37,7 @@ const EXPECTED_ANALYZERS = [ "approvalIntegrity", "ciCheckSignals", "undocumentedExport", + "unusedExport", "staleBranch", "commitHygiene", "pendingReviewRequests", diff --git a/review-enrichment/test/unused-export.test.ts b/review-enrichment/test/unused-export.test.ts new file mode 100644 index 0000000000..7023772206 --- /dev/null +++ b/review-enrichment/test/unused-export.test.ts @@ -0,0 +1,140 @@ +// Units for the unused-export analyzer (#2025). Own file so concurrent analyzer PRs don't collide. +// All network is mocked. Runs against the compiled dist/. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + collectAddedExports, + codeSearchQuery, + fragmentsLookLikeExportDeclaration, + symbolHasNonDeclarationReference, + scanUnusedExport, +} from "../dist/analyzers/unused-export.js"; +import { renderBrief } from "../dist/render.js"; + +const patchOf = (lines: string[]) => + `@@ -1,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join("\n")}`; + +const req = (files, extra = {}) => ({ + repoFullName: "octo/repo", + prNumber: 1, + githubToken: "ghp_test", + headSha: "abc123", + files, + ...extra, +}); + +const searchResponse = (body: unknown, status = 200) => + async (url: string) => { + if (String(url).includes("/search/code")) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + } + return new Response("", { status: 404 }); + }; + +test("codeSearchQuery: scopes to owner/repo and symbol", () => { + assert.equal(codeSearchQuery("octo", "repo", "deadHelper"), "repo:octo/repo deadHelper"); +}); + +test("collectAddedExports: gathers direct added exports across files", () => { + const files = [ + { path: "src/a.ts", patch: patchOf(["export const dead = 1;"]) }, + { path: "src/b.ts", patch: patchOf(["export function live() {}"]) }, + ]; + assert.deepEqual(collectAddedExports(files), [ + { file: "src/a.ts", line: 1, symbol: "dead" }, + { file: "src/b.ts", line: 1, symbol: "live" }, + ]); +}); + +test("symbolHasNonDeclarationReference: zero hits means unreferenced", () => { + assert.equal(symbolHasNonDeclarationReference("dead", "src/a.ts", { total_count: 0, items: [] }), false); +}); + +test("symbolHasNonDeclarationReference: a hit in another file is a reference", () => { + assert.equal( + symbolHasNonDeclarationReference("live", "src/export.ts", { + total_count: 2, + items: [ + { path: "src/export.ts", text_matches: [{ fragment: "export function live() {}" }] }, + { path: "src/use.ts", text_matches: [{ fragment: "import { live } from './export';" }] }, + ], + }), + true, + ); +}); + +test("symbolHasNonDeclarationReference: a lone declaration hit is not a reference", () => { + assert.equal( + symbolHasNonDeclarationReference("dead", "src/a.ts", { + total_count: 1, + items: [{ path: "src/a.ts", text_matches: [{ fragment: "export const dead = 1;" }] }], + }), + false, + ); +}); + +test("fragmentsLookLikeExportDeclaration: rejects use-site fragments", () => { + assert.equal(fragmentsLookLikeExportDeclaration("live", ["import { live } from './a';"]), false); + assert.equal(fragmentsLookLikeExportDeclaration("dead", ["export const dead = 1;"]), true); +}); + +test("scanUnusedExport: flags an unreferenced new export", async () => { + const findings = await scanUnusedExport( + req([{ path: "src/helpers.ts", patch: patchOf(["export const orphan = 42;"]) }]), + searchResponse({ total_count: 1, items: [{ path: "src/helpers.ts", text_matches: [{ fragment: "export const orphan = 42;" }] }] }), + ); + assert.deepEqual(findings, [{ file: "src/helpers.ts", line: 1, symbol: "orphan" }]); +}); + +test("scanUnusedExport: does not flag a referenced export", async () => { + const findings = await scanUnusedExport( + req([{ path: "src/helpers.ts", patch: patchOf(["export function shared() {}"]) }]), + searchResponse({ + total_count: 2, + items: [ + { path: "src/helpers.ts", text_matches: [{ fragment: "export function shared() {}" }] }, + { path: "src/worker.ts", text_matches: [{ fragment: "shared();" }] }, + ], + }), + ); + assert.deepEqual(findings, []); +}); + +test("scanUnusedExport: respects the search cap", async () => { + let calls = 0; + const countingFetch = async (url: string) => { + if (String(url).includes("/search/code")) calls += 1; + return new Response(JSON.stringify({ total_count: 0, items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const files = [ + { path: "src/a.ts", patch: patchOf(["export const a = 1;"]) }, + { path: "src/b.ts", patch: patchOf(["export const b = 2;"]) }, + { path: "src/c.ts", patch: patchOf(["export const c = 3;"]) }, + ]; + await scanUnusedExport(req(files), countingFetch, { maxSearches: 2 }); + assert.equal(calls, 2); +}); + +test("scanUnusedExport: skips when github token is absent", async () => { + let called = false; + const fetchFn = async () => { + called = true; + return new Response("", { status: 200 }); + }; + const findings = await scanUnusedExport( + req([{ path: "src/a.ts", patch: patchOf(["export const x = 1;"]) }], { githubToken: undefined }), + fetchFn, + ); + assert.deepEqual(findings, []); + assert.equal(called, false); +}); + +test("renderBrief: includes unusedExport findings via descriptor render", () => { + const findings = [{ file: "src/a.ts", line: 2, symbol: "orphan" }]; + const brief = renderBrief({ unusedExport: findings }).promptSection; + assert.match(brief, /Unused exports/i); + assert.match(brief, /orphan/); +}); diff --git a/src/review/enrichment-analyzer-names.ts b/src/review/enrichment-analyzer-names.ts index 8c7113c29e..bfc6a913b5 100644 --- a/src/review/enrichment-analyzer-names.ts +++ b/src/review/enrichment-analyzer-names.ts @@ -31,6 +31,7 @@ export const REES_ANALYZER_NAMES = [ "approvalIntegrity", "ciCheckSignals", "undocumentedExport", + "unusedExport", "staleBranch", "commitHygiene", "pendingReviewRequests",