eslint-plugin-no-mistakes 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-no-mistakes",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "description": "ESLint and Oxlint rules for deterministic no-mistakes code analysis",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "@typescript-eslint/parser": "^8.65.0",
25
25
  "@vitest/coverage-v8": "^4.1.10",
26
26
  "eslint": "^10.8.0",
27
- "oxlint": "^1.76.0",
27
+ "oxlint": "^1.77.0",
28
28
  "vitest": "^4.1.6"
29
29
  },
30
30
  "peerDependencies": {
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ const rules = {
10
10
  "module-mock-preserve-exports": require("./rules/module-mock-preserve-exports"),
11
11
  "nextjs-metadata-exports-location": require("./rules/nextjs-metadata-exports-location"),
12
12
  "nextjs-no-manual-script-tags": require("./rules/nextjs-no-manual-script-tags"),
13
+ "no-banned-import-outside-allowed-paths": require("./rules/no-banned-import-outside-allowed-paths"),
13
14
  "no-global-fetch-outside-helper": require("./rules/no-global-fetch-outside-helper"),
14
15
  "no-delete-property": require("./rules/no-delete-property"),
15
16
  "no-import-only-test-files": require("./rules/no-import-only-test-files"),
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+
3
+ const { propertyName } = require("./module-mock-helpers");
4
+ const {
5
+ bindingIdentifier,
6
+ bindingIdentifiers,
7
+ resolveVariable,
8
+ } = require("./no-global-fetch-outside-helper-bindings");
9
+ const {
10
+ collectAssignmentExpressions,
11
+ collectVariableDeclarators,
12
+ isMaybeExecuted,
13
+ isOptionalChainArgument,
14
+ } = require("./no-global-fetch-outside-helper-traversal");
15
+ const { hasBannedName } = require("./no-banned-import-outside-allowed-paths-config");
16
+ const {
17
+ resolveNodeModuleCreateRequireTag,
18
+ tagForExpression,
19
+ } = require("./no-banned-import-outside-allowed-paths-tags");
20
+
21
+ function setOrClearTag(identifier, tag, context, aliasMap, clearedAliases) {
22
+ const variable = resolveVariable(identifier, context);
23
+ if (!variable) return;
24
+ if (tag) {
25
+ aliasMap.set(variable, tag);
26
+ clearedAliases?.delete(variable);
27
+ } else {
28
+ aliasMap.delete(variable);
29
+ clearedAliases?.add(variable);
30
+ }
31
+ }
32
+
33
+ function resolvePropertyTag(initTag, key, config) {
34
+ if (initTag?.kind !== "object" || !key) return null;
35
+ for (const module of initTag.modules) {
36
+ const createRequireTag = resolveNodeModuleCreateRequireTag(module, key);
37
+ if (createRequireTag) return createRequireTag;
38
+ if (hasBannedName(config, module, key)) return { kind: "direct", module, name: key };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ // Real-time (control-flow-sensitive) destructure recorder: clears every
44
+ // destructured identifier that doesn't resolve to a banned name, and
45
+ // conservatively keeps the whole object tag on a rest element.
46
+ function applyObjectPatternTag(pattern, initTag, context, aliasMap, clearedAliases, config) {
47
+ for (const property of pattern.properties) {
48
+ if (property.type === "RestElement") {
49
+ const identifier = bindingIdentifier(property.argument);
50
+ const tag = initTag?.kind === "object" ? initTag : null;
51
+ if (identifier) setOrClearTag(identifier, tag, context, aliasMap, clearedAliases);
52
+ continue;
53
+ }
54
+ if (property.type !== "Property") continue;
55
+ const identifier = bindingIdentifier(property.value);
56
+ if (!identifier) {
57
+ // The target is itself a nested pattern (unsupported for tagging), but
58
+ // any identifier it rebinds must still lose a stale tag from an
59
+ // earlier assignment, or a later use would be a false positive.
60
+ for (const nested of bindingIdentifiers(property.value)) {
61
+ setOrClearTag(nested, null, context, aliasMap, clearedAliases);
62
+ }
63
+ continue;
64
+ }
65
+ const tag = resolvePropertyTag(initTag, propertyName(property.key), config);
66
+ setOrClearTag(identifier, tag, context, aliasMap, clearedAliases);
67
+ }
68
+ }
69
+
70
+ function recordVariableTag(
71
+ node,
72
+ context,
73
+ aliasMap,
74
+ clearedAliases,
75
+ config,
76
+ readAliasMap = aliasMap,
77
+ ) {
78
+ if (!node.init) return;
79
+ if (node.id.type === "Identifier") {
80
+ const tag = tagForExpression(node.init, context, readAliasMap, config);
81
+ setOrClearTag(node.id, tag, context, aliasMap, clearedAliases);
82
+ return;
83
+ }
84
+ if (node.id.type === "ObjectPattern") {
85
+ const initTag = tagForExpression(node.init, context, readAliasMap, config);
86
+ applyObjectPatternTag(node.id, initTag, context, aliasMap, clearedAliases, config);
87
+ }
88
+ }
89
+
90
+ function recordAssignmentTag(
91
+ node,
92
+ context,
93
+ aliasMap,
94
+ clearedAliases,
95
+ config,
96
+ readAliasMap = aliasMap,
97
+ ) {
98
+ if (isOptionalChainArgument(node)) return;
99
+ if (node.operator === "||=" || node.operator === "??=") return;
100
+ if (node.operator !== "=") {
101
+ if (node.left?.type === "Identifier") {
102
+ setOrClearTag(node.left, null, context, aliasMap, clearedAliases);
103
+ }
104
+ return;
105
+ }
106
+ if (node.left?.type === "Identifier") {
107
+ const tag = tagForExpression(node.right, context, readAliasMap, config);
108
+ setOrClearTag(node.left, tag, context, aliasMap, clearedAliases);
109
+ return;
110
+ }
111
+ if (node.left?.type === "ObjectPattern") {
112
+ const initTag = tagForExpression(node.right, context, readAliasMap, config);
113
+ applyObjectPatternTag(node.left, initTag, context, aliasMap, clearedAliases, config);
114
+ return;
115
+ }
116
+ if (node.left?.type === "ArrayPattern") {
117
+ // Array destructuring never resolves to a tracked module tag (arrays
118
+ // aren't "the module object" in this model), but every identifier it
119
+ // rebinds must still lose a stale tag from an earlier assignment.
120
+ for (const identifier of bindingIdentifiers(node.left)) {
121
+ setOrClearTag(identifier, null, context, aliasMap, clearedAliases);
122
+ }
123
+ }
124
+ }
125
+
126
+ // Fixed-point (forward-reference) seeder: add-only, never clears, so repeated
127
+ // passes over the whole program monotonically converge (mirrors the
128
+ // reference rule's `collectPossibleAlias`).
129
+ function applyObjectPatternTagAddOnly(pattern, initTag, context, aliasMap, config) {
130
+ for (const property of pattern.properties) {
131
+ if (property.type === "RestElement") {
132
+ if (initTag.kind !== "object") continue;
133
+ const identifier = bindingIdentifier(property.argument);
134
+ if (identifier) setOrClearTag(identifier, initTag, context, aliasMap);
135
+ continue;
136
+ }
137
+ if (property.type !== "Property") continue;
138
+ const identifier = bindingIdentifier(property.value);
139
+ if (!identifier) continue;
140
+ const tag = resolvePropertyTag(initTag, propertyName(property.key), config);
141
+ if (tag) setOrClearTag(identifier, tag, context, aliasMap);
142
+ }
143
+ }
144
+
145
+ function collectPossibleTag(node, context, aliasMap, config) {
146
+ if (node.type === "VariableDeclarator") {
147
+ if (!node.init) return;
148
+ if (node.id.type === "Identifier") {
149
+ const tag = tagForExpression(node.init, context, aliasMap, config);
150
+ if (tag) setOrClearTag(node.id, tag, context, aliasMap);
151
+ return;
152
+ }
153
+ if (node.id.type === "ObjectPattern") {
154
+ const initTag = tagForExpression(node.init, context, aliasMap, config);
155
+ if (initTag) applyObjectPatternTagAddOnly(node.id, initTag, context, aliasMap, config);
156
+ }
157
+ return;
158
+ }
159
+ if (node.operator !== "=") return;
160
+ if (node.left?.type === "Identifier") {
161
+ const tag = tagForExpression(node.right, context, aliasMap, config);
162
+ if (tag) setOrClearTag(node.left, tag, context, aliasMap);
163
+ return;
164
+ }
165
+ if (node.left?.type === "ObjectPattern") {
166
+ const initTag = tagForExpression(node.right, context, aliasMap, config);
167
+ if (initTag) applyObjectPatternTagAddOnly(node.left, initTag, context, aliasMap, config);
168
+ }
169
+ }
170
+
171
+ function collectBannedAliases(program, context, aliasMap, config) {
172
+ const candidates = [
173
+ ...collectVariableDeclarators(program),
174
+ ...collectAssignmentExpressions(program),
175
+ ];
176
+ let changed = true;
177
+ while (changed) {
178
+ changed = false;
179
+ for (const node of candidates) {
180
+ if (isMaybeExecuted(node)) continue;
181
+ const before = aliasMap.size;
182
+ collectPossibleTag(node, context, aliasMap, config);
183
+ changed ||= aliasMap.size > before;
184
+ }
185
+ }
186
+ }
187
+
188
+ module.exports = {
189
+ collectBannedAliases,
190
+ recordAssignmentTag,
191
+ recordVariableTag,
192
+ setOrClearTag,
193
+ };
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ const { repoRelativeFilename, stringMatches } = require("./module-mock-helpers");
4
+
5
+ // Modules whose `createRequire` export is Node's real createRequire()
6
+ // regardless of how a config bans/allows their other exports; tracked
7
+ // unconditionally (see no-banned-import-outside-allowed-paths-tags.js's
8
+ // `resolveNodeModuleCreateRequireTag`) since createRequire is itself a
9
+ // capability that can synchronously load any other banned module, whether
10
+ // obtained via a static `import` or a `require()` call.
11
+ const CREATE_REQUIRE_MODULES = new Set(["node:module", "module"]);
12
+
13
+ // Normalizes the `bannedImports` option into `Map<module, Set<name>>`. The
14
+ // sentinel name `"default"` bans the module's default export when it is used
15
+ // as a directly callable value; any other name bans that named export (or,
16
+ // when reached as a member off a namespace/default/require binding, that
17
+ // method/property name).
18
+ // `entries` is already schema-validated (each item requires a string `module`
19
+ // and a non-empty string-array `names`), so no defensive shape-checking here.
20
+ function normalizeBannedImports(entries) {
21
+ const config = new Map();
22
+ for (const entry of entries ?? []) {
23
+ const names = config.get(entry.module) ?? new Set();
24
+ for (const name of entry.names) names.add(name);
25
+ config.set(entry.module, names);
26
+ }
27
+ return config;
28
+ }
29
+
30
+ function hasBannedName(config, module, name) {
31
+ return config.get(module)?.has(name) ?? false;
32
+ }
33
+
34
+ function hasAnyBannedName(config, module) {
35
+ return Boolean(config.get(module)?.size);
36
+ }
37
+
38
+ // `export * from "mod"` never re-exports a module's default export (ES module
39
+ // semantics), so the reserved "default" name must not by itself make an
40
+ // unaliased export-star declaration reachable.
41
+ function hasAnyNonDefaultBannedName(config, module) {
42
+ const names = config.get(module);
43
+ if (!names) return false;
44
+ for (const name of names) {
45
+ if (name !== "default") return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function shouldCheckFile(filename, options) {
51
+ const checked = options?.checkedPathPatterns ?? [];
52
+ if (checked.length === 0) return false;
53
+ const file = repoRelativeFilename(filename);
54
+ return stringMatches(file, checked) && !stringMatches(file, options.allowedPathPatterns ?? []);
55
+ }
56
+
57
+ module.exports = {
58
+ CREATE_REQUIRE_MODULES,
59
+ hasAnyBannedName,
60
+ hasAnyNonDefaultBannedName,
61
+ hasBannedName,
62
+ normalizeBannedImports,
63
+ shouldCheckFile,
64
+ };
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+
3
+ const { importSpecifierName } = require("./module-mock-helpers");
4
+ const { resolveVariable } = require("./no-global-fetch-outside-helper-bindings");
5
+ const {
6
+ CREATE_REQUIRE_MODULES,
7
+ hasAnyBannedName,
8
+ hasAnyNonDefaultBannedName,
9
+ hasBannedName,
10
+ } = require("./no-banned-import-outside-allowed-paths-config");
11
+ const {
12
+ resolveNodeModuleCreateRequireTag,
13
+ tagForExpression,
14
+ } = require("./no-banned-import-outside-allowed-paths-tags");
15
+
16
+ function isTypeOnlyImport(node, specifier) {
17
+ return node.importKind === "type" || specifier.importKind === "type";
18
+ }
19
+
20
+ function isTypeOnlyExport(node, specifier) {
21
+ return node.exportKind === "type" || specifier.exportKind === "type";
22
+ }
23
+
24
+ function seedImportSpecifier(specifier, node, moduleSpecifier, context, config, aliasMap) {
25
+ if (isTypeOnlyImport(node, specifier)) return;
26
+ const variable = resolveVariable(specifier.local, context);
27
+ if (!variable) return;
28
+ if (specifier.type === "ImportSpecifier") {
29
+ const name = importSpecifierName(specifier);
30
+ const createRequireTag = resolveNodeModuleCreateRequireTag(moduleSpecifier, name);
31
+ if (createRequireTag) {
32
+ aliasMap.set(variable, createRequireTag);
33
+ return;
34
+ }
35
+ if (name && hasBannedName(config, moduleSpecifier, name)) {
36
+ aliasMap.set(variable, { kind: "direct", module: moduleSpecifier, name });
37
+ }
38
+ return;
39
+ }
40
+ if (
41
+ specifier.type === "ImportDefaultSpecifier" ||
42
+ specifier.type === "ImportNamespaceSpecifier"
43
+ ) {
44
+ // A CREATE_REQUIRE_MODULES namespace/default binding is tracked as an
45
+ // object whether or not the config separately bans anything from it, so
46
+ // a member access like `mod.createRequire` still resolves below (see
47
+ // resolveNodeModuleCreateRequireTag) even when nothing else about the
48
+ // module is configured as banned.
49
+ if (hasAnyBannedName(config, moduleSpecifier) || CREATE_REQUIRE_MODULES.has(moduleSpecifier)) {
50
+ aliasMap.set(variable, { kind: "object", modules: new Set([moduleSpecifier]) });
51
+ }
52
+ }
53
+ }
54
+
55
+ // Seeds import-derived tags. Import bindings are always top-level and
56
+ // unconditional (re-assigning them is a syntax error), so this is a single
57
+ // non-fixed-point pass, unlike the require()/createRequire() forward pass.
58
+ function seedImportTags(program, context, config, aliasMap) {
59
+ for (const node of program.body) {
60
+ if (node.type !== "ImportDeclaration") continue;
61
+ const moduleSpecifier = node.source.value;
62
+ for (const specifier of node.specifiers) {
63
+ seedImportSpecifier(specifier, node, moduleSpecifier, context, config, aliasMap);
64
+ }
65
+ }
66
+ }
67
+
68
+ function specifierSourceName(node) {
69
+ return node.type === "Literal" ? String(node.value) : node.name;
70
+ }
71
+
72
+ function reportTagLeak(reportNode, tag, config, context) {
73
+ if (tag?.kind === "direct") {
74
+ context.report({
75
+ node: reportNode,
76
+ messageId: "bannedReExport",
77
+ data: { module: tag.module, name: tag.name },
78
+ });
79
+ return true;
80
+ }
81
+ if (tag?.kind !== "object") return false;
82
+ for (const module of tag.modules) {
83
+ if (hasAnyBannedName(config, module)) {
84
+ context.report({
85
+ node: reportNode,
86
+ messageId: "bannedReExport",
87
+ data: { module, name: "*" },
88
+ });
89
+ return true;
90
+ }
91
+ }
92
+ return false;
93
+ }
94
+
95
+ // Resolves the tag reachable through `variable` for an export check: prefer
96
+ // a live real-time tag, but never fall back to the fixed-point forward tag
97
+ // once the variable has been explicitly cleared (real-time overwritten to
98
+ // something untracked) — otherwise a since-overwritten alias would "revive"
99
+ // its stale, no-longer-true forward tag and produce a false positive. A
100
+ // forward tag is only consulted for a variable never yet touched in real
101
+ // time, i.e. a genuine forward reference.
102
+ function resolveExportedTag(variable, aliasMap, clearedAliases, forwardAliasMap) {
103
+ if (!variable) return null;
104
+ if (aliasMap.has(variable)) return aliasMap.get(variable);
105
+ if (clearedAliases.has(variable)) return null;
106
+ return forwardAliasMap.get(variable) ?? null;
107
+ }
108
+
109
+ function reportLocalReExport(
110
+ specifier,
111
+ context,
112
+ config,
113
+ aliasMap,
114
+ clearedAliases,
115
+ forwardAliasMap,
116
+ ) {
117
+ const variable = resolveVariable(specifier.local, context);
118
+ const tag = resolveExportedTag(variable, aliasMap, clearedAliases, forwardAliasMap);
119
+ reportTagLeak(specifier, tag, config, context);
120
+ }
121
+
122
+ function checkExportedDeclaration(
123
+ declaration,
124
+ context,
125
+ config,
126
+ aliasMap,
127
+ clearedAliases,
128
+ forwardAliasMap,
129
+ ) {
130
+ if (declaration?.type !== "VariableDeclaration") return;
131
+ for (const declarator of declaration.declarations) {
132
+ if (declarator.id.type !== "Identifier") continue;
133
+ const variable = resolveVariable(declarator.id, context);
134
+ const tag = resolveExportedTag(variable, aliasMap, clearedAliases, forwardAliasMap);
135
+ reportTagLeak(declarator, tag, config, context);
136
+ }
137
+ }
138
+
139
+ function checkExportLeaks(node, context, config, aliasMap, clearedAliases, forwardAliasMap) {
140
+ if (node.type === "ExportAllDeclaration") {
141
+ const moduleSpecifier = node.source?.value;
142
+ // An unaliased `export * from "mod"` never re-exports the module's
143
+ // default export (ES module semantics), so a module banned only on
144
+ // "default" exposes nothing reachable through this form.
145
+ if (hasAnyNonDefaultBannedName(config, moduleSpecifier)) {
146
+ context.report({
147
+ node,
148
+ messageId: "bannedReExport",
149
+ data: { module: moduleSpecifier, name: "*" },
150
+ });
151
+ }
152
+ return;
153
+ }
154
+ // An inline export declaration (`export const compile = ts.createProgram;`)
155
+ // exposes a tagged value directly, with no `specifiers` entry to inspect.
156
+ checkExportedDeclaration(
157
+ node.declaration,
158
+ context,
159
+ config,
160
+ aliasMap,
161
+ clearedAliases,
162
+ forwardAliasMap,
163
+ );
164
+ for (const specifier of node.specifiers ?? []) {
165
+ if (specifier.type !== "ExportSpecifier" || isTypeOnlyExport(node, specifier)) continue;
166
+ if (node.source) {
167
+ const sourceName = specifierSourceName(specifier.local);
168
+ if (hasBannedName(config, node.source.value, sourceName)) {
169
+ context.report({
170
+ node: specifier,
171
+ messageId: "bannedReExport",
172
+ data: { module: node.source.value, name: sourceName },
173
+ });
174
+ }
175
+ continue;
176
+ }
177
+ reportLocalReExport(specifier, context, config, aliasMap, clearedAliases, forwardAliasMap);
178
+ }
179
+ }
180
+
181
+ function checkDefaultExportLeak(node, context, config, aliasMap, clearedAliases, forwardAliasMap) {
182
+ const { declaration } = node;
183
+ if (declaration.type === "Identifier") {
184
+ const variable = resolveVariable(declaration, context);
185
+ const tag = resolveExportedTag(variable, aliasMap, clearedAliases, forwardAliasMap);
186
+ reportTagLeak(node, tag, config, context);
187
+ return;
188
+ }
189
+ // A non-identifier declaration (`export default ts.createProgram;` or
190
+ // `export default require("typescript").createProgram;`) is resolved with
191
+ // the same expression tagger used for calls and aliases, matching
192
+ // real-time (depth-0, no forward merge) semantics.
193
+ const tag = tagForExpression(declaration, context, aliasMap, config);
194
+ reportTagLeak(node, tag, config, context);
195
+ }
196
+
197
+ module.exports = {
198
+ checkDefaultExportLeak,
199
+ checkExportLeaks,
200
+ seedImportTags,
201
+ };
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ // Tracks binding-tag scopes with discard-on-exit semantics for conditionally
4
+ // executed constructs, mirroring the reference rule
5
+ // no-global-fetch-outside-helper.js's push/pop-scope pattern (if/for/while
6
+ // bodies, switch cases with fallthrough tracking, try block/handler,
7
+ // non-static field initializers, and conditional/logical branch
8
+ // expressions). A tagged alias assigned inside one of these constructs must
9
+ // not leak into the surrounding scope, since the construct may execute zero
10
+ // or more times, or not on every path.
11
+ function createAliasScopeTracker() {
12
+ let aliases = new Map();
13
+ let clearedForwardAliases = new Set();
14
+ const aliasStack = [];
15
+ const clearedAliasStack = [];
16
+ const switchStack = [];
17
+
18
+ function push() {
19
+ aliasStack.push(aliases);
20
+ clearedAliasStack.push(clearedForwardAliases);
21
+ aliases = new Map(aliases);
22
+ clearedForwardAliases = new Set(clearedForwardAliases);
23
+ }
24
+
25
+ function pop() {
26
+ aliases = aliasStack.pop();
27
+ clearedForwardAliases = clearedAliasStack.pop();
28
+ }
29
+
30
+ // Discards the current branch's mutations and starts a fresh clone from
31
+ // the same base a sibling branch (e.g. a ternary's alternate) should see.
32
+ // Used between mutually exclusive branches that share one push(), instead
33
+ // of a second pop-then-push pair keyed to the branch node itself: keying
34
+ // to the branch node races an enter/exit listener on that same node (see
35
+ // the call sites in the main rule file for why).
36
+ function resetBranch() {
37
+ pop();
38
+ push();
39
+ }
40
+
41
+ // A switch case's aliases start from the switch's base state, or, on
42
+ // fallthrough from a case with no terminating break/return/throw, from
43
+ // whatever the previous case left behind; they're discarded again on exit
44
+ // unless that case also falls through.
45
+ function enterSwitch() {
46
+ switchStack.push({ baseAliases: null, baseCleared: null, fallthrough: false });
47
+ }
48
+
49
+ function exitSwitch() {
50
+ const state = switchStack.pop();
51
+ if (!state.baseAliases) return;
52
+ aliases = state.baseAliases;
53
+ clearedForwardAliases = state.baseCleared;
54
+ }
55
+
56
+ function enterSwitchCase() {
57
+ const state = switchStack.at(-1);
58
+ if (!state) return;
59
+ if (!state.baseAliases) {
60
+ state.baseAliases = aliases;
61
+ state.baseCleared = clearedForwardAliases;
62
+ }
63
+ if (state.fallthrough) return;
64
+ aliases = new Map(state.baseAliases);
65
+ clearedForwardAliases = new Set(state.baseCleared);
66
+ }
67
+
68
+ // A terminator nested inside a bare `{ }` block still runs unconditionally
69
+ // (the block's statements execute in sequence, same as direct case-body
70
+ // children), so it's scanned too. A terminator nested inside an `if` or
71
+ // other conditional/repeated construct is deliberately NOT scanned: it
72
+ // isn't a guaranteed terminator for the whole case, and treating it as one
73
+ // would wrongly discard a real fallthrough — the same class of unsoundness
74
+ // this scope tracker exists to prevent.
75
+ function terminatesCase(node) {
76
+ if (
77
+ node.type === "BreakStatement" ||
78
+ node.type === "ReturnStatement" ||
79
+ node.type === "ThrowStatement"
80
+ ) {
81
+ return true;
82
+ }
83
+ return node.type === "BlockStatement" && node.body.some(terminatesCase);
84
+ }
85
+
86
+ function exitsSwitchCase(node) {
87
+ return node.consequent?.some(terminatesCase) ?? false;
88
+ }
89
+
90
+ function exitSwitchCase(node) {
91
+ const state = switchStack.at(-1);
92
+ if (!state) return;
93
+ state.fallthrough = !exitsSwitchCase(node);
94
+ if (!state.fallthrough) {
95
+ aliases = new Map(state.baseAliases);
96
+ clearedForwardAliases = new Set(state.baseCleared);
97
+ }
98
+ }
99
+
100
+ return {
101
+ push,
102
+ pop,
103
+ resetBranch,
104
+ enterSwitch,
105
+ exitSwitch,
106
+ enterSwitchCase,
107
+ exitSwitchCase,
108
+ get aliases() {
109
+ return aliases;
110
+ },
111
+ get clearedForwardAliases() {
112
+ return clearedForwardAliases;
113
+ },
114
+ };
115
+ }
116
+
117
+ module.exports = { createAliasScopeTracker };
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+
3
+ const { literalString, memberPropertyName } = require("./module-mock-helpers");
4
+ const { hasLocalBinding, resolveVariable } = require("./no-global-fetch-outside-helper-bindings");
5
+ const {
6
+ CREATE_REQUIRE_MODULES,
7
+ hasBannedName,
8
+ } = require("./no-banned-import-outside-allowed-paths-config");
9
+
10
+ // Tag shapes tracked per resolved variable:
11
+ // { kind: "direct", module, name } - this binding IS a specific banned export's value
12
+ // { kind: "object", modules: Set } - this binding is "the module object" (namespace/default
13
+ // import, require()/dynamic-import() result, or a spread
14
+ // merge of such objects); member access and destructure
15
+ // against it are checked against each module's banned names
16
+ // { kind: "require-fn" } - this binding is a Node `require`-shaped function, either
17
+ // the global `require` or the result of calling createRequire()
18
+ // { kind: "create-require" } - this binding is Node's `createRequire` itself
19
+
20
+ const UNWRAP_TYPES = new Set([
21
+ "ChainExpression",
22
+ "TSAsExpression",
23
+ "TSSatisfiesExpression",
24
+ "TSNonNullExpression",
25
+ "TSInstantiationExpression",
26
+ "TSTypeAssertion",
27
+ ]);
28
+
29
+ function unwrapExpression(node) {
30
+ let current = node;
31
+ while (current && (current.type === "AwaitExpression" || UNWRAP_TYPES.has(current.type))) {
32
+ current = current.type === "AwaitExpression" ? current.argument : current.expression;
33
+ }
34
+ return current;
35
+ }
36
+
37
+ function isUnshadowedRequire(node, context) {
38
+ return node?.type === "Identifier" && node.name === "require" && !hasLocalBinding(node, context);
39
+ }
40
+
41
+ function tagForIdentifier(node, context, aliasMap) {
42
+ if (node.type !== "Identifier") return null;
43
+ const variable = resolveVariable(node, context);
44
+ return (variable && aliasMap.get(variable)) ?? null;
45
+ }
46
+
47
+ // `createRequire` is Node's real createRequire() whenever it's reached off
48
+ // one of CREATE_REQUIRE_MODULES, regardless of the binding form (a static
49
+ // `import { createRequire }` specifier, a require()'d object's destructured
50
+ // property, or a require()'d object's member access) — it's a capability in
51
+ // its own right, not a name a config bans per module.
52
+ function resolveNodeModuleCreateRequireTag(module, name) {
53
+ if (CREATE_REQUIRE_MODULES.has(module) && name === "createRequire") {
54
+ return { kind: "create-require" };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function tagForCallExpression(node, context, aliasMap, config) {
60
+ // Resolve the callee through the full expression tagger, not just
61
+ // identifiers, so a `createRequire` reached directly off a member
62
+ // expression (e.g. `nodeModuleNs.createRequire(url)`, with no
63
+ // intermediate variable) is recognized the same way as an aliased one.
64
+ const calleeTag = tagForExpression(node.callee, context, aliasMap, config);
65
+ if (calleeTag?.kind === "create-require") return { kind: "require-fn" };
66
+ const isRequireFn = calleeTag?.kind === "require-fn";
67
+ if (!isRequireFn && !isUnshadowedRequire(node.callee, context)) return null;
68
+ const specifier = literalString(node.arguments[0]);
69
+ if (!specifier) return null;
70
+ // A require()'d CREATE_REQUIRE_MODULES object is tracked whether or not
71
+ // the config separately bans anything from it, so its createRequire
72
+ // property still resolves below even when nothing else about the module
73
+ // is configured as banned.
74
+ if (!CREATE_REQUIRE_MODULES.has(specifier) && !config.has(specifier)) return null;
75
+ return { kind: "object", modules: new Set([specifier]) };
76
+ }
77
+
78
+ function tagForMemberExpression(node, context, aliasMap, config) {
79
+ // Resolve the object through the full expression tagger, not just
80
+ // identifiers, so member access chained directly off an inline
81
+ // require()/import() call (e.g. `require("typescript").createProgram()`)
82
+ // is tracked without first assigning the module object to a variable.
83
+ const objectTag = tagForExpression(node.object, context, aliasMap, config);
84
+ if (objectTag?.kind !== "object") return null;
85
+ const name = memberPropertyName(node);
86
+ if (!name) return null;
87
+ for (const module of objectTag.modules) {
88
+ const createRequireTag = resolveNodeModuleCreateRequireTag(module, name);
89
+ if (createRequireTag) return createRequireTag;
90
+ if (hasBannedName(config, module, name)) return { kind: "direct", module, name };
91
+ }
92
+ return null;
93
+ }
94
+
95
+ function tagForDynamicImport(node, config) {
96
+ const specifier = literalString(node.source);
97
+ if (!specifier || !config.has(specifier)) return null;
98
+ return { kind: "object", modules: new Set([specifier]) };
99
+ }
100
+
101
+ function tagForSpread(node, context, aliasMap, config) {
102
+ const modules = new Set();
103
+ for (const property of node.properties) {
104
+ if (property.type !== "SpreadElement") continue;
105
+ const tag = tagForExpression(property.argument, context, aliasMap, config);
106
+ if (tag?.kind === "object") for (const module of tag.modules) modules.add(module);
107
+ }
108
+ return modules.size > 0 ? { kind: "object", modules } : null;
109
+ }
110
+
111
+ function tagForExpression(node, context, aliasMap, config) {
112
+ const unwrapped = unwrapExpression(node);
113
+ if (!unwrapped) return null;
114
+ if (unwrapped.type === "Identifier") return tagForIdentifier(unwrapped, context, aliasMap);
115
+ if (unwrapped.type === "MemberExpression") {
116
+ return tagForMemberExpression(unwrapped, context, aliasMap, config);
117
+ }
118
+ if (unwrapped.type === "CallExpression") {
119
+ return tagForCallExpression(unwrapped, context, aliasMap, config);
120
+ }
121
+ if (unwrapped.type === "ImportExpression") return tagForDynamicImport(unwrapped, config);
122
+ if (unwrapped.type === "ObjectExpression")
123
+ return tagForSpread(unwrapped, context, aliasMap, config);
124
+ return null;
125
+ }
126
+
127
+ module.exports = {
128
+ isUnshadowedRequire,
129
+ resolveNodeModuleCreateRequireTag,
130
+ tagForExpression,
131
+ tagForIdentifier,
132
+ unwrapExpression,
133
+ };
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+
3
+ const { rule } = require("../helpers");
4
+ const {
5
+ hasBannedName,
6
+ normalizeBannedImports,
7
+ shouldCheckFile,
8
+ } = require("./no-banned-import-outside-allowed-paths-config");
9
+ const {
10
+ checkDefaultExportLeak,
11
+ checkExportLeaks,
12
+ seedImportTags,
13
+ } = require("./no-banned-import-outside-allowed-paths-imports");
14
+ const {
15
+ collectBannedAliases,
16
+ recordAssignmentTag,
17
+ recordVariableTag,
18
+ } = require("./no-banned-import-outside-allowed-paths-aliases");
19
+ const { createAliasScopeTracker } = require("./no-banned-import-outside-allowed-paths-scopes");
20
+ const { tagForExpression } = require("./no-banned-import-outside-allowed-paths-tags");
21
+
22
+ module.exports = rule(
23
+ {
24
+ type: "problem",
25
+ docs: {
26
+ description: "disallow banned capability imports outside allowed paths",
27
+ recommended: false,
28
+ },
29
+ schema: [
30
+ {
31
+ type: "object",
32
+ properties: {
33
+ checkedPathPatterns: { type: "array", items: { type: "string" } },
34
+ allowedPathPatterns: { type: "array", items: { type: "string" } },
35
+ bannedImports: {
36
+ type: "array",
37
+ items: {
38
+ type: "object",
39
+ properties: {
40
+ module: { type: "string" },
41
+ names: { type: "array", items: { type: "string" }, minItems: 1 },
42
+ },
43
+ required: ["module", "names"],
44
+ additionalProperties: false,
45
+ },
46
+ },
47
+ },
48
+ additionalProperties: false,
49
+ },
50
+ ],
51
+ messages: {
52
+ bannedImport:
53
+ 'Reachable use of banned import "{{name}}" from "{{module}}" outside allowed paths. Move it into an allowed helper path.',
54
+ bannedReExport:
55
+ 'Do not re-export banned import "{{name}}" from "{{module}}"; it stays reachable outside allowed paths.',
56
+ },
57
+ },
58
+ (context) => {
59
+ const options = context.options?.[0] ?? {};
60
+ if (!shouldCheckFile(context.filename, options)) return {};
61
+ const config = normalizeBannedImports(options.bannedImports);
62
+ if (config.size === 0) return {};
63
+
64
+ const scopes = createAliasScopeTracker();
65
+ const forwardAliases = new Map();
66
+ let functionDepth = 0;
67
+
68
+ function isIifeFunction(node) {
69
+ const parent = node?.parent;
70
+ return parent?.type === "CallExpression" && parent.callee === node;
71
+ }
72
+
73
+ function pushFunctionScope(node) {
74
+ if (isIifeFunction(node)) return;
75
+ functionDepth += 1;
76
+ scopes.push();
77
+ }
78
+
79
+ function popFunctionScope(node) {
80
+ if (isIifeFunction(node)) return;
81
+ scopes.pop();
82
+ functionDepth -= 1;
83
+ }
84
+
85
+ // Merges forward-declared module-scope tags (imports, plus hoistable
86
+ // require()/createRequire() forward references) with the current
87
+ // block-scoped tags when resolving inside a function body. At module
88
+ // depth 0, `aliases` alone reflects real top-to-bottom JS execution
89
+ // order, so no forward merge happens there (matches the reference rule).
90
+ function activeAliases() {
91
+ if (functionDepth === 0) return scopes.aliases;
92
+ const active = new Map(forwardAliases);
93
+ for (const variable of scopes.clearedForwardAliases) active.delete(variable);
94
+ for (const [variable, tag] of scopes.aliases) active.set(variable, tag);
95
+ return active;
96
+ }
97
+
98
+ function reportCall(node, module, name) {
99
+ context.report({ node, messageId: "bannedImport", data: { module, name } });
100
+ }
101
+
102
+ // Shared by CallExpression and NewExpression: a banned capability is
103
+ // just as reachable through `new BannedClient()` as through
104
+ // `BannedClient()`, and both invocation forms resolve their callee the
105
+ // same way.
106
+ function checkInvocation(node) {
107
+ const tag = tagForExpression(node.callee, context, activeAliases(), config);
108
+ if (tag?.kind === "direct") {
109
+ reportCall(node.callee, tag.module, tag.name);
110
+ return;
111
+ }
112
+ if (tag?.kind !== "object") return;
113
+ for (const module of tag.modules) {
114
+ if (hasBannedName(config, module, "default")) {
115
+ reportCall(node.callee, module, "default");
116
+ return;
117
+ }
118
+ }
119
+ }
120
+
121
+ return {
122
+ Program(node) {
123
+ seedImportTags(node, context, config, scopes.aliases);
124
+ for (const [variable, tag] of scopes.aliases) forwardAliases.set(variable, tag);
125
+ collectBannedAliases(node, context, forwardAliases, config);
126
+ },
127
+ FunctionDeclaration: pushFunctionScope,
128
+ "FunctionDeclaration:exit": popFunctionScope,
129
+ FunctionExpression: pushFunctionScope,
130
+ "FunctionExpression:exit": popFunctionScope,
131
+ ArrowFunctionExpression: pushFunctionScope,
132
+ "ArrowFunctionExpression:exit": popFunctionScope,
133
+ "IfStatement > .consequent": scopes.push,
134
+ "IfStatement > .consequent:exit": scopes.pop,
135
+ "IfStatement > .alternate": scopes.push,
136
+ "IfStatement > .alternate:exit": scopes.pop,
137
+ "ForStatement > .body": scopes.push,
138
+ "ForStatement > .body:exit": scopes.pop,
139
+ "ForInStatement > .body": scopes.push,
140
+ "ForInStatement > .body:exit": scopes.pop,
141
+ "ForOfStatement > .body": scopes.push,
142
+ "ForOfStatement > .body:exit": scopes.pop,
143
+ "WhileStatement > .body": scopes.push,
144
+ "WhileStatement > .body:exit": scopes.pop,
145
+ SwitchStatement: scopes.enterSwitch,
146
+ "SwitchStatement:exit": scopes.exitSwitch,
147
+ SwitchCase: scopes.enterSwitchCase,
148
+ "SwitchCase:exit": scopes.exitSwitchCase,
149
+ "TryStatement > .block": scopes.push,
150
+ "TryStatement > .block:exit": scopes.pop,
151
+ "TryStatement > .handler": scopes.push,
152
+ "TryStatement > .handler:exit": scopes.pop,
153
+ "FieldDefinition[static=false] > .value": scopes.push,
154
+ "FieldDefinition[static=false] > .value:exit": scopes.pop,
155
+ "PropertyDefinition[static=false] > .value": scopes.push,
156
+ "PropertyDefinition[static=false] > .value:exit": scopes.pop,
157
+ // A ternary's or `&&`/`||`'s conditionally-executed operand can itself
158
+ // be a bare AssignmentExpression (no wrapping statement), unlike an
159
+ // `if`/loop/switch/try body, which is always a Statement. Pushing on
160
+ // that operand's own field selector would race the operand's own
161
+ // enter listener (both fire on the same node, and the plain-type
162
+ // listener runs first), so the push/pop below is keyed to the
163
+ // guaranteed-unconditional sibling's exit and the container's exit
164
+ // instead, which always bracket the conditional operand's own enter
165
+ // and exit regardless of listener-specificity ordering.
166
+ "ConditionalExpression > .test:exit": scopes.push,
167
+ "ConditionalExpression > .consequent:exit": scopes.resetBranch,
168
+ "ConditionalExpression:exit": scopes.pop,
169
+ "LogicalExpression > .left:exit": scopes.push,
170
+ "LogicalExpression:exit": scopes.pop,
171
+ VariableDeclarator(node) {
172
+ recordVariableTag(
173
+ node,
174
+ context,
175
+ scopes.aliases,
176
+ scopes.clearedForwardAliases,
177
+ config,
178
+ activeAliases(),
179
+ );
180
+ },
181
+ AssignmentExpression(node) {
182
+ recordAssignmentTag(
183
+ node,
184
+ context,
185
+ scopes.aliases,
186
+ scopes.clearedForwardAliases,
187
+ config,
188
+ activeAliases(),
189
+ );
190
+ },
191
+ CallExpression: checkInvocation,
192
+ NewExpression: checkInvocation,
193
+ ExportNamedDeclaration(node) {
194
+ checkExportLeaks(
195
+ node,
196
+ context,
197
+ config,
198
+ scopes.aliases,
199
+ scopes.clearedForwardAliases,
200
+ forwardAliases,
201
+ );
202
+ },
203
+ ExportAllDeclaration(node) {
204
+ checkExportLeaks(
205
+ node,
206
+ context,
207
+ config,
208
+ scopes.aliases,
209
+ scopes.clearedForwardAliases,
210
+ forwardAliases,
211
+ );
212
+ },
213
+ ExportDefaultDeclaration(node) {
214
+ checkDefaultExportLeak(
215
+ node,
216
+ context,
217
+ config,
218
+ scopes.aliases,
219
+ scopes.clearedForwardAliases,
220
+ forwardAliases,
221
+ );
222
+ },
223
+ };
224
+ },
225
+ );