@ethlete/eslint-plugin 1.0.0-next.18 → 1.0.0-next.19

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.19
4
+
5
+ ### Minor Changes
6
+
7
+ - [#3055](https://github.com/ethlete-io/ethdk/pull/3055) [`2fcb8e3`](https://github.com/ethlete-io/ethdk/commit/2fcb8e3799169956571534b75fdc152acc983877) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `require-form-submit` (a form handles its own submission, a submit control reaches a form) plus the opt-in `no-cdk-import` and `no-legacy-query-import`, which name each legacy symbol's successor in the message.
8
+
3
9
  ## 1.0.0-next.18
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.18",
3
+ "version": "1.0.0-next.19",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -422,6 +422,8 @@ const recommendedTemplate = {
422
422
  // (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
423
423
  // which a template rule cannot verify)
424
424
  'ethlete/prefer-static-boolean-properties': 'warn',
425
+ // A form must handle its own submission, and a type="submit" control must reach a form
426
+ 'ethlete/require-form-submit': 'error',
425
427
  },
426
428
  };
427
429
 
package/src/index.js CHANGED
@@ -55,6 +55,9 @@ const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
55
55
  const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-properties');
56
56
  const noImpureTopLevelProvider = require('./rules/no-impure-top-level-provider');
57
57
  const noLegacyPrepareWithoutInjector = require('./rules/no-legacy-prepare-without-injector');
58
+ const requireFormSubmit = require('./rules/require-form-submit');
59
+ const noCdkImport = require('./rules/no-cdk-import');
60
+ const noLegacyQueryImport = require('./rules/no-legacy-query-import');
58
61
  const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
59
62
 
60
63
  /** @type {import('eslint').ESLint.Plugin} */
@@ -118,6 +121,9 @@ const plugin = {
118
121
  'prefer-present-tense-output': preferPresentTenseOutput,
119
122
  'prefer-static-boolean-properties': preferStaticBooleanProperties,
120
123
  'no-impure-top-level-provider': noImpureTopLevelProvider,
124
+ 'require-form-submit': requireFormSubmit,
125
+ 'no-cdk-import': noCdkImport,
126
+ 'no-legacy-query-import': noLegacyQueryImport,
121
127
  },
122
128
  };
123
129
 
