@blumintinc/eslint-plugin-blumint 1.20.142 → 1.20.144

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/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.142',
226
+ version: '1.20.144',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -338,27 +338,6 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
338
338
  }
339
339
  }
340
340
  },
341
- // Handle dynamic imports
342
- 'AwaitExpression > CallExpression[callee.type="ImportExpression"]'(node) {
343
- const parent = node.parent;
344
- if (parent?.type === utils_1.AST_NODE_TYPES.AwaitExpression &&
345
- parent.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
346
- parent.parent.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
347
- for (const prop of parent.parent.id.properties) {
348
- if (prop.type === utils_1.AST_NODE_TYPES.Property &&
349
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
350
- prop.key.name === 'mockFirestore') {
351
- mockFirestoreNodes.add(parent.parent);
352
- // Track renamed destructured imports
353
- if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier &&
354
- prop.value.name !== 'mockFirestore') {
355
- customMockFirestoreNames.add(prop.value.name);
356
- }
357
- break;
358
- }
359
- }
360
- }
361
- },
362
341
  // Handle complex object destructuring
363
342
  'ObjectPattern > Property > ObjectPattern > Property > ObjectPattern > Property[key.name="mockFirestore"]'(node) {
364
343
  let current = node;
@@ -28,6 +28,7 @@ const utils_1 = require("@typescript-eslint/utils");
28
28
  const visitor_keys_1 = require("@typescript-eslint/visitor-keys");
29
29
  const ts = __importStar(require("typescript"));
30
30
  const ASTHelpers_1 = require("../utils/ASTHelpers");
31
+ const arrowAnnotationGap_1 = require("../utils/arrowAnnotationGap");
31
32
  const createRule_1 = require("../utils/createRule");
32
33
  const disableDirectives_1 = require("../utils/disableDirectives");
33
34
  const importRemoval_1 = require("../utils/importRemoval");
@@ -864,7 +865,7 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
864
865
  * trading an unused import for a dangling type.
865
866
  */
866
867
  const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
867
- function collectIfRedundant(annotation, assertion, reportNode, fixerTarget) {
868
+ function collectIfRedundant(annotation, assertion, reportNode, fixerTarget, arrowReturnType) {
868
869
  const matchingType = haveMatchingTypes(annotation.typeAnnotation, assertion, checker, parserServices);
869
870
  if (!matchingType)
870
871
  return null;
@@ -872,6 +873,7 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
872
873
  reportNode,
873
874
  removal: annotationRemovalRange(fixerTarget, sourceCode),
874
875
  matchingType,
876
+ arrowReturnType,
875
877
  };
876
878
  sites.push(site);
877
879
  return site;
@@ -890,25 +892,54 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
890
892
  // Whether the annotation is load-bearing is decided at `Program:exit`:
891
893
  // the cycle can run through a function elsewhere in the file, and every
892
894
  // annotation in it goes in the same batched fix.
893
- const site = collectIfRedundant(annotation, assertionSite.assertion, reportNode, annotation);
895
+ const site = collectIfRedundant(annotation, assertionSite.assertion, reportNode, annotation, node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression
896
+ ? annotation
897
+ : undefined);
894
898
  if (site)
895
899
  returnCandidates.push({ site, owners, references });
896
900
  }
901
+ /**
902
+ * The text a site's removal writes in place of the annotation.
903
+ *
904
+ * An arrow's annotation is the one that cannot simply be deleted: its slice
905
+ * sits inside a restricted production, so a comment left in the gap — or
906
+ * stranded there by the deletion — turns the output into a SyntaxError that
907
+ * only a compiler reports (#1969). Every other subject ends its signature at
908
+ * a body or a separator, so its slice is deleted exactly as before.
909
+ */
910
+ function planEdits(site) {
911
+ if (!site.arrowReturnType) {
912
+ return [{ range: site.removal, text: '' }];
913
+ }
914
+ return (0, arrowAnnotationGap_1.planArrowAnnotationEdits)(sourceCode, site.arrowReturnType, site.removal);
915
+ }
897
916
  /**
898
917
  * The sites whose fixes actually ship. A site is excluded when its report
899
- * will be suppressed, or when its own removal orphans something the helper
900
- * cannot rewrite — a local alias, an interface, a type parameter. Deleting a
901
- * declaration is a materially riskier edit than dropping an import
902
- * specifier, and the author is better placed to decide whether the type
903
- * should go or be used elsewhere.
918
+ * will be suppressed, when its own removal orphans something the helper
919
+ * cannot rewrite — a local alias, an interface, a type parameter or when
920
+ * its edits cannot be planned without moving a comment whose meaning is its
921
+ * position. Deleting a declaration is a materially riskier edit than
922
+ * dropping an import specifier, and the author is better placed to decide
923
+ * whether the type should go or be used elsewhere.
904
924
  *
905
925
  * Screening individually before batching keeps one unfixable site from
906
926
  * vetoing the rest: orphanhood grows monotonically with the removed set, so
907
927
  * a site that cannot be planned alone can only ever poison the batch.
908
928
  */
909
929
  function selectFixableSites(candidates) {
910
- return candidates.filter((site) => !isReportSuppressed(site.reportNode) &&
911
- (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, [site.removal]) !== null);
930
+ const fixable = [];
931
+ for (const site of candidates) {
932
+ if (isReportSuppressed(site.reportNode))
933
+ continue;
934
+ if ((0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, [site.removal]) === null) {
935
+ continue;
936
+ }
937
+ const edits = planEdits(site);
938
+ if (edits === null)
939
+ continue;
940
+ fixable.push({ site, edits });
941
+ }
942
+ return fixable;
912
943
  }
913
944
  /**
914
945
  * The file's inference graph, built only where a return annotation is at
@@ -936,27 +967,33 @@ exports.noRedundantAnnotationAssertion = (0, createRule_1.createRule)({
936
967
  if (reportable.length === 0)
937
968
  return;
938
969
  const fixable = selectFixableSites(reportable);
939
- const removals = fixable.map((site) => site.removal);
970
+ const removals = fixable.map((entry) => entry.site.removal);
940
971
  // One plan over every surviving removal: an import referenced solely by
941
972
  // annotations that all go in this pass is orphaned by their union, even
942
973
  // though no single one of them orphans it.
943
974
  const importRanges = removals.length > 0
944
975
  ? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, removals)
945
976
  : null;
977
+ // The batch rewrites some spans rather than deleting them, and ships as
978
+ // one fix: ESLint rejects a fix whose edits overlap, so an overlap
979
+ // withdraws the fix instead of throwing at apply time. Only a wrong
980
+ // premise can produce one — an annotation's gap and an import
981
+ // declaration are disjoint regions of the file.
982
+ const edits = [
983
+ ...fixable.flatMap((entry) => entry.edits),
984
+ ...(importRanges ?? []).map((range) => ({ range, text: '' })),
985
+ ];
946
986
  // The whole batch ships as one fix, so no removal can land without the
947
987
  // others that the import's orphanhood was judged against. The rest
948
988
  // report without a fixer; the carrier's pass already resolves them.
949
- const carrier = importRanges ? fixable[0] : undefined;
989
+ const carrier = importRanges && (0, arrowAnnotationGap_1.isDisjoint)(edits) ? fixable[0]?.site : undefined;
950
990
  for (const site of reportable) {
951
991
  context.report({
952
992
  node: site.reportNode,
953
993
  messageId: 'redundantAnnotationAndAssertion',
954
994
  data: { type: site.matchingType },
955
- fix: site === carrier && importRanges
956
- ? (fixer) => [
957
- ...removals.map((range) => fixer.removeRange([range[0], range[1]])),
958
- ...importRanges.map((range) => fixer.removeRange([range[0], range[1]])),
959
- ]
995
+ fix: site === carrier
996
+ ? (fixer) => edits.map((edit) => fixer.replaceTextRange([edit.range[0], edit.range[1]], edit.text))
960
997
  : null,
961
998
  });
962
999
  }
@@ -146,20 +146,37 @@ const DEFAULT_MUI_COMPONENTS = new Set([
146
146
  'Toolbar',
147
147
  ]);
148
148
  /**
149
- * Components whose public prop API defines `color` as a closed semantic enum
150
- * (a palette / variant selector like `'primary' | 'secondary' | 'error' | …`),
151
- * not a CSS-forwarded system style prop. On these, `color` feeds
152
- * `ownerState.color`, selecting theme variants and MUI's internal
153
- * `.Mui*-color*` class selectors moving it into `sx` both drops the variant
154
- * selection and produces an invalid CSS `color` value. So `color` here is a
155
- * first-class component prop, never a deprecated system prop.
149
+ * Props a component declares as its own first-class API, keyed by the
150
+ * (component, prop) pair. Each name below collides with a system prop, but the
151
+ * component *consumes* it feeding `ownerState`, selecting a theme value and
152
+ * MUI's internal `.Mui*-*` class selectors instead of forwarding it as CSS.
153
+ * The system-prop reading is therefore wrong for the pair whatever value is
154
+ * written, and moving the prop into `sx` emits a declaration whose value is not
155
+ * a CSS value for that property: the browser drops it, so the fix type-checks
156
+ * (`SxProps` accepts `string | number`), lints clean, and silently loses the
157
+ * styling.
158
+ *
159
+ * The keying has to be per pair, not per prop name: the same name is a genuine
160
+ * system prop elsewhere (`color` on `Typography`, `maxWidth` on `Box`), so
161
+ * exempting the bare name would blind the rule on every other component.
156
162
  */
157
- const COMPONENT_COLOR_IS_SEMANTIC = new Set([
158
- 'Button',
159
- 'IconButton',
160
- 'Chip',
161
- 'Badge',
163
+ const COMPONENT_OWN_PROPS = new Map([
164
+ // `color` is a closed palette/variant selector (`'primary' | 'error' | …`)
165
+ // on these — never a CSS color (#1273). On `AppBar` it picks the *background*
166
+ // shade, so the system-prop reading also targets the wrong CSS property.
167
+ ['Button', new Set(['color'])],
168
+ ['IconButton', new Set(['color'])],
169
+ ['Chip', new Set(['color'])],
170
+ ['Badge', new Set(['color'])],
171
+ ['AppBar', new Set(['color'])],
172
+ // `maxWidth` is a breakpoint KEY (`'xs' | … | 'xl' | false`) that selects a
173
+ // width from `theme.breakpoints.values` and drives the `maxWidth*` class
174
+ // (#1966). As CSS, `max-width: xl` is invalid and the element unbounds.
175
+ ['Container', new Set(['maxWidth'])],
176
+ ['Dialog', new Set(['maxWidth'])],
162
177
  ]);
178
+ /** True when `propName` belongs to `componentName`'s own prop API. */
179
+ const componentOwnsProp = (componentName, propName) => COMPONENT_OWN_PROPS.get(componentName)?.has(propName) === true;
163
180
  /**
164
181
  * Props that must never be moved to `sx` because they are genuine component
165
182
  * API props, not MUI system styling shorthands. `direction` and `spacing` are
@@ -782,10 +799,10 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
782
799
  return false;
783
800
  }
784
801
  function isSystemProp(name, componentName) {
785
- // `color` is a semantic enum prop (not a CSS system prop) on components
786
- // like Button/IconButton/Chip/Badge exempt it there so the autofix
787
- // never rewrites a variant selector into an invalid CSS color.
788
- if (name === 'color' && COMPONENT_COLOR_IS_SEMANTIC.has(componentName)) {
802
+ // A prop the component owns is never the system prop of the same name, so
803
+ // the autofix must not rewrite it into a CSS declaration the browser
804
+ // discards.
805
+ if (componentOwnsProp(componentName, name)) {
789
806
  return false;
790
807
  }
791
808
  return MUI_SYSTEM_PROPS.has(name) && !isAllowedProp(name);
@@ -0,0 +1,72 @@
1
+ import { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
+ import { TextRange } from './importRemoval';
3
+ /**
4
+ * The span between an arrow function's parameter list and its `=>` is the one
5
+ * place a return-type annotation sits inside a restricted production:
6
+ * `ArrowParameters [no LineTerminator here] =>` forbids a line terminator
7
+ * there, and the syntactic grammar counts a block comment carrying a line
8
+ * terminator AS one. Stripping the annotation and leaving such a comment behind
9
+ * therefore emits a hard SyntaxError (TS1200 / V8 `Unexpected token '=>'`) —
10
+ * which `@typescript-eslint/parser` accepts, so no reparse-based guard sees it
11
+ * (#1964, #1969).
12
+ *
13
+ * Every other subject an annotation can hang off — a function declaration, a
14
+ * method, a class property with a body, a plain binding — ends its signature at
15
+ * a body or a separator, so nothing about the comments around it is restricted
16
+ * and a plain deletion stays correct.
17
+ */
18
+ /** One span of a fix, and the text that replaces it. */
19
+ export type Edit = {
20
+ range: TextRange;
21
+ text: string;
22
+ };
23
+ /**
24
+ * A comment whose meaning is tied to where it sits. Re-emitting one somewhere
25
+ * else retargets it — a disable directive lands on an unrelated line and a
26
+ * `@ts-expect-error` becomes an error of its own — so a rewrite that would move
27
+ * one is withheld instead.
28
+ */
29
+ export declare function isPositionalDirective(comment: TSESTree.Comment): boolean;
30
+ /** The indentation of the line `offset` sits on, for a carried line break. */
31
+ export declare function indentAt(source: TSESLint.SourceCode, offset: number): string;
32
+ /**
33
+ * The span an arrow's return annotation occupies between the parameter list and
34
+ * the `=>`, together with that arrow token.
35
+ *
36
+ * The span holds the annotation, whitespace and comments and nothing else,
37
+ * which is what makes it safe to rewrite wholesale: no binding reference can
38
+ * hide in it beyond the ones the annotation itself names.
39
+ */
40
+ export declare function arrowAnnotationGap(source: TSESLint.SourceCode, returnType: TSESTree.TSTypeAnnotation): {
41
+ gap: TextRange;
42
+ arrow: TSESTree.Token;
43
+ } | null;
44
+ /**
45
+ * The edits that strip an arrow function's return annotation without leaving a
46
+ * line terminator in the restricted gap, carrying every comment the strip
47
+ * strands rather than deleting it (#1877).
48
+ *
49
+ * `removal` is the span the calling rule would otherwise delete: it covers the
50
+ * annotation and may reach further back over the horizontal whitespace ahead of
51
+ * the `:`. It must lie inside the gap, which it does for any annotation the
52
+ * caller located on the arrow itself.
53
+ *
54
+ * A comment that must own a line is re-emitted past the `=>`, the nearest
55
+ * position outside the restricted gap that cannot itself begin one; hoisting it
56
+ * above the enclosing line would anchor an insertion at a column zero that may
57
+ * sit inside a template literal or JSX text, where the comment would become
58
+ * content rather than code. A comment that trips no restricted production stays
59
+ * exactly where it was written, since moving comments gratuitously is its own
60
+ * regression.
61
+ *
62
+ * `null` withholds the fix, for a comment whose meaning is its position and
63
+ * which cannot stay where it is.
64
+ */
65
+ export declare function planArrowAnnotationEdits(source: TSESLint.SourceCode, returnType: TSESTree.TSTypeAnnotation, removal: TextRange): Edit[] | null;
66
+ /**
67
+ * ESLint applies a fix whole or not at all, and rejects one whose edits
68
+ * overlap. Spans planned independently — several annotations, and the bindings
69
+ * their removal orphans — can only overlap if a premise behind them is wrong,
70
+ * so an overlap withdraws the fix rather than throwing at apply time.
71
+ */
72
+ export declare function isDisjoint(edits: readonly Edit[]): boolean;
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isDisjoint = exports.planArrowAnnotationEdits = exports.arrowAnnotationGap = exports.indentAt = exports.isPositionalDirective = void 0;
4
+ const disableDirectives_1 = require("./disableDirectives");
5
+ const replacementSegments_1 = require("./replacementSegments");
6
+ /** Every character the syntactic grammar counts as a LineTerminator. */
7
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
8
+ function containsRange(outer, inner) {
9
+ return inner[0] >= outer[0] && inner[1] <= outer[1];
10
+ }
11
+ const textOf = (source, range) => source.text.slice(range[0], range[1]);
12
+ /**
13
+ * A comment whose meaning is tied to where it sits. Re-emitting one somewhere
14
+ * else retargets it — a disable directive lands on an unrelated line and a
15
+ * `@ts-expect-error` becomes an error of its own — so a rewrite that would move
16
+ * one is withheld instead.
17
+ */
18
+ function isPositionalDirective(comment) {
19
+ if ((0, disableDirectives_1.parseDisableDirectives)([comment]).length > 0) {
20
+ return true;
21
+ }
22
+ const value = comment.value.trim();
23
+ return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
24
+ }
25
+ exports.isPositionalDirective = isPositionalDirective;
26
+ /** The indentation of the line `offset` sits on, for a carried line break. */
27
+ function indentAt(source, offset) {
28
+ const lineStart = source.text.lastIndexOf('\n', offset - 1) + 1;
29
+ const [indent] = /^[ \t]*/.exec(source.text.slice(lineStart, offset)) ?? [''];
30
+ return indent;
31
+ }
32
+ exports.indentAt = indentAt;
33
+ /**
34
+ * The span an arrow's return annotation occupies between the parameter list and
35
+ * the `=>`, together with that arrow token.
36
+ *
37
+ * The span holds the annotation, whitespace and comments and nothing else,
38
+ * which is what makes it safe to rewrite wholesale: no binding reference can
39
+ * hide in it beyond the ones the annotation itself names.
40
+ */
41
+ function arrowAnnotationGap(source, returnType) {
42
+ const parametersEnd = source.getTokenBefore(returnType);
43
+ const arrow = source.getTokenAfter(returnType, {
44
+ filter: (token) => token.value === '=>',
45
+ });
46
+ if (!parametersEnd || !arrow)
47
+ return null;
48
+ const gap = [parametersEnd.range[1], arrow.range[0]];
49
+ return containsRange(gap, returnType.range) ? { gap, arrow } : null;
50
+ }
51
+ exports.arrowAnnotationGap = arrowAnnotationGap;
52
+ /**
53
+ * Re-emits `comments` on the far side of the arrow, where a line terminator is
54
+ * inert, consuming the horizontal whitespace the arrow already had after it so
55
+ * the body keeps a single separator.
56
+ */
57
+ function hoistPastArrow(source, arrow, comments) {
58
+ const indent = indentAt(source, arrow.range[0]);
59
+ const trailingText = source.text.slice(arrow.range[1]);
60
+ const [spacing] = /^[ \t]*/.exec(trailingText) ?? [''];
61
+ const body = (0, replacementSegments_1.joinSegmentBody)(comments.map((comment) => ({
62
+ text: textOf(source, comment.range),
63
+ breakAfter: true,
64
+ })), indent);
65
+ const rest = trailingText.slice(spacing.length);
66
+ const separator = LINE_TERMINATOR.test(rest.charAt(0))
67
+ ? ''
68
+ : (0, replacementSegments_1.requiresLineBreakAfter)(comments[comments.length - 1])
69
+ ? `\n${indent}`
70
+ : ' ';
71
+ return {
72
+ range: [arrow.range[1], arrow.range[1] + spacing.length],
73
+ text: ` ${body}${separator}`,
74
+ };
75
+ }
76
+ /**
77
+ * The edits that strip an arrow function's return annotation without leaving a
78
+ * line terminator in the restricted gap, carrying every comment the strip
79
+ * strands rather than deleting it (#1877).
80
+ *
81
+ * `removal` is the span the calling rule would otherwise delete: it covers the
82
+ * annotation and may reach further back over the horizontal whitespace ahead of
83
+ * the `:`. It must lie inside the gap, which it does for any annotation the
84
+ * caller located on the arrow itself.
85
+ *
86
+ * A comment that must own a line is re-emitted past the `=>`, the nearest
87
+ * position outside the restricted gap that cannot itself begin one; hoisting it
88
+ * above the enclosing line would anchor an insertion at a column zero that may
89
+ * sit inside a template literal or JSX text, where the comment would become
90
+ * content rather than code. A comment that trips no restricted production stays
91
+ * exactly where it was written, since moving comments gratuitously is its own
92
+ * regression.
93
+ *
94
+ * `null` withholds the fix, for a comment whose meaning is its position and
95
+ * which cannot stay where it is.
96
+ */
97
+ function planArrowAnnotationEdits(source, returnType, removal) {
98
+ const gapInfo = arrowAnnotationGap(source, returnType);
99
+ if (!gapInfo)
100
+ return null;
101
+ const { gap, arrow } = gapInfo;
102
+ if (!containsRange(gap, removal))
103
+ return null;
104
+ const comments = source
105
+ .getAllComments()
106
+ .filter((comment) => containsRange(gap, comment.range));
107
+ const stranded = comments.filter((comment) => containsRange(removal, comment.range));
108
+ // What the plain deletion would leave between the parameters and the arrow.
109
+ // A comment left there contributes its own text, so a line comment or a
110
+ // multi-line block comment shows up here as the line terminator it is.
111
+ const residue = `${textOf(source, [gap[0], removal[0]])}${textOf(source, [
112
+ removal[1],
113
+ gap[1],
114
+ ])}`;
115
+ // The plain deletion is kept wherever it already lands a legal gap and
116
+ // strands nothing, so no output that survives today moves by a byte.
117
+ if (stranded.length === 0 && !LINE_TERMINATOR.test(residue)) {
118
+ return [{ range: removal, text: '' }];
119
+ }
120
+ // Rewriting the gap collapses the lines it spanned, which moves the line a
121
+ // directive inside it points at, so the whole fix is withheld rather than
122
+ // retargeting one. The gap a directive can share with nothing else is left
123
+ // untouched by the branch above.
124
+ if (comments.some(isPositionalDirective))
125
+ return null;
126
+ const hoisted = comments.filter(replacementSegments_1.requiresOwnLine);
127
+ const inline = comments
128
+ .filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
129
+ .map((comment) => textOf(source, comment.range));
130
+ const edits = [
131
+ { range: gap, text: inline.length === 0 ? ' ' : ` ${inline.join(' ')} ` },
132
+ ];
133
+ if (hoisted.length > 0) {
134
+ edits.push(hoistPastArrow(source, arrow, hoisted));
135
+ }
136
+ return edits;
137
+ }
138
+ exports.planArrowAnnotationEdits = planArrowAnnotationEdits;
139
+ /**
140
+ * ESLint applies a fix whole or not at all, and rejects one whose edits
141
+ * overlap. Spans planned independently — several annotations, and the bindings
142
+ * their removal orphans — can only overlap if a premise behind them is wrong,
143
+ * so an overlap withdraws the fix rather than throwing at apply time.
144
+ */
145
+ function isDisjoint(edits) {
146
+ const sorted = [...edits].sort((left, right) => left.range[0] - right.range[0]);
147
+ return sorted.every((edit, index) => index === 0 || sorted[index - 1].range[1] <= edit.range[0]);
148
+ }
149
+ exports.isDisjoint = isDisjoint;
150
+ //# sourceMappingURL=arrowAnnotationGap.js.map
@@ -0,0 +1,45 @@
1
+ export type RestrictedProduction = 'arrow' | 'throw';
2
+ export type RestrictedBreach = {
3
+ production: RestrictedProduction;
4
+ /** 1-indexed line of the token that closes the gap. */
5
+ line: number;
6
+ /** The offending text between the two tokens, comments included. */
7
+ gap: string;
8
+ };
9
+ type Token = {
10
+ type: string;
11
+ value: string;
12
+ range: [number, number];
13
+ };
14
+ type Node = {
15
+ type: string;
16
+ range: [number, number];
17
+ } & Record<string, unknown>;
18
+ type ParsedSource = {
19
+ ast: Node;
20
+ tokens: Token[];
21
+ } | null;
22
+ /**
23
+ * `.ts` and `.tsx` are not ordered by permissiveness — only `.ts` accepts
24
+ * `<T>expr` and only `.tsx` accepts JSX — so a snippet is tried both ways rather
25
+ * than parsed under a guessed extension. A snippet that parses under neither is
26
+ * `null`: unparsable text is `fixture-corpus-parsability`'s axis, and reporting
27
+ * it here would double-count it.
28
+ */
29
+ export declare function parseForRestrictedProductions(code: string): ParsedSource;
30
+ /**
31
+ * Every restricted-production breach in `code`, or `null` when it does not parse
32
+ * at all.
33
+ *
34
+ * The gap is measured between TOKENS, so the text it spans is whitespace and
35
+ * comments and nothing else. That is what makes a block comment carrying a line
36
+ * terminator indistinguishable from a raw newline here — which is the whole
37
+ * point, since it is indistinguishable to the grammar too.
38
+ */
39
+ export declare function restrictedProductionBreaches(code: string): RestrictedBreach[] | null;
40
+ /**
41
+ * The non-comment token stream, used to prove a planted comment changed nothing
42
+ * but comments. `null` when the text does not parse.
43
+ */
44
+ export declare function tokenSignatureOf(code: string): string | null;
45
+ export {};
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.tokenSignatureOf = exports.restrictedProductionBreaches = exports.parseForRestrictedProductions = void 0;
27
+ const tsParser = __importStar(require("@typescript-eslint/parser"));
28
+ /**
29
+ * Restricted productions: the places the ECMAScript grammar forbids a
30
+ * LineTerminator, and where `@typescript-eslint/parser` accepts one anyway.
31
+ *
32
+ * A fixer that leaves — or carries — a line break into one of these gaps emits
33
+ * text no engine will run, and NOTHING else in this repo's pipeline says so.
34
+ * Every parse-based guard (`fixture-corpus-parsability`, `fix-fixpoint-closure`'s
35
+ * fatal check, `fix-orphan-binding-closure`'s `message.fatal` gate, the agora
36
+ * `fix: true` sweep) reads the broken text as clean, because the parser they all
37
+ * share is the one that accepts it. #1964 shipped through every one of them.
38
+ *
39
+ * WHICH productions belong here is MEASURED, not copied from the spec. The
40
+ * grammar lists seven; only two are detectable here, because for the rest
41
+ * `@typescript-eslint/parser` already agrees with V8:
42
+ *
43
+ * | production | parser | V8 | detectable here |
44
+ * | -------------------------- | ------- | ------- | ------------------- |
45
+ * | `ArrowParameters [] =>` | accepts | rejects | YES |
46
+ * | `throw []` | accepts | rejects | YES |
47
+ * | `async [] (params) =>` | rejects | rejects | no — parser sees it |
48
+ * | `async [] method()` | rejects | rejects | no — parser sees it |
49
+ * | `yield [] *` | rejects | rejects | no — parser sees it |
50
+ * | postfix `++` / `--` | rejects | rejects | no — parser sees it |
51
+ * | `return` / `yield` / label | ASI | ASI | no — both insert `;` |
52
+ *
53
+ * The last row is the one worth stating plainly: for `return`, `yield`,
54
+ * `break`/`continue` with a label and `async function`, the parser applies
55
+ * automatic semicolon insertion exactly as V8 does, so the two never disagree
56
+ * and there is no divergence to detect. The rows the parser rejects are already
57
+ * fatal to every parse-based guard, so they need no help from this module.
58
+ *
59
+ * `src/tests/restricted-production-closure.test.ts` re-measures that table on
60
+ * every run: an arm that stops being redundant (a parser upgrade turning a
61
+ * "rejects" into an "accepts") fails there rather than silently going unchecked.
62
+ */
63
+ /**
64
+ * Every character the syntactic grammar counts as a LineTerminator, by code
65
+ * point rather than as a regular expression: U+2028 and U+2029 terminate a line
66
+ * in JavaScript SOURCE too, so a literal holding them cannot be written here
67
+ * without breaking this file.
68
+ */
69
+ const LINE_TERMINATOR_CODES = new Set([0x0a, 0x0d, 0x2028, 0x2029]);
70
+ const hasLineTerminator = (text) => {
71
+ for (let index = 0; index < text.length; index++) {
72
+ if (LINE_TERMINATOR_CODES.has(text.charCodeAt(index)))
73
+ return true;
74
+ }
75
+ return false;
76
+ };
77
+ const PARSE_OPTIONS = {
78
+ ecmaVersion: 2022,
79
+ sourceType: 'module',
80
+ range: true,
81
+ loc: true,
82
+ comment: true,
83
+ tokens: true,
84
+ };
85
+ /**
86
+ * `.ts` and `.tsx` are not ordered by permissiveness — only `.ts` accepts
87
+ * `<T>expr` and only `.tsx` accepts JSX — so a snippet is tried both ways rather
88
+ * than parsed under a guessed extension. A snippet that parses under neither is
89
+ * `null`: unparsable text is `fixture-corpus-parsability`'s axis, and reporting
90
+ * it here would double-count it.
91
+ */
92
+ function parseForRestrictedProductions(code) {
93
+ for (const jsx of [true, false]) {
94
+ try {
95
+ const ast = tsParser.parse(code, {
96
+ ...PARSE_OPTIONS,
97
+ ecmaFeatures: { jsx },
98
+ });
99
+ if (ast.tokens)
100
+ return { ast, tokens: ast.tokens };
101
+ }
102
+ catch {
103
+ continue;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ exports.parseForRestrictedProductions = parseForRestrictedProductions;
109
+ function visit(node, callback) {
110
+ if (!node || typeof node !== 'object')
111
+ return;
112
+ const record = node;
113
+ if (typeof record.type === 'string' && Array.isArray(record.range)) {
114
+ callback(record);
115
+ }
116
+ for (const [key, value] of Object.entries(record)) {
117
+ // `parent` is a back-edge on some node shapes and would loop forever.
118
+ if (key === 'parent')
119
+ continue;
120
+ if (Array.isArray(value)) {
121
+ for (const item of value)
122
+ visit(item, callback);
123
+ }
124
+ else if (value && typeof value === 'object') {
125
+ visit(value, callback);
126
+ }
127
+ }
128
+ }
129
+ /** Index of the last token ending at or before `position`. */
130
+ function lastTokenIndexBefore(tokens, position) {
131
+ let low = 0;
132
+ let high = tokens.length - 1;
133
+ let found = -1;
134
+ while (low <= high) {
135
+ const middle = (low + high) >> 1;
136
+ if (tokens[middle].range[1] <= position) {
137
+ found = middle;
138
+ low = middle + 1;
139
+ }
140
+ else {
141
+ high = middle - 1;
142
+ }
143
+ }
144
+ return found;
145
+ }
146
+ const lineAt = (code, position) => code.slice(0, position).split('\n').length;
147
+ /**
148
+ * Every restricted-production breach in `code`, or `null` when it does not parse
149
+ * at all.
150
+ *
151
+ * The gap is measured between TOKENS, so the text it spans is whitespace and
152
+ * comments and nothing else. That is what makes a block comment carrying a line
153
+ * terminator indistinguishable from a raw newline here — which is the whole
154
+ * point, since it is indistinguishable to the grammar too.
155
+ */
156
+ function restrictedProductionBreaches(code) {
157
+ const parsed = parseForRestrictedProductions(code);
158
+ if (!parsed)
159
+ return null;
160
+ const { ast, tokens } = parsed;
161
+ const breaches = [];
162
+ const record = (production, from, to) => {
163
+ const gap = code.slice(from, to);
164
+ if (!hasLineTerminator(gap))
165
+ return;
166
+ breaches.push({ production, line: lineAt(code, to), gap });
167
+ };
168
+ visit(ast, (node) => {
169
+ if (node.type === 'ArrowFunctionExpression') {
170
+ const body = node.body;
171
+ if (!body)
172
+ return;
173
+ /**
174
+ * The arrow token is the LAST `=>` before the body, never the first: a
175
+ * default parameter may itself be an arrow, and its `=>` sits inside this
176
+ * node's range.
177
+ *
178
+ * Taking the token immediately before it is also what makes a TypeScript
179
+ * return annotation fall out correctly. The grammar forbids the break
180
+ * between the SIGNATURE and `=>`, and the annotation is part of the
181
+ * signature — `() \n : T => 1` is legal and `(): T \n => 1` is not, which
182
+ * is exactly the pair this comparison distinguishes.
183
+ */
184
+ const end = lastTokenIndexBefore(tokens, body.range[0]);
185
+ for (let index = end; index >= 0; index--) {
186
+ if (tokens[index].range[1] <= node.range[0])
187
+ break;
188
+ if (tokens[index].value !== '=>')
189
+ continue;
190
+ if (index > 0) {
191
+ record('arrow', tokens[index - 1].range[1], tokens[index].range[0]);
192
+ }
193
+ break;
194
+ }
195
+ return;
196
+ }
197
+ if (node.type === 'ThrowStatement' && node.argument) {
198
+ // The keyword opens the statement, so it is the token after the last one
199
+ // that ends at or before the statement's start.
200
+ const index = lastTokenIndexBefore(tokens, node.range[0]) + 1;
201
+ const keyword = tokens[index];
202
+ if (!keyword || keyword.value !== 'throw')
203
+ return;
204
+ /**
205
+ * The parser does not reject the breach, but it does RECOVER from it: it
206
+ * emits a zero-width token where the argument should have been and hands
207
+ * back a `ThrowStatement` whose argument is an empty node. Measuring the
208
+ * gap to that phantom would measure nothing at all, so the width filter
209
+ * is what makes this arm detect anything.
210
+ */
211
+ const next = tokens
212
+ .slice(index + 1)
213
+ .find((token) => token.range[1] > token.range[0]);
214
+ if (!next)
215
+ return;
216
+ record('throw', keyword.range[1], next.range[0]);
217
+ }
218
+ });
219
+ return breaches;
220
+ }
221
+ exports.restrictedProductionBreaches = restrictedProductionBreaches;
222
+ /**
223
+ * The non-comment token stream, used to prove a planted comment changed nothing
224
+ * but comments. `null` when the text does not parse.
225
+ */
226
+ function tokenSignatureOf(code) {
227
+ const parsed = parseForRestrictedProductions(code);
228
+ if (!parsed)
229
+ return null;
230
+ return parsed.tokens.map((token) => `${token.type} ${token.value}`).join(' ');
231
+ }
232
+ exports.tokenSignatureOf = tokenSignatureOf;
233
+ //# sourceMappingURL=restrictedProductions.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.142",
3
+ "version": "1.20.144",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.20.144",
4
+ "date": "2026-08-12T12:16:01.495Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-redundant-annotation-assertion",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1969
11
+ ],
12
+ "summary": "carry a stranded comment past the arrow (closes #1969)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.143",
18
+ "date": "2026-08-12T07:48:25.970Z",
19
+ "rules": [
20
+ {
21
+ "name": "prefer-sx-prop-over-system-props",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1966
25
+ ],
26
+ "summary": "key the exemption on (component, prop) (closes #1966)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.20.142",
4
32
  "date": "2026-08-12T07:04:58.677Z",