@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/dist/index.js CHANGED
@@ -9,7 +9,7 @@ var GENERATED_MARKER_RE = /(?:@generated\b|generated (?:with|by)|generated (?:gr
9
9
  function isTestFile(filename) {
10
10
  const normalized = filename.replaceAll("\\", "/");
11
11
  const base = normalized.slice(normalized.lastIndexOf("/") + 1);
12
- if (/\.(test|spec|e2e|integration)\.[cm]?[jt]sx?$/.test(base)) {
12
+ if (/[.\-_](test|spec|e2e)\.[cm]?[jt]sx?$/.test(base) || /\.integration\.[cm]?[jt]sx?$/.test(base)) {
13
13
  return true;
14
14
  }
15
15
  return /(^|\/)(tests?|__tests__|__mocks__|fixtures|e2e|integration)\//.test(normalized);
@@ -433,9 +433,9 @@ function restatesStatementHead(body, statement) {
433
433
 
434
434
  // src/rules/no-comment-cruft.ts
435
435
  var LEADING_PREAMBLE_MIN = 4;
436
- 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;
436
+ var STEP_NARRATION_RE = /^(?:first(?:ly)?|second(?:ly)?|third(?:ly)?|then|next|after(?:wards| that)?|finally|lastly|now)\s*[,:]\s*\S/i;
437
437
  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;
438
- 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;
438
+ 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;
439
439
  var LICENSE_RE = /copyright|licen[cs]ed?|spdx|permission is hereby granted|all rights reserved/i;
440
440
  var ENUMERATED_ITEM_RE = /^(?:\d+[.):]|[-*•])\s+\S/;
441
441
  var ENUMERATED_ITEM_MIN_WORDS = 3;
@@ -501,7 +501,8 @@ function isSectionLabel(text) {
501
501
  }
502
502
  var HELPER_OPENER_RE = /^(?:a\s+)?helper\s+(?:function|method|component|hook|class|type|util(?:ity)?)\b/i;
503
503
  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;
504
- var ENUMERATION_RE = /^(?:\d+[.)]\s+\S|phase\s+\d+\b)/i;
504
+ var ENUMERATION_RE = /^(?:\d+[.)]\s+\S|(?:phase|step)\s+\d+\b)/i;
505
+ var DUMMY_TRANSLATION_RE = /^(?:increment|return|returns|get|gets|set\b(?! up\b)|sets\b(?! up\b)|function to|method to)\b/i;
505
506
  var DIAGRAM_ARROW_RE = /[-=~]{2,}>|<[-=~]{2,}/;
506
507
  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\.)/;
507
508
  var CODE_TAIL_RE = /[;{}()]\s*$|=>\s*$|,\s*$/;
@@ -549,6 +550,14 @@ function isRedundantNarration(body, statementBelow, standalone, isolatedEnumerat
549
550
  if (STEP_NARRATION_RE.test(t)) return true;
550
551
  if (META_COMMENTARY_RE.test(t) && !JUSTIFICATION_RE.test(t)) return true;
551
552
  if (HELPER_OPENER_RE.test(t) || LETS_RE.test(t)) return true;
553
+ const words = t.split(/\s+/);
554
+ if (words.length <= 4 && DUMMY_TRANSLATION_RE.test(t) && !/[():=]/.test(t)) {
555
+ const lowerT = t.toLowerCase();
556
+ const rationaleWords = ["when", "because", "if", "so that", "due to", "for", "instead of", "to prevent", "to avoid", "only"];
557
+ if (!rationaleWords.some((w) => lowerT.includes(w))) {
558
+ if (words.length > 1) return true;
559
+ }
560
+ }
552
561
  if (!nested && isSectionLabel(t)) return true;
553
562
  if (isolatedEnumeration && ENUMERATION_RE.test(t)) return true;
554
563
  }
@@ -616,7 +625,8 @@ var no_comment_cruft_default = ESLintUtils3.RuleCreator(
616
625
  commentedOutCode: "Commented-out code \u2014 delete it; git history remembers.",
617
626
  sectionBanner: "Section-banner / region comment \u2014 structure code with functions, not ASCII rules.",
618
627
  fileHeaderPreamble: "File-header comment preamble \u2014 use a brief doc comment for the why, not a block of `//` lines.",
619
- redundantNarration: "Comment narrates the code \u2014 delete it or say *why*, not *what*. Code is self-documenting."
628
+ redundantNarration: "Comment narrates the code \u2014 delete it or say *why*, not *what*. Code is self-documenting.",
629
+ untrackedTodo: "Untracked TODO/FIXME marker \u2014 add an issue ticket or context link."
620
630
  }
621
631
  },
622
632
  defaultOptions: [],
