pi-lens 3.8.69 → 3.8.71

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 (147) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +31 -13
  3. package/dist/clients/bootstrap.js +3 -1
  4. package/dist/clients/bus-events-logger.js +53 -0
  5. package/dist/clients/bus-publish.js +45 -2
  6. package/dist/clients/cache/rule-cache.js +9 -5
  7. package/dist/clients/cache-manager.js +19 -13
  8. package/dist/clients/code-quality-warnings.js +27 -0
  9. package/dist/clients/dead-code-client.js +5 -23
  10. package/dist/clients/diagnostic-dispositions.js +298 -0
  11. package/dist/clients/diagnostics-publish.js +38 -2
  12. package/dist/clients/dispatch/auxiliary-lsp.js +144 -6
  13. package/dist/clients/dispatch/dispatcher.js +6 -1
  14. package/dist/clients/dispatch/facts/function-facts.js +83 -0
  15. package/dist/clients/dispatch/integration.js +19 -4
  16. package/dist/clients/dispatch/rules/async-noise.js +1 -1
  17. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
  18. package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
  19. package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
  20. package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
  21. package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
  22. package/dist/clients/dispatch/rules/high-complexity.js +12 -1
  23. package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
  24. package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
  25. package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
  26. package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
  27. package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
  28. package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
  29. package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
  30. package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
  31. package/dist/clients/dispatch/runners/lsp.js +23 -34
  32. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  33. package/dist/clients/dispatch/suppress-writer.js +68 -0
  34. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  35. package/dist/clients/disposition-logger.js +39 -0
  36. package/dist/clients/disposition-publish.js +109 -0
  37. package/dist/clients/file-utils.js +94 -33
  38. package/dist/clients/format-events-publish.js +299 -0
  39. package/dist/clients/git-tracked-ignore.js +208 -0
  40. package/dist/clients/gitleaks-client.js +40 -5
  41. package/dist/clients/installer/index.js +123 -6
  42. package/dist/clients/instance-reaper.js +229 -2
  43. package/dist/clients/instance-registry.js +141 -2
  44. package/dist/clients/json-cache-read.js +65 -0
  45. package/dist/clients/knip-client.js +35 -38
  46. package/dist/clients/language-profile.js +11 -11
  47. package/dist/clients/lens-engine.js +12 -0
  48. package/dist/clients/lens-map.js +1079 -0
  49. package/dist/clients/log-cleanup.js +61 -19
  50. package/dist/clients/lsp/aggregation.js +69 -2
  51. package/dist/clients/lsp/cascade-tier.js +31 -19
  52. package/dist/clients/lsp/client.js +124 -14
  53. package/dist/clients/lsp/config.js +27 -1
  54. package/dist/clients/lsp/index.js +1028 -68
  55. package/dist/clients/lsp/server-strategies.js +17 -8
  56. package/dist/clients/lsp/server.js +80 -28
  57. package/dist/clients/lsp/tsserver-sync.js +145 -0
  58. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  59. package/dist/clients/lsp-budget.js +148 -0
  60. package/dist/clients/mcp/session.js +9 -0
  61. package/dist/clients/middle-man-analysis.js +260 -0
  62. package/dist/clients/module-report.js +173 -11
  63. package/dist/clients/ndjson-logger.js +24 -0
  64. package/dist/clients/opengrep-client.js +217 -0
  65. package/dist/clients/opengrep-config.js +2 -10
  66. package/dist/clients/path-utils.js +66 -0
  67. package/dist/clients/pipeline.js +5 -1
  68. package/dist/clients/project-diagnostics/cache.js +5 -12
  69. package/dist/clients/project-diagnostics/extractors.js +21 -1
  70. package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
  71. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
  72. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
  73. package/dist/clients/project-snapshot.js +3 -6
  74. package/dist/clients/quiet-window.js +57 -2
  75. package/dist/clients/read-expansion.js +2 -3
  76. package/dist/clients/read-guard-tool-lines.js +10 -0
  77. package/dist/clients/read-guard.js +1 -1
  78. package/dist/clients/resource-sampler.js +254 -0
  79. package/dist/clients/review-graph/builder.js +322 -42
  80. package/dist/clients/review-graph/import-resolvers.js +53 -19
  81. package/dist/clients/review-graph/symbol-id.js +57 -0
  82. package/dist/clients/runtime-agent-end.js +26 -0
  83. package/dist/clients/runtime-coordinator.js +9 -1
  84. package/dist/clients/runtime-session.js +93 -17
  85. package/dist/clients/runtime-tool-call.js +789 -0
  86. package/dist/clients/runtime-tool-result.js +15 -2
  87. package/dist/clients/runtime-turn.js +62 -13
  88. package/dist/clients/safe-spawn.js +48 -2
  89. package/dist/clients/session-state-store.js +6 -9
  90. package/dist/clients/sgconfig.js +2 -9
  91. package/dist/clients/source-filter.js +17 -25
  92. package/dist/clients/source-walker.js +66 -0
  93. package/dist/clients/startup-scan.js +105 -43
  94. package/dist/clients/symbol-containment.js +24 -0
  95. package/dist/clients/test-runner-client.js +544 -29
  96. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  97. package/dist/clients/typos-config.js +2 -11
  98. package/dist/clients/widget-state.js +83 -1
  99. package/dist/clients/write-ordering-guard.js +58 -0
  100. package/dist/clients/zizmor-config.js +29 -10
  101. package/dist/index.js +25511 -19414
  102. package/dist/mcp/server.js +95 -19
  103. package/dist/tools/activate-tools.js +72 -0
  104. package/dist/tools/ast-dump.js +2 -8
  105. package/dist/tools/lens-diagnostic-mark.js +158 -0
  106. package/dist/tools/lens-diagnostics.js +327 -33
  107. package/dist/tools/lsp-diagnostics.js +525 -105
  108. package/dist/tools/module-report.js +18 -5
  109. package/docs/agent-tools.md +136 -0
  110. package/docs/ast-grep_rules_catalog.md +564 -0
  111. package/docs/astplayground.md +178 -0
  112. package/docs/audit1.md +177 -0
  113. package/docs/custom-rules.md +231 -0
  114. package/docs/dependencies.md +54 -0
  115. package/docs/dispositions.md +85 -0
  116. package/docs/env_variables.md +10 -0
  117. package/docs/environment-variables.md +83 -0
  118. package/docs/fable.md +140 -0
  119. package/docs/features.md +338 -0
  120. package/docs/globalconfig.md +79 -0
  121. package/docs/language-coverage.md +48 -0
  122. package/docs/lsp-capability-matrix.md +157 -0
  123. package/docs/lsp-latency-benchmark.md +169 -0
  124. package/docs/mcp.md +438 -0
  125. package/docs/module-report-read-symbol.md +258 -0
  126. package/docs/servercapabilities.md +118 -0
  127. package/docs/subagent-compat.md +281 -0
  128. package/docs/tools.md +16 -0
  129. package/docs/tools_improvement2.md +345 -0
  130. package/docs/tree-sitter_rules_catalog.md +296 -0
  131. package/docs/usage.md +129 -0
  132. package/docs/word-index.md +150 -0
  133. package/package.json +10 -1
  134. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  135. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  136. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  137. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
  138. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  139. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
  140. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
  141. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
  142. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +13 -1
  143. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  144. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
  145. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
  146. package/skills/pi-lens-ast-grep/SKILL.md +3 -1
  147. package/dist/clients/production-readiness.js +0 -493
