@stapel/eslint-plugin 0.3.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
@@ -38,11 +38,40 @@ The `recommended` preset:
38
38
  - **overrides** the content rules off in tests and fixtures (they exercise the
39
39
  anti-patterns on purpose).
40
40
 
41
+ ### `reserved-paths.json`
42
+
43
+ `stapel/no-reserved-backend-route` reads a project-root `reserved-paths.json`
44
+ (no fixed location config beyond `settings.stapel.reservedPathsFile` — see
45
+ below), the same flat projection stapel-tools' project generator emits:
46
+
47
+ ```json
48
+ {
49
+ "reservedPathPrefixes": [
50
+ "/admin",
51
+ "/staticfiles",
52
+ "/media",
53
+ "/calendar/api",
54
+ "/calendar/swagger"
55
+ ]
56
+ }
57
+ ```
58
+
59
+ `reservedPathPrefixes` is a flat array of path prefixes the backend owns. A
60
+ route is flagged when it **equals** a prefix or continues **past a segment
61
+ boundary** beneath one (`/calendar/api/x` matches `/calendar/api`;
62
+ `/calendar-archive` does not). **Never** put a bare module root
63
+ (`/calendar`) in this list — the generator only emits sub-path reservations,
64
+ because a bare root belongs to the frontend SPA by convention. If the file is
65
+ missing, the rule is a no-op — it never fails the lint run.
66
+
67
+
68
+
41
69
  ## Rules
42
70
 
43
71
  | Rule | Catches |
44
72
  |---|---|
45
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"]`) |
46
75
  | `stapel/no-raw-token-import` | importing `@stapel/tokens/raw` outside theme-config / showcase |
47
76
  | `stapel/no-raw-fetch` | `fetch` / `globalThis.fetch` / `new XMLHttpRequest()` / `axios` / `ky` outside the codegen client |