@@ -670,6 +680,16 @@ var no_comment_cruft_default = ESLintUtils3.RuleCreator(
670
680
  if (isJsDoc(comment) || !isStandalone(comment)) continue;
671
681
  if (LICENSE_RE.test(comment.value)) continue;
672
682
  const texts = comment.value.split("\n").map(stripCommentMarker).filter((l) => l.length > 0 && !isDirective(l));
683
+ const firstText = texts[0];
684
+ if (firstText !== void 0 && /^(?:todo|fixme)\b/i.test(firstText)) {
685
+ if (!runCitesAReference(comments, i)) {
686
+ context.report({
687
+ node: comment,
688
+ messageId: "untrackedTodo"
689
+ });
690
+ }
691
+ continue;
692
+ }
673
693
  if (texts.some(isBanner)) {
674
694
  context.report({ node: comment, messageId: "sectionBanner" });
675
695
  continue;
@@ -5134,8 +5154,8 @@ var single_public_export_default = ESLintUtils29.RuleCreator(
5134
5154
  if (base.endsWith(".d.ts")) return {};
5135
5155
  if (TEST_FILE_RE.test(base)) return {};
5136
5156
  if (isTestFile(context.filename)) return {};
5137
- const stem2 = stemOf(base);
5138
- if (!JUNK_DRAWER_STEMS.has(stem2.toLowerCase())) return {};
5157
+ const stem3 = stemOf(base);
5158
+ if (!JUNK_DRAWER_STEMS.has(stem3.toLowerCase())) return {};
5139
5159
  return {
5140
5160
  Program(node) {
5141
5161
  const { names, hasReExport, candidate } = summarizeExports(node.body);
@@ -5143,11 +5163,11 @@ var single_public_export_default = ESLintUtils29.RuleCreator(
5143
5163
  if (names !== 1 || candidate === null) return;
5144
5164
  if (CONVENTIONAL_BUCKET_EXPORTS.has(candidate.name)) return;
5145
5165
  const expected = kebabCase2(candidate.name);
5146
- if (stem2 === expected) return;
5166
+ if (stem3 === expected) return;
5147
5167
  context.report({
5148
5168
  node: candidate.node,
5149
5169
  messageId: "renameJunkDrawer",
5150
- data: { stem: stem2, name: candidate.name, expected }
5170
+ data: { stem: stem3, name: candidate.name, expected }
5151
5171
  });
5152
5172
  }
5153
5173
  };
@@ -5301,7 +5321,7 @@ function createSqlListener(handler) {
5301
5321
  }
5302
5322
 
5303
5323
  // src/rules/no-offset-pagination.ts
5304
- var OFFSET_PAGINATION = /\bOFFSET\s+(?:\?\d*|:\w+|@\w+|\$\d+|\d+)/i;
5324
+ var OFFSET_PAGINATION = /\bOFFSET\s+(?:%s|%\(\w+\)s|\?\d*|:\w+|@\w+|\$\d+|\d+)/i;
5305
5325
  var OFFSET_GATE = /offset/i;
5306
5326
  var no_offset_pagination_default = ESLintUtils30.RuleCreator(
5307
5327
  (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
@@ -5493,7 +5513,6 @@ var no_positional_tuple_return_default = ESLintUtils31.RuleCreator(
5493
5513
  // src/rules/no-repeated-string-literal.ts
5494
5514
  import { AST_NODE_TYPES as AST_NODE_TYPES23, ESLintUtils as ESLintUtils32 } from "@typescript-eslint/utils";
5495
5515
  var MIN_LENGTH = 40;
5496
- var MIN_OCCURRENCES = 3;
5497
5516
  var MIN_DISTINCT_SCOPES = 2;
5498
5517
  var PREVIEW_LENGTH = 40;
5499
5518
  var SQL_KEYWORD_RE = /\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|VALUES|ON CONFLICT|RETURNING|GROUP BY|ORDER BY)\b/;
@@ -5575,9 +5594,6 @@ var no_repeated_string_literal_default = ESLintUtils32.RuleCreator(
5575
5594
  },
5576
5595
  "Program:exit": () => {
5577
5596
  for (const [value, nodes] of occurrences) {
5578
- if (nodes.length < MIN_OCCURRENCES) {
5579
- continue;
5580
- }
5581
5597
  const distinctScopes = new Set(
5582
5598
  nodes.map((node) => scopes.get(node)).filter((scope) => scope != null)
5583
5599
  );
@@ -7258,6 +7274,442 @@ var no_tautological_expect_default = ESLintUtils45.RuleCreator(
7258
7274
  }
7259
7275
  });
7260
7276
 
7277
+ // src/rules/require-interface-for-injected-service.ts
7278
+ import { AST_NODE_TYPES as AST_NODE_TYPES33, ESLintUtils as ESLintUtils46 } from "@typescript-eslint/utils";
7279
+ var CONFIGISH_TYPE_RE = /(?:Options|Opts|Config|Configuration|Settings|Params|Props|Args|Env|Environment|Callbacks|Flags)$/;
7280
+ var CONFIGISH_NAME_RE = /^(?:options|opts|config|configuration|settings|params|props|args|env|environment|callbacks|flags|logger|log|clock)$/i;
7281
+ var HTTP_TRANSPORT_TYPE_RE = /^(?:KyInstance|AxiosInstance|Session)$/;
7282
+ var TRANSPORT_WRAPPER_NAME_RE = /Client$/;
7283
+ var ROUTER_FACTORY_NAME = "Router";
7284
+ var FRAMEWORK_HTTP_TYPES = /* @__PURE__ */ new Set(["Request", "Response", "NextFunction"]);
7285
+ var isExportedClass = (node) => node.parent.type === AST_NODE_TYPES33.ExportNamedDeclaration || node.parent.type === AST_NODE_TYPES33.ExportDefaultDeclaration;
7286
+ var qualifiedName = (name) => name.type === AST_NODE_TYPES33.Identifier ? name.name : name.type === AST_NODE_TYPES33.TSQualifiedName ? `${qualifiedName(name.left)}.${name.right.name}` : "";
7287
+ var typeReferenceName = (annotated) => {
7288
+ let target = annotated;
7289
+ if (target.type === AST_NODE_TYPES33.TSParameterProperty) target = target.parameter;
7290
+ if (target.type === AST_NODE_TYPES33.AssignmentPattern) target = target.left;
7291
+ if (target.type !== AST_NODE_TYPES33.Identifier) return null;
7292
+ const annotation = target.typeAnnotation?.typeAnnotation;
7293
+ if (annotation === void 0 || annotation.type !== AST_NODE_TYPES33.TSTypeReference) {
7294
+ return null;
7295
+ }
7296
+ const { typeName } = annotation;
7297
+ const rightmost = typeName.type === AST_NODE_TYPES33.Identifier ? typeName.name : typeName.type === AST_NODE_TYPES33.TSQualifiedName ? typeName.right.name : null;
7298
+ if (rightmost === null) return null;
7299
+ return { name: target.name, typeName: rightmost, display: qualifiedName(typeName) };
7300
+ };
7301
+ var readConstructor = (ctor) => {
7302
+ const body = ctor.value.body;
7303
+ const storedFrom = /* @__PURE__ */ new Set();
7304
+ let constructedFields = 0;
7305
+ if (body !== null && body !== void 0) {
7306
+ for (const statement of body.body) {
7307
+ if (statement.type !== AST_NODE_TYPES33.ExpressionStatement) continue;
7308
+ const expression = statement.expression;
7309
+ if (expression.type !== AST_NODE_TYPES33.AssignmentExpression || expression.operator !== "=" || expression.left.type !== AST_NODE_TYPES33.MemberExpression || expression.left.object.type !== AST_NODE_TYPES33.ThisExpression) {
7310
+ continue;
7311
+ }
7312
+ const source = expression.right;
7313
+ if (source.type === AST_NODE_TYPES33.NewExpression) {
7314
+ constructedFields += 1;
7315
+ } else if (source.type === AST_NODE_TYPES33.Identifier) {
7316
+ storedFrom.add(source.name);
7317
+ } else if (source.type === AST_NODE_TYPES33.MemberExpression && source.object.type === AST_NODE_TYPES33.Identifier) {
7318
+ storedFrom.add(source.object.name);
7319
+ }
7320
+ }
7321
+ }
7322
+ const collaborators = [];
7323
+ for (const parameter of ctor.value.params) {
7324
+ const reference = typeReferenceName(parameter);
7325
+ if (reference === null) continue;
7326
+ const stored = parameter.type === AST_NODE_TYPES33.TSParameterProperty || storedFrom.has(reference.name);
7327
+ if (!stored) continue;
7328
+ if (CONFIGISH_TYPE_RE.test(reference.typeName)) continue;
7329
+ if (CONFIGISH_NAME_RE.test(reference.name)) continue;
7330
+ collaborators.push(reference);
7331
+ }
7332
+ return { collaborators, constructedFields };
7333
+ };
7334
+ var subtreeHas = (root, found) => {
7335
+ let hit = false;
7336
+ const visit = (current) => {
7337
+ if (hit) return;
7338
+ if (found(current)) {
7339
+ hit = true;
7340
+ return;
7341
+ }
7342
+ for (const key of Object.keys(current)) {
7343
+ if (key === "parent") continue;
7344
+ const value = current[key];
7345
+ for (const child of Array.isArray(value) ? value : [value]) {
7346
+ if (child !== null && typeof child === "object" && typeof child.type === "string") {
7347
+ visit(child);
7348
+ }
7349
+ }
7350
+ }
7351
+ };
7352
+ visit(root);
7353
+ return hit;
7354
+ };
7355
+ var isFrameworkWiring = (body) => subtreeHas(body, (node) => {
7356
+ if (node.type === AST_NODE_TYPES33.CallExpression) {
7357
+ const { callee } = node;
7358
+ if (callee.type === AST_NODE_TYPES33.Identifier) return callee.name === ROUTER_FACTORY_NAME;
7359
+ return callee.type === AST_NODE_TYPES33.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES33.Identifier && callee.property.name === ROUTER_FACTORY_NAME;
7360
+ }
7361
+ return node.type === AST_NODE_TYPES33.TSTypeReference && node.typeName.type === AST_NODE_TYPES33.TSQualifiedName && FRAMEWORK_HTTP_TYPES.has(node.typeName.right.name);
7362
+ });
7363
+ var stem2 = (name) => name.replace(/^I(?=[A-Z])/, "").replace(/Impl$/, "");
7364
+ var fileInterfaceNames = (program) => {
7365
+ const names = [];
7366
+ for (const statement of program.body) {
7367
+ const declaration = statement.type === AST_NODE_TYPES33.ExportNamedDeclaration ? statement.declaration : statement;
7368
+ if (declaration?.type === AST_NODE_TYPES33.TSInterfaceDeclaration) names.push(declaration.id.name);
7369
+ }
7370
+ return names;
7371
+ };
7372
+ var isTransportWrapper = (className, collaborators, program) => {
7373
+ const [only] = collaborators;
7374
+ if (collaborators.length !== 1 || only === void 0) return false;
7375
+ if (!HTTP_TRANSPORT_TYPE_RE.test(only.typeName)) return false;
7376
+ if (!TRANSPORT_WRAPPER_NAME_RE.test(className)) return false;
7377
+ const target = stem2(className);
7378
+ return !fileInterfaceNames(program).some((name) => {
7379
+ const other = stem2(name);
7380
+ return target.endsWith(other) || other.endsWith(target);
7381
+ });
7382
+ };
7383
+ var publicMethodNames = (body) => {
7384
+ const names = [];
7385
+ for (const member of body.body) {
7386
+ if (member.type !== AST_NODE_TYPES33.MethodDefinition) continue;
7387
+ if (member.kind !== "method" || member.static) continue;
7388
+ if (member.accessibility === "private" || member.accessibility === "protected") continue;
7389
+ if (member.key.type === AST_NODE_TYPES33.PrivateIdentifier) continue;
7390
+ if (member.key.type === AST_NODE_TYPES33.Identifier) names.push(member.key.name);
7391
+ else names.push("\u2026");
7392
+ }
7393
+ return names;
7394
+ };
7395
+ var require_interface_for_injected_service_default = ESLintUtils46.RuleCreator(
7396
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7397
+ )({
7398
+ name: "require-interface-for-injected-service",
7399
+ meta: {
7400
+ type: "suggestion",
7401
+ docs: {
7402
+ 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."
7403
+ },
7404
+ schema: [],
7405
+ messages: {
7406
+ 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>`."
7407
+ }
7408
+ },
7409
+ defaultOptions: [],
7410
+ create(context) {
7411
+ const { filename } = context;
7412
+ if (isTestFile(filename) || isStoryFile(filename) || isScriptFile(filename)) return {};
7413
+ if (isGeneratedFile(filename, context.sourceCode.getText())) return {};
7414
+ return {
7415
+ ClassDeclaration(node) {
7416
+ if (node.id === null) return;
7417
+ if (!isExportedClass(node)) return;
7418
+ if (node.abstract === true) return;
7419
+ if (node.superClass !== null) return;
7420
+ if (node.implements.length > 0) return;
7421
+ if (node.decorators.length > 0) return;
7422
+ const ctor = node.body.body.find(
7423
+ (member) => member.type === AST_NODE_TYPES33.MethodDefinition && member.kind === "constructor"
7424
+ );
7425
+ if (ctor === void 0) return;
7426
+ const { collaborators, constructedFields } = readConstructor(ctor);
7427
+ if (collaborators.length === 0) return;
7428
+ if (constructedFields > collaborators.length) return;
7429
+ if (isFrameworkWiring(node.body)) return;
7430
+ if (isTransportWrapper(node.id.name, collaborators, context.sourceCode.ast)) return;
7431
+ const methods = publicMethodNames(node.body);
7432
+ if (methods.length === 0) return;
7433
+ context.report({
7434
+ node: node.id,
7435
+ messageId: "requireInterface",
7436
+ data: {
7437
+ name: node.id.name,
7438
+ deps: collaborators.map((c) => `${c.name}: ${c.display}`).join(", "),
7439
+ methods: methods.join(", ")
7440
+ }
7441
+ });
7442
+ }
7443
+ };
7444
+ }
7445
+ });
7446
+
7447
+ // src/rules/prefer-non-nullable-collection.ts
7448
+ import { AST_NODE_TYPES as AST_NODE_TYPES34, ESLintUtils as ESLintUtils47 } from "@typescript-eslint/utils";
7449
+ var ARRAY_TYPE_NAMES = /* @__PURE__ */ new Set(["Array", "ReadonlyArray"]);
7450
+ function propertyName(node) {
7451
+ const key = node.key;
7452
+ if (key.type === AST_NODE_TYPES34.Identifier) return key.name;
7453
+ if (key.type === AST_NODE_TYPES34.Literal) return String(key.value);
7454
+ return "collection";
7455
+ }
7456
+ function isArrayType(node) {
7457
+ if (node.type === AST_NODE_TYPES34.TSArrayType) return true;
7458
+ return node.type === AST_NODE_TYPES34.TSTypeReference && node.typeName.type === AST_NODE_TYPES34.Identifier && ARRAY_TYPE_NAMES.has(node.typeName.name);
7459
+ }
7460
+ function isNullishType(node) {
7461
+ return node.type === AST_NODE_TYPES34.TSNullKeyword || node.type === AST_NODE_TYPES34.TSUndefinedKeyword;
7462
+ }
7463
+ function isNullableArrayOnly(node) {
7464
+ const values = node.types.filter((member) => !isNullishType(member));
7465
+ return values.length > 0 && values.length < node.types.length && values.every(isArrayType);
7466
+ }
7467
+ var prefer_non_nullable_collection_default = ESLintUtils47.RuleCreator(
7468
+ (name) => `https://github.com/sarj-ai/standards/tree/main/packages/typescript#${name}`
7469
+ )({
7470
+ name: "prefer-non-nullable-collection",
7471
+ meta: {
7472
+ type: "suggestion",
7473
+ docs: {
7474
+ description: "Require array types to use an empty array instead of explicit nullish unions with two equivalent empty states."
7475
+ },
7476
+ schema: [],
7477
+ messages: {
7478
+ preferNonNullableCollection: "`{{name}}` is a nullable array, so nullish and `[]` represent the same empty collection. Use a non-null array and default omitted values to `[]`."
7479
+ }
7480
+ },
7481
+ defaultOptions: [],
7482
+ create(context) {
7483
+ const normalizedFilename = context.filename.replaceAll("\\", "/");
7484
+ if (isTestFile(context.filename) || isGeneratedFile(context.filename, context.sourceCode.text) || /\/(?:vendor|vendored)\//u.test(normalizedFilename)) {
7485
+ return {};
7486
+ }
7487
+ function checkOptionalProperty(node) {
7488
+ const annotation = node.typeAnnotation?.typeAnnotation;
7489
+ if (annotation === void 0) return;
7490
+ if (annotation.type !== AST_NODE_TYPES34.TSUnionType || !isNullableArrayOnly(annotation)) {
7491
+ return;
7492
+ }
7493
+ context.report({
7494
+ node,
7495
+ messageId: "preferNonNullableCollection",
7496
+ data: { name: propertyName(node) }
7497
+ });
7498
+ }
7499
+ return {
7500
+ TSPropertySignature: checkOptionalProperty,
7501
+ PropertyDefinition: checkOptionalProperty,
7502
+ TSTypeAliasDeclaration(node) {
7503
+ if (node.typeAnnotation.type !== AST_NODE_TYPES34.TSUnionType) return;
7504
+ if (!isNullableArrayOnly(node.typeAnnotation)) return;
7505
+ context.report({
7506
+ node,
7507
+ messageId: "preferNonNullableCollection",
7508
+ data: { name: node.id.name }
7509
+ });
7510
+ }
7511
+ };
7512
+ }
7513
+ });
7514
+
7515
+ // src/rules/primary-export-file-name.ts
7516
+ import { AST_NODE_TYPES as AST_NODE_TYPES35, ESLintUtils as ESLintUtils48 } from "@typescript-eslint/utils";
7517
+ var CONVENTIONAL_BUCKET_EXPORTS2 = /* @__PURE__ */ new Set(["cn"]);
7518
+ var GENERIC_ENTRYPOINTS = /* @__PURE__ */ new Set(["main", "run", "cli", "setup", "teardown", "execute"]);
7519
+ var ACRONYM_OVERRIDES2 = [
7520
+ [/OAuth/g, "Oauth"],
7521
+ [/GraphQL/g, "Graphql"],
7522
+ [/gRPC/g, "Grpc"]
7523
+ ];
7524
+ var CAMEL_BOUNDARY_RE2 = /(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g;
7525
+ var basename2 = (filename) => filename.split(/[/\\]/).pop() ?? filename;
7526
+ var stemOf2 = (base) => base.replace(/\.(test|spec|config|d|stories)\.[cm]?[jt]sx?$/i, "").replace(/\.[cm]?[jt]sx?$/i, "");
7527
+ var kebabCase3 = (name) => {
7528
+ let normalized = name;
7529
+ for (const [pattern, replacement] of ACRONYM_OVERRIDES2) {
7530
+ normalized = normalized.replace(pattern, replacement);
7531
+ }
7532
+ return normalized.replace(CAMEL_BOUNDARY_RE2, "-").toLowerCase();
7533
+ };
7534
+ var isFunctionOrClass = (node) => node.type === AST_NODE_TYPES35.ArrowFunctionExpression || node.type === AST_NODE_TYPES35.FunctionExpression;
7535
+ var findPrimaryExport = (body) => {
7536
+ let exportCount = 0;
7537
+ let primaryCandidate = null;
7538
+ for (const statement of body) {
7539
+ if (statement.type === AST_NODE_TYPES35.ExportAllDeclaration) {
7540
+ return null;
7541
+ }
7542
+ if (statement.type === AST_NODE_TYPES35.ExportDefaultDeclaration) {
7543
+ exportCount += 1;
7544
+ const decl = statement.declaration;
7545
+ if ((decl.type === AST_NODE_TYPES35.FunctionDeclaration || decl.type === AST_NODE_TYPES35.ClassDeclaration) && decl.id !== null) {
7546
+ primaryCandidate = { name: decl.id.name, node: statement, isDefault: true };
7547
+ } else if (decl.type === AST_NODE_TYPES35.Identifier) {
7548
+ primaryCandidate = { name: decl.name, node: statement, isDefault: true };
7549
+ }
7550
+ } else if (statement.type === AST_NODE_TYPES35.ExportNamedDeclaration) {
7551
+ if (statement.source !== null) {
7552
+ return null;
7553
+ }
7554
+ const decl = statement.declaration;
7555
+ if (decl === null) {
7556
+ exportCount += statement.specifiers.length;
7557
+ if (statement.specifiers.length === 1 && primaryCandidate === null) {
7558
+ const spec = statement.specifiers[0];
7559
+ if (spec && spec.exported.type === AST_NODE_TYPES35.Identifier) {
7560
+ primaryCandidate = { name: spec.exported.name, node: statement, isDefault: false };
7561
+ }
7562
+ }
7563
+ } else if (decl.type === AST_NODE_TYPES35.FunctionDeclaration || decl.type === AST_NODE_TYPES35.ClassDeclaration) {
7564
+ if (decl.id !== null) {
7565
+ exportCount += 1;
7566
+ if (primaryCandidate === null) {
7567
+ primaryCandidate = { name: decl.id.name, node: statement, isDefault: false };
7568
+ }
7569
+ }
7570
+ } else if (decl.type === AST_NODE_TYPES35.VariableDeclaration) {
7571
+ for (const declarator of decl.declarations) {
7572
+ if (declarator.id.type === AST_NODE_TYPES35.Identifier) {
7573
+ exportCount += 1;
7574
+ if (primaryCandidate === null && declarator.init && isFunctionOrClass(declarator.init)) {
7575
+ primaryCandidate = { name: declarator.id.name, node: statement, isDefault: false };
7576
+ }
7577
+ }
7578
+ }
7579
+ } else if (decl.type === AST_NODE_TYPES35.TSTypeAliasDeclaration || decl.type === AST_NODE_TYPES35.TSInterfaceDeclaration) {
7580
+ exportCount += 1;
7581
+ if (primaryCandidate === null) {
7582
+ primaryCandidate = { name: decl.id.name, node: statement, isDefault: false };
7583
+ }
7584
+ }
7585
+ }
7586
+ }
7587
+ if (primaryCandidate === null || exportCount > 2) {
7588
+ return null;
7589
+ }
7590
+ return { ...primaryCandidate, count: exportCount };
7591
+ };
7592
+ var primary_export_file_name_default = ESLintUtils48.RuleCreator(
7593
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7594
+ )({
7595
+ name: "primary-export-file-name",
7596
+ meta: {
7597
+ type: "suggestion",
7598
+ docs: {
7599
+ description: "Enforce semantic naming alignment: a file with a single primary export must be named after that export."
7600
+ },
7601
+ schema: [],
7602
+ messages: {
7603
+ primaryExportMismatch: "Module stem '{{stem}}' does not match its primary export '{{name}}' \u2014 rename the file to '{{expected}}{{ext}}' to describe its responsibility."
7604
+ }
7605
+ },
7606
+ defaultOptions: [],
7607
+ create(context) {
7608
+ const base = basename2(context.filename);
7609
+ if (base.endsWith(".d.ts") || base.startsWith("index.") || base.startsWith("_")) return {};
7610
+ if (isTestFile(context.filename) || isGeneratedFile(context.filename, context.sourceCode.text)) return {};
7611
+ const stem3 = stemOf2(base);
7612
+ 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("[")) {
7613
+ return {};
7614
+ }
7615
+ return {
7616
+ Program(node) {
7617
+ const primary = findPrimaryExport(node.body);
7618
+ if (primary === null) return;
7619
+ if (CONVENTIONAL_BUCKET_EXPORTS2.has(primary.name) || GENERIC_ENTRYPOINTS.has(primary.name)) return;
7620
+ const expectedStem = kebabCase3(primary.name);
7621
+ if (stem3 === expectedStem) return;
7622
+ const ext = base.endsWith(".tsx") ? ".tsx" : ".ts";
7623
+ context.report({
7624
+ node: primary.node,
7625
+ messageId: "primaryExportMismatch",
7626
+ data: { stem: stem3, name: primary.name, expected: expectedStem, ext }
7627
+ });
7628
+ }
7629
+ };
7630
+ }
7631
+ });
7632
+
7633
+ // src/rules/no-implicit-attribute-access.ts
7634
+ import { AST_NODE_TYPES as AST_NODE_TYPES36, ESLintUtils as ESLintUtils49 } from "@typescript-eslint/utils";
7635
+ var EXCLUDED_BASES = /* @__PURE__ */ new Set([
7636
+ "environ",
7637
+ "headers",
7638
+ "cookies",
7639
+ "session",
7640
+ "redis",
7641
+ "cache",
7642
+ "state",
7643
+ "config",
7644
+ "env",
7645
+ "process",
7646
+ "localStorage",
7647
+ "sessionStorage"
7648
+ ]);
7649
+ function getBaseName(node) {
7650
+ if (node.type === AST_NODE_TYPES36.Identifier) {
7651
+ return node.name;
7652
+ }
7653
+ if (node.type === AST_NODE_TYPES36.MemberExpression && node.property.type === AST_NODE_TYPES36.Identifier) {
7654
+ return node.property.name;
7655
+ }
7656
+ return null;
7657
+ }
7658
+ var no_implicit_attribute_access_default = ESLintUtils49.RuleCreator(
7659
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7660
+ )({
7661
+ name: "no-implicit-attribute-access",
7662
+ meta: {
7663
+ type: "problem",
7664
+ docs: {
7665
+ description: "Disallow imperative dictionary access with string literals; use declarative Zod schemas."
7666
+ },
7667
+ schema: [],
7668
+ messages: {
7669
+ noImplicitAttributeAccess: "Imperative lookup for '{{key}}' \u2014 if the key is known, parse the object declaratively with a Zod schema instead."
7670
+ }
7671
+ },
7672
+ defaultOptions: [],
7673
+ create(context) {
7674
+ return {
7675
+ MemberExpression(node) {
7676
+ if (!node.computed) {
7677
+ return;
7678
+ }
7679
+ if (node.property.type === AST_NODE_TYPES36.Literal && typeof node.property.value === "string") {
7680
+ const baseName = getBaseName(node.object);
7681
+ if (!baseName || !EXCLUDED_BASES.has(baseName)) {
7682
+ context.report({
7683
+ node,
7684
+ messageId: "noImplicitAttributeAccess",
7685
+ data: { key: node.property.value }
7686
+ });
7687
+ }
7688
+ }
7689
+ },
7690
+ CallExpression(node) {
7691
+ const callee = node.callee;
7692
+ if (callee.type === AST_NODE_TYPES36.MemberExpression) {
7693
+ const method = callee.property.type === AST_NODE_TYPES36.Identifier ? callee.property.name : null;
7694
+ if (method === "get") {
7695
+ const arg = node.arguments[0];
7696
+ if (arg && arg.type === AST_NODE_TYPES36.Literal && typeof arg.value === "string") {
7697
+ const baseName = getBaseName(callee.object);
7698
+ if (!baseName || !EXCLUDED_BASES.has(baseName)) {
7699
+ context.report({
7700
+ node,
7701
+ messageId: "noImplicitAttributeAccess",
7702
+ data: { key: arg.value }
7703
+ });
7704
+ }
7705
+ }
7706
+ }
7707
+ }
7708
+ }
7709
+ };
7710
+ }
7711
+ });
7712
+
7261
7713
  // src/index.ts
7262
7714
  var rules = {
7263
7715
  "enforce-file-structure": enforce_file_structure_default,
@@ -7286,6 +7738,7 @@ var rules = {
7286
7738
  "no-unsafe-cast": no_unsafe_cast_default,
7287
7739
  "prefer-string-literal-union": prefer_string_literal_union_default,
7288
7740
  "single-public-export": single_public_export_default,
7741
+ "primary-export-file-name": primary_export_file_name_default,
7289
7742
  "no-silent-promise-catch": no_silent_promise_catch_default,
7290
7743
  "require-fetch-timeout": require_fetch_timeout_default,
7291
7744
  "require-schema-validate-search": require_schema_validate_search_default,
@@ -7304,12 +7757,15 @@ var rules = {
7304
7757
  "jsdoc-restates-signature": jsdoc_restates_signature_default,
7305
7758
  "no-restated-comment": no_restated_comment_default,
7306
7759
  "trailing-value-narration": trailing_value_narration_default,
7307
- "no-tautological-expect": no_tautological_expect_default
7760
+ "no-tautological-expect": no_tautological_expect_default,
7761
+ "require-interface-for-injected-service": require_interface_for_injected_service_default,
7762
+ "prefer-non-nullable-collection": prefer_non_nullable_collection_default,
7763
+ "no-implicit-attribute-access": no_implicit_attribute_access_default
7308
7764
  };
7309
7765
  var plugin = {
7310
7766
  meta: {
7311
7767
  name: "@sarj/eslint-plugin",
7312
- version: "2.14.0"
7768
+ version: "2.16.0"
7313
7769
  },
7314
7770
  rules,
7315
7771
  configs: {
@@ -7334,13 +7790,17 @@ var plugin = {
7334
7790
  "@sarj/prefer-discriminated-union": "warn",
7335
7791
  "@sarj/no-comment-cruft": "warn",
7336
7792
  // Frontend / styling — distilled from frontend PR-review mining.
7337
- "@sarj/prefer-semantic-colors": ["warn", { requireSemanticTokens: true }],
7793
+ "@sarj/prefer-semantic-colors": [
7794
+ "warn",
7795
+ { requireSemanticTokens: true }
7796
+ ],
7338
7797
  // Ported from sarj-python-lint (SARJ), corpus-validated FP~0.
7339
7798
  "@sarj/no-fat-try-blocks": "warn",
7340
7799
  "@sarj/no-cors-wildcard-with-credentials": "warn",
7341
7800
  "@sarj/no-secret-in-log": "warn",
7342
7801
  "@sarj/no-unsafe-cast": "warn",
7343
7802
  "@sarj/single-public-export": "warn",
7803
+ "@sarj/primary-export-file-name": "warn",
7344
7804
  "@sarj/prefer-string-literal-union": "warn",
7345
7805
  // Mined from 2y of PR review feedback + 5-repo code-smell audit (2026-07).
7346
7806
  "@sarj/require-fetch-timeout": "warn",
@@ -7384,7 +7844,14 @@ var plugin = {
7384
7844
  // Measured across 5,819 .ts/.tsx files (1,003 of them test files) in
7385
7845
  // six internal repos plus got / hono / swr / trpc: 3 hits, 3 true
7386
7846
  // positives, 0 false positives.
7387
- "@sarj/no-tautological-expect": "warn"
7847
+ "@sarj/no-tautological-expect": "warn",
7848
+ // Substitutability: an exported service class with injected
7849
+ // collaborators and no interface above it can only be tested by
7850
+ // mocking. 11-repo sweep: 229 exported classes, 82% already carry a
7851
+ // port, 29 fire, 28 of them true positives.
7852
+ "@sarj/require-interface-for-injected-service": "warn",
7853
+ "@sarj/prefer-non-nullable-collection": "warn",
7854
+ "@sarj/no-implicit-attribute-access": "warn"
7388
7855
  }
7389
7856
  },
7390
7857
  strict: {
@@ -7412,13 +7879,17 @@ var plugin = {
7412
7879
  "@sarj/no-comment-cruft": "error",
7413
7880
  // Frontend / styling — distilled from frontend PR-review mining. Stylistic,
7414
7881
  // no autofix → warn (rollout should prove the FP rate before raising it).
7415
- "@sarj/prefer-semantic-colors": ["error", { requireSemanticTokens: true }],
7882
+ "@sarj/prefer-semantic-colors": [
7883
+ "error",
7884
+ { requireSemanticTokens: true }
7885
+ ],
7416
7886
  // Ported from sarj-python-lint (SARJ), corpus-validated FP~0.
7417
7887
  "@sarj/no-fat-try-blocks": "error",
7418
7888
  "@sarj/no-cors-wildcard-with-credentials": "error",
7419
7889
  "@sarj/no-secret-in-log": "error",
7420
7890
  "@sarj/no-unsafe-cast": "error",
7421
7891
  "@sarj/single-public-export": "error",
7892
+ "@sarj/primary-export-file-name": "error",
7422
7893
  // Promoted to error 2026-07-25 — strict means strict (user directive).
7423
7894
  "@sarj/prefer-string-literal-union": "error",
7424
7895
  // Mined from 2y of PR review feedback + 5-repo code-smell audit (2026-07).
@@ -7454,7 +7925,13 @@ var plugin = {
7454
7925
  "@sarj/jsdoc-restates-signature": "error",
7455
7926
  "@sarj/trailing-value-narration": "error",
7456
7927
  // TS half of SARJ057 — see the `recommended` block for the measurement.
7457
- "@sarj/no-tautological-expect": "error"
7928
+ "@sarj/no-tautological-expect": "error",
7929
+ // Substitutability: the TS sibling of the Python `prefer-real-store-in-tests`
7930
+ // / `prefer-library-fake` wave. The convention already exists in the
7931
+ // corpus (175 `implements` clauses vs 29 hits), so strict enforces it.
7932
+ "@sarj/require-interface-for-injected-service": "error",
7933
+ "@sarj/prefer-non-nullable-collection": "error",
7934
+ "@sarj/no-implicit-attribute-access": "error"
7458
7935
  }
7459
7936
  }
7460
7937
  }