Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'

This commit is contained in:
-Puter
2026-07-19 03:25:10 +05:30
parent dd1071cc10
commit 2daf979036
2214 changed files with 673090 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
{
"$schema": "../../../node_modules/@effect/docgen/schema.json",
"exclude": ["**/*.ts"]
}

View File

@@ -0,0 +1,84 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "oxc", "eslint", "unicorn", "node"],
"categories": {
"correctness": "error",
"suspicious": "error",
"perf": "error"
},
"rules": {
// Effect custom rules
"effect/no-bigint-literals": "error",
"effect/no-import-from-barrel-package": ["error", {
"checkPatterns": [
"^effect$",
"^effect/(.+/)?[a-z][a-z0-9]*$",
"^@effect/[^/]+$",
"^@effect/[^/]+/(.+/)?[a-z][a-z0-9]*$"
],
"checkRelativeIndexImports": true
}],
"effect/no-js-extension-imports": "error",
"effect/no-opaque-instance-fields": "error",
"effect/no-unused-internal": "error",
"effect/jsdocs": "error",
// Tune native rules
// Import rules
"typescript/consistent-type-imports": ["error", {
"fixStyle": "inline-type-imports"
}],
"typescript/no-import-type-side-effects": "error",
"import/no-duplicates": "error",
"import/no-self-import": "error",
"import/no-empty-named-blocks": "error",
// TypeScript cleanup
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-useless-empty-export": "error",
// Code quality
"eslint/no-console": "error",
"eslint/no-var": "error",
"eslint/no-useless-constructor": "error",
"unicorn/no-abusive-eslint-disable": "error",
"eslint/no-unneeded-ternary": "error",
"eslint/no-useless-concat": "error",
"oxc/misrefactored-assign-op": "error",
// Unicorn
"unicorn/prefer-array-flat-map": "error",
"unicorn/no-accessor-recursion": "error",
"oxc/no-map-spread": "off",
"eslint/object-shorthand": "off",
"eslint/no-shadow": "off",
"eslint/no-unused-vars": "off",
"eslint/require-yield": "off",
"eslint/no-fallthrough": "off",
"eslint/no-await-in-loop": "off",
"unicorn/no-new-array": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-array-sort": "off",
"unicorn/no-array-reverse": "off",
"unicorn/require-post-message-target-origin": "off",
"unicorn/prefer-add-event-listener": "off",
"unicorn/prefer-set-has": "off",
"no-dangling-underscore": "off",
"typescript/no-explicit-any": "off",
"typescript/no-empty-interface": "off",
"typescript/ban-ts-comment": "off",
"typescript/no-namespace": "off",
"typescript/no-non-null-assertion": "off",
"typescript/no-dynamic-delete": "off",
"typescript/no-invalid-void-type": "off",
"typescript/no-unsafe-function-type": "off",
"typescript/unified-signatures": "off",
"typescript/no-empty-object-type": "off",
"typescript/no-confusing-non-null-assertion": "off",
"typescript/array-type": ["error", {
"default": "generic",
"readonly": "generic"
}],
"typescript/no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}]
}
}

View File

@@ -0,0 +1,71 @@
{
"name": "@effect/oxc",
"version": "0.0.0",
"type": "module",
"private": true,
"license": "MIT",
"description": "Opinionated linting and formatting configuration for Effect",
"homepage": "https://effect.website",
"repository": {
"type": "git",
"url": "https://github.com/Effect-TS/effect.git",
"directory": "packages/tools/oxc"
},
"sideEffects": [],
"bugs": {
"url": "https://github.com/Effect-TS/effect/issues"
},
"tags": [
"typescript",
"formatting",
"linting",
"oxc"
],
"keywords": [
"typescript",
"formatting",
"linting",
"oxc"
],
"exports": {
"./package.json": "./package.json",
"./oxlint": "./src/oxlint/index.ts",
"./oxlint/rules/*": "./src/oxlint/rules/*.ts",
"./oxlintrc.json": "./oxlintrc.json"
},
"files": [
"oxlintrc.json",
"src/**/*.ts",
"dist/**/*.js",
"dist/**/*.js.map",
"dist/**/*.d.ts",
"dist/**/*.d.ts.map"
],
"publishConfig": {
"provenance": true,
"exports": {
"./package.json": "./package.json",
"./oxlint": "./dist/oxlint/index.js",
"./oxlint/rules/*": "./dist/oxlint/rules/*.js",
"./oxlintrc.json": "./oxlintrc.json"
}
},
"scripts": {
"build": "tsc -b tsconfig.json && pnpm babel",
"babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps",
"check": "tsc -b tsconfig.json",
"test": "vitest",
"coverage": "vitest --coverage"
},
"dependencies": {
"@effect/jsdocs": "workspace:^"
},
"peerDependencies": {
"typescript": ">=5.0.0 <7.0.0"
},
"devDependencies": {
"@types/node": "^26.1.1",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
}
}

View File

@@ -0,0 +1,20 @@
import jsdocs from "./rules/jsdocs.ts"
import noBigIntLiterals from "./rules/no-bigint-literals.ts"
import noImportFromBarrelPackage from "./rules/no-import-from-barrel-package.ts"
import noJsExtensionImports from "./rules/no-js-extension-imports.ts"
import noOpaqueInstanceFields from "./rules/no-opaque-instance-fields.ts"
import noUnusedInternal from "./rules/no-unused-internal.ts"
export default {
meta: {
name: "effect"
},
rules: {
"no-bigint-literals": noBigIntLiterals,
"no-import-from-barrel-package": noImportFromBarrelPackage,
"no-js-extension-imports": noJsExtensionImports,
"no-opaque-instance-fields": noOpaqueInstanceFields,
"no-unused-internal": noUnusedInternal,
"jsdocs": jsdocs
}
}

View File

