@sarj/eslint-plugin 2.13.0 → 2.15.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.d.cts CHANGED
@@ -343,6 +343,15 @@ declare const rules: {
343
343
  "trailing-value-narration": _typescript_eslint_utils_ts_eslint.RuleModule<"narratesValue", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
344
344
  name: string;
345
345
  };
346
+ "no-tautological-expect": _typescript_eslint_utils_ts_eslint.RuleModule<"tautologicalComparison" | "tautologicalMatcher", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
347
+ name: string;
348
+ };
349
+ "require-interface-for-injected-service": _typescript_eslint_utils_ts_eslint.RuleModule<"requireInterface", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
350
+ name: string;
351
+ };
352
+ "prefer-non-nullable-collection": _typescript_eslint_utils_ts_eslint.RuleModule<"preferNonNullableCollection", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
353
+ name: string;
354
+ };
346
355
  };
347
356
  declare const plugin: {
348
357
  meta: {
@@ -496,6 +505,15 @@ declare const plugin: {
496
505
  "trailing-value-narration": _typescript_eslint_utils_ts_eslint.RuleModule<"narratesValue", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
497
506
  name: string;
498
507
  };
508
+ "no-tautological-expect": _typescript_eslint_utils_ts_eslint.RuleModule<"tautologicalComparison" | "tautologicalMatcher", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
509
+ name: string;
510
+ };
511
+ "require-interface-for-injected-service": _typescript_eslint_utils_ts_eslint.RuleModule<"requireInterface", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
512
+ name: string;
513
+ };
514
+ "prefer-non-nullable-collection": _typescript_eslint_utils_ts_eslint.RuleModule<"preferNonNullableCollection", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
515
+ name: string;
516
+ };
499
517
  };
500
518
  configs: {
501
519
  recommended: {
@@ -542,6 +560,9 @@ declare const plugin: {
542
560
  "@sarj/no-restated-comment": string;
543
561
  "@sarj/jsdoc-restates-signature": string;
544
562
  "@sarj/trailing-value-narration": string;
563
+ "@sarj/no-tautological-expect": string;
564
+ "@sarj/require-interface-for-injected-service": string;
565
+ "@sarj/prefer-non-nullable-collection": string;
545
566
  };
546
567
  };
547
568
  strict: {
@@ -593,6 +614,9 @@ declare const plugin: {
593
614
  "@sarj/no-restated-comment": string;
594
615
  "@sarj/jsdoc-restates-signature": string;
595
616
  "@sarj/trailing-value-narration": string;
617
+ "@sarj/no-tautological-expect": string;
618
+ "@sarj/require-interface-for-injected-service": string;
619
+ "@sarj/prefer-non-nullable-collection": string;
596
620
  };
597
621
  };
598
622
  };
package/dist/index.d.ts CHANGED
@@ -343,6 +343,15 @@ declare const rules: {
343
343
  "trailing-value-narration": _typescript_eslint_utils_ts_eslint.RuleModule<"narratesValue", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
344
344
  name: string;
345
345
  };
346
+ "no-tautological-expect": _typescript_eslint_utils_ts_eslint.RuleModule<"tautologicalComparison" | "tautologicalMatcher", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
347
+ name: string;
348
+ };
349
+ "require-interface-for-injected-service": _typescript_eslint_utils_ts_eslint.RuleModule<"requireInterface", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
350
+ name: string;
351
+ };
352
+ "prefer-non-nullable-collection": _typescript_eslint_utils_ts_eslint.RuleModule<"preferNonNullableCollection", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
353
+ name: string;
354
+ };
346
355
  };
347
356
  declare const plugin: {
348
357
  meta: {
@@ -496,6 +505,15 @@ declare const plugin: {
496
505
  "trailing-value-narration": _typescript_eslint_utils_ts_eslint.RuleModule<"narratesValue", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
497
506
  name: string;
498
507
  };
508
+ "no-tautological-expect": _typescript_eslint_utils_ts_eslint.RuleModule<"tautologicalComparison" | "tautologicalMatcher", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
509
+ name: string;
510
+ };
511
+ "require-interface-for-injected-service": _typescript_eslint_utils_ts_eslint.RuleModule<"requireInterface", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
512
+ name: string;
513
+ };
514
+ "prefer-non-nullable-collection": _typescript_eslint_utils_ts_eslint.RuleModule<"preferNonNullableCollection", readonly [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
515
+ name: string;
516
+ };
499
517
  };
500
518
  configs: {
501
519
  recommended: {
@@ -542,6 +560,9 @@ declare const plugin: {
542
560
  "@sarj/no-restated-comment": string;
543
561
  "@sarj/jsdoc-restates-signature": string;
544
562
  "@sarj/trailing-value-narration": string;
563
+ "@sarj/no-tautological-expect": string;
564
+ "@sarj/require-interface-for-injected-service": string;
565
+ "@sarj/prefer-non-nullable-collection": string;
545
566
  };
546
567
  };
547
568
  strict: {
@@ -593,6 +614,9 @@ declare const plugin: {
593
614
  "@sarj/no-restated-comment": string;
594
615
  "@sarj/jsdoc-restates-signature": string;
595
616
  "@sarj/trailing-value-narration": string;
617
+ "@sarj/no-tautological-expect": string;
618
+ "@sarj/require-interface-for-injected-service": string;
619
+ "@sarj/prefer-non-nullable-collection": string;
596
620
  };
597
621
  };
598
622
  };
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);
@@ -5134,8 +5134,8 @@ var single_public_export_default = ESLintUtils29.RuleCreator(
5134
5134
  if (base.endsWith(".d.ts")) return {};
5135
5135
  if (TEST_FILE_RE.test(base)) return {};
5136
5136
  if (isTestFile(context.filename)) return {};
5137
- const stem2 = stemOf(base);
5138
- if (!JUNK_DRAWER_STEMS.has(stem2.toLowerCase())) return {};
5137
+ const stem3 = stemOf(base);
5138
+ if (!JUNK_DRAWER_STEMS.has(stem3.toLowerCase())) return {};
5139
5139
  return {
5140
5140
  Program(node) {
5141
5141
  const { names, hasReExport, candidate } = summarizeExports(node.body);
@@ -5143,11 +5143,11 @@ var single_public_export_default = ESLintUtils29.RuleCreator(
5143
5143
  if (names !== 1 || candidate === null) return;
5144
5144
  if (CONVENTIONAL_BUCKET_EXPORTS.has(candidate.name)) return;
5145
5145
  const expected = kebabCase2(candidate.name);
5146
- if (stem2 === expected) return;
5146
+ if (stem3 === expected) return;
5147
5147
  context.report({
5148
5148
  node: candidate.node,
5149
5149
  messageId: "renameJunkDrawer",
5150
- data: { stem: stem2, name: candidate.name, expected }
5150
+ data: { stem: stem3, name: candidate.name, expected }
5151
5151
  });
5152
5152
  }
5153
5153
  };
@@ -5301,7 +5301,7 @@ function createSqlListener(handler) {
5301
5301
  }
5302
5302
 
5303
5303
  // src/rules/no-offset-pagination.ts
5304
- var OFFSET_PAGINATION = /\bOFFSET\s+(?:\?\d*|:\w+|@\w+|\$\d+|\d+)/i;
5304
+ var OFFSET_PAGINATION = /\bOFFSET\s+(?:%s|%\(\w+\)s|\?\d*|:\w+|@\w+|\$\d+|\d+)/i;
5305
5305
  var OFFSET_GATE = /offset/i;
5306
5306
  var no_offset_pagination_default = ESLintUtils30.RuleCreator(
5307
5307
  (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
@@ -5493,7 +5493,6 @@ var no_positional_tuple_return_default = ESLintUtils31.RuleCreator(
5493
5493
  // src/rules/no-repeated-string-literal.ts
5494
5494
  import { AST_NODE_TYPES as AST_NODE_TYPES23, ESLintUtils as ESLintUtils32 } from "@typescript-eslint/utils";
5495
5495
  var MIN_LENGTH = 40;
5496
- var MIN_OCCURRENCES = 3;
5497
5496
  var MIN_DISTINCT_SCOPES = 2;
5498
5497
  var PREVIEW_LENGTH = 40;
5499
5498
  var SQL_KEYWORD_RE = /\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|VALUES|ON CONFLICT|RETURNING|GROUP BY|ORDER BY)\b/;
@@ -5575,9 +5574,6 @@ var no_repeated_string_literal_default = ESLintUtils32.RuleCreator(
5575
5574
  },
5576
5575
  "Program:exit": () => {
5577
5576
  for (const [value, nodes] of occurrences) {
5578
- if (nodes.length < MIN_OCCURRENCES) {
5579
- continue;
5580
- }
5581
5577
  const distinctScopes = new Set(
5582
5578
  nodes.map((node) => scopes.get(node)).filter((scope) => scope != null)
5583
5579
  );
@@ -7157,6 +7153,345 @@ var trailing_value_narration_default = ESLintUtils44.RuleCreator(
7157
7153
  }
7158
7154
  });
7159
7155
 
7156
+ // src/rules/no-tautological-expect.ts
7157
+ import { AST_NODE_TYPES as AST_NODE_TYPES32, ESLintUtils as ESLintUtils45 } from "@typescript-eslint/utils";
7158
+ var EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual"]);
7159
+ var ZERO_ARG_MATCHERS = /* @__PURE__ */ new Set([
7160
+ "toBeDefined",
7161
+ "toBeUndefined",
7162
+ "toBeNull",
7163
+ "toBeTruthy",
7164
+ "toBeFalsy",
7165
+ "toBeNaN"
7166
+ ]);
7167
+ var OPERAND_PREVIEW_CHARS = 40;
7168
+ var NUMERIC_SIGNS = /* @__PURE__ */ new Set(["-", "+"]);
7169
+ function isLiteral(node) {
7170
+ switch (node.type) {
7171
+ case AST_NODE_TYPES32.Literal:
7172
+ return true;
7173
+ case AST_NODE_TYPES32.TemplateLiteral:
7174
+ return node.expressions.length === 0;
7175
+ case AST_NODE_TYPES32.UnaryExpression:
7176
+ return NUMERIC_SIGNS.has(node.operator) && isLiteral(node.argument);
7177
+ case AST_NODE_TYPES32.ArrayExpression:
7178
+ return node.elements.every((element) => element !== null && isLiteral(element));
7179
+ case AST_NODE_TYPES32.ObjectExpression:
7180
+ return node.properties.every(
7181
+ (property) => property.type === AST_NODE_TYPES32.Property && !property.computed && isLiteral(property.value)
7182
+ );
7183
+ default:
7184
+ return false;
7185
+ }
7186
+ }
7187
+ function expectOperand(callee) {
7188
+ const receiver = callee.object;
7189
+ if (receiver.type !== AST_NODE_TYPES32.CallExpression || receiver.callee.type !== AST_NODE_TYPES32.Identifier || receiver.callee.name !== "expect" || receiver.arguments.length !== 1) {
7190
+ return null;
7191
+ }
7192
+ return receiver.arguments[0] ?? null;
7193
+ }
7194
+ var no_tautological_expect_default = ESLintUtils45.RuleCreator(
7195
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7196
+ )({
7197
+ name: "no-tautological-expect",
7198
+ meta: {
7199
+ type: "problem",
7200
+ docs: {
7201
+ description: "Disallow an assertion whose operands are all literals; its outcome is fixed before the code runs, so it can never fail."
7202
+ },
7203
+ schema: [],
7204
+ messages: {
7205
+ tautologicalComparison: "`expect({{operand}}).{{matcher}}({{operand}})` compares a literal with an identical literal \u2014 it passes even if the code under test is deleted. Assert on a value the code produced, or delete the test.",
7206
+ tautologicalMatcher: "`expect({{operand}}).{{matcher}}()` asserts on a literal, so its outcome is fixed before the code runs. Assert on a value the code produced, or delete the test."
7207
+ }
7208
+ },
7209
+ defaultOptions: [],
7210
+ create(context) {
7211
+ if (!isTestFile(context.filename)) {
7212
+ return {};
7213
+ }
7214
+ const preview2 = (node) => {
7215
+ const text = context.sourceCode.getText(node).replaceAll(/\s+/gu, " ");
7216
+ return text.length > OPERAND_PREVIEW_CHARS ? `${text.slice(0, OPERAND_PREVIEW_CHARS)}\u2026` : text;
7217
+ };
7218
+ return {
7219
+ CallExpression(node) {
7220
+ const callee = node.callee;
7221
+ if (callee.type !== AST_NODE_TYPES32.MemberExpression || callee.computed) {
7222
+ return;
7223
+ }
7224
+ if (callee.property.type !== AST_NODE_TYPES32.Identifier) {
7225
+ return;
7226
+ }
7227
+ const matcher = callee.property.name;
7228
+ const operand = expectOperand(callee);
7229
+ if (operand === null || !isLiteral(operand)) {
7230
+ return;
7231
+ }
7232
+ if (ZERO_ARG_MATCHERS.has(matcher) && node.arguments.length === 0) {
7233
+ context.report({
7234
+ node,
7235
+ messageId: "tautologicalMatcher",
7236
+ data: { operand: preview2(operand), matcher }
7237
+ });
7238
+ return;
7239
+ }
7240
+ const expected = node.arguments[0];
7241
+ if (!EQUALITY_MATCHERS.has(matcher) || node.arguments.length !== 1 || expected === void 0 || !isLiteral(expected)) {
7242
+ return;
7243
+ }
7244
+ if (context.sourceCode.getText(operand) !== context.sourceCode.getText(expected)) {
7245
+ return;
7246
+ }
7247
+ context.report({
7248
+ node,
7249
+ messageId: "tautologicalComparison",
7250
+ data: { operand: preview2(operand), matcher }
7251
+ });
7252
+ }
7253
+ };
7254
+ }
7255
+ });
7256
+
7257
+ // src/rules/require-interface-for-injected-service.ts
7258
+ import { AST_NODE_TYPES as AST_NODE_TYPES33, ESLintUtils as ESLintUtils46 } from "@typescript-eslint/utils";
7259
+ var CONFIGISH_TYPE_RE = /(?:Options|Opts|Config|Configuration|Settings|Params|Props|Args|Env|Environment|Callbacks|Flags)$/;
7260
+ var CONFIGISH_NAME_RE = /^(?:options|opts|config|configuration|settings|params|props|args|env|environment|callbacks|flags|logger|log|clock)$/i;
7261
+ var HTTP_TRANSPORT_TYPE_RE = /^(?:KyInstance|AxiosInstance|Session)$/;
7262
+ var TRANSPORT_WRAPPER_NAME_RE = /Client$/;
7263
+ var ROUTER_FACTORY_NAME = "Router";
7264
+ var FRAMEWORK_HTTP_TYPES = /* @__PURE__ */ new Set(["Request", "Response", "NextFunction"]);
7265
+ var isExportedClass = (node) => node.parent.type === AST_NODE_TYPES33.ExportNamedDeclaration || node.parent.type === AST_NODE_TYPES33.ExportDefaultDeclaration;
7266
+ var qualifiedName = (name) => name.type === AST_NODE_TYPES33.Identifier ? name.name : name.type === AST_NODE_TYPES33.TSQualifiedName ? `${qualifiedName(name.left)}.${name.right.name}` : "";
7267
+ var typeReferenceName = (annotated) => {
7268
+ let target = annotated;
7269
+ if (target.type === AST_NODE_TYPES33.TSParameterProperty) target = target.parameter;
7270
+ if (target.type === AST_NODE_TYPES33.AssignmentPattern) target = target.left;
7271
+ if (target.type !== AST_NODE_TYPES33.Identifier) return null;
7272
+ const annotation = target.typeAnnotation?.typeAnnotation;
7273
+ if (annotation === void 0 || annotation.type !== AST_NODE_TYPES33.TSTypeReference) {
7274
+ return null;
7275
+ }
7276
+ const { typeName } = annotation;
7277
+ const rightmost = typeName.type === AST_NODE_TYPES33.Identifier ? typeName.name : typeName.type === AST_NODE_TYPES33.TSQualifiedName ? typeName.right.name : null;
7278
+ if (rightmost === null) return null;
7279
+ return { name: target.name, typeName: rightmost, display: qualifiedName(typeName) };
7280
+ };
7281
+ var readConstructor = (ctor) => {
7282
+ const body = ctor.value.body;
7283
+ const storedFrom = /* @__PURE__ */ new Set();
7284
+ let constructedFields = 0;
7285
+ if (body !== null && body !== void 0) {
7286
+ for (const statement of body.body) {
7287
+ if (statement.type !== AST_NODE_TYPES33.ExpressionStatement) continue;
7288
+ const expression = statement.expression;
7289
+ if (expression.type !== AST_NODE_TYPES33.AssignmentExpression || expression.operator !== "=" || expression.left.type !== AST_NODE_TYPES33.MemberExpression || expression.left.object.type !== AST_NODE_TYPES33.ThisExpression) {
7290
+ continue;
7291
+ }
7292
+ const source = expression.right;
7293
+ if (source.type === AST_NODE_TYPES33.NewExpression) {
7294
+ constructedFields += 1;
7295
+ } else if (source.type === AST_NODE_TYPES33.Identifier) {
7296
+ storedFrom.add(source.name);
7297
+ } else if (source.type === AST_NODE_TYPES33.MemberExpression && source.object.type === AST_NODE_TYPES33.Identifier) {
7298
+ storedFrom.add(source.object.name);
7299
+ }
7300
+ }
7301
+ }
7302
+ const collaborators = [];
7303
+ for (const parameter of ctor.value.params) {
7304
+ const reference = typeReferenceName(parameter);
7305
+ if (reference === null) continue;
7306
+ const stored = parameter.type === AST_NODE_TYPES33.TSParameterProperty || storedFrom.has(reference.name);
7307
+ if (!stored) continue;
7308
+ if (CONFIGISH_TYPE_RE.test(reference.typeName)) continue;
7309
+ if (CONFIGISH_NAME_RE.test(reference.name)) continue;
7310
+ collaborators.push(reference);
7311
+ }
7312
+ return { collaborators, constructedFields };
7313
+ };
7314
+ var subtreeHas = (root, found) => {
7315
+ let hit = false;
7316
+ const visit = (current) => {
7317
+ if (hit) return;
7318
+ if (found(current)) {
7319
+ hit = true;
7320
+ return;
7321
+ }
7322
+ for (const key of Object.keys(current)) {
7323
+ if (key === "parent") continue;
7324
+ const value = current[key];
7325
+ for (const child of Array.isArray(value) ? value : [value]) {
7326
+ if (child !== null && typeof child === "object" && typeof child.type === "string") {
7327
+ visit(child);
7328
+ }
7329
+ }
7330
+ }
7331
+ };
7332
+ visit(root);
7333
+ return hit;
7334
+ };
7335
+ var isFrameworkWiring = (body) => subtreeHas(body, (node) => {
7336
+ if (node.type === AST_NODE_TYPES33.CallExpression) {
7337
+ const { callee } = node;
7338
+ if (callee.type === AST_NODE_TYPES33.Identifier) return callee.name === ROUTER_FACTORY_NAME;
7339
+ return callee.type === AST_NODE_TYPES33.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES33.Identifier && callee.property.name === ROUTER_FACTORY_NAME;
7340
+ }
7341
+ return node.type === AST_NODE_TYPES33.TSTypeReference && node.typeName.type === AST_NODE_TYPES33.TSQualifiedName && FRAMEWORK_HTTP_TYPES.has(node.typeName.right.name);
7342
+ });
7343
+ var stem2 = (name) => name.replace(/^I(?=[A-Z])/, "").replace(/Impl$/, "");
7344
+ var fileInterfaceNames = (program) => {
7345
+ const names = [];
7346
+ for (const statement of program.body) {
7347
+ const declaration = statement.type === AST_NODE_TYPES33.ExportNamedDeclaration ? statement.declaration : statement;
7348
+ if (declaration?.type === AST_NODE_TYPES33.TSInterfaceDeclaration) names.push(declaration.id.name);
7349
+ }
7350
+ return names;
7351
+ };
7352
+ var isTransportWrapper = (className, collaborators, program) => {
7353
+ const [only] = collaborators;
7354
+ if (collaborators.length !== 1 || only === void 0) return false;
7355
+ if (!HTTP_TRANSPORT_TYPE_RE.test(only.typeName)) return false;
7356
+ if (!TRANSPORT_WRAPPER_NAME_RE.test(className)) return false;
7357
+ const target = stem2(className);
7358
+ return !fileInterfaceNames(program).some((name) => {
7359
+ const other = stem2(name);
7360
+ return target.endsWith(other) || other.endsWith(target);
7361
+ });
7362
+ };
7363
+ var publicMethodNames = (body) => {
7364
+ const names = [];
7365
+ for (const member of body.body) {
7366
+ if (member.type !== AST_NODE_TYPES33.MethodDefinition) continue;
7367
+ if (member.kind !== "method" || member.static) continue;
7368
+ if (member.accessibility === "private" || member.accessibility === "protected") continue;
7369
+ if (member.key.type === AST_NODE_TYPES33.PrivateIdentifier) continue;
7370
+ if (member.key.type === AST_NODE_TYPES33.Identifier) names.push(member.key.name);
7371
+ else names.push("\u2026");
7372
+ }
7373
+ return names;
7374
+ };
7375
+ var require_interface_for_injected_service_default = ESLintUtils46.RuleCreator(
7376
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7377
+ )({
7378
+ name: "require-interface-for-injected-service",
7379
+ meta: {
7380
+ type: "suggestion",
7381
+ docs: {
7382
+ 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."
7383
+ },
7384
+ schema: [],
7385
+ messages: {
7386
+ 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>`."
7387
+ }
7388
+ },
7389
+ defaultOptions: [],
7390
+ create(context) {
7391
+ const { filename } = context;
7392
+ if (isTestFile(filename) || isStoryFile(filename) || isScriptFile(filename)) return {};
7393
+ if (isGeneratedFile(filename, context.sourceCode.getText())) return {};
7394
+ return {
7395
+ ClassDeclaration(node) {
7396
+ if (node.id === null) return;
7397
+ if (!isExportedClass(node)) return;
7398
+ if (node.abstract === true) return;
7399
+ if (node.superClass !== null) return;
7400
+ if (node.implements.length > 0) return;
7401
+ if (node.decorators.length > 0) return;
7402
+ const ctor = node.body.body.find(
7403
+ (member) => member.type === AST_NODE_TYPES33.MethodDefinition && member.kind === "constructor"
7404
+ );
7405
+ if (ctor === void 0) return;
7406
+ const { collaborators, constructedFields } = readConstructor(ctor);
7407
+ if (collaborators.length === 0) return;
7408
+ if (constructedFields > collaborators.length) return;
7409
+ if (isFrameworkWiring(node.body)) return;
7410
+ if (isTransportWrapper(node.id.name, collaborators, context.sourceCode.ast)) return;
7411
+ const methods = publicMethodNames(node.body);
7412
+ if (methods.length === 0) return;
7413
+ context.report({
7414
+ node: node.id,
7415
+ messageId: "requireInterface",
7416
+ data: {
7417
+ name: node.id.name,
7418
+ deps: collaborators.map((c) => `${c.name}: ${c.display}`).join(", "),
7419
+ methods: methods.join(", ")
7420
+ }
7421
+ });
7422
+ }
7423
+ };
7424
+ }
7425
+ });
7426
+
7427
+ // src/rules/prefer-non-nullable-collection.ts
7428
+ import { AST_NODE_TYPES as AST_NODE_TYPES34, ESLintUtils as ESLintUtils47 } from "@typescript-eslint/utils";
7429
+ var ARRAY_TYPE_NAMES = /* @__PURE__ */ new Set(["Array", "ReadonlyArray"]);
7430
+ function propertyName(node) {
7431
+ const key = node.key;
7432
+ if (key.type === AST_NODE_TYPES34.Identifier) return key.name;
7433
+ if (key.type === AST_NODE_TYPES34.Literal) return String(key.value);
7434
+ return "collection";
7435
+ }
7436
+ function isArrayType(node) {
7437
+ if (node.type === AST_NODE_TYPES34.TSArrayType) return true;
7438
+ return node.type === AST_NODE_TYPES34.TSTypeReference && node.typeName.type === AST_NODE_TYPES34.Identifier && ARRAY_TYPE_NAMES.has(node.typeName.name);
7439
+ }
7440
+ function isNullishType(node) {
7441
+ return node.type === AST_NODE_TYPES34.TSNullKeyword || node.type === AST_NODE_TYPES34.TSUndefinedKeyword;
7442
+ }
7443
+ function isNullableArrayOnly(node) {
7444
+ const values = node.types.filter((member) => !isNullishType(member));
7445
+ return values.length > 0 && values.length < node.types.length && values.every(isArrayType);
7446
+ }
7447
+ var prefer_non_nullable_collection_default = ESLintUtils47.RuleCreator(
7448
+ (name) => `https://github.com/sarj-ai/standards/tree/main/packages/typescript#${name}`
7449
+ )({
7450
+ name: "prefer-non-nullable-collection",
7451
+ meta: {
7452
+ type: "suggestion",
7453
+ docs: {
7454
+ description: "Require array types to use an empty array instead of explicit nullish unions with two equivalent empty states."
7455
+ },
7456
+ schema: [],
7457
+ messages: {
7458
+ preferNonNullableCollection: "`{{name}}` is a nullable array, so nullish and `[]` represent the same empty collection. Use a non-null array and default omitted values to `[]`."
7459
+ }
7460
+ },
7461
+ defaultOptions: [],
7462
+ create(context) {
7463
+ const normalizedFilename = context.filename.replaceAll("\\", "/");
7464
+ if (isTestFile(context.filename) || isGeneratedFile(context.filename, context.sourceCode.text) || /\/(?:vendor|vendored)\//u.test(normalizedFilename)) {
7465
+ return {};
7466
+ }
7467
+ function checkOptionalProperty(node) {
7468
+ const annotation = node.typeAnnotation?.typeAnnotation;
7469
+ if (annotation === void 0) return;
7470
+ if (annotation.type !== AST_NODE_TYPES34.TSUnionType || !isNullableArrayOnly(annotation)) {
7471
+ return;
7472
+ }
7473
+ context.report({
7474
+ node,
7475
+ messageId: "preferNonNullableCollection",
7476
+ data: { name: propertyName(node) }
7477
+ });
7478
+ }
7479
+ return {
7480
+ TSPropertySignature: checkOptionalProperty,
7481
+ PropertyDefinition: checkOptionalProperty,
7482
+ TSTypeAliasDeclaration(node) {
7483
+ if (node.typeAnnotation.type !== AST_NODE_TYPES34.TSUnionType) return;
7484
+ if (!isNullableArrayOnly(node.typeAnnotation)) return;
7485
+ context.report({
7486
+ node,
7487
+ messageId: "preferNonNullableCollection",
7488
+ data: { name: node.id.name }
7489
+ });
7490
+ }
7491
+ };
7492
+ }
7493
+ });
7494
+
7160
7495
  // src/index.ts
7161
7496
  var rules = {
7162
7497
  "enforce-file-structure": enforce_file_structure_default,
@@ -7202,12 +7537,15 @@ var rules = {
7202
7537
  "prefer-module-level-constant": prefer_module_level_constant_default,
7203
7538
  "jsdoc-restates-signature": jsdoc_restates_signature_default,
7204
7539
  "no-restated-comment": no_restated_comment_default,
7205
- "trailing-value-narration": trailing_value_narration_default
7540
+ "trailing-value-narration": trailing_value_narration_default,
7541
+ "no-tautological-expect": no_tautological_expect_default,
7542
+ "require-interface-for-injected-service": require_interface_for_injected_service_default,
7543
+ "prefer-non-nullable-collection": prefer_non_nullable_collection_default
7206
7544
  };
7207
7545
  var plugin = {
7208
7546
  meta: {
7209
7547
  name: "@sarj/eslint-plugin",
7210
- version: "2.13.0"
7548
+ version: "2.15.0"
7211
7549
  },
7212
7550
  rules,
7213
7551
  configs: {
@@ -7232,7 +7570,10 @@ var plugin = {
7232
7570
  "@sarj/prefer-discriminated-union": "warn",
7233
7571
  "@sarj/no-comment-cruft": "warn",
7234
7572
  // Frontend / styling — distilled from frontend PR-review mining.
7235
- "@sarj/prefer-semantic-colors": ["warn", { requireSemanticTokens: true }],
7573
+ "@sarj/prefer-semantic-colors": [
7574
+ "warn",
7575
+ { requireSemanticTokens: true }
7576
+ ],
7236
7577
  // Ported from sarj-python-lint (SARJ), corpus-validated FP~0.
7237
7578
  "@sarj/no-fat-try-blocks": "warn",
7238
7579
  "@sarj/no-cors-wildcard-with-credentials": "warn",
@@ -7274,7 +7615,21 @@ var plugin = {
7274
7615
  // wrong deletion is silent information loss.
7275
7616
  "@sarj/no-restated-comment": "warn",
7276
7617
  "@sarj/jsdoc-restates-signature": "warn",
7277
- "@sarj/trailing-value-narration": "warn"
7618
+ "@sarj/trailing-value-narration": "warn",
7619
+ // The TS half of SARJ057 (2026-07). Python has caught the
7620
+ // assertion-FREE test since 0.15.0 (SARJ043) and had no TS
7621
+ // counterpart, which is how `expect(true).toBe(true); // placeholder`
7622
+ // survived in internal-automations: the file HAS an assertion.
7623
+ // Measured across 5,819 .ts/.tsx files (1,003 of them test files) in
7624
+ // six internal repos plus got / hono / swr / trpc: 3 hits, 3 true
7625
+ // positives, 0 false positives.
7626
+ "@sarj/no-tautological-expect": "warn",
7627
+ // Substitutability: an exported service class with injected
7628
+ // collaborators and no interface above it can only be tested by
7629
+ // mocking. 11-repo sweep: 229 exported classes, 82% already carry a
7630
+ // port, 29 fire, 28 of them true positives.
7631
+ "@sarj/require-interface-for-injected-service": "warn",
7632
+ "@sarj/prefer-non-nullable-collection": "warn"
7278
7633
  }
7279
7634
  },
7280
7635
  strict: {
@@ -7302,7 +7657,10 @@ var plugin = {
7302
7657
  "@sarj/no-comment-cruft": "error",
7303
7658
  // Frontend / styling — distilled from frontend PR-review mining. Stylistic,
7304
7659
  // no autofix → warn (rollout should prove the FP rate before raising it).
7305
- "@sarj/prefer-semantic-colors": ["error", { requireSemanticTokens: true }],
7660
+ "@sarj/prefer-semantic-colors": [
7661
+ "error",
7662
+ { requireSemanticTokens: true }
7663
+ ],
7306
7664
  // Ported from sarj-python-lint (SARJ), corpus-validated FP~0.
7307
7665
  "@sarj/no-fat-try-blocks": "error",
7308
7666
  "@sarj/no-cors-wildcard-with-credentials": "error",
@@ -7342,7 +7700,14 @@ var plugin = {
7342
7700
  // for the measured hit counts and false-positive rates.
7343
7701
  "@sarj/no-restated-comment": "error",
7344
7702
  "@sarj/jsdoc-restates-signature": "error",
7345
- "@sarj/trailing-value-narration": "error"
7703
+ "@sarj/trailing-value-narration": "error",
7704
+ // TS half of SARJ057 — see the `recommended` block for the measurement.
7705
+ "@sarj/no-tautological-expect": "error",
7706
+ // Substitutability: the TS sibling of the Python `prefer-real-store-in-tests`
7707
+ // / `prefer-library-fake` wave. The convention already exists in the
7708
+ // corpus (175 `implements` clauses vs 29 hits), so strict enforces it.
7709
+ "@sarj/require-interface-for-injected-service": "error",
7710
+ "@sarj/prefer-non-nullable-collection": "error"
7346
7711
  }
7347
7712
  }
7348
7713
  }