@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/README.md CHANGED
@@ -12,10 +12,41 @@ import sarj from "@sarj/eslint-plugin";
12
12
  export default [...sarj.configs.recommended];
13
13
  ```
14
14
 
15
- 44 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
 
19
+ ## New in 2.14.0 — `no-tautological-expect`
20
+
21
+ The TS half of SARJ057. An `expect(...)` whose operands are all literals has
22
+ already decided its outcome before the test runs — `expect(true).toBe(true)`
23
+ passes if you delete the module under test.
24
+
25
+ Python has caught the assertion-*free* test since 0.15.0 (`SARJ043
26
+ zero-assertion-test`) and had no TypeScript counterpart, which is exactly how
27
+ `expect(true).toBe(true); // placeholder` survived in an internal suite named for
28
+ the behaviour it was supposed to check: the file *has* an assertion, so nothing
29
+ was looking at it.
30
+
31
+ | Rule | What it catches | Preset |
32
+ |---|---|---|
33
+ | `no-tautological-expect` | `expect(<literal>).toBe/toEqual/toStrictEqual(<textually identical literal>)`, and `expect(<literal>).toBeDefined()/toBeTruthy()/toBeNull()/…` — a zero-argument matcher on a literal receiver. | warn / error |
34
+
35
+ **The narrowness is the rule.** The obvious generalisation — "flag a comparison
36
+ of a thing with itself" — measures ~95% false positives:
37
+ `expect(hash([o])).toEqual(hash([o]))` is a *determinism* test,
38
+ `expect(memo(x)).toBe(memo(x))` a *memoization* test, `expect(a).toEqual(a)` on a
39
+ value with custom equality a *reflexivity* test. All three can genuinely fail. So
40
+ an identifier, member-expression or call operand is never enough: both sides must
41
+ be literals, and textually identical ones. A modified chain (`.not`, `.resolves`,
42
+ `.rejects`), a spread, an interpolated template literal, and two *different*
43
+ literals are all left alone.
44
+
45
+ Measured before shipping: **3 hits across 5,819 `.ts`/`.tsx` files** (1,003 of
46
+ them test files, where the rule is active) — six internal repos plus `got`,
47
+ `hono`, `swr` and `trpc`. 3 true positives, **0 false positives**; every hit is
48
+ an abandoned placeholder.
49
+
19
50
  ## New in 2.13.0 — the anti-comment-verbosity family
20
51
 
21
52
  From a 37,918-comment, nine-repo measurement study. All three are
@@ -37,6 +68,7 @@ Both distilled from two years of PR-review comments across ~1,065 PRs.
37
68
  |---|---|---|
38
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 |
39
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 |
40
72
 
41
73
  ## Options
42
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);
@@ -5143,8 +5143,8 @@ var single_public_export_default = import_utils33.ESLintUtils.RuleCreator(
5143
5143
  if (base.endsWith(".d.ts")) return {};
5144
5144
  if (TEST_FILE_RE.test(base)) return {};
5145
5145
  if (isTestFile(context.filename)) return {};
5146
- const stem2 = stemOf(base);
5147
- if (!JUNK_DRAWER_STEMS.has(stem2.toLowerCase())) return {};
5146
+ const stem3 = stemOf(base);
5147
+ if (!JUNK_DRAWER_STEMS.has(stem3.toLowerCase())) return {};
5148
5148
  return {
5149
5149
  Program(node) {
5150
5150
  const { names, hasReExport, candidate } = summarizeExports(node.body);
@@ -5152,11 +5152,11 @@ var single_public_export_default = import_utils33.ESLintUtils.RuleCreator(
5152
5152
  if (names !== 1 || candidate === null) return;
5153
5153
  if (CONVENTIONAL_BUCKET_EXPORTS.has(candidate.name)) return;
5154
5154
  const expected = kebabCase2(candidate.name);
5155
- if (stem2 === expected) return;
5155
+ if (stem3 === expected) return;
5156
5156
  context.report({
5157
5157
  node: candidate.node,
5158
5158
  messageId: "renameJunkDrawer",
5159
- data: { stem: stem2, name: candidate.name, expected }
5159
+ data: { stem: stem3, name: candidate.name, expected }
5160
5160
  });
5161
5161
  }
5162
5162
  };
@@ -5310,7 +5310,7 @@ function createSqlListener(handler) {
5310
5310
  }
5311
5311
 
5312
5312
  // src/rules/no-offset-pagination.ts
5313
- var OFFSET_PAGINATION = /\bOFFSET\s+(?:\?\d*|:\w+|@\w+|\$\d+|\d+)/i;
5313
+ var OFFSET_PAGINATION = /\bOFFSET\s+(?:%s|%\(\w+\)s|\?\d*|:\w+|@\w+|\$\d+|\d+)/i;
5314
5314
  var OFFSET_GATE = /offset/i;
5315
5315
  var no_offset_pagination_default = import_utils35.ESLintUtils.RuleCreator(
5316
5316
  (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
@@ -5502,7 +5502,6 @@ var no_positional_tuple_return_default = import_utils36.ESLintUtils.RuleCreator(
5502
5502
  // src/rules/no-repeated-string-literal.ts
5503
5503
  var import_utils37 = require("@typescript-eslint/utils");
5504
5504
  var MIN_LENGTH = 40;
5505
- var MIN_OCCURRENCES = 3;
5506
5505
  var MIN_DISTINCT_SCOPES = 2;
5507
5506
  var PREVIEW_LENGTH = 40;
5508
5507
  var SQL_KEYWORD_RE = /\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|VALUES|ON CONFLICT|RETURNING|GROUP BY|ORDER BY)\b/;
@@ -5584,9 +5583,6 @@ var no_repeated_string_literal_default = import_utils37.ESLintUtils.RuleCreator(
5584
5583
  },
5585
5584
  "Program:exit": () => {
5586
5585
  for (const [value, nodes] of occurrences) {
5587
- if (nodes.length < MIN_OCCURRENCES) {
5588
- continue;
5589
- }
5590
5586
  const distinctScopes = new Set(
5591
5587
  nodes.map((node) => scopes.get(node)).filter((scope) => scope != null)
5592
5588
  );
@@ -7160,6 +7156,345 @@ var trailing_value_narration_default = import_utils49.ESLintUtils.RuleCreator(
7160
7156
  }
7161
7157
  });
7162
7158
 
7159
+ // src/rules/no-tautological-expect.ts
7160
+ var import_utils50 = require("@typescript-eslint/utils");
7161
+ var EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual"]);
7162
+ var ZERO_ARG_MATCHERS = /* @__PURE__ */ new Set([
7163
+ "toBeDefined",
7164
+ "toBeUndefined",
7165
+ "toBeNull",
7166
+ "toBeTruthy",
7167
+ "toBeFalsy",
7168
+ "toBeNaN"
7169
+ ]);
7170
+ var OPERAND_PREVIEW_CHARS = 40;
7171
+ var NUMERIC_SIGNS = /* @__PURE__ */ new Set(["-", "+"]);
7172
+ function isLiteral(node) {
7173
+ switch (node.type) {
7174
+ case import_utils50.AST_NODE_TYPES.Literal:
7175
+ return true;
7176
+ case import_utils50.AST_NODE_TYPES.TemplateLiteral:
7177
+ return node.expressions.length === 0;
7178
+ case import_utils50.AST_NODE_TYPES.UnaryExpression:
7179
+ return NUMERIC_SIGNS.has(node.operator) && isLiteral(node.argument);
7180
+ case import_utils50.AST_NODE_TYPES.ArrayExpression:
7181
+ return node.elements.every((element) => element !== null && isLiteral(element));
7182
+ case import_utils50.AST_NODE_TYPES.ObjectExpression:
7183
+ return node.properties.every(
7184
+ (property) => property.type === import_utils50.AST_NODE_TYPES.Property && !property.computed && isLiteral(property.value)
7185
+ );
7186
+ default:
7187
+ return false;
7188
+ }
7189
+ }
7190
+ function expectOperand(callee) {
7191
+ const receiver = callee.object;
7192
+ if (receiver.type !== import_utils50.AST_NODE_TYPES.CallExpression || receiver.callee.type !== import_utils50.AST_NODE_TYPES.Identifier || receiver.callee.name !== "expect" || receiver.arguments.length !== 1) {
7193
+ return null;
7194
+ }
7195
+ return receiver.arguments[0] ?? null;
7196
+ }
7197
+ var no_tautological_expect_default = import_utils50.ESLintUtils.RuleCreator(
7198
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7199
+ )({
7200
+ name: "no-tautological-expect",
7201
+ meta: {
7202
+ type: "problem",
7203
+ docs: {
7204
+ description: "Disallow an assertion whose operands are all literals; its outcome is fixed before the code runs, so it can never fail."
7205
+ },
7206
+ schema: [],
7207
+ messages: {
7208
+ 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.",
7209
+ 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."
7210
+ }
7211
+ },
7212
+ defaultOptions: [],
7213
+ create(context) {
7214
+ if (!isTestFile(context.filename)) {
7215
+ return {};
7216
+ }
7217
+ const preview2 = (node) => {
7218
+ const text = context.sourceCode.getText(node).replaceAll(/\s+/gu, " ");
7219
+ return text.length > OPERAND_PREVIEW_CHARS ? `${text.slice(0, OPERAND_PREVIEW_CHARS)}\u2026` : text;
7220
+ };
7221
+ return {
7222
+ CallExpression(node) {
7223
+ const callee = node.callee;
7224
+ if (callee.type !== import_utils50.AST_NODE_TYPES.MemberExpression || callee.computed) {
7225
+ return;
7226
+ }
7227
+ if (callee.property.type !== import_utils50.AST_NODE_TYPES.Identifier) {
7228
+ return;
7229
+ }
7230
+ const matcher = callee.property.name;
7231
+ const operand = expectOperand(callee);
7232
+ if (operand === null || !isLiteral(operand)) {
7233
+ return;
7234
+ }
7235
+ if (ZERO_ARG_MATCHERS.has(matcher) && node.arguments.length === 0) {
7236
+ context.report({
7237
+ node,
7238
+ messageId: "tautologicalMatcher",
7239
+ data: { operand: preview2(operand), matcher }
7240
+ });
7241
+ return;
7242
+ }
7243
+ const expected = node.arguments[0];
7244
+ if (!EQUALITY_MATCHERS.has(matcher) || node.arguments.length !== 1 || expected === void 0 || !isLiteral(expected)) {
7245
+ return;
7246
+ }
7247
+ if (context.sourceCode.getText(operand) !== context.sourceCode.getText(expected)) {
7248
+ return;
7249
+ }
7250
+ context.report({
7251
+ node,
7252
+ messageId: "tautologicalComparison",
7253
+ data: { operand: preview2(operand), matcher }
7254
+ });
7255
+ }
7256
+ };
7257
+ }
7258
+ });
7259
+
7260
+ // src/rules/require-interface-for-injected-service.ts
7261
+ var import_utils51 = require("@typescript-eslint/utils");
7262
+ var CONFIGISH_TYPE_RE = /(?:Options|Opts|Config|Configuration|Settings|Params|Props|Args|Env|Environment|Callbacks|Flags)$/;
7263
+ var CONFIGISH_NAME_RE = /^(?:options|opts|config|configuration|settings|params|props|args|env|environment|callbacks|flags|logger|log|clock)$/i;
7264
+ var HTTP_TRANSPORT_TYPE_RE = /^(?:KyInstance|AxiosInstance|Session)$/;
7265
+ var TRANSPORT_WRAPPER_NAME_RE = /Client$/;
7266
+ var ROUTER_FACTORY_NAME = "Router";
7267
+ var FRAMEWORK_HTTP_TYPES = /* @__PURE__ */ new Set(["Request", "Response", "NextFunction"]);
7268
+ var isExportedClass = (node) => node.parent.type === import_utils51.AST_NODE_TYPES.ExportNamedDeclaration || node.parent.type === import_utils51.AST_NODE_TYPES.ExportDefaultDeclaration;
7269
+ 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}` : "";
7270
+ var typeReferenceName = (annotated) => {
7271
+ let target = annotated;
7272
+ if (target.type === import_utils51.AST_NODE_TYPES.TSParameterProperty) target = target.parameter;
7273
+ if (target.type === import_utils51.AST_NODE_TYPES.AssignmentPattern) target = target.left;
7274
+ if (target.type !== import_utils51.AST_NODE_TYPES.Identifier) return null;
7275
+ const annotation = target.typeAnnotation?.typeAnnotation;
7276
+ if (annotation === void 0 || annotation.type !== import_utils51.AST_NODE_TYPES.TSTypeReference) {
7277
+ return null;
7278
+ }
7279
+ const { typeName } = annotation;
7280
+ const rightmost = typeName.type === import_utils51.AST_NODE_TYPES.Identifier ? typeName.name : typeName.type === import_utils51.AST_NODE_TYPES.TSQualifiedName ? typeName.right.name : null;
7281
+ if (rightmost === null) return null;
7282
+ return { name: target.name, typeName: rightmost, display: qualifiedName(typeName) };
7283
+ };
7284
+ var readConstructor = (ctor) => {
7285
+ const body = ctor.value.body;
7286
+ const storedFrom = /* @__PURE__ */ new Set();
7287
+ let constructedFields = 0;
7288
+ if (body !== null && body !== void 0) {
7289
+ for (const statement of body.body) {
7290
+ if (statement.type !== import_utils51.AST_NODE_TYPES.ExpressionStatement) continue;
7291
+ const expression = statement.expression;
7292
+ 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) {
7293
+ continue;
7294
+ }
7295
+ const source = expression.right;
7296
+ if (source.type === import_utils51.AST_NODE_TYPES.NewExpression) {
7297
+ constructedFields += 1;
7298
+ } else if (source.type === import_utils51.AST_NODE_TYPES.Identifier) {
7299
+ storedFrom.add(source.name);
7300
+ } else if (source.type === import_utils51.AST_NODE_TYPES.MemberExpression && source.object.type === import_utils51.AST_NODE_TYPES.Identifier) {
7301
+ storedFrom.add(source.object.name);
7302
+ }
7303
+ }
7304
+ }
7305
+ const collaborators = [];
7306
+ for (const parameter of ctor.value.params) {
7307
+ const reference = typeReferenceName(parameter);
7308
+ if (reference === null) continue;
7309
+ const stored = parameter.type === import_utils51.AST_NODE_TYPES.TSParameterProperty || storedFrom.has(reference.name);
7310
+ if (!stored) continue;
7311
+ if (CONFIGISH_TYPE_RE.test(reference.typeName)) continue;
7312
+ if (CONFIGISH_NAME_RE.test(reference.name)) continue;
7313
+ collaborators.push(reference);
7314
+ }
7315
+ return { collaborators, constructedFields };
7316
+ };
7317
+ var subtreeHas = (root, found) => {
7318
+ let hit = false;
7319
+ const visit = (current) => {
7320
+ if (hit) return;
7321
+ if (found(current)) {
7322
+ hit = true;
7323
+ return;
7324
+ }
7325
+ for (const key of Object.keys(current)) {
7326
+ if (key === "parent") continue;
7327
+ const value = current[key];
7328
+ for (const child of Array.isArray(value) ? value : [value]) {
7329
+ if (child !== null && typeof child === "object" && typeof child.type === "string") {
7330
+ visit(child);
7331
+ }
7332
+ }
7333
+ }
7334
+ };
7335
+ visit(root);
7336
+ return hit;
7337
+ };
7338
+ var isFrameworkWiring = (body) => subtreeHas(body, (node) => {
7339
+ if (node.type === import_utils51.AST_NODE_TYPES.CallExpression) {
7340
+ const { callee } = node;
7341
+ if (callee.type === import_utils51.AST_NODE_TYPES.Identifier) return callee.name === ROUTER_FACTORY_NAME;
7342
+ 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;
7343
+ }
7344
+ 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);
7345
+ });
7346
+ var stem2 = (name) => name.replace(/^I(?=[A-Z])/, "").replace(/Impl$/, "");
7347
+ var fileInterfaceNames = (program) => {
7348
+ const names = [];
7349
+ for (const statement of program.body) {
7350
+ const declaration = statement.type === import_utils51.AST_NODE_TYPES.ExportNamedDeclaration ? statement.declaration : statement;
7351
+ if (declaration?.type === import_utils51.AST_NODE_TYPES.TSInterfaceDeclaration) names.push(declaration.id.name);
7352
+ }
7353
+ return names;
7354
+ };
7355
+ var isTransportWrapper = (className, collaborators, program) => {
7356
+ const [only] = collaborators;
7357
+ if (collaborators.length !== 1 || only === void 0) return false;
7358
+ if (!HTTP_TRANSPORT_TYPE_RE.test(only.typeName)) return false;
7359
+ if (!TRANSPORT_WRAPPER_NAME_RE.test(className)) return false;
7360
+ const target = stem2(className);
7361
+ return !fileInterfaceNames(program).some((name) => {
7362
+ const other = stem2(name);
7363
+ return target.endsWith(other) || other.endsWith(target);
7364
+ });
7365
+ };
7366
+ var publicMethodNames = (body) => {
7367
+ const names = [];
7368
+ for (const member of body.body) {
7369
+ if (member.type !== import_utils51.AST_NODE_TYPES.MethodDefinition) continue;
7370
+ if (member.kind !== "method" || member.static) continue;
7371
+ if (member.accessibility === "private" || member.accessibility === "protected") continue;
7372
+ if (member.key.type === import_utils51.AST_NODE_TYPES.PrivateIdentifier) continue;
7373
+ if (member.key.type === import_utils51.AST_NODE_TYPES.Identifier) names.push(member.key.name);
7374
+ else names.push("\u2026");
7375
+ }
7376
+ return names;
7377
+ };
7378
+ var require_interface_for_injected_service_default = import_utils51.ESLintUtils.RuleCreator(
7379
+ (name) => `https://github.com/sarj-ai/standards/blob/main/packages/typescript/src/rules/${name}.ts`
7380
+ )({
7381
+ name: "require-interface-for-injected-service",
7382
+ meta: {
7383
+ type: "suggestion",
7384
+ docs: {
7385
+ 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."
7386
+ },
7387
+ schema: [],
7388
+ messages: {
7389
+ 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>`."
7390
+ }
7391
+ },
7392
+ defaultOptions: [],
7393
+ create(context) {
7394
+ const { filename } = context;
7395
+ if (isTestFile(filename) || isStoryFile(filename) || isScriptFile(filename)) return {};
7396
+ if (isGeneratedFile(filename, context.sourceCode.getText())) return {};
7397
+ return {
7398
+ ClassDeclaration(node) {
7399
+ if (node.id === null) return;
7400
+ if (!isExportedClass(node)) return;
7401
+ if (node.abstract === true) return;
7402
+ if (node.superClass !== null) return;
7403
+ if (node.implements.length > 0) return;
7404
+ if (node.decorators.length > 0) return;
7405
+ const ctor = node.body.body.find(
7406
+ (member) => member.type === import_utils51.AST_NODE_TYPES.MethodDefinition && member.kind === "constructor"
7407
+ );
7408
+ if (ctor === void 0) return;
7409
+ const { collaborators, constructedFields } = readConstructor(ctor);
7410
+ if (collaborators.length === 0) return;
7411
+ if (constructedFields > collaborators.length) return;
7412
+ if (isFrameworkWiring(node.body)) return;
7413
+ if (isTransportWrapper(node.id.name, collaborators, context.sourceCode.ast)) return;
7414
+ const methods = publicMethodNames(node.body);
7415
+ if (methods.length === 0) return;
7416
+ context.report({
7417
+ node: node.id,
7418
+ messageId: "requireInterface",
7419
+ data: {
7420
+ name: node.id.name,
7421
+ deps: collaborators.map((c) => `${c.name}: ${c.display}`).join(", "),
7422
+ methods: methods.join(", ")
7423
+ }
7424
+ });
7425
+ }
7426
+ };
7427
+ }
7428
+ });
7429
+
7430
+ // src/rules/prefer-non-nullable-collection.ts
7431
+ var import_utils52 = require("@typescript-eslint/utils");
7432
+ var ARRAY_TYPE_NAMES = /* @__PURE__ */ new Set(["Array", "ReadonlyArray"]);
7433
+ function propertyName(node) {
7434
+ const key = node.key;
7435
+ if (key.type === import_utils52.AST_NODE_TYPES.Identifier) return key.name;
7436
+ if (key.type === import_utils52.AST_NODE_TYPES.Literal) return String(key.value);
7437
+ return "collection";
7438
+ }
7439
+ function isArrayType(node) {
7440
+ if (node.type === import_utils52.AST_NODE_TYPES.TSArrayType) return true;
7441
+ 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);
7442
+ }
7443
+ function isNullishType(node) {
7444
+ return node.type === import_utils52.AST_NODE_TYPES.TSNullKeyword || node.type === import_utils52.AST_NODE_TYPES.TSUndefinedKeyword;
7445
+ }
7446
+ function isNullableArrayOnly(node) {
7447
+ const values = node.types.filter((member) => !isNullishType(member));
7448
+ return values.length > 0 && values.length < node.types.length && values.every(isArrayType);
7449
+ }
7450
+ var prefer_non_nullable_collection_default = import_utils52.ESLintUtils.RuleCreator(
7451
+ (name) => `https://github.com/sarj-ai/standards/tree/main/packages/typescript#${name}`
7452
+ )({
7453
+ name: "prefer-non-nullable-collection",
7454
+ meta: {
7455
+ type: "suggestion",
7456
+ docs: {
7457
+ description: "Require array types to use an empty array instead of explicit nullish unions with two equivalent empty states."
7458
+ },
7459
+ schema: [],
7460
+ messages: {
7461
+ preferNonNullableCollection: "`{{name}}` is a nullable array, so nullish and `[]` represent the same empty collection. Use a non-null array and default omitted values to `[]`."
7462
+ }
7463
+ },
7464
+ defaultOptions: [],
7465
+ create(context) {
7466
+ const normalizedFilename = context.filename.replaceAll("\\", "/");
7467
+ if (isTestFile(context.filename) || isGeneratedFile(context.filename, context.sourceCode.text) || /\/(?:vendor|vendored)\//u.test(normalizedFilename)) {
7468
+ return {};
7469
+ }
7470
+ function checkOptionalProperty(node) {
7471
+ const annotation = node.typeAnnotation?.typeAnnotation;
7472
+ if (annotation === void 0) return;
7473
+ if (annotation.type !== import_utils52.AST_NODE_TYPES.TSUnionType || !isNullableArrayOnly(annotation)) {
7474
+ return;
7475
+ }
7476
+ context.report({
7477
+ node,
7478
+ messageId: "preferNonNullableCollection",
7479
+ data: { name: propertyName(node) }
7480
+ });
7481
+ }
7482
+ return {
7483
+ TSPropertySignature: checkOptionalProperty,
7484
+ PropertyDefinition: checkOptionalProperty,
7485
+ TSTypeAliasDeclaration(node) {
7486
+ if (node.typeAnnotation.type !== import_utils52.AST_NODE_TYPES.TSUnionType) return;
7487
+ if (!isNullableArrayOnly(node.typeAnnotation)) return;
7488
+ context.report({
7489
+ node,
7490
+ messageId: "preferNonNullableCollection",
7491
+ data: { name: node.id.name }
7492
+ });
7493
+ }
7494
+ };
7495
+ }
7496
+ });
7497
+
7163
7498
  // src/index.ts
7164
7499
  var rules = {
7165
7500
  "enforce-file-structure": enforce_file_structure_default,
@@ -7205,12 +7540,15 @@ var rules = {
7205
7540
  "prefer-module-level-constant": prefer_module_level_constant_default,
7206
7541
  "jsdoc-restates-signature": jsdoc_restates_signature_default,
7207
7542
  "no-restated-comment": no_restated_comment_default,
7208
- "trailing-value-narration": trailing_value_narration_default
7543
+ "trailing-value-narration": trailing_value_narration_default,
7544
+ "no-tautological-expect": no_tautological_expect_default,
7545
+ "require-interface-for-injected-service": require_interface_for_injected_service_default,
7546
+ "prefer-non-nullable-collection": prefer_non_nullable_collection_default
7209
7547
  };
7210
7548
  var plugin = {
7211
7549
  meta: {
7212
7550
  name: "@sarj/eslint-plugin",
7213
- version: "2.13.0"
7551
+ version: "2.15.0"
7214
7552
  },
7215
7553
  rules,
7216
7554
  configs: {
@@ -7235,7 +7573,10 @@ var plugin = {
7235
7573
  "@sarj/prefer-discriminated-union": "warn",
7236
7574
  "@sarj/no-comment-cruft": "warn",
7237
7575
  // Frontend / styling — distilled from frontend PR-review mining.
7238
- "@sarj/prefer-semantic-colors": ["warn", { requireSemanticTokens: true }],
7576
+ "@sarj/prefer-semantic-colors": [
7577
+ "warn",
7578
+ { requireSemanticTokens: true }
7579
+ ],
7239
7580
  // Ported from sarj-python-lint (SARJ), corpus-validated FP~0.
7240
7581
  "@sarj/no-fat-try-blocks": "warn",
7241
7582
  "@sarj/no-cors-wildcard-with-credentials": "warn",
@@ -7277,7 +7618,21 @@ var plugin = {
7277
7618
  // wrong deletion is silent information loss.
7278
7619
  "@sarj/no-restated-comment": "warn",
7279
7620
  "@sarj/jsdoc-restates-signature": "warn",
7280
- "@sarj/trailing-value-narration": "warn"
7621
+ "@sarj/trailing-value-narration": "warn",
7622
+ // The TS half of SARJ057 (2026-07). Python has caught the
7623
+ // assertion-FREE test since 0.15.0 (SARJ043) and had no TS
7624
+ // counterpart, which is how `expect(true).toBe(true); // placeholder`
7625
+ // survived in internal-automations: the file HAS an assertion.
7626
+ // Measured across 5,819 .ts/.tsx files (1,003 of them test files) in
7627
+ // six internal repos plus got / hono / swr / trpc: 3 hits, 3 true
7628
+ // positives, 0 false positives.
7629
+ "@sarj/no-tautological-expect": "warn",
7630
+ // Substitutability: an exported service class with injected
7631
+ // collaborators and no interface above it can only be tested by
7632
+ // mocking. 11-repo sweep: 229 exported classes, 82% already carry a
7633
+ // port, 29 fire, 28 of them true positives.
7634
+ "@sarj/require-interface-for-injected-service": "warn",
7635
+ "@sarj/prefer-non-nullable-collection": "warn"
7281
7636
  }
7282
7637
  },
7283
7638
  strict: {
@@ -7305,7 +7660,10 @@ var plugin = {
7305
7660
  "@sarj/no-comment-cruft": "error",
7306
7661
  // Frontend / styling — distilled from frontend PR-review mining. Stylistic,
7307
7662
  // no autofix → warn (rollout should prove the FP rate before raising it).
7308
- "@sarj/prefer-semantic-colors": ["error", { requireSemanticTokens: true }],
7663
+ "@sarj/prefer-semantic-colors": [
7664
+ "error",
7665
+ { requireSemanticTokens: true }
7666
+ ],
7309
7667
  // Ported from sarj-python-lint (SARJ), corpus-validated FP~0.
7310
7668
  "@sarj/no-fat-try-blocks": "error",
7311
7669
  "@sarj/no-cors-wildcard-with-credentials": "error",
@@ -7345,7 +7703,14 @@ var plugin = {
7345
7703
  // for the measured hit counts and false-positive rates.
7346
7704
  "@sarj/no-restated-comment": "error",
7347
7705
  "@sarj/jsdoc-restates-signature": "error",
7348
- "@sarj/trailing-value-narration": "error"
7706
+ "@sarj/trailing-value-narration": "error",
7707
+ // TS half of SARJ057 — see the `recommended` block for the measurement.
7708
+ "@sarj/no-tautological-expect": "error",
7709
+ // Substitutability: the TS sibling of the Python `prefer-real-store-in-tests`
7710
+ // / `prefer-library-fake` wave. The convention already exists in the
7711
+ // corpus (175 `implements` clauses vs 29 hits), so strict enforces it.
7712
+ "@sarj/require-interface-for-injected-service": "error",
7713
+ "@sarj/prefer-non-nullable-collection": "error"
7349
7714
  }
7350
7715
  }
7351
7716
  }