@sarj/eslint-plugin 2.14.0 → 2.16.0

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/README.md CHANGED
@@ -12,7 +12,7 @@ import sarj from "@sarj/eslint-plugin";
12
12
  export default [...sarj.configs.recommended];
13
13
  ```
14
14
 
15
- 45 rules. Each rule's source under `src/rules/` carries its own `@fileoverview` rationale plus `meta.docs.description` + `meta.messages` — read the file for the full reasoning, including the false positives it deliberately does not fire on.
15
+ 46 rules. Each rule's source under `src/rules/` carries its own `@fileoverview` rationale plus `meta.docs.description` + `meta.messages` — read the file for the full reasoning, including the false positives it deliberately does not fire on.
16
16
 
17
17
  Presets: `recommended` (warn-first), `strict` (every rule at error), `style-guide` (formatting/naming subset).
18
18
 
@@ -68,6 +68,7 @@ Both distilled from two years of PR-review comments across ~1,065 PRs.
68
68
  |---|---|---|
69
69
  | `no-zod-native-enum` | `z.nativeEnum(...)` and `z.enum(SomeTsEnum)` — the schema-layer back door around `no-enum`. Autofixes an inline string-literal object to `z.enum([...])`. | warn / error |
70
70
  | `prefer-module-level-constant` | A literal-only `const` collection (array, object, `Set`, `Map`, `Object.freeze`) or non-global regex declared inside a function body, never mutated and never escaping — hoist it to module scope. Options: `minElements` (default 3), `checkRegex`, `ignoreTestFiles`. | warn / error |
71
+ | `prefer-non-nullable-collection` | An array type explicitly combined with `null`/`undefined`, creating two equivalent empty states. | warn / error |
71
72
 
72
73
  ## Options
73
74
 
package/dist/index.cjs CHANGED
@@ -46,7 +46,7 @@ var GENERATED_MARKER_RE = /(?:@generated\b|generated (?:with|by)|generated (?:gr
46
46
  function isTestFile(filename) {
47
47
  const normalized = filename.replaceAll("\\", "/");
48
48
  const base = normalized.slice(normalized.lastIndexOf("/") + 1);
49
- if (/\.(test|spec|e2e|integration)\.[cm]?[jt]sx?$/.test(base)) {
49
+ if (/[.\-_](test|spec|e2e)\.[cm]?[jt]sx?$/.test(base) || /\.integration\.[cm]?[jt]sx?$/.test(base)) {
50
50
  return true;
51
51
  }
52
52
  return /(^|\/)(tests?|__tests__|__mocks__|fixtures|e2e|integration)\//.test(normalized);
@@ -467,9 +467,9 @@ function restatesStatementHead(body, statement) {
467
467
 
468
468
  // src/rules/no-comment-cruft.ts
469
469
  var LEADING_PREAMBLE_MIN = 4;
470
- var STEP_NARRATION_RE = /^(?:first(?:ly)?|second(?:ly)?|third(?:ly)?|then|next|after(?:wards| that)?|finally|lastly|now)\s*[,:]\s*\S|^step\s+\d+\b/i;
470
+ var STEP_NARRATION_RE = /^(?:first(?:ly)?|second(?:ly)?|third(?:ly)?|then|next|after(?:wards| that)?|finally|lastly|now)\s*[,:]\s*\S/i;
471
471
  var META_COMMENTARY_RE = /\b(?:for now|keeping (?:it|this) simple|could be (?:refactored|improved|cleaned up|simplified)|refactor(?:ed|ing)? (?:later|this)|not sure (?:if|whether|why|how)|quick[- ](?:and[- ]dirty|fix)|(?:a |bit of a )?hacky|is a hack|temporary (?:solution|workaround|fix|hack)|revisit (?:this|later|below)|clean (?:this|it) up|not ideal|placeholder for now)\b/i;
472
- var DIRECTIVE_RE = /^(eslint\b|eslint-|sarj-noqa\b|@ts-|prettier-ignore|prettier\b|biome-|c8\b|v8\b|istanbul\b|@type\b|@vite|webpack|<reference|<amd|global\b|noinspection|todo\b|fixme\b|hack\b|xxx\b)/i;
472
+ var DIRECTIVE_RE = /^(eslint\b|eslint-|sarj-noqa\b|@ts-|prettier-ignore|prettier\b|biome-|c8\b|v8\b|istanbul\b|@type\b|@vite|webpack|<reference|<amd|global\b|noinspection|hack\b|xxx\b)/i;
473
473
  var LICENSE_RE = /copyright|licen[cs]ed?|spdx|permission is hereby granted|all rights reserved/i;
474
474
  var ENUMERATED_ITEM_RE = /^(?:\d+[.):]|[-*•])\s+\S/;
475
475
  var ENUMERATED_ITEM_MIN_WORDS = 3;
@@ -535,7 +535,8 @@ function isSectionLabel(text) {
535
535
  }
536
536
  var HELPER_OPENER_RE = /^(?:a\s+)?helper\s+(?:function|method|component|hook|class|type|util(?:ity)?)\b/i;
537
537
  var LETS_RE = /^let'?s\s+(?:not\s+|just\s+|now\s+|first\s+)?(?:add|append|assign|await|build|calculate|call|check|clear|close|compute|convert|copy|count|create|declare|decrement|define|delete|extract|fetch|filter|find|format|generate|get|handle|increment|init|initialise|initialize|insert|iterate|join|load|log|loop|map|merge|open|parse|print|process|push|read|remove|render|reset|return|save|send|set|setup|sort|split|start|stop|store|update|validate|wrap|write)(?:s|es|ed|ing)?\b/i;
538
- var ENUMERATION_RE = /^(?:\d+[.)]\s+\S|phase\s+\d+\b)/i;
538
+ var ENUMERATION_RE = /^(?:\d+[.)]\s+\S|(?:phase|step)\s+\d+\b)/i;
539
+ var DUMMY_TRANSLATION_RE = /^(?:increment|return|returns|get|gets|set\b(?! up\b)|sets\b(?! up\b)|function to|method to)\b/i;
539
540
  var DIAGRAM_ARROW_RE = /[-=~]{2,}>|<[-=~]{2,}/;
540
541
  var CODE_KEYWORD_RE = /^(import |export |const |let |var |function\b|class |interface |type \w|enum |return\b|throw |await |async |if\s*\(|for\s*\(|while\s*\(|switch\s*\(|new |console\.)/;
541
542
  var CODE_TAIL_RE = /[;{}()]\s*$|=>\s*$|,\s*$/;
@@ -583,6 +584,14 @@ function isRedundantNarration(body, statementBelow, standalone, isolatedEnumerat
583
584
  if (STEP_NARRATION_RE.test(t)) return true;
584
585
  if (META_COMMENTARY_RE.test(t) && !JUSTIFICATION_RE.test(t)) return true;
585
586
  if (HELPER_OPENER_RE.test(t) || LETS_RE.test(t)) return true;
587
+ const words = t.split(/\s+/);
588
+ if (words.length <= 4 && DUMMY_TRANSLATION_RE.test(t) && !/[():=]/.test(t)) {
589
+ const lowerT = t.toLowerCase();
590
+ const rationaleWords = ["when", "because", "if", "so that", "due to", "for", "instead of", "to prevent", "to avoid", "only"];
591
+ if (!rationaleWords.some((w) => lowerT.includes(w))) {
592
+ if (words.length > 1) return true;
593
+ }
594
+ }
586
595
  if (!nested && isSectionLabel(t)) return true;
587
596
  if (isolatedEnumeration && ENUMERATION_RE.test(t)) return true;
588
597
  }
@@ -650,7 +659,8 @@ var no_comment_cruft_default = import_utils4.ESLintUtils.RuleCreator(
650
659
  commentedOutCode: "Commented-out code \u2014 delete it; git history remembers.",
651
660
  sectionBanner: "Section-banner / region comment \u2014 structure code with functions, not ASCII rules.",
652
661
  fileHeaderPreamble: "File-header comment preamble \u2014 use a brief doc comment for the why, not a block of `//` lines.",
