@ethlete/eslint-plugin 1.0.0-next.11 → 1.0.0-next.12

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.12
4
+
5
+ ### Minor Changes
6
+
7
+ - [`44adcac`](https://github.com/ethlete-io/ethdk/commit/44adcac94d7e0f56742e02901221c6e04da7df47) Thanks [@TomTomB](https://github.com/TomTomB)! - Add two component I/O naming rules:
8
+ - `no-native-html-input-name` (error) — flags an `input()`/`model()` named after a global HTML attribute (`title`, `id`, `hidden`, `role`, `tabindex`, …), which collides with the attribute the host element carries natively.
9
+ - `prefer-present-tense-output` (warn) — nudges `output()` names toward the present tense like native DOM events (`playerSelect`, not `playerSelected`).
10
+
11
+ The `on`-prefix case is already covered by `@angular-eslint/no-output-on-prefix`, so no rule is added for it.
12
+
3
13
  ## 1.0.0-next.11
4
14
 
5
15
  ### Major Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.11",
3
+ "version": "1.0.0-next.12",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -382,6 +382,13 @@ const recommendedTs = {
382
382
 
383
383
  // Routing components must use paths containing "-view" and class names ending in "ViewComponent"
384
384
  'ethlete/enforce-routing-view-naming': 'error',
385
+
386
+ // Inputs/models must not be named after a global HTML attribute (title, id, hidden, …) — it collides with the host element
387
+ 'ethlete/no-native-html-input-name': 'error',
388
+
389
+ // Outputs should read in the present tense like native DOM events: (playerSelect), not (playerSelected)
390
+ // (The `on` prefix is already covered by @angular-eslint/no-output-on-prefix.)
391
+ 'ethlete/prefer-present-tense-output': 'warn',
385
392
  },
386
393
  };
387
394
 
package/src/index.js CHANGED
@@ -48,6 +48,8 @@ const classConstantProperty = require('./rules/class-constant-property');
48
48
  const preferConciseAngularHostDirectives = require('./rules/prefer-concise-angular-host-directives');
49
49
  const preferConciseAngularStyleMetadata = require('./rules/prefer-concise-angular-style-metadata');
50
50
  const noTypedInjectedElementRef = require('./rules/no-typed-injected-element-ref');
51
+ const noNativeHtmlInputName = require('./rules/no-native-html-input-name');
52
+ const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
51
53
  const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
52
54
 
53
55
  /** @type {import('eslint').ESLint.Plugin} */
@@ -104,6 +106,8 @@ const plugin = {
104
106
  'prefer-concise-angular-host-directives': preferConciseAngularHostDirectives,
105
107
  'prefer-concise-angular-style-metadata': preferConciseAngularStyleMetadata,
106
108
  'no-typed-injected-element-ref': noTypedInjectedElementRef,
109
+ 'no-native-html-input-name': noNativeHtmlInputName,
110
+ 'prefer-present-tense-output': preferPresentTenseOutput,
107
111
  },
108
112
  };
109
113
 