@@ -0,0 +1,108 @@
import * as Jsdocs from "@effect/jsdocs/Jsdocs"
import * as fs from "node:fs"
import * as path from "node:path"
import type { CreateRule, ESTree, Visitor } from "oxlint"
interface RuleOptions {
readonly model?: string
}
interface CachedModel {
readonly result: ReturnType<typeof Jsdocs.readJSDocModel>
readonly filesByPath: ReadonlyMap<string, ReadonlyArray<unknown>[number]>
readonly mtimeMs: number
readonly size: number
}
const modelCache = new Map<string, CachedModel>()
function getSourceText(context: {
readonly sourceCode: { readonly text?: string; getText(node?: unknown): string }
}): string {
return context.sourceCode.text ?? context.sourceCode.getText()
}
function getCwd(context: { readonly cwd?: string; getCwd?: () => string }): string {
return context.cwd ?? context.getCwd?.() ?? process.cwd()
}
function normalizePathName(filename: string): string {
return filename.split(path.sep).join("/")
}
function readCachedModel(modelPath: string): CachedModel["result"] {
if (!fs.existsSync(modelPath)) return { _tag: "Failure", error: "missing" }
const stats = fs.statSync(modelPath)
const cached = modelCache.get(modelPath)
if (cached !== undefined && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached.result
const result = Jsdocs.readJSDocModel(modelPath)
modelCache.set(modelPath, {
result,
filesByPath: result._tag === "Success" ? new Map(result.value.files.map((file) => [file.file, file])) : new Map(),
mtimeMs: stats.mtimeMs,
size: stats.size
})
return result
}
function getCachedFile(
modelPath: string,
file: string
): ReturnType<typeof Jsdocs.readJSDocModel> extends infer R
? R extends { readonly _tag: "Success"; readonly value: { readonly files: ReadonlyArray<infer A> } } ? A | undefined
: never
: never
{
return modelCache.get(modelPath)?.filesByPath.get(file) as never
}
function rangeNode(range: readonly [number, number]): { readonly range: [number, number] } {
return { range: [range[0], range[1]] }
}
const rule: CreateRule = {
meta: {
type: "problem",
docs: { description: "Enforce Effect's public API JSDoc structure" },
schema: [
{
type: "object",
properties: {
model: { type: "string" }
},
additionalProperties: false
}
]
},
create(context) {
const options = (context.options[0] as RuleOptions | undefined) ?? {}
const source = getSourceText(context)
const cwd = getCwd(context)
const modelPath = path.resolve(cwd, options.model ?? ".data/jsdocs.json")
const result = readCachedModel(modelPath)
if (result._tag === "Failure") {
if (result.error === "missing") return {} as Visitor
return {
Program(node: ESTree.Node) {
context.report({ node, message: result.error })
}
} as Visitor
}
const relative = normalizePathName(path.relative(cwd, context.filename))
const file = getCachedFile(modelPath, relative)
if (file === undefined) return {} as Visitor
return {
Program(node: ESTree.Node) {
if (file.hash !== Jsdocs.sourceHash(source)) {
context.report({ node, message: "JSDoc model is stale for this file; run `pnpm jsdocs`" })
return
}
for (const diagnostic of file.diagnostics) {
context.report({ node: rangeNode(diagnostic.range), message: diagnostic.message })
}
}
} as Visitor
}
}
export default rule

View File

@@ -0,0 +1,25 @@
import type { CreateRule, Visitor } from "oxlint"
const rule: CreateRule = {
meta: {
type: "problem",
docs: { description: "Disallow bigint literals" },
fixable: "code"
},
create(context) {
return {
Literal(node) {
if (typeof node.value === "bigint") {
const fixedSource = `BigInt(${node.value})`
context.report({
node,
message: "BigInt literals are not allowed",
fix: (fixer) => fixer.replaceText(node, fixedSource)
})
}
}
} as Visitor
}
}
export default rule

View File

@@ -0,0 +1,144 @@
import * as fs from "node:fs"
import * as path from "node:path"
import type { CreateRule, ESTree, Visitor } from "oxlint"
interface RuleOptions {
checkPatterns?: Array<string>
checkRelativeIndexImports?: boolean
}
const extensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]
function getModuleName(specifier: ESTree.ImportSpecifier): string {
return specifier.imported.type === "Identifier"
? specifier.imported.name
: specifier.imported.value
}
function isRelativeImport(source: string): boolean {
return source.startsWith("./") || source.startsWith("../")
}
function hasIndexFile(dirPath: string): boolean {
for (const ext of extensions) {
if (fs.existsSync(path.join(dirPath, `index${ext}`))) {
return true
}
}
return false
}
function isIndexImport(importPath: string): boolean {
const basename = path.basename(importPath)
return basename === "index" || /^index\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/.test(basename)
}
function resolvesToBarrel(importSource: string, currentFile: string): boolean {
const dir = path.dirname(currentFile)
const resolved = path.resolve(dir, importSource)
if (isIndexImport(importSource)) {
return true
}
if (hasIndexFile(resolved)) {
return true
}
return false
}
function createBarrelMatcher(options: RuleOptions): (source: string, currentFile: string) => boolean {
const patterns = (options.checkPatterns ?? []).map((p) => new RegExp(p))
const checkRelative = options.checkRelativeIndexImports !== false
return (source: string, currentFile: string): boolean => {
if (isRelativeImport(source)) {
return checkRelative && resolvesToBarrel(source, currentFile)
}
for (const pattern of patterns) {
if (pattern.test(source)) {
return true
}
}
return false
}
}
const rule: CreateRule = {
meta: {
type: "suggestion",
docs: {
description: "Disallow importing from barrel files (index.ts), encourage importing specific modules instead"
},
schema: [
{
type: "object",
properties: {
checkPatterns: {
type: "array",
items: { type: "string" },
description: "Regex patterns to match barrel imports"
},
checkRelativeIndexImports: {
type: "boolean",
description: "Whether to check relative imports that resolve to index files"
}
},
additionalProperties: false
}
]
},
create(context) {
const currentFile = context.filename
const options: RuleOptions = (context.options[0] as RuleOptions) ?? {}
const isBarrelImport = createBarrelMatcher(options)
return {
ImportDeclaration(node: ESTree.ImportDeclaration) {
if (node.importKind === "type") return
const importSource = node.source.value
if (!isBarrelImport(importSource, currentFile)) return
const namespaceSpecifiers: Array<ESTree.ImportNamespaceSpecifier> = []
const namedValueSpecifiers: Array<ESTree.ImportSpecifier> = []
for (const specifier of node.specifiers) {
if (specifier.type === "ImportNamespaceSpecifier") {
namespaceSpecifiers.push(specifier)
} else if (specifier.type === "ImportSpecifier") {
if (specifier.importKind !== "type") {
namedValueSpecifiers.push(specifier)
}
}
}
for (const specifier of namespaceSpecifiers) {
context.report({
node: specifier,
message:
`Do not use namespace import from barrel file "${importSource}", import from specific modules instead`
})
}
for (const specifier of namedValueSpecifiers) {
const moduleName = getModuleName(specifier)
const localName = specifier.local.name
const message = isRelativeImport(importSource)
? `Do not import "${moduleName}" from barrel file "${importSource}", import from specific module instead`
: `Use import * as ${localName} from "${importSource}/${moduleName}" instead`
context.report({
node: specifier,
message
})
}
}
} as Visitor
}
}
export default rule

View File

@@ -0,0 +1,73 @@
import type { CreateRule, ESTree, Fixer, Visitor } from "oxlint"
const jsExtensions = [".js", ".jsx", ".mjs", ".cjs"]
const extensionMap: Record<string, string> = {
".js": ".ts",
".jsx": ".tsx",
".mjs": ".mts",
".cjs": ".cts"
}
function isRelativeImport(source: string): boolean {
return source.startsWith("./") || source.startsWith("../")
}
function getJsExtension(source: string): string | undefined {
for (const ext of jsExtensions) {
if (source.endsWith(ext)) {
return ext
}
}
return undefined
}
const rule: CreateRule = {
meta: {
type: "problem",
docs: {
description:
"Disallow .js, .jsx, .mjs and .cjs extensions in relative imports, use .ts, .tsx, .mts or .cts instead"
},
fixable: "code"
},
create(context) {
function checkSource(source: ESTree.StringLiteral) {
const value = source.value
if (!isRelativeImport(value)) return
const ext = getJsExtension(value)
if (!ext) return
const tsExt = extensionMap[ext]
const fixedSource = value.slice(0, -ext.length) + tsExt
context.report({
node: source,
message: `Use "${tsExt}" extension instead of "${ext}" for relative imports`,
fix: (fixer: Fixer) => fixer.replaceTextRange(source.range, `"${fixedSource}"`)
})
}
function handleImportDeclaration(node: ESTree.ImportDeclaration) {
checkSource(node.source)
}
function handleExportAllDeclaration(node: ESTree.ExportAllDeclaration) {
checkSource(node.source)
}
function handleExportNamedDeclaration(node: ESTree.ExportNamedDeclaration) {
if (node.source) {
checkSource(node.source)
}
}
return {
ImportDeclaration: handleImportDeclaration,
ExportAllDeclaration: handleExportAllDeclaration,
ExportNamedDeclaration: handleExportNamedDeclaration
} as Visitor
}
}
export default rule

View File

