@stapel/eslint-plugin 0.2.0 → 0.4.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 +38 -3
- package/index.js +56 -1
- package/lib/data.js +73 -0
- package/package.json +2 -2
- package/rules/no-adhoc-401.js +68 -0
- package/rules/no-direct-analytics-provider.js +3 -2
- package/rules/no-raw-storage.js +126 -0
- package/rules/no-reserved-backend-route.js +216 -0
package/README.md
CHANGED
|
@@ -31,11 +31,41 @@ 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/`),
|
|
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
|
|
|
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
|
+
|
|
39
69
|
## Rules
|
|
40
70
|
|
|
41
71
|
| Rule | Catches |
|
|
@@ -54,6 +84,9 @@ The `recommended` preset:
|
|
|
54
84
|
| `stapel/known-event` (warn) | `track()`/`tracked()` with an event name absent from the generated `events.json` — registry drift; run `pnpm gen:events` (§3) |
|
|
55
85
|
| `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
86
|
| `stapel/demo-literal-meta` | `defineDemo()` with non-literal meta (dynamic `id`/`title`/`description`/`covers`) — breaks static extraction into `demos.json`/`manifest.demos` (§4.2) |
|
|
87
|
+
| `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` |
|
|
88
|
+
| `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 |
|
|
89
|
+
| `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) |
|
|
57
90
|
|
|
58
91
|
### Settings
|
|
59
92
|
|
|
@@ -66,8 +99,10 @@ settings: {
|
|
|
66
99
|
i18nKeys: ["auth.otp.…"], // or i18nManifests: [manifest, …]
|
|
67
100
|
httpModules: ["my-http"], // extra banned HTTP clients
|
|
68
101
|
rawModules: ["@x/raw"], // extra raw-token entry points
|
|
102
|
+
storageModules: ["level"], // extra banned storage-backend packages
|
|
69
103
|
eventsManifests: [manifest],// or eventNames: ["pricing.plan.selected", …]
|
|
70
|
-
operationsManifests: [manifest], // or operationPaths: ["/auth/api/me/", …]
|
|
104
|
+
operationsManifests: [manifest], // or operationPaths: ["/auth/api/v1/me/", …]
|
|
105
|
+
reservedPathsFile: "./reserved-paths.json", // or reservedPaths: ["/admin", …]
|
|
71
106
|
httpVerbs: ["get","post"], // client methods no-string-paths inspects
|
|
72
107
|
queryHooks: ["useQuery"], // extra react-query hooks to inspect for keys
|
|
73
108
|
trackedWrappers: ["tracked"],
|
package/index.js
CHANGED
|
@@ -18,6 +18,9 @@ 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";
|
|
23
|
+
import noReservedBackendRoute from "./rules/no-reserved-backend-route.js";
|
|
21
24
|
|
|
22
25
|
const rules = {
|
|
23
26
|
"no-raw-colors": noRawColors,
|
|
@@ -37,6 +40,12 @@ const rules = {
|
|
|
37
40
|
"no-direct-analytics-provider": noDirectAnalyticsProvider,
|
|
38
41
|
// Showcase guardrail (frontend-guardrails §4, task G7).
|
|
39
42
|
"demo-literal-meta": demoLiteralMeta,
|
|
43
|
+
// Session-substrate guardrails (frontend-core-architecture-v2 §43).
|
|
44
|
+
"no-raw-storage": noRawStorage,
|
|
45
|
+
"no-adhoc-401": noAdhoc401,
|
|
46
|
+
// Front/back path-collision guardrail (owner directive: SPA router must not
|
|
47
|
+
// claim a reserved backend sub-path).
|
|
48
|
+
"no-reserved-backend-route": noReservedBackendRoute,
|
|
40
49
|
};
|
|
41
50
|
|
|
42
51
|
const plugin = {
|
|
@@ -81,6 +90,7 @@ const FETCH_ALLOWED = [
|
|
|
81
90
|
"**/api/**",
|
|
82
91
|
"**/*client.{ts,js}",
|
|
83
92
|
"**/analytics/providers.{ts,js}",
|
|
93
|
+
"**/analytics/src/providers.{ts,js}",
|
|
84
94
|
"**/scripts/**",
|
|
85
95
|
];
|
|
86
96
|
|
|
@@ -101,6 +111,23 @@ const KEY_FACTORY = [
|
|
|
101
111
|
"**/query-keys.{ts,tsx,js,mjs}",
|
|
102
112
|
];
|
|
103
113
|
|
|
114
|
+
// @stapel/core's storage/repository internals — the ONE legal home of direct
|
|
115
|
+
// localStorage/indexedDB access (§43.4 override; everything else persists
|
|
116
|
+
// through createRepository, which is what makes wipe-at-logout mechanical).
|
|
117
|
+
const STORAGE_ALLOWED = [
|
|
118
|
+
"**/core/src/storage.{ts,js}",
|
|
119
|
+
"**/core/src/repository.{ts,js}",
|
|
120
|
+
"**/core/src/query.{ts,js}",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
// @stapel/core's client + session internals — the ONE legal home of 401
|
|
124
|
+
// handling (§43.2 override; the onAuthRefresh seam + SessionManager.refresh()
|
|
125
|
+
// are where the single-flight retry lives).
|
|
126
|
+
const ADHOC_401_ALLOWED = [
|
|
127
|
+
"**/core/src/client.{ts,js}",
|
|
128
|
+
"**/core/src/session.{ts,js}",
|
|
129
|
+
];
|
|
130
|
+
|
|
104
131
|
/**
|
|
105
132
|
* Flat-config `recommended` preset. Consumers spread it AFTER their parser
|
|
106
133
|
* config:
|
|
@@ -132,6 +159,15 @@ const recommended = [
|
|
|
132
159
|
"stapel/no-direct-analytics-provider": "error",
|
|
133
160
|
// Showcase (§4): defineDemo meta must stay literal (extractable).
|
|
134
161
|
"stapel/demo-literal-meta": "error",
|
|
162
|
+
// Session substrate (frontend-core-architecture-v2 §43): persistence
|
|
163
|
+
// only through createRepository; 401 handling only in core's client
|
|
164
|
+
// + SessionManager.
|
|
165
|
+
"stapel/no-raw-storage": "error",
|
|
166
|
+
"stapel/no-adhoc-401": "error",
|
|
167
|
+
// Path-collision guardrail: the SPA router must not claim a reserved
|
|
168
|
+
// backend sub-path (/<mod>/api/…, /<mod>/swagger…, /admin, /staticfiles,
|
|
169
|
+
// /media — §57 nginx canon). No-op without reserved-paths.json.
|
|
170
|
+
"stapel/no-reserved-backend-route": "error",
|
|
135
171
|
},
|
|
136
172
|
},
|
|
137
173
|
{
|
|
@@ -158,10 +194,22 @@ const recommended = [
|
|
|
158
194
|
files: KEY_FACTORY,
|
|
159
195
|
rules: { "stapel/query-keys-from-factory": "off" },
|
|
160
196
|
},
|
|
197
|
+
{
|
|
198
|
+
// Core's storage/repository internals — the one legal home of raw
|
|
199
|
+
// storage access (§43.4 override).
|
|
200
|
+
files: STORAGE_ALLOWED,
|
|
201
|
+
rules: { "stapel/no-raw-storage": "off" },
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
// Core's client + session — the one legal home of 401 handling
|
|
205
|
+
// (§43.2 override).
|
|
206
|
+
files: ADHOC_401_ALLOWED,
|
|
207
|
+
rules: { "stapel/no-adhoc-401": "off" },
|
|
208
|
+
},
|
|
161
209
|
{
|
|
162
210
|
// The facade's provider adapters — the ONE legal home of vendor SDK
|
|
163
211
|
// imports (§2.2 override; mirrors the FETCH_ALLOWED api-layer carve-out).
|
|
164
|
-
files: ["**/analytics/providers.{ts,js}", "**/analytics/providers/**"],
|
|
212
|
+
files: ["**/analytics/providers.{ts,js}", "**/analytics/src/providers.{ts,js}", "**/analytics/providers/**"],
|
|
165
213
|
rules: { "stapel/no-direct-analytics-provider": "off" },
|
|
166
214
|
},
|
|
167
215
|
{
|
|
@@ -183,6 +231,13 @@ const recommended = [
|
|
|
183
231
|
"stapel/known-event": "off",
|
|
184
232
|
"stapel/no-direct-analytics-provider": "off",
|
|
185
233
|
"stapel/demo-literal-meta": "off",
|
|
234
|
+
// Tests legitimately assert on raw storage state (the wipe-at-logout
|
|
235
|
+
// contract test greps localStorage directly) and on 401 fixtures.
|
|
236
|
+
"stapel/no-raw-storage": "off",
|
|
237
|
+
"stapel/no-adhoc-401": "off",
|
|
238
|
+
// Route fixtures legitimately probe reserved-path collisions on purpose
|
|
239
|
+
// (that's what this rule's own tests do).
|
|
240
|
+
"stapel/no-reserved-backend-route": "off",
|
|
186
241
|
// require-disable-description stays ON — disable hygiene applies everywhere.
|
|
187
242
|
},
|
|
188
243
|
},
|
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
|
+
"version": "0.4.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.
|
|
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
|
|
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
|
|
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
|
+
};
|
|
@@ -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
|
+
};
|