@@ -0,0 +1,78 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Shared detection for Angular signal-based inputs and outputs.
6
+ *
7
+ * This repo declares component I/O exclusively via the signal factory functions
8
+ * (no `@Input()` / `@Output()` decorators):
9
+ *
10
+ * foo = input(...) → input
11
+ * foo = input.required<T>(...) → input
12
+ * foo = model(...) → model (two-way input)
13
+ * foo = model.required<T>(...) → model
14
+ * foo = output<T>() → output
15
+ * foo = outputFromObservable(...) → output
16
+ *
17
+ * `getReactiveIo` maps a class `PropertyDefinition` node onto one of those kinds
18
+ * (or null when the property is not a reactive I/O declaration).
19
+ */
20
+
21
+ /** Factory identifier → I/O kind. */
22
+ const IO_FACTORIES = {
23
+ input: 'input',
24
+ model: 'model',
25
+ output: 'output',
26
+ outputFromObservable: 'output',
27
+ };
28
+
29
+ /**
30
+ * Resolves the factory function name of a call expression, handling both the
31
+ * plain form (`input(...)`) and the `.required` member form
32
+ * (`input.required(...)`).
33
+ *
34
+ * @param {any} callee
35
+ * @returns {string | null}
36
+ */
37
+ const getFactoryName = (callee) => {
38
+ if (!callee) return null;
39
+
40
+ if (callee.type === 'Identifier') return callee.name;
41
+
42
+ // input.required(...) / model.required(...)
43
+ if (
44
+ callee.type === 'MemberExpression' &&
45
+ !callee.computed &&
46
+ callee.object.type === 'Identifier' &&
47
+ callee.property.type === 'Identifier' &&
48
+ callee.property.name === 'required'
49
+ ) {
50
+ return callee.object.name;
51
+ }
52
+
53
+ return null;
54
+ };
55
+
56
+ /**
57
+ * Classifies a class property as an Angular signal input/model/output.
58
+ *
59
+ * @param {any} node PropertyDefinition
60
+ * @returns {{ kind: 'input' | 'model' | 'output', name: string, keyNode: any } | null}
61
+ */
62
+ const getReactiveIo = (node) => {
63
+ if (!node || node.type !== 'PropertyDefinition') return null;
64
+ if (node.computed || node.key.type !== 'Identifier') return null;
65
+
66
+ const value = node.value;
67
+ if (!value || value.type !== 'CallExpression') return null;
68
+
69
+ const factory = getFactoryName(value.callee);
70
+ if (!factory) return null;
71
+
72
+ const kind = /** @type {Record<string, 'input' | 'model' | 'output'>} */ (IO_FACTORIES)[factory];
73
+ if (!kind) return null;
74
+
75
+ return { kind, name: node.key.name, keyNode: node.key };
76
+ };
77
+
78
+ module.exports = { getReactiveIo };
@@ -0,0 +1,94 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const { getReactiveIo } = require('./internals/angular-io');
5
+
6
+ /**
7
+ * Disallows naming an `input()` / `model()` after a global HTML attribute.
8
+ *
9
+ * A component's host element carries every global attribute natively, so an
10
+ * input named `title`, `id`, `hidden`, `role`, … collides with the real DOM
11
+ * attribute: the browser applies its native behaviour (a `title` tooltip, a
12
+ * duplicated `id`, native `hidden`/`role` semantics) in addition to — or
13
+ * instead of — the component's intent. Rename the input (e.g. `label`,
14
+ * `overlayId`, `isHidden`) so it can't clash.
15
+ *
16
+ * Only the *global* attributes (those valid on any element) are flagged.
17
+ * Element-specific attributes that libraries routinely mirror on purpose
18
+ * (`disabled`, `value`, `placeholder`, `type`, `size`, `min`, `max`, …) are
19
+ * intentionally NOT flagged — they don't apply to an arbitrary host element and
20
+ * mirroring them is a deliberate, well-understood pattern.
21
+ *
22
+ * ❌ title = input('...')
23
+ * ❌ id = input(...)
24
+ * ❌ hidden = input(false)
25
+ * ✅ label = input('...')
26
+ * ✅ disabled = input(false) // element-specific, intentional mirror
27
+ */
28
+
29
+ // Global HTML attributes (valid on every element) + `role`, which behaves the
30
+ // same way. Element-specific attributes are deliberately excluded — see above.
31
+ //
32
+ // Microdata (`itemid`, `itemprop`, …) and other niche globals (`is`, `nonce`,
33
+ // `part`, `exportparts`, …) are also excluded: they are rarely used, so an input
34
+ // named after one is almost never a real collision, whereas the names double as
35
+ // common domain terms (e.g. `itemId` on a list item). Keeping them in would
36
+ // produce more false positives than caught footguns.
37
+ const GLOBAL_HTML_ATTRIBUTES = new Set([
38
+ 'accesskey',
39
+ 'autocapitalize',
40
+ 'autofocus',
41
+ 'class',
42
+ 'contenteditable',
43
+ 'dir',
44
+ 'draggable',
45
+ 'enterkeyhint',
46
+ 'hidden',
47
+ 'id',
48
+ 'inert',
49
+ 'inputmode',
50
+ 'lang',
51
+ 'popover',
52
+ 'role',
53
+ 'slot',
54
+ 'spellcheck',
55
+ 'style',
56
+ 'tabindex',
57
+ 'title',
58
+ 'translate',
59
+ ]);
60
+
61
+ /** @type {import('eslint').Rule.RuleModule} */
62
+ const rule = {
63
+ meta: {
64
+ type: 'problem',
65
+ docs: {
66
+ description: 'Disallow naming an input/model after a global HTML attribute (it collides with the host element).',
67
+ recommended: true,
68
+ },
69
+ schema: [],
70
+ messages: {
71
+ nativeName:
72
+ "Input '{{ name }}' shares its name with the global HTML attribute '{{ name }}', which every host element carries natively. Rename it to avoid the clash.",
73
+ },
74
+ },
75
+ create(context) {
76
+ return {
77
+ PropertyDefinition(node) {
78
+ const io = getReactiveIo(node);
79
+ if (!io) return;
80
+ if (io.kind === 'output') return;
81
+
82
+ if (!GLOBAL_HTML_ATTRIBUTES.has(io.name.toLowerCase())) return;
83
+
84
+ context.report({
85
+ node: io.keyNode,
86
+ messageId: 'nativeName',
87
+ data: { name: io.name },
88
+ });
89
+ },
90
+ };
91
+ },
92
+ };
93
+
94
+ module.exports = rule;
@@ -0,0 +1,92 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const { getReactiveIo } = require('./internals/angular-io');
5
+
6
+ /**
7
+ * Nudges `output()` names toward the present tense, matching native DOM events.
8
+ *
9
+ * Native events are named in the present tense after the thing that happens:
10
+ * `input`, `change`, `select`, `mousedown`, `dragstart`. Components should read
11
+ * the same way in a template — `(playerSelect)`, not `(playerSelected)`. A
12
+ * past-tense name describes a state after the fact rather than the event.
13
+ *
14
+ * ❌ playerSelected = output<...>()
15
+ * ❌ filesRejected = output<...>()
16
+ * ✅ playerSelect = output<...>()
17
+ * ✅ filesReject = output<...>()
18
+ *
19
+ * Detection is heuristic: the final camel-case segment is flagged when it ends
20
+ * in `-ed` (the dominant past-participle pattern), excluding an allowlist of
21
+ * base-form words that merely happen to end that way (`succeed`, `feed`,
22
+ * `speed`, …). Irregular past tenses that don't end in `-ed` (`sent`, `shown`,
23
+ * `built`, …) are NOT detected — this rule catches the common case, not every
24
+ * one. No auto-fix: de-conjugating a verb reliably (and renaming the matching
25
+ * template bindings) is out of scope, so the corrected name is left to the
26
+ * author.
27
+ */
28
+
29
+ // Base-form / noun words ending in "-ed" that are not past participles.
30
+ const NON_PAST_ED_WORDS = new Set([
31
+ 'succeed',
32
+ 'proceed',
33
+ 'exceed',
34
+ 'feed',
35
+ 'need',
36
+ 'breed',
37
+ 'bleed',
38
+ 'speed',
39
+ 'seed',
40
+ 'weed',
41
+ 'embed',
42
+ 'shed',
43
+ 'wed',
44
+ 'indeed',
45
+ ]);
46
+
47
+ /**
48
+ * Returns the final camel-case segment of an identifier, lower-cased.
49
+ * `filesRejected` → `rejected`, `removed` → `removed`.
50
+ *
51
+ * @param {string} name
52
+ */
53
+ const lastWord = (name) => {
54
+ const segments = name.split(/(?=[A-Z])/);
55
+ return segments[segments.length - 1].toLowerCase();
56
+ };
57
+
58
+ /** @type {import('eslint').Rule.RuleModule} */
59
+ const rule = {
60
+ meta: {
61
+ type: 'suggestion',
62
+ docs: {
63
+ description: 'Prefer present-tense output names (like native DOM events) over past-tense ones.',
64
+ recommended: true,
65
+ },
66
+ schema: [],
67
+ messages: {
68
+ pastTense:
69
+ "Output '{{ name }}' looks past tense ('{{ word }}'). Name outputs after the present-tense event like native DOM events (e.g. 'change', 'select'), not their past participle ('changed', 'selected').",
70
+ },
71
+ },
72
+ create(context) {
73
+ return {
74
+ PropertyDefinition(node) {
75
+ const io = getReactiveIo(node);
76
+ if (!io || io.kind !== 'output') return;
77
+
78
+ const word = lastWord(io.name);
79
+ if (word.length <= 2 || !word.endsWith('ed')) return;
80
+ if (NON_PAST_ED_WORDS.has(word)) return;
81
+
82
+ context.report({
83
+ node: io.keyNode,
84
+ messageId: 'pastTense',
85
+ data: { name: io.name, word },
86
+ });
87
+ },
88
+ };
89
+ },
90
+ };
91
+
92
+ module.exports = rule;