@blumintinc/eslint-plugin-blumint 1.20.196 → 1.20.198

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.196',
226
+ version: '1.20.198',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -109,6 +109,51 @@ function readExhaustiveDepsDisable(comment) {
109
109
  }
110
110
  return comment.type === utils_1.AST_TOKEN_TYPES.Block ? 'file' : null;
111
111
  }
112
+ /**
113
+ * Whether a dependency's type is a primitive, so that narrowing it into member
114
+ * paths is meaningless and an unread entry is a deliberate recompute trigger
115
+ * rather than a stale object.
116
+ *
117
+ * why the `*Like` flags, never the bare ones: each `*Like` is the union of a
118
+ * primitive and its LITERAL form, and a literal type is what this repo's own
119
+ * fixers produce. `global-const-style` appends `as const`, which turns `0` into
120
+ * the numeric literal type `0`. The list used to name `StringLiteral`
121
+ * explicitly but `Number` and `Boolean` bare, so a string literal was primitive
122
+ * while `0 as const` and `true as const` were not — the same value lost its
123
+ * exemption the moment a sibling fixer froze it, which made this an exemption
124
+ * destroyed by COMPOSITION rather than a latent gap (#2238).
125
+ *
126
+ * why unions are walked: a union carries `TypeFlags.Union` and none of the
127
+ * flags above, so a bitmask test on the union ITSELF answers no for
128
+ * `'compact' | 'full'` — a string at runtime, and the shape
129
+ * `prefer-union-from-const-array` leaves behind. A union is primitive exactly
130
+ * when every constituent is, which also keeps `string | { a: number }` an
131
+ * object.
132
+ */
133
+ function isPrimitiveType(type) {
134
+ // why the mask is built HERE and not hoisted to module scope: this module is
135
+ // imported by `src/index.ts`, so a module-scope `TypeFlags.X` is read the
136
+ // moment the plugin loads. Against a compiler that does not root-export the
137
+ // API, `TypeFlags` is `undefined` and the read throws — taking down every
138
+ // rule in the plugin, not just this one. `plugin-load-without-compiler-api`
139
+ // is the guard that says so.
140
+ const primitiveFlags = typescript_1.TypeFlags.StringLike |
141
+ typescript_1.TypeFlags.NumberLike |
142
+ typescript_1.TypeFlags.BooleanLike |
143
+ typescript_1.TypeFlags.BigIntLike |
144
+ typescript_1.TypeFlags.ESSymbolLike |
145
+ typescript_1.TypeFlags.Null |
146
+ typescript_1.TypeFlags.Undefined |
147
+ typescript_1.TypeFlags.Void |
148
+ typescript_1.TypeFlags.Never;
149
+ if (type.flags & primitiveFlags)
150
+ return true;
151
+ if (type.flags & typescript_1.TypeFlags.Union) {
152
+ const constituents = type.types;
153
+ return constituents.length > 0 && constituents.every(isPrimitiveType);
154
+ }
155
+ return false;
156
+ }
112
157
  /** `channelGroupActive` -> `setChannelGroupActive`, `a` -> `setA`. */
