[eslint-plugin-react-hooks] updates for component syntax (#33089)

Adds support for Flow's component and hook syntax.
[docs](https://flow.org/en/docs/react/component-syntax/)
This commit is contained in:
Jan Kassens 2025-05-02 15:04:45 -04:00 committed by GitHub
parent dc2b11817b
commit 4c4a57c4f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 3152 additions and 11 deletions

View File

@ -34,7 +34,7 @@ function normalizeIndent(strings) {
// }
// ***************************************************
const tests = {
const allTests = {
valid: [
{
code: normalizeIndent`
@ -44,6 +44,25 @@ const tests = {
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Component syntax
component Button() {
useHook();
return <div>Button!</div>;
}
`,
},
{
syntax: 'flow',
code: normalizeIndent`
// Hook syntax
hook useSampleHook() {
useHook();
}
`,
},
{
code: normalizeIndent`
// Valid because components can use hooks.
@ -563,6 +582,28 @@ const tests = {
},
],
invalid: [
{
syntax: 'flow',
code: normalizeIndent`
component Button(cond: boolean) {
if (cond) {
useConditionalHook();
}
}
`,
errors: [conditionalError('useConditionalHook')],
},
{
syntax: 'flow',
code: normalizeIndent`
hook useTest(cond: boolean) {
if (cond) {
useConditionalHook();
}
}
`,
errors: [conditionalError('useConditionalHook')],
},
{
code: normalizeIndent`
// Invalid because it's dangerous and might not warn otherwise.
@ -1287,8 +1328,8 @@ const tests = {
};
if (__EXPERIMENTAL__) {
tests.valid = [
...tests.valid,
allTests.valid = [
...allTests.valid,
{
code: normalizeIndent`
// Valid because functions created with useEffectEvent can be called in a useEffect.
@ -1385,8 +1426,8 @@ if (__EXPERIMENTAL__) {
`,
},
];
tests.invalid = [
...tests.invalid,
allTests.invalid = [
...allTests.invalid,
{
code: normalizeIndent`
function MyComponent({ theme }) {
@ -1536,7 +1577,7 @@ function asyncComponentHookError(fn) {
if (!process.env.CI) {
let only = [];
let skipped = [];
[...tests.valid, ...tests.invalid].forEach(t => {
[...allTests.valid, ...allTests.invalid].forEach(t => {
if (t.skip) {
delete t.skip;
skipped.push(t);
@ -1555,10 +1596,23 @@ if (!process.env.CI) {
}
return true;
};
tests.valid = tests.valid.filter(predicate);
tests.invalid = tests.invalid.filter(predicate);
allTests.valid = allTests.valid.filter(predicate);
allTests.invalid = allTests.invalid.filter(predicate);
}
function filteredTests(predicate) {
return {
valid: allTests.valid.filter(predicate),
invalid: allTests.invalid.filter(predicate),
};
}
const flowTests = filteredTests(t => t.syntax == null || t.syntax === 'flow');
const tests = filteredTests(t => t.syntax !== 'flow');
allTests.valid.forEach(t => delete t.syntax);
allTests.invalid.forEach(t => delete t.syntax);
describe('rules-of-hooks/rules-of-hooks', () => {
const parserOptionsV7 = {
ecmaFeatures: {
@ -1594,6 +1648,25 @@ describe('rules-of-hooks/rules-of-hooks', () => {
tests
);
new ESLintTesterV7({
parser: require.resolve('hermes-eslint'),
parserOptions: {
sourceType: 'module',
enableExperimentalComponentSyntax: true,
},
}).run('eslint: v7, parser: hermes-eslint', ReactHooksESLintRule, flowTests);
new ESLintTesterV9({
languageOptions: {
...languageOptionsV9,
parser: require('hermes-eslint'),
parserOptions: {
sourceType: 'module',
enableExperimentalComponentSyntax: true,
},
},
}).run('eslint: v9, parser: hermes-eslint', ReactHooksESLintRule, flowTests);
new ESLintTesterV7({
parser: require.resolve('@typescript-eslint/parser-v2'),
parserOptions: parserOptionsV7,

View File

@ -0,0 +1,19 @@
Copyright OpenJS Foundation and other contributors, <www.openjsf.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,6 @@
# Code Path Analyzer
This code is a forked version of ESLints Code Path Analyzer which includes
support for Component Syntax.
Forked from: https://github.com/eslint/eslint/tree/main/lib/linter/code-path-analysis

View File

@ -0,0 +1,9 @@
'use strict';
function assert(cond) {
if (!cond) {
throw new Error('Assertion violated.');
}
}
module.exports = assert;

View File

@ -0,0 +1,802 @@
'use strict';
/* eslint-disable react-internal/no-primitive-constructors */
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
// eslint-disable-next-line
const assert = require('./assert');
// eslint-disable-next-line
const CodePath = require('./code-path');
// eslint-disable-next-line
const CodePathSegment = require('./code-path-segment');
// eslint-disable-next-line
const IdGenerator = require('./id-generator');
const breakableTypePattern =
/^(?:(?:Do)?While|For(?:In|Of)?|Switch)Statement$/u;
//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------
/**
* Checks whether or not a given node is a `case` node (not `default` node).
* @param {ASTNode} node A `SwitchCase` node to check.
* @returns {boolean} `true` if the node is a `case` node (not `default` node).
*/
function isCaseNode(node) {
return Boolean(node.test);
}
/**
* Checks if a given node appears as the value of a PropertyDefinition node.
* @param {ASTNode} node THe node to check.
* @returns {boolean} `true` if the node is a PropertyDefinition value,
* false if not.
*/
function isPropertyDefinitionValue(node) {
const parent = node.parent;
return (
parent && parent.type === 'PropertyDefinition' && parent.value === node
);
}
/**
* Checks whether the given logical operator is taken into account for the code
* path analysis.
* @param {string} operator The operator found in the LogicalExpression node
* @returns {boolean} `true` if the operator is "&&" or "||" or "??"
*/
function isHandledLogicalOperator(operator) {
return operator === '&&' || operator === '||' || operator === '??';
}
/**
* Checks whether the given assignment operator is a logical assignment operator.
* Logical assignments are taken into account for the code path analysis
* because of their short-circuiting semantics.
* @param {string} operator The operator found in the AssignmentExpression node
* @returns {boolean} `true` if the operator is "&&=" or "||=" or "??="
*/
function isLogicalAssignmentOperator(operator) {
return operator === '&&=' || operator === '||=' || operator === '??=';
}
/**
* Gets the label if the parent node of a given node is a LabeledStatement.
* @param {ASTNode} node A node to get.
* @returns {string|null} The label or `null`.
*/
function getLabel(node) {
if (node.parent.type === 'LabeledStatement') {
return node.parent.label.name;
}
return null;
}
/**
* Checks whether or not a given logical expression node goes different path
* between the `true` case and the `false` case.
* @param {ASTNode} node A node to check.
* @returns {boolean} `true` if the node is a test of a choice statement.
*/
function isForkingByTrueOrFalse(node) {
const parent = node.parent;
switch (parent.type) {
case 'ConditionalExpression':
case 'IfStatement':
case 'WhileStatement':
case 'DoWhileStatement':
case 'ForStatement':
return parent.test === node;
case 'LogicalExpression':
return isHandledLogicalOperator(parent.operator);
case 'AssignmentExpression':
return isLogicalAssignmentOperator(parent.operator);
default:
return false;
}
}
/**
* Gets the boolean value of a given literal node.
*
* This is used to detect infinity loops (e.g. `while (true) {}`).
* Statements preceded by an infinity loop are unreachable if the loop didn't
* have any `break` statement.
* @param {ASTNode} node A node to get.
* @returns {boolean|undefined} a boolean value if the node is a Literal node,
* otherwise `undefined`.
*/
function getBooleanValueIfSimpleConstant(node) {
if (node.type === 'Literal') {
return Boolean(node.value);
}
return void 0;
}
/**
* Checks that a given identifier node is a reference or not.
*
* This is used to detect the first throwable node in a `try` block.
* @param {ASTNode} node An Identifier node to check.
* @returns {boolean} `true` if the node is a reference.
*/
function isIdentifierReference(node) {
const parent = node.parent;
switch (parent.type) {
case 'LabeledStatement':
case 'BreakStatement':
case 'ContinueStatement':
case 'ArrayPattern':
case 'RestElement':
case 'ImportSpecifier':
case 'ImportDefaultSpecifier':
case 'ImportNamespaceSpecifier':
case 'CatchClause':
return false;
case 'FunctionDeclaration':
case 'ComponentDeclaration':
case 'HookDeclaration':
case 'FunctionExpression':
case 'ArrowFunctionExpression':
case 'ClassDeclaration':
case 'ClassExpression':
case 'VariableDeclarator':
return parent.id !== node;
case 'Property':
case 'PropertyDefinition':
case 'MethodDefinition':
return parent.key !== node || parent.computed || parent.shorthand;
case 'AssignmentPattern':
return parent.key !== node;
default:
return true;
}
}
/**
* Updates the current segment with the head segment.
* This is similar to local branches and tracking branches of git.
*
* To separate the current and the head is in order to not make useless segments.
*
* In this process, both "onCodePathSegmentStart" and "onCodePathSegmentEnd"
* events are fired.
* @param {CodePathAnalyzer} analyzer The instance.
* @param {ASTNode} node The current AST node.
* @returns {void}
*/
function forwardCurrentToHead(analyzer, node) {
const codePath = analyzer.codePath;
const state = CodePath.getState(codePath);
const currentSegments = state.currentSegments;
const headSegments = state.headSegments;
const end = Math.max(currentSegments.length, headSegments.length);
let i, currentSegment, headSegment;
// Fires leaving events.
for (i = 0; i < end; ++i) {
currentSegment = currentSegments[i];
headSegment = headSegments[i];
if (currentSegment !== headSegment && currentSegment) {
if (currentSegment.reachable) {
analyzer.emitter.emit('onCodePathSegmentEnd', currentSegment, node);
}
}
}
// Update state.
state.currentSegments = headSegments;
// Fires entering events.
for (i = 0; i < end; ++i) {
currentSegment = currentSegments[i];
headSegment = headSegments[i];
if (currentSegment !== headSegment && headSegment) {
CodePathSegment.markUsed(headSegment);
if (headSegment.reachable) {
analyzer.emitter.emit('onCodePathSegmentStart', headSegment, node);
}
}
}
}
/**
* Updates the current segment with empty.
* This is called at the last of functions or the program.
* @param {CodePathAnalyzer} analyzer The instance.
* @param {ASTNode} node The current AST node.
* @returns {void}
*/
function leaveFromCurrentSegment(analyzer, node) {
const state = CodePath.getState(analyzer.codePath);
const currentSegments = state.currentSegments;
for (let i = 0; i < currentSegments.length; ++i) {
const currentSegment = currentSegments[i];
if (currentSegment.reachable) {
analyzer.emitter.emit('onCodePathSegmentEnd', currentSegment, node);
}
}
state.currentSegments = [];
}
/**
* Updates the code path due to the position of a given node in the parent node
* thereof.
*
* For example, if the node is `parent.consequent`, this creates a fork from the
* current path.
* @param {CodePathAnalyzer} analyzer The instance.
* @param {ASTNode} node The current AST node.
* @returns {void}
*/
function preprocess(analyzer, node) {
const codePath = analyzer.codePath;
const state = CodePath.getState(codePath);
const parent = node.parent;
switch (parent.type) {
// The `arguments.length == 0` case is in `postprocess` function.
case 'CallExpression':
if (
parent.optional === true &&
parent.arguments.length >= 1 &&
parent.arguments[0] === node
) {
state.makeOptionalRight();
}
break;
case 'MemberExpression':
if (parent.optional === true && parent.property === node) {
state.makeOptionalRight();
}
break;
case 'LogicalExpression':
if (parent.right === node && isHandledLogicalOperator(parent.operator)) {
state.makeLogicalRight();
}
break;
case 'AssignmentExpression':
if (
parent.right === node &&
isLogicalAssignmentOperator(parent.operator)
) {
state.makeLogicalRight();
}
break;
case 'ConditionalExpression':
case 'IfStatement':
/*
* Fork if this node is at `consequent`/`alternate`.
* `popForkContext()` exists at `IfStatement:exit` and
* `ConditionalExpression:exit`.
*/
if (parent.consequent === node) {
state.makeIfConsequent();
} else if (parent.alternate === node) {
state.makeIfAlternate();
}
break;
case 'SwitchCase':
if (parent.consequent[0] === node) {
state.makeSwitchCaseBody(false, !parent.test);
}
break;
case 'TryStatement':
if (parent.handler === node) {
state.makeCatchBlock();
} else if (parent.finalizer === node) {
state.makeFinallyBlock();
}
break;
case 'WhileStatement':
if (parent.test === node) {
state.makeWhileTest(getBooleanValueIfSimpleConstant(node));
} else {
assert(parent.body === node);
state.makeWhileBody();
}
break;
case 'DoWhileStatement':
if (parent.body === node) {
state.makeDoWhileBody();
} else {
assert(parent.test === node);
state.makeDoWhileTest(getBooleanValueIfSimpleConstant(node));
}
break;
case 'ForStatement':
if (parent.test === node) {
state.makeForTest(getBooleanValueIfSimpleConstant(node));
} else if (parent.update === node) {
state.makeForUpdate();
} else if (parent.body === node) {
state.makeForBody();
}
break;
case 'ForInStatement':
case 'ForOfStatement':
if (parent.left === node) {
state.makeForInOfLeft();
} else if (parent.right === node) {
state.makeForInOfRight();
} else {
assert(parent.body === node);
state.makeForInOfBody();
}
break;
case 'AssignmentPattern':
/*
* Fork if this node is at `right`.
* `left` is executed always, so it uses the current path.
* `popForkContext()` exists at `AssignmentPattern:exit`.
*/
if (parent.right === node) {
state.pushForkContext();
state.forkBypassPath();
state.forkPath();
}
break;
default:
break;
}
}
/**
* Updates the code path due to the type of a given node in entering.
* @param {CodePathAnalyzer} analyzer The instance.
* @param {ASTNode} node The current AST node.
* @returns {void}
*/
function processCodePathToEnter(analyzer, node) {
let codePath = analyzer.codePath;
let state = codePath && CodePath.getState(codePath);
const parent = node.parent;
/**
* Creates a new code path and trigger the onCodePathStart event
* based on the currently selected node.
* @param {string} origin The reason the code path was started.
* @returns {void}
*/
function startCodePath(origin) {
if (codePath) {
// Emits onCodePathSegmentStart events if updated.
forwardCurrentToHead(analyzer, node);
}
// Create the code path of this scope.
codePath = analyzer.codePath = new CodePath({
id: analyzer.idGenerator.next(),
origin,
upper: codePath,
onLooped: analyzer.onLooped,
});
state = CodePath.getState(codePath);
// Emits onCodePathStart events.
analyzer.emitter.emit('onCodePathStart', codePath, node);
}
/*
* Special case: The right side of class field initializer is considered
* to be its own function, so we need to start a new code path in this
* case.
*/
if (isPropertyDefinitionValue(node)) {
startCodePath('class-field-initializer');
/*
* Intentional fall through because `node` needs to also be
* processed by the code below. For example, if we have:
*
* class Foo {
* a = () => {}
* }
*
* In this case, we also need start a second code path.
*/
}
switch (node.type) {
case 'Program':
startCodePath('program');
break;
case 'FunctionDeclaration':
case 'ComponentDeclaration':
case 'HookDeclaration':
case 'FunctionExpression':
case 'ArrowFunctionExpression':
startCodePath('function');
break;
case 'StaticBlock':
startCodePath('class-static-block');
break;
case 'ChainExpression':
state.pushChainContext();
break;
case 'CallExpression':
if (node.optional === true) {
state.makeOptionalNode();
}
break;
case 'MemberExpression':
if (node.optional === true) {
state.makeOptionalNode();
}
break;
case 'LogicalExpression':
if (isHandledLogicalOperator(node.operator)) {
state.pushChoiceContext(node.operator, isForkingByTrueOrFalse(node));
}
break;
case 'AssignmentExpression':
if (isLogicalAssignmentOperator(node.operator)) {
state.pushChoiceContext(
node.operator.slice(0, -1), // removes `=` from the end
isForkingByTrueOrFalse(node),
);
}
break;
case 'ConditionalExpression':
case 'IfStatement':
state.pushChoiceContext('test', false);
break;
case 'SwitchStatement':
state.pushSwitchContext(node.cases.some(isCaseNode), getLabel(node));
break;
case 'TryStatement':
state.pushTryContext(Boolean(node.finalizer));
break;
case 'SwitchCase':
/*
* Fork if this node is after the 2st node in `cases`.
* It's similar to `else` blocks.
* The next `test` node is processed in this path.
*/
if (parent.discriminant !== node && parent.cases[0] !== node) {
state.forkPath();
}
break;
case 'WhileStatement':
case 'DoWhileStatement':
case 'ForStatement':
case 'ForInStatement':
case 'ForOfStatement':
state.pushLoopContext(node.type, getLabel(node));
break;
case 'LabeledStatement':
if (!breakableTypePattern.test(node.body.type)) {
state.pushBreakContext(false, node.label.name);
}
break;
default:
break;
}
// Emits onCodePathSegmentStart events if updated.
forwardCurrentToHead(analyzer, node);
}
/**
* Updates the code path due to the type of a given node in leaving.
* @param {CodePathAnalyzer} analyzer The instance.
* @param {ASTNode} node The current AST node.
* @returns {void}
*/
function processCodePathToExit(analyzer, node) {
const codePath = analyzer.codePath;
const state = CodePath.getState(codePath);
let dontForward = false;
switch (node.type) {
case 'ChainExpression':
state.popChainContext();
break;
case 'IfStatement':
case 'ConditionalExpression':
state.popChoiceContext();
break;
case 'LogicalExpression':
if (isHandledLogicalOperator(node.operator)) {
state.popChoiceContext();
}
break;
case 'AssignmentExpression':
if (isLogicalAssignmentOperator(node.operator)) {
state.popChoiceContext();
}
break;
case 'SwitchStatement':
state.popSwitchContext();
break;
case 'SwitchCase':
/*
* This is the same as the process at the 1st `consequent` node in
* `preprocess` function.
* Must do if this `consequent` is empty.
*/
if (node.consequent.length === 0) {
state.makeSwitchCaseBody(true, !node.test);
}
if (state.forkContext.reachable) {
dontForward = true;
}
break;
case 'TryStatement':
state.popTryContext();
break;
case 'BreakStatement':
forwardCurrentToHead(analyzer, node);
state.makeBreak(node.label && node.label.name);
dontForward = true;
break;
case 'ContinueStatement':
forwardCurrentToHead(analyzer, node);
state.makeContinue(node.label && node.label.name);
dontForward = true;
break;
case 'ReturnStatement':
forwardCurrentToHead(analyzer, node);
state.makeReturn();
dontForward = true;
break;
case 'ThrowStatement':
forwardCurrentToHead(analyzer, node);
state.makeThrow();
dontForward = true;
break;
case 'Identifier':
if (isIdentifierReference(node)) {
state.makeFirstThrowablePathInTryBlock();
dontForward = true;
}
break;
case 'CallExpression':
case 'ImportExpression':
case 'MemberExpression':
case 'NewExpression':
case 'YieldExpression':
state.makeFirstThrowablePathInTryBlock();
break;
case 'WhileStatement':
case 'DoWhileStatement':
case 'ForStatement':
case 'ForInStatement':
case 'ForOfStatement':
state.popLoopContext();
break;
case 'AssignmentPattern':
state.popForkContext();
break;
case 'LabeledStatement':
if (!breakableTypePattern.test(node.body.type)) {
state.popBreakContext();
}
break;
default:
break;
}
// Emits onCodePathSegmentStart events if updated.
if (!dontForward) {
forwardCurrentToHead(analyzer, node);
}
}
/**
* Updates the code path to finalize the current code path.
* @param {CodePathAnalyzer} analyzer The instance.
* @param {ASTNode} node The current AST node.
* @returns {void}
*/
function postprocess(analyzer, node) {
/**
* Ends the code path for the current node.
* @returns {void}
*/
function endCodePath() {
let codePath = analyzer.codePath;
// Mark the current path as the final node.
CodePath.getState(codePath).makeFinal();
// Emits onCodePathSegmentEnd event of the current segments.
leaveFromCurrentSegment(analyzer, node);
// Emits onCodePathEnd event of this code path.
analyzer.emitter.emit('onCodePathEnd', codePath, node);
codePath = analyzer.codePath = analyzer.codePath.upper;
}
switch (node.type) {
case 'Program':
case 'FunctionDeclaration':
case 'ComponentDeclaration':
case 'HookDeclaration':
case 'FunctionExpression':
case 'ArrowFunctionExpression':
case 'StaticBlock': {
endCodePath();
break;
}
// The `arguments.length >= 1` case is in `preprocess` function.
case 'CallExpression':
if (node.optional === true && node.arguments.length === 0) {
CodePath.getState(analyzer.codePath).makeOptionalRight();
}
break;
default:
break;
}
/*
* Special case: The right side of class field initializer is considered
* to be its own function, so we need to end a code path in this
* case.
*
* We need to check after the other checks in order to close the
* code paths in the correct order for code like this:
*
*
* class Foo {
* a = () => {}
* }
*
* In this case, The ArrowFunctionExpression code path is closed first
* and then we need to close the code path for the PropertyDefinition
* value.
*/
if (isPropertyDefinitionValue(node)) {
endCodePath();
}
}
//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------
/**
* The class to analyze code paths.
* This class implements the EventGenerator interface.
*/
class CodePathAnalyzer {
/**
* @param {EventGenerator} eventGenerator An event generator to wrap.
*/
constructor(emitters) {
this.emitter = {
emit(event, ...args) {
emitters[event]?.(...args);
},
};
this.codePath = null;
this.idGenerator = new IdGenerator('s');
this.currentNode = null;
this.onLooped = this.onLooped.bind(this);
}
/**
* Does the process to enter a given AST node.
* This updates state of analysis and calls `enterNode` of the wrapped.
* @param {ASTNode} node A node which is entering.
* @returns {void}
*/
enterNode(node) {
this.currentNode = node;
// Updates the code path due to node's position in its parent node.
if (node.parent) {
preprocess(this, node);
}
/*
* Updates the code path.
* And emits onCodePathStart/onCodePathSegmentStart events.
*/
processCodePathToEnter(this, node);
this.currentNode = null;
}
/**
* Does the process to leave a given AST node.
* This updates state of analysis and calls `leaveNode` of the wrapped.
* @param {ASTNode} node A node which is leaving.
* @returns {void}
*/
leaveNode(node) {
this.currentNode = node;
/*
* Updates the code path.
* And emits onCodePathStart/onCodePathSegmentStart events.
*/
processCodePathToExit(this, node);
// Emits the last onCodePathStart/onCodePathSegmentStart events.
postprocess(this, node);
this.currentNode = null;
}
/**
* This is called on a code path looped.
* Then this raises a looped event.
* @param {CodePathSegment} fromSegment A segment of prev.
* @param {CodePathSegment} toSegment A segment of next.
* @returns {void}
*/
onLooped(fromSegment, toSegment) {
if (fromSegment.reachable && toSegment.reachable) {
this.emitter.emit(
'onCodePathSegmentLoop',
fromSegment,
toSegment,
this.currentNode,
);
}
}
}
module.exports = CodePathAnalyzer;

View File

@ -0,0 +1,225 @@
'use strict';
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------
/**
* Checks whether or not a given segment is reachable.
* @param {CodePathSegment} segment A segment to check.
* @returns {boolean} `true` if the segment is reachable.
*/
function isReachable(segment) {
return segment.reachable;
}
//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------
/**
* A code path segment.
*/
class CodePathSegment {
/**
* @param {string} id An identifier.
* @param {CodePathSegment[]} allPrevSegments An array of the previous segments.
* This array includes unreachable segments.
* @param {boolean} reachable A flag which shows this is reachable.
*/
constructor(id, allPrevSegments, reachable) {
/**
* The identifier of this code path.
* Rules use it to store additional information of each rule.
* @type {string}
*/
this.id = id;
/**
* An array of the next segments.
* @type {CodePathSegment[]}
*/
this.nextSegments = [];
/**
* An array of the previous segments.
* @type {CodePathSegment[]}
*/
this.prevSegments = allPrevSegments.filter(isReachable);
/**
* An array of the next segments.
* This array includes unreachable segments.
* @type {CodePathSegment[]}
*/
this.allNextSegments = [];
/**
* An array of the previous segments.
* This array includes unreachable segments.
* @type {CodePathSegment[]}
*/
this.allPrevSegments = allPrevSegments;
/**
* A flag which shows this is reachable.
* @type {boolean}
*/
this.reachable = reachable;
// Internal data.
Object.defineProperty(this, 'internal', {
value: {
used: false,
loopedPrevSegments: [],
},
});
}
/**
* Checks a given previous segment is coming from the end of a loop.
* @param {CodePathSegment} segment A previous segment to check.
* @returns {boolean} `true` if the segment is coming from the end of a loop.
*/
isLoopedPrevSegment(segment) {
return this.internal.loopedPrevSegments.includes(segment);
}
/**
* Creates the root segment.
* @param {string} id An identifier.
* @returns {CodePathSegment} The created segment.
*/
static newRoot(id) {
return new CodePathSegment(id, [], true);
}
/**
* Creates a segment that follows given segments.
* @param {string} id An identifier.
* @param {CodePathSegment[]} allPrevSegments An array of the previous segments.
* @returns {CodePathSegment} The created segment.
*/
static newNext(id, allPrevSegments) {
return new CodePathSegment(
id,
CodePathSegment.flattenUnusedSegments(allPrevSegments),
allPrevSegments.some(isReachable),
);
}
/**
* Creates an unreachable segment that follows given segments.
* @param {string} id An identifier.
* @param {CodePathSegment[]} allPrevSegments An array of the previous segments.
* @returns {CodePathSegment} The created segment.
*/
static newUnreachable(id, allPrevSegments) {
const segment = new CodePathSegment(
id,
CodePathSegment.flattenUnusedSegments(allPrevSegments),
false,
);
/*
* In `if (a) return a; foo();` case, the unreachable segment preceded by
* the return statement is not used but must not be remove.
*/
CodePathSegment.markUsed(segment);
return segment;
}
/**
* Creates a segment that follows given segments.
* This factory method does not connect with `allPrevSegments`.
* But this inherits `reachable` flag.
* @param {string} id An identifier.
* @param {CodePathSegment[]} allPrevSegments An array of the previous segments.
* @returns {CodePathSegment} The created segment.
*/
static newDisconnected(id, allPrevSegments) {
return new CodePathSegment(id, [], allPrevSegments.some(isReachable));
}
/**
* Makes a given segment being used.
*
* And this function registers the segment into the previous segments as a next.
* @param {CodePathSegment} segment A segment to mark.
* @returns {void}
*/
static markUsed(segment) {
if (segment.internal.used) {
return;
}
segment.internal.used = true;
let i;
if (segment.reachable) {
for (i = 0; i < segment.allPrevSegments.length; ++i) {
const prevSegment = segment.allPrevSegments[i];
prevSegment.allNextSegments.push(segment);
prevSegment.nextSegments.push(segment);
}
} else {
for (i = 0; i < segment.allPrevSegments.length; ++i) {
segment.allPrevSegments[i].allNextSegments.push(segment);
}
}
}
/**
* Marks a previous segment as looped.
* @param {CodePathSegment} segment A segment.
* @param {CodePathSegment} prevSegment A previous segment to mark.
* @returns {void}
*/
static markPrevSegmentAsLooped(segment, prevSegment) {
segment.internal.loopedPrevSegments.push(prevSegment);
}
/**
* Replaces unused segments with the previous segments of each unused segment.
* @param {CodePathSegment[]} segments An array of segments to replace.
* @returns {CodePathSegment[]} The replaced array.
*/
static flattenUnusedSegments(segments) {
const done = Object.create(null);
const retv = [];
for (let i = 0; i < segments.length; ++i) {
const segment = segments[i];
// Ignores duplicated.
if (done[segment.id]) {
continue;
}
// Use previous segments if unused.
if (!segment.internal.used) {
for (let j = 0; j < segment.allPrevSegments.length; ++j) {
const prevSegment = segment.allPrevSegments[j];
if (!done[prevSegment.id]) {
done[prevSegment.id] = true;
retv.push(prevSegment);
}
}
} else {
done[segment.id] = true;
retv.push(segment);
}
}
return retv;
}
}
module.exports = CodePathSegment;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,239 @@
'use strict';
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
// eslint-disable-next-line
const CodePathState = require('./code-path-state');
// eslint-disable-next-line
const IdGenerator = require('./id-generator');
//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------
/**
* A code path.
*/
class CodePath {
/**
* Creates a new instance.
* @param {Object} options Options for the function (see below).
* @param {string} options.id An identifier.
* @param {string} options.origin The type of code path origin.
* @param {CodePath|null} options.upper The code path of the upper function scope.
* @param {Function} options.onLooped A callback function to notify looping.
*/
constructor({id, origin, upper, onLooped}) {
/**
* The identifier of this code path.
* Rules use it to store additional information of each rule.
* @type {string}
*/
this.id = id;
/**
* The reason that this code path was started. May be "program",
* "function", "class-field-initializer", or "class-static-block".
* @type {string}
*/
this.origin = origin;
/**
* The code path of the upper function scope.
* @type {CodePath|null}
*/
this.upper = upper;
/**
* The code paths of nested function scopes.
* @type {CodePath[]}
*/
this.childCodePaths = [];
// Initializes internal state.
Object.defineProperty(this, 'internal', {
value: new CodePathState(new IdGenerator(`${id}_`), onLooped),
});
// Adds this into `childCodePaths` of `upper`.
if (upper) {
upper.childCodePaths.push(this);
}
}
/**
* Gets the state of a given code path.
* @param {CodePath} codePath A code path to get.
* @returns {CodePathState} The state of the code path.
*/
static getState(codePath) {
return codePath.internal;
}
/**
* The initial code path segment.
* @type {CodePathSegment}
*/
get initialSegment() {
return this.internal.initialSegment;
}
/**
* Final code path segments.
* This array is a mix of `returnedSegments` and `thrownSegments`.
* @type {CodePathSegment[]}
*/
get finalSegments() {
return this.internal.finalSegments;
}
/**
* Final code path segments which is with `return` statements.
* This array contains the last path segment if it's reachable.
* Since the reachable last path returns `undefined`.
* @type {CodePathSegment[]}
*/
get returnedSegments() {
return this.internal.returnedForkContext;
}
/**
* Final code path segments which is with `throw` statements.
* @type {CodePathSegment[]}
*/
get thrownSegments() {
return this.internal.thrownForkContext;
}
/**
* Current code path segments.
* @type {CodePathSegment[]}
*/
get currentSegments() {
return this.internal.currentSegments;
}
/**
* Traverses all segments in this code path.
*
* codePath.traverseSegments(function(segment, controller) {
* // do something.
* });
*
* This method enumerates segments in order from the head.
*
* The `controller` object has two methods.
*
* - `controller.skip()` - Skip the following segments in this branch.
* - `controller.break()` - Skip all following segments.
* @param {Object} [options] Omittable.
* @param {CodePathSegment} [options.first] The first segment to traverse.
* @param {CodePathSegment} [options.last] The last segment to traverse.
* @param {Function} callback A callback function.
* @returns {void}
*/
traverseSegments(options, callback) {
let resolvedOptions;
let resolvedCallback;
if (typeof options === 'function') {
resolvedCallback = options;
resolvedOptions = {};
} else {
resolvedOptions = options || {};
resolvedCallback = callback;
}
const startSegment = resolvedOptions.first || this.internal.initialSegment;
const lastSegment = resolvedOptions.last;
let item = null;
let index = 0;
let end = 0;
let segment = null;
const visited = Object.create(null);
const stack = [[startSegment, 0]];
let skippedSegment = null;
let broken = false;
const controller = {
skip() {
if (stack.length <= 1) {
broken = true;
} else {
skippedSegment = stack[stack.length - 2][0];
}
},
break() {
broken = true;
},
};
/**
* Checks a given previous segment has been visited.
* @param {CodePathSegment} prevSegment A previous segment to check.
* @returns {boolean} `true` if the segment has been visited.
*/
function isVisited(prevSegment) {
return (
visited[prevSegment.id] || segment.isLoopedPrevSegment(prevSegment)
);
}
while (stack.length > 0) {
item = stack[stack.length - 1];
segment = item[0];
index = item[1];
if (index === 0) {
// Skip if this segment has been visited already.
if (visited[segment.id]) {
stack.pop();
continue;
}
// Skip if all previous segments have not been visited.
if (
segment !== startSegment &&
segment.prevSegments.length > 0 &&
!segment.prevSegments.every(isVisited)
) {
stack.pop();
continue;
}
// Reset the flag of skipping if all branches have been skipped.
if (skippedSegment && segment.prevSegments.includes(skippedSegment)) {
skippedSegment = null;
}
visited[segment.id] = true;
// Call the callback when the first time.
if (!skippedSegment) {
resolvedCallback.call(this, segment, controller);
if (segment === lastSegment) {
controller.skip();
}
if (broken) {
break;
}
}
}
// Update the stack.
end = segment.nextSegments.length - 1;
if (index < end) {
item[1] += 1;
stack.push([segment.nextSegments[index], 0]);
} else if (index === end) {
item[0] = segment.nextSegments[index];
item[1] = 0;
} else {
stack.pop();
}
}
}
}
module.exports = CodePath;

View File

@ -0,0 +1,252 @@
'use strict';
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
// eslint-disable-next-line
const assert = require('./assert');
// eslint-disable-next-line
const CodePathSegment = require('./code-path-segment');
//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------
/**
* Gets whether or not a given segment is reachable.
* @param {CodePathSegment} segment A segment to get.
* @returns {boolean} `true` if the segment is reachable.
*/
function isReachable(segment) {
return segment.reachable;
}
/**
* Creates new segments from the specific range of `context.segmentsList`.
*
* When `context.segmentsList` is `[[a, b], [c, d], [e, f]]`, `begin` is `0`, and
* `end` is `-1`, this creates `[g, h]`. This `g` is from `a`, `c`, and `e`.
* This `h` is from `b`, `d`, and `f`.
* @param {ForkContext} context An instance.
* @param {number} begin The first index of the previous segments.
* @param {number} end The last index of the previous segments.
* @param {Function} create A factory function of new segments.
* @returns {CodePathSegment[]} New segments.
*/
function makeSegments(context, begin, end, create) {
const list = context.segmentsList;
const normalizedBegin = begin >= 0 ? begin : list.length + begin;
const normalizedEnd = end >= 0 ? end : list.length + end;
const segments = [];
for (let i = 0; i < context.count; ++i) {
const allPrevSegments = [];
for (let j = normalizedBegin; j <= normalizedEnd; ++j) {
allPrevSegments.push(list[j][i]);
}
segments.push(create(context.idGenerator.next(), allPrevSegments));
}
return segments;
}
/**
* `segments` becomes doubly in a `finally` block. Then if a code path exits by a
* control statement (such as `break`, `continue`) from the `finally` block, the
* destination's segments may be half of the source segments. In that case, this
* merges segments.
* @param {ForkContext} context An instance.
* @param {CodePathSegment[]} segments Segments to merge.
* @returns {CodePathSegment[]} The merged segments.
*/
function mergeExtraSegments(context, segments) {
let currentSegments = segments;
while (currentSegments.length > context.count) {
const merged = [];
for (
let i = 0, length = (currentSegments.length / 2) | 0;
i < length;
++i
) {
merged.push(
CodePathSegment.newNext(context.idGenerator.next(), [
currentSegments[i],
currentSegments[i + length],
]),
);
}
currentSegments = merged;
}
return currentSegments;
}
//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------
/**
* A class to manage forking.
*/
class ForkContext {
/**
* @param {IdGenerator} idGenerator An identifier generator for segments.
* @param {ForkContext|null} upper An upper fork context.
* @param {number} count A number of parallel segments.
*/
constructor(idGenerator, upper, count) {
this.idGenerator = idGenerator;
this.upper = upper;
this.count = count;
this.segmentsList = [];
}
/**
* The head segments.
* @type {CodePathSegment[]}
*/
get head() {
const list = this.segmentsList;
return list.length === 0 ? [] : list[list.length - 1];
}
/**
* A flag which shows empty.
* @type {boolean}
*/
get empty() {
return this.segmentsList.length === 0;
}
/**
* A flag which shows reachable.
* @type {boolean}
*/
get reachable() {
const segments = this.head;
return segments.length > 0 && segments.some(isReachable);
}
/**
* Creates new segments from this context.
* @param {number} begin The first index of previous segments.
* @param {number} end The last index of previous segments.
* @returns {CodePathSegment[]} New segments.
*/
makeNext(begin, end) {
return makeSegments(this, begin, end, CodePathSegment.newNext);
}
/**
* Creates new segments from this context.
* The new segments is always unreachable.
* @param {number} begin The first index of previous segments.
* @param {number} end The last index of previous segments.
* @returns {CodePathSegment[]} New segments.
*/
makeUnreachable(begin, end) {
return makeSegments(this, begin, end, CodePathSegment.newUnreachable);
}
/**
* Creates new segments from this context.
* The new segments don't have connections for previous segments.
* But these inherit the reachable flag from this context.
* @param {number} begin The first index of previous segments.
* @param {number} end The last index of previous segments.
* @returns {CodePathSegment[]} New segments.
*/
makeDisconnected(begin, end) {
return makeSegments(this, begin, end, CodePathSegment.newDisconnected);
}
/**
* Adds segments into this context.
* The added segments become the head.
* @param {CodePathSegment[]} segments Segments to add.
* @returns {void}
*/
add(segments) {
assert(
segments.length >= this.count,
`${segments.length} >= ${this.count}`,
);
this.segmentsList.push(mergeExtraSegments(this, segments));
}
/**
* Replaces the head segments with given segments.
* The current head segments are removed.
* @param {CodePathSegment[]} segments Segments to add.
* @returns {void}
*/
replaceHead(segments) {
assert(
segments.length >= this.count,
`${segments.length} >= ${this.count}`,
);
this.segmentsList.splice(-1, 1, mergeExtraSegments(this, segments));
}
/**
* Adds all segments of a given fork context into this context.
* @param {ForkContext} context A fork context to add.
* @returns {void}
*/
addAll(context) {
assert(context.count === this.count);
const source = context.segmentsList;
for (let i = 0; i < source.length; ++i) {
this.segmentsList.push(source[i]);
}
}
/**
* Clears all segments in this context.
* @returns {void}
*/
clear() {
this.segmentsList = [];
}
/**
* Creates the root fork context.
* @param {IdGenerator} idGenerator An identifier generator for segments.
* @returns {ForkContext} New fork context.
*/
static newRoot(idGenerator) {
const context = new ForkContext(idGenerator, null, 1);
context.add([CodePathSegment.newRoot(idGenerator.next())]);
return context;
}
/**
* Creates an empty fork context preceded by a given context.
* @param {ForkContext} parentContext The parent fork context.
* @param {boolean} forkLeavingPath A flag which shows inside of `finally` block.
* @returns {ForkContext} New fork context.
*/
static newEmpty(parentContext, forkLeavingPath) {
return new ForkContext(
parentContext.idGenerator,
parentContext,
(forkLeavingPath ? 2 : 1) * parentContext.count,
);
}
}
module.exports = ForkContext;

View File

@ -0,0 +1,37 @@
'use strict';
/* eslint-disable react-internal/safe-string-coercion */
//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------
/**
* A generator for unique ids.
*/
class IdGenerator {
/**
* @param {string} prefix Optional. A prefix of generated ids.
*/
constructor(prefix) {
this.prefix = String(prefix);
this.n = 0;
}
/**
* Generates id.
* @returns {string} A generated id.
*/
next() {
this.n = (1 + this.n) | 0;
/* c8 ignore start */
if (this.n < 0) {
this.n = 1;
} /* c8 ignore stop */
return this.prefix + this.n;
}
}
module.exports = IdGenerator;

View File

@ -9,6 +9,9 @@
import type {Rule, Scope} from 'eslint';
import type {CallExpression, DoWhileStatement, Node} from 'estree';
// @ts-expect-error untyped module
import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer';
/**
* Catch all identifiers that begin with "use" followed by an uppercase Latin
* character to exclude identifiers like "user".
@ -184,9 +187,25 @@ const rule = {
return getSourceCode().getScope(node);
};
return {
function hasFlowSuppression(node: Node, suppression: string) {
const sourceCode = getSourceCode();
const comments = sourceCode.getAllComments();
const flowSuppressionRegex = new RegExp(
'\\$FlowFixMe\\[' + suppression + '\\]',
);
return comments.some(
commentNode =>
flowSuppressionRegex.test(commentNode.value) &&
commentNode.loc != null &&
node.loc != null &&
commentNode.loc.end.line === node.loc.start.line - 1,
);
}
const analyzer = new CodePathAnalyzer({
// Maintain code segment path stack as we traverse.
onCodePathSegmentStart: segment => codePathSegmentStack.push(segment),
onCodePathSegmentStart: (segment: Rule.CodePathSegment) =>
codePathSegmentStack.push(segment),
onCodePathSegmentEnd: () => codePathSegmentStack.pop(),
// Maintain code path stack as we traverse.
@ -199,7 +218,7 @@ const rule = {
//
// Everything is ok if all React Hooks are both reachable from the initial
// segment and reachable from every final segment.
onCodePathEnd(codePath, codePathNode) {
onCodePathEnd(codePath: any, codePathNode: Node) {
const reactHooksMap = codePathReactHooksMapStack.pop();
if (reactHooksMap?.size === 0) {
return;
@ -508,6 +527,11 @@ const rule = {
const cycled = cyclic.has(segment.id);
for (const hook of reactHooks) {
// Skip reporting if this hook already has a relevant flow suppression.
if (hasFlowSuppression(hook, 'react-rule-hook')) {
continue;
}
// Report an error if a hook may be called more then once.
// `use(...)` can be called in loops.
if (
@ -611,6 +635,16 @@ const rule = {
}
}
},
});
return {
'*'(node: any) {
analyzer.enterNode(node);
},
'*:exit'(node: any) {
analyzer.leaveNode(node);
},
// Missed opportunity...We could visit all `Identifier`s instead of all
// `CallExpression`s and check that _every use_ of a hook name is valid.
@ -696,6 +730,10 @@ const rule = {
function getFunctionName(node: Node) {
if (
// @ts-expect-error parser-hermes produces these node types
node.type === 'ComponentDeclaration' ||
// @ts-expect-error parser-hermes produces these node types
node.type === 'HookDeclaration' ||
node.type === 'FunctionDeclaration' ||
(node.type === 'FunctionExpression' && node.id)
) {