@@ -0,0 +1,92 @@
import type { CreateRule, ESTree, Visitor } from "oxlint"
const SCHEMA_SOURCES = new Set(["effect", "effect/Schema"])
const SCHEMA_NAMESPACE_SOURCES = new Set(["effect/Schema"])
const rule: CreateRule = {
meta: {
type: "problem",
docs: { description: "Disallow instance members in Schema.Opaque classes" }
},
create(context) {
// Track identifiers that point to Schema or Opaque imported from Schema.
// Local names that refer to the Schema module (Schema or namespace import).
// Example: `import { Schema } from "effect"` or `import * as S from "effect/Schema"`.
const schemaIdentifiers = new Set<string>()
// Local names that refer to Opaque imported directly from the Schema module.
// Example: `import { Opaque as MyOpaque } from "effect/Schema"`.
const opaqueIdentifiers = new Set<string>()
// Match `class X extends Schema.Opaque(...)` or `class X extends Opaque(...)` when
// the identifiers are tied to the Schema module via imports.
function isSchemaOpaqueExtension(node: ESTree.Class): boolean {
const sc = node.superClass
if (!sc || sc.type !== "CallExpression") return false
const inner = sc.callee
if (!inner || inner.type !== "CallExpression") return false
return isOpaqueCallee(inner.callee)
}
// Validate the outer `Opaque` call, allowing either `Opaque` or `<SchemaNamespace>.Opaque`.
function isOpaqueCallee(node: ESTree.Expression | null | undefined): boolean {
if (!node) return false
if (node.type === "Identifier") return opaqueIdentifiers.has(node.name)
if (node.type !== "MemberExpression") return false
if (node.property?.type !== "Identifier" || node.property.name !== "Opaque") return false
return isSchemaObject(node.object)
}
// Ensure `<SchemaNamespace>.Opaque` is actually Schema (imported from Schema module).
function isSchemaObject(node: ESTree.Expression | null | undefined): boolean {
if (!node) return false
if (node.type === "Identifier") return schemaIdentifiers.has(node.name)
return false
}
function checkClass(node: ESTree.Class) {
if (!isSchemaOpaqueExtension(node)) return
for (const element of node.body.body) {
if (element.type === "PropertyDefinition" && !element.static) {
context.report({
node: element,
message: "Classes extending Schema.Opaque must not have instance members"
})
} else if (element.type === "MethodDefinition" && !element.static) {
context.report({
node: element,
message: "Classes extending Schema.Opaque must not have instance members"
})
}
}
}
return {
// Record identifiers for Schema/Opaque imports so we don't match unrelated modules.
ImportDeclaration(node: ESTree.ImportDeclaration) {
if (node.importKind === "type") return
const source = node.source.value
if (typeof source !== "string" || !SCHEMA_SOURCES.has(source)) return
for (const specifier of node.specifiers) {
if (specifier.type === "ImportNamespaceSpecifier") {
if (SCHEMA_NAMESPACE_SOURCES.has(source)) {
schemaIdentifiers.add(specifier.local.name)
}
} else if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type") {
if (specifier.imported.type !== "Identifier") continue
const importedName = specifier.imported.name
if (importedName === "Schema") {
schemaIdentifiers.add(specifier.local.name)
} else if (importedName === "Opaque") {
opaqueIdentifiers.add(specifier.local.name)
}
}
}
},
ClassDeclaration: checkClass,
ClassExpression: checkClass
} as Visitor
}
}
export default rule

View File

