From fea09a9c72ef76001baffa5082633ebacabe1d9e Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:37:31 -0700 Subject: [PATCH 01/10] chore: add anti-slop lint rules, dead-code checks and stricter Biome constraints (CMP-80) (#145) --- .agents/skills/install-anti-slop/SKILL.md | 70 +++ .../assets/anti-slop/index.ts | 31 ++ .../rules/no-chained-type-assertions.ts | 77 +++ .../no-conditional-empty-object-spread.ts | 49 ++ .../rules/no-known-value-widening.ts | 247 ++++++++++ .../anti-slop/rules/no-object-parameters.ts | 112 +++++ .../anti-slop/rules/no-runtime-typeof.ts | 25 + .../rules/no-shape-in-symbol-names.ts | 39 ++ .../anti-slop/rules/no-unknown-parameters.ts | 83 ++++ .../rules/no-unknown-type-aliases.ts | 69 +++ .../rules/no-unsafe-dictionary-type.ts | 94 ++++ .../anti-slop/rules/no-widen-then-assert.ts | 363 ++++++++++++++ .../anti-slop/shared/dictionary-types.ts | 448 ++++++++++++++++++ .../install-anti-slop/scripts/install.mjs | 21 + .gitignore | 3 + .oxlintrc.json | 31 ++ biome.jsonc | 27 +- bun.lock | 169 ++++++- knip.json | 41 ++ package.json | 5 + skills-lock.json | 6 + tools/oxlint/anti-slop/index.ts | 31 ++ .../rules/no-chained-type-assertions.ts | 77 +++ .../no-conditional-empty-object-spread.ts | 49 ++ .../rules/no-known-value-widening.ts | 247 ++++++++++ .../anti-slop/rules/no-object-parameters.ts | 112 +++++ .../anti-slop/rules/no-runtime-typeof.ts | 25 + .../rules/no-shape-in-symbol-names.ts | 39 ++ .../anti-slop/rules/no-unknown-parameters.ts | 83 ++++ .../rules/no-unknown-type-aliases.ts | 69 +++ .../rules/no-unsafe-dictionary-type.ts | 94 ++++ .../anti-slop/rules/no-widen-then-assert.ts | 363 ++++++++++++++ .../anti-slop/shared/dictionary-types.ts | 448 ++++++++++++++++++ 33 files changed, 3640 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/install-anti-slop/SKILL.md create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/index.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-chained-type-assertions.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-conditional-empty-object-spread.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-known-value-widening.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-object-parameters.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-runtime-typeof.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-shape-in-symbol-names.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-parameters.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-type-aliases.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-unsafe-dictionary-type.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/rules/no-widen-then-assert.ts create mode 100644 .agents/skills/install-anti-slop/assets/anti-slop/shared/dictionary-types.ts create mode 100644 .agents/skills/install-anti-slop/scripts/install.mjs create mode 100644 .oxlintrc.json create mode 100644 knip.json create mode 100644 tools/oxlint/anti-slop/index.ts create mode 100644 tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts create mode 100644 tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts create mode 100644 tools/oxlint/anti-slop/rules/no-known-value-widening.ts create mode 100644 tools/oxlint/anti-slop/rules/no-object-parameters.ts create mode 100644 tools/oxlint/anti-slop/rules/no-runtime-typeof.ts create mode 100644 tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unknown-parameters.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts create mode 100644 tools/oxlint/anti-slop/rules/no-widen-then-assert.ts create mode 100644 tools/oxlint/anti-slop/shared/dictionary-types.ts diff --git a/.agents/skills/install-anti-slop/SKILL.md b/.agents/skills/install-anti-slop/SKILL.md new file mode 100644 index 000000000..e365b4d94 --- /dev/null +++ b/.agents/skills/install-anti-slop/SKILL.md @@ -0,0 +1,70 @@ +--- +name: install-anti-slop +description: Install and configure the anti-slop Oxlint plugin in a local TypeScript or JavaScript repository. Use whenever a user asks to add anti-slop lint rules, copy the anti-slop plugin, configure opinionated Oxlint rules, or migrate an existing local anti-slop setup. +--- + +# Install anti-slop + +Install the bundled Oxlint plugin into the current repository and integrate it with the repository's existing lint setup. Preserve unrelated work and adapt to the project's package manager and configuration style. + +## Procedure + +1. Inspect the repository before changing it: + - Read its agent instructions. + - Check `git status` and preserve unrelated changes. + - Identify the package manager from `packageManager` and lockfiles. + - Find Oxlint configuration (`oxlint.config.*`, `.oxlintrc*`, or a Vite+ config). + - Check whether anti-slop files or rules already exist. Do not overwrite them without reviewing the diff. + +2. Copy the bundled plugin from this skill. Run from the target repository: + + ```bash + node /scripts/install.mjs + ``` + + This creates `tools/oxlint/anti-slop/`. Pass another relative destination as the first argument when the repository has an established tooling layout. The script refuses to replace an existing destination; only use `--force` after backing up and reviewing existing files. + +3. Install current compatible dependencies rather than trusting versions remembered by the agent: + - Query `npm view oxlint version` and `npm view @oxlint/plugins version`. + - Install the same current version of both packages with the repository's package manager. + - `oxlint` is a development dependency. The copied source imports `@oxlint/plugins`, so install it as a development dependency for a local-only plugin. + - Do not replace the package manager or rewrite unrelated dependency ranges. + +4. Register the plugin and enable all rules. For `oxlint.config.ts` or `.oxlintrc.json`, add: + + ```ts + jsPlugins: [ + { name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" }, + ], + ``` + + For Vite+, add that same entry to `lint.jsPlugins`. Merge it with existing entries instead of replacing them. + + Enable these rules at `"error"`: + + ```json + { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error" + } + ``` + +5. Run the repository's lint command and typecheck. If findings appear, report them and fix them only when the user asked for migration/cleanup. Do not suppress rules, weaken rule severity, add unsafe casts, or mechanically launder types to make lint pass. + +6. Review the final diff and clearly report: + - copied path, + - dependency versions installed, + - configuration changed, + - checks run and any remaining findings. + +## Migration guidance + +When replacing an older local copy, compare its rules and diagnostics before overwriting. Keep project-specific rules in their own plugin; anti-slop is intentionally generic. Prefer inference, `as const`, `satisfies`, named owner contracts, and boundary parsing when resolving findings. diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/index.ts b/.agents/skills/install-anti-slop/assets/anti-slop/index.ts new file mode 100644 index 000000000..0a4c10f82 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/index.ts @@ -0,0 +1,31 @@ +import { definePlugin } from "@oxlint/plugins"; + +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = definePlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-object-parameters": noObjectParametersRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + }, +}); + +export default antiSlopPlugin; diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-chained-type-assertions.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 000000000..beb661dfe --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "Chained type assertions discard existing type evidence and fabricate the target type without parsing. Preserve the value's original precise type, or parse genuinely unknown input at its boundary before using it.", + }, + }, + create(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-conditional-empty-object-spread.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 000000000..d1a14495d --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "Do not use conditional empty-object spreads. Prefer a direct property or build the object in separate statements.", + }, + }, + create(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-known-value-widening.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 000000000..c71e119fa --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,247 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The known initializer supplying {{subject}} carries established type evidence, but the explicit {{target}} target type discards it. Preserve inference, use `satisfies`, or introduce/use a named owner contract; parse genuinely external data once at its boundary.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-object-parameters.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 000000000..729750364 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,112 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceCode: SourceCode): string { + return parameter.type === "Identifier" + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); +} + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` accepts the broad `object` type. Use the expected owner type or decode the external input at its boundary.", + }, + }, + create(context) { + const aliases = new Map(); + + const resolvesToObject = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSObjectKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToObject(type.typeAnnotation, visited); + if (type.type === "TSUnionType") { + return type.types.some((member) => resolvesToObject(member, visited)); + } + if ( + type.type !== "TSTypeReference" || + type.typeName.type !== "Identifier" || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.has(type.typeName.name) + ) { + return false; + } + const alias = aliases.get(type.typeName.name); + if (alias === undefined) return false; + const nextVisited = new Set(visited); + nextVisited.add(type.typeName.name); + return resolvesToObject(alias, nextVisited); + }; + + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if ( + declaration?.type === "TSTypeAliasDeclaration" && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-runtime-typeof.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 000000000..858becf24 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,25 @@ +import { defineRule } from "@oxlint/plugins"; + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", + }, + messages: { + runtimeTypeof: + "A runtime `typeof` check only narrows an unparsed representation; it does not establish the expected contract. Parse the value into a strongly typed domain type at the earliest possible point, as close as possible to the I/O boundary where the data originated.", + }, + }, + create(context) { + return { + UnaryExpression(node) { + if (node.operator === "typeof") { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-shape-in-symbol-names.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 000000000..3c18a75f0 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Do not use the case-insensitive substring "shape" in symbol names (found "{{name}}").', + }, + }, + create(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-parameters.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 000000000..8b7588bc0 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceText: string): string { + if (parameter.type === "TSParameterProperty") { + return parameterName(parameter.parameter, sourceText); + } + if (parameter.type === "AssignmentPattern") { + return parameterName(parameter.left, sourceText); + } + if (parameter.type === "RestElement") { + return parameterName(parameter.argument, sourceText); + } + return parameter.type === "Identifier" + ? parameter.name + : sourceText.replace(/\s*:\s*unknown\s*$/u, ""); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.", + }, + messages: { + unknownParameter: + "Parameter `{{parameter}}` accepts `unknown` without establishing its contract. Define the expected schema or parser so the value becomes a strongly typed domain type at the earliest possible point, as close as possible to the I/O boundary where the data originated.", + }, + }, + create(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue; + const name = parameterName(parameter, context.sourceCode.getText(parameter)); + if (name === "cause") continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "unknownParameter", + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-type-aliases.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 000000000..c7c035d21 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,69 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` only renames `unknown`. Keep `unknown` explicit on an allowed `cause` field or replace it with the parsed owner type.", + }, + }, + create(context) { + const aliases = new Map(); + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToUnknown(type.typeAnnotation, visited); + const name = referencedAliasName(type); + if (name === null || visited.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, nextVisited); + }; + + return { + Program(node) { + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue; + context.report({ + node: alias.id, + messageId: "unknownAlias", + data: { alias: alias.id.name }, + }); + } + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unsafe-dictionary-type.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 000000000..afc0eb8d3 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,94 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return node.type.startsWith("TS") && node.type !== "TSTypeAnnotation"; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node); +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This object dictionary's direct value type is an unsafe {{value}} escape hatch. Replace it with a concrete owner/schema-derived value type and parse external data at its boundary.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-widen-then-assert.ts b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 000000000..876a2c226 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,363 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" erases established type evidence by widening the value, then reconstructs that evidence with a type assertion. Preserve the precise type end-to-end; if the input is genuinely unknown, parse it once at the boundary instead.', + }, + }, + create(context) { + const scopes = context.sourceCode.scopeManager.scopes; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/.agents/skills/install-anti-slop/assets/anti-slop/shared/dictionary-types.ts b/.agents/skills/install-anti-slop/assets/anti-slop/shared/dictionary-types.ts new file mode 100644 index 000000000..0533e6289 --- /dev/null +++ b/.agents/skills/install-anti-slop/assets/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,448 @@ +import type { ESTree } from "@oxlint/plugins"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map(); + const interfaces = new Map(); + const shadowedBuiltIns = new Set(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type === "ImportDeclaration") { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); + } + continue; + } + + if (declaration?.type === "TSTypeAliasDeclaration") { + const existing = aliases.get(declaration.id.name); + if (existing === undefined) aliases.set(declaration.id.name, declaration); + else shadowedBuiltIns.add(declaration.id.name); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSInterfaceDeclaration") { + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSEnumDeclaration") { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if ( + (declaration?.type === "ClassDeclaration" || + declaration?.type === "FunctionDeclaration") && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + } + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" }; + const alias = environment.aliases.get(name); + if (alias === undefined) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: "generic container" } + : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases); + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/.agents/skills/install-anti-slop/scripts/install.mjs b/.agents/skills/install-anti-slop/scripts/install.mjs new file mode 100644 index 000000000..6a66663fd --- /dev/null +++ b/.agents/skills/install-anti-slop/scripts/install.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { cpSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const source = resolve(skillRoot, "assets/anti-slop"); +const arguments_ = process.argv.slice(2); +const targetArgument = arguments_.find((argument) => !argument.startsWith("--")); +const target = resolve(process.cwd(), targetArgument ?? "tools/oxlint/anti-slop"); +const force = arguments_.includes("--force"); + +if (existsSync(target) && !force) { + console.error(`Refusing to overwrite ${target}. Re-run with --force only after reviewing the existing files.`); + process.exit(1); +} + +mkdirSync(dirname(target), { recursive: true }); +cpSync(source, target, { recursive: true, force }); +console.log(`Copied the anti-slop plugin to ${target}`); +console.log(`Configure Oxlint with: ${target}/index.ts`); diff --git a/.gitignore b/.gitignore index 4bef1b571..06b9c6813 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ yarn-error.log* # Agent worktrees .claude/worktrees/ +# Skills are installed to .agents/skills; .claude/skills mirrors them per-tool +.claude/skills/ + # eve build output .eve .output diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..767eedbf6 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [], + "categories": { + "correctness": "off" + }, + "jsPlugins": ["./tools/oxlint/anti-slop/index.ts"], + "ignorePatterns": [ + "**/node_modules/**", + "**/dist/**", + "**/.next/**", + "**/.eve/**", + "**/.scratch/**", + "apps/api/src/generated/**", + "packages/db/src/generated/**", + "packages/ui/src/components/**", + "tools/oxlint/anti-slop/**" + ], + "rules": { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error" + } +} diff --git a/biome.jsonc b/biome.jsonc index 5c892fb5a..f93ad2386 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -17,6 +17,7 @@ "!**/*.svg", "!.claude", "!.agents", + "!tools/oxlint/anti-slop", "!skills-lock.json" ] }, @@ -27,7 +28,21 @@ "linter": { "enabled": true, "rules": { - "preset": "recommended" + "preset": "recommended", + "complexity": { + "noExcessiveLinesPerFunction": { + "level": "error", + "options": { "maxLines": 620, "skipBlankLines": true } + }, + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 62 } + } + }, + "performance": { + "noBarrelFile": "warn", + "noReExportAll": "warn" + } } }, "css": { @@ -70,6 +85,16 @@ } } } + }, + { + "includes": ["apps/api/src/**"], + "linter": { + "rules": { + "suspicious": { + "noConsole": "error" + } + } + } } ] } diff --git a/bun.lock b/bun.lock index 6af442d4e..83eb5885b 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,9 @@ "name": "crm", "devDependencies": { "@biomejs/biome": "^2.4.10", + "@oxlint/plugins": "1.78.0", + "knip": "6.32.2", + "oxlint": "1.78.0", "turbo": "^2.10.8", "typescript": "5.9.2", }, @@ -420,11 +423,11 @@ "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.3.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" } }, "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg=="], - "@emnapi/core": ["@emnapi/core@2.0.0-alpha.3", "", { "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g=="], + "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], @@ -578,7 +581,123 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.143.0", "", { "os": "android", "cpu": "arm" }, "sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.143.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.143.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.143.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.143.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ=="], + + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.143.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg=="], + + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.143.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ=="], + + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.143.0", "", { "os": "none", "cpu": "arm64" }, "sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.143.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.143.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.143.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw=="], + + "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], + + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw=="], + + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA=="], + + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw=="], + + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ=="], + + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg=="], + + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg=="], + + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.24.2", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg=="], + + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], + + "@oxlint/plugins": ["@oxlint/plugins@1.78.0", "", {}, "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], @@ -1462,6 +1581,8 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="], @@ -1484,6 +1605,8 @@ "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -1520,6 +1643,8 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="], + "giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="], "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], @@ -1692,6 +1817,8 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "knip": ["knip@6.32.2", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.1", "jiti": "^2.7.0", "oxc-parser": "^0.143.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.5", "smol-toml": "^1.7.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.9", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg=="], + "kysely": ["kysely@0.29.4", "", {}, "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -1956,6 +2083,12 @@ "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], + "oxc-parser": ["oxc-parser@0.143.0", "", { "dependencies": { "@oxc-project/types": "^0.143.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.143.0", "@oxc-parser/binding-android-arm64": "0.143.0", "@oxc-parser/binding-darwin-arm64": "0.143.0", "@oxc-parser/binding-darwin-x64": "0.143.0", "@oxc-parser/binding-freebsd-x64": "0.143.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.143.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.143.0", "@oxc-parser/binding-linux-arm64-gnu": "0.143.0", "@oxc-parser/binding-linux-arm64-musl": "0.143.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-musl": "0.143.0", "@oxc-parser/binding-linux-s390x-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-musl": "0.143.0", "@oxc-parser/binding-openharmony-arm64": "0.143.0", "@oxc-parser/binding-win32-arm64-msvc": "0.143.0", "@oxc-parser/binding-win32-ia32-msvc": "0.143.0", "@oxc-parser/binding-win32-x64-msvc": "0.143.0" } }, "sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA=="], + + "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], + + "oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], @@ -2012,7 +2145,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], @@ -2156,6 +2289,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], @@ -2278,7 +2413,7 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], @@ -2316,6 +2451,8 @@ "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], @@ -2358,6 +2495,8 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="], + "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -2414,6 +2553,8 @@ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], @@ -2526,12 +2667,16 @@ "@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "@dotenvx/dotenvx/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "@dotenvx/dotenvx/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "@dotenvx/dotenvx/yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], "@mermaid-js/parser/@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], @@ -2692,6 +2837,8 @@ "@reduxjs/toolkit/reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@2.0.0-alpha.3", "", { "dependencies": { "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g=="], + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@2.0.0-alpha.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -2812,8 +2959,12 @@ "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.142.0", "", {}, "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ=="], + "seek-bzip/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -2862,10 +3013,16 @@ "@dotenvx/dotenvx/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@prisma/studio-core/@radix-ui/react-toggle/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ=="], + "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], diff --git a/knip.json b/knip.json new file mode 100644 index 000000000..1d653dc63 --- /dev/null +++ b/knip.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "workspaces": { + ".": { + "project": ["tools/**/*.ts"] + }, + "apps/api": { + "entry": ["api/index.ts", "scripts/*.ts", "test/**/*.spec.ts"], + "project": ["src/**/*.ts", "scripts/**/*.ts"] + }, + "apps/agent": { + "entry": [ + "agent/**/*.ts", + "scripts/*.ts", + "evals/**/*.eval.ts", + "test/**/*.spec.ts" + ], + "project": ["agent/**/*.ts", "scripts/**/*.ts"] + }, + "apps/app": { + "entry": [ + "app/**/{page,layout,route,error,not-found,loading,template,default}.{ts,tsx}", + "test/**/*.spec.{ts,tsx}" + ], + "project": ["**/*.{ts,tsx}"] + }, + "packages/*": { + "entry": ["src/*.ts"], + "project": ["src/**/*.{ts,tsx}"] + }, + "packages/db": { + "entry": ["src/*.ts"], + "project": ["src/**/*.ts"] + }, + "packages/ui": { + "entry": ["src/components/**/*.tsx", "src/lib/*.ts", "src/hooks/*.ts"], + "project": ["src/**/*.{ts,tsx}"] + } + }, + "ignore": ["**/.eve/**", "**/.scratch/**", "**/generated/**"] +} diff --git a/package.json b/package.json index 263e7172f..8d11b4d29 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", + "lint:slop": "oxlint", + "lint:dead": "knip --no-progress", "format": "biome format --write .", "check-types": "turbo run check-types", "test": "turbo run test --concurrency=1", @@ -23,6 +25,9 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@oxlint/plugins": "1.78.0", + "knip": "6.32.2", + "oxlint": "1.78.0", "turbo": "^2.10.8", "typescript": "5.9.2" }, diff --git a/skills-lock.json b/skills-lock.json index dd1dea09c..7a4093828 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -67,6 +67,12 @@ "skillPath": "skills/eve/SKILL.md", "computedHash": "4833349453c45c606f79202e495b7bbc42ea380f329f3beee36b9927763f19ef" }, + "install-anti-slop": { + "source": "dmmulroy/anti-slop", + "sourceType": "github", + "skillPath": "skills/install-anti-slop/SKILL.md", + "computedHash": "b9c3852b1a4895ca33f92dd5015502bb75e36848e66f8767e125d37ea02e4b58" + }, "nestjs-best-practices": { "source": "kadajett/agent-nestjs-skills", "sourceType": "github", diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 000000000..0a4c10f82 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,31 @@ +import { definePlugin } from "@oxlint/plugins"; + +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = definePlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-object-parameters": noObjectParametersRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + }, +}); + +export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 000000000..beb661dfe --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "Chained type assertions discard existing type evidence and fabricate the target type without parsing. Preserve the value's original precise type, or parse genuinely unknown input at its boundary before using it.", + }, + }, + create(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 000000000..d1a14495d --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "Do not use conditional empty-object spreads. Prefer a direct property or build the object in separate statements.", + }, + }, + create(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 000000000..c71e119fa --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,247 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The known initializer supplying {{subject}} carries established type evidence, but the explicit {{target}} target type discards it. Preserve inference, use `satisfies`, or introduce/use a named owner contract; parse genuinely external data once at its boundary.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 000000000..729750364 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,112 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceCode: SourceCode): string { + return parameter.type === "Identifier" + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); +} + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` accepts the broad `object` type. Use the expected owner type or decode the external input at its boundary.", + }, + }, + create(context) { + const aliases = new Map(); + + const resolvesToObject = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSObjectKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToObject(type.typeAnnotation, visited); + if (type.type === "TSUnionType") { + return type.types.some((member) => resolvesToObject(member, visited)); + } + if ( + type.type !== "TSTypeReference" || + type.typeName.type !== "Identifier" || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.has(type.typeName.name) + ) { + return false; + } + const alias = aliases.get(type.typeName.name); + if (alias === undefined) return false; + const nextVisited = new Set(visited); + nextVisited.add(type.typeName.name); + return resolvesToObject(alias, nextVisited); + }; + + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if ( + declaration?.type === "TSTypeAliasDeclaration" && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 000000000..858becf24 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,25 @@ +import { defineRule } from "@oxlint/plugins"; + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", + }, + messages: { + runtimeTypeof: + "A runtime `typeof` check only narrows an unparsed representation; it does not establish the expected contract. Parse the value into a strongly typed domain type at the earliest possible point, as close as possible to the I/O boundary where the data originated.", + }, + }, + create(context) { + return { + UnaryExpression(node) { + if (node.operator === "typeof") { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 000000000..3c18a75f0 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Do not use the case-insensitive substring "shape" in symbol names (found "{{name}}").', + }, + }, + create(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 000000000..8b7588bc0 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceText: string): string { + if (parameter.type === "TSParameterProperty") { + return parameterName(parameter.parameter, sourceText); + } + if (parameter.type === "AssignmentPattern") { + return parameterName(parameter.left, sourceText); + } + if (parameter.type === "RestElement") { + return parameterName(parameter.argument, sourceText); + } + return parameter.type === "Identifier" + ? parameter.name + : sourceText.replace(/\s*:\s*unknown\s*$/u, ""); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.", + }, + messages: { + unknownParameter: + "Parameter `{{parameter}}` accepts `unknown` without establishing its contract. Define the expected schema or parser so the value becomes a strongly typed domain type at the earliest possible point, as close as possible to the I/O boundary where the data originated.", + }, + }, + create(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue; + const name = parameterName(parameter, context.sourceCode.getText(parameter)); + if (name === "cause") continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "unknownParameter", + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 000000000..c7c035d21 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,69 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` only renames `unknown`. Keep `unknown` explicit on an allowed `cause` field or replace it with the parsed owner type.", + }, + }, + create(context) { + const aliases = new Map(); + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToUnknown(type.typeAnnotation, visited); + const name = referencedAliasName(type); + if (name === null || visited.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, nextVisited); + }; + + return { + Program(node) { + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue; + context.report({ + node: alias.id, + messageId: "unknownAlias", + data: { alias: alias.id.name }, + }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 000000000..afc0eb8d3 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,94 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return node.type.startsWith("TS") && node.type !== "TSTypeAnnotation"; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node); +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This object dictionary's direct value type is an unsafe {{value}} escape hatch. Replace it with a concrete owner/schema-derived value type and parse external data at its boundary.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 000000000..876a2c226 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,363 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" erases established type evidence by widening the value, then reconstructs that evidence with a type assertion. Preserve the precise type end-to-end; if the input is genuinely unknown, parse it once at the boundary instead.', + }, + }, + create(context) { + const scopes = context.sourceCode.scopeManager.scopes; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 000000000..0533e6289 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,448 @@ +import type { ESTree } from "@oxlint/plugins"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map(); + const interfaces = new Map(); + const shadowedBuiltIns = new Set(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type === "ImportDeclaration") { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); + } + continue; + } + + if (declaration?.type === "TSTypeAliasDeclaration") { + const existing = aliases.get(declaration.id.name); + if (existing === undefined) aliases.set(declaration.id.name, declaration); + else shadowedBuiltIns.add(declaration.id.name); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSInterfaceDeclaration") { + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSEnumDeclaration") { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if ( + (declaration?.type === "ClassDeclaration" || + declaration?.type === "FunctionDeclaration") && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + } + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" }; + const alias = environment.aliases.get(name); + if (alias === undefined) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: "generic container" } + : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases); + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} From bfd4dadfd1df44566676902a2477bfa112ca1413 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:58:32 -0700 Subject: [PATCH 02/10] refactor: clear anti-slop type assertions and conditional object spreads (CMP-81) (#146) --- .oxlintrc.json | 19 ++++++++++-- apps/agent/agent/hooks/audit.ts | 29 +++++++++---------- apps/agent/agent/lib/facts.ts | 6 ++-- apps/agent/agent/lib/lookup.ts | 23 +++++++-------- apps/api/src/agent/agent-trigger.service.ts | 29 +++++++++---------- .../conversations/conversations.service.ts | 17 ++++++----- packages/db/src/client.ts | 10 +++---- 7 files changed, 71 insertions(+), 62 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 767eedbf6..ca8b61ea0 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,10 +22,25 @@ "anti-slop/no-known-value-widening": "error", "anti-slop/no-object-parameters": "error", "anti-slop/no-runtime-typeof": "error", - "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-shape-in-symbol-names": "off", "anti-slop/no-unknown-parameters": "error", "anti-slop/no-unknown-type-aliases": "error", "anti-slop/no-unsafe-dictionary-type": "error", "anti-slop/no-widen-then-assert": "error" - } + }, + "overrides": [ + { + "files": [ + "**/test/**", + "**/tests/**", + "**/evals/**", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.eval.ts" + ], + "rules": { + "anti-slop/no-chained-type-assertions": "off" + } + } + ] } diff --git a/apps/agent/agent/hooks/audit.ts b/apps/agent/agent/hooks/audit.ts index b553cc188..1cb42a0e6 100644 --- a/apps/agent/agent/hooks/audit.ts +++ b/apps/agent/agent/hooks/audit.ts @@ -116,13 +116,9 @@ async function persistRunEvent( where: { id: run.id }, data: { nextEventSequence: sequence, - ...(mayStart - ? { - sessionId: ctx.session.id, - status: "RUNNING", - startedAt: run.startedAt ?? new Date(), - } - : {}), + sessionId: mayStart ? ctx.session.id : undefined, + status: mayStart ? "RUNNING" : undefined, + startedAt: mayStart ? (run.startedAt ?? new Date()) : undefined, }, }); @@ -150,15 +146,16 @@ async function persistRunEvent( await tx.agentRun.update({ where: { id: runId }, data: { - ...(inputTokens !== null - ? { inputTokens: (current.inputTokens ?? 0) + inputTokens } - : {}), - ...(outputTokens !== null - ? { outputTokens: (current.outputTokens ?? 0) + outputTokens } - : {}), - ...(costUsd !== null - ? { costUsd: Number(current.costUsd ?? 0) + costUsd } - : {}), + inputTokens: + inputTokens === null + ? undefined + : (current.inputTokens ?? 0) + inputTokens, + outputTokens: + outputTokens === null + ? undefined + : (current.outputTokens ?? 0) + outputTokens, + costUsd: + costUsd === null ? undefined : Number(current.costUsd ?? 0) + costUsd, }, }); } diff --git a/apps/agent/agent/lib/facts.ts b/apps/agent/agent/lib/facts.ts index ebcc02e16..3daf89890 100644 --- a/apps/agent/agent/lib/facts.ts +++ b/apps/agent/agent/lib/facts.ts @@ -1,4 +1,4 @@ -import { db, FactBand, FactStatus } from "@crm/db"; +import { db, FactBand, FactStatus, type Prisma } from "@crm/db"; import { type Evidence, scoreEvidence } from "./evidence"; import { currentFocus } from "./focus"; import { isDerivedName, splitName } from "./names"; @@ -195,7 +195,7 @@ export async function recordFact( value: trimmed, score: scored.score, band: scored.band as FactBand, - evidence: input.evidence as unknown as object, + evidence: input.evidence as Prisma.InputJsonValue, method: input.method, sourceUrl: input.sourceUrl ?? null, sessionId, @@ -287,7 +287,7 @@ export async function writeBrief(input: { const data = { narrative: input.narrative.trim(), - sections: input.sections as unknown as object, + sections: input.sections as Prisma.InputJsonValue, score: scored.score, sourceUrl: input.sourceUrl ?? null, sessionId: currentFocus().sessionId, diff --git a/apps/agent/agent/lib/lookup.ts b/apps/agent/agent/lib/lookup.ts index 76accbcd8..8f8385e73 100644 --- a/apps/agent/agent/lib/lookup.ts +++ b/apps/agent/agent/lib/lookup.ts @@ -77,24 +77,23 @@ export async function listDeals(options: DealListOptions = {}) { const rows = await db.deal.findMany({ where: { - ...(stages ? { stage: { in: stages } } : {}), - ...(options.companyId ? { companyId: options.companyId } : {}), - ...(options.ownerId ? { ownerId: options.ownerId } : {}), - ...(cutoff - ? { - OR: [ - { lastActivityAt: { lte: cutoff } }, - { lastActivityAt: null, createdAt: { lte: cutoff } }, - ], - } - : {}), + stage: stages ? { in: stages } : undefined, + companyId: options.companyId ?? undefined, + ownerId: options.ownerId ?? undefined, + OR: cutoff + ? [ + { lastActivityAt: { lte: cutoff } }, + { lastActivityAt: null, createdAt: { lte: cutoff } }, + ] + : undefined, }, orderBy: [ { lastActivityAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }, { id: "asc" }, ], - ...(options.cursor ? { cursor: { id: options.cursor }, skip: 1 } : {}), + cursor: options.cursor ? { id: options.cursor } : undefined, + skip: options.cursor ? 1 : undefined, take: limit + 1, select: { id: true, diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index ea5226eeb..1d5928dda 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -384,16 +384,11 @@ export class AgentTriggerService { where: { kind: task.kind, finishedAt: null, - ...(task.contactId ? { contactId: task.contactId } : {}), - ...(task.companyId ? { companyId: task.companyId } : {}), - ...(task.subject - ? { - payload: { - path: task.subject.path, - equals: task.subject.value, - }, - } - : {}), + contactId: task.contactId ?? undefined, + companyId: task.companyId ?? undefined, + payload: task.subject + ? { path: task.subject.path, equals: task.subject.value } + : undefined, }, select: { id: true }, }); @@ -408,7 +403,7 @@ export class AgentTriggerService { priority: task.priority, budget: task.budget, dueAt: new Date(), - ...(task.payload ? { payload: task.payload } : {}), + payload: task.payload ?? undefined, }, }); return true; @@ -493,13 +488,15 @@ export class AgentTriggerService { if (!agent) return false; try { + const headers = new Headers({ + authorization: `Bearer ${agent.secret}`, + }); + if (body) headers.set("content-type", "application/json"); + const response = await fetch(agent.url(path), { method: "POST", - headers: { - authorization: `Bearer ${agent.secret}`, - ...(body ? { "content-type": "application/json" } : {}), - }, - ...(body ? { body: JSON.stringify(body) } : {}), + headers, + body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(AGENT_DISPATCH.poke.timeoutMs), }); diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 5be1d3ad2..3004ce885 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -77,9 +77,9 @@ export class ConversationsService { const rows = await this.db.agentConversation.findMany({ where: { userId, - ...(input.contactId ? { contactId: input.contactId } : {}), - ...(input.companyId ? { companyId: input.companyId } : {}), - ...(input.dealId ? { dealId: input.dealId } : {}), + contactId: input.contactId ?? undefined, + companyId: input.companyId ?? undefined, + dealId: input.dealId ?? undefined, }, orderBy: { lastMessageAt: "desc" }, take: 20, @@ -1163,10 +1163,11 @@ function pendingBuilderQuestionOf(value: unknown) { { id: option.id, label: option.label, - ...(typeof option.description === "string" - ? { description: option.description } - : {}), - ...(style ? { style } : {}), + description: + typeof option.description === "string" + ? option.description + : undefined, + style, }, ]; }); @@ -1175,7 +1176,7 @@ function pendingBuilderQuestionOf(value: unknown) { kind: "question" as const, requestId: request.requestId, prompt: request.prompt, - ...(display ? { display } : {}), + display, options, allowFreeform: request.allowFreeform === true || diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 886839955..db092b1ad 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -120,14 +120,14 @@ const createPrismaClient = () => { return client; }; -const globalForPrisma = globalThis as unknown as { - prisma: ReturnType | undefined; -}; +declare global { + var prisma: ReturnType | undefined; +} -export const db = globalForPrisma.prisma ?? createPrismaClient(); +export const db = globalThis.prisma ?? createPrismaClient(); if (process.env.NODE_ENV !== "production") { - globalForPrisma.prisma = db; + globalThis.prisma = db; } export type Db = typeof db; From 64440c6827394af69659a1d0205574a6726868a8 Mon Sep 17 00:00:00 2001 From: twinprime19 <38123958+twinprime19@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:16:27 +0700 Subject: [PATCH 03/10] docs: propose an i18n layer (#143) --- adrs/i18n.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 adrs/i18n.md diff --git a/adrs/i18n.md b/adrs/i18n.md new file mode 100644 index 000000000..31bc1eb79 --- /dev/null +++ b/adrs/i18n.md @@ -0,0 +1,9 @@ +# Let the CRM speak more than English + +I'm heavily testing the CRM for use in both my own start up and in a more established company at the same time. Both of these companies are in Vietnam. The copy is hardcoded English everywhere, so localizing means forking every file that renders a sentence. It's a lot of work to redo the localization in the future when upstream moves. I'm keen to keep working on this repo so I'd like to propose we make it multi-lingual. I'm sure this would be useful to other users worldwide. This work would set the foundation that allows other contributors to then contribute their own language pack. When this is done, I will also provide the first Vietnamese translation with guides on how users can do that most efficiently with their coding agents. I think this would help the repo, as great as it already is, grow and reach further. + +What I want to change: every user-visible string in `apps/app` and `packages/ui` goes through a catalog — next-intl, English-only this round, so a language is a translation job, not a refactor. Strings only; date, number and currency formatting stay. No URL segment, no middleware: the app sits behind sign-in, so a cookie plus a `user.locale` column is enough. `packages/ui` takes zero i18n dependency — English defaults in the package, overrides through a provider. + +Enforcement: an AST checker in CI fails on hardcoded JSX text, translatable attributes and toasts; a pseudo-locale catches what static analysis cannot. Rendered English is unchanged — plumbing, not a copy edit. Copy then lives in one place, so your future edits are key-level catalog diffs instead of JSX archaeology. + +Costs: `/` drops full static prerender for a static shell; the other prerendered routes keep theirs. And every new string must enter the catalog or CI fails. English stays the only built-in language — packs arrive as contributions (Vietnamese first, from me), missing keys fall back to English per-string, so a release never waits on a translation. It lands as roughly ten PRs: infrastructure, then per-area extraction, each small and green. All of it already runs on our fork. Happy either way: I can send it in reviewable slices, or you build it your way and I'll contribute the Vietnamese pack on top. From 3fb9922b63aae5e97518d6712037e70b21899a76 Mon Sep 17 00:00:00 2001 From: grim <75869731+ripgrim@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:03:31 -0700 Subject: [PATCH 04/10] refactor: parse every remaining I/O boundary into a domain type (CMP-82) (#151) --- .oxlintrc.json | 23 ++ AGENTS.md | 5 +- apps/agent/agent/channels/crm.ts | 81 +++--- apps/agent/agent/hooks/activity.ts | 10 +- apps/agent/agent/hooks/audit.ts | 47 ++-- apps/agent/agent/hooks/telemetry.ts | 14 +- apps/agent/agent/instructions/task.ts | 31 ++- apps/agent/agent/lib/accounts.ts | 14 +- apps/agent/agent/lib/agent-actions.ts | 20 +- apps/agent/agent/lib/blank-facts.ts | 5 +- apps/agent/agent/lib/brand-images.ts | 9 +- apps/agent/agent/lib/brand-mapping.ts | 14 +- apps/agent/agent/lib/builder-input.ts | 7 +- apps/agent/agent/lib/builder-runtime.ts | 50 ++-- apps/agent/agent/lib/capabilities.ts | 6 +- apps/agent/agent/lib/context-dev.ts | 110 +++++--- apps/agent/agent/lib/crm.ts | 8 +- apps/agent/agent/lib/custom-agent-dispatch.ts | 130 ++++----- apps/agent/agent/lib/dispatch.ts | 13 +- apps/agent/agent/lib/enrichment.ts | 2 +- apps/agent/agent/lib/evidence.ts | 4 +- apps/agent/agent/lib/facts.ts | 28 +- apps/agent/agent/lib/focus.ts | 6 +- apps/agent/agent/lib/linkdapi.ts | 200 +++++++------ apps/agent/agent/lib/perplexity.ts | 2 +- apps/agent/agent/lib/portrait-sources.ts | 35 ++- apps/agent/agent/lib/run-preflight.ts | 16 +- apps/agent/agent/lib/run-runtime.ts | 238 +++++++++------- apps/agent/agent/lib/session-purpose.ts | 21 +- apps/agent/agent/lib/slack-join-task.ts | 5 +- apps/agent/agent/lib/socials.ts | 58 ++-- apps/agent/agent/lib/tasks.ts | 2 +- .../agent_builder/lib/draft-input.ts | 2 +- .../agent_runner/tools/finish_run.ts | 2 +- apps/agent/agent/tools/enrich_company.ts | 4 +- apps/agent/agent/tools/identify_contact.ts | 2 +- apps/agent/agent/tools/record_fact.ts | 2 +- apps/agent/agent/tools/research_company.ts | 89 +++--- apps/agent/evals/agent-builder.eval.ts | 24 +- apps/agent/scripts/backfill-brand-images.ts | 23 +- apps/agent/test/channel-auth.spec.ts | 22 +- .../durable-agent-runtime.integration.spec.ts | 24 +- apps/agent/test/e2e/dispatch.e2e.ts | 2 +- apps/agent/test/e2e/e2e-agents.ts | 4 +- apps/agent/test/e2e/slack-join.e2e.ts | 4 +- .../test/slack-people.integration.spec.ts | 21 +- apps/api/src/activities/activities.service.ts | 6 +- .../src/agent/agent-definitions.service.ts | 70 ++--- apps/api/src/agent/agent-queue.service.ts | 12 +- apps/api/src/agent/agent-trigger.service.ts | 6 +- apps/api/src/agent/research-key.service.ts | 22 +- apps/api/src/backfill/backfill.service.ts | 12 +- apps/api/src/backfill/image-mirror.service.ts | 53 ++-- apps/api/src/companies/companies.service.ts | 20 +- apps/api/src/config/env.validation.ts | 6 +- apps/api/src/contacts/contacts.service.ts | 32 ++- .../conversations/conversation-attachments.ts | 14 +- .../conversations/conversations.service.ts | 94 ++----- apps/api/src/crm/enrichment-log.service.ts | 3 +- apps/api/src/currency/rates.service.ts | 42 ++- apps/api/src/dashboard/dashboard.service.ts | 3 +- apps/api/src/deals/deals.service.ts | 26 +- apps/api/src/fields/fields.service.ts | 12 +- apps/api/src/google/conversation.service.ts | 34 ++- apps/api/src/logging/all-exceptions.filter.ts | 29 +- apps/api/src/logging/context-logger.ts | 9 +- apps/api/src/logging/prisma-log.bridge.ts | 9 +- apps/api/src/mailbox/mailbox.constants.ts | 8 +- apps/api/src/mailbox/participants.ts | 10 +- apps/api/src/mailbox/sync-state.service.ts | 15 +- apps/api/src/mailbox/thread-writer.service.ts | 26 +- apps/api/src/main.ts | 4 +- .../api/src/settings/model-catalog.service.ts | 86 +++--- apps/api/src/settings/settings.service.ts | 4 +- .../api/src/slack/slack-connection.service.ts | 15 +- apps/api/src/sso/sso.service.ts | 54 ++-- apps/api/src/telemetry/rollup.service.ts | 15 +- .../src/tracking/tracking-filing.service.ts | 10 +- .../src/tracking/tracking-ingest.service.ts | 45 ++- apps/api/src/tracking/tracking.controller.ts | 27 +- apps/api/src/trpc/error-formatter.ts | 42 +-- apps/api/src/trpc/list-input.ts | 22 +- apps/api/src/workspace/workspace.service.ts | 6 +- apps/api/test/agent-runs.spec.ts | 5 +- apps/api/test/conversation-sharing.spec.ts | 19 +- apps/api/test/conversations.spec.ts | 43 +-- apps/api/test/error-formatter.spec.ts | 2 +- apps/api/test/logging.spec.ts | 10 +- apps/api/test/mailbox-api-client.spec.ts | 3 +- apps/api/test/outlook-sync.spec.ts | 33 ++- apps/api/test/slack-channels.spec.ts | 5 +- apps/api/test/sso.spec.ts | 4 +- .../(agent-builder)/agents/[agentId]/page.tsx | 10 +- .../(agent-builder)/chat/[chatId]/page.tsx | 12 +- .../[slug]/(agent-builder)/missing-record.ts | 14 +- apps/app/app/(app)/[slug]/layout.tsx | 19 +- apps/app/app/(app)/[slug]/overview-scope.tsx | 4 +- apps/app/app/(app)/[slug]/sales-dashboard.tsx | 7 +- .../connections/google-connection.tsx | 10 +- .../connections/microsoft-connection.tsx | 10 +- .../slack/slack-connect-button.tsx | 27 +- .../(landing)/grant-access/grant-access.tsx | 9 +- apps/app/app/(landing)/grant-access/page.tsx | 6 +- apps/app/app/(landing)/sign-in/page.tsx | 16 +- .../app/(landing)/sign-in/social-sign-in.tsx | 8 +- apps/app/app/t/[site]/route.ts | 14 +- .../agent-builder/agent-builder-chat.tsx | 215 +++++++------- .../agent-builder/agent-builder-sidebar.tsx | 12 +- .../agent-builder/agent-capabilities.tsx | 69 ++--- .../agent-builder/agent-composer.tsx | 4 +- .../agent-builder/agent-history.tsx | 77 ++--- .../components/agent-builder/agent-result.tsx | 32 ++- .../agent-builder/team-agent-detail.tsx | 33 +-- apps/app/components/crm/agent-panel.tsx | 16 +- .../components/crm/fields/field-editor.tsx | 8 +- apps/app/components/crm/fields/fields-copy.ts | 12 +- .../components/crm/fields/fields-entity.ts | 8 +- .../components/crm/fields/standard-fields.ts | 4 +- .../crm/record-sheet/record-actions.tsx | 4 +- .../crm/record-sheet/record-stack.ts | 4 +- .../crm/timeline/activity-composer.tsx | 4 +- .../crm/timeline/timeline-entry.tsx | 16 +- apps/app/components/crm/timeline/timeline.tsx | 15 +- apps/app/components/inline-script.tsx | 2 +- apps/app/components/landing/analytics.tsx | 2 +- apps/app/lib/activity-presentation.ts | 24 +- apps/app/lib/agent-bridge.ts | 43 ++- apps/app/lib/agent-builder-state.ts | 35 +-- apps/app/lib/agent-record.ts | 20 +- apps/app/lib/agent-results.ts | 3 +- apps/app/lib/agent-run-failure.ts | 4 +- apps/app/lib/agent-tool-display.ts | 27 +- apps/app/lib/agent-transcript.ts | 263 +++++++----------- apps/app/lib/deal-stage.ts | 7 +- apps/app/lib/enrichment-status.ts | 6 +- apps/app/lib/onboarding.ts | 50 ++-- apps/app/lib/social-links.ts | 7 +- apps/app/lib/trpc/query-client.ts | 22 +- apps/app/package.json | 3 +- apps/app/test/agent-results.spec.ts | 2 +- apps/app/test/agent-transcript.spec.ts | 73 ++--- apps/app/test/onboarding-gate.spec.ts | 8 +- apps/app/test/tracking-bundle.spec.ts | 2 +- bun.lock | 2 + packages/auth/src/auth.ts | 70 ++--- packages/auth/src/client.ts | 2 +- packages/auth/src/scopes.ts | 4 +- packages/auth/src/slack-connect.ts | 5 +- packages/auth/src/slack-grant.ts | 11 +- packages/auth/src/slack-scopes.ts | 6 +- packages/db/package.json | 1 + packages/db/prisma/seed.ts | 8 +- packages/db/src/attribution.ts | 12 +- packages/db/src/blob.ts | 4 +- packages/db/src/crm-events.ts | 4 - packages/db/src/fields-shape.ts | 10 +- packages/db/src/fields.ts | 3 +- packages/db/src/index.ts | 2 + packages/db/src/json.ts | 22 ++ packages/db/src/tracking.ts | 8 +- packages/db/src/workspace.ts | 37 ++- packages/db/test/currency.spec.ts | 4 +- packages/env/src/index.ts | 11 +- packages/env/test/root.spec.ts | 9 +- packages/telemetry/src/allowlist.ts | 10 +- packages/telemetry/src/client.ts | 30 +- .../telemetry/test/client.integration.spec.ts | 60 ++-- packages/ui/src/lib/dither.ts | 4 +- packages/validation/package.json | 9 +- packages/validation/src/activity-meta.ts | 9 + packages/validation/src/agent-events.ts | 22 ++ .../validation/src}/agent-manifest.ts | 86 +++++- packages/validation/src/builder-question.ts | 43 +++ packages/validation/src/eve-stream.ts | 49 ++++ packages/validation/src/eve-tool.ts | 40 +++ packages/validation/src/index.ts | 48 +++- packages/validation/src/slack.ts | 59 +++- 177 files changed, 2542 insertions(+), 1930 deletions(-) create mode 100644 packages/validation/src/activity-meta.ts create mode 100644 packages/validation/src/agent-events.ts rename {apps/agent/agent/lib => packages/validation/src}/agent-manifest.ts (54%) create mode 100644 packages/validation/src/builder-question.ts create mode 100644 packages/validation/src/eve-stream.ts create mode 100644 packages/validation/src/eve-tool.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index ca8b61ea0..8a779c4eb 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -11,6 +11,8 @@ "**/.next/**", "**/.eve/**", "**/.scratch/**", + ".agents/**", + ".claude/**", "apps/api/src/generated/**", "packages/db/src/generated/**", "packages/ui/src/components/**", @@ -41,6 +43,27 @@ "rules": { "anti-slop/no-chained-type-assertions": "off" } + }, + { + "files": ["packages/validation/src/**"], + "rules": { + "anti-slop/no-unknown-parameters": "off" + } + }, + { + "files": [ + "packages/telemetry/src/**", + "apps/api/src/logging/**", + "packages/db/src/fields.ts", + "packages/db/src/fields-shape.ts", + "apps/api/src/fields/**", + "apps/app/components/crm/inline-field.tsx" + ], + "rules": { + "anti-slop/no-unsafe-dictionary-type": "off", + "anti-slop/no-unknown-parameters": "off", + "anti-slop/no-runtime-typeof": "off" + } } ] } diff --git a/AGENTS.md b/AGENTS.md index a5c78b963..2be0b3881 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,10 @@ const slack = manifest.actions.find( const id = slack?.destination.id; ``` -`apps/agent/agent/lib/agent-manifest.ts` is the pattern. Rules that follow from +`packages/validation/src/agent-manifest.ts` is the pattern. A shape that crosses +a package boundary — a `Json` column two apps read, a payload one app writes and +another consumes — lives in `packages/validation/src`, one module per shape, and +is imported by subpath (`@crm/validation/agent-manifest`). Rules that follow from it: - The schema describes what is **actually stored**, not the loosest thing that diff --git a/apps/agent/agent/channels/crm.ts b/apps/agent/agent/channels/crm.ts index 0128065cf..fabb45c45 100644 --- a/apps/agent/agent/channels/crm.ts +++ b/apps/agent/agent/channels/crm.ts @@ -2,7 +2,9 @@ import { timingSafeEqual } from "node:crypto"; import { EnrichmentStatus, Prisma } from "@crm/db"; import { MAX_ATTEMPTS } from "@crm/db/agent-tasks"; import { schemas } from "@crm/validation"; +import { eveTurnFailure } from "@crm/validation/eve-stream"; import { defineChannel, GET, POST } from "eve/channels"; +import { z } from "zod"; import { persistBuilderInputRequest } from "../lib/builder-input"; import { verifyKey } from "../lib/context-dev"; import { @@ -26,7 +28,7 @@ import { } from "../lib/dispatch"; import { DISPATCH } from "../lib/dispatch-config"; import { settle } from "../lib/enrichment"; -import { finishRun } from "../lib/run-runtime"; +import { finishRun, runResultOf } from "../lib/run-runtime"; import { attribute } from "../lib/session-purpose"; import { createSlackChannel } from "../lib/slack-membership"; import { completeTask, taskSubject } from "../lib/tasks"; @@ -34,6 +36,28 @@ import { completeTask, taskSubject } from "../lib/tasks"; const TASK_MARKER = "task:"; const STALE_QUEUE_MS = DISPATCH.sweep.staleQueueMs; +type InternalDispatchPrincipal = { + readonly authenticator: string; + readonly principalId: string; + readonly principalType: string; +} | null; + +const identifier = z.string().trim().min(1).nullable().catch(null); + +const cancelRunRequest = z.object({ runId: identifier }).catch({ runId: null }); + +const verifyKeyRequest = z + .object({ apiKey: identifier }) + .catch({ apiKey: null }); + +const receiveTarget = z + .object({ + builderSubmissionId: z.string().nullable().catch(null), + runId: z.string().nullable().catch(null), + taskId: z.string().nullable().catch(null), + }) + .catch({ builderSubmissionId: null, runId: null, taskId: null }); + function authorised(request: Request): boolean { const secret = process.env.AGENT_BRIDGE_SECRET?.trim(); if (!secret) return false; @@ -140,10 +164,9 @@ export default defineChannel({ return new Response("Unauthorized", { status: 401 }); } - const body = (await request.json().catch(() => null)) as { - runId?: unknown; - } | null; - const runId = typeof body?.runId === "string" ? body.runId.trim() : null; + const { runId } = cancelRunRequest.parse( + await request.json().catch(() => null), + ); if (!runId) { return Response.json({ error: "No run id was sent." }, { status: 400 }); } @@ -184,12 +207,9 @@ export default defineChannel({ return new Response("Unauthorized", { status: 401 }); } - const body = (await request.json().catch(() => null)) as { - apiKey?: unknown; - } | null; - - const apiKey = - typeof body?.apiKey === "string" ? body.apiKey.trim() : null; + const { apiKey } = verifyKeyRequest.parse( + await request.json().catch(() => null), + ); if (!apiKey) { return Response.json( @@ -249,9 +269,7 @@ export default defineChannel({ async "turn.failed"(data, channel) { const taskId = taskFromToken(channel.continuationToken); const reason = - typeof data === "object" && data && "message" in data - ? String((data as { message: unknown }).message) - : "The agent turn failed."; + eveTurnFailure.parse(data).message ?? "The agent turn failed."; if (taskId) { const subject = await taskSubject(taskId); @@ -300,7 +318,7 @@ export default defineChannel({ try { await finishRun(runId, { summary: run.summary ?? "The agent run completed.", - result: recordOf(run.result), + result: runResultOf(run.result), }); } catch (error) { await failRun( @@ -357,47 +375,32 @@ export default defineChannel({ }, async receive(input, { send }) { - const builderSubmissionId = - typeof input.target?.builderSubmissionId === "string" - ? input.target.builderSubmissionId - : null; - if (builderSubmissionId) { + const target = receiveTarget.parse(input.target); + if (target.builderSubmissionId) { assertInternalDispatchAuth(input.auth); - return dispatchBuilderSubmission(builderSubmissionId, send); + return dispatchBuilderSubmission(target.builderSubmissionId, send); } - const runId = - typeof input.target?.runId === "string" ? input.target.runId : null; - if (runId) { + if (target.runId) { assertInternalDispatchAuth(input.auth); - return dispatchAgentRun(runId, send); + return dispatchAgentRun(target.runId, send); } - const taskId = - typeof input.target?.taskId === "string" ? input.target.taskId : null; - return send(input.message, { auth: input.auth, - continuationToken: taskId - ? taskToken(taskId) + continuationToken: target.taskId + ? taskToken(target.taskId) : `crm:adhoc:${crypto.randomUUID()}`, }); }, }); -function assertInternalDispatchAuth(value: unknown): void { - const auth = recordOf(value); +function assertInternalDispatchAuth(auth: InternalDispatchPrincipal): void { if ( - auth.authenticator !== "app" || + auth?.authenticator !== "app" || auth.principalType !== "runtime" || auth.principalId !== "eve:app" ) { throw new Error("Internal agent dispatch requires Eve app authentication."); } } - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/agent/agent/hooks/activity.ts b/apps/agent/agent/hooks/activity.ts index ac6e5105a..2e661bd15 100644 --- a/apps/agent/agent/hooks/activity.ts +++ b/apps/agent/agent/hooks/activity.ts @@ -1,7 +1,11 @@ import { defineHook, type HookEvent } from "eve/hooks"; +import { z } from "zod"; type ActionRequest = HookEvent<"actions.requested">["data"]["actions"][number]; type ActionResult = HookEvent<"action.result">["data"]["result"]; +type ActionInput = ActionRequest["input"]; + +const inputText = z.string().nullable().catch(null); const SHOW_CONTENT = process.env.NODE_ENV !== "production"; const MAX_IN_FLIGHT = 256; @@ -16,14 +20,14 @@ function line(symbol: string, text: string): void { console.error(`[agent] ${symbol} ${text}`); } -function preview(input: unknown): string { - if (!SHOW_CONTENT || typeof input !== "object" || input === null) return ""; +function preview(input: ActionInput): string { + if (!SHOW_CONTENT) return ""; const parts: string[] = []; for (const [key, value] of Object.entries(input)) { if (value === null || value === undefined) continue; - const text = typeof value === "string" ? value : JSON.stringify(value); + const text = inputText.parse(value) ?? JSON.stringify(value); parts.push(`${key}=${truncate(text ?? String(value), 48)}`); } diff --git a/apps/agent/agent/hooks/audit.ts b/apps/agent/agent/hooks/audit.ts index 1cb42a0e6..e2b59e9bc 100644 --- a/apps/agent/agent/hooks/audit.ts +++ b/apps/agent/agent/hooks/audit.ts @@ -1,10 +1,29 @@ import { db, Prisma } from "@crm/db"; import { defineHook } from "eve/hooks"; +import { z } from "zod"; import { isTransportOnlyEvent } from "../lib/event-persistence"; import { currentFocus } from "../lib/focus"; import { lockAgentRun } from "../lib/run-state"; import { attribute, purposeOf } from "../lib/session-purpose"; +const finiteNumber = z.number().refine(Number.isFinite).nullable().catch(null); + +const stepUsage = z + .object({ + usage: z + .object({ + inputTokens: finiteNumber, + outputTokens: finiteNumber, + costUsd: finiteNumber, + }) + .catch({ inputTokens: null, outputTokens: null, costUsd: null }), + }) + .catch({ usage: { inputTokens: null, outputTokens: null, costUsd: null } }); + +const completedMessage = z + .object({ message: z.string().nullable().catch(null) }) + .catch({ message: null }); + export default defineHook({ events: { async "*"(event, ctx) { @@ -13,7 +32,9 @@ export default defineHook({ if (!id || isTransportOnlyEvent(event.type)) return; try { - const data = ("data" in event ? (event.data ?? {}) : {}) as object; + const data = ( + "data" in event ? (event.data ?? {}) : {} + ) as Prisma.InputJsonObject; const emittedAt = event.meta?.at ? new Date(event.meta.at) : new Date(); const purpose = purposeOf(ctx); const conversationId = @@ -86,7 +107,7 @@ async function persistRunEvent( tx: Prisma.TransactionClient, eventId: string, type: string, - data: object, + data: Prisma.InputJsonObject, emittedAt: Date, ctx: Parameters[0] & { session: { id: string } }, ) { @@ -128,17 +149,13 @@ async function persistRunEvent( runId, sequence, type, - data: data as Prisma.InputJsonValue, + data, emittedAt, }, }); if (type === "step.completed") { - const usage = recordOf(data).usage; - const values = recordOf(usage); - const inputTokens = numberOf(values.inputTokens); - const outputTokens = numberOf(values.outputTokens); - const costUsd = numberOf(values.costUsd); + const { inputTokens, outputTokens, costUsd } = stepUsage.parse(data).usage; const current = await tx.agentRun.findUniqueOrThrow({ where: { id: runId }, select: { inputTokens: true, outputTokens: true, costUsd: true }, @@ -161,8 +178,8 @@ async function persistRunEvent( } if (type === "message.completed") { - const message = recordOf(data).message; - if (typeof message === "string" && message.trim()) { + const { message } = completedMessage.parse(data); + if (message?.trim()) { await tx.agentRun.updateMany({ where: { id: runId, status: "RUNNING" }, data: { summary: message.slice(0, 1000) }, @@ -174,13 +191,3 @@ async function persistRunEvent( function isRootSession(ctx: Parameters[0]): boolean { return !("parent" in ctx.session) || !ctx.session.parent; } - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function numberOf(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} diff --git a/apps/agent/agent/hooks/telemetry.ts b/apps/agent/agent/hooks/telemetry.ts index d203f847d..da537aebc 100644 --- a/apps/agent/agent/hooks/telemetry.ts +++ b/apps/agent/agent/hooks/telemetry.ts @@ -2,6 +2,13 @@ import { db } from "@crm/db"; import { readAgentModel } from "@crm/db/settings"; import { agentError, modelError } from "@crm/telemetry"; import { defineHook } from "eve/hooks"; +import { z } from "zod"; + +type SessionPrincipal = { + readonly attributes?: Readonly>; +} | null; + +const attributeText = z.string().trim().min(1).nullable().catch(null); let modelId: string | null = null; @@ -27,11 +34,8 @@ const MODEL_CODES = [ "unauthorized", ]; -function taskKind( - auth: { attributes?: Record } | null, -): string | null { - const kind = auth?.attributes?.taskKind; - return typeof kind === "string" && kind.trim() ? kind.trim() : null; +function taskKind(auth: SessionPrincipal): string | null { + return attributeText.parse(auth?.attributes?.taskKind); } function looksLikeModel(code: string): boolean { diff --git a/apps/agent/agent/instructions/task.ts b/apps/agent/agent/instructions/task.ts index e617aab09..36a15096b 100644 --- a/apps/agent/agent/instructions/task.ts +++ b/apps/agent/agent/instructions/task.ts @@ -1,9 +1,19 @@ import { defineDynamic, defineInstructions } from "eve/instructions"; +import { z } from "zod"; import { focusOn, setBudget } from "../lib/focus"; import { sessionPreamble } from "../lib/preamble"; import { RESEARCH_INSTRUCTIONS } from "../lib/research-instructions"; import { attribute, purposeOf } from "../lib/session-purpose"; +const attributeText = z.string().trim().min(1).nullable().catch(null); + +const attributeNumber = z + .union([z.string(), z.number()]) + .transform(Number) + .refine(Number.isFinite) + .nullable() + .catch(null); + export default defineDynamic({ events: { "session.started": async (_event, ctx) => { @@ -19,21 +29,21 @@ export default defineDynamic({ } const attributes = ctx.session.auth.current?.attributes ?? {}; - const budget = asNumber(attributes.budget); - const kind = asString(attributes.taskKind); + const budget = attributeNumber.parse(attributes.budget); + const kind = attributeText.parse(attributes.taskKind); if (budget) setBudget(budget); const { markdown, focus } = await sessionPreamble( { - contactId: asString(attributes.contactId), - companyId: asString(attributes.companyId), - dealId: asString(attributes.dealId), + contactId: attributeText.parse(attributes.contactId), + companyId: attributeText.parse(attributes.companyId), + dealId: attributeText.parse(attributes.dealId), }, { dispatched: Boolean(kind), kind, - reason: asString(attributes.reason), + reason: attributeText.parse(attributes.reason), budget, }, ); @@ -71,12 +81,3 @@ export function builderTaskMarkdown( ? `Before any other work, call set_chat_title once. Summarize the user's first message as a polished title of three to seven words in sentence case. Capture the intent, remove slash-command syntax and filler, and do not use quotation marks or ending punctuation.\n\n${task}` : task; } - -function asString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function asNumber(value: unknown): number | null { - const parsed = typeof value === "string" ? Number(value) : value; - return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null; -} diff --git a/apps/agent/agent/lib/accounts.ts b/apps/agent/agent/lib/accounts.ts index 459c39b24..742d37732 100644 --- a/apps/agent/agent/lib/accounts.ts +++ b/apps/agent/agent/lib/accounts.ts @@ -1,8 +1,16 @@ import { ActivityType, db, EmailDirection } from "@crm/db"; +import { z } from "zod"; import { isDerivedName } from "./names"; const BODY_LIMIT = 4000; +const stageChangeMeta = z + .object({ + from: z.string().nullable().catch(null), + to: z.string().nullable().catch(null), + }) + .catch({ from: null, to: null }); + export type AccountThread = { subject: string | null; contact: { id: string; name: string } | null; @@ -487,10 +495,10 @@ export async function readDealHistory( role, })), stageHistory: stageChanges.map((change) => { - const meta = (change.meta ?? {}) as { from?: unknown; to?: unknown }; + const meta = stageChangeMeta.parse(change.meta); return { - from: typeof meta.from === "string" ? meta.from : null, - to: typeof meta.to === "string" ? meta.to : null, + from: meta.from, + to: meta.to, at: change.createdAt.toISOString(), }; }), diff --git a/apps/agent/agent/lib/agent-actions.ts b/apps/agent/agent/lib/agent-actions.ts index a2993a50d..8f403b4ac 100644 --- a/apps/agent/agent/lib/agent-actions.ts +++ b/apps/agent/agent/lib/agent-actions.ts @@ -1,11 +1,7 @@ -export const AGENT_ACTION_TYPES = { - CRM_ACTIVITY_CREATE: "crm.activity.create", - RUN_SUMMARY: "run.summary", - SLACK_MESSAGE_POST: "slack.message.post", -} as const; - -export type AgentActionType = - (typeof AGENT_ACTION_TYPES)[keyof typeof AGENT_ACTION_TYPES]; +import { + AGENT_ACTION_TYPES, + type AgentActionType, +} from "@crm/validation/agent-manifest"; export const AGENT_ACTION_EXECUTORS = { [AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE]: "create_crm_activity", @@ -13,12 +9,14 @@ export const AGENT_ACTION_EXECUTORS = { [AGENT_ACTION_TYPES.SLACK_MESSAGE_POST]: "post_slack_message", } as const satisfies Record; -export function isAgentActionType(value: unknown): value is AgentActionType { - return Object.hasOwn(AGENT_ACTION_EXECUTORS, String(value)); +export function isAgentActionType(value: string): value is AgentActionType { + return Object.hasOwn(AGENT_ACTION_EXECUTORS, value); } +export type AgentActionDependencyId = "slack"; + export type AgentActionDependency = { - readonly id: string; + readonly id: AgentActionDependencyId; readonly label: string; readonly resourceId: string; readonly fix: string; diff --git a/apps/agent/agent/lib/blank-facts.ts b/apps/agent/agent/lib/blank-facts.ts index f05952494..f2088c016 100644 --- a/apps/agent/agent/lib/blank-facts.ts +++ b/apps/agent/agent/lib/blank-facts.ts @@ -79,13 +79,12 @@ export async function sweepBlankFacts( for (const group of groupByField(proposals)) { const [best] = group; const field = best.field as FactField; - const contact = best.contact as FactSubject; + const contact: FactSubject = best.contact; const column = factColumn(field); const current = applied.get(key(best.contactId, field)); if (!fillsBlank({ field, contact, hasAgentFact: current !== undefined })) { - const value = - current ?? (column ? (contact[column] as string | null) : null); + const value = current ?? (column ? contact[column] : null); const stale = redundant(group, value); sweep.waiting += group.length - stale.length; diff --git a/apps/agent/agent/lib/brand-images.ts b/apps/agent/agent/lib/brand-images.ts index f00f2fe57..df017e752 100644 --- a/apps/agent/agent/lib/brand-images.ts +++ b/apps/agent/agent/lib/brand-images.ts @@ -1,6 +1,9 @@ import type { Prisma } from "@crm/db"; import { blobEnabled, mirror } from "@crm/db/blob"; import { COMPANY_IMAGE_FIELDS } from "@crm/db/images"; +import { z } from "zod"; + +const mirrorableUrl = z.string().regex(/\S/).nullable().catch(null); export async function mirrorBrandImages( companyId: string, @@ -12,7 +15,7 @@ export async function mirrorBrandImages( await Promise.all( COMPANY_IMAGE_FIELDS.map(async (slot) => { - const source = plain(update[slot]); + const source = mirrorableUrl.parse(update[slot]); if (!source) return; const stored = await mirror(source, `companies/${companyId}/${slot}`); @@ -25,7 +28,3 @@ export async function mirrorBrandImages( return { update, mirrored }; } - -function plain(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value : null; -} diff --git a/apps/agent/agent/lib/brand-mapping.ts b/apps/agent/agent/lib/brand-mapping.ts index 1d6994d0e..0c92ef8fb 100644 --- a/apps/agent/agent/lib/brand-mapping.ts +++ b/apps/agent/agent/lib/brand-mapping.ts @@ -115,7 +115,7 @@ export function brandToUpdate( value: string | null, ) => { if (value && fillable(key, current)) { - (update as Record)[key] = value; + update[key] = value; } }; @@ -162,15 +162,9 @@ export function stillFillable( update: BrandUpdate, current: CompanySnapshot, ): BrandUpdate { - const next: BrandUpdate = {}; - - for (const [key, value] of Object.entries(update)) { - if (fillable(key, current)) { - (next as Record)[key] = value; - } - } - - return next; + return Object.fromEntries( + Object.entries(update).filter(([key]) => fillable(key, current)), + ); } export function filledFields(update: BrandUpdate): string[] { diff --git a/apps/agent/agent/lib/builder-input.ts b/apps/agent/agent/lib/builder-input.ts index 6e97f263c..a3dbbed7c 100644 --- a/apps/agent/agent/lib/builder-input.ts +++ b/apps/agent/agent/lib/builder-input.ts @@ -1,6 +1,7 @@ import { db, type Prisma } from "@crm/db"; import { lockIdempotencyKey } from "@crm/db/idempotency"; import { type InputRequested, parse, schemas } from "@crm/validation"; +import type { ChannelEvents } from "eve/channels"; import { builderIdFromToken, builderToken, @@ -12,8 +13,12 @@ const BUILDER_INPUT = { idPrefix: "builder-input", } as const; +type InputRequestedEvent = Parameters< + NonNullable +>[0]; + export async function persistBuilderInputRequest( - data: unknown, + data: InputRequestedEvent, continuationToken: string | undefined, authenticatedConversationId?: string | null, ): Promise { diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 8a20494c0..4a2a2f9a8 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -7,7 +7,9 @@ import { } from "@crm/db/crm-events"; import { readAgentModel } from "@crm/db/settings"; import { WORKSPACE_ID } from "@crm/db/workspace"; -import { AGENT_ACTION_TYPES, actionDependency } from "./agent-actions"; +import { AGENT_ACTION_TYPES } from "@crm/validation/agent-manifest"; +import { z } from "zod"; +import { actionDependency } from "./agent-actions"; import { requestStaleSlackInventorySync } from "./slack-people"; const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; @@ -19,6 +21,19 @@ export type BuilderResource = { label: string; }; +const taggedResource = z + .object({ + kind: z.enum(["integration", "company", "contact", "deal"]), + id: z.string(), + label: z.string(), + }) + .nullable() + .catch(null); + +const taggedResources = z + .object({ resources: z.array(taggedResource).catch([]) }) + .catch({ resources: [] }); + export type DraftTrigger = { type: "MANUAL" | "SCHEDULE" | "EVENT"; name: string; @@ -71,11 +86,11 @@ export const BUILDER_ARTIFACT_PATHS = [ export type BuilderArtifactPath = (typeof BUILDER_ARTIFACT_PATHS)[number]; -const ARTIFACT_LANGUAGES: Record = { +const ARTIFACT_LANGUAGES = { "agent/README.md": "markdown", "agent/instructions.md": "markdown", "agent/manifest.json": "json", -}; +} satisfies Record; export async function writeBuilderArtifact( conversationId: string, @@ -247,7 +262,7 @@ export async function saveBuilderDraft( credentials: "app-runtime-only", summary: "Isolated sandbox · deny-all network · bounded CRM tools", }; - const files = artifactFiles(input, manifest); + const files = artifactFiles(input, JSON.stringify(manifest, null, 2)); for (const file of files) assertSafeArtifact(file.content); return db.$transaction(async (tx) => { @@ -777,25 +792,10 @@ async function missingResourceIds(resources: BuilderResource[]) { return described.filter((resource) => !resource.record); } -function resourcesOf(value: unknown): BuilderResource[] { - if (!value || typeof value !== "object" || !("resources" in value)) return []; - const resources = (value as { resources?: unknown }).resources; - if (!Array.isArray(resources)) return []; - - return resources.flatMap((resource) => { - if (!resource || typeof resource !== "object") return []; - const row = resource as Record; - if ( - !["integration", "company", "contact", "deal"].includes( - String(row.kind), - ) || - typeof row.id !== "string" || - typeof row.label !== "string" - ) { - return []; - } - return [resource as BuilderResource]; - }); +function resourcesOf(value: Prisma.JsonValue): BuilderResource[] { + return taggedResources + .parse(value) + .resources.filter((resource) => resource !== null); } function uniqueResources(resources: BuilderResource[]): BuilderResource[] { @@ -815,7 +815,7 @@ function scheduleDate(trigger: DraftTrigger, now: Date): Date | null { return parsed > now ? parsed : null; } -function artifactFiles(input: DraftAgentInput, manifest: object) { +function artifactFiles(input: DraftAgentInput, manifestJson: string) { const triggerSummary = input.triggers .map((trigger) => `- ${trigger.summary}`) .join("\n"); @@ -833,7 +833,7 @@ function artifactFiles(input: DraftAgentInput, manifest: object) { { path: "agent/manifest.json" as const, language: ARTIFACT_LANGUAGES["agent/manifest.json"], - content: `${JSON.stringify(manifest, null, 2)}\n`, + content: `${manifestJson}\n`, }, ]; } diff --git a/apps/agent/agent/lib/capabilities.ts b/apps/agent/agent/lib/capabilities.ts index cef15dcc0..6660bf77d 100644 --- a/apps/agent/agent/lib/capabilities.ts +++ b/apps/agent/agent/lib/capabilities.ts @@ -75,11 +75,13 @@ export async function enabled(id: string): Promise { ); } -export function unavailable(env: string): { +export type UnavailableCapability = { ok: false; configured: false; reason: string; -} { +}; + +export function unavailable(env: string): UnavailableCapability { return { ok: false, configured: false, diff --git a/apps/agent/agent/lib/context-dev.ts b/apps/agent/agent/lib/context-dev.ts index 6f093c43f..cd981fe9d 100644 --- a/apps/agent/agent/lib/context-dev.ts +++ b/apps/agent/agent/lib/context-dev.ts @@ -1,7 +1,25 @@ import ContextDev from "context.dev"; import { APIError } from "context.dev/core/error"; +import { z } from "zod"; import { contextDevKey } from "./capabilities"; +export type JsonSchema = { + type?: + | "array" + | "boolean" + | "integer" + | "null" + | "number" + | "object" + | "string"; + description?: string; + properties?: Record; + items?: JsonSchema; + required?: string[]; + enum?: (string | number | boolean | null)[]; + additionalProperties?: boolean | JsonSchema; +}; + export type Brand = { domain?: string | null; title?: string | null; @@ -99,16 +117,16 @@ export async function verifyKey(key: string): Promise { } } -export function classifyKey(error: unknown): KeyCheck { - if (!(error instanceof APIError)) { - return { outcome: "unknown", reason: describe(error) }; +export function classifyKey(cause: unknown): KeyCheck { + if (!(cause instanceof APIError)) { + return { outcome: "unknown", reason: describe(cause) }; } - if (error.status === undefined) { - return { outcome: "unknown", reason: describe(error) }; + if (cause.status === undefined) { + return { outcome: "unknown", reason: describe(cause) }; } - if (error.status === 401 && !recognisedKeyFailure(error)) { + if (cause.status === 401 && !recognisedKeyFailure(cause)) { return { outcome: "invalid", reason: "Context did not recognise that API key.", @@ -122,12 +140,13 @@ export async function brandByDomain( domain: string, maxAgeMs?: number, ): Promise { - return lookup({ + const params = { type: "by_domain", domain, timeoutMS: TIMEOUT_MS, - ...(maxAgeMs === undefined ? {} : { maxAgeMs }), - }); + } as const; + + return lookup(maxAgeMs === undefined ? params : { ...params, maxAgeMs }); } export async function brandByEmail(email: string): Promise { @@ -145,7 +164,7 @@ export async function prefetch(domain: string): Promise { export async function extract( url: string, - schema: Record, + schema: JsonSchema, instructions: string, ): Promise< { outcome: "found"; data: unknown } | { outcome: "failed"; reason: string } @@ -181,15 +200,18 @@ export async function search( return { outcome: "failed", reason: "Context.dev is not configured." }; } + const params = { + query, + numResults: Math.max(options.limit ?? 10, 10), + markdownOptions: { enabled: true }, + }; + try { - const response = await api.web.search({ - query, - numResults: Math.max(options.limit ?? 10, 10), - markdownOptions: { enabled: true }, - ...(options.excludeDomains - ? { excludeDomains: options.excludeDomains } - : {}), - }); + const response = await api.web.search( + options.excludeDomains + ? { ...params, excludeDomains: options.excludeDomains } + : params, + ); const results = (response.results ?? []).map((result) => ({ url: result.url ?? null, @@ -227,14 +249,14 @@ async function lookup( } } -function classify(error: unknown): LookupResult { - if (!(error instanceof APIError)) { - return { outcome: "failed", reason: describe(error), retryable: true }; +function classify(cause: unknown): LookupResult { + if (!(cause instanceof APIError)) { + return { outcome: "failed", reason: describe(cause), retryable: true }; } - const code = errorCode(error); + const code = errorCode(cause); - if (error.status === 400) { + if (cause.status === 400) { if (code === "NOT_FOUND" || code === "WEBSITE_ACCESS_ERROR") { return { outcome: "skipped", @@ -244,42 +266,46 @@ function classify(error: unknown): LookupResult { : "The site could not be reached.", }; } - return { outcome: "failed", reason: describe(error), retryable: false }; + return { outcome: "failed", reason: describe(cause), retryable: false }; } - if (error.status === 422) { + if (cause.status === 422) { return { outcome: "skipped", reason: "That is a personal or disposable email address.", }; } - if (error.status === 401 || error.status === 403) { - return { outcome: "failed", reason: describe(error), retryable: false }; + if (cause.status === 401 || cause.status === 403) { + return { outcome: "failed", reason: describe(cause), retryable: false }; } - if (error.status === 408 || error.status === 429) { - return { outcome: "failed", reason: describe(error), retryable: true }; + if (cause.status === 408 || cause.status === 429) { + return { outcome: "failed", reason: describe(cause), retryable: true }; } return { outcome: "failed", - reason: describe(error), - retryable: (error.status ?? 500) >= 500, + reason: describe(cause), + retryable: (cause.status ?? 500) >= 500, }; } +const apiErrorBody = z + .object({ + error_code: z.string().optional().catch(undefined), + message: z.string().optional().catch(undefined), + }) + .catch({}); + function errorCode(error: APIError): string | undefined { - const body = error.error as { error_code?: unknown } | undefined; - return typeof body?.error_code === "string" ? body.error_code : undefined; + return apiErrorBody.parse(error.error).error_code; } function recognisedKeyFailure(error: APIError): boolean { - const body = error.error as - | { error_code?: unknown; message?: unknown } - | undefined; - const detail = [body?.error_code, body?.message, error.message] - .filter((value): value is string => typeof value === "string") + const body = apiErrorBody.parse(error.error); + const detail = [body.error_code, body.message, error.message] + .filter((value) => value !== undefined) .join(" "); return /(usage|credit|quota|allowance|billing|rate.?limit|limit.?exceeded|insufficient.?permission)/i.test( @@ -287,9 +313,9 @@ function recognisedKeyFailure(error: APIError): boolean { ); } -function describe(error: unknown): string { - if (error instanceof APIError) { - return `${error.status ?? "?"} ${errorCode(error) ?? error.message}`; +function describe(cause: unknown): string { + if (cause instanceof APIError) { + return `${cause.status ?? "?"} ${errorCode(cause) ?? cause.message}`; } - return error instanceof Error ? error.message : String(error); + return cause instanceof Error ? cause.message : String(cause); } diff --git a/apps/agent/agent/lib/crm.ts b/apps/agent/agent/lib/crm.ts index 91599187f..0c0e5e82e 100644 --- a/apps/agent/agent/lib/crm.ts +++ b/apps/agent/agent/lib/crm.ts @@ -1,4 +1,4 @@ -import { db, EnrichmentStatus } from "@crm/db"; +import { db, EnrichmentStatus, type Prisma } from "@crm/db"; import { domainOf, isDerivedName } from "./names"; import type { Person } from "./socials"; @@ -340,9 +340,7 @@ export async function setEnrichmentStatus( data: { enrichmentStatus: status, enrichmentError: error ?? null, - ...(status === EnrichmentStatus.COMPLETE - ? { enrichedAt: new Date() } - : {}), + enrichedAt: status === EnrichmentStatus.COMPLETE ? new Date() : undefined, }, }); } @@ -351,7 +349,7 @@ export async function writeTimelineNote( contactId: string, subject: string, body: string, - meta: Record = {}, + meta: Prisma.InputJsonObject = {}, ): Promise { const contact = await db.contact.findUnique({ where: { id: contactId }, diff --git a/apps/agent/agent/lib/custom-agent-dispatch.ts b/apps/agent/agent/lib/custom-agent-dispatch.ts index e351939f4..aff74d24e 100644 --- a/apps/agent/agent/lib/custom-agent-dispatch.ts +++ b/apps/agent/agent/lib/custom-agent-dispatch.ts @@ -1,7 +1,10 @@ import { db, Prisma } from "@crm/db"; -import { CRM_EVENT_CATALOG, isCrmEventType } from "@crm/db/crm-events"; +import { CRM_EVENT_CATALOG } from "@crm/db/crm-events"; import { lockIdempotencyKey } from "@crm/db/idempotency"; +import { crmEventTask } from "@crm/validation/agent-events"; +import { readAgentTriggerConfig } from "@crm/validation/agent-manifest"; import type { SendFn } from "eve/channels"; +import { z } from "zod"; import { DISPATCH } from "./dispatch-config"; import { DEPENDENCY_UNAVAILABLE, runDependencyFailure } from "./run-preflight"; import { @@ -17,6 +20,32 @@ const MAX_BUILDER_ATTEMPTS = DISPATCH.builder.maxAttempts; const BUILDER_LEASE_MS = DISPATCH.builder.leaseMs; const RUN_DELIVERY_LEASE_MS = DISPATCH.run.deliveryLeaseMs; +type BuilderMessageParts = Extract[0], readonly unknown[]>; + +const trimmedText = z.string().trim().catch(""); + +const builderInputResponse = z + .object({ + requestId: trimmedText, + optionId: trimmedText, + text: trimmedText, + }) + .catch({ requestId: "", optionId: "", text: "" }); + +const builderSubmissionMessage = z + .object({ + text: z.string().catch(""), + resources: z + .array(z.object({ label: z.string().catch("") }).catch({ label: "" })) + .catch([]), + inputResponse: builderInputResponse, + }) + .catch({ + text: "", + resources: [], + inputResponse: { requestId: "", optionId: "", text: "" }, + }); + export async function pendingBuilderSubmissionIds(): Promise { await recoverBuilderSubmissions(); const rows = await db.agentConversationSubmission.findMany({ @@ -219,7 +248,9 @@ export async function queueDueAgentRuns(now = new Date()): Promise { for (const trigger of triggers) { if (!trigger.nextRunAt) continue; const scheduledAt = trigger.nextRunAt; - const intervalMinutes = intervalOf(trigger.config); + const intervalMinutes = readAgentTriggerConfig( + trigger.config, + ).intervalMinutes; const nextRunAt = advance(scheduledAt, intervalMinutes, now); const idempotencyKey = `${trigger.id}:${scheduledAt.toISOString()}`; const claimed = await db.$transaction(async (tx) => { @@ -271,29 +302,22 @@ export async function queueEventAgentRuns( "id" | "contactId" | "companyId" | "dealId" | "payload" >, ): Promise { - const payload = recordOf(task.payload); - const eventType = payload.type; - const record = recordOf(payload.record); - const recordKind = textOf(record.kind); - const recordId = textOf(record.id); - const occurredAt = textOf(payload.occurredAt); + const parsed = crmEventTask.safeParse(task.payload); + if (!parsed.success) { + throw new Error("The queued agent event is invalid."); + } + + const { type: eventType, occurredAt, data } = parsed.data; + const recordKind = CRM_EVENT_CATALOG[eventType].recordKind; + const recordId = parsed.data.record.id; const occurredAtDate = new Date(occurredAt); const taskRecordId = recordKind === "contact" ? task.contactId : recordKind === "company" ? task.companyId - : recordKind === "deal" - ? task.dealId - : null; - if ( - !isCrmEventType(eventType) || - CRM_EVENT_CATALOG[eventType].recordKind !== recordKind || - !recordId || - taskRecordId !== recordId || - !occurredAt || - Number.isNaN(occurredAtDate.getTime()) - ) { + : task.dealId; + if (taskRecordId !== recordId) { throw new Error("The queued agent event is invalid."); } @@ -314,7 +338,7 @@ export async function queueEventAgentRuns( let matched = 0; for (const trigger of triggers) { - if (recordOf(trigger.config).event !== eventType) continue; + if (readAgentTriggerConfig(trigger.config).event !== eventType) continue; const idempotencyKey = `event:${task.id}:trigger:${trigger.id}`; const queued = await db.$transaction(async (tx) => { @@ -340,13 +364,9 @@ export async function queueEventAgentRuns( idempotencyKey, correlationId: `trigger:${trigger.id}:event:${task.id}`, input: { - event: { - type: eventType, - occurredAt, - data: recordOf(payload.data), - }, + event: { type: eventType, occurredAt, data }, record: { kind: recordKind, id: recordId }, - } as Prisma.InputJsonValue, + }, events: { create: { sequence: 0, @@ -812,14 +832,11 @@ async function recoverAgentRuns() { export function builderDeliveryMessage( submissionId: string, - value: unknown, + value: Prisma.JsonValue, attachments: readonly BuilderDeliveryAttachment[] = [], ): Parameters[0] { - const message = recordOf(value); - const inputResponse = recordOf(message.inputResponse); - const requestId = textOf(inputResponse.requestId); - const optionId = textOf(inputResponse.optionId); - const responseText = textOf(inputResponse.text); + const message = builderSubmissionMessage.parse(value); + const { requestId, optionId, text: responseText } = message.inputResponse; if (requestId && (optionId || responseText)) { return { inputResponses: [ @@ -831,18 +848,19 @@ export function builderDeliveryMessage( }; } - const text = typeof message.text === "string" ? message.text : ""; - const resources = Array.isArray(message.resources) ? message.resources : []; + const labels = message.resources + .map((resource) => resource.label) + .filter(Boolean); const context = [ `Submission id: ${submissionId}`, - resources.length > 0 - ? `Tagged resources: ${resources.map(resourceLabel).filter(Boolean).join(", ")}` + message.resources.length > 0 + ? `Tagged resources: ${labels.join(", ")}` : null, ] .filter(Boolean) .join("\n"); - const parts: Array> = [ - { type: "text", text: `${context}\n\n${text}` }, + const parts: BuilderMessageParts = [ + { type: "text", text: `${context}\n\n${message.text}` }, ]; for (const attachment of attachments) { @@ -854,18 +872,16 @@ export function builderDeliveryMessage( }); } - return parts as Parameters[0]; + return parts; } export function builderCommandType( commandType: string, - value: unknown, + value: Prisma.JsonValue, ): string { - const inputResponse = recordOf(recordOf(value).inputResponse); - return textOf(inputResponse.requestId) && - (textOf(inputResponse.optionId) || textOf(inputResponse.text)) - ? "CREATE_AGENT" - : commandType; + const { requestId, optionId, text } = + builderSubmissionMessage.parse(value).inputResponse; + return requestId && (optionId || text) ? "CREATE_AGENT" : commandType; } type BuilderDeliveryAttachment = { @@ -874,24 +890,6 @@ type BuilderDeliveryAttachment = { content: Uint8Array; }; -function resourceLabel(value: unknown): string | null { - const row = recordOf(value); - return typeof row.label === "string" ? row.label : null; -} - -function textOf(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function intervalOf(value: unknown): number { - const interval = recordOf(value).intervalMinutes; - return typeof interval === "number" && - Number.isFinite(interval) && - interval >= 1 - ? Math.min(interval, 525_600) - : 1440; -} - function advance(from: Date, intervalMinutes: number, now: Date): Date { const intervalMs = intervalMinutes * 60_000; const missed = Math.max( @@ -908,9 +906,3 @@ function idFromToken(token: string | undefined, marker: string): string | null { const id = token.slice(index + marker.length); return id || null; } - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index 5f0b9a048..eaf70d22e 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -252,20 +252,23 @@ export async function linkSession( return false; } -function reasonOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); +function reasonOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); } export function taskAuth(task: LeasedTask, base: AppAuth = APP_AUTH): AppAuth { + const records: Record = {}; + if (task.contactId) records.contactId = task.contactId; + if (task.companyId) records.companyId = task.companyId; + if (task.dealId) records.dealId = task.dealId; + return { ...base, attributes: { taskKind: task.kind, reason: task.reason, budget: String(task.budget), - ...(task.contactId ? { contactId: task.contactId } : {}), - ...(task.companyId ? { companyId: task.companyId } : {}), - ...(task.dealId ? { dealId: task.dealId } : {}), + ...records, }, }; } diff --git a/apps/agent/agent/lib/enrichment.ts b/apps/agent/agent/lib/enrichment.ts index 601a9e5f1..06efad02d 100644 --- a/apps/agent/agent/lib/enrichment.ts +++ b/apps/agent/agent/lib/enrichment.ts @@ -32,7 +32,7 @@ async function write( const data = { enrichmentStatus: status, enrichmentError: error, - ...(status === EnrichmentStatus.COMPLETE ? { enrichedAt: new Date() } : {}), + enrichedAt: status === EnrichmentStatus.COMPLETE ? new Date() : undefined, }; const guard: SettleGuard = onlyIfRunning diff --git a/apps/agent/agent/lib/evidence.ts b/apps/agent/agent/lib/evidence.ts index 67bcb4d3a..fcc3a5a82 100644 --- a/apps/agent/agent/lib/evidence.ts +++ b/apps/agent/agent/lib/evidence.ts @@ -19,7 +19,7 @@ type Weighting = { label: string; }; -export const WEIGHTS: Record = { +export const WEIGHTS = { "profile.email-match": { weight: 0.95, primary: true, @@ -75,7 +75,7 @@ export const WEIGHTS: Record = { primary: false, label: "another source disagrees", }, -}; +} satisfies Record; export type Evidence = { kind: EvidenceKind; diff --git a/apps/agent/agent/lib/facts.ts b/apps/agent/agent/lib/facts.ts index 3daf89890..10eef6992 100644 --- a/apps/agent/agent/lib/facts.ts +++ b/apps/agent/agent/lib/facts.ts @@ -18,15 +18,17 @@ const FIELDS = { export type FactField = keyof typeof FIELDS; +export type FactColumn = NonNullable<(typeof FIELDS)[FactField]["column"]>; + export const FACT_FIELDS = Object.keys(FIELDS) as FactField[]; export type FactSubject = { email: string | null; firstName: string; lastName: string | null; -} & Record; +} & { [Column in FactColumn]: string | null }; -export function factColumn(field: FactField): string | null { +export function factColumn(field: FactField): FactColumn | null { return FIELDS[field].column; } @@ -310,12 +312,8 @@ function humanOwns({ hasAgentFact, }: { field: FactField; - column: string | null; - contact: { - email: string | null; - firstName: string; - lastName: string | null; - } & Record; + column: FactColumn | null; + contact: FactSubject; hasAgentFact: boolean; }): boolean { if (field === "name") { @@ -334,8 +332,8 @@ function isEmpty({ hasAgentFact, }: { field: FactField; - column: string | null; - contact: Record; + column: FactColumn | null; + contact: FactSubject; hasAgentFact: boolean; }): boolean { if (hasAgentFact) return false; @@ -345,10 +343,10 @@ function isEmpty({ return !contact[column]; } -const HOST_ALIASES: Record = { - "twitter.com": "x.com", - "mobile.twitter.com": "x.com", -}; +const HOST_ALIASES = new Map([ + ["twitter.com", "x.com"], + ["mobile.twitter.com", "x.com"], +]); export function sameValue(a: string, b: string): boolean { return canonicalValue(a) === canonicalValue(b); @@ -363,7 +361,7 @@ export function canonicalValue(value: string): string { const host = url.host.replace(/^www\./, ""); const path = url.pathname.replace(/\/+$/, ""); - return `${HOST_ALIASES[host] ?? host}${path}`; + return `${HOST_ALIASES.get(host) ?? host}${path}`; } function asWebUrl(value: string): URL | null { diff --git a/apps/agent/agent/lib/focus.ts b/apps/agent/agent/lib/focus.ts index 439acbd8f..2217c237e 100644 --- a/apps/agent/agent/lib/focus.ts +++ b/apps/agent/agent/lib/focus.ts @@ -10,10 +10,12 @@ export const focus = defineState("crm.focus", () => ({ exhausted: false, })); -export function currentFocus(): { +export type CurrentFocus = { contactId: string | null; sessionId: string | null; -} { +}; + +export function currentFocus(): CurrentFocus { try { const state = focus.get(); return { contactId: state.contactId, sessionId: state.sessionId }; diff --git a/apps/agent/agent/lib/linkdapi.ts b/apps/agent/agent/lib/linkdapi.ts index 1b3be0cd3..aab557d18 100644 --- a/apps/agent/agent/lib/linkdapi.ts +++ b/apps/agent/agent/lib/linkdapi.ts @@ -1,6 +1,33 @@ +import { z } from "zod"; + const HOST = "linkdapi-best-unofficial-linkedin-api.p.rapidapi.com"; const TIMEOUT_MS = 20_000; +const json = z.json(); +const text = z.string().trim().min(1).nullable().catch(null); +const rawText = z.string().nullable().catch(null); +const count = z.number().nullable().catch(null); +const fields = z.record(z.string(), json).catch({}); +const optionalFields = z.record(z.string(), json).nullable().catch(null); +const rows = z.array(json).nullable().catch(null); + +const envelope = z + .object({ + success: z.boolean().nullable().catch(null), + data: json.catch(null), + }) + .catch({ success: null, data: null }); + +const experienceEnvelope = z + .object({ experience: rows, experiences: rows }) + .catch({ experience: null, experiences: null }); + +const companyLookup = z.object({ companies: rows }).catch({ companies: null }); + +type Json = z.infer; + +export type LinkedinFields = z.infer; + export type Profile = { slug: string; profileUrl: string; @@ -64,29 +91,29 @@ export function slugFromProfileUrl(raw: string | null): string | null { } export async function getProfile(slug: string): Promise> { - const result = await call("/api/v1/profile/overview", { - username: slug, - }); + const result = await call("/api/v1/profile/overview", { username: slug }); if (!result.ok) return result; - const d = result.data; + const d = fields.parse(result.data); return { ok: true, data: { slug, profileUrl: `https://www.linkedin.com/in/${slug}`, - fullName: str(d.fullName), - firstName: str(d.firstName), - lastName: str(d.lastName), - headline: str(d.headline), - location: str(d.location), - urn: str(d.urn), - followerCount: int(d.followerCount), - connectionsCount: int(d.connectionsCount), + fullName: text.parse(d.fullName), + firstName: text.parse(d.firstName), + lastName: text.parse(d.lastName), + headline: text.parse(d.headline), + location: text.parse(d.location), + urn: text.parse(d.urn), + followerCount: count.parse(d.followerCount), + connectionsCount: count.parse(d.connectionsCount), photoUrl: profilePhotoUrl(d), - positions: (d.CurrentPositions ?? []).flatMap((p) => - p?.name ? [{ name: p.name, url: str(p.url) }] : [], - ), + positions: (rows.parse(d.CurrentPositions) ?? []).flatMap((entry) => { + const position = fields.parse(entry); + const name = rawText.parse(position.name); + return name ? [{ name, url: text.parse(position.url) }] : []; + }), }, }; } @@ -94,70 +121,73 @@ export async function getProfile(slug: string): Promise> { export async function getExperience( urn: string, ): Promise> { - const result = await call("/api/v1/profile/full-experience", { - urn, - }); + const result = await call("/api/v1/profile/full-experience", { urn }); if (!result.ok) return result; - const payload = result.data; - const rows: RawExperienceRow[] = Array.isArray(payload) - ? payload - : (payload.experience ?? payload.experiences ?? []); + return { ok: true, data: experienceRows(result.data).map(toExperience) }; +} + +function experienceRows(payload: Json): Json[] { + if (Array.isArray(payload)) return payload; + const envelope = experienceEnvelope.parse(payload); + return envelope.experience ?? envelope.experiences ?? []; +} + +function toExperience(row: Json): Experience { + const entry = fields.parse(row); return { - ok: true, - data: rows.map( - (row): Experience => ({ - title: str(row?.title), - company: str(row?.companyName ?? row?.company), - dateRange: str(row?.dateRange ?? row?.duration), - location: str(row?.location), - }), - ), + title: text.parse(entry.title), + company: text.parse(entry.companyName ?? entry.company), + dateRange: text.parse(entry.dateRange ?? entry.duration), + location: text.parse(entry.location), }; } export async function lookupCompany( query: string, ): Promise> { - const result = await call("/api/v1/companies/name-lookup", { - query, - }); + const result = await call("/api/v1/companies/name-lookup", { query }); if (!result.ok) return result; + const companies = companyLookup.parse(result.data).companies ?? []; return { ok: true, - data: (result.data.companies ?? []).flatMap((c) => - c?.id ? [{ id: c.id, displayName: c.displayName ?? c.id }] : [], - ), + data: companies.flatMap((entry) => { + const company = fields.parse(entry); + const id = rawText.parse(company.id); + return id + ? [{ id, displayName: rawText.parse(company.displayName) ?? id }] + : []; + }), }; } export async function getCompany(nameOrId: string): Promise> { const numeric = /^\d+$/.test(nameOrId); - const result = await call("/api/v1/companies/company/info", { + const result = await call("/api/v1/companies/company/info", { [numeric ? "id" : "name"]: nameOrId, }); if (!result.ok) return result; - const d = result.data; + const d = fields.parse(result.data); return { ok: true, data: { - id: str(d.id), - name: str(d.name), - universalName: str(d.universalName), - tagline: str(d.tagline), - description: str(d.description), - linkedinUrl: str(d.linkedinUrl), + id: text.parse(d.id), + name: text.parse(d.name), + universalName: text.parse(d.universalName), + tagline: text.parse(d.tagline), + description: text.parse(d.description), + linkedinUrl: text.parse(d.linkedinUrl), }, }; } -async function call( +async function call( path: string, params: Record, -): Promise> { +): Promise> { const apiKey = key(); if (!apiKey) return { ok: false, missing: false, reason: "No RAPIDAPI_KEY." }; @@ -177,44 +207,29 @@ async function call( return { ok: false, missing: false, reason: `HTTP ${response.status}` }; } - const body = (await response.json()) as { - success?: boolean; - data?: T | null; - }; + const body = envelope.parse(await response.json()); - if (body.success !== true || body.data == null) { + if (body.success !== true || body.data === null) { return { ok: false, missing: true }; } return { ok: true, data: body.data }; - } catch (error) { - const aborted = error instanceof Error && error.name === "AbortError"; + } catch (cause) { + const aborted = cause instanceof Error && cause.name === "AbortError"; return { ok: false, missing: false, reason: aborted ? `Timed out after ${TIMEOUT_MS}ms.` - : error instanceof Error - ? error.message - : String(error), + : cause instanceof Error + ? cause.message + : String(cause), }; } finally { clearTimeout(timer); } } -type RawProfile = { - fullName?: unknown; - firstName?: unknown; - lastName?: unknown; - headline?: unknown; - location?: unknown; - urn?: unknown; - followerCount?: unknown; - connectionsCount?: unknown; - CurrentPositions?: { name?: string; url?: unknown }[] | null; -} & Record; - const PHOTO_KEYS = [ "profilePictureURL", "profilePicture", @@ -229,8 +244,8 @@ const PHOTO_KEYS = [ "avatar", ]; -export function profilePhotoUrl(raw: Record): string | null { - const byLowerKey = new Map(); +export function profilePhotoUrl(raw: LinkedinFields): string | null { + const byLowerKey = new Map(); for (const [key, value] of Object.entries(raw)) { byLowerKey.set(key.toLowerCase(), value); } @@ -251,9 +266,7 @@ export function profilePhotoUrl(raw: Record): string | null { return null; } -function firstUrl(value: unknown): string | null { - if (typeof value === "string") return str(value); - +function firstUrl(value: Json | undefined): string | null { if (Array.isArray(value)) { for (const entry of [...value].reverse()) { const found = firstUrl(entry); @@ -262,43 +275,16 @@ function firstUrl(value: unknown): string | null { return null; } - if (value && typeof value === "object") { - const record = value as Record; + const direct = text.parse(value); + if (direct) return direct; + + const nested = optionalFields.parse(value); + if (nested) { for (const key of ["url", "displayUrl", "src", "large", "original"]) { - const found = firstUrl(record[key]); + const found = firstUrl(nested[key]); if (found) return found; } } return null; } - -type RawExperienceRow = { - title?: unknown; - companyName?: unknown; - company?: unknown; - dateRange?: unknown; - duration?: unknown; - location?: unknown; -}; - -type RawExperience = - | RawExperienceRow[] - | { experience?: RawExperienceRow[]; experiences?: RawExperienceRow[] }; -type RawLookup = { companies?: { id?: string; displayName?: string }[] | null }; -type RawCompany = { - id?: unknown; - name?: unknown; - universalName?: unknown; - tagline?: unknown; - description?: unknown; - linkedinUrl?: unknown; -}; - -function str(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function int(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} diff --git a/apps/agent/agent/lib/perplexity.ts b/apps/agent/agent/lib/perplexity.ts index 4f1c235da..1e5acca4a 100644 --- a/apps/agent/agent/lib/perplexity.ts +++ b/apps/agent/agent/lib/perplexity.ts @@ -44,7 +44,7 @@ export async function ask( : []), { role: "user", content: question }, ], - ...(options.domains ? { search_domain_filter: options.domains } : {}), + search_domain_filter: options.domains, }), }); diff --git a/apps/agent/agent/lib/portrait-sources.ts b/apps/agent/agent/lib/portrait-sources.ts index 269e92e5d..d56ea6f91 100644 --- a/apps/agent/agent/lib/portrait-sources.ts +++ b/apps/agent/agent/lib/portrait-sources.ts @@ -1,4 +1,5 @@ -import { extract } from "./context-dev"; +import { z } from "zod"; +import { extract, type JsonSchema } from "./context-dev"; import { getProfile, slugFromProfileUrl } from "./linkdapi"; import { namesMatch } from "./names"; @@ -71,7 +72,7 @@ export async function findPortrait( return { found: false, tried }; } -const TEAM_SCHEMA = { +const TEAM_SCHEMA: JsonSchema = { type: "object", properties: { people: { @@ -93,6 +94,21 @@ const TEAM_SCHEMA = { required: ["people"], }; +const teamPage = z + .object({ + people: z + .array( + z + .object({ + name: z.string().nullable().catch(null), + photoUrl: z.string().nullable().catch(null), + }) + .catch({ name: null, photoUrl: null }), + ) + .catch([]), + }) + .catch({ people: [] }); + async function fromEmployerSite( subject: PortraitSubject, ): Promise { @@ -106,20 +122,13 @@ async function fromEmployerSite( if (result.outcome !== "found") return null; - const people = (result.data as { people?: unknown } | null)?.people; - if (!Array.isArray(people)) return null; - - for (const entry of people) { - if (!entry || typeof entry !== "object") continue; - const row = entry as Record; - - const name = typeof row.name === "string" ? row.name : null; - const photo = typeof row.photoUrl === "string" ? row.photoUrl : null; - if (!name || !photo) continue; + for (const person of teamPage.parse(result.data).people) { + const { name, photoUrl } = person; + if (!name || !photoUrl) continue; if (!namesMatch(name, subject.name)) continue; try { - const parsed = new URL(photo); + const parsed = new URL(photoUrl); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") continue; return { source: "employer-site", url: parsed.toString() }; } catch {} diff --git a/apps/agent/agent/lib/run-preflight.ts b/apps/agent/agent/lib/run-preflight.ts index c1d71468b..8a89e34b3 100644 --- a/apps/agent/agent/lib/run-preflight.ts +++ b/apps/agent/agent/lib/run-preflight.ts @@ -1,22 +1,25 @@ import { db } from "@crm/db"; -import { actionDependency } from "./agent-actions"; import { type AgentManifest, InvalidAgentManifest, parseAgentManifest, -} from "./agent-manifest"; +} from "@crm/validation/agent-manifest"; +import { + type AgentActionDependencyId, + actionDependency, +} from "./agent-actions"; import { slackConnected } from "./slack-connection"; export const DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE"; -const CHECKS: Record Promise> = { +const CHECKS = { slack: slackConnected, -}; +} satisfies Record Promise>; export async function missingRunDependencies( manifest: AgentManifest, ): Promise { - const required = new Map(); + const required = new Map(); for (const action of manifest.actions) { const dependency = actionDependency(action.type); @@ -25,8 +28,7 @@ export async function missingRunDependencies( const missing: string[] = []; for (const [id, fix] of required) { - const check = CHECKS[id]; - if (check && !(await check())) missing.push(fix); + if (!(await CHECKS[id]())) missing.push(fix); } return missing; diff --git a/apps/agent/agent/lib/run-runtime.ts b/apps/agent/agent/lib/run-runtime.ts index 75262e010..bc9d9c2f8 100644 --- a/apps/agent/agent/lib/run-runtime.ts +++ b/apps/agent/agent/lib/run-runtime.ts @@ -2,13 +2,14 @@ import { createHash, randomUUID } from "node:crypto"; import { ActivityType, db, type Prisma } from "@crm/db"; import type { AgentActionStatus, AgentTriggerType } from "@crm/db/enums"; import { lockIdempotencyKey } from "@crm/db/idempotency"; -import { readCompanyHistory, readDealHistory } from "./accounts"; import { - AGENT_ACTION_EXECUTORS, AGENT_ACTION_TYPES, - isAgentActionType, -} from "./agent-actions"; -import { parseAgentManifest } from "./agent-manifest"; + type AgentManifestResource, + parseAgentManifest, +} from "@crm/validation/agent-manifest"; +import { z } from "zod"; +import { readCompanyHistory, readDealHistory } from "./accounts"; +import { AGENT_ACTION_EXECUTORS, isAgentActionType } from "./agent-actions"; import { readCrmHistory } from "./crm"; import { DISPATCH } from "./dispatch-config"; import { searchCrm } from "./lookup"; @@ -24,13 +25,64 @@ const NO_ACTION_TRIGGER_TYPES = new Set( DISPATCH.run.noActionTriggerTypes, ); -type RunResource = { - kind: "integration" | "company" | "contact" | "deal"; +type RunRecordScope = "SELECTED" | "WORKSPACE"; + +const json = z.json(); + +type Json = z.infer; + +const runResult = z.record(z.string(), json); + +export type RunResult = z.infer; + +const storedRunResult = runResult.catch({}); + +export type SlackRunDestination = { + kind: "channel" | "user"; id: string; label: string; }; -type RunRecordScope = "SELECTED" | "WORKSPACE"; +type RunDataScope = { + mode: RunRecordScope; + resources: AgentManifestResource[]; +}; + +export type RunHistorySources = { + gmail: boolean; + calendar: boolean; +}; + +type SlackRequestBody = Record; + +type HashableRequest = Record; + +const optionalText = z.string().nullable().catch(null); + +const slackActionMetadata = z + .object({ clientMessageId: z.string().min(1).nullable().catch(null) }) + .catch({ clientMessageId: null }); + +const slackEnvelope = z + .object({ ok: z.boolean().nullable().catch(null), error: optionalText }) + .catch({ ok: null, error: null }); + +const slackOpenedConversation = z + .object({ + channel: z + .object({ id: z.string().min(1).nullable().catch(null) }) + .nullable() + .catch(null), + }) + .catch({ channel: null }); + +const slackPostedMessage = z + .object({ channel: optionalText, ts: optionalText }) + .catch({ channel: null, ts: null }); + +const noActionResult = z + .object({ noActionNeeded: optionalText }) + .catch({ noActionNeeded: null }); type RunActionRow = { id: string; @@ -381,8 +433,8 @@ export async function postRunSlackMessage( const { actionId, claimedAt } = claim; try { await assertRunActive(runId); - const clientMessageId = recordOf(claim.metadata).clientMessageId; - if (typeof clientMessageId !== "string" || !clientMessageId) { + const { clientMessageId } = slackActionMetadata.parse(claim.metadata); + if (!clientMessageId) { throw new Error("This Slack action is missing its replay key."); } const accessToken = await slackAccessToken(); @@ -540,7 +592,7 @@ async function failRunAction( export async function sendSlackMessage( accessToken: string, - destination: { kind: "channel" | "user"; id: string; label: string }, + destination: SlackRunDestination, text: string, clientMessageId: string, options: { @@ -552,37 +604,40 @@ export async function sendSlackMessage( const { fetcher = fetch, abortSignal, beforePost } = options; let channel = destination.id; if (destination.kind === "user") { - const opened = await slackApiRequest( - fetcher, - accessToken, - "conversations.open", - { users: destination.id, return_im: true }, - abortSignal, + const opened = slackOpenedConversation.parse( + await slackApiRequest( + fetcher, + accessToken, + "conversations.open", + { users: destination.id, return_im: true }, + abortSignal, + ), ); - const conversation = recordOf(opened.channel); - if (typeof conversation.id !== "string" || !conversation.id) { + if (!opened.channel?.id) { throw new Error("Slack did not return a direct-message channel."); } - channel = conversation.id; + channel = opened.channel.id; } await beforePost?.(); - const data = await slackApiRequest( - fetcher, - accessToken, - "chat.postMessage", - { - channel, - text, - client_msg_id: clientMessageId, - }, - abortSignal, + const posted = slackPostedMessage.parse( + await slackApiRequest( + fetcher, + accessToken, + "chat.postMessage", + { + channel, + text, + client_msg_id: clientMessageId, + }, + abortSignal, + ), ); - if (typeof data.channel !== "string" || typeof data.ts !== "string") { + if (posted.channel === null || posted.ts === null) { throw new Error("Slack returned an incomplete message receipt."); } - return { channel: data.channel, ts: data.ts }; + return { channel: posted.channel, ts: posted.ts }; } async function assertRunActive(runId: string): Promise { @@ -641,9 +696,9 @@ async function slackApiRequest( fetcher: typeof fetch, accessToken: string, method: string, - body: Record, + body: SlackRequestBody, abortSignal?: AbortSignal, -): Promise> { +): Promise { const response = await fetcher(`https://slack.com/api/${method}`, { method: "POST", headers: { @@ -655,9 +710,10 @@ async function slackApiRequest( }); if (!response.ok) throw new Error("Slack message delivery failed."); - const data = recordOf(await response.json()); - if (data.ok !== true) { - const reason = typeof data.error === "string" ? data.error : "rejected"; + const data = json.catch(null).parse(await response.json()); + const envelope = slackEnvelope.parse(data); + if (envelope.ok !== true) { + const reason = envelope.error ?? "rejected"; if (reason === "not_in_channel") { throw new Error( "The Slack bot is not in the selected channel. Invite the app to that channel and retry the run.", @@ -684,7 +740,7 @@ export async function stageRunResult( runId: string, input: { summary: string; - result?: Record | null; + result?: RunResult | null; noActionNeeded?: { reason: string } | null; }, ) { @@ -698,12 +754,10 @@ export async function stageRunResult( if (refusal) throw new Error(refusal); } - const result = { - ...(input.result ?? {}), - ...(input.noActionNeeded - ? { noActionNeeded: input.noActionNeeded.reason } - : {}), - }; + const result: RunResult = { ...(input.result ?? {}) }; + if (input.noActionNeeded) { + result.noActionNeeded = input.noActionNeeded.reason; + } await tx.agentRun.update({ where: { id: runId }, @@ -717,18 +771,19 @@ export async function stageRunResult( }); } -export function runReportedNoActionNeeded(result: unknown): boolean { - return ( - typeof result === "object" && - result !== null && - !Array.isArray(result) && - typeof (result as Record).noActionNeeded === "string" - ); +export function runResultOf(value: Prisma.JsonValue): RunResult { + return storedRunResult.parse(value); +} + +export function runReportedNoActionNeeded( + result: RunResult | null | undefined, +): boolean { + return noActionResult.parse(result).noActionNeeded !== null; } export async function finishRun( runId: string, - input: { summary: string; result?: Record | null }, + input: { summary: string; result?: RunResult | null }, ) { return db.$transaction(async (tx) => { const run = await lockAgentRun(tx, runId); @@ -834,7 +889,7 @@ async function requiredActionFailure( }); for (const action of external) { - const type = typeof action.type === "string" ? action.type : "unknown"; + const type = action.type; const rows = recorded.filter((row) => row.type === type); if (rows.some((row) => row.status === "SUCCEEDED")) continue; @@ -857,10 +912,7 @@ async function requiredActionFailure( type, provider: type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST ? "slack" : "crm", - summary: - typeof action.summary === "string" - ? action.summary - : `Perform ${type}`, + summary: action.summary, status: "FAILED", idempotencyKey: `run:${run.id}:required:${type}`, requestHash: hashRequest({ type, required: true }), @@ -928,12 +980,9 @@ async function failLockedRun( return { id: run.id, status: "FAILED" as const }; } -function manifestDataScope(value: unknown): { - mode: RunRecordScope; - resources: RunResource[]; -} { +function manifestDataScope(value: Prisma.JsonValue): RunDataScope { const scope = parseAgentManifest(value).dataScope; - const resources = scope.resources as RunResource[]; + const resources = scope.resources; const records = resources.filter( (resource) => resource.kind !== "integration", ); @@ -946,24 +995,23 @@ function manifestDataScope(value: unknown): { return { mode: scope.mode, resources }; } -function manifestActions(value: unknown) { +function manifestActions(value: Prisma.JsonValue) { return parseAgentManifest(value).actions; } -function externalManifestActions(value: unknown) { +function externalManifestActions(value: Prisma.JsonValue) { return manifestActions(value).filter( (action) => action.type !== AGENT_ACTION_TYPES.RUN_SUMMARY, ); } function assertActivityAllowed( - manifest: unknown, + manifest: Prisma.JsonValue, activityType: "NOTE" | "TASK", ) { const allowed = manifestActions(manifest).some( (action) => - action.type === "crm.activity.create" && - Array.isArray(action.activityTypes) && + action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE && action.activityTypes.includes(activityType), ); if (!allowed) { @@ -973,11 +1021,9 @@ function assertActivityAllowed( } } -export function approvedSlackDestination(manifest: unknown): { - kind: "channel" | "user"; - id: string; - label: string; -} { +export function approvedSlackDestination( + manifest: Prisma.JsonValue, +): SlackRunDestination { const scope = manifestDataScope(manifest); if ( !scope.resources.some( @@ -988,26 +1034,17 @@ export function approvedSlackDestination(manifest: unknown): { throw new Error("Agent version does not allow Slack."); } - const destinations = manifestActions(manifest).flatMap((action) => { - if (action.type !== "slack.message.post") return []; - const destination = recordOf(action.destination); - if ( - !["channel", "user"].includes(String(destination.kind)) || - typeof destination.id !== "string" || - !destination.id || - typeof destination.label !== "string" || - !destination.label - ) { - return []; - } - return [ - { - kind: destination.kind as "channel" | "user", - id: destination.id, - label: destination.label, - }, - ]; - }); + const destinations = manifestActions(manifest).flatMap((action) => + action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST + ? [ + { + kind: action.destination.kind, + id: action.destination.id, + label: action.destination.label, + }, + ] + : [], + ); const [destination] = destinations; if (!destination || destinations.length !== 1) { throw new Error( @@ -1020,7 +1057,7 @@ export function approvedSlackDestination(manifest: unknown): { function assertResourceAllowed( mode: RunRecordScope, - resources: RunResource[], + resources: AgentManifestResource[], input: { kind: "contact" | "company" | "deal"; id: string }, ) { if (mode === "WORKSPACE") return; @@ -1039,10 +1076,9 @@ function assertResourceAllowed( ); } -export function allowedHistorySources(resources: RunResource[]): { - gmail: boolean; - calendar: boolean; -} { +export function allowedHistorySources( + resources: AgentManifestResource[], +): RunHistorySources { const integrations = new Set( resources .filter((resource) => resource.kind === "integration") @@ -1100,12 +1136,6 @@ async function targetRecord(kind: "company" | "contact" | "deal", id: string) { : null; } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function actionRequestHash(input: { type: "NOTE" | "TASK"; targetKind: "company" | "contact" | "deal"; @@ -1124,7 +1154,7 @@ function actionRequestHash(input: { }); } -function hashRequest(input: Record): string { +function hashRequest(input: HashableRequest): string { return createHash("sha256").update(JSON.stringify(input)).digest("hex"); } diff --git a/apps/agent/agent/lib/session-purpose.ts b/apps/agent/agent/lib/session-purpose.ts index 2b85f12d1..f3b292881 100644 --- a/apps/agent/agent/lib/session-purpose.ts +++ b/apps/agent/agent/lib/session-purpose.ts @@ -1,18 +1,24 @@ +import { z } from "zod"; + export type SessionPurpose = "builder" | "team-agent" | "research"; +type SessionAttributes = Readonly>; + type PurposeContext = { readonly session: { readonly auth: { readonly current: { - readonly attributes: Readonly>; + readonly attributes: SessionAttributes; } | null; readonly initiator: { - readonly attributes: Readonly>; + readonly attributes: SessionAttributes; } | null; }; }; }; +const attributeText = z.string().trim().min(1).nullable().catch(null); + export function purposeOf(ctx: PurposeContext): SessionPurpose { const purpose = attribute(ctx, "purpose"); if (purpose === "builder" || purpose === "team-agent") return purpose; @@ -20,13 +26,10 @@ export function purposeOf(ctx: PurposeContext): SessionPurpose { } export function attribute(ctx: PurposeContext, key: string): string | null { - const current = ctx.session.auth.current?.attributes[key]; - if (typeof current === "string" && current.trim()) return current.trim(); - - const initiator = ctx.session.auth.initiator?.attributes[key]; - return typeof initiator === "string" && initiator.trim() - ? initiator.trim() - : null; + return ( + attributeText.parse(ctx.session.auth.current?.attributes[key]) ?? + attributeText.parse(ctx.session.auth.initiator?.attributes[key]) + ); } export function requireAttribute(ctx: PurposeContext, key: string): string { diff --git a/apps/agent/agent/lib/slack-join-task.ts b/apps/agent/agent/lib/slack-join-task.ts index b2482bab3..27d29c653 100644 --- a/apps/agent/agent/lib/slack-join-task.ts +++ b/apps/agent/agent/lib/slack-join-task.ts @@ -1,7 +1,10 @@ +import type { Prisma } from "@crm/db"; import { parse, schemas } from "@crm/validation"; import { joinSlackChannel } from "./slack-membership"; -export async function runSlackChannelJoin(value: unknown): Promise { +export async function runSlackChannelJoin( + value: Prisma.JsonValue, +): Promise { const { channelId, channelName } = parse( schemas.slack.joinPayload, value, diff --git a/apps/agent/agent/lib/socials.ts b/apps/agent/agent/lib/socials.ts index 2364ebd5a..a59e5906c 100644 --- a/apps/agent/agent/lib/socials.ts +++ b/apps/agent/agent/lib/socials.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { Evidence } from "./evidence"; import { looksLikeSameCompany, @@ -6,6 +7,27 @@ import { } from "./names"; import { ask } from "./perplexity"; +const text = z.string().trim().min(1).nullable().catch(null); +const rawText = z.string().nullable().catch(null); + +const githubAccount = z + .object({ + login: rawText, + name: text, + company: text, + blog: text, + bio: text, + type: rawText, + }) + .catch({ + login: null, + name: null, + company: null, + blog: null, + bio: null, + type: null, + }); + export type Network = "x" | "github"; export type SocialProfile = { @@ -159,18 +181,16 @@ async function fetchGithubUser( handle: string, ): Promise<{ ok: true; user: GithubUser } | { ok: false; reason: string }> { const token = process.env.GITHUB_TOKEN; + const headers = new Headers({ + accept: "application/vnd.github+json", + "user-agent": "comp-ai-crm-research-agent", + }); + if (token) headers.set("authorization", `Bearer ${token}`); try { const response = await fetch( `https://api.github.com/users/${encodeURIComponent(handle)}`, - { - headers: { - accept: "application/vnd.github+json", - "user-agent": "comp-ai-crm-research-agent", - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, - signal: AbortSignal.timeout(15_000), - }, + { headers, signal: AbortSignal.timeout(15_000) }, ); if (response.status === 404) { @@ -186,23 +206,23 @@ async function fetchGithubUser( return { ok: false, reason: `GitHub returned HTTP ${response.status}.` }; } - const body = (await response.json()) as Record; + const account = githubAccount.parse(await response.json()); return { ok: true, user: { - login: String(body.login ?? handle), - name: str(body.name), - company: str(body.company), - blog: str(body.blog), - bio: str(body.bio), - type: String(body.type ?? "User"), + login: account.login ?? handle, + name: account.name, + company: account.company, + blog: account.blog, + bio: account.bio, + type: account.type ?? "User", }, }; - } catch (error) { + } catch (cause) { return { ok: false, - reason: error instanceof Error ? error.message : String(error), + reason: cause instanceof Error ? cause.message : String(cause), }; } } @@ -396,7 +416,3 @@ export async function findSocialCandidates( return { candidates, citations: answer.data.citations }; } - -function str(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} diff --git a/apps/agent/agent/lib/tasks.ts b/apps/agent/agent/lib/tasks.ts index 89ab2d518..a29637f98 100644 --- a/apps/agent/agent/lib/tasks.ts +++ b/apps/agent/agent/lib/tasks.ts @@ -94,7 +94,7 @@ export async function completeTask( data: { finishedAt: new Date(), outcome: outcome.slice(0, 500), - ...(sessionId ? { sessionId } : {}), + sessionId: sessionId || undefined, }, }); diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index 453e3cf39..67d1d926b 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -1,6 +1,6 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; +import { AGENT_ACTION_TYPES } from "@crm/validation/agent-manifest"; import { z } from "zod"; -import { AGENT_ACTION_TYPES } from "../../../lib/agent-actions"; import type { DraftAgentInput } from "../../../lib/builder-runtime"; const recordResource = z.object({ diff --git a/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts b/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts index f182fa00a..57e301438 100644 --- a/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts +++ b/apps/agent/agent/subagents/agent_runner/tools/finish_run.ts @@ -8,7 +8,7 @@ export default defineTool({ "Finish this run successfully with its concise summary and structured result. Set noActionNeeded when the trigger fired but this run's condition was not met, so none of the declared actions applied — an agent that watches for something is expected to do nothing when that thing did not happen.", inputSchema: z.object({ summary: z.string().trim().min(1).max(1000), - result: z.record(z.string(), z.unknown()).nullish(), + result: z.record(z.string(), z.json()).nullish(), noActionNeeded: z .object({ reason: z.string().trim().min(1).max(500), diff --git a/apps/agent/agent/tools/enrich_company.ts b/apps/agent/agent/tools/enrich_company.ts index 58fc2acd8..02502f5d0 100644 --- a/apps/agent/agent/tools/enrich_company.ts +++ b/apps/agent/agent/tools/enrich_company.ts @@ -24,9 +24,7 @@ export default defineTool({ return { enriched: false as const, reason: result.reason, - ...(result.retryable === undefined - ? {} - : { retryable: result.retryable }), + retryable: result.retryable, }; } diff --git a/apps/agent/agent/tools/identify_contact.ts b/apps/agent/agent/tools/identify_contact.ts index e31f7d281..c6953fd92 100644 --- a/apps/agent/agent/tools/identify_contact.ts +++ b/apps/agent/agent/tools/identify_contact.ts @@ -44,7 +44,7 @@ export default defineTool({ band: result.band, score: Number(result.score.toFixed(2)), rationale: result.rationale, - ...(result.reason ? { reason: result.reason } : {}), + reason: result.reason || undefined, }; }, }); diff --git a/apps/agent/agent/tools/record_fact.ts b/apps/agent/agent/tools/record_fact.ts index 899400f21..efe6612d7 100644 --- a/apps/agent/agent/tools/record_fact.ts +++ b/apps/agent/agent/tools/record_fact.ts @@ -64,7 +64,7 @@ export default defineTool({ band: result.band, score: Number(result.score.toFixed(2)), rationale: result.rationale, - ...(result.reason ? { reason: result.reason } : {}), + reason: result.reason || undefined, }; }, }); diff --git a/apps/agent/agent/tools/research_company.ts b/apps/agent/agent/tools/research_company.ts index 05b09a715..fd6ea10cb 100644 --- a/apps/agent/agent/tools/research_company.ts +++ b/apps/agent/agent/tools/research_company.ts @@ -1,10 +1,10 @@ import { ActivityType, db } from "@crm/db"; import { defineTool } from "eve/tools"; import { z } from "zod"; -import { extract } from "../lib/context-dev"; +import { extract, type JsonSchema } from "../lib/context-dev"; import { spend } from "../lib/focus"; -const RESEARCH_SCHEMA = { +const RESEARCH_SCHEMA: JsonSchema = { type: "object", properties: { positioning: { @@ -31,13 +31,43 @@ const RESEARCH_SCHEMA = { }, }, required: ["positioning"], -} as const; +}; const RESEARCH_INSTRUCTIONS = "Read this company's marketing site and answer as a salesperson preparing " + "for a first call. Be specific and factual; leave a field empty rather than " + "guessing."; +const briefText = z.string().trim().min(1).nullable().catch(null); + +const briefList = z + .array(z.string().nullable().catch(null)) + .transform((items) => items.filter((item) => item !== null)) + .catch([]); + +const briefScalar = z + .union([z.string(), z.number(), z.boolean()]) + .nullable() + .catch(null); + +const researchBrief = z + .object({ + positioning: briefText, + pricingModel: briefText, + targetCustomer: briefText, + notableCustomers: briefList, + recentNews: briefList, + }) + .catch({ + positioning: null, + pricingModel: null, + targetCustomer: null, + notableCustomers: [], + recentNews: [], + }); + +type ResearchBrief = z.infer; + export default defineTool({ description: "Read a company's marketing site and write a research brief to its timeline: positioning, pricing, who they sell to, notable customers, recent news.", @@ -72,11 +102,7 @@ export default defineTool({ const charge = spend(2); if (!charge.ok) return { written: false as const, reason: charge.reason }; - const result = await extract( - url, - RESEARCH_SCHEMA as unknown as Record, - RESEARCH_INSTRUCTIONS, - ); + const result = await extract(url, RESEARCH_SCHEMA, RESEARCH_INSTRUCTIONS); if (result.outcome === "failed") { return { written: false as const, reason: result.reason }; @@ -90,11 +116,15 @@ export default defineTool({ if (!author) return { written: false as const, reason: "No user to attribute to." }; + const scalar = briefScalar.parse(result.data); const activity = await db.activity.create({ data: { type: ActivityType.ENRICHMENT, subject: `Research brief — ${company.name}`, - body: formatBrief(result.data), + body: + scalar === null + ? formatBrief(researchBrief.parse(result.data)) + : String(scalar), occurredAt: new Date(), companyId: company.id, createdById: author, @@ -117,40 +147,21 @@ export default defineTool({ }, }); -function formatBrief(data: unknown): string { - if (typeof data !== "object" || data === null) return String(data ?? ""); - - const brief = data as { - positioning?: unknown; - pricingModel?: unknown; - targetCustomer?: unknown; - notableCustomers?: unknown; - recentNews?: unknown; - }; - +function formatBrief(brief: ResearchBrief): string { const lines: string[] = []; - const text = (value: unknown) => - typeof value === "string" && value.trim() ? value.trim() : null; - const list = (value: unknown) => - Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; - const positioning = text(brief.positioning); - if (positioning) lines.push(positioning); + if (brief.positioning) lines.push(brief.positioning); + if (brief.pricingModel) lines.push(`Pricing: ${brief.pricingModel}`); + if (brief.targetCustomer) lines.push(`Sells to: ${brief.targetCustomer}`); - const pricing = text(brief.pricingModel); - if (pricing) lines.push(`Pricing: ${pricing}`); - - const target = text(brief.targetCustomer); - if (target) lines.push(`Sells to: ${target}`); - - const customers = list(brief.notableCustomers); - if (customers.length > 0) lines.push(`Customers: ${customers.join(", ")}`); + if (brief.notableCustomers.length > 0) { + lines.push(`Customers: ${brief.notableCustomers.join(", ")}`); + } - const news = list(brief.recentNews); - if (news.length > 0) { - lines.push(`Recently:\n${news.map((item) => `• ${item}`).join("\n")}`); + if (brief.recentNews.length > 0) { + lines.push( + `Recently:\n${brief.recentNews.map((item) => `• ${item}`).join("\n")}`, + ); } return lines.join("\n\n"); diff --git a/apps/agent/evals/agent-builder.eval.ts b/apps/agent/evals/agent-builder.eval.ts index f32c620e8..6612ee907 100644 --- a/apps/agent/evals/agent-builder.eval.ts +++ b/apps/agent/evals/agent-builder.eval.ts @@ -1,4 +1,5 @@ import { db } from "@crm/db"; +import { agentManifest } from "@crm/validation/agent-manifest"; import { defineEval } from "eve/evals"; import { equals, satisfies } from "eve/evals/expect"; @@ -62,8 +63,8 @@ export default defineEval({ sessionId = await waitForBuilderSession(conversation.id, t.signal); await t.require( sessionId, - satisfies( - (value) => typeof value === "string", + satisfies( + (value) => value !== null, "builder session started", ), ); @@ -98,15 +99,12 @@ export default defineEval({ t.check( saved.agent?.versions[0]?.manifest, satisfies((value) => { - const manifest = recordOf(value); - const scope = recordOf(manifest.dataScope); - const actions = Array.isArray(manifest.actions) - ? manifest.actions.map(recordOf) - : []; + const parsed = agentManifest.safeParse(value); return ( - scope.mode === "WORKSPACE" && - actions.length === 1 && - actions[0]?.type === "run.summary" + parsed.success && + parsed.data.dataScope.mode === "WORKSPACE" && + parsed.data.actions.length === 1 && + parsed.data.actions[0]?.type === "run.summary" ); }, "saved manifest is workspace-scoped and side-effect-free"), ); @@ -166,9 +164,3 @@ async function cleanupBuilderEval( } await db.user.deleteMany({ where: { id: userId } }); } - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/agent/scripts/backfill-brand-images.ts b/apps/agent/scripts/backfill-brand-images.ts index 7b8d31680..8f5a9a3c7 100644 --- a/apps/agent/scripts/backfill-brand-images.ts +++ b/apps/agent/scripts/backfill-brand-images.ts @@ -1,8 +1,15 @@ import { db } from "@crm/db"; import { blobEnabled, isMirrored, mirror } from "@crm/db/blob"; -import { COMPANY_IMAGE_FIELDS } from "@crm/db/images"; +import { COMPANY_IMAGE_FIELDS, type CompanyImageField } from "@crm/db/images"; +import { z } from "zod"; import { brandToUpdate } from "../agent/lib/brand-mapping"; +type CompanyImagePatch = Partial< + Record +>; + +const storedText = z.string().nullable().catch(null); + if (!blobEnabled()) { console.warn( "No BLOB_READ_WRITE_TOKEN — icon tone and dark artwork will still be " + @@ -54,14 +61,12 @@ for (const row of rows) { nameIsPlaceholder: false, }); - const patch: Record = { - ...(typeof update.iconDarkUrl === "string" - ? { iconDarkUrl: update.iconDarkUrl } - : {}), - ...(typeof update.iconTone === "string" - ? { iconTone: update.iconTone } - : {}), - }; + const patch: CompanyImagePatch = {}; + const iconDarkUrl = storedText.parse(update.iconDarkUrl); + const iconTone = storedText.parse(update.iconTone); + + if (iconDarkUrl !== null) patch.iconDarkUrl = iconDarkUrl; + if (iconTone !== null) patch.iconTone = iconTone; for (const slot of COMPANY_IMAGE_FIELDS) { const current = row.company[slot]; diff --git a/apps/agent/test/channel-auth.spec.ts b/apps/agent/test/channel-auth.spec.ts index 0108b5d8e..06fa37ae4 100644 --- a/apps/agent/test/channel-auth.spec.ts +++ b/apps/agent/test/channel-auth.spec.ts @@ -9,11 +9,21 @@ import { isAutomated } from "../agent/lib/approval"; const SECRET = "test-secret-at-least-long-enough-to-be-a-secret"; const auth = repFromCrm(SECRET); -async function mint( - claims: Record, - secret = SECRET, -): Promise { - const encode = (value: object) => +type BridgeHeader = { alg: string; typ: string }; + +type BridgeClaims = { + iss: string; + aud: string; + sub: string | undefined; + email: string; + name: string; + iat: number; + nbf: number; + exp: number; +}; + +async function mint(claims: BridgeClaims, secret = SECRET): Promise { + const encode = (value: BridgeClaims | BridgeHeader) => Buffer.from(JSON.stringify(value)).toString("base64url"); const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`; @@ -40,7 +50,7 @@ function request(token: string | null): Request { }); } -function claims(overrides: Record = {}) { +function claims(overrides: Partial = {}): BridgeClaims { const now = Math.floor(Date.now() / 1000); return { iss: BRIDGE_ISSUER, diff --git a/apps/agent/test/durable-agent-runtime.integration.spec.ts b/apps/agent/test/durable-agent-runtime.integration.spec.ts index 669f10f7e..27f74db78 100644 --- a/apps/agent/test/durable-agent-runtime.integration.spec.ts +++ b/apps/agent/test/durable-agent-runtime.integration.spec.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; import type { SendFn } from "eve/channels"; +import { z } from "zod"; import audit from "../agent/hooks/audit"; import { builderToken, @@ -15,9 +16,12 @@ import { import { createRunActivity, finishRun, + runResultOf, stageRunResult, } from "../agent/lib/run-runtime"; +const attachmentBytes = z.object({ data: z.instanceof(Uint8Array) }); + const suffix = crypto.randomUUID(); const userId = `durable-runtime-user-${suffix}`; const domain = `durable-${suffix}.example.test`; @@ -503,8 +507,8 @@ describe("durable custom-agent runtime", () => { select: { id: true, submissions: { select: { id: true } } }, }); builderConversationIds.push(conversation.id); - let delivered: unknown; - const send = (async (input: unknown) => { + let delivered: Parameters[0] | null = null; + const send = (async (input: Parameters[0]) => { delivered = input; return { id: `durable-session-${suffix}-attachment` }; }) as unknown as SendFn; @@ -520,7 +524,7 @@ describe("durable custom-agent runtime", () => { mediaType: "text/plain", filename: "brief.txt", }); - expect(Buffer.from(recordOf(parts[1]).data as Uint8Array)).toEqual(content); + expect(Buffer.from(attachmentBytes.parse(parts[1]).data)).toEqual(content); }); it("ingests a replayed Eve event and its usage exactly once", async () => { @@ -536,7 +540,7 @@ describe("durable custom-agent runtime", () => { session: { id: string; auth: { - current: { attributes: Record }; + current: { attributes: Record }; initiator: null; }; }; @@ -590,7 +594,7 @@ describe("durable custom-agent runtime", () => { id: string; parent?: unknown; auth: { - current: { attributes: Record }; + current: { attributes: Record }; initiator: null; }; }; @@ -689,7 +693,7 @@ describe("durable custom-agent runtime", () => { await satisfyRequiredActivity(run.id, "staged-success"); await finishRun(run.id, { summary: staged.summary ?? "Staged safely", - result: staged.result as Record, + result: runResultOf(staged.result), }); expect( await db.agentRun.findUniqueOrThrow({ where: { id: run.id } }), @@ -766,7 +770,7 @@ describe("durable custom-agent runtime", () => { const finished = await finishRun(run.id, { summary: staged.summary ?? "The deal was already closed", - result: staged.result as Record, + result: runResultOf(staged.result), }); expect(finished).toEqual({ id: run.id, status: "SUCCEEDED" }); expect(await db.agentAction.count({ where: { runId: run.id } })).toBe(0); @@ -848,9 +852,3 @@ describe("durable custom-agent runtime", () => { ).toBe(0); }); }); - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/agent/test/e2e/dispatch.e2e.ts b/apps/agent/test/e2e/dispatch.e2e.ts index 10e0db9e7..6ae373760 100644 --- a/apps/agent/test/e2e/dispatch.e2e.ts +++ b/apps/agent/test/e2e/dispatch.e2e.ts @@ -205,7 +205,7 @@ async function main() { } finally { const failure = await cleanUp(agentId, companyId, dealId, taskId).then( () => null, - (error: unknown) => reasonOf(error), + (cause: unknown) => reasonOf(cause), ); record( "cleanup leaves no seeded rows", diff --git a/apps/agent/test/e2e/e2e-agents.ts b/apps/agent/test/e2e/e2e-agents.ts index 5bb131ff2..c70427d34 100644 --- a/apps/agent/test/e2e/e2e-agents.ts +++ b/apps/agent/test/e2e/e2e-agents.ts @@ -49,6 +49,6 @@ export async function removeEventRuns( return runIds.length; } -export function reasonOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); +export function reasonOf(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); } diff --git a/apps/agent/test/e2e/slack-join.e2e.ts b/apps/agent/test/e2e/slack-join.e2e.ts index 5874daf2a..be17cc4ef 100644 --- a/apps/agent/test/e2e/slack-join.e2e.ts +++ b/apps/agent/test/e2e/slack-join.e2e.ts @@ -97,8 +97,8 @@ async function main() { type: "slack.channel.join", channelId: E2E.slackJoin.bogusChannelId, channelName: E2E.slackJoin.bogusChannelName, - }).catch((error: unknown) => - error instanceof Error ? error.message : String(error), + }).catch((cause: unknown) => + cause instanceof Error ? cause.message : String(cause), ); record( "a channel that does not exist is refused", diff --git a/apps/agent/test/slack-people.integration.spec.ts b/apps/agent/test/slack-people.integration.spec.ts index ce618b6a4..91fc768d7 100644 --- a/apps/agent/test/slack-people.integration.spec.ts +++ b/apps/agent/test/slack-people.integration.spec.ts @@ -62,7 +62,21 @@ afterEach(async () => { } }); -function slackReply(body: unknown): Response { +type SlackChannelReply = { + id: string; + name: string; + is_member: boolean; + unknown?: number; +}; + +type SlackListReply = { + ok: boolean; + error?: string; + channels?: SlackChannelReply[]; + response_metadata?: { next_cursor: string }; +}; + +function slackReply(body: SlackListReply): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, @@ -187,7 +201,10 @@ describe("persistSlackChannels", () => { describe("refreshSlackChannels", () => { it("aborts a stalled Slack list request", async () => { let signal: AbortSignal | null = null; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + globalThis.fetch = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { signal = init?.signal ?? null; return slackReply({ ok: true, channels: [] }); }) as typeof fetch; diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 23510a356..5fc2353bc 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -1,4 +1,5 @@ import { ActivityType, type Db, type Prisma } from "@crm/db"; +import { activityMeta } from "@crm/validation/activity-meta"; import { BadRequestException, Injectable, @@ -80,7 +81,8 @@ export class ActivitiesService { const rows = await this.db.activity.findMany({ where, take: input.limit + 1, - ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + cursor: input.cursor ? { id: input.cursor } : undefined, + skip: input.cursor ? 1 : undefined, orderBy: [ { occurredAt: { sort: "desc", nulls: "last" } }, { id: "desc" }, @@ -273,7 +275,7 @@ function serializeEntry(entry: Entry) { dueAt: entry.dueAt?.toISOString() ?? null, completedAt: entry.completedAt?.toISOString() ?? null, createdAt: entry.createdAt.toISOString(), - meta: entry.meta as Record | null, + meta: activityMeta.parse(entry.meta), emailThread: entry.emailThread ? { diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 9d6521fd8..a211d5718 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,11 +1,13 @@ import type { Db, Prisma } from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; import { schemas } from "@crm/validation"; +import { readAgentManifestSummary } from "@crm/validation/agent-manifest"; import { BadRequestException, Injectable, NotFoundException, } from "@nestjs/common"; +import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; import { AgentAccessService } from "./agent-access.service"; import { AgentTriggerService } from "./agent-trigger.service"; @@ -20,6 +22,18 @@ import { const INSTRUCTIONS_PATH = "agent/instructions.md"; +type VersionMetadata = { + name?: string; + description?: string | null; +}; + +const capabilityName = z.string().nullable().catch(null); + +const versionValidation = z + .object({ capabilities: z.array(z.json()).catch([]) }) + .catchall(z.json()) + .catch({ capabilities: [] }); + @Injectable() export class AgentDefinitionsService { constructor( @@ -131,6 +145,7 @@ export class AgentDefinitionsService { if (!row) throw new NotFoundException(`No agent with id ${id}.`); const { versions, ...agent } = row; + const draft = versions[0]; return { ...agent, @@ -144,7 +159,10 @@ export class AgentDefinitionsService { deployedAt: agent.currentVersion.deployedAt?.toISOString() ?? null, } : null, - reviewVersion: agent.status === "DRAFT" ? (versions[0] ?? null) : null, + reviewVersion: + agent.status === "DRAFT" && draft + ? { ...draft, manifest: readAgentManifestSummary(draft.manifest) } + : null, triggers: agent.triggers.map((trigger) => ({ ...trigger, nextRunAt: trigger.nextRunAt?.toISOString() ?? null, @@ -152,7 +170,7 @@ export class AgentDefinitionsService { })), runCount: agent._count.runs, capabilities: readCapabilities( - agent.currentVersion?.manifest ?? versions[0]?.manifest, + agent.currentVersion?.manifest ?? draft?.manifest, ), }; } @@ -793,28 +811,22 @@ export class AgentDefinitionsService { } } -function versionMetadata(manifest: unknown): { - name?: string; - description?: string | null; -} { - if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { - return {}; - } - - const record = manifest as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; +function versionMetadata(manifest: Prisma.JsonValue): VersionMetadata { + const summary = readAgentManifestSummary(manifest); + const name = summary.name?.trim() ?? ""; const description = - typeof record.description === "string" - ? record.description.trim() || null - : undefined; + summary.description === undefined + ? undefined + : summary.description.trim() || null; - return { - ...(name ? { name } : {}), - ...(description !== undefined ? { description } : {}), - }; + const metadata: VersionMetadata = {}; + if (name) metadata.name = name; + if (description !== undefined) metadata.description = description; + + return metadata; } -function readCapabilities(manifest: unknown) { +function readCapabilities(manifest: Prisma.JsonValue | undefined) { const parsed = schemas.agents.capabilities.safeParse(manifest); if (!parsed.success) { @@ -857,28 +869,24 @@ function reviseSummary(input: { } function reviseValidation( - validation: unknown, + validation: Prisma.JsonValue, before: string[], after: string[], ): Prisma.InputJsonValue { const removed = before.filter((type) => !after.includes(type)); - const base = - validation && typeof validation === "object" && !Array.isArray(validation) - ? (validation as Record) - : {}; + const base = versionValidation.parse(validation); - const capabilities = Array.isArray(base.capabilities) - ? base.capabilities.filter( - (entry) => typeof entry === "string" && !removed.includes(entry), - ) - : []; + const capabilities = base.capabilities.flatMap((entry) => { + const name = capabilityName.parse(entry); + return name !== null && !removed.includes(name) ? [name] : []; + }); return { ...base, status: "passed", checkedAt: new Date().toISOString(), capabilities, - } as Prisma.InputJsonValue; + }; } async function nextVersionNumber( diff --git a/apps/api/src/agent/agent-queue.service.ts b/apps/api/src/agent/agent-queue.service.ts index 2089400dd..e1bfa2c8b 100644 --- a/apps/api/src/agent/agent-queue.service.ts +++ b/apps/api/src/agent/agent-queue.service.ts @@ -1,4 +1,4 @@ -import type { Db } from "@crm/db"; +import type { Db, Prisma } from "@crm/db"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -18,12 +18,12 @@ export class AgentQueueService { companyId?: string; contactId?: string; }): Promise { + const where: Prisma.AgentTaskWhereInput = { finishedAt: null }; + if (subject.companyId) where.companyId = subject.companyId; + if (subject.contactId) where.contactId = subject.contactId; + const row = await this.db.agentTask.findFirst({ - where: { - finishedAt: null, - ...(subject.companyId ? { companyId: subject.companyId } : {}), - ...(subject.contactId ? { contactId: subject.contactId } : {}), - }, + where, select: { id: true }, }); diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index 1d5928dda..4b8005138 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -316,11 +316,13 @@ export class AgentTriggerService { finishedAt: null, [subject]: { in: ids }, }, - select: { [subject]: true }, + select: { companyId: true, contactId: true }, }); const taken = new Set( - outstanding.map((row) => (row as Record)[subject]), + outstanding.map((row) => + subject === "contactId" ? row.contactId : row.companyId, + ), ); const fresh = ids.filter((id) => !taken.has(id)); diff --git a/apps/api/src/agent/research-key.service.ts b/apps/api/src/agent/research-key.service.ts index fd9e3edb1..83808cf81 100644 --- a/apps/api/src/agent/research-key.service.ts +++ b/apps/api/src/agent/research-key.service.ts @@ -1,8 +1,16 @@ import { Injectable, Logger } from "@nestjs/common"; +import { z } from "zod"; import { bridge } from "./bridge"; const VERIFY_TIMEOUT_MS = 20_000; +const verifyAnswer = z + .object({ + outcome: z.string().nullable().catch(null), + reason: z.string().nullable().catch(null), + }) + .catch({ outcome: null, reason: null }); + export type KeyCheck = | { outcome: "valid" } | { outcome: "invalid"; reason: string } @@ -44,26 +52,18 @@ export class ResearchKeyService { return this.cannotTell(`The agent answered ${response.status}.`); } - const body = (await response.json()) as { - outcome?: string; - reason?: string; - }; + const body = verifyAnswer.parse(await response.json()); if (body.outcome === "valid") return { outcome: "valid" }; if (body.outcome === "invalid") { return { outcome: "invalid", - reason: - typeof body.reason === "string" && body.reason - ? body.reason - : "Context did not recognise that API key.", + reason: body.reason || "Context did not recognise that API key.", }; } - return this.cannotTell( - typeof body.reason === "string" ? body.reason : "No answer.", - ); + return this.cannotTell(body.reason ?? "No answer."); } catch (error) { return this.cannotTell( error instanceof Error ? error.message : String(error), diff --git a/apps/api/src/backfill/backfill.service.ts b/apps/api/src/backfill/backfill.service.ts index 8f4b96071..303c82375 100644 --- a/apps/api/src/backfill/backfill.service.ts +++ b/apps/api/src/backfill/backfill.service.ts @@ -273,12 +273,14 @@ export class BackfillService implements OnModuleInit { .map((row) => row.companyId) .filter((id): id is string => id !== null); - return { + const where: Prisma.CompanyWhereInput = { domain: { not: null }, logoUrl: null, iconUrl: null, - ...(recentlyChecked.length > 0 ? { id: { notIn: recentlyChecked } } : {}), }; + if (recentlyChecked.length > 0) where.id = { notIn: recentlyChecked }; + + return where; } /** @@ -311,15 +313,17 @@ export class BackfillService implements OnModuleInit { .map((row) => row.contactId) .filter((id): id is string => id !== null); - return { + const where: Prisma.ContactWhereInput = { imageUrl: null, - ...(recentlyChecked.length > 0 ? { id: { notIn: recentlyChecked } } : {}), OR: [ { linkedinUrl: { not: null } }, { githubUrl: { not: null } }, { company: { domain: { not: null } } }, ], }; + if (recentlyChecked.length > 0) where.id = { notIn: recentlyChecked }; + + return where; } private contactsNeverResearched(): Prisma.ContactWhereInput { diff --git a/apps/api/src/backfill/image-mirror.service.ts b/apps/api/src/backfill/image-mirror.service.ts index c6f46c850..1f295de76 100644 --- a/apps/api/src/backfill/image-mirror.service.ts +++ b/apps/api/src/backfill/image-mirror.service.ts @@ -1,11 +1,27 @@ import type { Db, Prisma } from "@crm/db"; import { blobEnabled, mirror } from "@crm/db/blob"; -import { BLOB_HOST_SUFFIX, COMPANY_IMAGE_FIELDS } from "@crm/db/images"; +import { + BLOB_HOST_SUFFIX, + COMPANY_IMAGE_FIELDS, + type CompanyImageField, +} from "@crm/db/images"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; const MAX_PER_SWEEP = 25; +type CompanyImageRow = Record; + +const EXTERNAL_CONTACT_IMAGE: Prisma.ContactWhereInput = { + imageUrl: { not: null }, + NOT: { imageUrl: { contains: BLOB_HOST_SUFFIX } }, +}; + +const EXTERNAL_USER_IMAGE: Prisma.UserWhereInput = { + image: { not: null }, + NOT: { image: { contains: BLOB_HOST_SUFFIX } }, +}; + export type ImageMirrorResult = { scanned: number; copied: number; @@ -44,7 +60,7 @@ export class ImageMirrorService { private async sweepCompanies(): Promise { const rows = await this.db.company.findMany({ where: { - OR: COMPANY_IMAGE_FIELDS.map((field) => external(field)), + OR: COMPANY_IMAGE_FIELDS.map(externalCompanyImage), }, orderBy: { createdAt: "asc" }, take: MAX_PER_SWEEP, @@ -86,7 +102,7 @@ export class ImageMirrorService { private async sweepContacts(): Promise { const rows = await this.db.contact.findMany({ - where: external("imageUrl"), + where: EXTERNAL_CONTACT_IMAGE, orderBy: { createdAt: "asc" }, take: MAX_PER_SWEEP, select: { id: true, imageUrl: true }, @@ -113,7 +129,7 @@ export class ImageMirrorService { private async sweepUsers(): Promise { const rows = await this.db.user.findMany({ - where: external("image"), + where: EXTERNAL_USER_IMAGE, take: MAX_PER_SWEEP, select: { id: true, image: true }, }); @@ -138,20 +154,21 @@ export class ImageMirrorService { } } -function external( - field: T, -): Record & { NOT: Record } { - return { - ...({ [field]: { not: null } } as Record), - NOT: { [field]: { contains: BLOB_HOST_SUFFIX } } as Record< - T, - { contains: string } - >, - }; +function externalCompanyImage( + field: CompanyImageField, +): Prisma.CompanyWhereInput { + const mirrored: Prisma.CompanyWhereInput = {}; + mirrored[field] = { contains: BLOB_HOST_SUFFIX }; + + const where: Prisma.CompanyWhereInput = { NOT: mirrored }; + where[field] = { not: null }; + + return where; } -function unchanged(row: Record): Prisma.CompanyWhereInput { - return Object.fromEntries( - COMPANY_IMAGE_FIELDS.map((field) => [field, row[field] ?? null]), - ); +function unchanged(row: CompanyImageRow): Prisma.CompanyWhereInput { + const where: Prisma.CompanyWhereInput = {}; + for (const field of COMPANY_IMAGE_FIELDS) where[field] = row[field] ?? null; + + return where; } diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts index 80e79dc7e..cc70757f8 100644 --- a/apps/api/src/companies/companies.service.ts +++ b/apps/api/src/companies/companies.service.ts @@ -29,6 +29,7 @@ import { FACET_ALL, FACET_UNASSIGNED, type ListResult, + type OrderByColumns, ownerFilter, paginate, resolveOrderBy, @@ -75,10 +76,7 @@ export type CompanyRow = { fields: Record; }; -const SORTABLE: Record< - string, - (dir: Prisma.SortOrder) => Prisma.CompanyOrderByWithRelationInput -> = { +const SORTABLE: OrderByColumns = { name: (dir) => ({ name: dir }), domain: (dir) => ({ domain: dir }), industry: (dir) => ({ industry: dir }), @@ -606,17 +604,17 @@ export class CompaniesService { }; } - private translate(error: unknown, id: string): unknown { - if (error instanceof PrismaNamespace.PrismaClientKnownRequestError) { - if (error.code === "P2025") { - return new NotFoundException(`No company with id ${id}.`); + private translate(cause: unknown, id: string): never { + if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { + if (cause.code === "P2025") { + throw new NotFoundException(`No company with id ${id}.`); } - if (error.code === "P2002") { - return new ConflictException( + if (cause.code === "P2002") { + throw new ConflictException( "Another company already uses that domain.", ); } } - return error; + throw cause; } } diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 2f81dc723..08cb676c2 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -128,9 +128,9 @@ export class EnvironmentVariables { CRM_TELEMETRY_DISABLED?: string; } -export function validateEnv( - config: Record, -): EnvironmentVariables { +export type RawEnvironment = Record; + +export function validateEnv(config: RawEnvironment): EnvironmentVariables { const validated = plainToInstance(EnvironmentVariables, config, { enableImplicitConversion: true, exposeDefaultValues: true, diff --git a/apps/api/src/contacts/contacts.service.ts b/apps/api/src/contacts/contacts.service.ts index 6518d060f..fabdbd56a 100644 --- a/apps/api/src/contacts/contacts.service.ts +++ b/apps/api/src/contacts/contacts.service.ts @@ -29,6 +29,7 @@ import { FACET_ALL, FACET_UNASSIGNED, type ListResult, + type OrderByColumns, ownerFilter, paginate, resolveOrderBy, @@ -61,7 +62,9 @@ const COMPANY_SELECT = { const NO_COMPANY = "none"; -const FACT_COLUMNS: Record = { +type FactColumns = Record; + +const FACT_COLUMNS: FactColumns = { title: "title", linkedinUrl: "linkedinUrl", twitterUrl: "twitterUrl", @@ -95,10 +98,7 @@ export type ContactRow = { fields: Record; }; -const SORTABLE: Record< - string, - (dir: Prisma.SortOrder) => Prisma.ContactOrderByWithRelationInput[] -> = { +const SORTABLE: OrderByColumns = { name: (dir) => [{ lastName: dir }, { firstName: dir }], email: (dir) => [{ email: dir }], title: (dir) => [{ title: dir }, { lastName: "asc" }], @@ -403,7 +403,9 @@ export class ContactsService { if (input.firstName !== undefined) data.firstName = input.firstName.trim(); if (input.lastName !== undefined) data.lastName = blankToNull(input.lastName); - if (input.email !== undefined) data.email = normalizeEmail(input.email); + const email = + input.email === undefined ? null : normalizeEmail(input.email); + if (input.email !== undefined) data.email = email; if (input.phone !== undefined) data.phone = blankToNull(input.phone); if (input.title !== undefined) data.title = blankToNull(input.title); if (input.linkedinUrl !== undefined) { @@ -438,8 +440,8 @@ export class ContactsService { select: { id: true, firstName: true, lastName: true }, }); - if (typeof data.email === "string") { - await this.allowAgain(tx, data.email); + if (email !== null) { + await this.allowAgain(tx, email); } return updated; @@ -753,17 +755,17 @@ export class ContactsService { }; } - private translate(error: unknown, id: string): unknown { - if (error instanceof PrismaNamespace.PrismaClientKnownRequestError) { - if (error.code === "P2025") { - return new NotFoundException(`No contact with id ${id}.`); + private translate(cause: unknown, id: string): never { + if (cause instanceof PrismaNamespace.PrismaClientKnownRequestError) { + if (cause.code === "P2025") { + throw new NotFoundException(`No contact with id ${id}.`); } - if (error.code === "P2002") { - return new ConflictException( + if (cause.code === "P2002") { + throw new ConflictException( "Another contact already uses that email address.", ); } } - return error; + throw cause; } } diff --git a/apps/api/src/conversations/conversation-attachments.ts b/apps/api/src/conversations/conversation-attachments.ts index 4dc8d4fab..9eacc3758 100644 --- a/apps/api/src/conversations/conversation-attachments.ts +++ b/apps/api/src/conversations/conversation-attachments.ts @@ -1,4 +1,7 @@ import type { Prisma } from "@crm/db"; +import { z } from "zod"; + +const builderMessageFields = z.record(z.string(), z.json()).catch({}); export type StoredBuilderAttachment = { id: string; @@ -12,9 +15,8 @@ export function builderMessageWithAttachments( attachments: StoredBuilderAttachment[], shareToken?: string, ): Prisma.JsonObject { - const message = recordOf(value); return { - ...message, + ...builderMessageFields.parse(value), attachments: attachments.map((attachment) => ({ id: attachment.id, name: attachment.name, @@ -24,7 +26,7 @@ export function builderMessageWithAttachments( ? attachmentUrl(attachment.id, shareToken) : null, })), - } as Prisma.JsonObject; + }; } export function isPreviewableImage(mediaType: string): boolean { @@ -37,9 +39,3 @@ function attachmentUrl(id: string, shareToken?: string): string { const path = `/api/conversations/attachments/${encodeURIComponent(id)}`; return shareToken ? `${path}?share=${encodeURIComponent(shareToken)}` : path; } - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 3004ce885..7ee80a4cf 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -1,5 +1,10 @@ import { WORKSPACE_ID } from "@crm/auth"; import { type Db, type Prisma, Prisma as PrismaNamespace } from "@crm/db"; +import { readAgentManifestSummary } from "@crm/validation/agent-manifest"; +import { + type BuilderQuestion, + builderQuestion, +} from "@crm/validation/builder-question"; import { BadRequestException, Injectable, @@ -51,6 +56,10 @@ export interface BuilderConversationSummary { } | null; } +type ReplayedRequest = { + id: string; +}; + type ExistingBuilderRequest = { id: string; conversationId: string; @@ -396,6 +405,7 @@ export class ConversationsService { : null, createdVersions: row.createdVersions.map((version) => ({ ...version, + manifest: readAgentManifestSummary(version.manifest), createdAt: version.createdAt.toISOString(), })), builderArtifacts: row.builderArtifacts.map((artifact) => ({ @@ -585,8 +595,7 @@ export class ConversationsService { throw new BadRequestException("Choose an answer before submitting."); } - const displayText = - typeof selected?.label === "string" ? selected.label : answer; + const displayText = selected?.label ?? answer; const inputResponse = input.optionId ? { requestId: input.requestId, optionId: input.optionId } : { requestId: input.requestId, text: input.text }; @@ -1082,7 +1091,7 @@ export class ConversationsService { private replayBuilderCreation( existing: ExistingBuilderRequest, userId: string, - ): { id: string } { + ): ReplayedRequest { if ( existing.conversation.userId !== userId || existing.conversation.kind !== "BUILDER" @@ -1097,7 +1106,7 @@ export class ConversationsService { existing: ExistingBuilderRequest, conversationId: string, userId: string, - ): { id: string } { + ): ReplayedRequest { if ( existing.conversationId !== conversationId || existing.submittedById !== userId @@ -1109,78 +1118,15 @@ export class ConversationsService { } } -function isUniqueConstraint(error: unknown): boolean { +function isUniqueConstraint(cause: unknown): boolean { return ( - error instanceof PrismaNamespace.PrismaClientKnownRequestError && - error.code === "P2002" + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + cause.code === "P2002" ); } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function arrayOf(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function pendingBuilderQuestionOf(value: unknown) { - const request = recordOf(value); - if ( - request.kind !== "question" || - typeof request.requestId !== "string" || - !request.requestId || - typeof request.prompt !== "string" || - !request.prompt - ) { - return null; - } - - const display = ["confirmation", "select", "text"].includes( - String(request.display), - ) - ? (request.display as "confirmation" | "select" | "text") - : undefined; - const options = arrayOf(request.options).flatMap((value) => { - const option = recordOf(value); - if ( - typeof option.id !== "string" || - !option.id || - typeof option.label !== "string" || - !option.label - ) { - return []; - } - const style = ["danger", "default", "primary"].includes( - String(option.style), - ) - ? (option.style as "danger" | "default" | "primary") - : undefined; - - return [ - { - id: option.id, - label: option.label, - description: - typeof option.description === "string" - ? option.description - : undefined, - style, - }, - ]; - }); - - return { - kind: "question" as const, - requestId: request.requestId, - prompt: request.prompt, - display, - options, - allowFreeform: - request.allowFreeform === true || - display === "text" || - options.length === 0, - }; +function pendingBuilderQuestionOf( + value: Prisma.JsonValue, +): BuilderQuestion | null { + return builderQuestion.parse(value); } diff --git a/apps/api/src/crm/enrichment-log.service.ts b/apps/api/src/crm/enrichment-log.service.ts index 73f335693..c5087d8e8 100644 --- a/apps/api/src/crm/enrichment-log.service.ts +++ b/apps/api/src/crm/enrichment-log.service.ts @@ -1,4 +1,5 @@ import { ActivityType, type Db } from "@crm/db"; +import type { ActivityMetaFields } from "@crm/validation/activity-meta"; import { Injectable } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { ActivityStampService } from "./activity-stamp.service"; @@ -8,7 +9,7 @@ export type EnrichmentEvent = { contactId?: string | null; subject: string; body?: string | null; - meta?: Record; + meta?: ActivityMetaFields; }; @Injectable() diff --git a/apps/api/src/currency/rates.service.ts b/apps/api/src/currency/rates.service.ts index eefab65a7..a37c31f9e 100644 --- a/apps/api/src/currency/rates.service.ts +++ b/apps/api/src/currency/rates.service.ts @@ -11,6 +11,7 @@ import { writeRatesRefreshedAt, } from "@crm/db/settings"; import { Injectable, Logger } from "@nestjs/common"; +import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; export const RATES_PROVIDER = "open.er-api.com"; @@ -31,17 +32,29 @@ export interface RateRefresh { reason: string | null; } -interface OpenExchangeResponse { - result?: unknown; - base_code?: unknown; - time_last_update_unix?: unknown; - rates?: Record; - "error-type"?: unknown; -} - -function parseAsOf(value: unknown): Date | null { - if (typeof value !== "number" || !Number.isFinite(value)) return null; - const date = new Date(value * 1000); +const UNREADABLE_FEED = { + result: "", + time_last_update_unix: null, + rates: {}, + "error-type": null, +}; + +const openExchangeResponse = z + .object({ + result: z.string().catch(""), + time_last_update_unix: z + .number() + .refine(Number.isFinite) + .nullable() + .catch(null), + rates: z.record(z.string(), z.json()).catch({}), + "error-type": z.string().nullable().catch(null), + }) + .catch(UNREADABLE_FEED); + +function parseAsOf(seconds: number | null): Date | null { + if (seconds === null) return null; + const date = new Date(seconds * 1000); return Number.isNaN(date.getTime()) ? null : date; } @@ -179,15 +192,14 @@ export class RatesService { return null; } - const body = (await response.json()) as OpenExchangeResponse; + const body = openExchangeResponse.parse(await response.json()); if (body.result !== "success") { this.logger.warn({ message: "Exchange rate provider refused the request", base, attempt, - errorType: - typeof body["error-type"] === "string" ? body["error-type"] : null, + errorType: body["error-type"], }); return null; } @@ -195,7 +207,7 @@ export class RatesService { const asOf = parseAsOf(body.time_last_update_unix) ?? new Date(); const rates = new Map(); - for (const [code, value] of Object.entries(body.rates ?? {})) { + for (const [code, value] of Object.entries(body.rates)) { const quoteCurrency = normalizeCurrency(code); if (!isCurrencyCode(quoteCurrency)) continue; if (quoteCurrency === normalizeCurrency(base)) continue; diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index f338fd466..75065fc24 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -1,5 +1,6 @@ import { ActivityType, type Db, DealStage } from "@crm/db"; import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; +import { activityMeta } from "@crm/validation/activity-meta"; import { Injectable } from "@nestjs/common"; import { toCents } from "../crm/values"; import { ConversionService } from "../currency/conversion.service"; @@ -282,7 +283,7 @@ export class DashboardService { recentActivity: recentActivity.map(({ createdAt, meta, ...entry }) => ({ ...entry, createdAt: createdAt.toISOString(), - meta: meta as Record | null, + meta: activityMeta.parse(meta), })), }; } diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 5bb09fecf..3f5b23d03 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -38,6 +38,7 @@ import { FACET_ALL, FACET_UNASSIGNED, type ListResult, + type OrderByColumns, paginate, resolveOrderBy, } from "../trpc/list-input"; @@ -83,10 +84,7 @@ const CONTACT_SELECT = { const LOSING = new Set(LOSING_DEAL_STAGES); -const SORTABLE: Record< - string, - (dir: Prisma.SortOrder) => Prisma.DealOrderByWithRelationInput[] -> = { +const SORTABLE: OrderByColumns = { name: (dir) => [{ name: dir }], company: (dir) => [{ company: { name: dir } }, { name: "asc" }], stage: (dir) => [{ stage: dir }, { expectedCloseDate: "asc" }], @@ -709,26 +707,26 @@ export class DealsService { }; } - private translate(error: unknown, id: string): unknown { + private translate(cause: unknown, id: string): never { if ( - error instanceof PrismaNamespace.PrismaClientKnownRequestError && - error.code === "P2025" + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + cause.code === "P2025" ) { - return new NotFoundException(`No deal with id ${id}.`); + throw new NotFoundException(`No deal with id ${id}.`); } - return this.translateRelations(error); + return this.translateRelations(cause); } - private translateRelations(error: unknown): unknown { + private translateRelations(cause: unknown): never { if ( - error instanceof PrismaNamespace.PrismaClientKnownRequestError && - (error.code === "P2003" || error.code === "P2025") + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + (cause.code === "P2003" || cause.code === "P2025") ) { - return new BadRequestException( + throw new BadRequestException( "That company or owner does not exist any more.", ); } - return error; + throw cause; } } diff --git a/apps/api/src/fields/fields.service.ts b/apps/api/src/fields/fields.service.ts index ceb09a81d..0f589dfb7 100644 --- a/apps/api/src/fields/fields.service.ts +++ b/apps/api/src/fields/fields.service.ts @@ -48,7 +48,7 @@ export class FieldsService { includeArchived: boolean, ): Promise { const definitions = await this.db.fieldDefinition.findMany({ - where: { entity, ...(includeArchived ? {} : { archivedAt: null }) }, + where: { entity, archivedAt: includeArchived ? undefined : null }, include: WITH_OPTIONS, orderBy: { position: "asc" }, }); @@ -421,15 +421,15 @@ export class FieldsService { } } - private translate(error: unknown): unknown { + private translate(cause: unknown): never { if ( - error instanceof PrismaNamespace.PrismaClientKnownRequestError && - error.code === "P2025" + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + cause.code === "P2025" ) { - return new NotFoundException("That field does not exist."); + throw new NotFoundException("That field does not exist."); } - return error; + throw cause; } } diff --git a/apps/api/src/google/conversation.service.ts b/apps/api/src/google/conversation.service.ts index b995c74d0..232b84f58 100644 --- a/apps/api/src/google/conversation.service.ts +++ b/apps/api/src/google/conversation.service.ts @@ -1,7 +1,18 @@ -import type { Db } from "@crm/db"; +import type { Db, Prisma } from "@crm/db"; import { Injectable, NotFoundException } from "@nestjs/common"; +import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; +const storedRecipient = z.object({ + email: z.string(), + name: z.string().nullable().catch(null), + kind: z.string().catch("to"), +}); + +type StoredRecipient = z.infer; + +const storedRecipients = z.array(z.json()).catch([]); + @Injectable() export class ConversationService { constructor(@InjectDatabase() private readonly db: Db) {} @@ -142,22 +153,9 @@ export class ConversationService { } } -function recipientsOf( - value: unknown, -): { email: string; name: string | null; kind: string }[] { - if (!Array.isArray(value)) return []; - - return value.flatMap((entry) => { - if (typeof entry !== "object" || entry === null) return []; - const record = entry as Record; - if (typeof record.email !== "string") return []; - - return [ - { - email: record.email, - name: typeof record.name === "string" ? record.name : null, - kind: typeof record.kind === "string" ? record.kind : "to", - }, - ]; +function recipientsOf(value: Prisma.JsonValue): StoredRecipient[] { + return storedRecipients.parse(value).flatMap((entry) => { + const parsed = storedRecipient.safeParse(entry); + return parsed.success ? [parsed.data] : []; }); } diff --git a/apps/api/src/logging/all-exceptions.filter.ts b/apps/api/src/logging/all-exceptions.filter.ts index 6282a5e82..fb8f46038 100644 --- a/apps/api/src/logging/all-exceptions.filter.ts +++ b/apps/api/src/logging/all-exceptions.filter.ts @@ -74,26 +74,31 @@ function describe(exception: unknown): string { return typeof exception === "string" ? exception : "Unknown exception"; } +interface ErrorBody { + statusCode?: number; + message?: unknown; + requestId?: string; + [field: string]: unknown; +} + function body( exception: unknown, status: number, requestId: string | undefined, -): Record { - const withRequestId = requestId ? { requestId } : {}; +): ErrorBody { + const reported = exceptionBody(exception, status); + + return requestId ? { ...reported, requestId } : reported; +} +function exceptionBody(exception: unknown, status: number): ErrorBody { if (!(exception instanceof HttpException)) { - return { - statusCode: status, - message: "Internal server error", - ...withRequestId, - }; + return { statusCode: status, message: "Internal server error" }; } const original = exception.getResponse(); - if (typeof original === "string") { - return { statusCode: status, message: original, ...withRequestId }; - } - - return { ...(original as Record), ...withRequestId }; + return typeof original === "string" + ? { statusCode: status, message: original } + : { ...original }; } diff --git a/apps/api/src/logging/context-logger.ts b/apps/api/src/logging/context-logger.ts index b3030e390..13d23912e 100644 --- a/apps/api/src/logging/context-logger.ts +++ b/apps/api/src/logging/context-logger.ts @@ -67,11 +67,16 @@ export class ContextLogger extends ConsoleLogger { return record; } - return { + const correlated: JsonLogRecord = { ...record, requestId: request.requestId, - ...(request.userId ? { userId: request.userId } : {}), }; + + if (!request.userId) { + return correlated; + } + + return { ...correlated, userId: request.userId }; } protected override stringifyMessage( diff --git a/apps/api/src/logging/prisma-log.bridge.ts b/apps/api/src/logging/prisma-log.bridge.ts index 3a0b9508f..11f52bd33 100644 --- a/apps/api/src/logging/prisma-log.bridge.ts +++ b/apps/api/src/logging/prisma-log.bridge.ts @@ -12,11 +12,10 @@ export class PrismaLogBridge implements OnModuleInit, OnApplicationShutdown { onModuleInit(): void { setPrismaLogSink(({ level, message, target, durationMs }) => { - const payload = { - message, - target, - ...(durationMs === undefined ? {} : { durationMs }), - }; + const payload = + durationMs === undefined + ? { message, target } + : { message, target, durationMs }; if (level === "error") { this.logger.error(payload); diff --git a/apps/api/src/mailbox/mailbox.constants.ts b/apps/api/src/mailbox/mailbox.constants.ts index 4e0625809..a9228d8b9 100644 --- a/apps/api/src/mailbox/mailbox.constants.ts +++ b/apps/api/src/mailbox/mailbox.constants.ts @@ -37,14 +37,14 @@ export function isMicrosoftSyncSource( return (MICROSOFT_SYNC_SOURCES as readonly string[]).includes(source); } -export const SCOPE_FOR_SOURCE: Record = { +export const SCOPE_FOR_SOURCE = { calendar: CALENDAR_SCOPE, gmail: GMAIL_SCOPE, outlook: OUTLOOK_MAIL_SCOPE, -}; +} satisfies Record; -export const PROVIDER_FOR_SOURCE: Record = { +export const PROVIDER_FOR_SOURCE = { calendar: GOOGLE_PROVIDER_ID, gmail: GOOGLE_PROVIDER_ID, outlook: MICROSOFT_PROVIDER_ID, -}; +} satisfies Record; diff --git a/apps/api/src/mailbox/participants.ts b/apps/api/src/mailbox/participants.ts index fcb9c1dc8..4da7a5ed7 100644 --- a/apps/api/src/mailbox/participants.ts +++ b/apps/api/src/mailbox/participants.ts @@ -188,10 +188,12 @@ export function dominantDomain( return best; } -export function splitName( - name: string | null, - email: string, -): { firstName: string; lastName: string | null } { +export type PersonName = { + firstName: string; + lastName: string | null; +}; + +export function splitName(name: string | null, email: string): PersonName { const cleaned = name?.trim().replace(/\s+/g, " ") ?? ""; if (cleaned && !cleaned.includes("@")) { diff --git a/apps/api/src/mailbox/sync-state.service.ts b/apps/api/src/mailbox/sync-state.service.ts index d669983be..ca56462b4 100644 --- a/apps/api/src/mailbox/sync-state.service.ts +++ b/apps/api/src/mailbox/sync-state.service.ts @@ -2,6 +2,7 @@ import { type Db, GoogleSyncStatus, type MailboxSyncModel as MailboxSync, + type Prisma, } from "@crm/db"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; @@ -25,9 +26,10 @@ export class SyncStateService { userId: string, sources?: readonly SyncSource[], ): Promise { - return this.db.mailboxSync.findMany({ - where: { userId, ...(sources ? { source: { in: [...sources] } } : {}) }, - }); + const where: Prisma.MailboxSyncWhereInput = { userId }; + if (sources) where.source = { in: [...sources] }; + + return this.db.mailboxSync.findMany({ where }); } async due(now: Date): Promise { @@ -164,9 +166,10 @@ export class SyncStateService { } async remove(userId: string, source?: SyncSource): Promise { - await this.db.mailboxSync.deleteMany({ - where: { userId, ...(source ? { source } : {}) }, - }); + const where: Prisma.MailboxSyncWhereInput = { userId }; + if (source) where.source = source; + + await this.db.mailboxSync.deleteMany({ where }); } } diff --git a/apps/api/src/mailbox/thread-writer.service.ts b/apps/api/src/mailbox/thread-writer.service.ts index 57d131b2a..9b06692c6 100644 --- a/apps/api/src/mailbox/thread-writer.service.ts +++ b/apps/api/src/mailbox/thread-writer.service.ts @@ -172,17 +172,15 @@ export class ThreadWriterService { const firstMessageAt = stats._min.sentAt ?? parsed.sentAt; const lastMessageAt = stats._max.sentAt ?? parsed.sentAt; - await tx.emailThread.update({ - where: { id: record.id }, - data: { - messageCount: stats._count._all, - firstMessageAt, - lastMessageAt, - ...(parsed.sentAt <= firstMessageAt - ? { subject: parsed.subject } - : {}), - }, - }); + const data: Prisma.EmailThreadUpdateInput = { + messageCount: stats._count._all, + firstMessageAt, + lastMessageAt, + }; + + if (parsed.sentAt <= firstMessageAt) data.subject = parsed.subject; + + await tx.emailThread.update({ where: { id: record.id }, data }); return this.project(tx, record.id, row.userId, { subject: parsed.subject ?? "(no subject)", @@ -204,12 +202,12 @@ export class ThreadWriterService { } private async storedElsewhere( - error: unknown, + cause: unknown, rfcMessageId: string, ): Promise { const duplicate = - error instanceof PrismaNamespace.PrismaClientKnownRequestError && - error.code === "P2002"; + cause instanceof PrismaNamespace.PrismaClientKnownRequestError && + cause.code === "P2002"; if (!duplicate) return false; const winner = await this.db.emailMessage.findFirst({ diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f6e3bead5..af118dec5 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -15,10 +15,10 @@ async function bootstrap() { }); } -void bootstrap().catch((error: unknown) => { +void bootstrap().catch((cause: unknown) => { new Logger("Bootstrap").fatal( { message: "API failed to start" }, - error instanceof Error ? error.stack : String(error), + cause instanceof Error ? cause.stack : String(cause), ); process.exit(1); }); diff --git a/apps/api/src/settings/model-catalog.service.ts b/apps/api/src/settings/model-catalog.service.ts index 5f7c515d4..13ebcbbff 100644 --- a/apps/api/src/settings/model-catalog.service.ts +++ b/apps/api/src/settings/model-catalog.service.ts @@ -1,6 +1,7 @@ import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Inject, Injectable, Logger } from "@nestjs/common"; import type { Cache } from "cache-manager"; +import { z } from "zod"; const CATALOG_URL = "https://ai-gateway.vercel.sh/v1/models"; @@ -18,29 +19,47 @@ export interface CatalogModel { pricing: { input: number; output: number } | null; } -interface GatewayModel { - id?: unknown; - name?: unknown; - owned_by?: unknown; - type?: unknown; - tags?: unknown; - context_window?: unknown; - pricing?: { input?: unknown; output?: unknown } | null; -} +const gatewayRate = z + .union([z.number(), z.string()]) + .transform((value) => Number(value)) + .refine((value) => Number.isFinite(value)) + .nullable() + .catch(null); + +const gatewayModel = z.object({ + id: z.string(), + name: z.string().catch(""), + owned_by: z.string().catch(""), + type: z.string().catch(""), + tags: z.array(z.json()).catch([]), + context_window: z.number(), + pricing: z + .object({ input: gatewayRate, output: gatewayRate }) + .nullable() + .catch(null), +}); + +type GatewayModel = z.infer; + +const gatewayCatalog = z + .object({ data: z.array(z.json()).catch([]) }) + .catch({ data: [] }); -function rate(value: unknown): number | null { - const parsed = typeof value === "string" ? Number(value) : value; - return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null; +function usable(model: GatewayModel): boolean { + return model.type === "language" && model.tags.includes("tool-use"); } -function usable(model: GatewayModel): boolean { - const tags = Array.isArray(model.tags) ? model.tags : []; - return ( - typeof model.id === "string" && - model.type === "language" && - tags.includes("tool-use") && - typeof model.context_window === "number" - ); +function toCatalogModel(model: GatewayModel): CatalogModel { + const input = model.pricing?.input ?? null; + const output = model.pricing?.output ?? null; + + return { + id: model.id, + name: model.name || model.id, + provider: model.owned_by || (model.id.split("/")[0] ?? model.id), + contextWindowTokens: model.context_window, + pricing: input !== null && output !== null ? { input, output } : null, + }; } @Injectable() @@ -80,26 +99,13 @@ export class ModelCatalogService { return null; } - const body = (await response.json()) as { data?: unknown }; - const rows = Array.isArray(body.data) - ? (body.data as GatewayModel[]) - : []; - - const models = rows.filter(usable).map((model): CatalogModel => { - const id = model.id as string; - const input = rate(model.pricing?.input); - const output = rate(model.pricing?.output); - - return { - id, - name: typeof model.name === "string" && model.name ? model.name : id, - provider: - typeof model.owned_by === "string" && model.owned_by - ? model.owned_by - : (id.split("/")[0] ?? id), - contextWindowTokens: model.context_window as number, - pricing: input !== null && output !== null ? { input, output } : null, - }; + const body = gatewayCatalog.parse(await response.json()); + + const models = body.data.flatMap((entry) => { + const parsed = gatewayModel.safeParse(entry); + return parsed.success && usable(parsed.data) + ? [toCatalogModel(parsed.data)] + : []; }); models.sort( diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index ebb6e1b8e..71c55e24d 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -133,10 +133,10 @@ export class SettingsService { }); } }) - .catch((error: unknown) => { + .catch((cause: unknown) => { this.logger.warn( { message: "Could not queue the waiting research" }, - error instanceof Error ? error.stack : String(error), + cause instanceof Error ? cause.stack : String(cause), ); }); diff --git a/apps/api/src/slack/slack-connection.service.ts b/apps/api/src/slack/slack-connection.service.ts index b58e3d4d5..ab8d63213 100644 --- a/apps/api/src/slack/slack-connection.service.ts +++ b/apps/api/src/slack/slack-connection.service.ts @@ -3,7 +3,7 @@ import { isSlackConfigured, WORKSPACE_ID, } from "@crm/auth"; -import type { Db } from "@crm/db"; +import type { Db, Prisma } from "@crm/db"; import { schemas } from "@crm/validation"; import { BadRequestException, @@ -177,17 +177,16 @@ export class SlackConnectionService { const take = input.limit ?? SLACK.channels.pageSize; const needle = input.query?.trim() ?? ""; + const where: Prisma.SlackChannelWhereInput = { available: true }; + if (needle) where.name = { contains: needle, mode: "insensitive" }; + const [rows, grant, sync] = await Promise.all([ this.db.slackChannel.findMany({ - where: { - available: true, - ...(needle - ? { name: { contains: needle, mode: "insensitive" } } - : {}), - }, + where, orderBy: [{ isMember: "desc" }, { name: "asc" }, { id: "asc" }], take: take + 1, - ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + cursor: input.cursor ? { id: input.cursor } : undefined, + skip: input.cursor ? 1 : undefined, select: { id: true, name: true, diff --git a/apps/api/src/sso/sso.service.ts b/apps/api/src/sso/sso.service.ts index 6537ba08d..bdb09d26c 100644 --- a/apps/api/src/sso/sso.service.ts +++ b/apps/api/src/sso/sso.service.ts @@ -19,8 +19,14 @@ import { Logger, } from "@nestjs/common"; import { APIError } from "better-auth/api"; +import { z } from "zod"; import { InjectDatabase } from "../database/database.constants"; -import { type ListResult, paginate, resolveOrderBy } from "../trpc/list-input"; +import { + type ListResult, + type OrderByColumns, + paginate, + resolveOrderBy, +} from "../trpc/list-input"; import type { DeleteSsoProviderInput, RegisterSsoProviderInput, @@ -65,23 +71,20 @@ type ProviderRow = Prisma.SsoProviderGetPayload<{ select: typeof PROVIDER_SELECT; }>; -const SORTABLE: Record< - string, - (dir: "asc" | "desc") => Prisma.SsoProviderOrderByWithRelationInput -> = { +const SORTABLE: OrderByColumns = { providerId: (dir) => ({ providerId: dir }), domain: (dir) => ({ domain: dir }), issuer: (dir) => ({ issuer: dir }), }; -const STATUS_BY_CODE: Record = { - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - CONFLICT: 409, - UNPROCESSABLE_ENTITY: 400, -}; +const STATUS_BY_CODE = new Map([ + ["BAD_REQUEST", 400], + ["UNAUTHORIZED", 401], + ["FORBIDDEN", 403], + ["NOT_FOUND", 404], + ["CONFLICT", 409], + ["UNPROCESSABLE_ENTITY", 400], +]); function splitDomains(value: string): string[] { return value @@ -96,27 +99,28 @@ function splitDomains(value: string): string[] { .filter(Boolean); } -function lastFour(clientId: unknown): string | null { - return typeof clientId === "string" && clientId.length >= 4 - ? clientId.slice(-4) - : null; +const oidcConfig = z + .object({ clientId: z.string().catch("") }) + .catch({ clientId: "" }); + +type OidcConfig = z.infer; + +function lastFour(clientId: string): string | null { + return clientId.length >= 4 ? clientId.slice(-4) : null; } -function parseConfig(value: string | null): Record | null { +function readOidcConfig(value: string | null): OidcConfig | null { if (!value) return null; try { - const parsed: unknown = JSON.parse(value); - return parsed && typeof parsed === "object" - ? (parsed as Record) - : null; + return oidcConfig.parse(JSON.parse(value)); } catch { return null; } } function toProvider(row: ProviderRow): SsoProvider { - const oidc = parseConfig(row.oidcConfig); + const oidc = readOidcConfig(row.oidcConfig); return { providerId: row.providerId, @@ -270,11 +274,11 @@ export class SsoService { } catch (error) { if (error instanceof APIError) { const status = - STATUS_BY_CODE[error.body?.code ?? ""] ?? error.statusCode; + STATUS_BY_CODE.get(error.body?.code ?? "") ?? error.statusCode; throw new HttpException( error.body?.message ?? "The identity provider could not be saved.", - typeof status === "number" ? status : 400, + status, ); } diff --git a/apps/api/src/telemetry/rollup.service.ts b/apps/api/src/telemetry/rollup.service.ts index 928cd990c..20ee5b663 100644 --- a/apps/api/src/telemetry/rollup.service.ts +++ b/apps/api/src/telemetry/rollup.service.ts @@ -587,14 +587,16 @@ function isSet(name: string): boolean { return Boolean(process.env[name]?.trim()); } -function byKind(rows: Counted[]): Record { +type CountsByKey = Record; + +function byKind(rows: Counted[]): CountsByKey { return merge( rows.map((row) => ({ ...row, key: permittedTaskKind(row.key) })), ); } -function merge(rows: Counted[]): Record { - const counts: Record = {}; +function merge(rows: Counted[]): CountsByKey { + const counts: CountsByKey = {}; for (const row of rows) { counts[row.key] = (counts[row.key] ?? 0) + row.count; @@ -603,12 +605,9 @@ function merge(rows: Counted[]): Record { return counts; } -function countsOf( - rows: Counted[], - keys: readonly string[], -): Record { +function countsOf(rows: Counted[], keys: readonly string[]): CountsByKey { const merged = merge(rows); - const complete: Record = {}; + const complete: CountsByKey = {}; for (const key of keys) complete[key] = merged[key] ?? 0; diff --git a/apps/api/src/tracking/tracking-filing.service.ts b/apps/api/src/tracking/tracking-filing.service.ts index ceca0b122..16aa5427e 100644 --- a/apps/api/src/tracking/tracking-filing.service.ts +++ b/apps/api/src/tracking/tracking-filing.service.ts @@ -20,10 +20,12 @@ import { } from "../mailbox/participants"; import { TrackingCounterService } from "./tracking-counter.service"; +type TouchColumns = Record; + function columns( touch: Touch | undefined, prefix: "first" | "last", -): Record { +): TouchColumns { if (!touch) return {}; return { @@ -144,12 +146,12 @@ export class TrackingFilingService { } private async raced( - error: unknown, + cause: unknown, email: string, ): Promise<{ id: string } | null> { if ( - !(error instanceof Prisma.PrismaClientKnownRequestError) || - error.code !== "P2002" + !(cause instanceof Prisma.PrismaClientKnownRequestError) || + cause.code !== "P2002" ) { return null; } diff --git a/apps/api/src/tracking/tracking-ingest.service.ts b/apps/api/src/tracking/tracking-ingest.service.ts index bffd39eac..e4f265b1a 100644 --- a/apps/api/src/tracking/tracking-ingest.service.ts +++ b/apps/api/src/tracking/tracking-ingest.service.ts @@ -13,6 +13,7 @@ import { type TrackingConfig, } from "@crm/db/tracking"; import { Injectable, Logger } from "@nestjs/common"; +import { z } from "zod"; import { normalizeEmail } from "../crm/values"; import { InjectDatabase } from "../database/database.constants"; import { TrackingConfigService } from "./tracking-config.service"; @@ -35,6 +36,21 @@ const CARD = /^[0-9 -]{12,25}$/; const ADDRESS = /^[^\s@]+@[^\s@.]+\.[^\s@]+$/; +const fieldText = z.string().nullable().catch(null); + +export type FormFields = Record; + +type StoredTouch = { + source: string; + medium: string; + campaign: string | null; + term: string | null; + content: string | null; + referrer: string | null; + landing: string | null; + at: string; +}; + export interface IncomingEvent { type: string; host: string; @@ -42,7 +58,7 @@ export interface IncomingEvent { referrer?: string; label?: string; at?: number; - fields?: Record; + fields?: FormFields; touch?: RawTouch; firstTouch?: RawTouch; } @@ -250,7 +266,7 @@ function arriving(touch: RawTouch): RawTouch { }; } -function stored(touch: Touch): Record { +function stored(touch: Touch): StoredTouch { return { source: touch.source, medium: touch.medium, @@ -267,7 +283,7 @@ function scripted(events: IncomingEvent[]): boolean { if (events.length < 3) return false; const stamps = events.flatMap((event) => - typeof event.at === "number" && Number.isFinite(event.at) ? [event.at] : [], + event.at !== undefined && Number.isFinite(event.at) ? [event.at] : [], ); if (stamps.length !== events.length) return false; @@ -277,7 +293,7 @@ function scripted(events: IncomingEvent[]): boolean { function occurredAt(at: number | undefined): Date { const now = Date.now(); - if (typeof at !== "number" || !Number.isFinite(at)) return new Date(now); + if (at === undefined || !Number.isFinite(at)) return new Date(now); const bounded = Math.min(Math.max(at, now - 86_400_000), now); @@ -289,28 +305,27 @@ function trim(value: string, max: number): string { } function sanitizeId(value: string | undefined): string | null { - if (typeof value !== "string") return null; - - const trimmed = value.trim(); + const trimmed = fieldText.parse(value)?.trim() ?? ""; return /^[a-zA-Z0-9_-]{8,64}$/.test(trimmed) ? trimmed : null; } -function clean(fields: Record): Record { - const kept: Record = {}; +function clean(fields: FormFields): FormFields { + const kept: FormFields = {}; for (const [key, value] of Object.entries(fields).slice(0, 40)) { - if (typeof value !== "string") continue; + const text = fieldText.parse(value); + if (text === null) continue; if (SENSITIVE.test(key)) continue; - if (CARD.test(value.trim())) continue; + if (CARD.test(text.trim())) continue; - kept[trim(key, 64)] = trim(value, 512); + kept[trim(key, 64)] = trim(text, 512); } return kept; } -function emailFrom(fields: Record): string | null { +function emailFrom(fields: FormFields): string | null { for (const [key, value] of Object.entries(fields)) { if (!/mail/i.test(key)) continue; const email = address(value); @@ -331,7 +346,7 @@ function address(value: string): string | null { return email && ADDRESS.test(email) ? email : null; } -function nameFrom(fields: Record): string | null { +function nameFrom(fields: FormFields): string | null { const first = pick(fields, /^(first[\s_-]?name|fname|given)/i); const last = pick(fields, /^(last[\s_-]?name|lname|surname|family)/i); @@ -340,7 +355,7 @@ function nameFrom(fields: Record): string | null { return pick(fields, /^(full[\s_-]?name|name)$/i) ?? pick(fields, /name/i); } -function pick(fields: Record, pattern: RegExp): string | null { +function pick(fields: FormFields, pattern: RegExp): string | null { for (const [key, value] of Object.entries(fields)) { if (pattern.test(key) && value.trim()) return value.trim(); } diff --git a/apps/api/src/tracking/tracking.controller.ts b/apps/api/src/tracking/tracking.controller.ts index 1c96016c6..21ccbf238 100644 --- a/apps/api/src/tracking/tracking.controller.ts +++ b/apps/api/src/tracking/tracking.controller.ts @@ -21,6 +21,7 @@ import { import { ConfigService } from "@nestjs/config"; import { AllowAnonymous } from "@thallesp/nestjs-better-auth"; import type { Response } from "express"; +import { z } from "zod"; import type { EnvironmentVariables } from "../config/env.validation"; import { InjectDatabase } from "../database/database.constants"; import { TrackingConfigService } from "./tracking-config.service"; @@ -35,6 +36,18 @@ const SWEEP_BATCH = 10_000; const MAX_SWEEP_PASSES = 50; +const parsedBody = z + .union([ + z.string().transform((text) => ({ text, json: null })), + z + .union([z.array(z.json()), z.looseObject({})]) + .transform((json) => ({ text: null, json })), + ]) + .nullable() + .catch(null); + +const trackingRequest = z.object({ body: parsedBody }).catch({ body: null }); + @Controller("api/t") export class TrackingController { private readonly logger = new Logger(TrackingController.name); @@ -203,12 +216,14 @@ async function read( request: IncomingMessage, limit: number, ): Promise { - const existing = (request as { body?: unknown }).body; - if (typeof existing === "string") { - return existing.length > limit ? null : existing; - } - if (existing && typeof existing === "object") { - return JSON.stringify(existing); + const existing = trackingRequest.parse(request).body; + + if (existing !== null) { + if (existing.text !== null) { + return existing.text.length > limit ? null : existing.text; + } + + return JSON.stringify(existing.json); } return new Promise((resolve) => { diff --git a/apps/api/src/trpc/error-formatter.ts b/apps/api/src/trpc/error-formatter.ts index f0855e92c..6bcb8d134 100644 --- a/apps/api/src/trpc/error-formatter.ts +++ b/apps/api/src/trpc/error-formatter.ts @@ -1,9 +1,21 @@ import type { TRPCDefaultErrorShape, TRPCErrorFormatter } from "@trpc/server"; +import { z } from "zod"; -interface Issue { - message?: unknown; - path?: unknown; -} +const issueShape = z + .object({ + message: z.string().catch(""), + path: z.array(z.unknown()).catch([]), + }) + .catch({ message: "", path: [] }); + +type Issue = z.infer; + +const pathSegment = z.string().nullable().catch(null); + +const failedParse = z + .object({ issues: z.array(issueShape).min(1) }) + .nullable() + .catch(null); /** * tRPC stringifies a failed input parse into the whole `ZodError`, so a form @@ -16,29 +28,23 @@ interface Issue { * matching and put the JSON back on screen. */ function issuesIn(cause: unknown): Issue[] | null { - if (typeof cause !== "object" || cause === null) return null; - - const issues = (cause as { issues?: unknown }).issues; - - return Array.isArray(issues) && issues.length > 0 - ? (issues as Issue[]) - : null; + return failedParse.parse(cause)?.issues ?? null; } function sentence(issue: Issue): string | null { - if (typeof issue.message !== "string" || issue.message.trim() === "") { - return null; - } - const message = issue.message.trim(); + if (message === "") return null; // Zod's own defaults ("Required", "Invalid input") name nothing, so they are // only useful with the field in front of them. Ours are whole sentences. if (/[.!?]$/.test(message)) return message; - const field = Array.isArray(issue.path) - ? issue.path.filter((part) => typeof part === "string").at(-1) - : undefined; + const field = issue.path + .flatMap((part) => { + const segment = pathSegment.parse(part); + return segment === null ? [] : [segment]; + }) + .at(-1); return field ? `${field}: ${message}` : message; } diff --git a/apps/api/src/trpc/list-input.ts b/apps/api/src/trpc/list-input.ts index e2b46fc95..e207d96eb 100644 --- a/apps/api/src/trpc/list-input.ts +++ b/apps/api/src/trpc/list-input.ts @@ -10,7 +10,9 @@ export const listInput = z.object({ export type ListInput = z.infer; -type FacetCounts = Record>; +export type FacetCount = Record; + +type FacetCounts = Record; export type ListResult = { rows: TRow[]; @@ -18,19 +20,27 @@ export type ListResult = { facetCounts: FacetCounts; }; -export function paginate(input: Pick): { +export type Page = { skip: number; take: number; -} { +}; + +export function paginate(input: Pick): Page { return { skip: (input.page - 1) * input.pageSize, take: input.pageSize, }; } +export type SortDirection = ListInput["dir"]; + +export interface OrderByColumns { + [column: string]: (dir: SortDirection) => TOrderBy; +} + export function resolveOrderBy( input: Pick, - columns: Record TOrderBy>, + columns: OrderByColumns, fallback: TOrderBy, ): TOrderBy { const column = columns[input.sort]; @@ -42,8 +52,8 @@ export function countsByKey< TGroup extends { _count: { _all: number } } & { [K in TKey]?: string | null; }, ->(groups: TGroup[], key: TKey, nullKey?: string): Record { - const counts: Record = {}; +>(groups: TGroup[], key: TKey, nullKey?: string): FacetCount { + const counts: FacetCount = {}; for (const group of groups) { const value = group[key] ?? nullKey; diff --git a/apps/api/src/workspace/workspace.service.ts b/apps/api/src/workspace/workspace.service.ts index e498f6d85..8e49e6f29 100644 --- a/apps/api/src/workspace/workspace.service.ts +++ b/apps/api/src/workspace/workspace.service.ts @@ -24,6 +24,7 @@ import { countsByKey, FACET_ALL, type ListResult, + type OrderByColumns, paginate, resolveOrderBy, } from "../trpc/list-input"; @@ -65,10 +66,7 @@ const MEMBER_SELECT = { type MemberRow = Prisma.MemberGetPayload<{ select: typeof MEMBER_SELECT }>; -const SORTABLE: Record< - string, - (dir: Prisma.SortOrder) => Prisma.MemberOrderByWithRelationInput -> = { +const SORTABLE: OrderByColumns = { name: (dir) => ({ user: { name: dir } }), email: (dir) => ({ user: { email: dir } }), role: (dir) => ({ role: dir }), diff --git a/apps/api/test/agent-runs.spec.ts b/apps/api/test/agent-runs.spec.ts index 024b59529..22d4d8c40 100644 --- a/apps/api/test/agent-runs.spec.ts +++ b/apps/api/test/agent-runs.spec.ts @@ -582,7 +582,10 @@ describe("cancelling a run", () => { const realFetch = globalThis.fetch; const realSecret = process.env.AGENT_BRIDGE_SECRET; process.env.AGENT_BRIDGE_SECRET = "run-cancel-test"; - globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + globalThis.fetch = (async ( + _url: string | URL | Request, + init?: RequestInit, + ) => { const body = JSON.parse(String(init?.body)) as { runId: string }; asked.push(body.runId); return new Response(null, { status }); diff --git a/apps/api/test/conversation-sharing.spec.ts b/apps/api/test/conversation-sharing.spec.ts index c1443504c..75f1f60dc 100644 --- a/apps/api/test/conversation-sharing.spec.ts +++ b/apps/api/test/conversation-sharing.spec.ts @@ -2,9 +2,14 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; import { db } from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; +import { z } from "zod"; import { ConversationSharingService } from "../src/conversations/conversation-sharing.service"; import { ConversationsService } from "../src/conversations/conversations.service"; +const record = z.record(z.string(), z.unknown()).catch({}); + +const list = z.array(z.unknown()).catch([]); + const suffix = crypto.randomUUID(); const userId = `share-user-${suffix}`; const outsiderId = `share-outsider-${suffix}`; @@ -173,8 +178,8 @@ describe("conversation sharing", () => { it("authorizes attachment bytes with the active share token only", async () => { const { token } = await service.create(conversationId, userId); const shared = await service.resolve(token, viewerId); - const message = recordOf(shared.submissions[0]?.message); - const attachment = recordOf(arrayOf(message.attachments)[0]); + const message = record.parse(shared.submissions[0]?.message); + const attachment = record.parse(list.parse(message.attachments)[0]); expect(attachment.previewUrl).toBe( `/api/conversations/attachments/${attachmentId}?share=${encodeURIComponent(token)}`, ); @@ -214,13 +219,3 @@ describe("conversation sharing", () => { expect(unavailable).toBeDefined(); }); }); - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function arrayOf(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} diff --git a/apps/api/test/conversations.spec.ts b/apps/api/test/conversations.spec.ts index de0d3ab11..25c99ca66 100644 --- a/apps/api/test/conversations.spec.ts +++ b/apps/api/test/conversations.spec.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; import { db } from "@crm/db"; import { workspaceSlug } from "@crm/db/workspace"; +import { z } from "zod"; import { builderConversationCreateInput, conversationListInput, @@ -9,6 +10,12 @@ import { } from "../src/conversations/conversations.contracts"; import { ConversationsService } from "../src/conversations/conversations.service"; +const record = z.record(z.string(), z.unknown()).catch({}); + +const list = z.array(z.unknown()).catch([]); + +const text = z.string().nullable().catch(null); + const suffix = process.env.TEST_RUN_ID ?? "conversations-spec"; const email = `conversation.subject.${suffix}@example.test`; const userId = `user-${suffix}`; @@ -449,8 +456,10 @@ describe("ConversationsService", () => { const submission = detail.submissions.find( (row) => row.clientRequestId === input.clientRequestId, ); - const message = recordOf(submission?.message); - const attachments = arrayOf(message.attachments).map(recordOf); + const message = record.parse(submission?.message); + const attachments = list + .parse(message.attachments) + .map((entry) => record.parse(entry)); expect(JSON.stringify(message)).not.toContain("contentBase64"); expect(attachments).toEqual([ @@ -467,8 +476,8 @@ describe("ConversationsService", () => { previewUrl: null, }), ]); - const imageId = attachments[0]?.id; - if (typeof imageId !== "string") throw new Error("Missing attachment id"); + const imageId = text.parse(attachments[0]?.id); + if (imageId === null) throw new Error("Missing attachment id"); const image = await service.attachment(imageId, userId); expect(Buffer.from(image.content)).toEqual(imageBytes); expect(image).toMatchObject({ @@ -515,9 +524,9 @@ describe("ConversationsService", () => { userId, ); const original = await service.builderById(conversation.id, userId); - const originalMessage = recordOf(original.submissions[0]?.message); - const originalAttachment = recordOf( - arrayOf(originalMessage.attachments)[0], + const originalMessage = record.parse(original.submissions[0]?.message); + const originalAttachment = record.parse( + list.parse(originalMessage.attachments)[0], ); await service.submitBuilder( @@ -541,8 +550,10 @@ describe("ConversationsService", () => { ); const detail = await service.builderById(conversation.id, userId); - const retryMessage = recordOf(detail.submissions.at(-1)?.message); - const retryAttachment = recordOf(arrayOf(retryMessage.attachments)[0]); + const retryMessage = record.parse(detail.submissions.at(-1)?.message); + const retryAttachment = record.parse( + list.parse(retryMessage.attachments)[0], + ); expect(retryAttachment.id).not.toBe(originalAttachment.id); const stored = await service.attachment(String(retryAttachment.id), userId); expect(Buffer.from(stored.content)).toEqual(bytes); @@ -578,8 +589,8 @@ describe("ConversationsService", () => { userId, ); const sourceDetail = await service.builderById(source.id, userId); - const sourceMessage = recordOf(sourceDetail.submissions[0]?.message); - const attachment = recordOf(arrayOf(sourceMessage.attachments)[0]); + const sourceMessage = record.parse(sourceDetail.submissions[0]?.message); + const attachment = record.parse(list.parse(sourceMessage.attachments)[0]); let submitError: unknown; try { @@ -842,13 +853,3 @@ describe("ConversationsService", () => { ).toBe(1); }); }); - -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function arrayOf(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} diff --git a/apps/api/test/error-formatter.spec.ts b/apps/api/test/error-formatter.spec.ts index 6cd27af78..f6126cddb 100644 --- a/apps/api/test/error-formatter.spec.ts +++ b/apps/api/test/error-formatter.spec.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { setResearchKeyInput } from "../src/settings/settings.contracts"; import { readableInputError } from "../src/trpc/error-formatter"; -const causeOf = (schema: z.ZodType, value: unknown) => { +const causeOf = (schema: z.ZodType, value: z.core.util.JSONType) => { const result = schema.safeParse(value); if (result.success) throw new Error("expected the parse to fail"); return result.error; diff --git a/apps/api/test/logging.spec.ts b/apps/api/test/logging.spec.ts index 0367b66ba..20664959e 100644 --- a/apps/api/test/logging.spec.ts +++ b/apps/api/test/logging.spec.ts @@ -121,11 +121,13 @@ describe("request context", () => { }); }); +type MiddlewareRun = { + requestId: string; + seen: string | undefined; +}; + describe("RequestLoggerMiddleware", () => { - function run(headers: Record): { - requestId: string; - seen: string | undefined; - } { + function run(headers: Record): MiddlewareRun { const middleware = new RequestLoggerMiddleware(); let requestId = ""; let seen: string | undefined; diff --git a/apps/api/test/mailbox-api-client.spec.ts b/apps/api/test/mailbox-api-client.spec.ts index 89315ae9f..256881941 100644 --- a/apps/api/test/mailbox-api-client.spec.ts +++ b/apps/api/test/mailbox-api-client.spec.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; +import type { z } from "zod"; import { MailboxApiClient } from "../src/mailbox/mailbox-api.client"; const realFetch = globalThis.fetch; @@ -9,7 +10,7 @@ afterEach(() => { function stub( status: number, - body: unknown, + body: z.core.util.JSONType, headers: Record = {}, ): void { globalThis.fetch = (async () => diff --git a/apps/api/test/outlook-sync.spec.ts b/apps/api/test/outlook-sync.spec.ts index 89b33985b..38b2adb2c 100644 --- a/apps/api/test/outlook-sync.spec.ts +++ b/apps/api/test/outlook-sync.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { MailboxSyncModel as MailboxSync } from "@crm/db"; +import type { SyncSource } from "../src/mailbox/mailbox.constants"; import type { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; import type { SyncStateService } from "../src/mailbox/sync-state.service"; import type { @@ -18,6 +19,13 @@ type NotOk = const ok = (data: T): Ok => ({ outcome: "ok", data }); +type StoreOptions = { mailbox: string; origin: SyncSource }; + +type GraphPage = { + value: GraphMessage[]; + "@odata.nextLink"?: string; +}; + const row = { id: "sync-1", userId: "user-1", @@ -51,6 +59,13 @@ function harness(options: { const pages = options.pages ?? [[]]; let index = 0; + const page = (at: number): GraphPage => { + const body: GraphPage = { value: pages[at] ?? [] }; + if (at + 1 < pages.length) body["@odata.nextLink"] = `next-${at + 1}`; + + return body; + }; + const graph = { async me() { if (options.meDelayMs) { @@ -66,19 +81,11 @@ function harness(options: { }, async listMessages() { index = 0; - return ok({ - value: pages[0] ?? [], - ...(pages.length > 1 ? { "@odata.nextLink": "next-1" } : {}), - }); + return ok(page(0)); }, async nextPage() { index += 1; - return ok({ - value: pages[index] ?? [], - ...(index + 1 < pages.length - ? { "@odata.nextLink": `next-${index + 1}` } - : {}), - }); + return ok(page(index)); }, } as unknown as GraphClient; @@ -109,7 +116,11 @@ function harness(options: { async context() { return {}; }, - async store(_row: MailboxSync, _options: unknown, parsed: IncomingMessage) { + async store( + _row: MailboxSync, + _options: StoreOptions, + parsed: IncomingMessage, + ) { stored.push(parsed); return true; }, diff --git a/apps/api/test/slack-channels.spec.ts b/apps/api/test/slack-channels.spec.ts index d9e6ef0e8..558a22e51 100644 --- a/apps/api/test/slack-channels.spec.ts +++ b/apps/api/test/slack-channels.spec.ts @@ -6,7 +6,10 @@ const realFetch = globalThis.fetch; const realSecret = process.env.AGENT_BRIDGE_SECRET; function agentAnswers(status: number, body: string | null) { - globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => + globalThis.fetch = (async ( + _url: string | URL | Request, + _init?: RequestInit, + ) => new Response(body, { status, headers: { "content-type": "application/json" }, diff --git a/apps/api/test/sso.spec.ts b/apps/api/test/sso.spec.ts index 46486d7ec..6f28a3ccb 100644 --- a/apps/api/test/sso.spec.ts +++ b/apps/api/test/sso.spec.ts @@ -20,8 +20,10 @@ const LIST = { pageSize: 25, }; +type Seen = { providerWhere?: unknown }; + function service(role: string | null, rows: Row[] = []) { - const seen: { providerWhere?: unknown } = {}; + const seen: Seen = {}; const db = { member: { diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx index baaf9e59d..1f5aa61c1 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/agents/[agentId]/page.tsx @@ -31,13 +31,9 @@ async function PrefetchedTeamAgent({ const client = getServerTrpcClient(); const [agent, runs, activity] = await Promise.all([ - client.agents.byId.query({ id: agentId }).catch(nullIfMissing), - client.agents.history - .query({ id: agentId, limit: 50 }) - .catch(nullIfMissing), - client.agents.activity - .query({ id: agentId, limit: 100 }) - .catch(nullIfMissing), + nullIfMissing(client.agents.byId.query({ id: agentId })), + nullIfMissing(client.agents.history.query({ id: agentId, limit: 50 })), + nullIfMissing(client.agents.activity.query({ id: agentId, limit: 100 })), ]); if (!agent || !runs || !activity) notFound(); diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx b/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx index 68014cd94..21e32bed4 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx +++ b/apps/app/app/(app)/[slug]/(agent-builder)/chat/[chatId]/page.tsx @@ -30,16 +30,16 @@ async function PrefetchedAgentChat({ const client = getServerTrpcClient(); if (isSharedChatToken(chatId)) { - const shared = await client.conversations.shared - .query({ token: chatId }) - .catch(nullIfMissing); + const shared = await nullIfMissing( + client.conversations.shared.query({ token: chatId }), + ); return ; } - const conversation = await client.conversations.builderById - .query({ id: chatId }) - .catch(nullIfMissing); + const conversation = await nullIfMissing( + client.conversations.builderById.query({ id: chatId }), + ); if (!conversation) notFound(); diff --git a/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts b/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts index 66d6f58a2..e14a5a163 100644 --- a/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts +++ b/apps/app/app/(app)/[slug]/(agent-builder)/missing-record.ts @@ -1,9 +1,13 @@ import { TRPCClientError } from "@trpc/client"; -export function nullIfMissing(error: unknown): null { - if (error instanceof TRPCClientError && error.data?.code === "NOT_FOUND") { - return null; - } +export async function nullIfMissing(query: Promise): Promise { + try { + return await query; + } catch (error) { + if (error instanceof TRPCClientError && error.data?.code === "NOT_FOUND") { + return null; + } - throw error; + throw error; + } } diff --git a/apps/app/app/(app)/[slug]/layout.tsx b/apps/app/app/(app)/[slug]/layout.tsx index b993c2113..863d0da85 100644 --- a/apps/app/app/(app)/[slug]/layout.tsx +++ b/apps/app/app/(app)/[slug]/layout.tsx @@ -40,20 +40,25 @@ export default function AppLayout({ ); } +async function loadWorkspace() { + try { + return await getServerQueryClient().fetchQuery( + getServerTrpc().workspace.get.queryOptions(), + ); + } catch (error) { + unstable_rethrow(error); + return null; + } +} + async function WorkspaceHeader({ params, }: Pick, "params">) { await connection(); - const workspacePromise = getServerQueryClient() - .fetchQuery(getServerTrpc().workspace.get.queryOptions()) - .catch((error: unknown) => { - unstable_rethrow(error); - return null; - }); const [{ user }, { slug }, workspace] = await Promise.all([ requireMailboxAccess(), params, - workspacePromise, + loadWorkspace(), ]); if (workspace && workspace.slug !== slug) notFound(); diff --git a/apps/app/app/(app)/[slug]/overview-scope.tsx b/apps/app/app/(app)/[slug]/overview-scope.tsx index 8a0ceb9d9..dcc0632c2 100644 --- a/apps/app/app/(app)/[slug]/overview-scope.tsx +++ b/apps/app/app/(app)/[slug]/overview-scope.tsx @@ -8,10 +8,10 @@ import { overviewParsers, } from "./overview-search-params"; -const LABELS: Record = { +const LABELS = { me: "Me", everyone: "Everyone", -}; +} satisfies Record; function isScope(value: string): value is OverviewScope { return (OVERVIEW_SCOPES as readonly string[]).includes(value); diff --git a/apps/app/app/(app)/[slug]/sales-dashboard.tsx b/apps/app/app/(app)/[slug]/sales-dashboard.tsx index cba5acee1..d4a3162d8 100644 --- a/apps/app/app/(app)/[slug]/sales-dashboard.tsx +++ b/apps/app/app/(app)/[slug]/sales-dashboard.tsx @@ -58,11 +58,8 @@ export function SalesDashboard({ summary }: { summary: Summary }) { } = summary; const money = (cents: number) => formatMoneyCompact(cents, reportingCurrency); - const exact = (value: unknown) => - formatMoney( - typeof value === "number" ? value : Number(value), - reportingCurrency, - ); + const exact = (value: number | string) => + formatMoney(Number(value), reportingCurrency); const hasTrend = trend.some((point) => point.won > 0 || point.created > 0); diff --git a/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx index bc9adb386..20f3fca8b 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/google-connection.tsx @@ -123,10 +123,12 @@ function GoogleUnavailable() { ); } -const CONNECT_ERRORS: Record = { - "email_doesn't_match": +const CONNECT_ERRORS = new Map([ + [ + "email_doesn't_match", "That Google account has a different email address to the one you sign in with, so it cannot be attached to your account. Connect the Google account that matches your sign-in address.", -}; + ], +]); function ConnectGoogle({ slug, @@ -196,7 +198,7 @@ function ConnectGoogle({ Google did not finish connecting - {CONNECT_ERRORS[connectError] ?? + {CONNECT_ERRORS.get(connectError) ?? "Google returned an error before the connection was made. Try again."} diff --git a/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx b/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx index 9418fe806..6be4afa5e 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/microsoft-connection.tsx @@ -42,10 +42,12 @@ import { useTRPC } from "@/lib/trpc/client"; const AUTO_CREATE = "Add the company and contact when you reply to someone new"; -const CONNECT_ERRORS: Record = { - "email_doesn't_match": +const CONNECT_ERRORS = new Map([ + [ + "email_doesn't_match", "That Microsoft account has a different email address to the one you sign in with, so it cannot be attached to your account. Connect the Microsoft account that matches your sign-in address.", -}; + ], +]); function MicrosoftUnavailable() { return ( @@ -134,7 +136,7 @@ function ConnectMicrosoft({ Microsoft did not finish connecting - {CONNECT_ERRORS[connectError] ?? + {CONNECT_ERRORS.get(connectError) ?? "Microsoft returned an error before the connection was made. Try again."} diff --git a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx index 919e79589..74b7fc389 100644 --- a/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx +++ b/apps/app/app/(app)/[slug]/settings/connections/slack/slack-connect-button.tsx @@ -5,17 +5,28 @@ import { Button } from "@crm/ui/components/button"; import { useState } from "react"; import { toast } from "sonner"; -const CONNECT_ERRORS: Record = { - access_denied: "Slack installation was cancelled before access was granted.", - account_already_linked_to_different_user: +const CONNECT_ERRORS = new Map([ + [ + "access_denied", + "Slack installation was cancelled before access was granted.", + ], + [ + "account_already_linked_to_different_user", "That Slack installer is already linked to another CRM account.", - "email_doesn't_match": + ], + [ + "email_doesn't_match", "The Slack installer's email must match the CRM account you are signed in with.", - oauth_code_verification_failed: + ], + [ + "oauth_code_verification_failed", "Slack rejected the app credentials or redirect URL. Check the client ID, client secret, and OAuth redirect URL, then try again.", - user_info_is_missing: + ], + [ + "user_info_is_missing", "Slack did not return the installer's profile. Confirm the app has users:read and users:read.email, reinstall it, then try again.", -}; + ], +]); async function startSlackOAuth(slug: string) { try { @@ -77,7 +88,7 @@ export function SlackConnectButton({ {connectError ? (

- {CONNECT_ERRORS[connectError] ?? + {CONNECT_ERRORS.get(connectError) ?? `Slack could not be connected (${connectError.replaceAll("_", " ")}).`}

) : null} diff --git a/apps/app/app/(landing)/grant-access/grant-access.tsx b/apps/app/app/(landing)/grant-access/grant-access.tsx index acf729813..9f5ee1fad 100644 --- a/apps/app/app/(landing)/grant-access/grant-access.tsx +++ b/apps/app/app/(landing)/grant-access/grant-access.tsx @@ -10,10 +10,17 @@ import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; import { Button } from "@crm/ui/components/button"; import { Spinner } from "@crm/ui/components/spinner"; +import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; import { signOutAndRedirect } from "@/lib/sign-out"; +type ProviderGrant = { + label: string; + scopes: readonly string[]; + Logo: FC>; +}; + const PROVIDERS = { google: { label: "Grant Google access", @@ -25,7 +32,7 @@ const PROVIDERS = { scopes: [...MICROSOFT_SYNC_SCOPES], Logo: MicrosoftLogo, }, -} as const satisfies Record; +} as const satisfies Record; export function GrantAccess({ providers, diff --git a/apps/app/app/(landing)/grant-access/page.tsx b/apps/app/app/(landing)/grant-access/page.tsx index f5ee181d9..39d306de4 100644 --- a/apps/app/app/(landing)/grant-access/page.tsx +++ b/apps/app/app/(landing)/grant-access/page.tsx @@ -1,4 +1,4 @@ -import { mailboxGrantsNeeded } from "@crm/auth"; +import { type MailboxProviderId, mailboxGrantsNeeded } from "@crm/auth"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { AuthHeading, AuthShell } from "@/components/auth-shell"; @@ -11,12 +11,12 @@ export const metadata: Metadata = { export const instant = false; -const DESCRIPTION: Record = { +const DESCRIPTION = { google: "This CRM reads your Gmail and Calendar so meetings and email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", microsoft: "This CRM reads your Outlook mail so email threads show up on the right company. It is read-only — nothing is ever sent on your behalf.", -}; +} satisfies Record; const BOTH = "This CRM reads your mail and calendar so meetings and email threads show up on the right company. It is read-only — nothing is ever sent on your behalf."; diff --git a/apps/app/app/(landing)/sign-in/page.tsx b/apps/app/app/(landing)/sign-in/page.tsx index 0a7929fc1..5b0e451af 100644 --- a/apps/app/app/(landing)/sign-in/page.tsx +++ b/apps/app/app/(landing)/sign-in/page.tsx @@ -30,6 +30,16 @@ async function signInOptions(): Promise { } } +async function currentSession() { + try { + return await getSession(); + } catch (error) { + unstable_rethrow(error); + console.error("Sign-in: could not read the session.", error); + return null; + } +} + export default function SignInPage({ searchParams }: PageProps<"/sign-in">) { return ( @@ -51,11 +61,7 @@ async function SignIn({ searchParams, }: Pick, "searchParams">) { const [session, options, { method }] = await Promise.all([ - getSession().catch((error: unknown) => { - unstable_rethrow(error); - console.error("Sign-in: could not read the session.", error); - return null; - }), + currentSession(), signInOptions(), searchParams, ]); diff --git a/apps/app/app/(landing)/sign-in/social-sign-in.tsx b/apps/app/app/(landing)/sign-in/social-sign-in.tsx index 021d2249d..882f89c12 100644 --- a/apps/app/app/(landing)/sign-in/social-sign-in.tsx +++ b/apps/app/app/(landing)/sign-in/social-sign-in.tsx @@ -6,13 +6,19 @@ import GoogleLogo from "@crm/ui/components/brand-logos/google"; import MicrosoftLogo from "@crm/ui/components/brand-logos/microsoft"; import { Button } from "@crm/ui/components/button"; import { Spinner } from "@crm/ui/components/spinner"; +import type { FC, SVGProps } from "react"; import { useState } from "react"; import { toast } from "sonner"; +type ProviderChoice = { + label: string; + Logo: FC>; +}; + const PROVIDERS = { google: { label: "Continue with Google", Logo: GoogleLogo }, microsoft: { label: "Continue with Microsoft", Logo: MicrosoftLogo }, -} as const satisfies Record; +} as const satisfies Record; export function SocialSignIn({ provider }: { provider: MailboxProviderId }) { const [pending, setPending] = useState(false); diff --git a/apps/app/app/t/[site]/route.ts b/apps/app/app/t/[site]/route.ts index 082350e72..98e5e16f5 100644 --- a/apps/app/app/t/[site]/route.ts +++ b/apps/app/app/t/[site]/route.ts @@ -35,14 +35,14 @@ export async function GET( `${origin}/api/t/e`, ); - return new Response(source, { - headers: { - "content-type": "application/javascript; charset=utf-8", - "cache-control": `public, max-age=${CONFIG_MAX_AGE_SECONDS}, s-maxage=${CONFIG_MAX_AGE_SECONDS}`, - "x-content-type-options": "nosniff", - ...(payload.hash ? { etag: `"${payload.hash}"` } : {}), - }, + const headers = new Headers({ + "content-type": "application/javascript; charset=utf-8", + "cache-control": `public, max-age=${CONFIG_MAX_AGE_SECONDS}, s-maxage=${CONFIG_MAX_AGE_SECONDS}`, + "x-content-type-options": "nosniff", }); + if (payload.hash) headers.set("etag", `"${payload.hash}"`); + + return new Response(source, { headers }); } function empty(): Response { diff --git a/apps/app/components/agent-builder/agent-builder-chat.tsx b/apps/app/components/agent-builder/agent-builder-chat.tsx index f377fe5f2..b2e3626ea 100644 --- a/apps/app/components/agent-builder/agent-builder-chat.tsx +++ b/apps/app/components/agent-builder/agent-builder-chat.tsx @@ -35,12 +35,14 @@ import { Reasoning } from "@crm/ui/components/reasoning"; import { ThinkingIndicator } from "@crm/ui/components/thinking-indicator"; import { useMountEffect } from "@crm/ui/hooks/use-mount-effect"; import { cn } from "@crm/ui/lib/utils"; +import type { AgentManifestSummary } from "@crm/validation/agent-manifest"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Client, type MessageStreamEvent } from "eve/client"; import type { EveMessage, EveMessageInputRequest } from "eve/react"; import Link from "next/link"; import { Fragment, type ReactNode, useState } from "react"; import { toast } from "sonner"; +import { z } from "zod"; import { AgentClarificationComposer, type ClarificationResponse, @@ -100,27 +102,87 @@ const BUILDER_STEP_ARTIFACTS = [ "agent/manifest.json", "agent/README.md", ] as const; -type DraftVersion = { - id: string; - status: string; - manifest: unknown; -}; +const submissionResource = z.object({ + kind: z.enum(["integration", "company", "contact", "deal"]), + id: z.string().min(1), + label: z.string().min(1), + detail: z.string().nullable().optional().catch(null), + imageUrl: z.string().nullable().optional().catch(null), +}); + +const storedAttachment = z.object({ + id: z.string().min(1), + name: z.string().min(1), + type: z.string().min(1), + size: z.number(), + previewUrl: z.string().nullable().optional().catch(null), +}); + +const uploadAttachment = z.object({ + name: z.string().min(1), + type: z.string().min(1), + size: z.number(), + contentBase64: z.string().min(1), + previewUrl: z.string().nullable().optional().catch(null), +}); + +const builderMessage = z + .object({ + text: z.string().nullable().catch(null), + resources: z.array(submissionResource).catch([]), + attachments: z + .array(z.union([storedAttachment, uploadAttachment])) + .catch([]), + inputResponse: z + .object({ requestId: z.string().min(1) }) + .nullable() + .catch(null), + }) + .catch({ + text: null, + resources: [], + attachments: [], + inputResponse: null, + }); -type BuilderSubmission = { - id: string; - createdAt: string; - clientRequestId?: string | null; - commandType: "CHAT" | "CREATE_AGENT"; - message: unknown; - status: string; - errorMessage: string | null; -}; +type BuilderMessage = z.infer; + +const builderSubmissions = z.array( + z.object({ + id: z.string(), + createdAt: z.string(), + clientRequestId: z.string().nullable().catch(null), + commandType: z.enum(["CHAT", "CREATE_AGENT"]), + message: builderMessage, + status: z.string(), + errorMessage: z.string().nullable().catch(null), + }), +); + +type BuilderSubmission = z.infer[number]; + +const streamEventShape = z.object({ + type: z.string().min(1), + meta: z.object({ id: z.string().min(1), at: z.string().min(1) }), +}); + +const streamEvents = z + .array( + z + .custom( + (value) => streamEventShape.safeParse(value).success, + ) + .nullable() + .catch(null), + ) + .catch([]) + .transform((events) => events.filter((event) => event !== null)); type PendingSubmission = { clientRequestId: string; createdAt: string; commandType: "CHAT" | "CREATE_AGENT"; - message: unknown; + message: BuilderMessage; }; export function AgentBuilderChat({ @@ -232,7 +294,7 @@ export function AgentBuilderChat({ } const data = conversation.data ?? (initialData as Conversation); - const submissions = data.submissions as BuilderSubmission[]; + const submissions = builderSubmissions.parse(data.submissions); const confirmedRequestIds = new Set( submissions .map((submission) => submission.clientRequestId) @@ -241,8 +303,7 @@ export function AgentBuilderChat({ const pendingSubmissions = sending.filter( (item) => !confirmedRequestIds.has(item.clientRequestId), ); - const persistedEvents = (events.data ?? - []) as unknown as MessageStreamEvent[]; + const persistedEvents = streamEvents.parse(events.data ?? []); const streamKey = builderSessionStreamKey( data.sessionId, submissions.at(-1)?.id ?? null, @@ -287,6 +348,7 @@ export function AgentBuilderChat({ text: prompt.message, resources: prompt.resources, attachments: prompt.attachments, + inputResponse: null, }, }, ]); @@ -387,6 +449,7 @@ export function AgentBuilderChat({ submission={{ id: item.clientRequestId, createdAt: item.createdAt, + clientRequestId: item.clientRequestId, commandType: item.commandType, message: item.message, status: "PENDING", @@ -539,13 +602,15 @@ function BuilderEventFollower({ } }; - void follow() - .catch((error: unknown) => { + void (async () => { + try { + await follow(); + } catch (error) { if (!controller.signal.aborted) console.error(error); - }) - .finally(() => { + } finally { if (!controller.signal.aborted) onEnded(); - }); + } + })(); return () => controller.abort(); }); @@ -601,9 +666,8 @@ function SharedAgentChat({ }: { conversation: SharedConversation; }) { - const submissions = - conversation.submissions as unknown as BuilderSubmission[]; - const events = conversation.events as unknown as MessageStreamEvent[]; + const submissions = builderSubmissions.parse(conversation.submissions); + const events = streamEvents.parse(conversation.events); const messages = messagesFromEvents(events); const timeline = conversationTimeline(submissions, events, messages); const answeredQuestionIds = questionResponseIds(submissions); @@ -745,13 +809,14 @@ function UserSubmission({ error: string | null; sending?: boolean; }) { - const message = builderMessageOf(submission.message); + const message = submission.message; + const messageText = message.text ?? "Message unavailable"; const command = submission.commandType === "CREATE_AGENT" - ? consumeBuilderCommand(message.text) + ? consumeBuilderCommand(messageText) : null; - const text = command?.body ?? message.text; - const response = inputResponseOf(submission.message); + const text = command?.body ?? messageText; + const response = message.inputResponse; return (
candidate.id === versionId, - ) as DraftVersion | undefined; + ); const agent = conversation.agent; - const manifest = manifestOf(version?.manifest); if (!version || !agent) return null; + const manifest = manifestOf(version.manifest); return (
@@ -1430,19 +1495,6 @@ const RESOURCE_ICONS = { deal: Partnership, } as const; -function builderMessageOf(message: unknown) { - const row = recordOf(message); - return { - text: typeof row.text === "string" ? row.text : "Message unavailable", - resources: Array.isArray(row.resources) - ? (row.resources as BuilderPrompt["resources"]) - : [], - attachments: Array.isArray(row.attachments) - ? (row.attachments as BuilderPrompt["attachments"]) - : [], - }; -} - function hasQueuedQuestionResponse( submissions: BuilderSubmission[], requestId: string, @@ -1452,7 +1504,7 @@ function hasQueuedQuestionResponse( return false; } - return inputResponseOf(submission.message)?.requestId === requestId; + return submission.message.inputResponse?.requestId === requestId; }); } @@ -1465,36 +1517,25 @@ function questionResponseIds( return []; } - const response = inputResponseOf(submission.message); + const response = submission.message.inputResponse; return response ? [response.requestId] : []; }), ); } -function inputResponseOf(message: unknown): { requestId: string } | null { - const response = recordOf(recordOf(message).inputResponse); - return typeof response.requestId === "string" && response.requestId - ? { requestId: response.requestId } - : null; -} - function retryPromptOf( submission: BuilderSubmission | undefined, ): BuilderPrompt | null { if (!submission) return null; - const row = recordOf(submission.message); - if (Object.keys(recordOf(row.inputResponse)).length > 0) return null; - if (typeof row.text !== "string" || !row.text.trim()) return null; + const { message } = submission; + if (message.inputResponse) return null; + if (!message.text?.trim()) return null; return { commandType: submission.commandType, - message: row.text, - resources: Array.isArray(row.resources) - ? (row.resources as BuilderPrompt["resources"]) - : [], - attachments: Array.isArray(row.attachments) - ? (row.attachments as BuilderPrompt["attachments"]) - : [], + message: message.text, + resources: message.resources, + attachments: message.attachments, }; } @@ -1510,30 +1551,11 @@ function sharedConversationNeedsPolling( return !eventStreamSettled(conversation.events); } -function manifestOf(value: unknown) { - const manifest = recordOf(value); - const triggers = Array.isArray(manifest.triggers) - ? manifest.triggers.map(recordOf) - : []; - const dataScope = recordOf(manifest.dataScope); - const actions = Array.isArray(manifest.actions) - ? manifest.actions.map(recordOf) - : []; - const access = Array.isArray(manifest.access) - ? manifest.access.filter((item): item is string => typeof item === "string") - : []; - +function manifestOf(manifest: AgentManifestSummary) { return { - name: - typeof manifest.name === "string" && manifest.name.trim() - ? manifest.name.trim() - : null, - description: - typeof manifest.description === "string" && manifest.description.trim() - ? manifest.description.trim() - : null, + name: manifest.name?.trim() || null, trigger: - triggers + manifest.triggers .map((trigger) => trigger.type === "MANUAL" ? "On demand" @@ -1543,25 +1565,22 @@ function manifestOf(value: unknown) { ), ) .join(" · ") || "On demand", - looksAt: textOf(dataScope.summary, "CRM records in the approved scope"), + looksAt: textOf( + manifest.dataScope.summary, + "CRM records in the approved scope", + ), action: compactSummary( - actions[0]?.summary, + manifest.actions[0]?.summary, "Perform the requested team action", ), - access, + access: manifest.access, }; } -function compactSummary(value: unknown, fallback: string): string { +function compactSummary(value: string | undefined, fallback: string): string { return textOf(value, fallback).replace(/\s+\([^()]+\)\s*\.?$/, ""); } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function textOf(value: unknown, fallback: string): string { - return typeof value === "string" && value.trim() ? value : fallback; +function textOf(value: string | undefined, fallback: string): string { + return value?.trim() ? value : fallback; } diff --git a/apps/app/components/agent-builder/agent-builder-sidebar.tsx b/apps/app/components/agent-builder/agent-builder-sidebar.tsx index b8502b4d1..7b58eba03 100644 --- a/apps/app/components/agent-builder/agent-builder-sidebar.tsx +++ b/apps/app/components/agent-builder/agent-builder-sidebar.tsx @@ -234,18 +234,16 @@ function groupConversations(conversations: Conversation[], now: number) { if (!now) return []; const labels: ChatDateGroup[] = ["Today", "Yesterday", "Last 7 days"]; - const items: Record = { - Today: [], - Yesterday: [], - "Last 7 days": [], - }; + const items = new Map( + labels.map((label) => [label, []]), + ); for (const conversation of conversations) { const label = chatDateGroup(conversation.lastMessageAt, now); - if (label) items[label].push(conversation); + if (label) items.get(label)?.push(conversation); } return labels - .map((label) => ({ label, items: items[label] })) + .map((label) => ({ label, items: items.get(label) ?? [] })) .filter((group) => group.items.length > 0); } diff --git a/apps/app/components/agent-builder/agent-capabilities.tsx b/apps/app/components/agent-builder/agent-capabilities.tsx index 9fe8fa03b..1f016172b 100644 --- a/apps/app/components/agent-builder/agent-capabilities.tsx +++ b/apps/app/components/agent-builder/agent-capabilities.tsx @@ -22,27 +22,21 @@ import { } from "@/components/slack/channel-picker"; import { useSlackChannels } from "@/components/slack/use-slack-channels"; import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; import { CreateChannelDialog } from "./create-channel-dialog"; -export type Resource = { id: string; kind: string; label: string }; +export type Capabilities = RouterOutputs["agents"]["byId"]["capabilities"]; -export type Capabilities = { - readable: boolean; - problem: string | null; - channel: { kind: "channel" | "user"; id: string; label: string } | null; - actions: Array<{ type: string; provider: string; summary: string }>; - dataScope: { - mode: "SELECTED" | "WORKSPACE"; - summary: string; - resources: Resource[]; - } | null; -}; +export type Resource = Extract< + Capabilities, + { readable: true } +>["dataScope"]["resources"][number]; -const ACTION_LABELS: Record = { - "slack.message.post": "Post a message", - "crm.activity.create": "Write a note or task on the record", - "run.summary": "Write a summary of the run", -}; +const ACTION_LABELS = new Map([ + ["slack.message.post", "Post a message"], + ["crm.activity.create", "Write a note or task on the record"], + ["run.summary", "Write a summary of the run"], +]); export function AgentCapabilities({ agentId, @@ -136,29 +130,22 @@ export function AgentCapabilities({ revise.mutate({ id: agentId, clientRequestId: crypto.randomUUID(), - ...(channelChanged && picked - ? { channel: { id: picked.id, name: picked.name } } - : {}), - ...(actionsChanged - ? { - actions: capabilities.actions - .map((action) => action.type) - .filter((type) => !off.includes(type)), - } - : {}), - ...(scopeChanged - ? { - resources: shownResources.map((resource) => ({ - id: resource.id, - kind: resource.kind as - | "company" - | "contact" - | "deal" - | "integration", - label: resource.label, - })), - } - : {}), + channel: + channelChanged && picked + ? { id: picked.id, name: picked.name } + : undefined, + actions: actionsChanged + ? capabilities.actions + .map((action) => action.type) + .filter((type) => !off.includes(type)) + : undefined, + resources: scopeChanged + ? shownResources.map((resource) => ({ + id: resource.id, + kind: resource.kind, + label: resource.label, + })) + : undefined, }); }; @@ -207,7 +194,7 @@ export function AgentCapabilities({ >

- {ACTION_LABELS[action.type] ?? action.type} + {ACTION_LABELS.get(action.type) ?? action.type}

{action.summary || action.provider} diff --git a/apps/app/components/agent-builder/agent-composer.tsx b/apps/app/components/agent-builder/agent-composer.tsx index df1612926..4bed19abd 100644 --- a/apps/app/components/agent-builder/agent-composer.tsx +++ b/apps/app/components/agent-builder/agent-composer.tsx @@ -1457,12 +1457,12 @@ function ResourceResultsSkeleton() { ); } -const RESOURCE_ICONS: Record = { +const RESOURCE_ICONS = { integration: Application, company: Building, contact: User, deal: Partnership, -}; +} satisfies Record; function ResourceButton({ icon, diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx index 089032f69..7df33a1ea 100644 --- a/apps/app/components/agent-builder/agent-history.tsx +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -19,23 +19,33 @@ import { Button } from "@crm/ui/components/button"; import { Icon } from "@crm/ui/components/icon"; import { cn } from "@crm/ui/lib/utils"; import { useState } from "react"; +import { z } from "zod"; import { runFailureReason } from "@/lib/agent-run-failure"; import type { RouterOutputs } from "@/lib/trpc/types"; type Runs = RouterOutputs["agents"]["history"]; type Activity = RouterOutputs["agents"]["activity"]; -type RunRow = Omit & { - events: Array<{ - id: string; - type: string; - data: unknown; - emittedAt: string; - }>; -}; -type ActivityRow = Omit & { - before: unknown; - after: unknown; -}; +type RunRow = Runs[number]; +type AuditRow = Activity[number]; + +const runEvents = z.array( + z.object({ + id: z.string(), + type: z.string(), + data: z.json(), + emittedAt: z.string(), + }), +); + +type RunEvent = z.infer[number]; + +const eventSummary = z + .object({ summary: z.string().refine((text) => text.trim().length > 0) }) + .transform((event) => event.summary) + .nullable() + .catch(null); + +const auditChange = z.object({ before: z.json(), after: z.json() }); const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", @@ -180,9 +190,7 @@ export function AgentRuns({ ) : null}

- {expanded === run.id ? ( - - ) : null} + {expanded === run.id ? : null}
))} @@ -225,8 +233,9 @@ export function AgentRuns({ } function ExpandedRun({ run }: { run: RunRow }) { + const events = runEvents.parse(run.events); const timeline = [ - ...run.events.map((event) => ({ + ...events.map((event) => ({ kind: "event" as const, at: event.emittedAt, event, @@ -261,7 +270,7 @@ function ExpandedRun({ run }: { run: RunRow }) { {formatTime(entry.at)} - {eventLabel(entry.event.type, entry.event.data)} + {eventLabel(entry.event)} event @@ -294,8 +303,8 @@ function ExpandedRun({ run }: { run: RunRow }) { )} {run.eventsTruncated ? (
- Showing the first {run.events.length} of {run.totalEvents} steps. - This run is too long to display in full. + Showing the first {events.length} of {run.totalEvents} steps. This + run is too long to display in full.
) : null}
@@ -327,8 +336,7 @@ function RunMeta({ export function AgentActivity({ activity }: { activity: Activity }) { const [kind, setKind] = useState("ALL"); - const rows = activity as unknown as ActivityRow[]; - const visible = rows.filter( + const visible = activity.filter( (event) => kind === "ALL" || event.type.startsWith(kind), ); @@ -374,9 +382,9 @@ export function AgentActivity({ activity }: { activity: Activity }) { {event.summary} - {changeDetail(event.before, event.after) ? ( + {changeDetail(event) ? ( - {changeDetail(event.before, event.after)} + {changeDetail(event)} ) : null} @@ -400,16 +408,6 @@ export function AgentActivity({ activity }: { activity: Activity }) { ); } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function textOf(value: unknown, fallback: string): string { - return typeof value === "string" && value.trim() ? value : fallback; -} - function humanStatus(value: string): string { return value .toLowerCase() @@ -434,19 +432,22 @@ function duration(startedAt: string | null, finishedAt: string | null): string { return `${Math.max(0, milliseconds / 1000).toFixed(1)}s`; } -function eventLabel(type: string, data: unknown): string { - const payload = recordOf(data); - return textOf(payload.summary, humanStatus(type.replace(/\./g, " "))); +function eventLabel(event: RunEvent): string { + return ( + eventSummary.parse(event.data) ?? + humanStatus(event.type.replace(/\./g, " ")) + ); } -function changeDetail(before: unknown, after: unknown): string | null { +function changeDetail(event: AuditRow): string | null { + const { before, after } = auditChange.parse(event); if (!before && !after) return null; const previous = JSON.stringify(before); const next = JSON.stringify(after); return previous && next ? `${previous} → ${next}` : next || previous; } -function exportJson(name: string, value: unknown) { +function exportJson(name: string, value: readonly AuditRow[]) { const url = URL.createObjectURL( new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }), ); diff --git a/apps/app/components/agent-builder/agent-result.tsx b/apps/app/components/agent-builder/agent-result.tsx index 29ebcac45..b857e3552 100644 --- a/apps/app/components/agent-builder/agent-result.tsx +++ b/apps/app/components/agent-builder/agent-result.tsx @@ -1,4 +1,5 @@ import { Skeleton } from "@crm/ui/components/skeleton"; +import type { EveToolOutput } from "@crm/validation/eve-tool"; import type { ReactNode } from "react"; import { anchorResults } from "@/lib/agent-results"; import { @@ -22,7 +23,7 @@ function defineResult({ skeleton, }: { tool: string; - validate: (output: unknown) => T | null; + validate: (output: EveToolOutput) => T | null; group?: ( results: readonly { itemId: string; value: T }[], ) => readonly { itemId: string; value: T }[]; @@ -58,22 +59,27 @@ const listSkeleton = ( ); -const REGISTRY: Record = { - list_deals: defineResult({ - tool: "list_deals", - validate: dealListResultOf, - group: groupDealListPages, - render: (result, key) => , - skeleton: listSkeleton, - }), -}; +const REGISTRY = new Map([ + [ + "list_deals", + defineResult({ + tool: "list_deals", + validate: dealListResultOf, + group: groupDealListPages, + render: (result, key) => ( + + ), + skeleton: listSkeleton, + }), + ], +]); export function hasAgentResult(tool: string): boolean { - return tool in REGISTRY; + return REGISTRY.has(tool); } export function agentResultSkeleton(tool: string): ReactNode { - return REGISTRY[tool]?.skeleton ?? null; + return REGISTRY.get(tool)?.skeleton ?? null; } export function agentResultsByItem( @@ -81,7 +87,7 @@ export function agentResultsByItem( ): Map { const rendered = new Map(); - for (const entry of Object.values(REGISTRY)) { + for (const entry of REGISTRY.values()) { for (const [itemId, nodes] of entry.anchor(items)) { const bucket = rendered.get(itemId); if (bucket) bucket.push(...nodes); diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index e85f0cd39..4bfb67771 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -43,7 +43,7 @@ import { import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@/lib/trpc/types"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; -import { AgentCapabilities, type Capabilities } from "./agent-capabilities"; +import { AgentCapabilities } from "./agent-capabilities"; import { AgentCode } from "./agent-code"; import { AgentRunsDrawer } from "./agent-runs-drawer"; @@ -183,22 +183,14 @@ export function TeamAgentDetail({ const data = agent.data ?? initialAgent; const isDraft = data.status === "DRAFT"; - const reviewManifest = recordOf( - ( - data.reviewVersion as unknown as { - manifest: unknown; - } | null - )?.manifest, - ); + const reviewManifest = data.reviewVersion?.manifest; + const fallbackDescription = data.description ?? "A durable team automation."; const displayedName = isDraft - ? textOf(reviewManifest.name, data.name) + ? textOf(reviewManifest?.name, data.name) : data.name; const displayedDescription = isDraft - ? textOf( - reviewManifest.description, - data.description ?? "A durable team automation.", - ) - : (data.description ?? "A durable team automation."); + ? textOf(reviewManifest?.description, fallbackDescription) + : fallbackDescription; const displayedVersionNumber = data.currentVersion?.number ?? data.reviewVersion?.number; const enabledTriggers = data.triggers.filter((trigger) => trigger.enabled); @@ -531,8 +523,7 @@ function DeleteAgentAction({ } function AgentOverview({ agent }: { agent: AgentDetail }) { - const detail = agent as unknown as { capabilities?: Capabilities }; - const capabilities = detail.capabilities; + const { capabilities } = agent; const deployed = agent.currentVersion !== null; const canEdit = agent.canManage && deployed; @@ -577,14 +568,8 @@ function _DetailRow({ label, value }: { label: string; value: ReactNode }) { ); } -function recordOf(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function textOf(value: unknown, fallback: string): string { - return typeof value === "string" && value.trim() ? value : fallback; +function textOf(value: string | undefined, fallback: string): string { + return value?.trim() ? value : fallback; } function formatDate(value: string): string { diff --git a/apps/app/components/crm/agent-panel.tsx b/apps/app/components/crm/agent-panel.tsx index da2cec42f..61990ef01 100644 --- a/apps/app/components/crm/agent-panel.tsx +++ b/apps/app/components/crm/agent-panel.tsx @@ -375,17 +375,17 @@ function Failure({ message }: { message: string }) { ); } -const TONE_ICONS: Record = { +const TONE_ICONS = { neutral: CircleDash, success: Checkmark, warning: Warning, -}; +} satisfies Record; -const SOURCE_ICONS: Record = { +const SOURCE_ICONS = { linkedin: LogoLinkedin, github: LogoGithub, web: Document, -}; +} satisfies Record; function Item({ item }: { item: TranscriptItem }) { if (item.kind === "said") { @@ -503,14 +503,14 @@ function useSavedConversation({ const persist = useEffectEvent(() => { save.mutate( { - ...(contactId ? { contactId } : {}), - ...(companyId ? { companyId } : {}), - ...(dealId ? { dealId } : {}), + contactId: contactId || undefined, + companyId: companyId || undefined, + dealId: dealId || undefined, sessionId: sessionId ?? "", continuationToken: token, streamIndex, messageCount: messages, - ...(isNew ? { title: opening.current ?? undefined } : {}), + title: isNew ? (opening.current ?? undefined) : undefined, }, { onSuccess: () => { diff --git a/apps/app/components/crm/fields/field-editor.tsx b/apps/app/components/crm/fields/field-editor.tsx index 35ac13dfe..e5afe92d8 100644 --- a/apps/app/components/crm/fields/field-editor.tsx +++ b/apps/app/components/crm/fields/field-editor.tsx @@ -67,11 +67,11 @@ import { } from "./fields-copy"; import { type FieldEntity, kindOf } from "./fields-entity"; -const COVERAGE_NOUN: Record = { +const COVERAGE_NOUN = { COMPANY: "companies", CONTACT: "contacts", DEAL: "deals", -}; +} satisfies Record; type FieldRecord = RouterOutputs["fields"]["list"][number]; @@ -85,7 +85,7 @@ type Draft = { showOnTable: boolean; }; -const TYPE_HINTS: Record = { +const TYPE_HINTS = { TEXT: "Text — a short line", LONG_TEXT: "Long text — a paragraph", NUMBER: "Number", @@ -96,7 +96,7 @@ const TYPE_HINTS: Record = { EMAIL: "Email", PHONE: "Phone", USER: "User — someone in the workspace", -}; +} satisfies Record<(typeof FIELD_TYPES)[number], string>; function optionId(option: { id?: string }, index: number): string { return option.id ?? `draft-${index}`; diff --git a/apps/app/components/crm/fields/fields-copy.ts b/apps/app/components/crm/fields/fields-copy.ts index 4c809845e..9c2648f62 100644 --- a/apps/app/components/crm/fields/fields-copy.ts +++ b/apps/app/components/crm/fields/fields-copy.ts @@ -3,11 +3,11 @@ import type { FieldEntity } from "./fields-entity"; export const SHEET_TITLE = "Fields"; -const SUBTITLE: Record = { +const SUBTITLE = { company: "This shapes every company in your CRM.", contact: "This shapes every contact in your CRM.", deal: "This shapes every deal in your CRM.", -}; +} satisfies Record; export function subtitleFor(kind: RecordKind): string { return SUBTITLE[kind]; @@ -57,17 +57,17 @@ export const SAVE = "Save changes"; export const ARCHIVE = "Archive"; export const FILL_REST = "Fill the rest"; -const SHEET_PLACEMENT: Record = { +const SHEET_PLACEMENT = { COMPANY: "Show on the company sheet", CONTACT: "Show on the contact sheet", DEAL: "Show on the deal sheet", -}; +} satisfies Record; -const TABLE_PLACEMENT: Record = { +const TABLE_PLACEMENT = { COMPANY: "Offer as a column on the Companies table", CONTACT: "Offer as a column on the Contacts table", DEAL: "Offer as a column on the Deals table", -}; +} satisfies Record; export function sheetPlacement(entity: FieldEntity): string { return SHEET_PLACEMENT[entity]; diff --git a/apps/app/components/crm/fields/fields-entity.ts b/apps/app/components/crm/fields/fields-entity.ts index a7e1c88ad..290de09f2 100644 --- a/apps/app/components/crm/fields/fields-entity.ts +++ b/apps/app/components/crm/fields/fields-entity.ts @@ -2,17 +2,17 @@ import type { RecordKind } from "@/components/crm/record-sheet/record-stack"; export type FieldEntity = "COMPANY" | "CONTACT" | "DEAL"; -const TO_ENTITY: Record = { +const TO_ENTITY = { company: "COMPANY", contact: "CONTACT", deal: "DEAL", -}; +} satisfies Record; -const TO_KIND: Record = { +const TO_KIND = { COMPANY: "company", CONTACT: "contact", DEAL: "deal", -}; +} satisfies Record; export function entityOf(kind: RecordKind): FieldEntity { return TO_ENTITY[kind]; diff --git a/apps/app/components/crm/fields/standard-fields.ts b/apps/app/components/crm/fields/standard-fields.ts index 55774f5c0..5e7cc071e 100644 --- a/apps/app/components/crm/fields/standard-fields.ts +++ b/apps/app/components/crm/fields/standard-fields.ts @@ -1,6 +1,6 @@ import type { FieldEntity } from "./fields-entity"; -export const STANDARD_FIELDS: Record = { +export const STANDARD_FIELDS = { COMPANY: [ "Name", "Domain", @@ -31,4 +31,4 @@ export const STANDARD_FIELDS: Record = { "Owner", "Stage", ], -}; +} satisfies Record; diff --git a/apps/app/components/crm/record-sheet/record-actions.tsx b/apps/app/components/crm/record-sheet/record-actions.tsx index 1e13c6301..d826c36f2 100644 --- a/apps/app/components/crm/record-sheet/record-actions.tsx +++ b/apps/app/components/crm/record-sheet/record-actions.tsx @@ -31,11 +31,11 @@ import { useRecordStack, } from "./record-stack"; -const NOUN: Record = { +const NOUN = { company: "company", contact: "contact", deal: "deal", -}; +} satisfies Record; function useDeleteRecord(record: RecordRef) { const trpc = useTRPC(); diff --git a/apps/app/components/crm/record-sheet/record-stack.ts b/apps/app/components/crm/record-sheet/record-stack.ts index e4d8bec23..31dcaff3d 100644 --- a/apps/app/components/crm/record-sheet/record-stack.ts +++ b/apps/app/components/crm/record-sheet/record-stack.ts @@ -22,10 +22,10 @@ const RECORD_FORMS = ["contact", "deal"] as const; export type RecordForm = (typeof RECORD_FORMS)[number]; -const FORM_TAB: Record = { +const FORM_TAB = { contact: "contacts", deal: "deals", -}; +} satisfies Record; const params = { record: parseAsArrayOf(parseAsString, ",").withDefault([]), diff --git a/apps/app/components/crm/timeline/activity-composer.tsx b/apps/app/components/crm/timeline/activity-composer.tsx index 6ef2d612b..6cebb9918 100644 --- a/apps/app/components/crm/timeline/activity-composer.tsx +++ b/apps/app/components/crm/timeline/activity-composer.tsx @@ -34,13 +34,13 @@ const dueFormat = new Intl.DateTimeFormat("en-US", { day: "numeric", }); -const PLACEHOLDER: Record = { +const PLACEHOLDER = { NOTE: "Log a note, call, email, meeting or task…", CALL: "What came out of the call?", EMAIL: "What was said?", MEETING: "What came out of the meeting?", TASK: "What needs doing?", -}; +} satisfies Record; export function ActivityComposer({ anchor }: { anchor: TimelineAnchor }) { const trpc = useTRPC(); diff --git a/apps/app/components/crm/timeline/timeline-entry.tsx b/apps/app/components/crm/timeline/timeline-entry.tsx index 8a86f98a2..0a9d730a7 100644 --- a/apps/app/components/crm/timeline/timeline-entry.tsx +++ b/apps/app/components/crm/timeline/timeline-entry.tsx @@ -1,10 +1,12 @@ "use client"; +import { DealStage } from "@crm/db/enums"; import { Checkbox } from "@crm/ui/components/checkbox"; import { StatusIndicator } from "@crm/ui/components/status-indicator"; import { cn } from "@crm/ui/lib/utils"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; +import { z } from "zod"; import { RecordLink } from "@/components/crm/record-sheet/record-link"; import { LocalDateTime, LocalRelativeTime } from "@/components/local-date-time"; import { activityLabel } from "@/lib/activity-presentation"; @@ -25,11 +27,10 @@ const TIME_OPTIONS: Intl.DateTimeFormatOptions = { minute: "2-digit", }; -function stageChange(meta: Record | null) { - const from = typeof meta?.from === "string" ? meta.from : null; - const to = typeof meta?.to === "string" ? meta.to : null; - return from && to ? { from, to } : null; -} +const stageChange = z + .object({ from: z.enum(DealStage), to: z.enum(DealStage) }) + .nullable() + .catch(null); function anchorId(anchor: TimelineAnchor): string { if ("companyId" in anchor) return anchor.companyId; @@ -62,7 +63,8 @@ export function TimelineEntry({ entry.dueAt !== null && new Date(entry.dueAt) < new Date(); - const change = entry.type === "STAGE_CHANGE" ? stageChange(entry.meta) : null; + const change = + entry.type === "STAGE_CHANGE" ? stageChange.parse(entry.meta) : null; const when = entry.occurredAt ?? entry.createdAt; const synced = entry.meta?.synced === true; @@ -73,7 +75,7 @@ export function TimelineEntry({ : entry.createdBy.name; const headline = change - ? `${dealStageLabel(change.from as never)} → ${dealStageLabel(change.to as never)}` + ? `${dealStageLabel(change.from)} → ${dealStageLabel(change.to)}` : entry.subject; const here = anchorId(anchor); diff --git a/apps/app/components/crm/timeline/timeline.tsx b/apps/app/components/crm/timeline/timeline.tsx index d2fde05f8..e84d12c81 100644 --- a/apps/app/components/crm/timeline/timeline.tsx +++ b/apps/app/components/crm/timeline/timeline.tsx @@ -31,19 +31,16 @@ export type TimelineAnchor = | { contactId: string } | { dealId: string }; -const TAB_LABELS: Record = { +const TAB_LABELS = { all: "All", notes: "Notes", email: "Email", meetings: "Meetings", upcoming: "Upcoming", done: "Done", -}; +} satisfies Record; -const EMPTY_STATES: Record< - TimelineTab, - { title: string; description: string } -> = { +const EMPTY_STATES = { all: { title: "Nothing has happened yet", description: @@ -73,16 +70,16 @@ const EMPTY_STATES: Record< title: "Nothing finished yet", description: "Tasks move here once you tick them off.", }, -}; +} satisfies Record; -const EMPTY_ICONS: Record = { +const EMPTY_ICONS = { all: Time, notes: Chat, email: Email, meetings: Events, upcoming: Task, done: Checkmark, -}; +} satisfies Record; const dayFormat = new Intl.DateTimeFormat("en-US", { weekday: "short", diff --git a/apps/app/components/inline-script.tsx b/apps/app/components/inline-script.tsx index ea27ff048..3884979b2 100644 --- a/apps/app/components/inline-script.tsx +++ b/apps/app/components/inline-script.tsx @@ -1,7 +1,7 @@ export function InlineScript({ html }: { html: string }) { return (