@@ -1,3 +1,4 @@
1
+ import { findOwnerName } from "../../symbol-containment.js";
1
2
  import { extractFactsFromTree, firstChildOfType, walk, } from "./tree-sitter-facts.js";
2
3
  const BOUNDARY_PREFIXES = [
3
4
  "fetch",
@@ -160,6 +161,62 @@ function collectOutgoingCalls(body) {
160
161
  });
161
162
  return [...calls];
162
163
  }
164
+ /**
165
+ * `obj.method()` call sites with a simple-identifier receiver (refs #655
166
+ * phase 2). Only the plain shape `identifier.identifier(...)` is captured —
167
+ * chained (`a.b.c()`), computed (`a[x]()`), and `this.`-receiver calls are
168
+ * left out of this list (they still show up in `outgoingCalls`'s flattened
169
+ * text form as before); those richer shapes need real type inference to
170
+ * resolve safely, which is out of scope for this bounded slice.
171
+ */
172
+ function collectMemberCallSites(body) {
173
+ const sites = [];
174
+ walk(body, (node) => {
175
+ if (node.type !== "call_expression")
176
+ return;
177
+ const callee = (node.children ?? [])[0];
178
+ if (!callee || callee.type !== "member_expression")
179
+ return;
180
+ const object = (callee.children ?? [])[0];
181
+ const property = (callee.children ?? []).find((c) => c?.type === "property_identifier");
182
+ if (!object || object.type !== "identifier" || !property)
183
+ return;
184
+ sites.push({ receiver: object.text, method: property.text });
185
+ });
186
+ return sites;
187
+ }
188
+ /**
189
+ * Best-effort receiver -> class-name map for one function (refs #655 phase
190
+ * 2). Two clear, common shapes only — see {@link FunctionSummary.receiverTypes}.
191
+ */
192
+ function collectReceiverTypes(body, params) {
193
+ const types = {};
194
+ for (const param of params) {
195
+ const id = firstNamedChild(param);
196
+ if (!id || id.type !== "identifier")
197
+ continue;
198
+ const typeAnnotation = (param.children ?? []).find((c) => c?.type === "type_annotation");
199
+ const typeId = typeAnnotation
200
+ ? firstChildOfType(typeAnnotation, "type_identifier")
201
+ : undefined;
202
+ if (typeId)
203
+ types[id.text] = typeId.text;
204
+ }
205
+ walk(body, (node) => {
206
+ if (node.type !== "variable_declarator")
207
+ return;
208
+ const id = firstChildOfType(node, "identifier");
209
+ if (!id)
210
+ return;
211
+ const value = (node.children ?? []).find((c) => c?.type === "new_expression");
212
+ if (!value)
213
+ return;
214
+ const ctor = firstNamedChild(value);
215
+ if (ctor?.type === "identifier")
216
+ types[id.text] = ctor.text;
217
+ });
218
+ return types;
219
+ }
163
220
  function hasAwaitInNode(node) {
164
221
  let found = false;
165
222
  walk(node, (n) => {
@@ -192,6 +249,13 @@ export const functionFactProvider = {
192
249
  async run(ctx, store) {
193
250
  await extractFactsFromTree(ctx, store, { "file.functionSummaries": [] }, (root) => {
194
251
  const summaries = [];
252
+ // Class/interface declarations, collected in the SAME walk over the
253
+ // already-parsed tree (refs #655 phase 2) so owner/qualified-name
254
+ // computation for jsts needs no second parse — see
255
+ // `symbol-containment.ts`'s doc comment for why this can't literally
256
+ // share code with module-report.ts's tree-sitter-symbol-extractor path
257
+ // (different tree-sitter integration), only the same algorithm.
258
+ const containers = [];
195
259
  const addSummary = (node) => {
196
260
  const body = getBody(node);
197
261
  if (!body)
@@ -210,6 +274,7 @@ export const functionFactProvider = {
210
274
  name: getFunctionName(node),
211
275
  line: node.startPosition.row + 1,
212
276
  column: node.startPosition.column + 1,
277
+ endLine: body.endPosition.row + 1,
213
278
  isAsync: Boolean(firstChildOfType(node, "async")),
214
279
  hasAwait: hasAwaitInNode(body),
215
280
  hasReturnAwaitCall: hasReturnAwaitCall(body),
@@ -221,12 +286,30 @@ export const functionFactProvider = {
221
286
  cyclomaticComplexity: calcCyclomaticComplexity(body),
222
287
  maxNestingDepth: calcMaxNestingDepth(body),
223
288
  outgoingCalls: collectOutgoingCalls(body),
289
+ memberCallSites: collectMemberCallSites(body),
290
+ receiverTypes: collectReceiverTypes(body, params),
224
291
  });
225
292
  };
226
293
  walk(root, (node) => {
227
294
  if (FUNCTION_TYPES.has(node.type))
228
295
  addSummary(node);
296
+ if (node.type === "class_declaration" ||
297
+ node.type === "interface_declaration") {
298
+ const nameNode = firstChildOfType(node, "type_identifier");
299
+ if (nameNode) {
300
+ containers.push({
301
+ name: nameNode.text,
302
+ startLine: node.startPosition.row + 1,
303
+ endLine: node.endPosition.row + 1,
304
+ });
305
+ }
306
+ }
229
307
  });
308
+ for (const summary of summaries) {
309
+ const owner = findOwnerName(containers, summary.line, summary.endLine ?? summary.line);
310
+ if (owner)
311
+ summary.owner = owner;
312
+ }
230
313
  return { "file.functionSummaries": summaries };
231
314
  });
232
315
  },
@@ -821,7 +821,13 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
821
821
  coldSnapshotPaths.push(neighborPath);
822
822
  continue;
823
823
  }
824
- const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" });
824
+ // #692: `source: "cascade"` used to be passed here to label `rule`
825
+ // (`cascade:<code>`) — that override is gone (identity must come from
826
+ // the diagnostic's own source; see `scanOrigin`'s doc comment), and
827
+ // cascade neighbor diagnostics are ephemeral display-only output
828
+ // (never reconciled into persisted widget/dedup state), so the label
829
+ // had no remaining purpose and is simply dropped rather than migrated.
830
+ const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
825
831
  producedLspData = true;
826
832
  const durationMs = Date.now() - neighborStart;
827
833
  logCascade({
@@ -994,7 +1000,11 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
994
1000
  });
995
1001
  if (!rawDiags)
996
1002
  return undefined;
997
- const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" });
1003
+ // #692: `source: "cascade"` no longer overrides `rule` (see the
1004
+ // doc comment on the sibling call above) — dropped rather than
1005
+ // migrated to `scanOrigin` since cascade output never touches
1006
+ // persisted widget/dedup state.
1007
+ const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
998
1008
  const durationMs = Date.now() - neighborStart;
999
1009
  // Update cache for this neighbor at the current write sequence
1000
1010
  if (writeSeq != null) {
@@ -1051,10 +1061,13 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1051
1061
  error: String(result.reason),
1052
1062
  });
1053
1063
  const entry = allDiags.get(normalizeMapKey(neighborPath));
1064
+ // #692: `source: "cascade"` dropped (see the doc comment above the
1065
+ // first cascade call site in this file) — no longer affects `rule`
1066
+ // and cascade output never touches persisted widget/dedup state.
1054
1067
  const diags = entry && Date.now() - entry.ts < CASCADE_TTL_MS
1055
1068
  ? convertLspDiagnostics(entry.diags
1056
1069
  .filter((d) => d.severity === 1)
1057
- .slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" })
1070
+ .slice(0, MAX_PER_FILE), neighborPath)
1058
1071
  : [];
1059
1072
  neighbors.push({
1060
1073
  filePath: neighborPath,
@@ -1162,7 +1175,9 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
1162
1175
  continue;
1163
1176
  if (now - ts > CASCADE_TTL_MS)
1164
1177
  continue;
1165
- const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath, { source: "cascade" });
1178
+ // #692: `source: "cascade"` dropped — see the doc comment above the
1179
+ // first cascade `convertLspDiagnostics` call site in this file.
1180
+ const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath);
1166
1181
  if (errors.length === 0)
1167
1182
  continue;
1168
1183
  neighbors.push({
@@ -23,7 +23,7 @@ export const asyncNoiseRule = {
23
23
  fn.statementCount > 1) {
24
24
  diagnostics.push({
25
25
  id: `async-noise:${ctx.filePath}:${fn.line}:${fn.column}`,
26
- tool: "async-noise",
26
+ tool: "fact-rules",
27
27
  filePath: ctx.filePath,
28
28
  line: fn.line,
29
29
  column: fn.column,
@@ -12,7 +12,7 @@ export const asyncUnnecessaryWrapperRule = {
12
12
  continue;
13
13
  diagnostics.push({
14
14
  id: `async-unnecessary-wrapper:${ctx.filePath}:${f.line}`,
15
- tool: "async-unnecessary-wrapper",
15
+ tool: "fact-rules",
16
16
  rule: "async-unnecessary-wrapper",
17
17
  filePath: ctx.filePath,
18
18
  line: f.line,
@@ -38,7 +38,7 @@ export const corsWildcardRule = {
38
38
  if (isWildcard) {
39
39
  diagnostics.push({
40
40
  id: `cors-wildcard:${ctx.filePath}:${i + 1}`,
41
- tool: "cors-wildcard",
41
+ tool: "fact-rules",
42
42
  rule: "cors-wildcard",
43
43
  filePath: ctx.filePath,
44
44
  line: i + 1,
@@ -16,7 +16,8 @@ export const errorObscuringRule = {
16
16
  !s.bodyText.includes(s.catchParam)) {
17
17
  diagnostics.push({
18
18
  id: `error-obscuring:${ctx.filePath}:${s.line}:${s.column}`,
19
- tool: "error-obscuring",
19
+ tool: "fact-rules",
20
+ rule: "error-obscuring",
20
21
  filePath: ctx.filePath,
21
22
  line: s.line,
22
23
  column: s.column,
@@ -13,7 +13,8 @@ export const errorSwallowingRule = {
13
13
  if (s.isEmpty && !s.isDocumentedLocalFallback && s.boundaryCategory !== "fs") {
14
14
  diagnostics.push({
15
15
  id: `error-swallowing:${ctx.filePath}:${s.line}:${s.column}`,
16
- tool: "error-swallowing",
16
+ tool: "fact-rules",
17
+ rule: "error-swallowing",
17
18
  filePath: ctx.filePath,
18
19
  line: s.line,
19
20
  column: s.column,
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared test-framework-call recognition for `high-fan-out` and `high-complexity`
3
+ * (#577). A normal `it()`/`describe()` body naturally invokes many assertion/mock
4
+ * helpers — that's test structure, not a coordination smell or genuine complexity.
5
+ *
6
+ * Two complementary heuristics, both call-name-based (no `ctx.fileRole` gating —
7
+ * these calls only exist in test-framework code, so the signal is safe to apply
8
+ * unconditionally):
9
+ *
10
+ * 1. `isTestFrameworkNoiseCall` — filters individual "meaningful calls" the same
11
+ * way `console.*`/`Math.*`/etc. are already excluded in high-fan-out. Handles
12
+ * assertion-heavy `it()` bodies directly.
13
+ * 2. `isTestSuiteOrganizer` — a `describe()`/`it()`/`test()` wrapper's own
14
+ * FunctionSummary aggregates every call and branch from ALL of its nested test
15
+ * bodies (the shared tree-sitter walk in function-facts.ts does not stop at
16
+ * nested-function boundaries, since each nested callback also gets its own,
17
+ * correctly-scoped, FunctionSummary). That aggregation is not real fan-out or
18
+ * complexity — it's the sum of independent nested tests. A function is an
19
+ * "organizer" if it directly invokes `it`/`test`/`describe` (or their
20
+ * `.only`/`.skip`/`.each` variants) — i.e. it exists to group other tests, not
21
+ * to implement logic itself. This does NOT exempt genuinely complex/tangled
22
+ * test HELPER functions, since those don't call `it`/`describe`/`test`
23
+ * themselves.
24
+ */
25
+ const EXPECT_CHAIN_PREFIX = "expect(";
26
+ const TEST_LIFECYCLE_NAMES = new Set([
27
+ "it",
28
+ "test",
29
+ "describe",
30
+ "beforeeach",
31
+ "aftereach",
32
+ "beforeall",
33
+ "afterall",
34
+ ]);
35
+ const TEST_LIFECYCLE_PREFIXES = ["it.", "test.", "describe."];
36
+ const MOCK_LIBRARY_PREFIXES = ["vi.", "jest."];
37
+ /**
38
+ * True if `call` (a raw callee string from `FunctionSummary.outgoingCalls`) is
39
+ * test-framework noise: an `expect(...)` assertion chain, a test lifecycle call
40
+ * (`it`/`describe`/`test`/`beforeEach`/etc., including `.only`/`.skip`/`.each`
41
+ * variants), or a mock-library call (`vi.*`/`jest.*`).
42
+ */
43
+ export function isTestFrameworkNoiseCall(call) {
44
+ const lower = call.toLowerCase();
45
+ if (lower === "expect" || lower.startsWith(EXPECT_CHAIN_PREFIX))
46
+ return true;
47
+ if (TEST_LIFECYCLE_NAMES.has(lower))
48
+ return true;
49
+ if (TEST_LIFECYCLE_PREFIXES.some((p) => lower.startsWith(p)))
50
+ return true;
51
+ if (MOCK_LIBRARY_PREFIXES.some((p) => lower.startsWith(p)))
52
+ return true;
53
+ return false;
54
+ }
55
+ /**
56
+ * True if this function's (unfiltered) outgoing calls include a direct call to
57
+ * `it`/`test`/`describe` — i.e. it groups nested test bodies rather than
58
+ * implementing logic itself. Its aggregated fan-out/complexity reflects the sum
59
+ * of independent nested tests, not one function's real signal.
60
+ */
61
+ export function isTestSuiteOrganizer(calls) {
62
+ return calls.some((call) => {
63
+ const lower = call.toLowerCase();
64
+ const base = lower.split("(")[0].split(".")[0];
65
+ return base === "it" || base === "test" || base === "describe";
66
+ });
67
+ }
@@ -1,3 +1,4 @@
1
+ import { isTestSuiteOrganizer } from "./framework-call-noise.js";
1
2
  // Defaults match the historical hardcoded values so behavior is unchanged for
2
3
  // projects without a config. Project-specific overrides are read from the
3
4
  // per-dispatch context; these mutable fallbacks exist for tests/legacy direct
@@ -35,6 +36,16 @@ export const highComplexityRule = {
35
36
  const configuredCcThreshold = ctx.projectConfig?.rules["high-complexity"]?.threshold;
36
37
  const activeCcThreshold = configuredCcThreshold ?? ccThreshold;
37
38
  for (const f of fns) {
39
+ // A describe()/it()/test() wrapper's own complexity/nesting-depth
40
+ // aggregates branches from ALL of its nested test bodies (#577) — e.g. a
41
+ // `for` loop inside each of several `it()`s sums into the enclosing
42
+ // `describe()`'s cyclomatic complexity, even though no single test is
43
+ // complex. Unlike high-fan-out's call-name filter, there's no per-call
44
+ // signal to exclude for branch counting, so the equivalent fix here is to
45
+ // skip the organizer function itself. Genuinely complex test HELPER
46
+ // functions don't call it/describe/test themselves, so they're unaffected.
47
+ if (isTestSuiteOrganizer(f.outgoingCalls))
48
+ continue;
38
49
  const ccBreached = f.cyclomaticComplexity >= activeCcThreshold;
39
50
  const depthBreached = f.maxNestingDepth >= depthThreshold;
40
51
  if (!ccBreached && !depthBreached)
@@ -46,7 +57,7 @@ export const highComplexityRule = {
46
57
  parts.push(`nesting depth ${f.maxNestingDepth}`);
47
58
  diagnostics.push({
48
59
  id: `high-complexity:${ctx.filePath}:${f.line}`,
49
- tool: "high-complexity",
60
+ tool: "fact-rules",
50
61
  rule: "high-complexity",
51
62
  filePath: ctx.filePath,
52
63
  line: f.line,
@@ -1,3 +1,4 @@
1
+ import { isTestFrameworkNoiseCall, isTestSuiteOrganizer, } from "./framework-call-noise.js";
1
2
  // Default matches the historical hardcoded value so behavior is unchanged for
2
3
  // projects without a config. Project-specific overrides are read from the
3
4
  // per-dispatch context; this mutable fallback exists for tests/legacy direct
@@ -27,7 +28,16 @@ export const highFanOutRule = {
27
28
  const configuredFanOutThreshold = ctx.projectConfig?.rules["high-fan-out"]?.threshold;
28
29
  const activeFanOutThreshold = configuredFanOutThreshold ?? fanOutThreshold;
29
30
  for (const f of fns) {
30
- // Filter out noise: utility calls, logging, type assertions
31
+ // A describe()/it()/test() wrapper's own call list aggregates every call
32
+ // from ALL of its nested test bodies (#577) — not real fan-out. Genuinely
33
+ // complex test HELPER functions don't call it/describe/test themselves, so
34
+ // they're unaffected.
35
+ if (isTestSuiteOrganizer(f.outgoingCalls))
36
+ continue;
37
+ // Filter out noise: utility calls, logging, type assertions, and
38
+ // test-framework calls (expect/it/describe/vi.*/jest.*, #577) — assertion-
39
+ // and mock-heavy test bodies naturally call many of these; that's test
40
+ // structure, not a coordination smell.
31
41
  const meaningful = f.outgoingCalls.filter((c) => {
32
42
  const lower = c.toLowerCase();
33
43
  return (!lower.startsWith("console.") &&
@@ -42,13 +52,14 @@ export const highFanOutRule = {
42
52
  c !== "resolve" &&
43
53
  c !== "reject" &&
44
54
  c !== "next" &&
45
- c !== "done");
55
+ c !== "done" &&
56
+ !isTestFrameworkNoiseCall(c));
46
57
  });
47
58
  if (meaningful.length < activeFanOutThreshold)
48
59
  continue;
49
60
  diagnostics.push({
50
61
  id: `high-fan-out:${ctx.filePath}:${f.line}`,
51
- tool: "high-fan-out",
62
+ tool: "fact-rules",
52
63
  rule: "high-fan-out",
53
64
  filePath: ctx.filePath,
54
65
  line: f.line,
@@ -23,7 +23,7 @@ export const highImportCouplingRule = {
23
23
  return [
24
24
  {
25
25
  id: `high-import-coupling:${ctx.filePath}:1:1`,
26
- tool: "high-import-coupling",
26
+ tool: "fact-rules",
27
27
  rule: "high-import-coupling",
28
28
  filePath: ctx.filePath,
29
29
  line: 1,
@@ -46,7 +46,7 @@ export const missingErrorPropagationRule = {
46
46
  continue;
47
47
  diagnostics.push({
48
48
  id: `missing-error-propagation:${ctx.filePath}:${c.line}`,
49
- tool: "missing-error-propagation",
49
+ tool: "fact-rules",
50
50
  rule: "missing-error-propagation",
51
51
  filePath: ctx.filePath,
52
52
  line: c.line,
@@ -34,7 +34,7 @@ export const commentedCredentialsRule = {
34
34
  if (p.test(line)) {
35
35
  diagnostics.push({
36
36
  id: `no-commented-credentials:${ctx.filePath}:${i + 1}`,
37
- tool: "no-commented-credentials",
37
+ tool: "fact-rules",
38
38
  rule: "no-commented-credentials",
39
39
  filePath: ctx.filePath,
40
40
  line: i + 1,
@@ -23,7 +23,7 @@ export const passThroughWrappersRule = {
23
23
  continue;
24
24
  diagnostics.push({
25
25
  id: `pass-through-wrapper:${ctx.filePath}:${fn.line}:${fn.column}`,
26
- tool: "pass-through-wrappers",
26
+ tool: "fact-rules",
27
27
  filePath: ctx.filePath,
28
28
  line: fn.line,
29
29
  column: fn.column,
@@ -25,7 +25,7 @@ export const placeholderCommentsRule = {
25
25
  continue;
26
26
  diagnostics.push({
27
27
  id: `placeholder-comments:${ctx.filePath}:${comment.line}:1`,
28
- tool: "placeholder-comments",
28
+ tool: "fact-rules",
29
29
  filePath: ctx.filePath,
30
30
  line: comment.line,
31
31
  column: 1,
@@ -80,7 +80,7 @@ export const unsafeBoundaryRule = {
80
80
  continue;
81
81
  diagnostics.push({
82
82
  id: `unsafe-boundary:${ctx.filePath}:${f.line}`,
83
- tool: "unsafe-boundary",
83
+ tool: "fact-rules",
84
84
  rule: "unsafe-boundary",
85
85
  filePath: ctx.filePath,
86
86
  line: f.line,
@@ -38,14 +38,31 @@ const SUPPORTED_EXTS = [".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".htm"];
38
38
  const MAX_MATCHES_PER_RULE = 10;
39
39
  /** Maximum total diagnostics per file to prevent output spam */
40
40
  const MAX_TOTAL_DIAGNOSTICS = 50;
41
- /** Rules already covered by tree-sitter runner (priority 14, runs first) */
42
- const TREE_SITTER_OVERLAP = new Set([
43
- "constructor-super",
44
- "empty-catch",
45
- "long-parameter-list",
46
- "nested-ternary",
47
- "no-dupe-class-members",
48
- ]);
41
+ /**
42
+ * #660: this runner used to skip a hardcoded set of rule ids
43
+ * (`constructor-super`, `empty-catch`, `long-parameter-list`,
44
+ * `nested-ternary`, `no-dupe-class-members`) on the assumption that the
45
+ * tree-sitter query runner (priority 14) already covered them, to avoid
46
+ * double-reporting. That assumption was false for every entry: three of
47
+ * them (`nested-ternary`, `long-parameter-list`, `no-dupe-class-members`)
48
+ * have no active tree-sitter query — their would-be queries either live
49
+ * under `rules/tree-sitter-queries/typescript-disabled/` (excluded from
50
+ * loading, see clients/tree-sitter-query-loader.ts) or were never written —
51
+ * so those three rule ids had ZERO coverage in the NAPI fallback runner
52
+ * (used when the ast-grep binary isn't installed) despite having a
53
+ * perfectly good, shipped, active ast-grep rule sitting right there. The
54
+ * other two (`constructor-super`, `empty-catch`) are disabled everywhere
55
+ * (ast-grep AND tree-sitter, see rules-disabled/, #206), so skipping them
56
+ * was already a no-op. The whole skip-set has been removed; if tree-sitter
57
+ * coverage is ever added back for one of these rule ids, reintroduce a
58
+ * scoped skip alongside the query that actually covers it — don't recreate
59
+ * a blanket assumption-based list.
60
+ *
61
+ * Note: `no-dupe-class-members` didn't actually fire immediately
62
+ * post-removal — its rule YAML uses a top-level `utils:` block that this
63
+ * runner's native-config passthrough dropped entirely, a separate bug
64
+ * (affecting 5 shipped rules, not just this one) fixed in #663.
65
+ */
49
66
  /**
50
67
  * Rules commonly covered by ESLint/Biome correctness checks.
51
68
  * We can suppress these from ast-grep in lint-enabled projects to reduce noise.
@@ -103,6 +120,32 @@ function normalizeRuleId(ruleId) {
103
120
  export function canHandle(filePath) {
104
121
  return SUPPORTED_EXTS.includes(path.extname(filePath).toLowerCase());
105
122
  }
123
+ /**
124
+ * The TypeScript grammar is a syntactic superset of JavaScript, so a
125
+ * `JavaScript`-tagged rule using generic node kinds (`variable_declarator`,
126
+ * `assignment_expression`, …) still matches against a parsed `.ts`/`.tsx`
127
+ * root — and vice versa isn't an issue since JS files never parse
128
+ * TS-only syntax, but a `TypeScript`-tagged rule with a plain-JS-compatible
129
+ * body would equally double-fire alongside a `JavaScript` twin on a `.ts`
130
+ * file. Without this, `language:` reads as a real filter but isn't one for
131
+ * ts↔js pairs, so twin rules sharing a base name (e.g. `hardcoded-url` /
132
+ * `hardcoded-url-js`) both match the same construct in the SAME runner
133
+ * invocation (#657). Returns undefined for extensions this scoping doesn't
134
+ * apply to (css/html), where no filtering is added.
135
+ */
136
+ export function ruleLanguageForFile(filePath) {
137
+ const ext = path.extname(filePath).toLowerCase();
138
+ switch (ext) {
139
+ case ".ts":
140
+ case ".tsx":
141
+ return "typescript";
142
+ case ".js":
143
+ case ".jsx":
144
+ return "javascript";
145
+ default:
146
+ return undefined;
147
+ }
148
+ }
106
149
  export function getLang(filePath, sgModule) {
107
150
  const ext = path.extname(filePath).toLowerCase();
108
151
  switch (ext) {
@@ -169,9 +212,11 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
169
212
  const maxMatchesPerRule = options.maxMatchesPerRule ?? MAX_MATCHES_PER_RULE;
170
213
  const maxTotalDiagnostics = options.maxTotalDiagnostics ?? MAX_TOTAL_DIAGNOSTICS;
171
214
  const blockingOnly = options.blockingOnly === true;
215
+ const log = options.log;
172
216
  const diagnostics = [];
173
217
  const seenRuleIds = new Set();
174
218
  const suppressLinterOverlap = kind === "jsts" && hasEslintConfig(cwd);
219
+ const fileLang = ruleLanguageForFile(filePath);
175
220
  // Shared with the raw sgconfig materializer so both surfaces walk the same
176
221
  // workspace-rooted sources in the same precedence order.
177
222
  const ruleSources = getAstGrepRuleSources(options.projectRoot ?? cwd);
@@ -205,9 +250,6 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
205
250
  !NON_SUPPRESSIBLE.has(normalizeRuleId(rule.id))) {
206
251
  continue;
207
252
  }
208
- // Skip rules already handled by tree-sitter runner (priority 14)
209
- if (TREE_SITTER_OVERLAP.has(rule.id))
210
- continue;
211
253
  // Skip rules whose top-level pattern is overly broad ($NAME, $X, etc.)
212
254
  // without additional structural constraints to narrow matches.
213
255
  if (rule.rule &&
@@ -219,6 +261,12 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
219
261
  if (lang && lang !== "typescript" && lang !== "javascript") {
220
262
  continue;
221
263
  }
264
+ // Scope TypeScript/JavaScript-tagged rules to the file's actual
265
+ // grammar (#657) — otherwise a `-js` twin sharing generic node
266
+ // kinds with its TS sibling double-fires on every .ts file.
267
+ if (lang && fileLang && lang !== fileLang) {
268
+ continue;
269
+ }
222
270
  if (blockingOnly && rule.rule) {
223
271
  const complexity = calculateRuleComplexity(rule.rule);
224
272
  if (complexity > MAX_BLOCKING_RULE_COMPLEXITY) {
@@ -232,18 +280,26 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
232
280
  // Delegate matching to napi's native engine, which handles the
233
281
  // full ast-grep rule grammar (pattern, kind, has/inside/follows/
234
282
  // precedes/stopBy/field/nthChild, any/all/not) plus metavariable
235
- // `constraints` (#206). A faithful js-yaml parse feeds the rule
283
+ // `constraints` (#206) AND top-level `utils` — reusable named
284
+ // matchers referenced via `matches: <name>` inside `rule`
285
+ // (#663; `NapiConfig.utils: Record<string, Rule>` per
286
+ // @ast-grep/napi's types, same shape napi already expects for
287
+ // `rule`/`constraints`). A faithful js-yaml parse feeds the rule
236
288
  // object straight through. If napi rejects the rule (a malformed
237
- // or invalid-kind rule), skip it — never silently match nothing
238
- // through a partial interpreter.
239
- const nativeConfig = rule.constraints
240
- ? { rule: rule.rule, constraints: rule.constraints }
241
- : { rule: rule.rule };
289
+ // or invalid-kind rule, or an unresolved `matches:` reference),
290
+ // skip it — never silently match nothing through a partial
291
+ // interpreter.
292
+ const nativeConfig = { rule: rule.rule };
293
+ if (rule.constraints)
294
+ nativeConfig.constraints = rule.constraints;
295
+ if (rule.utils)
296
+ nativeConfig.utils = rule.utils;
242
297
  try {
243
298
  matches = rootNode.findAll(nativeConfig);
244
299
  }
245
- catch {
300
+ catch (err) {
246
301
  matches = [];
302
+ log?.(`ast-grep-napi: rule "${rule.id}" rejected by native engine (${err instanceof Error ? err.message : String(err)})`);
247
303
  }
248
304
  const limitedMatches = matches.slice(0, maxMatchesPerRule);
249
305
  for (const match of limitedMatches) {
@@ -355,6 +411,7 @@ const astGrepNapiRunner = {
355
411
  const diagnostics = evaluateAstGrepRules(ctx.filePath, rootNode, ctx.cwd, ctx.kind, {
356
412
  blockingOnly: ctx.blockingOnly,
357
413
  projectRoot: ctx.projectRoot,
414
+ log: (message) => ctx.log(message),
358
415
  });
359
416
  const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
360
417
  let semantic = "none";