@@ -0,0 +1,642 @@
import * as fs from "node:fs"
import * as path from "node:path"
import type { CreateRule, ESTree, Visitor } from "oxlint"
import ts from "typescript"
interface InternalExport {
readonly fileName: string
readonly declaration: ts.Node
readonly name: string
readonly range: readonly [number, number]
used: boolean
}
interface Diagnostic {
readonly fileName: string
readonly range: readonly [number, number]
readonly message: string
}
interface Analysis {
readonly diagnosticsByFile: ReadonlyMap<string, ReadonlyArray<Diagnostic>>
}
interface ImportBinding {
readonly fileName: string
readonly importedName: string
}
interface FileInfo {
readonly fileName: string
readonly sourceFile: ts.SourceFile
readonly imports: Map<string, ImportBinding>
readonly namespaceImports: Map<string, string>
readonly internalExports: Map<string, Array<InternalExport>>
readonly publicDeclarations: Array<ts.Node>
}
const cache = new Map<string, Analysis>()
const sourceExtensions = new Set([".ts"])
function getCwd(context: { readonly cwd?: string; getCwd?: () => string }): string {
return context.cwd ?? context.getCwd?.() ?? process.cwd()
}
function normalizePathName(filename: string): string {
return path.resolve(filename).split(path.sep).join("/")
}
function rangeNode(range: readonly [number, number]): ESTree.Node {
return { range: [range[0], range[1]] } as ESTree.Node
}
function isSourceFile(fileName: string): boolean {
return sourceExtensions.has(path.extname(fileName)) && !fileName.endsWith(".d.ts")
}
function findSourceFiles(cwd: string): Array<string> {
const packagesDir = path.join(cwd, "packages")
if (!fs.existsSync(packagesDir)) return []
const files: Array<string> = []
const visit = (directory: string) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) {
if (entry.name !== "dist" && entry.name !== "build" && entry.name !== "node_modules") {
visit(entryPath)
}
} else if (entry.isFile() && isSourceFile(entryPath) && normalizePathName(entryPath).includes("/src/")) {
files.push(entryPath)
}
}
}
visit(packagesDir)
return files
}
function findWorkspacePackages(cwd: string): Map<string, string> {
const packagesDir = path.join(cwd, "packages")
const packages = new Map<string, string>()
if (!fs.existsSync(packagesDir)) return packages
const visit = (directory: string) => {
const packageJson = path.join(directory, "package.json")
if (fs.existsSync(packageJson)) {
try {
const json = JSON.parse(fs.readFileSync(packageJson, "utf8")) as { readonly name?: unknown }
if (typeof json.name === "string") {
packages.set(json.name, directory)
}
} catch {
// Ignore malformed package metadata; TypeScript will report that elsewhere.
}
return
}
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "dist" && entry.name !== "build") {
visit(path.join(directory, entry.name))
}
}
}
visit(packagesDir)
return packages
}
function resolveSourceFile(basePath: string): string | undefined {
const candidates = path.extname(basePath) === ".ts"
? [basePath]
: [basePath, `${basePath}.ts`, path.join(basePath, "index.ts")]
for (const candidate of candidates) {
if (fs.existsSync(candidate) && isSourceFile(candidate)) {
return normalizePathName(candidate)
}
}
return undefined
}
function resolveModule(
source: string,
containingFile: string,
workspacePackages: ReadonlyMap<string, string>
): string | undefined {
if (source.startsWith("./") || source.startsWith("../")) {
return resolveSourceFile(path.resolve(path.dirname(containingFile), source))
}
let packageName: string | undefined
for (const name of workspacePackages.keys()) {
if (source === name || source.startsWith(`${name}/`)) {
if (packageName === undefined || name.length > packageName.length) {
packageName = name
}
}
}
if (packageName === undefined) return undefined
const packageRoot = workspacePackages.get(packageName)
if (packageRoot === undefined) return undefined
if (source === packageName) {
return resolveSourceFile(path.join(packageRoot, "src", "index.ts"))
}
const subpath = source.slice(packageName.length + 1)
return resolveSourceFile(path.join(packageRoot, "src", subpath))
}
function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
return ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === kind)
}
function hasExportModifier(node: ts.Node): boolean {
return hasModifier(node, ts.SyntaxKind.ExportKeyword)
}
function hasPrivateOrProtectedModifier(node: ts.Node): boolean {
return hasModifier(node, ts.SyntaxKind.PrivateKeyword) || hasModifier(node, ts.SyntaxKind.ProtectedKeyword)
}
function hasInternalJSDoc(node: ts.Node): boolean {
return ts.getJSDocTags(node).some((tag) => tag.tagName.getText() === "internal")
}
function hasInternalApiJSDoc(node: ts.Node): boolean {
if (hasInternalJSDoc(node)) return true
if (ts.isVariableDeclaration(node)) {
return hasInternalJSDoc(node.parent.parent)
}
if (ts.isBindingElement(node) && ts.isVariableDeclaration(node.parent.parent)) {
return hasInternalJSDoc(node.parent.parent.parent)
}
return false
}
function isInternalPath(fileName: string): boolean {
return /\/src\/(?:.*\/)?internal\//.test(normalizePathName(fileName))
}
function collectBindingNames(name: ts.BindingName, out: Array<ts.Identifier>) {
if (ts.isIdentifier(name)) {
out.push(name)
return
}
for (const element of name.elements) {
if (ts.isBindingElement(element)) {
collectBindingNames(element.name, out)
}
}
}
function getTopLevelDeclarationNameNodes(node: ts.Node): Array<ts.Identifier> {
if (
(ts.isFunctionDeclaration(node) ||
ts.isClassDeclaration(node) ||
ts.isInterfaceDeclaration(node) ||
ts.isTypeAliasDeclaration(node) ||
ts.isEnumDeclaration(node) ||
ts.isModuleDeclaration(node)) &&
node.name !== undefined &&
ts.isIdentifier(node.name)
) {
return [node.name]
}
if (ts.isVariableStatement(node)) {
const names: Array<ts.Identifier> = []
for (const declaration of node.declarationList.declarations) {
collectBindingNames(declaration.name, names)
}
return names
}
return []
}
function getDeclarationForName(node: ts.Node, name: ts.Identifier): ts.Node {
if (!ts.isVariableStatement(node)) return node
let result: ts.Node = node
const visit = (current: ts.Node) => {
if (ts.isVariableDeclaration(current) || ts.isBindingElement(current)) {
const names: Array<ts.Identifier> = []
collectBindingNames(current.name, names)
if (names.includes(name)) {
result = current
return
}
}
ts.forEachChild(current, visit)
}
visit(node.declarationList)
return result
}
function isImportOrExportBinding(identifier: ts.Identifier): boolean {
const parent = identifier.parent
return (ts.isImportSpecifier(parent) && (parent.name === identifier || parent.propertyName === identifier)) ||
(ts.isImportClause(parent) && parent.name === identifier) ||
(ts.isNamespaceImport(parent) && parent.name === identifier) ||
(ts.isExportSpecifier(parent) && (parent.name === identifier || parent.propertyName === identifier))
}
function isDeclarationName(identifier: ts.Identifier): boolean {
const parent = identifier.parent
return (ts.isVariableDeclaration(parent) && parent.name === identifier) ||
(ts.isBindingElement(parent) && parent.name === identifier) ||
(ts.isParameter(parent) && parent.name === identifier) ||
(ts.isFunctionDeclaration(parent) && parent.name === identifier) ||
(ts.isClassDeclaration(parent) && parent.name === identifier) ||
(ts.isInterfaceDeclaration(parent) && parent.name === identifier) ||
(ts.isTypeAliasDeclaration(parent) && parent.name === identifier) ||
(ts.isEnumDeclaration(parent) && parent.name === identifier) ||
(ts.isEnumMember(parent) && parent.name === identifier) ||
(ts.isModuleDeclaration(parent) && parent.name === identifier)
}
function isNonReferenceName(identifier: ts.Identifier): boolean {
const parent = identifier.parent
return isImportOrExportBinding(identifier) ||
isDeclarationName(identifier) ||
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
(ts.isPropertyAssignment(parent) && parent.name === identifier) ||
(ts.isPropertyDeclaration(parent) && parent.name === identifier) ||
(ts.isPropertySignature(parent) && parent.name === identifier) ||
(ts.isMethodDeclaration(parent) && parent.name === identifier) ||
(ts.isMethodSignature(parent) && parent.name === identifier) ||
(ts.isGetAccessorDeclaration(parent) && parent.name === identifier) ||
(ts.isSetAccessorDeclaration(parent) && parent.name === identifier)
}
function isInside(node: ts.Node, container: ts.Node): boolean {
if (node.getSourceFile() !== container.getSourceFile()) return false
return node.getStart() >= container.getStart() && node.getEnd() <= container.getEnd()
}
function getExports(
fileInfos: ReadonlyMap<string, FileInfo>,
fileName: string | undefined,
exportName: string
): ReadonlyArray<InternalExport> {
if (fileName === undefined) return []
return fileInfos.get(fileName)?.internalExports.get(exportName) ?? []
}
function markUsed(exports: ReadonlyArray<InternalExport>, node: ts.Node) {
for (const internal of exports) {
if (!isInside(node, internal.declaration)) {
internal.used = true
}
}
}
function addFileDiagnostic(
diagnosticsByFile: Map<string, Array<Diagnostic>>,
diagnostic: Diagnostic
) {
const normalized = normalizePathName(diagnostic.fileName)
const diagnostics = diagnosticsByFile.get(normalized) ?? []
diagnostics.push(diagnostic)
diagnosticsByFile.set(normalized, diagnostics)
}
function addInternalExport(fileInfo: FileInfo, internal: InternalExport) {
const exports = fileInfo.internalExports.get(internal.name) ?? []
exports.push(internal)
fileInfo.internalExports.set(internal.name, exports)
}
function collectFileInfo(
sourceFile: ts.SourceFile,
workspacePackages: ReadonlyMap<string, string>
): FileInfo {
const fileInfo: FileInfo = {
fileName: normalizePathName(sourceFile.fileName),
sourceFile,
imports: new Map(),
namespaceImports: new Map(),
internalExports: new Map(),
publicDeclarations: []
}
const publicDeclarationSet = new Set<ts.Node>()
const isInternalFile = isInternalPath(sourceFile.fileName)
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
const importedFile = resolveModule(statement.moduleSpecifier.text, sourceFile.fileName, workspacePackages)
const importClause = statement.importClause
if (importedFile === undefined || importClause === undefined) continue
if (importClause.name !== undefined) {
fileInfo.imports.set(importClause.name.text, { fileName: importedFile, importedName: "default" })
}
const namedBindings = importClause.namedBindings
if (namedBindings === undefined) continue
if (ts.isNamespaceImport(namedBindings)) {
fileInfo.namespaceImports.set(namedBindings.name.text, importedFile)
} else {
for (const specifier of namedBindings.elements) {
const importedName = (specifier.propertyName ?? specifier.name).text
fileInfo.imports.set(specifier.name.text, { fileName: importedFile, importedName })
}
}
continue
}
if (!hasExportModifier(statement)) continue
const names = getTopLevelDeclarationNameNodes(statement)
if (names.length === 0) continue
const statementIsInternal = hasInternalApiJSDoc(statement)
for (const name of names) {
const declaration = getDeclarationForName(statement, name)
const declarationIsInternal = statementIsInternal || hasInternalApiJSDoc(declaration)
if (declarationIsInternal) {
addInternalExport(fileInfo, {
fileName: sourceFile.fileName,
declaration,
name: name.text,
range: [name.getStart(sourceFile), name.getEnd()],
used: false
})
} else if (!isInternalFile && !publicDeclarationSet.has(statement)) {
fileInfo.publicDeclarations.push(statement)
publicDeclarationSet.add(statement)
}
}
}
return fileInfo
}
function markNamespaceReference(
fileInfos: ReadonlyMap<string, FileInfo>,
fileInfo: FileInfo,
namespace: ts.Identifier,
name: ts.Identifier
) {
const importedFile = fileInfo.namespaceImports.get(namespace.text)
markUsed(getExports(fileInfos, importedFile, name.text), name)
}
function scanUsage(fileInfos: ReadonlyMap<string, FileInfo>, fileInfo: FileInfo) {
const visit = (node: ts.Node) => {
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && ts.isIdentifier(node.name)) {
markNamespaceReference(fileInfos, fileInfo, node.expression, node.name)
} else if (ts.isQualifiedName(node) && ts.isIdentifier(node.left)) {
markNamespaceReference(fileInfos, fileInfo, node.left, node.right)
} else if (ts.isIdentifier(node) && !isNonReferenceName(node)) {
markUsed(fileInfo.internalExports.get(node.text) ?? [], node)
const imported = fileInfo.imports.get(node.text)
markUsed(getExports(fileInfos, imported?.fileName, imported?.importedName ?? ""), node)
}
ts.forEachChild(node, visit)
}
visit(fileInfo.sourceFile)
}
function findInternalReferences(
fileInfos: ReadonlyMap<string, FileInfo>,
fileInfo: FileInfo,
diagnostics: Array<Diagnostic>,
node: ts.Node
) {
const visit = (current: ts.Node) => {
if (ts.isPropertyAccessExpression(current) && ts.isIdentifier(current.expression)) {
const importedFile = fileInfo.namespaceImports.get(current.expression.text)
for (const internal of getExports(fileInfos, importedFile, current.name.text)) {
diagnostics.push({
fileName: fileInfo.fileName,
range: [current.name.getStart(), current.name.getEnd()],
message: `Do not reference @internal export "${internal.name}" in a public exported type signature`
})
}
} else if (ts.isQualifiedName(current) && ts.isIdentifier(current.left)) {
const importedFile = fileInfo.namespaceImports.get(current.left.text)
for (const internal of getExports(fileInfos, importedFile, current.right.text)) {
diagnostics.push({
fileName: fileInfo.fileName,
range: [current.right.getStart(), current.right.getEnd()],
message: `Do not reference @internal export "${internal.name}" in a public exported type signature`
})
}
} else if (ts.isIdentifier(current) && !isNonReferenceName(current)) {
const localExports = fileInfo.internalExports.get(current.text) ?? []
const imported = fileInfo.imports.get(current.text)
const importedExports = getExports(fileInfos, imported?.fileName, imported?.importedName ?? "")
for (const internal of [...localExports, ...importedExports]) {
diagnostics.push({
fileName: fileInfo.fileName,
range: [current.getStart(), current.getEnd()],
message: `Do not reference @internal export "${internal.name}" in a public exported type signature`
})
}
}
ts.forEachChild(current, visit)
}
visit(node)
}
function scanTypeParameters(
fileInfos: ReadonlyMap<string, FileInfo>,
fileInfo: FileInfo,
diagnostics: Array<Diagnostic>,
typeParameters: ts.NodeArray<ts.TypeParameterDeclaration> | undefined
) {
if (typeParameters === undefined) return
for (const typeParameter of typeParameters) {
if (typeParameter.constraint !== undefined) {
findInternalReferences(fileInfos, fileInfo, diagnostics, typeParameter.constraint)
}
if (typeParameter.default !== undefined) {
findInternalReferences(fileInfos, fileInfo, diagnostics, typeParameter.default)
}
}
}
function scanParameterTypes(
fileInfos: ReadonlyMap<string, FileInfo>,
fileInfo: FileInfo,
diagnostics: Array<Diagnostic>,
parameters: ts.NodeArray<ts.ParameterDeclaration>
) {
for (const parameter of parameters) {
if (parameter.type !== undefined) {
findInternalReferences(fileInfos, fileInfo, diagnostics, parameter.type)
}
}
}
function scanFunctionLikeSignature(
fileInfos: ReadonlyMap<string, FileInfo>,
fileInfo: FileInfo,
diagnostics: Array<Diagnostic>,
node: ts.SignatureDeclarationBase
) {
scanTypeParameters(fileInfos, fileInfo, diagnostics, node.typeParameters)
scanParameterTypes(fileInfos, fileInfo, diagnostics, node.parameters)
if (node.type !== undefined) {
findInternalReferences(fileInfos, fileInfo, diagnostics, node.type)
}
}
function scanClassMemberSignature(
fileInfos: ReadonlyMap<string, FileInfo>,
fileInfo: FileInfo,
diagnostics: Array<Diagnostic>,
member: ts.ClassElement
) {
if (hasInternalJSDoc(member)) return
if (hasPrivateOrProtectedModifier(member)) return
if (
ts.isMethodDeclaration(member) ||
ts.isConstructorDeclaration(member) ||
ts.isGetAccessorDeclaration(member) ||
ts.isSetAccessorDeclaration(member)
) {
scanFunctionLikeSignature(fileInfos, fileInfo, diagnostics, member)
} else if (ts.isPropertyDeclaration(member) && member.type !== undefined) {
findInternalReferences(fileInfos, fileInfo, diagnostics, member.type)
}
}
function scanInterfaceMemberSignature(
fileInfos: ReadonlyMap<string, FileInfo>,
fileInfo: FileInfo,
diagnostics: Array<Diagnostic>,
member: ts.TypeElement
) {
if (hasInternalJSDoc(member)) return
if (
ts.isMethodSignature(member) || ts.isCallSignatureDeclaration(member) || ts.isConstructSignatureDeclaration(member)
) {
scanFunctionLikeSignature(fileInfos, fileInfo, diagnostics, member)
} else if (ts.isPropertySignature(member) && member.type !== undefined) {
findInternalReferences(fileInfos, fileInfo, diagnostics, member.type)
} else if (ts.isIndexSignatureDeclaration(member)) {
scanFunctionLikeSignature(fileInfos, fileInfo, diagnostics, member)
}
}
function scanPublicSignature(
fileInfos: ReadonlyMap<string, FileInfo>,
fileInfo: FileInfo,
diagnostics: Array<Diagnostic>,
node: ts.Node
) {
if (ts.isFunctionDeclaration(node)) {
scanFunctionLikeSignature(fileInfos, fileInfo, diagnostics, node)
} else if (ts.isVariableStatement(node)) {
for (const declaration of node.declarationList.declarations) {
if (declaration.type !== undefined) {
findInternalReferences(fileInfos, fileInfo, diagnostics, declaration.type)
}
}
} else if (ts.isTypeAliasDeclaration(node)) {
scanTypeParameters(fileInfos, fileInfo, diagnostics, node.typeParameters)
findInternalReferences(fileInfos, fileInfo, diagnostics, node.type)
} else if (ts.isInterfaceDeclaration(node)) {
scanTypeParameters(fileInfos, fileInfo, diagnostics, node.typeParameters)
for (const heritage of node.heritageClauses ?? []) {
for (const type of heritage.types) {
findInternalReferences(fileInfos, fileInfo, diagnostics, type)
}
}
for (const member of node.members) {
scanInterfaceMemberSignature(fileInfos, fileInfo, diagnostics, member)
}
} else if (ts.isClassDeclaration(node)) {
scanTypeParameters(fileInfos, fileInfo, diagnostics, node.typeParameters)
for (const heritage of node.heritageClauses ?? []) {
for (const type of heritage.types) {
findInternalReferences(fileInfos, fileInfo, diagnostics, type)
}
}
for (const member of node.members) {
scanClassMemberSignature(fileInfos, fileInfo, diagnostics, member)
}
}
}
function analyze(cwd: string): Analysis {
const normalizedCwd = normalizePathName(cwd)
const cached = cache.get(normalizedCwd)
if (cached !== undefined) return cached
const workspacePackages = findWorkspacePackages(cwd)
const fileInfos = new Map<string, FileInfo>()
for (const fileName of findSourceFiles(cwd)) {
const sourceFile = ts.createSourceFile(fileName, fs.readFileSync(fileName, "utf8"), ts.ScriptTarget.Latest, true)
const fileInfo = collectFileInfo(sourceFile, workspacePackages)
fileInfos.set(fileInfo.fileName, fileInfo)
}
for (const fileInfo of fileInfos.values()) {
scanUsage(fileInfos, fileInfo)
}
const diagnosticsByFile = new Map<string, Array<Diagnostic>>()
for (const fileInfo of fileInfos.values()) {
const diagnostics: Array<Diagnostic> = []
for (const declaration of fileInfo.publicDeclarations) {
scanPublicSignature(fileInfos, fileInfo, diagnostics, declaration)
}
for (const diagnostic of diagnostics) {
addFileDiagnostic(diagnosticsByFile, diagnostic)
}
for (const exportsForName of fileInfo.internalExports.values()) {
for (const internal of exportsForName) {
if (!internal.used) {
addFileDiagnostic(diagnosticsByFile, {
fileName: internal.fileName,
range: internal.range,
message: `@internal export "${internal.name}" is not used by production sources`
})
}
}
}
}
for (const diagnostics of diagnosticsByFile.values()) {
diagnostics.sort((self, that) => self.range[0] - that.range[0])
}
const analysis = { diagnosticsByFile }
cache.set(normalizedCwd, analysis)
return analysis
}
const rule: CreateRule = {
meta: {
type: "problem",
docs: {
description: "Disallow unused @internal exports and references to @internal exports from public type signatures"
}
},
create(context) {
const analysis = analyze(getCwd(context))
const diagnostics = analysis.diagnosticsByFile.get(normalizePathName(context.filename)) ?? []
return {
Program() {
for (const diagnostic of diagnostics) {
context.report({
node: rangeNode(diagnostic.range),
message: diagnostic.message
})
}
}
} as Visitor
}
}
export default rule

