@stapel/eslint-plugin 0.4.0 → 0.5.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/README.md CHANGED
@@ -71,6 +71,7 @@ missing, the rule is a no-op — it never fails the lint run.
71
71
  | Rule | Catches |
72
72
  |---|---|
73
73
  | `stapel/no-raw-colors` | hex/rgb/hsl/named colours in style objects & CSS templates; Tailwind arbitrary colour values `bg-[#…]`; arbitrary values built by interpolation `bg-[${x}]` (JIT-invisible); bare raw-ramp refs `gray.500` |
74
+ | `stapel/valid-token-name` | `cssVar("…")` / `var(--stapel-…)` naming a colour-token role absent from the live `@stapel/tokens` catalog (§68) — a renamed/removed legacy role (`accent`, `background-*-subtle`, the old L3 component tier, …) or a plain typo. Suggests the nearest catalog role when one is close. Non-colour scale suffixes (`radius-*`, `space-*`, `font-*`, `breakpoint-*`, `elevation-*`) are a different vocabulary and never flagged. Extend the recognised call names via `options.functions` or `settings.stapel.cssVarFunctions` (default `["cssVar"]`) |
74
75
  | `stapel/no-raw-token-import` | importing `@stapel/tokens/raw` outside theme-config / showcase |
75
76
  | `stapel/no-raw-fetch` | `fetch` / `globalThis.fetch` / `new XMLHttpRequest()` / `axios` / `ky` outside the codegen client |
76
77
  | `stapel/no-string-paths` | a hand-written API path — `client.get("/…")` on an http verb, or a bare literal/template that IS a catalogued operation path (`manifest.json §operations`) — outside the codegen api layer. Call the named operation instead. Off in `api/`, `*client.ts`, `generated/` |
