@stapel/eslint-plugin 0.2.0 → 0.3.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
@@ -31,8 +31,10 @@ The `recommended` preset:
31
31
  - turns the rules on for `**/*.{ts,tsx,js,jsx,mjs,…}` (JSX-only rules on `*.{tsx,jsx}`);
32
32
  - **overrides** `no-raw-token-import` **off** in theme-config / showcase / demo / scripts,
33
33
  `no-raw-fetch` and `no-string-paths` **off** in the codegen api layer
34
- (`**/api/**`, `*client.ts`, `generated/`), and `query-keys-from-factory`
35
- **off** in the key-factory file (`**/queryKeys.*`);
34
+ (`**/api/**`, `*client.ts`, `generated/`), `query-keys-from-factory`
35
+ **off** in the key-factory file (`**/queryKeys.*`), `no-raw-storage` **off**
36
+ in core's storage/repository internals, and `no-adhoc-401` **off** in core's
37
+ client/session (each rule's one legal home);
36
38
  - **overrides** the content rules off in tests and fixtures (they exercise the
37
39
  anti-patterns on purpose).
38
40
 
@@ -54,6 +56,8 @@ The `recommended` preset:
54
56
  | `stapel/known-event` (warn) | `track()`/`tracked()` with an event name absent from the generated `events.json` — registry drift; run `pnpm gen:events` (§3) |
55
57
  | `stapel/no-direct-analytics-provider` | importing an analytics vendor SDK (posthog-js, mixpanel, `@amplitude/*`, `@segment/*`, …) outside the core facade's provider adapters (`analytics/providers.*`) — bypasses consent/PII/queue (§3). Extend the vendor list via `options.providers` or `settings.stapel.providerModules` |
56
58
  | `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
+ | `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
+ | `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 |
57
61
 
58
62
  ### Settings
59
63
 
@@ -66,6 +70,7 @@ settings: {
66
70
  i18nKeys: ["auth.otp.…"], // or i18nManifests: [manifest, …]
67
71
  httpModules: ["my-http"], // extra banned HTTP clients
68
72
  rawModules: ["@x/raw"], // extra raw-token entry points
73
+ storageModules: ["level"], // extra banned storage-backend packages
69
74
  eventsManifests: [manifest],// or eventNames: ["pricing.plan.selected", …]
70
75
  operationsManifests: [manifest], // or operationPaths: ["/auth/api/me/", …]
71
76
  httpVerbs: ["get","post"], // client methods no-string-paths inspects
package/index.js CHANGED
@@ -18,6 +18,8 @@ import eventLiteralMeta from "./rules/event-literal-meta.js";
18
18
  import knownEvent from "./rules/known-event.js";
19
19
  import noDirectAnalyticsProvider from "./rules/no-direct-analytics-provider.js";
20
20
  import demoLiteralMeta from "./rules/demo-literal-meta.js";
21
+ import noRawStorage from "./rules/no-raw-storage.js";
22
+ import noAdhoc401 from "./rules/no-adhoc-401.js";
21
23
 
22
24
  const rules = {
23
25
  "no-raw-colors": noRawColors,
@@ -37,6 +39,9 @@ const rules = {
37
39
  "no-direct-analytics-provider": noDirectAnalyticsProvider,
38
40
  // Showcase guardrail (frontend-guardrails §4, task G7).
39
41
  "demo-literal-meta": demoLiteralMeta,
42
+ // Session-substrate guardrails (frontend-core-architecture-v2 §43).
43
+ "no-raw-storage": noRawStorage,
44
+ "no-adhoc-401": noAdhoc401,
40
45
  };
41
46
 
42
47
  const plugin = {
@@ -81,6 +86,7 @@ const FETCH_ALLOWED = [
81
86
  "**/api/**",
82
87
  "**/*client.{ts,js}",
83
88
  "**/analytics/providers.{ts,js}",
89
+ "**/analytics/src/providers.{ts,js}",
84
90
  "**/scripts/**",
85
91
  ];
86
92
 
@@ -101,6 +107,23 @@ const KEY_FACTORY = [
101
107
  "**/query-keys.{ts,tsx,js,mjs}",
102
108
  ];
103
109
 
110
+ // @stapel/core's storage/repository internals — the ONE legal home of direct
111
+ // localStorage/indexedDB access (§43.4 override; everything else persists
112
+ // through createRepository, which is what makes wipe-at-logout mechanical).
113
+ const STORAGE_ALLOWED = [
114
+ "**/core/src/storage.{ts,js}",
115
+ "**/core/src/repository.{ts,js}",
116
+ "**/core/src/query.{ts,js}",
117
+ ];
118
+
119
+ // @stapel/core's client + session internals — the ONE legal home of 401
120
+ // handling (§43.2 override; the onAuthRefresh seam + SessionManager.refresh()
121
+ // are where the single-flight retry lives).
122
+ const ADHOC_401_ALLOWED = [
123
+ "**/core/src/client.{ts,js}",
124
+ "**/core/src/session.{ts,js}",
125
+ ];
126
+
104
127
  /**
105
128
  * Flat-config `recommended` preset. Consumers spread it AFTER their parser
106
129
  * config:
@@ -132,6 +155,11 @@ const recommended = [
132
155
  "stapel/no-direct-analytics-provider": "error",
133
156
  // Showcase (§4): defineDemo meta must stay literal (extractable).
134
157
  "stapel/demo-literal-meta": "error",
158
+ // Session substrate (frontend-core-architecture-v2 §43): persistence
159
+ // only through createRepository; 401 handling only in core's client
160
+ // + SessionManager.
161
+ "stapel/no-raw-storage": "error",
162
+ "stapel/no-adhoc-401": "error",
135
163
  },
136
164
  },
137
165
  {
@@ -158,10 +186,22 @@ const recommended = [
158
186
  files: KEY_FACTORY,
159
187
  rules: { "stapel/query-keys-from-factory": "off" },
160
188
  },
189
+ {
190
+ // Core's storage/repository internals — the one legal home of raw
191
+ // storage access (§43.4 override).
192
+ files: STORAGE_ALLOWED,
193
+ rules: { "stapel/no-raw-storage": "off" },
194
+ },
195
+ {
196
+ // Core's client + session — the one legal home of 401 handling
197
+ // (§43.2 override).
198
+ files: ADHOC_401_ALLOWED,
199
+ rules: { "stapel/no-adhoc-401": "off" },
200
+ },
161
201
  {
162
202
  // The facade's provider adapters — the ONE legal home of vendor SDK
163
203
  // imports (§2.2 override; mirrors the FETCH_ALLOWED api-layer carve-out).
164
- files: ["**/analytics/providers.{ts,js}", "**/analytics/providers/**"],
204
+ files: ["**/analytics/providers.{ts,js}", "**/analytics/src/providers.{ts,js}", "**/analytics/providers/**"],
165
205
  rules: { "stapel/no-direct-analytics-provider": "off" },
166
206
  },
167
207
  {
@@ -183,6 +223,10 @@ const recommended = [
183
223
  "stapel/known-event": "off",
184
224
  "stapel/no-direct-analytics-provider": "off",
185
225
  "stapel/demo-literal-meta": "off",
226
+ // Tests legitimately assert on raw storage state (the wipe-at-logout
227
+ // contract test greps localStorage directly) and on 401 fixtures.
228
+ "stapel/no-raw-storage": "off",
229
+ "stapel/no-adhoc-401": "off",
186
230
  // require-disable-description stays ON — disable hygiene applies everywhere.
187
231
  },
188
232
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/eslint-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.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.2.0"
27
+ "@stapel/tokens": "^0.4.0"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "eslint": ">=9",
@@ -0,0 +1,68 @@
1
+ // stapel/no-adhoc-401 — frontend-core-architecture-v2 §43.2.
2
+ // 401 handling (single-flight refresh → retry once → SessionManager.sessionLost())
3
+ // lives in ONE place: `@stapel/core`'s `createStapelClient` (`onAuthRefresh`
4
+ // seam) + `SessionManager`. A module/service writing its own "if status is
5
+ // 401, refresh/redirect/retry" branch bypasses the single-flight coalescing
6
+ // and the logout-hook registry silently — the failure mode is invisible until
7
+ // two concurrent requests both refresh and race each other. The recommended
8
+ // preset turns this rule OFF for `@stapel/core`'s own client/session
9
+ // internals (the ONE legal home), mirroring the no-raw-fetch api-layer
10
+ // carve-out.
11
+
12
+ // Named constant (not an inline literal in the comparison below) so the rule
13
+ // passes ITSELF — its own `value === HTTP_UNAUTHORIZED` check must not read
14
+ // as ad hoc 401 handling when the monorepo lints this file.
15
+ const HTTP_UNAUTHORIZED = 401;
16
+
17
+ export default {
18
+ meta: {
19
+ type: "problem",
20
+ docs: {
21
+ description:
22
+ "Disallow ad hoc 401 handling / auth interceptors outside @stapel/core's client and SessionManager.",
23
+ },
24
+ schema: [],
25
+ messages: {
26
+ literal401:
27
+ "Ad hoc 401 handling. 401s are handled ONCE, in @stapel/core's createStapelClient (onAuthRefresh seam) + SessionManager.refresh() — single-flight refresh, retry once, SessionManager.sessionLost() on failure (frontend-core-architecture-v2 §43.2). Wire onAuthRefresh to your SessionManager instead of branching on the status code here.",
28
+ interceptor:
29
+ "Ad hoc auth interceptor ({{what}}). Requests already go through createStapelClient, whose onAuthRefresh/onVerificationChallenge seams are the one legal place to intercept auth failures — see §43.2.",
30
+ },
31
+ },
32
+ create(context) {
33
+ function isNumeric401(node) {
34
+ return node.type === "Literal" && node.value === HTTP_UNAUTHORIZED;
35
+ }
36
+
37
+ return {
38
+ BinaryExpression(node) {
39
+ if (!["==", "===", "!=", "!=="].includes(node.operator)) return;
40
+ if (isNumeric401(node.left) || isNumeric401(node.right)) {
41
+ context.report({ node, messageId: "literal401" });
42
+ }
43
+ },
44
+
45
+ SwitchCase(node) {
46
+ if (node.test && isNumeric401(node.test)) {
47
+ context.report({ node, messageId: "literal401" });
48
+ }
49
+ },
50
+
51
+ // `client.interceptors.response.use(...)` / `axios.interceptors...` —
52
+ // axios-shaped ad hoc auth interceptors, regardless of the object.
53
+ MemberExpression(node) {
54
+ if (
55
+ !node.computed &&
56
+ node.property.type === "Identifier" &&
57
+ node.property.name === "interceptors"
58
+ ) {
59
+ context.report({
60
+ node,
61
+ messageId: "interceptor",
62
+ data: { what: "*.interceptors" },
63
+ });
64
+ }
65
+ },
66
+ };
67
+ },
68
+ };
@@ -1,5 +1,6 @@
1
1
  // stapel/no-direct-analytics-provider — frontend-guardrails §2.2 / §3.
2
- // Analytics goes through the @stapel/core facade (consent gate, PII guard,
2
+ // Analytics goes through the Stapel facade (the @stapel/core type seam,
3
+ // implemented by @stapel/analytics): consent gate, PII guard,
3
4
  // offline queue, fan-out), NEVER straight into a vendor SDK. Importing a
4
5
  // provider package anywhere but the facade's provider-adapter module bypasses
5
6
  // consent and PII enforcement in one line — the cheapest F9 violation there
@@ -49,7 +50,7 @@ export default {
49
50
  ],
50
51
  messages: {
51
52
  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
+ 'Direct analytics provider import "{{source}}". Emit through the Stapel analytics 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 implementation: @stapel/analytics; type seam: @stapel/core).',
53
54
  },
54
55
  },
55
56
  create(context) {
@@ -0,0 +1,126 @@
1
+ // stapel/no-raw-storage — frontend-core-architecture-v2 §43.4.
2
+ // `createRepository` (@stapel/core) is the ONE sanctioned client-side
3
+ // persistence primitive: `scope: "user"` repositories auto-wipe on logout
4
+ // (no opt-out) and are encrypted by default — a guarantee that only holds if
5
+ // nothing else in the app reaches `localStorage`/`sessionStorage`/`indexedDB`
6
+ // directly, since that data would never be torn down or encrypted. Direct
7
+ // access is banned everywhere except `@stapel/core`'s own storage/repository
8
+ // internals (the recommended preset turns this rule OFF there via a file
9
+ // override, mirroring the no-raw-fetch api-layer carve-out).
10
+ import { stapelSettings } from "../lib/data.js";
11
+
12
+ const STORAGE_GLOBALS = new Set(["localStorage", "sessionStorage"]);
13
+ const IDB_GLOBALS = new Set(["indexedDB"]);
14
+ const GLOBAL_HOSTS = new Set(["window", "self", "globalThis"]);
15
+ const BANNED_IMPORTS = new Set(["idb-keyval"]);
16
+
17
+ export default {
18
+ meta: {
19
+ type: "problem",
20
+ docs: {
21
+ description:
22
+ "Disallow direct localStorage/sessionStorage/indexedDB access outside @stapel/core's repository layer.",
23
+ },
24
+ schema: [
25
+ {
26
+ type: "object",
27
+ properties: {
28
+ modules: { type: "array", items: { type: "string" } },
29
+ },
30
+ additionalProperties: false,
31
+ },
32
+ ],
33
+ messages: {
34
+ rawStorage:
35
+ 'Direct {{what}} access. Persist through createRepository(namespace, { scope, storage, encrypted }) (@stapel/core) instead — it is the one sanctioned client-side store: scope "user" auto-wipes on logout (no opt-out) and is encrypted by default, scope "app" survives logout. Raw storage is neither wiped nor encrypted (frontend-core-architecture-v2 §43.4).',
36
+ rawImport:
37
+ 'Import of storage-backend package "{{source}}". Use createRepository() (@stapel/core) instead of building on this directly — see §43.4.',
38
+ },
39
+ },
40
+ create(context) {
41
+ const settings = stapelSettings(context);
42
+ const extraModules = context.options[0]?.modules ?? [];
43
+ const bannedModules = new Set([
44
+ ...BANNED_IMPORTS,
45
+ ...(settings.storageModules ?? []),
46
+ ...extraModules,
47
+ ]);
48
+
49
+ function report(node, what) {
50
+ context.report({ node, messageId: "rawStorage", data: { what } });
51
+ }
52
+
53
+ function identifierName(node) {
54
+ return node.type === "Identifier" ? node.name : null;
55
+ }
56
+
57
+ function isStorageName(name) {
58
+ return STORAGE_GLOBALS.has(name) || IDB_GLOBALS.has(name);
59
+ }
60
+
61
+ return {
62
+ // Bare `localStorage` / `sessionStorage` / `indexedDB` uses — resolved
63
+ // through scope analysis so a local binding that merely SHARES the name
64
+ // (`function f(indexedDB) {…}`) is never flagged: only references that
65
+ // resolve to a global (env/globals config) or to nothing at all (the
66
+ // usual appearance of browser globals) are the real storage globals.
67
+ "Program:exit"(node) {
68
+ const globalScope = context.sourceCode.getScope(node);
69
+ // Unresolved references — no declaration anywhere in the file.
70
+ for (const ref of globalScope.through) {
71
+ const name = ref.identifier.name;
72
+ if (isStorageName(name)) report(ref.identifier, name);
73
+ }
74
+ // References that RESOLVE to a declared ambient global (a variable
75
+ // with no defs, injected via languageOptions.globals).
76
+ for (const name of [...STORAGE_GLOBALS, ...IDB_GLOBALS]) {
77
+ const variable = globalScope.set.get(name);
78
+ if (!variable || variable.defs.length > 0) continue;
79
+ for (const ref of variable.references) {
80
+ report(ref.identifier, name);
81
+ }
82
+ }
83
+ },
84
+
85
+ // `window.localStorage`, `self.indexedDB`, `globalThis.sessionStorage`.
86
+ MemberExpression(node) {
87
+ if (node.computed) return;
88
+ const objectName = identifierName(node.object);
89
+ const propertyName = identifierName(node.property);
90
+ if (
91
+ objectName &&
92
+ GLOBAL_HOSTS.has(objectName) &&
93
+ propertyName &&
94
+ (STORAGE_GLOBALS.has(propertyName) || IDB_GLOBALS.has(propertyName))
95
+ ) {
96
+ report(node, `${objectName}.${propertyName}`);
97
+ }
98
+ },
99
+
100
+ ImportDeclaration(node) {
101
+ if (bannedModules.has(node.source.value)) {
102
+ context.report({
103
+ node,
104
+ messageId: "rawImport",
105
+ data: { source: node.source.value },
106
+ });
107
+ }
108
+ },
109
+
110
+ CallExpression(node) {
111
+ if (
112
+ node.callee.type === "Identifier" &&
113
+ node.callee.name === "require" &&
114
+ node.arguments[0]?.type === "Literal" &&
115
+ bannedModules.has(node.arguments[0].value)
116
+ ) {
117
+ context.report({
118
+ node,
119
+ messageId: "rawImport",
120
+ data: { source: node.arguments[0].value },
121
+ });
122
+ }
123
+ },
124
+ };
125
+ },
126
+ };