@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.
- package/LICENSE +50 -0
- package/README.md +31 -0
- package/constructor-validates-public-construct.js +351 -0
- package/human-readable-durations.js +103 -0
- package/iam-secrets-arn-suffix.js +79 -0
- package/index.js +61 -0
- package/mask-before-truncate.js +154 -0
- package/mask-error-message-at-boundary.js +673 -0
- package/no-bare-sdk-abort-timeout.js +79 -0
- package/no-classic-connected-account-assume.js +62 -0
- package/no-clickhouse-internal-reexport.js +99 -0
- package/no-duplicate-fjall-util-helper.js +275 -0
- package/no-empty-string-env-fallthrough.js +117 -0
- package/no-l2-asg-lifecycle-hook.js +61 -0
- package/no-mask-identity-mock.js +339 -0
- package/no-raw-block-device-volume.js +75 -0
- package/no-raw-cdk-properties-on-public-constructs.js +80 -0
- package/no-raw-db-transaction.js +112 -0
- package/no-throw-in-services.js +63 -0
- package/no-tier-stage-conflation.js +423 -0
- package/no-undefined-prop-in-construct-id.js +119 -0
- package/no-vacuous-cdk-synth-regex.js +109 -0
- package/no-zod-enum-redeclaring-named-constant.js +159 -0
- package/no-zod-optional-string-empty-trap.js +193 -0
- package/package.json +29 -0
- package/paired-ecs-validation.js +208 -0
- package/prefer-set-has.js +84 -0
- package/prefer-with-agent-flags.js +64 -0
- package/require-abort-precheck-in-sdk-loop.js +169 -0
- package/zod-companion-type.js +159 -0
- package/zod-strict-required.js +153 -0
|
@@ -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
|
+
}
|