@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,64 @@
1
+ /**
2
+ * ESLint Rule: prefer-with-agent-flags
3
+ *
4
+ * Inline declarations of the four canonical agent flags
5
+ * (`--agent`, `--budget`, `--fields`, `--full`) MUST go through
6
+ * `withAgentFlags()` from `cli/src/commands/registration/helpers.ts`.
7
+ *
8
+ * The 2026-04-28 /review of the MCP infra-app-creation branch found this
9
+ * drift twice in one week — once in `infrastructureCommands.ts`, once in
10
+ * `apiCommands.ts`. The helper is the single source of truth for flag
11
+ * descriptions; inline drift produces inconsistent `--help` output across
12
+ * subcommands. See `.claude/rules/cli-standards.md` for context.
13
+ *
14
+ * Detects: `.option("--<agent-flag>", ...)` calls in registration files,
15
+ * excluding `helpers.ts` itself (which legitimately defines the helper).
16
+ */
17
+
18
+ const AGENT_FLAGS = new Set(["--agent", "--budget", "--fields", "--full"]);
19
+
20
+ /** @type {import('eslint').Rule.RuleModule} */
21
+ export default {
22
+ meta: {
23
+ type: "problem",
24
+ docs: {
25
+ description:
26
+ "Agent flags (--agent/--budget/--fields/--full) must be registered via withAgentFlags(), not inline.",
27
+ category: "Best Practices",
28
+ recommended: false
29
+ },
30
+ messages: {
31
+ preferHelper:
32
+ "Use withAgentFlags() from ./helpers.js instead of declaring `{{flag}}` inline. The helper is the single source of truth for the four agent flags."
33
+ },
34
+ schema: []
35
+ },
36
+
37
+ create(context) {
38
+ return {
39
+ CallExpression(node) {
40
+ const callee = node.callee;
41
+ if (callee.type !== "MemberExpression") return;
42
+ if (
43
+ callee.property.type !== "Identifier" ||
44
+ callee.property.name !== "option"
45
+ ) {
46
+ return;
47
+ }
48
+ const firstArg = node.arguments[0];
49
+ if (!firstArg) return;
50
+ if (firstArg.type !== "Literal" || typeof firstArg.value !== "string") {
51
+ return;
52
+ }
53
+ const flag = firstArg.value.split(/[ ,]/, 1)[0];
54
+ if (AGENT_FLAGS.has(flag)) {
55
+ context.report({
56
+ node: firstArg,
57
+ messageId: "preferHelper",
58
+ data: { flag }
59
+ });
60
+ }
61
+ }
62
+ };
63
+ }
64
+ };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * ESLint Rule: require-abort-precheck-in-sdk-loop
3
+ *
4
+ * Complements no-bare-sdk-abort-timeout. That rule enforces the timeout-
5
+ * COMPOSITION half of the SDK-probe abort contract (every send routes through
6
+ * composeSdkAbortSignal so an in-flight call aborts on shutdown). THIS rule
7
+ * enforces the pagination-PRE-CHECK half: a sequential loop that issues a
8
+ * composed SDK send (composeSdkAbortSignal with a real caller signal) MUST
9
+ * pre-check the abort state between iterations — `isAborted(signal)` or a
10
+ * `signal.aborted` read that can break/return/throw. Without it, on shutdown the
11
+ * loop keeps starting fast-failing sends across every remaining iteration
12
+ * rather than breaking cleanly (the scanAccountDelivery class — the lone
13
+ * non-conformer the composition rule could not see).
14
+ *
15
+ * Exempt (no flag):
16
+ * - No-caller-signal sends — composeSdkAbortSignal(undefined, ...) or
17
+ * composeSdkAbortSignal() — there is no signal to check, so a pre-check would
18
+ * be dead ceremony ("justify ceremony or drop it").
19
+ * - Concurrent sends inside a nested function/callback (.map(async …)), where a
20
+ * per-iteration pre-check does not apply — the sends fire together and the
21
+ * composed signal aborts them.
22
+ * - Single (non-loop) sends — the composed signal alone bounds shutdown.
23
+ *
24
+ * Per .claude/rules/robustness-standards.md § "SDK probe helpers take optional
25
+ * abortSignal and compose with the timeout" — "pre-check isAborted between pages
26
+ * of paginated loops".
27
+ */
28
+
29
+ const LOOP_TYPES = new Set([
30
+ "ForStatement",
31
+ "ForOfStatement",
32
+ "ForInStatement",
33
+ "WhileStatement",
34
+ "DoWhileStatement"
35
+ ]);
36
+
37
+ const FUNCTION_TYPES = new Set([
38
+ "FunctionDeclaration",
39
+ "FunctionExpression",
40
+ "ArrowFunctionExpression"
41
+ ]);
42
+
43
+ /**
44
+ * A composeSdkAbortSignal(signal, …) call whose first argument is a real caller
45
+ * signal — not `undefined` and not absent. These are the sends that need a
46
+ * between-iteration pre-check.
47
+ *
48
+ * The Identifier-callee match is deliberate: the helper is always imported and
49
+ * called bare across deploy-core/cli (no namespaced `ns.composeSdkAbortSignal()`
50
+ * call sites exist, and the bare import is the convention the companion
51
+ * no-bare-sdk-abort-timeout rule steers toward). Extend to MemberExpression
52
+ * only if a namespaced call ever lands — speculative coverage now would be
53
+ * defending a shape that does not exist.
54
+ */
55
+ function isComposedSendWithRealSignal(node) {
56
+ if (
57
+ node.type !== "CallExpression" ||
58
+ node.callee.type !== "Identifier" ||
59
+ node.callee.name !== "composeSdkAbortSignal"
60
+ ) {
61
+ return false;
62
+ }
63
+ const firstArg = node.arguments[0];
64
+ if (firstArg === undefined) return false;
65
+ if (firstArg.type === "Identifier" && firstArg.name === "undefined") {
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * An `isAborted(...)` call or any `.aborted` member read — an abort guard.
73
+ *
74
+ * Mere presence counts; the guard is not required to sit in a conditional that
75
+ * provably breaks/returns/throws. This leniency is deliberate: tightening to
76
+ * "must be in conditional position" would false-positive the common
77
+ * assign-then-branch shape (`const stop = isAborted(sig); if (stop) break;`),
78
+ * and catching a genuinely-discarded read without that false positive needs
79
+ * full data-flow analysis — disproportionate here. The rule errs toward a rare
80
+ * false-negative (a discarded read) over flagging correct code.
81
+ */
82
+ function isAbortGuard(node) {
83
+ if (
84
+ node.type === "CallExpression" &&
85
+ node.callee.type === "Identifier" &&
86
+ node.callee.name === "isAborted"
87
+ ) {
88
+ return true;
89
+ }
90
+ return (
91
+ node.type === "MemberExpression" &&
92
+ !node.computed &&
93
+ node.property.type === "Identifier" &&
94
+ node.property.name === "aborted"
95
+ );
96
+ }
97
+
98
+ /** @type {import('eslint').Rule.RuleModule} */
99
+ export default {
100
+ meta: {
101
+ type: "problem",
102
+ docs: {
103
+ description:
104
+ "Require an isAborted pre-check inside a sequential loop that issues a composed SDK send — the pagination half of the abort contract.",
105
+ category: "Possible Errors",
106
+ recommended: true
107
+ },
108
+ messages: {
109
+ missingAbortPrecheck:
110
+ "This loop issues a composed SDK send (composeSdkAbortSignal with a caller signal) but never pre-checks abort between iterations — on shutdown it keeps starting fast-failing sends across every remaining iteration instead of breaking cleanly. Add `if (isAborted(signal)) break;` (or a signal.aborted check) at the top of the loop body, matching listAccounts in organisations/accounts.ts. If this loop genuinely cannot benefit, add an eslint-disable with a reason."
111
+ },
112
+ schema: []
113
+ },
114
+
115
+ create(context) {
116
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
117
+ const visitorKeys = sourceCode.visitorKeys;
118
+
119
+ /**
120
+ * Depth-first search of `root`'s subtree for a node matching `predicate`.
121
+ * When `stopAtLoopsAndFns` is true, nested loop/function subtrees are not
122
+ * descended into (so a send is attributed to its innermost enclosing loop,
123
+ * and a guard inside a deeper nested scope does not count for this loop).
124
+ */
125
+ function subtreeHas(root, predicate, stopAtLoopsAndFns) {
126
+ if (!root) return false;
127
+ const stack = [{ node: root, isRoot: true }];
128
+ while (stack.length > 0) {
129
+ const { node, isRoot } = stack.pop();
130
+ if (!node || typeof node.type !== "string") continue;
131
+ if (
132
+ !isRoot &&
133
+ stopAtLoopsAndFns &&
134
+ (LOOP_TYPES.has(node.type) || FUNCTION_TYPES.has(node.type))
135
+ ) {
136
+ continue;
137
+ }
138
+ if (predicate(node)) return true;
139
+ for (const key of visitorKeys[node.type] ?? []) {
140
+ const child = node[key];
141
+ if (Array.isArray(child)) {
142
+ for (const c of child) {
143
+ if (c) stack.push({ node: c, isRoot: false });
144
+ }
145
+ } else if (child && typeof child.type === "string") {
146
+ stack.push({ node: child, isRoot: false });
147
+ }
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
153
+ function checkLoop(node) {
154
+ if (!subtreeHas(node.body, isComposedSendWithRealSignal, true)) return;
155
+ const guardInTest = subtreeHas(node.test, isAbortGuard, false);
156
+ const guardInBody = subtreeHas(node.body, isAbortGuard, true);
157
+ if (guardInTest || guardInBody) return;
158
+ context.report({ node, messageId: "missingAbortPrecheck" });
159
+ }
160
+
161
+ return {
162
+ ForStatement: checkLoop,
163
+ ForOfStatement: checkLoop,
164
+ ForInStatement: checkLoop,
165
+ WhileStatement: checkLoop,
166
+ DoWhileStatement: checkLoop
167
+ };
168
+ }
169
+ };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * ESLint Rule: zod-companion-type
3
+ *
4
+ * Catches the most common shape of Zod Pitfall 6 — `export const XSchema =
5
+ * z.<builder>(...)` where the schema name ends in `Schema` and the
6
+ * initializer is a direct `z.*` call — and requires a companion exported
7
+ * type with the matching name (e.g. `ComputeTypeSchema` → `ComputeType`).
8
+ * The companion may be:
9
+ * 1. `export type X = z.infer<typeof XSchema>` (the strongest form)
10
+ * 2. A re-export from a canonical source — `export { type X } from "..."`
11
+ * or `export type { X } from "..."` — when the canonical type is
12
+ * structurally derived from the same underlying constants the schema
13
+ * consumes (e.g. `z.enum(COMPUTE_TYPES)` paired with
14
+ * `type ComputeType = (typeof COMPUTE_TYPES)[number]` re-exported here).
15
+ *
16
+ * Intentional gaps in the catch surface (these forms bypass the rule and
17
+ * are NOT a bug — they are heuristic carve-outs to keep the rule low-noise):
18
+ * - Helper-wrapped initializers — `export const FooSchema =
19
+ * optionalOrDisabled(InnerSchema)` — the wrapper hides the `z.*` call.
20
+ * If a companion type for these is needed, derive it manually with
21
+ * `z.infer<typeof FooSchema>`; the rule won't enforce.
22
+ * - Names that do not end in `Schema` — `export const
23
+ * DatabaseGeneratorSchemaFromUI = z.object(...)` — by convention, the
24
+ * `Schema` suffix marks "this is the type-bearing schema worth pairing
25
+ * with a companion". Suffix-less exports are typically internal-shaped
26
+ * or input-shaped and don't earn a companion.
27
+ *
28
+ * Without the companion, downstream consumers either widen to
29
+ * `string`/`unknown` (losing the brand) or duplicate the schema's regex/shape
30
+ * — both reintroduce the schema-interface drift the schema was supposed to
31
+ * prevent.
32
+ *
33
+ * Codifies typescript-standards.md § "Pitfall 6: Exported Schema Without
34
+ * Companion z.infer Type" after 5 occurrences in 8 days (2026-04-21 to
35
+ * 2026-04-28: NetworkName, CidrString, ComputeType, VpcPeerAccepterOrchestratorSuccess
36
+ * structural-coupling sibling, InsightId).
37
+ */
38
+
39
+ /** @type {import('eslint').Rule.RuleModule} */
40
+ export default {
41
+ meta: {
42
+ type: "problem",
43
+ docs: {
44
+ description:
45
+ "Require companion type for every exported Zod schema (z.infer or re-export)",
46
+ category: "Best Practices",
47
+ recommended: true
48
+ },
49
+ messages: {
50
+ missingCompanionType:
51
+ "Exported Zod schema '{{schemaName}}' has no companion `export type {{typeName}}` in the same file. Add `export type {{typeName}} = z.infer<typeof {{schemaName}}>` or re-export the canonical type to prevent schema-interface drift."
52
+ },
53
+ schema: []
54
+ },
55
+
56
+ create(context) {
57
+ const exportedSchemas = new Map();
58
+ const exportedTypeNames = new Set();
59
+
60
+ function recordExportedTypeName(name) {
61
+ if (typeof name === "string" && name.length > 0) {
62
+ exportedTypeNames.add(name);
63
+ }
64
+ }
65
+
66
+ return {
67
+ ExportNamedDeclaration(node) {
68
+ if (node.declaration) {
69
+ if (node.declaration.type === "VariableDeclaration") {
70
+ for (const decl of node.declaration.declarations) {
71
+ if (decl.id.type !== "Identifier") continue;
72
+ const name = decl.id.name;
73
+ if (!name.endsWith("Schema")) continue;
74
+ if (!isZodSchemaInit(decl.init)) continue;
75
+ exportedSchemas.set(name, { node: decl.id });
76
+ }
77
+ }
78
+ if (node.declaration.type === "TSTypeAliasDeclaration") {
79
+ recordExportedTypeName(node.declaration.id.name);
80
+ }
81
+ if (node.declaration.type === "TSInterfaceDeclaration") {
82
+ recordExportedTypeName(node.declaration.id.name);
83
+ }
84
+ }
85
+
86
+ if (Array.isArray(node.specifiers)) {
87
+ const isTypeOnlyExport = node.exportKind === "type";
88
+ for (const spec of node.specifiers) {
89
+ if (spec.type !== "ExportSpecifier") continue;
90
+ if (isTypeOnlyExport || spec.exportKind === "type") {
91
+ const exported =
92
+ spec.exported.type === "Identifier"
93
+ ? spec.exported.name
94
+ : spec.exported.value;
95
+ recordExportedTypeName(exported);
96
+ }
97
+ }
98
+ }
99
+ },
100
+
101
+ "Program:exit"() {
102
+ if (exportedSchemas.size === 0) return;
103
+
104
+ const sourceText = context.sourceCode.getText();
105
+
106
+ for (const [schemaName, { node }] of exportedSchemas) {
107
+ const typeName = schemaName.replace(/Schema$/, "");
108
+
109
+ const inferPattern = new RegExp(
110
+ `z\\.infer\\s*<\\s*typeof\\s+${escapeRegex(schemaName)}\\s*>`
111
+ );
112
+ if (inferPattern.test(sourceText)) continue;
113
+
114
+ if (exportedTypeNames.has(typeName)) continue;
115
+
116
+ context.report({
117
+ node,
118
+ messageId: "missingCompanionType",
119
+ data: { schemaName, typeName }
120
+ });
121
+ }
122
+ }
123
+ };
124
+ }
125
+ };
126
+
127
+ function escapeRegex(str) {
128
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
129
+ }
130
+
131
+ function isZodSchemaInit(init) {
132
+ if (!init) return false;
133
+
134
+ let current = init;
135
+ while (current) {
136
+ if (current.type === "Identifier") {
137
+ // `*Schema.extend({...}).strict()` chains root at an imported schema.
138
+ return current.name === "z" || current.name.endsWith("Schema");
139
+ }
140
+ if (current.type === "MemberExpression") {
141
+ if (current.object.type === "Identifier") {
142
+ if (
143
+ current.object.name === "z" ||
144
+ current.object.name.endsWith("Schema")
145
+ ) {
146
+ return true;
147
+ }
148
+ }
149
+ current = current.object;
150
+ continue;
151
+ }
152
+ if (current.type === "CallExpression") {
153
+ current = current.callee;
154
+ continue;
155
+ }
156
+ return false;
157
+ }
158
+ return false;
159
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * ESLint Rule: zod-strict-required
3
+ *
4
+ * All z.object() schemas must use .strict() to reject unknown properties.
5
+ * Without .strict(), Zod silently strips unknown properties which can
6
+ * mask schema-interface drift bugs.
7
+ *
8
+ * */
9
+
10
+ /** @type {import('eslint').Rule.RuleModule} */
11
+ export default {
12
+ meta: {
13
+ type: "problem",
14
+ docs: {
15
+ description: "Require .strict() on all z.object() schemas",
16
+ category: "Best Practices",
17
+ recommended: true
18
+ },
19
+ messages: {
20
+ missingStrict:
21
+ "z.object() must use .strict() to reject unknown properties. Add .strict() after the object definition."
22
+ },
23
+ schema: [],
24
+ fixable: "code"
25
+ },
26
+
27
+ create(context) {
28
+ return {
29
+ CallExpression(node) {
30
+ // Check for z.object(...) calls
31
+ if (!isZodObjectCall(node)) {
32
+ return;
33
+ }
34
+
35
+ // Check if this z.object() has .strict() chained
36
+ if (hasStrictChained(node)) {
37
+ return;
38
+ }
39
+
40
+ // Check if it's part of a chain that eventually has .strict()
41
+ if (isPartOfStrictChain(node)) {
42
+ return;
43
+ }
44
+
45
+ context.report({
46
+ node,
47
+ messageId: "missingStrict",
48
+ fix(fixer) {
49
+ // Find the end of the z.object({...}) call
50
+ const sourceCode = context.sourceCode;
51
+ const callEnd = node.range[1];
52
+
53
+ // Check what comes after - if it's a method chain, insert before the dot
54
+ const tokensAfter = sourceCode.getTokensAfter(node, { count: 1 });
55
+ if (tokensAfter.length > 0 && tokensAfter[0].value === ".") {
56
+ return fixer.insertTextAfter(node, ".strict()");
57
+ }
58
+
59
+ return fixer.insertTextAfterRange(
60
+ [callEnd - 1, callEnd],
61
+ ".strict()"
62
+ );
63
+ }
64
+ });
65
+ }
66
+ };
67
+ }
68
+ };
69
+
70
+ /**
71
+ * Check if a node is a z.object() call
72
+ */
73
+ function isZodObjectCall(node) {
74
+ if (node.type !== "CallExpression") return false;
75
+
76
+ const callee = node.callee;
77
+
78
+ // z.object(...)
79
+ if (
80
+ callee.type === "MemberExpression" &&
81
+ callee.object.type === "Identifier" &&
82
+ callee.object.name === "z" &&
83
+ callee.property.type === "Identifier" &&
84
+ callee.property.name === "object"
85
+ ) {
86
+ return true;
87
+ }
88
+
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * Check if node has .strict() directly chained
94
+ */
95
+ function hasStrictChained(node) {
96
+ let parent = node.parent;
97
+
98
+ // Walk up the chain looking for .strict()
99
+ while (parent) {
100
+ if (
101
+ parent.type === "MemberExpression" &&
102
+ parent.property.type === "Identifier" &&
103
+ parent.property.name === "strict"
104
+ ) {
105
+ return true;
106
+ }
107
+
108
+ // If parent is a CallExpression, check its parent (for method chains)
109
+ if (parent.type === "CallExpression") {
110
+ parent = parent.parent;
111
+ } else if (parent.type === "MemberExpression") {
112
+ parent = parent.parent;
113
+ } else {
114
+ break;
115
+ }
116
+ }
117
+
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Check if this z.object() is part of a larger chain that has .strict()
123
+ * e.g., z.object({...}).extend({...}).strict()
124
+ */
125
+ function isPartOfStrictChain(node) {
126
+ // Start from the z.object() node and traverse up the entire chain
127
+ let current = node;
128
+
129
+ while (current.parent) {
130
+ const parent = current.parent;
131
+
132
+ // If we hit a MemberExpression accessing 'strict', we're good
133
+ if (
134
+ parent.type === "MemberExpression" &&
135
+ parent.property.type === "Identifier" &&
136
+ parent.property.name === "strict"
137
+ ) {
138
+ return true;
139
+ }
140
+
141
+ // Continue up through CallExpressions and MemberExpressions
142
+ if (
143
+ parent.type === "CallExpression" ||
144
+ parent.type === "MemberExpression"
145
+ ) {
146
+ current = parent;
147
+ } else {
148
+ break;
149
+ }
150
+ }
151
+
152
+ return false;
153
+ }