@mrclrchtr/supi-lsp 11.0.1 → 11.2.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 (85) hide show
  1. package/README.md +43 -6
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +41 -1
  5. package/node_modules/ignore/README.md +36 -10
  6. package/node_modules/ignore/index.d.ts +3 -2
  7. package/node_modules/ignore/index.js +57 -53
  8. package/node_modules/ignore/legacy.js +50 -46
  9. package/node_modules/ignore/package.json +1 -1
  10. package/node_modules/semver/LICENSE +15 -0
  11. package/node_modules/semver/README.md +680 -0
  12. package/node_modules/semver/bin/semver.js +195 -0
  13. package/node_modules/semver/classes/comparator.js +143 -0
  14. package/node_modules/semver/classes/index.js +7 -0
  15. package/node_modules/semver/classes/range.js +577 -0
  16. package/node_modules/semver/classes/semver.js +350 -0
  17. package/node_modules/semver/functions/clean.js +8 -0
  18. package/node_modules/semver/functions/cmp.js +54 -0
  19. package/node_modules/semver/functions/coerce.js +62 -0
  20. package/node_modules/semver/functions/compare-build.js +9 -0
  21. package/node_modules/semver/functions/compare-loose.js +5 -0
  22. package/node_modules/semver/functions/compare.js +7 -0
  23. package/node_modules/semver/functions/diff.js +60 -0
  24. package/node_modules/semver/functions/eq.js +5 -0
  25. package/node_modules/semver/functions/gt.js +5 -0
  26. package/node_modules/semver/functions/gte.js +5 -0
  27. package/node_modules/semver/functions/inc.js +21 -0
  28. package/node_modules/semver/functions/lt.js +5 -0
  29. package/node_modules/semver/functions/lte.js +5 -0
  30. package/node_modules/semver/functions/major.js +5 -0
  31. package/node_modules/semver/functions/minor.js +5 -0
  32. package/node_modules/semver/functions/neq.js +5 -0
  33. package/node_modules/semver/functions/parse.js +18 -0
  34. package/node_modules/semver/functions/patch.js +5 -0
  35. package/node_modules/semver/functions/prerelease.js +8 -0
  36. package/node_modules/semver/functions/rcompare.js +5 -0
  37. package/node_modules/semver/functions/rsort.js +5 -0
  38. package/node_modules/semver/functions/satisfies.js +12 -0
  39. package/node_modules/semver/functions/sort.js +5 -0
  40. package/node_modules/semver/functions/truncate.js +48 -0
  41. package/node_modules/semver/functions/valid.js +8 -0
  42. package/node_modules/semver/index.js +93 -0
  43. package/node_modules/semver/internal/constants.js +37 -0
  44. package/node_modules/semver/internal/debug.js +11 -0
  45. package/node_modules/semver/internal/identifiers.js +29 -0
  46. package/node_modules/semver/internal/lrucache.js +42 -0
  47. package/node_modules/semver/internal/parse-options.js +17 -0
  48. package/node_modules/semver/internal/re.js +223 -0
  49. package/node_modules/semver/package.json +78 -0
  50. package/node_modules/semver/preload.js +4 -0
  51. package/node_modules/semver/range.bnf +17 -0
  52. package/node_modules/semver/ranges/gtr.js +6 -0
  53. package/node_modules/semver/ranges/intersects.js +9 -0
  54. package/node_modules/semver/ranges/ltr.js +6 -0
  55. package/node_modules/semver/ranges/max-satisfying.js +27 -0
  56. package/node_modules/semver/ranges/min-satisfying.js +26 -0
  57. package/node_modules/semver/ranges/min-version.js +63 -0
  58. package/node_modules/semver/ranges/outside.js +82 -0
  59. package/node_modules/semver/ranges/simplify.js +49 -0
  60. package/node_modules/semver/ranges/subset.js +249 -0
  61. package/node_modules/semver/ranges/to-comparators.js +10 -0
  62. package/node_modules/semver/ranges/valid.js +13 -0
  63. package/node_modules/vscode-jsonrpc/README.md +5 -0
  64. package/node_modules/vscode-jsonrpc/lib/browser/ril.js +8 -0
  65. package/node_modules/vscode-jsonrpc/lib/common/connection.js +7 -2
  66. package/node_modules/vscode-jsonrpc/lib/common/ral.d.ts +8 -0
  67. package/node_modules/vscode-jsonrpc/lib/node/main.d.ts +14 -0
  68. package/node_modules/vscode-jsonrpc/lib/node/main.js +6 -4
  69. package/node_modules/vscode-jsonrpc/lib/node/ril.js +5 -1
  70. package/node_modules/vscode-jsonrpc/package.json +4 -2
  71. package/node_modules/vscode-languageserver-protocol/package.json +4 -3
  72. package/node_modules/vscode-languageserver-types/lib/esm/main.js +2 -2
  73. package/node_modules/vscode-languageserver-types/lib/umd/main.js +2 -2
  74. package/node_modules/vscode-languageserver-types/package.json +2 -1
  75. package/package.json +8 -6
  76. package/src/client/client.ts +44 -5
  77. package/src/config/config.ts +53 -2
  78. package/src/config/operation-support.ts +19 -4
  79. package/src/config/server-config.ts +22 -0
  80. package/src/config/typescript-launch.ts +276 -0
  81. package/src/manager/manager-project-info.ts +22 -10
  82. package/src/manager/manager.ts +53 -20
  83. package/src/provider/refactor-planning.ts +4 -3
  84. package/src/session/runtime-controller.ts +2 -2
  85. package/src/session/scanner.ts +27 -15
