@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/LICENSE +21 -0
- package/README.md +89 -0
- package/index.js +194 -0
- package/lib/colors.js +73 -0
- package/lib/data.js +349 -0
- package/package.json +54 -0
- package/rules/clickable-needs-event.js +194 -0
- package/rules/demo-literal-meta.js +124 -0
- package/rules/event-literal-meta.js +154 -0
- package/rules/i18n-key-exists.js +77 -0
- package/rules/known-event.js +133 -0
- package/rules/no-direct-analytics-provider.js +102 -0
- package/rules/no-double-count.js +180 -0
- package/rules/no-hardcoded-text.js +99 -0
- package/rules/no-raw-colors.js +197 -0
- package/rules/no-raw-fetch.js +100 -0
- package/rules/no-raw-token-import.js +65 -0
- package/rules/no-string-paths.js +168 -0
- package/rules/query-keys-from-factory.js +143 -0
- package/rules/require-disable-description.js +60 -0
- package/stylelint/index.js +93 -0
- package/stylelint/preset.js +14 -0
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
|
+
};
|