@sarj/eslint-plugin 16.36.5 → 16.36.6

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/dist/index.cjs CHANGED
@@ -2314,8 +2314,13 @@ function commentSuppressesChecking(text, kind) {
2314
2314
 
2315
2315
  // src/rules/_closed-object-contract.ts
2316
2316
  var import_typescript2 = __toESM(require("typescript"), 1);
2317
- function closedObjectContract(type, checker) {
2318
- return visitContract(type, { checker, active: /* @__PURE__ */ new Set(), completed: /* @__PURE__ */ new WeakMap() });
2317
+ function closedObjectContract(type, checker, singlePropertyOrigins = false) {
2318
+ return visitContract(type, {
2319
+ checker,
2320
+ active: /* @__PURE__ */ new Set(),
2321
+ completed: /* @__PURE__ */ new WeakMap(),
2322
+ origins: singlePropertyOrigins ? /* @__PURE__ */ new Map() : void 0
2323
+ });
2319
2324
  }
2320
2325
  function visitContract(type, proof) {
2321
2326
  const known2 = proof.completed.get(type);
@@ -2329,10 +2334,20 @@ function visitContract(type, proof) {
2329
2334
  }
2330
2335
  function completeObject(type, proof) {
2331
2336
  if (!ordinaryObject(type, proof.checker)) return false;
2337
+ if (proof.origins && !singleOrigins(type, proof.origins)) return false;
2332
2338
  const declarations = type.getSymbol()?.getDeclarations()?.filter((declaration) => import_typescript2.default.isInterfaceDeclaration(declaration) || import_typescript2.default.isTypeLiteralNode(declaration));
2333
2339
  if (!declarations?.length) return false;
2334
2340
  return declarations.every((declaration) => completeDeclaration(declaration, proof));
2335
2341
  }
2342
+ function singleOrigins(type, origins) {
2343
+ for (const property of type.getProperties()) {
2344
+ const declarations = property.getDeclarations();
2345
+ const declaration = declarations?.[0];
2346
+ if (!declaration || declarations?.length !== 1 || origins.has(property.name) && origins.get(property.name) !== declaration) return false;
2347
+ origins.set(property.name, declaration);
2348
+ }
2349
+ return true;
2350
+ }
2336
2351
  function ordinaryObject(type, checker) {
2337
2352
  if ((type.flags & import_typescript2.default.TypeFlags.Object) === 0 || type.isUnion() || checker.getIndexInfosOfType(type).length > 0) return false;
2338
2353
  const object = type;
@@ -2651,6 +2666,9 @@ function directEval(node) {
2651
2666
  function primitiveKeyword(node) {
2652
2667
  return node !== void 0 && (node.kind === import_typescript6.default.SyntaxKind.StringKeyword || node.kind === import_typescript6.default.SyntaxKind.NumberKeyword || node.kind === import_typescript6.default.SyntaxKind.BooleanKeyword || node.kind === import_typescript6.default.SyntaxKind.BigIntKeyword);
2653
2668
  }
2669
+ function nullishPrimitiveUnion(node) {
2670
+ return node !== void 0 && import_typescript6.default.isUnionTypeNode(node) && node.types.length === 2 && node.types.some(primitiveKeyword) && node.types.some((part) => part.kind === import_typescript6.default.SyntaxKind.UndefinedKeyword || import_typescript6.default.isLiteralTypeNode(part) && part.literal.kind === import_typescript6.default.SyntaxKind.NullKeyword);
2671
+ }
2654
2672
  function consumerAnnotation(node) {
2655
2673
  if (node.params.length !== 1) return void 0;
2656
2674
  const parameter = node.params[0];
@@ -2708,6 +2726,7 @@ function directCalls(binding3) {
2708
2726
  // src/rules/no-redundant-consumer-union.ts
2709
2727
  var CONFIG = { path: "tsconfig.json", source: '{"compilerOptions":{"strict":true,"target":"ESNext","module":"ESNext","moduleResolution":"Bundler"}}' };
2710
2728
  var EXAMPLE = 'export {}; interface Animal {readonly name: string} interface Dog extends Animal {readonly breed: string} export type KeptDog = Dog; const dog: Dog = {name: "Ada", breed: "Collie"}; function label(value: Dog | Animal): string {return value.name;} label(dog);';
2729
+ var NULLABLE_EXAMPLE = EXAMPLE.replace("readonly name: string", "readonly name: string | null").replace("return value.name;", "return value.name ?? '';");
2711
2730
  var NO_REDUNDANT_CONSUMER_UNION_DOCUMENTATION = {
2712
2731
  defaultLevel: "warning",
2713
2732
  summary: "Keep the covering object contract in a private scalar consumer union.",
@@ -2718,21 +2737,23 @@ var NO_REDUNDANT_CONSUMER_UNION_DOCUMENTATION = {
2718
2737
  limitations: [
2719
2738
  "Automatic fixes additionally require every existing identifier caller to be natively assignable to the original contract, without Any, unknown, error or unresolved types on the relevant surface. Comments inside the annotation and TypeScript suppression directives keep the finding manual. Removed named types/imports must retain an independent top-level exported type-alias or direct type-export read. Other removable annotations/assertions, the covering annotation and own-definition/self references cannot keep one another live; otherwise simultaneous fixes can introduce unused-type errors. The exact authored covering type and terminator token sequence are retained; edits that would merge distinct CR and LF terminators remain manual; no producer, call or function body is rewritten.",
2720
2739
  "Only an inline binary parameter union in a strict module's top-level private scalar consumer is supported. Exactly one parameter, an explicit broad primitive return annotation, a single pure returned scalar expression and direct identifier-argument callers are required. Type queries or type-only exports of the callable, public APIs, callbacks, contextual signatures, overloads, generics, decorators, async functions, direct eval and fresh-object callers prevent findings.",
2721
- "Both alternatives must have complete ordinary finite structural surfaces consisting only of explicitly readonly broad string, number, boolean or bigint properties. Aliases, imports, re-exports and ordinary inheritance are resolved by the parser-owned TypeScript checker. Any, unknown, missing imports, open/generic/mapped/indexed types, classes, brands, literal discriminants, methods, properties with scalar type aliases, callbacks and recursive or nested object fields are unsupported.",
2740
+ "Both alternatives must have complete ordinary finite structural surfaces consisting only of explicitly readonly broad string, number, boolean or bigint properties, optionally paired directly with null or undefined in an exact two-part union. Required and optional nullable fields may be read under the same native coverage proof. Every field must have one actual declaration origin throughout each alternative's inheritance/intersection graph. Shared ancestors retain that origin; repeated merged fields, explicit overrides and independently declared overlapping fields abstain, including valid compatible declarations. This excludes recovered conflicting properties without claiming whole-source health. Aliases, imports, re-exports and ordinary inheritance are resolved by the parser-owned TypeScript checker. Any, unknown, missing imports, open/generic/mapped/indexed types, classes, brands, literal discriminants, methods, properties with scalar type aliases, parenthesized or more complex property unions, callbacks and recursive or nested object fields are unsupported.",
2722
2741
  "Reports only proper native assignability: covered is assignable to covering and the reverse is false. Every covering member, including optional members, must exist in the covered type's enumerated declared surface. Otherwise an existing identifier may carry an incompatible extra field and be rejected after simplification; implicit Object members containing Any also cannot witness coverage. The covering type must declare every read field and a read required field; an all-optional reduced contract can reject existing callers. Identical alternatives and primitive/top/bottom redundancy remain upstream-owned; mutually assignable but nonidentical structures are excluded.",
2723
2742
  "General union simplification can change Extract, conditional types, in-narrowing, fresh-literal checking and public presentation. Those contexts are excluded even when ordinary assignment succeeds. Flat destructuring counts every bound field as read; whole-object escape, mutation, helpers, closures, reflection, serialization, dynamic reads, implicit Object capabilities and JSX prevent findings. Imported Python protocol assignability is not approximated by this rule."
2724
2743
  ],
2725
2744
  examples: [
2726
2745
  { id: "covered-alternative", title: "The private consumer needs the covering Animal contract", outcome: "match", files: [{ path: "src/case.ts", source: EXAMPLE }, CONFIG], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: EXAMPLE.replace("Dog | Animal", "Animal") }] },
2727
- { id: "public-contract", title: "A public union retains its observable alternatives", outcome: "no-match", files: [{ path: "src/case.ts", source: EXAMPLE.replace("function label", "export function label") }, CONFIG], focusPath: "src/case.ts", expectedCount: 0, public: true }
2746
+ { id: "public-contract", title: "A public union retains its observable alternatives", outcome: "no-match", files: [{ path: "src/case.ts", source: EXAMPLE.replace("function label", "export function label") }, CONFIG], focusPath: "src/case.ts", expectedCount: 0, public: true },
2747
+ { id: "nullable-covered-alternative", scenarioId: "nullable-readonly-contract", title: "A nullable name retains the covering Animal contract", outcome: "match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE }, CONFIG], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE.replace("Dog | Animal", "Animal") }] },
2748
+ { id: "nullable-domain-contract", scenarioId: "nullable-readonly-contract", title: "A literal breed keeps the authored domain contract", outcome: "no-match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE.replace("readonly breed: string", "readonly breed: 'Collie'") }, CONFIG], focusPath: "src/case.ts", expectedCount: 0, public: true }
2728
2749
  ]
2729
2750
  };
2730
2751
  function scalarFields(type, checker) {
2731
- if (!closedObjectContract(type, checker)) return null;
2752
+ if (!closedObjectContract(type, checker, true)) return null;
2732
2753
  const fields2 = /* @__PURE__ */ new Map();
2733
2754
  for (const property of type.getProperties()) {
2734
2755
  const declarations = property.getDeclarations();
2735
- if (!declarations?.length || !declarations.every((member3) => import_typescript7.default.isPropertySignature(member3) && member3.name && (import_typescript7.default.isIdentifier(member3.name) || import_typescript7.default.isStringLiteralLike(member3.name)) && member3.modifiers?.length === 1 && member3.modifiers[0]?.kind === import_typescript7.default.SyntaxKind.ReadonlyKeyword && primitiveKeyword(member3.type))) return null;
2756
+ if (!declarations?.length || !declarations.every((member3) => import_typescript7.default.isPropertySignature(member3) && member3.name && (import_typescript7.default.isIdentifier(member3.name) || import_typescript7.default.isStringLiteralLike(member3.name)) && member3.modifiers?.length === 1 && member3.modifiers[0]?.kind === import_typescript7.default.SyntaxKind.ReadonlyKeyword && (primitiveKeyword(member3.type) || nullishPrimitiveUnion(member3.type)))) return null;
2736
2757
  fields2.set(property.name, (property.flags & import_typescript7.default.SymbolFlags.Optional) === 0);
2737
2758
  }
2738
2759
  return fields2.size ? fields2 : null;
@@ -2819,7 +2840,7 @@ var import_typescript8 = __toESM(require("typescript"), 1);
2819
2840
  var CONFIG2 = { path: "tsconfig.json", source: '{"compilerOptions":{"strict":true,"target":"ESNext","module":"ESNext","moduleResolution":"Bundler"}}' };
2820
2841
  var EXAMPLE2 = 'export {}; const account = {name: "Ada", email: "a"}; function label(user: {readonly email: string; readonly name: string}): string {return user.name;} label(account);';
2821
2842
  var HELPER_EXAMPLE = 'export {}; const account = {name: "Ada", email: "a"}; function formatName(name: string): string {return name.toUpperCase();} function label(user: {email: string; name: string}): string {return formatName(user.name);} label(account);';
2822
- var NULLABLE_EXAMPLE = 'export {}; const account = {name: "Ada", email: null}; function label(user: {email: string | null; name: string}): string {return user.name;} label(account);';
2843
+ var NULLABLE_EXAMPLE2 = 'export {}; const account = {name: "Ada", email: null}; function label(user: {email: string | null; name: string}): string {return user.name;} label(account);';
2823
2844
  var PREFER_MINIMAL_PARAMETER_CONTRACT_DOCUMENTATION = {
2824
2845
  defaultLevel: "warning",
2825
2846
  summary: "Prefer the proven read-only subset for a private consumer parameter.",
@@ -2838,8 +2859,8 @@ var PREFER_MINIMAL_PARAMETER_CONTRACT_DOCUMENTATION = {
2838
2859
  { id: "used-fields", title: "Every required dependency is read", outcome: "no-match", files: [{ path: "src/case.ts", source: EXAMPLE2.replace("return user.name;", "return user.name + user.email;") }, CONFIG2], focusPath: "src/case.ts", expectedCount: 0, public: true },
2839
2860
  { id: "scalar-helper", scenarioId: "local-scalar-helper", title: "Only the name crosses a local scalar helper call", outcome: "match", files: [{ path: "src/case.ts", source: HELPER_EXAMPLE }, CONFIG2], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: HELPER_EXAMPLE.replace("email: string;", "") }] },
2840
2861
  { id: "helper-and-other-field", scenarioId: "local-scalar-helper", title: "The consumer still requires a separately read field", outcome: "no-match", files: [{ path: "src/case.ts", source: HELPER_EXAMPLE.replace("return formatName(user.name);", "return formatName(user.name) + user.email;") }, CONFIG2], focusPath: "src/case.ts", expectedCount: 0, public: true },
2841
- { id: "unread-nullable-field", scenarioId: "nullable-private-field", title: "A required nullable field is never inspected", outcome: "match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE }, CONFIG2], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE.replace("email: string | null;", "") }] },
2842
- { id: "read-nullable-field", scenarioId: "nullable-private-field", title: "An inspected nullable field keeps its contract", outcome: "no-match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE.replace("return user.name;", "return user.email ?? user.name;") }, CONFIG2], focusPath: "src/case.ts", expectedCount: 0, public: true }
2862
+ { id: "unread-nullable-field", scenarioId: "nullable-private-field", title: "A required nullable field is never inspected", outcome: "match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE2 }, CONFIG2], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE2.replace("email: string | null;", "") }] },
2863
+ { id: "read-nullable-field", scenarioId: "nullable-private-field", title: "An inspected nullable field keeps its contract", outcome: "no-match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE2.replace("return user.name;", "return user.email ?? user.name;") }, CONFIG2], focusPath: "src/case.ts", expectedCount: 0, public: true }
2843
2864
  ]
2844
2865
  };
2845
2866
  function inlineFields(node, reads) {
@@ -2853,9 +2874,6 @@ function inlineFields(node, reads) {
2853
2874
  }
2854
2875
  return fields2;
2855
2876
  }
2856
- function nullishPrimitiveUnion(node) {
2857
- return node !== void 0 && import_typescript8.default.isUnionTypeNode(node) && node.types.length === 2 && node.types.some(primitiveKeyword) && node.types.some((part) => part.kind === import_typescript8.default.SyntaxKind.UndefinedKeyword || import_typescript8.default.isLiteralTypeNode(part) && part.literal.kind === import_typescript8.default.SyntaxKind.NullKeyword);
2858
- }
2859
2877
  var prefer_minimal_parameter_contract_default = createRule({
2860
2878
  name: "prefer-minimal-parameter-contract",
2861
2879
  documentation: PREFER_MINIMAL_PARAMETER_CONTRACT_DOCUMENTATION,
@@ -4007,7 +4025,7 @@ function isSearchParamReceiver(call, services) {
4007
4025
  // package.json
4008
4026
  var package_default = {
4009
4027
  name: "@sarj/eslint-plugin",
4010
- version: "16.36.5",
4028
+ version: "16.36.6",
4011
4029
  packageManager: "npm@12.2.1",
4012
4030
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
4013
4031
  type: "module",
package/dist/index.js CHANGED
@@ -2263,8 +2263,13 @@ function commentSuppressesChecking(text, kind) {
2263
2263
 
2264
2264
  // src/rules/_closed-object-contract.ts
2265
2265
  import ts2 from "typescript";
2266
- function closedObjectContract(type, checker) {
2267
- return visitContract(type, { checker, active: /* @__PURE__ */ new Set(), completed: /* @__PURE__ */ new WeakMap() });
2266
+ function closedObjectContract(type, checker, singlePropertyOrigins = false) {
2267
+ return visitContract(type, {
2268
+ checker,
2269
+ active: /* @__PURE__ */ new Set(),
2270
+ completed: /* @__PURE__ */ new WeakMap(),
2271
+ origins: singlePropertyOrigins ? /* @__PURE__ */ new Map() : void 0
2272
+ });
2268
2273
  }
2269
2274
  function visitContract(type, proof) {
2270
2275
  const known2 = proof.completed.get(type);
@@ -2278,10 +2283,20 @@ function visitContract(type, proof) {
2278
2283
  }
2279
2284
  function completeObject(type, proof) {
2280
2285
  if (!ordinaryObject(type, proof.checker)) return false;
2286
+ if (proof.origins && !singleOrigins(type, proof.origins)) return false;
2281
2287
  const declarations = type.getSymbol()?.getDeclarations()?.filter((declaration) => ts2.isInterfaceDeclaration(declaration) || ts2.isTypeLiteralNode(declaration));
2282
2288
  if (!declarations?.length) return false;
2283
2289
  return declarations.every((declaration) => completeDeclaration(declaration, proof));
2284
2290
  }
2291
+ function singleOrigins(type, origins) {
2292
+ for (const property of type.getProperties()) {
2293
+ const declarations = property.getDeclarations();
2294
+ const declaration = declarations?.[0];
2295
+ if (!declaration || declarations?.length !== 1 || origins.has(property.name) && origins.get(property.name) !== declaration) return false;
2296
+ origins.set(property.name, declaration);
2297
+ }
2298
+ return true;
2299
+ }
2285
2300
  function ordinaryObject(type, checker) {
2286
2301
  if ((type.flags & ts2.TypeFlags.Object) === 0 || type.isUnion() || checker.getIndexInfosOfType(type).length > 0) return false;
2287
2302
  const object = type;
@@ -2600,6 +2615,9 @@ function directEval(node) {
2600
2615
  function primitiveKeyword(node) {
2601
2616
  return node !== void 0 && (node.kind === ts6.SyntaxKind.StringKeyword || node.kind === ts6.SyntaxKind.NumberKeyword || node.kind === ts6.SyntaxKind.BooleanKeyword || node.kind === ts6.SyntaxKind.BigIntKeyword);
2602
2617
  }
2618
+ function nullishPrimitiveUnion(node) {
2619
+ return node !== void 0 && ts6.isUnionTypeNode(node) && node.types.length === 2 && node.types.some(primitiveKeyword) && node.types.some((part) => part.kind === ts6.SyntaxKind.UndefinedKeyword || ts6.isLiteralTypeNode(part) && part.literal.kind === ts6.SyntaxKind.NullKeyword);
2620
+ }
2603
2621
  function consumerAnnotation(node) {
2604
2622
  if (node.params.length !== 1) return void 0;
2605
2623
  const parameter = node.params[0];
@@ -2657,6 +2675,7 @@ function directCalls(binding3) {
2657
2675
  // src/rules/no-redundant-consumer-union.ts
2658
2676
  var CONFIG = { path: "tsconfig.json", source: '{"compilerOptions":{"strict":true,"target":"ESNext","module":"ESNext","moduleResolution":"Bundler"}}' };
2659
2677
  var EXAMPLE = 'export {}; interface Animal {readonly name: string} interface Dog extends Animal {readonly breed: string} export type KeptDog = Dog; const dog: Dog = {name: "Ada", breed: "Collie"}; function label(value: Dog | Animal): string {return value.name;} label(dog);';
2678
+ var NULLABLE_EXAMPLE = EXAMPLE.replace("readonly name: string", "readonly name: string | null").replace("return value.name;", "return value.name ?? '';");
2660
2679
  var NO_REDUNDANT_CONSUMER_UNION_DOCUMENTATION = {
2661
2680
  defaultLevel: "warning",
2662
2681
  summary: "Keep the covering object contract in a private scalar consumer union.",
@@ -2667,21 +2686,23 @@ var NO_REDUNDANT_CONSUMER_UNION_DOCUMENTATION = {
2667
2686
  limitations: [
2668
2687
  "Automatic fixes additionally require every existing identifier caller to be natively assignable to the original contract, without Any, unknown, error or unresolved types on the relevant surface. Comments inside the annotation and TypeScript suppression directives keep the finding manual. Removed named types/imports must retain an independent top-level exported type-alias or direct type-export read. Other removable annotations/assertions, the covering annotation and own-definition/self references cannot keep one another live; otherwise simultaneous fixes can introduce unused-type errors. The exact authored covering type and terminator token sequence are retained; edits that would merge distinct CR and LF terminators remain manual; no producer, call or function body is rewritten.",
2669
2688
  "Only an inline binary parameter union in a strict module's top-level private scalar consumer is supported. Exactly one parameter, an explicit broad primitive return annotation, a single pure returned scalar expression and direct identifier-argument callers are required. Type queries or type-only exports of the callable, public APIs, callbacks, contextual signatures, overloads, generics, decorators, async functions, direct eval and fresh-object callers prevent findings.",
2670
- "Both alternatives must have complete ordinary finite structural surfaces consisting only of explicitly readonly broad string, number, boolean or bigint properties. Aliases, imports, re-exports and ordinary inheritance are resolved by the parser-owned TypeScript checker. Any, unknown, missing imports, open/generic/mapped/indexed types, classes, brands, literal discriminants, methods, properties with scalar type aliases, callbacks and recursive or nested object fields are unsupported.",
2689
+ "Both alternatives must have complete ordinary finite structural surfaces consisting only of explicitly readonly broad string, number, boolean or bigint properties, optionally paired directly with null or undefined in an exact two-part union. Required and optional nullable fields may be read under the same native coverage proof. Every field must have one actual declaration origin throughout each alternative's inheritance/intersection graph. Shared ancestors retain that origin; repeated merged fields, explicit overrides and independently declared overlapping fields abstain, including valid compatible declarations. This excludes recovered conflicting properties without claiming whole-source health. Aliases, imports, re-exports and ordinary inheritance are resolved by the parser-owned TypeScript checker. Any, unknown, missing imports, open/generic/mapped/indexed types, classes, brands, literal discriminants, methods, properties with scalar type aliases, parenthesized or more complex property unions, callbacks and recursive or nested object fields are unsupported.",
2671
2690
  "Reports only proper native assignability: covered is assignable to covering and the reverse is false. Every covering member, including optional members, must exist in the covered type's enumerated declared surface. Otherwise an existing identifier may carry an incompatible extra field and be rejected after simplification; implicit Object members containing Any also cannot witness coverage. The covering type must declare every read field and a read required field; an all-optional reduced contract can reject existing callers. Identical alternatives and primitive/top/bottom redundancy remain upstream-owned; mutually assignable but nonidentical structures are excluded.",
2672
2691
  "General union simplification can change Extract, conditional types, in-narrowing, fresh-literal checking and public presentation. Those contexts are excluded even when ordinary assignment succeeds. Flat destructuring counts every bound field as read; whole-object escape, mutation, helpers, closures, reflection, serialization, dynamic reads, implicit Object capabilities and JSX prevent findings. Imported Python protocol assignability is not approximated by this rule."
2673
2692
  ],
2674
2693
  examples: [
2675
2694
  { id: "covered-alternative", title: "The private consumer needs the covering Animal contract", outcome: "match", files: [{ path: "src/case.ts", source: EXAMPLE }, CONFIG], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: EXAMPLE.replace("Dog | Animal", "Animal") }] },
2676
- { id: "public-contract", title: "A public union retains its observable alternatives", outcome: "no-match", files: [{ path: "src/case.ts", source: EXAMPLE.replace("function label", "export function label") }, CONFIG], focusPath: "src/case.ts", expectedCount: 0, public: true }
2695
+ { id: "public-contract", title: "A public union retains its observable alternatives", outcome: "no-match", files: [{ path: "src/case.ts", source: EXAMPLE.replace("function label", "export function label") }, CONFIG], focusPath: "src/case.ts", expectedCount: 0, public: true },
2696
+ { id: "nullable-covered-alternative", scenarioId: "nullable-readonly-contract", title: "A nullable name retains the covering Animal contract", outcome: "match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE }, CONFIG], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE.replace("Dog | Animal", "Animal") }] },
2697
+ { id: "nullable-domain-contract", scenarioId: "nullable-readonly-contract", title: "A literal breed keeps the authored domain contract", outcome: "no-match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE.replace("readonly breed: string", "readonly breed: 'Collie'") }, CONFIG], focusPath: "src/case.ts", expectedCount: 0, public: true }
2677
2698
  ]
2678
2699
  };
2679
2700
  function scalarFields(type, checker) {
2680
- if (!closedObjectContract(type, checker)) return null;
2701
+ if (!closedObjectContract(type, checker, true)) return null;
2681
2702
  const fields2 = /* @__PURE__ */ new Map();
2682
2703
  for (const property of type.getProperties()) {
2683
2704
  const declarations = property.getDeclarations();
2684
- if (!declarations?.length || !declarations.every((member3) => ts7.isPropertySignature(member3) && member3.name && (ts7.isIdentifier(member3.name) || ts7.isStringLiteralLike(member3.name)) && member3.modifiers?.length === 1 && member3.modifiers[0]?.kind === ts7.SyntaxKind.ReadonlyKeyword && primitiveKeyword(member3.type))) return null;
2705
+ if (!declarations?.length || !declarations.every((member3) => ts7.isPropertySignature(member3) && member3.name && (ts7.isIdentifier(member3.name) || ts7.isStringLiteralLike(member3.name)) && member3.modifiers?.length === 1 && member3.modifiers[0]?.kind === ts7.SyntaxKind.ReadonlyKeyword && (primitiveKeyword(member3.type) || nullishPrimitiveUnion(member3.type)))) return null;
2685
2706
  fields2.set(property.name, (property.flags & ts7.SymbolFlags.Optional) === 0);
2686
2707
  }
2687
2708
  return fields2.size ? fields2 : null;
@@ -2768,7 +2789,7 @@ import ts8 from "typescript";
2768
2789
  var CONFIG2 = { path: "tsconfig.json", source: '{"compilerOptions":{"strict":true,"target":"ESNext","module":"ESNext","moduleResolution":"Bundler"}}' };
2769
2790
  var EXAMPLE2 = 'export {}; const account = {name: "Ada", email: "a"}; function label(user: {readonly email: string; readonly name: string}): string {return user.name;} label(account);';
2770
2791
  var HELPER_EXAMPLE = 'export {}; const account = {name: "Ada", email: "a"}; function formatName(name: string): string {return name.toUpperCase();} function label(user: {email: string; name: string}): string {return formatName(user.name);} label(account);';
2771
- var NULLABLE_EXAMPLE = 'export {}; const account = {name: "Ada", email: null}; function label(user: {email: string | null; name: string}): string {return user.name;} label(account);';
2792
+ var NULLABLE_EXAMPLE2 = 'export {}; const account = {name: "Ada", email: null}; function label(user: {email: string | null; name: string}): string {return user.name;} label(account);';
2772
2793
  var PREFER_MINIMAL_PARAMETER_CONTRACT_DOCUMENTATION = {
2773
2794
  defaultLevel: "warning",
2774
2795
  summary: "Prefer the proven read-only subset for a private consumer parameter.",
@@ -2787,8 +2808,8 @@ var PREFER_MINIMAL_PARAMETER_CONTRACT_DOCUMENTATION = {
2787
2808
  { id: "used-fields", title: "Every required dependency is read", outcome: "no-match", files: [{ path: "src/case.ts", source: EXAMPLE2.replace("return user.name;", "return user.name + user.email;") }, CONFIG2], focusPath: "src/case.ts", expectedCount: 0, public: true },
2788
2809
  { id: "scalar-helper", scenarioId: "local-scalar-helper", title: "Only the name crosses a local scalar helper call", outcome: "match", files: [{ path: "src/case.ts", source: HELPER_EXAMPLE }, CONFIG2], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: HELPER_EXAMPLE.replace("email: string;", "") }] },
2789
2810
  { id: "helper-and-other-field", scenarioId: "local-scalar-helper", title: "The consumer still requires a separately read field", outcome: "no-match", files: [{ path: "src/case.ts", source: HELPER_EXAMPLE.replace("return formatName(user.name);", "return formatName(user.name) + user.email;") }, CONFIG2], focusPath: "src/case.ts", expectedCount: 0, public: true },
2790
- { id: "unread-nullable-field", scenarioId: "nullable-private-field", title: "A required nullable field is never inspected", outcome: "match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE }, CONFIG2], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE.replace("email: string | null;", "") }] },
2791
- { id: "read-nullable-field", scenarioId: "nullable-private-field", title: "An inspected nullable field keeps its contract", outcome: "no-match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE.replace("return user.name;", "return user.email ?? user.name;") }, CONFIG2], focusPath: "src/case.ts", expectedCount: 0, public: true }
2811
+ { id: "unread-nullable-field", scenarioId: "nullable-private-field", title: "A required nullable field is never inspected", outcome: "match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE2 }, CONFIG2], focusPath: "src/case.ts", expectedCount: 1, public: true, fixedFiles: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE2.replace("email: string | null;", "") }] },
2812
+ { id: "read-nullable-field", scenarioId: "nullable-private-field", title: "An inspected nullable field keeps its contract", outcome: "no-match", files: [{ path: "src/case.ts", source: NULLABLE_EXAMPLE2.replace("return user.name;", "return user.email ?? user.name;") }, CONFIG2], focusPath: "src/case.ts", expectedCount: 0, public: true }
2792
2813
  ]
2793
2814
  };
2794
2815
  function inlineFields(node, reads) {
@@ -2802,9 +2823,6 @@ function inlineFields(node, reads) {
2802
2823
  }
2803
2824
  return fields2;
2804
2825
  }
2805
- function nullishPrimitiveUnion(node) {
2806
- return node !== void 0 && ts8.isUnionTypeNode(node) && node.types.length === 2 && node.types.some(primitiveKeyword) && node.types.some((part) => part.kind === ts8.SyntaxKind.UndefinedKeyword || ts8.isLiteralTypeNode(part) && part.literal.kind === ts8.SyntaxKind.NullKeyword);
2807
- }
2808
2826
  var prefer_minimal_parameter_contract_default = createRule({
2809
2827
  name: "prefer-minimal-parameter-contract",
2810
2828
  documentation: PREFER_MINIMAL_PARAMETER_CONTRACT_DOCUMENTATION,
@@ -3960,7 +3978,7 @@ function isSearchParamReceiver(call, services) {
3960
3978
  // package.json
3961
3979
  var package_default = {
3962
3980
  name: "@sarj/eslint-plugin",
3963
- version: "16.36.5",
3981
+ version: "16.36.6",
3964
3982
  packageManager: "npm@12.2.1",
3965
3983
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
3966
3984
  type: "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarj/eslint-plugin",
3
- "version": "16.36.5",
3
+ "version": "16.36.6",
4
4
  "packageManager": "npm@12.2.1",
5
5
  "description": "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
6
6
  "type": "module",