@sarj/eslint-plugin 16.23.3 → 16.26.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.
Files changed (3) hide show
  1. package/dist/index.cjs +2142 -1993
  2. package/dist/index.js +219 -70
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -836,7 +836,7 @@ function isSearchParamReceiver(call, services) {
836
836
  // package.json
837
837
  var package_default = {
838
838
  name: "@sarj/eslint-plugin",
839
- version: "16.23.3",
839
+ version: "16.26.0",
840
840
  packageManager: "npm@12.2.0",
841
841
  description: "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
842
842
  type: "module",
@@ -7275,32 +7275,39 @@ import { ASTUtils as ASTUtils21, AST_NODE_TYPES as AST_NODE_TYPES40 } from "@typ
7275
7275
 
7276
7276
  // src/rules/_sql.ts
7277
7277
  import { AST_NODE_TYPES as AST_NODE_TYPES39, ASTUtils as ASTUtils20 } from "@typescript-eslint/utils";
7278
- function stripSqlNoise(text) {
7279
- return scanSqlNoise(text);
7278
+ var SQL_IDENTIFIER_CONTINUATION = /[\w$\u0080-\u{10FFFF}]/u;
7279
+ var DOLLAR_DELIMITER = /\$(?:[A-Za-z_\u0080-\u{10FFFF}][A-Za-z_0-9\u0080-\u{10FFFF}]*)?\$/uy;
7280
+ var SQL_LINE_END = /[\r\n]/g;
7281
+ var CONTINUED_QUOTE = /[ \t\f\v]*(?:--[^\r\n]*)?[\r\n](?:[ \t\f\v\r\n]|--[^\r\n]*[\r\n])*'/y;
7282
+ function stripSqlNoise(text, options) {
7283
+ return scanSqlNoise(text, void 0, options?.preserveQuotedTokens === true);
7280
7284
  }
7281
7285
  function sqlNoiseInfo(text) {
7282
7286
  const singleQuotedRanges = [];
7283
7287
  const masked = scanSqlNoise(text, (start, end) => singleQuotedRanges.push([start, end]));
7284
7288
  return { text: masked, singleQuotedRanges };
7285
7289
  }
7286
- function scanSqlNoise(text, onSingleQuoted) {
7290
+ function scanSqlNoise(text, onSingleQuoted, preserveQuotedTokens = false) {
7287
7291
  const out = text.split("");
7288
7292
  let index = 0;
7289
7293
  while (index < text.length) {
7290
7294
  const dollarEnd = dollarQuotedSqlEnd(text, index);
7291
7295
  if (dollarEnd !== null) {
7292
7296
  maskSqlRange(text, out, index, dollarEnd);
7297
+ if (preserveQuotedTokens) out[index] = "?";
7293
7298
  index = dollarEnd;
7294
7299
  continue;
7295
7300
  }
7296
7301
  const character = text[index];
7297
7302
  if (character === "'" || character === '"') {
7303
+ const start = index;
7298
7304
  index = quotedSqlEnd(text, out, index, onSingleQuoted);
7305
+ if (preserveQuotedTokens) out[start] = "?";
7299
7306
  continue;
7300
7307
  }
7301
7308
  if (character === "-" && text[index + 1] === "-") {
7302
- const newline = text.indexOf("\n", index);
7303
- const end = newline === -1 ? text.length : newline;
7309
+ SQL_LINE_END.lastIndex = index;
7310
+ const end = SQL_LINE_END.exec(text)?.index ?? text.length;
7304
7311
  maskSqlRange(text, out, index, end);
7305
7312
  index = end;
7306
7313
  continue;
@@ -7314,8 +7321,9 @@ function scanSqlNoise(text, onSingleQuoted) {
7314
7321
  return out.join("");
7315
7322
  }
7316
7323
  function dollarQuotedSqlEnd(text, start) {
7317
- if (text[start] !== "$" || /[\w$]/u.test(text[start - 1] ?? "")) return null;
7318
- const delimiter = /^\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$/u.exec(text.slice(start))?.[0];
7324
+ if (text[start] !== "$" || SQL_IDENTIFIER_CONTINUATION.test(text[start - 1] ?? "")) return null;
7325
+ DOLLAR_DELIMITER.lastIndex = start;
7326
+ const delimiter = DOLLAR_DELIMITER.exec(text)?.[0];
7319
7327
  if (delimiter === void 0) return null;
7320
7328
  const closing = text.indexOf(delimiter, start + delimiter.length);
7321
7329
  return closing < 0 ? text.length : closing + delimiter.length;
@@ -7333,17 +7341,24 @@ function blockSqlCommentEnd(text, out, start) {
7333
7341
  index += 2;
7334
7342
  continue;
7335
7343
  }
7336
- if (text[index] !== "\n") out[index] = " ";
7344
+ if (text[index] !== "\n" && text[index] !== "\r") out[index] = " ";
7337
7345
  index += 1;
7338
7346
  }
7339
7347
  return index;
7340
7348
  }
7341
7349
  function quotedSqlEnd(text, out, start, onSingleQuoted) {
7342
7350
  const quote = text[start];
7351
+ const escapeString = quote === "'" && /[Ee]/u.test(text[start - 1] ?? "") && !SQL_IDENTIFIER_CONTINUATION.test(text[start - 2] ?? "");
7352
+ let segmentStart = start;
7343
7353
  out[start] = " ";
7344
7354
  let index = start + 1;
7345
7355
  while (index < text.length) {
7346
7356
  const character = text[index];
7357
+ if (character === "\\" && index + 1 < text.length) {
7358
+ maskSqlRange(text, out, index, index + 2);
7359
+ index += 2;
7360
+ continue;
7361
+ }
7347
7362
  if (character === quote) {
7348
7363
  out[index] = " ";
7349
7364
  if (index + 1 < text.length && text[index + 1] === quote) {
@@ -7352,17 +7367,29 @@ function quotedSqlEnd(text, out, start, onSingleQuoted) {
7352
7367
  continue;
7353
7368
  }
7354
7369
  index += 1;
7355
- if (quote === "'") onSingleQuoted?.(start, index);
7370
+ if (quote === "'") onSingleQuoted?.(segmentStart, index);
7371
+ const continuation = escapedSqlContinuation(text, index, escapeString);
7372
+ if (continuation !== null) {
7373
+ maskSqlRange(text, out, index, continuation);
7374
+ segmentStart = continuation - 1;
7375
+ index = continuation;
7376
+ continue;
7377
+ }
7356
7378
  break;
7357
7379
  }
7358
- if (character !== "\n") out[index] = " ";
7380
+ if (character !== "\n" && character !== "\r") out[index] = " ";
7359
7381
  index += 1;
7360
7382
  }
7361
7383
  return index;
7362
7384
  }
7385
+ function escapedSqlContinuation(text, start, escapeString) {
7386
+ if (!escapeString) return null;
7387
+ CONTINUED_QUOTE.lastIndex = start;
7388
+ return CONTINUED_QUOTE.test(text) ? CONTINUED_QUOTE.lastIndex : null;
7389
+ }
7363
7390
  function maskSqlRange(text, out, start, end) {
7364
7391
  for (let index = start; index < end; index += 1) {
7365
- if (text[index] !== "\n") out[index] = " ";
7392
+ if (text[index] !== "\n" && text[index] !== "\r") out[index] = " ";
7366
7393
  }
7367
7394
  }
7368
7395
  var SUBSTITUTION_MARKER = "?";
@@ -7428,7 +7455,7 @@ function markConsumed(node, consumed) {
7428
7455
  markConsumed(node.quasi, consumed);
7429
7456
  }
7430
7457
  }
7431
- function createSqlListener(handler) {
7458
+ function createSqlListener(handler, options) {
7432
7459
  const consumed = /* @__PURE__ */ new WeakSet();
7433
7460
  const visit = (node) => {
7434
7461
  if (consumed.has(node) || isDiscardedSqlText(node)) {
@@ -7439,7 +7466,7 @@ function createSqlListener(handler) {
7439
7466
  return;
7440
7467
  }
7441
7468
  markConsumed(node, consumed);
7442
- handler(stripSqlNoise(text), node);
7469
+ handler(stripSqlNoise(text, options), node, text);
7443
7470
  };
7444
7471
  return {
7445
7472
  BinaryExpression: (node) => {
@@ -8783,6 +8810,20 @@ import {
8783
8810
  AST_NODE_TYPES as AST_NODE_TYPES45,
8784
8811
  ASTUtils as ASTUtils24
8785
8812
  } from "@typescript-eslint/utils";
8813
+
8814
+ // src/rules/_stable-value-binding.ts
8815
+ import "@typescript-eslint/utils";
8816
+ function stableValueBinding(variable, acceptRead) {
8817
+ let initializer;
8818
+ return variable.references.every((reference) => {
8819
+ if (!reference.isWrite()) return acceptRead?.(reference) ?? true;
8820
+ if (reference.init !== true || initializer !== void 0 && initializer !== reference.identifier) return false;
8821
+ initializer = reference.identifier;
8822
+ return true;
8823
+ });
8824
+ }
8825
+
8826
+ // src/rules/no-detached-global-fetch.ts
8786
8827
  var NO_DETACHED_GLOBAL_FETCH_DOCUMENTATION = {
8787
8828
  summary: "Keep the ambient global fetch receiver-safe when storing or explicitly rebinding it.",
8788
8829
  rationale: "Calling a raw ambient fetch through a class or object property supplies that object as `this`; receiver-sensitive hosts can reject that invocation only after deployment.",
@@ -8851,14 +8892,9 @@ var no_detached_global_fetch_default = createRule({
8851
8892
  function isGlobalReceiver(node) {
8852
8893
  return node.type === AST_NODE_TYPES45.Identifier && GLOBAL_RECEIVERS.has(node.name) && resolvesToGlobal(node);
8853
8894
  }
8854
- function isStableAlias(variable) {
8855
- return !variable.references.some(
8856
- (reference) => reference.isWrite() && reference.init !== true
8857
- );
8858
- }
8859
8895
  function recordAlias(identifier) {
8860
8896
  const variable = bindingOf(identifier);
8861
- if (variable !== null && isStableAlias(variable)) aliases.add(variable);
8897
+ if (variable !== null && stableValueBinding(variable)) aliases.add(variable);
8862
8898
  }
8863
8899
  function staticMemberName7(node) {
8864
8900
  if (!node.computed) {
@@ -8871,7 +8907,7 @@ var no_detached_global_fetch_default = createRule({
8871
8907
  if (expression.type === AST_NODE_TYPES45.Identifier) {
8872
8908
  if (expression.name === "fetch" && resolvesToGlobal(expression)) return true;
8873
8909
  const variable = bindingOf(expression);
8874
- return variable !== null && aliases.has(variable) && isStableAlias(variable);
8910
+ return variable !== null && aliases.has(variable);
8875
8911
  }
8876
8912
  if (expression.type === AST_NODE_TYPES45.MemberExpression) {
8877
8913
  return isGlobalFetchMember(expression);
@@ -12624,18 +12660,18 @@ var no_router_refresh_polling_default = createRule({
12624
12660
  if (hookCallee.type !== AST_NODE_TYPES62.Identifier) return;
12625
12661
  const hook = ASTUtils39.findVariable(context.sourceCode.getScope(hookCallee), hookCallee.name);
12626
12662
  const router = ASTUtils39.findVariable(context.sourceCode.getScope(node.id), node.id.name);
12627
- if (hook !== null && router !== null && routerHooks.has(hook)) routers.add(router);
12663
+ if (hook !== null && router !== null && routerHooks.has(hook) && stableValueBinding(router)) routers.add(router);
12628
12664
  }
12629
12665
  },
12630
12666
  CallExpression(node) {
12631
12667
  const unwrappedNodeCallee = unwrapExpression(node.callee);
12632
12668
  const receiver = unwrappedNodeCallee.type === AST_NODE_TYPES62.MemberExpression ? unwrapExpression(unwrappedNodeCallee.object) : unwrappedNodeCallee;
12633
- if (unwrappedNodeCallee.type !== AST_NODE_TYPES62.MemberExpression || receiver.type !== AST_NODE_TYPES62.Identifier || ASTUtils39.getPropertyName(unwrappedNodeCallee) === null || (ASTUtils39.getPropertyName(unwrappedNodeCallee) ?? "") !== "refresh") return;
12669
+ if (unwrappedNodeCallee.type !== AST_NODE_TYPES62.MemberExpression || receiver.type !== AST_NODE_TYPES62.Identifier || ASTUtils39.getPropertyName(unwrappedNodeCallee) !== "refresh") return;
12634
12670
  const router = ASTUtils39.findVariable(
12635
12671
  context.sourceCode.getScope(receiver),
12636
12672
  receiver.name
12637
12673
  );
12638
- if (router === null || !routers.has(router) || router.references.some((reference) => reference.isWrite() && reference.init !== true)) return;
12674
+ if (router === null || !routers.has(router)) return;
12639
12675
  const callback = enclosingIntervalCallback(context.sourceCode, node);
12640
12676
  if (callback !== null && !reportedCallbacks.has(callback)) {
12641
12677
  reportedCallbacks.add(callback);
@@ -13594,51 +13630,138 @@ var NO_SELECT_STAR_DOCUMENTATION = {
13594
13630
  rationale: "Wildcard projections couple row shape and query cost to unrelated schema changes.",
13595
13631
  remediation: "List every required column explicitly in the projection.",
13596
13632
  category: "correctness",
13597
- limitations: ["Only statically visible embedded SQL is checked; quoted strings (including PostgreSQL dollar strings), comments, function arguments such as COUNT(*), and stars inside EXISTS are excluded. This is a bounded lexical scan, not a complete SQL parser."],
13633
+ limitations: ["Only statically visible embedded SQL is checked; quoted strings (including PostgreSQL dollar strings), comments, function arguments such as COUNT(*), and stars inside EXISTS are excluded. Escaped ordinary strings, brackets, and backticks are handled conservatively. This is a bounded lexical scan, not a complete SQL parser."],
13598
13634
  examples: [
13599
13635
  { id: "explicit-projection", title: "Select the required columns", outcome: "no-match", files: [{ path: "src/runs.ts", source: "db.prepare(`SELECT id, status FROM runs`).all();" }], focusPath: "src/runs.ts", expectedCount: 0, public: true },
13600
13636
  { id: "wildcard-projection", title: "Do not select every column", outcome: "match", files: [{ path: "src/runs.ts", source: "db.prepare(`SELECT * FROM runs`).all();" }], focusPath: "src/runs.ts", expectedCount: 1, public: true },
13601
13637
  { id: "decoded-query", scenarioId: "native-decoded-sql", title: "Inspect native-decoded query text", outcome: "match", files: [{ path: "src/page.ts", source: 'db.query("SEL\\u0045CT * FROM runs");' }], focusPath: "src/page.ts", expectedCount: 1, public: true },
13602
- { id: "decoded-comment-data", scenarioId: "native-decoded-sql", title: "Ignore native-decoded comment data", outcome: "no-match", files: [{ path: "src/page.ts", source: 'db.query("SEL\\x45CT id FROM runs /* SELECT * FROM runs */");' }], focusPath: "src/page.ts", expectedCount: 0, public: true }
13638
+ { id: "decoded-comment-data", scenarioId: "native-decoded-sql", title: "Ignore native-decoded comment data", outcome: "no-match", files: [{ path: "src/page.ts", source: 'db.query("SEL\\x45CT id FROM runs /* SELECT * FROM runs */");' }], focusPath: "src/page.ts", expectedCount: 0, public: true },
13639
+ { "id": "wrapped-exists-outer-wildcard", "scenarioId": "projection-owner", "title": "The outer projection still selects every column", "outcome": "match", "files": [{ "path": "src/queries.ts", "source": 'db.query("SELECT EXISTS((SELECT * FROM jobs)), * FROM runs");' }], "focusPath": "src/queries.ts", "expectedCount": 1, "public": true },
13640
+ { "id": "quoted-column-arithmetic", "scenarioId": "projection-owner", "title": "Quoted arithmetic operands are not wildcards", "outcome": "no-match", "files": [{ "path": "src/queries.ts", "source": 'db.query("SELECT \\"units\\" * \\"price\\" AS total FROM entries");' }], "focusPath": "src/queries.ts", "expectedCount": 0, "public": true }
13603
13641
  ]
13604
13642
  };
13605
- var QUERY_SHAPE = /\bSELECT\b[\s\S]*?\bFROM\b/i;
13606
- var SELECT_KEYWORD = /\bSELECT\b/gi;
13607
- var FROM_KEYWORD = /^FROM\b/i;
13608
- var EXISTS_BEFORE = /\bEXISTS\s*\(\s*$/i;
13609
- var QUALIFIED_PREFIX = /\w\.$/;
13610
- function hasRealSelectStar(sql) {
13611
- const selects = [...sql.matchAll(SELECT_KEYWORD)].map((m) => m.index);
13612
- for (let pos = 0; pos < sql.length; pos++) {
13613
- if (sql[pos] !== "*" || !isProjectionStar(sql, pos)) {
13614
- continue;
13615
- }
13616
- const owning = selects.filter((start) => start < pos).at(-1);
13617
- if (owning !== void 0 && !EXISTS_BEFORE.test(sql.slice(0, owning))) {
13618
- return true;
13619
- }
13643
+ var QUERY_SELECT = /\bSELECT\b/i;
13644
+ var QUERY_FROM = /\bFROM\b/i;
13645
+ var SQL_TOKEN = /[A-Za-z_\u0080-\u{10FFFF}][A-Za-z0-9_$\u0080-\u{10FFFF}]*|\d+(?:\.\d+)?|\.[ \t\r\n\f\v]*\[(?:[^\]]|\]\])*\]|[-+*/<>=~!@#%^&|\x60?]+|[(),.;]/gu;
13646
+ var ASCII_WORD = /^[A-Za-z_]+$/u;
13647
+ var AMBIGUOUS_QUOTING = /[\x60[\]\\]/u;
13648
+ var CONSERVATIVE_SELECT = /\bSELECT\b/gi;
13649
+ var CONSERVATIVE_FROM = /FROM\b/iy;
13650
+ var ASCII_IDENTIFIER_CHARACTER = /[A-Za-z0-9_]/u;
13651
+ var SQL_WHITESPACE = /\s/u;
13652
+ var EXISTS_KEYWORD_LENGTH = 6;
13653
+ var PROJECTION_END = /\s*(?:[,)]|FROM(?![A-Za-z0-9_$\u0080-\u{10FFFF}])|$)/iyu;
13654
+ var SELECT_PREFIX_WORDS = /* @__PURE__ */ new Set([
13655
+ "ALL",
13656
+ "DISTINCT",
13657
+ "HIGH_PRIORITY",
13658
+ "PERCENT",
13659
+ "SQL_BIG_RESULT",
13660
+ "SQL_BUFFER_RESULT",
13661
+ "SQL_CACHE",
13662
+ "SQL_CALC_FOUND_ROWS",
13663
+ "SQL_NO_CACHE",
13664
+ "SQL_SMALL_RESULT",
13665
+ "STRAIGHT_JOIN",
13666
+ "TIES",
13667
+ "TOP",
13668
+ "WITH"
13669
+ ]);
13670
+ function hasQueryShape(sql) {
13671
+ return QUERY_SELECT.test(sql) && QUERY_FROM.test(sql);
13672
+ }
13673
+ function hasRealSelectStar(sql, original = sql, conservativeSource = sql) {
13674
+ const conservative = AMBIGUOUS_QUOTING.test(original) ? { source: conservativeSource, selects: [...conservativeSource.matchAll(CONSERVATIVE_SELECT)].map((match) => ({ offset: match.index, allowsStar: selectAllowsStar(conservativeSource, match.index) })) } : void 0;
13675
+ const strictSuffix = /[\x60[\]\\?]/u.test(original);
13676
+ const projections = /* @__PURE__ */ new Map();
13677
+ const existsScopes = /* @__PURE__ */ new Set();
13678
+ let depth = 0;
13679
+ let previous = "";
13680
+ let prior = "";
13681
+ for (const match of sql.matchAll(SQL_TOKEN)) {
13682
+ const raw = match[0];
13683
+ const token = ASCII_WORD.test(raw) ? raw.toUpperCase() : raw;
13684
+ if (token === ")") {
13685
+ projections.delete(depth);
13686
+ existsScopes.delete(depth);
13687
+ depth = Math.max(0, depth - 1);
13688
+ }
13689
+ const projection = projections.get(depth);
13690
+ if (isKeyword(token, previous, "SELECT")) {
13691
+ projections.set(depth, { exists: existsScopes.size > 0, strictSuffix, conservative, prefix: [], star: false });
13692
+ } else if (isKeyword(token, previous, "FROM")) {
13693
+ if (projection?.star === true) return true;
13694
+ projections.delete(depth);
13695
+ } else if (token === ";") {
13696
+ projections.clear();
13697
+ existsScopes.clear();
13698
+ } else if (projection !== void 0) {
13699
+ observeProjection(sql, match, previous, projection);
13700
+ }
13701
+ if (token === "(") {
13702
+ depth += 1;
13703
+ if (isKeyword(previous, prior, "EXISTS")) existsScopes.add(depth);
13704
+ }
13705
+ prior = previous;
13706
+ previous = token;
13620
13707
  }
13621
13708
  return false;
13622
13709
  }
13623
- function isProjectionStar(sql, pos) {
13624
- if (QUALIFIED_PREFIX.test(sql.slice(0, pos))) {
13625
- return true;
13626
- }
13627
- let before = pos - 1;
13628
- while (before >= 0 && /\s/.test(sql[before] ?? "")) {
13629
- before -= 1;
13630
- }
13631
- let after = pos + 1;
13632
- while (after < sql.length && /\s/.test(sql[after] ?? "")) {
13633
- after += 1;
13634
- }
13635
- const beforeChar = before >= 0 ? sql[before] ?? "" : "";
13636
- const afterChar = after < sql.length ? sql[after] ?? "" : "";
13637
- const terminates = afterChar === "" || afterChar === "," || afterChar === ")" || FROM_KEYWORD.test(sql.slice(after));
13638
- if (!terminates) {
13639
- return false;
13640
- }
13641
- return !(beforeChar === "(" && afterChar === ")");
13710
+ function selectAllowsStar(source, offset) {
13711
+ const opening = skipWhitespace(source, offset - 1, -1);
13712
+ if (source[opening] !== "(") return true;
13713
+ const wordEnd = skipWhitespace(source, opening - 1, -1);
13714
+ const wordStart = wordEnd - EXISTS_KEYWORD_LENGTH + 1;
13715
+ return wordStart < 0 || source.slice(wordStart, wordEnd + 1).toUpperCase() !== "EXISTS" || wordStart !== 0 && ASCII_IDENTIFIER_CHARACTER.test(source[wordStart - 1] ?? "");
13716
+ }
13717
+ function isKeyword(token, previous, keyword) {
13718
+ return token === keyword && previous !== ".";
13719
+ }
13720
+ function observeProjection(sql, match, previous, projection) {
13721
+ if (isProjectionStar(sql, match, previous, projection)) {
13722
+ if (projection.conservative === void 0 || conservativeProjectionStar(projection.conservative, match.index)) {
13723
+ projection.star = true;
13724
+ }
13725
+ } else if (projection.prefix.length < 2) {
13726
+ const raw = match[0];
13727
+ projection.prefix.push(ASCII_WORD.test(raw) ? raw.toUpperCase() : raw);
13728
+ }
13729
+ }
13730
+ function conservativeProjectionStar(query, offset) {
13731
+ const { source, selects } = query;
13732
+ let low = 0;
13733
+ let high = selects.length;
13734
+ while (low < high) {
13735
+ const middle = Math.floor((low + high) / 2);
13736
+ const start = selects[middle];
13737
+ if (start !== void 0 && start.offset < offset) low = middle + 1;
13738
+ else high = middle;
13739
+ }
13740
+ const owning = selects[low - 1];
13741
+ if (owning?.allowsStar !== true) return false;
13742
+ if (source[offset - 1] === "." && ASCII_IDENTIFIER_CHARACTER.test(source[offset - 2] ?? "")) return true;
13743
+ const before = skipWhitespace(source, offset - 1, -1);
13744
+ const after = skipWhitespace(source, offset + 1, 1);
13745
+ const afterChar = source[after] ?? "";
13746
+ CONSERVATIVE_FROM.lastIndex = after;
13747
+ return (afterChar === "" || afterChar === "," || afterChar === ")" || CONSERVATIVE_FROM.test(source)) && !(source[before] === "(" && afterChar === ")");
13748
+ }
13749
+ function skipWhitespace(source, offset, direction) {
13750
+ let cursor = offset;
13751
+ while (cursor >= 0 && cursor < source.length && SQL_WHITESPACE.test(source[cursor] ?? "")) cursor += direction;
13752
+ return cursor;
13753
+ }
13754
+ function isProjectionStar(sql, match, previous, projection) {
13755
+ if (match[0] !== "*" || projection.exists) return false;
13756
+ const { prefix } = projection;
13757
+ if (previous === ".") return true;
13758
+ if (!projection.strictSuffix && (prefix.length === 0 || previous === ",")) return true;
13759
+ if (!endsProjection(sql, match.index + 1)) return false;
13760
+ return previous === "SELECT" || previous === "," || SELECT_PREFIX_WORDS.has(previous) || previous === ")" && (prefix[0] === "DISTINCT" && prefix[1] === "ON" || prefix[0] === "TOP");
13761
+ }
13762
+ function endsProjection(sql, offset) {
13763
+ PROJECTION_END.lastIndex = offset;
13764
+ return PROJECTION_END.test(sql);
13642
13765
  }
13643
13766
  var no_select_star_default = createRule({
13644
13767
  name: "no-select-star",
@@ -13658,12 +13781,12 @@ var no_select_star_default = createRule({
13658
13781
  if (isTestFile(context)) {
13659
13782
  return {};
13660
13783
  }
13661
- return createSqlListener((sql, node) => {
13662
- if (!QUERY_SHAPE.test(sql) || !hasRealSelectStar(sql)) {
13663
- return;
13664
- }
13784
+ return createSqlListener((sql, node, original) => {
13785
+ if (!hasQueryShape(sql)) return;
13786
+ const conservativeSource = AMBIGUOUS_QUOTING.test(original) ? stripSqlNoise(original) : sql;
13787
+ if (!hasRealSelectStar(sql, original, conservativeSource)) return;
13665
13788
  context.report({ node, messageId: "noSelectStar" });
13666
- });
13789
+ }, { preserveQuotedTokens: true });
13667
13790
  }
13668
13791
  });
13669
13792
 
@@ -20666,16 +20789,42 @@ var PREFER_NODE_CRYPTO_HASH_DOCUMENTATION = {
20666
20789
  remediation: "On a supported Node runtime, consider hash(algorithm, value, encoding). Preserve the output encoding explicitly: digest() returns a Buffer, while hash defaults to hex. Keep createHash for streams or multiple updates.",
20667
20790
  category: "performance",
20668
20791
  limitations: [
20669
- "Only bindings and inline calls with statically proven provenance from crypto or node:crypto are analyzed; arbitrary assignments and dynamic module specifiers are excluded.",
20792
+ "Only direct imports, const bindings, and inline calls with statically proven provenance from crypto or node:crypto are analyzed; arbitrary assignments and dynamic module specifiers are excluded.",
20670
20793
  "Only a literal algorithm with exactly one update call is reported; streaming and incremental hashes remain valid.",
20794
+ "Namespace bindings must remain read-only: visible writes, aliases, exports, inherited mutation or alias methods, unknown computed members, and other escaping uses are excluded; type-only references and direct member calls remain eligible.",
20671
20795
  "Runtime support and output encoding require manual review; no autofix or guaranteed speedup is promised."
20672
20796
  ],
20673
20797
  references: ["https://nodejs.org/api/crypto.html#cryptohashalgorithm-data-options"],
20674
20798
  examples: [
20675
20799
  { id: "one-shot-hash", title: "Use Node's one-shot hash API", outcome: "no-match", files: [{ path: "case.ts", source: "import { hash } from 'node:crypto'; export const digest = hash('sha256', 'value', 'hex');" }], focusPath: "case.ts", expectedCount: 0, public: true },
20676
- { id: "mutable-one-shot-chain", title: "Avoid mutable state for one value", outcome: "match", files: [{ path: "case.ts", source: "import { createHash } from 'node:crypto'; export const digest = createHash('sha256').update('value').digest('hex');" }], focusPath: "case.ts", expectedCount: 1, public: true }
20800
+ { id: "mutable-one-shot-chain", title: "Avoid mutable state for one value", outcome: "match", files: [{ path: "case.ts", source: "import { createHash } from 'node:crypto'; export const digest = createHash('sha256').update('value').digest('hex');" }], focusPath: "case.ts", expectedCount: 1, public: true },
20801
+ { id: "native-namespace", scenarioId: "stable-native-namespace", title: "An unchanged namespace retains native provenance", outcome: "match", files: [{ path: "case.ts", source: "const crypto = require('node:crypto'); export const digest = crypto.createHash('sha256').update('value').digest('hex');" }], focusPath: "case.ts", expectedCount: 1, public: true },
20802
+ { id: "replaced-namespace-member", scenarioId: "stable-native-namespace", title: "A replaced member has a different contract", outcome: "no-match", files: [{ path: "case.ts", source: "const crypto = require('node:crypto'); crypto.createHash = () => ({ update() { return this; }, digest() { return 'custom'; } }); export const digest = crypto.createHash('sha256').update('value').digest('hex');" }], focusPath: "case.ts", expectedCount: 0, public: true }
20677
20803
  ]
20678
20804
  };
20805
+ var UNSAFE_NAMESPACE_METHODS = /* @__PURE__ */ new Set(["__defineGetter__", "__defineSetter__", "valueOf"]);
20806
+ function stableNamespace(variable) {
20807
+ if (variable.defs.some((definition) => definition.type === "Variable" && definition.parent.parent.type === AST_NODE_TYPES99.ExportNamedDeclaration)) return false;
20808
+ return stableValueBinding(variable, (reference) => {
20809
+ if (reference.isTypeReference && !reference.isValueReference) return true;
20810
+ const receiver = outerExpression(reference.identifier);
20811
+ if (typeQueryReference(receiver)) return true;
20812
+ const member = receiver.parent;
20813
+ if (member?.type !== AST_NODE_TYPES99.MemberExpression || member.object !== receiver) {
20814
+ return member?.type === AST_NODE_TYPES99.UnaryExpression && member.operator === "typeof";
20815
+ }
20816
+ const use = outerExpression(member);
20817
+ const name = ASTUtils61.getPropertyName(member);
20818
+ return name !== null && !UNSAFE_NAMESPACE_METHODS.has(name) && use.parent?.type === AST_NODE_TYPES99.CallExpression && use.parent.callee === use;
20819
+ });
20820
+ }
20821
+ function typeQueryReference(node) {
20822
+ let current = node;
20823
+ while (current.parent?.type === AST_NODE_TYPES99.TSQualifiedName && current.parent.left === current) {
20824
+ current = current.parent;
20825
+ }
20826
+ return current.parent?.type === AST_NODE_TYPES99.TSTypeQuery && current.parent.exprName === current;
20827
+ }
20679
20828
  function isCryptoLoader(node, resolve2) {
20680
20829
  const unwrappedNodeCallee = node.type === "CallExpression" || node.type === "NewExpression" ? unwrapExpression(node.callee) : null;
20681
20830
  if (node.type !== AST_NODE_TYPES99.CallExpression || node.arguments.length !== 1) return false;
@@ -20718,7 +20867,7 @@ var prefer_node_crypto_hash_default = createRule({
20718
20867
  }
20719
20868
  function record(identifier, destination) {
20720
20869
  const variable = resolve2(identifier);
20721
- if (variable !== null) destination.add(variable);
20870
+ if (variable !== null && (destination === namespaceBindings ? stableNamespace(variable) : stableValueBinding(variable))) destination.add(variable);
20722
20871
  }
20723
20872
  return {
20724
20873
  ImportDeclaration(node) {
@@ -20860,11 +21009,11 @@ var prefer_node_fs_promises_default = createRule({
20860
21009
  };
20861
21010
  const isNamespace = (node) => {
20862
21011
  const binding = bindingOf(node);
20863
- return binding !== null && namespaces.has(binding) && !binding.references.some((reference) => reference.isWrite() && reference.init !== true);
21012
+ return binding !== null && namespaces.has(binding);
20864
21013
  };
20865
21014
  const recordNamespace = (node) => {
20866
21015
  const binding = bindingOf(node);
20867
- if (binding !== null) namespaces.add(binding);
21016
+ if (binding !== null && stableValueBinding(binding)) namespaces.add(binding);
20868
21017
  };
20869
21018
  return {
20870
21019
  ImportDeclaration(node) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarj/eslint-plugin",
3
- "version": "16.23.3",
3
+ "version": "16.26.0",
4
4
  "packageManager": "npm@12.2.0",
5
5
  "description": "Custom ESLint rules for hypermodern TypeScript / React / Next.js projects",
6
6
  "type": "module",