@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,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
|
+
};
|