From 8f17c837d6f46b4aa3d9e9f8623cbacadf829158 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Mon, 29 Jun 2026 23:11:25 -0700
Subject: [PATCH 1/2] perf(rees): add cost-aware analyzer scheduling
---
review-enrichment/src/analysis-context.ts | 2 +-
review-enrichment/src/analyzers/types.ts | 4 +
review-enrichment/src/brief.ts | 207 +++++++---
review-enrichment/src/scheduler.ts | 372 ++++++++++++++++++
review-enrichment/src/types.ts | 5 +-
review-enrichment/test/enrichment.test.ts | 8 +-
review-enrichment/test/scheduler.test.ts | 171 ++++++++
.../test/sentry-degradation.test.ts | 18 +-
src/review/enrichment-wire.ts | 23 ++
test/unit/enrichment-wire.test.ts | 45 +++
10 files changed, 789 insertions(+), 66 deletions(-)
create mode 100644 review-enrichment/src/scheduler.ts
create mode 100644 review-enrichment/test/scheduler.test.ts
diff --git a/review-enrichment/src/analysis-context.ts b/review-enrichment/src/analysis-context.ts
index 71663f6904..d69ce04944 100644
--- a/review-enrichment/src/analysis-context.ts
+++ b/review-enrichment/src/analysis-context.ts
@@ -345,7 +345,7 @@ function categorizeFile(path: string): FileCategory {
}
if (
/^Dockerfile(?:\..*)?$/.test(basename) ||
- [".env", ".ini", ".json", ".toml", ".yaml", ".yml"].includes(extension)
+ [".env", ".hcl", ".ini", ".json", ".tf", ".toml", ".yaml", ".yml"].includes(extension)
) {
return { path, extension, category: "config" };
}
diff --git a/review-enrichment/src/analyzers/types.ts b/review-enrichment/src/analyzers/types.ts
index 9b6fec5ce9..d94a3c7bd9 100644
--- a/review-enrichment/src/analyzers/types.ts
+++ b/review-enrichment/src/analyzers/types.ts
@@ -2,6 +2,7 @@ import type {
AnalyzerDiagnostics,
BriefFindings,
EnrichRequest,
+ ReesProfileName,
} from "../types.js";
import type { AnalysisContext } from "../analysis-context.js";
import type { AnalyzerRenderHelpers } from "../render-helpers.js";
@@ -39,6 +40,9 @@ export interface AnalyzerRunContext {
timeoutMs: number;
startedAtMs: number;
deadlineMs: number;
+ requestDeadlineMs: number;
+ profile: ReesProfileName;
+ costClass: AnalyzerCostClass;
diagnostics: AnalyzerDiagnostics;
analysis: AnalysisContext;
}
diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts
index 8901a54201..fd936fbf8d 100644
--- a/review-enrichment/src/brief.ts
+++ b/review-enrichment/src/brief.ts
@@ -11,6 +11,7 @@ import type {
import type {
AnalyzerRegistry,
AnalyzerRunContext,
+ AnalyzerCostClass,
} from "./analyzers/types.js";
import {
createAnalysisContext,
@@ -18,6 +19,14 @@ import {
} from "./analysis-context.js";
import { ANALYZERS } from "./analyzers/registry.js";
import { renderBrief } from "./render.js";
+import {
+ COST_ORDER,
+ analyzerTimeoutMs,
+ costClassConcurrency,
+ planAnalyzers,
+ shouldStartAnalyzer,
+ type AnalyzerPlanItem,
+} from "./scheduler.js";
import { captureAnalyzerDegradation } from "./sentry.js";
const DEFAULT_ANALYZER_TIMEOUT_MS = 8000;
@@ -39,6 +48,11 @@ function runWithTimeout(
ms: number,
diagnostics: AnalyzerDiagnostics,
analysis: AnalysisContext,
+ meta: {
+ requestDeadlineMs: number;
+ profile: AnalyzerRunContext["profile"];
+ costClass: AnalyzerCostClass;
+ },
): Promise {
const controller = new AbortController();
const startedAtMs = Date.now();
@@ -47,6 +61,9 @@ function runWithTimeout(
timeoutMs: ms,
startedAtMs,
deadlineMs: startedAtMs + ms,
+ requestDeadlineMs: meta.requestDeadlineMs,
+ profile: meta.profile,
+ costClass: meta.costClass,
diagnostics,
analysis,
};
@@ -81,6 +98,43 @@ function resultIsPartial(result: unknown): boolean {
);
}
+async function runWithConcurrency(
+ items: readonly T[],
+ limit: number,
+ run: (item: T) => Promise,
+): Promise {
+ const concurrency = Math.max(1, Math.floor(limit));
+ let index = 0;
+ async function worker(): Promise {
+ for (;;) {
+ const item = items[index];
+ index += 1;
+ if (!item) return;
+ await run(item);
+ }
+ }
+ await Promise.all(
+ Array.from(
+ { length: Math.min(concurrency, items.length) },
+ () => worker(),
+ ),
+ );
+}
+
+function statusFromDiagnostics(
+ diagnostics: AnalyzerDiagnostics,
+ fallback: AnalyzerStatus,
+): AnalyzerStatus {
+ if (diagnostics.partialReason === "analyzer_timeout") return "timeout";
+ if (diagnostics.capped || diagnostics.externalFailureReason === "call_cap") return "capped";
+ return fallback;
+}
+
+function timeoutStatus(error: unknown, diagnostics: AnalyzerDiagnostics): AnalyzerStatus {
+ if (error instanceof Error && error.message === "analyzer_timeout") return "timeout";
+ return statusFromDiagnostics(diagnostics, "degraded");
+}
+
function captureDegradation(
error: unknown,
input: {
@@ -147,77 +201,116 @@ export async function buildBrief(
): Promise {
const start = Date.now();
const all = Object.keys(analyzers) as Array;
- const requested = Array.isArray(req.analyzers)
- ? all.filter((name) => req.analyzers!.includes(name))
- : all;
const budgetMs = resolveAnalyzerTimeoutMs(req.budget?.timeoutMs);
const analysis = createAnalysisContext(req, {
startedAtMs: start,
deadlineMs: start + budgetMs,
});
+ const plan = planAnalyzers(req, analyzers, analysis, {
+ budgetMs,
+ startedAtMs: start,
+ });
const findings: BriefFindings = {};
const analyzerStatus: Record = {};
let partial = false;
- await Promise.all(
- requested.map(async (name) => {
- const analyzerStartedAt = Date.now();
- const diagnostics: AnalyzerDiagnostics = {
- partialStatus: "complete",
- };
- try {
- const analyzer = analyzers[name];
- if (!analyzer) throw new Error("analyzer_unregistered");
- const result = await runWithTimeout(
- (context) => analyzer(req, context),
- budgetMs,
- diagnostics,
- analysis,
- );
- findings[name] = result as never;
- if (resultIsPartial(result) || diagnostics.partialStatus === "partial") {
- analyzerStatus[name] = "degraded";
- partial = true;
- diagnostics.partialStatus = "partial";
- diagnostics.partialReason ??= "analyzer_partial";
- if (diagnostics.captureDegradation) {
- attachAnalysisMetrics(diagnostics, analysis);
- captureDegradation(new Error(diagnostics.partialReason), {
- analyzer: name,
- requested,
- req,
- timeoutMs: budgetMs,
- elapsedMs: Date.now() - analyzerStartedAt,
- analyzerStatus: "degraded",
- diagnostics,
- options,
- });
- }
- } else {
- analyzerStatus[name] = "ok";
- }
- } catch (error) {
- analyzerStatus[name] = "degraded";
+ for (const item of plan.skipped) analyzerStatus[item.name] = "skipped";
+
+ async function runAnalyzer(item: AnalyzerPlanItem): Promise {
+ const name = item.name;
+ const analyzerStartedAt = Date.now();
+ const diagnostics: AnalyzerDiagnostics = {
+ partialStatus: "complete",
+ };
+ const remainingMs = plan.executionDeadlineMs - Date.now();
+ if (!shouldStartAnalyzer(plan.profile, remainingMs)) {
+ analyzerStatus[name] = "capped";
+ partial = true;
+ analysis.metrics.recordCappedWork("analyzer_budget", 1);
+ return;
+ }
+ const timeoutMs = analyzerTimeoutMs(
+ plan.profile,
+ item.descriptor.cost,
+ remainingMs,
+ plan.explicitAnalyzers,
+ );
+ if (timeoutMs <= 0) {
+ analyzerStatus[name] = "capped";
+ partial = true;
+ analysis.metrics.recordCappedWork(`analyzer_${item.descriptor.cost}`, 1);
+ return;
+ }
+ try {
+ const analyzer = analyzers[name];
+ if (!analyzer) throw new Error("analyzer_unregistered");
+ const result = await runWithTimeout(
+ (context) => analyzer(req, context),
+ timeoutMs,
+ diagnostics,
+ analysis,
+ {
+ requestDeadlineMs: plan.executionDeadlineMs,
+ profile: plan.profile,
+ costClass: item.descriptor.cost,
+ },
+ );
+ findings[name] = result as never;
+ if (resultIsPartial(result) || diagnostics.partialStatus === "partial") {
+ const status = statusFromDiagnostics(diagnostics, "degraded");
+ analyzerStatus[name] = status;
partial = true;
diagnostics.partialStatus = "partial";
- diagnostics.partialReason ??= error instanceof Error ? error.message : "analyzer_error";
- attachAnalysisMetrics(diagnostics, analysis);
- captureDegradation(error, {
- analyzer: name,
- requested,
- req,
- timeoutMs: budgetMs,
- elapsedMs: Date.now() - analyzerStartedAt,
- analyzerStatus: "degraded",
- diagnostics,
- options,
- });
+ diagnostics.partialReason ??= status === "capped" ? "analyzer_capped" : "analyzer_partial";
+ if (diagnostics.captureDegradation) {
+ attachAnalysisMetrics(diagnostics, analysis);
+ captureDegradation(new Error(diagnostics.partialReason), {
+ analyzer: name,
+ requested: plan.requested,
+ req,
+ timeoutMs,
+ elapsedMs: Date.now() - analyzerStartedAt,
+ analyzerStatus: status,
+ diagnostics,
+ options,
+ });
+ }
+ } else {
+ analyzerStatus[name] = "ok";
}
- }),
- );
+ } catch (error) {
+ const status = timeoutStatus(error, diagnostics);
+ analyzerStatus[name] = status;
+ partial = true;
+ diagnostics.partialStatus = "partial";
+ diagnostics.partialReason ??= error instanceof Error ? error.message : "analyzer_error";
+ attachAnalysisMetrics(diagnostics, analysis);
+ captureDegradation(error, {
+ analyzer: name,
+ requested: plan.requested,
+ req,
+ timeoutMs,
+ elapsedMs: Date.now() - analyzerStartedAt,
+ analyzerStatus: status,
+ diagnostics,
+ options,
+ });
+ }
+ }
+
+ for (const cost of COST_ORDER) {
+ const items = plan.runnable.filter((item) => item.descriptor.cost === cost);
+ if (!items.length) continue;
+ await runWithConcurrency(
+ items,
+ costClassConcurrency(plan.profile, cost, plan.explicitAnalyzers),
+ runAnalyzer,
+ );
+ }
+
for (const name of all)
- if (!requested.includes(name)) analyzerStatus[name] = "skipped";
+ if (!plan.requested.includes(name)) analyzerStatus[name] = "skipped";
const { promptSection, systemSuffix } = renderBrief(
findings,
diff --git a/review-enrichment/src/scheduler.ts b/review-enrichment/src/scheduler.ts
new file mode 100644
index 0000000000..3748d4dc63
--- /dev/null
+++ b/review-enrichment/src/scheduler.ts
@@ -0,0 +1,372 @@
+import type { AnalysisContext } from "./analysis-context.js";
+import {
+ ANALYZER_NAMES,
+ getAnalyzerDescriptor,
+} from "./analyzers/registry.js";
+import type {
+ AnalyzerCostClass,
+ AnalyzerDescriptor,
+ AnalyzerName,
+ AnalyzerRegistry,
+ AnyAnalyzerDescriptor,
+} from "./analyzers/types.js";
+import type {
+ AnalyzerStatus,
+ EnrichRequest,
+ ReesProfileName,
+} from "./types.js";
+
+export const DEFAULT_REES_PROFILE: ReesProfileName = "balanced";
+
+export const REES_PROFILES = ["fast", "balanced", "deep"] as const satisfies readonly ReesProfileName[];
+
+export const COST_ORDER: readonly AnalyzerCostClass[] = [
+ "local",
+ "registry",
+ "github-light",
+ "github-heavy",
+ "tooling",
+];
+
+const PROFILE_CONFIG: Record<
+ ReesProfileName,
+ {
+ costs: ReadonlySet;
+ concurrency: Record;
+ timeoutMs: Record;
+ responseReserveMs: number;
+ minStartMs: number;
+ }
+> = {
+ fast: {
+ costs: new Set(["local", "registry"]),
+ concurrency: {
+ local: 8,
+ registry: 2,
+ "github-light": 0,
+ "github-heavy": 0,
+ tooling: 0,
+ },
+ timeoutMs: {
+ local: 400,
+ registry: 800,
+ "github-light": 0,
+ "github-heavy": 0,
+ tooling: 0,
+ },
+ responseReserveMs: 500,
+ minStartMs: 1,
+ },
+ balanced: {
+ costs: new Set(COST_ORDER),
+ concurrency: {
+ local: 8,
+ registry: 3,
+ "github-light": 2,
+ "github-heavy": 1,
+ tooling: 1,
+ },
+ timeoutMs: {
+ local: 750,
+ registry: 1400,
+ "github-light": 1400,
+ "github-heavy": 2200,
+ tooling: 1400,
+ },
+ responseReserveMs: 750,
+ minStartMs: 1,
+ },
+ deep: {
+ costs: new Set(COST_ORDER),
+ concurrency: {
+ local: 8,
+ registry: 4,
+ "github-light": 2,
+ "github-heavy": 1,
+ tooling: 1,
+ },
+ timeoutMs: {
+ local: 1000,
+ registry: 2500,
+ "github-light": 2500,
+ "github-heavy": 4000,
+ tooling: 2500,
+ },
+ responseReserveMs: 1000,
+ minStartMs: 1,
+ },
+};
+
+export interface AnalyzerPlanItem {
+ name: AnalyzerName;
+ descriptor: AnalyzerDescriptor;
+ status?: AnalyzerStatus;
+ skipReason?: string;
+}
+
+export interface AnalyzerPlan {
+ profile: ReesProfileName;
+ explicitAnalyzers: boolean;
+ requested: AnalyzerName[];
+ runnable: AnalyzerPlanItem[];
+ skipped: AnalyzerPlanItem[];
+ responseReserveMs: number;
+ executionDeadlineMs: number;
+}
+
+export function resolveReesProfile(value: unknown): ReesProfileName {
+ if (typeof value !== "string") return DEFAULT_REES_PROFILE;
+ const normalized = value.trim().toLowerCase();
+ return isReesProfileName(normalized) ? normalized : DEFAULT_REES_PROFILE;
+}
+
+export function isReesProfileName(value: string): value is ReesProfileName {
+ return (REES_PROFILES as readonly string[]).includes(value);
+}
+
+export function responseReserveMs(profile: ReesProfileName, budgetMs: number): number {
+ const configured = PROFILE_CONFIG[profile].responseReserveMs;
+ const proportional = Math.floor(Math.max(0, budgetMs) * 0.2);
+ const reserve = Math.min(configured, Math.max(150, proportional));
+ return Math.min(Math.max(0, budgetMs - 1), reserve);
+}
+
+export function costClassConcurrency(
+ profile: ReesProfileName,
+ cost: AnalyzerCostClass,
+ explicitAnalyzer = false,
+): number {
+ const configured = Math.max(0, PROFILE_CONFIG[profile].concurrency[cost] ?? 0);
+ if (configured > 0 || !explicitAnalyzer) return configured;
+ return Math.max(1, PROFILE_CONFIG[DEFAULT_REES_PROFILE].concurrency[cost] ?? 1);
+}
+
+export function analyzerTimeoutMs(
+ profile: ReesProfileName,
+ cost: AnalyzerCostClass,
+ remainingMs: number,
+ explicitAnalyzer = false,
+): number {
+ const configured = Math.max(0, PROFILE_CONFIG[profile].timeoutMs[cost] ?? 0);
+ const classBudget =
+ configured > 0 || !explicitAnalyzer
+ ? configured
+ : Math.max(0, PROFILE_CONFIG[DEFAULT_REES_PROFILE].timeoutMs[cost] ?? 0);
+ return Math.max(0, Math.min(classBudget, Math.floor(remainingMs)));
+}
+
+export function shouldStartAnalyzer(
+ profile: ReesProfileName,
+ remainingMs: number,
+): boolean {
+ return remainingMs >= PROFILE_CONFIG[profile].minStartMs;
+}
+
+export function planAnalyzers(
+ req: EnrichRequest,
+ analyzers: AnalyzerRegistry,
+ analysis: AnalysisContext,
+ options: { budgetMs: number; startedAtMs: number },
+): AnalyzerPlan {
+ const profile = resolveReesProfile(req.profile);
+ const explicitAnalyzers = Array.isArray(req.analyzers);
+ const configuredReserve = responseReserveMs(profile, options.budgetMs);
+ const executionDeadlineMs = options.startedAtMs + options.budgetMs - configuredReserve;
+ const allNames = analyzerNamesForRegistry(analyzers);
+ const requested = selectRequestedAnalyzers(req, allNames, profile);
+ const runnable: AnalyzerPlanItem[] = [];
+ const skipped: AnalyzerPlanItem[] = [];
+
+ for (const name of requested) {
+ const descriptor = descriptorForAnalyzer(name);
+ const skipReason = skipReasonForAnalyzer(
+ req,
+ analysis,
+ descriptor,
+ profile,
+ explicitAnalyzers,
+ );
+ if (skipReason) {
+ skipped.push({
+ name,
+ descriptor,
+ status: "skipped",
+ skipReason,
+ });
+ analysis.metrics.recordSkippedWork(`analyzer_${skipReason}`);
+ continue;
+ }
+ runnable.push({ name, descriptor });
+ }
+
+ runnable.sort(
+ (left, right) =>
+ COST_ORDER.indexOf(left.descriptor.cost) - COST_ORDER.indexOf(right.descriptor.cost) ||
+ allNames.indexOf(left.name) - allNames.indexOf(right.name),
+ );
+
+ return {
+ profile,
+ explicitAnalyzers,
+ requested,
+ runnable,
+ skipped,
+ responseReserveMs: configuredReserve,
+ executionDeadlineMs,
+ };
+}
+
+function analyzerNamesForRegistry(analyzers: AnalyzerRegistry): AnalyzerName[] {
+ const names = Object.keys(analyzers) as AnalyzerName[];
+ return names.sort((left, right) => {
+ const leftIndex = ANALYZER_NAMES.indexOf(left);
+ const rightIndex = ANALYZER_NAMES.indexOf(right);
+ if (leftIndex === -1 && rightIndex === -1) return left.localeCompare(right);
+ if (leftIndex === -1) return 1;
+ if (rightIndex === -1) return -1;
+ return leftIndex - rightIndex;
+ });
+}
+
+function selectRequestedAnalyzers(
+ req: EnrichRequest,
+ names: readonly AnalyzerName[],
+ profile: ReesProfileName,
+): AnalyzerName[] {
+ if (Array.isArray(req.analyzers)) {
+ return names.filter((name) => req.analyzers!.includes(name));
+ }
+ const config = PROFILE_CONFIG[profile];
+ return names.filter((name) => {
+ const descriptor = descriptorForAnalyzer(name);
+ return descriptor.defaultEnabled && config.costs.has(descriptor.cost);
+ });
+}
+
+function descriptorForAnalyzer(name: AnalyzerName): AnalyzerDescriptor {
+ const descriptor = getAnalyzerDescriptor(name);
+ if (descriptor) return descriptor as AnalyzerDescriptor;
+ return {
+ name,
+ title: name,
+ category: "quality",
+ cost: "local",
+ defaultEnabled: true,
+ requires: [],
+ docs: {
+ summary: "Custom analyzer supplied by a caller.",
+ looksAt: "Caller-provided inputs.",
+ reports: "Caller-defined findings.",
+ network: "Unknown.",
+ notes: "Synthetic descriptor used for tests or injected registries.",
+ },
+ run: async () => [] as never,
+ };
+}
+
+function skipReasonForAnalyzer(
+ req: EnrichRequest,
+ analysis: AnalysisContext,
+ descriptor: AnyAnalyzerDescriptor | AnalyzerDescriptor,
+ profile: ReesProfileName,
+ explicitAnalyzers: boolean,
+): string | null {
+ if (!explicitAnalyzers && !PROFILE_CONFIG[profile].costs.has(descriptor.cost)) return "profile";
+ if (!explicitAnalyzers && costClassConcurrency(profile, descriptor.cost) <= 0) return "profile";
+
+ if (
+ descriptor.requires.includes("files") &&
+ analysis.changedFiles.length === 0 &&
+ !historyCanRunWithoutGitHub(req, descriptor.name)
+ ) {
+ return "no_files";
+ }
+ if (descriptor.requires.includes("head-sha") && !req.headSha) {
+ return "missing_head_sha";
+ }
+ if (descriptor.requires.includes("base-sha") && !req.baseSha) {
+ return "missing_base_sha";
+ }
+ if (descriptor.requires.includes("author") && !req.author && descriptor.name !== "history") {
+ return "missing_author";
+ }
+ if (
+ descriptor.requires.includes("github-token") &&
+ !req.githubToken &&
+ !historyCanRunWithoutGitHub(req, descriptor.name)
+ ) {
+ return "missing_github_token";
+ }
+
+ return inputSkipReason(descriptor.name, analysis, req);
+}
+
+function inputSkipReason(
+ name: AnalyzerName,
+ analysis: AnalysisContext,
+ req: EnrichRequest,
+): string | null {
+ switch (name) {
+ case "dependency":
+ case "license":
+ case "installScript":
+ case "heavyDependency":
+ case "typosquat":
+ case "nativeBuild":
+ return analysis.dependencyManifestPaths.length ? null : "no_dependency_manifest";
+ case "lockfileDrift":
+ return analysis.fileCategories.some((file) => file.category === "lockfile")
+ ? null
+ : "no_lockfile";
+ case "actionPin":
+ return analysis.fileCategories.some((file) => file.category === "workflow")
+ ? null
+ : "no_workflow";
+ case "eol":
+ return analysis.changedFilePaths.some(isRuntimePinPath) ? null : "no_runtime_pin";
+ case "redos":
+ case "secret":
+ case "secretLog":
+ return analysis.addedLines.length ? null : "no_added_lines";
+ case "provenance":
+ return analysis.dependencyManifestPaths.length ||
+ analysis.changedFiles.some((file) => file.status === "added" || file.status === "copied")
+ ? null
+ : "no_provenance_input";
+ case "codeowners":
+ return analysis.changedFilePaths.length ? null : "no_changed_paths";
+ case "assetWeight":
+ return analysis.fileCategories.some((file) => file.category === "asset")
+ ? null
+ : "no_asset_paths";
+ case "commitSignature":
+ return null;
+ case "iacMisconfig":
+ return analysis.fileCategories.some((file) => file.category === "config")
+ ? null
+ : "no_config_paths";
+ case "history":
+ if (historyCanRunWithoutGitHub(req, name)) return null;
+ return req.githubToken && req.author && analysis.changedFilePaths.length
+ ? null
+ : "no_history_input";
+ default:
+ return null;
+ }
+}
+
+function historyCanRunWithoutGitHub(
+ req: EnrichRequest,
+ name: AnalyzerName,
+): boolean {
+ return name === "history" && Boolean(req.linkedIssue && (req.diff || req.files?.length));
+}
+
+function isRuntimePinPath(path: string): boolean {
+ const basename = path.split("/").pop() ?? path;
+ return (
+ /^Dockerfile(?:\..*)?$/.test(basename) ||
+ basename === ".nvmrc" ||
+ basename === "go.mod"
+ );
+}
diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts
index e764eb9000..671882d042 100644
--- a/review-enrichment/src/types.ts
+++ b/review-enrichment/src/types.ts
@@ -25,9 +25,12 @@ export interface EnrichRequest {
* whether the diff covers the issue's stated requirement without an extra fetch. Absent ⇒ alignment omitted. (#1478) */
linkedIssue?: EnrichLinkedIssue;
budget?: { timeoutMs?: number; maxBriefChars?: number };
+ profile?: ReesProfileName;
analyzers?: string[];
}
+export type ReesProfileName = "fast" | "balanced" | "deep";
+
/** A PR's linked issue, as carried in the request envelope. `title`/`body` hold the stated requirement the history
* analyzer measures the diff against; only the number is mandatory. (#1478) */
export interface EnrichLinkedIssue {
@@ -302,7 +305,7 @@ export interface DocCommentDriftFinding {
staleParams: string[];
}
-export type AnalyzerStatus = "ok" | "degraded" | "skipped";
+export type AnalyzerStatus = "ok" | "degraded" | "skipped" | "capped" | "timeout";
/** Internal, public-safe analyzer diagnostics for Sentry. Never attach request bodies, diffs, tokens, or raw prompts. */
export interface AnalyzerDiagnostics {
diff --git a/review-enrichment/test/enrichment.test.ts b/review-enrichment/test/enrichment.test.ts
index 7aa778e178..837fd0bd2e 100644
--- a/review-enrichment/test/enrichment.test.ts
+++ b/review-enrichment/test/enrichment.test.ts
@@ -736,6 +736,10 @@ test("buildBrief: dependency + secret analyzers both run", async () => {
repoFullName: "o/r",
prNumber: 9,
files: [
+ {
+ path: "package.json",
+ patch: '+ "lodash": "4.17.20",',
+ },
{
path: "app.ts",
patch:
@@ -1670,7 +1674,7 @@ test("buildBrief: timeout aborts dependency scan so OSV work stops", async () =>
repoFullName: "o/r",
prNumber: 10,
analyzers: ["dependency"],
- budget: { timeoutMs: 1 },
+ budget: { timeoutMs: 200 },
files: Array.from({ length: 5 }, (_, index) => ({
path: "package.json",
patch: `+ "pkg-${index}": "1.0.0",`,
@@ -1678,7 +1682,7 @@ test("buildBrief: timeout aborts dependency scan so OSV work stops", async () =>
});
assert.equal(brief.partial, true);
- assert.equal(brief.analyzerStatus.dependency, "degraded");
+ assert.equal(brief.analyzerStatus.dependency, "timeout");
assert.equal(fetchCount, 1);
assert.equal(signals.length, 1);
assert.equal(signals[0].aborted, true);
diff --git a/review-enrichment/test/scheduler.test.ts b/review-enrichment/test/scheduler.test.ts
new file mode 100644
index 0000000000..071244e237
--- /dev/null
+++ b/review-enrichment/test/scheduler.test.ts
@@ -0,0 +1,171 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { buildBrief } from "../dist/brief.js";
+
+test("fast profile skips GitHub-heavy defaults without running them", async () => {
+ let ran = false;
+ const brief = await buildBrief(
+ {
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1811,
+ profile: "fast",
+ githubToken: "token",
+ author: "jsonbored",
+ headSha: "abcdef1234567890",
+ files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,1 @@\n+export const a = 1;" }],
+ },
+ {
+ history: async () => {
+ ran = true;
+ return [];
+ },
+ },
+ );
+
+ assert.equal(ran, false);
+ assert.equal(brief.partial, false);
+ assert.equal(brief.analyzerStatus.history, "skipped");
+});
+
+test("explicit analyzer selection overrides profile membership while retaining bounded budgets", async () => {
+ let sawProfile = "";
+ let sawCostClass = "";
+ let sawTimeoutMs = 0;
+ const brief = await buildBrief(
+ {
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1811,
+ profile: "fast",
+ analyzers: ["history"],
+ githubToken: "token",
+ author: "jsonbored",
+ headSha: "abcdef1234567890",
+ files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,1 @@\n+export const a = 1;" }],
+ budget: { timeoutMs: 2000 },
+ },
+ {
+ history: async (_req, context) => {
+ sawProfile = context.profile;
+ sawCostClass = context.costClass;
+ sawTimeoutMs = context.timeoutMs;
+ return [];
+ },
+ },
+ );
+
+ assert.equal(brief.analyzerStatus.history, "ok");
+ assert.equal(sawProfile, "fast");
+ assert.equal(sawCostClass, "github-heavy");
+ assert.ok(sawTimeoutMs > 0);
+ assert.ok(sawTimeoutMs < 2000);
+});
+
+test("slow analyzers time out inside the reserved response budget", async () => {
+ const started = Date.now();
+ const brief = await buildBrief(
+ {
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1811,
+ analyzers: ["history"],
+ githubToken: "token",
+ author: "jsonbored",
+ headSha: "abcdef1234567890",
+ files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,1 @@\n+export const a = 1;" }],
+ budget: { timeoutMs: 300 },
+ },
+ {
+ history: async () => new Promise(() => undefined),
+ },
+ );
+
+ assert.equal(brief.partial, true);
+ assert.equal(brief.analyzerStatus.history, "timeout");
+ assert.ok(Date.now() - started < 1000);
+ assert.ok(brief.elapsedMs < 1000);
+});
+
+test("cost classes run in priority order instead of starting all at once", async () => {
+ const events: string[] = [];
+
+ const brief = await buildBrief(
+ {
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1811,
+ analyzers: ["secret", "dependency", "history"],
+ githubToken: "token",
+ author: "jsonbored",
+ headSha: "abcdef1234567890",
+ files: [
+ {
+ path: "package.json",
+ patch: [
+ "@@ -1,3 +1,4 @@",
+ ' { "dependencies": {',
+ '+ "left-pad": "1.3.0",',
+ '+ "apiKey": "test"',
+ ].join("\n"),
+ },
+ ],
+ budget: { timeoutMs: 2000 },
+ },
+ {
+ secret: async () => {
+ events.push("local:start");
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ events.push("local:end");
+ return [];
+ },
+ dependency: async () => {
+ events.push("registry:start");
+ assert.deepEqual(events, ["local:start", "local:end", "registry:start"]);
+ events.push("registry:end");
+ return [];
+ },
+ history: async () => {
+ events.push("github-heavy:start");
+ assert.deepEqual(events, [
+ "local:start",
+ "local:end",
+ "registry:start",
+ "registry:end",
+ "github-heavy:start",
+ ]);
+ events.push("github-heavy:end");
+ return [];
+ },
+ },
+ );
+
+ assert.equal(brief.partial, false);
+ assert.deepEqual(events, [
+ "local:start",
+ "local:end",
+ "registry:start",
+ "registry:end",
+ "github-heavy:start",
+ "github-heavy:end",
+ ]);
+});
+
+test("registry analyzers skip when their relevant inputs are absent", async () => {
+ let dependencyRan = false;
+ const brief = await buildBrief(
+ {
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1811,
+ files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,1 @@\n+export const a = 1;" }],
+ },
+ {
+ dependency: async () => {
+ dependencyRan = true;
+ return [];
+ },
+ secret: async () => [],
+ },
+ );
+
+ assert.equal(dependencyRan, false);
+ assert.equal(brief.analyzerStatus.dependency, "skipped");
+ assert.equal(brief.analyzerStatus.secret, "ok");
+});
diff --git a/review-enrichment/test/sentry-degradation.test.ts b/review-enrichment/test/sentry-degradation.test.ts
index e01fb31058..8c07cd9326 100644
--- a/review-enrichment/test/sentry-degradation.test.ts
+++ b/review-enrichment/test/sentry-degradation.test.ts
@@ -218,7 +218,9 @@ test("buildBrief stays fail-open and captures a degraded analyzer", async () =>
repoFullName: "JSONbored/gittensory",
prNumber: 42,
headSha: "head-sha",
- budget: { timeoutMs: 50 },
+ analyzers: ["dependency"],
+ files: [{ path: "package.json", patch: '+ "lodash": "4.17.20",' }],
+ budget: { timeoutMs: 200 },
},
{
dependency: async () => {
@@ -238,17 +240,23 @@ test("buildBrief stays fail-open and captures a degraded analyzer", async () =>
assert.equal(sentry.tags.repo, "JSONbored/gittensory");
assert.equal(sentry.tags.pullNumber, "42");
assert.equal(sentry.tags.headShaPrefix, "head-sha");
- assert.equal(sentry.tags.timeoutMs, "50");
+ const capturedTimeoutMs = Number(sentry.tags.timeoutMs);
+ assert.ok(capturedTimeoutMs > 0);
+ assert.ok(capturedTimeoutMs <= 200);
});
-test("buildBrief returns a degraded partial response before the caller timeout budget is spent", async () => {
+test("buildBrief returns a timed-out partial response before the caller timeout budget is spent", async () => {
const started = Date.now();
const brief = await buildBrief(
{
repoFullName: "JSONbored/metagraphed",
prNumber: 2359,
headSha: "abcdef1234567890",
- budget: { timeoutMs: 20 },
+ analyzers: ["history"],
+ githubToken: "token",
+ author: "jsonbored",
+ files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,1 @@\n+export const a = 1;" }],
+ budget: { timeoutMs: 300 },
},
{
history: async () => new Promise(() => undefined),
@@ -257,7 +265,7 @@ test("buildBrief returns a degraded partial response before the caller timeout b
);
assert.equal(brief.partial, true);
- assert.equal(brief.analyzerStatus.history, "degraded");
+ assert.equal(brief.analyzerStatus.history, "timeout");
assert.deepEqual(brief.findings, {});
assert.ok(Date.now() - started < 500);
assert.ok(brief.elapsedMs < 500);
diff --git a/src/review/enrichment-wire.ts b/src/review/enrichment-wire.ts
index c1cbb139ce..e0bb183e9c 100644
--- a/src/review/enrichment-wire.ts
+++ b/src/review/enrichment-wire.ts
@@ -16,6 +16,7 @@ interface EnrichmentEnv {
REES_SHARED_SECRET?: string | undefined;
REES_TIMEOUT_MS?: string | undefined;
REES_ANALYZERS?: string | undefined;
+ REES_PROFILE?: string | undefined;
REES_FORWARD_GITHUB_TOKEN?: string | undefined;
}
@@ -94,6 +95,9 @@ export const REES_ANALYZER_NAMES = [
] as const;
const REES_ANALYZER_NAME_SET = new Set(REES_ANALYZER_NAMES);
+const REES_PROFILE_NAMES = ["fast", "balanced", "deep"] as const;
+type ReesProfileName = (typeof REES_PROFILE_NAMES)[number];
+const REES_PROFILE_NAME_SET = new Set(REES_PROFILE_NAMES);
function sanitizeEnrichmentPromptSection(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
@@ -178,6 +182,21 @@ export function resolveReesAnalyzers(env: Env): string[] | undefined {
return selected;
}
+export function resolveReesProfile(env: Env): ReesProfileName | undefined {
+ const raw = reesConfig(env).REES_PROFILE?.trim();
+ if (!raw) return undefined;
+ const normalized = raw.toLowerCase();
+ if (REES_PROFILE_NAME_SET.has(normalized)) return normalized as ReesProfileName;
+ console.warn(
+ JSON.stringify({
+ level: "warn",
+ event: "rees_profile_config_invalid",
+ profile: raw.slice(0, 40),
+ }),
+ );
+ return undefined;
+}
+
/** POST the PR to the REES and return the spliceable brief, or undefined on any error/timeout/empty (fail-safe). */
export async function buildReviewEnrichment(
env: Env,
@@ -195,6 +214,7 @@ export async function buildReviewEnrichment(
const timeoutMs = resolveReesTransportTimeoutMs(cfg.REES_TIMEOUT_MS);
const analyzerBudgetMs = resolveReesAnalyzerBudgetMs(timeoutMs);
const analyzers = resolveReesAnalyzers(env);
+ const profile = resolveReesProfile(env);
const requestId = newReesRequestId();
try {
const response = await fetch(`${base.replace(/\/+$/, "")}/v1/enrich`, {
@@ -225,6 +245,7 @@ export async function buildReviewEnrichment(
})),
diff: input.diff,
...(analyzers ? { analyzers } : {}),
+ ...(profile ? { profile } : {}),
budget: {
timeoutMs: analyzerBudgetMs,
maxBriefChars: MAX_ENRICHMENT_PROMPT_SECTION_CHARS,
@@ -249,6 +270,7 @@ export async function buildReviewEnrichment(
requestId,
timeoutMs,
analyzerBudgetMs,
+ reesProfile: profile ?? "default",
requestedAnalyzers: analyzers ?? "all",
authConfigured,
authHeaderSent: authConfigured,
@@ -296,6 +318,7 @@ export async function buildReviewEnrichment(
requestId,
timeoutMs,
analyzerBudgetMs,
+ reesProfile: profile ?? "default",
requestedAnalyzers: analyzers ?? "all",
authConfigured,
authHeaderSent: authConfigured,
diff --git a/test/unit/enrichment-wire.test.ts b/test/unit/enrichment-wire.test.ts
index 82cafae732..0e5ea5c198 100644
--- a/test/unit/enrichment-wire.test.ts
+++ b/test/unit/enrichment-wire.test.ts
@@ -5,6 +5,7 @@ import {
isReesGithubTokenForwardingEnabled,
resolveReesAnalyzers,
resolveReesAnalyzerBudgetMs,
+ resolveReesProfile,
resolveReesTransportTimeoutMs,
} from "../../src/review/enrichment-wire";
@@ -108,6 +109,7 @@ describe("buildReviewEnrichment", () => {
expect(body.author).toBe("alice");
expect(body.githubToken).toBe("gh-read-token");
expect(body.analyzers).toBeUndefined();
+ expect(body.profile).toBeUndefined();
expect(body.budget).toEqual({ timeoutMs: 11000, maxBriefChars: 8000 });
expect(body.files).toEqual([
{
@@ -174,6 +176,24 @@ describe("buildReviewEnrichment", () => {
]);
});
+ it("sends a configured REES profile when no explicit analyzer subset is required", async () => {
+ const calls: RequestInit[] = [];
+ globalThis.fetch = vi.fn(async (_url: unknown, init: RequestInit) => {
+ calls.push(init);
+ return {
+ ok: true,
+ json: async () => ({ promptSection: "brief" }),
+ } as Response;
+ }) as unknown as typeof fetch;
+ await buildReviewEnrichment(
+ env({ REES_URL: "https://r", REES_PROFILE: " fast " }),
+ input,
+ );
+ const body = JSON.parse(calls[0]!.body as string);
+ expect(body.profile).toBe("fast");
+ expect(body.analyzers).toBeUndefined();
+ });
+
it("sends an explicit empty analyzer list when REES_ANALYZERS has no valid names", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const calls: RequestInit[] = [];
@@ -516,6 +536,31 @@ describe("resolveReesAnalyzers", () => {
});
});
+describe("resolveReesProfile", () => {
+ it("returns undefined for unset profiles", () => {
+ expect(resolveReesProfile(env({}))).toBeUndefined();
+ });
+
+ it("normalizes supported profile names", () => {
+ expect(resolveReesProfile(env({ REES_PROFILE: " FAST " }))).toBe("fast");
+ expect(resolveReesProfile(env({ REES_PROFILE: "balanced" }))).toBe("balanced");
+ expect(resolveReesProfile(env({ REES_PROFILE: "Deep" }))).toBe("deep");
+ });
+
+ it("warns and omits unsupported profiles", () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ expect(resolveReesProfile(env({ REES_PROFILE: "everything" }))).toBeUndefined();
+ expect(
+ warnSpy.mock.calls.some(
+ (c) =>
+ String(c[0]).includes("rees_profile_config_invalid") &&
+ String(c[0]).includes("everything"),
+ ),
+ ).toBe(true);
+ warnSpy.mockRestore();
+ });
+});
+
describe("REES timeout budget helpers", () => {
it("keeps analyzer execution below the HTTP transport timeout", () => {
expect(resolveReesTransportTimeoutMs(undefined)).toBe(8000);
From e28f9eb75870dfee2db689aac0e2ca9375f872dc Mon Sep 17 00:00:00 2001
From: ghost <49853598+JSONbored@users.noreply.github.com>
Date: Tue, 30 Jun 2026 00:12:07 -0700
Subject: [PATCH 2/2] feat(rees): generate analyzer config metadata (#1835)
* feat(rees): generate analyzer config metadata
* feat(rees): add performance guardrails and telemetry (#1836)
---
.env.example | 22 +-
apps/gittensory-ui/src/lib/rees-analyzers.ts | 547 +++++++++++++---
.../docs.self-hosting-rees-analyzers.tsx | 75 ++-
.../src/routes/docs.self-hosting-rees.tsx | 23 +-
package.json | 2 +
review-enrichment/analyzer-metadata.json | 584 ++++++++++++++++++
review-enrichment/package-lock.json | 17 +
review-enrichment/package.json | 5 +-
.../scripts/generate-analyzer-metadata.mjs | 157 +++++
review-enrichment/src/brief.ts | 115 +++-
review-enrichment/src/request-guardrails.ts | 152 +++++
review-enrichment/src/scheduler.ts | 23 +
review-enrichment/src/sentry.ts | 12 +
review-enrichment/src/server.ts | 22 +-
review-enrichment/src/types.ts | 31 +
.../test/analyzer-metadata.test.ts | 49 ++
.../test/request-guardrails.test.ts | 153 +++++
review-enrichment/test/scheduler.test.ts | 9 +
.../test/sentry-degradation.test.ts | 43 +-
19 files changed, 1902 insertions(+), 139 deletions(-)
create mode 100644 review-enrichment/analyzer-metadata.json
create mode 100644 review-enrichment/scripts/generate-analyzer-metadata.mjs
create mode 100644 review-enrichment/src/request-guardrails.ts
create mode 100644 review-enrichment/test/analyzer-metadata.test.ts
create mode 100644 review-enrichment/test/request-guardrails.test.ts
diff --git a/.env.example b/.env.example
index 843edbcd52..a2cc364cb7 100644
--- a/.env.example
+++ b/.env.example
@@ -54,15 +54,29 @@ GITTENSORY_REVIEW_ENRICHMENT=false
# REES_URL=https://enrichment.example.internal
# REES_SHARED_SECRET= # bearer secret configured on the REES service
# REES_TIMEOUT_MS=8000 # optional; minimum 1000, default 8000
+# REES_PROFILE=balanced # optional; fast | balanced | deep. Unset uses balanced.
# REES_FORWARD_GITHUB_TOKEN=false # optional; default false. Set true only when REES_URL is inside
# # your trust boundary and token-aware analyzers need a GitHub
# # token for CODEOWNERS/blob-size reads (installation token when
# # available; otherwise GITHUB_PUBLIC_TOKEN).
-# REES_ANALYZERS=all # all | comma-list of exact names:
-# # dependency,lockfileDrift,secret,license,installScript,
-# # actionPin,eol,redos,provenance,codeowners,secretLog,
-# # assetWeight,typosquat
+# REES_ANALYZERS=all # all | comma-list of exact names.
# # Unknown names warn and are ignored; a typo-only list runs no analyzers.
+# BEGIN GENERATED REES ANALYZERS
+# Current analyzer names:
+# dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol,redos
+# provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig,nativeBuild
+# history,docCommentDrift
+#
+# Profile defaults:
+# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
+# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild
+# balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency
+# actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature
+# iacMisconfig,nativeBuild,history,docCommentDrift
+# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
+# redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
+# nativeBuild,history,docCommentDrift
+# END GENERATED REES ANALYZERS
# Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep
# submitters to a deterministic-only review. Never surfaced publicly.
diff --git a/apps/gittensory-ui/src/lib/rees-analyzers.ts b/apps/gittensory-ui/src/lib/rees-analyzers.ts
index aaf4fd4e9e..a73ff5eec9 100644
--- a/apps/gittensory-ui/src/lib/rees-analyzers.ts
+++ b/apps/gittensory-ui/src/lib/rees-analyzers.ts
@@ -1,153 +1,510 @@
+// Generated by review-enrichment/scripts/generate-analyzer-metadata.mjs.
+// Do not edit by hand; update review-enrichment analyzer descriptors instead.
+
+export type ReesProfileName = "fast" | "balanced" | "deep";
+
export type ReesAnalyzerDoc = {
name: string;
title: string;
- summary: string;
- looksAt: string;
- reports: string;
- network: string;
- notes: string;
+ category: string;
+ cost: string;
+ defaultEnabled: boolean;
+ profiles: readonly ReesProfileName[];
+ requires: readonly string[];
+ limits: Readonly>;
+ docs: {
+ summary: string;
+ looksAt: string;
+ reports: string;
+ network: string;
+ notes: string;
+ };
+};
+
+export type ReesProfileDoc = {
+ name: ReesProfileName;
+ default: boolean;
+ costClasses: readonly string[];
+ concurrency: Readonly>;
+ timeoutMs: Readonly>;
+ responseReserveMs: number;
};
-export const REES_ANALYZERS: ReesAnalyzerDoc[] = [
+export const REES_DEFAULT_PROFILE = "balanced" as const;
+
+export const REES_PROFILES = [
+ {
+ name: "fast",
+ default: false,
+ costClasses: ["local", "registry"],
+ concurrency: {
+ local: 8,
+ registry: 2,
+ "github-light": 0,
+ "github-heavy": 0,
+ tooling: 0,
+ },
+ timeoutMs: {
+ local: 400,
+ registry: 800,
+ "github-light": 0,
+ "github-heavy": 0,
+ tooling: 0,
+ },
+ responseReserveMs: 500,
+ },
+ {
+ name: "balanced",
+ default: true,
+ costClasses: ["local", "registry", "github-light", "github-heavy", "tooling"],
+ concurrency: {
+ local: 8,
+ registry: 3,
+ "github-light": 2,
+ "github-heavy": 1,
+ tooling: 1,
+ },
+ timeoutMs: {
+ local: 750,
+ registry: 1400,
+ "github-light": 1400,
+ "github-heavy": 2200,
+ tooling: 1400,
+ },
+ responseReserveMs: 750,
+ },
+ {
+ name: "deep",
+ default: false,
+ costClasses: ["local", "registry", "github-light", "github-heavy", "tooling"],
+ concurrency: {
+ local: 8,
+ registry: 4,
+ "github-light": 2,
+ "github-heavy": 1,
+ tooling: 1,
+ },
+ timeoutMs: {
+ local: 1000,
+ registry: 2500,
+ "github-light": 2500,
+ "github-heavy": 4000,
+ tooling: 2500,
+ },
+ responseReserveMs: 1000,
+ },
+] as const satisfies readonly ReesProfileDoc[];
+
+export const REES_ANALYZERS = [
{
name: "dependency",
title: "Dependency vulnerabilities",
- summary: "Checks changed direct dependency versions against OSV.dev.",
- looksAt: "Added or upgraded dependencies in package.json, requirements.txt, and go.mod diffs.",
- reports:
- "Known CVEs with severity, advisory id, summary, and fixed version when OSV publishes one.",
- network: "Calls OSV.dev. No GitHub token required.",
- notes: "Manifest-only by design; use lockfileDrift for transitive lockfile changes.",
+ category: "supply-chain",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {
+ maxManifestFiles: 20,
+ maxPatchLinesPerFile: 500,
+ maxDependencyQueries: 25,
+ },
+ docs: {
+ summary: "Checks changed direct dependency versions against OSV.dev.",
+ looksAt:
+ "Added or upgraded dependencies in package.json, requirements.txt, and go.mod diffs.",
+ reports:
+ "Known CVEs with severity, advisory id, summary, and fixed version when OSV publishes one.",
+ network: "Calls OSV.dev. No GitHub token required.",
+ notes: "Manifest-only by design; use lockfileDrift for transitive lockfile changes.",
+ },
},
{
name: "lockfileDrift",
title: "Lockfile drift",
- summary:
- "Finds vulnerable transitive dependency versions introduced only through lockfile changes.",
- looksAt:
- "package-lock.json, yarn.lock, and poetry.lock patches, excluding packages already named in a changed manifest.",
- reports: "Lockfile line, package/version, ecosystem, direction, and OSV vulnerability details.",
- network: "Calls OSV.dev querybatch. No GitHub token required.",
- notes:
- "Useful when a PR does not touch a top-level manifest but changes resolved dependency pins.",
+ category: "supply-chain",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {
+ maxLockfileFiles: 12,
+ maxPatchLinesPerFile: 1200,
+ maxOsvQueries: 40,
+ },
+ docs: {
+ summary:
+ "Finds vulnerable transitive dependency versions introduced only through lockfile changes.",
+ looksAt:
+ "package-lock.json, yarn.lock, and poetry.lock patches, excluding packages already named in a changed manifest.",
+ reports:
+ "Lockfile line, package/version, ecosystem, direction, and OSV vulnerability details.",
+ network: "Calls OSV.dev querybatch. No GitHub token required.",
+ notes:
+ "Useful when a PR does not touch a top-level manifest but changes resolved dependency pins.",
+ },
},
{
name: "secret",
title: "Hardcoded secrets",
- summary: "Scans added diff lines for credential-shaped values.",
- looksAt: "Added lines in every changed file patch.",
- reports: "File, line, secret kind, and confidence. The matched value is never returned.",
- network: "Pure local analyzer. No external network call.",
- notes:
- "High-confidence patterns are treated as rotate-and-remove candidates; generic assignments stay verify-first.",
+ category: "security",
+ cost: "local",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files"],
+ limits: {},
+ docs: {
+ summary: "Scans added diff lines for credential-shaped values.",
+ looksAt: "Added lines in every changed file patch.",
+ reports: "File, line, secret kind, and confidence. The matched value is never returned.",
+ network: "Pure local analyzer. No external network call.",
+ notes:
+ "High-confidence patterns are treated as rotate-and-remove candidates; generic assignments stay verify-first.",
+ },
},
{
name: "license",
title: "Dependency licenses",
- summary: "Checks licenses for newly added or upgraded dependencies.",
- looksAt: "The same direct dependency changes used by the dependency analyzer.",
- reports:
- "Copyleft or unknown license classifications that need maintainer compatibility review.",
- network: "Calls deps.dev. No GitHub token required.",
- notes: "Permissive and otherwise-known licenses are intentionally silent.",
+ category: "supply-chain",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {
+ maxLicenseLookups: 25,
+ },
+ docs: {
+ summary: "Checks licenses for newly added or upgraded dependencies.",
+ looksAt: "The same direct dependency changes used by the dependency analyzer.",
+ reports:
+ "Copyleft or unknown license classifications that need maintainer compatibility review.",
+ network: "Calls deps.dev. No GitHub token required.",
+ notes: "Permissive and otherwise-known licenses are intentionally silent.",
+ },
},
{
name: "installScript",
title: "npm install scripts",
- summary: "Flags npm packages that run lifecycle hooks during install.",
- looksAt: "New or upgraded npm dependencies.",
- reports: "Package, version, hook names, and publish date when available.",
- network: "Calls the npm registry. No GitHub token required.",
- notes: "The script body is not returned, which keeps the brief compact and non-executable.",
+ category: "supply-chain",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {},
+ docs: {
+ summary: "Flags npm packages that run lifecycle hooks during install.",
+ looksAt: "New or upgraded npm dependencies.",
+ reports: "Package, version, hook names, and publish date when available.",
+ network: "Calls the npm registry. No GitHub token required.",
+ notes: "The script body is not returned, which keeps the brief compact and non-executable.",
+ },
+ },
+ {
+ name: "heavyDependency",
+ title: "Heavy dependencies used trivially",
+ category: "performance",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {
+ maxWeightLookups: 20,
+ maxFindings: 15,
+ },
+ docs: {
+ summary: "Flags materially heavy npm dependencies used only a few times in changed lines.",
+ looksAt: "New or upgraded npm dependencies plus direct uses in added lines.",
+ reports: "Package size, dependency count, usage count, and line-cited usage locations.",
+ network: "Calls Bundlephobia. No GitHub token required.",
+ notes: "Only reports packages with trivial direct usage so the finding stays actionable.",
+ },
},
{
name: "actionPin",
title: "Unpinned GitHub Actions",
- summary: "Detects third-party workflow actions pinned to mutable tags or branches.",
- looksAt: "Added uses: lines in .github/workflows YAML patches.",
- reports: "Workflow file, line, action, and mutable ref.",
- network: "Pure local analyzer. No external network call.",
- notes: "Official actions/* and github/* actions are excluded to keep the signal focused.",
+ category: "supply-chain",
+ cost: "local",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files"],
+ limits: {},
+ docs: {
+ summary: "Detects third-party workflow actions pinned to mutable tags or branches.",
+ looksAt: "Added uses: lines in .github/workflows YAML patches.",
+ reports: "Workflow file, line, action, and mutable ref.",
+ network: "Pure local analyzer. No external network call.",
+ notes: "Official actions/* and github/* actions are excluded to keep the signal focused.",
+ },
},
{
name: "eol",
title: "End-of-life runtimes",
- summary: "Checks changed runtime and base-image pins against EOL calendars.",
- looksAt: "Dockerfile FROM lines, .nvmrc, and go.mod runtime pins.",
- reports:
- "File, product, version, EOL date, and whether the release is already EOL or close to EOL.",
- network: "Calls endoflife.date. No GitHub token required.",
- notes: "Only changed pins are checked; existing old runtimes outside the PR are not reported.",
+ category: "supply-chain",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {
+ maxFiles: 40,
+ maxPatchLines: 1000,
+ maxPins: 80,
+ },
+ docs: {
+ summary: "Checks changed runtime and base-image pins against EOL calendars.",
+ looksAt: "Dockerfile FROM lines, .nvmrc, and go.mod runtime pins.",
+ reports:
+ "File, product, version, EOL date, and whether the release is already EOL or close to EOL.",
+ network: "Calls endoflife.date. No GitHub token required.",
+ notes:
+ "Only changed pins are checked; existing old runtimes outside the PR are not reported.",
+ },
},
{
name: "redos",
title: "ReDoS-prone regex",
- summary: "Finds newly introduced regex shapes that can catastrophically backtrack.",
- looksAt: "Regex literals and RegExp constructor string arguments in added lines.",
- reports: "File, line, and a truncated vulnerable pattern.",
- network: "Pure local analyzer. No external network call.",
- notes:
- "Structural and precision-first; it flags nested unbounded quantifier shapes such as (a+)+.",
+ category: "security",
+ cost: "local",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files"],
+ limits: {
+ maxFindings: 25,
+ maxPatternChars: 1000,
+ maxLineChars: 2000,
+ },
+ docs: {
+ summary: "Finds newly introduced regex shapes that can catastrophically backtrack.",
+ looksAt: "Regex literals and RegExp constructor string arguments in added lines.",
+ reports: "File, line, and a truncated vulnerable pattern.",
+ network: "Pure local analyzer. No external network call.",
+ notes:
+ "Structural and precision-first; it flags nested unbounded quantifier shapes such as (a+)+.",
+ },
},
{
name: "provenance",
title: "Provenance and committed artifacts",
- summary: "Checks package attestations and reviewability of newly added artifacts.",
- looksAt: "New npm/PyPI dependency versions plus added binary, vendored, and minified files.",
- reports:
- "Missing attestations, binary files without reviewable source, and vendored or minified code.",
- network:
- "Calls npm and PyPI attestation/provenance endpoints for package checks. Path checks are local.",
- notes: "Network failures fail safe; it flags only confident no-attestation responses.",
+ category: "supply-chain",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {
+ maxAttestationChecks: 20,
+ maxFindings: 30,
+ },
+ docs: {
+ summary: "Checks package attestations and reviewability of newly added artifacts.",
+ looksAt: "New npm/PyPI dependency versions plus added binary, vendored, and minified files.",
+ reports:
+ "Missing attestations, binary files without reviewable source, and vendored or minified code.",
+ network:
+ "Calls npm and PyPI attestation/provenance endpoints for package checks. Path checks are local.",
+ notes: "Network failures fail safe; it flags only confident no-attestation responses.",
+ },
},
{
name: "codeowners",
title: "CODEOWNERS coverage",
- summary: "Checks whether changed files cross ownership domains not owned by the PR author.",
- looksAt: ".github/CODEOWNERS, CODEOWNERS, or docs/CODEOWNERS plus the changed file list.",
- reports:
- "Owned files where the PR author is not listed, plus ownership blast-radius context in the rendered brief.",
- network:
- "Calls the GitHub API. Requires author plus GitHub token forwarding for private repos.",
- notes:
- "Leave REES_FORWARD_GITHUB_TOKEN unset/false to disable token forwarding; this analyzer will then skip when it cannot read CODEOWNERS.",
+ category: "ownership",
+ cost: "github-light",
+ defaultEnabled: true,
+ profiles: ["balanced", "deep"],
+ requires: ["files", "author", "github-token"],
+ limits: {
+ maxFilesReported: 20,
+ maxCodeownersBytes: 65536,
+ maxCodeownersRules: 1000,
+ },
+ docs: {
+ summary: "Checks whether changed files cross ownership domains not owned by the PR author.",
+ looksAt: ".github/CODEOWNERS, CODEOWNERS, or docs/CODEOWNERS plus the changed file list.",
+ reports:
+ "Owned files where the PR author is not listed, plus ownership blast-radius context in the rendered brief.",
+ network:
+ "Calls the GitHub API. Requires author plus GitHub token forwarding for private repos.",
+ notes:
+ "Leave REES_FORWARD_GITHUB_TOKEN unset/false to disable token forwarding; this analyzer will then skip when it cannot read CODEOWNERS.",
+ },
},
{
name: "secretLog",
title: "Secrets or PII in logs",
- summary: "Flags added code that writes sensitive values to logs or stdout.",
- looksAt: "Added lines that call console, logger, process.stdout, or process.stderr sinks.",
- reports: "File, line, sink, and category: secret, pii, or request-object.",
- network: "Pure local analyzer. No external network call.",
- notes:
- "String log messages are stripped before matching, so ordinary prose like password reset is not enough to trigger.",
+ category: "security",
+ cost: "local",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files"],
+ limits: {
+ maxFindings: 25,
+ maxLineChars: 2000,
+ },
+ docs: {
+ summary: "Flags added code that writes sensitive values to logs or stdout.",
+ looksAt: "Added lines that call console, logger, process.stdout, or process.stderr sinks.",
+ reports: "File, line, sink, and category: secret, pii, or request-object.",
+ network: "Pure local analyzer. No external network call.",
+ notes:
+ "String log messages are stripped before matching, so ordinary prose like password reset is not enough to trigger.",
+ },
},
{
name: "assetWeight",
title: "Heavy binary assets",
- summary:
- "Finds large binary assets added to a PR, and growth deltas when base size is available.",
- looksAt:
- "Changed binary assets such as images, fonts, archives, PDFs, videos, and compiled binaries.",
- reports: "Path, size, delta, and whether the asset was added or grown.",
- network:
- "Calls the GitHub API. Requires headSha and GitHub token forwarding for private repos.",
- notes:
- "Added asset detection works from headSha. Growth comparison needs baseSha in the enrichment request.",
+ category: "performance",
+ cost: "github-heavy",
+ defaultEnabled: true,
+ profiles: ["balanced", "deep"],
+ requires: ["files", "github-token", "head-sha"],
+ limits: {
+ maxFindings: 50,
+ },
+ docs: {
+ summary:
+ "Finds large binary assets added to a PR, and growth deltas when base size is available.",
+ looksAt:
+ "Changed binary assets such as images, fonts, archives, PDFs, videos, and compiled binaries.",
+ reports: "Path, size, delta, and whether the asset was added or grown.",
+ network:
+ "Calls the GitHub API. Requires headSha and GitHub token forwarding for private repos.",
+ notes:
+ "Added asset detection works from headSha. Growth comparison needs baseSha in the enrichment request.",
+ },
},
{
name: "typosquat",
title: "Typosquat and dependency-confusion risk",
- summary:
- "Checks newly added dependency names for near-miss and publicly claimable package names.",
- looksAt: "Newly added npm and PyPI dependency names.",
- reports:
- "Typosquat matches against popular packages, or unscoped names missing from the public registry.",
- network:
- "Uses bundled popular-package lists plus npm/PyPI registry lookups for dependency-confusion checks.",
- notes:
- "Scoped npm packages are treated as namespace-protected and are not flagged as typosquats.",
- },
-];
+ category: "supply-chain",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {
+ maxDeps: 50,
+ maxConfusionQueries: 15,
+ },
+ docs: {
+ summary:
+ "Checks newly added dependency names for near-miss and publicly claimable package names.",
+ looksAt: "Newly added npm and PyPI dependency names.",
+ reports:
+ "Typosquat matches against popular packages, or unscoped names missing from the public registry.",
+ network:
+ "Uses bundled popular-package lists plus npm/PyPI registry lookups for dependency-confusion checks.",
+ notes:
+ "Scoped npm packages are treated as namespace-protected and are not flagged as typosquats.",
+ },
+ },
+ {
+ name: "commitSignature",
+ title: "Head commit signature",
+ category: "supply-chain",
+ cost: "github-light",
+ defaultEnabled: true,
+ profiles: ["balanced", "deep"],
+ requires: ["github-token", "head-sha"],
+ limits: {},
+ docs: {
+ summary: "Checks head commit signature and public author provenance.",
+ looksAt: "The head commit plus a bounded slice of recent repository commit history.",
+ reports: "GitHub signature verification reason and public boolean provenance flags.",
+ network:
+ "Calls the GitHub API. Requires headSha and GitHub token forwarding for private repos.",
+ notes:
+ "Does not expose emails or private identity data; only public GitHub commit facts are surfaced.",
+ },
+ },
+ {
+ name: "iacMisconfig",
+ title: "IaC / config misconfiguration",
+ category: "config",
+ cost: "local",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files"],
+ limits: {
+ maxFindings: 25,
+ maxLineChars: 2000,
+ },
+ docs: {
+ summary: "Flags risky IaC/config changes such as public buckets or insecure CORS.",
+ looksAt: "Added lines in Docker, Terraform, YAML, JSON, and similar config files.",
+ reports: "File, line, and public-safe rule kind.",
+ network: "Pure local analyzer. No external network call.",
+ notes: "Reports configuration shapes only; it does not inspect private runtime config.",
+ },
+ },
+ {
+ name: "nativeBuild",
+ title: "Native-build dependencies",
+ category: "performance",
+ cost: "registry",
+ defaultEnabled: true,
+ profiles: ["fast", "balanced", "deep"],
+ requires: ["files", "public-network"],
+ limits: {
+ maxQueries: 25,
+ maxRegistryJsonBytes: 2097152,
+ },
+ docs: {
+ summary: "Flags newly-added dependencies that compile native code or ship sdist-only builds.",
+ looksAt: "New npm/PyPI dependency versions.",
+ reports: "Package, version, ecosystem, native-build kind, and public-safe reason.",
+ network: "Calls npm and PyPI registries. No GitHub token required.",
+ notes: "Registry JSON is capped so large package metadata cannot monopolize REES memory.",
+ },
+ },
+ {
+ name: "history",
+ title: "Author and change-area history",
+ category: "history",
+ cost: "github-heavy",
+ defaultEnabled: true,
+ profiles: ["balanced", "deep"],
+ requires: ["files", "github-token", "author"],
+ limits: {
+ maxFilesProbed: 5,
+ commitsPerFile: 10,
+ maxPrLookups: 12,
+ maxSimilarPrs: 8,
+ },
+ docs: {
+ summary:
+ "Shows public author track record, same-file PR history, and linked-issue alignment.",
+ looksAt:
+ "The PR author, changed file paths, linked issue text, added diff lines, and bounded GitHub history lookups.",
+ reports:
+ "Prior PR counts, similar past PRs, linked issue coverage, and partial/degraded status.",
+ network:
+ "Calls GitHub API with bounded fanout. Requires author plus GitHub token forwarding for private repos.",
+ notes:
+ "Returns partial findings when GitHub lookups are skipped, capped, or budget-exhausted.",
+ },
+ },
+ {
+ name: "docCommentDrift",
+ title: "Doc-comment drift",
+ category: "quality",
+ cost: "github-light",
+ defaultEnabled: true,
+ profiles: ["balanced", "deep"],
+ requires: ["files", "github-token", "head-sha"],
+ limits: {
+ maxFiles: 20,
+ maxFindings: 50,
+ },
+ docs: {
+ summary:
+ "Flags a JSDoc/TSDoc @param that names a parameter the PR removed or renamed but left documented.",
+ looksAt:
+ "Changed TS/JS source files at headSha, comparing each named function's old vs new parameter list.",
+ reports: "File, line, function, and the stale parameter name(s).",
+ network:
+ "Calls the GitHub API for changed file contents. Requires headSha and token forwarding for private repos.",
+ notes:
+ "Conservative: only named function declarations with confidently-enumerable params; non-parameter signature edits are not reported.",
+ },
+ },
+] as const satisfies readonly ReesAnalyzerDoc[];
export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name);
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-rees-analyzers.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-rees-analyzers.tsx
index d5c384a03b..f25def2ea9 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-rees-analyzers.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-rees-analyzers.tsx
@@ -2,7 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { DocsPage } from "@/components/site/docs-page";
import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives";
-import { REES_ANALYZERS, REES_ANALYZER_NAMES } from "@/lib/rees-analyzers";
+import { REES_ANALYZERS, REES_ANALYZER_NAMES, REES_PROFILES } from "@/lib/rees-analyzers";
export const Route = createFileRoute("/docs/self-hosting-rees-analyzers")({
head: () => ({
@@ -37,7 +37,8 @@ function SelfHostingReesAnalyzers() {
REES runs analyzers independently. A failed analyzer is marked degraded, completed analyzers
still return findings, and an empty result produces no user-facing brief. Use exact analyzer
names in REES_ANALYZERS. A typo-only analyzer list fails closed with no
- analyzers selected.
+ analyzers selected. Leave REES_PROFILE unset for the balanced profile, or set
+ fast during incidents to favor local and low-cost registry checks.
+ Profiles
+
+ {REES_PROFILES.map((profile) => (
+
+
+
{profile.name}
+ {profile.default ? (
+
+ default
+
+ ) : null}
+
+
+
+
Cost classes
+ {profile.costClasses.join(", ")}
+
+
+
Concurrency caps
+
+ {Object.entries(profile.concurrency)
+ .filter(([, value]) => value > 0)
+ .map(([key, value]) => `${key}:${value}`)
+ .join(", ")}
+
+
+
+
Response reserve
+ {profile.responseReserveMs} ms
+
+
+
+ ))}
+
+
All analyzer names
@@ -61,17 +97,17 @@ REES_ANALYZERS=unknownName`}
{
title: "Pure analyzers",
description:
- "secret, actionPin, redos, and secretLog work only from the diff/files sent to REES.",
+ "secret, actionPin, redos, secretLog, and iacMisconfig work only from the diff/files sent to REES.",
},
{
title: "Public registry analyzers",
description:
- "dependency, lockfileDrift, license, installScript, eol, provenance, and typosquat call public package or lifecycle APIs.",
+ "dependency, lockfileDrift, license, installScript, heavyDependency, eol, provenance, typosquat, and nativeBuild call public package or lifecycle APIs.",
},
{
title: "GitHub API analyzers",
description:
- "codeowners and assetWeight need author/head metadata and GitHub token forwarding when the repo is private.",
+ "codeowners, assetWeight, commitSignature, and history need author/head metadata and GitHub token forwarding when the repo is private.",
},
]}
/>
@@ -89,29 +125,42 @@ REES_ANALYZERS=unknownName`}
{analyzer.title}
- {analyzer.summary}
+ {analyzer.docs.summary}
-
- {analyzer.name}
-
+
+
+ {analyzer.name}
+
+
+ {analyzer.cost}
+
+
Looks at
- {analyzer.looksAt}
+ {analyzer.docs.looksAt}
Reports
- {analyzer.reports}
+ {analyzer.docs.reports}
Network
- {analyzer.network}
+ {analyzer.docs.network}
Operational note
- {analyzer.notes}
+ {analyzer.docs.notes}
+
+
+
Profiles
+ {analyzer.profiles.join(", ")}
+
+
+
Requirements
+ {analyzer.requires.join(", ")}
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-rees.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-rees.tsx
index b160cd29a6..b75ec86e38 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-rees.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-rees.tsx
@@ -79,6 +79,7 @@ GITTENSORY_REVIEW_ENRICHMENT=true
REES_URL=https://enrichment.example.internal
REES_SHARED_SECRET=
REES_TIMEOUT_MS=8000
+REES_PROFILE=balanced
REES_FORWARD_GITHUB_TOKEN=false
REES_ANALYZERS=all`}
/>
@@ -101,6 +102,11 @@ REES_ANALYZERS=all`}
title: "REES_TIMEOUT_MS",
description: "Request timeout. Defaults to 8000 ms and is clamped to at least 1000 ms.",
},
+ {
+ title: "REES_PROFILE",
+ description:
+ "Optional analyzer profile. balanced is the default; fast favors local/registry checks during incidents; deep allows larger per-class budgets.",
+ },
{
title: "REES_FORWARD_GITHUB_TOKEN",
description:
@@ -127,13 +133,18 @@ REES_FORWARD_GITHUB_TOKEN=true`}
Analyzer selection
- Leave REES_ANALYZERS unset, all, or * to run the full
- REES registry. To run a subset, use exact comma-separated analyzer names. Unknown names are
- ignored with a rees_analyzer_config_invalid warning and the remaining valid
- analyzers still run. If every configured name is invalid, the engine sends an empty analyzer
- list so the typo fails closed instead of running the full registry.
+ Leave REES_ANALYZERS unset, all, or * to use the
+ selected REES_PROFILE defaults. To run a subset, use exact comma-separated
+ analyzer names. Unknown names are ignored with a rees_analyzer_config_invalid{" "}
+ warning and the remaining valid analyzers still run. If every configured name is invalid,
+ the engine sends an empty analyzer list so the typo fails closed instead of running the full
+ registry.
-
+
See the REES analyzer reference for each
diff --git a/package.json b/package.json
index c4166829c3..dc4f8a0401 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,8 @@
"test:mcp-pack": "node scripts/check-mcp-package.mjs",
"rees:install": "npm ci --prefix review-enrichment --prefer-offline --no-audit --no-fund",
"rees:test": "npm run rees:install && npm --prefix review-enrichment test",
+ "rees:metadata": "npm --prefix review-enrichment run metadata",
+ "rees:metadata:check": "npm --prefix review-enrichment run metadata:check",
"rees:validate-sourcemaps": "npm --prefix review-enrichment run validate:sourcemaps",
"db:migrations:check": "node scripts/check-migrations.mjs",
"actionlint": "node scripts/actionlint.mjs",
diff --git a/review-enrichment/analyzer-metadata.json b/review-enrichment/analyzer-metadata.json
new file mode 100644
index 0000000000..9ad7e58ad8
--- /dev/null
+++ b/review-enrichment/analyzer-metadata.json
@@ -0,0 +1,584 @@
+{
+ "schemaVersion": 1,
+ "generatedFrom": "review-enrichment/src/analyzers/registry.ts",
+ "defaultProfile": "balanced",
+ "profiles": [
+ {
+ "name": "fast",
+ "default": false,
+ "costClasses": [
+ "local",
+ "registry"
+ ],
+ "concurrency": {
+ "local": 8,
+ "registry": 2,
+ "github-light": 0,
+ "github-heavy": 0,
+ "tooling": 0
+ },
+ "timeoutMs": {
+ "local": 400,
+ "registry": 800,
+ "github-light": 0,
+ "github-heavy": 0,
+ "tooling": 0
+ },
+ "responseReserveMs": 500
+ },
+ {
+ "name": "balanced",
+ "default": true,
+ "costClasses": [
+ "local",
+ "registry",
+ "github-light",
+ "github-heavy",
+ "tooling"
+ ],
+ "concurrency": {
+ "local": 8,
+ "registry": 3,
+ "github-light": 2,
+ "github-heavy": 1,
+ "tooling": 1
+ },
+ "timeoutMs": {
+ "local": 750,
+ "registry": 1400,
+ "github-light": 1400,
+ "github-heavy": 2200,
+ "tooling": 1400
+ },
+ "responseReserveMs": 750
+ },
+ {
+ "name": "deep",
+ "default": false,
+ "costClasses": [
+ "local",
+ "registry",
+ "github-light",
+ "github-heavy",
+ "tooling"
+ ],
+ "concurrency": {
+ "local": 8,
+ "registry": 4,
+ "github-light": 2,
+ "github-heavy": 1,
+ "tooling": 1
+ },
+ "timeoutMs": {
+ "local": 1000,
+ "registry": 2500,
+ "github-light": 2500,
+ "github-heavy": 4000,
+ "tooling": 2500
+ },
+ "responseReserveMs": 1000
+ }
+ ],
+ "analyzers": [
+ {
+ "name": "dependency",
+ "title": "Dependency vulnerabilities",
+ "category": "supply-chain",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {
+ "maxManifestFiles": 20,
+ "maxPatchLinesPerFile": 500,
+ "maxDependencyQueries": 25
+ },
+ "docs": {
+ "summary": "Checks changed direct dependency versions against OSV.dev.",
+ "looksAt": "Added or upgraded dependencies in package.json, requirements.txt, and go.mod diffs.",
+ "reports": "Known CVEs with severity, advisory id, summary, and fixed version when OSV publishes one.",
+ "network": "Calls OSV.dev. No GitHub token required.",
+ "notes": "Manifest-only by design; use lockfileDrift for transitive lockfile changes."
+ }
+ },
+ {
+ "name": "lockfileDrift",
+ "title": "Lockfile drift",
+ "category": "supply-chain",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {
+ "maxLockfileFiles": 12,
+ "maxPatchLinesPerFile": 1200,
+ "maxOsvQueries": 40
+ },
+ "docs": {
+ "summary": "Finds vulnerable transitive dependency versions introduced only through lockfile changes.",
+ "looksAt": "package-lock.json, yarn.lock, and poetry.lock patches, excluding packages already named in a changed manifest.",
+ "reports": "Lockfile line, package/version, ecosystem, direction, and OSV vulnerability details.",
+ "network": "Calls OSV.dev querybatch. No GitHub token required.",
+ "notes": "Useful when a PR does not touch a top-level manifest but changes resolved dependency pins."
+ }
+ },
+ {
+ "name": "secret",
+ "title": "Hardcoded secrets",
+ "category": "security",
+ "cost": "local",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files"
+ ],
+ "limits": {},
+ "docs": {
+ "summary": "Scans added diff lines for credential-shaped values.",
+ "looksAt": "Added lines in every changed file patch.",
+ "reports": "File, line, secret kind, and confidence. The matched value is never returned.",
+ "network": "Pure local analyzer. No external network call.",
+ "notes": "High-confidence patterns are treated as rotate-and-remove candidates; generic assignments stay verify-first."
+ }
+ },
+ {
+ "name": "license",
+ "title": "Dependency licenses",
+ "category": "supply-chain",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {
+ "maxLicenseLookups": 25
+ },
+ "docs": {
+ "summary": "Checks licenses for newly added or upgraded dependencies.",
+ "looksAt": "The same direct dependency changes used by the dependency analyzer.",
+ "reports": "Copyleft or unknown license classifications that need maintainer compatibility review.",
+ "network": "Calls deps.dev. No GitHub token required.",
+ "notes": "Permissive and otherwise-known licenses are intentionally silent."
+ }
+ },
+ {
+ "name": "installScript",
+ "title": "npm install scripts",
+ "category": "supply-chain",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {},
+ "docs": {
+ "summary": "Flags npm packages that run lifecycle hooks during install.",
+ "looksAt": "New or upgraded npm dependencies.",
+ "reports": "Package, version, hook names, and publish date when available.",
+ "network": "Calls the npm registry. No GitHub token required.",
+ "notes": "The script body is not returned, which keeps the brief compact and non-executable."
+ }
+ },
+ {
+ "name": "heavyDependency",
+ "title": "Heavy dependencies used trivially",
+ "category": "performance",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {
+ "maxWeightLookups": 20,
+ "maxFindings": 15
+ },
+ "docs": {
+ "summary": "Flags materially heavy npm dependencies used only a few times in changed lines.",
+ "looksAt": "New or upgraded npm dependencies plus direct uses in added lines.",
+ "reports": "Package size, dependency count, usage count, and line-cited usage locations.",
+ "network": "Calls Bundlephobia. No GitHub token required.",
+ "notes": "Only reports packages with trivial direct usage so the finding stays actionable."
+ }
+ },
+ {
+ "name": "actionPin",
+ "title": "Unpinned GitHub Actions",
+ "category": "supply-chain",
+ "cost": "local",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files"
+ ],
+ "limits": {},
+ "docs": {
+ "summary": "Detects third-party workflow actions pinned to mutable tags or branches.",
+ "looksAt": "Added uses: lines in .github/workflows YAML patches.",
+ "reports": "Workflow file, line, action, and mutable ref.",
+ "network": "Pure local analyzer. No external network call.",
+ "notes": "Official actions/* and github/* actions are excluded to keep the signal focused."
+ }
+ },
+ {
+ "name": "eol",
+ "title": "End-of-life runtimes",
+ "category": "supply-chain",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {
+ "maxFiles": 40,
+ "maxPatchLines": 1000,
+ "maxPins": 80
+ },
+ "docs": {
+ "summary": "Checks changed runtime and base-image pins against EOL calendars.",
+ "looksAt": "Dockerfile FROM lines, .nvmrc, and go.mod runtime pins.",
+ "reports": "File, product, version, EOL date, and whether the release is already EOL or close to EOL.",
+ "network": "Calls endoflife.date. No GitHub token required.",
+ "notes": "Only changed pins are checked; existing old runtimes outside the PR are not reported."
+ }
+ },
+ {
+ "name": "redos",
+ "title": "ReDoS-prone regex",
+ "category": "security",
+ "cost": "local",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files"
+ ],
+ "limits": {
+ "maxFindings": 25,
+ "maxPatternChars": 1000,
+ "maxLineChars": 2000
+ },
+ "docs": {
+ "summary": "Finds newly introduced regex shapes that can catastrophically backtrack.",
+ "looksAt": "Regex literals and RegExp constructor string arguments in added lines.",
+ "reports": "File, line, and a truncated vulnerable pattern.",
+ "network": "Pure local analyzer. No external network call.",
+ "notes": "Structural and precision-first; it flags nested unbounded quantifier shapes such as (a+)+."
+ }
+ },
+ {
+ "name": "provenance",
+ "title": "Provenance and committed artifacts",
+ "category": "supply-chain",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {
+ "maxAttestationChecks": 20,
+ "maxFindings": 30
+ },
+ "docs": {
+ "summary": "Checks package attestations and reviewability of newly added artifacts.",
+ "looksAt": "New npm/PyPI dependency versions plus added binary, vendored, and minified files.",
+ "reports": "Missing attestations, binary files without reviewable source, and vendored or minified code.",
+ "network": "Calls npm and PyPI attestation/provenance endpoints for package checks. Path checks are local.",
+ "notes": "Network failures fail safe; it flags only confident no-attestation responses."
+ }
+ },
+ {
+ "name": "codeowners",
+ "title": "CODEOWNERS coverage",
+ "category": "ownership",
+ "cost": "github-light",
+ "defaultEnabled": true,
+ "profiles": [
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "author",
+ "github-token"
+ ],
+ "limits": {
+ "maxFilesReported": 20,
+ "maxCodeownersBytes": 65536,
+ "maxCodeownersRules": 1000
+ },
+ "docs": {
+ "summary": "Checks whether changed files cross ownership domains not owned by the PR author.",
+ "looksAt": ".github/CODEOWNERS, CODEOWNERS, or docs/CODEOWNERS plus the changed file list.",
+ "reports": "Owned files where the PR author is not listed, plus ownership blast-radius context in the rendered brief.",
+ "network": "Calls the GitHub API. Requires author plus GitHub token forwarding for private repos.",
+ "notes": "Leave REES_FORWARD_GITHUB_TOKEN unset/false to disable token forwarding; this analyzer will then skip when it cannot read CODEOWNERS."
+ }
+ },
+ {
+ "name": "secretLog",
+ "title": "Secrets or PII in logs",
+ "category": "security",
+ "cost": "local",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files"
+ ],
+ "limits": {
+ "maxFindings": 25,
+ "maxLineChars": 2000
+ },
+ "docs": {
+ "summary": "Flags added code that writes sensitive values to logs or stdout.",
+ "looksAt": "Added lines that call console, logger, process.stdout, or process.stderr sinks.",
+ "reports": "File, line, sink, and category: secret, pii, or request-object.",
+ "network": "Pure local analyzer. No external network call.",
+ "notes": "String log messages are stripped before matching, so ordinary prose like password reset is not enough to trigger."
+ }
+ },
+ {
+ "name": "assetWeight",
+ "title": "Heavy binary assets",
+ "category": "performance",
+ "cost": "github-heavy",
+ "defaultEnabled": true,
+ "profiles": [
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "github-token",
+ "head-sha"
+ ],
+ "limits": {
+ "maxFindings": 50
+ },
+ "docs": {
+ "summary": "Finds large binary assets added to a PR, and growth deltas when base size is available.",
+ "looksAt": "Changed binary assets such as images, fonts, archives, PDFs, videos, and compiled binaries.",
+ "reports": "Path, size, delta, and whether the asset was added or grown.",
+ "network": "Calls the GitHub API. Requires headSha and GitHub token forwarding for private repos.",
+ "notes": "Added asset detection works from headSha. Growth comparison needs baseSha in the enrichment request."
+ }
+ },
+ {
+ "name": "typosquat",
+ "title": "Typosquat and dependency-confusion risk",
+ "category": "supply-chain",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {
+ "maxDeps": 50,
+ "maxConfusionQueries": 15
+ },
+ "docs": {
+ "summary": "Checks newly added dependency names for near-miss and publicly claimable package names.",
+ "looksAt": "Newly added npm and PyPI dependency names.",
+ "reports": "Typosquat matches against popular packages, or unscoped names missing from the public registry.",
+ "network": "Uses bundled popular-package lists plus npm/PyPI registry lookups for dependency-confusion checks.",
+ "notes": "Scoped npm packages are treated as namespace-protected and are not flagged as typosquats."
+ }
+ },
+ {
+ "name": "commitSignature",
+ "title": "Head commit signature",
+ "category": "supply-chain",
+ "cost": "github-light",
+ "defaultEnabled": true,
+ "profiles": [
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "github-token",
+ "head-sha"
+ ],
+ "limits": {},
+ "docs": {
+ "summary": "Checks head commit signature and public author provenance.",
+ "looksAt": "The head commit plus a bounded slice of recent repository commit history.",
+ "reports": "GitHub signature verification reason and public boolean provenance flags.",
+ "network": "Calls the GitHub API. Requires headSha and GitHub token forwarding for private repos.",
+ "notes": "Does not expose emails or private identity data; only public GitHub commit facts are surfaced."
+ }
+ },
+ {
+ "name": "iacMisconfig",
+ "title": "IaC / config misconfiguration",
+ "category": "config",
+ "cost": "local",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files"
+ ],
+ "limits": {
+ "maxFindings": 25,
+ "maxLineChars": 2000
+ },
+ "docs": {
+ "summary": "Flags risky IaC/config changes such as public buckets or insecure CORS.",
+ "looksAt": "Added lines in Docker, Terraform, YAML, JSON, and similar config files.",
+ "reports": "File, line, and public-safe rule kind.",
+ "network": "Pure local analyzer. No external network call.",
+ "notes": "Reports configuration shapes only; it does not inspect private runtime config."
+ }
+ },
+ {
+ "name": "nativeBuild",
+ "title": "Native-build dependencies",
+ "category": "performance",
+ "cost": "registry",
+ "defaultEnabled": true,
+ "profiles": [
+ "fast",
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "public-network"
+ ],
+ "limits": {
+ "maxQueries": 25,
+ "maxRegistryJsonBytes": 2097152
+ },
+ "docs": {
+ "summary": "Flags newly-added dependencies that compile native code or ship sdist-only builds.",
+ "looksAt": "New npm/PyPI dependency versions.",
+ "reports": "Package, version, ecosystem, native-build kind, and public-safe reason.",
+ "network": "Calls npm and PyPI registries. No GitHub token required.",
+ "notes": "Registry JSON is capped so large package metadata cannot monopolize REES memory."
+ }
+ },
+ {
+ "name": "history",
+ "title": "Author and change-area history",
+ "category": "history",
+ "cost": "github-heavy",
+ "defaultEnabled": true,
+ "profiles": [
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "github-token",
+ "author"
+ ],
+ "limits": {
+ "maxFilesProbed": 5,
+ "commitsPerFile": 10,
+ "maxPrLookups": 12,
+ "maxSimilarPrs": 8
+ },
+ "docs": {
+ "summary": "Shows public author track record, same-file PR history, and linked-issue alignment.",
+ "looksAt": "The PR author, changed file paths, linked issue text, added diff lines, and bounded GitHub history lookups.",
+ "reports": "Prior PR counts, similar past PRs, linked issue coverage, and partial/degraded status.",
+ "network": "Calls GitHub API with bounded fanout. Requires author plus GitHub token forwarding for private repos.",
+ "notes": "Returns partial findings when GitHub lookups are skipped, capped, or budget-exhausted."
+ }
+ },
+ {
+ "name": "docCommentDrift",
+ "title": "Doc-comment drift",
+ "category": "quality",
+ "cost": "github-light",
+ "defaultEnabled": true,
+ "profiles": [
+ "balanced",
+ "deep"
+ ],
+ "requires": [
+ "files",
+ "github-token",
+ "head-sha"
+ ],
+ "limits": {
+ "maxFiles": 20,
+ "maxFindings": 50
+ },
+ "docs": {
+ "summary": "Flags a JSDoc/TSDoc @param that names a parameter the PR removed or renamed but left documented.",
+ "looksAt": "Changed TS/JS source files at headSha, comparing each named function's old vs new parameter list.",
+ "reports": "File, line, function, and the stale parameter name(s).",
+ "network": "Calls the GitHub API for changed file contents. Requires headSha and token forwarding for private repos.",
+ "notes": "Conservative: only named function declarations with confidently-enumerable params; non-parameter signature edits are not reported."
+ }
+ }
+ ]
+}
diff --git a/review-enrichment/package-lock.json b/review-enrichment/package-lock.json
index 5d4b500539..11a02820da 100644
--- a/review-enrichment/package-lock.json
+++ b/review-enrichment/package-lock.json
@@ -15,6 +15,7 @@
},
"devDependencies": {
"@types/node": "^22.10.2",
+ "prettier": "3.8.4",
"typescript": "^5.7.2"
},
"engines": {
@@ -609,6 +610,22 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/prettier": {
+ "version": "3.8.4",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
+ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
diff --git a/review-enrichment/package.json b/review-enrichment/package.json
index 0b919e0d61..404ebd3477 100644
--- a/review-enrichment/package.json
+++ b/review-enrichment/package.json
@@ -9,10 +9,12 @@
},
"scripts": {
"build": "tsc -p tsconfig.json",
+ "metadata": "npm run build && node scripts/generate-analyzer-metadata.mjs",
+ "metadata:check": "npm run build && node scripts/generate-analyzer-metadata.mjs --check",
"validate:sourcemaps": "node scripts/validate-sourcemaps.mjs",
"start": "node dist/server.js",
"dev": "node --experimental-strip-types --watch src/server.ts",
- "test": "npm run build && npm run validate:sourcemaps && node --test --experimental-strip-types \"test/**/*.test.ts\""
+ "test": "npm run build && npm run validate:sourcemaps && node scripts/generate-analyzer-metadata.mjs --check && node --test --experimental-strip-types \"test/**/*.test.ts\""
},
"dependencies": {
"@hono/node-server": "^1.13.7",
@@ -22,6 +24,7 @@
},
"devDependencies": {
"@types/node": "^22.10.2",
+ "prettier": "3.8.4",
"typescript": "^5.7.2"
}
}
diff --git a/review-enrichment/scripts/generate-analyzer-metadata.mjs b/review-enrichment/scripts/generate-analyzer-metadata.mjs
new file mode 100644
index 0000000000..6bc05c3715
--- /dev/null
+++ b/review-enrichment/scripts/generate-analyzer-metadata.mjs
@@ -0,0 +1,157 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { format, resolveConfig } from "prettier";
+
+import { ANALYZER_DESCRIPTORS } from "../dist/analyzers/registry.js";
+import { reesProfileMetadata } from "../dist/scheduler.js";
+
+const CHECK = process.argv.includes("--check");
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const reviewRoot = resolve(scriptDir, "..");
+const repoRoot = resolve(reviewRoot, "..");
+const jsonPath = resolve(reviewRoot, "analyzer-metadata.json");
+const uiPath = resolve(repoRoot, "apps/gittensory-ui/src/lib/rees-analyzers.ts");
+const envPath = resolve(repoRoot, ".env.example");
+const envStart = "# BEGIN GENERATED REES ANALYZERS";
+const envEnd = "# END GENERATED REES ANALYZERS";
+
+const profiles = reesProfileMetadata();
+const defaultProfile = profiles.find((profile) => profile.default)?.name ?? "balanced";
+
+const analyzers = ANALYZER_DESCRIPTORS.map((descriptor) => ({
+ name: descriptor.name,
+ title: descriptor.title,
+ category: descriptor.category,
+ cost: descriptor.cost,
+ defaultEnabled: descriptor.defaultEnabled,
+ profiles: profiles
+ .filter(
+ (profile) =>
+ descriptor.defaultEnabled &&
+ profile.costClasses.includes(descriptor.cost),
+ )
+ .map((profile) => profile.name),
+ requires: [...descriptor.requires],
+ limits: descriptor.limits ? { ...descriptor.limits } : {},
+ docs: { ...descriptor.docs },
+}));
+
+const metadata = {
+ schemaVersion: 1,
+ generatedFrom: "review-enrichment/src/analyzers/registry.ts",
+ defaultProfile,
+ profiles,
+ analyzers,
+};
+
+const generatedJson = `${JSON.stringify(metadata, null, 2)}\n`;
+const rawGeneratedUi = `// Generated by review-enrichment/scripts/generate-analyzer-metadata.mjs.
+// Do not edit by hand; update review-enrichment analyzer descriptors instead.
+
+export type ReesProfileName = "fast" | "balanced" | "deep";
+
+export type ReesAnalyzerDoc = {
+ name: string;
+ title: string;
+ category: string;
+ cost: string;
+ defaultEnabled: boolean;
+ profiles: readonly ReesProfileName[];
+ requires: readonly string[];
+ limits: Readonly>;
+ docs: {
+ summary: string;
+ looksAt: string;
+ reports: string;
+ network: string;
+ notes: string;
+ };
+};
+
+export type ReesProfileDoc = {
+ name: ReesProfileName;
+ default: boolean;
+ costClasses: readonly string[];
+ concurrency: Readonly>;
+ timeoutMs: Readonly>;
+ responseReserveMs: number;
+};
+
+export const REES_DEFAULT_PROFILE = ${JSON.stringify(defaultProfile)} as const;
+
+export const REES_PROFILES = ${JSON.stringify(profiles, null, 2)} as const satisfies readonly ReesProfileDoc[];
+
+export const REES_ANALYZERS = ${JSON.stringify(analyzers, null, 2)} as const satisfies readonly ReesAnalyzerDoc[];
+
+export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name);
+`;
+const uiPrettierOptions = (await resolveConfig(uiPath)) ?? {};
+const generatedUi = await format(rawGeneratedUi, {
+ ...uiPrettierOptions,
+ filepath: uiPath,
+ parser: "typescript",
+});
+
+const envBlock = [
+ envStart,
+ "# Current analyzer names:",
+ ...wrapComment(analyzers.map((analyzer) => analyzer.name).join(",")),
+ "#",
+ "# Profile defaults:",
+ ...profiles.flatMap((profile) =>
+ wrapComment(
+ `${profile.name}${profile.default ? " (default)" : ""}: ${analyzers
+ .filter((analyzer) => analyzer.profiles.includes(profile.name))
+ .map((analyzer) => analyzer.name)
+ .join(",")}`,
+ ),
+ ),
+ envEnd,
+].join("\n");
+
+const currentEnv = await readFile(envPath, "utf8");
+const generatedEnv = replaceGeneratedEnvBlock(currentEnv, envBlock);
+
+await writeOrCheck(jsonPath, generatedJson);
+await writeOrCheck(uiPath, generatedUi);
+await writeOrCheck(envPath, generatedEnv);
+
+function wrapComment(value, max = 96) {
+ const words = value.split(",");
+ const lines = [];
+ let current = "# ";
+ for (const word of words) {
+ const next = current === "# " ? `${current}${word}` : `${current},${word}`;
+ if (next.length > max && current !== "# ") {
+ lines.push(current);
+ current = `# ${word}`;
+ } else {
+ current = next;
+ }
+ }
+ if (current !== "# ") lines.push(current);
+ return lines;
+}
+
+function replaceGeneratedEnvBlock(content, block) {
+ const start = content.indexOf(envStart);
+ const end = content.indexOf(envEnd);
+ if (start === -1 || end === -1 || end < start) {
+ throw new Error(".env.example is missing generated REES analyzer markers");
+ }
+ const afterEnd = end + envEnd.length;
+ return `${content.slice(0, start)}${block}${content.slice(afterEnd)}`;
+}
+
+async function writeOrCheck(path, expected) {
+ if (!CHECK) {
+ await writeFile(path, expected);
+ return;
+ }
+ const actual = await readFile(path, "utf8").catch(() => "");
+ if (actual !== expected) {
+ throw new Error(`${path.replace(`${repoRoot}/`, "")} is stale; run npm --prefix review-enrichment run metadata`);
+ }
+}
diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts
index fd936fbf8d..0982d4f52d 100644
--- a/review-enrichment/src/brief.ts
+++ b/review-enrichment/src/brief.ts
@@ -7,6 +7,7 @@ import type {
BriefFindings,
AnalyzerStatus,
AnalyzerDiagnostics,
+ AnalyzerTelemetry,
} from "./types.js";
import type {
AnalyzerRegistry,
@@ -31,6 +32,7 @@ import { captureAnalyzerDegradation } from "./sentry.js";
const DEFAULT_ANALYZER_TIMEOUT_MS = 8000;
const MIN_ANALYZER_TIMEOUT_MS = 1;
+const PUBLIC_PARTIAL_REASON_RE = /^[A-Za-z0-9_.:-]{1,120}$/;
interface BuildBriefOptions {
requestId?: string;
@@ -135,6 +137,11 @@ function timeoutStatus(error: unknown, diagnostics: AnalyzerDiagnostics): Analyz
return statusFromDiagnostics(diagnostics, "degraded");
}
+function publicPartialReason(value: string | undefined, fallback: string): string {
+ if (value && PUBLIC_PARTIAL_REASON_RE.test(value)) return value;
+ return fallback;
+}
+
function captureDegradation(
error: unknown,
input: {
@@ -144,6 +151,9 @@ function captureDegradation(
timeoutMs: number;
elapsedMs: number;
analyzerStatus: AnalyzerStatus;
+ profile: string;
+ costClass?: string;
+ responseReserveMs?: number;
diagnostics: AnalyzerDiagnostics;
options: BuildBriefOptions;
},
@@ -157,6 +167,9 @@ function captureDegradation(
timeoutMs: input.timeoutMs,
elapsedMs: input.elapsedMs,
analyzerStatus: input.analyzerStatus,
+ profile: input.profile,
+ costClass: input.costClass,
+ responseReserveMs: input.responseReserveMs,
partialStatus: input.diagnostics.partialStatus,
partialReason: input.diagnostics.partialReason,
phase: input.diagnostics.phase,
@@ -213,9 +226,18 @@ export async function buildBrief(
const findings: BriefFindings = {};
const analyzerStatus: Record = {};
+ const analyzerTelemetry: Record = {};
let partial = false;
- for (const item of plan.skipped) analyzerStatus[item.name] = "skipped";
+ for (const item of plan.skipped) {
+ analyzerStatus[item.name] = "skipped";
+ analyzerTelemetry[item.name] = {
+ status: "skipped",
+ elapsedMs: 0,
+ costClass: item.descriptor.cost,
+ skipReason: item.skipReason,
+ };
+ }
async function runAnalyzer(item: AnalyzerPlanItem): Promise {
const name = item.name;
@@ -226,6 +248,14 @@ export async function buildBrief(
const remainingMs = plan.executionDeadlineMs - Date.now();
if (!shouldStartAnalyzer(plan.profile, remainingMs)) {
analyzerStatus[name] = "capped";
+ analyzerTelemetry[name] = {
+ status: "capped",
+ elapsedMs: Date.now() - analyzerStartedAt,
+ costClass: item.descriptor.cost,
+ partialStatus: "partial",
+ partialReason: "analyzer_budget_exhausted",
+ capped: true,
+ };
partial = true;
analysis.metrics.recordCappedWork("analyzer_budget", 1);
return;
@@ -238,6 +268,15 @@ export async function buildBrief(
);
if (timeoutMs <= 0) {
analyzerStatus[name] = "capped";
+ analyzerTelemetry[name] = {
+ status: "capped",
+ elapsedMs: Date.now() - analyzerStartedAt,
+ timeoutMs,
+ costClass: item.descriptor.cost,
+ partialStatus: "partial",
+ partialReason: "analyzer_budget_exhausted",
+ capped: true,
+ };
partial = true;
analysis.metrics.recordCappedWork(`analyzer_${item.descriptor.cost}`, 1);
return;
@@ -259,10 +298,23 @@ export async function buildBrief(
findings[name] = result as never;
if (resultIsPartial(result) || diagnostics.partialStatus === "partial") {
const status = statusFromDiagnostics(diagnostics, "degraded");
+ const partialReason = publicPartialReason(
+ diagnostics.partialReason,
+ status === "capped" ? "analyzer_capped" : "analyzer_partial",
+ );
analyzerStatus[name] = status;
+ analyzerTelemetry[name] = {
+ status,
+ elapsedMs: Date.now() - analyzerStartedAt,
+ timeoutMs,
+ costClass: item.descriptor.cost,
+ partialStatus: "partial",
+ partialReason,
+ capped: status === "capped" || diagnostics.capped,
+ };
partial = true;
diagnostics.partialStatus = "partial";
- diagnostics.partialReason ??= status === "capped" ? "analyzer_capped" : "analyzer_partial";
+ diagnostics.partialReason = partialReason;
if (diagnostics.captureDegradation) {
attachAnalysisMetrics(diagnostics, analysis);
captureDegradation(new Error(diagnostics.partialReason), {
@@ -272,27 +324,50 @@ export async function buildBrief(
timeoutMs,
elapsedMs: Date.now() - analyzerStartedAt,
analyzerStatus: status,
+ profile: plan.profile,
+ costClass: item.descriptor.cost,
+ responseReserveMs: plan.responseReserveMs,
diagnostics,
options,
});
}
} else {
analyzerStatus[name] = "ok";
+ analyzerTelemetry[name] = {
+ status: "ok",
+ elapsedMs: Date.now() - analyzerStartedAt,
+ timeoutMs,
+ costClass: item.descriptor.cost,
+ partialStatus: diagnostics.partialStatus,
+ };
}
} catch (error) {
const status = timeoutStatus(error, diagnostics);
+ const partialReason = publicPartialReason(diagnostics.partialReason, "analyzer_error");
analyzerStatus[name] = status;
+ analyzerTelemetry[name] = {
+ status,
+ elapsedMs: Date.now() - analyzerStartedAt,
+ timeoutMs,
+ costClass: item.descriptor.cost,
+ partialStatus: "partial",
+ partialReason,
+ capped: status === "capped" || diagnostics.capped,
+ };
partial = true;
diagnostics.partialStatus = "partial";
- diagnostics.partialReason ??= error instanceof Error ? error.message : "analyzer_error";
+ diagnostics.partialReason = partialReason;
attachAnalysisMetrics(diagnostics, analysis);
- captureDegradation(error, {
+ captureDegradation(new Error(partialReason), {
analyzer: name,
requested: plan.requested,
req,
timeoutMs,
elapsedMs: Date.now() - analyzerStartedAt,
analyzerStatus: status,
+ profile: plan.profile,
+ costClass: item.descriptor.cost,
+ responseReserveMs: plan.responseReserveMs,
diagnostics,
options,
});
@@ -310,21 +385,49 @@ export async function buildBrief(
}
for (const name of all)
- if (!plan.requested.includes(name)) analyzerStatus[name] = "skipped";
+ if (!plan.requested.includes(name)) {
+ analyzerStatus[name] = "skipped";
+ analyzerTelemetry[name] ??= {
+ status: "skipped",
+ elapsedMs: 0,
+ skipReason: "not_requested",
+ };
+ }
const { promptSection, systemSuffix } = renderBrief(
findings,
req.budget?.maxBriefChars ?? 6000,
);
+ const elapsedMs = Date.now() - start;
+ const metrics = analysis.snapshotMetrics();
+ const cacheTotal = metrics.cacheHits + metrics.cacheMisses;
return {
schemaVersion: 1,
repoFullName: req.repoFullName,
prNumber: req.prNumber,
headSha: req.headSha ?? null,
generatedAtIso: new Date().toISOString(),
- elapsedMs: Date.now() - start,
+ elapsedMs,
partial,
analyzerStatus,
+ telemetry: {
+ profile: plan.profile,
+ responseReserveMs: plan.responseReserveMs,
+ requestedAnalyzers: plan.requested,
+ analyzerCount: {
+ requested: plan.requested.length,
+ runnable: plan.runnable.length,
+ skipped: plan.skipped.length,
+ },
+ analyzers: analyzerTelemetry,
+ cacheHits: metrics.cacheHits,
+ cacheMisses: metrics.cacheMisses,
+ cacheHitRate: cacheTotal > 0 ? metrics.cacheHits / cacheTotal : 0,
+ externalCallsByCategory: metrics.externalCallsByCategory,
+ skippedWorkByCategory: metrics.skippedWorkByCategory,
+ cappedWorkByCategory: metrics.cappedWorkByCategory,
+ elapsedMs,
+ },
findings,
promptSection,
systemSuffix,
diff --git a/review-enrichment/src/request-guardrails.ts b/review-enrichment/src/request-guardrails.ts
new file mode 100644
index 0000000000..5cbf2ade61
--- /dev/null
+++ b/review-enrichment/src/request-guardrails.ts
@@ -0,0 +1,152 @@
+import type { EnrichRequest } from "./types.js";
+
+export const MAX_BODY_BYTES = 2 * 1024 * 1024;
+const MAX_FILES = 300;
+const MAX_DIFF_BYTES = 1_000_000;
+const MAX_TOTAL_PATCH_BYTES = 1_500_000;
+const MAX_PATH_CHARS = 1000;
+const MAX_ANALYZERS = 100;
+
+export type EnrichRequestParseResult =
+ | { ok: true; payload: EnrichRequest; bodyBytes: number }
+ | { ok: false; status: 400 | 413; error: string; bodyBytes: number };
+
+export type EnrichRequestBodyReadResult =
+ | { ok: true; raw: string; bodyBytes: number }
+ | { ok: false; status: 413; error: "request_too_large"; bodyBytes: number };
+
+export async function readEnrichRequestText(request: Request): Promise {
+ const contentLength = request.headers.get("content-length");
+ if (contentLength) {
+ const parsedLength = Number.parseInt(contentLength, 10);
+ if (Number.isFinite(parsedLength) && parsedLength > MAX_BODY_BYTES) {
+ return {
+ ok: false,
+ status: 413,
+ error: "request_too_large",
+ bodyBytes: parsedLength,
+ };
+ }
+ }
+
+ const reader = request.body?.getReader();
+ if (!reader) return { ok: true, raw: "", bodyBytes: 0 };
+
+ const chunks: Uint8Array[] = [];
+ let bodyBytes = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ bodyBytes += value.byteLength;
+ if (bodyBytes > MAX_BODY_BYTES) {
+ await reader.cancel();
+ return {
+ ok: false,
+ status: 413,
+ error: "request_too_large",
+ bodyBytes,
+ };
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ return { ok: true, raw: decodeChunks(chunks, bodyBytes), bodyBytes };
+}
+
+export function parseEnrichRequestBody(raw: string): EnrichRequestParseResult {
+ const bodyBytes = byteLength(raw);
+ if (bodyBytes > MAX_BODY_BYTES) {
+ return { ok: false, status: 413, error: "request_too_large", bodyBytes };
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return { ok: false, status: 400, error: "bad_json", bodyBytes };
+ }
+
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return { ok: false, status: 400, error: "bad_request", bodyBytes };
+ }
+
+ const payload = parsed as EnrichRequest;
+ if (!validRepo(payload.repoFullName) || !validPullNumber(payload.prNumber)) {
+ return { ok: false, status: 400, error: "bad_request", bodyBytes };
+ }
+ if (payload.files !== undefined && !Array.isArray(payload.files)) {
+ return { ok: false, status: 400, error: "bad_files", bodyBytes };
+ }
+ if ((payload.files?.length ?? 0) > MAX_FILES) {
+ return { ok: false, status: 413, error: "too_many_files", bodyBytes };
+ }
+ if (typeof payload.diff === "string" && byteLength(payload.diff) > MAX_DIFF_BYTES) {
+ return { ok: false, status: 413, error: "diff_too_large", bodyBytes };
+ }
+ if (!validAnalyzers(payload.analyzers)) {
+ return { ok: false, status: 400, error: "bad_analyzers", bodyBytes };
+ }
+ if (!validFiles(payload.files)) {
+ return { ok: false, status: 400, error: "bad_files", bodyBytes };
+ }
+ if (totalPatchBytes(payload.files) > MAX_TOTAL_PATCH_BYTES) {
+ return { ok: false, status: 413, error: "patches_too_large", bodyBytes };
+ }
+
+ return { ok: true, payload, bodyBytes };
+}
+
+function validRepo(value: unknown): value is string {
+ return (
+ typeof value === "string" &&
+ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) &&
+ value.length <= 200
+ );
+}
+
+function validPullNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
+}
+
+function validAnalyzers(value: unknown): boolean {
+ if (value === undefined) return true;
+ if (!Array.isArray(value) || value.length > MAX_ANALYZERS) return false;
+ return value.every((entry) => typeof entry === "string" && entry.length <= 80);
+}
+
+function validFiles(files: EnrichRequest["files"]): boolean {
+ if (!files) return true;
+ return files.every((file) => {
+ if (!file || typeof file !== "object") return false;
+ if (typeof file.path !== "string" || !file.path || file.path.length > MAX_PATH_CHARS) return false;
+ if (file.patch !== undefined && typeof file.patch !== "string") return false;
+ if (file.status !== undefined && typeof file.status !== "string") return false;
+ if (file.previousPath !== undefined && typeof file.previousPath !== "string") return false;
+ return true;
+ });
+}
+
+function totalPatchBytes(files: EnrichRequest["files"]): number {
+ return (files ?? []).reduce(
+ (total, file) => total + (typeof file.patch === "string" ? byteLength(file.patch) : 0),
+ 0,
+ );
+}
+
+function decodeChunks(chunks: readonly Uint8Array[], bodyBytes: number): string {
+ const buffer = new Uint8Array(bodyBytes);
+ let offset = 0;
+ for (const chunk of chunks) {
+ buffer.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return new TextDecoder().decode(buffer);
+}
+
+function byteLength(value: string): number {
+ return new TextEncoder().encode(value).byteLength;
+}
diff --git a/review-enrichment/src/scheduler.ts b/review-enrichment/src/scheduler.ts
index 3748d4dc63..51fdb523bc 100644
--- a/review-enrichment/src/scheduler.ts
+++ b/review-enrichment/src/scheduler.ts
@@ -114,6 +114,15 @@ export interface AnalyzerPlan {
executionDeadlineMs: number;
}
+export interface ReesProfileMetadata {
+ name: ReesProfileName;
+ default: boolean;
+ costClasses: AnalyzerCostClass[];
+ concurrency: Record;
+ timeoutMs: Record;
+ responseReserveMs: number;
+}
+
export function resolveReesProfile(value: unknown): ReesProfileName {
if (typeof value !== "string") return DEFAULT_REES_PROFILE;
const normalized = value.trim().toLowerCase();
@@ -124,6 +133,20 @@ export function isReesProfileName(value: string): value is ReesProfileName {
return (REES_PROFILES as readonly string[]).includes(value);
}
+export function reesProfileMetadata(): ReesProfileMetadata[] {
+ return REES_PROFILES.map((name) => {
+ const config = PROFILE_CONFIG[name];
+ return {
+ name,
+ default: name === DEFAULT_REES_PROFILE,
+ costClasses: COST_ORDER.filter((cost) => config.costs.has(cost)),
+ concurrency: { ...config.concurrency },
+ timeoutMs: { ...config.timeoutMs },
+ responseReserveMs: config.responseReserveMs,
+ };
+ });
+}
+
export function responseReserveMs(profile: ReesProfileName, budgetMs: number): number {
const configured = PROFILE_CONFIG[profile].responseReserveMs;
const proportional = Math.floor(Math.max(0, budgetMs) * 0.2);
diff --git a/review-enrichment/src/sentry.ts b/review-enrichment/src/sentry.ts
index 0722d0cec1..c178dbbf69 100644
--- a/review-enrichment/src/sentry.ts
+++ b/review-enrichment/src/sentry.ts
@@ -111,6 +111,9 @@ export interface AnalyzerDegradationContext {
timeoutMs?: number;
elapsedMs?: number;
analyzerStatus?: string;
+ profile?: string;
+ costClass?: string;
+ responseReserveMs?: number;
partialStatus?: string;
partialReason?: string;
phase?: string;
@@ -147,6 +150,9 @@ export function captureAnalyzerDegradation(error: unknown, context: AnalyzerDegr
timeoutMs: context.timeoutMs,
elapsedMs: context.elapsedMs,
analyzerStatus: context.analyzerStatus,
+ profile: context.profile,
+ costClass: context.costClass,
+ responseReserveMs: context.responseReserveMs,
partialStatus: context.partialStatus,
partialReason: context.partialReason,
phase: context.phase,
@@ -187,6 +193,9 @@ export function captureAnalyzerDegradation(error: unknown, context: AnalyzerDegr
if (timeoutTag) scope.setTag("timeoutMs", timeoutTag);
if (releaseTag) scope.setTag("release", releaseTag);
const analyzerStatusTag = sentryTagValue(context.analyzerStatus);
+ const profileTag = sentryTagValue(context.profile);
+ const costClassTag = sentryTagValue(context.costClass);
+ const responseReserveTag = sentryTagValue(context.responseReserveMs);
const partialStatusTag = sentryTagValue(context.partialStatus);
const phaseTag = sentryTagValue(context.phase);
const endpointCategoryTag = sentryTagValue(context.endpointCategory);
@@ -197,6 +206,9 @@ export function captureAnalyzerDegradation(error: unknown, context: AnalyzerDegr
const cacheHitsTag = sentryTagValue(context.cacheHits);
const cacheMissesTag = sentryTagValue(context.cacheMisses);
if (analyzerStatusTag) scope.setTag("analyzerStatus", analyzerStatusTag);
+ if (profileTag) scope.setTag("profile", profileTag);
+ if (costClassTag) scope.setTag("costClass", costClassTag);
+ if (responseReserveTag) scope.setTag("responseReserveMs", responseReserveTag);
if (partialStatusTag) scope.setTag("partialStatus", partialStatusTag);
if (phaseTag) scope.setTag("phase", phaseTag);
if (endpointCategoryTag) scope.setTag("endpointCategory", endpointCategoryTag);
diff --git a/review-enrichment/src/server.ts b/review-enrichment/src/server.ts
index 248ac2f8cc..c981b80186 100644
--- a/review-enrichment/src/server.ts
+++ b/review-enrichment/src/server.ts
@@ -10,8 +10,11 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { normalizeSharedSecret, verifyBearer } from "./auth.js";
-import type { EnrichRequest } from "./types.js";
import { buildBrief } from "./brief.js";
+import {
+ parseEnrichRequestBody,
+ readEnrichRequestText,
+} from "./request-guardrails.js";
import {
captureError,
flushSentry,
@@ -54,18 +57,13 @@ app.post("/v1/enrich", async (c) => {
if (!verifyBearer(c.req.header("authorization"), secret))
return c.json({ error: "unauthorized" }, 401);
- const payload = (await c.req
- .json()
- .catch(() => null)) as EnrichRequest | null;
- if (
- !payload ||
- typeof payload.repoFullName !== "string" ||
- typeof payload.prNumber !== "number"
- ) {
- return c.json({ error: "bad_request" }, 400);
- }
+ const body = await readEnrichRequestText(c.req.raw);
+ if (!body.ok) return c.json({ error: body.error }, body.status);
+
+ const parsed = parseEnrichRequestBody(body.raw);
+ if (!parsed.ok) return c.json({ error: parsed.error }, parsed.status);
- const brief = await buildBrief(payload, undefined, {
+ const brief = await buildBrief(parsed.payload, undefined, {
requestId: c.req.header("x-gittensory-request-id") ?? c.req.header("x-request-id"),
traceId: traceIdFromTraceparent(c.req.header("traceparent")),
});
diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts
index 671882d042..d3e4bd61e7 100644
--- a/review-enrichment/src/types.ts
+++ b/review-enrichment/src/types.ts
@@ -350,7 +350,38 @@ export interface ReviewBrief {
elapsedMs: number;
partial: boolean;
analyzerStatus: Record;
+ telemetry: ReviewBriefTelemetry;
findings: BriefFindings;
promptSection: string;
systemSuffix: string;
}
+
+export interface ReviewBriefTelemetry {
+ profile: ReesProfileName;
+ responseReserveMs: number;
+ requestedAnalyzers: string[];
+ analyzerCount: {
+ requested: number;
+ runnable: number;
+ skipped: number;
+ };
+ analyzers: Record;
+ cacheHits: number;
+ cacheMisses: number;
+ cacheHitRate: number;
+ externalCallsByCategory: Record;
+ skippedWorkByCategory: Record;
+ cappedWorkByCategory: Record;
+ elapsedMs: number;
+}
+
+export interface AnalyzerTelemetry {
+ status: AnalyzerStatus;
+ elapsedMs: number;
+ timeoutMs?: number;
+ costClass?: string;
+ partialStatus?: "complete" | "partial";
+ partialReason?: string;
+ skipReason?: string;
+ capped?: boolean;
+}
diff --git a/review-enrichment/test/analyzer-metadata.test.ts b/review-enrichment/test/analyzer-metadata.test.ts
new file mode 100644
index 0000000000..e7e2c80486
--- /dev/null
+++ b/review-enrichment/test/analyzer-metadata.test.ts
@@ -0,0 +1,49 @@
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { ANALYZER_DESCRIPTORS, ANALYZER_NAMES } from "../dist/analyzers/registry.js";
+import { reesProfileMetadata } from "../dist/scheduler.js";
+
+test("generated analyzer metadata matches the runtime registry and profiles", () => {
+ const metadata = JSON.parse(readFileSync("analyzer-metadata.json", "utf8")) as {
+ schemaVersion: number;
+ defaultProfile: string;
+ profiles: Array<{ name: string; default: boolean; costClasses: string[] }>;
+ analyzers: Array<{
+ name: string;
+ title: string;
+ category: string;
+ cost: string;
+ defaultEnabled: boolean;
+ profiles: string[];
+ requires: string[];
+ limits: Record;
+ docs: Record;
+ }>;
+ };
+
+ assert.equal(metadata.schemaVersion, 1);
+ assert.equal(metadata.defaultProfile, "balanced");
+ assert.deepEqual(
+ metadata.profiles.map((profile) => profile.name),
+ reesProfileMetadata().map((profile) => profile.name),
+ );
+ assert.deepEqual(
+ metadata.analyzers.map((analyzer) => analyzer.name),
+ ANALYZER_NAMES,
+ );
+
+ for (const descriptor of ANALYZER_DESCRIPTORS) {
+ const generated = metadata.analyzers.find((analyzer) => analyzer.name === descriptor.name);
+ assert.ok(generated, `missing generated metadata for ${descriptor.name}`);
+ assert.equal(generated.title, descriptor.title);
+ assert.equal(generated.category, descriptor.category);
+ assert.equal(generated.cost, descriptor.cost);
+ assert.equal(generated.defaultEnabled, descriptor.defaultEnabled);
+ assert.deepEqual(generated.requires, descriptor.requires);
+ assert.deepEqual(generated.limits, descriptor.limits ?? {});
+ assert.equal(generated.docs.summary, descriptor.docs.summary);
+ assert.ok(generated.profiles.includes("balanced"));
+ }
+});
diff --git a/review-enrichment/test/request-guardrails.test.ts b/review-enrichment/test/request-guardrails.test.ts
new file mode 100644
index 0000000000..ae7b2fc953
--- /dev/null
+++ b/review-enrichment/test/request-guardrails.test.ts
@@ -0,0 +1,153 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ MAX_BODY_BYTES,
+ parseEnrichRequestBody,
+ readEnrichRequestText,
+} from "../dist/request-guardrails.js";
+
+test("parseEnrichRequestBody accepts a minimal valid enrichment request", () => {
+ const result = parseEnrichRequestBody(
+ JSON.stringify({
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1814,
+ files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,1 @@\n+export const a = 1;" }],
+ }),
+ );
+
+ assert.equal(result.ok, true);
+ if (result.ok) {
+ assert.equal(result.payload.repoFullName, "JSONbored/gittensory");
+ assert.equal(result.payload.prNumber, 1814);
+ assert.ok(result.bodyBytes > 0);
+ }
+});
+
+test("parseEnrichRequestBody rejects malformed JSON and invalid shallow schema", () => {
+ const malformed = parseEnrichRequestBody("{not json");
+ assert.deepEqual(malformed, {
+ ok: false,
+ status: 400,
+ error: "bad_json",
+ bodyBytes: 9,
+ });
+
+ const badSchema = parseEnrichRequestBody(JSON.stringify({ repoFullName: "bad", prNumber: 0 }));
+ assert.equal(badSchema.ok, false);
+ if (!badSchema.ok) {
+ assert.equal(badSchema.status, 400);
+ assert.equal(badSchema.error, "bad_request");
+ }
+});
+
+test("parseEnrichRequestBody rejects oversized body, file list, diff, and patch payloads", () => {
+ const hugeBody = parseEnrichRequestBody("x".repeat(2 * 1024 * 1024 + 1));
+ assert.equal(hugeBody.ok, false);
+ if (!hugeBody.ok) {
+ assert.equal(hugeBody.status, 413);
+ assert.equal(hugeBody.error, "request_too_large");
+ }
+
+ const tooManyFiles = parseEnrichRequestBody(
+ JSON.stringify({
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1814,
+ files: Array.from({ length: 301 }, (_, index) => ({ path: `src/${index}.ts` })),
+ }),
+ );
+ assert.equal(tooManyFiles.ok, false);
+ if (!tooManyFiles.ok) assert.equal(tooManyFiles.error, "too_many_files");
+
+ const hugeDiff = parseEnrichRequestBody(
+ JSON.stringify({
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1814,
+ diff: "x".repeat(1_000_001),
+ }),
+ );
+ assert.equal(hugeDiff.ok, false);
+ if (!hugeDiff.ok) assert.equal(hugeDiff.error, "diff_too_large");
+
+ const hugePatch = parseEnrichRequestBody(
+ JSON.stringify({
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 1814,
+ files: [{ path: "src/a.ts", patch: "x".repeat(1_500_001) }],
+ }),
+ );
+ assert.equal(hugePatch.ok, false);
+ if (!hugePatch.ok) assert.equal(hugePatch.error, "patches_too_large");
+});
+
+test("readEnrichRequestText rejects an oversized Content-Length without reading the body", async () => {
+ const body = new ReadableStream({
+ pull(controller) {
+ controller.enqueue(new Uint8Array([123]));
+ controller.close();
+ },
+ });
+ const request = new Request("https://rees.example/v1/enrich", {
+ method: "POST",
+ headers: { "content-length": String(MAX_BODY_BYTES + 1) },
+ body,
+ duplex: "half",
+ } as RequestInit);
+
+ const result = await readEnrichRequestText(request);
+
+ assert.equal(result.ok, false);
+ if (!result.ok) {
+ assert.equal(result.status, 413);
+ assert.equal(result.error, "request_too_large");
+ assert.equal(result.bodyBytes, MAX_BODY_BYTES + 1);
+ }
+ assert.equal(request.bodyUsed, false);
+});
+
+test("readEnrichRequestText stops streaming once the request body exceeds the cap", async () => {
+ let pulls = 0;
+ let canceled = false;
+ const body = new ReadableStream({
+ pull(controller) {
+ pulls += 1;
+ controller.enqueue(new Uint8Array(1024 * 1024));
+ if (pulls > 5) controller.close();
+ },
+ cancel() {
+ canceled = true;
+ },
+ });
+ const request = new Request("https://rees.example/v1/enrich", {
+ method: "POST",
+ body,
+ duplex: "half",
+ } as RequestInit);
+
+ const result = await readEnrichRequestText(request);
+
+ assert.equal(result.ok, false);
+ if (!result.ok) {
+ assert.equal(result.status, 413);
+ assert.equal(result.error, "request_too_large");
+ assert.ok(result.bodyBytes > MAX_BODY_BYTES);
+ }
+ assert.equal(canceled, true);
+ assert.ok(pulls < 6);
+});
+
+test("readEnrichRequestText returns a small request body", async () => {
+ const raw = JSON.stringify({ repoFullName: "JSONbored/gittensory", prNumber: 1836 });
+ const request = new Request("https://rees.example/v1/enrich", {
+ method: "POST",
+ body: raw,
+ });
+
+ const result = await readEnrichRequestText(request);
+
+ assert.equal(result.ok, true);
+ if (result.ok) {
+ assert.equal(result.raw, raw);
+ assert.equal(result.bodyBytes, new TextEncoder().encode(raw).byteLength);
+ }
+});
diff --git a/review-enrichment/test/scheduler.test.ts b/review-enrichment/test/scheduler.test.ts
index 071244e237..c562479651 100644
--- a/review-enrichment/test/scheduler.test.ts
+++ b/review-enrichment/test/scheduler.test.ts
@@ -81,6 +81,12 @@ test("slow analyzers time out inside the reserved response budget", async () =>
assert.equal(brief.partial, true);
assert.equal(brief.analyzerStatus.history, "timeout");
+ assert.equal(brief.telemetry.profile, "balanced");
+ assert.equal(brief.telemetry.requestedAnalyzers[0], "history");
+ assert.equal(brief.telemetry.analyzers.history.status, "timeout");
+ assert.equal(brief.telemetry.analyzers.history.partialReason, "analyzer_timeout");
+ assert.ok((brief.telemetry.analyzers.history.timeoutMs ?? 0) < 300);
+ assert.ok(brief.telemetry.responseReserveMs > 0);
assert.ok(Date.now() - started < 1000);
assert.ok(brief.elapsedMs < 1000);
});
@@ -168,4 +174,7 @@ test("registry analyzers skip when their relevant inputs are absent", async () =
assert.equal(dependencyRan, false);
assert.equal(brief.analyzerStatus.dependency, "skipped");
assert.equal(brief.analyzerStatus.secret, "ok");
+ assert.equal(brief.telemetry.analyzers.dependency.skipReason, "no_dependency_manifest");
+ assert.equal(brief.telemetry.analyzers.secret.status, "ok");
+ assert.ok(brief.telemetry.skippedWorkByCategory.analyzer_no_dependency_manifest >= 1);
});
diff --git a/review-enrichment/test/sentry-degradation.test.ts b/review-enrichment/test/sentry-degradation.test.ts
index 8c07cd9326..75195b4b58 100644
--- a/review-enrichment/test/sentry-degradation.test.ts
+++ b/review-enrichment/test/sentry-degradation.test.ts
@@ -130,6 +130,9 @@ test("captureAnalyzerDegradation attaches safe attribution context for history f
timeoutMs: 7000,
elapsedMs: 6812,
analyzerStatus: "degraded",
+ profile: "balanced",
+ costClass: "github-heavy",
+ responseReserveMs: 750,
partialStatus: "partial",
partialReason: "history_budget_exhausted",
phase: "similar_past_prs",
@@ -161,6 +164,9 @@ test("captureAnalyzerDegradation attaches safe attribution context for history f
assert.equal(sentry.tags.headShaPrefix, "abcdef123456");
assert.equal(sentry.tags.timeoutMs, "7000");
assert.equal(sentry.tags.analyzerStatus, "degraded");
+ assert.equal(sentry.tags.profile, "balanced");
+ assert.equal(sentry.tags.costClass, "github-heavy");
+ assert.equal(sentry.tags.responseReserveMs, "750");
assert.equal(sentry.tags.partialStatus, "partial");
assert.equal(sentry.tags.phase, "similar_past_prs");
assert.equal(sentry.tags.endpointCategory, "github-commit-pulls");
@@ -180,6 +186,9 @@ test("captureAnalyzerDegradation attaches safe attribution context for history f
timeoutMs: 7000,
elapsedMs: 6812,
analyzerStatus: "degraded",
+ profile: "balanced",
+ costClass: "github-heavy",
+ responseReserveMs: 750,
partialStatus: "partial",
partialReason: "history_budget_exhausted",
phase: "similar_past_prs",
@@ -212,6 +221,7 @@ test("captureAnalyzerDegradation attaches safe attribution context for history f
test("buildBrief stays fail-open and captures a degraded analyzer", async () => {
const sentry = sentryHarness();
+ const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_");
const brief = await buildBrief(
{
@@ -224,7 +234,7 @@ test("buildBrief stays fail-open and captures a degraded analyzer", async () =>
},
{
dependency: async () => {
- throw new Error("osv unavailable");
+ throw new Error(`osv unavailable for ${fakeToken}`);
},
},
);
@@ -234,8 +244,11 @@ test("buildBrief stays fail-open and captures a degraded analyzer", async () =>
assert.deepEqual(brief.findings, {});
assert.equal(brief.repoFullName, "JSONbored/gittensory");
assert.equal(brief.prNumber, 42);
+ assert.equal(brief.telemetry.analyzers.dependency.partialReason, "analyzer_error");
+ assert.equal(JSON.stringify(brief.telemetry).includes(fakeToken), false);
+ assert.equal(JSON.stringify(brief.telemetry).includes("osv unavailable"), false);
assert.equal(sentry.captured.length, 1);
- assert.equal(sentry.captured[0].message, "osv unavailable");
+ assert.equal(sentry.captured[0].message, "analyzer_error");
assert.equal(sentry.tags.analyzer, "dependency");
assert.equal(sentry.tags.repo, "JSONbored/gittensory");
assert.equal(sentry.tags.pullNumber, "42");
@@ -245,6 +258,32 @@ test("buildBrief stays fail-open and captures a degraded analyzer", async () =>
assert.ok(capturedTimeoutMs <= 200);
});
+test("buildBrief normalizes unsafe analyzer partial reasons before response telemetry", async () => {
+ const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_");
+
+ const brief = await buildBrief(
+ {
+ repoFullName: "JSONbored/gittensory",
+ prNumber: 42,
+ analyzers: ["history"],
+ linkedIssue: { number: 9, title: "add history context" },
+ diff: `+${fakeToken}`,
+ budget: { timeoutMs: 200 },
+ },
+ {
+ history: async (_req, context) => {
+ context.diagnostics.partialReason = `unsafe ${fakeToken}`;
+ return [{ author: null, similarPastPrs: [], linkedIssueAlignment: null, partial: true }];
+ },
+ },
+ );
+
+ assert.equal(brief.partial, true);
+ assert.equal(brief.analyzerStatus.history, "degraded");
+ assert.equal(brief.telemetry.analyzers.history.partialReason, "analyzer_partial");
+ assert.equal(JSON.stringify(brief.telemetry).includes(fakeToken), false);
+});
+
test("buildBrief returns a timed-out partial response before the caller timeout budget is spent", async () => {
const started = Date.now();
const brief = await buildBrief(