113
158
  function toSetterName(dependencyName) {
114
159
  return `set${dependencyName.charAt(0).toUpperCase()}${dependencyName.slice(1)}`;
@@ -119,19 +164,16 @@ function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
119
164
  if (!tsNode)
120
165
  return false;
121
166
  const type = checker.getTypeAtLocation(tsNode);
122
- // Check if it's a primitive type
123
- if (type.flags &
124
- (typescript_1.TypeFlags.String |
125
- typescript_1.TypeFlags.StringLike |
126
- typescript_1.TypeFlags.StringLiteral |
127
- typescript_1.TypeFlags.Number |
128
- typescript_1.TypeFlags.Boolean |
129
- typescript_1.TypeFlags.Null |
130
- typescript_1.TypeFlags.Undefined |
131
- typescript_1.TypeFlags.Void |
132
- typescript_1.TypeFlags.Never |
133
- typescript_1.TypeFlags.BigInt |
134
- typescript_1.TypeFlags.ESSymbol)) {
167
+ // why: the `*Like` flags, never the bare ones. Each `*Like` is the union of
168
+ // a primitive and its LITERAL form, and a literal type is what this repo's
169
+ // own `global-const-style` produces — it appends `as const`, which turns
170
+ // `0` into the numeric literal type `0`. The list used to name
171
+ // `StringLiteral` explicitly but `Number` and `Boolean` bare, so a string
172
+ // literal was primitive while `0 as const` and `true as const` were not:
173
+ // the same value lost the exemption the moment a sibling fixer froze it
174
+ // (#2238). Enum members come in through `NumberLike`/`StringLike` for the
175
+ // same reason — an enum member is a primitive at runtime.
176
+ if (isPrimitiveType(type)) {
135
177
  return true;
136
178
  }
137
179
  // Check if it's an array type
@@ -1310,6 +1352,46 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
1310
1352
  dependencyEntryIdentifiers = entries;
1311
1353
  return entries;
1312
1354
  }
1355
+ /**
1356
+ * Whether a parameter's binding comes out of a DESTRUCTURING pattern - a
1357
+ * destructured prop rather than a positional parameter.
1358
+ *
1359
+ * why: this is the line `noUnusedParameters` draws, measured against the
1360
+ * consumer's own compiler. It reports a destructured property wherever it
1361
+ * sits, including a rest sibling and including an `_`-prefixed one; a
1362
+ * positional parameter it reports only when the name does not start with
1363
+ * `_`. Keying on the pattern rather than on the name is therefore the
1364
+ * accurate test for the destructured case, and `_`-prefixing must NOT be
1365
+ * read as an opt-out there - tsc ignores the name inside a pattern, so
1366
+ * honouring it would readmit the strand on a binding that merely looks
1367
+ * deliberate.
1368
+ *
1369
+ * The AssignmentPattern step is carried but not reachable from the report
1370
+ * path: measured, the rule emits nothing at all for a DEFAULTED
1371
+ * destructured prop (`({ label, revision = 0 })`), so the fixer never gets
1372
+ * to judge one. It stays because dropping it would classify such a prop as
1373
+ * positional the moment that reporting gap is closed, which is the strand
1374
+ * this function exists to prevent - not because a fixture exercises it.
1375
+ */
1376
+ function isDestructuredParameter(name) {
1377
+ if (name.type !== utils_1.AST_NODE_TYPES.Identifier)
1378
+ return false;
1379
+ let node = name.parent;
1380
+ while (node) {
1381
+ if (node.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
1382
+ node.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
1383
+ return true;
1384
+ }
1385
+ if (node.type === utils_1.AST_NODE_TYPES.Property ||
1386
+ node.type === utils_1.AST_NODE_TYPES.RestElement ||
1387
+ node.type === utils_1.AST_NODE_TYPES.AssignmentPattern) {
1388
+ node = node.parent;
1389
+ continue;
1390
+ }
1391
+ return false;
1392
+ }
1393
+ return false;
1394
+ }
1313
1395
  /**
1314
1396
  * Whether removing `element`'s binding from every dependency array that
1315
1397
  * lists it would leave the binding with no reader in the file.
@@ -1336,11 +1418,35 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
1336
1418
  * inference is safe in the conservative direction — if a sibling report is
1337
1419
  * suppressed the cost is a withheld fix, never a dangling reference.
1338
1420
  *
1339
- * Parameters are deliberately exempt. An unread parameter is not an unused
1340
- * BINDING to either instrument — `no-unused-vars` runs with `args: 'none'`
1341
- * and `noUnusedLocals` does not cover parameters so declining there would
1342
- * withhold a fix without preventing any breakage, and it would silently
1343
- * settle the reporting question #1621 defers.
1421
+ * A DESTRUCTURED parameter is not exempt; a positional one still is.
1422
+ *
1423
+ * why: the instrument that covers a parameter is `noUnusedParameters`, not
1424
+ * `noUnusedLocals`, and the consumer sets `noUnusedParameters: true` while
1425
+ * setting `noUnusedLocals: false` so a blanket parameter exemption reads
1426
+ * the one flag the consumer has turned OFF and misses the one it has turned
1427
+ * ON. It also cites `no-unused-vars` with `args: 'none'`, which is not the
1428
+ * consumer's setting either. Stranding a destructured prop is therefore a
1429
+ * red build there, from a `tsc --noEmit` gate, and `no-unused-props` cannot
1430
+ * clean up after this fixer because that rule is report-only.
1431
+ *
1432
+ * Nearly every dependency entry in a React component is a destructured
1433
+ * prop, so this is the common case rather than an edge: 21 composed
1434
+ * findings over 13 distinct fixture shapes, every one of them a
1435
+ * destructured prop this fixer stranded (#2236).
1436
+ *
1437
+ * The POSITIONAL parameter stays exempt, and deliberately so. tsc reports
1438
+ * one too, so this IS a residue — but the composed sweep over 23,785
1439
+ * fixtures reached zero of them on its own, and declining there would
1440
+ * settle the reporting question #1621 defers on unmeasured ground while
1441
+ * withholding fixes the corpus shows to be safe, including the #2208
1442
+ * margin-comment arm whose subject is a positional parameter. The residue
1443
+ * is carried deliberately and it is WITNESSED: the control fixture added
1444
+ * with this fix is now the sweep's only surviving stranded parameter, so
1445
+ * the cost of the exemption is visible in that guard's dump rather than
1446
+ * asserted here and forgotten.
1447
+ *
1448
+ * The report stands either way. Only the rewrite is withheld, which is the
1449
+ * conservative direction the rest of this function already takes.
1344
1450
  */
1345
1451
  function wouldStrandBinding(element) {
1346
1452
  const identifier = unwrapExpression(element);
@@ -1349,7 +1455,9 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
1349
1455
  const variable = resolveBinding(identifier);
1350
1456
  if (!variable || variable.defs.length === 0)
1351
1457
  return false;
1352
- if (variable.defs.some((def) => def.type === 'Parameter')) {
1458
+ const parameterDefs = variable.defs.filter((def) => def.type === 'Parameter');
1459
+ if (parameterDefs.length === variable.defs.length &&
1460
+ !parameterDefs.some((def) => isDestructuredParameter(def.name))) {
1353
1461
  return false;
1354
1462
  }
1355
1463
  const entries = collectDependencyEntryIdentifiers();
@@ -256,8 +256,39 @@ export type DiagnosticsFn = (before: string[], after: string[]) => string[];
256
256
  * they run now match `tsconfig.json`.
257
257
  */
258
258
  export declare const introducedDiagnosticsIgnoringUnused: DiagnosticsFn;
259
+ export declare const multisetIntersect: (lists: string[][]) => string[];
260
+ /** The `TS####` prefix a corpus diagnostic is built with in `compileCorpus`. */
261
+ export declare const codeOf: (diagnostic: string) => string;
262
+ export declare const canonicalizeDiagnostic: (diagnostic: string) => string;
263
+ export type DiagnosticIntersection = {
264
+ /** The shared multiset, carrying the FIRST list's original message strings. */
265
+ common: string[];
266
+ /**
267
+ * Everything ANY mode saw that the intersection did not keep - what the
268
+ * oracle silenced. Taken over every list, not just the first: the artifact
269
+ * class this discount exists for is the STRICT-only diagnostic, which never
270
+ * appears in the default mode's list and so is invisible to a counter read
271
+ * off `lists[0]` alone.
272
+ */
273
+ dropped: string[];
274
+ /**
275
+ * The subset of `dropped` that a code-only intersection would have KEPT: the
276
+ * TS code is present in every list with the multiplicity to match, and only
277
+ * the message text diverged. A genuinely mode-specific diagnostic is not in
278
+ * here, so this counter isolates exactly the silent-divergence failure and a
279
+ * guard can assert it to zero.
280
+ *
281
+ * Measured zero across all three consuming guards. If one ever appears, the
282
+ * remedy is to extend `canonicalizeDiagnostic` when it is another print-order
283
+ * divergence, or to record that one shape by name in the guard's own baseline
284
+ * when the two modes genuinely produce different diagnostics under the same
285
+ * TS code. Widening the comparison back toward the code alone is not a
286
+ * remedy: it resumes silencing, which is the defect.
287
+ */
288
+ codeMatchedDrops: string[];
289
+ };
259
290
  /**
260
- * The multiset every list shares.
291
+ * The multiset every list shares, with an account of what it discarded.
261
292
  *
262
293
  * This is the mode discount, and it is the one place the cross-corpus oracles
263
294
  * deliberately differ from `fixer-type-safety`'s. That guard UNIONS the
@@ -286,8 +317,12 @@ export declare const introducedDiagnosticsIgnoringUnused: DiagnosticsFn;
286
317
  * The intersection only bites where both modes could judge. A pair whose input
287
318
  * compiles under one mode only has a single-element intersection, so for it
288
319
  * this is identical to the union.
320
+ *
321
+ * Because dropping is how this oracle produces a clean, every drop is counted
322
+ * rather than discarded in silence, and `codeMatchedDrops` separates "the modes
323
+ * disagree" from "the modes agree and the message merely printed differently".
289
324
  */
290
- export declare const multisetIntersect: (lists: string[][]) => string[];
325
+ export declare const intersectDiagnostics: (lists: string[][]) => DiagnosticIntersection;
291
326
  /**
292
327
  * `ts.createProgram` SILENTLY drops a root file whose name it does not
293
328
  * recognize as TypeScript: `corpus.ts-7` is filtered out with no diagnostic at
@@ -26,7 +26,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
27
27
  };
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.DECLARES_INTO_SHARED_SCOPE = exports.withSuffix = exports.multisetIntersect = exports.introducedDiagnosticsIgnoringUnused = exports.introducedDiagnostics = exports.isFragmentArtifact = exports.isUnusedDeclaration = exports.missingNameOf = exports.UNRESOLVED_NAME = exports.multisetDiff = exports.compileCorpus = exports.MODES = exports.STUBS = exports.SUBSTITUTION_PARTNER_STUBS = exports.FIRESTORE_CLIENT_STUB = exports.FIRESTORE_ADMIN_STUB = exports.FIRESTORE_COMMON = exports.TIMESTAMP_ADMIN = exports.REACT_STUB = void 0;
29
+ exports.DECLARES_INTO_SHARED_SCOPE = exports.withSuffix = exports.intersectDiagnostics = exports.canonicalizeDiagnostic = exports.codeOf = exports.multisetIntersect = exports.introducedDiagnosticsIgnoringUnused = exports.introducedDiagnostics = exports.isFragmentArtifact = exports.isUnusedDeclaration = exports.missingNameOf = exports.UNRESOLVED_NAME = exports.multisetDiff = exports.compileCorpus = exports.MODES = exports.STUBS = exports.SUBSTITUTION_PARTNER_STUBS = exports.FIRESTORE_CLIENT_STUB = exports.FIRESTORE_ADMIN_STUB = exports.FIRESTORE_COMMON = exports.TIMESTAMP_ADMIN = exports.REACT_STUB = void 0;
30
30
  const path_1 = __importDefault(require("path"));
31
31
  const ts = __importStar(require("typescript"));
32
32
  /**
@@ -725,8 +725,266 @@ exports.introducedDiagnostics = introducedDiagnostics;
725
725
  */
726
726
  const introducedDiagnosticsIgnoringUnused = (before, after) => (0, exports.introducedDiagnostics)(before, after).filter((d) => !(0, exports.isUnusedDeclaration)(d));
727
727
  exports.introducedDiagnosticsIgnoringUnused = introducedDiagnosticsIgnoringUnused;
728
+ const multisetIntersect = (lists) => intersectBy(lists, exports.canonicalizeDiagnostic);
729
+ exports.multisetIntersect = multisetIntersect;
730
+ /** The `TS####` prefix a corpus diagnostic is built with in `compileCorpus`. */
731
+ const codeOf = (diagnostic) => {
732
+ const colon = diagnostic.indexOf(':');
733
+ return colon < 0 ? diagnostic : diagnostic.slice(0, colon);
734
+ };
735
+ exports.codeOf = codeOf;
736
+ const OPENERS = '<([{';
737
+ const CLOSERS = '>)]}';
738
+ const isOpener = (char) => OPENERS.includes(char);
739
+ const isCloser = (char) => CLOSERS.includes(char);
740
+ /**
741
+ * Every scan below treats `=>` as ONE token. Its `>` is not a closing bracket,
742
+ * and letting it decrement the depth drives a function-typed member negative
743
+ * and splits the rest of the string in the wrong places.
744
+ */
745
+ const skipsArrow = (text, index) => text[index] === '=' && text[index + 1] === '>';
746
+ /** Splits on `separator` where it sits at bracket depth zero. */
747
+ const splitTopLevel = (text, separator) => {
748
+ const parts = [];
749
+ let depth = 0;
750
+ let start = 0;
751
+ for (let index = 0; index < text.length; index++) {
752
+ const char = text[index];
753
+ if (skipsArrow(text, index)) {
754
+ index++;
755
+ continue;
756
+ }
757
+ if (isOpener(char))
758
+ depth++;
759
+ else if (isCloser(char))
760
+ depth--;
761
+ else if (char === separator && depth === 0) {
762
+ parts.push(text.slice(start, index));
763
+ start = index + 1;
764
+ }
765
+ }
766
+ parts.push(text.slice(start));
767
+ return parts;
768
+ };
769
+ /**
770
+ * A bracketed group's contents are an element LIST - tuple elements, type
771
+ * arguments, parameters, object members - separated by `,` or `;`. Splitting
772
+ * one as if it were a union is how `{ a: A | B; }` canonicalizes to
773
+ * `{B; | a: A}`: the `|` belongs to the member's type, not to the body.
774
+ */
775
+ const splitListElements = (text) => {
776
+ const parts = [];
777
+ const separators = [];
778
+ let depth = 0;
779
+ let start = 0;
780
+ for (let index = 0; index < text.length; index++) {
781
+ const char = text[index];
782
+ if (skipsArrow(text, index)) {
783
+ index++;
784
+ continue;
785
+ }
786
+ if (isOpener(char))
787
+ depth++;
788
+ else if (isCloser(char))
789
+ depth--;
790
+ else if ((char === ',' || char === ';') && depth === 0) {
791
+ parts.push(text.slice(start, index));
792
+ separators.push(char);
793
+ start = index + 1;
794
+ }
795
+ }
796
+ parts.push(text.slice(start));
797
+ return { parts, separators };
798
+ };
799
+ /** Splits on a depth-zero `=>`, so `(x: A) => B | C` unions only `B | C`. */
800
+ const splitTopLevelArrow = (text) => {
801
+ const parts = [];
802
+ let depth = 0;
803
+ let start = 0;
804
+ for (let index = 0; index < text.length; index++) {
805
+ const char = text[index];
806
+ if (skipsArrow(text, index)) {
807
+ if (depth === 0) {
808
+ parts.push(text.slice(start, index));
809
+ start = index + 2;
810
+ }
811
+ index++;
812
+ continue;
813
+ }
814
+ if (isOpener(char))
815
+ depth++;
816
+ else if (isCloser(char))
817
+ depth--;
818
+ }
819
+ parts.push(text.slice(start));
820
+ return parts;
821
+ };
822
+ /** The depth-zero `:` separating a member's name from its type, or -1. */
823
+ const labelEnd = (text) => {
824
+ let depth = 0;
825
+ for (let index = 0; index < text.length; index++) {
826
+ const char = text[index];
827
+ if (skipsArrow(text, index)) {
828
+ index++;
829
+ continue;
830
+ }
831
+ if (isOpener(char))
832
+ depth++;
833
+ else if (isCloser(char))
834
+ depth--;
835
+ else if (char === ':' && depth === 0)
836
+ return index;
837
+ }
838
+ return -1;
839
+ };
840
+ /** The index of the bracket closing the one at `open`, or -1 if unbalanced. */
841
+ const matchingBracket = (text, open) => {
842
+ let depth = 0;
843
+ for (let index = open; index < text.length; index++) {
844
+ const char = text[index];
845
+ if (skipsArrow(text, index)) {
846
+ index++;
847
+ continue;
848
+ }
849
+ if (isOpener(char))
850
+ depth++;
851
+ else if (isCloser(char)) {
852
+ depth--;
853
+ if (depth === 0)
854
+ return index;
855
+ if (depth < 0)
856
+ return -1;
857
+ }
858
+ }
859
+ return -1;
860
+ };
861
+ /** Rewrites the contents of every bracketed group as an element list. */
862
+ const sortUnionsInside = (text) => {
863
+ let out = '';
864
+ let index = 0;
865
+ while (index < text.length) {
866
+ const char = text[index];
867
+ if (isOpener(char)) {
868
+ const close = matchingBracket(text, index);
869
+ if (close < 0) {
870
+ out += char;
871
+ index++;
872
+ continue;
873
+ }
874
+ out += char + sortUnionsList(text.slice(index + 1, close)) + text[close];
875
+ index = close + 1;
876
+ continue;
877
+ }
878
+ out += char;
879
+ index++;
880
+ }
881
+ return out;
882
+ };
883
+ const sortUnionsList = (text) => {
884
+ const { parts, separators } = splitListElements(text);
885
+ const sorted = parts.map((part) => sortUnionsMember(part.trim()));
886
+ return sorted.reduce((out, part, index) => index ? `${out}${separators[index - 1]} ${part}` : part, '');
887
+ };
888
+ /** `name: T` unions only `T`; the name is not a union member. */
889
+ const sortUnionsMember = (text) => {
890
+ const colon = labelEnd(text);
891
+ if (colon < 0)
892
+ return sortUnions(text);
893
+ return `${text.slice(0, colon)}: ${sortUnions(text.slice(colon + 1).trim())}`;
894
+ };
895
+ /**
896
+ * Only UNION members are reordered. Tuple elements, type arguments, parameters
897
+ * and object members print in DECLARATION order, which is a property of the
898
+ * source and stable across programs, so sorting those would erase a real
899
+ * difference rather than a spurious one - they are rebuilt in place.
900
+ */
901
+ const sortUnions = (text) => {
902
+ const arrowParts = splitTopLevelArrow(text);
903
+ if (arrowParts.length > 1) {
904
+ return arrowParts
905
+ .map((part, index) => index === arrowParts.length - 1
906
+ ? sortUnions(part.trim())
907
+ : sortUnionsInside(part.trim()))
908
+ .join(' => ');
909
+ }
910
+ if (splitListElements(text).separators.length)
911
+ return sortUnionsList(text);
912
+ const members = splitTopLevel(text, '|').map((member) => sortUnionsInside(member.trim()));
913
+ if (members.length === 1)
914
+ return members[0];
915
+ return [...members].sort().join(' | ');
916
+ };
728
917
  /**
729
- * The multiset every list shares.
918
+ * A diagnostic message with every printed union in a canonical member order.
919
+ *
920
+ * TypeScript orders a union's members by type ID - the order the checker
921
+ * happened to CREATE those types in - not by anything in the source, and a
922
+ * type ID is per-program. Two programs over the same files can therefore print
923
+ * one union two ways:
924
+ *
925
+ * TS2345: ... parameter of type 'Record<string, unknown> | unknown[]'.
926
+ * TS2345: ... parameter of type 'unknown[] | Record<string, unknown>'.
927
+ *
928
+ * That matters because `intersectDiagnostics` is a SILENCING oracle: what it
929
+ * drops becomes a clean. Comparing raw messages discarded a diagnostic present
930
+ * in BOTH modes as strict-only, which is why `cross-fixture-fixer-type-safety`
931
+ * read 0 findings while `fixer-type-safety` - which unions instead of
932
+ * intersecting - baselines the same 4 `enforce-microdiff` TS2345 pairs (#2235).
933
+ *
934
+ * Rewriting is confined to single-quoted spans because that is where and only
935
+ * where TypeScript prints a type; a string-literal type nested in one is
936
+ * printed with double quotes, so the spans do not nest. This is a COMPARISON
937
+ * KEY - every diagnostic reported to a maintainer is the original string.
938
+ */
939
+ const canonicalCache = new Map();
940
+ const canonicalizeDiagnostic = (diagnostic) => {
941
+ const cached = canonicalCache.get(diagnostic);
942
+ if (cached !== undefined)
943
+ return cached;
944
+ const canonical = diagnostic.replace(/'([^']*)'/g, (_match, inner) => `'${sortUnions(inner)}'`);
945
+ canonicalCache.set(diagnostic, canonical);
946
+ return canonical;
947
+ };
948
+ exports.canonicalizeDiagnostic = canonicalizeDiagnostic;
949
+ /** The entries of `list` that `kept` does not cover, compared by `keyOf`. */
950
+ const subtractBy = (kept, list, keyOf) => {
951
+ const counts = new Map();
952
+ for (const diagnostic of kept) {
953
+ const key = keyOf(diagnostic);
954
+ counts.set(key, (counts.get(key) || 0) + 1);
955
+ }
956
+ return list.filter((diagnostic) => {
957
+ const key = keyOf(diagnostic);
958
+ const remaining = counts.get(key) || 0;
959
+ if (remaining <= 0)
960
+ return true;
961
+ counts.set(key, remaining - 1);
962
+ return false;
963
+ });
964
+ };
965
+ const intersectBy = (lists, keyOf) => {
966
+ if (!lists.length)
967
+ return [];
968
+ let common = [...lists[0]];
969
+ for (const list of lists.slice(1)) {
970
+ const counts = new Map();
971
+ for (const diagnostic of list) {
972
+ const key = keyOf(diagnostic);
973
+ counts.set(key, (counts.get(key) || 0) + 1);
974
+ }
975
+ common = common.filter((diagnostic) => {
976
+ const key = keyOf(diagnostic);
977
+ const remaining = counts.get(key) || 0;
978
+ if (remaining <= 0)
979
+ return false;
980
+ counts.set(key, remaining - 1);
981
+ return true;
982
+ });
983
+ }
984
+ return common;
985
+ };
986
+ /**
987
+ * The multiset every list shares, with an account of what it discarded.
730
988
  *
731
989
  * This is the mode discount, and it is the one place the cross-corpus oracles
732
990
  * deliberately differ from `fixer-type-safety`'s. That guard UNIONS the
@@ -755,27 +1013,26 @@ exports.introducedDiagnosticsIgnoringUnused = introducedDiagnosticsIgnoringUnuse
755
1013
  * The intersection only bites where both modes could judge. A pair whose input
756
1014
  * compiles under one mode only has a single-element intersection, so for it
757
1015
  * this is identical to the union.
1016
+ *
1017
+ * Because dropping is how this oracle produces a clean, every drop is counted
1018
+ * rather than discarded in silence, and `codeMatchedDrops` separates "the modes
1019
+ * disagree" from "the modes agree and the message merely printed differently".
758
1020
  */
759
- const multisetIntersect = (lists) => {
760
- if (!lists.length)
761
- return [];
762
- let common = [...lists[0]];
763
- for (const list of lists.slice(1)) {
764
- const counts = new Map();
765
- for (const diagnostic of list) {
766
- counts.set(diagnostic, (counts.get(diagnostic) || 0) + 1);
767
- }
768
- common = common.filter((diagnostic) => {
769
- const remaining = counts.get(diagnostic) || 0;
770
- if (remaining <= 0)
771
- return false;
772
- counts.set(diagnostic, remaining - 1);
773
- return true;
774
- });
775
- }
776
- return common;
1021
+ const intersectDiagnostics = (lists) => {
1022
+ const common = intersectBy(lists, exports.canonicalizeDiagnostic);
1023
+ const byCode = intersectBy(lists, exports.codeOf);
1024
+ return {
1025
+ common,
1026
+ // Compared by the CANONICAL key, like `common` itself: subtracting by raw
1027
+ // string would report the other mode's spelling of a KEPT diagnostic as a
1028
+ // drop, which is the #2235 confusion inverted.
1029
+ dropped: lists.flatMap((list) => subtractBy(common, list, exports.canonicalizeDiagnostic)),
1030
+ // Canonical-key equality implies code equality, so `byCode` contains
1031
+ // `common` as a multiset and the difference is exactly the divergence.
1032
+ codeMatchedDrops: subtractBy(common, byCode, exports.canonicalizeDiagnostic),
1033
+ };
777
1034
  };
778
- exports.multisetIntersect = multisetIntersect;
1035
+ exports.intersectDiagnostics = intersectDiagnostics;
779
1036
  /**
780
1037
  * `ts.createProgram` SILENTLY drops a root file whose name it does not
781
1038
  * recognize as TypeScript: `corpus.ts-7` is filtered out with no diagnostic at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.196",
3
+ "version": "1.20.198",
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.198",
4
+ "date": "2026-08-31T11:06:34.784Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-entire-object-hook-deps",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2238
11
+ ],
12
+ "summary": "treat a literal type as the primitive it is (closes #2238)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.197",
18
+ "date": "2026-08-31T08:50:13.198Z",
19
+ "rules": [
20
+ {
21
+ "name": "no-entire-object-hook-deps",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2236
25
+ ],
26
+ "summary": "decline a removal that strands a destructured prop (closes #2236)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.20.196",
4
32
  "date": "2026-08-31T07:14:16.368Z",