@@ -0,0 +1,154 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const { createRequire } = require('node:module');
5
+ const path = require('node:path');
6
+ const fs = require('node:fs');
7
+
8
+ /**
9
+ * Disallows importing from `@ethlete/cdk`, the maintenance-mode predecessor of `@ethlete/components`,
10
+ * and names the successor of each imported symbol in the message.
11
+ *
12
+ * import { ButtonComponent } from '@ethlete/cdk';
13
+ * → `ButtonComponent` is legacy @ethlete/cdk. Use `ButtonComponent` from @ethlete/components
14
+ * (https://…/components/button): a real button system - variant, size and color inputs plus
15
+ * theming instead of CSS-only classes.
16
+ *
17
+ * The successors come from `migration-map.json`, which ships inside the `@ethlete/cdk` package - so
18
+ * the advice is always the installed version's, and this rule holds no copy of it. Without the map on
19
+ * disk the rule still reports, pointing at the migration guide instead of a specific symbol.
20
+ *
21
+ * Off by default: it is only useful once an app has decided to leave the cdk behind.
22
+ */
23
+
24
+ const CDK_PACKAGE = '@ethlete/cdk';
25
+ const MIGRATION_MAP = '@ethlete/cdk/migration-map.json';
26
+ const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
27
+
28
+ /** @type {Map<string, Record<string, { kind: string, to?: string, package?: string, docs?: string, note?: string }> | null>} */
29
+ const mapCache = new Map();
30
+
31
+ /**
32
+ * @param {string | undefined} mapPath
33
+ * @param {string} cwd
34
+ */
35
+ const loadMigrationMap = (mapPath, cwd) => {
36
+ const cacheKey = mapPath ? path.resolve(cwd, mapPath) : MIGRATION_MAP;
37
+
38
+ if (mapCache.has(cacheKey)) return mapCache.get(cacheKey) ?? null;
39
+
40
+ let map;
41
+
42
+ try {
43
+ map = mapPath
44
+ ? JSON.parse(fs.readFileSync(cacheKey, 'utf8'))
45
+ : createRequire(path.join(cwd, 'noop.js'))(MIGRATION_MAP);
46
+ } catch {
47
+ map = null;
48
+ }
49
+
50
+ mapCache.set(cacheKey, map);
51
+
52
+ return map;
53
+ };
54
+
55
+ /** @param {any} specifier */
56
+ const importedName = (specifier) => {
57
+ if (specifier.type !== 'ImportSpecifier') return null;
58
+
59
+ return specifier.imported.name ?? specifier.imported.value ?? null;
60
+ };
61
+
62
+ /** @type {import('eslint').Rule.RuleModule} */
63
+ const noCdkImport = {
64
+ meta: {
65
+ type: 'suggestion',
66
+ docs: {
67
+ description: 'Disallow importing from the maintenance-mode @ethlete/cdk; name the @ethlete/components successor.',
68
+ },
69
+ schema: [
70
+ {
71
+ type: 'object',
72
+ properties: {
73
+ /** Where to read the migration map from, when `@ethlete/cdk` is not resolvable from the linted project. */
74
+ migrationMapPath: { type: 'string' },
75
+ /** Base URL the migration map's doc paths are appended to. */
76
+ docsBaseUrl: { type: 'string' },
77
+ },
78
+ additionalProperties: false,
79
+ },
80
+ ],
81
+ messages: {
82
+ successor: '`{{ name }}` is legacy {{ cdk }}. Use `{{ to }}` from {{ package }} instead ({{ docs }}).{{ note }}',
83
+ noSuccessor: '`{{ name }}` is legacy {{ cdk }} and has no successor{{ note }} - see {{ docs }}.',
84
+ unmapped: '`{{ name }}` is legacy {{ cdk }}. Move it to @ethlete/components - see {{ docs }}.',
85
+ module: 'Do not depend on the legacy {{ cdk }} - move to @ethlete/components, see {{ docs }}.',
86
+ },
87
+ },
88
+ create(context) {
89
+ const options = context.options[0] ?? {};
90
+ const docsBaseUrl = (options.docsBaseUrl ?? DEFAULT_DOCS_BASE_URL).replace(/\/$/, '');
91
+ const migrationDocs = `${docsBaseUrl}/cdk/migration`;
92
+ const map = loadMigrationMap(options.migrationMapPath, context.cwd);
93
+
94
+ /** @param {string} name */
95
+ const entryFor = (name) => map?.[name] ?? null;
96
+
97
+ return {
98
+ ImportDeclaration(node) {
99
+ const declaration = /** @type {any} */ (node);
100
+ const source = declaration.source.value;
101
+
102
+ if (typeof source !== 'string' || (source !== CDK_PACKAGE && !source.startsWith(`${CDK_PACKAGE}/`))) return;
103
+
104
+ const named = declaration.specifiers.filter(/** @param {any} s */ (s) => importedName(s));
105
+
106
+ if (!named.length) {
107
+ context.report({ node, messageId: 'module', data: { cdk: CDK_PACKAGE, docs: migrationDocs } });
108
+
109
+ return;
110
+ }
111
+
112
+ for (const specifier of named) {
113
+ const name = /** @type {string} */ (importedName(specifier));
114
+ const entry = entryFor(name);
115
+
116
+ if (!entry) {
117
+ context.report({
118
+ node: specifier,
119
+ messageId: 'unmapped',
120
+ data: { name, cdk: CDK_PACKAGE, docs: migrationDocs },
121
+ });
122
+
123
+ continue;
124
+ }
125
+
126
+ if (!entry.to) {
127
+ context.report({
128
+ node: specifier,
129
+ messageId: 'noSuccessor',
130
+ data: { name, cdk: CDK_PACKAGE, note: entry.note ? ` (${entry.note})` : '', docs: migrationDocs },
131
+ });
132
+
133
+ continue;
134
+ }
135
+
136
+ context.report({
137
+ node: specifier,
138
+ messageId: 'successor',
139
+ data: {
140
+ name,
141
+ cdk: CDK_PACKAGE,
142
+ to: entry.to,
143
+ package: entry.package ?? '@ethlete/components',
144
+ docs: entry.docs ? `${docsBaseUrl}${entry.docs}` : migrationDocs,
145
+ note: entry.note ? ` ${entry.note}.` : '',
146
+ },
147
+ });
148
+ }
149
+ },
150
+ };
151
+ },
152
+ };
153
+
154
+ module.exports = noCdkImport;
@@ -0,0 +1,136 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows importing the legacy (v2) query system from `@ethlete/query`, and names the current-system
6
+ * API in the message.
7
+ *
8
+ * import { V2QueryClient, filterSuccess } from '@ethlete/query';
9
+ * → `V2QueryClient` is the legacy (v2) query system. Use `createQueryClient` instead (…/query/queries).
10
+ * → `filterSuccess` … Use `query.response()` instead - the query is already signals (…/query/queries).
11
+ *
12
+ * Two things are matched: every `V2`/`AnyV2`-prefixed export - the prefix the library gives the legacy
13
+ * system's half of a colliding name - and the legacy APIs that never collided, which carry their
14
+ * successor from the migration guide. `createLegacyQueryCreator` is deliberately **not** matched: it is
15
+ * the sanctioned interop seam a migration leans on until its call sites are converted.
16
+ *
17
+ * Off by default, and deliberately not type-aware: it names successors rather than repeating the
18
+ * `@deprecated` tag every legacy export already carries. For the whole deprecated surface (including
19
+ * the types this rule leaves alone), enable `@typescript-eslint/no-deprecated` alongside it.
20
+ */
21
+
22
+ const QUERY_PACKAGE = '@ethlete/query';
23
+ const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
24
+
25
+ /**
26
+ * The legacy APIs whose names never collided with the current system, and what the migration guide
27
+ * (`/query/legacy#migrating-to-the-current-system`) maps each one to.
28
+ *
29
+ * @type {Record<string, { to: string, docs: string }>}
30
+ */
31
+ const LEGACY_SYMBOLS = {
32
+ def: { to: 'the type parameter of a current creator - `createGetQuery(client)<TArgs>(route)`', docs: '/query/http' },
33
+ BasicAuthProvider: { to: 'no equivalent - the current system authenticates through `createBearerAuthProvider`', docs: '/query/auth' }, // prettier-ignore
34
+ CustomHeaderAuthProvider: { to: '`headers` on `createQueryClient`, which re-reads a function form per request', docs: '/query/queries#the-query-client' }, // prettier-ignore
35
+ EntityStore: { to: 'nothing directly - caching dedupes by request, and shared state derives from signals', docs: '/query/caching' }, // prettier-ignore
36
+ InfinityQuery: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
37
+ InfinityQueryDirective: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
38
+ InfinityQueryTriggerDirective: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
39
+ createInfinityQueryConfig: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
40
+ QueryDirective: { to: "the query's own signals, read directly in the template", docs: '/query/migrating-from-v2#templates-read-signals-not-directives' }, // prettier-ignore
41
+ filterSuccess: { to: '`query.response()`, or `query.response.asObservable()` where a stream is needed', docs: '/query/queries#the-query-object' }, // prettier-ignore
42
+ filterFailure: { to: '`query.error()`, or `query.error.asObservable()` where a stream is needed', docs: '/query/queries#the-query-object' }, // prettier-ignore
43
+ switchQueryState: { to: "the query's own signals - each one is an `ObservableSignal`", docs: '/query/queries#the-query-object' }, // prettier-ignore
44
+ takeUntilResponse: { to: "the query's own signals - each one is an `ObservableSignal`", docs: '/query/queries#the-query-object' }, // prettier-ignore
45
+ toQuerySignal: { to: 'the query object itself - it is already signals', docs: '/query/queries#the-query-object' },
46
+ queryStateSignal: { to: 'the query object itself - it is already signals', docs: '/query/queries#the-query-object' }, // prettier-ignore
47
+ queryStateResponseSignal: { to: '`query.response()`', docs: '/query/queries#the-query-object' },
48
+ queryStateErrorSignal: { to: '`query.error()`', docs: '/query/queries#the-query-object' },
49
+ queryStateLoadingSignal: { to: '`query.loading()`', docs: '/query/queries#the-query-object' },
50
+ validateWithV2Query: { to: '`validateWithQuery`', docs: '/query/errors#validating-against-the-server-as-the-user-types' }, // prettier-ignore
51
+ provideQueryClientForDevtools: { to: '`provideQueryDevtools()` from `@ethlete/query-devtools`, which registers every client at once', docs: '/query-devtools/' }, // prettier-ignore
52
+ };
53
+
54
+ /** The current-system counterparts of the names the legacy system had to give up its half of. */
55
+ const V2_SUCCESSORS = {
56
+ V2QueryClient: { to: '`createQueryClient`', docs: '/query/queries#the-query-client' },
57
+ V2QueryClientConfig: { to: 'the config of `createQueryClient`', docs: '/query/queries#the-query-client' },
58
+ V2QueryCreator: { to: '`createGetQuery` and its siblings', docs: '/query/http' },
59
+ AnyV2QueryCreator: { to: '`createGetQuery` and its siblings', docs: '/query/http' },
60
+ V2BearerAuthProvider: { to: '`createBearerAuthProvider` plus the secure creator templates', docs: '/query/auth' },
61
+ };
62
+
63
+ /** @param {any} specifier */
64
+ const importedName = (specifier) => {
65
+ if (specifier.type !== 'ImportSpecifier') return null;
66
+
67
+ return specifier.imported.name ?? specifier.imported.value ?? null;
68
+ };
69
+
70
+ /** @param {string} name */
71
+ const isV2Symbol = (name) => /^(V2|AnyV2)[A-Z]/.test(name);
72
+
73
+ /** @type {import('eslint').Rule.RuleModule} */
74
+ const noLegacyQueryImport = {
75
+ meta: {
76
+ type: 'suggestion',
77
+ docs: {
78
+ description: 'Disallow importing the legacy (v2) query system; name the current-system API instead.',
79
+ },
80
+ schema: [
81
+ {
82
+ type: 'object',
83
+ properties: {
84
+ /** Base URL the guide paths in the messages are appended to. */
85
+ docsBaseUrl: { type: 'string' },
86
+ },
87
+ additionalProperties: false,
88
+ },
89
+ ],
90
+ messages: {
91
+ successor: '`{{ name }}` is the legacy (v2) query system. Use {{ to }} instead ({{ docs }}).',
92
+ legacySystem:
93
+ '`{{ name }}` is the legacy (v2) query system. Migrate to the current one - see {{ docs }}, and run `nx g @ethlete/query:migrate-to-query-v3` for the mechanical parts.',
94
+ },
95
+ },
96
+ create(context) {
97
+ const options = context.options[0] ?? {};
98
+ const docsBaseUrl = (options.docsBaseUrl ?? DEFAULT_DOCS_BASE_URL).replace(/\/$/, '');
99
+
100
+ return {
101
+ ImportDeclaration(node) {
102
+ const declaration = /** @type {any} */ (node);
103
+
104
+ if (declaration.source.value !== QUERY_PACKAGE) return;
105
+
106
+ for (const specifier of declaration.specifiers) {
107
+ const name = importedName(specifier);
108
+
109
+ if (!name) continue;
110
+
111
+ const successor = V2_SUCCESSORS[name] ?? LEGACY_SYMBOLS[name];
112
+
113
+ if (successor) {
114
+ context.report({
115
+ node: specifier,
116
+ messageId: 'successor',
117
+ data: { name, to: successor.to, docs: `${docsBaseUrl}${successor.docs}` },
118
+ });
119
+
120
+ continue;
121
+ }
122
+
123
+ if (!isV2Symbol(name)) continue;
124
+
125
+ context.report({
126
+ node: specifier,
127
+ messageId: 'legacySystem',
128
+ data: { name, docs: `${docsBaseUrl}/query/migrating-from-v2` },
129
+ });
130
+ }
131
+ },
132
+ };
133
+ },
134
+ };
135
+
136
+ module.exports = noLegacyQueryImport;
@@ -0,0 +1,92 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Checks the two ends of the same wire: that a `<form>` handles its own submission, and that a
6
+ * submit control reaches a form at all.
7
+ *
8
+ * <form (ngSubmit)="save()"> ✔
9
+ * <form> ✘ pressing Enter in a field does nothing, or reloads the page
10
+ *
11
+ * <form …><button type="submit"> ✔
12
+ * <button type="submit" form="edit"> ✔ associated by id, outside the form's subtree
13
+ * <button type="submit"> ✘ submits nothing
14
+ *
15
+ * A form declaring native submission (`action`, `ngNoForm`, `method="dialog"`) is left alone - it is
16
+ * handled by the platform rather than by a handler.
17
+ */
18
+
19
+ /** @param {any} node @param {string} name */
20
+ const attribute = (node, name) => node.attributes?.find(/** @param {any} a */ (a) => a.name === name);
21
+
22
+ /** @param {any} node @param {string} name */
23
+ const hasBinding = (node, name) =>
24
+ node.inputs?.some(/** @param {any} i */ (i) => i.name === name) ||
25
+ node.attributes?.some(/** @param {any} a */ (a) => a.name === name);
26
+
27
+ /** @param {any} node */
28
+ const handlesSubmit = (node) =>
29
+ node.outputs?.some(/** @param {any} o */ (o) => o.name === 'submit' || o.name === 'ngSubmit');
30
+
31
+ /** @param {any} node */
32
+ const submitsNatively = (node) =>
33
+ hasBinding(node, 'action') || hasBinding(node, 'ngNoForm') || attribute(node, 'method')?.value === 'dialog';
34
+
35
+ /** @param {any} node */
36
+ const isSubmitControl = (node) =>
37
+ (node.name === 'button' || node.name === 'input') && attribute(node, 'type')?.value === 'submit';
38
+
39
+ /** @type {import('eslint').Rule.RuleModule} */
40
+ const requireFormSubmit = {
41
+ meta: {
42
+ type: 'problem',
43
+ docs: {
44
+ description: 'Require a `<form>` to handle its own submission, and a submit control to reach a form.',
45
+ },
46
+ schema: [],
47
+ messages: {
48
+ missingSubmitHandler:
49
+ 'This `<form>` handles no submission - pressing Enter in a field either does nothing or reloads the page. Bind `(ngSubmit)` (reactive forms) or `(submit)`, or use a plain element if this is not a form.',
50
+ submitOutsideForm:
51
+ 'A `type="submit"` control outside a `<form>` submits nothing. Put it inside the form, or associate it with one by id: `form="the-form-id"`.',
52
+ },
53
+ },
54
+ create(context) {
55
+ const parserServices = /** @type {any} */ (context.sourceCode.parserServices);
56
+
57
+ if (!parserServices?.convertNodeSourceSpanToLoc) return {};
58
+
59
+ let formDepth = 0;
60
+
61
+ /** @param {any} node */
62
+ const report = (node, messageId) =>
63
+ context.report({
64
+ loc: parserServices.convertNodeSourceSpanToLoc(node.startSourceSpan ?? node.sourceSpan),
65
+ messageId,
66
+ });
67
+
68
+ return {
69
+ /** @param {any} node */
70
+ Element(node) {
71
+ if (node.name === 'form') {
72
+ formDepth++;
73
+
74
+ if (!handlesSubmit(node) && !submitsNatively(node)) report(node, 'missingSubmitHandler');
75
+
76
+ return;
77
+ }
78
+
79
+ if (formDepth > 0 || !isSubmitControl(node) || hasBinding(node, 'form')) return;
80
+
81
+ report(node, 'submitOutsideForm');
82
+ },
83
+
84
+ /** @param {any} node */
85
+ 'Element:exit'(node) {
86
+ if (node.name === 'form') formDepth--;
87
+ },
88
+ };
89
+ },
90
+ };
91
+
92
+ module.exports = requireFormSubmit;