@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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// stapel/demo-literal-meta — frontend-guardrails §4.2. defineDemo() must be
|
|
2
|
+
// called with a LITERAL object so the static extractor (gen:demos, AST) can
|
|
3
|
+
// project it into demos.json / manifest.demos / CSF stories and evaluate the
|
|
4
|
+
// completeness gate. A dynamic definition is not "wrong" at runtime; it is
|
|
5
|
+
// INVISIBLE to those projections, so the demo silently vanishes from the
|
|
6
|
+
// catalog, the viewer, and the gate. This rule keeps the meta extractable: the
|
|
7
|
+
// argument is an object literal; `id`/`title`/`description` are string
|
|
8
|
+
// literals; `component` is a plain reference (identifier or member); and
|
|
9
|
+
// `variants` is an object literal with static keys. `render` closures and other
|
|
10
|
+
// values are intentionally free — only the statically-read meta is constrained.
|
|
11
|
+
const CATALOG =
|
|
12
|
+
"gen:demos extracts demos.json/manifest.demos/CSF from these literals — keep the meta static or the demo never reaches the catalog, the viewer, or the completeness gate.";
|
|
13
|
+
|
|
14
|
+
const DEFINERS = new Set(["defineDemo"]);
|
|
15
|
+
|
|
16
|
+
function isStringLiteral(node) {
|
|
17
|
+
return node && node.type === "Literal" && typeof node.value === "string";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function objProp(objExpr, key) {
|
|
21
|
+
for (const p of objExpr.properties) {
|
|
22
|
+
if (
|
|
23
|
+
p.type === "Property" &&
|
|
24
|
+
!p.computed &&
|
|
25
|
+
((p.key.type === "Identifier" && p.key.name === key) ||
|
|
26
|
+
(p.key.type === "Literal" && p.key.value === key))
|
|
27
|
+
)
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isReference(node) {
|
|
34
|
+
return (
|
|
35
|
+
node &&
|
|
36
|
+
(node.type === "Identifier" ||
|
|
37
|
+
(node.type === "MemberExpression" && !node.computed))
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isDefiner(callee) {
|
|
42
|
+
if (callee.type === "Identifier") return DEFINERS.has(callee.name);
|
|
43
|
+
if (
|
|
44
|
+
callee.type === "MemberExpression" &&
|
|
45
|
+
!callee.computed &&
|
|
46
|
+
callee.property.type === "Identifier"
|
|
47
|
+
)
|
|
48
|
+
return DEFINERS.has(callee.property.name);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
meta: {
|
|
54
|
+
type: "problem",
|
|
55
|
+
docs: {
|
|
56
|
+
description:
|
|
57
|
+
"Require defineDemo() to be called with a literal object (literal id/title/description, a component reference, an object-literal variants map) so the demo registry stays statically extractable.",
|
|
58
|
+
},
|
|
59
|
+
schema: [],
|
|
60
|
+
messages: {
|
|
61
|
+
notObject:
|
|
62
|
+
"defineDemo() needs a literal object argument, got a dynamic value. " +
|
|
63
|
+
CATALOG +
|
|
64
|
+
" §4.2 stapel/demo-literal-meta",
|
|
65
|
+
dynamicString:
|
|
66
|
+
'defineDemo `{{key}}` must be a string literal — it is the catalog/viewer copy. ' +
|
|
67
|
+
CATALOG +
|
|
68
|
+
" §4.2 stapel/demo-literal-meta",
|
|
69
|
+
dynamicComponent:
|
|
70
|
+
"defineDemo `component` must be a component reference (e.g. `PasswordlessLogin`) so the completeness gate can read its name. " +
|
|
71
|
+
CATALOG +
|
|
72
|
+
" §4.2 stapel/demo-literal-meta",
|
|
73
|
+
dynamicVariants:
|
|
74
|
+
"defineDemo `variants` must be an object literal with static keys — each key becomes a viewer story and a smoke test. " +
|
|
75
|
+
CATALOG +
|
|
76
|
+
" §4.2 stapel/demo-literal-meta",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
create(context) {
|
|
80
|
+
return {
|
|
81
|
+
CallExpression(node) {
|
|
82
|
+
if (!isDefiner(node.callee)) return;
|
|
83
|
+
const arg = node.arguments[0];
|
|
84
|
+
if (!arg || arg.type !== "ObjectExpression") {
|
|
85
|
+
context.report({ node: arg ?? node, messageId: "notObject" });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const key of ["id", "title", "description"]) {
|
|
90
|
+
const p = objProp(arg, key);
|
|
91
|
+
if (p && !isStringLiteral(p.value)) {
|
|
92
|
+
context.report({
|
|
93
|
+
node: p.value,
|
|
94
|
+
messageId: "dynamicString",
|
|
95
|
+
data: { key },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const componentProp = objProp(arg, "component");
|
|
101
|
+
if (componentProp && !isReference(componentProp.value)) {
|
|
102
|
+
context.report({
|
|
103
|
+
node: componentProp.value,
|
|
104
|
+
messageId: "dynamicComponent",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const variantsProp = objProp(arg, "variants");
|
|
109
|
+
if (variantsProp) {
|
|
110
|
+
const v = variantsProp.value;
|
|
111
|
+
if (v.type !== "ObjectExpression") {
|
|
112
|
+
context.report({ node: v, messageId: "dynamicVariants" });
|
|
113
|
+
} else {
|
|
114
|
+
for (const p of v.properties) {
|
|
115
|
+
if (p.type !== "Property" || p.computed) {
|
|
116
|
+
context.report({ node: p, messageId: "dynamicVariants" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// stapel/event-literal-meta — frontend-guardrails §3.1. defineEvent() must be
|
|
2
|
+
// called with a LITERAL object so the static extractor (gen:events, ts-morph/
|
|
3
|
+
// AST) can project it into events.json — the single source the lint (G4) and
|
|
4
|
+
// the report (G5) read. A dynamic definition is not "wrong" at runtime; it is
|
|
5
|
+
// INVISIBLE to the registry and reports, so the whole typed-analytics layer
|
|
6
|
+
// silently loses that event. This rule keeps definitions statically
|
|
7
|
+
// extractable: the argument is an object literal, `name`/`description` are
|
|
8
|
+
// string literals, and every prop is built with a prop.* builder.
|
|
9
|
+
import { stapelSettings } from "../lib/data.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DEFINERS = ["defineEvent"];
|
|
12
|
+
const DEFAULT_PROP_BUILDER = "prop"; // prop.string/number/boolean/oneOf
|
|
13
|
+
|
|
14
|
+
const CATALOG =
|
|
15
|
+
"gen:events extracts events.json from these literals — keep it static or the event never reaches the registry/reports.";
|
|
16
|
+
|
|
17
|
+
function isStringLiteral(node) {
|
|
18
|
+
return node && node.type === "Literal" && typeof node.value === "string";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function objProp(objExpr, key) {
|
|
22
|
+
for (const p of objExpr.properties) {
|
|
23
|
+
if (
|
|
24
|
+
p.type === "Property" &&
|
|
25
|
+
!p.computed &&
|
|
26
|
+
((p.key.type === "Identifier" && p.key.name === key) ||
|
|
27
|
+
(p.key.type === "Literal" && p.key.value === key))
|
|
28
|
+
)
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True if `node` is a `prop.<builder>(...)` call. */
|
|
35
|
+
function isPropBuilderCall(node, builderName) {
|
|
36
|
+
return (
|
|
37
|
+
node &&
|
|
38
|
+
node.type === "CallExpression" &&
|
|
39
|
+
node.callee.type === "MemberExpression" &&
|
|
40
|
+
!node.callee.computed &&
|
|
41
|
+
node.callee.object.type === "Identifier" &&
|
|
42
|
+
node.callee.object.name === builderName &&
|
|
43
|
+
node.callee.property.type === "Identifier"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
meta: {
|
|
49
|
+
type: "problem",
|
|
50
|
+
docs: {
|
|
51
|
+
description:
|
|
52
|
+
"Require defineEvent() to be called with a literal object (literal name/description, prop.* builders) so the event registry stays statically extractable.",
|
|
53
|
+
},
|
|
54
|
+
schema: [
|
|
55
|
+
{
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
definers: { type: "array", items: { type: "string" } },
|
|
59
|
+
propBuilder: { type: "string" },
|
|
60
|
+
},
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
messages: {
|
|
65
|
+
notObject:
|
|
66
|
+
"defineEvent() needs a literal object argument, got a dynamic value. " +
|
|
67
|
+
CATALOG +
|
|
68
|
+
" §3.1 stapel/event-literal-meta",
|
|
69
|
+
dynamicName:
|
|
70
|
+
'defineEvent `name` must be a string literal (e.g. "pricing.plan.selected"), not a computed value. ' +
|
|
71
|
+
CATALOG +
|
|
72
|
+
" §3.1 stapel/event-literal-meta",
|
|
73
|
+
missingDescription:
|
|
74
|
+
"defineEvent `description` must be a string literal — it is the registry/report copy for this event. " +
|
|
75
|
+
CATALOG +
|
|
76
|
+
" §3.1 stapel/event-literal-meta",
|
|
77
|
+
dynamicProps:
|
|
78
|
+
"defineEvent `props` must be a literal object of prop.* builders. " +
|
|
79
|
+
CATALOG +
|
|
80
|
+
" §3.1 stapel/event-literal-meta",
|
|
81
|
+
dynamicProp:
|
|
82
|
+
'defineEvent prop "{{prop}}" must use a prop.* builder (e.g. prop.oneOf(["a","b"], "…")), not a dynamic value — otherwise the extractor cannot read its meta. ' +
|
|
83
|
+
CATALOG +
|
|
84
|
+
" §3.1 stapel/event-literal-meta",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
create(context) {
|
|
88
|
+
const settings = stapelSettings(context);
|
|
89
|
+
const definers = new Set(
|
|
90
|
+
context.options[0]?.definers ?? settings.eventDefiners ?? DEFAULT_DEFINERS
|
|
91
|
+
);
|
|
92
|
+
const builder = context.options[0]?.propBuilder ?? DEFAULT_PROP_BUILDER;
|
|
93
|
+
|
|
94
|
+
function isDefiner(callee) {
|
|
95
|
+
if (callee.type === "Identifier") return definers.has(callee.name);
|
|
96
|
+
if (
|
|
97
|
+
callee.type === "MemberExpression" &&
|
|
98
|
+
!callee.computed &&
|
|
99
|
+
callee.property.type === "Identifier"
|
|
100
|
+
)
|
|
101
|
+
return definers.has(callee.property.name);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
CallExpression(node) {
|
|
107
|
+
if (!isDefiner(node.callee)) return;
|
|
108
|
+
const arg = node.arguments[0];
|
|
109
|
+
if (!arg || arg.type !== "ObjectExpression") {
|
|
110
|
+
context.report({ node: arg ?? node, messageId: "notObject" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const nameProp = objProp(arg, "name");
|
|
115
|
+
if (nameProp && !isStringLiteral(nameProp.value)) {
|
|
116
|
+
context.report({ node: nameProp.value, messageId: "dynamicName" });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const descProp = objProp(arg, "description");
|
|
120
|
+
if (descProp && !isStringLiteral(descProp.value)) {
|
|
121
|
+
context.report({ node: descProp.value, messageId: "missingDescription" });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const propsProp = objProp(arg, "props");
|
|
125
|
+
if (propsProp) {
|
|
126
|
+
const propsVal = propsProp.value;
|
|
127
|
+
if (propsVal.type !== "ObjectExpression") {
|
|
128
|
+
context.report({ node: propsVal, messageId: "dynamicProps" });
|
|
129
|
+
} else {
|
|
130
|
+
for (const p of propsVal.properties) {
|
|
131
|
+
if (p.type !== "Property" || p.computed) {
|
|
132
|
+
context.report({ node: p, messageId: "dynamicProps" });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!isPropBuilderCall(p.value, builder)) {
|
|
136
|
+
const key =
|
|
137
|
+
p.key.type === "Identifier"
|
|
138
|
+
? p.key.name
|
|
139
|
+
: p.key.type === "Literal"
|
|
140
|
+
? String(p.key.value)
|
|
141
|
+
: "?";
|
|
142
|
+
context.report({
|
|
143
|
+
node: p.value,
|
|
144
|
+
messageId: "dynamicProp",
|
|
145
|
+
data: { prop: key },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// stapel/i18n-key-exists — frontend-guardrails §2.2.
|
|
2
|
+
// A `t("...")` key must exist in the generated i18n registry (pair manifests +
|
|
3
|
+
// app keys). Data-driven: the key set comes from the manifests the codegen
|
|
4
|
+
// writes. False-positive policy (§2.2): only string-literal keys are checked,
|
|
5
|
+
// and only within a MANAGED namespace (a top-level segment some manifest owns)
|
|
6
|
+
// — an unknown key under an unmanaged namespace is assumed app-local, not a
|
|
7
|
+
// typo, so bespoke host keys never false-positive.
|
|
8
|
+
import { loadI18nRegistry, stapelSettings } from "../lib/data.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CALLEES = ["t", "translate"];
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
meta: {
|
|
14
|
+
type: "problem",
|
|
15
|
+
docs: {
|
|
16
|
+
description:
|
|
17
|
+
"Disallow translation keys that are absent from the generated i18n registry.",
|
|
18
|
+
},
|
|
19
|
+
schema: [
|
|
20
|
+
{
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
functionNames: { type: "array", items: { type: "string" } },
|
|
24
|
+
},
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
messages: {
|
|
29
|
+
unknownKey:
|
|
30
|
+
'Unknown i18n key "{{key}}". It is not in the generated registry for the "{{ns}}" namespace. Add it to the owning package\'s keys (e.g. i18n/keys.ts) or fix the typo. Convention: @stapel/core/llms.txt §i18n.',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
const settings = stapelSettings(context);
|
|
35
|
+
const registry = loadI18nRegistry(settings);
|
|
36
|
+
if (!registry.loaded) return {}; // no catalog → no-op, never guess
|
|
37
|
+
|
|
38
|
+
const names = new Set(
|
|
39
|
+
context.options[0]?.functionNames ??
|
|
40
|
+
settings.i18nFunctions ??
|
|
41
|
+
DEFAULT_CALLEES
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function keyArg(node) {
|
|
45
|
+
const first = node.arguments[0];
|
|
46
|
+
if (first && first.type === "Literal" && typeof first.value === "string")
|
|
47
|
+
return first;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isTranslator(callee) {
|
|
52
|
+
if (callee.type === "Identifier") return names.has(callee.name);
|
|
53
|
+
// obj.t("...") — e.g. i18n.t, useT()-returned `t` is usually a bare id,
|
|
54
|
+
// but support member form for `.t`/`.translate`.
|
|
55
|
+
if (
|
|
56
|
+
callee.type === "MemberExpression" &&
|
|
57
|
+
!callee.computed &&
|
|
58
|
+
callee.property.type === "Identifier"
|
|
59
|
+
)
|
|
60
|
+
return names.has(callee.property.name);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
CallExpression(node) {
|
|
66
|
+
if (!isTranslator(node.callee)) return;
|
|
67
|
+
const arg = keyArg(node);
|
|
68
|
+
if (!arg) return; // dynamic key → skip (FP policy)
|
|
69
|
+
const key = arg.value;
|
|
70
|
+
if (!registry.manages(key)) return; // unmanaged namespace → app-local
|
|
71
|
+
if (registry.has(key)) return;
|
|
72
|
+
const ns = key.slice(0, key.indexOf("."));
|
|
73
|
+
context.report({ node: arg, messageId: "unknownKey", data: { key, ns } });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// stapel/known-event — frontend-guardrails §3 (failure mode F10: "string events
|
|
2
|
+
// with invented names"). A track()/tracked()/trackedSubmit() call whose event
|
|
3
|
+
// is not in the generated events.json is flagged as DRIFT — a warning, because
|
|
4
|
+
// it is legitimately transient: right after you add a defineEvent (or a flow),
|
|
5
|
+
// the registry is stale until `pnpm gen:events` runs. The message says so. Once
|
|
6
|
+
// the drift gate is green, an unknown name is a typo or a deleted event.
|
|
7
|
+
//
|
|
8
|
+
// Data-driven (§2.1): the known set is the `events` section of the package
|
|
9
|
+
// manifests — the same projection the report (G5) reads. A missing catalog
|
|
10
|
+
// degrades the rule to a no-op (never guesses). Resolution is conservative:
|
|
11
|
+
// a string-literal event name, or an in-file identifier that resolves to a
|
|
12
|
+
// defineEvent({ name: "…" }) — anything else is skipped, so this rule never
|
|
13
|
+
// false-positives on an event it cannot statically read.
|
|
14
|
+
import { loadEventsCatalog, stapelSettings } from "../lib/data.js";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_EMITTERS = ["track", "tracked", "trackedSubmit"];
|
|
17
|
+
const DEFAULT_DEFINERS = ["defineEvent"];
|
|
18
|
+
|
|
19
|
+
function isStringLiteral(node) {
|
|
20
|
+
return node && node.type === "Literal" && typeof node.value === "string";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function objProp(objExpr, key) {
|
|
24
|
+
for (const p of objExpr.properties) {
|
|
25
|
+
if (
|
|
26
|
+
p.type === "Property" &&
|
|
27
|
+
!p.computed &&
|
|
28
|
+
((p.key.type === "Identifier" && p.key.name === key) ||
|
|
29
|
+
(p.key.type === "Literal" && p.key.value === key))
|
|
30
|
+
)
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function calleeName(callee) {
|
|
37
|
+
if (callee.type === "Identifier") return callee.name;
|
|
38
|
+
if (
|
|
39
|
+
callee.type === "MemberExpression" &&
|
|
40
|
+
!callee.computed &&
|
|
41
|
+
callee.property.type === "Identifier"
|
|
42
|
+
)
|
|
43
|
+
return callee.property.name;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
meta: {
|
|
49
|
+
type: "suggestion",
|
|
50
|
+
docs: {
|
|
51
|
+
description:
|
|
52
|
+
"Warn when a tracked event name is absent from the generated events.json (registry drift — run gen:events).",
|
|
53
|
+
},
|
|
54
|
+
schema: [
|
|
55
|
+
{
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
emitters: { type: "array", items: { type: "string" } },
|
|
59
|
+
definers: { type: "array", items: { type: "string" } },
|
|
60
|
+
},
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
messages: {
|
|
65
|
+
unknownEvent:
|
|
66
|
+
'Event "{{name}}" is not in the generated events.json. If you just declared it, run `pnpm gen:events` to refresh the registry; otherwise it is a typo or a removed event. §3 stapel/known-event',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
create(context) {
|
|
70
|
+
const settings = stapelSettings(context);
|
|
71
|
+
const catalog = loadEventsCatalog(settings);
|
|
72
|
+
if (!catalog.loaded) return {}; // no catalog → no-op, never guess
|
|
73
|
+
|
|
74
|
+
const emitters = new Set(
|
|
75
|
+
context.options[0]?.emitters ?? settings.eventEmitters ?? DEFAULT_EMITTERS
|
|
76
|
+
);
|
|
77
|
+
const definers = new Set(
|
|
78
|
+
context.options[0]?.definers ?? settings.eventDefiners ?? DEFAULT_DEFINERS
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// identifier → event name, filled from `const X = defineEvent({name})`.
|
|
82
|
+
const localEvents = new Map();
|
|
83
|
+
// Deferred checks — declarations may follow first use in source order.
|
|
84
|
+
const pending = [];
|
|
85
|
+
|
|
86
|
+
function definerName(callee) {
|
|
87
|
+
const n = calleeName(callee);
|
|
88
|
+
return n && definers.has(n);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
VariableDeclarator(node) {
|
|
93
|
+
if (
|
|
94
|
+
node.id.type === "Identifier" &&
|
|
95
|
+
node.init &&
|
|
96
|
+
node.init.type === "CallExpression" &&
|
|
97
|
+
definerName(node.init.callee)
|
|
98
|
+
) {
|
|
99
|
+
const arg = node.init.arguments[0];
|
|
100
|
+
if (arg && arg.type === "ObjectExpression") {
|
|
101
|
+
const nameProp = objProp(arg, "name");
|
|
102
|
+
if (nameProp && isStringLiteral(nameProp.value)) {
|
|
103
|
+
localEvents.set(node.id.name, nameProp.value.value);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
CallExpression(node) {
|
|
110
|
+
const name = calleeName(node.callee);
|
|
111
|
+
if (!name || !emitters.has(name)) return;
|
|
112
|
+
const evArg = node.arguments[0];
|
|
113
|
+
if (!evArg) return;
|
|
114
|
+
if (isStringLiteral(evArg)) {
|
|
115
|
+
pending.push({ node: evArg, name: evArg.value });
|
|
116
|
+
} else if (evArg.type === "Identifier") {
|
|
117
|
+
pending.push({ node: evArg, ident: evArg.name });
|
|
118
|
+
}
|
|
119
|
+
// member/other event expressions → not statically resolvable → skip.
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
"Program:exit"() {
|
|
123
|
+
for (const p of pending) {
|
|
124
|
+
const name = p.name ?? localEvents.get(p.ident);
|
|
125
|
+
if (name == null) continue; // unresolved identifier → skip
|
|
126
|
+
if (!catalog.isKnown(name)) {
|
|
127
|
+
context.report({ node: p.node, messageId: "unknownEvent", data: { name } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// stapel/no-direct-analytics-provider — frontend-guardrails §2.2 / §3.
|
|
2
|
+
// Analytics goes through the @stapel/core facade (consent gate, PII guard,
|
|
3
|
+
// offline queue, fan-out), NEVER straight into a vendor SDK. Importing a
|
|
4
|
+
// provider package anywhere but the facade's provider-adapter module bypasses
|
|
5
|
+
// consent and PII enforcement in one line — the cheapest F9 violation there
|
|
6
|
+
// is. The recommended preset turns this rule OFF for `analytics/providers.*`
|
|
7
|
+
// (the one legal adapter home) via file overrides; the rule itself always
|
|
8
|
+
// flags.
|
|
9
|
+
//
|
|
10
|
+
// Vendor list: known analytics SDK package prefixes. Extendable per host via
|
|
11
|
+
// options ({ providers: ["@acme/tracker"] }) or settings.stapel.providerModules.
|
|
12
|
+
import { stapelSettings } from "../lib/data.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PROVIDERS = [
|
|
15
|
+
"posthog-js",
|
|
16
|
+
"posthog-node",
|
|
17
|
+
"mixpanel-browser",
|
|
18
|
+
"mixpanel",
|
|
19
|
+
"amplitude-js",
|
|
20
|
+
"@amplitude/",
|
|
21
|
+
"@segment/",
|
|
22
|
+
"analytics-node",
|
|
23
|
+
"@rudderstack/",
|
|
24
|
+
"rudder-sdk-js",
|
|
25
|
+
"@snowplow/",
|
|
26
|
+
"react-ga",
|
|
27
|
+
"react-ga4",
|
|
28
|
+
"@google-analytics/",
|
|
29
|
+
"@datadog/browser-rum",
|
|
30
|
+
"@fullstory/",
|
|
31
|
+
"heap-api",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: {
|
|
38
|
+
description:
|
|
39
|
+
"Disallow importing analytics provider SDKs outside the core facade's provider adapters.",
|
|
40
|
+
},
|
|
41
|
+
schema: [
|
|
42
|
+
{
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
providers: { type: "array", items: { type: "string" } },
|
|
46
|
+
},
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
messages: {
|
|
51
|
+
directProvider:
|
|
52
|
+
'Direct analytics provider import "{{source}}". Emit through the @stapel/core facade instead — analytics.track(event, props) / tracked(event, props, handler) — which owns consent, PII guarding, and the offline queue (§3). Provider SDKs are wired ONCE, in the facade\'s provider adapter (analytics/providers.ts). See @stapel/core/llms.txt §analytics.',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
create(context) {
|
|
56
|
+
const settings = stapelSettings(context);
|
|
57
|
+
const extra = context.options[0]?.providers ?? [];
|
|
58
|
+
const prefixes = [
|
|
59
|
+
...DEFAULT_PROVIDERS,
|
|
60
|
+
...(settings.providerModules ?? []),
|
|
61
|
+
...extra,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function isProvider(source) {
|
|
65
|
+
return prefixes.some((p) =>
|
|
66
|
+
p.endsWith("/") || p.endsWith("-")
|
|
67
|
+
? source.startsWith(p)
|
|
68
|
+
: source === p || source.startsWith(`${p}/`)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function check(node, source) {
|
|
73
|
+
if (typeof source === "string" && isProvider(source)) {
|
|
74
|
+
context.report({ node, messageId: "directProvider", data: { source } });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ImportDeclaration(node) {
|
|
80
|
+
check(node, node.source.value);
|
|
81
|
+
},
|
|
82
|
+
ExportNamedDeclaration(node) {
|
|
83
|
+
if (node.source) check(node, node.source.value);
|
|
84
|
+
},
|
|
85
|
+
ExportAllDeclaration(node) {
|
|
86
|
+
if (node.source) check(node, node.source.value);
|
|
87
|
+
},
|
|
88
|
+
ImportExpression(node) {
|
|
89
|
+
if (node.source.type === "Literal") check(node, node.source.value);
|
|
90
|
+
},
|
|
91
|
+
CallExpression(node) {
|
|
92
|
+
if (
|
|
93
|
+
node.callee.type === "Identifier" &&
|
|
94
|
+
node.callee.name === "require" &&
|
|
95
|
+
node.arguments[0]?.type === "Literal"
|
|
96
|
+
) {
|
|
97
|
+
check(node, node.arguments[0].value);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|