Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions apps/gittensory-ui/src/lib/rees-analyzers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions review-enrichment/analyzer-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions review-enrichment/src/analyzers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
177 changes: 177 additions & 0 deletions review-enrichment/src/analyzers/unused-export.ts
Original file line number Diff line number Diff line change
@@ -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<AnalysisContext, "fetchJson">;
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<string, string> {
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<EnrichRequest["files"]>,
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<CodeSearchResponse | null> {
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<CodeSearchResponse>(url, fetchOptions)
: await boundedFetchJson<CodeSearchResponse>(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<UnusedExportFinding[]> {
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;
}
1 change: 1 addition & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
9 changes: 9 additions & 0 deletions review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -593,6 +601,7 @@ export interface BriefFindings {
approvalIntegrity?: ApprovalIntegrityFinding[];
ciCheckSignals?: CiCheckSignalFinding[];
undocumentedExport?: UndocumentedExportFinding[];
unusedExport?: UnusedExportFinding[];
staleBranch?: StaleBranchFinding[];
commitHygiene?: CommitHygieneFinding[];
pendingReviewRequests?: PendingReviewRequestFinding[];
Expand Down
1 change: 1 addition & 0 deletions review-enrichment/test/analyzer-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const EXPECTED_ANALYZERS = [
"approvalIntegrity",
"ciCheckSignals",
"undocumentedExport",
"unusedExport",
"staleBranch",
"commitHygiene",
"pendingReviewRequests",
Expand Down
Loading
Loading