package/README.md CHANGED
@@ -20,7 +20,7 @@ npm install @mrclrchtr/supi-lsp
20
20
 
21
21
  ## Language-server support
22
22
 
23
- SuPi provides 13 built-in server definitions. All are enabled unless `lsp.servers.<key>.enabled` is `false`. Install the language servers yourself; SuPi does not download or install them. Built-in commands must be on `PATH`; a configured absolute command path is also supported. Code Intelligence starts the runtime only for trusted projects.
23
+ SuPi provides 13 built-in server definitions. All are enabled unless `lsp.servers.<key>.enabled` is `false`. Install the language servers yourself; SuPi does not download or install them. Built-in commands must be on `PATH`, except project-local native TypeScript; a configured absolute command path is also supported. Code Intelligence starts the runtime only for trusted projects.
24
24
 
25
25
  ### Built-in route matrix
26
26
 
@@ -28,7 +28,7 @@ This matrix describes the default routing configuration, not a live test or a gu
28
28
 
29
29
  | Language / config key | Default command and arguments | File extensions | Root markers for automatic discovery |
30
30
  |---|---|---|---|
31
- | TypeScript / JavaScript — `typescript` | `typescript-language-server --stdio` | `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, `.mjs`, `.cjs` | `tsconfig.json`, `jsconfig.json`, `package.json` |
31
+ | TypeScript / JavaScript — `typescript` | Project-local native `tsc --lsp --stdio`, otherwise `typescript-language-server --stdio` | `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, `.mjs`, `.cjs` | `tsconfig.json`, `jsconfig.json`, `package.json` |
32
32
  | Python — `python` | `pyright-langserver --stdio` | `.py`, `.pyi` | `pyproject.toml`, `setup.py`, `setup.cfg`, `requirements.txt`, `pyrightconfig.json` |
33
33
  | Rust — `rust` | `rust-analyzer` | `.rs` | `Cargo.toml` |
34
34
  | Go — `go` | `gopls serve` | `.go`, `.mod` | `go.mod`, `go.sum` |
@@ -59,7 +59,7 @@ A configured route is not a ready server. Features depend on the active server,
59
59
  | Hover, definitions, references, implementations, document/workspace symbols | Supported through the server's corresponding LSP methods; availability and coverage differ by server. |
60
60
  | Diagnostics | Native pull, the eligible TypeScript request adapter, or ambient push. Push observation alone cannot confirm that a file is clean. See the diagnostic policy below. |
61
61
  | `rename_symbol` | Requires server rename support and validated text edits; uses `prepareRename` when advertised. |
62
- | `update_imports` / `delete_dead_code` | Requires matching `source.organizeImports` / `source.removeUnused` code actions that produce validated text edits. |
62
+ | `update_imports` / `delete_dead_code` | Requires matching `source.organizeImports` / (`source.removeUnused` or `source.removeUnusedImports`) code actions that produce validated text edits. Imports-only advertisement reports limited support, not arbitrary unused-declaration removal. |
63
63
  | `extract_function` / `extract_variable` | Unavailable for all languages: standard LSP code actions do not provide a safe exact extraction-name contract. |
64
64
 
65
65
  The [operation-support model](src/config/operation-support.ts) separates server advertisement from SuPi planning limits. A server can advertise an action that SuPi cannot safely turn into a plan.
@@ -72,6 +72,40 @@ Pyright remains the default Python server, but the audited server lacks implemen
72
72
 
73
73
  JDTLS acceptance uses a compatible pinned JDK and isolated writable `-data` and `-configuration` directories. Keep test workspaces separate from personal JDTLS state. The [capability baseline](../../docs/conventions/code-language-baseline.md) defines the operation and evidence requirements.
74
74
 