View File

@@ -0,0 +1,75 @@
import rule from "@effect/oxc/oxlint/rules/jsdocs"
import * as crypto from "node:crypto"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { describe, expect, it } from "vitest"
import { createTestContext } from "./utils.ts"
function hash(source: string): string {
return crypto.createHash("sha256").update(source).digest("hex")
}
function run(source: string, cwd: string, filename: string, model = ".data/jsdocs.json") {
const { context, errors } = createTestContext({ sourceCode: source, cwd, filename, ruleOptions: [{ model }] })
const visitors = rule.create(context as never)
visitors.Program?.({ type: "Program", range: [0, source.length] } as never)
return errors
}
describe("jsdocs", () => {
it("skips when the model is missing", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-rule-"))
const errors = run("export const a = 1\n", cwd, path.join(cwd, "src/Foo.ts"))
expect(errors).toEqual([])
})
it("reports invalid model files", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-rule-"))
fs.mkdirSync(path.join(cwd, ".data"), { recursive: true })
fs.writeFileSync(path.join(cwd, ".data/jsdocs.json"), "{")
const errors = run("export const a = 1\n", cwd, path.join(cwd, "src/Foo.ts"))
expect(errors.map((error) => error.message)).toEqual([expect.stringContaining("Invalid jsdocs model")])
})
it("reports stale model files", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-rule-"))
fs.mkdirSync(path.join(cwd, ".data"), { recursive: true })
fs.writeFileSync(
path.join(cwd, ".data/jsdocs.json"),
JSON.stringify({
version: 2,
generatedBy: "@effect/jsdocs",
generatedAt: "now",
files: [{ file: "src/Foo.ts", hash: "old", diagnostics: [], declarations: [], namespaces: [] }],
apis: []
})
)
const errors = run("export const a = 1\n", cwd, path.join(cwd, "src/Foo.ts"))
expect(errors.map((error) => error.message)).toEqual(["JSDoc model is stale for this file; run `pnpm jsdocs`"])
})
it("reports model diagnostics", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "jsdocs-rule-"))
const source = "export const a = 1\n"
fs.mkdirSync(path.join(cwd, ".data"), { recursive: true })
fs.writeFileSync(
path.join(cwd, ".data/jsdocs.json"),
JSON.stringify({
version: 2,
generatedBy: "@effect/jsdocs",
generatedAt: "now",
apis: [],
files: [{
file: "src/Foo.ts",
hash: hash(source),
diagnostics: [{ code: "missing-jsdoc", message: "Public JSDoc is required", range: [0, 6] }],
declarations: [],
namespaces: []
}]
})
)
const errors = run(source, cwd, path.join(cwd, "src/Foo.ts"))
expect(errors.map((error) => error.message)).toEqual(["Public JSDoc is required"])
})
})

