From 2bb5e4a673e169e577e0ed670d0cdab2fc016737 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 26 May 2026 15:56:17 +0200 Subject: [PATCH 1/6] feat(eslint-rules): add base-hook-signature rule Registers @nx/workspace-base-hook-signature. Rule is not yet enabled in any project config and is fully inert until wired up in a follow-up PR. --- tools/eslint-rules/index.ts | 2 + .../src/components/Sibling/useSiblingBase.ts | 3 + .../rules/base-hook-signature.spec.ts | 241 +++++++++++ .../eslint-rules/rules/base-hook-signature.ts | 387 ++++++++++++++++++ tools/eslint-rules/tsconfig.lint.json | 2 +- 5 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSiblingBase.ts create mode 100644 tools/eslint-rules/rules/base-hook-signature.spec.ts create mode 100644 tools/eslint-rules/rules/base-hook-signature.ts diff --git a/tools/eslint-rules/index.ts b/tools/eslint-rules/index.ts index 6aeaae6c06da8e..6411bbfbf1ddeb 100644 --- a/tools/eslint-rules/index.ts +++ b/tools/eslint-rules/index.ts @@ -4,6 +4,7 @@ import { RULE_NAME as consistentCallbackTypeName, rule as consistentCallbackType, } from './rules/consistent-callback-type'; +import { RULE_NAME as baseHookSignatureName, rule as baseHookSignature } from './rules/base-hook-signature'; /** * Import your custom workspace rules at the top of this file. @@ -32,6 +33,7 @@ module.exports = { */ rules: { [consistentCallbackTypeName]: consistentCallbackType, + [baseHookSignatureName]: baseHookSignature, [noRestrictedGlobalsName]: noRestrictedGlobals, [noMissingJsxPragmaName]: noMissingJsxPragma, }, diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSiblingBase.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSiblingBase.ts new file mode 100644 index 00000000000000..4133e7b12d5f5b --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSiblingBase.ts @@ -0,0 +1,3 @@ +export const useSiblingBase_unstable = (props: { a: number }, ref: React.Ref) => { + return { props, ref }; +}; diff --git a/tools/eslint-rules/rules/base-hook-signature.spec.ts b/tools/eslint-rules/rules/base-hook-signature.spec.ts new file mode 100644 index 00000000000000..8dd4ed49e25a5a --- /dev/null +++ b/tools/eslint-rules/rules/base-hook-signature.spec.ts @@ -0,0 +1,241 @@ +import * as path from 'node:path'; +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { rule, RULE_NAME } from './base-hook-signature'; + +const FIXTURE_ROOT = path.join(__dirname, '__fixtures__/base-hook-signature'); +const SIBLING_FILENAME = path.join(FIXTURE_ROOT, 'src/components/Sibling/useSibling.ts'); +const ORPHAN_FILENAME = path.join(FIXTURE_ROOT, 'src/components/Orphan/useOrphan.ts'); + +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + // Valid base hook: namespace import — `import * as React from 'react'` + `React.Ref<...>`. + { + code: ` + import * as React from 'react'; + export const useThingBase_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + `, + }, + // Valid base hook: named import — `import { Ref } from 'react'` + `Ref<...>` (FunctionDeclaration form). + { + code: ` + import { Ref } from 'react'; + export function useThingBase_unstable(props, ref: Ref) { + return { props, ref }; + } + `, + }, + // Valid base hook: default import — `import React from 'react'` + `React.Ref<...>`. + { + code: ` + import React from 'react'; + export const useThingBase_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + `, + }, + // Valid base hook with only \`props\` (ref is optional). + { + code: ` + export const useThingBase_unstable = (props) => { + return { props }; + }; + `, + }, + // Non-base hook without a paired base hook is not subject to the contract. + { + code: ` + export const useThing_unstable = (props, ref, extra) => { + return { props, ref, extra }; + }; + `, + }, + // Pair detection (same file): a state hook `useThing_unstable` next to its base hook + // `useThingBase_unstable` IS subject to the contract. Correct signature passes. + { + code: ` + import * as React from 'react'; + export const useThing_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + export const useThingBase_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + `, + }, + // Pair detection (no sibling base hook on disk): `useOrphanContextValues_unstable(state)` + // is NOT a paired wrapping hook and must NOT be flagged for its non-(props, ref) signature. + // The Orphan folder has no `useOrphanContextValuesBase.ts(x)` next to it. + { + filename: ORPHAN_FILENAME, + code: ` + export function useOrphanContextValues_unstable(state) { + return { state }; + } + `, + }, + // Pair detection (sibling file): wrapping state hook lives in `useSibling.ts`, paired with + // `useSiblingBase.ts` in the same folder. Correct (props, ref) signature passes. + { + filename: SIBLING_FILENAME, + code: ` + import * as React from 'react'; + export const useSibling_unstable = (props, ref: React.Ref) => { + return { props, ref }; + }; + `, + }, + ], + invalid: [ + // Too few params (0). + { + code: ` + export const useThingBase_unstable = () => ({}); + `, + errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThingBase_unstable', actual: 0 } }], + }, + // Too many params. + { + code: ` + export const useThingBase_unstable = (props, ref, extra) => ({ props, ref, extra }); + `, + errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThingBase_unstable', actual: 3 } }], + }, + // Wrong param names. + { + code: ` + export const useThingBase_unstable = (p, r) => ({ p, r }); + `, + errors: [ + { + messageId: 'invalidParamName', + data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: 'p' }, + }, + { + messageId: 'invalidParamName', + data: { hookName: 'useThingBase_unstable', index: 2, expected: 'ref', actual: 'r' }, + }, + ], + }, + // ObjectPattern for \`props\` is not allowed. + { + code: ` + import * as React from 'react'; + export const useThingBase_unstable = ({ a }, ref: React.Ref) => ({ a, ref }); + `, + errors: [ + { + messageId: 'invalidParamName', + data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: '{ ... }' }, + }, + ], + }, + // \`ref\` parameter without a type annotation. + { + code: ` + export const useThingBase_unstable = (props, ref) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: '' }, + }, + ], + }, + // \`ref\` parameter typed as something other than React.Ref. + { + code: ` + export const useThingBase_unstable = (props, ref: HTMLElement) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'HTMLElement' }, + }, + ], + }, + // \`ref\` parameter typed as React.ForwardedRef (must be React.Ref). + { + code: ` + export const useThingBase_unstable = (props, ref: React.ForwardedRef) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'React.ForwardedRef' }, + }, + ], + }, + // \`Ref\` is a locally declared type alias, not imported from react. + { + code: ` + type Ref = { current: T | null }; + export const useThingBase_unstable = (props, ref: Ref) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'Ref' }, + }, + ], + }, + // \`Ref\` imported from a non-react package is not accepted. + { + code: ` + import { Ref } from 'not-react'; + export const useThingBase_unstable = (props, ref: Ref) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'Ref' }, + }, + ], + }, + // \`React\` is a locally declared identifier, not the react module namespace. + { + code: ` + const React = { Ref: null }; + export const useThingBase_unstable = (props, ref: React.Ref) => ({ props, ref }); + `, + errors: [ + { + messageId: 'invalidRefType', + data: { hookName: 'useThingBase_unstable', actual: 'React.Ref' }, + }, + ], + }, + // Pair detection (same file): wrapping state hook with too many params is flagged because + // its sibling base hook in the same file marks it as a paired wrapper. + { + code: ` + import * as React from 'react'; + export const useThing_unstable = (props, ref, extra) => ({ props, ref, extra }); + export const useThingBase_unstable = (props, ref: React.Ref) => ({ props, ref }); + `, + errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThing_unstable', actual: 3 } }], + }, + // Pair detection (sibling file): wrapping state hook in `useSibling.ts` is paired with + // `useSiblingBase.ts` in the same folder. Wrong param names are flagged. + { + filename: SIBLING_FILENAME, + code: ` + import * as React from 'react'; + export const useSibling_unstable = (p, r: React.Ref) => ({ p, r }); + `, + errors: [ + { + messageId: 'invalidParamName', + data: { hookName: 'useSibling_unstable', index: 1, expected: 'props', actual: 'p' }, + }, + { + messageId: 'invalidParamName', + data: { hookName: 'useSibling_unstable', index: 2, expected: 'ref', actual: 'r' }, + }, + ], + }, + ], +}); diff --git a/tools/eslint-rules/rules/base-hook-signature.ts b/tools/eslint-rules/rules/base-hook-signature.ts new file mode 100644 index 00000000000000..377c0272b0b053 --- /dev/null +++ b/tools/eslint-rules/rules/base-hook-signature.ts @@ -0,0 +1,387 @@ +import type { TSESTree, TSESLint } from '@typescript-eslint/utils'; +import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// NOTE: The rule will be available in ESLint configs as "@nx/workspace-base-hook-signature" +export const RULE_NAME = 'base-hook-signature'; + +/** + * Names of v9 "base hooks": the implementation-only half of a `useFoo` / `useFooBase_unstable` + * pair, kept free of focus/keyboard runtime so it can be composed by callers that may opt out + * of those concerns. + */ +const BASE_HOOK_NAME_PATTERN = /^use[A-Z]\w*Base_unstable$/; + +/** + * Names of any `_unstable` hook, including base hooks themselves. Used in the rule's selector + * which then dispatches by whether the name matches `BASE_HOOK_NAME_PATTERN` (always checked) + * or is a wrapping state hook paired with a base hook (only checked when a pair exists). + */ +const STATE_HOOK_NAME_PATTERN = /^use[A-Z]\w*_unstable$/; + +const BASE_SUFFIX = 'Base_unstable'; +const UNSTABLE_SUFFIX = '_unstable'; +const SIBLING_EXTENSIONS: ReadonlyArray = ['.ts', '.tsx']; + +const EXPECTED_PARAM_NAMES = ['props', 'ref'] as const; +const MIN_PARAM_COUNT = 1; +const MAX_PARAM_COUNT = 2; + +/** + * Any function-literal form a base or paired state hook can take: top-level function declaration, + * inline arrow function, or function expression bound to a variable / export. The signature rule + * runs the same parameter validation against all three. + */ +type BaseHookFunction = TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression; + +type Options = []; + +type MessageIds = 'invalidParamCount' | 'invalidParamName' | 'invalidRefType'; + +export const rule = ESLintUtils.RuleCreator(() => __filename)({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + 'Enforce the API contract for v9 "base" hooks (`useBase_unstable`) and their paired wrapping state hooks (`use_unstable` declared in the same file or a sibling component-folder file): a required `props` parameter and an optional `ref` parameter typed as `React.Ref<...>`.', + }, + schema: [], + messages: { + invalidParamCount: + 'Hook `{{hookName}}` must take 1 or 2 positional parameters (`props`, optional `ref`), got {{actual}}.', + invalidParamName: + 'Hook `{{hookName}}` parameter #{{index}} must be named `{{expected}}` (Identifier), got `{{actual}}`.', + invalidRefType: 'Hook `{{hookName}}` parameter `ref` must be typed as `React.Ref<...>`, got `{{actual}}`.', + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + const pairDetector = createPairDetector(context.filename); + + /** + * Validates the hook signature: 1 or 2 positional params, first must be Identifier `props`, + * optional second must be Identifier `ref` typed as `React.Ref<...>` (verified to originate + * from the `react` package so collisions with same-named locals don't pass). + */ + function checkParameters(hookName: string, hookFn: BaseHookFunction, reportNode: TSESTree.Node): void { + if (hookFn.params.length < MIN_PARAM_COUNT || hookFn.params.length > MAX_PARAM_COUNT) { + context.report({ + node: reportNode, + messageId: 'invalidParamCount', + data: { hookName, actual: hookFn.params.length }, + }); + return; + } + + hookFn.params.forEach((param, index) => { + const expected = EXPECTED_PARAM_NAMES[index]; + if (param.type !== AST_NODE_TYPES.Identifier || param.name !== expected) { + context.report({ + node: reportNode, + messageId: 'invalidParamName', + data: { hookName, index: index + 1, expected, actual: describeParam(param) }, + }); + return; + } + if (index === 1 && !isReactRefTypeAnnotation(param.typeAnnotation, sourceCode.getScope(param))) { + context.report({ + node: reportNode, + messageId: 'invalidRefType', + data: { hookName, actual: describeRefType(param.typeAnnotation) }, + }); + } + }); + } + + return { + // Populate the pair detector's same-file index from top-level declarations so the state-hook + // visitor can synchronously decide pairing for the same-file case (82 / 85 occurrences across + // react-components). + Program(node: TSESTree.Program): void { + for (const stmt of node.body) { + collectBaseHookNames(stmt, pairDetector.baseHooksInCurrentFile); + } + }, + + // Broader selector matches both base hooks and state-hook candidates; the handler dispatches + // by name. State hooks only get the signature check when paired with a sibling base hook. + [`FunctionDeclaration[id.name=/${STATE_HOOK_NAME_PATTERN.source}/]`]: (node: TSESTree.FunctionDeclaration) => { + // `export default function () {}` produces an anonymous FunctionDeclaration (id === null). + // The esquery selector above requires `id.name`, so this branch should be unreachable in + // practice — kept as a type-narrowing guard so TS treats `node.id` as non-null below. + if (!node.id) { + return; + } + const name = node.id.name; + if (BASE_HOOK_NAME_PATTERN.test(name) || pairDetector.hasPairedBaseHook(name)) { + checkParameters(name, node, node.id); + } + }, + + [`VariableDeclarator[id.name=/${STATE_HOOK_NAME_PATTERN.source}/]`]: (node: TSESTree.VariableDeclarator) => { + const init = getFunctionInit(node); + if (!init || node.id.type !== AST_NODE_TYPES.Identifier) { + return; + } + const name = node.id.name; + if (BASE_HOOK_NAME_PATTERN.test(name) || pairDetector.hasPairedBaseHook(name)) { + checkParameters(name, init, node.id); + } + }, + }; + }, +}); + +// --------------------------------------------------------------------------- +// AST helpers +// --------------------------------------------------------------------------- + +/** + * Returns a human-readable label for a function parameter, used in `invalidParamName` diagnostics + * so the user sees what they actually wrote (destructuring, rest, default value, …) instead of + * just `Identifier`. + */ +function describeParam(param: TSESTree.Parameter): string { + switch (param.type) { + case AST_NODE_TYPES.Identifier: + return param.name; + case AST_NODE_TYPES.ObjectPattern: + return '{ ... }'; + case AST_NODE_TYPES.ArrayPattern: + return '[ ... ]'; + case AST_NODE_TYPES.RestElement: + return '...rest'; + case AST_NODE_TYPES.AssignmentPattern: + return param.left.type === AST_NODE_TYPES.Identifier ? `${param.left.name} = …` : '… = …'; + default: + return param.type; + } +} + +/** + * Collects names of top-level declarations matching `BASE_HOOK_NAME_PATTERN` into `out`. + * Handles both `export const useFooBase_unstable = ...` (incl. `export const` chains) and + * `export function useFooBase_unstable() {}`, plus the unexported / `export { ... }` forms. + */ +function collectBaseHookNames(stmt: TSESTree.Node, out: Set): void { + // `export const useFooBase_unstable = ...` / `export function useFooBase_unstable() {}` + if (stmt.type === AST_NODE_TYPES.ExportNamedDeclaration && stmt.declaration) { + collectBaseHookNames(stmt.declaration, out); + return; + } + // `function useFooBase_unstable() {}` + if (stmt.type === AST_NODE_TYPES.FunctionDeclaration) { + if (stmt.id && BASE_HOOK_NAME_PATTERN.test(stmt.id.name)) { + out.add(stmt.id.name); + } + return; + } + // `const useFooBase_unstable = ...` (incl. multi-declarator forms) + if (stmt.type === AST_NODE_TYPES.VariableDeclaration) { + for (const decl of stmt.declarations) { + if (decl.id.type === AST_NODE_TYPES.Identifier && BASE_HOOK_NAME_PATTERN.test(decl.id.name)) { + out.add(decl.id.name); + } + } + } +} + +/** + * Returns the function literal initializer of a `VariableDeclarator` when the declarator is a + * plain Identifier bound to an inline arrow/function expression; otherwise `undefined`. Skips + * destructuring patterns (no inspectable function literal) and non-function initializers. + */ +function getFunctionInit(node: TSESTree.VariableDeclarator): BaseHookFunction | undefined { + if (node.id.type !== AST_NODE_TYPES.Identifier) { + return undefined; + } + const init = node.init; + if ( + !init || + (init.type !== AST_NODE_TYPES.ArrowFunctionExpression && init.type !== AST_NODE_TYPES.FunctionExpression) + ) { + return undefined; + } + return init; +} + +/** + * Stateful helper that decides whether a wrapping state hook (`useFoo_unstable`) is paired with + * a sibling base hook (`useFooBase_unstable`) — either declared in the same file or as a sibling + * `.ts` / `.tsx` file in the same directory. The base hook is the structural marker; when found, + * the wrapping hook is required to honor the `(props, ref)` signature contract. + * + * Per-instance state: + * - `baseHooksInCurrentFile` is populated by the rule's `Program` visitor. + * - `siblingFileExistsCache` memoizes `fs.statSync` results so each component directory pays at + * most one syscall per linted run. + * + * Anchoring detection on the base hook eliminates false positives on other `_unstable` hooks + * (e.g. `useFooContextValues_unstable`, `useFooStyles_unstable`) that intentionally take + * different signatures. + */ +function createPairDetector(filename: string | undefined) { + const baseHooksInCurrentFile = new Set(); + const siblingFileExistsCache = new Map(); + + function hasPairedBaseHook(stateHookName: string): boolean { + const baseHookName = stateHookName.slice(0, -UNSTABLE_SUFFIX.length) + BASE_SUFFIX; + if (baseHooksInCurrentFile.has(baseHookName)) { + return true; + } + // ESLint passes synthetic filenames like `` for inline code; nothing to check. + if (!filename || !path.isAbsolute(filename)) { + return false; + } + const dir = path.dirname(filename); + // Sibling base-hook file (the wrapping hook lives in `useFoo.tsx`, the base in `useFooBase.tsx`). + const siblingBasename = baseHookName.slice(0, -UNSTABLE_SUFFIX.length); // e.g. `useFooBase` + for (const ext of SIBLING_EXTENSIONS) { + const candidate = path.join(dir, siblingBasename + ext); + if (candidate === filename) { + continue; + } + let exists = siblingFileExistsCache.get(candidate); + if (exists === undefined) { + try { + exists = fs.statSync(candidate).isFile(); + } catch { + exists = false; + } + siblingFileExistsCache.set(candidate, exists); + } + if (exists) { + return true; + } + } + return false; + } + + return { + baseHooksInCurrentFile, + hasPairedBaseHook, + }; +} + +/** + * Returns `true` when `annotation` is `React.Ref<...>` (qualified) or `Ref<...>` (named) AND the + * referenced identifier was imported from the `react` package in the surrounding scope. The scope + * check guards against false positives when a local `Ref` shadows the React import. + */ +function isReactRefTypeAnnotation( + annotation: TSESTree.TSTypeAnnotation | undefined, + scope: TSESLint.Scope.Scope, +): boolean { + if (!annotation) { + return false; + } + const type = annotation.typeAnnotation; + if (type.type !== AST_NODE_TYPES.TSTypeReference) { + return false; + } + const { typeName } = type; + if (typeName.type === AST_NODE_TYPES.Identifier) { + return typeName.name === 'Ref' && isReactImportedIdentifier(typeName, scope, 'Ref'); + } + if (typeName.type === AST_NODE_TYPES.TSQualifiedName) { + return ( + typeName.left.type === AST_NODE_TYPES.Identifier && + typeName.left.name === 'React' && + typeName.right.name === 'Ref' && + isReactImportedIdentifier(typeName.left, scope, '*') + ); + } + return false; +} + +/** + * Resolves the given identifier in `scope` and verifies it was imported from the `react` + * package. `expectedImportedName` is matched against the original import name: + * - a named-import specifier (e.g. `import { Ref } from 'react'`) must match the name, + * - a namespace/default import (e.g. `import * as React from 'react'`) matches `'*'`/`'default'`. + * + * Scope-based (no ParserServices required), so the rule still works without TypeScript type + * information. + */ +function isReactImportedIdentifier( + identifier: TSESTree.Identifier, + scope: TSESLint.Scope.Scope, + expectedImportedName: string, +): boolean { + const variable = findVariableInScope(scope, identifier.name); + if (!variable) { + return false; + } + return variable.defs.some(def => { + if (def.type !== 'ImportBinding') { + return false; + } + const importDecl = def.parent; + if (!importDecl || importDecl.type !== AST_NODE_TYPES.ImportDeclaration) { + return false; + } + if (importDecl.source.value !== 'react') { + return false; + } + const specifier = def.node; + switch (specifier.type) { + case AST_NODE_TYPES.ImportSpecifier: { + const importedName = + specifier.imported.type === AST_NODE_TYPES.Identifier + ? specifier.imported.name + : String(specifier.imported.value); + return importedName === expectedImportedName; + } + case AST_NODE_TYPES.ImportNamespaceSpecifier: + return expectedImportedName === '*'; + case AST_NODE_TYPES.ImportDefaultSpecifier: + // `import React from 'react'` is also a valid way to access `React.Ref`. + return expectedImportedName === '*' || expectedImportedName === 'default'; + default: + return false; + } + }); +} + +/** + * Walks the scope chain looking for a variable with the given name. Plain `scope.set.get` only + * inspects the local scope, so this helper enables identifier resolution that matches JavaScript's + * lookup semantics. + */ +function findVariableInScope(scope: TSESLint.Scope.Scope, name: string): TSESLint.Scope.Variable | undefined { + let current: TSESLint.Scope.Scope | null = scope; + while (current) { + const variable = current.set.get(name); + if (variable) { + return variable; + } + current = current.upper; + } + return undefined; +} + +/** + * Renders the actual ref type annotation as a string for `invalidRefType` diagnostics, so users + * see what they wrote (`HTMLAttributes`, `Ref`, `MyType`…) instead of bare AST node types. + */ +function describeRefType(annotation: TSESTree.TSTypeAnnotation | undefined): string { + if (!annotation) { + return ''; + } + const type = annotation.typeAnnotation; + if (type.type !== AST_NODE_TYPES.TSTypeReference) { + return type.type; + } + const { typeName } = type; + if (typeName.type === AST_NODE_TYPES.Identifier) { + return typeName.name; + } + if (typeName.type === AST_NODE_TYPES.TSQualifiedName) { + const left = typeName.left.type === AST_NODE_TYPES.Identifier ? typeName.left.name : '…'; + return `${left}.${typeName.right.name}`; + } + return type.type; +} diff --git a/tools/eslint-rules/tsconfig.lint.json b/tools/eslint-rules/tsconfig.lint.json index bb717c5e289e34..5215cb85923e4e 100644 --- a/tools/eslint-rules/tsconfig.lint.json +++ b/tools/eslint-rules/tsconfig.lint.json @@ -4,6 +4,6 @@ "outDir": "../../dist/out-tsc", "types": ["node"] }, - "exclude": ["**/*.spec.ts"], + "exclude": ["**/*.spec.ts", "**/__fixtures__/**"], "include": ["**/*.ts"] } From 726c21506b6728c65570b5543732456c8300bd77 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 26 May 2026 16:59:59 +0200 Subject: [PATCH 2/6] test(eslint-rules): make base-hook-signature fixture tree self-documenting Add docs-only Sibling/useSibling.ts and Orphan/useOrphan.ts stubs so the fixture folder layout mirrors the situations the tests assert against, and document in the spec why useSiblingBase.ts (must exist) and the absent useOrphanContextValuesBase.ts(x) (must NOT exist) are the only files that actually drive rule behavior. --- .../src/components/Orphan/useOrphan.ts | 13 +++++++++++++ .../src/components/Sibling/useSibling.ts | 12 ++++++++++++ .../rules/base-hook-signature.spec.ts | 15 +++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Orphan/useOrphan.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSibling.ts diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Orphan/useOrphan.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Orphan/useOrphan.ts new file mode 100644 index 00000000000000..c1af332b83d25b --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Orphan/useOrphan.ts @@ -0,0 +1,13 @@ +// Docs-only stub fixture for `base-hook-signature.spec.ts`. +// +// The rule never reads this file's contents — `RuleTester` always feeds source code in-memory. +// This file exists purely so the fixture tree mirrors a real component folder layout: +// +// Orphan/ +// └── useOrphan.ts ← virtual filename used by tests +// +// Crucially, there is NO `useOrphanContextValuesBase.ts(x)` next to this file. That absence is +// the whole point: tests that pass `filename: ORPHAN_FILENAME` assert "when no paired base +// hook exists, the contract does NOT apply" — i.e. `useOrphanContextValues_unstable(state)` +// is a legitimate non-wrapping hook and must not be flagged. +export {}; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSibling.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSibling.ts new file mode 100644 index 00000000000000..c711f7be7affe2 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-signature/src/components/Sibling/useSibling.ts @@ -0,0 +1,12 @@ +// Docs-only stub fixture for `base-hook-signature.spec.ts`. +// +// The rule never reads this file's contents — `RuleTester` always feeds source code in-memory. +// This file exists purely so the fixture tree mirrors a real component folder layout: +// +// Sibling/ +// ├── useSibling.ts ← virtual filename used by tests +// └── useSiblingBase.ts ← MUST exist; rule does `fs.statSync` to detect the pair +// +// Tests that pass `filename: SIBLING_FILENAME` assert "when a base hook exists next to me, +// my signature is enforced". +export {}; diff --git a/tools/eslint-rules/rules/base-hook-signature.spec.ts b/tools/eslint-rules/rules/base-hook-signature.spec.ts index 8dd4ed49e25a5a..1f4e7e7009fb49 100644 --- a/tools/eslint-rules/rules/base-hook-signature.spec.ts +++ b/tools/eslint-rules/rules/base-hook-signature.spec.ts @@ -3,6 +3,21 @@ import { RuleTester } from '@typescript-eslint/rule-tester'; import { rule, RULE_NAME } from './base-hook-signature'; const FIXTURE_ROOT = path.join(__dirname, '__fixtures__/base-hook-signature'); +// NOTE on fixture filenames below: +// `RuleTester` always lints source code provided in-memory via the `code` field — it never +// reads the file at `filename` from disk. The `filename` value is only used (a) as a label +// in error messages and (b) by rules that perform their OWN filesystem lookups relative to it. +// +// `base-hook-signature` does exactly that: given a state-hook file `useFoo.ts(x)`, it calls +// `fs.statSync` to check whether a sibling `useFooBase.ts(x)` exists in the same folder, and +// only enforces the contract when a pair is detected. +// +// So for the fixture tree under `__fixtures__/base-hook-signature/src/components/`: +// - The two stub files `Sibling/useSibling.ts` and `Orphan/useOrphan.ts` are docs-only — +// their existence does NOT affect any assertion (the rule never reads them). +// - What actually drives the test outcomes is the presence of `Sibling/useSiblingBase.ts` +// (pair detected → contract enforced) and the absence of +// `Orphan/useOrphanContextValuesBase.ts(x)` (no pair → contract NOT enforced). const SIBLING_FILENAME = path.join(FIXTURE_ROOT, 'src/components/Sibling/useSibling.ts'); const ORPHAN_FILENAME = path.join(FIXTURE_ROOT, 'src/components/Orphan/useOrphan.ts'); From 776e18c1fb6fc5ab1256ea64c6bbc2b5cd9453f7 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 26 May 2026 17:08:17 +0200 Subject: [PATCH 3/6] feat(eslint-rules): enforce explicit type annotation on base hook `props` param Untyped `props` would be inferred as `any` and fail under `noImplicitAny`. The rule now reports `missingPropsType` when a base/paired hook's first parameter has no type annotation. The shape of the type is intentionally not validated \u2014 just its presence. --- .../rules/base-hook-signature.spec.ts | 54 +++++++++++++------ .../eslint-rules/rules/base-hook-signature.ts | 16 +++++- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/tools/eslint-rules/rules/base-hook-signature.spec.ts b/tools/eslint-rules/rules/base-hook-signature.spec.ts index 1f4e7e7009fb49..9efe6b7c796054 100644 --- a/tools/eslint-rules/rules/base-hook-signature.spec.ts +++ b/tools/eslint-rules/rules/base-hook-signature.spec.ts @@ -29,7 +29,7 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import * as React from 'react'; - export const useThingBase_unstable = (props, ref: React.Ref) => { + export const useThingBase_unstable = (props: {}, ref: React.Ref) => { return { props, ref }; }; `, @@ -38,7 +38,7 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import { Ref } from 'react'; - export function useThingBase_unstable(props, ref: Ref) { + export function useThingBase_unstable(props: {}, ref: Ref) { return { props, ref }; } `, @@ -47,15 +47,15 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import React from 'react'; - export const useThingBase_unstable = (props, ref: React.Ref) => { + export const useThingBase_unstable = (props: {}, ref: React.Ref) => { return { props, ref }; }; `, }, - // Valid base hook with only \`props\` (ref is optional). + // Valid base hook with only \`props\` (ref is optional). \`props\` still needs a type annotation. { code: ` - export const useThingBase_unstable = (props) => { + export const useThingBase_unstable = (props: {}) => { return { props }; }; `, @@ -73,10 +73,10 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import * as React from 'react'; - export const useThing_unstable = (props, ref: React.Ref) => { + export const useThing_unstable = (props: {}, ref: React.Ref) => { return { props, ref }; }; - export const useThingBase_unstable = (props, ref: React.Ref) => { + export const useThingBase_unstable = (props: {}, ref: React.Ref) => { return { props, ref }; }; `, @@ -98,7 +98,7 @@ ruleTester.run(RULE_NAME, rule, { filename: SIBLING_FILENAME, code: ` import * as React from 'react'; - export const useSibling_unstable = (props, ref: React.Ref) => { + export const useSibling_unstable = (props: {}, ref: React.Ref) => { return { props, ref }; }; `, @@ -148,10 +148,11 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, - // \`ref\` parameter without a type annotation. + // \`ref\` parameter without a type annotation. \`props\` is typed so this case stays focused + // on the ref-type assertion (an untyped \`props\` would also trigger \`missingPropsType\`). { code: ` - export const useThingBase_unstable = (props, ref) => ({ props, ref }); + export const useThingBase_unstable = (props: {}, ref) => ({ props, ref }); `, errors: [ { @@ -163,7 +164,7 @@ ruleTester.run(RULE_NAME, rule, { // \`ref\` parameter typed as something other than React.Ref. { code: ` - export const useThingBase_unstable = (props, ref: HTMLElement) => ({ props, ref }); + export const useThingBase_unstable = (props: {}, ref: HTMLElement) => ({ props, ref }); `, errors: [ { @@ -175,7 +176,7 @@ ruleTester.run(RULE_NAME, rule, { // \`ref\` parameter typed as React.ForwardedRef (must be React.Ref). { code: ` - export const useThingBase_unstable = (props, ref: React.ForwardedRef) => ({ props, ref }); + export const useThingBase_unstable = (props: {}, ref: React.ForwardedRef) => ({ props, ref }); `, errors: [ { @@ -188,7 +189,7 @@ ruleTester.run(RULE_NAME, rule, { { code: ` type Ref = { current: T | null }; - export const useThingBase_unstable = (props, ref: Ref) => ({ props, ref }); + export const useThingBase_unstable = (props: {}, ref: Ref) => ({ props, ref }); `, errors: [ { @@ -201,7 +202,7 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import { Ref } from 'not-react'; - export const useThingBase_unstable = (props, ref: Ref) => ({ props, ref }); + export const useThingBase_unstable = (props: {}, ref: Ref) => ({ props, ref }); `, errors: [ { @@ -214,7 +215,7 @@ ruleTester.run(RULE_NAME, rule, { { code: ` const React = { Ref: null }; - export const useThingBase_unstable = (props, ref: React.Ref) => ({ props, ref }); + export const useThingBase_unstable = (props: {}, ref: React.Ref) => ({ props, ref }); `, errors: [ { @@ -224,12 +225,13 @@ ruleTester.run(RULE_NAME, rule, { ], }, // Pair detection (same file): wrapping state hook with too many params is flagged because - // its sibling base hook in the same file marks it as a paired wrapper. + // its sibling base hook in the same file marks it as a paired wrapper. The base hook itself + // is correctly typed so only the wrapping hook's error is asserted. { code: ` import * as React from 'react'; export const useThing_unstable = (props, ref, extra) => ({ props, ref, extra }); - export const useThingBase_unstable = (props, ref: React.Ref) => ({ props, ref }); + export const useThingBase_unstable = (props: {}, ref: React.Ref) => ({ props, ref }); `, errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThing_unstable', actual: 3 } }], }, @@ -252,5 +254,23 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + // \`props\` parameter without a type annotation (would be inferred as \`any\` and fail + // \`noImplicitAny\` under TS strict). Asserted on a base hook with only \`props\`. + { + code: ` + export const useThingBase_unstable = (props) => ({ props }); + `, + errors: [{ messageId: 'missingPropsType', data: { hookName: 'useThingBase_unstable' } }], + }, + // \`props\` without type annotation, even when a correctly-typed \`ref\` is present. + // Demonstrates that the \`props\`-type check short-circuits before the \`ref\` check, so the + // user sees the more fundamental problem first. + { + code: ` + import * as React from 'react'; + export const useThingBase_unstable = (props, ref: React.Ref) => ({ props, ref }); + `, + errors: [{ messageId: 'missingPropsType', data: { hookName: 'useThingBase_unstable' } }], + }, ], }); diff --git a/tools/eslint-rules/rules/base-hook-signature.ts b/tools/eslint-rules/rules/base-hook-signature.ts index 377c0272b0b053..2bbece2b29233f 100644 --- a/tools/eslint-rules/rules/base-hook-signature.ts +++ b/tools/eslint-rules/rules/base-hook-signature.ts @@ -37,7 +37,7 @@ type BaseHookFunction = TSESTree.FunctionDeclaration | TSESTree.FunctionExpressi type Options = []; -type MessageIds = 'invalidParamCount' | 'invalidParamName' | 'invalidRefType'; +type MessageIds = 'invalidParamCount' | 'invalidParamName' | 'invalidRefType' | 'missingPropsType'; export const rule = ESLintUtils.RuleCreator(() => __filename)({ name: RULE_NAME, @@ -45,7 +45,7 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)Base_unstable`) and their paired wrapping state hooks (`use_unstable` declared in the same file or a sibling component-folder file): a required `props` parameter and an optional `ref` parameter typed as `React.Ref<...>`.', + 'Enforce the API contract for v9 "base" hooks (`useBase_unstable`) and their paired wrapping state hooks (`use_unstable` declared in the same file or a sibling component-folder file): a required `props` parameter (with an explicit type annotation, otherwise TypeScript infers `any`) and an optional `ref` parameter typed as `React.Ref<...>`.', }, schema: [], messages: { @@ -53,6 +53,8 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)`, got `{{actual}}`.', }, }, @@ -86,6 +88,16 @@ export const rule = ESLintUtils.RuleCreator(() => __filename) Date: Tue, 26 May 2026 17:51:09 +0200 Subject: [PATCH 4/6] fix: address Copilot review feedback on base-hook-signature rule - Fix short-circuiting: use for loop instead of forEach to allow early returns after missingPropsType check, preventing simultaneous reporting of missingPropsType and invalidRefType - Add invalidBaseHookInit check: report errors for non-function base hook initializers (literals like 42, {}, []), while allowing valid re-exports (identifiers) - Distinguish between param-name errors (all reported) and type-annotation errors (short-circuit for fundamental issues) - Add describeInitializer() helper for user-facing error messages - Add test cases for invalid initializers (4 new invalid cases) and valid re-exports (2 new valid cases) - All 65 tests passing --- .../rules/base-hook-signature.spec.ts | 44 +++++++++++ .../eslint-rules/rules/base-hook-signature.ts | 76 ++++++++++++++++--- 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/tools/eslint-rules/rules/base-hook-signature.spec.ts b/tools/eslint-rules/rules/base-hook-signature.spec.ts index 9efe6b7c796054..da008cb4245569 100644 --- a/tools/eslint-rules/rules/base-hook-signature.spec.ts +++ b/tools/eslint-rules/rules/base-hook-signature.spec.ts @@ -103,6 +103,20 @@ ruleTester.run(RULE_NAME, rule, { }; `, }, + // Re-export of a base hook from another module is valid. We can't inspect the params + // of an identifier initializer, so we skip validation but accept it as a pairing marker. + { + code: ` + export const useThingBase_unstable = useThingBase; + `, + }, + // Re-export of an externally-imported base hook is also valid. + { + code: ` + import { useExternalBase_unstable } from 'external-lib'; + export const useThingBase_unstable = useExternalBase_unstable; + `, + }, ], invalid: [ // Too few params (0). @@ -272,5 +286,35 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [{ messageId: 'missingPropsType', data: { hookName: 'useThingBase_unstable' } }], }, + // Base hook initialized to a number literal is invalid. + { + code: ` + export const useThingBase_unstable = 42; + `, + errors: [{ messageId: 'invalidBaseHookInit', data: { hookName: 'useThingBase_unstable', actual: '42' } }], + }, + // Base hook initialized to an object literal is invalid. + { + code: ` + export const useThingBase_unstable = {}; + `, + errors: [{ messageId: 'invalidBaseHookInit', data: { hookName: 'useThingBase_unstable', actual: '{}' } }], + }, + // Base hook initialized to an array literal is invalid. + { + code: ` + export const useThingBase_unstable = []; + `, + errors: [{ messageId: 'invalidBaseHookInit', data: { hookName: 'useThingBase_unstable', actual: '[]' } }], + }, + // Base hook initialized to a string literal is invalid. + { + code: ` + export const useThingBase_unstable = "not-a-function"; + `, + errors: [ + { messageId: 'invalidBaseHookInit', data: { hookName: 'useThingBase_unstable', actual: '"not-a-function"' } }, + ], + }, ], }); diff --git a/tools/eslint-rules/rules/base-hook-signature.ts b/tools/eslint-rules/rules/base-hook-signature.ts index 2bbece2b29233f..ccd1fe29d18fba 100644 --- a/tools/eslint-rules/rules/base-hook-signature.ts +++ b/tools/eslint-rules/rules/base-hook-signature.ts @@ -37,7 +37,12 @@ type BaseHookFunction = TSESTree.FunctionDeclaration | TSESTree.FunctionExpressi type Options = []; -type MessageIds = 'invalidParamCount' | 'invalidParamName' | 'invalidRefType' | 'missingPropsType'; +type MessageIds = + | 'invalidParamCount' + | 'invalidParamName' + | 'invalidRefType' + | 'missingPropsType' + | 'invalidBaseHookInit'; export const rule = ESLintUtils.RuleCreator(() => __filename)({ name: RULE_NAME, @@ -56,6 +61,8 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)`, got `{{actual}}`.', + invalidBaseHookInit: + 'Base hook `{{hookName}}` must be a function declaration, function expression, arrow function, or a re-export of another function; got `{{actual}}`.', }, }, defaultOptions: [], @@ -78,7 +85,8 @@ export const rule = ESLintUtils.RuleCreator(() => __filename) { + for (let index = 0; index < hookFn.params.length; index++) { + const param = hookFn.params[index]; const expected = EXPECTED_PARAM_NAMES[index]; if (param.type !== AST_NODE_TYPES.Identifier || param.name !== expected) { context.report({ @@ -86,9 +94,9 @@ export const rule = ESLintUtils.RuleCreator(() => __filename) __filename) __filename) { - const init = getFunctionInit(node); - if (!init || node.id.type !== AST_NODE_TYPES.Identifier) { + if (node.id.type !== AST_NODE_TYPES.Identifier) { return; } const name = node.id.name; - if (BASE_HOOK_NAME_PATTERN.test(name) || pairDetector.hasPairedBaseHook(name)) { + const isBase = BASE_HOOK_NAME_PATTERN.test(name); + const init = getFunctionInit(node); + + // If this is a base hook, validate the initializer type. + // Valid: FunctionExpression, ArrowFunctionExpression (getFunctionInit accepts these), + // or Identifier (re-export; we can't inspect params but accept it). + // Invalid: literals like 42, {}, etc. (would have init !== undefined but fail getFunctionInit) + if (isBase && node.init) { + if ( + node.init.type !== AST_NODE_TYPES.ArrowFunctionExpression && + node.init.type !== AST_NODE_TYPES.FunctionExpression && + node.init.type !== AST_NODE_TYPES.Identifier + ) { + // Invalid initializer: not a function, not a re-export identifier + context.report({ + node: node.id, + messageId: 'invalidBaseHookInit', + data: { hookName: name, actual: describeInitializer(node.init) }, + }); + return; + } + } + + // Only validate parameters if we have an inline function (not a re-export). + if (!init) { + return; + } + if (isBase || pairDetector.hasPairedBaseHook(name)) { checkParameters(name, init, node.id); } }, @@ -397,3 +431,21 @@ function describeRefType(annotation: TSESTree.TSTypeAnnotation | undefined): str } return type.type; } + +/** + * Renders the actual initializer type as a string for `invalidBaseHookInit` diagnostics. + */ +function describeInitializer(node: TSESTree.Expression): string { + switch (node.type) { + case AST_NODE_TYPES.Literal: + return typeof node.value === 'string' ? `"${node.value}"` : String(node.value); + case AST_NODE_TYPES.ObjectExpression: + return '{}'; + case AST_NODE_TYPES.ArrayExpression: + return '[]'; + case AST_NODE_TYPES.Identifier: + return node.name; + default: + return node.type; + } +} From eae00e7a6ca75214bbb65a5d8766b0ed33887b5a Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 26 May 2026 18:00:20 +0200 Subject: [PATCH 5/6] test: update expectations for early-return validation order Parameters with wrong names now stop validation immediately (don't check subsequent params or types). Update tests to expect only first param-name error. --- tools/eslint-rules/rules/base-hook-signature.spec.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tools/eslint-rules/rules/base-hook-signature.spec.ts b/tools/eslint-rules/rules/base-hook-signature.spec.ts index da008cb4245569..ba1a75473685b9 100644 --- a/tools/eslint-rules/rules/base-hook-signature.spec.ts +++ b/tools/eslint-rules/rules/base-hook-signature.spec.ts @@ -143,10 +143,6 @@ ruleTester.run(RULE_NAME, rule, { messageId: 'invalidParamName', data: { hookName: 'useThingBase_unstable', index: 1, expected: 'props', actual: 'p' }, }, - { - messageId: 'invalidParamName', - data: { hookName: 'useThingBase_unstable', index: 2, expected: 'ref', actual: 'r' }, - }, ], }, // ObjectPattern for \`props\` is not allowed. @@ -250,7 +246,7 @@ ruleTester.run(RULE_NAME, rule, { errors: [{ messageId: 'invalidParamCount', data: { hookName: 'useThing_unstable', actual: 3 } }], }, // Pair detection (sibling file): wrapping state hook in `useSibling.ts` is paired with - // `useSiblingBase.ts` in the same folder. Wrong param names are flagged. + // `useSiblingBase.ts` in the same folder. Wrong param names are flagged (stops at first). { filename: SIBLING_FILENAME, code: ` @@ -262,10 +258,6 @@ ruleTester.run(RULE_NAME, rule, { messageId: 'invalidParamName', data: { hookName: 'useSibling_unstable', index: 1, expected: 'props', actual: 'p' }, }, - { - messageId: 'invalidParamName', - data: { hookName: 'useSibling_unstable', index: 2, expected: 'ref', actual: 'r' }, - }, ], }, // \`props\` parameter without a type annotation (would be inferred as \`any\` and fail From 44a998b3428029a51e9bd487dc9c7078f450bf78 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 26 May 2026 18:36:29 +0200 Subject: [PATCH 6/6] refactor: replace two-pass for-loops with direct destructured param checks Since the max param count is 2, loops add no value. Destructure [propsParam, refParam] directly and check each in sequence for clearer linear flow. --- .../eslint-rules/rules/base-hook-signature.ts | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/tools/eslint-rules/rules/base-hook-signature.ts b/tools/eslint-rules/rules/base-hook-signature.ts index ccd1fe29d18fba..b17d6b4a601216 100644 --- a/tools/eslint-rules/rules/base-hook-signature.ts +++ b/tools/eslint-rules/rules/base-hook-signature.ts @@ -74,6 +74,13 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)` (verified to originate * from the `react` package so collisions with same-named locals don't pass). + * + * Validation order: + * 1. Param count must be 1 or 2 + * 2. `props` name must be correct + * 3. `ref` name must be correct (if present) + * 4. `props` must have a type annotation + * 5. `ref` type must be React.Ref<...> (if present) */ function checkParameters(hookName: string, hookFn: BaseHookFunction, reportNode: TSESTree.Node): void { if (hookFn.params.length < MIN_PARAM_COUNT || hookFn.params.length > MAX_PARAM_COUNT) { @@ -85,34 +92,39 @@ export const rule = ESLintUtils.RuleCreator(() => __filename)