@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,79 @@
1
+ /**
2
+ * ESLint Rule: no-bare-sdk-abort-timeout
3
+ *
4
+ * Flags a bare `AbortSignal.timeout(...)` in deploy-core. A per-request timeout
5
+ * passed directly to an AWS SDK `.send()` ignores the caller's shutdown signal
6
+ * (`DeployParams.abortSignal` / the worker SIGTERM AbortController), so an
7
+ * in-flight call stalls graceful shutdown for the full timeout. Route through
8
+ * `composeSdkAbortSignal(abortSignal, timeoutMs?)` from
9
+ * `deploy-core/src/aws/organisations/types.ts` so the timeout composes with the
10
+ * caller signal via `AbortSignal.any`, and thread the caller's `abortSignal`.
11
+ *
12
+ * Permitted shape — `AbortSignal.timeout(ms)` as a direct element of
13
+ * `AbortSignal.any([...])` (the sanctioned inline composition). The canonical
14
+ * helper lives in `types.ts`, which is excluded from this rule at config level.
15
+ *
16
+ * Per .claude/rules/robustness-standards.md § "SDK probe helpers take optional
17
+ * abortSignal and compose with the timeout".
18
+ */
19
+
20
+ function isAbortSignalTimeout(node) {
21
+ return (
22
+ node &&
23
+ node.type === "CallExpression" &&
24
+ node.callee.type === "MemberExpression" &&
25
+ !node.callee.computed &&
26
+ node.callee.object.type === "Identifier" &&
27
+ node.callee.object.name === "AbortSignal" &&
28
+ node.callee.property.type === "Identifier" &&
29
+ node.callee.property.name === "timeout"
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Returns true iff `node` is a direct element of the array literal passed to
35
+ * `AbortSignal.any(...)` — the one sanctioned raw-timeout shape.
36
+ */
37
+ function isInsideAbortSignalAny(node) {
38
+ const arrayParent = node.parent;
39
+ if (!arrayParent || arrayParent.type !== "ArrayExpression") return false;
40
+ const callParent = arrayParent.parent;
41
+ return (
42
+ callParent &&
43
+ callParent.type === "CallExpression" &&
44
+ callParent.callee.type === "MemberExpression" &&
45
+ !callParent.callee.computed &&
46
+ callParent.callee.object.type === "Identifier" &&
47
+ callParent.callee.object.name === "AbortSignal" &&
48
+ callParent.callee.property.type === "Identifier" &&
49
+ callParent.callee.property.name === "any"
50
+ );
51
+ }
52
+
53
+ /** @type {import('eslint').Rule.RuleModule} */
54
+ export default {
55
+ meta: {
56
+ type: "problem",
57
+ docs: {
58
+ description:
59
+ "Disallow bare AbortSignal.timeout() in deploy-core SDK code — compose with the caller shutdown signal via composeSdkAbortSignal().",
60
+ category: "Possible Errors",
61
+ recommended: true
62
+ },
63
+ messages: {
64
+ bareSdkTimeout:
65
+ "Bare AbortSignal.timeout() ignores the caller shutdown signal — an in-flight SDK call stalls SIGTERM for the full timeout. Route through composeSdkAbortSignal(abortSignal, timeoutMs?) from organisations/types.js and thread the caller's abortSignal. If this is a genuine standalone timeout, add an eslint-disable with a reason."
66
+ },
67
+ schema: []
68
+ },
69
+
70
+ create(context) {
71
+ return {
72
+ CallExpression(node) {
73
+ if (!isAbortSignalTimeout(node)) return;
74
+ if (isInsideAbortSignalAny(node)) return;
75
+ context.report({ node, messageId: "bareSdkTimeout" });
76
+ }
77
+ };
78
+ }
79
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @fileoverview ESLint rule: no-classic-connected-account-assume.
3
+ *
4
+ * `webapp-standards.md` § "Connected-account sessions go through
5
+ * CredentialProvider web-identity, never hand-rolled classic STS" requires that
6
+ * any webapp service obtaining a session inside a CONNECTED AWS account route
7
+ * through `CredentialProvider.assumeRole(roleArn, region, organisationId)`
8
+ * (`fjall-discovery/utils/credential-provider.ts`), which mints a Fjall OIDC JWT
9
+ * (subject `org:{orgId}:*`) and exchanges it via `AssumeRoleWithWebIdentity`.
10
+ *
11
+ * A hand-rolled classic `new AssumeRoleCommand(...)` is issued from the app
12
+ * container's ambient ECS task-role credentials. Connected `FjallDeploy<orgId>`
13
+ * roles trust the OIDC web identity, NOT the app task role — so classic
14
+ * AssumeRole returns AccessDenied at runtime, invisible to typecheck and lint.
15
+ *
16
+ * The rule flags `new AssumeRoleCommand(...)`. It deliberately does NOT flag
17
+ * `new STSClient(...)`: the web-identity path and every post-assume client
18
+ * legitimately construct an STSClient, so flagging it would false-positive.
19
+ *
20
+ * The one legitimate classic-assume site is the INITIAL connection handshake
21
+ * (`app/routes/api/aws-accounts/verify.ts`): pre-OIDC, `FjallAudit<orgId>`
22
+ * trusts the Fjall platform account + an `ExternalId` (classic cross-account
23
+ * trust); the web identity does not exist until the account is deployed. That
24
+ * site carries an inline `eslint-disable-next-line` with its reason.
25
+ *
26
+ * Authored after the same defect shipped three times in two sessions
27
+ * (`executeBucketCure`, `orgMembershipCheck.listOrgMemberAccountIds`,
28
+ * `promote-root.confirmManagementAccount` — webapp `50f02107` + `a0f762e2`),
29
+ * each an AccessDenied caught only at prod E2E. Scoped to the webapp via
30
+ * `webapp/eslint.config.mjs` only — the CLI / deploy-core legitimately issue
31
+ * classic AssumeRole with the operator's own credentials.
32
+ */
33
+
34
+ /** @type {import('eslint').Rule.RuleModule} */
35
+ const noClassicConnectedAccountAssume = {
36
+ meta: {
37
+ type: "problem",
38
+ docs: {
39
+ description:
40
+ "Connected-account sessions must use CredentialProvider.assumeRole (OIDC web-identity), not a hand-rolled classic new AssumeRoleCommand."
41
+ },
42
+ messages: {
43
+ classicConnectedAssume:
44
+ 'Use `CredentialProvider.assumeRole(roleArn, region, organisationId)` (OIDC web-identity) instead of `new AssumeRoleCommand(...)`. The app task role is not trusted by connected `FjallDeploy<orgId>` roles, so classic AssumeRole returns AccessDenied at runtime — invisible to typecheck/lint. The only legitimate classic-assume site is the initial connection handshake against `FjallAudit<orgId>` (trusts the platform account + ExternalId pre-OIDC); allow it with an inline `eslint-disable-next-line fjall/no-classic-connected-account-assume -- <reason>`. See webapp-standards.md § "Connected-account sessions go through CredentialProvider web-identity".'
45
+ },
46
+ schema: []
47
+ },
48
+ create(context) {
49
+ return {
50
+ NewExpression(node) {
51
+ if (
52
+ node.callee.type === "Identifier" &&
53
+ node.callee.name === "AssumeRoleCommand"
54
+ ) {
55
+ context.report({ node, messageId: "classicConnectedAssume" });
56
+ }
57
+ }
58
+ };
59
+ }
60
+ };
61
+
62
+ export default noClassicConnectedAccountAssume;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * ESLint Rule: no-clickhouse-internal-reexport
3
+ *
4
+ * Forbids re-exporting ClickHouse-internal plumbing symbols from public
5
+ * barrels of `@fjall/components-infrastructure`. The runtime hygiene test
6
+ * (`lib/__tests__/clickhouseHygiene.test.ts`) walks the resolved public
7
+ * surface; this rule shifts the failure left to author-time so a reviewer
8
+ * sees the lint error before running tests.
9
+ *
10
+ * Forbidden surface symbols (the hygiene test's `forbiddenRuntimeSymbols`):
11
+ * - ClickHouseInstance
12
+ * - ServiceDiscoveryNamespace
13
+ * - buildClickHouseUserData
14
+ * - createClickHouseSecurityGroup
15
+ *
16
+ * Catches the directly-named re-export shapes:
17
+ * export { ClickHouseInstance } from "./...";
18
+ * export { ClickHouseInstance };
19
+ * export { ClickHouseInstance as Foo }; // original name still fails
20
+ * export class ClickHouseInstance { ... }
21
+ * export function buildClickHouseUserData() { ... }
22
+ *
23
+ * Does NOT statically resolve `export * from "./internal.js"` chains —
24
+ * the runtime hygiene test catches those via `Object.keys(pkg)`. The two
25
+ * checks are complementary: this rule covers the static shape, the test
26
+ * covers the resolved surface.
27
+ */
28
+
29
+ const FORBIDDEN_SYMBOLS = new Set([
30
+ "ClickHouseInstance",
31
+ "ServiceDiscoveryNamespace",
32
+ "buildClickHouseUserData",
33
+ "createClickHouseSecurityGroup"
34
+ ]);
35
+
36
+ /** @type {import('eslint').Rule.RuleModule} */
37
+ export default {
38
+ meta: {
39
+ type: "problem",
40
+ docs: {
41
+ description:
42
+ "Disallow re-exporting ClickHouse-internal plumbing symbols from public barrels.",
43
+ category: "Best Practices",
44
+ recommended: true
45
+ },
46
+ messages: {
47
+ forbiddenReexport:
48
+ "Symbol `{{name}}` is ClickHouse-internal plumbing and must not appear on the public surface. Consume via the DatabaseFactory or a typed connector instead. See aiDocs/patterns/clickhouse-database-factory-pattern.md."
49
+ },
50
+ schema: []
51
+ },
52
+
53
+ create(context) {
54
+ function reportIfForbidden(name, node) {
55
+ if (FORBIDDEN_SYMBOLS.has(name)) {
56
+ context.report({
57
+ node,
58
+ messageId: "forbiddenReexport",
59
+ data: { name }
60
+ });
61
+ }
62
+ }
63
+
64
+ return {
65
+ ExportNamedDeclaration(node) {
66
+ if (node.declaration) {
67
+ const decl = node.declaration;
68
+ if (
69
+ (decl.type === "ClassDeclaration" ||
70
+ decl.type === "FunctionDeclaration") &&
71
+ decl.id &&
72
+ decl.id.type === "Identifier"
73
+ ) {
74
+ reportIfForbidden(decl.id.name, decl.id);
75
+ return;
76
+ }
77
+ if (decl.type === "VariableDeclaration") {
78
+ for (const variable of decl.declarations) {
79
+ if (variable.id && variable.id.type === "Identifier") {
80
+ reportIfForbidden(variable.id.name, variable.id);
81
+ }
82
+ }
83
+ return;
84
+ }
85
+ }
86
+
87
+ for (const specifier of node.specifiers) {
88
+ if (
89
+ specifier.type === "ExportSpecifier" &&
90
+ specifier.local &&
91
+ specifier.local.type === "Identifier"
92
+ ) {
93
+ reportIfForbidden(specifier.local.name, specifier);
94
+ }
95
+ }
96
+ }
97
+ };
98
+ }
99
+ };
@@ -0,0 +1,275 @@
1
+ /**
2
+ * ESLint Rule: no-duplicate-fjall-util-helper
3
+ *
4
+ * Flags two shapes that re-implement canonical `@fjall/util` helpers in files
5
+ * that already import from `@fjall/util`. The canonical helpers (`getErrorMessage`,
6
+ * `toError`, `maskSensitiveOutput`, `filterDangerousEnvVars`) are the single
7
+ * source of truth — duplicating their shape locally creates silent drift the
8
+ * moment the canonical implementation tightens (a new credential regex in
9
+ * `maskSensitiveOutput`, a stack-preservation tweak in `toError`).
10
+ *
11
+ * Two detection shapes:
12
+ *
13
+ * 1. Local declaration shadowing a canonical name. Five AST shapes covered:
14
+ * - `function getErrorMessage(...)` (plain)
15
+ * - `export function getErrorMessage(...)` (named export)
16
+ * - `export default function getErrorMessage(...)` (default export)
17
+ * - `const getErrorMessage = (...) => ...` (var arrow / fn-expr)
18
+ * - `export const getErrorMessage = (...) => ...` (exported var arrow)
19
+ * Fires on the FIRST occurrence (1-site threshold) — a declaration is
20
+ * always wrong because callers reference the local symbol rather than
21
+ * the canonical.
22
+ *
23
+ * 2. Inline reimplementation of `getErrorMessage` / `toError` patterns at 2+
24
+ * sites in the same file. The coupled-values rule
25
+ * (`.claude/rules/code-quality.md § "Coupled values"`) says drift between
26
+ * two canonical-helper sites is a silent bug — this rule mechanises that
27
+ * at the 2-site threshold for the two patterns whose inline shape is
28
+ * unambiguous:
29
+ * - `<id> instanceof Error ? <id>.message : String(<id>)` → use `getErrorMessage`
30
+ * - `<id> instanceof Error ? <id> : new Error(String(<id>))` → use `toError`
31
+ *
32
+ * `maskSensitiveOutput` and `filterDangerousEnvVars` have no inlineable
33
+ * shape (the regex/blocklist is too large to inline), so they only fire
34
+ * on shape 1.
35
+ *
36
+ * Gate: `@fjall/util` (or `@fjall/util/<subpath>`) must be imported in the
37
+ * file. Files with no path to the canonical helpers are not bothered.
38
+ *
39
+ * Limitations:
40
+ * - Same-file only. A helper defined in a sibling module and re-imported
41
+ * locally is not detected — this is the `helper-indirection` shape
42
+ * already covered by `mask-error-message-at-boundary`.
43
+ * - Name match only. A function named `getErrorMessage` that does NOT
44
+ * return the canonical shape (e.g. returns `err.code`) is still flagged
45
+ * — the rule trusts the name. If you genuinely need a different
46
+ * extractor, rename it (`getErrorCode`, `extractFirstLine`).
47
+ * - No autofix. Folding inline shapes into a canonical-import edit requires
48
+ * ensuring the import list contains the canonical name; the rule cannot
49
+ * make that edit safely in all cases.
50
+ *
51
+ * Per `.claude/rules/webapp-standards.md § "Worker Scripts (webapp/scripts/**)"`
52
+ * and `.claude/rules/code-quality.md § "Coupled values"`.
53
+ */
54
+
55
+ const CANONICAL_HELPERS = new Set([
56
+ "getErrorMessage",
57
+ "toError",
58
+ "maskSensitiveOutput",
59
+ "filterDangerousEnvVars"
60
+ ]);
61
+
62
+ const FJALL_UTIL_RE = /^@fjall\/util(\/.*)?$/;
63
+
64
+ /**
65
+ * Match `<id> instanceof Error ? <id>.message : String(<id>)`.
66
+ * Returns the identifier name if matched, null otherwise.
67
+ */
68
+ function matchGetErrorMessagePattern(node) {
69
+ if (node.type !== "ConditionalExpression") return null;
70
+ const { test, consequent, alternate } = node;
71
+
72
+ if (
73
+ test.type !== "BinaryExpression" ||
74
+ test.operator !== "instanceof" ||
75
+ test.left.type !== "Identifier" ||
76
+ test.right.type !== "Identifier" ||
77
+ test.right.name !== "Error"
78
+ )
79
+ return null;
80
+ const idName = test.left.name;
81
+
82
+ if (
83
+ consequent.type !== "MemberExpression" ||
84
+ consequent.computed ||
85
+ consequent.object.type !== "Identifier" ||
86
+ consequent.object.name !== idName ||
87
+ consequent.property.type !== "Identifier" ||
88
+ consequent.property.name !== "message"
89
+ )
90
+ return null;
91
+
92
+ if (
93
+ alternate.type !== "CallExpression" ||
94
+ alternate.callee.type !== "Identifier" ||
95
+ alternate.callee.name !== "String" ||
96
+ alternate.arguments.length !== 1 ||
97
+ alternate.arguments[0].type !== "Identifier" ||
98
+ alternate.arguments[0].name !== idName
99
+ )
100
+ return null;
101
+
102
+ return idName;
103
+ }
104
+
105
+ /**
106
+ * Match `<id> instanceof Error ? <id> : new Error(String(<id>))`.
107
+ * Returns the identifier name if matched, null otherwise.
108
+ */
109
+ function matchToErrorPattern(node) {
110
+ if (node.type !== "ConditionalExpression") return null;
111
+ const { test, consequent, alternate } = node;
112
+
113
+ if (
114
+ test.type !== "BinaryExpression" ||
115
+ test.operator !== "instanceof" ||
116
+ test.left.type !== "Identifier" ||
117
+ test.right.type !== "Identifier" ||
118
+ test.right.name !== "Error"
119
+ )
120
+ return null;
121
+ const idName = test.left.name;
122
+
123
+ if (consequent.type !== "Identifier" || consequent.name !== idName)
124
+ return null;
125
+
126
+ if (
127
+ alternate.type !== "NewExpression" ||
128
+ alternate.callee.type !== "Identifier" ||
129
+ alternate.callee.name !== "Error" ||
130
+ alternate.arguments.length !== 1
131
+ )
132
+ return null;
133
+ const inner = alternate.arguments[0];
134
+ if (
135
+ inner.type !== "CallExpression" ||
136
+ inner.callee.type !== "Identifier" ||
137
+ inner.callee.name !== "String" ||
138
+ inner.arguments.length !== 1 ||
139
+ inner.arguments[0].type !== "Identifier" ||
140
+ inner.arguments[0].name !== idName
141
+ )
142
+ return null;
143
+
144
+ return idName;
145
+ }
146
+
147
+ /** @type {import('eslint').Rule.RuleModule} */
148
+ export default {
149
+ meta: {
150
+ type: "problem",
151
+ docs: {
152
+ description:
153
+ "Flag local declarations and 2+ inline reimplementations of canonical @fjall/util helpers in files that already import from @fjall/util.",
154
+ category: "Best Practices",
155
+ recommended: true
156
+ },
157
+ messages: {
158
+ duplicateCanonicalHelper:
159
+ 'Local `{{name}}` shadows the canonical `@fjall/util` export. Add `{{name}}` to your `@fjall/util` import and delete this declaration. See .claude/rules/webapp-standards.md § "Worker Scripts (webapp/scripts/**)" canonical-helper table.',
160
+ inlineDuplicateCanonicalHelper:
161
+ 'Inline `{{name}}` reimplementation (this file has {{count}}). Import `{{name}}` from `@fjall/util` and replace each call site — drift between canonical-helper sites is a silent bug. See .claude/rules/code-quality.md § "Coupled values: shared source at 2 occurrences".'
162
+ },
163
+ schema: []
164
+ },
165
+
166
+ create(context) {
167
+ let hasFjallUtilImport = false;
168
+ const localDeclarations = [];
169
+ const inlineGetErrorMessage = [];
170
+ const inlineToError = [];
171
+
172
+ return {
173
+ ImportDeclaration(node) {
174
+ if (
175
+ node.source &&
176
+ typeof node.source.value === "string" &&
177
+ FJALL_UTIL_RE.test(node.source.value)
178
+ ) {
179
+ hasFjallUtilImport = true;
180
+ }
181
+ },
182
+
183
+ "Program > FunctionDeclaration"(node) {
184
+ if (node.id && CANONICAL_HELPERS.has(node.id.name)) {
185
+ localDeclarations.push({ node, name: node.id.name });
186
+ }
187
+ },
188
+
189
+ "Program > ExportNamedDeclaration > FunctionDeclaration"(node) {
190
+ if (node.id && CANONICAL_HELPERS.has(node.id.name)) {
191
+ localDeclarations.push({ node, name: node.id.name });
192
+ }
193
+ },
194
+
195
+ "Program > VariableDeclaration > VariableDeclarator"(node) {
196
+ if (
197
+ node.id &&
198
+ node.id.type === "Identifier" &&
199
+ CANONICAL_HELPERS.has(node.id.name) &&
200
+ node.init &&
201
+ (node.init.type === "ArrowFunctionExpression" ||
202
+ node.init.type === "FunctionExpression")
203
+ ) {
204
+ localDeclarations.push({ node, name: node.id.name });
205
+ }
206
+ },
207
+
208
+ "Program > ExportNamedDeclaration > VariableDeclaration > VariableDeclarator"(
209
+ node
210
+ ) {
211
+ if (
212
+ node.id &&
213
+ node.id.type === "Identifier" &&
214
+ CANONICAL_HELPERS.has(node.id.name) &&
215
+ node.init &&
216
+ (node.init.type === "ArrowFunctionExpression" ||
217
+ node.init.type === "FunctionExpression")
218
+ ) {
219
+ localDeclarations.push({ node, name: node.id.name });
220
+ }
221
+ },
222
+
223
+ "Program > ExportDefaultDeclaration > FunctionDeclaration"(node) {
224
+ if (node.id && CANONICAL_HELPERS.has(node.id.name)) {
225
+ localDeclarations.push({ node, name: node.id.name });
226
+ }
227
+ },
228
+
229
+ ConditionalExpression(node) {
230
+ if (matchGetErrorMessagePattern(node)) {
231
+ inlineGetErrorMessage.push(node);
232
+ } else if (matchToErrorPattern(node)) {
233
+ inlineToError.push(node);
234
+ }
235
+ },
236
+
237
+ "Program:exit"() {
238
+ if (!hasFjallUtilImport) return;
239
+
240
+ for (const { node, name } of localDeclarations) {
241
+ context.report({
242
+ node,
243
+ messageId: "duplicateCanonicalHelper",
244
+ data: { name }
245
+ });
246
+ }
247
+
248
+ if (inlineGetErrorMessage.length >= 2) {
249
+ for (const node of inlineGetErrorMessage) {
250
+ context.report({
251
+ node,
252
+ messageId: "inlineDuplicateCanonicalHelper",
253
+ data: {
254
+ name: "getErrorMessage",
255
+ count: String(inlineGetErrorMessage.length)
256
+ }
257
+ });
258
+ }
259
+ }
260
+ if (inlineToError.length >= 2) {
261
+ for (const node of inlineToError) {
262
+ context.report({
263
+ node,
264
+ messageId: "inlineDuplicateCanonicalHelper",
265
+ data: {
266
+ name: "toError",
267
+ count: String(inlineToError.length)
268
+ }
269
+ });
270
+ }
271
+ }
272
+ }
273
+ };
274
+ }
275
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * ESLint Rule: no-empty-string-env-fallthrough
3
+ *
4
+ * Flags `process.env.X !== undefined` and `process.env.X ?? "fallback"`
5
+ * patterns when X carries data (path/dir/url/key/token/host). CI shells,
6
+ * Kubernetes ConfigMaps, and docker-compose env-files can legitimately
7
+ * export `X=""`, which both forms accept as "set" — silently producing
8
+ * empty paths, host-less URLs, or empty credentials downstream.
9
+ *
10
+ * Required shape: explicit `value !== undefined && value !== ""` check.
11
+ *
12
+ * Per .claude/rules/robustness-standards.md § "Environment Variable
13
+ * Truthy Checks". Recurrence-driven escalation from rule-doc → ESLint
14
+ * (3 separate /review passes flagged this exact pattern in 5 days).
15
+ */
16
+
17
+ const DATA_ENV_VAR_PATTERN = /_(DIR|PATH|URL|HOST|KEY|TOKEN|ID|FILE|ROOT)$/;
18
+
19
+ /** @type {import('eslint').Rule.RuleModule} */
20
+ export default {
21
+ meta: {
22
+ type: "problem",
23
+ docs: {
24
+ description:
25
+ "Disallow process.env.X !== undefined and process.env.X ?? fallback for data env vars; require explicit empty-string rejection.",
26
+ category: "Possible Errors",
27
+ recommended: true
28
+ },
29
+ messages: {
30
+ undefinedCheck:
31
+ 'process.env.{{name}} !== undefined accepts the empty string. CI/ConfigMap/compose can export {{name}}="". Use: const v = process.env.{{name}}; v !== undefined && v !== ""',
32
+ nullishCoalesce:
33
+ 'process.env.{{name}} ?? fallback accepts the empty string (?? only falls through on null/undefined). Use explicit: v !== undefined && v !== "" ? v : fallback'
34
+ },
35
+ schema: []
36
+ },
37
+
38
+ create(context) {
39
+ return {
40
+ BinaryExpression(node) {
41
+ if (node.operator !== "!==" && node.operator !== "===") return;
42
+ const envName = extractProcessEnvName(node.left);
43
+ if (!envName) return;
44
+ if (!isUndefinedLiteral(node.right)) return;
45
+ if (!DATA_ENV_VAR_PATTERN.test(envName)) return;
46
+
47
+ context.report({
48
+ node,
49
+ messageId: "undefinedCheck",
50
+ data: { name: envName }
51
+ });
52
+ },
53
+
54
+ LogicalExpression(node) {
55
+ if (node.operator !== "??") return;
56
+ const envName = extractProcessEnvName(node.left);
57
+ if (!envName) return;
58
+ if (!DATA_ENV_VAR_PATTERN.test(envName)) return;
59
+
60
+ // Allow `?? ""` — caller is explicitly opting into empty as default
61
+ if (
62
+ node.right.type === "Literal" &&
63
+ typeof node.right.value === "string" &&
64
+ node.right.value === ""
65
+ ) {
66
+ return;
67
+ }
68
+
69
+ context.report({
70
+ node,
71
+ messageId: "nullishCoalesce",
72
+ data: { name: envName }
73
+ });
74
+ }
75
+ };
76
+ }
77
+ };
78
+
79
+ /**
80
+ * Returns the env-var name when node is `process.env.X` or `process.env["X"]`,
81
+ * otherwise null.
82
+ */
83
+ function extractProcessEnvName(node) {
84
+ if (!node || node.type !== "MemberExpression") return null;
85
+ const object = node.object;
86
+ if (
87
+ object.type !== "MemberExpression" ||
88
+ object.object.type !== "Identifier" ||
89
+ object.object.name !== "process" ||
90
+ object.property.type !== "Identifier" ||
91
+ object.property.name !== "env"
92
+ ) {
93
+ return null;
94
+ }
95
+ if (node.property.type === "Identifier") return node.property.name;
96
+ if (
97
+ node.property.type === "Literal" &&
98
+ typeof node.property.value === "string"
99
+ ) {
100
+ return node.property.value;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function isUndefinedLiteral(node) {
106
+ if (!node) return false;
107
+ if (node.type === "Identifier" && node.name === "undefined") return true;
108
+ if (
109
+ node.type === "UnaryExpression" &&
110
+ node.operator === "void" &&
111
+ node.argument.type === "Literal" &&
112
+ node.argument.value === 0
113
+ ) {
114
+ return true;
115
+ }
116
+ return false;
117
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * ESLint Rule: no-l2-asg-lifecycle-hook
3
+ *
4
+ * Disallows `autoScalingGroup.addLifecycleHook(id, props)` — the AWS CDK L2
5
+ * method that emits a standalone `AWS::AutoScaling::LifecycleHook` resource.
6
+ * The standalone resource is created AFTER the ASG, so on a fresh stack the
7
+ * first instance launches BEFORE the hook is attached, and the hook fires
8
+ * zero notifications for that instance.
9
+ *
10
+ * Production-confirmed in account 985539798308 on 2026-05-19: the ClickHouse
11
+ * PDV LAUNCHING hook never fired for the first instance — the Lambda log
12
+ * group only saw a TEST_NOTIFICATION ~30s after instance launch. The volume
13
+ * was never attached, cloud-init timed out, ECS agent never started.
14
+ *
15
+ * Required shape: route through `attachInlineAsgLifecycleHook` at
16
+ * `components/infrastructure/lib/resources/aws/compute/asgInlineLifecycleHook.ts`,
17
+ * which appends to `CfnAutoScalingGroup.lifecycleHookSpecificationList`. The
18
+ * spec is then atomic with ASG creation — the ASG cannot exist in a state
19
+ * where it has instances but no hooks.
20
+ *
21
+ * Discriminator: the L2 ASG signature is `addLifecycleHook(id, props)` —
22
+ * 2 arguments. The ECS service signature is `addLifecycleHook(target)` — 1
23
+ * argument. This rule flags any 2-arg `.addLifecycleHook(_, _)` call,
24
+ * which uniquely identifies the ASG L2 footgun regardless of whether the
25
+ * first arg is a string literal, a template literal, or a variable
26
+ * reference.
27
+ */
28
+
29
+ /** @type {import('eslint').Rule.RuleModule} */
30
+ export default {
31
+ meta: {
32
+ type: "problem",
33
+ docs: {
34
+ description:
35
+ "Disallow autoScalingGroup.addLifecycleHook(id, props) — re-introduces the CFN race where the standalone hook resource attaches AFTER the ASG's first instance launches. Use attachInlineAsgLifecycleHook from lib/resources/aws/compute/asgInlineLifecycleHook.ts.",
36
+ category: "Possible Errors",
37
+ recommended: true
38
+ },
39
+ messages: {
40
+ l2AsgHook:
41
+ "ASG L2 `addLifecycleHook(id, props)` emits a standalone AWS::AutoScaling::LifecycleHook resource that creates AFTER the ASG — the first instance launches before the hook is attached (production-confirmed regression, account 985539798308, 2026-05-19). Use `attachInlineAsgLifecycleHook` from `lib/resources/aws/compute/asgInlineLifecycleHook.ts`, which appends to `CfnAutoScalingGroup.lifecycleHookSpecificationList` atomically with the ASG."
42
+ },
43
+ schema: []
44
+ },
45
+
46
+ create(context) {
47
+ return {
48
+ CallExpression(node) {
49
+ if (node.callee.type !== "MemberExpression") return;
50
+ const property = node.callee.property;
51
+ if (property.type !== "Identifier") return;
52
+ if (property.name !== "addLifecycleHook") return;
53
+ if (node.arguments.length !== 2) return;
54
+ context.report({
55
+ node,
56
+ messageId: "l2AsgHook"
57
+ });
58
+ }
59
+ };
60
+ }
61
+ };