@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,423 @@
1
+ /**
2
+ * ESLint Rule: no-tier-stage-conflation
3
+ *
4
+ * `ConnectedAwsAccount` carries two INDEPENDENT axes:
5
+ * - `role` = structural TIER (organisation | platform | account)
6
+ * - `environment` = workload STAGE (production | staging | development |
7
+ * platform | compliance, nullable)
8
+ *
9
+ * "platform" is BOTH a tier and a stage — same name, two independent axes.
10
+ * Only "root" is excluded from the stage vocabulary.
11
+ *
12
+ * A lossy bridge once derived one axis from the other — `environmentToRole()`
13
+ * on every write, plus a `role === 'organisation' ? null : environment` wipe,
14
+ * plus a name-fallback that mis-derived the tier of an account literally
15
+ * named "platform"/"root". Selecting a "stage" of platform silently flipped
16
+ * the tier; a worker re-syncing a stage demoted a root. Phase 3 (2026-06-07
17
+ * tier/stage separation) deleted those derivations. This rule stops them
18
+ * coming back.
19
+ *
20
+ * Detects three AST shapes (ADR
21
+ * decisions/2026-06-07-account-tier-vs-stage-separation.md § "Regression
22
+ * prevention" item 1):
23
+ *
24
+ * A. The structural "root" marker admitted into a picker-shaped array — a
25
+ * string literal "root" (or an environment-constant `.ROOT` member such
26
+ * as `STRUCTURAL_ENVIRONMENTS.ROOT`) inside an ArrayExpression bound to
27
+ * a name matching /(_STAGES|_OPTIONS|_CHOICES)$/i, including the
28
+ * spread-recomposition form `[...ACCOUNT_STAGES, "root"]`. "root" is the
29
+ * TIER wire marker (decodes to role=organisation), never a workload
30
+ * STAGE — pickers must not offer it. NB: "platform" IS a legitimate
31
+ * stage and is allowed in stage arrays.
32
+ *
33
+ * B. `role: <call(...environment...)>` where the callee is NOT a sanctioned
34
+ * wire decoder — deriving the TIER from the STAGE on a write path; plus
35
+ * `environment: <role === 'organisation' ? ... : ...>` — forcing/wiping
36
+ * the STAGE based on the TIER.
37
+ *
38
+ * C. Tier-from-name derivation — a `role:`/`tier:` property or variable
39
+ * whose value compares (or regex-tests) an account NAME member
40
+ * (`account.name`, `acc.Name`, `account.displayName`, ...) against
41
+ * organisation | platform | root | management. ADR symptom 3: the
42
+ * deleted name-fallback bridge corrupted the tier of an account whose
43
+ * display name happened to be "platform"/"root" — the name is
44
+ * independent of both axes.
45
+ *
46
+ * Shape A exemptions (false-positive bias is deliberately LOW):
47
+ * - Names containing WITH_ROOT: the canonical wire supersets in
48
+ * fjall/util/src/environments.ts (`ACCOUNT_STAGES_WITH_ROOT`) deliberately
49
+ * admit "root" for superset-tolerant ingress and say so in their name.
50
+ * - Names matching /WIRE|LEGACY/: wire-tolerance tuples for out-of-version
51
+ * CLI ingest are sanctioned by the ADR ("Wire tuples exempt from shape A").
52
+ * - `.ROOT` members only count when the object name references the
53
+ * environment vocabulary (/ENVIRONMENT|STAGE/i) — `ROUTES.ROOT` in a nav
54
+ * options array is not the structural marker.
55
+ * - Test files are excluded at registration (both eslint.config.mjs
56
+ * registrations carry test-glob ignores).
57
+ *
58
+ * Shape C is conservative: it only fires when the comparison demonstrably
59
+ * feeds a role/tier binding (a `role:`/`tier:` property, a variable named
60
+ * `role`/`tier`, or an assignment to `.role`/`.tier`), and does not descend
61
+ * into nested function bodies (a `.find((r) => r.name === ...)` predicate is
62
+ * a lookup, not a derivation).
63
+ *
64
+ * The sanctioned one-directional wire decoders (`environmentToTier`,
65
+ * `accountTier`) are exempt from shape B by name: decoding the
66
+ * root/platform-tolerant wire `environment` into a tier at ingress is the
67
+ * correct replacement. A novel re-derivation — for any shape — requires an
68
+ * explicit `eslint-disable-next-line fjall/no-tier-stage-conflation --
69
+ * <reason>` so the exception is visible in review.
70
+ */
71
+
72
+ const SANCTIONED_TIER_DECODERS = new Set(["environmentToTier", "accountTier"]);
73
+ const STRUCTURAL_ROLE_LITERALS = new Set(["organisation", "platform"]);
74
+
75
+ const PICKER_ARRAY_NAME = /(_STAGES|_OPTIONS|_CHOICES)$/i;
76
+ const WIRE_TOLERANT_ARRAY_NAME = /WITH_ROOT|WIRE|LEGACY/i;
77
+ const ENVIRONMENT_CONSTANT_OBJECT = /ENVIRONMENT|STAGE/i;
78
+
79
+ const TIER_BINDING_NAME = /^(role|tier)$/;
80
+ const NAME_MEMBER_PROPERTY = /name/i;
81
+ const TIER_NAME_LITERALS = new Set([
82
+ "organisation",
83
+ "platform",
84
+ "root",
85
+ "management"
86
+ ]);
87
+ const TIER_NAME_REGEX_WORD = /\b(organisation|platform|root|management)\b/i;
88
+ const EQUALITY_OPERATORS = new Set(["===", "!==", "==", "!="]);
89
+
90
+ /** @type {import('eslint').Rule.RuleModule} */
91
+ export default {
92
+ meta: {
93
+ type: "problem",
94
+ docs: {
95
+ description:
96
+ "Disallow conflating the ConnectedAwsAccount TIER (role) and STAGE (environment) axes: no structural 'root' in picker arrays, no tier derived from the stage (or vice versa) on a write path, and no tier derived from an account name. Suppress intentional exceptions with `eslint-disable-next-line fjall/no-tier-stage-conflation -- <reason>`.",
97
+ category: "Possible Errors",
98
+ recommended: true
99
+ },
100
+ messages: {
101
+ roleFromEnvironment:
102
+ "Do not derive `role` (TIER) from `environment` (STAGE): `{{callee}}(…)` conflates the two axes. Decode the wire value with the sanctioned `environmentToTier` / `accountTier`, or write an explicit role.",
103
+ environmentWipedByRole:
104
+ "Do not force/wipe `environment` (STAGE) from `role === '{{tier}}'` (TIER): the axes are independent. Write the decoded stage (`stageFromWireEnvironment`) instead of a role-keyed ternary.",
105
+ structuralRootInPickerArray:
106
+ "Do not admit the structural 'root' marker into the picker-shaped array `{{name}}`: 'root' is the TIER wire marker (decodes to role=organisation), never a workload STAGE. Use ACCOUNT_STAGES, or import the canonical wire superset ACCOUNT_STAGES_WITH_ROOT from @fjall/util for wire-tolerant ingress schemas. Intentional exceptions need `eslint-disable-next-line fjall/no-tier-stage-conflation -- <reason>`.",
107
+ tierFromAccountName:
108
+ "Do not derive `{{binding}}` (TIER) from an account NAME comparison ({{comparator}}): the name is independent of both axes — an account literally named 'root'/'platform' must keep its tier. Decode the wire environment via `environmentToTier`/`accountTier`, or write the tier explicitly. Intentional exceptions need `eslint-disable-next-line fjall/no-tier-stage-conflation -- <reason>`."
109
+ },
110
+ schema: []
111
+ },
112
+
113
+ create(context) {
114
+ return {
115
+ Property(node) {
116
+ const keyName = propertyKeyName(node);
117
+ if (keyName === null) return;
118
+ if (keyName === "role") {
119
+ const offending = findUnsanctionedEnvCall(node.value);
120
+ if (offending) {
121
+ context.report({
122
+ node: node.value,
123
+ messageId: "roleFromEnvironment",
124
+ data: { callee: offending }
125
+ });
126
+ }
127
+ } else if (keyName === "environment") {
128
+ const tier = roleKeyedStructuralTernary(node.value);
129
+ if (tier) {
130
+ context.report({
131
+ node: node.value,
132
+ messageId: "environmentWipedByRole",
133
+ data: { tier }
134
+ });
135
+ }
136
+ }
137
+ if (TIER_BINDING_NAME.test(keyName)) {
138
+ reportTierFromName(context, keyName, node.value);
139
+ }
140
+ checkPickerArray(context, keyName, node.value);
141
+ },
142
+
143
+ VariableDeclarator(node) {
144
+ if (node.id.type !== "Identifier" || !node.init) return;
145
+ checkPickerArray(context, node.id.name, node.init);
146
+ if (TIER_BINDING_NAME.test(node.id.name)) {
147
+ reportTierFromName(context, node.id.name, node.init);
148
+ }
149
+ },
150
+
151
+ AssignmentExpression(node) {
152
+ const targetName = assignmentTargetName(node.left);
153
+ if (targetName === null) return;
154
+ checkPickerArray(context, targetName, node.right);
155
+ if (TIER_BINDING_NAME.test(targetName)) {
156
+ reportTierFromName(context, targetName, node.right);
157
+ }
158
+ }
159
+ };
160
+ }
161
+ };
162
+
163
+ function propertyKeyName(node) {
164
+ if (!node.key) return null;
165
+ if (node.key.type === "Identifier") return node.key.name;
166
+ if (node.key.type === "Literal" && typeof node.key.value === "string") {
167
+ return node.key.value;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function assignmentTargetName(left) {
173
+ if (left.type === "Identifier") return left.name;
174
+ if (
175
+ left.type === "MemberExpression" &&
176
+ !left.computed &&
177
+ left.property.type === "Identifier"
178
+ ) {
179
+ return left.property.name;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ function calleeName(callee) {
185
+ if (callee.type === "Identifier") return callee.name;
186
+ if (
187
+ callee.type === "MemberExpression" &&
188
+ callee.property.type === "Identifier"
189
+ ) {
190
+ return callee.property.name;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function referencesEnvironment(node) {
196
+ let found = false;
197
+ walk(node, (n) => {
198
+ if (n.type === "Identifier" && n.name === "environment") found = true;
199
+ if (
200
+ n.type === "MemberExpression" &&
201
+ n.property.type === "Identifier" &&
202
+ n.property.name === "environment"
203
+ ) {
204
+ found = true;
205
+ }
206
+ });
207
+ return found;
208
+ }
209
+
210
+ // Returns the offending callee name, or null. Recurses through ternary/logical
211
+ // wrappers so `tier ?? environmentToRole(environment)` is caught while
212
+ // `tier ?? environmentToTier(environment)` (sanctioned) is not.
213
+ function findUnsanctionedEnvCall(node) {
214
+ let offending = null;
215
+ walk(node, (n) => {
216
+ if (offending) return;
217
+ if (n.type === "CallExpression") {
218
+ const name = calleeName(n.callee);
219
+ if (
220
+ name &&
221
+ !SANCTIONED_TIER_DECODERS.has(name) &&
222
+ n.arguments.some((arg) => referencesEnvironment(arg))
223
+ ) {
224
+ offending = name;
225
+ }
226
+ }
227
+ });
228
+ return offending;
229
+ }
230
+
231
+ function roleKeyedStructuralTernary(node) {
232
+ if (!node || node.type !== "ConditionalExpression") return null;
233
+ const test = node.test;
234
+ if (!test || test.type !== "BinaryExpression") return null;
235
+ if (test.operator !== "===" && test.operator !== "!==") return null;
236
+
237
+ const refsRole = (side) =>
238
+ (side.type === "Identifier" && side.name === "role") ||
239
+ (side.type === "MemberExpression" &&
240
+ side.property.type === "Identifier" &&
241
+ side.property.name === "role");
242
+
243
+ const structuralLiteral = (side) => {
244
+ if (side.type === "Literal" && typeof side.value === "string") {
245
+ return STRUCTURAL_ROLE_LITERALS.has(side.value) ? side.value : null;
246
+ }
247
+ if (
248
+ side.type === "MemberExpression" &&
249
+ side.property.type === "Identifier"
250
+ ) {
251
+ const name = side.property.name.toLowerCase();
252
+ return STRUCTURAL_ROLE_LITERALS.has(name) ? name : null;
253
+ }
254
+ return null;
255
+ };
256
+
257
+ if (refsRole(test.left)) return structuralLiteral(test.right);
258
+ if (refsRole(test.right)) return structuralLiteral(test.left);
259
+ return null;
260
+ }
261
+
262
+ // --- Shape A: structural "root" in a picker-shaped array -------------------
263
+
264
+ function checkPickerArray(context, name, valueNode) {
265
+ if (!PICKER_ARRAY_NAME.test(name)) return;
266
+ // WITH_ROOT names (canonical wire supersets) and WIRE/LEGACY tuples
267
+ // deliberately admit "root" — see the header rationale.
268
+ if (WIRE_TOLERANT_ARRAY_NAME.test(name)) return;
269
+ const arrayNode = unwrapTsExpressions(valueNode);
270
+ if (!arrayNode || arrayNode.type !== "ArrayExpression") return;
271
+ for (const element of arrayNode.elements) {
272
+ if (!element || element.type === "SpreadElement") continue;
273
+ const unwrapped = unwrapTsExpressions(element);
274
+ if (isStructuralRootElement(unwrapped)) {
275
+ context.report({
276
+ node: unwrapped,
277
+ messageId: "structuralRootInPickerArray",
278
+ data: { name }
279
+ });
280
+ }
281
+ }
282
+ }
283
+
284
+ function isStructuralRootElement(node) {
285
+ if (!node) return false;
286
+ if (node.type === "Literal" && node.value === "root") return true;
287
+ // `.ROOT` member access counts only when the object is an
288
+ // environment-vocabulary constant (STRUCTURAL_ENVIRONMENTS.ROOT) — a
289
+ // `ROUTES.ROOT` nav entry is unrelated to the tier/stage axes.
290
+ return (
291
+ node.type === "MemberExpression" &&
292
+ !node.computed &&
293
+ node.property.type === "Identifier" &&
294
+ node.property.name === "ROOT" &&
295
+ node.object.type === "Identifier" &&
296
+ ENVIRONMENT_CONSTANT_OBJECT.test(node.object.name)
297
+ );
298
+ }
299
+
300
+ // --- Shape C: tier derived from an account NAME ----------------------------
301
+
302
+ function reportTierFromName(context, bindingName, valueNode) {
303
+ const match = findTierFromNameComparison(valueNode);
304
+ if (match) {
305
+ context.report({
306
+ node: match.node,
307
+ messageId: "tierFromAccountName",
308
+ data: { binding: bindingName, comparator: match.comparator }
309
+ });
310
+ }
311
+ }
312
+
313
+ // Returns the offending comparison/regex-test, or null. Recurses through
314
+ // ternary/?? wrappers (same piercing as shape B) but NOT into nested function
315
+ // bodies — a comparison inside a callback is a lookup predicate, not the
316
+ // value of the role/tier binding.
317
+ function findTierFromNameComparison(node) {
318
+ let match = null;
319
+ walk(
320
+ node,
321
+ (n) => {
322
+ if (match) return;
323
+ if (n.type === "BinaryExpression" && EQUALITY_OPERATORS.has(n.operator)) {
324
+ const operandPairs = [
325
+ [n.left, n.right],
326
+ [n.right, n.left]
327
+ ];
328
+ for (const [memberSide, literalSide] of operandPairs) {
329
+ if (isNameMember(memberSide) && isTierNameLiteral(literalSide)) {
330
+ match = { node: n, comparator: `'${literalSide.value}'` };
331
+ return;
332
+ }
333
+ }
334
+ }
335
+ if (
336
+ n.type === "CallExpression" &&
337
+ n.callee.type === "MemberExpression" &&
338
+ !n.callee.computed &&
339
+ n.callee.property.type === "Identifier"
340
+ ) {
341
+ const method = n.callee.property.name;
342
+ if (
343
+ method === "test" &&
344
+ isTierNameRegex(n.callee.object) &&
345
+ n.arguments.some(isNameMember)
346
+ ) {
347
+ match = { node: n, comparator: `/${n.callee.object.regex.pattern}/` };
348
+ } else if (method === "match" && isNameMember(n.callee.object)) {
349
+ const regexArg = n.arguments.find(isTierNameRegex);
350
+ if (regexArg) {
351
+ match = { node: n, comparator: `/${regexArg.regex.pattern}/` };
352
+ }
353
+ }
354
+ }
355
+ },
356
+ { intoFunctions: false }
357
+ );
358
+ return match;
359
+ }
360
+
361
+ function isNameMember(node) {
362
+ return (
363
+ node.type === "MemberExpression" &&
364
+ !node.computed &&
365
+ node.property.type === "Identifier" &&
366
+ NAME_MEMBER_PROPERTY.test(node.property.name)
367
+ );
368
+ }
369
+
370
+ function isTierNameLiteral(node) {
371
+ return (
372
+ node.type === "Literal" &&
373
+ typeof node.value === "string" &&
374
+ TIER_NAME_LITERALS.has(node.value.toLowerCase())
375
+ );
376
+ }
377
+
378
+ function isTierNameRegex(node) {
379
+ return (
380
+ node.type === "Literal" &&
381
+ Boolean(node.regex) &&
382
+ TIER_NAME_REGEX_WORD.test(node.regex.pattern)
383
+ );
384
+ }
385
+
386
+ // --- Shared helpers ---------------------------------------------------------
387
+
388
+ const TS_WRAPPER_TYPES = new Set([
389
+ "TSAsExpression",
390
+ "TSSatisfiesExpression",
391
+ "TSTypeAssertion",
392
+ "TSNonNullExpression"
393
+ ]);
394
+
395
+ function unwrapTsExpressions(node) {
396
+ let current = node;
397
+ while (current && TS_WRAPPER_TYPES.has(current.type)) {
398
+ current = current.expression;
399
+ }
400
+ return current;
401
+ }
402
+
403
+ const FUNCTION_NODE_TYPES = new Set([
404
+ "FunctionDeclaration",
405
+ "FunctionExpression",
406
+ "ArrowFunctionExpression"
407
+ ]);
408
+
409
+ // Minimal recursive AST walker over node-shaped own properties.
410
+ function walk(node, visit, { intoFunctions = true } = {}) {
411
+ if (!node || typeof node.type !== "string") return;
412
+ visit(node);
413
+ if (!intoFunctions && FUNCTION_NODE_TYPES.has(node.type)) return;
414
+ for (const key of Object.keys(node)) {
415
+ if (key === "parent") continue;
416
+ const child = node[key];
417
+ if (Array.isArray(child)) {
418
+ for (const item of child) walk(item, visit, { intoFunctions });
419
+ } else if (child && typeof child.type === "string") {
420
+ walk(child, visit, { intoFunctions });
421
+ }
422
+ }
423
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * ESLint Rule: no-undefined-prop-in-construct-id
3
+ *
4
+ * Flags template-literal CDK construct IDs that interpolate `props.<X>` where
5
+ * `props` is a function parameter and `<X>` is an optionally-typed field on
6
+ * that parameter's type. When the caller passes `undefined`, the literal
7
+ * string `"undefined"` is baked into the CloudFormation logical ID — silent
8
+ * at synth time, audit-visible at runtime, drifts across deploys depending
9
+ * on caller choice.
10
+ *
11
+ * The M-9 class from the 2026-05-17 /review on the database factory variant
12
+ * narrowing landing — `rdsInstance.ts` + `rdsAurora.ts` carried 23 sites of
13
+ * shape `new Foo(this, \`${props.databaseName}Suffix\`, ...)` where
14
+ * `RdsProps.databaseName` is `string | undefined`. Constructor populated
15
+ * `this.databaseNameValue` with a fallback, so the canonical fix was to
16
+ * route every template-literal site through the class field.
17
+ *
18
+ * Detection (AST-only, no type info):
19
+ * - File matches `lib/{resources,patterns,config}/aws/**\/*.ts` (via the
20
+ * `files:` glob at the registration site in `fjall/eslint.config.mjs`).
21
+ * - NewExpression's 2nd argument is a TemplateLiteral.
22
+ * - The template literal contains an expression of shape `props.<X>`
23
+ * (or any function-parameter name whose declared type marks `<X>?`).
24
+ * - The interpolated field is declared optional in the file's local
25
+ * type/interface declarations.
26
+ *
27
+ * Pragmatic narrowing: we hard-code `props` as the parameter name (the
28
+ * codebase convention for every CDK construct constructor) AND require the
29
+ * file to declare an interface or type alias with a field matching the
30
+ * interpolated name marked optional. This keeps the rule precise — required
31
+ * fields are never flagged, even though they sit at the same site.
32
+ *
33
+ * Known limitation (multi-interface false positive): `optionalFields` is a
34
+ * Set scoped to the whole `create()` call — every `TSInterfaceDeclaration`
35
+ * and `TSTypeAliasDeclaration` in the file adds to the same pool. If file F
36
+ * declares `interface PropsA { vpc?: T }` AND `interface PropsB { vpc: T }`
37
+ * and a class typed `PropsB` interpolates `props.vpc`, the rule fires
38
+ * spuriously because the optional `vpc` from `PropsA` taints the set. The
39
+ * shape has not appeared in production code under this rule's glob
40
+ * (`lib/{resources,patterns,config}/aws/**\/*.ts`) — every file in scope
41
+ * declares one props interface per construct. If a future file legitimately
42
+ * declares multiple props interfaces with overlapping field names, either
43
+ * (a) split into sibling files, or (b) extend the rule to track
44
+ * field-optionality per-interface via the constructor parameter's
45
+ * `typeAnnotation` (would require per-class scope tracking).
46
+ *
47
+ * Canonical fix: read the optional in the constructor, store on `this` with
48
+ * a fallback (`this.<name>Value = props.<X> ?? <fallback>`), then use
49
+ * `this.<name>Value` in every template-literal site. See
50
+ * `aiDocs/research/2026-05-17-review-deltas.md § "Fixes (this run)"` and
51
+ * `fjall/components/infrastructure/lib/resources/aws/database/rdsInstance.ts:119`.
52
+ */
53
+
54
+ /** @type {import('eslint').Rule.RuleModule} */
55
+ export default {
56
+ meta: {
57
+ type: "problem",
58
+ docs: {
59
+ description:
60
+ "Disallow optionally-typed `props.<X>` interpolation in CDK construct IDs (template-literal 2nd arg of `new ...`). Optional fields produce the literal string 'undefined' in CFN logical IDs when callers omit them.",
61
+ category: "Possible Errors",
62
+ recommended: true
63
+ },
64
+ messages: {
65
+ optionalInConstructId:
66
+ "Construct ID interpolates `props.{{field}}` which is optionally typed; when the caller omits this field the literal string 'undefined' is baked into the CloudFormation logical ID. Resolve in the constructor (`this.{{field}}Value = props.{{field}} ?? <fallback>`) and use `this.{{field}}Value` here. See aiDocs/research/2026-05-17-review-deltas.md § M-9."
67
+ },
68
+ schema: []
69
+ },
70
+
71
+ create(context) {
72
+ const optionalFields = new Set();
73
+
74
+ function collectFromMembers(members) {
75
+ if (!Array.isArray(members)) return;
76
+ for (const member of members) {
77
+ if (
78
+ member.type === "TSPropertySignature" &&
79
+ member.optional === true &&
80
+ member.key &&
81
+ member.key.type === "Identifier"
82
+ ) {
83
+ optionalFields.add(member.key.name);
84
+ }
85
+ }
86
+ }
87
+
88
+ return {
89
+ TSInterfaceDeclaration(node) {
90
+ if (node.body) collectFromMembers(node.body.body);
91
+ },
92
+ TSTypeLiteral(node) {
93
+ collectFromMembers(node.members);
94
+ },
95
+ NewExpression(node) {
96
+ const constructId = node.arguments && node.arguments[1];
97
+ if (!constructId || constructId.type !== "TemplateLiteral") return;
98
+ for (const expr of constructId.expressions) {
99
+ if (
100
+ expr.type === "MemberExpression" &&
101
+ expr.computed === false &&
102
+ expr.object &&
103
+ expr.object.type === "Identifier" &&
104
+ expr.object.name === "props" &&
105
+ expr.property &&
106
+ expr.property.type === "Identifier" &&
107
+ optionalFields.has(expr.property.name)
108
+ ) {
109
+ context.report({
110
+ node: expr,
111
+ messageId: "optionalInConstructId",
112
+ data: { field: expr.property.name }
113
+ });
114
+ }
115
+ }
116
+ }
117
+ };
118
+ }
119
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * ESLint Rule: no-vacuous-cdk-synth-regex
3
+ *
4
+ * Flags regex literals with NO metacharacters (no `.`, `*`, `+`, `?`, `^`,
5
+ * `$`, `[`, `\`, `(`, `|`) used inside `.test(...)` calls in test files.
6
+ * The bug shape: `expect(arr.some((id) => /BackupTaskTaskRole/.test(id))).toBe(false)`
7
+ * — the literal pattern claims to guard against an ID containing the
8
+ * concatenated tokens, but CDK synth output interleaves construct path
9
+ * segments between concept tokens (e.g. the actual synthesised logical ID
10
+ * is `ClickHouseComputeComputeClickHouseBackupTaskTaskDefinitionTaskRole40342F49`),
11
+ * so the literal pattern never matches and the assertion passes whether
12
+ * the regression has occurred or not.
13
+ *
14
+ * Per `.claude/rules/code-quality.md § "Regression Tests Must Distinguish
15
+ * from the Bug Shape"`. The 2026-05-10 ClickHouse review found this exact
16
+ * shape at `clickhouseBackup.test.ts:132`; the canonical fix replaced
17
+ * `/BackupTaskTaskRole/` with `/BackupTask.*TaskRole/` (allowing intervening
18
+ * synth-output segments). This rule shifts that detection left to author-time.
19
+ *
20
+ * Detection (heuristic):
21
+ * 1. File must be a test file (`*.test.ts`, `*.test.tsx`, or under `__tests__/`).
22
+ * 2. Node must be a regex Literal where `node.regex.pattern` matches
23
+ * `^[A-Za-z0-9_]+$` (purely word chars; no metacharacters).
24
+ * 3. Pattern must contain at least one uppercase letter AND be at least
25
+ * 8 chars long (heuristic for "looks like a CDK construct ID token
26
+ * concatenation" — filters out short literal regex like `/abc/` or
27
+ * `/error/` that are usually fine).
28
+ * 4. Parent chain must be `<regex>.test(<arg>)` — i.e. the regex is the
29
+ * object of a `.test()` member call.
30
+ *
31
+ * False-positive escape: developers can suppress with
32
+ * `// eslint-disable-next-line fjall/no-vacuous-cdk-synth-regex` on the line.
33
+ * Legitimate uses where the regex genuinely should match an exact substring
34
+ * with no synth-output interleaving (rare in CDK assertion contexts) are
35
+ * better expressed as `value.includes("...")` anyway.
36
+ *
37
+ * Detection ceiling: only regex Literals are walked. The constructor form
38
+ * `new RegExp("BackupTaskTaskRole").test(id)` bypasses detection because
39
+ * its pattern is a string expression, not a `Literal` node with a `.regex`
40
+ * property. CDK assertions almost never use the constructor form, but if
41
+ * they do, the same bug shape lands undetected — pair this rule with the
42
+ * "Regression Tests Must Distinguish from the Bug Shape" review discipline
43
+ * for any test pinning a synth-output assertion.
44
+ */
45
+
46
+ const PATTERN_HEURISTIC = /^[A-Za-z0-9_]+$/;
47
+ const HAS_UPPERCASE = /[A-Z]/;
48
+ const MIN_PATTERN_LENGTH = 8;
49
+
50
+ /** @type {import('eslint').Rule.RuleModule} */
51
+ export default {
52
+ meta: {
53
+ type: "problem",
54
+ docs: {
55
+ description:
56
+ "Disallow literal-only regex (no wildcards) in `.test()` calls inside test files when the pattern looks like a CDK construct-ID token concatenation. CDK synth output interleaves construct path segments between concept tokens, so the literal regex silently never matches.",
57
+ category: "Possible Errors",
58
+ recommended: true
59
+ },
60
+ messages: {
61
+ vacuousRegex:
62
+ 'Literal regex `/{{pattern}}/` has no wildcards and looks like a CDK construct-ID token concatenation. CDK synth output interleaves construct path segments between concept tokens, so this literal pattern likely never matches the actual synthesised logical ID — the assertion would pass whether the regression has occurred or not. Either insert `.*` between concept tokens (e.g. `/Foo.*Bar/`) to allow intervening segments, or use `value.includes("{{pattern}}")` if you genuinely need an exact substring match. See .claude/rules/code-quality.md § "Regression Tests Must Distinguish from the Bug Shape".'
63
+ },
64
+ schema: []
65
+ },
66
+
67
+ create(context) {
68
+ const filename = context.filename ?? context.getFilename();
69
+ const isTestFile =
70
+ /\.test\.[cm]?[jt]sx?$/.test(filename) || /__tests__\//.test(filename);
71
+ if (!isTestFile) return {};
72
+
73
+ return {
74
+ Literal(node) {
75
+ if (!node.regex) return;
76
+ const pattern = node.regex.pattern;
77
+ if (!PATTERN_HEURISTIC.test(pattern)) return;
78
+ if (!HAS_UPPERCASE.test(pattern)) return;
79
+ if (pattern.length < MIN_PATTERN_LENGTH) return;
80
+
81
+ const parent = node.parent;
82
+ if (
83
+ !parent ||
84
+ parent.type !== "MemberExpression" ||
85
+ parent.object !== node ||
86
+ parent.property.type !== "Identifier" ||
87
+ parent.property.name !== "test"
88
+ ) {
89
+ return;
90
+ }
91
+
92
+ const grandparent = parent.parent;
93
+ if (
94
+ !grandparent ||
95
+ grandparent.type !== "CallExpression" ||
96
+ grandparent.callee !== parent
97
+ ) {
98
+ return;
99
+ }
100
+
101
+ context.report({
102
+ node,
103
+ messageId: "vacuousRegex",
104
+ data: { pattern }
105
+ });
106
+ }
107
+ };
108
+ }
109
+ };