653
- redundantNarration: "Comment narrates the code \u2014 delete it or say *why*, not *what*. Code is self-documenting."
662
+ redundantNarration: "Comment narrates the code \u2014 delete it or say *why*, not *what*. Code is self-documenting.",
663
+ untrackedTodo: "Untracked TODO/FIXME marker \u2014 add an issue ticket or context link."
654
664
  }
655
665
  },
656
666
  defaultOptions: [],
@@ -704,6 +714,16 @@ var no_comment_cruft_default = import_utils4.ESLintUtils.RuleCreator(
704
714
  if (isJsDoc(comment) || !isStandalone(comment)) continue;
705
715
  if (LICENSE_RE.test(comment.value)) continue;
706
716
  const texts = comment.value.split("\n").map(stripCommentMarker).filter((l) => l.length > 0 && !isDirective(l));
717
+ const firstText = texts[0];
718
+ if (firstText !== void 0 && /^(?:todo|fixme)\b/i.test(firstText)) {
719
+ if (!runCitesAReference(comments, i)) {
720
+ context.report({
721
+ node: comment,
722
+ messageId: "untrackedTodo"
723
+ });
724
+ }
725
+ continue;
726
+ }
707
727
  if (texts.some(isBanner)) {
708
728
  context.report({ node: comment, messageId: "sectionBanner" });
709
729
  continue;
@@ -5143,8 +5163,8 @@ var single_public_export_default = import_utils33.ESLintUtils.RuleCreator(
5143
5163
  if (base.endsWith(".d.ts")) return {};
5144
5164
  if (TEST_FILE_RE.test(base)) return {};
5145
5165
  if (isTestFile(context.filename)) return {};
5146
- const stem2 = stemOf(base);
5147
- if (!JUNK_DRAWER_STEMS.has(stem2.toLowerCase())) return {};
5166
+ const stem3 = stemOf(base);
5167
+ if (!JUNK_DRAWER_STEMS.has(stem3.toLowerCase())) return {};
5148
5168
  return {
5149
5169
  Program(node) {
5150
5170
  const { names, hasReExport, candidate } = summarizeExports(node.body);
@@ -5152,11 +5172,11 @@ var single_public_export_default = import_utils33.ESLintUtils.RuleCreator(
5152
5172
  if (names !== 1 || candidate === null) return;
5153
5173
  if (CONVENTIONAL_BUCKET_EXPORTS.has(candidate.name)) return;
5154
5174
  const expected = kebabCase2(candidate.name);
5155
- if (stem2 === expected) return;
5175
+ if (stem3 === expected) return;
5156
5176
  context.report({
5157
5177
  node: candidate.node,
5158
5178
  messageId: "renameJunkDrawer",
5159
- data: { stem: stem2, name: candidate.name, expected }
5179
+ data: { stem: stem3, name: candidate.name, expected }
5160
5180
  });
5161
5181
  }
5162
5182
  };
@@ -5310,7 +5330,7 @@ function createSqlListener(handler) {
5310
5330
  }
5311
5331
 
5312
5332
  // src/rules/no-offset-pagination.ts
5313
- var OFFSET_PAGINATION = /\bOFFSET\s+(?:\?\d*|:\w+|@\w+|\$\d+|\d+)/i;
5333
+ var OFFSET_PAGINATION = /\bOFFSET\s+(?:%s|%\(\w+\)s|\?\d*|:\w+|@\w+|\$\d+|\d+)/i;
5314
5334
  var OFFSET_GATE = /offset/i;
5315
5335
  var no_offset_pagination_default = import_utils35.ESLintUtils.RuleCreator(
5316
5336
  (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
@@ -5502,7 +5522,6 @@ var no_positional_tuple_return_default = import_utils36.ESLintUtils.RuleCreator(
5502
5522
  // src/rules/no-repeated-string-literal.ts
5503
5523
  var import_utils37 = require("@typescript-eslint/utils");
5504
5524
  var MIN_LENGTH = 40;
5505
- var MIN_OCCURRENCES = 3;
5506
5525
  var MIN_DISTINCT_SCOPES = 2;
5507
5526
  var PREVIEW_LENGTH = 40;
5508
5527
  var SQL_KEYWORD_RE = /\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|VALUES|ON CONFLICT|RETURNING|GROUP BY|ORDER BY)\b/;
@@ -5584,9 +5603,6 @@ var no_repeated_string_literal_default = import_utils37.ESLintUtils.RuleCreator(
5584
5603
  },
5585
5604
  "Program:exit": () => {
5586
5605
  for (const [value, nodes] of occurrences) {
5587
- if (nodes.length < MIN_OCCURRENCES) {
5588
- continue;
5589
- }
5590
5606
  const distinctScopes = new Set(
5591
5607
  nodes.map((node) => scopes.get(node)).filter((scope) => scope != null)
5592
5608
  );
@@ -7261,6 +7277,442 @@ var no_tautological_expect_default = import_utils50.ESLintUtils.RuleCreator(
7261
7277
  }
7262
7278
  });
7263
7279
 
7280
+ // src/rules/require-interface-for-injected-service.ts
7281
+ var import_utils51 = require("@typescript-eslint/utils");
7282
+ var CONFIGISH_TYPE_RE = /(?:Options|Opts|Config|Configuration|Settings|Params|Props|Args|Env|Environment|Callbacks|Flags)$/;
7283
+ var CONFIGISH_NAME_RE = /^(?:options|opts|config|configuration|settings|params|props|args|env|environment|callbacks|flags|logger|log|clock)$/i;
7284
+ var HTTP_TRANSPORT_TYPE_RE = /^(?:KyInstance|AxiosInstance|Session)$/;
7285
+ var TRANSPORT_WRAPPER_NAME_RE = /Client$/;
7286
+ var ROUTER_FACTORY_NAME = "Router";
7287
+ var FRAMEWORK_HTTP_TYPES = /* @__PURE__ */ new Set(["Request", "Response", "NextFunction"]);
7288
+ var isExportedClass = (node) => node.parent.type === import_utils51.AST_NODE_TYPES.ExportNamedDeclaration || node.parent.type === import_utils51.AST_NODE_TYPES.ExportDefaultDeclaration;
7289
+ var qualifiedName = (name) => name.type === import_utils51.AST_NODE_TYPES.Identifier ? name.name : name.type === import_utils51.AST_NODE_TYPES.TSQualifiedName ? `${qualifiedName(name.left)}.${name.right.name}` : "";
7290
+ var typeReferenceName = (annotated) => {
7291
+ let target = annotated;
7292
+ if (target.type === import_utils51.AST_NODE_TYPES.TSParameterProperty) target = target.parameter;
7293
+ if (target.type === import_utils51.AST_NODE_TYPES.AssignmentPattern) target = target.left;
7294
+ if (target.type !== import_utils51.AST_NODE_TYPES.Identifier) return null;
7295
+ const annotation = target.typeAnnotation?.typeAnnotation;
7296
+ if (annotation === void 0 || annotation.type !== import_utils51.AST_NODE_TYPES.TSTypeReference) {
7297
+ return null;
7298
+ }
7299
+ const { typeName } = annotation;
7300
+ const rightmost = typeName.type === import_utils51.AST_NODE_TYPES.Identifier ? typeName.name : typeName.type === import_utils51.AST_NODE_TYPES.TSQualifiedName ? typeName.right.name : null;
7301
+ if (rightmost === null) return null;
7302
+ return { name: target.name, typeName: rightmost, display: qualifiedName(typeName) };
7303
+ };
7304
+ var readConstructor = (ctor) => {
7305
+ const body = ctor.value.body;
7306
+ const storedFrom = /* @__PURE__ */ new Set();
7307
+ let constructedFields = 0;
7308
+ if (body !== null && body !== void 0) {
7309
+ for (const statement of body.body) {
7310
+ if (statement.type !== import_utils51.AST_NODE_TYPES.ExpressionStatement) continue;
7311
+ const expression = statement.expression;
7312
+ if (expression.type !== import_utils51.AST_NODE_TYPES.AssignmentExpression || expression.operator !== "=" || expression.left.type !== import_utils51.AST_NODE_TYPES.MemberExpression || expression.left.object.type !== import_utils51.AST_NODE_TYPES.ThisExpression) {
7313
+ continue;
7314
+ }
7315
+ const source = expression.right;
7316
+ if (source.type === import_utils51.AST_NODE_TYPES.NewExpression) {
7317
+ constructedFields += 1;
7318
+ } else if (source.type === import_utils51.AST_NODE_TYPES.Identifier) {
7319
+ storedFrom.add(source.name);
7320
+ } else if (source.type === import_utils51.AST_NODE_TYPES.MemberExpression && source.object.type === import_utils51.AST_NODE_TYPES.Identifier) {
7321
+ storedFrom.add(source.object.name);
7322
+ }
7323
+ }
7324
+ }
7325
+ const collaborators = [];
7326
+ for (const parameter of ctor.value.params) {
7327
+ const reference = typeReferenceName(parameter);
7328
+ if (reference === null) continue;
7329
+ const stored = parameter.type === import_utils51.AST_NODE_TYPES.TSParameterProperty || storedFrom.has(reference.name);
7330
+ if (!stored) continue;
7331
+ if (CONFIGISH_TYPE_RE.test(reference.typeName)) continue;
7332
+ if (CONFIGISH_NAME_RE.test(reference.name)) continue;
7333
+ collaborators.push(reference);
7334
+ }
7335
+ return { collaborators, constructedFields };
7336
+ };
7337
+ var subtreeHas = (root, found) => {
7338
+ let hit = false;
7339
+ const visit = (current) => {
7340
+ if (hit) return;
7341
+ if (found(current)) {
7342
+ hit = true;
7343
+ return;
7344
+ }
7345
+ for (const key of Object.keys(current)) {
7346
+ if (key === "parent") continue;
7347
+ const value = current[key];
7348
+ for (const child of Array.isArray(value) ? value : [value]) {
7349
+ if (child !== null && typeof child === "object" && typeof child.type === "string") {
7350
+ visit(child);
7351
+ }
7352
+ }
7353
+ }
7354
+ };
7355
+ visit(root);
7356
+ return hit;
7357
+ };
7358
+ var isFrameworkWiring = (body) => subtreeHas(body, (node) => {
7359
+ if (node.type === import_utils51.AST_NODE_TYPES.CallExpression) {
7360
+ const { callee } = node;
7361
+ if (callee.type === import_utils51.AST_NODE_TYPES.Identifier) return callee.name === ROUTER_FACTORY_NAME;
7362
+ return callee.type === import_utils51.AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === import_utils51.AST_NODE_TYPES.Identifier && callee.property.name === ROUTER_FACTORY_NAME;
7363
+ }
7364
+ return node.type === import_utils51.AST_NODE_TYPES.TSTypeReference && node.typeName.type === import_utils51.AST_NODE_TYPES.TSQualifiedName && FRAMEWORK_HTTP_TYPES.has(node.typeName.right.name);
7365
+ });
7366
+ var stem2 = (name) => name.replace(/^I(?=[A-Z])/, "").replace(/Impl$/, "");
7367
+ var fileInterfaceNames = (program) => {
7368
+ const names = [];
7369
+ for (const statement of program.body) {
7370
+ const declaration = statement.type === import_utils51.AST_NODE_TYPES.ExportNamedDeclaration ? statement.declaration : statement;
7371
+ if (declaration?.type === import_utils51.AST_NODE_TYPES.TSInterfaceDeclaration) names.push(declaration.id.name);
7372
+ }
7373
+ return names;
7374
+ };
7375
+ var isTransportWrapper = (className, collaborators, program) => {
7376
+ const [only] = collaborators;
7377
+ if (collaborators.length !== 1 || only === void 0) return false;
7378
+ if (!HTTP_TRANSPORT_TYPE_RE.test(only.typeName)) return false;
7379
+ if (!TRANSPORT_WRAPPER_NAME_RE.test(className)) return false;
7380
+ const target = stem2(className);
7381
+ return !fileInterfaceNames(program).some((name) => {
7382
+ const other = stem2(name);
7383
+ return target.endsWith(other) || other.endsWith(target);
7384
+ });
7385
+ };
7386
+ var publicMethodNames = (body) => {
7387
+ const names = [];
7388
+ for (const member of body.body) {
7389
+ if (member.type !== import_utils51.AST_NODE_TYPES.MethodDefinition) continue;
7390
+ if (member.kind !== "method" || member.static) continue;
7391
+ if (member.accessibility === "private" || member.accessibility === "protected") continue;
7392
+ if (member.key.type === import_utils51.AST_NODE_TYPES.PrivateIdentifier) continue;
7393
+ if (member.key.type === import_utils51.AST_NODE_TYPES.Identifier) names.push(member.key.name);
7394
+ else names.push("\u2026");
7395
+ }
7396
+ return names;
7397
+ };
7398
+ var require_interface_for_injected_service_default = import_utils51.ESLintUtils.RuleCreator(
7399
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7400
+ )({
7401
+ name: "require-interface-for-injected-service",
7402
+ meta: {
7403
+ type: "suggestion",
7404
+ docs: {
7405
+ description: "An exported service class with constructor-injected collaborators must implement an interface, so consumers depend on a port they can substitute instead of mocking the class."
7406
+ },
7407
+ schema: [],
7408
+ messages: {
7409
+ requireInterface: "`{{name}}` stores injected collaborator(s) ({{deps}}) but implements no interface, so every consumer depends on this concrete class and can only be tested by mocking it. Declare an interface with its public method signature(s) ({{methods}}) and `class {{name}} implements <Interface>`."
7410
+ }
7411
+ },
7412
+ defaultOptions: [],
7413
+ create(context) {
7414
+ const { filename } = context;
7415
+ if (isTestFile(filename) || isStoryFile(filename) || isScriptFile(filename)) return {};
7416
+ if (isGeneratedFile(filename, context.sourceCode.getText())) return {};
7417
+ return {
7418
+ ClassDeclaration(node) {
7419
+ if (node.id === null) return;
7420
+ if (!isExportedClass(node)) return;
7421
+ if (node.abstract === true) return;
7422
+ if (node.superClass !== null) return;
7423
+ if (node.implements.length > 0) return;
7424
+ if (node.decorators.length > 0) return;
7425
+ const ctor = node.body.body.find(
7426
+ (member) => member.type === import_utils51.AST_NODE_TYPES.MethodDefinition && member.kind === "constructor"
7427
+ );
7428
+ if (ctor === void 0) return;
7429
+ const { collaborators, constructedFields } = readConstructor(ctor);
7430
+ if (collaborators.length === 0) return;
7431
+ if (constructedFields > collaborators.length) return;
7432
+ if (isFrameworkWiring(node.body)) return;
7433
+ if (isTransportWrapper(node.id.name, collaborators, context.sourceCode.ast)) return;
7434
+ const methods = publicMethodNames(node.body);
7435
+ if (methods.length === 0) return;
7436
+ context.report({
7437
+ node: node.id,
7438
+ messageId: "requireInterface",
7439
+ data: {
7440
+ name: node.id.name,
7441
+ deps: collaborators.map((c) => `${c.name}: ${c.display}`).join(", "),
7442
+ methods: methods.join(", ")
7443
+ }
7444
+ });
7445
+ }
7446
+ };
7447
+ }
7448
+ });
7449
+
7450
+ // src/rules/prefer-non-nullable-collection.ts
7451
+ var import_utils52 = require("@typescript-eslint/utils");
7452
+ var ARRAY_TYPE_NAMES = /* @__PURE__ */ new Set(["Array", "ReadonlyArray"]);
7453
+ function propertyName(node) {
7454
+ const key = node.key;
7455
+ if (key.type === import_utils52.AST_NODE_TYPES.Identifier) return key.name;
7456
+ if (key.type === import_utils52.AST_NODE_TYPES.Literal) return String(key.value);
7457
+ return "collection";
7458
+ }
7459
+ function isArrayType(node) {
7460
+ if (node.type === import_utils52.AST_NODE_TYPES.TSArrayType) return true;
7461
+ return node.type === import_utils52.AST_NODE_TYPES.TSTypeReference && node.typeName.type === import_utils52.AST_NODE_TYPES.Identifier && ARRAY_TYPE_NAMES.has(node.typeName.name);
7462
+ }
7463
+ function isNullishType(node) {
7464
+ return node.type === import_utils52.AST_NODE_TYPES.TSNullKeyword || node.type === import_utils52.AST_NODE_TYPES.TSUndefinedKeyword;
7465
+ }
7466
+ function isNullableArrayOnly(node) {
7467
+ const values = node.types.filter((member) => !isNullishType(member));
7468
+ return values.length > 0 && values.length < node.types.length && values.every(isArrayType);
7469
+ }
7470
+ var prefer_non_nullable_collection_default = import_utils52.ESLintUtils.RuleCreator(
7471
+ (name) => `https://github.com/sarj-ai/standards/tree/main/packages/typescript#${name}`
7472
+ )({
7473
+ name: "prefer-non-nullable-collection",
7474
+ meta: {
7475
+ type: "suggestion",
7476
+ docs: {
7477
+ description: "Require array types to use an empty array instead of explicit nullish unions with two equivalent empty states."
7478
+ },
7479
+ schema: [],
7480
+ messages: {
7481
+ preferNonNullableCollection: "`{{name}}` is a nullable array, so nullish and `[]` represent the same empty collection. Use a non-null array and default omitted values to `[]`."
7482
+ }
7483
+ },
7484
+ defaultOptions: [],
7485
+ create(context) {
7486
+ const normalizedFilename = context.filename.replaceAll("\\", "/");
7487
+ if (isTestFile(context.filename) || isGeneratedFile(context.filename, context.sourceCode.text) || /\/(?:vendor|vendored)\//u.test(normalizedFilename)) {
7488
+ return {};
7489
+ }
7490
+ function checkOptionalProperty(node) {
7491
+ const annotation = node.typeAnnotation?.typeAnnotation;
7492
+ if (annotation === void 0) return;
7493
+ if (annotation.type !== import_utils52.AST_NODE_TYPES.TSUnionType || !isNullableArrayOnly(annotation)) {
7494
+ return;
7495
+ }
7496
+ context.report({
7497
+ node,
7498
+ messageId: "preferNonNullableCollection",
7499
+ data: { name: propertyName(node) }
7500
+ });
7501
+ }
7502
+ return {
7503
+ TSPropertySignature: checkOptionalProperty,
7504
+ PropertyDefinition: checkOptionalProperty,
7505
+ TSTypeAliasDeclaration(node) {
7506
+ if (node.typeAnnotation.type !== import_utils52.AST_NODE_TYPES.TSUnionType) return;
7507
+ if (!isNullableArrayOnly(node.typeAnnotation)) return;
7508
+ context.report({
7509
+ node,
7510
+ messageId: "preferNonNullableCollection",
7511
+ data: { name: node.id.name }
7512
+ });
7513
+ }
7514
+ };
7515
+ }
7516
+ });
7517
+
7518
+ // src/rules/primary-export-file-name.ts
7519
+ var import_utils53 = require("@typescript-eslint/utils");
7520
+ var CONVENTIONAL_BUCKET_EXPORTS2 = /* @__PURE__ */ new Set(["cn"]);
7521
+ var GENERIC_ENTRYPOINTS = /* @__PURE__ */ new Set(["main", "run", "cli", "setup", "teardown", "execute"]);
7522
+ var ACRONYM_OVERRIDES2 = [
7523
+ [/OAuth/g, "Oauth"],
7524
+ [/GraphQL/g, "Graphql"],
7525
+ [/gRPC/g, "Grpc"]
7526
+ ];
7527
+ var CAMEL_BOUNDARY_RE2 = /(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g;
7528
+ var basename2 = (filename) => filename.split(/[/\\]/).pop() ?? filename;
7529
+ var stemOf2 = (base) => base.replace(/\.(test|spec|config|d|stories)\.[cm]?[jt]sx?$/i, "").replace(/\.[cm]?[jt]sx?$/i, "");
7530
+ var kebabCase3 = (name) => {
7531
+ let normalized = name;
7532
+ for (const [pattern, replacement] of ACRONYM_OVERRIDES2) {
7533
+ normalized = normalized.replace(pattern, replacement);
7534
+ }
7535
+ return normalized.replace(CAMEL_BOUNDARY_RE2, "-").toLowerCase();
7536
+ };
7537
+ var isFunctionOrClass = (node) => node.type === import_utils53.AST_NODE_TYPES.ArrowFunctionExpression || node.type === import_utils53.AST_NODE_TYPES.FunctionExpression;
7538
+ var findPrimaryExport = (body) => {
7539
+ let exportCount = 0;
7540
+ let primaryCandidate = null;
7541
+ for (const statement of body) {
7542
+ if (statement.type === import_utils53.AST_NODE_TYPES.ExportAllDeclaration) {
7543
+ return null;
7544
+ }
7545
+ if (statement.type === import_utils53.AST_NODE_TYPES.ExportDefaultDeclaration) {
7546
+ exportCount += 1;
7547
+ const decl = statement.declaration;
7548
+ if ((decl.type === import_utils53.AST_NODE_TYPES.FunctionDeclaration || decl.type === import_utils53.AST_NODE_TYPES.ClassDeclaration) && decl.id !== null) {
7549
+ primaryCandidate = { name: decl.id.name, node: statement, isDefault: true };
7550
+ } else if (decl.type === import_utils53.AST_NODE_TYPES.Identifier) {
7551
+ primaryCandidate = { name: decl.name, node: statement, isDefault: true };
7552
+ }
7553
+ } else if (statement.type === import_utils53.AST_NODE_TYPES.ExportNamedDeclaration) {
7554
+ if (statement.source !== null) {
7555
+ return null;
7556
+ }
7557
+ const decl = statement.declaration;
7558
+ if (decl === null) {
7559
+ exportCount += statement.specifiers.length;
7560
+ if (statement.specifiers.length === 1 && primaryCandidate === null) {
7561
+ const spec = statement.specifiers[0];
7562
+ if (spec && spec.exported.type === import_utils53.AST_NODE_TYPES.Identifier) {
7563
+ primaryCandidate = { name: spec.exported.name, node: statement, isDefault: false };
7564
+ }
7565
+ }
7566
+ } else if (decl.type === import_utils53.AST_NODE_TYPES.FunctionDeclaration || decl.type === import_utils53.AST_NODE_TYPES.ClassDeclaration) {
7567
+ if (decl.id !== null) {
7568
+ exportCount += 1;
7569
+ if (primaryCandidate === null) {
7570
+ primaryCandidate = { name: decl.id.name, node: statement, isDefault: false };
7571
+ }
7572
+ }
7573
+ } else if (decl.type === import_utils53.AST_NODE_TYPES.VariableDeclaration) {
7574
+ for (const declarator of decl.declarations) {
7575
+ if (declarator.id.type === import_utils53.AST_NODE_TYPES.Identifier) {
7576
+ exportCount += 1;
7577
+ if (primaryCandidate === null && declarator.init && isFunctionOrClass(declarator.init)) {
7578
+ primaryCandidate = { name: declarator.id.name, node: statement, isDefault: false };
7579
+ }
7580
+ }
7581
+ }
7582
+ } else if (decl.type === import_utils53.AST_NODE_TYPES.TSTypeAliasDeclaration || decl.type === import_utils53.AST_NODE_TYPES.TSInterfaceDeclaration) {
7583
+ exportCount += 1;
7584
+ if (primaryCandidate === null) {
7585
+ primaryCandidate = { name: decl.id.name, node: statement, isDefault: false };
7586
+ }
7587
+ }
7588
+ }
7589
+ }
7590
+ if (primaryCandidate === null || exportCount > 2) {
7591
+ return null;
7592
+ }
7593
+ return { ...primaryCandidate, count: exportCount };
7594
+ };
7595
+ var primary_export_file_name_default = import_utils53.ESLintUtils.RuleCreator(
7596
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7597
+ )({
7598
+ name: "primary-export-file-name",
7599
+ meta: {
7600
+ type: "suggestion",
7601
+ docs: {
7602
+ description: "Enforce semantic naming alignment: a file with a single primary export must be named after that export."
7603
+ },
7604
+ schema: [],
7605
+ messages: {
7606
+ primaryExportMismatch: "Module stem '{{stem}}' does not match its primary export '{{name}}' \u2014 rename the file to '{{expected}}{{ext}}' to describe its responsibility."
7607
+ }
7608
+ },
7609
+ defaultOptions: [],
7610
+ create(context) {
7611
+ const base = basename2(context.filename);
7612
+ if (base.endsWith(".d.ts") || base.startsWith("index.") || base.startsWith("_")) return {};
7613
+ if (isTestFile(context.filename) || isGeneratedFile(context.filename, context.sourceCode.text)) return {};
7614
+ const stem3 = stemOf2(base);
7615
+ if (stem3 === "page" || stem3 === "layout" || stem3 === "loading" || stem3 === "error" || stem3 === "not-found" || stem3 === "route" || stem3 === "__root" || stem3 === "config" || stem3 === "constants" || stem3 === "types" || stem3.startsWith("$") || stem3.startsWith("[")) {
7616
+ return {};
7617
+ }
7618
+ return {
7619
+ Program(node) {
7620
+ const primary = findPrimaryExport(node.body);
7621
+ if (primary === null) return;
7622
+ if (CONVENTIONAL_BUCKET_EXPORTS2.has(primary.name) || GENERIC_ENTRYPOINTS.has(primary.name)) return;
7623
+ const expectedStem = kebabCase3(primary.name);
7624
+ if (stem3 === expectedStem) return;
7625
+ const ext = base.endsWith(".tsx") ? ".tsx" : ".ts";
7626
+ context.report({
7627
+ node: primary.node,
7628
+ messageId: "primaryExportMismatch",
7629
+ data: { stem: stem3, name: primary.name, expected: expectedStem, ext }
7630
+ });
7631
+ }
7632
+ };
7633
+ }
7634
+ });
7635
+
7636
+ // src/rules/no-implicit-attribute-access.ts
7637
+ var import_utils54 = require("@typescript-eslint/utils");
7638
+ var EXCLUDED_BASES = /* @__PURE__ */ new Set([
7639
+ "environ",
7640
+ "headers",
7641
+ "cookies",
7642
+ "session",
7643
+ "redis",
7644
+ "cache",
7645
+ "state",
7646
+ "config",
7647
+ "env",
7648
+ "process",
7649
+ "localStorage",
7650
+ "sessionStorage"
7651
+ ]);
7652
+ function getBaseName(node) {
7653
+ if (node.type === import_utils54.AST_NODE_TYPES.Identifier) {
7654
+ return node.name;
7655
+ }
7656
+ if (node.type === import_utils54.AST_NODE_TYPES.MemberExpression && node.property.type === import_utils54.AST_NODE_TYPES.Identifier) {
7657
+ return node.property.name;
7658
+ }
7659
+ return null;
7660
+ }
7661
+ var no_implicit_attribute_access_default = import_utils54.ESLintUtils.RuleCreator(
7662
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7663
+ )({
7664
+ name: "no-implicit-attribute-access",
7665
+ meta: {
7666
+ type: "problem",
7667
+ docs: {
7668
+ description: "Disallow imperative dictionary access with string literals; use declarative Zod schemas."
7669
+ },
7670
+ schema: [],
7671
+ messages: {
7672
+ noImplicitAttributeAccess: "Imperative lookup for '{{key}}' \u2014 if the key is known, parse the object declaratively with a Zod schema instead."
7673
+ }
7674
+ },
7675
+ defaultOptions: [],
7676
+ create(context) {
7677
+ return {
7678
+ MemberExpression(node) {
7679
+ if (!node.computed) {
7680
+ return;
7681
+ }
7682
+ if (node.property.type === import_utils54.AST_NODE_TYPES.Literal && typeof node.property.value === "string") {
7683
+ const baseName = getBaseName(node.object);
7684
+ if (!baseName || !EXCLUDED_BASES.has(baseName)) {
7685
+ context.report({
7686
+ node,
7687
+ messageId: "noImplicitAttributeAccess",
7688
+ data: { key: node.property.value }
7689
+ });
7690
+ }
7691
+ }
7692
+ },
7693
+ CallExpression(node) {
7694
+ const callee = node.callee;
7695
+ if (callee.type === import_utils54.AST_NODE_TYPES.MemberExpression) {
7696
+ const method = callee.property.type === import_utils54.AST_NODE_TYPES.Identifier ? callee.property.name : null;
7697
+ if (method === "get") {
7698
+ const arg = node.arguments[0];
7699
+ if (arg && arg.type === import_utils54.AST_NODE_TYPES.Literal && typeof arg.value === "string") {
7700
+ const baseName = getBaseName(callee.object);
7701
+ if (!baseName || !EXCLUDED_BASES.has(baseName)) {
7702
+ context.report({
7703
+ node,
7704
+ messageId: "noImplicitAttributeAccess",
7705
+ data: { key: arg.value }
7706
+ });
7707
+ }
7708
+ }
7709
+ }
7710
+ }
7711
+ }
7712
+ };
7713
+ }
7714
+ });
7715
+
7264
7716
  // src/index.ts
7265
7717
  var rules = {
7266
7718
  "enforce-file-structure": enforce_file_structure_default,
@@ -7289,6 +7741,7 @@ var rules = {
7289
7741
  "no-unsafe-cast": no_unsafe_cast_default,
7290
7742
  "prefer-string-literal-union": prefer_string_literal_union_default,
7291
7743
  "single-public-export": single_public_export_default,
7744
+ "primary-export-file-name": primary_export_file_name_default,
7292
7745
  "no-silent-promise-catch": no_silent_promise_catch_default,
7293
7746
  "require-fetch-timeout": require_fetch_timeout_default,
7294
7747
  "require-schema-validate-search": require_schema_validate_search_default,
@@ -7307,12 +7760,15 @@ var rules = {
7307
7760
  "jsdoc-restates-signature": jsdoc_restates_signature_default,
7308
7761
  "no-restated-comment": no_restated_comment_default,
7309
7762
  "trailing-value-narration": trailing_value_narration_default,
7310
- "no-tautological-expect": no_tautological_expect_default
7763
+ "no-tautological-expect": no_tautological_expect_default,
7764
+ "require-interface-for-injected-service": require_interface_for_injected_service_default,
7765
+ "prefer-non-nullable-collection": prefer_non_nullable_collection_default,
7766
+ "no-implicit-attribute-access": no_implicit_attribute_access_default
7311
7767
  };
7312
7768
  var plugin = {
7313
7769
  meta: {
7314
7770
  name: "@sarj/eslint-plugin",
7315
- version: "2.14.0"
7771
+ version: "2.16.0"
7316
7772
  },
7317
7773
  rules,
7318
7774
  configs: {
@@ -7337,13 +7793,17 @@ var plugin = {
7337
7793
  "@sarj/prefer-discriminated-union": "warn",
7338
7794
  "@sarj/no-comment-cruft": "warn",
7339
7795
  // Frontend / styling — distilled from frontend PR-review mining.
7340
- "@sarj/prefer-semantic-colors": ["warn", { requireSemanticTokens: true }],
7796
+ "@sarj/prefer-semantic-colors": [
7797
+ "warn",
7798
+ { requireSemanticTokens: true }
7799
+ ],
7341
7800
  // Ported from sarj-python-lint (SARJ), corpus-validated FP~0.
7342
7801
  "@sarj/no-fat-try-blocks": "warn",
7343
7802
  "@sarj/no-cors-wildcard-with-credentials": "warn",
7344
7803
  "@sarj/no-secret-in-log": "warn",
7345
7804
  "@sarj/no-unsafe-cast": "warn",
7346
7805
  "@sarj/single-public-export": "warn",
7806
+ "@sarj/primary-export-file-name": "warn",
7347
7807
  "@sarj/prefer-string-literal-union": "warn",
7348
7808
  // Mined from 2y of PR review feedback + 5-repo code-smell audit (2026-07).
7349
7809
  "@sarj/require-fetch-timeout": "warn",
@@ -7387,7 +7847,14 @@ var plugin = {
7387
7847
  // Measured across 5,819 .ts/.tsx files (1,003 of them test files) in
7388
7848
  // six internal repos plus got / hono / swr / trpc: 3 hits, 3 true
7389
7849
  // positives, 0 false positives.
7390
- "@sarj/no-tautological-expect": "warn"
7850
+ "@sarj/no-tautological-expect": "warn",
7851
+ // Substitutability: an exported service class with injected
7852
+ // collaborators and no interface above it can only be tested by
7853
+ // mocking. 11-repo sweep: 229 exported classes, 82% already carry a
7854
+ // port, 29 fire, 28 of them true positives.
7855
+ "@sarj/require-interface-for-injected-service": "warn",
7856
+ "@sarj/prefer-non-nullable-collection": "warn",
7857
+ "@sarj/no-implicit-attribute-access": "warn"
7391
7858
  }
7392
7859
  },
7393
7860
  strict: {
@@ -7415,13 +7882,17 @@ var plugin = {
7415
7882
  "@sarj/no-comment-cruft": "error",
7416
7883
  // Frontend / styling — distilled from frontend PR-review mining. Stylistic,
7417
7884
  // no autofix → warn (rollout should prove the FP rate before raising it).
7418
- "@sarj/prefer-semantic-colors": ["error", { requireSemanticTokens: true }],
7885
+ "@sarj/prefer-semantic-colors": [
7886
+ "error",
7887
+ { requireSemanticTokens: true }
7888
+ ],
7419
7889
  // Ported from sarj-python-lint (SARJ), corpus-validated FP~0.
7420
7890
  "@sarj/no-fat-try-blocks": "error",
7421
7891
  "@sarj/no-cors-wildcard-with-credentials": "error",
7422
7892
  "@sarj/no-secret-in-log": "error",
7423
7893
  "@sarj/no-unsafe-cast": "error",
7424
7894
  "@sarj/single-public-export": "error",
7895
+ "@sarj/primary-export-file-name": "error",
7425
7896
  // Promoted to error 2026-07-25 — strict means strict (user directive).
7426
7897
  "@sarj/prefer-string-literal-union": "error",
7427
7898
  // Mined from 2y of PR review feedback + 5-repo code-smell audit (2026-07).
@@ -7457,7 +7928,13 @@ var plugin = {
7457
7928
  "@sarj/jsdoc-restates-signature": "error",
7458
7929
  "@sarj/trailing-value-narration": "error",
7459
7930
  // TS half of SARJ057 — see the `recommended` block for the measurement.
7460
- "@sarj/no-tautological-expect": "error"
7931
+ "@sarj/no-tautological-expect": "error",
7932
+ // Substitutability: the TS sibling of the Python `prefer-real-store-in-tests`
7933
+ // / `prefer-library-fake` wave. The convention already exists in the
7934
+ // corpus (175 `implements` clauses vs 29 hits), so strict enforces it.
7935
+ "@sarj/require-interface-for-injected-service": "error",
7936
+ "@sarj/prefer-non-nullable-collection": "error",
7937
+ "@sarj/no-implicit-attribute-access": "error"
7461
7938
  }
7462
7939
  }
7463
7940
  }