feat: enforce tagged error messages
This commit is contained in:
parent
cf80b5c470
commit
b30440ec26
31 changed files with 466 additions and 47 deletions
75
script/lint/opencode.mjs
Normal file
75
script/lint/opencode.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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) {
|
||||
const schemas = new Set()
|
||||
|
||||
function visitClass(node) {
|
||||
const fields = taggedErrorFields(node.superClass, schemas)
|
||||
if (
|
||||
!fields ||
|
||||
fields.properties.some((property) => property.type === "SpreadElement" || property.computed)
|
||||
)
|
||||
return
|
||||
if (fields.properties.some((property) => propertyName(property) === "message")) return
|
||||
if (node.body.body.some(hasInstanceMessage)) return
|
||||
context.report({ node, messageId: "missing" })
|
||||
}
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value !== "effect" && node.source.value !== "effect/Schema") return
|
||||
for (const specifier of node.specifiers) {
|
||||
if (specifier.type === "ImportSpecifier" && specifier.imported.name === "Schema") schemas.add(specifier.local.name)
|
||||
if (specifier.type === "ImportNamespaceSpecifier" && node.source.value === "effect/Schema")
|
||||
schemas.add(specifier.local.name)
|
||||
}
|
||||
},
|
||||
ClassDeclaration: visitClass,
|
||||
ClassExpression: visitClass,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function taggedErrorFields(superClass, schemas) {
|
||||
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" || !schemas.has(taggedError.object.name)) return null
|
||||
if (taggedError.property.type !== "Identifier" || taggedError.property.name !== "TaggedErrorClass") return null
|
||||
return superClass.arguments[1]
|
||||
}
|
||||
|
||||
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 === "MethodDefinition") return member.kind === "get"
|
||||
return member.type === "PropertyDefinition" && member.value !== null
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
name: "opencode",
|
||||
},
|
||||
rules: {
|
||||
"tagged-error-message": taggedErrorMessage,
|
||||
},
|
||||
}
|
||||
121
script/lint/test.ts
Normal file
121
script/lint/test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
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: 0,
|
||||
source: `import { Schema } from "effect"
|
||||
const fields = { message: Schema.String }
|
||||
class Example extends Schema.TaggedErrorClass<Example>()("Example", { ...fields }) {}`,
|
||||
},
|
||||
{
|
||||
name: "computed fields",
|
||||
errors: 0,
|
||||
source: `import { Schema } from "effect"
|
||||
const key = "message"
|
||||
class Example extends Schema.TaggedErrorClass<Example>()("Example", { [key]: Schema.String }) {}`,
|
||||
},
|
||||
{
|
||||
name: "unrelated Schema binding",
|
||||
errors: 0,
|
||||
source: `const Schema = getSchema()
|
||||
class Example 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: "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" },
|
||||
}),
|
||||
)
|
||||
for (const [index, fixture] of cases.entries()) {
|
||||
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(
|
||||
(item) => typeof item === "object" && item !== null && "code" in item && item.code === "opencode(tagged-error-message)",
|
||||
).length
|
||||
if (errors !== fixture.errors)
|
||||
throw new Error(
|
||||
`${fixture.name}: expected ${fixture.errors} errors, received ${errors}\n${result.stdout.toString()}`,
|
||||
)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue