pi-lens 4.1.2 → 4.1.4

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 (359) hide show
  1. package/CHANGELOG.md +1308 -0
  2. package/README.md +3 -0
  3. package/config/biome/core.jsonc +11 -2
  4. package/config/dependency-cruiser-eager-allowlist.json +91 -0
  5. package/dist/clients/actionable-warnings-logger.js +18 -3
  6. package/dist/clients/actionable-warnings.js +927 -80
  7. package/dist/clients/agent-behavior-client.js +13 -4
  8. package/dist/clients/ast-grep-client.js +136 -9
  9. package/dist/clients/ast-grep-rule-manager.js +60 -5
  10. package/dist/clients/ast-grep-tool-logger.js +2 -2
  11. package/dist/clients/availability-probe-flight.js +12 -0
  12. package/dist/clients/bash-file-access.js +1 -3
  13. package/dist/clients/biome-client.js +16 -2
  14. package/dist/clients/blocker-freshness.js +34 -0
  15. package/dist/clients/bootstrap.js +509 -73
  16. package/dist/clients/bounded-cache.js +152 -12
  17. package/dist/clients/bounded-telemetry.js +115 -23
  18. package/dist/clients/build-identity.js +95 -0
  19. package/dist/clients/bundled-resource-health.js +113 -0
  20. package/dist/clients/bus-events-logger.js +2 -2
  21. package/dist/clients/bus-publish.js +3 -0
  22. package/dist/clients/cache/rule-cache.js +33 -5
  23. package/dist/clients/cache-manager.js +105 -6
  24. package/dist/clients/cache-observability.js +46 -19
  25. package/dist/clients/cargo-manifest.js +422 -0
  26. package/dist/clients/cascade-logger.js +17 -3
  27. package/dist/clients/code-quality-warnings.js +13 -3
  28. package/dist/clients/complexity-client.js +16 -5
  29. package/dist/clients/config-core/deny.js +221 -0
  30. package/dist/clients/config-core/index.js +47 -0
  31. package/dist/clients/config-core/merge.js +357 -0
  32. package/dist/clients/config-core/normalize.js +340 -0
  33. package/dist/clients/config-core/process-spec.js +248 -0
  34. package/dist/clients/config-core/provenance.js +171 -0
  35. package/dist/clients/config-core/records.js +218 -0
  36. package/dist/clients/config-core/resolve.js +125 -0
  37. package/dist/clients/config-core/safe-object.js +78 -0
  38. package/dist/clients/config-core/schema.js +167 -0
  39. package/dist/clients/config-diagnostic-codes.js +281 -0
  40. package/dist/clients/config-locations.js +160 -0
  41. package/dist/clients/config-resolve.js +789 -0
  42. package/dist/clients/config-schema.js +197 -0
  43. package/dist/clients/config-warn.js +407 -0
  44. package/dist/clients/dead-code-client.js +33 -25
  45. package/dist/clients/dead-code-logger.js +2 -2
  46. package/dist/clients/deadline-utils.js +178 -0
  47. package/dist/clients/debug-handles.js +2 -2
  48. package/dist/clients/debug-heap.js +3 -3
  49. package/dist/clients/deferred-lsp-work.js +106 -0
  50. package/dist/clients/degradation-ledger.js +166 -8
  51. package/dist/clients/demoted-finding-render.js +14 -0
  52. package/dist/clients/dependency-checker.js +28 -22
  53. package/dist/clients/deps/minimatch.js +1 -1
  54. package/dist/clients/diagnostic-line-freshness.js +12 -9
  55. package/dist/clients/diagnostic-logger.js +14 -4
  56. package/dist/clients/dispatch/collect-later-tier.js +39 -0
  57. package/dist/clients/dispatch/dispatcher.js +304 -19
  58. package/dist/clients/dispatch/fact-store.js +328 -9
  59. package/dist/clients/dispatch/integration.js +214 -107
  60. package/dist/clients/dispatch/pending-runner-findings.js +87 -0
  61. package/dist/clients/dispatch/plan.js +21 -5
  62. package/dist/clients/dispatch/runners/actionlint.js +0 -1
  63. package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
  64. package/dist/clients/dispatch/runners/biome-check.js +0 -1
  65. package/dist/clients/dispatch/runners/cpp-check.js +0 -1
  66. package/dist/clients/dispatch/runners/credo.js +0 -1
  67. package/dist/clients/dispatch/runners/cue-vet.js +0 -1
  68. package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  69. package/dist/clients/dispatch/runners/detekt.js +0 -1
  70. package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  71. package/dist/clients/dispatch/runners/elixir-check.js +0 -1
  72. package/dist/clients/dispatch/runners/eslint.js +5 -2
  73. package/dist/clients/dispatch/runners/fact-rules.js +0 -1
  74. package/dist/clients/dispatch/runners/fish-indent.js +0 -1
  75. package/dist/clients/dispatch/runners/gleam-check.js +0 -1
  76. package/dist/clients/dispatch/runners/go-vet.js +1 -3
  77. package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  78. package/dist/clients/dispatch/runners/hadolint.js +0 -1
  79. package/dist/clients/dispatch/runners/helm-lint.js +16 -4
  80. package/dist/clients/dispatch/runners/helm-render.js +54 -10
  81. package/dist/clients/dispatch/runners/htmlhint.js +0 -1
  82. package/dist/clients/dispatch/runners/index.js +1 -3
  83. package/dist/clients/dispatch/runners/javac.js +0 -1
  84. package/dist/clients/dispatch/runners/ktlint.js +0 -1
  85. package/dist/clients/dispatch/runners/lsp.js +37 -2
  86. package/dist/clients/dispatch/runners/markdownlint.js +4 -2
  87. package/dist/clients/dispatch/runners/mypy.js +0 -1
  88. package/dist/clients/dispatch/runners/oxlint.js +24 -22
  89. package/dist/clients/dispatch/runners/php-lint.js +0 -1
  90. package/dist/clients/dispatch/runners/phpstan.js +0 -1
  91. package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  92. package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
  93. package/dist/clients/dispatch/runners/pyright.js +7 -4
  94. package/dist/clients/dispatch/runners/rubocop.js +0 -1
  95. package/dist/clients/dispatch/runners/ruff.js +0 -1
  96. package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
  97. package/dist/clients/dispatch/runners/shellcheck.js +0 -1
  98. package/dist/clients/dispatch/runners/shfmt.js +0 -1
  99. package/dist/clients/dispatch/runners/spellcheck.js +0 -1
  100. package/dist/clients/dispatch/runners/spotbugs.js +0 -1
  101. package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  102. package/dist/clients/dispatch/runners/stylelint.js +0 -1
  103. package/dist/clients/dispatch/runners/swiftlint.js +0 -1
  104. package/dist/clients/dispatch/runners/taplo.js +0 -1
  105. package/dist/clients/dispatch/runners/terragrunt.js +0 -1
  106. package/dist/clients/dispatch/runners/tflint.js +0 -1
  107. package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  108. package/dist/clients/dispatch/runners/trivy-config.js +0 -1
  109. package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  110. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  111. package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
  112. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  113. package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  114. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
  115. package/dist/clients/dispatch/runners/vale.js +0 -1
  116. package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  117. package/dist/clients/dispatch/runners/yamllint.js +0 -1
  118. package/dist/clients/dispatch/runners/zig-check.js +0 -1
  119. package/dist/clients/disposition-logger.js +3 -3
  120. package/dist/clients/effective-config.js +403 -0
  121. package/dist/clients/error-class.js +23 -0
  122. package/dist/clients/event-loop-hold.js +274 -0
  123. package/dist/clients/event-loop-monitor.js +66 -1
  124. package/dist/clients/extension-log.js +2 -2
  125. package/dist/clients/feature-hints.js +2 -1
  126. package/dist/clients/file-time.js +6 -1
  127. package/dist/clients/file-utils.js +266 -13
  128. package/dist/clients/finding-delivery-gate.js +55 -13
  129. package/dist/clients/format-service.js +6 -1
  130. package/dist/clients/formatters.js +303 -104
  131. package/dist/clients/freshness-cadence.js +17 -0
  132. package/dist/clients/generated-artifacts.js +114 -19
  133. package/dist/clients/generation-guard.js +4 -9
  134. package/dist/clients/git-guard.js +198 -48
  135. package/dist/clients/git-tracked-ignore.js +32 -1
  136. package/dist/clients/go-client.js +39 -0
  137. package/dist/clients/govulncheck-client.js +5 -1
  138. package/dist/clients/gradle-ktfmt-style.js +252 -0
  139. package/dist/clients/gzip-stage-write.js +7 -0
  140. package/dist/clients/hashline-anchor.js +424 -0
  141. package/dist/clients/hook-budgets.js +76 -0
  142. package/dist/clients/host-edit-normalize.js +5 -2
  143. package/dist/clients/host-ports.js +1 -1
  144. package/dist/clients/installer/index.js +200 -84
  145. package/dist/clients/installer/managed-tool-refresh.js +3 -1
  146. package/dist/clients/instance-reaper.js +108 -243
  147. package/dist/clients/instance-registry-lock.js +145 -0
  148. package/dist/clients/instance-registry.js +435 -122
  149. package/dist/clients/jscpd-client.js +6 -1
  150. package/dist/clients/language-profile.js +4 -2
  151. package/dist/clients/language-registry.js +595 -0
  152. package/dist/clients/latency-logger.js +179 -3
  153. package/dist/clients/ledger-bounds.js +13 -0
  154. package/dist/clients/lens-config.js +141 -30
  155. package/dist/clients/lens-engine.js +30 -37
  156. package/dist/clients/lens-flag-registry.js +67 -6
  157. package/dist/clients/lens-map.js +3 -1
  158. package/dist/clients/log-cleanup.js +2 -2
  159. package/dist/clients/lsp/aggregation.js +3 -1
  160. package/dist/clients/lsp/cascade-tier.js +4 -0
  161. package/dist/clients/lsp/client.js +672 -49
  162. package/dist/clients/lsp/config.js +504 -156
  163. package/dist/clients/lsp/diagnostic-binding.js +10 -5
  164. package/dist/clients/lsp/edits.js +15 -2
  165. package/dist/clients/lsp/index.js +1179 -247
  166. package/dist/clients/lsp/jvm-runtime.js +5 -0
  167. package/dist/clients/lsp/language.js +18 -169
  168. package/dist/clients/lsp/launch.js +38 -9
  169. package/dist/clients/lsp/pending-aux-coverage.js +113 -12
  170. package/dist/clients/lsp/server.js +426 -160
  171. package/dist/clients/lsp/session-roots.js +192 -0
  172. package/dist/clients/lsp/spawn-history.js +2 -7
  173. package/dist/clients/lsp/tsserver-sync.js +9 -2
  174. package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
  175. package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  176. package/dist/clients/lsp-mutation.js +179 -23
  177. package/dist/clients/map-with-concurrency.js +36 -0
  178. package/dist/clients/mcp/analyze.js +45 -10
  179. package/dist/clients/mcp/session.js +19 -17
  180. package/dist/clients/memory-sampler.js +28 -1
  181. package/dist/clients/message-end-attribution.js +30 -0
  182. package/dist/clients/metrics-history.js +21 -4
  183. package/dist/clients/middle-man-analysis.js +2 -4
  184. package/dist/clients/module-report.js +20 -45
  185. package/dist/clients/mutating-tool.js +651 -0
  186. package/dist/clients/mutation-attribution.js +368 -0
  187. package/dist/clients/mutation-bridge.js +240 -0
  188. package/dist/clients/ndjson-logger.js +247 -20
  189. package/dist/clients/observed-mutation-sources.js +101 -0
  190. package/dist/clients/observed-mutation.js +1215 -0
  191. package/dist/clients/opaque-mutation-scan.js +300 -40
  192. package/dist/clients/opengrep-config.js +4 -0
  193. package/dist/clients/package-manager.js +201 -44
  194. package/dist/clients/partial-edit-apply.js +359 -80
  195. package/dist/clients/path-attribution-telemetry.js +23 -7
  196. package/dist/clients/path-keyed-map.js +35 -3
  197. package/dist/clients/path-utils.js +460 -13
  198. package/dist/clients/persist-debounce.js +8 -1
  199. package/dist/clients/php-cs-fixer-config.js +114 -0
  200. package/dist/clients/pipeline.js +77 -24
  201. package/dist/clients/probe-home-state.js +228 -0
  202. package/dist/clients/process-bridge.js +66 -0
  203. package/dist/clients/process-singletons.js +210 -0
  204. package/dist/clients/process-snapshot.js +68 -0
  205. package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  206. package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  207. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  208. package/dist/clients/project-diagnostics/scanner.js +61 -24
  209. package/dist/clients/project-lens-config.js +393 -122
  210. package/dist/clients/project-snapshot.js +85 -35
  211. package/dist/clients/python-environment.js +217 -0
  212. package/dist/clients/python-provenance.js +639 -0
  213. package/dist/clients/quiet-window.js +6 -1
  214. package/dist/clients/read-bridge.js +13 -21
  215. package/dist/clients/read-expansion.js +9 -46
  216. package/dist/clients/read-guard-logger.js +39 -6
  217. package/dist/clients/read-guard-tool-lines.js +364 -212
  218. package/dist/clients/read-guard.js +95 -7
  219. package/dist/clients/recent-touches.js +17 -5
  220. package/dist/clients/resource-sampler.js +195 -96
  221. package/dist/clients/review-graph/builder.js +752 -132
  222. package/dist/clients/review-graph/service.js +55 -3
  223. package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  224. package/dist/clients/review-graph/tsconfig-paths.js +5 -1
  225. package/dist/clients/review-graph/workspace-modules.js +10 -74
  226. package/dist/clients/review-graph-logger.js +30 -4
  227. package/dist/clients/runtime-agent-end.js +18 -2
  228. package/dist/clients/runtime-context.js +49 -16
  229. package/dist/clients/runtime-coordinator.js +93 -6
  230. package/dist/clients/runtime-session.js +169 -34
  231. package/dist/clients/runtime-tool-call.js +183 -35
  232. package/dist/clients/runtime-tool-result.js +894 -241
  233. package/dist/clients/runtime-turn.js +1264 -74
  234. package/dist/clients/rust-client.js +31 -0
  235. package/dist/clients/safe-spawn.js +191 -28
  236. package/dist/clients/sanitize.js +7 -1
  237. package/dist/clients/scratch-tree-policy.js +1 -3
  238. package/dist/clients/security-scan-client.js +51 -17
  239. package/dist/clients/session-lifecycle.js +238 -42
  240. package/dist/clients/session-start-observability.js +79 -0
  241. package/dist/clients/sessionstart-logger.js +12 -3
  242. package/dist/clients/sg-runner.js +27 -15
  243. package/dist/clients/sgconfig.js +6 -1
  244. package/dist/clients/shared-checkout-guard.js +338 -0
  245. package/dist/clients/skills-resolver.js +105 -0
  246. package/dist/clients/smells-rollup.js +2 -2
  247. package/dist/clients/spawn-output-cap.js +37 -0
  248. package/dist/clients/spawn-timeout-cooldown.js +15 -1
  249. package/dist/clients/startup-scan.js +6 -7
  250. package/dist/clients/startup-timing.js +69 -4
  251. package/dist/clients/string-utils.js +33 -0
  252. package/dist/clients/subagent-mode.js +17 -4
  253. package/dist/clients/test-runner-client.js +586 -118
  254. package/dist/clients/test-runner-delivery.js +239 -0
  255. package/dist/clients/tool-definition.js +41 -1
  256. package/dist/clients/tool-policy.js +92 -22
  257. package/dist/clients/tree-sitter-cache.js +40 -40
  258. package/dist/clients/tree-sitter-client.js +97 -53
  259. package/dist/clients/tree-sitter-logger.js +19 -3
  260. package/dist/clients/tree-sitter-query-loader.js +81 -1
  261. package/dist/clients/tree-sitter-shared.js +51 -46
  262. package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  263. package/dist/clients/typos-config.js +5 -0
  264. package/dist/clients/user-notify.js +6 -2
  265. package/dist/clients/warm-attach.js +8 -10
  266. package/dist/clients/widget-state.js +191 -26
  267. package/dist/clients/word-index-logger.js +15 -3
  268. package/dist/clients/word-index-store.js +463 -0
  269. package/dist/clients/word-index.js +815 -125
  270. package/dist/clients/workspace-topology.js +25 -1
  271. package/dist/clients/zizmor-config.js +29 -6
  272. package/dist/index.js +68577 -53950
  273. package/dist/mcp/analyze-cli.js +3 -1
  274. package/dist/mcp/build-staleness.js +8 -7
  275. package/dist/mcp/cli.js +3 -1
  276. package/dist/mcp/server.js +133 -6
  277. package/dist/scripts/lib/process-scan.mjs +583 -0
  278. package/dist/scripts/lib/skills-predicate.mjs +129 -0
  279. package/dist/tools/effective-config.js +89 -0
  280. package/dist/tools/lens-diagnostics.js +99 -24
  281. package/dist/tools/lsp-diagnostics.js +81 -85
  282. package/dist/tools/lsp-navigation.js +40 -17
  283. package/dist/tools/shared.js +0 -1
  284. package/docs/agent-guide.md +68 -3
  285. package/docs/ast-grep_rules_catalog.md +41 -10
  286. package/docs/astplayground.md +26 -17
  287. package/docs/configuration.md +222 -0
  288. package/docs/dependencies.md +3 -3
  289. package/docs/features.md +44 -5
  290. package/docs/language-coverage.md +2 -2
  291. package/docs/pi-lens-fixer.md +25 -0
  292. package/docs/pi-lens-investigator.md +25 -0
  293. package/docs/pi-lens-reviewer.md +27 -0
  294. package/docs/pi-lens-subagent.md +38 -0
  295. package/docs/pi-lens-warden.md +55 -0
  296. package/docs/public-api-stability.md +359 -0
  297. package/docs/release-qa-baseline.md +182 -0
  298. package/docs/subagent-compat.md +110 -30
  299. package/docs/tree-sitter_rules_catalog.md +2 -2
  300. package/package.json +17 -21
  301. package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  302. package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  303. package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  304. package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  305. package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  306. package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  307. package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  308. package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  309. package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  310. package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  311. package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  312. package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  313. package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  314. package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  315. package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  316. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  317. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  318. package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  319. package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  320. package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  321. package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  322. package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  323. package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  324. package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  325. package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  326. package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  327. package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  328. package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  329. package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  330. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  331. package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  332. package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  333. package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  334. package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  335. package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  336. package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  337. package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  338. package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  339. package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  340. package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  341. package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  342. package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  343. package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  344. package/rules/ast-grep-rules/rules/no-important.yml +21 -0
  345. package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  346. package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  347. package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  348. package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  349. package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  350. package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  351. package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  352. package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  353. package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  354. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  355. package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  356. package/scripts/analyze-pi-lens-logs.mjs +192 -1
  357. package/scripts/install-selftest.mjs +58 -2
  358. package/scripts/lib/skills-predicate.mjs +129 -0
  359. package/scripts/rpc-load-check.mjs +3 -1
@@ -9,7 +9,7 @@ defect_class: hallucination
9
9
  inline_tier: warning
10
10
  language: python
11
11
 
12
- message: "'{METHOD}' is not a Python method — likely a {LANG} idiom leaking in"
12
+ message: "'{{METHOD}}' is not a Python method — likely a cross-language idiom leaking in"
13
13
 
14
14
  description: |
15
15
  A method name from another language is being called on a Python object.
@@ -27,7 +27,7 @@ query: |
27
27
  function: (attribute
28
28
  object: (_) @OBJ
29
29
  attribute: (identifier) @METHOD)
30
- (#match? @METHOD "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"))
30
+ (#match? @METHOD "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"))
31
31
 
32
32
  metavars:
33
33
  - OBJ
@@ -35,7 +35,7 @@ metavars:
35
35
 
36
36
  post_filter: match_captures
37
37
  post_filter_params:
38
- METHOD: "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
38
+ METHOD: "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
39
39
 
40
40
  has_fix: false
41
41
 
@@ -9,7 +9,7 @@ defect_class: hallucination
9
9
  inline_tier: blocking
10
10
  language: python
11
11
 
12
- message: "Hallucinated import — '{NAME}' does not exist in '{MODULE}'"
12
+ message: "Hallucinated import — '{{NAME}}' does not exist in '{{MODULE}}'"
13
13
 
14
14
  description: |
15
15
  This import is from the wrong package. A name from one framework is being
@@ -32,7 +32,7 @@ metavars:
32
32
 
33
33
  post_filter: match_captures
34
34
  post_filter_params:
35
- MODULE: "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib|sqlalchemy)$"
35
+ MODULE: "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib)$"
36
36
  NAME: "^(JSONResponse|HTMLResponse|RedirectResponse|StreamingResponse|Depends|Query|Path|Body|Header|Cookie|Form|File|UploadFile|FastAPI|APIRouter|HTTPException|BackgroundTasks|dataclass|fields|BaseModel|Field|validator|aiohttp|parse|stringify|fixture|TestCase|get|post|put|delete|Model|Session|Column|Integer|String)$"
37
37
 
38
38
  has_fix: false
@@ -18,8 +18,18 @@ description: |
18
18
 
19
19
  Suppressed:
20
20
  - cursor.execute(query, params) — parameterized query (safe)
21
- - session.execute(select(...)) — SQLAlchemy ORM (safe by construction)
22
- - cursor.execute(f"...") — f-string with literal-only interpolation (rare false positive)
21
+ - session.execute(...) — receiver conventionally named for a SQLAlchemy session
22
+ - x.execute(select(...)) — statement builder in argument position (safe by
23
+ construction)
24
+ - db.query(...) / db.execute(stmt) where `db: Session` and `Session` is proven
25
+ from a same-file `sqlalchemy.orm` / `sqlalchemy.ext.asyncio` import, and
26
+ `stmt` is bound exactly once to a builder call
27
+ - cursor.execute(sql.SQL("...").format(sql.Identifier(...))) where `sql` is
28
+ an unshadowed same-file psycopg or psycopg2 import and every format argument
29
+ is an Identifier
30
+
31
+ Other raw, dynamic, or ambiguous SQL remains diagnostic so this rule fails
32
+ closed when it cannot prove the safe API provenance.
23
33
 
24
34
  ✅ FIX: use placeholders and parameter binding instead of string composition.
25
35
 
@@ -221,6 +221,35 @@ function createState(files) {
221
221
  progress: [],
222
222
  },
223
223
  },
224
+ // #2526: the config stack's positive-observability rows. `resolved`
225
+ // counts `config_resolved` phase records; `legacyWithoutRecords` counts
226
+ // the rows that carry a deprecated document but produced no
227
+ // migration/notice record — the deprecation machinery gone silent.
228
+ //
229
+ // Round 2, F2: the session->row relation is a JOIN on the session id both
230
+ // sides carry, not a subtraction of two counts.
231
+ //
232
+ // Round 3, S1: the session-side half of the join is no longer a
233
+ // PREDICTION. `pendingSessions` holds every session id that published a
234
+ // `config_resolution_pending` mark — written by `loadLSPConfig` itself,
235
+ // at the instant a resolution is actually attempted, never by a
236
+ // `runtime-session.ts` handler guessing from `no-lsp`/subagent/warm-attach
237
+ // flags ahead of time. A session that never reaches that call (quick or
238
+ // minimal mode's second-and-later session in a process, for the same
239
+ // root) never gets a mark, so it is silently excluded rather than
240
+ // counted against. `resolvedSessions` holds every session id a
241
+ // resolution actually reported, from EITHER sink (the latency row or the
242
+ // sessionstart line — one call writes both, so this is one fact
243
+ // surviving an independent rotation of either log, not a second source
244
+ // of truth).
245
+ config: {
246
+ resolved: 0,
247
+ pendingSessions: new Map(),
248
+ resolvedSessions: new Set(),
249
+ legacyDocuments: 0,
250
+ legacyWithoutRecords: 0,
251
+ examples: [],
252
+ },
224
253
  diagnostics: {
225
254
  bySeverity: counter(),
226
255
  byTool: counter(),
@@ -351,7 +380,44 @@ async function analyzeLatency(files, state) {
351
380
  } else if (entry.type === "phase") {
352
381
  const phase = entry.phase ?? "unknown";
353
382
  state.latency.phaseCounts.inc(phase);
354
- if (/_timeout$/.test(phase)) state.latency.phaseTimeouts.inc(phase);
383
+ if (phase.endsWith("_timeout")) state.latency.phaseTimeouts.inc(phase);
384
+ if (phase === "config_resolved") {
385
+ // #2526. Counted here rather than derived from phaseCounts so the
386
+ // legacy/record cross-check reads the same row it counts.
387
+ state.config.resolved += 1;
388
+ const md = entry.metadata ?? {};
389
+ // #2526 R2 F2 / #2552 R4: the join key is (session, root), not
390
+ // session alone — see `configJoinKey`'s doc comment. `entry.filePath`
391
+ // is this row's root (the same `configResolutionKey(cwd)` value the
392
+ // pending mark's `root=` carries); a row from a build that predates
393
+ // the session id carries none, and simply joins to nothing.
394
+ if (
395
+ typeof md.sessionId === "string" &&
396
+ md.sessionId.length > 0 &&
397
+ typeof entry.filePath === "string" &&
398
+ entry.filePath.length > 0
399
+ ) {
400
+ state.config.resolvedSessions.add(
401
+ configJoinKey(md.sessionId, entry.filePath),
402
+ );
403
+ }
404
+ const documents = Array.isArray(md.documents) ? md.documents : [];
405
+ const legacy = documents.filter((doc) => doc?.legacy === true);
406
+ state.config.legacyDocuments += legacy.length;
407
+ // A deprecated document that produced NO record means the
408
+ // migration notices went silent — the user is on a removal
409
+ // schedule and is never told. Zero records with zero legacy
410
+ // documents is the correct canonical-only answer, not a smell.
411
+ if (legacy.length > 0 && Number(md.recordCount ?? 0) === 0) {
412
+ state.config.legacyWithoutRecords += 1;
413
+ pushTop(
414
+ state.config.examples,
415
+ summarizeConfigResolved(entry, "legacy document with 0 records"),
416
+ limit * 3,
417
+ byDuration,
418
+ );
419
+ }
420
+ }
355
421
  if (
356
422
  phase === "total" &&
357
423
  (entry.durationMs ?? 0) >= thresholds.totalSlowMs
@@ -636,6 +702,40 @@ async function analyzeSessionStart(files, state) {
636
702
  trackProject(state, cwd);
637
703
  }
638
704
 
705
+ // #2526 R3 S1: this session's config-resolution PENDING mark, published
706
+ // by `loadLSPConfig` itself at the instant a resolution is actually
707
+ // attempted — never a start-line prediction from `no-lsp`/subagent/
708
+ // warm-attach flags. A session that never calls `loadLSPConfig` (quick
709
+ // or minimal mode's second-and-later session in a process, for the
710
+ // same root) never writes this line, and is correctly absent from the
711
+ // join rather than counted against.
712
+ //
713
+ // #2552 R4: `root=` is REQUIRED in the match — a warm MCP process keeps
714
+ // one session id for its whole life but marks once per served root
715
+ // (`configJoinKey`'s doc comment), so a line missing it cannot be
716
+ // attributed to a specific root and is left out of the join rather than
717
+ // guessed into a wrong one.
718
+ const pending =
719
+ /session_start config_resolution_pending session=(\S+) root=(.*)$/.exec(
720
+ message,
721
+ );
722
+ if (pending) {
723
+ const [, sessionId, root] = pending;
724
+ state.config.pendingSessions.set(configJoinKey(sessionId, root), {
725
+ ts: iso(ts),
726
+ sessionId,
727
+ root,
728
+ });
729
+ }
730
+ // The loader's own line is the second half of the join, so a rotated
731
+ // latency.log cannot manufacture a deficit on its own.
732
+ const resolvedSession =
733
+ /config resolved .*\bsession=(\S+) root=(.*)$/.exec(message);
734
+ if (resolvedSession) {
735
+ const [, sessionId, root] = resolvedSession;
736
+ state.config.resolvedSessions.add(configJoinKey(sessionId, root));
737
+ }
738
+
639
739
  const total = /session_start total:\s*(\d+)ms/.exec(message);
640
740
  if (total && Number(total[1]) >= thresholds.startupSlowMs) {
641
741
  state.smellTotals.inc("slow-session-start");
@@ -899,6 +999,23 @@ function iso(date) {
899
999
  return date?.toISOString?.() ?? "unknown";
900
1000
  }
901
1001
 
1002
+ /**
1003
+ * The config-resolution join key (#2552 review round 4). A warm MCP process
1004
+ * keeps ONE session id for its entire life but calls `loadLSPConfig` once per
1005
+ * SERVED ROOT (#2526 review round 2, F3's premise, reintroduced one layer up
1006
+ * here) — joining on session id alone let one root's row silently clear every
1007
+ * OTHER root's deficit under the same session id. `root` is whatever string
1008
+ * the producer already wrote (the pending mark's `root=`, or the row's own
1009
+ * `filePath` — both come from the SAME `configResolutionKey(cwd)` call in
1010
+ * `clients/lsp/config.ts`, which folds separators, canonicalizes, then folds
1011
+ * again — `normalizeFilePath(path.resolve(normalizeFilePath(cwd)))`, in that
1012
+ * order (#2518 review F6; resolving first breaks POSIX backslash names), so they compare equal without this script re-deriving any
1013
+ * path normalization of its own).
1014
+ */
1015
+ function configJoinKey(sessionId, root) {
1016
+ return `${sessionId} ${root}`;
1017
+ }
1018
+
902
1019
  function counter() {
903
1020
  const map = new Map();
904
1021
  return {
@@ -985,6 +1102,33 @@ function summarizeLatency(entry) {
985
1102
  };
986
1103
  }
987
1104
 
1105
+ /**
1106
+ * A `config_resolved` row (#2526), summarised for a smell example.
1107
+ *
1108
+ * Deliberately NOT `summarizeLatency`: that helper's metadata whitelist would
1109
+ * drop every field this row carries, so the example would print a phase name
1110
+ * and nothing a reader could act on.
1111
+ */
1112
+ function summarizeConfigResolved(entry, note) {
1113
+ const md = entry.metadata ?? {};
1114
+ const documents = Array.isArray(md.documents) ? md.documents : [];
1115
+ return {
1116
+ ts: entry.ts,
1117
+ durationMs: entry.durationMs,
1118
+ phase: entry.phase,
1119
+ project: projectOf(entry.filePath),
1120
+ note,
1121
+ documents: documents
1122
+ .map(
1123
+ (doc) =>
1124
+ `${doc?.tier ?? "?"}:${doc?.file ?? "?"}${doc?.legacy ? " (legacy)" : ""}`,
1125
+ )
1126
+ .slice(0, 8),
1127
+ recordCount: md.recordCount,
1128
+ deniedServers: md.deniedServers,
1129
+ };
1130
+ }
1131
+
988
1132
  function summarizeWorkspaceSweep(entry) {
989
1133
  const md = entry.metadata ?? {};
990
1134
  const bits = [];
@@ -1193,6 +1337,43 @@ function buildReport(state) {
1193
1337
  `session_start background tasks >= ${thresholds.backgroundSlowMs}ms`,
1194
1338
  state.session.slowTasks.slice(0, limit),
1195
1339
  );
1340
+ // #2526: config resolution has to prove it HAPPENED. Two shapes, one smell:
1341
+ //
1342
+ // (a) a session that has a `config_resolution_pending` mark and produced no
1343
+ // `config_resolved` row. Round 3, S1: the pending mark is written by
1344
+ // `loadLSPConfig` itself, at the instant a resolution is actually
1345
+ // attempted — never predicted from a handler's own flags ahead of time.
1346
+ // Round 2, F2 established the JOIN itself: on the session id both sides
1347
+ // carry, never a subtraction of two counts (subtracting counted quick
1348
+ // sessions' rows against full sessions' start lines — different
1349
+ // populations — which both masked total silence and charged
1350
+ // `--no-lsp`/subagent sessions with a permanent deficit they could never
1351
+ // clear).
1352
+ // Round 4, #2552 review: the join is on (session, root), not session
1353
+ // alone — a warm MCP process keeps one session id for its whole life but
1354
+ // marks once per SERVED ROOT (`configJoinKey`'s doc comment), so one
1355
+ // root's row must not clear another root's deficit under the same id.
1356
+ // (b) a legacy document present with zero records — the deprecation
1357
+ // machinery went silent while the user is on a removal schedule.
1358
+ const pendingConfigResolution = [...state.config.pendingSessions.entries()];
1359
+ const unresolvedSessions = pendingConfigResolution.filter(
1360
+ ([key]) => !state.config.resolvedSessions.has(key),
1361
+ );
1362
+ for (const [, value] of unresolvedSessions.slice(0, limit * 3)) {
1363
+ state.config.examples.push({
1364
+ ts: value.ts,
1365
+ session: value.sessionId,
1366
+ root: value.root,
1367
+ note: "session had a config_resolution_pending mark and produced no config_resolved row",
1368
+ });
1369
+ }
1370
+ addSmell(
1371
+ smells,
1372
+ "config-resolution",
1373
+ unresolvedSessions.length + state.config.legacyWithoutRecords,
1374
+ `Sessions with a config_resolution_pending mark but no config_resolved row (${unresolvedSessions.length} of ${pendingConfigResolution.length}) or a legacy config document that produced no migration record (${state.config.legacyWithoutRecords})`,
1375
+ state.config.examples.slice(0, limit),
1376
+ );
1196
1377
  addSmell(
1197
1378
  smells,
1198
1379
  "cascade-fallbacks",
@@ -1352,6 +1533,16 @@ function buildReport(state) {
1352
1533
  rotations: state.session.rotations.toJSON(),
1353
1534
  errors: state.session.errors.slice(0, limit),
1354
1535
  },
1536
+ // #2526: the positive-observability counters behind the
1537
+ // `config-resolution` smell, so a reader can see WHY it fired (or that
1538
+ // it correctly did not) without re-deriving the deficit.
1539
+ config: {
1540
+ resolved: state.config.resolved,
1541
+ sessionsPendingResolution: pendingConfigResolution.length,
1542
+ sessionsWithoutResolution: unresolvedSessions.length,
1543
+ legacyDocuments: state.config.legacyDocuments,
1544
+ legacyWithoutRecords: state.config.legacyWithoutRecords,
1545
+ },
1355
1546
  actionable: {
1356
1547
  events: state.actionable.events.toJSON(),
1357
1548
  reports: state.actionable.reports,
@@ -17,7 +17,9 @@
17
17
  * Force-imports the modules whose TOP-LEVEL bare imports are the documented
18
18
  * failure points, plus the bare specifiers directly, and checks the two
19
19
  * build-script-provided assets (ast-grep CLI binary + tree-sitter grammars)
20
- * that pnpm/bun skip by default. It runs no model and needs no credentials.
20
+ * that pnpm/bun skip by default. It also resolves the `pi.skills` manifest
21
+ * entries the way pi does, from the installed package root (#2587). It runs no
22
+ * model and needs no credentials.
21
23
  *
22
24
  * WHAT IT NO LONGER COVERS (#1926)
23
25
  * pi supplies `typebox` and `@earendil-works/pi-tui` from its own runtime, so
@@ -47,6 +49,7 @@ import { createRequire } from "node:module";
47
49
  import * as path from "node:path";
48
50
  import { fileURLToPath } from "node:url";
49
51
  import { HOST_PROVIDED_PACKAGES } from "./lib/host-provided-deps.mjs";
52
+ import { collectSkillEntryPaths } from "./lib/skills-predicate.mjs";
50
53
 
51
54
  const here = path.dirname(fileURLToPath(import.meta.url));
52
55
  const pkgRoot = path.resolve(here, "..");
@@ -242,6 +245,56 @@ try {
242
245
  }
243
246
  record("tree-sitter grammars", "asset", hasCoreGrammar, grammarDetail);
244
247
 
248
+ // --- 4. pi.skills manifest resolution, AS INSTALLED (#2587) ----------------
249
+ // Guards the recurrence of #2587: a `pi.skills` entry that resolves outside the
250
+ // installed package, so pi registers none of the shipped skills. This probe runs
251
+ // from the INSTALLED package (`require.resolve("pi-lens/scripts/…")`), so it sees
252
+ // the real layout — hoisted node_modules, pnpm's symlink store, bun, yarn — which
253
+ // is exactly where `../../skills` escaped and where a static check on the source
254
+ // tree cannot look. Replicates pi's own resolver, `PackageManager#collectFiles\
255
+ // FromManifestEntries` in `@earendil-works/pi-coding-agent`
256
+ // `dist/core/package-manager.js`: a non-glob entry is `resolve(packageRoot, entry)`
257
+ // (verified identical in 0.78.1 / 0.84.1 / 0.85.1).
258
+ //
259
+ // #2626 review round 2 (F2): the "does this dir have a skill" walk used to be
260
+ // a hand-rolled `countSkillFiles` here AND a second, differently-wrong one in
261
+ // `clients/skills-resolver.ts`. Both now derive from the ONE structural
262
+ // predicate in `./lib/skills-predicate.mjs`, which replicates pi's actual
263
+ // `loadSkillsFromDirInternal` walk (see that module's header for the exact
264
+ // discovery rules and the two documented scope gaps).
265
+ {
266
+ const pkgJson = JSON.parse(
267
+ fs.readFileSync(path.join(pkgRoot, "package.json"), "utf8"),
268
+ );
269
+ const entries = pkgJson.pi?.skills ?? [];
270
+ if (entries.length === 0) {
271
+ record("pi.skills declared", "manifest", false, "pi.skills is empty");
272
+ }
273
+ for (const entry of entries) {
274
+ const label = `pi.skills "${entry}"`;
275
+ // pi globs entries containing * or ? through a different branch; this
276
+ // probe only replicates the non-glob one.
277
+ if (/[*?]/.test(entry)) {
278
+ record(label, "manifest", true, "glob entry — not probed");
279
+ continue;
280
+ }
281
+ const resolved = path.resolve(pkgRoot, entry);
282
+ if (resolved !== pkgRoot && !resolved.startsWith(pkgRoot + path.sep)) {
283
+ record(label, "manifest", false, `escapes the package: ${resolved}`);
284
+ continue;
285
+ }
286
+ const found = collectSkillEntryPaths(resolved).length;
287
+ record(
288
+ label,
289
+ "manifest",
290
+ found > 0,
291
+ found > 0
292
+ ? `${found} SKILL.md under ${resolved}`
293
+ : `no SKILL.md under ${resolved}`,
294
+ );
295
+ }
296
+ }
297
+
245
298
  // --- Report ----------------------------------------------------------------
246
299
  const pad = Math.max(...results.map((r) => r.name.length));
247
300
  let hardFail = 0;
@@ -249,7 +302,10 @@ let softFail = 0;
249
302
  for (const r of results) {
250
303
  const soft = (r.kind === "asset" || r.kind === "tool") && allowSoft;
251
304
  const status = r.ok ? "PASS" : soft ? "WARN" : "FAIL";
252
- if (!r.ok) soft ? softFail++ : hardFail++;
305
+ if (!r.ok) {
306
+ if (soft) softFail++;
307
+ else hardFail++;
308
+ }
253
309
  console.log(
254
310
  ` [${status}] ${r.name.padEnd(pad)} ${r.detail ? "— " + r.detail : ""}`,
255
311
  );
@@ -0,0 +1,129 @@
1
+ // The ONE structural replica of pi's own skill-loading walk (#2626 review
2
+ // round 2, F1/F2).
3
+ //
4
+ // Two independent detectors used to answer "does this directory tree hold a
5
+ // skill pi would load" with two DIFFERENT, hand-rolled heuristics that both
6
+ // diverged from pi's real resolver:
7
+ // - `clients/skills-resolver.ts` (a `SKILL.md` one level down only, no
8
+ // recursion, no root `.md` acceptance, no symlink-follow, no dot/
9
+ // node_modules skip)
10
+ // - `scripts/install-selftest.mjs`'s `countSkillFiles` (recursed
11
+ // UNBOUNDED — would double-count a `SKILL.md` sitting inside another
12
+ // skill's own resource subdirectory — with no dot/node_modules skip, no
13
+ // root `.md` acceptance, no symlink handling)
14
+ // Neither matched `loadSkillsFromDirInternal` in
15
+ // `@earendil-works/pi-coding-agent` `dist/core/skills.js` (mirrored by
16
+ // `collectSkillEntries` in `dist/core/package-manager.js`, byte-identical
17
+ // walk, confirmed against the 0.85.1 tarball). This module is the fold: BOTH
18
+ // consumers now derive from `collectSkillEntryPaths` below.
19
+ //
20
+ // Discovery rules, replicated from `loadSkillsFromDirInternal` (verified
21
+ // against the extracted 0.85.1 `skills.js`):
22
+ // 1. If `dir` directly contains a (non-symlink-broken) `SKILL.md`, `dir` IS
23
+ // one skill root — return immediately, do NOT look at `dir`'s other
24
+ // entries or recurse into its subdirectories (a `SKILL.md` inside a
25
+ // skill's own reference/ subfolder is the skill's OWN resource, not a
26
+ // second skill).
27
+ // 2. Otherwise, skip dot-prefixed entries and `node_modules`; recurse into
28
+ // every remaining subdirectory (same two rules, one level down); at the
29
+ // OUTERMOST call only (`isRoot`), a loose `*.md` file directly in `dir`
30
+ // also counts (pi's `includeRootFiles`, true only for the top call).
31
+ // 3. A symlink is followed via `statSync` (broken symlink = skip) for BOTH
32
+ // the `SKILL.md` check and the directory/file classification in step 2.
33
+ //
34
+ // Deliberately NOT replicated (out of #2626's scope — packaging/path
35
+ // resolution, not skill-content validation):
36
+ // - `.gitignore`/`.ignore`/`.fdignore` matching inside the skills tree.
37
+ // - frontmatter parsing: pi additionally requires a non-empty `description`
38
+ // field before a discovered file becomes a loaded skill. A `SKILL.md`
39
+ // file that exists but fails THAT validation is a content-authoring
40
+ // defect a maintainer would see in normal use, not a silent zero.
41
+ // Both gaps mean this predicate can be a bit more OPTIMISTIC than pi's true
42
+ // count in a pathological case (an ignored or description-less file still
43
+ // counts as "found" here); it is never more PESSIMISTIC — every real skill
44
+ // pi loads is found by this walk too — which is the direction #2626 cares
45
+ // about (never claim "no skills" when pi would load one).
46
+
47
+ import * as fs from "node:fs";
48
+ import * as path from "node:path";
49
+
50
+ /**
51
+ * Symlink-aware entry classification, mirroring `loadSkillsFromDirInternal`'s
52
+ * own `entry.isSymbolicLink() ? statSync(fullPath) : entry`.
53
+ * @param {string} fullPath
54
+ * @param {import("node:fs").Dirent} entry
55
+ * @returns {"file" | "dir" | "other"}
56
+ */
57
+ export function resolvedEntryKind(fullPath, entry) {
58
+ if (entry.isSymbolicLink()) {
59
+ try {
60
+ const stats = fs.statSync(fullPath);
61
+ if (stats.isDirectory()) return "dir";
62
+ if (stats.isFile()) return "file";
63
+ return "other";
64
+ } catch {
65
+ return "other"; // broken symlink
66
+ }
67
+ }
68
+ if (entry.isDirectory()) return "dir";
69
+ if (entry.isFile()) return "file";
70
+ return "other";
71
+ }
72
+
73
+ /**
74
+ * The structural walk over an ALREADY-READ directory listing — split out
75
+ * from `collectSkillEntryPaths` so a caller that already has `entries` (a
76
+ * top-level ENOENT/EACCES distinction, say) is not forced into a second
77
+ * `readdirSync`.
78
+ * @param {string} dir
79
+ * @param {import("node:fs").Dirent[]} entries
80
+ * @param {boolean} isRoot
81
+ * @returns {string[]} absolute paths of every `SKILL.md` (or, at the root,
82
+ * loose `.md`) file pi's own resolver would treat as a skill entry point.
83
+ */
84
+ export function scanEntriesForSkills(dir, entries, isRoot) {
85
+ for (const entry of entries) {
86
+ if (
87
+ entry.name === "SKILL.md" &&
88
+ resolvedEntryKind(path.join(dir, entry.name), entry) === "file"
89
+ ) {
90
+ return [path.join(dir, entry.name)];
91
+ }
92
+ }
93
+ const found = [];
94
+ for (const entry of entries) {
95
+ if (entry.name.startsWith(".") || entry.name === "node_modules") {
96
+ continue;
97
+ }
98
+ const fullPath = path.join(dir, entry.name);
99
+ const kind = resolvedEntryKind(fullPath, entry);
100
+ if (kind === "dir") {
101
+ found.push(...collectSkillEntryPaths(fullPath, false));
102
+ continue;
103
+ }
104
+ if (isRoot && kind === "file" && entry.name.endsWith(".md")) {
105
+ found.push(fullPath);
106
+ }
107
+ }
108
+ return found;
109
+ }
110
+
111
+ /**
112
+ * Every skill entry point under `dir`, recursively, per pi's discovery
113
+ * rules. Fail-closed: a `readdirSync` error at `dir` (absent, unreadable) or
114
+ * any recursed-into subdirectory returns `[]` for that branch rather than
115
+ * throwing — matching `loadSkillsFromDirInternal`'s own bare `try { … }
116
+ * catch {}` per directory.
117
+ * @param {string} dir
118
+ * @param {boolean} [isRoot]
119
+ * @returns {string[]}
120
+ */
121
+ export function collectSkillEntryPaths(dir, isRoot = true) {
122
+ let entries;
123
+ try {
124
+ entries = fs.readdirSync(dir, { withFileTypes: true });
125
+ } catch {
126
+ return [];
127
+ }
128
+ return scanEntriesForSkills(dir, entries, isRoot);
129
+ }
@@ -61,7 +61,9 @@ function handle(line) {
61
61
  if (m.type === "response" && m.command === "get_commands") {
62
62
  const cmds = (m.data && m.data.commands) || [];
63
63
  const lens = cmds.filter(
64
- (c) => /^lens-/.test(c.name) || String(c.path || "").includes("pi-lens"),
64
+ (c) =>
65
+ String(c.name || "").startsWith("lens-") ||
66
+ String(c.path || "").includes("pi-lens"),
65
67
  );
66
68
  console.log(
67
69
  `total commands: ${cmds.length}; pi-lens commands: ${lens.map((c) => c.name).join(", ") || "(none)"}`,