@spa-kit/eslint-plugin 0.0.0-stage → 0.1.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
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ # @spa-kit/eslint-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Opt-in ESLint rules for spa-kit applications. This package is development tooling;
4
+ it does not add lint dependencies to `@spa-kit/react-router`.
5
+
6
+ ## Setup
7
+
8
+ Install ESLint, a TypeScript parser, and the plugin as development dependencies:
9
+
10
+ ```sh
11
+ npm install --save-dev eslint @typescript-eslint/parser @spa-kit/eslint-plugin
12
+ ```
13
+
14
+ ESLint 9 and 10 are supported. Use a Node.js version supported by your ESLint release.
15
+
16
+ Add the rule to your flat configuration (`eslint.config.mjs`):
17
+
18
+ ```js
19
+ import tsParser from "@typescript-eslint/parser";
20
+ import spaKit from "@spa-kit/eslint-plugin";
21
+
22
+ export default [
23
+ {
24
+ files: ["**/*.ts", "**/*.tsx"],
25
+ languageOptions: {
26
+ parser: tsParser,
27
+ parserOptions: { ecmaFeatures: { jsx: true } },
28
+ },
29
+ plugins: { "@spa-kit": spaKit },
30
+ rules: {
31
+ "@spa-kit/require-preload-context": "error",
32
+ },
33
+ },
34
+ ];
35
+ ```
36
+
37
+ If your configuration already selects a TypeScript parser, add only the plugin
38
+ and rule entries. Installing or registering the plugin does not enable the rule.
39
+ No TypeScript project or parser services are required.
40
+
41
+ ## `require-preload-context`
42
+
43
+ Require a parameter type annotation on `preload` callbacks in route configurations
44
+ passed to `createSpaRouter` from `@spa-kit/react-router`. This preserves inference
45
+ of the preload return type in the sibling `render` callback.
46
+
47
+ ```tsx
48
+ import { createSpaRouter } from "@spa-kit/react-router";
49
+ import { WikiRoutes } from "./generated/WikiRoutes";
50
+ import type { ViewContext } from "./generated/WikiRoutes";
51
+
52
+ createSpaRouter(WikiRoutes, {
53
+ View: {
54
+ preload: ({ params }: ViewContext) => loadQuery(environment, Query, {
55
+ wikiId: params.wikiId,
56
+ }),
57
+ render: ({ preload }) => <ViewWikiPage queryRef={preload} />,
58
+ },
59
+ // Other generated routes omitted here.
60
+ });
61
+ ```
62
+
63
+ These callbacks are reported:
64
+
65
+ ```ts
66
+ preload: ({ params }) => loadQuery(/* ... */)
67
+ preload: context => prepare(context)
68
+ preload({ params }) { return prepare(params); }
69
+ ```
70
+
71
+ These are accepted:
72
+
73
+ ```ts
74
+ preload: ({ params }: ViewContext) => prepare(params)
75
+ preload(context: ViewContext) { return prepare(context.params); }
76
+ preload: () => prepareWithoutInputs()
77
+ ```
78
+
79
+ The rule recognizes renamed imports and namespace imports, and follows local
80
+ `const` bindings for router aliases, configurations, route entries, and preload
81
+ functions. It also checks local function declarations and statically known object
82
+ spreads. Shadowed imports and unrelated functions named `createSpaRouter` are
83
+ ignored. Shared callbacks are reported only once per file.
84
+
85
+ The rule checks annotation presence, not the annotation's meaning. TypeScript
86
+ checks compatibility with the generated route context and resource types. A type
87
+ assertion on the function or a variable type does not replace the required
88
+ annotation on its parameter.
89
+
90
+ Imported callbacks/configurations, mutable bindings, dynamic property keys,
91
+ factory calls, and cross-file re-exports are not resolved. Keep the configuration
92
+ and annotated preload callback in the same file for enforcement. There are no
93
+ options and no autofix: selecting and importing the generated context type is an
94
+ application decision.
package/dist/index.cjs ADDED
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ // package.json
4
+ var name = "@spa-kit/eslint-plugin";
5
+ var version = "0.1.0";
6
+
7
+ // src/rules/requirePreloadContext.ts
8
+ var wrappers = /* @__PURE__ */ new Set(["TSAsExpression", "TSSatisfiesExpression", "TSTypeAssertion", "TSNonNullExpression"]);
9
+ function unwrap(node) {
10
+ let current = node;
11
+ while (wrappers.has(current.type) && current.expression) {
12
+ current = current.expression;
13
+ }
14
+ return current;
15
+ }
16
+ function propertyName(node, computed) {
17
+ if (!computed && node.type === "Identifier") {
18
+ return node.name;
19
+ }
20
+ if (node.type === "Literal" && typeof node.value === "string") {
21
+ return node.value;
22
+ }
23
+ return void 0;
24
+ }
25
+ var requirePreloadContext = {
26
+ meta: {
27
+ type: "problem",
28
+ docs: {
29
+ description: "Require an explicit parameter type on route preloads to preserve render inference"
30
+ },
31
+ schema: [],
32
+ messages: {
33
+ missingContext: "Annotate the preload parameter with its generated route context type to preserve render inference."
34
+ }
35
+ },
36
+ create(context) {
37
+ const source = context.sourceCode;
38
+ const reported = /* @__PURE__ */ new Set();
39
+ function variableFor(node) {
40
+ if (node.type !== "Identifier") {
41
+ return void 0;
42
+ }
43
+ let scope = source.getScope(node);
44
+ while (scope) {
45
+ const variable = scope.set.get(node.name);
46
+ if (variable) {
47
+ return variable;
48
+ }
49
+ scope = scope.upper;
50
+ }
51
+ return void 0;
52
+ }
53
+ function resolve(node, seen = /* @__PURE__ */ new Set()) {
54
+ const value = unwrap(node);
55
+ if (seen.has(value) || value.type !== "Identifier") {
56
+ return value;
57
+ }
58
+ seen.add(value);
59
+ const variable = variableFor(value);
60
+ const definition = variable?.defs[0];
61
+ if (definition?.type === "FunctionName" && definition.node.type === "FunctionDeclaration") {
62
+ return definition.node;
63
+ }
64
+ if (definition?.type === "Variable" && definition.parent?.kind === "const" && definition.node.init) {
65
+ return resolve(definition.node.init, seen);
66
+ }
67
+ return value;
68
+ }
69
+ function isRouterImport(node, namespace) {
70
+ const definition = variableFor(node)?.defs[0];
71
+ if (definition?.type !== "ImportBinding" || definition.parent.source.value !== "@spa-kit/react-router") {
72
+ return false;
73
+ }
74
+ const specifier = definition.node;
75
+ if (namespace) {
76
+ return specifier.type === "ImportNamespaceSpecifier";
77
+ }
78
+ return specifier.type === "ImportSpecifier" && propertyName(specifier.imported, false) === "createSpaRouter";
79
+ }
80
+ function isRouterCall(callee) {
81
+ const value = resolve(callee);
82
+ if (value.type === "Identifier") {
83
+ return isRouterImport(value, false);
84
+ }
85
+ return value.type === "MemberExpression" && propertyName(value.property, value.computed) === "createSpaRouter" && isRouterImport(resolve(value.object), true);
86
+ }
87
+ function checkPreload(node) {
88
+ const callback = resolve(node);
89
+ if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression" && callback.type !== "FunctionDeclaration") {
90
+ return;
91
+ }
92
+ const parameter = callback.params.find((param) => param.type !== "Identifier" || param.name !== "this");
93
+ if (!parameter) {
94
+ return;
95
+ }
96
+ const input = parameter.type === "AssignmentPattern" ? parameter.left : parameter;
97
+ if (!input.typeAnnotation && !reported.has(input)) {
98
+ reported.add(input);
99
+ context.report({ node: input, messageId: "missingContext" });
100
+ }
101
+ }
102
+ function properties(node, seen = /* @__PURE__ */ new Set()) {
103
+ const object = resolve(node);
104
+ if (object.type !== "ObjectExpression" || seen.has(object)) {
105
+ return void 0;
106
+ }
107
+ const path = new Set(seen).add(object);
108
+ const result = /* @__PURE__ */ new Map();
109
+ for (const property of object.properties) {
110
+ if (property.type === "SpreadElement") {
111
+ const spread = properties(property.argument, path);
112
+ if (spread) {
113
+ for (const [name2, value] of spread) result.set(name2, value);
114
+ } else {
115
+ result.clear();
116
+ }
117
+ continue;
118
+ }
119
+ if (property.type === "Property") {
120
+ const name2 = propertyName(property.key, property.computed);
121
+ if (name2 === void 0) {
122
+ result.clear();
123
+ } else if (property.kind === "init") {
124
+ result.set(name2, property.value);
125
+ } else {
126
+ result.delete(name2);
127
+ }
128
+ }
129
+ }
130
+ return result;
131
+ }
132
+ return {
133
+ CallExpression(node) {
134
+ if (!isRouterCall(node.callee)) {
135
+ return;
136
+ }
137
+ const config = node.arguments[1];
138
+ if (!config || config.type === "SpreadElement") {
139
+ return;
140
+ }
141
+ for (const routeConfig of properties(config)?.values() ?? []) {
142
+ const callback = properties(routeConfig)?.get("preload");
143
+ if (callback) checkPreload(callback);
144
+ }
145
+ }
146
+ };
147
+ }
148
+ };
149
+
150
+ // src/index.ts
151
+ var plugin = {
152
+ meta: { name, version },
153
+ rules: {
154
+ "require-preload-context": requirePreloadContext
155
+ }
156
+ };
157
+ var index_default = plugin;
158
+
159
+ module.exports = index_default;
160
+ //# sourceMappingURL=index.cjs.map
161
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../package.json","../src/rules/requirePreloadContext.ts","../src/index.ts"],"names":["name"],"mappings":"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","file":"index.cjs","sourcesContent":["{\n \"name\": \"@spa-kit/eslint-plugin\",\n \"version\": \"0.1.0\",\n \"description\": \"Opt-in ESLint rules for spa-kit applications\",\n \"author\": \"Casey McGuire\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\",\n \"require\": \"./dist/index.cjs\"\n }\n },\n \"files\": [\n \"dist\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"typecheck\": \"tsc --noEmit\",\n \"typecheck:test\": \"tsc --project tsconfig.test.json\"\n },\n \"peerDependencies\": {\n \"eslint\": \"^9.0.0 || ^10.0.0\"\n },\n \"devDependencies\": {\n \"eslint\": \"^10.12.0\",\n \"@typescript-eslint/parser\": \"^8.71.1\"\n },\n \"engines\": {\n \"node\": \">=18.18.0\"\n },\n \"publishConfig\": {\n \"access\": \"public\"\n }\n}\n","import type { Rule } from \"eslint\";\n\n// ESLint's core node types describe JavaScript. These are the additional fields\n// used from the TypeScript ESTree parser; no type-checker services are needed.\ntype TypeScriptNode = Rule.Node & {\n expression?: Rule.Node;\n typeAnnotation?: unknown;\n};\n\nconst wrappers = new Set([\"TSAsExpression\", \"TSSatisfiesExpression\", \"TSTypeAssertion\", \"TSNonNullExpression\"]);\n\nfunction unwrap(node: Rule.Node): Rule.Node {\n let current = node as TypeScriptNode;\n while (wrappers.has(current.type) && current.expression) {\n current = current.expression as TypeScriptNode;\n }\n return current;\n}\n\nfunction propertyName(node: Rule.Node, computed: boolean): string | undefined {\n if (!computed && node.type === \"Identifier\") {\n return node.name;\n }\n if (node.type === \"Literal\" && typeof node.value === \"string\") {\n return node.value;\n }\n return undefined;\n}\n\n/** Require explicit generated context annotations on route preload inputs. */\nexport const requirePreloadContext: Rule.RuleModule = {\n meta: {\n type: \"problem\",\n docs: {\n description: \"Require an explicit parameter type on route preloads to preserve render inference\",\n },\n schema: [],\n messages: {\n missingContext: \"Annotate the preload parameter with its generated route context type to preserve render inference.\",\n },\n },\n create(context) {\n const source = context.sourceCode;\n const reported = new Set<Rule.Node>();\n\n function variableFor(node: Rule.Node) {\n if (node.type !== \"Identifier\") {\n return undefined;\n }\n let scope: ReturnType<typeof source.getScope> | null = source.getScope(node);\n while (scope) {\n const variable = scope.set.get(node.name);\n if (variable) {\n return variable;\n }\n scope = scope.upper;\n }\n return undefined;\n }\n\n // Follow immutable bindings in this file only. Imported values, mutable\n // bindings, and dynamically constructed configurations are intentionally opaque.\n function resolve(node: Rule.Node, seen = new Set<Rule.Node>()): Rule.Node {\n const value = unwrap(node);\n if (seen.has(value) || value.type !== \"Identifier\") {\n return value;\n }\n seen.add(value);\n const variable = variableFor(value);\n const definition = variable?.defs[0];\n if (definition?.type === \"FunctionName\" && definition.node.type === \"FunctionDeclaration\") {\n return definition.node as Rule.Node;\n }\n if (definition?.type === \"Variable\" && definition.parent?.kind === \"const\" && definition.node.init) {\n return resolve(definition.node.init as Rule.Node, seen);\n }\n return value;\n }\n\n function isRouterImport(node: Rule.Node, namespace: boolean): boolean {\n const definition = variableFor(node)?.defs[0];\n if (definition?.type !== \"ImportBinding\" || definition.parent.source.value !== \"@spa-kit/react-router\") {\n return false;\n }\n const specifier = definition.node;\n if (namespace) {\n return specifier.type === \"ImportNamespaceSpecifier\";\n }\n return specifier.type === \"ImportSpecifier\" &&\n propertyName(specifier.imported as Rule.Node, false) === \"createSpaRouter\";\n }\n\n function isRouterCall(callee: Rule.Node): boolean {\n const value = resolve(callee);\n if (value.type === \"Identifier\") {\n return isRouterImport(value, false);\n }\n return value.type === \"MemberExpression\" &&\n propertyName(value.property as Rule.Node, value.computed) === \"createSpaRouter\" &&\n isRouterImport(resolve(value.object as Rule.Node), true);\n }\n\n function checkPreload(node: Rule.Node) {\n const callback = resolve(node);\n if (callback.type !== \"ArrowFunctionExpression\" && callback.type !== \"FunctionExpression\" &&\n callback.type !== \"FunctionDeclaration\") {\n return;\n }\n // A TypeScript `this` parameter is erased and is not the route context.\n const parameter = callback.params.find((param) => param.type !== \"Identifier\" || param.name !== \"this\");\n if (!parameter) {\n return;\n }\n const input = (parameter.type === \"AssignmentPattern\" ? parameter.left : parameter) as TypeScriptNode;\n if (!input.typeAnnotation && !reported.has(input)) {\n reported.add(input);\n context.report({ node: input, messageId: \"missingContext\" });\n }\n }\n\n function properties(node: Rule.Node, seen = new Set<Rule.Node>()): Map<string, Rule.Node> | undefined {\n const object = resolve(node);\n if (object.type !== \"ObjectExpression\" || seen.has(object)) {\n return undefined;\n }\n const path = new Set(seen).add(object);\n const result = new Map<string, Rule.Node>();\n for (const property of object.properties) {\n if (property.type === \"SpreadElement\") {\n const spread = properties(property.argument as Rule.Node, path);\n if (spread) {\n for (const [name, value] of spread) result.set(name, value);\n } else {\n // An opaque spread may replace any preceding property.\n result.clear();\n }\n continue;\n }\n if (property.type === \"Property\") {\n const name = propertyName(property.key as Rule.Node, property.computed);\n if (name === undefined) {\n result.clear();\n } else if (property.kind === \"init\") {\n result.set(name, property.value as Rule.Node);\n } else {\n result.delete(name);\n }\n }\n }\n return result;\n }\n\n return {\n CallExpression(node) {\n if (!isRouterCall(node.callee as Rule.Node)) {\n return;\n }\n const config = node.arguments[1];\n if (!config || config.type === \"SpreadElement\") {\n return;\n }\n for (const routeConfig of properties(config as Rule.Node)?.values() ?? []) {\n const callback = properties(routeConfig)?.get(\"preload\");\n if (callback) checkPreload(callback);\n }\n },\n };\n },\n};\n","import type { ESLint } from \"eslint\";\nimport { name, version } from \"../package.json\";\nimport { requirePreloadContext } from \"./rules/requirePreloadContext.js\";\n\n/** Opt-in ESLint rules for spa-kit consumers. */\nconst plugin: ESLint.Plugin = {\n meta: { name, version },\n rules: {\n \"require-preload-context\": requirePreloadContext,\n },\n};\n\nexport default plugin;\n"]}
@@ -0,0 +1,6 @@
1
+ import { ESLint } from 'eslint';
2
+
3
+ /** Opt-in ESLint rules for spa-kit consumers. */
4
+ declare const plugin: ESLint.Plugin;
5
+
6
+ export { plugin as default };
@@ -0,0 +1,6 @@
1
+ import { ESLint } from 'eslint';
2
+
3
+ /** Opt-in ESLint rules for spa-kit consumers. */
4
+ declare const plugin: ESLint.Plugin;
5
+
6
+ export { plugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,159 @@
1
+ // package.json
2
+ var name = "@spa-kit/eslint-plugin";
3
+ var version = "0.1.0";
4
+
5
+ // src/rules/requirePreloadContext.ts
6
+ var wrappers = /* @__PURE__ */ new Set(["TSAsExpression", "TSSatisfiesExpression", "TSTypeAssertion", "TSNonNullExpression"]);
7
+ function unwrap(node) {
8
+ let current = node;
9
+ while (wrappers.has(current.type) && current.expression) {
10
+ current = current.expression;
11
+ }
12
+ return current;
13
+ }
14
+ function propertyName(node, computed) {
15
+ if (!computed && node.type === "Identifier") {
16
+ return node.name;
17
+ }
18
+ if (node.type === "Literal" && typeof node.value === "string") {
19
+ return node.value;
20
+ }
21
+ return void 0;
22
+ }
23
+ var requirePreloadContext = {
24
+ meta: {
25
+ type: "problem",
26
+ docs: {
27
+ description: "Require an explicit parameter type on route preloads to preserve render inference"
28
+ },
29
+ schema: [],
30
+ messages: {
31
+ missingContext: "Annotate the preload parameter with its generated route context type to preserve render inference."
32
+ }
33
+ },
34
+ create(context) {
35
+ const source = context.sourceCode;
36
+ const reported = /* @__PURE__ */ new Set();
37
+ function variableFor(node) {
38
+ if (node.type !== "Identifier") {
39
+ return void 0;
40
+ }
41
+ let scope = source.getScope(node);
42
+ while (scope) {
43
+ const variable = scope.set.get(node.name);
44
+ if (variable) {
45
+ return variable;
46
+ }
47
+ scope = scope.upper;
48
+ }
49
+ return void 0;
50
+ }
51
+ function resolve(node, seen = /* @__PURE__ */ new Set()) {
52
+ const value = unwrap(node);
53
+ if (seen.has(value) || value.type !== "Identifier") {
54
+ return value;
55
+ }
56
+ seen.add(value);
57
+ const variable = variableFor(value);
58
+ const definition = variable?.defs[0];
59
+ if (definition?.type === "FunctionName" && definition.node.type === "FunctionDeclaration") {
60
+ return definition.node;
61
+ }
62
+ if (definition?.type === "Variable" && definition.parent?.kind === "const" && definition.node.init) {
63
+ return resolve(definition.node.init, seen);
64
+ }
65
+ return value;
66
+ }
67
+ function isRouterImport(node, namespace) {
68
+ const definition = variableFor(node)?.defs[0];
69
+ if (definition?.type !== "ImportBinding" || definition.parent.source.value !== "@spa-kit/react-router") {
70
+ return false;
71
+ }
72
+ const specifier = definition.node;
73
+ if (namespace) {
74
+ return specifier.type === "ImportNamespaceSpecifier";
75
+ }
76
+ return specifier.type === "ImportSpecifier" && propertyName(specifier.imported, false) === "createSpaRouter";
77
+ }
78
+ function isRouterCall(callee) {
79
+ const value = resolve(callee);
80
+ if (value.type === "Identifier") {
81
+ return isRouterImport(value, false);
82
+ }
83
+ return value.type === "MemberExpression" && propertyName(value.property, value.computed) === "createSpaRouter" && isRouterImport(resolve(value.object), true);
84
+ }
85
+ function checkPreload(node) {
86
+ const callback = resolve(node);
87
+ if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression" && callback.type !== "FunctionDeclaration") {
88
+ return;
89
+ }
90
+ const parameter = callback.params.find((param) => param.type !== "Identifier" || param.name !== "this");
91
+ if (!parameter) {
92
+ return;
93
+ }
94
+ const input = parameter.type === "AssignmentPattern" ? parameter.left : parameter;
95
+ if (!input.typeAnnotation && !reported.has(input)) {
96
+ reported.add(input);
97
+ context.report({ node: input, messageId: "missingContext" });
98
+ }
99
+ }
100
+ function properties(node, seen = /* @__PURE__ */ new Set()) {
101
+ const object = resolve(node);
102
+ if (object.type !== "ObjectExpression" || seen.has(object)) {
103
+ return void 0;
104
+ }
105
+ const path = new Set(seen).add(object);
106
+ const result = /* @__PURE__ */ new Map();
107
+ for (const property of object.properties) {
108
+ if (property.type === "SpreadElement") {
109
+ const spread = properties(property.argument, path);
110
+ if (spread) {
111
+ for (const [name2, value] of spread) result.set(name2, value);
112
+ } else {
113
+ result.clear();
114
+ }
115
+ continue;
116
+ }
117
+ if (property.type === "Property") {
118
+ const name2 = propertyName(property.key, property.computed);
119
+ if (name2 === void 0) {
120
+ result.clear();
121
+ } else if (property.kind === "init") {
122
+ result.set(name2, property.value);
123
+ } else {
124
+ result.delete(name2);
125
+ }
126
+ }
127
+ }
128
+ return result;
129
+ }
130
+ return {
131
+ CallExpression(node) {
132
+ if (!isRouterCall(node.callee)) {
133
+ return;
134
+ }
135
+ const config = node.arguments[1];
136
+ if (!config || config.type === "SpreadElement") {
137
+ return;
138
+ }
139
+ for (const routeConfig of properties(config)?.values() ?? []) {
140
+ const callback = properties(routeConfig)?.get("preload");
141
+ if (callback) checkPreload(callback);
142
+ }
143
+ }
144
+ };
145
+ }
146
+ };
147
+
148
+ // src/index.ts
149
+ var plugin = {
150
+ meta: { name, version },
151
+ rules: {
152
+ "require-preload-context": requirePreloadContext
153
+ }
154
+ };
155
+ var index_default = plugin;
156
+
157
+ export { index_default as default };
158
+ //# sourceMappingURL=index.js.map
159
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../package.json","../src/rules/requirePreloadContext.ts","../src/index.ts"],"names":["name"],"mappings":";AACE,IAAA,IAAA,GAAQ,wBAAA;AACR,IAAA,OAAA,GAAW,OAAA;;;ACOb,IAAM,QAAA,uBAAe,GAAA,CAAI,CAAC,kBAAkB,uBAAA,EAAyB,iBAAA,EAAmB,qBAAqB,CAAC,CAAA;AAE9G,SAAS,OAAO,IAAA,EAA4B;AAC1C,EAAA,IAAI,OAAA,GAAU,IAAA;AACd,EAAA,OAAO,SAAS,GAAA,CAAI,OAAA,CAAQ,IAAI,CAAA,IAAK,QAAQ,UAAA,EAAY;AACvD,IAAA,OAAA,GAAU,OAAA,CAAQ,UAAA;AAAA,EACpB;AACA,EAAA,OAAO,OAAA;AACT;AAEA,SAAS,YAAA,CAAa,MAAiB,QAAA,EAAuC;AAC5E,EAAA,IAAI,CAAC,QAAA,IAAY,IAAA,CAAK,IAAA,KAAS,YAAA,EAAc;AAC3C,IAAA,OAAO,IAAA,CAAK,IAAA;AAAA,EACd;AACA,EAAA,IAAI,KAAK,IAAA,KAAS,SAAA,IAAa,OAAO,IAAA,CAAK,UAAU,QAAA,EAAU;AAC7D,IAAA,OAAO,IAAA,CAAK,KAAA;AAAA,EACd;AACA,EAAA,OAAO,MAAA;AACT;AAGO,IAAM,qBAAA,GAAyC;AAAA,EACpD,IAAA,EAAM;AAAA,IACJ,IAAA,EAAM,SAAA;AAAA,IACN,IAAA,EAAM;AAAA,MACJ,WAAA,EAAa;AAAA,KACf;AAAA,IACA,QAAQ,EAAC;AAAA,IACT,QAAA,EAAU;AAAA,MACR,cAAA,EAAgB;AAAA;AAClB,GACF;AAAA,EACA,OAAO,OAAA,EAAS;AACd,IAAA,MAAM,SAAS,OAAA,CAAQ,UAAA;AACvB,IAAA,MAAM,QAAA,uBAAe,GAAA,EAAe;AAEpC,IAAA,SAAS,YAAY,IAAA,EAAiB;AACpC,MAAA,IAAI,IAAA,CAAK,SAAS,YAAA,EAAc;AAC9B,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,IAAI,KAAA,GAAmD,MAAA,CAAO,QAAA,CAAS,IAAI,CAAA;AAC3E,MAAA,OAAO,KAAA,EAAO;AACZ,QAAA,MAAM,QAAA,GAAW,KAAA,CAAM,GAAA,CAAI,GAAA,CAAI,KAAK,IAAI,CAAA;AACxC,QAAA,IAAI,QAAA,EAAU;AACZ,UAAA,OAAO,QAAA;AAAA,QACT;AACA,QAAA,KAAA,GAAQ,KAAA,CAAM,KAAA;AAAA,MAChB;AACA,MAAA,OAAO,MAAA;AAAA,IACT;AAIA,IAAA,SAAS,OAAA,CAAQ,IAAA,EAAiB,IAAA,mBAAO,IAAI,KAAe,EAAc;AACxE,MAAA,MAAM,KAAA,GAAQ,OAAO,IAAI,CAAA;AACzB,MAAA,IAAI,KAAK,GAAA,CAAI,KAAK,CAAA,IAAK,KAAA,CAAM,SAAS,YAAA,EAAc;AAClD,QAAA,OAAO,KAAA;AAAA,MACT;AACA,MAAA,IAAA,CAAK,IAAI,KAAK,CAAA;AACd,MAAA,MAAM,QAAA,GAAW,YAAY,KAAK,CAAA;AAClC,MAAA,MAAM,UAAA,GAAa,QAAA,EAAU,IAAA,CAAK,CAAC,CAAA;AACnC,MAAA,IAAI,YAAY,IAAA,KAAS,cAAA,IAAkB,UAAA,CAAW,IAAA,CAAK,SAAS,qBAAA,EAAuB;AACzF,QAAA,OAAO,UAAA,CAAW,IAAA;AAAA,MACpB;AACA,MAAA,IAAI,UAAA,EAAY,SAAS,UAAA,IAAc,UAAA,CAAW,QAAQ,IAAA,KAAS,OAAA,IAAW,UAAA,CAAW,IAAA,CAAK,IAAA,EAAM;AAClG,QAAA,OAAO,OAAA,CAAQ,UAAA,CAAW,IAAA,CAAK,IAAA,EAAmB,IAAI,CAAA;AAAA,MACxD;AACA,MAAA,OAAO,KAAA;AAAA,IACT;AAEA,IAAA,SAAS,cAAA,CAAe,MAAiB,SAAA,EAA6B;AACpE,MAAA,MAAM,UAAA,GAAa,WAAA,CAAY,IAAI,CAAA,EAAG,KAAK,CAAC,CAAA;AAC5C,MAAA,IAAI,YAAY,IAAA,KAAS,eAAA,IAAmB,WAAW,MAAA,CAAO,MAAA,CAAO,UAAU,uBAAA,EAAyB;AACtG,QAAA,OAAO,KAAA;AAAA,MACT;AACA,MAAA,MAAM,YAAY,UAAA,CAAW,IAAA;AAC7B,MAAA,IAAI,SAAA,EAAW;AACb,QAAA,OAAO,UAAU,IAAA,KAAS,0BAAA;AAAA,MAC5B;AACA,MAAA,OAAO,UAAU,IAAA,KAAS,iBAAA,IACxB,aAAa,SAAA,CAAU,QAAA,EAAuB,KAAK,CAAA,KAAM,iBAAA;AAAA,IAC7D;AAEA,IAAA,SAAS,aAAa,MAAA,EAA4B;AAChD,MAAA,MAAM,KAAA,GAAQ,QAAQ,MAAM,CAAA;AAC5B,MAAA,IAAI,KAAA,CAAM,SAAS,YAAA,EAAc;AAC/B,QAAA,OAAO,cAAA,CAAe,OAAO,KAAK,CAAA;AAAA,MACpC;AACA,MAAA,OAAO,KAAA,CAAM,IAAA,KAAS,kBAAA,IACpB,YAAA,CAAa,MAAM,QAAA,EAAuB,KAAA,CAAM,QAAQ,CAAA,KAAM,qBAC9D,cAAA,CAAe,OAAA,CAAQ,KAAA,CAAM,MAAmB,GAAG,IAAI,CAAA;AAAA,IAC3D;AAEA,IAAA,SAAS,aAAa,IAAA,EAAiB;AACrC,MAAA,MAAM,QAAA,GAAW,QAAQ,IAAI,CAAA;AAC7B,MAAA,IAAI,QAAA,CAAS,SAAS,yBAAA,IAA6B,QAAA,CAAS,SAAS,oBAAA,IACjE,QAAA,CAAS,SAAS,qBAAA,EAAuB;AAC3C,QAAA;AAAA,MACF;AAEA,MAAA,MAAM,SAAA,GAAY,QAAA,CAAS,MAAA,CAAO,IAAA,CAAK,CAAC,KAAA,KAAU,KAAA,CAAM,IAAA,KAAS,YAAA,IAAgB,KAAA,CAAM,IAAA,KAAS,MAAM,CAAA;AACtG,MAAA,IAAI,CAAC,SAAA,EAAW;AACd,QAAA;AAAA,MACF;AACA,MAAA,MAAM,KAAA,GAAS,SAAA,CAAU,IAAA,KAAS,mBAAA,GAAsB,UAAU,IAAA,GAAO,SAAA;AACzE,MAAA,IAAI,CAAC,KAAA,CAAM,cAAA,IAAkB,CAAC,QAAA,CAAS,GAAA,CAAI,KAAK,CAAA,EAAG;AACjD,QAAA,QAAA,CAAS,IAAI,KAAK,CAAA;AAClB,QAAA,OAAA,CAAQ,OAAO,EAAE,IAAA,EAAM,KAAA,EAAO,SAAA,EAAW,kBAAkB,CAAA;AAAA,MAC7D;AAAA,IACF;AAEA,IAAA,SAAS,UAAA,CAAW,IAAA,EAAiB,IAAA,mBAAO,IAAI,KAAe,EAAuC;AACpG,MAAA,MAAM,MAAA,GAAS,QAAQ,IAAI,CAAA;AAC3B,MAAA,IAAI,OAAO,IAAA,KAAS,kBAAA,IAAsB,IAAA,CAAK,GAAA,CAAI,MAAM,CAAA,EAAG;AAC1D,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,MAAM,OAAO,IAAI,GAAA,CAAI,IAAI,CAAA,CAAE,IAAI,MAAM,CAAA;AACrC,MAAA,MAAM,MAAA,uBAAa,GAAA,EAAuB;AAC1C,MAAA,KAAA,MAAW,QAAA,IAAY,OAAO,UAAA,EAAY;AACxC,QAAA,IAAI,QAAA,CAAS,SAAS,eAAA,EAAiB;AACrC,UAAA,MAAM,MAAA,GAAS,UAAA,CAAW,QAAA,CAAS,QAAA,EAAuB,IAAI,CAAA;AAC9D,UAAA,IAAI,MAAA,EAAQ;AACV,YAAA,KAAA,MAAW,CAACA,OAAM,KAAK,CAAA,IAAK,QAAQ,MAAA,CAAO,GAAA,CAAIA,OAAM,KAAK,CAAA;AAAA,UAC5D,CAAA,MAAO;AAEL,YAAA,MAAA,CAAO,KAAA,EAAM;AAAA,UACf;AACA,UAAA;AAAA,QACF;AACA,QAAA,IAAI,QAAA,CAAS,SAAS,UAAA,EAAY;AAChC,UAAA,MAAMA,KAAAA,GAAO,YAAA,CAAa,QAAA,CAAS,GAAA,EAAkB,SAAS,QAAQ,CAAA;AACtE,UAAA,IAAIA,UAAS,MAAA,EAAW;AACtB,YAAA,MAAA,CAAO,KAAA,EAAM;AAAA,UACf,CAAA,MAAA,IAAW,QAAA,CAAS,IAAA,KAAS,MAAA,EAAQ;AACnC,YAAA,MAAA,CAAO,GAAA,CAAIA,KAAAA,EAAM,QAAA,CAAS,KAAkB,CAAA;AAAA,UAC9C,CAAA,MAAO;AACL,YAAA,MAAA,CAAO,OAAOA,KAAI,CAAA;AAAA,UACpB;AAAA,QACF;AAAA,MACF;AACA,MAAA,OAAO,MAAA;AAAA,IACT;AAEA,IAAA,OAAO;AAAA,MACL,eAAe,IAAA,EAAM;AACnB,QAAA,IAAI,CAAC,YAAA,CAAa,IAAA,CAAK,MAAmB,CAAA,EAAG;AAC3C,UAAA;AAAA,QACF;AACA,QAAA,MAAM,MAAA,GAAS,IAAA,CAAK,SAAA,CAAU,CAAC,CAAA;AAC/B,QAAA,IAAI,CAAC,MAAA,IAAU,MAAA,CAAO,IAAA,KAAS,eAAA,EAAiB;AAC9C,UAAA;AAAA,QACF;AACA,QAAA,KAAA,MAAW,eAAe,UAAA,CAAW,MAAmB,GAAG,MAAA,EAAO,IAAK,EAAC,EAAG;AACzE,UAAA,MAAM,QAAA,GAAW,UAAA,CAAW,WAAW,CAAA,EAAG,IAAI,SAAS,CAAA;AACvD,UAAA,IAAI,QAAA,eAAuB,QAAQ,CAAA;AAAA,QACrC;AAAA,MACF;AAAA,KACF;AAAA,EACF;AACF,CAAA;;;ACnKA,IAAM,MAAA,GAAwB;AAAA,EAC5B,IAAA,EAAM,EAAE,IAAA,EAAM,OAAA,EAAQ;AAAA,EACtB,KAAA,EAAO;AAAA,IACL,yBAAA,EAA2B;AAAA;AAE/B,CAAA;AAEA,IAAO,aAAA,GAAQ","file":"index.js","sourcesContent":["{\n \"name\": \"@spa-kit/eslint-plugin\",\n \"version\": \"0.1.0\",\n \"description\": \"Opt-in ESLint rules for spa-kit applications\",\n \"author\": \"Casey McGuire\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\",\n \"require\": \"./dist/index.cjs\"\n }\n },\n \"files\": [\n \"dist\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"typecheck\": \"tsc --noEmit\",\n \"typecheck:test\": \"tsc --project tsconfig.test.json\"\n },\n \"peerDependencies\": {\n \"eslint\": \"^9.0.0 || ^10.0.0\"\n },\n \"devDependencies\": {\n \"eslint\": \"^10.12.0\",\n \"@typescript-eslint/parser\": \"^8.71.1\"\n },\n \"engines\": {\n \"node\": \">=18.18.0\"\n },\n \"publishConfig\": {\n \"access\": \"public\"\n }\n}\n","import type { Rule } from \"eslint\";\n\n// ESLint's core node types describe JavaScript. These are the additional fields\n// used from the TypeScript ESTree parser; no type-checker services are needed.\ntype TypeScriptNode = Rule.Node & {\n expression?: Rule.Node;\n typeAnnotation?: unknown;\n};\n\nconst wrappers = new Set([\"TSAsExpression\", \"TSSatisfiesExpression\", \"TSTypeAssertion\", \"TSNonNullExpression\"]);\n\nfunction unwrap(node: Rule.Node): Rule.Node {\n let current = node as TypeScriptNode;\n while (wrappers.has(current.type) && current.expression) {\n current = current.expression as TypeScriptNode;\n }\n return current;\n}\n\nfunction propertyName(node: Rule.Node, computed: boolean): string | undefined {\n if (!computed && node.type === \"Identifier\") {\n return node.name;\n }\n if (node.type === \"Literal\" && typeof node.value === \"string\") {\n return node.value;\n }\n return undefined;\n}\n\n/** Require explicit generated context annotations on route preload inputs. */\nexport const requirePreloadContext: Rule.RuleModule = {\n meta: {\n type: \"problem\",\n docs: {\n description: \"Require an explicit parameter type on route preloads to preserve render inference\",\n },\n schema: [],\n messages: {\n missingContext: \"Annotate the preload parameter with its generated route context type to preserve render inference.\",\n },\n },\n create(context) {\n const source = context.sourceCode;\n const reported = new Set<Rule.Node>();\n\n function variableFor(node: Rule.Node) {\n if (node.type !== \"Identifier\") {\n return undefined;\n }\n let scope: ReturnType<typeof source.getScope> | null = source.getScope(node);\n while (scope) {\n const variable = scope.set.get(node.name);\n if (variable) {\n return variable;\n }\n scope = scope.upper;\n }\n return undefined;\n }\n\n // Follow immutable bindings in this file only. Imported values, mutable\n // bindings, and dynamically constructed configurations are intentionally opaque.\n function resolve(node: Rule.Node, seen = new Set<Rule.Node>()): Rule.Node {\n const value = unwrap(node);\n if (seen.has(value) || value.type !== \"Identifier\") {\n return value;\n }\n seen.add(value);\n const variable = variableFor(value);\n const definition = variable?.defs[0];\n if (definition?.type === \"FunctionName\" && definition.node.type === \"FunctionDeclaration\") {\n return definition.node as Rule.Node;\n }\n if (definition?.type === \"Variable\" && definition.parent?.kind === \"const\" && definition.node.init) {\n return resolve(definition.node.init as Rule.Node, seen);\n }\n return value;\n }\n\n function isRouterImport(node: Rule.Node, namespace: boolean): boolean {\n const definition = variableFor(node)?.defs[0];\n if (definition?.type !== \"ImportBinding\" || definition.parent.source.value !== \"@spa-kit/react-router\") {\n return false;\n }\n const specifier = definition.node;\n if (namespace) {\n return specifier.type === \"ImportNamespaceSpecifier\";\n }\n return specifier.type === \"ImportSpecifier\" &&\n propertyName(specifier.imported as Rule.Node, false) === \"createSpaRouter\";\n }\n\n function isRouterCall(callee: Rule.Node): boolean {\n const value = resolve(callee);\n if (value.type === \"Identifier\") {\n return isRouterImport(value, false);\n }\n return value.type === \"MemberExpression\" &&\n propertyName(value.property as Rule.Node, value.computed) === \"createSpaRouter\" &&\n isRouterImport(resolve(value.object as Rule.Node), true);\n }\n\n function checkPreload(node: Rule.Node) {\n const callback = resolve(node);\n if (callback.type !== \"ArrowFunctionExpression\" && callback.type !== \"FunctionExpression\" &&\n callback.type !== \"FunctionDeclaration\") {\n return;\n }\n // A TypeScript `this` parameter is erased and is not the route context.\n const parameter = callback.params.find((param) => param.type !== \"Identifier\" || param.name !== \"this\");\n if (!parameter) {\n return;\n }\n const input = (parameter.type === \"AssignmentPattern\" ? parameter.left : parameter) as TypeScriptNode;\n if (!input.typeAnnotation && !reported.has(input)) {\n reported.add(input);\n context.report({ node: input, messageId: \"missingContext\" });\n }\n }\n\n function properties(node: Rule.Node, seen = new Set<Rule.Node>()): Map<string, Rule.Node> | undefined {\n const object = resolve(node);\n if (object.type !== \"ObjectExpression\" || seen.has(object)) {\n return undefined;\n }\n const path = new Set(seen).add(object);\n const result = new Map<string, Rule.Node>();\n for (const property of object.properties) {\n if (property.type === \"SpreadElement\") {\n const spread = properties(property.argument as Rule.Node, path);\n if (spread) {\n for (const [name, value] of spread) result.set(name, value);\n } else {\n // An opaque spread may replace any preceding property.\n result.clear();\n }\n continue;\n }\n if (property.type === \"Property\") {\n const name = propertyName(property.key as Rule.Node, property.computed);\n if (name === undefined) {\n result.clear();\n } else if (property.kind === \"init\") {\n result.set(name, property.value as Rule.Node);\n } else {\n result.delete(name);\n }\n }\n }\n return result;\n }\n\n return {\n CallExpression(node) {\n if (!isRouterCall(node.callee as Rule.Node)) {\n return;\n }\n const config = node.arguments[1];\n if (!config || config.type === \"SpreadElement\") {\n return;\n }\n for (const routeConfig of properties(config as Rule.Node)?.values() ?? []) {\n const callback = properties(routeConfig)?.get(\"preload\");\n if (callback) checkPreload(callback);\n }\n },\n };\n },\n};\n","import type { ESLint } from \"eslint\";\nimport { name, version } from \"../package.json\";\nimport { requirePreloadContext } from \"./rules/requirePreloadContext.js\";\n\n/** Opt-in ESLint rules for spa-kit consumers. */\nconst plugin: ESLint.Plugin = {\n meta: { name, version },\n rules: {\n \"require-preload-context\": requirePreloadContext,\n },\n};\n\nexport default plugin;\n"]}
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@spa-kit/eslint-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Opt-in ESLint rules for spa-kit applications",
5
+ "author": "Casey McGuire",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./dist/index.cjs",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "require": "./dist/index.cjs"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "dev": "tsup --watch",
24
+ "typecheck": "tsc --noEmit",
25
+ "typecheck:test": "tsc --project tsconfig.test.json"
26
+ },
27
+ "peerDependencies": {
28
+ "eslint": "^9.0.0 || ^10.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "eslint": "^10.12.0",
32
+ "@typescript-eslint/parser": "^8.71.1"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.18.0"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }