@sarj/eslint-plugin 2.15.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.cjs CHANGED
@@ -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;
@@ -7495,6 +7515,204 @@ var prefer_non_nullable_collection_default = import_utils52.ESLintUtils.RuleCrea
7495
7515
  }
7496
7516
  });
7497
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
+
7498
7716
  // src/index.ts
7499
7717
  var rules = {
7500
7718
  "enforce-file-structure": enforce_file_structure_default,
@@ -7523,6 +7741,7 @@ var rules = {
7523
7741
  "no-unsafe-cast": no_unsafe_cast_default,
7524
7742
  "prefer-string-literal-union": prefer_string_literal_union_default,
7525
7743
  "single-public-export": single_public_export_default,
7744
+ "primary-export-file-name": primary_export_file_name_default,
7526
7745
  "no-silent-promise-catch": no_silent_promise_catch_default,
7527
7746
  "require-fetch-timeout": require_fetch_timeout_default,
7528
7747
  "require-schema-validate-search": require_schema_validate_search_default,
@@ -7543,12 +7762,13 @@ var rules = {
7543
7762
  "trailing-value-narration": trailing_value_narration_default,
7544
7763
  "no-tautological-expect": no_tautological_expect_default,
7545
7764
  "require-interface-for-injected-service": require_interface_for_injected_service_default,
7546
- "prefer-non-nullable-collection": prefer_non_nullable_collection_default
7765
+ "prefer-non-nullable-collection": prefer_non_nullable_collection_default,
7766
+ "no-implicit-attribute-access": no_implicit_attribute_access_default
7547
7767
  };
7548
7768
  var plugin = {
7549
7769
  meta: {
7550
7770
  name: "@sarj/eslint-plugin",
7551
- version: "2.15.0"
7771
+ version: "2.16.0"
7552
7772
  },
7553
7773
  rules,
7554
7774
  configs: {
@@ -7583,6 +7803,7 @@ var plugin = {
7583
7803
  "@sarj/no-secret-in-log": "warn",
7584
7804
  "@sarj/no-unsafe-cast": "warn",
7585
7805
  "@sarj/single-public-export": "warn",
7806
+ "@sarj/primary-export-file-name": "warn",
7586
7807
  "@sarj/prefer-string-literal-union": "warn",
7587
7808
  // Mined from 2y of PR review feedback + 5-repo code-smell audit (2026-07).
7588
7809
  "@sarj/require-fetch-timeout": "warn",
@@ -7632,7 +7853,8 @@ var plugin = {
7632
7853
  // mocking. 11-repo sweep: 229 exported classes, 82% already carry a
7633
7854
  // port, 29 fire, 28 of them true positives.
7634
7855
  "@sarj/require-interface-for-injected-service": "warn",
7635
- "@sarj/prefer-non-nullable-collection": "warn"
7856
+ "@sarj/prefer-non-nullable-collection": "warn",
7857
+ "@sarj/no-implicit-attribute-access": "warn"
7636
7858
  }
7637
7859
  },
7638
7860
  strict: {
@@ -7670,6 +7892,7 @@ var plugin = {
7670
7892
  "@sarj/no-secret-in-log": "error",
7671
7893
  "@sarj/no-unsafe-cast": "error",
7672
7894
  "@sarj/single-public-export": "error",
7895
+ "@sarj/primary-export-file-name": "error",
7673
7896
  // Promoted to error 2026-07-25 — strict means strict (user directive).
7674
7897
  "@sarj/prefer-string-literal-union": "error",
7675
7898
  // Mined from 2y of PR review feedback + 5-repo code-smell audit (2026-07).
@@ -7710,7 +7933,8 @@ var plugin = {
7710
7933
  // / `prefer-library-fake` wave. The convention already exists in the
7711
7934
  // corpus (175 `implements` clauses vs 29 hits), so strict enforces it.
7712
7935
  "@sarj/require-interface-for-injected-service": "error",
7713
- "@sarj/prefer-non-nullable-collection": "error"
7936
+ "@sarj/prefer-non-nullable-collection": "error",
7937
+ "@sarj/no-implicit-attribute-access": "error"
7714
7938
  }
7715
7939
  }
7716
7940
  }