diff --git a/scripts/problem-cause-allowlist.json b/scripts/problem-cause-allowlist.json new file mode 100644 index 000000000..886207eb5 --- /dev/null +++ b/scripts/problem-cause-allowlist.json @@ -0,0 +1,87 @@ +{ + "schemaVersion": 1, + "baselineEntryCount": 9, + "entries": [ + { + "code": "problem-cause-not-forwarded", + "file": "packages/auth-better-auth/src/libs/problems/BetterAuthAuthenticationProblem.ts", + "className": "BetterAuthAuthenticationProblem", + "parameter": "error", + "owner": "framework-error-handling", + "reason": "Existing upstream error is reduced to redacted status and message evidence; forwarding behavior is deferred to the auth adapter's own fix.", + "expiresOn": "2026-10-24" + }, + { + "code": "problem-cause-not-forwarded", + "file": "packages/auth-clerk/src/libs/problems/ClerkProblems.ts", + "className": "ClerkTokenVerificationProblem", + "parameter": "error", + "owner": "framework-error-handling", + "reason": "Existing Clerk verification failure does not retain the upstream error; a package-specific fix must review sensitive cause handling.", + "expiresOn": "2026-10-24" + }, + { + "code": "problem-cause-not-forwarded", + "file": "packages/auth-clerk/src/libs/problems/ClerkProblems.ts", + "className": "ClerkTokenVerificationUpstreamProblem", + "parameter": "error", + "owner": "framework-error-handling", + "reason": "Existing Clerk upstream failure does not retain the upstream error; a package-specific fix must review sensitive cause handling.", + "expiresOn": "2026-10-24" + }, + { + "code": "problem-cause-not-forwarded", + "file": "packages/batch-qstash/src/libs/problems/QStashBatchProblems.ts", + "className": "QStashBatchPublishProblem", + "parameter": "error", + "owner": "framework-error-handling", + "reason": "Existing QStash batch failure records classified and redacted evidence without retaining the upstream Error; adapter repair is separate.", + "expiresOn": "2026-10-24" + }, + { + "code": "problem-cause-not-forwarded", + "file": "packages/events-core/src/libs/problems/EventsProblems.ts", + "className": "EventAfterCommitPublishFailedProblem", + "parameter": "observerError", + "owner": "framework-error-handling", + "reason": "The primary cause is forwarded; this optional second failure remains available as the observerError field rather than replacing the primary Error.cause.", + "expiresOn": "2026-12-24" + }, + { + "code": "problem-cause-not-forwarded", + "file": "packages/metering-core/src/libs/problems/RedisProblem.ts", + "className": "RedisProblem", + "parameter": "originalError", + "owner": "framework-error-handling", + "reason": "Existing Error-or-string input only contributes originalMessage; the metering package must preserve Error.cause in an Error-narrowed branch.", + "expiresOn": "2026-10-24" + }, + { + "code": "problem-cause-not-forwarded", + "file": "packages/metering-upstash/src/libs/problems/UpstashMeteringProblems.ts", + "className": "UpstashMeteringUpstreamProblem", + "parameter": "error", + "owner": "framework-error-handling", + "reason": "Existing Upstash metering failure records classified and redacted evidence without retaining the upstream Error; adapter repair is separate.", + "expiresOn": "2026-10-24" + }, + { + "code": "problem-cause-not-forwarded", + "file": "packages/ratelimit-upstash/src/libs/problems/RateLimitUpstashProblems.ts", + "className": "UpstashRateLimitUpstreamProblem", + "parameter": "error", + "owner": "framework-error-handling", + "reason": "Existing Upstash rate-limit failure records classified and redacted evidence without retaining the upstream Error; adapter repair is separate.", + "expiresOn": "2026-10-24" + }, + { + "code": "problem-cause-not-forwarded", + "file": "packages/tasks-qstash/src/libs/problems/QStashTaskProblems.ts", + "className": "QStashTaskPublishProblem", + "parameter": "error", + "owner": "framework-error-handling", + "reason": "Existing QStash task failure records classified and redacted evidence without retaining the upstream Error; adapter repair is separate.", + "expiresOn": "2026-10-24" + } + ] +} diff --git a/scripts/problem-registry.mts b/scripts/problem-registry.mts index a5daf54e7..8b84487cf 100644 --- a/scripts/problem-registry.mts +++ b/scripts/problem-registry.mts @@ -139,6 +139,18 @@ type ProblemConstructorForwarder = { readonly categoryArgumentIndex?: number; }; +type ProblemCauseAllowlistEntry = { + readonly code: "problem-cause-not-forwarded"; + readonly file: string; + readonly className: string; + readonly parameter: string; + readonly owner: string; + readonly reason: string; + readonly expiresOn: string; +}; + +const PROBLEM_CAUSE_ALLOWLIST_PATH = "scripts/problem-cause-allowlist.json"; + const registryPath = join("docs", "problem-code-registry.json"); const generatedRegistrySourcePath = join( "packages", @@ -232,6 +244,7 @@ export function runProblemRegistryCheck( registry, ), ...getProblemRedactionDiagnostics(absoluteRootDir), + ...getProblemCauseDiagnostics(absoluteRootDir), ]; const syncDiagnostics = preflightDiagnostics.length === 0 @@ -802,6 +815,693 @@ function getProblemRedactionDiagnostics(rootDir: string): readonly string[] { ); } +function getProblemCauseDiagnostics(rootDir: string): readonly string[] { + const violations = getSourceFiles(rootDir).flatMap((file) => { + const sourceFile = ts.createSourceFile( + file, + readFileSync(file, "utf-8"), + ts.ScriptTarget.Latest, + true, + file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const found: { + entry: Pick; + diagnostic: string; + }[] = []; + + function visit(node: ts.Node): void { + if ( + (ts.isClassDeclaration(node) || ts.isClassExpression(node)) && + node.name && + isProblemSubclass(node) + ) { + const constructor = node.members.find( + (member): member is ts.ConstructorDeclaration => + ts.isConstructorDeclaration(member) && member.body !== undefined, + ); + if (constructor) { + const superCalls: ts.CallExpression[] = []; + const aliases = new Map(); + const objectIds = new Map>(); + const mutatedAliases = new Set(); + let nextObjectId = 0; + function getObjectIds(name: string): ReadonlySet { + const existing = objectIds.get(name); + if (existing !== undefined) { + return existing; + } + const ids = new Set([++nextObjectId]); + objectIds.set(name, ids); + return ids; + } + function markObjectMutation(name: string): void { + const ids = getObjectIds(name); + for (const [alias, aliasIds] of objectIds) { + if ([...ids].some((id) => aliasIds.has(id))) { + mutatedAliases.add(alias); + } + } + } + function isConditionalRebinding(node: ts.Node): boolean { + let parent = node.parent; + while (parent && !ts.isConstructorDeclaration(parent)) { + if ( + ts.isFunctionLike(parent) || + ts.isIfStatement(parent) || + ts.isConditionalExpression(parent) || + ts.isSwitchStatement(parent) || + ts.isForStatement(parent) || + ts.isForInStatement(parent) || + ts.isForOfStatement(parent) || + ts.isWhileStatement(parent) || + ts.isDoStatement(parent) || + ts.isTryStatement(parent) || + (ts.isBinaryExpression(parent) && + (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + parent.operatorToken.kind === ts.SyntaxKind.BarBarToken || + parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)) + ) { + return true; + } + parent = parent.parent; + } + return false; + } + function collectSuperCalls(child: ts.Node): void { + if (ts.isClassDeclaration(child) || ts.isClassExpression(child)) { + return; + } + if (ts.isVariableDeclaration(child) && ts.isIdentifier(child.name)) { + const source = unwrapExpression(child.initializer); + objectIds.set( + child.name.text, + source && ts.isIdentifier(source) + ? getObjectIds(source.text) + : new Set([++nextObjectId]), + ); + } + if ( + ts.isBinaryExpression(child) && + child.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + child.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + const mutatesObject = + ts.isPropertyAccessExpression(child.left) || + ts.isElementAccessExpression(child.left); + const target = mutatesObject ? unwrapExpression(child.left.expression) : child.left; + if (target && ts.isIdentifier(target)) { + mutatedAliases.add(target.text); + if (mutatesObject) { + markObjectMutation(target.text); + } else { + const source = unwrapExpression(child.right); + const isLogicalAssignment = + child.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken || + child.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken || + child.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken; + const updatedIds = + (child.operatorToken.kind === ts.SyntaxKind.EqualsToken || + isLogicalAssignment) && + source && + ts.isIdentifier(source) + ? getObjectIds(source.text) + : new Set([++nextObjectId]); + objectIds.set( + target.text, + isLogicalAssignment || isConditionalRebinding(child) + ? new Set([...getObjectIds(target.text), ...updatedIds]) + : updatedIds, + ); + } + } + } + if (ts.isDeleteExpression(child)) { + const target = child.expression; + const object = + ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target) + ? unwrapExpression(target.expression) + : undefined; + if (object && ts.isIdentifier(object)) { + markObjectMutation(object.text); + } + } + if ( + ts.isCallExpression(child) && + child.expression.kind === ts.SyntaxKind.SuperKeyword + ) { + superCalls.push(child); + } + ts.forEachChild(child, collectSuperCalls); + } + for (const statement of constructor.body?.statements ?? []) { + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) { + if ((statement.declarationList.flags & ts.NodeFlags.Const) !== 0) { + aliases.set(declaration.name.text, declaration.initializer); + } + } + } + } + collectSuperCalls(statement); + } + for (const name of mutatedAliases) { + aliases.delete(name); + } + + if (superCalls.length === 0) { + return; + } + for (const parameter of constructor.parameters) { + if (!ts.isIdentifier(parameter.name) || !parameter.type) { + continue; + } + const kind = getErrorParameterKind(parameter); + if (!kind) { + continue; + } + const parameterName = parameter.name.text; + if ( + superCalls.every((call) => + forwardsProblemCause(call.arguments[3], parameterName, kind, aliases), + ) + ) { + continue; + } + const location = sourceFile.getLineAndCharacterOfPosition( + parameter.getStart(sourceFile), + ); + const relativeFile = toPosixPath(relative(rootDir, file)); + found.push({ + entry: { + file: relativeFile, + className: node.name.text, + parameter: parameter.name.text, + }, + diagnostic: `problem-cause-not-forwarded at ${relativeFile}:${location.line + 1}:${location.character + 1}: ${node.name.text} must forward '${parameter.name.text}' through super() options.cause.`, + }); + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return found; + }); + const { entries, diagnostics } = readProblemCauseAllowlist(rootDir); + const used = new Set(); + + for (const violation of violations) { + const allowed = entries.find( + (entry) => + entry.file === violation.entry.file && + entry.className === violation.entry.className && + entry.parameter === violation.entry.parameter, + ); + if (allowed) { + used.add(allowed); + } else { + diagnostics.push(violation.diagnostic); + } + } + + for (const entry of entries) { + if (!used.has(entry)) { + diagnostics.push( + `${PROBLEM_CAUSE_ALLOWLIST_PATH}: unused exception for ${entry.file} ${entry.className}.${entry.parameter}.`, + ); + } + } + + return diagnostics; +} + +function isProblemSubclass(node: ts.ClassDeclaration | ts.ClassExpression): boolean { + return ( + node.heritageClauses?.some( + (clause) => + clause.token === ts.SyntaxKind.ExtendsKeyword && + clause.types.some((type) => + getExpressionTerminalName(type.expression)?.endsWith("Problem"), + ), + ) ?? false + ); +} + +function getErrorParameterKind(parameter: ts.ParameterDeclaration): "error" | "mixed" | null { + const type = parameter.type; + if (!type) { + return null; + } + const isError = (node: ts.TypeNode): boolean => + ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + node.typeName.text === "Error"; + + if (isError(type)) { + return "error"; + } + if (ts.isUnionTypeNode(type) && type.types.some(isError)) { + return type.types.every( + (member) => + isError(member) || + member.kind === ts.SyntaxKind.UndefinedKeyword || + member.kind === ts.SyntaxKind.NullKeyword || + (ts.isLiteralTypeNode(member) && member.literal.kind === ts.SyntaxKind.NullKeyword), + ) + ? "error" + : "mixed"; + } + return type.kind === ts.SyntaxKind.UnknownKeyword && + /^(?:error|cause|lastError|originalError)$/iu.test(parameter.name.getText()) + ? "mixed" + : null; +} + +function forwardsProblemCause( + expression: ts.Expression | undefined, + parameter: string, + kind: "error" | "mixed", + aliases: ReadonlyMap, + guarded = false, + visited = new Set(), +): boolean { + if (!expression) { + return false; + } + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) + ) { + return forwardsProblemCause(expression.expression, parameter, kind, aliases, guarded, visited); + } + if ( + ts.isIdentifier(expression) && + aliases.has(expression.text) && + !visited.has(expression.text) + ) { + visited.add(expression.text); + return forwardsProblemCause( + aliases.get(expression.text), + parameter, + kind, + aliases, + guarded, + visited, + ); + } + if (ts.isConditionalExpression(expression)) { + const branch = getCauseConditionBranch(expression.condition, parameter); + const trueForwarded = forwardsProblemCause( + expression.whenTrue, + parameter, + kind, + aliases, + guarded || isErrorGuard(expression.condition, parameter), + new Set(visited), + ); + const falseForwarded = forwardsProblemCause( + expression.whenFalse, + parameter, + kind, + aliases, + guarded, + new Set(visited), + ); + return branch === "true" + ? trueForwarded + : branch === "false" + ? falseForwarded + : trueForwarded && falseForwarded; + } + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + ) { + return ( + getCauseConditionBranch(expression.left, parameter) === "true" && + forwardsProblemCause( + expression.right, + parameter, + kind, + aliases, + guarded || isErrorGuard(expression.left, parameter), + visited, + ) + ); + } + if (!ts.isObjectLiteralExpression(expression)) { + return false; + } + + let forwarded = false; + for (const property of expression.properties) { + if (ts.isSpreadAssignment(property)) { + const spreadForwarded = forwardsProblemCause( + property.expression, + parameter, + kind, + aliases, + guarded, + new Set(visited), + ); + if (spreadForwarded) { + forwarded = true; + } else if (!isCauseFreeSpread(property.expression, aliases)) { + forwarded = false; + } + continue; + } + if (ts.isShorthandPropertyAssignment(property)) { + if (property.name.text === "cause") { + forwarded = forwardsCauseValue( + property.name, + parameter, + kind, + aliases, + guarded, + new Set(visited), + ); + } + continue; + } + const propertyName = getStaticPropertyName(property.name); + if (propertyName !== null && propertyName !== "cause") { + continue; + } + if (!ts.isPropertyAssignment(property) || propertyName === null) { + forwarded = false; + continue; + } + forwarded = forwardsCauseValue( + property.initializer, + parameter, + kind, + aliases, + guarded, + new Set(visited), + ); + } + return forwarded; +} + +function isCauseFreeSpread( + expression: ts.Expression, + aliases: ReadonlyMap, + visited = new Set(), +): boolean { + if (ts.isIdentifier(expression)) { + if (expression.text === "undefined") { + return true; + } + const alias = aliases.get(expression.text); + if (!alias || visited.has(expression.text)) { + return false; + } + visited.add(expression.text); + return isCauseFreeSpread(alias, aliases, visited); + } + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) + ) { + return isCauseFreeSpread(expression.expression, aliases, visited); + } + if (ts.isConditionalExpression(expression)) { + return ( + isCauseFreeSpread(expression.whenTrue, aliases, new Set(visited)) && + isCauseFreeSpread(expression.whenFalse, aliases, new Set(visited)) + ); + } + if ( + ts.isCallExpression(expression) && + ts.isIdentifier(expression.expression) && + !aliases.has(expression.expression.text) + ) { + const helperName = expression.expression.text; + return expression.getSourceFile().statements.some((statement) => { + if ( + !ts.isFunctionDeclaration(statement) || + statement.name?.text !== helperName || + !statement.body || + statement.body.statements.length !== 1 + ) { + return false; + } + const [returnStatement] = statement.body.statements; + return ( + returnStatement && + ts.isReturnStatement(returnStatement) && + returnStatement.expression !== undefined && + isCauseFreeSpread(returnStatement.expression, new Map()) + ); + }); + } + if (ts.isObjectLiteralExpression(expression)) { + return expression.properties.every((property) => { + if (ts.isSpreadAssignment(property)) { + return isCauseFreeSpread(property.expression, aliases, new Set(visited)); + } + const name = getStaticPropertyName(property.name); + return name !== null && name !== "cause"; + }); + } + return ( + expression.kind === ts.SyntaxKind.UndefinedKeyword || + expression.kind === ts.SyntaxKind.FalseKeyword + ); +} + +function forwardsCauseValue( + expression: ts.Expression, + parameter: string, + kind: "error" | "mixed", + aliases: ReadonlyMap, + guarded: boolean, + visited: Set, +): boolean { + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) + ) { + return forwardsCauseValue(expression.expression, parameter, kind, aliases, guarded, visited); + } + if (ts.isIdentifier(expression)) { + if (expression.text === parameter) { + return kind === "error" || guarded; + } + const alias = aliases.get(expression.text); + if (alias && !visited.has(expression.text)) { + visited.add(expression.text); + return forwardsCauseValue(alias, parameter, kind, aliases, guarded, visited); + } + return false; + } + if (ts.isConditionalExpression(expression)) { + const branch = getCauseConditionBranch(expression.condition, parameter); + const trueForwarded = forwardsCauseValue( + expression.whenTrue, + parameter, + kind, + aliases, + guarded || isErrorGuard(expression.condition, parameter), + new Set(visited), + ); + const falseForwarded = forwardsCauseValue( + expression.whenFalse, + parameter, + kind, + aliases, + guarded, + new Set(visited), + ); + return branch === "true" + ? trueForwarded + : branch === "false" + ? falseForwarded + : trueForwarded && falseForwarded; + } + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + ) { + return ( + getCauseConditionBranch(expression.left, parameter) === "true" && + forwardsCauseValue( + expression.right, + parameter, + kind, + aliases, + guarded || isErrorGuard(expression.left, parameter), + visited, + ) + ); + } + if ( + ts.isCallExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === "toError" && + expression.arguments.length === 1 && + ts.isIdentifier(expression.arguments[0]) && + expression.arguments[0].text === parameter && + !aliases.has("toError") + ) { + return expression.getSourceFile().statements.some((statement) => { + if ( + !ts.isFunctionDeclaration(statement) || + statement.name?.text !== "toError" || + !statement.body || + statement.parameters.length !== 1 || + !ts.isIdentifier(statement.parameters[0]?.name) + ) { + return false; + } + const input = statement.parameters[0].name.text; + const [returnStatement] = statement.body.statements; + return ( + statement.body.statements.length === 1 && + returnStatement && + ts.isReturnStatement(returnStatement) && + returnStatement.expression && + ts.isConditionalExpression(returnStatement.expression) && + isErrorGuard(returnStatement.expression.condition, input) && + ts.isIdentifier(returnStatement.expression.whenTrue) && + returnStatement.expression.whenTrue.text === input + ); + }); + } + return false; +} + +function isErrorGuard(expression: ts.Expression, parameter: string): boolean { + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword && + ts.isIdentifier(expression.left) && + expression.left.text === parameter && + ts.isIdentifier(expression.right) && + expression.right.text === "Error" + ) { + return true; + } + return false; +} + +function getCauseConditionBranch( + expression: ts.Expression, + parameter: string, +): "true" | "false" | null { + if (expression.kind === ts.SyntaxKind.TrueKeyword) { + return "true"; + } + if (expression.kind === ts.SyntaxKind.FalseKeyword) { + return "false"; + } + if (ts.isIdentifier(expression) && expression.text === parameter) { + return "true"; + } + if ( + ts.isPrefixUnaryExpression(expression) && + expression.operator === ts.SyntaxKind.ExclamationToken && + ts.isIdentifier(expression.operand) && + expression.operand.text === parameter + ) { + return "false"; + } + if (isErrorGuard(expression, parameter)) { + return "true"; + } + if (ts.isBinaryExpression(expression)) { + const isParameter = (node: ts.Expression): boolean => + ts.isIdentifier(node) && node.text === parameter; + const isEmpty = (node: ts.Expression): boolean => + (ts.isIdentifier(node) && node.text === "undefined") || + node.kind === ts.SyntaxKind.NullKeyword; + if ( + (isParameter(expression.left) && isEmpty(expression.right)) || + (isEmpty(expression.left) && isParameter(expression.right)) + ) { + switch (expression.operatorToken.kind) { + case ts.SyntaxKind.EqualsEqualsToken: + case ts.SyntaxKind.EqualsEqualsEqualsToken: + return "false"; + case ts.SyntaxKind.ExclamationEqualsToken: + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + return "true"; + } + } + } + return null; +} + +function readProblemCauseAllowlist(rootDir: string): { + entries: ProblemCauseAllowlistEntry[]; + diagnostics: string[]; +} { + const path = join(rootDir, PROBLEM_CAUSE_ALLOWLIST_PATH); + if (!existsSync(path)) { + return { entries: [], diagnostics: [] }; + } + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + } catch { + return { entries: [], diagnostics: [`${PROBLEM_CAUSE_ALLOWLIST_PATH}: invalid JSON.`] }; + } + if ( + !parsed || + typeof parsed !== "object" || + !("entries" in parsed) || + !Array.isArray(parsed.entries) || + !("schemaVersion" in parsed) || + parsed.schemaVersion !== 1 || + !("baselineEntryCount" in parsed) || + !Number.isInteger(parsed.baselineEntryCount) || + parsed.baselineEntryCount !== parsed.entries.length + ) { + return { + entries: [], + diagnostics: [ + `${PROBLEM_CAUSE_ALLOWLIST_PATH}: expected schemaVersion 1 and matching baselineEntryCount and entries array.`, + ], + }; + } + const entries: ProblemCauseAllowlistEntry[] = []; + const diagnostics: string[] = []; + for (const [index, value] of parsed.entries.entries()) { + if (!value || typeof value !== "object") { + diagnostics.push(`${PROBLEM_CAUSE_ALLOWLIST_PATH}: invalid entry ${index}.`); + continue; + } + const entry = value as Record; + if ( + entry.code !== "problem-cause-not-forwarded" || + ![entry.file, entry.className, entry.parameter, entry.owner, entry.reason].every( + (field) => typeof field === "string" && field.trim().length > 0, + ) || + typeof entry.expiresOn !== "string" || + !/^\d{4}-\d{2}-\d{2}$/u.test(entry.expiresOn) || + !Number.isFinite(Date.parse(entry.expiresOn)) || + new Date(entry.expiresOn).toISOString().slice(0, 10) !== entry.expiresOn || + typeof entry.file !== "string" || + !entry.file.startsWith("packages/") || + !existsSync(join(rootDir, entry.file)) || + entry.expiresOn < new Date().toISOString().slice(0, 10) + ) { + diagnostics.push( + `${PROBLEM_CAUSE_ALLOWLIST_PATH}: entry ${index} requires code, file, className, parameter, owner, reason, and unexpired expiresOn.`, + ); + continue; + } + entries.push(entry as ProblemCauseAllowlistEntry); + } + return { entries, diagnostics }; +} + function getProblemRedactionDiagnosticsForFile(rootDir: string, file: string): readonly string[] { const source = readFileSync(file, "utf-8"); const sourceFile = ts.createSourceFile( @@ -1733,7 +2433,12 @@ function unwrapExpression(node: ts.Node | undefined): ts.Expression | undefined let expression: ts.Expression = node; - while (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) { + while ( + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isParenthesizedExpression(expression) || + ts.isNonNullExpression(expression) + ) { expression = expression.expression; } @@ -1768,6 +2473,19 @@ function getPropertyName(name: ts.PropertyName): string | null { return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : null; } +function getStaticPropertyName(name: ts.PropertyName): string | null { + if (ts.isComputedPropertyName(name)) { + return ts.isStringLiteral(name.expression) || + ts.isNoSubstitutionTemplateLiteral(name.expression) + ? name.expression.text + : null; + } + if (ts.isNumericLiteral(name)) { + return name.text; + } + return getPropertyName(name); +} + const telemetryAttributes = ["problem.code", "problem.category", "problem.status"] as const; const statusPolicyByCode: Partial> = { diff --git a/scripts/tests/problem-registry.spec.ts b/scripts/tests/problem-registry.spec.ts index 4740efd84..10c6a670d 100644 --- a/scripts/tests/problem-registry.spec.ts +++ b/scripts/tests/problem-registry.spec.ts @@ -26,6 +26,416 @@ describe("problem-registry.mts", () => { } }); + it("rejects Problem constructors that drop Error and Error-union causes", () => { + const repo = createTempRepo(); + writeFile( + repo, + "packages/alpha/src/problems.ts", + [ + 'import { Problem, ProblemCategory } from "@croco/problems-core";', + "export class AlphaProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/failed", ProblemCategory.InternalServerError, cause.message);', + " }", + "}", + "export class UnionProblem extends Problem {", + " constructor(originalError: Error | string) {", + ' super("alpha/union", ProblemCategory.InternalServerError, String(originalError));', + " }", + "}", + "export class UnknownProblem extends Problem {", + " constructor(error: unknown) {", + ' super("alpha/unknown", ProblemCategory.InternalServerError, String(error));', + " }", + "}", + ].join("\n"), + ); + + const result = runProblemRegistryCheck(repo, "write"); + + expect(result.status).toBe("fail"); + expect(result.diagnostics).toContainEqual( + expect.stringContaining("problem-cause-not-forwarded at packages/alpha/src/problems.ts:3:"), + ); + expect(result.diagnostics).toContainEqual( + expect.stringContaining("problem-cause-not-forwarded at packages/alpha/src/problems.ts:8:"), + ); + expect(result.diagnostics).toContainEqual( + expect.stringContaining("problem-cause-not-forwarded at packages/alpha/src/problems.ts:13:"), + ); + }, 30_000); + + it("accepts direct, guarded, aliased, spread, and Error-union cause forwarding", () => { + const repo = createTempRepo(); + writeFile( + repo, + "packages/alpha/src/problems.ts", + [ + 'import { Problem, ProblemCategory } from "@croco/problems-core";', + 'import type { ProblemOptions } from "@croco/problems-core";', + "export class DirectProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/direct", ProblemCategory.InternalServerError, "failed", { cause });', + " }", + "}", + "export class GuardedProblem extends Problem {", + " constructor(error: unknown) {", + ' super("alpha/guarded", ProblemCategory.InternalServerError, "failed", error instanceof Error ? { cause: error } : undefined);', + " }", + "}", + "export class UnionProblem extends Problem {", + " constructor(originalError: Error | string) {", + ' super("alpha/union", ProblemCategory.InternalServerError, "failed", { cause: originalError instanceof Error ? originalError : undefined });', + " }", + "}", + "export class NormalizedAliasProblem extends Problem {", + " constructor(error: unknown) {", + " const causeError = error instanceof Error ? error : new Error(String(error));", + ' super("alpha/normalized", ProblemCategory.InternalServerError, "failed", { cause: causeError });', + " }", + "}", + "export class OptionsAliasProblem extends Problem {", + " constructor(cause?: Error) {", + " const options = cause ? ({ cause } satisfies ProblemOptions) : undefined;", + ' super("alpha/options", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class SpreadProblem extends Problem {", + " constructor(cause: Error) {", + " const options = { cause };", + ' super("alpha/spread", ProblemCategory.InternalServerError, "failed", { ...options });', + " }", + "}", + "export class OptionalCauseProblem extends Problem {", + " constructor(cause?: Error) {", + ' super("alpha/optional-cause", ProblemCategory.InternalServerError, "failed", cause !== undefined ? { cause } : undefined);', + " }", + "}", + "export class ReboundAliasProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " let alias = options;", + " alias = {};", + " alias.cause = undefined;", + ' super("alpha/rebound-alias", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + ].join("\n"), + ); + + expect(runProblemRegistryCheck(repo, "write").status).toBe("pass"); + }, 30_000); + + it("rejects cause forwarding hidden by overloads, overwritten options, unrelated branches, and mutable aliases", () => { + const repo = createTempRepo(); + writeFile( + repo, + "packages/alpha/src/problems.ts", + [ + 'import { Problem, ProblemCategory } from "@croco/problems-core";', + 'import type { ProblemOptions } from "@croco/problems-core";', + "export class OverloadedProblem extends Problem {", + " constructor(cause: Error);", + " constructor(cause: Error) {", + ' super("alpha/overloaded", ProblemCategory.InternalServerError, cause.message);', + " }", + "}", + "export class OverwrittenSpreadProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/overwritten", ProblemCategory.InternalServerError, "failed", { cause, ...{ cause: undefined } });', + " }", + "}", + "export class FalseConditionalProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/false-branch", ProblemCategory.InternalServerError, "failed", false ? { cause } : undefined);', + " }", + "}", + "export class UnrelatedConditionalProblem extends Problem {", + " constructor(cause: Error, flag: boolean) {", + ' super("alpha/unrelated-branch", ProblemCategory.InternalServerError, "failed", flag ? { cause } : undefined);', + " }", + "}", + "export class ReassignedAliasProblem extends Problem {", + " constructor(cause: Error) {", + " let options: ProblemOptions = { cause };", + " options = {};", + ' super("alpha/reassigned", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "function toError(_error: unknown): Error { return new Error('unrelated'); }", + "export class BogusHelperProblem extends Problem {", + " constructor(error: unknown) {", + ' super("alpha/bogus-helper", ProblemCategory.InternalServerError, "failed", { cause: toError(error) });', + " }", + "}", + "export class ConjunctiveGuardProblem extends Problem {", + " constructor(error: unknown, flag: boolean) {", + ' super("alpha/conjunctive-guard", ProblemCategory.InternalServerError, "failed", flag && error instanceof Error ? { cause: error } : undefined);', + " }", + "}", + "export class MutatedConstOptionsProblem extends Problem {", + " constructor(cause: Error) {", + " const options: ProblemOptions = { cause };", + " options['cause'] = undefined;", + ' super("alpha/mutated-const-options", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "function extras(options: { cause?: Error }) { return options; }", + "export class HelperSpreadProblem extends Problem {", + " constructor(cause: Error) {", + " const options = { extensions: {} };", + ' super("alpha/helper-spread", ProblemCategory.InternalServerError, "failed", { cause, ...extras({ cause: undefined }) });', + " }", + "}", + "export class DeletedCauseProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " delete options.cause;", + ' super("alpha/deleted-cause", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class MutatedSecondAliasProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " const alias = options;", + " alias.cause = undefined;", + ' super("alpha/mutated-second-alias", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class MutatedLetAliasProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " let alias = options;", + " delete alias.cause;", + ' super("alpha/mutated-let-alias", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class MutatedBeforeRebindProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " let alias = options;", + " delete alias.cause;", + " alias = {};", + ' super("alpha/mutated-before-rebind", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class ConditionalRebindDeleteProblem extends Problem {", + " constructor(cause: Error, flag: boolean) {", + " const options: { cause?: Error } = { cause };", + " let alias = options;", + " if (flag) alias = {};", + " delete alias.cause;", + ' super("alpha/conditional-rebind-delete", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class MutatedParenthesizedAliasProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " const alias = (options as { cause?: Error });", + " alias.cause = undefined;", + ' super("alpha/mutated-parenthesized-alias", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class MutatedAssertedTargetProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " (options as { cause?: Error }).cause = undefined;", + ' super("alpha/mutated-asserted-target", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class DeletedAssertedTargetProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " delete (options as { cause?: Error }).cause;", + ' super("alpha/deleted-asserted-target", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class MutatedNonNullAliasProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " const alias = options!;", + " alias.cause = undefined;", + ' super("alpha/mutated-non-null-alias", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class LogicalRebindProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " let alias = options;", + " alias ??= {};", + " delete alias.cause;", + ' super("alpha/logical-rebind", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class LogicalOrRebindProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " let alias = options;", + " alias ||= {};", + " delete alias.cause;", + ' super("alpha/logical-or-rebind", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class ClosureRebindProblem extends Problem {", + " constructor(cause: Error) {", + " const options: { cause?: Error } = { cause };", + " let alias = options;", + " const reset = () => { alias = {}; };", + " delete alias.cause;", + ' super("alpha/closure-rebind", ProblemCategory.InternalServerError, "failed", options);', + " }", + "}", + "export class ComputedCauseProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/computed-cause", ProblemCategory.InternalServerError, "failed", { cause, ["cause"]: undefined });', + " }", + "}", + "export class ComputedSpreadProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/computed-spread", ProblemCategory.InternalServerError, "failed", { cause, ...{ ["cause"]: undefined } });', + " }", + "}", + "export class DynamicComputedCauseProblem extends Problem {", + " constructor(cause: Error) {", + ' const key = "cause";', + ' super("alpha/dynamic-computed-cause", ProblemCategory.InternalServerError, "failed", { cause, [key]: undefined });', + " }", + "}", + "export class GetterCauseProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/getter-cause", ProblemCategory.InternalServerError, "failed", { ...{ cause }, get cause() { return undefined; } });', + " }", + "}", + "export class MethodCauseProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/method-cause", ProblemCategory.InternalServerError, "failed", { ...{ cause }, cause() {} });', + " }", + "}", + ].join("\n"), + ); + + const result = runProblemRegistryCheck(repo, "write"); + + expect(result.status).toBe("fail"); + for (const className of [ + "OverloadedProblem", + "OverwrittenSpreadProblem", + "FalseConditionalProblem", + "UnrelatedConditionalProblem", + "ReassignedAliasProblem", + "BogusHelperProblem", + "ConjunctiveGuardProblem", + "MutatedConstOptionsProblem", + "HelperSpreadProblem", + "DeletedCauseProblem", + "MutatedSecondAliasProblem", + "MutatedLetAliasProblem", + "MutatedBeforeRebindProblem", + "ConditionalRebindDeleteProblem", + "MutatedParenthesizedAliasProblem", + "MutatedAssertedTargetProblem", + "DeletedAssertedTargetProblem", + "MutatedNonNullAliasProblem", + "LogicalRebindProblem", + "LogicalOrRebindProblem", + "ClosureRebindProblem", + "ComputedCauseProblem", + "ComputedSpreadProblem", + "DynamicComputedCauseProblem", + "GetterCauseProblem", + "MethodCauseProblem", + ]) { + expect(result.diagnostics).toContainEqual( + expect.stringContaining(`${className} must forward`), + ); + } + }, 30_000); + + it("accepts a cause property that overrides an earlier spread", () => { + const repo = createTempRepo(); + writeFile( + repo, + "packages/alpha/src/problems.ts", + [ + 'import { Problem, ProblemCategory } from "@croco/problems-core";', + "export class FinalCauseProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/final-cause", ProblemCategory.InternalServerError, "failed", { ...{ cause: undefined }, cause });', + " }", + "}", + "export class FinalComputedCauseProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/final-computed-cause", ProblemCategory.InternalServerError, "failed", { ...{ cause: undefined }, ["cause"]: cause });', + " }", + "}", + ].join("\n"), + ); + + expect(runProblemRegistryCheck(repo, "write").status).toBe("pass"); + }, 30_000); + + it("accepts an owned, reasoned, unexpired cause exception", () => { + const repo = createTempRepo(); + writeCauseViolation(repo); + writeCauseAllowlist(repo, [causeAllowlistEntry()]); + + expect(runProblemRegistryCheck(repo, "write").status).toBe("pass"); + }, 30_000); + + it.each(["owner", "reason", "expiresOn"])( + "rejects a cause exception without %s", + (field) => { + const repo = createTempRepo(); + writeCauseViolation(repo); + const entry = causeAllowlistEntry(); + delete entry[field]; + writeCauseAllowlist(repo, [entry]); + + const result = runProblemRegistryCheck(repo, "write"); + + expect(result.status).toBe("fail"); + expect(result.diagnostics).toContainEqual( + expect.stringContaining("scripts/problem-cause-allowlist.json"), + ); + }, + 30_000, + ); + + it("rejects an expired cause exception", () => { + const repo = createTempRepo(); + writeCauseViolation(repo); + writeCauseAllowlist(repo, [causeAllowlistEntry({ expiresOn: "2000-01-01" })]); + + const result = runProblemRegistryCheck(repo, "write"); + + expect(result.status).toBe("fail"); + expect(result.diagnostics).toContainEqual( + expect.stringContaining("scripts/problem-cause-allowlist.json"), + ); + }, 30_000); + + it("rejects an unused cause exception", () => { + const repo = createTempRepo(); + writeFile(repo, "packages/alpha/src/problems.ts", ""); + writeCauseAllowlist(repo, [causeAllowlistEntry()]); + + const result = runProblemRegistryCheck(repo, "write"); + + expect(result.status).toBe("fail"); + expect(result.diagnostics).toContainEqual(expect.stringContaining("unused exception")); + }, 30_000); + + it("rejects a cause exception added without increasing the baseline count", () => { + const repo = createTempRepo(); + writeCauseViolation(repo); + writeCauseAllowlist(repo, [causeAllowlistEntry()], 0); + + const result = runProblemRegistryCheck(repo, "write"); + + expect(result.status).toBe("fail"); + expect(result.diagnostics).toContainEqual(expect.stringContaining("baselineEntryCount")); + }, 30_000); + it("discovers Problem codes and writes deterministic registry and cookbook artifacts", () => { const repo = createTempRepo(); writeFile( @@ -1423,6 +1833,46 @@ function writeFile(repo: string, path: string, content: string): void { writeFileSync(absolutePath, content); } +function writeCauseViolation(repo: string): void { + writeFile( + repo, + "packages/alpha/src/problems.ts", + [ + 'import { Problem, ProblemCategory } from "@croco/problems-core";', + "export class AlphaProblem extends Problem {", + " constructor(cause: Error) {", + ' super("alpha/failed", ProblemCategory.InternalServerError, cause.message);', + " }", + "}", + ].join("\n"), + ); +} + +function causeAllowlistEntry(overrides: Record = {}): Record { + return { + code: "problem-cause-not-forwarded", + file: "packages/alpha/src/problems.ts", + className: "AlphaProblem", + parameter: "cause", + owner: "alpha-maintainers", + reason: "The cause must remain detached for this fixture.", + expiresOn: "2999-12-31", + ...overrides, + }; +} + +function writeCauseAllowlist( + repo: string, + entries: readonly Record[], + baselineEntryCount = entries.length, +): void { + writeFile( + repo, + "scripts/problem-cause-allowlist.json", + `${JSON.stringify({ schemaVersion: 1, baselineEntryCount, entries }, null, 2)}\n`, + ); +} + function writeProblemFactories(repo: string, path: string, codes: readonly string[]): void { writeFile( repo,