@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,339 @@
1
+ /**
2
+ * ESLint Rule: no-mask-identity-mock
3
+ *
4
+ * Flags vitest `vi.mock(...)` factories that replace a masking helper
5
+ * (`maskSensitiveOutput`, `maskSecrets`, `redact*`, `sanitis*`) with a
6
+ * shape that neuters the security boundary the test exists at. Two
7
+ * classes:
8
+ *
9
+ * 1. **Identity arrow** — `(s) => s` (or block-bodied equivalent). The
10
+ * masker becomes a pass-through; tests asserting "raw error text reaches
11
+ * the callback" pass whether or not the production code masked it.
12
+ *
13
+ * 2. **Partial-mask arrow** — the arrow's body produces a string that
14
+ * contains the parameter's value unchanged: template-literal sentinel
15
+ * wrappers (`(s) => \`«masked:${s}»\``), narrow `.replace()` chains
16
+ * (`(s) => s.replace(/AKIA[A-Z0-9]{16}/g, "***")`), and `param + "suffix"`
17
+ * concatenations. These shrink coverage to whatever the fixture happens
18
+ * to anticipate — every shape the production masker handles but the
19
+ * fixture omits ships unmasked. The 2026-05-24 pass found four sentinel
20
+ * wrappers across cli tests; the canonical fix routes through
21
+ * `vi.spyOn(util, "maskSensitiveOutput")` so the real masker runs AND
22
+ * call args remain assertable.
23
+ *
24
+ * Per .claude/rules/cli-standards.md § "Masking-mock identity neutering is
25
+ * FORBIDDEN" and security-standards.md § "Credential masking — 4th boundary".
26
+ *
27
+ * Canonical bad shapes:
28
+ * vi.mock("@fjall/util", async () => {
29
+ * const actual = await vi.importActual("@fjall/util");
30
+ * return { ...actual, maskSensitiveOutput: (s) => s };
31
+ * });
32
+ * vi.mock("@fjall/util", () => ({
33
+ * maskSensitiveOutput: vi.fn((input) => `«masked:${input}»`)
34
+ * }));
35
+ * vi.mock("@fjall/util", () => ({
36
+ * maskSensitiveOutput: (s) => s.replace(/AKIA[A-Z0-9]{16}/g, "***")
37
+ * }));
38
+ *
39
+ * Canonical good shape: don't mock the masker at all; let the real
40
+ * implementation run. If a test needs to inspect the pre-mask string, use
41
+ * `vi.spyOn(util, "maskSensitiveOutput")` to assert call args while the
42
+ * real impl produces masked output for downstream assertions.
43
+ */
44
+
45
+ const MASK_NAMES_RE = /^(mask|redact|saniti[sz]e)/i;
46
+
47
+ /**
48
+ * Returns true iff `node` is `(<param>) => <param>` — an arrow function
49
+ * whose body is an Identifier referencing its sole parameter.
50
+ */
51
+ function isIdentityArrow(node) {
52
+ if (!node) return false;
53
+ if (node.type !== "ArrowFunctionExpression") return false;
54
+ if (node.params.length !== 1) return false;
55
+
56
+ const param = node.params[0];
57
+ if (param.type !== "Identifier") return false;
58
+
59
+ const paramName = param.name;
60
+ const body = node.body;
61
+
62
+ if (body.type === "Identifier" && body.name === paramName) return true;
63
+
64
+ if (body.type === "BlockStatement") {
65
+ if (body.body.length !== 1) return false;
66
+ const stmt = body.body[0];
67
+ if (stmt.type !== "ReturnStatement") return false;
68
+ if (!stmt.argument) return false;
69
+ return (
70
+ stmt.argument.type === "Identifier" && stmt.argument.name === paramName
71
+ );
72
+ }
73
+
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Returns true iff `node` is `vi.fn(<identityArrow>)` — wraps an identity
79
+ * arrow in a vitest spy. Common shape that escapes a bare `isIdentityArrow`
80
+ * check because the AST root is a CallExpression, not an ArrowFunctionExpression.
81
+ */
82
+ function isViFnWrappingIdentityArrow(node) {
83
+ return isViFnWrapping(node, isIdentityArrow);
84
+ }
85
+
86
+ /**
87
+ * Returns true iff `node` is `vi.fn(<inner>)` for the supplied detector.
88
+ * Generalisation of the identity-wrapper check so the partial-mask
89
+ * detection can reuse the same vi.fn unwrapping logic.
90
+ */
91
+ function isViFnWrapping(node, detector) {
92
+ if (!node || node.type !== "CallExpression") return false;
93
+ if (node.arguments.length !== 1) return false;
94
+
95
+ const callee = node.callee;
96
+ if (callee.type !== "MemberExpression") return false;
97
+ if (callee.computed) return false;
98
+ if (callee.object.type !== "Identifier" || callee.object.name !== "vi") {
99
+ return false;
100
+ }
101
+ if (callee.property.type !== "Identifier" || callee.property.name !== "fn") {
102
+ return false;
103
+ }
104
+
105
+ return detector(node.arguments[0]);
106
+ }
107
+
108
+ /**
109
+ * Returns true iff `expr` references the named parameter directly (an
110
+ * Identifier with the same name). The partial-mask detection uses this
111
+ * as the leaf check inside template literals, binary expressions, and
112
+ * member-expression chains.
113
+ */
114
+ function refsParam(expr, paramName) {
115
+ return expr != null && expr.type === "Identifier" && expr.name === paramName;
116
+ }
117
+
118
+ /**
119
+ * Walks a `+` BinaryExpression tree returning true iff any leaf is the
120
+ * named parameter. `(s) => "prefix" + s` and `(s) => s + ":" + extra`
121
+ * both match; `(s) => "prefix" + helper(s)` does not (the param flows
122
+ * through a function call that may transform it).
123
+ */
124
+ function binaryExprRefsParam(expr, paramName) {
125
+ if (refsParam(expr, paramName)) return true;
126
+ if (expr.type !== "BinaryExpression") return false;
127
+ if (expr.operator !== "+") return false;
128
+ return (
129
+ binaryExprRefsParam(expr.left, paramName) ||
130
+ binaryExprRefsParam(expr.right, paramName)
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Walks a CallExpression's MemberExpression chain looking for the
136
+ * named parameter as the root object. `s.replace(/x/g, "y")` matches;
137
+ * `s.replace(...).slice(0, 4)` matches (the chain still roots at `s`);
138
+ * `realMask(s).slice(...)` does NOT match (the root is a call, not the
139
+ * param) — that shape is treated as a delegate-then-truncate pattern.
140
+ */
141
+ function callChainRootedAtParam(expr, paramName) {
142
+ let cur = expr;
143
+ while (
144
+ cur &&
145
+ cur.type === "CallExpression" &&
146
+ cur.callee &&
147
+ cur.callee.type === "MemberExpression"
148
+ ) {
149
+ cur = cur.callee.object;
150
+ }
151
+ return refsParam(cur, paramName);
152
+ }
153
+
154
+ /**
155
+ * Returns true iff `node` is a partial-mask arrow — a single-parameter
156
+ * arrow whose body produces a string containing the parameter's value
157
+ * unchanged. Three shapes covered:
158
+ * 1. Template literal with the param interpolated.
159
+ * 2. String-method chain rooted at the param.
160
+ * 3. `+` concatenation involving the param.
161
+ *
162
+ * Functions that delegate to a callee `(s) => fn(s)` or constant returns
163
+ * `(s) => "[REDACTED]"` remain valid — those are legitimate stubs.
164
+ */
165
+ function isPartialMaskArrow(node) {
166
+ if (!node) return false;
167
+ if (node.type !== "ArrowFunctionExpression") return false;
168
+ if (node.params.length !== 1) return false;
169
+
170
+ const param = node.params[0];
171
+ if (param.type !== "Identifier") return false;
172
+ const paramName = param.name;
173
+
174
+ let body = node.body;
175
+ if (body.type === "BlockStatement") {
176
+ if (body.body.length !== 1) return false;
177
+ const stmt = body.body[0];
178
+ if (stmt.type !== "ReturnStatement" || !stmt.argument) return false;
179
+ body = stmt.argument;
180
+ }
181
+
182
+ if (body.type === "TemplateLiteral") {
183
+ return body.expressions.some((e) => refsParam(e, paramName));
184
+ }
185
+
186
+ if (body.type === "CallExpression") {
187
+ return callChainRootedAtParam(body, paramName);
188
+ }
189
+
190
+ if (body.type === "BinaryExpression" && body.operator === "+") {
191
+ return binaryExprRefsParam(body, paramName);
192
+ }
193
+
194
+ return false;
195
+ }
196
+
197
+ /**
198
+ * `vi.fn(<partialMaskArrow>)` wrapper detection — the same wrapper class
199
+ * the identity-arrow check has, applied to the partial-mask shapes.
200
+ */
201
+ function isViFnWrappingPartialMaskArrow(node) {
202
+ return isViFnWrapping(node, isPartialMaskArrow);
203
+ }
204
+
205
+ /**
206
+ * Maps a mock-value node onto the messageId of the rule violation it
207
+ * exhibits. Returns null when the value is acceptable (real impl
208
+ * delegation, constant stub, non-arrow).
209
+ */
210
+ function detectBadMaskOverride(node) {
211
+ if (isIdentityArrow(node) || isViFnWrappingIdentityArrow(node)) {
212
+ return "identityMaskMock";
213
+ }
214
+ if (isPartialMaskArrow(node) || isViFnWrappingPartialMaskArrow(node)) {
215
+ return "partialMaskMock";
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Walk an ObjectExpression's properties, reporting any property whose
222
+ * key matches the mask-name pattern AND whose value is an identity arrow
223
+ * (either bare `(s) => s` or wrapped in `vi.fn((s) => s)`).
224
+ */
225
+ function checkObjectProperties(context, objectNode) {
226
+ if (!objectNode || objectNode.type !== "ObjectExpression") return;
227
+ for (const prop of objectNode.properties) {
228
+ if (prop.type !== "Property") continue;
229
+ if (prop.computed) continue;
230
+
231
+ let keyName;
232
+ if (prop.key.type === "Identifier") keyName = prop.key.name;
233
+ else if (prop.key.type === "Literal" && typeof prop.key.value === "string")
234
+ keyName = prop.key.value;
235
+ else continue;
236
+
237
+ if (!MASK_NAMES_RE.test(keyName)) continue;
238
+ const messageId = detectBadMaskOverride(prop.value);
239
+ if (!messageId) continue;
240
+
241
+ context.report({
242
+ node: prop,
243
+ messageId,
244
+ data: { name: keyName }
245
+ });
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Find ObjectExpressions inside a function body's return statements,
251
+ * recursing into common nesting nodes (logical chains, ternaries,
252
+ * `await` of an `importActual` spread + override pattern).
253
+ */
254
+ function findReturnedObjects(node, found = []) {
255
+ if (!node) return found;
256
+
257
+ if (node.type === "ObjectExpression") {
258
+ found.push(node);
259
+ return found;
260
+ }
261
+
262
+ if (
263
+ node.type === "ArrowFunctionExpression" ||
264
+ node.type === "FunctionExpression"
265
+ ) {
266
+ if (node.body.type === "BlockStatement") {
267
+ for (const stmt of node.body.body) {
268
+ if (stmt.type === "ReturnStatement" && stmt.argument) {
269
+ findReturnedObjects(stmt.argument, found);
270
+ }
271
+ }
272
+ } else {
273
+ findReturnedObjects(node.body, found);
274
+ }
275
+ return found;
276
+ }
277
+
278
+ if (node.type === "ConditionalExpression") {
279
+ findReturnedObjects(node.consequent, found);
280
+ findReturnedObjects(node.alternate, found);
281
+ return found;
282
+ }
283
+
284
+ if (node.type === "LogicalExpression") {
285
+ findReturnedObjects(node.left, found);
286
+ findReturnedObjects(node.right, found);
287
+ return found;
288
+ }
289
+
290
+ if (node.type === "AwaitExpression") {
291
+ findReturnedObjects(node.argument, found);
292
+ return found;
293
+ }
294
+
295
+ return found;
296
+ }
297
+
298
+ /** @type {import('eslint').Rule.RuleModule} */
299
+ export default {
300
+ meta: {
301
+ type: "problem",
302
+ docs: {
303
+ description:
304
+ "Disallow vi.mock factories that replace masking helpers with identity functions — neuters the security boundary the test exists at.",
305
+ category: "Possible Errors",
306
+ recommended: true
307
+ },
308
+ messages: {
309
+ identityMaskMock:
310
+ "Identity mock for masking helper '{{name}}' neuters the credential-masking boundary. Remove the override and let the real implementation run (security-standards.md § 'Credential masking — 4th boundary'). If you genuinely need to inspect the pre-mask string, use vi.spyOn(...) and assert the call args, then let the real impl produce the masked output.",
311
+ partialMaskMock:
312
+ "Partial-mask override for '{{name}}' shrinks the masker's coverage to whatever the fixture anticipates — every credential shape the production masker handles but the fixture omits ships unmasked. Drop the override and let the real implementation run. If a test needs to inspect the pre-mask string, use vi.spyOn(util, '{{name}}') to assert call args while the real impl produces masked output (cli-standards.md § 'Masking-mock identity neutering is FORBIDDEN')."
313
+ },
314
+ schema: []
315
+ },
316
+
317
+ create(context) {
318
+ return {
319
+ CallExpression(node) {
320
+ if (
321
+ node.callee.type !== "MemberExpression" ||
322
+ node.callee.object.type !== "Identifier" ||
323
+ node.callee.object.name !== "vi" ||
324
+ node.callee.property.type !== "Identifier" ||
325
+ node.callee.property.name !== "mock"
326
+ ) {
327
+ return;
328
+ }
329
+ if (node.arguments.length < 2) return;
330
+
331
+ const factory = node.arguments[1];
332
+ const returned = findReturnedObjects(factory);
333
+ for (const obj of returned) {
334
+ checkObjectProperties(context, obj);
335
+ }
336
+ }
337
+ };
338
+ }
339
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ESLint Rule: no-raw-block-device-volume
3
+ *
4
+ * Forbids raw `BlockDeviceVolume` / `Volume` named imports from
5
+ * `aws-cdk-lib/aws-ec2` or `aws-cdk-lib/aws-autoscaling` outside the two
6
+ * compute-layer wrapper basenames. `BlockDeviceVolume` is re-exported by
7
+ * both modules — flagging only one leaves the other as a permanent
8
+ * backdoor (the very asymmetry the v2 review caught).
9
+ *
10
+ * Use `safeEbs` from `lib/resources/aws/compute/blockDeviceVolume` for
11
+ * ephemeral / root volumes. `PersistentDataVolume` (Phase 1.75) covers
12
+ * data that must survive instance refresh.
13
+ */
14
+
15
+ import path from "node:path";
16
+
17
+ const ALLOWED_BASENAMES = new Set([
18
+ "blockDeviceVolume.ts",
19
+ "persistentDataVolume.ts"
20
+ ]);
21
+
22
+ const FORBIDDEN_SOURCES = new Set([
23
+ "aws-cdk-lib/aws-ec2",
24
+ "aws-cdk-lib/aws-autoscaling"
25
+ ]);
26
+
27
+ const FORBIDDEN_NAMED_IMPORTS = new Set(["BlockDeviceVolume", "Volume"]);
28
+
29
+ /** @type {import('eslint').Rule.RuleModule} */
30
+ export default {
31
+ meta: {
32
+ type: "problem",
33
+ docs: {
34
+ description:
35
+ "Disallow raw BlockDeviceVolume / Volume imports outside the compute-layer wrappers.",
36
+ category: "Best Practices",
37
+ recommended: true
38
+ },
39
+ messages: {
40
+ rawBlockDeviceVolume:
41
+ "Raw `{{name}}` import from `{{source}}` is forbidden. Use `safeEbs` from `lib/resources/aws/compute/blockDeviceVolume` for ephemeral / root volumes, or `PersistentDataVolume` (Phase 1.75) for data that must survive instance refresh. See aiDocs/patterns/infrastructure-wrapper-routing-pattern.md."
42
+ },
43
+ schema: []
44
+ },
45
+
46
+ create(context) {
47
+ const filename = context.filename ?? context.getFilename();
48
+ const basename = path.basename(filename);
49
+ if (ALLOWED_BASENAMES.has(basename)) {
50
+ return {};
51
+ }
52
+
53
+ return {
54
+ ImportDeclaration(node) {
55
+ if (!FORBIDDEN_SOURCES.has(node.source.value)) {
56
+ return;
57
+ }
58
+ for (const specifier of node.specifiers) {
59
+ if (specifier.type !== "ImportSpecifier") continue;
60
+ const importedName =
61
+ specifier.imported.type === "Identifier"
62
+ ? specifier.imported.name
63
+ : specifier.imported.value;
64
+ if (FORBIDDEN_NAMED_IMPORTS.has(importedName)) {
65
+ context.report({
66
+ node: specifier,
67
+ messageId: "rawBlockDeviceVolume",
68
+ data: { name: importedName, source: node.source.value }
69
+ });
70
+ }
71
+ }
72
+ }
73
+ };
74
+ }
75
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ESLint Rule: no-raw-cdk-properties-on-public-constructs
3
+ *
4
+ * Forbids public class properties whose names match raw CDK plumbing
5
+ * identifiers (`namespace`, `instance`, `cluster`, `application`) on
6
+ * infrastructure constructs in `lib/{resources,patterns}/`. Internal CDK
7
+ * handles must remain `private readonly` so the public surface only exposes
8
+ * Fjall's connector / interface contract.
9
+ *
10
+ * Per .claude/rules/generator-standards.md § "Wrapper Routing Discipline"
11
+ * and the ClickHouse promotion hygiene rules. The textual hygiene test in
12
+ * `clickhouseHygiene.test.ts` enforces the same shape at runtime; this rule
13
+ * shifts the failure left to author-time.
14
+ *
15
+ * Suffixed names (e.g. `namespaceArn`, `instanceEndpoint`, `clusterId`) are
16
+ * allowed — they are output strings, not CDK handles.
17
+ *
18
+ * Accessibility handling: TypeScript treats class fields with NO access
19
+ * modifier as implicitly public, so `class Foo { namespace: X }` and
20
+ * `class Foo { public namespace: X }` are equivalent at runtime. The rule
21
+ * therefore flags both — only `private` / `protected` are exempt.
22
+ *
23
+ * Out of scope (acknowledged ceiling, not oversight): constructor parameter
24
+ * properties (`constructor(public namespace: X)` → TSParameterProperty),
25
+ * accessor methods (`get namespace()` → MethodDefinition), and `accessor`
26
+ * fields (AccessorProperty). The runtime hygiene test at
27
+ * lib/__tests__/clickhouseHygiene.test.ts uses a textual regex
28
+ * (/public\s+readonly\s+(namespace|instance|cluster)\b\s*:/g) and therefore
29
+ * partially catches the TSParameterProperty case for the `public readonly`
30
+ * form — the regex matches the source text regardless of whether the field
31
+ * is a class member or a constructor parameter property. The remaining
32
+ * blind spots common to both checks are accessor methods and `accessor`
33
+ * fields; if a future construct uses one, extend both visitors at the same
34
+ * time.
35
+ */
36
+
37
+ export const FORBIDDEN_NAMES = new Set([
38
+ "namespace",
39
+ "instance",
40
+ "cluster",
41
+ "application"
42
+ ]);
43
+
44
+ /** @type {import('eslint').Rule.RuleModule} */
45
+ export default {
46
+ meta: {
47
+ type: "problem",
48
+ docs: {
49
+ description:
50
+ "Disallow public CDK plumbing properties (`namespace`, `instance`, `cluster`, `application`) on infrastructure constructs.",
51
+ category: "Best Practices",
52
+ recommended: true
53
+ },
54
+ messages: {
55
+ rawCdkProperty:
56
+ "Public property `{{name}}` exposes raw CDK plumbing. Mark it `private readonly` and surface a typed accessor (ARN, endpoint, connector) instead. See aiDocs/patterns/clickhouse-database-factory-pattern.md § Hygiene rules."
57
+ },
58
+ schema: []
59
+ },
60
+
61
+ create(context) {
62
+ return {
63
+ PropertyDefinition(node) {
64
+ if (
65
+ node.accessibility === "private" ||
66
+ node.accessibility === "protected"
67
+ ) {
68
+ return;
69
+ }
70
+ if (!node.key || node.key.type !== "Identifier") return;
71
+ if (!FORBIDDEN_NAMES.has(node.key.name)) return;
72
+ context.report({
73
+ node: node.key,
74
+ messageId: "rawCdkProperty",
75
+ data: { name: node.key.name }
76
+ });
77
+ }
78
+ };
79
+ }
80
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @fileoverview Tenant transactions must go through the `scopedTransaction` /
3
+ * `runScopedWithRls` primitives, never a raw `db.$transaction(...)`.
4
+ *
5
+ * The webapp's scoped Prisma client (`~/.server/utils/tenantScopedDb` `db`)
6
+ * carries a per-operation `$allOperations` hook that, under `RLS_GUC_ENABLED`,
7
+ * wraps each bare operation in its own two-statement transaction
8
+ * `[set_config('app.current_org_id', $1, true), <query>]` so the Postgres RLS
9
+ * policies read a fail-closed tenant filter (the GATE-0 / Option C mechanism —
10
+ * decisions/2026-06-17-rls-role-auth-and-launch-gating.md D6).
11
+ *
12
+ * A caller that opens its OWN `db.$transaction(...)` without setting the GUC as
13
+ * the transaction's first statement breaks that contract the moment the flag
14
+ * flips: the interactive form nests an incoherent second transaction, and the
15
+ * array form returns a Promise (not a PrismaPromise) so a leading
16
+ * `set_config` cannot be batched into it. The two primitives in
17
+ * `rlsOrgContext.ts` (`scopedTransaction` for atomicity-dependent callers,
18
+ * `runScopedWithRls` for read bursts) are the ONLY legitimate `db.$transaction`
19
+ * call sites — they set the GUC first and enter the RLS scope so `tx.*` ops skip
20
+ * the per-op wrap.
21
+ *
22
+ * Carve-outs this rule already encodes:
23
+ * - `rlsOrgContext.ts` (the primitives' home) is exempt wholesale by filename.
24
+ * - `dbBypass.$transaction(...)` is NOT flagged — `dbBypass` connects as the
25
+ * BYPASSRLS role and is exempt from tenant isolation by design.
26
+ * - An aliased scoped-client import (`import { db as scoped } from
27
+ * "~/.server/utils/tenantScopedDb"`) is resolved to its local binding, so
28
+ * `scoped.$transaction(...)` is flagged too — the literal name `db` is not
29
+ * the only trigger.
30
+ * - `Parameters<typeof db.$transaction>` TYPE queries are naturally excluded
31
+ * (this rule visits `CallExpression`, not type nodes).
32
+ * - A genuine future primitive belongs in `rlsOrgContext.ts`; an unavoidable
33
+ * one-off can use `// eslint-disable-next-line fjall/no-raw-db-transaction -- <reason>`.
34
+ *
35
+ * Why this rule exists: GATE-0 (webapp `91f872c3`) migrated the 15 then-existing
36
+ * `db.$transaction` callers to `scopedTransaction`. Without a guard, the next
37
+ * raw `db.$transaction` caller would silently reintroduce the hazard the moment
38
+ * `RLS_GUC_ENABLED` is enabled — invisible to typecheck. See webapp-standards.md
39
+ * § "Tenant transactions go through scopedTransaction".
40
+ */
41
+
42
+ /** @type {import('eslint').Rule.RuleModule} */
43
+ const noRawDbTransaction = {
44
+ meta: {
45
+ type: "problem",
46
+ docs: {
47
+ description:
48
+ "Tenant transactions must use scopedTransaction / runScopedWithRls from ~/.server/utils/rlsOrgContext, not a raw db.$transaction(...) that would bypass the RLS GUC contract."
49
+ },
50
+ messages: {
51
+ rawDbTransaction:
52
+ 'Use `scopedTransaction(fn, options)` (or `runScopedWithRls` for a read burst) from `~/.server/utils/rlsOrgContext` instead of calling `db.$transaction(...)` directly. Under `RLS_GUC_ENABLED` the scoped client wraps each bare op in its own transaction; a raw `db.$transaction` that does not set `app.current_org_id` as its first statement nests an incoherent transaction (interactive form) or breaks the batch (array form). The two primitives in `rlsOrgContext.ts` are the only legitimate `db.$transaction` call sites; `dbBypass.$transaction` (BYPASSRLS) is exempt. A genuine new primitive belongs in `rlsOrgContext.ts`. See webapp-standards.md § "Tenant transactions go through scopedTransaction".'
53
+ },
54
+ schema: []
55
+ },
56
+ create(context) {
57
+ const filename =
58
+ typeof context.filename === "string"
59
+ ? context.filename
60
+ : typeof context.getFilename === "function"
61
+ ? context.getFilename()
62
+ : "";
63
+ if (filename.endsWith("rlsOrgContext.ts")) {
64
+ return {};
65
+ }
66
+ // Flag `db.$transaction` plus any alias of the `db` import from tenantScopedDb
67
+ // (`import { db as scoped }`). Reports defer to `Program:exit` so an alias is
68
+ // resolved regardless of whether its import precedes the call in source order.
69
+ const scopedClientNames = new Set(["db"]);
70
+ const candidates = [];
71
+ return {
72
+ ImportDeclaration(node) {
73
+ if (
74
+ typeof node.source.value !== "string" ||
75
+ !node.source.value.endsWith("tenantScopedDb")
76
+ ) {
77
+ return;
78
+ }
79
+ for (const spec of node.specifiers) {
80
+ if (
81
+ spec.type === "ImportSpecifier" &&
82
+ spec.imported.type === "Identifier" &&
83
+ spec.imported.name === "db"
84
+ ) {
85
+ scopedClientNames.add(spec.local.name);
86
+ }
87
+ }
88
+ },
89
+ CallExpression(node) {
90
+ const callee = node.callee;
91
+ if (
92
+ callee.type === "MemberExpression" &&
93
+ !callee.computed &&
94
+ callee.object.type === "Identifier" &&
95
+ callee.property.type === "Identifier" &&
96
+ callee.property.name === "$transaction"
97
+ ) {
98
+ candidates.push({ node, name: callee.object.name });
99
+ }
100
+ },
101
+ "Program:exit"() {
102
+ for (const { node, name } of candidates) {
103
+ if (scopedClientNames.has(name)) {
104
+ context.report({ node, messageId: "rawDbTransaction" });
105
+ }
106
+ }
107
+ }
108
+ };
109
+ }
110
+ };
111
+
112
+ export default noRawDbTransaction;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * ESLint Rule: no-throw-in-services
3
+ *
4
+ * Services must use Result<T, E> pattern instead of throwing errors.
5
+ * Throwing breaks the explicit error handling contract and makes
6
+ * error paths implicit and harder to trace.
7
+ *
8
+ */
9
+
10
+ /** @type {import('eslint').Rule.RuleModule} */
11
+ export default {
12
+ meta: {
13
+ type: "problem",
14
+ docs: {
15
+ description: "Disallow throw statements in service files",
16
+ category: "Best Practices",
17
+ recommended: true
18
+ },
19
+ messages: {
20
+ noThrow:
21
+ "Services must use Result<T, E> pattern instead of throw. Use failure() to return errors.",
22
+ noThrowInConstructor:
23
+ "Consider using a factory function that returns Result instead of throwing in constructor."
24
+ },
25
+ schema: []
26
+ },
27
+
28
+ create(context) {
29
+ const filename = context.filename;
30
+
31
+ // Only apply to service files
32
+ const isServiceFile =
33
+ filename.includes("/services/") || filename.includes("Service.ts");
34
+
35
+ if (!isServiceFile) {
36
+ return {};
37
+ }
38
+
39
+ return {
40
+ ThrowStatement(node) {
41
+ // Check if we're inside a constructor
42
+ let parent = node.parent;
43
+ let inConstructor = false;
44
+
45
+ while (parent) {
46
+ if (
47
+ parent.type === "MethodDefinition" &&
48
+ parent.kind === "constructor"
49
+ ) {
50
+ inConstructor = true;
51
+ break;
52
+ }
53
+ parent = parent.parent;
54
+ }
55
+
56
+ context.report({
57
+ node,
58
+ messageId: inConstructor ? "noThrowInConstructor" : "noThrow"
59
+ });
60
+ }
61
+ };
62
+ }
63
+ };