Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'
This commit is contained in:
4
repos/effect/packages/tools/oxc/docgen.json
Normal file
4
repos/effect/packages/tools/oxc/docgen.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/@effect/docgen/schema.json",
|
||||
"exclude": ["**/*.ts"]
|
||||
}
|
||||
84
repos/effect/packages/tools/oxc/oxlintrc.json
Normal file
84
repos/effect/packages/tools/oxc/oxlintrc.json
Normal 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": "^_"
|
||||
}]
|
||||
}
|
||||
}
|
||||
71
repos/effect/packages/tools/oxc/package.json
Normal file
71
repos/effect/packages/tools/oxc/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
repos/effect/packages/tools/oxc/src/oxlint/index.ts
Normal file
20
repos/effect/packages/tools/oxc/src/oxlint/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
108
repos/effect/packages/tools/oxc/src/oxlint/rules/jsdocs.ts
Normal file
108
repos/effect/packages/tools/oxc/src/oxlint/rules/jsdocs.ts
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
75
repos/effect/packages/tools/oxc/test/jsdocs.test.ts
Normal file
75
repos/effect/packages/tools/oxc/test/jsdocs.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
129
repos/effect/packages/tools/oxc/test/no-unused-internal.test.ts
Normal file
129
repos/effect/packages/tools/oxc/test/no-unused-internal.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
61
repos/effect/packages/tools/oxc/test/utils.ts
Normal file
61
repos/effect/packages/tools/oxc/test/utils.ts
Normal 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
|
||||
}
|
||||
8
repos/effect/packages/tools/oxc/tsconfig.json
Normal file
8
repos/effect/packages/tools/oxc/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
6
repos/effect/packages/tools/oxc/vitest.config.ts
Normal file
6
repos/effect/packages/tools/oxc/vitest.config.ts
Normal 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)
|
||||
Reference in New Issue
Block a user