75
+ ### TypeScript compiler selection
76
+
77
+ The default TypeScript route prefers a project-local stable native TS7 server. Native launches use the selected package's declared `tsc` Node launcher with `--lsp --stdio`; neither TLS nor a global native executable is required. Legacy compiler launches still require `typescript-language-server` (TLS) on PATH. Nothing is installed automatically.
78
+
79
+ | Configuration | Launch policy |
80
+ |---|---|
81
+ | No explicit launch fields | Prefer stable native TS7 as `typescript`, then as `@typescript/native`. Otherwise use legacy `typescript`, or—only if absent—`@typescript/typescript6`. If all are absent, retain TLS's existing lookup. |
82
+ | `lsp.servers.typescript.typescriptPackage` | Resolve exactly this installed package name/alias. No arbitrary alias scan, package subpath, URL, or filesystem-path selector. |
83
+ | Explicit `command` **or** `args` | Manual launch, including inherited values equal to defaults. No automatic compiler selection or initialization-option injection. |
84
+ | Explicit `initializationOptions.tsserver.path` | Use the configured path without automatic compiler selection. |
85
+ | Package selector plus any explicit launch field above | Configuration conflict; the failure identifies fields and their configuration origins when available. Remove one side and reload. |
86
+
87
+ For example, select a compiler installed as `my-ts6` without changing the TLS command:
88
+
89
+ ```json
90
+ { "lsp": { "servers": { "typescript": { "typescriptPackage": "my-ts6" } } } }
91
+ ```
92
+
93
+ - Resolve packages using the route root's normal Node package search paths, including hoisted and symlinked packages. A package whose actual identity is `@typescript/typescript6`, including one installed as `typescript` or another alias, resolves `@typescript/old` from the wrapper's dependency graph. Pass the underlying implementation's actual `lib/tsserver.js` through `initializationOptions.tsserver.path`; preserve other initialization options.
94
+ - Native-first precedence deliberately replaces canonical-package-first selection: a native `@typescript/native` alias wins over legacy `typescript`. This alias must resolve to the stable native `typescript` package, not legacy TS or the old preview package. Other aliases require `typescriptPackage`.
95
+ - A consulted broken symlink, malformed manifest, missing compiler version, unsupported identity/major, or prerelease is an error, not absence. Stable TS7 releases are eligible without a patch allowlist; unknown native majors and preview/nightly packages require manual launch. Do not inspect lower-priority packages after a native winner. Only the winner's launch files are validated, so a legacy loser's missing `tsserver.js` does not block a healthy native alias.
96
+ - Never fall back after resolution or startup failure of the selected package. Manual launches receive no automatic initialization-option injection.
97
+ - Discovery, missing-server warnings, lazy starts, and recovery use the same policy. Every actual start/retry resolves fresh package files using the active settings snapshot. Installing or repairing a compiler can be followed by explicit health refresh; changing settings requires reload/restart. Healthy clients do not hot-swap.
98
+ - `code_health` and `/supi-ci-status` show the `native` or `tsserver` backend and resolved compiler version when known, separately from wrapper version. Manual/legacy-fallback launches have no inferred compiler identity. Missing versions remain unknown. Selection failures identify the package/root and a corrective action.
99
+ - Topmost-root collapsing remains unchanged. Mixed nested backends, Windows live qualification, dependency watchers, performance benchmarking, and full TLS/native feature parity are outside this change. Existing legacy TLS support is not deliberately removed.
100
+
101
+ Focused qualification pins native TypeScript **7.0.2**, TLS **6.0.2**, ordinary TypeScript **6.0.3**, and `@typescript/typescript6` **6.0.2** backed by `@typescript/old` → TypeScript **6.0.3**. The wrapper's dependency range does not guarantee that compiler version; the lockfile and [prerequisite pins](../../scripts/language-baseline/pins.json) fix these combinations. SuPi retains TS6 for its compiler-API dependency and legacy-server tests. Workspace typechecking and declaration builds use the native TS7 launcher directly to avoid ambiguity between the installed `tsc` binaries.
102
+
103
+ The required [Linux/macOS CI matrix](../../.github/workflows/ci.yml) keeps [selection/layout tests](__tests__/unit/typescript-launch.test.ts), [real native lifecycle tests](__tests__/integration/workspace-runtime.integration.typescript-native.test.ts), and [wrapper lifecycle tests](__tests__/integration/workspace-runtime.integration.typescript-launch.test.ts) separate from the [public TS/JS semantic matrix](../supi-code-intelligence/__tests__/acceptance/language-baseline-harness.ts). Required missing/unavailable behavior fails rather than skips. The matrix covers hover, definitions, references, meaningful implementations, document/workspace symbols, confirmed error → clean diagnostics, refresh, cross-file invalidation, Unicode-safe rename plan/apply and stale-plan rejection, and precise organize-imports. The TypeScript public matrix also checks unused-import removal, unchanged source during planning, stale-plan rejection, and preservation of unrelated text; native support is imports-only. Native TSX/JSX and module suffixes receive routing/synchronization checks rather than duplicate matrices. Existing stale-result and edit-safety suites remain in place.
104
+
105
+ [Bounded config comparisons](__tests__/integration/typescript-config-coverage.test.ts) compare TS6 and native TS7 `--showConfig` root-file coverage for TS/JS defaults, include/exclude, `allowJs`/`checkJs`, inherited patterns (including late-created files), and separate referenced projects. These cases found no false exclusion; filtering is unchanged. **Config coverage state is not TypeScript program membership**: imports may bring excluded files into a program, and references do not merge root-file lists.
106
+
107
+ Native `delete_dead_code` consumes `source.removeUnusedImports` through the same safe plan/apply workflow ([#554](https://github.com/mrclrchtr/supi/issues/554)). This mapping removes unused imports only: operation support reports `server: advertised`, `supi: limited`, with an imports-only reason. It does not qualify arbitrary unused-declaration removal. Extraction remains unavailable ([#467](https://github.com/mrclrchtr/supi/issues/467)); no TLS/native feature-parity claim is made. Only the pinned combinations are tested, not every stable TS7 release. Focused qualification passed on macOS arm64 (Node 24.22.1) and Linux arm64 (Debian container, Node 24.22.0), including the native/TS6 public TS/JS matrix. The unused-import follow-up has local macOS arm64 verification only; its Linux verification remains pending. The pinned Linux/macOS CI jobs remain pending until they run; this is not a qualification claim for the complete multi-language baseline.
108
+
75
109
  ### Java startup and project JDKs
76
110
 
77
111
  The JDK that starts JDTLS can differ from the JDK that builds your project. Set a compatible server runtime through the Java route without changing the shell or project compiler:
@@ -153,7 +187,7 @@ Caller cancellation or deadline stops only that caller's wait. Explicit diagnost
153
187
 
154
188
  If another request first opens a document during an active semantic or diagnostic request, the client can synchronize and repeat the request once. The retry retains the caller's original signal and deadline. Content, workspace, close, and lifecycle changes still reject stale results. A diagnostic retry collects new request evidence; it does not promote an old cache. This retry is not route recovery. A second enrollment during the retry still rejects the result. Semantic failure reasons state whether the retry failed or was exhausted. The bounded `request.retry` debug event records the retry reason and outcome with the public call's opaque operation ID, when supplied. A completed RPC alone does not establish accepted semantic evidence.
155
189
 
156
- A read-only semantic request can also retry once after an LSP `ContentModified` error (`-32801`). This retry shares the same one-retry budget as enrollment; it does not add another try. The client keeps the original request parameters and checks changes from the first failed input revision. It runs the full input sync before retry and checks current inputs before it accepts a result. An enrollment is still the only input change that can permit a retry. A content, workspace, close, lifecycle, or retirement change blocks it. Client replacement, caller cancellation, and a deadline also block retry. The post-request check rejects a result if inputs change while that attempt runs. This error-code retry applies only to read-only semantic requests. Diagnostics, rename, code actions, and refactors keep the default policy and do not retry `ContentModified`. See the [LSP 3.17 message-ordering rule](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#messageOrdering), the [retry guard](src/client/client-request-enrollment.ts), and the [client query path](src/client/client.ts).
190
+ A read-only semantic request can also retry once after an LSP `ContentModified` error (`-32801`). This retry shares the same one-retry budget as enrollment; it does not add another try. Before this protocol retry, the client waits 100 ms so an immediate retry does not race the same server analysis. The wait is cancellable, respects the original absolute deadline, and does not delay enrollment-only retries. The client keeps the original request parameters and checks changes from the first failed input revision. It runs the full input sync before retry and checks current inputs before it accepts a result. An enrollment is still the only input change that can permit a retry. A content, workspace, close, lifecycle, or retirement change blocks it. Client replacement, caller cancellation, and a deadline also block retry. The post-request check rejects a result if inputs change while that attempt runs. This error-code retry applies only to read-only semantic requests. Diagnostics, rename, code actions, and refactors keep the default policy and do not retry `ContentModified`. See the [LSP 3.17 message-ordering rule](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#messageOrdering), the [retry guard](src/client/client-request-enrollment.ts), and the [client query path](src/client/client.ts).
157
191
 
158
192
  Diagnostic collection does not close and reopen a document to obtain confirmation. It does not send a no-op `didChange` for that purpose. Refresh retains unchanged documents and their current server state, and resynchronizes changed or invalidated documents. A client restart may reopen tracked documents to restore client state; that is recovery, not diagnostic confirmation.
159
193
 
@@ -253,8 +287,11 @@ Disable one language with `lsp.servers.<language>.enabled: false`. Use `/supi-ci
253
287
  - negotiated operation support in project-server status
254
288
 
255
289
  Operation-specific planners request `source.organizeImports` for import updates and
256
- `source.removeUnused` for dead-code removal. They match the returned `CodeAction.kind`,
257
- not its title. Edit-only actions are validated before planning. Command actions,
290
+ both `source.removeUnused` and `source.removeUnusedImports` for dead-code removal.
291
+ These distinct kinds are alternatives, not parent/child kinds; either exact kind
292
+ or its dotted descendants can match the returned `CodeAction.kind`, never its title.
293
+ An imports-only advertisement reports a SuPi limitation rather than general
294
+ unused-declaration support. Edit-only actions are validated before planning. Command actions,
258
295
  resource operations, snippets, annotations, malformed actions, and stale responses
259
296
  stay unavailable. Distinct matching plans return an ambiguous result instead of
260
297
  selecting the first action. Rename uses `prepareRename` when the server advertises
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-code-runtime",
3
- "version": "11.0.1",
3
+ "version": "11.2.0",
4
4
  "description": "Shared workspace context and capability contracts for code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "11.0.1",
3
+ "version": "11.2.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,6 +14,8 @@ export interface DebugRegistryConfig {
14
14
  enabled: boolean;
15
15
  /** What the agent-callable debug tool may return. */
16
16
  agentAccess: DebugAgentAccess;
17
+ /** Whether Agent Run retrospective capture is explicitly enabled. */
18
+ agentRunRetrospectivesEnabled: boolean;
17
19
  /** Maximum number of session-local events to keep in memory. */
18
20
  maxEvents: number;
19
21
  }
@@ -21,6 +23,7 @@ export interface DebugRegistryConfig {
21
23
  export const DEBUG_REGISTRY_DEFAULTS: DebugRegistryConfig = {
22
24
  enabled: false,
23
25
  agentAccess: "sanitized",
26
+ agentRunRetrospectivesEnabled: false,
24
27
  maxEvents: 100,
25
28
  };
26
29
 
@@ -75,6 +78,9 @@ export interface DebugEventQueryResult {
75
78
  /** Receives a sanitized event whenever the registry records one. */
76
79
  export type DebugEventListener = (event: DebugEventView) => void;
77
80
 
81
+ /** Receives sanitized events for persistence; separate from ordinary event subscriptions. */
82
+ export type DebugPersistenceSink = (event: DebugEventView) => void;
83
+
78
84
  export interface DebugSummary {
79
85
  total: number;
80
86
  byLevel: Partial<Record<DebugLevel, number>>;
@@ -85,6 +91,7 @@ interface DebugRegistryState {
85
91
  config: DebugRegistryConfig;
86
92
  events: DebugEvent[];
87
93
  listeners: Set<DebugEventListener>;
94
+ persistenceSinks: Set<DebugPersistenceSink>;
88
95
  nextId: number;
89
96
  }
90
97
 
@@ -111,13 +118,15 @@ function getState(): DebugRegistryState {
111
118
  config: cloneConfig(DEBUG_REGISTRY_DEFAULTS),
112
119
  events: [],
113
120
  listeners: new Set(),
121
+ persistenceSinks: new Set(),
114
122
  nextId: 1,
115
123
  };
116
124
  (globalThis as Record<symbol, unknown>)[REGISTRY_KEY] = state;
117
125
  }
118
126
  // Keep the shared registry compatible with extension reloads that reuse an
119
- // instance created before listeners existed.
127
+ // instance created before listener sets existed.
120
128
  if (!state.listeners) state.listeners = new Set();
129
+ if (!state.persistenceSinks) state.persistenceSinks = new Set();
121
130
  return state;
122
131
  }
123
132
 
@@ -200,6 +209,19 @@ export function isDebugRegistryEnabled(): boolean {
200
209
  return getState().config.enabled;
201
210
  }
202
211
 
212
+ /**
213
+ * Return whether a retrospective can be captured: Debug and its explicit opt-in
214
+ * must be enabled, and at least one persistence sink must be registered.
215
+ */
216
+ export function canRecordAgentRunRetrospective(): boolean {
217
+ const state = getState();
218
+ return (
219
+ state.config.enabled &&
220
+ state.config.agentRunRetrospectivesEnabled &&
221
+ state.persistenceSinks.size > 0
222
+ );
223
+ }
224
+
203
225
  /** Best-effort redaction helper for data exposed through sanitized debug views. */
204
226
  export function redactDebugData<T>(value: T): T {
205
227
  return redactValue(value, 8) as T;
@@ -226,6 +248,16 @@ export function subscribeDebugEvents(listener: DebugEventListener): () => void {
226
248
  return () => state.listeners.delete(listener);
227
249
  }
228
250
 
251
+ /**
252
+ * Register a sanitized event persistence sink. Sinks are tracked separately from
253
+ * ordinary listeners so eligibility checks only succeed when logging is active.
254
+ */
255
+ export function subscribeDebugPersistence(sink: DebugPersistenceSink): () => void {
256
+ const state = getState();
257
+ state.persistenceSinks.add(sink);
258
+ return () => state.persistenceSinks.delete(sink);
259
+ }
260
+
229
261
  /** Record a session-local debug event if debugging is enabled. */
230
262
  export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
231
263
  const state = getState();
@@ -251,6 +283,13 @@ export function recordDebugEvent(input: DebugEventInput): DebugEvent | null {
251
283
  // Debug-event consumers must not alter producer behavior.
252
284
  }
253
285
  }
286
+ for (const sink of state.persistenceSinks) {
287
+ try {
288
+ sink(toSanitizedView(event));
289
+ } catch {
290
+ // Persistence failures must not alter producer behavior.
291
+ }
292
+ }
254
293
  return {
255
294
  ...event,
256
295
  data: event.data === undefined ? undefined : redactDebugData(event.data),
@@ -309,5 +348,6 @@ export function resetDebugRegistry(): void {
309
348
  state.config = cloneConfig(DEBUG_REGISTRY_DEFAULTS);
310
349
  state.events = [];
311
350
  state.listeners.clear();
351
+ state.persistenceSinks.clear();
312
352
  state.nextId = 1;
313
353
  }
@@ -282,6 +282,12 @@ Returns `function(path)` the filter function.
282
282
 
283
283
  > New in 5.0.0
284
284
 
285
+ **This method is NOT meant for ordinary users or everyday use.** If all you need is to tell whether a `pathname` is ignored, this is not the method you are looking for.
286
+
287
+ `.test()` is meant for packages that build upon `ignore`. Besides whether a `pathname` is ignored, it tells whether a negative pattern unignored it, which such a package can use to control its own caching, such as what to cache and when a cached result can be reused.
288
+
289
+ If you only need to know whether a `pathname` should be ignored, use `.ignores()` instead. It returns that answer as a boolean, and always agrees with `.test(pathname).ignored`.
290
+
285
291
  Returns `TestResult`
286
292
 
287
293
  ```ts
@@ -291,7 +297,8 @@ interface TestResult {
291
297
  // true if a negative pattern matched the `pathname` itself, and no parent
292
298
  // directory of it is ignored -- see "When both are false" below
293
299
  unignored: boolean
294
- // The `IgnoreRule` which ignores the pathname
300
+ // The `IgnoreRule` which ignores the pathname: the last rule to match, the
301
+ // one `git check-ignore -v` reports
295
302
  rule?: IgnoreRule
296
303
  }
297
304
 
@@ -339,13 +346,15 @@ To tell the two situations apart, test the parent directories of the `pathname`,
339
346
  ['foobar/'].some(dir => ig2.test(dir).unignored) // true
340
347
  ```
341
348
 
342
- `unignored` is meant for packages that implement their own caching mechanism upon `node-ignore`. To simply tell whether a `pathname` is ignored, use `.ignores()` or check `TestResult::ignored`.
343
-
344
349
  ## .checkIgnore(target: string): TestResult
345
350
 
346
351
  > new in 7.0.0
347
352
 
348
- Debugs gitignore / exclude files, which is equivalent to `git check-ignore -v`. Usually this method is used for other packages to implement the function of `git check-ignore -v` upon `node-ignore`
353
+ Debugs gitignore / exclude files the way `git check-ignore -v` does. Usually this method is used for other packages to implement the function of `git check-ignore -v` upon `node-ignore`. Apart from the [known differences](#known-differences-from-git):
354
+
355
+ - `ignored` is what `git check-ignore` answers for `target`.
356
+ - When `ignored` is `true`, `rule` is the rule `git check-ignore -v` reports. Its `pattern` is the pattern as it was added, so trailing spaces that git trims are still there.
357
+ - When the last pattern to match is a negative one, `git check-ignore -v` reports it, while `.checkIgnore()` returns `unignored: true` and no `rule`. For a directory passed with a trailing slash, `unignored` is also `true` when a negative pattern re-includes the directory itself, which `git check-ignore -v` does not report.
349
358
 
350
359
  - **target** `string` the target to test.
351
360
 
@@ -471,13 +480,30 @@ not in `git`. For ASCII paths and patterns there is no difference.
471
480
  code runs against a real repository, pass the value of the repository's
472
481
  `core.ignorecase` explicitly.
473
482
 
474
- ### `***` and friends follow the documentation, not the binary
483
+ ### A run of asterisks
484
+
485
+ `git` reads a run of two or more asterisks as a globstar whenever the run
486
+ fills a path segment: it starts the pattern or follows a `/`, and it ends
487
+ the pattern or comes before a `/`. `ignore` does the same, so `***/foo`,
488
+ `a/***` and `a/***/b` behave exactly as `**/foo`, `a/**` and `a/**/b`.
489
+
490
+ `git` takes two more cases as a globstar, and `ignore` does not:
491
+
492
+ - **A run right after a literal prefix**, as in `a**/b` or `a***/b`. Before
493
+ matching, `git` strips the part of a pattern that holds no wildcard, and
494
+ the run then starts what is left. gitignore(5) lists the positions where
495
+ `**` is special and calls every other run "regular asterisks", which is
496
+ what `ignore` does here.
497
+ - **A run next to an escaped slash**, as in `**\/b` or `x\/**/b`.
498
+
499
+ ```js
500
+ ignore().add('a**/b').ignores('axy/z/b') // false; git: true
501
+ ignore().add('**\\/b').ignores('x/y/b') // false; git: true
502
+ ignore().add('x\\/**/b').ignores('x/y/z/b') // false; git: true
503
+ ```
475
504
 
476
- gitignore(5) says a run of more than two asterisks is "considered regular
477
- asterisks". The `git` binary, however, strips the literal prefix of a
478
- pattern before matching, which makes patterns like `***/foo`, `a/***` and
479
- `a**/b` behave as `**` globstars there. `ignore` follows the documented
480
- behavior. If you need the globstar, write `**`.
505
+ To get a globstar in either case, give the run a segment of its own, such as
506
+ `a*/**/b`.
481
507
 
482
508
  ### `checkIgnore()` and a directory passed with a trailing slash
483
509
 
@@ -50,8 +50,9 @@ declare namespace ignore {
50
50
  */
51
51
  unignored: boolean
52
52
  /**
53
- * The rule that ignores the pathname. Present exactly when `ignored` is
54
- * true.
53
+ * The rule that ignores the pathname, or the parent directory it is
54
+ * ignored with: the last rule to match, which is the rule
55
+ * `git check-ignore -v` reports. Present exactly when `ignored` is true.
55
56
  */
56
57
  rule?: IgnoreRule
57
58
  }
@@ -15,6 +15,8 @@ const ESCAPE = '\\'
15
15
  const REGEX_LITERAL_SPECIAL = /[.*+?()[\]{}^$|\\/]/
16
16
  const BOM = '\uFEFF'
17
17
  const REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/
18
+ // A run of two or more asterisks is a globstar (#175)
19
+ const REGEX_ANCHORED_GLOBSTAR = /^\/\*{2,}$/
18
20
  const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/
19
21
  const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/
20
22
  const REGEX_SPLITALL_CRLF = /\r?\n/g
@@ -423,6 +425,21 @@ const REPLACERS = [
423
425
  ESCAPE + SPACE
424
426
  ],
425
427
 
428
+ // git's wildmatch() reads a run of two or more asterisks as one globstar
429
+ // whenever the run fills a path segment: it starts the pattern or follows
430
+ // a `/`, and it ends the pattern or comes before a `/`. A run of two is
431
+ // already `**`; a longer one is folded into `**` here, so the steps
432
+ // below only ever have to know `**`.
433
+ // `***/foo` -> `**/foo`, `a/***/b` -> `a/**/b`, `a/***` -> `a/**`
434
+ // git also takes a run beside a literal prefix (`a***/b`) or beside an
435
+ // escaped slash (`***\/b`) as a globstar, and this does not; the README
436
+ // lists both, under "A run of asterisks".
437
+ [
438
+ /(^|\/)\*{3,}(?=\/|$)/g,
439
+ (_, start) => `${start}**`,
440
+ '*'
441
+ ],
442
+
426
443
  // Escape metacharacters
427
444
  // which is written down by users but means special for regular expressions.
428
445
 
@@ -853,6 +870,15 @@ const pinWildcards = source => {
853
870
 
854
871
  // @param {pattern}
855
872
  const makeRegexPrefix = pattern => {
873
+ // `/**` matches every path, as `**` does. The translation below only finds
874
+ // a globstar beside a slash it keeps, and the leading one is turned into
875
+ // an anchor, so `/**` would come out as `/*` and match one level only.
876
+ // Here rather than in `createRule`, so that `checkSourceOf`, which
877
+ // compiles `/**/` again without its trailing slash, gets it too.
878
+ if (REGEX_ANCHORED_GLOBSTAR.test(pattern)) {
879
+ pattern = '**'
880
+ }
881
+
856
882
  const {source, sources} = extractBrackets(pattern)
857
883
 
858
884
  const replaced = REPLACERS.reduce(
@@ -1271,17 +1297,10 @@ class RuleManager {
1271
1297
 
1272
1298
  // Test one single path without recursively checking parent directories
1273
1299
  //
1274
- // - checkUnignored `boolean` whether should check if the path is unignored,
1275
- // setting `checkUnignored` to `false` could reduce additional
1276
- // path matching.
1277
- // - check `string` either `MODE_IGNORE` or `MODE_CHECK_IGNORE`
1278
-
1279
- // @returns {TestResult} true if a file is ignored
1280
- test (path, checkUnignored, mode) {
1281
- let ignored = false
1282
- let unignored = false
1283
- let matchedRule
1300
+ // - mode `string` either `MODE_IGNORE` or `MODE_CHECK_IGNORE`
1284
1301
 
1302
+ // @returns {TestResult}
1303
+ test (path, mode) {
1285
1304
  // Most of a .gitignore is patterns with no slash in them, and running
1286
1305
  // those against the whole path makes the regular expression engine walk
1287
1306
  // every directory name on the way to the only segment that could match.
@@ -1296,52 +1315,40 @@ class RuleManager {
1296
1315
  ? basenameOf(path)
1297
1316
  : path
1298
1317
 
1318
+ // The last rule to match decides, as in git, and it is the rule
1319
+ // `git check-ignore -v` reports, so the scan runs from the end and stops
1320
+ // at the first match.
1299
1321
  // A plain loop rather than `forEach`, so that `path`, `basename` and
1300
1322
  // `shortcut` are locals. As a callback they became closure variables,
1301
1323
  // and reaching for one of those once per rule cost 10% of a scan over a
1302
1324
  // large rule set -- more than the shortcut they were there to serve.
1303
- for (let index = 0; index < length; index ++) {
1325
+ for (let index = length - 1; index >= 0; index --) {
1304
1326
  const rule = rules[index]
1305
- const {negative} = rule
1306
-
1307
- // | ignored : unignored
1308
- // -------- | ---------------------------------------
1309
- // negative | 0:0 | 0:1 | 1:0 | 1:1
1310
- // -------- | ------- | ------- | ------- | --------
1311
- // 0 | TEST | TEST | SKIP | X
1312
- // 1 | TESTIF | SKIP | TEST | X
1313
-
1314
- // - SKIP: always skip
1315
- // - TEST: always test
1316
- // - TESTIF: only test if checkUnignored
1317
- // - X: that never happen
1318
- const skip = unignored === negative && ignored !== unignored
1319
- || negative && !ignored && !unignored && !checkUnignored
1320
-
1321
- if (!skip && rule[mode].test(
1327
+
1328
+ if (rule[mode].test(
1322
1329
  shortcut && rule._basenameOnly
1323
1330
  ? basename
1324
1331
  : path
1325
1332
  )) {
1326
- ignored = !negative
1327
- unignored = negative
1333
+ const {negative} = rule
1328
1334
 
1329
- matchedRule = negative
1330
- ? UNDEFINED
1331
- : rule
1332
- }
1333
- }
1335
+ const ret = {
1336
+ ignored: !negative,
1337
+ unignored: negative
1338
+ }
1334
1339
 
1335
- const ret = {
1336
- ignored,
1337
- unignored
1338
- }
1340
+ if (!negative) {
1341
+ ret.rule = rule
1342
+ }
1339
1343
 
1340
- if (matchedRule) {
1341
- ret.rule = matchedRule
1344
+ return ret
1345
+ }
1342
1346
  }
1343
1347
 
1344
- return ret
1348
+ return {
1349
+ ignored: false,
1350
+ unignored: false
1351
+ }
1345
1352
  }
1346
1353
  }
1347
1354
 
@@ -1457,7 +1464,7 @@ class Ignore {
1457
1464
  }
1458
1465
 
1459
1466
  // @returns {TestResult}
1460
- _test (originalPath, cache, checkUnignored) {
1467
+ _test (originalPath, cache) {
1461
1468
  const path = originalPath
1462
1469
  // Supports nullable path
1463
1470
  && checkPath.convert(originalPath)
@@ -1470,7 +1477,7 @@ class Ignore {
1470
1477
  : RETURN_FALSE
1471
1478
  )
1472
1479
 
1473
- return this._t(path, cache, checkUnignored)
1480
+ return this._t(path, cache)
1474
1481
  }
1475
1482
 
1476
1483
  checkIgnore (path) {
@@ -1483,7 +1490,7 @@ class Ignore {
1483
1490
  // Like `git check-ignore`: a directory excluded by itself or by an
1484
1491
  // ancestor settles it, and only then is the literal 'abc/' matched
1485
1492
  // against the rules (see `checkSourceOf`).
1486
- const dir = this._t(path, this._testCache, true)
1493
+ const dir = this._t(path, this._testCache)
1487
1494
 
1488
1495
  if (dir.ignored) {
1489
1496
  return dir
@@ -1501,10 +1508,7 @@ class Ignore {
1501
1508
  path,
1502
1509
 
1503
1510
  // The cache for the result of a certain checking
1504
- cache,
1505
-
1506
- // Whether should check if the path is unignored
1507
- checkUnignored
1511
+ cache
1508
1512
  ) {
1509
1513
  if (path in cache) {
1510
1514
  return cache[path]
@@ -1514,18 +1518,18 @@ class Ignore {
1514
1518
 
1515
1519
  // If the path contains a parent directory, check the parent first
1516
1520
  const parent = parentPath
1517
- ? this._t(parentPath, cache, checkUnignored)
1521
+ ? this._t(parentPath, cache)
1518
1522
  : UNDEFINED
1519
1523
 
1520
1524
  return cache[path] = parent && parent.ignored
1521
1525
  // > It is not possible to re-include a file if a parent directory of
1522
1526
  // > that file is excluded.
1523
1527
  ? parent
1524
- : this._rules.test(path, checkUnignored, MODE_IGNORE)
1528
+ : this._rules.test(path, MODE_IGNORE)
1525
1529
  }
1526
1530
 
1527
1531
  ignores (path) {
1528
- return this._test(path, this._ignoreCache, false).ignored
1532
+ return this._test(path, this._ignoreCache).ignored
1529
1533
  }
1530
1534
 
1531
1535
  createFilter () {
@@ -1538,7 +1542,7 @@ class Ignore {
1538
1542
 
1539
1543
  // @returns {TestResult}
1540
1544
  test (path) {
1541
- return this._test(path, this._testCache, true)
1545
+ return this._test(path, this._testCache)
1542
1546
  }
1543
1547
  }
1544
1548