@stapel/eslint-plugin 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ // stapel/no-string-paths — frontend-guardrails §2.2 (failure mode F2).
2
+ // API URLs are reached through NAMED operations of the codegen client (layer 2),
3
+ // never a hand-written path string. `client.get("/me/")` (or a bare
4
+ // `"/auth/api/me/"` literal) bypasses the typed operation surface: the wrong
5
+ // verb, a stale path, or a renamed endpoint all pass silently. The recommended
6
+ // preset turns this OFF for the api layer of a pair (`api/`, `*client.ts`,
7
+ // generated) — the ONE legal home of path strings (the operation *definitions*),
8
+ // mirroring the no-raw-fetch carve-out.
9
+ //
10
+ // Two detectors, in the spirit of the other guardrails:
11
+ // 1. Syntactic — a call `<recv>.<verb>("/…")` on an http verb with a
12
+ // leading-slash string/template path argument (the `client.get("/…")`
13
+ // bypass shape). Fires with or without a catalog.
14
+ // 2. Data-driven — a string / template path that equals (or, for a
15
+ // client-relative literal, ends with) a catalogued operation path from a
16
+ // package manifest.json §operations. A missing catalog degrades detector 2
17
+ // to a no-op; detector 1 still holds the syntactic line.
18
+ import { loadOperationCatalog, stapelSettings } from "../lib/data.js";
19
+
20
+ const DEFAULT_HTTP_VERBS = ["get", "post", "put", "patch", "delete", "request"];
21
+
22
+ const HINT =
23
+ "Requests go through a NAMED operation of the codegen client, never a path string: const api = createAuthApi(client); await api.<operation>(...). Operations: <pkg>/manifest.json §operations. §2.3 stapel/no-string-paths";
24
+
25
+ /** A meaningful API path: leading "/" plus at least one segment char. */
26
+ function isPathLike(str) {
27
+ return str.startsWith("/") && /[A-Za-z0-9]/.test(str);
28
+ }
29
+
30
+ /** A path-shaped string literal value ("/x/…"), else null. */
31
+ function stringLiteralPath(node) {
32
+ if (node && node.type === "Literal" && typeof node.value === "string") {
33
+ return isPathLike(node.value) ? node.value : null;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** A template literal whose first quasi begins with "/" — its static prefix. */
39
+ function templatePathPrefix(node) {
40
+ if (node && node.type === "TemplateLiteral" && node.quasis.length > 0) {
41
+ const head = node.quasis[0].value.cooked ?? node.quasis[0].value.raw ?? "";
42
+ return isPathLike(head) ? head : null;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** Render a template literal back to a readable path with `${…}` placeholders. */
48
+ function templateText(node) {
49
+ let out = "";
50
+ node.quasis.forEach((q, i) => {
51
+ out += q.value.cooked ?? q.value.raw ?? "";
52
+ if (i < node.expressions.length) out += "${…}";
53
+ });
54
+ return out;
55
+ }
56
+
57
+ export default {
58
+ meta: {
59
+ type: "problem",
60
+ docs: {
61
+ description:
62
+ "Disallow hand-written API path strings (client.<verb>(\"/…\") or a catalogued operation path) outside the codegen API layer.",
63
+ },
64
+ schema: [
65
+ {
66
+ type: "object",
67
+ properties: {
68
+ verbs: { type: "array", items: { type: "string" } },
69
+ },
70
+ additionalProperties: false,
71
+ },
72
+ ],
73
+ messages: {
74
+ // Detector 1 — the client.<verb>("/…") bypass shape.
75
+ clientPath: 'Hand-written API path "{{path}}" via .{{verb}}(). ' + HINT,
76
+ // Detector 2 — a literal/template that IS a catalogued operation path.
77
+ knownPath:
78
+ 'Hand-written API path "{{path}}" — this is the catalogued operation "{{operation}}" of {{pkg}}. ' +
79
+ HINT,
80
+ knownPathAnon:
81
+ 'Hand-written API path "{{path}}" — this is a catalogued operation. ' + HINT,
82
+ },
83
+ },
84
+ create(context) {
85
+ const settings = stapelSettings(context);
86
+ const catalog = loadOperationCatalog(settings);
87
+ const verbs = new Set(
88
+ context.options[0]?.verbs ?? settings.httpVerbs ?? DEFAULT_HTTP_VERBS
89
+ );
90
+
91
+ // Nodes already reported by detector 1 (the http-verb call argument), so
92
+ // detector 2 does not double-report the same string.
93
+ const reported = new WeakSet();
94
+
95
+ function reportKnown(node, path) {
96
+ const hit = catalog.resolve(path);
97
+ if (hit && hit.operation) {
98
+ context.report({
99
+ node,
100
+ messageId: "knownPath",
101
+ data: { path, operation: hit.operation, pkg: hit.pkg ?? "the pair" },
102
+ });
103
+ } else {
104
+ context.report({ node, messageId: "knownPathAnon", data: { path } });
105
+ }
106
+ reported.add(node);
107
+ }
108
+
109
+ return {
110
+ // Detector 1: `<recv>.<verb>(pathArg, …)` on an http verb.
111
+ CallExpression(node) {
112
+ const callee = node.callee;
113
+ if (
114
+ callee.type !== "MemberExpression" ||
115
+ callee.computed ||
116
+ callee.property.type !== "Identifier" ||
117
+ !verbs.has(callee.property.name)
118
+ )
119
+ return;
120
+ const arg = node.arguments[0];
121
+ if (!arg) return;
122
+ const literal = stringLiteralPath(arg);
123
+ const prefix = literal === null ? templatePathPrefix(arg) : null;
124
+ if (literal === null && prefix === null) return;
125
+ const path = literal ?? templateText(arg);
126
+ context.report({
127
+ node: arg,
128
+ messageId: "clientPath",
129
+ data: { path, verb: callee.property.name },
130
+ });
131
+ reported.add(arg);
132
+ },
133
+
134
+ // Detector 2: any literal / template path that IS a catalogued operation.
135
+ Literal(node) {
136
+ if (reported.has(node) || !catalog.loaded) return;
137
+ // A path in object-KEY position is a route / mock-handler table, not a
138
+ // request — the bypass this rule targets is passing a path to a client
139
+ // call (detector 1, argument position). Skip keys to avoid flagging
140
+ // legitimate `{ "/auth/api/me/": handler }` maps (e.g. demo MSW fixtures).
141
+ if (
142
+ node.parent &&
143
+ node.parent.type === "Property" &&
144
+ !node.parent.computed &&
145
+ node.parent.key === node
146
+ )
147
+ return;
148
+ const path = stringLiteralPath(node);
149
+ if (path !== null && catalog.matches(path)) reportKnown(node, path);
150
+ },
151
+ TemplateLiteral(node) {
152
+ if (reported.has(node) || !catalog.loaded) return;
153
+ if (
154
+ node.parent &&
155
+ node.parent.type === "Property" &&
156
+ node.parent.computed &&
157
+ node.parent.key === node
158
+ )
159
+ return;
160
+ const prefix = templatePathPrefix(node);
161
+ // Only flag a template when its STATIC prefix alone is a full catalogued
162
+ // path (interpolated segments make the rest unknowable — detector 1
163
+ // already covers `client.get(\`/…/${id}/\`)`).
164
+ if (prefix !== null && catalog.matches(prefix)) reportKnown(node, prefix);
165
+ },
166
+ };
167
+ },
168
+ };
@@ -0,0 +1,143 @@
1
+ // stapel/query-keys-from-factory — frontend-guardrails §2.2 (core gap #8).
2
+ // TanStack Query keys come ONLY from the module's key factory (`<module>QueryKeys`
3
+ // — e.g. `authQueryKeys.sessions()`). A hand-rolled inline array (`queryKey:
4
+ // ["auth","sessions"]`) is the classic drift source: it silently stops matching
5
+ // the factory the mutations invalidate against, so a write updates the server
6
+ // but the screen keeps a stale cache. The rule is a heuristic over the query
7
+ // surface: a call to `useQuery`/`useMutation`/… or a `queryClient.*` method with
8
+ // an INLINE array key. The recommended preset turns it OFF in the factory file
9
+ // itself (`**/queryKeys.*`), where the literal arrays legitimately live.
10
+ import { stapelSettings } from "../lib/data.js";
11
+
12
+ // Hooks whose options object carries `queryKey` / `mutationKey`.
13
+ const DEFAULT_QUERY_HOOKS = [
14
+ "useQuery",
15
+ "useQueries",
16
+ "useInfiniteQuery",
17
+ "useSuspenseQuery",
18
+ "useSuspenseQueries",
19
+ "useSuspenseInfiniteQuery",
20
+ "usePrefetchQuery",
21
+ "usePrefetchInfiniteQuery",
22
+ "useMutation",
23
+ "useIsFetching",
24
+ "useIsMutating",
25
+ "useMutationState",
26
+ ];
27
+
28
+ // QueryClient methods that take a key — as `{ queryKey: … }` (filters) or as a
29
+ // leading positional array (setQueryData/getQueryData/…).
30
+ const DEFAULT_CLIENT_METHODS = [
31
+ "invalidateQueries",
32
+ "removeQueries",
33
+ "cancelQueries",
34
+ "refetchQueries",
35
+ "resetQueries",
36
+ "isFetching",
37
+ "prefetchQuery",
38
+ "prefetchInfiniteQuery",
39
+ "fetchQuery",
40
+ "fetchInfiniteQuery",
41
+ "ensureQueryData",
42
+ "ensureInfiniteQueryData",
43
+ "getQueryData",
44
+ "getQueryState",
45
+ "setQueryData",
46
+ "getQueriesData",
47
+ "setQueriesData",
48
+ ];
49
+
50
+ // Methods whose FIRST positional argument is the key itself (not an options obj).
51
+ const POSITIONAL_KEY_METHODS = new Set([
52
+ "getQueryData",
53
+ "getQueryState",
54
+ "setQueryData",
55
+ "getQueriesData",
56
+ "setQueriesData",
57
+ ]);
58
+
59
+ const KEY_PROPS = new Set(["queryKey", "mutationKey"]);
60
+
61
+ export default {
62
+ meta: {
63
+ type: "problem",
64
+ docs: {
65
+ description:
66
+ "Require react-query keys to come from the module's key factory, not an inline array literal.",
67
+ },
68
+ schema: [
69
+ {
70
+ type: "object",
71
+ properties: {
72
+ queryHooks: { type: "array", items: { type: "string" } },
73
+ clientMethods: { type: "array", items: { type: "string" } },
74
+ factorySuffix: { type: "string" },
75
+ },
76
+ additionalProperties: false,
77
+ },
78
+ ],
79
+ messages: {
80
+ inlineKey:
81
+ 'Inline {{prop}} array. Query keys come only from the module key factory ({{factory}}): {{prop}}: authQueryKeys.<resource>(…). A hand-rolled array drifts from the invalidations that target the factory — the write lands, the cache goes stale. Factory: <module>/model/queryKeys.ts. §2.2 stapel/query-keys-from-factory',
82
+ },
83
+ },
84
+ create(context) {
85
+ const settings = stapelSettings(context);
86
+ const opt = context.options[0] ?? {};
87
+ const queryHooks = new Set(
88
+ opt.queryHooks ?? settings.queryHooks ?? DEFAULT_QUERY_HOOKS
89
+ );
90
+ const clientMethods = new Set(
91
+ opt.clientMethods ?? settings.queryClientMethods ?? DEFAULT_CLIENT_METHODS
92
+ );
93
+ const factory = opt.factorySuffix ?? settings.keyFactorySuffix ?? "<module>QueryKeys";
94
+
95
+ function report(node, prop) {
96
+ context.report({ node, messageId: "inlineKey", data: { prop, factory } });
97
+ }
98
+
99
+ /** Report a `queryKey`/`mutationKey: [ … ]` inline array in an options obj. */
100
+ function checkOptionsObject(objExpr) {
101
+ if (!objExpr || objExpr.type !== "ObjectExpression") return;
102
+ for (const p of objExpr.properties) {
103
+ if (
104
+ p.type === "Property" &&
105
+ !p.computed &&
106
+ p.key.type === "Identifier" &&
107
+ KEY_PROPS.has(p.key.name) &&
108
+ p.value.type === "ArrayExpression"
109
+ ) {
110
+ report(p.value, p.key.name);
111
+ }
112
+ }
113
+ }
114
+
115
+ return {
116
+ CallExpression(node) {
117
+ const callee = node.callee;
118
+ // useQuery({ queryKey: [...] }) — identifier hook.
119
+ if (callee.type === "Identifier" && queryHooks.has(callee.name)) {
120
+ for (const a of node.arguments) checkOptionsObject(a);
121
+ return;
122
+ }
123
+ // queryClient.invalidateQueries({ queryKey: [...] }) / setQueryData([...], …)
124
+ if (
125
+ callee.type === "MemberExpression" &&
126
+ !callee.computed &&
127
+ callee.property.type === "Identifier" &&
128
+ clientMethods.has(callee.property.name)
129
+ ) {
130
+ const method = callee.property.name;
131
+ if (POSITIONAL_KEY_METHODS.has(method)) {
132
+ const first = node.arguments[0];
133
+ if (first && first.type === "ArrayExpression") report(first, "queryKey");
134
+ // setQueriesData/getQueriesData also accept a filters object.
135
+ for (const a of node.arguments) checkOptionsObject(a);
136
+ } else {
137
+ for (const a of node.arguments) checkOptionsObject(a);
138
+ }
139
+ }
140
+ },
141
+ };
142
+ },
143
+ };
@@ -0,0 +1,60 @@
1
+ // stapel/require-disable-description — frontend-guardrails §2.4.
2
+ // An eslint-disable is a documented decision, not a hole: it must carry a
3
+ // `-- reason`. Self-contained equivalent of
4
+ // @eslint-community/eslint-comments/require-description, so the preset stays
5
+ // dependency-light and the escape-hatch policy is enforced for every rule
6
+ // (including future analytics rules). Disables stay greppable and can flow into
7
+ // the analytics/guardrail reports as "explicitly disabled".
8
+ const DIRECTIVES = [
9
+ "eslint-disable",
10
+ "eslint-disable-line",
11
+ "eslint-disable-next-line",
12
+ "eslint-enable",
13
+ ];
14
+
15
+ // Matches `<directive> <rules...> -- description`
16
+ function parse(text) {
17
+ const trimmed = text.trim();
18
+ const directive = DIRECTIVES.find(
19
+ (d) => trimmed === d || trimmed.startsWith(d + " ") || trimmed.startsWith(d + "\t")
20
+ );
21
+ if (!directive) return null;
22
+ const rest = trimmed.slice(directive.length);
23
+ const sep = rest.indexOf("--");
24
+ const description = sep === -1 ? "" : rest.slice(sep + 2).trim();
25
+ return { directive, description };
26
+ }
27
+
28
+ export default {
29
+ meta: {
30
+ type: "suggestion",
31
+ docs: {
32
+ description:
33
+ "Require a `-- reason` description on every eslint-disable directive.",
34
+ },
35
+ schema: [],
36
+ messages: {
37
+ missing:
38
+ "`{{directive}}` needs a reason: add `-- why` after the rule (§2.4 escape-hatch policy). Example: // eslint-disable-next-line stapel/no-raw-colors -- brand chrome, pending token.",
39
+ },
40
+ },
41
+ create(context) {
42
+ const sourceCode = context.sourceCode;
43
+ return {
44
+ Program() {
45
+ for (const comment of sourceCode.getAllComments()) {
46
+ const parsed = parse(comment.value);
47
+ if (!parsed) continue;
48
+ if (parsed.directive === "eslint-enable") continue;
49
+ if (parsed.description.length === 0) {
50
+ context.report({
51
+ loc: comment.loc,
52
+ messageId: "missing",
53
+ data: { directive: parsed.directive },
54
+ });
55
+ }
56
+ }
57
+ },
58
+ };
59
+ },
60
+ };
@@ -0,0 +1,93 @@
1
+ // @stapel/eslint-plugin/stylelint — the CSS half of the guardrails
2
+ // (frontend-guardrails §2.2 / §4 declaration-strict-value). A self-contained
3
+ // stylelint plugin (no third-party strict-value dependency) enforcing:
4
+ // • colour properties may ONLY be `var(--stapel-*)` (or CSS-wide keywords) —
5
+ // the strict-value discipline, scoped to colour;
6
+ // • no hex / rgb() / hsl() literal anywhere in a declaration value.
7
+ // Both messages teach the one right way and point at the token catalog.
8
+ import stylelint from "stylelint";
9
+ import {
10
+ HEX_RE,
11
+ COLOR_FUNC_RE,
12
+ isColorProperty,
13
+ } from "../lib/colors.js";
14
+
15
+ const { createPlugin, utils } = stylelint;
16
+
17
+ const RULE_NAME = "stapel/color-tokens-only";
18
+ const CATALOG = "@stapel/tokens/llms.txt §colors";
19
+
20
+ const messages = utils.ruleMessages(RULE_NAME, {
21
+ rawColor: (value) =>
22
+ `Raw colour "${value}" in CSS. Use a token: var(--stapel-color-<name>). Hex is born only in ramps. Catalog: ${CATALOG}`,
23
+ notAToken: (prop, value) =>
24
+ `Colour property "${prop}" must be a token, got "${value}". Use var(--stapel-color-<name>) (or var(--stapel-<component>)). Catalog: ${CATALOG}`,
25
+ });
26
+
27
+ // CSS-wide keywords + `transparent`/`currentColor` are allowed on colour props.
28
+ const ALLOWED_KEYWORDS = new Set([
29
+ "inherit",
30
+ "initial",
31
+ "unset",
32
+ "revert",
33
+ "revert-layer",
34
+ "transparent",
35
+ "currentcolor",
36
+ "none",
37
+ ]);
38
+
39
+ const HEX_G = new RegExp(HEX_RE.source, "gi");
40
+ const FUNC_G = new RegExp(COLOR_FUNC_RE.source, "gi");
41
+
42
+ function isStapelVarOnly(value) {
43
+ // Accept a value composed solely of var(--stapel-*) references (+ keywords).
44
+ const vars = value.match(/var\(\s*--[\w-]+/g) || [];
45
+ if (vars.length === 0) return false;
46
+ return vars.every((v) => /var\(\s*--stapel-/.test(v));
47
+ }
48
+
49
+ const ruleFunction = (primary) => (root, result) => {
50
+ const validOptions = utils.validateOptions(result, RULE_NAME, {
51
+ actual: primary,
52
+ possible: [true, false],
53
+ });
54
+ if (!validOptions || !primary) return;
55
+
56
+ root.walkDecls((decl) => {
57
+ const value = decl.value;
58
+ const prop = decl.prop;
59
+
60
+ // 1) No raw hex / colour-function literal anywhere in a value.
61
+ if (HEX_G.test(value) || FUNC_G.test(value)) {
62
+ HEX_G.lastIndex = 0;
63
+ FUNC_G.lastIndex = 0;
64
+ utils.report({
65
+ result,
66
+ ruleName: RULE_NAME,
67
+ node: decl,
68
+ message: messages.rawColor(value),
69
+ });
70
+ return;
71
+ }
72
+
73
+ // 2) Colour properties must resolve to a stapel token var.
74
+ if (isColorProperty(prop)) {
75
+ const v = value.trim().toLowerCase();
76
+ if (ALLOWED_KEYWORDS.has(v)) return;
77
+ if (v.startsWith("var(") && isStapelVarOnly(value)) return;
78
+ // A non-stapel var, a named colour, or any other literal on a colour prop.
79
+ utils.report({
80
+ result,
81
+ ruleName: RULE_NAME,
82
+ node: decl,
83
+ message: messages.notAToken(prop, value),
84
+ });
85
+ }
86
+ });
87
+ };
88
+
89
+ ruleFunction.ruleName = RULE_NAME;
90
+ ruleFunction.messages = messages;
91
+
92
+ export default createPlugin(RULE_NAME, ruleFunction);
93
+ export { RULE_NAME, messages };
@@ -0,0 +1,14 @@
1
+ // Stylelint preset (frontend-guardrails §2.2). Drop into a host's
2
+ // stylelint.config.js:
3
+ //
4
+ // import stapelPreset from "@stapel/eslint-plugin/stylelint/preset";
5
+ // export default { ...stapelPreset };
6
+ //
7
+ // Enables the self-contained colour-tokens-only rule (no hex/rgb/hsl in CSS;
8
+ // colour properties only via var(--stapel-*)).
9
+ export default {
10
+ plugins: ["@stapel/eslint-plugin/stylelint"],
11
+ rules: {
12
+ "stapel/color-tokens-only": true,
13
+ },
14
+ };