View File

@@ -0,0 +1,182 @@
import rule from "@effect/oxc/oxlint/rules/no-import-from-barrel-package"
import { describe, expect, it } from "vitest"
import { runRule } from "./utils.ts"
describe("no-import-from-barrel-package", () => {
const testOptions = {
filename: "/test/file.ts",
cwd: "/test",
ruleOptions: [{ checkPatterns: ["^effect$"] }]
}
const createImportDeclaration = (
source: string,
specifiers: Array<unknown>,
importKind?: "type" | "value"
) => {
const base = {
type: "ImportDeclaration" as const,
source: { value: source },
specifiers,
range: [0, 50] as [number, number]
}
return importKind !== undefined ? { ...base, importKind } : base
}
const createNamedSpecifier = (
name: string,
local?: string,
importKind?: "type" | "value"
) => {
const base = {
type: "ImportSpecifier" as const,
imported: { type: "Identifier" as const, name },
local: { name: local ?? name }
}
return importKind !== undefined ? { ...base, importKind } : base
}
it("should not report for imports from non-barrel packages", () => {
const node = createImportDeclaration("effect/Effect", [
createNamedSpecifier("Effect")
])
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(0)
})
it("should report for named imports from effect barrel", () => {
const node = createImportDeclaration("effect", [
createNamedSpecifier("Effect")
])
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(
`Use import * as Effect from "effect/Effect" instead`
)
})
it("should report for multiple named imports from effect barrel", () => {
const node = createImportDeclaration("effect", [
createNamedSpecifier("Effect"),
createNamedSpecifier("Option"),
createNamedSpecifier("Either")
])
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(3)
})
it("should not report for type-only import declarations", () => {
const node = createImportDeclaration(
"effect",
[createNamedSpecifier("Effect")],
"type"
)
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(0)
})
it("should not report for type imports within named specifiers", () => {
const node = createImportDeclaration("effect", [
createNamedSpecifier("Effect", "Effect", "type")
])
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(0)
})
it("should handle aliased imports", () => {
const node = createImportDeclaration("effect", [
createNamedSpecifier("Effect", "Eff")
])
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(
`Use import * as Eff from "effect/Effect" instead`
)
})
it("should report for namespace imports from barrel", () => {
const node = createImportDeclaration("effect", [
{
type: "ImportNamespaceSpecifier",
local: { name: "Effect" }
}
])
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(
`Do not use namespace import from barrel file "effect", import from specific modules instead`
)
})
it("should not report for namespace imports from module paths", () => {
const node = createImportDeclaration("effect/Effect", [
{
type: "ImportNamespaceSpecifier",
local: { name: "Effect" }
}
])
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(0)
})
it("should not report for default imports", () => {
const node = createImportDeclaration("effect", [
{
type: "ImportDefaultSpecifier",
local: { name: "Effect" }
}
])
const errors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(errors).toHaveLength(0)
})
describe("configuration", () => {
it("should match regex patterns", () => {
const node = createImportDeclaration("@myorg/utils", [
createNamedSpecifier("helper")
])
// No patterns configured - doesn't match
const defaultErrors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(defaultErrors).toHaveLength(0)
// Pattern matches @myorg/*
const customErrors = runRule(rule, "ImportDeclaration", node, {
...testOptions,
ruleOptions: [{ checkPatterns: ["^@myorg/"] }]
})
expect(customErrors).toHaveLength(1)
})
it("should match exact package with pattern", () => {
const node = createImportDeclaration("lodash", [
createNamedSpecifier("map")
])
// No patterns - doesn't match
const defaultErrors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(defaultErrors).toHaveLength(0)
// Exact match pattern
const customErrors = runRule(rule, "ImportDeclaration", node, {
...testOptions,
ruleOptions: [{ checkPatterns: ["^lodash$"] }]
})
expect(customErrors).toHaveLength(1)
})
it("should disable relative index checking when configured", () => {
const node = createImportDeclaration("./index.ts", [
createNamedSpecifier("foo")
])
// Default checks relative imports
const defaultErrors = runRule(rule, "ImportDeclaration", node, testOptions)
expect(defaultErrors).toHaveLength(1)
// Disabled relative checking
const customErrors = runRule(rule, "ImportDeclaration", node, {
...testOptions,
ruleOptions: [{ checkRelativeIndexImports: false }]
})
expect(customErrors).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,120 @@
import rule from "@effect/oxc/oxlint/rules/no-js-extension-imports"
import { describe, expect, it } from "vitest"
import { runRule } from "./utils.ts"
describe("no-js-extension-imports", () => {
const createImportDeclaration = (source: string) => ({
type: "ImportDeclaration",
source: { value: source, range: [8, 8 + source.length + 2] as [number, number] },
range: [0, 50] as [number, number]
})
const createExportAllDeclaration = (source: string) => ({
type: "ExportAllDeclaration",
source: { value: source, range: [14, 14 + source.length + 2] as [number, number] },
range: [0, 50] as [number, number]
})
const createExportNamedDeclaration = (source: string) => ({
type: "ExportNamedDeclaration",
source: { value: source, range: [9, 9 + source.length + 2] as [number, number] },
range: [0, 50] as [number, number]
})
describe("ImportDeclaration", () => {
it("should report .js extension in relative imports", () => {
const node = createImportDeclaration("./foo.js")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(`Use ".ts" extension instead of ".js" for relative imports`)
})
it("should report .jsx extension in relative imports", () => {
const node = createImportDeclaration("./component.jsx")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(`Use ".tsx" extension instead of ".jsx" for relative imports`)
})
it("should report .mjs extension in relative imports", () => {
const node = createImportDeclaration("../utils.mjs")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(`Use ".mts" extension instead of ".mjs" for relative imports`)
})
it("should report .cjs extension in relative imports", () => {
const node = createImportDeclaration("./config.cjs")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(`Use ".cts" extension instead of ".cjs" for relative imports`)
})
it("should not report .ts extension in relative imports", () => {
const node = createImportDeclaration("./foo.ts")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(0)
})
it("should not report .tsx extension in relative imports", () => {
const node = createImportDeclaration("./component.tsx")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(0)
})
it("should not report for package imports with .js extension", () => {
const node = createImportDeclaration("some-package/utils.js")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(0)
})
it("should not report for bare package imports", () => {
const node = createImportDeclaration("effect")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(0)
})
it("should handle deeply nested relative imports", () => {
const node = createImportDeclaration("../../lib/utils.js")
const errors = runRule(rule, "ImportDeclaration", node)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(`Use ".ts" extension instead of ".js" for relative imports`)
})
})
describe("ExportAllDeclaration", () => {
it("should report .js extension in relative re-exports", () => {
const node = createExportAllDeclaration("./foo.js")
const errors = runRule(rule, "ExportAllDeclaration", node)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(`Use ".ts" extension instead of ".js" for relative imports`)
})
it("should not report .ts extension in relative re-exports", () => {
const node = createExportAllDeclaration("./foo.ts")
const errors = runRule(rule, "ExportAllDeclaration", node)
expect(errors).toHaveLength(0)
})
})
describe("ExportNamedDeclaration", () => {
it("should report .js extension in relative named exports", () => {
const node = createExportNamedDeclaration("./foo.js")
const errors = runRule(rule, "ExportNamedDeclaration", node)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe(`Use ".ts" extension instead of ".js" for relative imports`)
})
it("should not report .ts extension in relative named exports", () => {
const node = createExportNamedDeclaration("./foo.ts")
const errors = runRule(rule, "ExportNamedDeclaration", node)
expect(errors).toHaveLength(0)
})
it("should handle export without source", () => {
const node = { type: "ExportNamedDeclaration", source: null }
const errors = runRule(rule, "ExportNamedDeclaration", node)
expect(errors).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,276 @@
import rule from "@effect/oxc/oxlint/rules/no-opaque-instance-fields"
import { describe, expect, it } from "vitest"
import { createTestContext, runRule } from "./utils.ts"
function runRuleWithNodes(nodes: Array<{ visitor: string; node: unknown }>) {
const { context, errors } = createTestContext()
const visitors = rule.create(context as never)
for (const { visitor, node } of nodes) {
const handler = visitors[visitor as keyof typeof visitors]
if (handler) {
;(handler as (node: unknown) => void)(node)
}
}
return errors
}
describe("no-opaque-instance-fields", () => {
const createOpaqueClass = (
callee: { type: string; name?: string; object?: unknown; property?: unknown },
members: Array<{ type: string; static: boolean }>
) => ({
type: "ClassDeclaration",
superClass: {
type: "CallExpression",
callee: {
type: "CallExpression",
callee
}
},
body: {
body: members
}
})
const createSchemaOpaqueClass = (members: Array<{ type: string; static: boolean }>) =>
createOpaqueClass({
type: "MemberExpression",
object: { type: "Identifier", name: "Schema" },
property: { type: "Identifier", name: "Opaque" }
}, members)
const createSchemaImport = () => ({
type: "ImportDeclaration",
source: { type: "Literal", value: "effect" },
specifiers: [{
type: "ImportSpecifier",
imported: { type: "Identifier", name: "Schema" },
local: { type: "Identifier", name: "Schema" },
importKind: "value"
}],
importKind: "value"
})
const createOpaqueImport = (localName = "Opaque") => ({
type: "ImportDeclaration",
source: { type: "Literal", value: "effect/Schema" },
specifiers: [{
type: "ImportSpecifier",
imported: { type: "Identifier", name: "Opaque" },
local: { type: "Identifier", name: localName },
importKind: "value"
}],
importKind: "value"
})
const createSchemaNamespaceImport = (localName = "S") => ({
type: "ImportDeclaration",
source: { type: "Literal", value: "effect/Schema" },
specifiers: [{
type: "ImportNamespaceSpecifier",
local: { type: "Identifier", name: localName }
}],
importKind: "value"
})
const createOtherOpaqueImport = (localName = "Opaque") => ({
type: "ImportDeclaration",
source: { type: "Literal", value: "other" },
specifiers: [{
type: "ImportSpecifier",
imported: { type: "Identifier", name: "Opaque" },
local: { type: "Identifier", name: localName },
importKind: "value"
}],
importKind: "value"
})
const createOtherNamespaceImport = (localName = "S") => ({
type: "ImportDeclaration",
source: { type: "Literal", value: "other" },
specifiers: [{
type: "ImportNamespaceSpecifier",
local: { type: "Identifier", name: localName }
}],
importKind: "value"
})
const createRegularClass = (members: Array<{ type: string; static: boolean }>) => ({
type: "ClassDeclaration",
superClass: {
type: "Identifier",
name: "SomeClass"
},
body: {
body: members
}
})
it("should not report for class without instance fields", () => {
const node = createSchemaOpaqueClass([])
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createSchemaImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(0)
})
it("should not report for static fields in Schema.Opaque class", () => {
const node = createSchemaOpaqueClass([
{ type: "PropertyDefinition", static: true }
])
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createSchemaImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(0)
})
it("should report for instance fields in Schema.Opaque class", () => {
const node = createSchemaOpaqueClass([
{ type: "PropertyDefinition", static: false }
])
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createSchemaImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe("Classes extending Schema.Opaque must not have instance members")
})
it("should not report when Schema.Opaque has no Schema import", () => {
const node = createSchemaOpaqueClass([
{ type: "PropertyDefinition", static: false }
])
const errors = runRule(rule, "ClassDeclaration", node)
expect(errors).toHaveLength(0)
})
it("should report for instance fields in Opaque class", () => {
const node = createOpaqueClass(
{ type: "Identifier", name: "Opaque" },
[{ type: "PropertyDefinition", static: false }]
)
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createOpaqueImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(1)
})
it("should report for instance fields in aliased Opaque class", () => {
const node = createOpaqueClass(
{
type: "MemberExpression",
object: { type: "Identifier", name: "S" },
property: { type: "Identifier", name: "Opaque" }
},
[{ type: "PropertyDefinition", static: false }]
)
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createSchemaNamespaceImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(1)
})
it("should not report for Opaque import from other module", () => {
const node = createOpaqueClass(
{ type: "Identifier", name: "Opaque" },
[{ type: "PropertyDefinition", static: false }]
)
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createOtherOpaqueImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(0)
})
it("should not report for namespace Opaque from other module", () => {
const node = createOpaqueClass(
{
type: "MemberExpression",
object: { type: "Identifier", name: "S" },
property: { type: "Identifier", name: "Opaque" }
},
[{ type: "PropertyDefinition", static: false }]
)
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createOtherNamespaceImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(0)
})
it("should report for multiple instance fields", () => {
const node = createSchemaOpaqueClass([
{ type: "PropertyDefinition", static: false },
{ type: "PropertyDefinition", static: true },
{ type: "PropertyDefinition", static: false }
])
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createSchemaImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(2)
})
it("should report for instance methods", () => {
const node = createSchemaOpaqueClass([
{ type: "MethodDefinition", static: false }
])
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createSchemaImport() },
{ visitor: "ClassDeclaration", node }
])
expect(errors).toHaveLength(1)
})
it("should not report for non-Schema.Opaque classes", () => {
const node = createRegularClass([
{ type: "PropertyDefinition", static: false }
])
const errors = runRule(rule, "ClassDeclaration", node)
expect(errors).toHaveLength(0)
})
it("should work with ClassExpression", () => {
const node = {
...createSchemaOpaqueClass([
{ type: "PropertyDefinition", static: false }
]),
type: "ClassExpression"
}
const errors = runRuleWithNodes([
{ visitor: "ImportDeclaration", node: createSchemaImport() },
{ visitor: "ClassExpression", node }
])
expect(errors).toHaveLength(1)
})
it("should not report for class without superClass", () => {
const node = {
type: "ClassDeclaration",
body: {
body: [{ type: "PropertyDefinition", static: false }]
}
}
const errors = runRule(rule, "ClassDeclaration", node)
expect(errors).toHaveLength(0)
})
it("should not report for class with non-CallExpression superClass", () => {
const node = {
type: "ClassDeclaration",
superClass: {
type: "Identifier",
name: "BaseClass"
},
body: {
body: [{ type: "PropertyDefinition", static: false }]
}
}
const errors = runRule(rule, "ClassDeclaration", node)
expect(errors).toHaveLength(0)
})
})

View File

@@ -0,0 +1,129 @@
import rule from "@effect/oxc/oxlint/rules/no-unused-internal"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { describe, expect, it } from "vitest"
import { createTestContext } from "./utils.ts"
function writeFixture(files: Record<string, string>): string {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "no-unused-internal-rule-"))
for (const [file, source] of Object.entries(files)) {
const fileName = path.join(cwd, file)
fs.mkdirSync(path.dirname(fileName), { recursive: true })
fs.writeFileSync(fileName, source)
}
return cwd
}
function run(cwd: string, file: string) {
const filename = path.join(cwd, file)
const source = fs.readFileSync(filename, "utf8")
const { context, errors } = createTestContext({ sourceCode: source, cwd, filename })
const visitors = rule.create(context as never)
visitors.Program?.({ type: "Program", range: [0, source.length] } as never)
return errors.map((error) => error.message)
}
describe("no-unused-internal", () => {
it("reports unused top-level @internal exports", () => {
const cwd = writeFixture({
"packages/foo/src/Internal.ts": `/** @internal */
export const unused = 1
`
})
expect(run(cwd, "packages/foo/src/Internal.ts")).toEqual([
`@internal export "unused" is not used by production sources`
])
})
it("counts same-file references as usage", () => {
const cwd = writeFixture({
"packages/foo/src/Internal.ts": `/** @internal */
export const used = 1
export const value = used
`
})
expect(run(cwd, "packages/foo/src/Internal.ts")).toEqual([])
})
it("counts type-only references as usage", () => {
const cwd = writeFixture({
"packages/foo/src/Internal.ts": `/** @internal */
export interface Internal {
readonly value: string
}
`,
"packages/foo/src/Consumer.ts": `import type { Internal } from "./Internal.ts"
type Local = Internal
`
})
expect(run(cwd, "packages/foo/src/Internal.ts")).toEqual([])
})
it("reports @internal exports used in public exported type signatures", () => {
const cwd = writeFixture({
"packages/foo/src/Internal.ts": `/** @internal */
export interface Internal {
readonly value: string
}
`,
"packages/foo/src/Public.ts": `import type { Internal } from "./Internal.ts"
export interface Public {
readonly value: Internal
}
`
})
expect(run(cwd, "packages/foo/src/Public.ts")).toEqual([
`Do not reference @internal export "Internal" in a public exported type signature`
])
expect(run(cwd, "packages/foo/src/Internal.ts")).toEqual([])
})
it("ignores @internal class member type signatures", () => {
const cwd = writeFixture({
"packages/foo/src/Internal.ts": `/** @internal */
export interface Internal {
readonly value: string
}
`,
"packages/foo/src/Public.ts": `import type { Internal } from "./Internal.ts"
export class Public {
/** @internal */
getInternal(): Internal {
throw new Error("not implemented")
}
}
`
})
expect(run(cwd, "packages/foo/src/Public.ts")).toEqual([])
expect(run(cwd, "packages/foo/src/Internal.ts")).toEqual([])
})
it("ignores public type signatures in internal directories", () => {
const cwd = writeFixture({
"packages/foo/src/Internal.ts": `/** @internal */
export interface Internal {
readonly value: string
}
`,
"packages/foo/src/internal/Consumer.ts": `import type { Internal } from "../Internal.ts"
export interface PublicInsideInternalDirectory {
readonly value: Internal
}
`
})
expect(run(cwd, "packages/foo/src/internal/Consumer.ts")).toEqual([])
expect(run(cwd, "packages/foo/src/Internal.ts")).toEqual([])
})
})

View File

@@ -0,0 +1,61 @@
import type { CreateRule, Visitor } from "oxlint"
export interface ReportedError {
node: unknown
message: string
}
export interface TestContextOptions {
sourceCode?: string
filename?: string
cwd?: string
ruleOptions?: Array<unknown>
}
export const createTestContext = (options: TestContextOptions = {}) => {
const {
sourceCode = "",
filename = "/test/file.ts",
cwd = "/test",
ruleOptions = []
} = options
const errors: Array<ReportedError> = []
const context = {
id: "test/rule",
filename,
physicalFilename: filename,
cwd,
options: ruleOptions,
getFilename: () => filename,
getCwd: () => cwd,
report(reportOptions: ReportedError) {
errors.push(reportOptions)
},
sourceCode: {
text: sourceCode,
getText(node?: { range?: [number, number] } | null) {
if (node?.range) {
return sourceCode.slice(node.range[0], node.range[1])
}
return sourceCode
}
}
}
return { errors, context }
}
export const runRule = (
rule: CreateRule,
visitor: keyof Visitor,
node: unknown,
options: TestContextOptions = {}
): Array<ReportedError> => {
const { context, errors } = createTestContext(options)
const visitors = rule.create(context as never)
const handler = visitors[visitor]
if (handler) {
;(handler as (node: unknown) => void)(node)
}
return errors
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "http://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"types": ["node"]
}
}

View File

@@ -0,0 +1,6 @@
import { mergeConfig, type ViteUserConfig } from "vitest/config"
import shared from "../../../vitest.shared.ts"
const config: ViteUserConfig = {}
export default mergeConfig(shared, config)