@@ -103,6 +104,7 @@ settings: {
103
104
  eventsManifests: [manifest],// or eventNames: ["pricing.plan.selected", …]
104
105
  operationsManifests: [manifest], // or operationPaths: ["/auth/api/v1/me/", …]
105
106
  reservedPathsFile: "./reserved-paths.json", // or reservedPaths: ["/admin", …]
107
+ cssVarFunctions: ["cssVar"], // extra token-accessor calls valid-token-name inspects
106
108
  httpVerbs: ["get","post"], // client methods no-string-paths inspects
107
109
  queryHooks: ["useQuery"], // extra react-query hooks to inspect for keys
108
110
  trackedWrappers: ["tracked"],
package/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // `recommended` preset; the stylelint preset lives at @stapel/eslint-plugin/
6
6
  // stylelint.
7
7
  import noRawColors from "./rules/no-raw-colors.js";
8
+ import validTokenName from "./rules/valid-token-name.js";
8
9
  import noRawTokenImport from "./rules/no-raw-token-import.js";
9
10
  import noRawFetch from "./rules/no-raw-fetch.js";
10
11
  import noStringPaths from "./rules/no-string-paths.js";
@@ -24,6 +25,7 @@ import noReservedBackendRoute from "./rules/no-reserved-backend-route.js";
24
25
 
25
26
  const rules = {
26
27
  "no-raw-colors": noRawColors,
28
+ "valid-token-name": validTokenName,
27
29
  "no-raw-token-import": noRawTokenImport,
28
30
  "no-raw-fetch": noRawFetch,
29
31
  // Server-state guardrails (frontend-guardrails §2.2 / §2.6).
@@ -49,7 +51,7 @@ const rules = {
49
51
  };
50
52
 
51
53
  const plugin = {
52
- meta: { name: "@stapel/eslint-plugin", version: "0.1.0" },
54
+ meta: { name: "@stapel/eslint-plugin", version: "0.5.0" },
53
55
  rules,
54
56
  };
55
57
 
@@ -141,6 +143,7 @@ const recommended = [
141
143
  files: TS_JS,
142
144
  rules: {
143
145
  "stapel/no-raw-colors": "error",
146
+ "stapel/valid-token-name": "error",
144
147
  "stapel/no-raw-token-import": "error",
145
148
  "stapel/no-raw-fetch": "error",
146
149
  // Server state: reach endpoints through named operations, keys through the
@@ -216,6 +219,7 @@ const recommended = [
216
219
  files: TEST_FILES,
217
220
  rules: {
218
221
  "stapel/no-raw-colors": "off",
222
+ "stapel/valid-token-name": "off",
219
223
  "stapel/no-hardcoded-text": "off",
220
224
  "stapel/i18n-key-exists": "off",
221
225
  "stapel/no-raw-fetch": "off",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/eslint-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "README.md"
25
25
  ],
26
26
  "dependencies": {
27
- "@stapel/tokens": "^0.4.0"
27
+ "@stapel/tokens": "^0.5.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "eslint": ">=9",
@@ -48,10 +48,10 @@ export default {
48
48
  schema: [],
49
49
  messages: {
50
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: ' +
51
+ 'Raw colour "{{value}}". Use a token: in TS — cssVar("{{suggest}}"), in CSS — var(--stapel-{{suggest}}), in Tailwind — a token utility (e.g. bg-surface). Hex is born only in ramps. Catalog: ' +
52
52
  LLMS,
53
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: ' +
54
+ 'Tailwind arbitrary colour "[{{value}}]". Use a token utility (e.g. bg-surface / text-text) instead of a raw value in [...]. Catalog: ' +
55
55
  LLMS,
56
56
  arbitraryInterpolation:
57
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: ' +
@@ -70,7 +70,7 @@ export default {
70
70
  context.report({
71
71
  node,
72
72
  messageId: "rawColor",
73
- data: { value: String(raw).slice(0, 40), suggest: "accent" },
73
+ data: { value: String(raw).slice(0, 40), suggest: "brand" },
74
74
  });
75
75
  }
76
76
 
@@ -24,7 +24,7 @@ export default {
24
24
  ],
25
25
  messages: {
26
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.',
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("brand") / var(--stapel-brand). See @stapel/tokens/llms.txt §Never.',
28
28
  },
29
29
  },
30
30
  create(context) {
@@ -0,0 +1,157 @@
1
+ // stapel/valid-token-name — frontend-guardrails §68 (colour-token canon).
2
+ // A `cssVar("<name>")` call or a `var(--stapel-<name>)` CSS reference must
3
+ // name a ROLE that exists in the live token catalog
4
+ // (@stapel/tokens/manifest.json `tokens.core`/`tokens.component`). Both
5
+ // failure modes this catches resolve SILENTLY at runtime instead of failing
6
+ // loudly — an unset custom property just falls through to `initial`/inherit,
7
+ // so the wrong colour (or no colour) never shows up as an error:
8
+ // - a RENAMED/REMOVED legacy role: the §68 neutral-dictionary migration
9
+ // deleted `accent`, `background-*-subtle`, `upperground-*`, `icon-*`,
10
+ // `text-invert`, `overlay`, and the whole L3 component tier
11
+ // (`button-primary-bg`, `card-bg`, ...) with no compatibility alias.
12
+ // - a plain typo in an otherwise-valid role name.
13
+ // Data-driven (§2.1): reads the SAME catalog no-raw-colors reads. No catalog
14
+ // → no-op, never guess (never crashes the lint run).
15
+ //
16
+ // Scoped to COLOUR roles only. `cssVar()`'s real type (`StapelVar`) also
17
+ // accepts a stable, unrelated set of scale suffixes this rule does not police
18
+ // — `font-family-*` / `font-size-*` / `font-weight-*` / `line-height-*` /
19
+ // `radius-*` / `space-*` / `breakpoint-*` / `elevation-*` — skipped by prefix
20
+ // so a legitimate `cssVar("radius-md")` never false-positives.
21
+ import { loadTokenCatalog, stapelSettings } from "../lib/data.js";
22
+
23
+ const LLMS = "@stapel/tokens/llms.txt §colors";
24
+
25
+ const DEFAULT_FUNCTIONS = ["cssVar"];
26
+
27
+ // Non-colour scale namespaces this rule never validates (see header).
28
+ const SCALE_PREFIXES = [
29
+ "font-family-",
30
+ "font-size-",
31
+ "font-weight-",
32
+ "line-height-",
33
+ "radius-",
34
+ "space-",
35
+ "breakpoint-",
36
+ "elevation-",
37
+ ];
38
+
39
+ function isScaleName(name) {
40
+ return SCALE_PREFIXES.some((prefix) => name.startsWith(prefix));
41
+ }
42
+
43
+ // var(--stapel-<name>) or var(--stapel-<name>, <fallback>) — captures <name>.
44
+ const VAR_RE = /var\(\s*--stapel-([a-zA-Z0-9-]+)\s*(?:,[^)]*)?\)/g;
45
+
46
+ /** Plain Levenshtein edit distance — small inputs (token role names), O(mn) is fine. */
47
+ function levenshtein(a, b) {
48
+ const m = a.length;
49
+ const n = b.length;
50
+ const dp = [];
51
+ for (let i = 0; i <= m; i++) dp.push([i, ...new Array(n).fill(0)]);
52
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
53
+ for (let i = 1; i <= m; i++) {
54
+ for (let j = 1; j <= n; j++) {
55
+ dp[i][j] =
56
+ a[i - 1] === b[j - 1]
57
+ ? dp[i - 1][j - 1]
58
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
59
+ }
60
+ }
61
+ return dp[m][n];
62
+ }
63
+
64
+ /** Nearest catalog name, or null when nothing is plausibly the same word. */
65
+ function closestName(name, names) {
66
+ let best = null;
67
+ let bestDist = Infinity;
68
+ for (const candidate of names) {
69
+ const d = levenshtein(name, candidate);
70
+ if (d < bestDist) {
71
+ bestDist = d;
72
+ best = candidate;
73
+ }
74
+ }
75
+ const threshold = Math.max(3, Math.ceil(name.length / 2));
76
+ return best !== null && bestDist <= threshold ? best : null;
77
+ }
78
+
79
+ export default {
80
+ meta: {
81
+ type: "problem",
82
+ docs: {
83
+ description:
84
+ "Disallow cssVar()/var(--stapel-*) references to a colour-token role absent from the live @stapel/tokens catalog.",
85
+ },
86
+ schema: [
87
+ {
88
+ type: "object",
89
+ properties: {
90
+ functions: { type: "array", items: { type: "string" } },
91
+ },
92
+ additionalProperties: false,
93
+ },
94
+ ],
95
+ messages: {
96
+ unknownToken:
97
+ 'Unknown token role "{{name}}" ({{form}}). It is not in the live @stapel/tokens catalog{{suggestion}}. Legacy/renamed roles do not alias — reference a current role. Catalog: ' +
98
+ LLMS,
99
+ },
100
+ },
101
+ create(context) {
102
+ const settings = stapelSettings(context);
103
+ const catalog = loadTokenCatalog(settings);
104
+ if (!catalog.loaded) return {}; // no manifest → no-op, never guess
105
+
106
+ const functions = new Set(
107
+ context.options[0]?.functions ?? settings.cssVarFunctions ?? DEFAULT_FUNCTIONS
108
+ );
109
+ const names = [...catalog.all];
110
+
111
+ function reportIfUnknown(node, name, form) {
112
+ if (isScaleName(name)) return;
113
+ if (catalog.hasToken(name)) return;
114
+ const suggestion = closestName(name, names);
115
+ context.report({
116
+ node,
117
+ messageId: "unknownToken",
118
+ data: {
119
+ name,
120
+ form,
121
+ suggestion: suggestion ? ` — did you mean "${suggestion}"?` : "",
122
+ },
123
+ });
124
+ }
125
+
126
+ function scanForVarRefs(node, text) {
127
+ VAR_RE.lastIndex = 0;
128
+ let m;
129
+ while ((m = VAR_RE.exec(text)) !== null) {
130
+ reportIfUnknown(node, m[1], "var(--stapel-*)");
131
+ }
132
+ }
133
+
134
+ return {
135
+ CallExpression(node) {
136
+ if (node.callee.type !== "Identifier" || !functions.has(node.callee.name))
137
+ return;
138
+ const arg = node.arguments[0];
139
+ if (arg && arg.type === "Literal" && typeof arg.value === "string") {
140
+ reportIfUnknown(arg, arg.value, "cssVar()");
141
+ }
142
+ },
143
+ // Plain string literals: `"var(--stapel-accent)"` in a style object,
144
+ // a plain assignment, a JSX attribute string, etc.
145
+ Literal(node) {
146
+ if (typeof node.value === "string") scanForVarRefs(node, node.value);
147
+ },
148
+ // Template literals (including CSS-in-JS tagged templates — ESLint
149
+ // visits the TemplateLiteral itself regardless of the tag).
150
+ TemplateLiteral(node) {
151
+ for (const q of node.quasis) {
152
+ scanForVarRefs(node, q.value.cooked ?? q.value.raw);
153
+ }
154
+ },
155
+ };
156
+ },
157
+ };