@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,180 @@
|
|
|
1
|
+
// stapel/no-double-count — frontend-guardrails §3.2 + user decision Q12а
|
|
2
|
+
// (hard ban; overrides open question §7). A flow machine is already
|
|
3
|
+
// auto-instrumented — every transition emits flow.<id>.<step>. Wrapping a
|
|
4
|
+
// handler that ALSO steps that machine in tracked()/trackedSubmit() double-
|
|
5
|
+
// counts the funnel. Exactly one channel is allowed.
|
|
6
|
+
//
|
|
7
|
+
// Two heuristics, both syntactic (§3.2):
|
|
8
|
+
//
|
|
9
|
+
// 1. tracked(event, props, handler) where the handler steps a machine — a
|
|
10
|
+
// call to a run / step / submit* method inside the handler function.
|
|
11
|
+
// 2. an element that BOTH carries data-analytics="flow" AND wraps its handler
|
|
12
|
+
// in tracked()/trackedSubmit() — the author asserted the flow channel and
|
|
13
|
+
// opened a second one.
|
|
14
|
+
//
|
|
15
|
+
// The teaching message is the same in both cases: the flow step already emits
|
|
16
|
+
// the funnel — drop the tracked() wrapper OR the flow marker, keep one channel.
|
|
17
|
+
import { stapelSettings } from "../lib/data.js";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_WRAPPERS = ["tracked", "trackedSubmit"];
|
|
20
|
+
const DEFAULT_HANDLERS = [
|
|
21
|
+
"onClick",
|
|
22
|
+
"onDoubleClick",
|
|
23
|
+
"onSubmit",
|
|
24
|
+
"onPointerDown",
|
|
25
|
+
"onPointerUp",
|
|
26
|
+
"onMouseDown",
|
|
27
|
+
"onMouseUp",
|
|
28
|
+
"onKeyDown",
|
|
29
|
+
"onKeyUp",
|
|
30
|
+
"onKeyPress",
|
|
31
|
+
"onTouchStart",
|
|
32
|
+
"onTouchEnd",
|
|
33
|
+
];
|
|
34
|
+
// Flow-machine stepping methods (frontend-guardrails §3.2 heuristic:
|
|
35
|
+
// "run/step/submit* methods of the machine"). `submit*` matches submit,
|
|
36
|
+
// submitCode, submitForm, …
|
|
37
|
+
const STEP_METHODS = new Set(["run", "step"]);
|
|
38
|
+
const STEP_PREFIX = "submit";
|
|
39
|
+
|
|
40
|
+
const LESSON =
|
|
41
|
+
"the flow step already emits the funnel (flow.<id>.<step>) — remove the tracked() wrapper OR the flow marker, keep exactly one channel (Q12а). §3.2 stapel/no-double-count";
|
|
42
|
+
|
|
43
|
+
function isWrapperCall(node, wrappers) {
|
|
44
|
+
if (!node || node.type !== "CallExpression") return false;
|
|
45
|
+
const callee = node.callee;
|
|
46
|
+
const name =
|
|
47
|
+
callee.type === "Identifier"
|
|
48
|
+
? callee.name
|
|
49
|
+
: callee.type === "MemberExpression" &&
|
|
50
|
+
!callee.computed &&
|
|
51
|
+
callee.property.type === "Identifier"
|
|
52
|
+
? callee.property.name
|
|
53
|
+
: null;
|
|
54
|
+
return !!name && wrappers.has(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isStepMethodCall(node) {
|
|
58
|
+
if (
|
|
59
|
+
node.type !== "CallExpression" ||
|
|
60
|
+
node.callee.type !== "MemberExpression" ||
|
|
61
|
+
node.callee.computed ||
|
|
62
|
+
node.callee.property.type !== "Identifier"
|
|
63
|
+
)
|
|
64
|
+
return false;
|
|
65
|
+
const m = node.callee.property.name;
|
|
66
|
+
return STEP_METHODS.has(m) || m.startsWith(STEP_PREFIX);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Walk `root`'s descendants for a flow-stepping method call. */
|
|
70
|
+
function containsStep(root) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const stack = [root];
|
|
73
|
+
while (stack.length) {
|
|
74
|
+
const node = stack.pop();
|
|
75
|
+
if (!node || typeof node !== "object" || seen.has(node)) continue;
|
|
76
|
+
seen.add(node);
|
|
77
|
+
if (typeof node.type === "string" && isStepMethodCall(node)) return true;
|
|
78
|
+
for (const key of Object.keys(node)) {
|
|
79
|
+
if (key === "parent") continue;
|
|
80
|
+
const child = node[key];
|
|
81
|
+
if (Array.isArray(child)) {
|
|
82
|
+
for (const c of child) if (c && typeof c === "object") stack.push(c);
|
|
83
|
+
} else if (child && typeof child === "object" && typeof child.type === "string") {
|
|
84
|
+
stack.push(child);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function attrName(attr) {
|
|
92
|
+
return attr.type === "JSXAttribute" && attr.name && attr.name.type === "JSXIdentifier"
|
|
93
|
+
? attr.name.name
|
|
94
|
+
: null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function attrLiteral(attr) {
|
|
98
|
+
const v = attr.value;
|
|
99
|
+
if (v == null) return true;
|
|
100
|
+
if (v.type === "Literal") return v.value;
|
|
101
|
+
if (v.type === "JSXExpressionContainer" && v.expression.type === "Literal")
|
|
102
|
+
return v.expression.value;
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default {
|
|
107
|
+
meta: {
|
|
108
|
+
type: "problem",
|
|
109
|
+
docs: {
|
|
110
|
+
description:
|
|
111
|
+
"Disallow tracked()/trackedSubmit() on a handler that also steps a flow machine (double-counting the funnel).",
|
|
112
|
+
},
|
|
113
|
+
schema: [
|
|
114
|
+
{
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
wrappers: { type: "array", items: { type: "string" } },
|
|
118
|
+
handlers: { type: "array", items: { type: "string" } },
|
|
119
|
+
},
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
messages: {
|
|
124
|
+
trackedStepsFlow: "Double count: tracked() wraps a handler that steps a flow machine — " + LESSON,
|
|
125
|
+
flowMarkerAndTracked:
|
|
126
|
+
'Double count: element is marked data-analytics="flow" AND its handler is wrapped in tracked() — ' + LESSON,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
create(context) {
|
|
130
|
+
const settings = stapelSettings(context);
|
|
131
|
+
const wrappers = new Set(
|
|
132
|
+
context.options[0]?.wrappers ?? settings.trackedWrappers ?? DEFAULT_WRAPPERS
|
|
133
|
+
);
|
|
134
|
+
const handlers = new Set(
|
|
135
|
+
context.options[0]?.handlers ?? settings.clickHandlers ?? DEFAULT_HANDLERS
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// tracked() calls already reported via the flow-marker heuristic — skip
|
|
139
|
+
// them in the CallExpression pass so an element that both marks flow and
|
|
140
|
+
// steps a machine reports once. JSXOpeningElement is visited before the
|
|
141
|
+
// CallExpression nested in its attribute (top-down traversal).
|
|
142
|
+
const reportedViaMarker = new Set();
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
JSXOpeningElement(node) {
|
|
146
|
+
let isFlow = false;
|
|
147
|
+
const wrapperCalls = [];
|
|
148
|
+
for (const attr of node.attributes) {
|
|
149
|
+
const name = attrName(attr);
|
|
150
|
+
if (name === "data-analytics" && attrLiteral(attr) === "flow") isFlow = true;
|
|
151
|
+
else if (name && handlers.has(name)) {
|
|
152
|
+
const v = attr.value;
|
|
153
|
+
const expr = v && v.type === "JSXExpressionContainer" ? v.expression : null;
|
|
154
|
+
if (isWrapperCall(expr, wrappers)) wrapperCalls.push(expr);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (isFlow && wrapperCalls.length) {
|
|
158
|
+
for (const call of wrapperCalls) {
|
|
159
|
+
reportedViaMarker.add(call);
|
|
160
|
+
context.report({ node: call, messageId: "flowMarkerAndTracked" });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
CallExpression(node) {
|
|
166
|
+
if (!isWrapperCall(node, wrappers)) return;
|
|
167
|
+
if (reportedViaMarker.has(node)) return;
|
|
168
|
+
const handler = node.arguments[2];
|
|
169
|
+
if (!handler) return;
|
|
170
|
+
if (
|
|
171
|
+
(handler.type === "ArrowFunctionExpression" ||
|
|
172
|
+
handler.type === "FunctionExpression") &&
|
|
173
|
+
containsStep(handler.body)
|
|
174
|
+
) {
|
|
175
|
+
context.report({ node, messageId: "trackedStepsFlow" });
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// stapel/no-hardcoded-text — frontend-guardrails §2.2 (failure mode F7).
|
|
2
|
+
// User-facing text belongs to an i18n key, never a literal. Catches JSX text
|
|
3
|
+
// nodes and the user-facing string attributes (alt/title/placeholder/aria-*).
|
|
4
|
+
// Heuristic, with a conservative false-positive policy: only strings that read
|
|
5
|
+
// as prose (a run of letters plus a space, or a ≥4-char word) fire — icons,
|
|
6
|
+
// punctuation, numbers, single technical tokens, and camelCase identifiers are
|
|
7
|
+
// left alone.
|
|
8
|
+
import { stapelSettings } from "../lib/data.js";
|
|
9
|
+
|
|
10
|
+
const USER_FACING_ATTRS = new Set([
|
|
11
|
+
"alt",
|
|
12
|
+
"title",
|
|
13
|
+
"placeholder",
|
|
14
|
+
"aria-label",
|
|
15
|
+
"aria-description",
|
|
16
|
+
"aria-placeholder",
|
|
17
|
+
"aria-valuetext",
|
|
18
|
+
"aria-roledescription",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Prose = has a letter, and either contains whitespace between word chars or
|
|
22
|
+
// is a single word of length ≥ 4. Rejects "OK"? -> "OK" is 2 letters, no space
|
|
23
|
+
// → not flagged (conservative). "Sign in" → flagged. "px" → not flagged.
|
|
24
|
+
function looksLikeProse(raw) {
|
|
25
|
+
const s = raw.trim();
|
|
26
|
+
if (s.length < 3) return false;
|
|
27
|
+
if (!/[A-Za-zÀ-ɏ]/.test(s)) return false; // no letters → skip
|
|
28
|
+
if (/^[\d\s.,:;!?%$#@/\\|()[\]{}<>=+*~`^&-]+$/.test(s)) return false; // symbols/nums
|
|
29
|
+
// camelCase / snake / kebab single identifier with no space → likely technical
|
|
30
|
+
if (!/\s/.test(s) && /^[a-z][A-Za-z0-9]*$/.test(s) && s.length < 4) return false;
|
|
31
|
+
if (!/\s/.test(s)) {
|
|
32
|
+
// single token: require it to be a real-ish word (≥ 4 letters, mostly alpha)
|
|
33
|
+
const letters = (s.match(/[A-Za-zÀ-ɏ]/g) || []).length;
|
|
34
|
+
return letters >= 4 && letters / s.length > 0.6;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
meta: {
|
|
41
|
+
type: "problem",
|
|
42
|
+
docs: {
|
|
43
|
+
description:
|
|
44
|
+
"Disallow hardcoded user-facing text in JSX; use an i18n key.",
|
|
45
|
+
},
|
|
46
|
+
schema: [
|
|
47
|
+
{
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
ignore: { type: "array", items: { type: "string" } },
|
|
51
|
+
},
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
messages: {
|
|
56
|
+
hardcoded:
|
|
57
|
+
'Hardcoded user-facing text "{{text}}". Add an i18n key and render it via t("<key>") instead of a literal. Convention: @stapel/core/llms.txt §i18n.',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
create(context) {
|
|
61
|
+
const settings = stapelSettings(context);
|
|
62
|
+
const ignore = new Set([
|
|
63
|
+
...(settings.textIgnore ?? []),
|
|
64
|
+
...(context.options[0]?.ignore ?? []),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function maybeReport(node, raw) {
|
|
68
|
+
const text = String(raw);
|
|
69
|
+
if (ignore.has(text.trim())) return;
|
|
70
|
+
if (!looksLikeProse(text)) return;
|
|
71
|
+
context.report({
|
|
72
|
+
node,
|
|
73
|
+
messageId: "hardcoded",
|
|
74
|
+
data: { text: text.trim().slice(0, 40) },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
JSXText(node) {
|
|
80
|
+
maybeReport(node, node.value);
|
|
81
|
+
},
|
|
82
|
+
JSXAttribute(node) {
|
|
83
|
+
const name = node.name && node.name.name;
|
|
84
|
+
if (typeof name !== "string" || !USER_FACING_ATTRS.has(name)) return;
|
|
85
|
+
const val = node.value;
|
|
86
|
+
if (val && val.type === "Literal" && typeof val.value === "string") {
|
|
87
|
+
maybeReport(val, val.value);
|
|
88
|
+
} else if (
|
|
89
|
+
val &&
|
|
90
|
+
val.type === "JSXExpressionContainer" &&
|
|
91
|
+
val.expression.type === "Literal" &&
|
|
92
|
+
typeof val.expression.value === "string"
|
|
93
|
+
) {
|
|
94
|
+
maybeReport(val.expression, val.expression.value);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// stapel/no-raw-colors — frontend-guardrails §2.2.
|
|
2
|
+
// Raw colours (hex/rgb/hsl/named) in styled surfaces, and Tailwind arbitrary
|
|
3
|
+
// values that embed a colour, are the cheapest token for an LLM to hardcode
|
|
4
|
+
// (§F7). Hex is born in exactly one place — the ramps — and everything above
|
|
5
|
+
// references tokens. Message teaches the one right way + points at the catalog.
|
|
6
|
+
import { loadTokenCatalog, stapelSettings } from "../lib/data.js";
|
|
7
|
+
import {
|
|
8
|
+
hasColorSyntax,
|
|
9
|
+
isColorProperty,
|
|
10
|
+
isNamedColorValue,
|
|
11
|
+
findArbitraryColor,
|
|
12
|
+
matchRampRef,
|
|
13
|
+
} from "../lib/colors.js";
|
|
14
|
+
|
|
15
|
+
const LLMS = "@stapel/tokens/llms.txt §colors";
|
|
16
|
+
|
|
17
|
+
const CSS_TAGS = new Set([
|
|
18
|
+
"css",
|
|
19
|
+
"styled",
|
|
20
|
+
"createGlobalStyle",
|
|
21
|
+
"keyframes",
|
|
22
|
+
"injectGlobal",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function isCssTag(tag) {
|
|
26
|
+
if (!tag) return false;
|
|
27
|
+
if (tag.type === "Identifier") return CSS_TAGS.has(tag.name);
|
|
28
|
+
// styled.div`...`, styled(Comp)`...`
|
|
29
|
+
if (tag.type === "MemberExpression" && tag.object?.name === "styled")
|
|
30
|
+
return true;
|
|
31
|
+
if (tag.type === "CallExpression" && tag.callee?.name === "styled")
|
|
32
|
+
return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** name of a className-ish JSX attribute */
|
|
37
|
+
function isClassNameAttr(name) {
|
|
38
|
+
return name === "className" || name === "class";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
meta: {
|
|
43
|
+
type: "problem",
|
|
44
|
+
docs: {
|
|
45
|
+
description:
|
|
46
|
+
"Disallow raw colour literals and Tailwind arbitrary colour values; use design tokens.",
|
|
47
|
+
},
|
|
48
|
+
schema: [],
|
|
49
|
+
messages: {
|
|
50
|
+
rawColor:
|
|
51
|
+
'Raw colour "{{value}}". Use a token: in TS — cssVar("color-{{suggest}}"), in CSS — var(--stapel-color-{{suggest}}), in Tailwind — a token utility (e.g. bg-background-primary). Hex is born only in ramps. Catalog: ' +
|
|
52
|
+
LLMS,
|
|
53
|
+
arbitraryColor:
|
|
54
|
+
'Tailwind arbitrary colour "[{{value}}]". Use a token utility (e.g. bg-background-primary / text-text-primary) instead of a raw value in [...]. Catalog: ' +
|
|
55
|
+
LLMS,
|
|
56
|
+
arbitraryInterpolation:
|
|
57
|
+
'Tailwind arbitrary value built by interpolation ("{{value}}"). The JIT scanner sees the literal source text, not the resolved value, so no utility is emitted — works in dev, breaks in prod. Use a static token utility. Catalog: ' +
|
|
58
|
+
LLMS,
|
|
59
|
+
rawRamp:
|
|
60
|
+
'Raw ramp reference "{{value}}". Ramps ({{ramp}}.*) are L1 and not for component code — reference a token instead. Catalog: ' +
|
|
61
|
+
LLMS,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
create(context) {
|
|
65
|
+
const settings = stapelSettings(context);
|
|
66
|
+
const catalog = loadTokenCatalog(settings);
|
|
67
|
+
const ramps = catalog.ramps;
|
|
68
|
+
|
|
69
|
+
function reportColorValue(node, raw) {
|
|
70
|
+
context.report({
|
|
71
|
+
node,
|
|
72
|
+
messageId: "rawColor",
|
|
73
|
+
data: { value: String(raw).slice(0, 40), suggest: "accent" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Style objects: JSX style={{...}} and any object literal property whose
|
|
78
|
+
// key is a colour property with a raw colour value.
|
|
79
|
+
function checkStyleProperty(prop) {
|
|
80
|
+
if (prop.type !== "Property" || prop.computed) return;
|
|
81
|
+
const key =
|
|
82
|
+
prop.key.type === "Identifier"
|
|
83
|
+
? prop.key.name
|
|
84
|
+
: prop.key.type === "Literal"
|
|
85
|
+
? prop.key.value
|
|
86
|
+
: null;
|
|
87
|
+
if (!isColorProperty(key)) return;
|
|
88
|
+
const v = prop.value;
|
|
89
|
+
if (v.type === "Literal" && typeof v.value === "string") {
|
|
90
|
+
if (hasColorSyntax(v.value) || isNamedColorValue(v.value)) {
|
|
91
|
+
reportColorValue(v, v.value);
|
|
92
|
+
}
|
|
93
|
+
} else if (v.type === "TemplateLiteral") {
|
|
94
|
+
for (const q of v.quasis) {
|
|
95
|
+
if (hasColorSyntax(q.value.cooked ?? q.value.raw)) {
|
|
96
|
+
reportColorValue(v, context.sourceCode.getText(v));
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// className analysis: string literal or template literal.
|
|
104
|
+
function checkClassName(node) {
|
|
105
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
106
|
+
const bad = findArbitraryColor(node.value, ramps);
|
|
107
|
+
if (bad !== null) {
|
|
108
|
+
context.report({
|
|
109
|
+
node,
|
|
110
|
+
messageId: "arbitraryColor",
|
|
111
|
+
data: { value: bad.slice(0, 40) },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} else if (node.type === "TemplateLiteral") {
|
|
115
|
+
// Interpolation inside a [...] arbitrary value is JIT-invisible.
|
|
116
|
+
if (interpolationInsideArbitrary(node)) {
|
|
117
|
+
context.report({
|
|
118
|
+
node,
|
|
119
|
+
messageId: "arbitraryInterpolation",
|
|
120
|
+
data: { value: context.sourceCode.getText(node).slice(0, 40) },
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Static quasis can still contain bg-[#...] literally.
|
|
125
|
+
for (const q of node.quasis) {
|
|
126
|
+
const bad = findArbitraryColor(q.value.cooked ?? q.value.raw, ramps);
|
|
127
|
+
if (bad !== null) {
|
|
128
|
+
context.report({
|
|
129
|
+
node,
|
|
130
|
+
messageId: "arbitraryColor",
|
|
131
|
+
data: { value: bad.slice(0, 40) },
|
|
132
|
+
});
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// True when any `${...}` expression sits inside an unclosed `[` of a
|
|
140
|
+
// Tailwind arbitrary value (e.g. `bg-[${x}]`).
|
|
141
|
+
function interpolationInsideArbitrary(tpl) {
|
|
142
|
+
for (let i = 0; i < tpl.expressions.length; i++) {
|
|
143
|
+
const before = tpl.quasis[i].value.cooked ?? tpl.quasis[i].value.raw;
|
|
144
|
+
const lastOpen = before.lastIndexOf("[");
|
|
145
|
+
const lastClose = before.lastIndexOf("]");
|
|
146
|
+
if (lastOpen > lastClose) return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
// Inline style objects, both JSX and plain.
|
|
153
|
+
ObjectExpression(node) {
|
|
154
|
+
for (const prop of node.properties) checkStyleProperty(prop);
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
JSXAttribute(node) {
|
|
158
|
+
const name = node.name && node.name.name;
|
|
159
|
+
if (!isClassNameAttr(name)) return;
|
|
160
|
+
const val = node.value;
|
|
161
|
+
if (!val) return;
|
|
162
|
+
if (val.type === "Literal") checkClassName(val);
|
|
163
|
+
else if (
|
|
164
|
+
val.type === "JSXExpressionContainer" &&
|
|
165
|
+
(val.expression.type === "Literal" ||
|
|
166
|
+
val.expression.type === "TemplateLiteral")
|
|
167
|
+
) {
|
|
168
|
+
checkClassName(val.expression);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
TaggedTemplateExpression(node) {
|
|
173
|
+
if (!isCssTag(node.tag)) return;
|
|
174
|
+
for (const q of node.quasi.quasis) {
|
|
175
|
+
const text = q.value.cooked ?? q.value.raw;
|
|
176
|
+
if (hasColorSyntax(text)) {
|
|
177
|
+
reportColorValue(node.quasi, text.trim().slice(0, 40));
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// Bare raw-ramp string references, e.g. "gray.500".
|
|
184
|
+
Literal(node) {
|
|
185
|
+
if (typeof node.value !== "string") return;
|
|
186
|
+
const ramp = matchRampRef(node.value, ramps);
|
|
187
|
+
if (ramp) {
|
|
188
|
+
context.report({
|
|
189
|
+
node,
|
|
190
|
+
messageId: "rawRamp",
|
|
191
|
+
data: { value: node.value, ramp },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// stapel/no-raw-fetch — frontend-guardrails §2.2 (failure mode F2).
|
|
2
|
+
// Network access goes through the codegen client only. Raw fetch/axios/ky/
|
|
3
|
+
// XMLHttpRequest bypasses typing, the error envelope, and auth/CSRF handling.
|
|
4
|
+
// The recommended preset turns this OFF for the api layer of a pair (the client
|
|
5
|
+
// itself is the one legal home of fetch) via file overrides.
|
|
6
|
+
import { stapelSettings } from "../lib/data.js";
|
|
7
|
+
|
|
8
|
+
const HINT =
|
|
9
|
+
"Requests go through the codegen client only: const api = createAuthApi(client); await api.me(). Operations: <pkg>/manifest.json §operations.";
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: {
|
|
15
|
+
description:
|
|
16
|
+
"Disallow raw fetch/axios/ky/XMLHttpRequest outside the codegen API layer.",
|
|
17
|
+
},
|
|
18
|
+
schema: [
|
|
19
|
+
{
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
modules: { type: "array", items: { type: "string" } },
|
|
23
|
+
},
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
messages: {
|
|
28
|
+
rawFetch: "Direct {{what}}(). " + HINT,
|
|
29
|
+
rawXhr: "Direct `new XMLHttpRequest()`. " + HINT,
|
|
30
|
+
rawImport: 'Import of HTTP client "{{source}}". ' + HINT,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
const settings = stapelSettings(context);
|
|
35
|
+
const extraModules = context.options[0]?.modules ?? [];
|
|
36
|
+
const bannedModules = new Set([
|
|
37
|
+
"axios",
|
|
38
|
+
"ky",
|
|
39
|
+
"got",
|
|
40
|
+
"superagent",
|
|
41
|
+
...(settings.httpModules ?? []),
|
|
42
|
+
...extraModules,
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function reportFetch(node, what) {
|
|
46
|
+
context.report({ node, messageId: "rawFetch", data: { what } });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
// fetch(...), globalThis.fetch(...), window.fetch(...), self.fetch(...)
|
|
51
|
+
CallExpression(node) {
|
|
52
|
+
const callee = node.callee;
|
|
53
|
+
if (callee.type === "Identifier" && callee.name === "fetch") {
|
|
54
|
+
reportFetch(node, "fetch");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
callee.type === "MemberExpression" &&
|
|
59
|
+
!callee.computed &&
|
|
60
|
+
callee.property.type === "Identifier" &&
|
|
61
|
+
callee.property.name === "fetch" &&
|
|
62
|
+
callee.object.type === "Identifier" &&
|
|
63
|
+
["globalThis", "window", "self"].includes(callee.object.name)
|
|
64
|
+
) {
|
|
65
|
+
reportFetch(node, `${callee.object.name}.fetch`);
|
|
66
|
+
}
|
|
67
|
+
// axios(...) / axios.get(...) / ky(...) — call on a banned identifier
|
|
68
|
+
const root =
|
|
69
|
+
callee.type === "Identifier"
|
|
70
|
+
? callee
|
|
71
|
+
: callee.type === "MemberExpression" &&
|
|
72
|
+
callee.object.type === "Identifier"
|
|
73
|
+
? callee.object
|
|
74
|
+
: null;
|
|
75
|
+
if (root && bannedModules.has(root.name)) {
|
|
76
|
+
reportFetch(node, root.name);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
NewExpression(node) {
|
|
81
|
+
if (
|
|
82
|
+
node.callee.type === "Identifier" &&
|
|
83
|
+
node.callee.name === "XMLHttpRequest"
|
|
84
|
+
) {
|
|
85
|
+
context.report({ node, messageId: "rawXhr" });
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
ImportDeclaration(node) {
|
|
90
|
+
if (bannedModules.has(node.source.value)) {
|
|
91
|
+
context.report({
|
|
92
|
+
node,
|
|
93
|
+
messageId: "rawImport",
|
|
94
|
+
data: { source: node.source.value },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// stapel/no-raw-token-import — frontend-guardrails §2.2.
|
|
2
|
+
// `@stapel/tokens/raw` is the ONLY legal door to the L1 ramps, and only the
|
|
3
|
+
// theme-config + the showcase may open it. Everywhere else raw ramps must not
|
|
4
|
+
// be reachable (components reference tokens, not hex). The recommended preset
|
|
5
|
+
// turns this rule OFF for theme-config/showcase/scripts via file overrides
|
|
6
|
+
// (§2.2: "overrides-исключения"); the rule itself always flags.
|
|
7
|
+
import { stapelSettings } from "../lib/data.js";
|
|
8
|
+
|
|
9
|
+
const RAW = "@stapel/tokens/raw";
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: {
|
|
15
|
+
description:
|
|
16
|
+
"Disallow importing @stapel/tokens/raw outside theme-config and the showcase.",
|
|
17
|
+
},
|
|
18
|
+
schema: [
|
|
19
|
+
{
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: { modules: { type: "array", items: { type: "string" } } },
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
messages: {
|
|
26
|
+
rawImport:
|
|
27
|
+
'Import from "{{source}}" is raw-ramp access (L1 hex). Only the theme config and the design-system showcase may import it — component code references tokens: cssVar("color-…") / var(--stapel-color-…). See @stapel/tokens/llms.txt §Never.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
create(context) {
|
|
31
|
+
const settings = stapelSettings(context);
|
|
32
|
+
const extra = context.options[0]?.modules ?? [];
|
|
33
|
+
const banned = new Set([RAW, ...(settings.rawModules ?? []), ...extra]);
|
|
34
|
+
|
|
35
|
+
function check(node, source) {
|
|
36
|
+
if (typeof source === "string" && banned.has(source)) {
|
|
37
|
+
context.report({ node, messageId: "rawImport", data: { source } });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
ImportDeclaration(node) {
|
|
43
|
+
check(node, node.source.value);
|
|
44
|
+
},
|
|
45
|
+
ExportNamedDeclaration(node) {
|
|
46
|
+
if (node.source) check(node, node.source.value);
|
|
47
|
+
},
|
|
48
|
+
ExportAllDeclaration(node) {
|
|
49
|
+
if (node.source) check(node, node.source.value);
|
|
50
|
+
},
|
|
51
|
+
ImportExpression(node) {
|
|
52
|
+
if (node.source.type === "Literal") check(node, node.source.value);
|
|
53
|
+
},
|
|
54
|
+
CallExpression(node) {
|
|
55
|
+
if (
|
|
56
|
+
node.callee.type === "Identifier" &&
|
|
57
|
+
node.callee.name === "require" &&
|
|
58
|
+
node.arguments[0]?.type === "Literal"
|
|
59
|
+
) {
|
|
60
|
+
check(node, node.arguments[0].value);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|