Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline 8fac3f7ce7 fix(core): preserve concise error messages 2026-06-23 18:48:22 -05:00
Aiden Cline bf24cf423c fix: tighten tagged error linting 2026-06-23 18:47:14 -05:00
Aiden Cline b30440ec26 feat: enforce tagged error messages 2026-06-23 18:47:14 -05:00
31 changed files with 556 additions and 48 deletions
+23
View File
@@ -0,0 +1,23 @@
name: lint
on:
push:
branches: [dev]
pull_request:
workflow_dispatch:
jobs:
lint:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Test lint rules
run: bun lint:test
- name: Run lint
run: bun lint --quiet
+3 -1
View File
@@ -1,5 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"jsPlugins": ["./script/lint/opencode.mjs"],
"options": {
"typeAware": true
},
@@ -7,6 +8,7 @@
"suspicious": "warn"
},
"rules": {
"opencode/tagged-error-message": "error",
"typescript/no-base-to-string": "warn",
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off",
@@ -47,5 +49,5 @@
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/.lint-tmp-*", "**/*.d.ts", "**/sdk.gen.ts"]
}
+1
View File
@@ -13,6 +13,7 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"lint:test": "bun script/lint/test.ts",
"typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
+5 -1
View File
@@ -123,7 +123,11 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
providerID: ProviderV2.ID,
cause: Schema.Defect(),
}) {}
}) {
override get message() {
return `Failed to initialize AI SDK provider: ${this.providerID}`
}
}
function initError(providerID: ProviderV2.ID) {
return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) })))
@@ -30,7 +30,11 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<Des
expected: ProjectV2.ID,
actual: ProjectV2.ID,
},
) {}
) {
override get message() {
return `Destination project ${this.actual} does not match session project ${this.expected}`
}
}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
+10 -2
View File
@@ -30,11 +30,19 @@ export interface RemoveInput {
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
path: Schema.String,
}) {}
}) {
override get message() {
return `File changed since it was read: ${this.path}`
}
}
export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
path: Schema.String,
}) {}
}) {
override get message() {
return `File already exists: ${this.path}`
}
}
export interface WriteResult {
readonly operation: "write"
+5 -1
View File
@@ -7,7 +7,11 @@ import { FileSystem } from "./filesystem"
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
"Image.ResizerUnavailableError",
{},
) {}
) {
override get message() {
return "Image resizer is unavailable"
}
}
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("Image.DecodeError", {
resource: Schema.String,
+10 -2
View File
@@ -169,11 +169,19 @@ export type AttemptStatus = typeof AttemptStatus.Type
export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {
attemptID: AttemptID,
}) {}
}) {
override get message() {
return `Authorization code required for OAuth attempt ${this.attemptID}`
}
}
export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("Integration.Authorization", {
cause: Schema.Defect(),
}) {}
}) {
override get message() {
return "Integration authorization failed"
}
}
export type Error = CodeRequiredError | AuthorizationError
+7 -1
View File
@@ -23,7 +23,13 @@ export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literals(["relative_escape", "location_escape", "non_directory_ancestor"]),
}) {}
}) {
override get message() {
if (this.reason === "relative_escape") return `Relative path escapes the location: ${this.path}`
if (this.reason === "location_escape") return `Path resolves outside the location: ${this.path}`
return `Path has a non-directory ancestor: ${this.path}`
}
}
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
+5 -1
View File
@@ -16,7 +16,11 @@ export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedErr
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
}) {
override get message() {
return `Failed to install dependencies in ${this.dir}`
}
}
export interface EntryPoint {
readonly directory: string
+21 -4
View File
@@ -83,19 +83,36 @@ export const Event = {
}),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {
override get message() {
return "The user rejected this permission request"
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
}) {}
}) {
override get message() {
return `The user rejected this permission request with feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
rules: PermissionSchema.Ruleset,
}) {}
}) {
override get message() {
if (this.rules.length === 0) return "Permission denied by configured rules"
return `Permission denied by configured rules: ${this.rules.map((rule) => `${rule.action} ${rule.resource}`).join(", ")}`
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
requestID: ID,
}) {}
}) {
override get message() {
return `Permission request not found: ${this.requestID}`
}
}
export type Error = DeniedError | RejectedError | CorrectedError
+30 -6
View File
@@ -58,32 +58,56 @@ export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"ProjectCopy.SourceDirectoryNotFoundError",
{ directory: AbsolutePath },
) {}
) {
override get message() {
return `Project copy source directory not found: ${this.directory}`
}
}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"ProjectCopy.DestinationExistsError",
{ directory: AbsolutePath },
) {}
) {
override get message() {
return `Project copy destination already exists: ${this.directory}`
}
}
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"ProjectCopy.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
) {
override get message() {
return `Project copy directory is unavailable: ${this.directory}`
}
}
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"ProjectCopy.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
) {
override get message() {
return `Invalid project copy directory: ${this.directory}`
}
}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"ProjectCopy.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
) {
override get message() {
return `Project copy strategy is unavailable: ${this.strategy}`
}
}
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
"ProjectCopy.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
) {
override get message() {
return `Project copy strategy is already registered: ${this.strategy}`
}
}
export type Error =
| SourceDirectoryNotFoundError
+10 -2
View File
@@ -94,11 +94,19 @@ export type Attachment = {
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
}) {}
}) {
override get message() {
return `PTY session not found: ${this.ptyID}`
}
}
export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
ptyID: PtyID,
}) {}
}) {
override get message() {
return `PTY session has exited: ${this.ptyID}`
}
}
export const Event = {
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
+5 -1
View File
@@ -87,7 +87,11 @@ export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Que
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("QuestionV2.NotFoundError", {
requestID: ID,
}) {}
}) {
override get message() {
return `Question request not found: ${this.requestID}`
}
}
export interface AskInput {
readonly sessionID: SessionSchema.ID
+15 -3
View File
@@ -83,21 +83,33 @@ type CompactInput = {
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: SessionSchema.ID,
}) {}
}) {
override get message() {
return `Session not found: ${this.sessionID}`
}
}
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"Session.OperationUnavailableError",
{
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
},
) {}
) {
override get message() {
return `Session ${this.operation} is not available yet`
}
}
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
}) {
override get message() {
return `Prompt message ${this.messageID} conflicts with an existing durable record in session ${this.sessionID}`
}
}
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
+1
View File
@@ -46,6 +46,7 @@ export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseServic
return row === undefined ? undefined : fromRow(row)
})
// oxlint-disable-next-line opencode/tagged-error-message -- internal defect sentinel for inconsistent projections
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
+11 -2
View File
@@ -16,11 +16,19 @@ export namespace EffectFlock {
export class LockTimeoutError extends Schema.TaggedErrorClass<LockTimeoutError>()("LockTimeoutError", {
key: Schema.String,
}) {}
}) {
override get message() {
return `Timed out acquiring lock: ${this.key}`
}
}
export class LockCompromisedError extends Schema.TaggedErrorClass<LockCompromisedError>()("LockCompromisedError", {
detail: Schema.String,
}) {}
}) {
override get message() {
return `Lock was compromised: ${this.detail}`
}
}
class ReleaseError extends Schema.TaggedErrorClass<ReleaseError>()("ReleaseError", {
detail: Schema.String,
@@ -32,6 +40,7 @@ export namespace EffectFlock {
}
/** Internal: signals "lock is held, retry later". Never leaks to callers. */
// oxlint-disable-next-line opencode/tagged-error-message -- internal retry sentinel for lock contention
class NotAcquired extends Schema.TaggedErrorClass<NotAcquired>()("NotAcquired", {}) {}
export type LockError = LockTimeoutError | LockCompromisedError
+5 -1
View File
@@ -91,6 +91,10 @@ export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("Permiss
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: ID,
}) {}
}) {
override get message() {
return `Permission request not found: ${this.requestID}`
}
}
export type Error = DeniedError | RejectedError | CorrectedError
+45 -9
View File
@@ -3,50 +3,86 @@ import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
}) {}
}) {
override get message() {
return `session not found: ${this.sessionId}`
}
}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{
configId: Schema.String,
},
) {}
) {
override get message() {
return `unknown config option: ${this.configId}`
}
}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
}) {
override get message() {
return `model not found: ${this.modelId}`
}
}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
effort: Schema.String,
}) {}
}) {
override get message() {
return `effort not found: ${this.effort}`
}
}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String,
}) {}
}) {
override get message() {
return `mode not found: ${this.mode}`
}
}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {
providerId: Schema.optional(Schema.String),
}) {}
}) {
override get message() {
return "provider authentication required"
}
}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPUnknownAuthMethodError",
{
methodId: Schema.String,
},
) {}
) {
override get message() {
return `unknown auth method: ${this.methodId}`
}
}
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
"ACPUnsupportedOperationError",
{
method: Schema.String,
},
) {}
) {
override get message() {
return `method not found: ${this.method}`
}
}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
}) {}
}) {
override get message() {
return this.safeMessage
}
}
export type Error =
| SessionNotFoundError
+1
View File
@@ -9,6 +9,7 @@ const wordmark = [
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
// oxlint-disable-next-line opencode/tagged-error-message -- CLI cancellation intentionally renders without an error message.
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("UICancelledError", {}) {}
export const Style = {
+2
View File
@@ -8,7 +8,9 @@ export interface Runner<A, E = never> {
readonly cancel: Effect.Effect<void>
}
// oxlint-disable-next-line opencode/tagged-error-message -- Internal coordinator signal, not a user-facing error.
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
// oxlint-disable-next-line opencode/tagged-error-message -- Internal coordinator signal, not a user-facing error.
export class Busy extends Schema.TaggedErrorClass<Busy>()("RunnerBusy", {}) {}
interface RunHandle<A, E> {
+5 -1
View File
@@ -29,7 +29,11 @@ export type Diagnostic = VSCodeDiagnostic
export class InitializeError extends Schema.TaggedErrorClass<InitializeError>()("LSPInitializeError", {
serverID: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
}) {
override get message() {
return `Failed to initialize LSP server: ${this.serverID}`
}
}
type DocumentDiagnosticReport = {
items?: Diagnostic[]
+5 -1
View File
@@ -80,7 +80,11 @@ export const Failed = NamedError.create("MCPFailed", {
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
name: Schema.String,
}) {}
}) {
override get message() {
return `MCP server not found: ${this.name}`
}
}
type MCPClient = Client
+5 -1
View File
@@ -102,7 +102,11 @@ export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePa
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
projectID: ProjectV2.ID,
}) {}
}) {
override get message() {
return `Project not found: ${this.projectID}`
}
}
// ---------------------------------------------------------------------------
// Effect service
+15 -3
View File
@@ -67,16 +67,28 @@ export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
export class OauthMissing extends Schema.TaggedErrorClass<OauthMissing>()("ProviderAuthOauthMissing", {
providerID: ProviderV2.ID,
}) {}
}) {
override get message() {
return `No pending OAuth authorization for provider: ${this.providerID}`
}
}
export class OauthCodeMissing extends Schema.TaggedErrorClass<OauthCodeMissing>()("ProviderAuthOauthCodeMissing", {
providerID: ProviderV2.ID,
}) {}
}) {
override get message() {
return `OAuth authorization code is required for provider: ${this.providerID}`
}
}
export class OauthCallbackFailed extends Schema.TaggedErrorClass<OauthCallbackFailed>()(
"ProviderAuthOauthCallbackFailed",
{},
) {}
) {
override get message() {
return "OAuth callback failed"
}
}
export class ValidationFailed extends Schema.TaggedErrorClass<ValidationFailed>()("ProviderAuthValidationFailed", {
field: Schema.String,
+5 -1
View File
@@ -98,7 +98,11 @@ export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Que
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Question.NotFoundError", {
requestID: QuestionID,
}) {}
}) {
override get message() {
return `Question request not found: ${this.requestID}`
}
}
interface PendingEntry {
info: Request
+5 -1
View File
@@ -454,7 +454,11 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("SessionBusyError", {
sessionID: SessionID,
}) {}
}) {
override get message() {
return `Session is busy: ${this.sessionID}`
}
}
export type NotFound = NotFoundError
+6 -2
View File
@@ -60,7 +60,7 @@ function isSkillFrontmatter(data: unknown): data is { name: string; description?
export class InvalidError extends Schema.TaggedErrorClass<InvalidError>()("SkillInvalidError", {
path: Schema.String,
message: Schema.optional(Schema.String),
message: Schema.String,
issues: Schema.optional(Schema.Array(Issue)),
}) {}
@@ -68,7 +68,11 @@ export class NameMismatchError extends Schema.TaggedErrorClass<NameMismatchError
path: Schema.String,
expected: Schema.String,
actual: Schema.String,
}) {}
}) {
override get message() {
return `Skill name mismatch at ${this.path}: expected "${this.expected}", got "${this.actual}"`
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Skill.NotFoundError", {
name: Schema.String,
+1
View File
@@ -18,6 +18,7 @@ const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const SAMPLE_BYTES = 4096
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
// oxlint-disable-next-line opencode/tagged-error-message -- Internal sentinel used only to terminate the read stream.
class ReadStop extends Schema.TaggedErrorClass<ReadStop>()("ReadStop", {}) {}
// `offset` and `limit` were originally `z.coerce.number()` — the runtime
+113
View File
@@ -0,0 +1,113 @@
const taggedErrorMessage = {
meta: {
type: "problem",
docs: {
description: "require Effect tagged errors to expose a message",
},
messages: {
missing: "Schema.TaggedErrorClass must define a message schema field or instance message implementation.",
},
schema: [],
},
create(context) {
function visitClass(node) {
const fields = taggedErrorFields(context, node.superClass)
if (!fields) return
if (fields.properties.some(hasRequiredMessage)) return
if (node.body.body.some(hasInstanceMessage)) return
context.report({ node, messageId: "missing" })
}
return {
ClassDeclaration: visitClass,
ClassExpression: visitClass,
}
},
}
function taggedErrorFields(context, superClass) {
if (superClass?.type !== "CallExpression" || superClass.arguments.length < 2) return null
if (superClass.arguments[1].type !== "ObjectExpression") return null
const factory = superClass.callee
if (factory.type !== "CallExpression" || factory.arguments.length !== 0) return null
const taggedError = factory.callee
if (taggedError.type !== "MemberExpression" || taggedError.computed) return null
if (taggedError.object.type !== "Identifier" || !isEffectSchema(context, taggedError.object)) return null
if (taggedError.property.type !== "Identifier" || taggedError.property.name !== "TaggedErrorClass") return null
return superClass.arguments[1]
}
function isEffectSchema(context, identifier) {
const variable = findVariable(context.sourceCode.getScope(identifier), identifier.name)
if (!variable || variable.defs.length !== 1 || variable.defs[0].type !== "ImportBinding") return false
const definition = variable.defs[0]
if (definition.parent.source.value === "effect")
return definition.node.type === "ImportSpecifier" && definition.node.imported.name === "Schema"
return definition.parent.source.value === "effect/Schema" && definition.node.type === "ImportNamespaceSpecifier"
}
function findVariable(scope, name) {
return scope.set.get(name) ?? (scope.upper ? findVariable(scope.upper, name) : null)
}
function hasRequiredMessage(property) {
if (propertyName(property) !== "message" || property.type !== "Property") return false
return isStringSchema(property.value) && !isOptional(property.value)
}
function isStringSchema(node) {
if (node.type === "MemberExpression")
return (
node.object.type === "Identifier" &&
node.object.name === "Schema" &&
node.property.type === "Identifier" &&
node.property.name === "String"
)
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return false
return isStringSchema(node.callee.object)
}
function isOptional(node) {
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return false
if (node.callee.object.type === "Identifier" && node.callee.object.name === "Schema")
return node.callee.property.type === "Identifier" && node.callee.property.name === "optional"
if (node.callee.property.type !== "Identifier" || node.callee.property.name !== "pipe") return false
return node.arguments.some(
(argument) =>
argument.type === "MemberExpression" &&
argument.object.type === "Identifier" &&
argument.object.name === "Schema" &&
argument.property.type === "Identifier" &&
argument.property.name === "optional",
)
}
function propertyName(property) {
if (property.type !== "Property" && property.type !== "PropertyDefinition" && property.type !== "MethodDefinition")
return null
if (property.computed) return null
if (property.key.type === "Identifier" || property.key.type === "Literal") return property.key.name ?? property.key.value
return null
}
function hasInstanceMessage(member) {
if (member.static || propertyName(member) !== "message") return false
if (member.type === "PropertyDefinition") return member.value !== null && !isEmptyMessage(member.value)
if (member.type !== "MethodDefinition" || member.kind !== "get") return false
if (member.value.body.body.length === 0) return false
const direct = member.value.body.body.length === 1 ? member.value.body.body[0] : null
return direct?.type !== "ReturnStatement" || (direct.argument !== null && !isEmptyMessage(direct.argument))
}
function isEmptyMessage(node) {
return (node.type === "Identifier" && node.name === "undefined") || (node.type === "Literal" && !node.value)
}
export default {
meta: {
name: "opencode",
},
rules: {
"tagged-error-message": taggedErrorMessage,
},
}
+176
View File
@@ -0,0 +1,176 @@
import path from "path"
import { mkdtemp, rm } from "fs/promises"
const cases = [
{
name: "schema field",
errors: 0,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", { message: Schema.String }) {}`,
},
{
name: "getter",
errors: 0,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", {}) {
override get message() { return "Example failed" }
}`,
},
{
name: "initialized property",
errors: 0,
source: `import { Schema } from "effect"
const Example = class extends Schema.TaggedErrorClass<Example>()("Example", {}) {
override message = "Example failed"
}`,
},
{
name: "namespace import",
errors: 0,
source: `import * as Schema from "effect/Schema"
export namespace Example { export class Error extends Schema.TaggedErrorClass<Error>()("Example", { message: Schema.String }) {} }`,
},
{
name: "spread fields",
errors: 1,
source: `import { Schema } from "effect"
const fields = { message: Schema.String }
class Example extends Schema.TaggedErrorClass<Example>()("Example", { ...fields }) {}`,
},
{
name: "computed fields",
errors: 1,
source: `import { Schema } from "effect"
const key = "message"
class Example extends Schema.TaggedErrorClass<Example>()("Example", { [key]: Schema.String }) {}`,
},
{
name: "optional message",
errors: 1,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", { message: Schema.optional(Schema.String) }) {}`,
},
{
name: "piped optional message",
errors: 1,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", { message: Schema.String.pipe(Schema.optional) }) {}`,
},
{
name: "unrelated Schema binding",
errors: 0,
source: `const Schema = getSchema()
class Example extends Schema.TaggedErrorClass()("Example", {}) {}`,
},
{
name: "shadowed Schema binding",
errors: 0,
source: `import { Schema } from "effect"
function example() {
const Schema = getSchema()
return class extends Schema.TaggedErrorClass()("Example", {}) {}
}`,
},
{
name: "missing message",
errors: 1,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", { cause: Schema.Defect }) {}`,
},
{
name: "static message",
errors: 1,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", {}) { static message = "Example failed" }`,
},
{
name: "uninitialized property",
errors: 1,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", {}) { declare message: string }`,
},
{
name: "undefined property",
errors: 1,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", {}) { override message = undefined }`,
},
{
name: "empty getter",
errors: 1,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", {}) { override get message() { return "" } }`,
},
{
name: "non-string message schema",
errors: 1,
source: `import { Schema } from "effect"
class Example extends Schema.TaggedErrorClass<Example>()("Example", { message: Schema.Unknown }) {}`,
},
{
name: "documented disable",
errors: 0,
source: `import { Schema } from "effect"
// oxlint-disable-next-line opencode/tagged-error-message -- internal control-flow sentinel
class Example extends Schema.TaggedErrorClass<Example>()("Example", {}) {}`,
},
]
const directory = await mkdtemp(path.join(import.meta.dir, "../../.lint-tmp-"))
const config = path.join(directory, ".oxlintrc.json")
try {
await Bun.write(
config,
JSON.stringify({
jsPlugins: [path.join(import.meta.dir, "opencode.mjs")],
rules: { "opencode/tagged-error-message": "error" },
}),
)
await Promise.all(
cases.map(async (fixture, index) => {
const file = path.join(directory, `${index}.ts`)
await Bun.write(file, fixture.source)
const result = Bun.spawnSync([
path.join(import.meta.dir, "../../node_modules/.bin/oxlint"),
"--config",
config,
"--format",
"json",
file,
])
if (!result.stdout.length) throw new Error(`${fixture.name}: ${result.stderr.toString()}`)
const diagnostics: unknown = JSON.parse(result.stdout.toString())
if (!hasDiagnostics(diagnostics)) throw new Error(`${fixture.name}: invalid Oxlint output`)
const errors = diagnostics.diagnostics.filter(isErrorDiagnostic)
if (errors.length !== fixture.errors || !errors.every(isRuleDiagnostic))
throw new Error(
`${fixture.name}: expected ${fixture.errors} errors, received ${errors.length}\n${result.stdout.toString()}`,
)
const exitCode = fixture.errors === 0 ? 0 : 1
if (result.exitCode !== exitCode || result.signalCode)
throw new Error(`${fixture.name}: expected exit ${exitCode}, received ${result.exitCode}`)
}),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
console.log(`Validated ${cases.length} tagged error lint fixtures`)
function hasDiagnostics(value: unknown): value is { diagnostics: unknown[] } {
return typeof value === "object" && value !== null && "diagnostics" in value && Array.isArray(value.diagnostics)
}
function isRuleDiagnostic(value: unknown) {
return (
typeof value === "object" &&
value !== null &&
"code" in value &&
value.code === "opencode(tagged-error-message)"
)
}
function isErrorDiagnostic(value: unknown) {
return typeof value === "object" && value !== null && "severity" in value && value.severity === "error"
}