48
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/` |
@@ -58,6 +87,7 @@ The `recommended` preset:
58
87
  | `stapel/demo-literal-meta` | `defineDemo()` with non-literal meta (dynamic `id`/`title`/`description`/`covers`) — breaks static extraction into `demos.json`/`manifest.demos` (§4.2) |
59
88
  | `stapel/no-raw-storage` | direct `localStorage`/`sessionStorage`/`indexedDB` (bare or via `window.`/`globalThis.`/`self.`) or importing `idb-keyval` outside `@stapel/core`'s repository layer — raw storage is neither wiped on logout nor encrypted; persist through `createRepository()` (frontend-core-architecture-v2 §43.4). Off in core's `storage.ts`/`repository.ts`/`query.ts` (the one legal home) and in tests. Extend the banned module list via `options.modules` or `settings.stapel.storageModules` |
60
89
  | `stapel/no-adhoc-401` | comparing a status to the literal `401` (`===`/`!==`/`case 401:`) or wiring an axios-style `*.interceptors` chain — ad hoc 401 handling bypasses the single-flight refresh + logout-hook registry; 401s are handled ONCE, in core's `createStapelClient` (`onAuthRefresh` seam) + `SessionManager` (§43.2). Off in core's `client.ts`/`session.ts` and in tests |
90
+ | `stapel/no-reserved-backend-route` | an SPA route (`<Route path="…">`, a `createBrowserRouter`/`createHashRouter`/`createMemoryRouter` array literal, or any `{ path: "…", element/Component/children/index/errorElement/loader/action/lazy: … }` RouteObject) whose path falls INTO a reserved backend sub-path — `/<mod>/api/…`, `/<mod>/swagger…`, or the project-wide `/admin`, `/staticfiles`, `/media` (§57 nginx canon). A **bare module root** (`/calendar`) is legitimate and never flagged — roots belong to the frontend; only sub-paths collide. Data-driven: reads the flat `reservedPathPrefixes` array from `reserved-paths.json` (emitted by stapel-tools' project generator) at the workspace root, or `settings.stapel.reservedPathsFile`/`reservedPaths`. No catalog → no-op (never a crash) |
61
91
 
62
92
  ### Settings
63
93
 
@@ -72,7 +102,9 @@ settings: {
72
102
  rawModules: ["@x/raw"], // extra raw-token entry points
73
103
  storageModules: ["level"], // extra banned storage-backend packages
74
104
  eventsManifests: [manifest],// or eventNames: ["pricing.plan.selected", …]
75
- operationsManifests: [manifest], // or operationPaths: ["/auth/api/me/", …]
105
+ operationsManifests: [manifest], // or operationPaths: ["/auth/api/v1/me/", …]
106
+ reservedPathsFile: "./reserved-paths.json", // or reservedPaths: ["/admin", …]
107
+ cssVarFunctions: ["cssVar"], // extra token-accessor calls valid-token-name inspects
76
108
  httpVerbs: ["get","post"], // client methods no-string-paths inspects
77
109
  queryHooks: ["useQuery"], // extra react-query hooks to inspect for keys
78
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";
@@ -20,9 +21,11 @@ import noDirectAnalyticsProvider from "./rules/no-direct-analytics-provider.js";
20
21
  import demoLiteralMeta from "./rules/demo-literal-meta.js";
21
22
  import noRawStorage from "./rules/no-raw-storage.js";
22
23
  import noAdhoc401 from "./rules/no-adhoc-401.js";
24
+ import noReservedBackendRoute from "./rules/no-reserved-backend-route.js";
23
25
 
24
26
  const rules = {
25
27
  "no-raw-colors": noRawColors,
28
+ "valid-token-name": validTokenName,
26
29
  "no-raw-token-import": noRawTokenImport,
27
30
  "no-raw-fetch": noRawFetch,
28
31
  // Server-state guardrails (frontend-guardrails §2.2 / §2.6).
@@ -42,10 +45,13 @@ const rules = {
42
45
  // Session-substrate guardrails (frontend-core-architecture-v2 §43).
43
46
  "no-raw-storage": noRawStorage,
44
47
  "no-adhoc-401": noAdhoc401,
48
+ // Front/back path-collision guardrail (owner directive: SPA router must not
49
+ // claim a reserved backend sub-path).
50
+ "no-reserved-backend-route": noReservedBackendRoute,
45
51
  };
46
52
 
47
53
  const plugin = {
48
- meta: { name: "@stapel/eslint-plugin", version: "0.1.0" },
54
+ meta: { name: "@stapel/eslint-plugin", version: "0.5.0" },
49
55
  rules,
50
56
  };
51
57
 
@@ -137,6 +143,7 @@ const recommended = [
137
143
  files: TS_JS,
138
144
  rules: {
139
145
  "stapel/no-raw-colors": "error",
146
+ "stapel/valid-token-name": "error",
140
147
  "stapel/no-raw-token-import": "error",
141
148
  "stapel/no-raw-fetch": "error",
142
149
  // Server state: reach endpoints through named operations, keys through the
@@ -160,6 +167,10 @@ const recommended = [
160
167
  // + SessionManager.
161
168
  "stapel/no-raw-storage": "error",
162
169
  "stapel/no-adhoc-401": "error",
170
+ // Path-collision guardrail: the SPA router must not claim a reserved
171
+ // backend sub-path (/<mod>/api/…, /<mod>/swagger…, /admin, /staticfiles,
172
+ // /media — §57 nginx canon). No-op without reserved-paths.json.
173
+ "stapel/no-reserved-backend-route": "error",
163
174
  },
164
175
  },
165
176
  {
@@ -208,6 +219,7 @@ const recommended = [
208
219
  files: TEST_FILES,
209
220
  rules: {
210
221
  "stapel/no-raw-colors": "off",
222
+ "stapel/valid-token-name": "off",
211
223
  "stapel/no-hardcoded-text": "off",
212
224
  "stapel/i18n-key-exists": "off",
213
225
  "stapel/no-raw-fetch": "off",
@@ -227,6 +239,9 @@ const recommended = [
227
239
  // contract test greps localStorage directly) and on 401 fixtures.
228
240
  "stapel/no-raw-storage": "off",
229
241
  "stapel/no-adhoc-401": "off",
242
+ // Route fixtures legitimately probe reserved-path collisions on purpose
243
+ // (that's what this rule's own tests do).
244
+ "stapel/no-reserved-backend-route": "off",
230
245
  // require-disable-description stays ON — disable hygiene applies everywhere.
231
246
  },
232
247
  },
package/lib/data.js CHANGED
@@ -333,6 +333,78 @@ export function loadOperationCatalog(settings = {}) {
333
333
  return _operationCatalogDefault;
334
334
  }
335
335
 
336
+ // ── reserved backend-path catalog (reserved-paths.json) ──────────────────────
337
+ //
338
+ // no-reserved-backend-route reads the SAME projection stapel-tools' project
339
+ // generator emits at the workspace root: a flat array of path PREFIXES a
340
+ // backend module reserves under its own slug (`/<mod>/api/…`,
341
+ // `/<mod>/swagger…`), plus the project-wide infra prefixes nginx enforces
342
+ // (`/admin`, `/staticfiles`, `/media` — §57 canon, compose_templates.NGINX_CONF).
343
+ // Schema: `{ "reservedPathPrefixes": string[] }`. A bare module ROOT
344
+ // (`/<mod>`) must never appear in the list — roots belong to the frontend SPA
345
+ // by convention; only sub-path reservations are catalogued. A missing/
346
+ // unreadable file degrades the rule to a no-op (empty catalog, never a
347
+ // crash) — exactly like the token/i18n/events/operation loaders above (§2.1).
348
+
349
+ function discoverReservedPathsFile(from) {
350
+ const root = workspaceRoot(from);
351
+ if (root) {
352
+ const p = join(root, "reserved-paths.json");
353
+ if (existsSync(p)) return p;
354
+ }
355
+ const local = join(from, "reserved-paths.json");
356
+ if (existsSync(local)) return local;
357
+ return null;
358
+ }
359
+
360
+ function buildReservedPathCatalog(prefixes) {
361
+ const list = (prefixes ?? []).filter(
362
+ (p) => typeof p === "string" && p.length > 0
363
+ );
364
+ return {
365
+ prefixes: list,
366
+ loaded: list.length > 0,
367
+ /**
368
+ * The reserved prefix a route path falls INTO — equal to it, or past a
369
+ * segment boundary beneath it — else null. A route that merely shares a
370
+ * prefix's leading segment (a bare module root, e.g. "/calendar" against
371
+ * reserved "/calendar/api") does NOT match: roots are the frontend's by
372
+ * canon, only sub-paths are reserved.
373
+ */
374
+ matches: (route) => {
375
+ if (typeof route !== "string" || !route.startsWith("/")) return null;
376
+ for (const p of list) {
377
+ const boundary = p.endsWith("/") ? p : `${p}/`;
378
+ if (route === p || route.startsWith(boundary)) return p;
379
+ }
380
+ return null;
381
+ },
382
+ };
383
+ }
384
+
385
+ let _reservedPathCatalogDefault;
386
+ export function loadReservedPathCatalog(settings = {}) {
387
+ if (settings.reservedPaths) {
388
+ return buildReservedPathCatalog(settings.reservedPaths);
389
+ }
390
+ if (settings.reservedPathsFile) {
391
+ // Explicit override → never memoized (mirrors the other loaders: only the
392
+ // zero-config discovered default is cached).
393
+ return buildReservedPathCatalog(
394
+ readJson(settings.reservedPathsFile)?.reservedPathPrefixes
395
+ );
396
+ }
397
+ if (!_reservedPathCatalogDefault) {
398
+ const file = discoverReservedPathsFile(
399
+ process.cwd ? process.cwd() : HERE
400
+ );
401
+ _reservedPathCatalogDefault = buildReservedPathCatalog(
402
+ file ? readJson(file)?.reservedPathPrefixes : undefined
403
+ );
404
+ }
405
+ return _reservedPathCatalogDefault;
406
+ }
407
+
336
408
  /** Read `context.settings.stapel` (flat config) with a stable empty default. */
337
409
  export function stapelSettings(context) {
338
410
  return (context.settings && context.settings.stapel) || {};
@@ -344,6 +416,7 @@ export function __resetCaches() {
344
416
  _i18nDefault = undefined;
345
417
  _eventsCatalogDefault = undefined;
346
418
  _operationCatalogDefault = undefined;
419
+ _reservedPathCatalogDefault = undefined;
347
420
  }
348
421
 
349
422
  export { resolve as _resolve };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/eslint-plugin",
3
- "version": "0.3.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,216 @@
1
+ // stapel/no-reserved-backend-route — path-collision guardrail between the SPA
2
+ // router and the backend's reserved namespace (§57 nginx canon:
3
+ // stapel-tools' _compose_templates.NGINX_CONF / NGINX_LOCAL_CONF_TEMPLATE —
4
+ // "a frontend app must never define a client route starting with
5
+ // /staticfiles/, /media/ or /<backend-slug>/api|swagger…").
6
+ //
7
+ // Canon (owner directive): a BARE module root (`/calendar`) is legitimate —
8
+ // roots belong to the frontend. Only a route that falls INTO a reserved
9
+ // SUB-path (`/calendar/api/…`, `/calendar/swagger…`, and the project-wide
10
+ // `/admin`, `/staticfiles`, `/media`) collides with the backend. The reserved
11
+ // list itself is data-driven — read from `reserved-paths.json` (the same
12
+ // projection stapel-tools' generator emits), never hand-maintained here, so
13
+ // the rule tracks a project's actual module set (§2.1). A missing catalog
14
+ // degrades the rule to a no-op (never a crash) — same policy as every other
15
+ // data-driven rule in this plugin.
16
+ //
17
+ // Two detector shapes, in the spirit of the other route/path guardrails
18
+ // (no-string-paths):
19
+ // 1. JSX — <Route path="…"> (react-router).
20
+ // 2. Object route configs — a `path` property on an object that reads as a
21
+ // RouteObject (has an `element`/`Component`/`children`/`index`/
22
+ // `errorElement`/`loader`/`action`/`lazy` sibling), OR sits inside the
23
+ // array literal passed straight to `createBrowserRouter`/
24
+ // `createHashRouter`/`createMemoryRouter` — covers `useRoutes([...])`
25
+ // configs and router-factory calls alike.
26
+ // Only string literals and the static prefix of a template literal are
27
+ // checked (dynamic segments are unknowable — false-positive policy, same as
28
+ // no-string-paths).
29
+ import { loadReservedPathCatalog, stapelSettings } from "../lib/data.js";
30
+
31
+ const ROUTER_FACTORIES = new Set([
32
+ "createBrowserRouter",
33
+ "createHashRouter",
34
+ "createMemoryRouter",
35
+ "createStaticRouter",
36
+ ]);
37
+
38
+ const ROUTE_SHAPE_KEYS = new Set([
39
+ "element",
40
+ "Component",
41
+ "children",
42
+ "index",
43
+ "errorElement",
44
+ "loader",
45
+ "action",
46
+ "lazy",
47
+ ]);
48
+
49
+ // Prefixes with more than one path segment (e.g. "/calendar/api") are a
50
+ // module's own sub-path reservation — the bare root one segment up
51
+ // ("/calendar") is the frontend's by canon, worth saying explicitly. A
52
+ // single-segment prefix ("/admin", "/staticfiles", "/media") is a
53
+ // project-wide infra reservation with no such carve-out.
54
+ const SUBPATH_HINT =
55
+ "Reserved for the backend (nginx §57 canon). A bare module root (\"/{{mod}}\") stays the frontend's — only this sub-path collides. Route somewhere else, or drop the reservation from reserved-paths.json if this module no longer owns it.";
56
+ const GLOBAL_HINT =
57
+ "Reserved project-wide for the backend/infra (nginx §57 canon: /admin, /staticfiles, /media) — there is no frontend carve-out for this prefix. Route somewhere else.";
58
+
59
+ /** A meaningful path: leading "/" plus at least one segment char. */
60
+ function isPathLike(str) {
61
+ return typeof str === "string" && str.startsWith("/") && /[A-Za-z0-9]/.test(str);
62
+ }
63
+
64
+ function jsxAttr(node, name) {
65
+ return node.attributes.find(
66
+ (a) =>
67
+ a.type === "JSXAttribute" &&
68
+ a.name &&
69
+ a.name.type === "JSXIdentifier" &&
70
+ a.name.name === name
71
+ );
72
+ }
73
+
74
+ /** The literal/template node behind a JSX attribute's value, or null. */
75
+ function jsxAttrValueNode(attr) {
76
+ if (!attr || attr.value == null) return null;
77
+ const v = attr.value;
78
+ if (v.type === "Literal") return v;
79
+ if (v.type === "JSXExpressionContainer") return v.expression;
80
+ return null;
81
+ }
82
+
83
+ /** String literal value, or a template literal's static-prefix, else null. */
84
+ function literalOrTemplatePrefix(node) {
85
+ if (!node) return null;
86
+ if (node.type === "Literal" && typeof node.value === "string") {
87
+ return isPathLike(node.value) ? node.value : null;
88
+ }
89
+ if (node.type === "TemplateLiteral" && node.quasis.length > 0) {
90
+ const head = node.quasis[0].value.cooked ?? node.quasis[0].value.raw ?? "";
91
+ return isPathLike(head) ? head : null;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function hasRouteShapeSibling(objExpr) {
97
+ return objExpr.properties.some(
98
+ (p) =>
99
+ p.type === "Property" &&
100
+ !p.computed &&
101
+ p.key.type === "Identifier" &&
102
+ ROUTE_SHAPE_KEYS.has(p.key.name)
103
+ );
104
+ }
105
+
106
+ /**
107
+ * True if `objExpr` sits (through any nesting of `children` arrays / plain
108
+ * arrays) inside the argument list of a `createBrowserRouter`-family call.
109
+ */
110
+ function isInRouterFactoryCall(objExpr) {
111
+ let cur = objExpr;
112
+ let parent = objExpr.parent;
113
+ while (parent) {
114
+ if (parent.type === "ArrayExpression") {
115
+ cur = parent;
116
+ parent = parent.parent;
117
+ continue;
118
+ }
119
+ if (
120
+ parent.type === "Property" &&
121
+ parent.value === cur &&
122
+ !parent.computed &&
123
+ parent.key.type === "Identifier" &&
124
+ parent.key.name === "children"
125
+ ) {
126
+ cur = parent.parent; // the enclosing ObjectExpression
127
+ parent = cur.parent;
128
+ continue;
129
+ }
130
+ if (parent.type === "CallExpression") {
131
+ return (
132
+ parent.arguments.includes(cur) &&
133
+ parent.callee.type === "Identifier" &&
134
+ ROUTER_FACTORIES.has(parent.callee.name)
135
+ );
136
+ }
137
+ return false;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ export default {
143
+ meta: {
144
+ type: "problem",
145
+ docs: {
146
+ description:
147
+ "Disallow an SPA route path that falls into a reserved backend sub-path (/<mod>/api/…, /<mod>/swagger…, /admin, /staticfiles, /media). A bare module root is legitimate.",
148
+ },
149
+ schema: [
150
+ {
151
+ type: "object",
152
+ properties: {
153
+ reservedPathPrefixes: { type: "array", items: { type: "string" } },
154
+ },
155
+ additionalProperties: false,
156
+ },
157
+ ],
158
+ messages: {
159
+ reservedSubPath:
160
+ 'Route path "{{path}}" collides with the reserved backend prefix "{{prefix}}". ' +
161
+ SUBPATH_HINT,
162
+ reservedGlobal:
163
+ 'Route path "{{path}}" collides with the reserved backend prefix "{{prefix}}". ' +
164
+ GLOBAL_HINT,
165
+ },
166
+ },
167
+ create(context) {
168
+ const settings = stapelSettings(context);
169
+ const catalog = loadReservedPathCatalog(
170
+ context.options[0]?.reservedPathPrefixes
171
+ ? { reservedPaths: context.options[0].reservedPathPrefixes }
172
+ : settings
173
+ );
174
+ if (!catalog.loaded) return {}; // no reserved-paths.json → no-op, never guess
175
+
176
+ function check(node, path) {
177
+ const prefix = catalog.matches(path);
178
+ if (!prefix) return;
179
+ const segments = prefix.split("/").filter(Boolean);
180
+ const isSubPath = segments.length > 1;
181
+ const mod = segments[0];
182
+ context.report({
183
+ node,
184
+ messageId: isSubPath ? "reservedSubPath" : "reservedGlobal",
185
+ data: { path, prefix, mod },
186
+ });
187
+ }
188
+
189
+ return {
190
+ // Detector 1: <Route path="…">.
191
+ JSXOpeningElement(node) {
192
+ if (!(node.name.type === "JSXIdentifier" && node.name.name === "Route")) return;
193
+ const attr = jsxAttr(node, "path");
194
+ const valueNode = jsxAttrValueNode(attr);
195
+ const path = literalOrTemplatePrefix(valueNode);
196
+ if (path) check(valueNode, path);
197
+ },
198
+ // Detector 2: object route configs (RouteObject shape or a
199
+ // createBrowserRouter-family array literal).
200
+ Property(node) {
201
+ if (
202
+ node.computed ||
203
+ node.key.type !== "Identifier" ||
204
+ node.key.name !== "path" ||
205
+ node.parent.type !== "ObjectExpression"
206
+ )
207
+ return;
208
+ const path = literalOrTemplatePrefix(node.value);
209
+ if (!path) return;
210
+ const objExpr = node.parent;
211
+ if (!hasRouteShapeSibling(objExpr) && !isInRouterFactoryCall(objExpr)) return;
212
+ check(node.value, path);
213
+ },
214
+ };
215
+ },
216
+ };
@@ -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
+ };