@stapel/eslint-plugin 0.2.0

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/lib/data.js ADDED
@@ -0,0 +1,349 @@
1
+ // Data layer for the guardrail rules (frontend-guardrails §2.1: "Data-driven,
2
+ // out of the SAME artifacts"). Rules never carry their own token / i18n lists —
3
+ // they read the generated manifests the codegen writes, so lint and code cannot
4
+ // drift. Everything here is defensive: a missing manifest degrades a rule to a
5
+ // no-op (empty catalog) rather than crashing the lint run, and every lookup is
6
+ // overridable via `settings.stapel` so consumers (and RuleTester) stay
7
+ // deterministic.
8
+ import { createRequire } from "node:module";
9
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+
16
+ function readJson(path) {
17
+ try {
18
+ return JSON.parse(readFileSync(path, "utf8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /** Walk up from `start` until a directory contains `marker`; null if none. */
25
+ function findUp(marker, start) {
26
+ let dir = start;
27
+ for (;;) {
28
+ if (existsSync(join(dir, marker))) return dir;
29
+ const parent = dirname(dir);
30
+ if (parent === dir) return null;
31
+ dir = parent;
32
+ }
33
+ }
34
+
35
+ /** Nearest pnpm/monorepo workspace root above `from`. */
36
+ function workspaceRoot(from) {
37
+ return (
38
+ findUp("pnpm-workspace.yaml", from) ??
39
+ findUp("pnpm-lock.yaml", from) ??
40
+ null
41
+ );
42
+ }
43
+
44
+ // ── Token catalog ──────────────────────────────────────────────────────────
45
+
46
+ function loadTokensManifest(settings) {
47
+ if (settings.tokensManifest) return settings.tokensManifest;
48
+ if (settings.tokensManifestPath) return readJson(settings.tokensManifestPath);
49
+ // The token authority is @stapel/tokens — a real dependency of this plugin.
50
+ try {
51
+ return require("@stapel/tokens/manifest.json");
52
+ } catch {
53
+ /* fall through to workspace discovery */
54
+ }
55
+ const root = workspaceRoot(HERE);
56
+ if (root) {
57
+ const m = readJson(join(root, "packages", "tokens", "manifest.json"));
58
+ if (m) return m;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function buildTokenCatalog(manifest) {
64
+ const t = manifest?.tokens ?? {};
65
+ const core = new Set(t.core ?? []);
66
+ const component = new Set(t.component ?? []);
67
+ const ramps = new Set(manifest?.ramps?.names ?? []);
68
+ const all = new Set([...core, ...component]);
69
+ return {
70
+ core,
71
+ component,
72
+ ramps,
73
+ all,
74
+ /** true if `name` is a known L2 core or L3 component token */
75
+ hasToken: (name) => all.has(name),
76
+ /** true if `name` is a known L1 raw ramp (e.g. "gray", "brand") */
77
+ hasRamp: (name) => ramps.has(name),
78
+ loaded: all.size > 0 || ramps.size > 0,
79
+ };
80
+ }
81
+
82
+ let _tokenCatalogDefault;
83
+ export function loadTokenCatalog(settings = {}) {
84
+ const overridden =
85
+ settings.tokensManifest || settings.tokensManifestPath;
86
+ if (overridden) return buildTokenCatalog(loadTokensManifest(settings));
87
+ if (!_tokenCatalogDefault) {
88
+ _tokenCatalogDefault = buildTokenCatalog(loadTokensManifest(settings));
89
+ }
90
+ return _tokenCatalogDefault;
91
+ }
92
+
93
+ // ── i18n key registry ────────────────────────────────────────────────────────
94
+
95
+ function discoverWorkspaceI18nKeys(from) {
96
+ const root = workspaceRoot(from);
97
+ if (!root) return [];
98
+ const pkgsDir = join(root, "packages");
99
+ if (!existsSync(pkgsDir)) return [];
100
+ const out = [];
101
+ let entries = [];
102
+ try {
103
+ entries = readdirSync(pkgsDir, { withFileTypes: true })
104
+ .filter((d) => d.isDirectory())
105
+ .map((d) => d.name);
106
+ } catch {
107
+ return [];
108
+ }
109
+ for (const name of entries) {
110
+ const manifest = readJson(join(pkgsDir, name, "manifest.json"));
111
+ if (manifest && Array.isArray(manifest.i18nKeys)) {
112
+ out.push(manifest);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ function buildI18nRegistry(manifests, extraKeys) {
119
+ const keys = new Set(extraKeys ?? []);
120
+ for (const m of manifests) {
121
+ for (const k of m.i18nKeys ?? []) keys.add(k);
122
+ }
123
+ // Managed namespaces = the top-level segment of every known key. The
124
+ // existence rule only fires inside a MANAGED namespace (false-positive
125
+ // policy, §2.2): an unknown key under a namespace nobody manages is treated
126
+ // as an app-local key, not a typo.
127
+ const namespaces = new Set();
128
+ for (const k of keys) {
129
+ const dot = k.indexOf(".");
130
+ if (dot > 0) namespaces.add(k.slice(0, dot));
131
+ }
132
+ return {
133
+ keys,
134
+ namespaces,
135
+ has: (k) => keys.has(k),
136
+ manages: (k) => {
137
+ const dot = k.indexOf(".");
138
+ return dot > 0 && namespaces.has(k.slice(0, dot));
139
+ },
140
+ loaded: keys.size > 0,
141
+ };
142
+ }
143
+
144
+ let _i18nDefault;
145
+ export function loadI18nRegistry(settings = {}) {
146
+ if (settings.i18nKeys) {
147
+ return buildI18nRegistry([], settings.i18nKeys);
148
+ }
149
+ if (settings.i18nManifests) {
150
+ return buildI18nRegistry(settings.i18nManifests, settings.extraI18nKeys);
151
+ }
152
+ if (!_i18nDefault) {
153
+ _i18nDefault = buildI18nRegistry(
154
+ discoverWorkspaceI18nKeys(process.cwd ? process.cwd() : HERE),
155
+ undefined
156
+ );
157
+ }
158
+ return _i18nDefault;
159
+ }
160
+
161
+ // ── analytics event registry (events.json / manifest.events) ─────────────────
162
+ //
163
+ // The known-event lint (G4) reads the SAME generated projection the report (G5)
164
+ // reads: the `events` section of each package manifest.json (defineEvent call
165
+ // sites → `defined`, flows.json funnels → `flows`). A missing manifest / events
166
+ // section degrades the rule to a no-op (empty catalog), never a crash — exactly
167
+ // like the token and i18n loaders above (§2.1: one source, all projections).
168
+
169
+ function discoverWorkspaceEventManifests(from) {
170
+ const root = workspaceRoot(from);
171
+ if (!root) return [];
172
+ const pkgsDir = join(root, "packages");
173
+ if (!existsSync(pkgsDir)) return [];
174
+ const out = [];
175
+ let entries = [];
176
+ try {
177
+ entries = readdirSync(pkgsDir, { withFileTypes: true })
178
+ .filter((d) => d.isDirectory())
179
+ .map((d) => d.name);
180
+ } catch {
181
+ return [];
182
+ }
183
+ for (const name of entries) {
184
+ const manifest = readJson(join(pkgsDir, name, "manifest.json"));
185
+ if (manifest && manifest.events) out.push(manifest);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /** Strip the `<step>` / `*` placeholder tail off a flow event pattern. */
191
+ function flowBaseOf(eventPattern) {
192
+ return String(eventPattern)
193
+ .replace(/\.<step>$/, "")
194
+ .replace(/\.\*$/, "");
195
+ }
196
+
197
+ function buildEventsCatalog(manifests, extraNames) {
198
+ const defined = new Set(extraNames ?? []);
199
+ const flowBases = new Set();
200
+ for (const m of manifests) {
201
+ const ev = m.events;
202
+ if (!ev) continue;
203
+ for (const d of ev.defined ?? []) if (d && d.name) defined.add(d.name);
204
+ for (const f of ev.flows ?? []) {
205
+ if (f && f.event) flowBases.add(flowBaseOf(f.event));
206
+ if (f && f.flow) flowBases.add(`flow.${f.flow}`);
207
+ }
208
+ }
209
+ return {
210
+ defined,
211
+ flowBases,
212
+ /**
213
+ * A name is known if it is a declared event, OR it is a flow event under a
214
+ * known funnel base (`flow.<id>.<step>`) — flow steps are open-ended, so we
215
+ * match by prefix rather than enumerating every step.
216
+ */
217
+ isKnown: (name) => {
218
+ if (defined.has(name)) return true;
219
+ for (const base of flowBases) {
220
+ if (name === base || name.startsWith(base + ".")) return true;
221
+ }
222
+ return false;
223
+ },
224
+ loaded: defined.size > 0 || flowBases.size > 0,
225
+ };
226
+ }
227
+
228
+ let _eventsCatalogDefault;
229
+ export function loadEventsCatalog(settings = {}) {
230
+ if (settings.eventNames) {
231
+ return buildEventsCatalog([], settings.eventNames);
232
+ }
233
+ if (settings.eventsManifests) {
234
+ return buildEventsCatalog(settings.eventsManifests, settings.extraEventNames);
235
+ }
236
+ if (!_eventsCatalogDefault) {
237
+ _eventsCatalogDefault = buildEventsCatalog(
238
+ discoverWorkspaceEventManifests(process.cwd ? process.cwd() : HERE),
239
+ undefined
240
+ );
241
+ }
242
+ return _eventsCatalogDefault;
243
+ }
244
+
245
+ // ── operation-path catalog (manifest.operations) ─────────────────────────────
246
+ //
247
+ // The no-string-paths lint reads the SAME generated projection the code and the
248
+ // llms.txt read: the `operations` section of each package manifest.json (schema
249
+ // operationId → { method, path }). A path an agent hand-writes is authoritative
250
+ // only if it matches a catalogued operation — so the rule can name the op to
251
+ // call instead. A missing manifest / operations section degrades the data to an
252
+ // empty catalog (the rule still flags the syntactic `client.<verb>("/…")` shape,
253
+ // never crashes) — exactly like the token / i18n / events loaders above (§2.1).
254
+
255
+ function discoverWorkspaceOperationManifests(from) {
256
+ const root = workspaceRoot(from);
257
+ if (!root) return [];
258
+ const pkgsDir = join(root, "packages");
259
+ if (!existsSync(pkgsDir)) return [];
260
+ const out = [];
261
+ let entries = [];
262
+ try {
263
+ entries = readdirSync(pkgsDir, { withFileTypes: true })
264
+ .filter((d) => d.isDirectory())
265
+ .map((d) => d.name);
266
+ } catch {
267
+ return [];
268
+ }
269
+ for (const name of entries) {
270
+ const manifest = readJson(join(pkgsDir, name, "manifest.json"));
271
+ if (manifest && manifest.operations) out.push(manifest);
272
+ }
273
+ return out;
274
+ }
275
+
276
+ function buildOperationCatalog(manifests, extraPaths) {
277
+ // path → { pkg, operation } for the exact-match lookup + a set of paths for
278
+ // template-prefix matching (`\`${base}/me/\`` shares the trailing segments).
279
+ const byPath = new Map();
280
+ for (const p of extraPaths ?? []) if (p) byPath.set(p, { pkg: null, operation: null });
281
+ for (const m of manifests) {
282
+ const pkg = m.package ?? null;
283
+ for (const [id, op] of Object.entries(m.operations ?? {})) {
284
+ if (op && typeof op.path === "string" && !byPath.has(op.path)) {
285
+ byPath.set(op.path, { pkg, operation: id });
286
+ }
287
+ }
288
+ }
289
+ const paths = [...byPath.keys()];
290
+ /**
291
+ * The catalogued operation for a path string, by exact match or — for a
292
+ * client-relative literal like `/me/` under a `/auth/api` base URL — by
293
+ * trailing-segment suffix. Returns { pkg, operation } or null.
294
+ */
295
+ const resolve = (str) => {
296
+ // A meaningful path has a segment beyond the leading slash — never resolve a
297
+ // bare "/" (every catalogued path ends with one, so it would match all).
298
+ if (typeof str !== "string" || str.length < 2 || !/[A-Za-z0-9]/.test(str)) {
299
+ return null;
300
+ }
301
+ const exact = byPath.get(str);
302
+ if (exact) return exact;
303
+ // Suffix match aligns at a segment boundary because `str` starts with "/".
304
+ for (const p of paths) if (str.startsWith("/") && p.endsWith(str)) return byPath.get(p);
305
+ return null;
306
+ };
307
+ return {
308
+ byPath,
309
+ paths,
310
+ resolve,
311
+ /** Exact catalogued operation for a literal path string, else null. */
312
+ lookup: (str) => byPath.get(str) ?? null,
313
+ /** True if `str` is (or ends with) a catalogued operation path. */
314
+ matches: (str) => resolve(str) !== null,
315
+ loaded: byPath.size > 0,
316
+ };
317
+ }
318
+
319
+ let _operationCatalogDefault;
320
+ export function loadOperationCatalog(settings = {}) {
321
+ if (settings.operationPaths) {
322
+ return buildOperationCatalog([], settings.operationPaths);
323
+ }
324
+ if (settings.operationsManifests) {
325
+ return buildOperationCatalog(settings.operationsManifests, settings.extraOperationPaths);
326
+ }
327
+ if (!_operationCatalogDefault) {
328
+ _operationCatalogDefault = buildOperationCatalog(
329
+ discoverWorkspaceOperationManifests(process.cwd ? process.cwd() : HERE),
330
+ undefined
331
+ );
332
+ }
333
+ return _operationCatalogDefault;
334
+ }
335
+
336
+ /** Read `context.settings.stapel` (flat config) with a stable empty default. */
337
+ export function stapelSettings(context) {
338
+ return (context.settings && context.settings.stapel) || {};
339
+ }
340
+
341
+ // Test hook: clear memoized defaults so RuleTester cases stay independent.
342
+ export function __resetCaches() {
343
+ _tokenCatalogDefault = undefined;
344
+ _i18nDefault = undefined;
345
+ _eventsCatalogDefault = undefined;
346
+ _operationCatalogDefault = undefined;
347
+ }
348
+
349
+ export { resolve as _resolve };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@stapel/eslint-plugin",
3
+ "version": "0.2.0",
4
+ "description": "Stapel frontend guardrails as static lint rules (frontend-guardrails §2): no raw colours, no raw fetch, no raw-ramp imports, i18n-key existence, no hardcoded user-facing text — every rule reads the same generated artifacts (tokens manifest, i18n key registries) as the codegen, so lint and code never drift. Ships a flat-config `recommended` preset plus a self-contained stylelint preset for CSS.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/usestapel/stapel-react.git",
9
+ "directory": "packages/eslint-plugin"
10
+ },
11
+ "type": "module",
12
+ "main": "./index.js",
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./stylelint": "./stylelint/index.js",
16
+ "./stylelint/preset": "./stylelint/preset.js",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "index.js",
21
+ "lib",
22
+ "rules",
23
+ "stylelint",
24
+ "README.md"
25
+ ],
26
+ "dependencies": {
27
+ "@stapel/tokens": "^0.2.0"
28
+ },
29
+ "peerDependencies": {
30
+ "eslint": ">=9",
31
+ "stylelint": ">=16"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "stylelint": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@typescript-eslint/parser": "^8.35.0",
40
+ "eslint": "^9.30.0",
41
+ "stylelint": "^16.0.0",
42
+ "vitest": "^3.2.4"
43
+ },
44
+ "engines": {
45
+ "node": ">=22"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "test": "vitest run",
52
+ "lint": "eslint ."
53
+ }
54
+ }
@@ -0,0 +1,194 @@
1
+ // stapel/clickable-needs-event — frontend-guardrails §3.2 (failure mode F10:
2
+ // "analytics as an afterthought — the agent generates clickable UI with no
3
+ // events"). A JSX element carrying an interactive handler (onClick/onSubmit/
4
+ // onPointerDown/…) must statically fall into exactly ONE of three outcomes:
5
+ //
6
+ // (a) the handler is wrapped — onClick={tracked(event, props, handler)} (or
7
+ // trackedSubmit(...)); the wrapper name is the static marker.
8
+ // (b) the click steps a flow machine — the element is marked
9
+ // data-analytics="flow"; the machine is already auto-instrumented
10
+ // (flow.<id>.<step>), so the funnel emits itself.
11
+ // (c) the click is deliberately untracked — data-analytics="none" plus a
12
+ // NON-EMPTY data-analytics-reason="…"; the report (G5) lists it under
13
+ // "explicitly untracked".
14
+ //
15
+ // Everything is checked syntactically (§3.2: "syntactic checkability >
16
+ // semantic undecidability"). The check is per-element, not per-attribute — one
17
+ // outcome covers all of an element's handlers.
18
+ //
19
+ // Decorative/technical clicks are EXEMPT by policy (documented here so the
20
+ // message can teach it): a handler whose body does nothing but call
21
+ // e.stopPropagation() / e.preventDefault() is plumbing, not a user action — it
22
+ // does not represent an interaction to track. Everything else that carries an
23
+ // interactive prop (including custom components that merely forward onClick)
24
+ // must pick an outcome; a forwarding component marks itself
25
+ // data-analytics="none" reason="passthrough" and the real click point is
26
+ // tracked downstream.
27
+ import { stapelSettings } from "../lib/data.js";
28
+
29
+ const DEFAULT_HANDLERS = [
30
+ "onClick",
31
+ "onDoubleClick",
32
+ "onSubmit",
33
+ "onPointerDown",
34
+ "onPointerUp",
35
+ "onMouseDown",
36
+ "onMouseUp",
37
+ "onKeyDown",
38
+ "onKeyUp",
39
+ "onKeyPress",
40
+ "onTouchStart",
41
+ "onTouchEnd",
42
+ ];
43
+ const DEFAULT_WRAPPERS = ["tracked", "trackedSubmit"];
44
+
45
+ const DEV_HINT =
46
+ 'Choose one: wrap the handler — onClick={tracked(event, props, handler)} (events: app/analytics/events.ts); mark a flow action data-analytics="flow" (the auto-instrumented funnel emits flow.<id>.<step> itself); or justify opting out data-analytics="none" data-analytics-reason="…". Decorative clicks whose handler only calls e.stopPropagation()/preventDefault() are exempt.';
47
+
48
+ function attrName(attr) {
49
+ return attr.type === "JSXAttribute" && attr.name && attr.name.type === "JSXIdentifier"
50
+ ? attr.name.name
51
+ : null;
52
+ }
53
+
54
+ /** Static string value of an attribute, or undefined if dynamic/absent. */
55
+ function attrStringValue(attr) {
56
+ const v = attr.value;
57
+ if (v == null) return true; // boolean shorthand `data-analytics`
58
+ if (v.type === "Literal") return v.value;
59
+ if (
60
+ v.type === "JSXExpressionContainer" &&
61
+ v.expression.type === "Literal"
62
+ )
63
+ return v.expression.value;
64
+ return undefined; // dynamic expression — not statically known
65
+ }
66
+
67
+ /** The CallExpression wrapping a handler, if it is tracked()/trackedSubmit(). */
68
+ function trackedCallOf(attr, wrappers) {
69
+ const v = attr.value;
70
+ if (!v || v.type !== "JSXExpressionContainer") return null;
71
+ const expr = v.expression;
72
+ if (expr.type !== "CallExpression") return null;
73
+ const callee = expr.callee;
74
+ const name =
75
+ callee.type === "Identifier"
76
+ ? callee.name
77
+ : callee.type === "MemberExpression" &&
78
+ !callee.computed &&
79
+ callee.property.type === "Identifier"
80
+ ? callee.property.name
81
+ : null;
82
+ return name && wrappers.has(name) ? expr : null;
83
+ }
84
+
85
+ /** True if `fn`'s body does nothing but stopPropagation()/preventDefault(). */
86
+ function isDecorativeHandler(fn) {
87
+ if (
88
+ !fn ||
89
+ (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression")
90
+ )
91
+ return false;
92
+ const isPlumbingCall = (node) =>
93
+ node &&
94
+ node.type === "CallExpression" &&
95
+ node.callee.type === "MemberExpression" &&
96
+ !node.callee.computed &&
97
+ node.callee.property.type === "Identifier" &&
98
+ (node.callee.property.name === "stopPropagation" ||
99
+ node.callee.property.name === "preventDefault");
100
+ const body = fn.body;
101
+ if (body.type === "CallExpression") return isPlumbingCall(body); // implicit return
102
+ if (body.type !== "BlockStatement") return false;
103
+ if (body.body.length === 0) return false;
104
+ return body.body.every(
105
+ (s) => s.type === "ExpressionStatement" && isPlumbingCall(s.expression)
106
+ );
107
+ }
108
+
109
+ export default {
110
+ meta: {
111
+ type: "problem",
112
+ docs: {
113
+ description:
114
+ "Require an interactive JSX element to declare an analytics outcome: tracked() wrapper, data-analytics=\"flow\", or data-analytics=\"none\" with a reason.",
115
+ },
116
+ schema: [
117
+ {
118
+ type: "object",
119
+ properties: {
120
+ handlers: { type: "array", items: { type: "string" } },
121
+ wrappers: { type: "array", items: { type: "string" } },
122
+ },
123
+ additionalProperties: false,
124
+ },
125
+ ],
126
+ messages: {
127
+ needsEvent: "Clickable element with no analytics outcome. " + DEV_HINT + " §3.2 stapel/clickable-needs-event",
128
+ noneNeedsReason:
129
+ 'data-analytics="none" needs a non-empty data-analytics-reason="…" — the report (G5) lists deliberate opt-outs under "explicitly untracked", so the decision stays visible on review. §3.2 stapel/clickable-needs-event',
130
+ },
131
+ },
132
+ create(context) {
133
+ const settings = stapelSettings(context);
134
+ const handlers = new Set(
135
+ context.options[0]?.handlers ?? settings.clickHandlers ?? DEFAULT_HANDLERS
136
+ );
137
+ const wrappers = new Set(
138
+ context.options[0]?.wrappers ?? settings.trackedWrappers ?? DEFAULT_WRAPPERS
139
+ );
140
+
141
+ return {
142
+ JSXOpeningElement(node) {
143
+ let interactiveAttrs = [];
144
+ let dataAnalytics; // undefined = absent; string/true = value; null = dynamic
145
+ let hasReason = false;
146
+ for (const attr of node.attributes) {
147
+ const name = attrName(attr);
148
+ if (!name) continue; // spread attribute — can't inspect
149
+ if (handlers.has(name) && attr.value != null) interactiveAttrs.push(attr);
150
+ else if (name === "data-analytics") {
151
+ const val = attrStringValue(attr);
152
+ dataAnalytics = val === undefined ? null : val;
153
+ } else if (name === "data-analytics-reason") {
154
+ const val = attrStringValue(attr);
155
+ hasReason = typeof val === "string" && val.trim().length > 0;
156
+ }
157
+ }
158
+
159
+ if (interactiveAttrs.length === 0) return; // nothing to enforce
160
+
161
+ // Dynamic data-analytics value — author took explicit control we can't
162
+ // read statically; don't cry wolf (§3.2 syntactic-only policy).
163
+ if (dataAnalytics === null) return;
164
+
165
+ // (b) flow action — the machine auto-emits; satisfied.
166
+ if (dataAnalytics === "flow") return;
167
+
168
+ // (c) explicit opt-out — needs a reason.
169
+ if (dataAnalytics === "none") {
170
+ if (!hasReason) {
171
+ context.report({ node, messageId: "noneNeedsReason" });
172
+ }
173
+ return;
174
+ }
175
+
176
+ // (a) at least one handler is a tracked()/trackedSubmit() wrapper.
177
+ if (interactiveAttrs.some((a) => trackedCallOf(a, wrappers))) return;
178
+
179
+ // Exempt decorative/technical handlers (stopPropagation/preventDefault
180
+ // only) — plumbing, not an interaction.
181
+ if (
182
+ interactiveAttrs.every((a) => {
183
+ const v = a.value;
184
+ const fn = v && v.type === "JSXExpressionContainer" ? v.expression : null;
185
+ return isDecorativeHandler(fn);
186
+ })
187
+ )
188
+ return;
189
+
190
+ context.report({ node, messageId: "needsEvent" });
191
+ },
192
+ };
193
+ },
194
+ };