@fjall/eslint-plugin 2.18.1

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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * ESLint Rule: no-zod-enum-redeclaring-named-constant
3
+ *
4
+ * Catches `z.enum([<string-literal>, ...])` declarations whose literal set
5
+ * matches any named SCREAMING_SNAKE_CASE `as const` tuple in
6
+ * `generator/src/schemas/constants.ts` or `sharedTypes.ts` — covers the
7
+ * `_TYPES`, `_TIERS`, `_PRESETS`, `_PROVIDERS`, `_ENGINES`, `_METHODS`,
8
+ * `_NAMES`, `_INTERVALS`, `_ARCHITECTURES`, `_VALUES` suffix families. Any
9
+ * exported tuple matching `/^[A-Z][A-Z0-9_]*$/` participates; suffix is not
10
+ * gated. Set equality is ordering-insensitive — a `z.enum(["AWS_IAM","NONE"])`
11
+ * site matches `FUNCTION_URL_AUTH_TYPES = ["NONE","AWS_IAM"]`.
12
+ *
13
+ * Surfaces during the 2026-05-07 review pass as the 4th recurrence of
14
+ * literal-set drift on a Zod enum within one branch (F4 architecture · F9
15
+ * functionUrl.authType · F14 BACKUP_VAULT_TIERS test · F17 deployment).
16
+ * The 14:42 deltas note pre-registered the threshold; this rule mechanises it.
17
+ *
18
+ * Why ordering-insensitive set equality is correct: divergent orderings
19
+ * between sibling sites (e.g. `z.enum(["NONE","AWS_IAM"])` at one schema
20
+ * arm and `z.enum(["AWS_IAM","NONE"])` at another) are themselves a
21
+ * silent-drift hazard — both should route through the named tuple.
22
+ *
23
+ * The registry is built dynamically at rule-init time by reading
24
+ * `constants.ts` + `sharedTypes.ts` relative to this rule file. Adding a
25
+ * new `<NAME> = [...] as const` tuple (any SCREAMING_SNAKE_CASE name) to
26
+ * either source file automatically extends the rule's catch surface — no
27
+ * rule edits needed. Spread/concat forms (`APP_TYPES = [...TIER_NAMES, X]`)
28
+ * are intentionally not extracted — they would yield partial literal sets.
29
+ *
30
+ * Codifies typescript-standards.md § "Zod-Derived Type Guards (MANDATORY)".
31
+ */
32
+
33
+ import { readFileSync } from "node:fs";
34
+ import { dirname, join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
+
39
+ const REGISTRY = buildRegistry();
40
+
41
+ function buildRegistry() {
42
+ const sources = [
43
+ join(__dirname, "..", "..", "generator", "src", "schemas", "constants.ts"),
44
+ join(__dirname, "..", "..", "generator", "src", "schemas", "sharedTypes.ts")
45
+ ];
46
+ const registry = new Map();
47
+ const failures = [];
48
+ for (const path of sources) {
49
+ let text;
50
+ try {
51
+ text = readFileSync(path, "utf8");
52
+ } catch (err) {
53
+ failures.push({
54
+ path,
55
+ error: err instanceof Error ? err.message : String(err)
56
+ });
57
+ continue;
58
+ }
59
+ extractNamedTuples(text, registry);
60
+ }
61
+ if (registry.size === 0 && failures.length > 0) {
62
+ const detail = failures.map((f) => `${f.path}: ${f.error}`).join("; ");
63
+ // eslint-disable-next-line no-console
64
+ console.warn(
65
+ `[fjall/no-zod-enum-redeclaring-named-constant] registry empty; rule disabled. ${detail}`
66
+ );
67
+ }
68
+ return registry;
69
+ }
70
+
71
+ function extractNamedTuples(source, registry) {
72
+ // Spread/concat forms like `APP_TYPES = [...TIER_NAMES, CUSTOM_TIER]` are
73
+ // intentionally not matched — they would yield partial literal sets.
74
+ const tuplePattern =
75
+ /export\s+const\s+([A-Z][A-Z0-9_]*)\s*=\s*\[\s*((?:"[^"]*"\s*,?\s*)+)\]\s*as\s+const/g;
76
+ let match;
77
+ while ((match = tuplePattern.exec(source)) !== null) {
78
+ const name = match[1];
79
+ const body = match[2];
80
+ const literals = Array.from(body.matchAll(/"([^"]*)"/g)).map((m) => m[1]);
81
+ if (literals.length === 0) continue;
82
+ const key = canonicalKey(literals);
83
+ if (!registry.has(key)) {
84
+ registry.set(key, name);
85
+ }
86
+ }
87
+ }
88
+
89
+ function canonicalKey(literals) {
90
+ return JSON.stringify([...literals].sort());
91
+ }
92
+
93
+ /** @type {import('eslint').Rule.RuleModule} */
94
+ export default {
95
+ meta: {
96
+ type: "problem",
97
+ docs: {
98
+ description:
99
+ "Disallow inline z.enum([...]) declarations whose literal set matches a named *_TYPES tuple",
100
+ category: "Best Practices",
101
+ recommended: true
102
+ },
103
+ messages: {
104
+ enumRedeclaresNamedConstant:
105
+ "z.enum([{{literals}}]) inline-redeclares the named constant `{{constantName}}` (literal set matches, ordering-insensitive). Replace with `z.enum({{constantName}})` to eliminate silent-drift risk."
106
+ },
107
+ schema: []
108
+ },
109
+
110
+ create(context) {
111
+ if (REGISTRY.size === 0) return {};
112
+
113
+ return {
114
+ CallExpression(node) {
115
+ if (!isZodEnumCall(node)) return;
116
+ const args = node.arguments;
117
+ if (args.length !== 1) return;
118
+ const arg = args[0];
119
+ if (arg.type !== "ArrayExpression") return;
120
+
121
+ const literals = [];
122
+ for (const element of arg.elements) {
123
+ if (
124
+ element === null ||
125
+ element.type !== "Literal" ||
126
+ typeof element.value !== "string"
127
+ ) {
128
+ return;
129
+ }
130
+ literals.push(element.value);
131
+ }
132
+ if (literals.length === 0) return;
133
+
134
+ const key = canonicalKey(literals);
135
+ const constantName = REGISTRY.get(key);
136
+ if (constantName === undefined) return;
137
+
138
+ context.report({
139
+ node: arg,
140
+ messageId: "enumRedeclaresNamedConstant",
141
+ data: {
142
+ literals: literals.map((l) => `"${l}"`).join(", "),
143
+ constantName
144
+ }
145
+ });
146
+ }
147
+ };
148
+ }
149
+ };
150
+
151
+ function isZodEnumCall(node) {
152
+ if (node.type !== "CallExpression") return false;
153
+ const callee = node.callee;
154
+ if (callee.type !== "MemberExpression") return false;
155
+ if (callee.property.type !== "Identifier") return false;
156
+ if (callee.property.name !== "enum") return false;
157
+ if (callee.object.type !== "Identifier") return false;
158
+ return callee.object.name === "z";
159
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * ESLint Rule: no-zod-optional-string-empty-trap
3
+ *
4
+ * Flags `z.string()...optional()` (or `.nullable()`) chains without `.min(N)`
5
+ * whose corresponding destructured field name is consumed by `??` in the same
6
+ * file. The empty string passes Zod's `.optional()` (only "absent" is rejected,
7
+ * not `""`), but `??` only falls through on null/undefined — so `""` flows past
8
+ * the fallback and lands in downstream consumers as an empty string.
9
+ *
10
+ * Per .claude/rules/typescript-standards.md § "Pitfall 9: `.optional()` String
11
+ * Composed With `??` Fallback (Empty-String Trap)".
12
+ *
13
+ * Detection algorithm (heuristic, file-scoped):
14
+ * Pass 1 — collect Property nodes whose value is `z.string()...optional()` or
15
+ * `.nullable()` chains lacking `.min(N)`. Index by field name.
16
+ * Pass 2 — collect `??` LogicalExpressions whose LHS is `Identifier(name)` or
17
+ * `MemberExpression(.name)`.
18
+ * Report — at Program:exit, intersect by name. Each vulnerable field is
19
+ * reported once even if consumed by multiple `??` operators.
20
+ *
21
+ * Rename-aware:
22
+ * `const { region: requestedRegion } = data; requestedRegion ?? "x"` is
23
+ * matched by walking ObjectPattern Property nodes (key.name → value.name)
24
+ * and looking up `??` LHS bindings against the rename map when a direct
25
+ * schema-property-name match fails.
26
+ *
27
+ * Heuristic limitations:
28
+ * - Cross-file flow not tracked. Schemas declared in one file and consumed
29
+ * in another are out of scope.
30
+ * - Multi-step rebinding (`const x = data.field; const y = x; y ?? "z"`) is
31
+ * not traced — only single-step destructuring renames are tracked.
32
+ */
33
+
34
+ /** @type {import('eslint').Rule.RuleModule} */
35
+ export default {
36
+ meta: {
37
+ type: "problem",
38
+ docs: {
39
+ description:
40
+ "Disallow z.string()...optional() (or .nullable()) without .min(N) when the destructured field flows into a `??` consumer.",
41
+ category: "Possible Errors",
42
+ recommended: true
43
+ },
44
+ messages: {
45
+ emptyStringTrap:
46
+ 'Field `{{name}}` is `z.string()...{{absentMethod}}()` without `.min(N)` and is consumed by `??` (line {{consumerLine}}). Empty-string body bypasses the fallback. Add `.min(1, "{{name}} cannot be empty")` before `.{{absentMethod}}()`, or document why empty string is valid here with an eslint-disable.'
47
+ },
48
+ schema: []
49
+ },
50
+
51
+ create(context) {
52
+ /** @type {Map<string, { propertyNode: any, absentMethod: string }>} */
53
+ const vulnerableFields = new Map();
54
+ /** @type {Array<{ node: any, name: string }>} */
55
+ const consumers = [];
56
+ /** @type {Map<string, string>} localBindingName -> originalSchemaPropertyName */
57
+ const renameMap = new Map();
58
+
59
+ return {
60
+ Property(node) {
61
+ if (node.key.type !== "Identifier") return;
62
+
63
+ if (node.parent && node.parent.type === "ObjectPattern") {
64
+ if (
65
+ !node.shorthand &&
66
+ node.value.type === "Identifier" &&
67
+ node.key.name !== node.value.name
68
+ ) {
69
+ renameMap.set(node.value.name, node.key.name);
70
+ }
71
+ return;
72
+ }
73
+
74
+ const fieldName = node.key.name;
75
+ const result = analyseZodChain(node.value);
76
+ if (result.isAbsentAcceptingString && !result.hasMin) {
77
+ vulnerableFields.set(fieldName, {
78
+ propertyNode: node,
79
+ absentMethod: result.absentMethod
80
+ });
81
+ }
82
+ },
83
+
84
+ "LogicalExpression[operator='??']"(node) {
85
+ const name = extractIdentifierName(node.left);
86
+ if (name) consumers.push({ node, name });
87
+ },
88
+
89
+ "Program:exit"() {
90
+ const reported = new Set();
91
+ for (const { node, name } of consumers) {
92
+ let schemaName = name;
93
+ let entry = vulnerableFields.get(schemaName);
94
+ if (!entry) {
95
+ const renamedFrom = renameMap.get(name);
96
+ if (renamedFrom) {
97
+ schemaName = renamedFrom;
98
+ entry = vulnerableFields.get(schemaName);
99
+ }
100
+ }
101
+ if (!entry || reported.has(schemaName)) continue;
102
+ reported.add(schemaName);
103
+ context.report({
104
+ node: entry.propertyNode,
105
+ messageId: "emptyStringTrap",
106
+ data: {
107
+ name: schemaName,
108
+ absentMethod: entry.absentMethod,
109
+ consumerLine: String(node.loc.start.line)
110
+ }
111
+ });
112
+ }
113
+ }
114
+ };
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Walks a Zod chain (the value of a Property in a z.object literal) and
120
+ * determines whether it forms `z.string()...optional()` or `.nullable()`
121
+ * without `.min(N)`. Returns the absent-accepting method name so the report
122
+ * can target it precisely.
123
+ */
124
+ function analyseZodChain(node) {
125
+ let baseIsString = false;
126
+ /** @type {string[]} */
127
+ const methodCalls = [];
128
+ let hasProtectiveMin = false;
129
+
130
+ let current = node;
131
+ while (current && current.type === "CallExpression") {
132
+ const callee = current.callee;
133
+ if (
134
+ callee.type !== "MemberExpression" ||
135
+ callee.property.type !== "Identifier"
136
+ ) {
137
+ break;
138
+ }
139
+ const methodName = callee.property.name;
140
+
141
+ if (
142
+ callee.object.type === "Identifier" &&
143
+ callee.object.name === "z" &&
144
+ methodName === "string"
145
+ ) {
146
+ baseIsString = true;
147
+ break;
148
+ }
149
+
150
+ if (methodName === "min" && isProtectiveMinCall(current)) {
151
+ hasProtectiveMin = true;
152
+ }
153
+
154
+ methodCalls.push(methodName);
155
+ current = callee.object;
156
+ }
157
+
158
+ let absentMethod = null;
159
+ if (methodCalls.includes("optional")) absentMethod = "optional";
160
+ else if (methodCalls.includes("nullable")) absentMethod = "nullable";
161
+
162
+ return {
163
+ isAbsentAcceptingString: baseIsString && absentMethod !== null,
164
+ hasMin: hasProtectiveMin,
165
+ absentMethod
166
+ };
167
+ }
168
+
169
+ /**
170
+ * `.min(N)` only protects against the empty-string trap when `N >= 1`.
171
+ * `.min(0)` accepts `""` so the trap stays open. Treat non-literal arguments
172
+ * (`.min(MIN_LEN)`) as protective — flagging known-good shapes is worse than
173
+ * missing edge-case literals.
174
+ */
175
+ function isProtectiveMinCall(callExpression) {
176
+ const arg = callExpression.arguments?.[0];
177
+ if (!arg) return false;
178
+ if (arg.type !== "Literal") return true;
179
+ return typeof arg.value === "number" && arg.value >= 1;
180
+ }
181
+
182
+ /**
183
+ * Returns the rightmost identifier name when node is `Identifier` or
184
+ * `MemberExpression` (so both `field ?? x` and `parsed.field ?? x` match).
185
+ */
186
+ function extractIdentifierName(node) {
187
+ if (!node) return null;
188
+ if (node.type === "Identifier") return node.name;
189
+ if (node.type === "MemberExpression" && node.property.type === "Identifier") {
190
+ return node.property.name;
191
+ }
192
+ return null;
193
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@fjall/eslint-plugin",
3
+ "version": "2.18.1",
4
+ "description": "ESLint plugin with Fjall-specific coding standard rules",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "files": [
8
+ "*.js",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "keywords": [
16
+ "eslint",
17
+ "eslintplugin",
18
+ "fjall"
19
+ ],
20
+ "scripts": {
21
+ "clean": "rm -rf ./dist ./sourcemaps",
22
+ "clean:node": "rm -rf ./node_modules",
23
+ "build": "echo \"@fjall/eslint-plugin: no build step (plain JS, published as source)\"",
24
+ "watch": "echo \"@fjall/eslint-plugin: no watch (no build step)\"",
25
+ "format": "prettier --write \"*.js\" \"__tests__/**/*.js\" README.md",
26
+ "format:check": "prettier --check \"*.js\" \"__tests__/**/*.js\" README.md"
27
+ },
28
+ "license": "SEE LICENSE IN LICENSE"
29
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * ESLint Rule: paired-ecs-validation
3
+ *
4
+ * Enforces the resources-vs-patterns layered validation discipline codified in
5
+ * `.claude/rules/generator-standards.md § "Validate at the Lowest Layer the
6
+ * Field Belongs To"`. When the patterns-layer hook (`validateEcsProps` in
7
+ * `lib/patterns/aws/computeEcs.ts`) checks a field on `service.X` or
8
+ * `service.deployment.X`, the same field MUST be referenced in the
9
+ * resources-layer hook (`validateEcsClusterProps` in
10
+ * `lib/resources/aws/compute/ecsValidation.ts`) — otherwise direct
11
+ * `new EcsCluster(...)` consumers bypass the check entirely.
12
+ *
13
+ * The 2026-05-10 ClickHouse review found `service.deployment.{min,max}HealthyPercent`
14
+ * and `service.cloudMapDnsRecordType` validated only in `validateEcsProps`. Six
15
+ * call sites in `lib/__tests__/ecs-loadbalancer-url.test.ts` plus the
16
+ * JSDoc-documented public-API shape bypassed every check. Same defect class
17
+ * as the 2026-05-07 F6 `directAccess` gap.
18
+ *
19
+ * This rule fires on the patterns-layer file. For each `service.<X>` (or
20
+ * `service.deployment.<X>`) member access inside `validateEcsProps`'s body,
21
+ * it verifies the same chain appears textually in the sibling
22
+ * resources-layer file. Cross-file read uses the synchronous fs API at
23
+ * rule-load time and is cached for the lifetime of the lint session.
24
+ *
25
+ * Detection limitations (acknowledged ceiling):
26
+ * - Textual cross-file match. A patterns-layer `service.deployment.minHealthyPercent`
27
+ * access requires the literal substring `deployment.minHealthyPercent` to
28
+ * appear somewhere in `ecsValidation.ts`. Renames or destructured
29
+ * references (`const { minHealthyPercent: min } = service.deployment`)
30
+ * would defeat the textual match and require structural AST analysis.
31
+ * - False-negatives via incidental substring. Because the check is a
32
+ * `String.includes(...)`, an unrelated occurrence of `.deployment` or
33
+ * `.<field>` in `ecsValidation.ts` (e.g. inside a comment, a JSDoc
34
+ * example, a type alias name like `DeploymentConfig`) satisfies the
35
+ * match without the actual field being validated. The rule trades
36
+ * this slack for cross-file simplicity; the runtime-test discipline
37
+ * in `code-quality.md § "ESLint-Mandated Validations Need Direct
38
+ * Runtime Tests"` is the backstop.
39
+ * - Only checks the patterns-layer → resources-layer direction. Resources
40
+ * layer can validate fields the patterns layer does not (correct: the
41
+ * resources layer is the lower mandatory layer; patterns is optional
42
+ * defence-in-depth).
43
+ * - Hardcoded for the `validateEcsProps` ↔ `validateEcsClusterProps` pair.
44
+ * If a future layered API needs the same check, factor the pair list
45
+ * into rule options.
46
+ */
47
+
48
+ import { readFileSync } from "node:fs";
49
+ import { dirname, resolve } from "node:path";
50
+
51
+ const PATTERNS_LAYER_FILENAME_SUFFIX =
52
+ "components/infrastructure/lib/patterns/aws/computeEcs.ts";
53
+ const RESOURCES_LAYER_RELATIVE_PATH =
54
+ "../../resources/aws/compute/ecsValidation.ts";
55
+ const PATTERNS_LAYER_FUNCTION_NAME = "validateEcsProps";
56
+ const RESOURCES_LAYER_FUNCTION_NAME = "validateEcsClusterProps";
57
+
58
+ /** Cache the resources-layer file content per process — invalidated only on
59
+ * ESLint restart. The lint session reads `ecsValidation.ts` once even if
60
+ * `computeEcs.ts` is linted across many changes in a watch session.
61
+ *
62
+ * Caveat: edits to `ecsValidation.ts` during a watch session are NOT picked
63
+ * up until ESLint restarts. Reload `eslint --cache --fix .` or restart the
64
+ * editor's ESLint server to refresh. The trade-off: per-lint disk reads
65
+ * would add noticeable latency on every `computeEcs.ts` edit in monorepo
66
+ * watch mode, whereas the rule's purpose is enforced at commit time via
67
+ * lefthook's lint-staged hook regardless of watch-session staleness. */
68
+ const resourcesFileCache = new Map();
69
+
70
+ function readResourcesLayer(patternsFilePath) {
71
+ const resourcesPath = resolve(
72
+ dirname(patternsFilePath),
73
+ RESOURCES_LAYER_RELATIVE_PATH
74
+ );
75
+ if (resourcesFileCache.has(resourcesPath)) {
76
+ return resourcesFileCache.get(resourcesPath);
77
+ }
78
+ let content = "";
79
+ try {
80
+ content = readFileSync(resourcesPath, "utf8");
81
+ } catch (err) {
82
+ process.emitWarning(
83
+ `paired-ecs-validation: could not read sibling file ${resourcesPath} (${err && err.code ? err.code : "ENOENT"}); rule disabled for ${patternsFilePath}.`,
84
+ "ESLintRuleWarning"
85
+ );
86
+ content = "";
87
+ }
88
+ resourcesFileCache.set(resourcesPath, content);
89
+ return content;
90
+ }
91
+
92
+ /** Recursively flatten a chain like `service.deployment.minHealthyPercent`
93
+ * into `["service", "deployment", "minHealthyPercent"]`. Returns null if any
94
+ * link in the chain is computed (`service[x]`) or non-Identifier. */
95
+ function flattenMemberChain(node) {
96
+ const chain = [];
97
+ let current = node;
98
+ while (current && current.type === "MemberExpression") {
99
+ if (current.computed) return null;
100
+ if (!current.property || current.property.type !== "Identifier") {
101
+ return null;
102
+ }
103
+ chain.unshift(current.property.name);
104
+ current = current.object;
105
+ }
106
+ if (!current || current.type !== "Identifier") return null;
107
+ chain.unshift(current.name);
108
+ return chain;
109
+ }
110
+
111
+ /** @type {import('eslint').Rule.RuleModule} */
112
+ export default {
113
+ meta: {
114
+ type: "problem",
115
+ docs: {
116
+ description:
117
+ "Enforces that fields validated in the patterns-layer `validateEcsProps` hook are also validated in the resources-layer `validateEcsClusterProps` hook. Direct `new EcsCluster(...)` consumers bypass the patterns layer.",
118
+ category: "Best Practices",
119
+ recommended: true
120
+ },
121
+ messages: {
122
+ missingResourcesLayerCheck:
123
+ '`{{chain}}` is validated here in `validateEcsProps` (patterns layer) but the field is not referenced in `validateEcsClusterProps` (resources layer). Direct `new EcsCluster(...)` consumers bypass the patterns-layer check — add the same validation to `lib/resources/aws/compute/ecsValidation.ts` so both code paths are protected. See .claude/rules/generator-standards.md § "Validate at the Lowest Layer the Field Belongs To".'
124
+ },
125
+ schema: []
126
+ },
127
+
128
+ create(context) {
129
+ const filename = context.filename ?? context.getFilename();
130
+ if (!filename.endsWith(PATTERNS_LAYER_FILENAME_SUFFIX)) return {};
131
+
132
+ const resourcesContent = readResourcesLayer(filename);
133
+ if (resourcesContent === "") return {};
134
+
135
+ let insideTargetFunction = 0;
136
+ const reportedChains = new Set();
137
+
138
+ function enterIfTarget(node) {
139
+ if (node.id && node.id.name === PATTERNS_LAYER_FUNCTION_NAME) {
140
+ insideTargetFunction += 1;
141
+ }
142
+ }
143
+
144
+ function exitIfTarget(node) {
145
+ if (node.id && node.id.name === PATTERNS_LAYER_FUNCTION_NAME) {
146
+ insideTargetFunction -= 1;
147
+ }
148
+ }
149
+
150
+ return {
151
+ FunctionDeclaration: enterIfTarget,
152
+ "FunctionDeclaration:exit": exitIfTarget,
153
+ VariableDeclarator(node) {
154
+ if (
155
+ node.init &&
156
+ (node.init.type === "ArrowFunctionExpression" ||
157
+ node.init.type === "FunctionExpression")
158
+ ) {
159
+ enterIfTarget(node);
160
+ }
161
+ },
162
+ "VariableDeclarator:exit"(node) {
163
+ if (
164
+ node.init &&
165
+ (node.init.type === "ArrowFunctionExpression" ||
166
+ node.init.type === "FunctionExpression")
167
+ ) {
168
+ exitIfTarget(node);
169
+ }
170
+ },
171
+
172
+ MemberExpression(node) {
173
+ if (insideTargetFunction === 0) return;
174
+ const chain = flattenMemberChain(node);
175
+ if (!chain || chain.length < 2) return;
176
+ const root = chain[0];
177
+ if (root !== "service" && root !== "props") return;
178
+
179
+ const tail = chain.slice(1).join(".");
180
+ if (!tail) return;
181
+
182
+ const ancestorTails = [tail];
183
+ for (let i = chain.length - 1; i > 1; i -= 1) {
184
+ ancestorTails.push(chain.slice(1, i).join("."));
185
+ }
186
+
187
+ for (const candidate of ancestorTails) {
188
+ if (
189
+ resourcesContent.includes(`${root}.${candidate}`) ||
190
+ resourcesContent.includes(`.${candidate}`)
191
+ ) {
192
+ return;
193
+ }
194
+ }
195
+
196
+ const reportKey = `${root}.${tail}`;
197
+ if (reportedChains.has(reportKey)) return;
198
+ reportedChains.add(reportKey);
199
+
200
+ context.report({
201
+ node,
202
+ messageId: "missingResourcesLayerCheck",
203
+ data: { chain: `${root}.${tail}` }
204
+ });
205
+ }
206
+ };
207
+ }
208
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ESLint Rule: prefer-set-has
3
+ *
4
+ * Prefer Set.has() over Array.includes() for SCREAMING_CASE lookup tables.
5
+ * - Set.has() is O(1), Array.includes() is O(n)
6
+ * - Only flags SCREAMING_CASE constants (e.g., ALLOWED_TYPES)
7
+ * - Does NOT flag camelCase variables (small arrays where O(n) is fine)
8
+ *
9
+ * Detects patterns like:
10
+ * - SOME_ARRAY.includes(value)
11
+ *
12
+ */
13
+
14
+ /** @type {import('eslint').Rule.RuleModule} */
15
+ export default {
16
+ meta: {
17
+ type: "suggestion",
18
+ docs: {
19
+ description:
20
+ "Prefer Set.has() over Array.includes() for membership checks",
21
+ category: "Performance",
22
+ recommended: false
23
+ },
24
+ messages: {
25
+ preferSet:
26
+ "Use Set.has() instead of Array.includes() for O(1) lookup. Define {{name}} as 'new Set([...])' and replace `.includes(x)` with `.has(x)` at the call site."
27
+ },
28
+ schema: []
29
+ },
30
+
31
+ create(context) {
32
+ return {
33
+ // Detect .includes() calls on SCREAMING_CASE arrays (likely lookup tables)
34
+ CallExpression(node) {
35
+ if (!isIncludesCall(node)) return;
36
+
37
+ const callee = node.callee;
38
+ if (callee.type !== "MemberExpression") return;
39
+
40
+ const object = callee.object;
41
+
42
+ // Only check identifiers
43
+ if (object.type !== "Identifier") return;
44
+
45
+ const arrayName = object.name;
46
+
47
+ // Only report SCREAMING_CASE constants - these are clearly lookup tables
48
+ // Don't report camelCase variables as they may be small/dynamic arrays
49
+ // where the O(1) vs O(n) difference is negligible
50
+ if (isScreamingCase(arrayName)) {
51
+ context.report({
52
+ node,
53
+ messageId: "preferSet",
54
+ data: { name: arrayName }
55
+ });
56
+ }
57
+ }
58
+ };
59
+ }
60
+ };
61
+
62
+ /**
63
+ * Check if node is a .includes() call
64
+ */
65
+ function isIncludesCall(node) {
66
+ if (node.type !== "CallExpression") return false;
67
+
68
+ const callee = node.callee;
69
+ if (callee.type !== "MemberExpression") return false;
70
+
71
+ return (
72
+ callee.property.type === "Identifier" && callee.property.name === "includes"
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Check if name is SCREAMING_SNAKE_CASE (constant naming convention).
78
+ * Requires an underscore separator OR length >= 4 so single-letter generics
79
+ * (`T`, `X`) and short locals (`OK`, `ID`) aren't matched as lookup tables.
80
+ */
81
+ function isScreamingCase(name) {
82
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) return false;
83
+ return name.includes("_") || name.length >= 4;
84
+ }