pi-lens 4.1.6 → 4.2.1

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 (125) hide show
  1. package/CHANGELOG.md +284 -0
  2. package/config/dependency-cruiser-eager-allowlist.json +3 -0
  3. package/dist/clients/actionable-warnings.js +51 -13
  4. package/dist/clients/agent-behavior-client.js +2 -0
  5. package/dist/clients/bash-file-access.js +26 -0
  6. package/dist/clients/biome-client.js +1 -1
  7. package/dist/clients/blocker-freshness.js +403 -35
  8. package/dist/clients/bootstrap.js +6 -1
  9. package/dist/clients/cascade-format.js +224 -4
  10. package/dist/clients/code-quality-warnings.js +10 -2
  11. package/dist/clients/degradation-ledger.js +16 -1
  12. package/dist/clients/diagnostic-dispositions.js +12 -1
  13. package/dist/clients/dispatch/ast-grep-catalog.js +65 -0
  14. package/dist/clients/dispatch/auxiliary-lsp.js +28 -0
  15. package/dist/clients/dispatch/dispatcher.js +9 -2
  16. package/dist/clients/dispatch/finding-policy.js +222 -0
  17. package/dist/clients/dispatch/indent-detect.js +389 -13
  18. package/dist/clients/dispatch/integration.js +175 -25
  19. package/dist/clients/dispatch/rule-ignores.js +62 -0
  20. package/dist/clients/dispatch/runners/ast-grep-napi.js +21 -41
  21. package/dist/clients/dispatch/runners/biome-check.js +6 -1
  22. package/dist/clients/dispatch/runners/rust-clippy.js +7 -7
  23. package/dist/clients/dispatch/runners/shfmt.js +2 -12
  24. package/dist/clients/dispatch/runners/tree-sitter.js +2 -21
  25. package/dist/clients/dispatch/runners/utils/runner-helpers.js +1 -1
  26. package/dist/clients/dispatch/types.js +4 -1
  27. package/dist/clients/disposition-publish.js +1 -0
  28. package/dist/clients/effective-config.js +1 -1
  29. package/dist/clients/extension-log.js +48 -1
  30. package/dist/clients/file-kinds.js +26 -1
  31. package/dist/clients/file-role.js +21 -21
  32. package/dist/clients/file-utils.js +156 -10
  33. package/dist/clients/finding-delivery-gate.js +67 -21
  34. package/dist/clients/format-service.js +39 -21
  35. package/dist/clients/formatters.js +60 -13
  36. package/dist/clients/generation-guard.js +2 -2
  37. package/dist/clients/git-guard.js +11 -3
  38. package/dist/clients/indent-retarget.js +78 -8
  39. package/dist/clients/installer/index.js +333 -114
  40. package/dist/clients/instance-reaper-state.js +7 -0
  41. package/dist/clients/instance-reaper.js +20 -10
  42. package/dist/clients/language-profile.js +23 -1
  43. package/dist/clients/language-registry.js +5 -1
  44. package/dist/clients/latency-logger.js +4 -1
  45. package/dist/clients/lens-engine.js +12 -3
  46. package/dist/clients/lens-events.js +2 -1
  47. package/dist/clients/lens-flag-registry.js +79 -0
  48. package/dist/clients/live-bus-emitter.js +2 -1
  49. package/dist/clients/lsp/cascade-tier.js +3 -0
  50. package/dist/clients/lsp/client.js +25 -21
  51. package/dist/clients/lsp/config.js +2 -2
  52. package/dist/clients/lsp/index.js +48 -7
  53. package/dist/clients/lsp/launch.js +16 -4
  54. package/dist/clients/lsp/server.js +19 -2
  55. package/dist/clients/lsp/workspace-diagnostics-cache.js +2 -1
  56. package/dist/clients/lsp-mutation.js +5 -3
  57. package/dist/clients/map-with-concurrency.js +12 -8
  58. package/dist/clients/mcp/analyze.js +9 -2
  59. package/dist/clients/mcp/session.js +36 -4
  60. package/dist/clients/ndjson-logger.js +24 -7
  61. package/dist/clients/observed-mutation.js +40 -2
  62. package/dist/clients/opaque-mutation-scan.js +23 -3
  63. package/dist/clients/opengrep-client.js +110 -15
  64. package/dist/clients/path-utils.js +165 -3
  65. package/dist/clients/performance-report.js +5 -1
  66. package/dist/clients/persistent-reverify.js +271 -0
  67. package/dist/clients/php-cs-fixer-config.js +1 -1
  68. package/dist/clients/pipeline.js +89 -6
  69. package/dist/clients/project-diagnostics/cache.js +37 -5
  70. package/dist/clients/project-diagnostics/fresh-fetch.js +145 -18
  71. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +4 -1
  72. package/dist/clients/project-diagnostics/scanner.js +59 -3
  73. package/dist/clients/project-lens-config.js +45 -2
  74. package/dist/clients/read-guard.js +70 -11
  75. package/dist/clients/resource-sampler.js +147 -9
  76. package/dist/clients/ruff-client.js +1 -1
  77. package/dist/clients/runtime-agent-end.js +33 -5
  78. package/dist/clients/runtime-context.js +6 -2
  79. package/dist/clients/runtime-coordinator.js +77 -7
  80. package/dist/clients/runtime-session.js +21 -2
  81. package/dist/clients/runtime-tool-call.js +43 -12
  82. package/dist/clients/runtime-tool-result.js +330 -85
  83. package/dist/clients/runtime-turn.js +271 -24
  84. package/dist/clients/safe-spawn.js +258 -5
  85. package/dist/clients/scratch-tree-policy.js +6 -0
  86. package/dist/clients/session-event-guard.js +50 -5
  87. package/dist/clients/sg-runner.js +23 -11
  88. package/dist/clients/sgconfig.js +79 -2
  89. package/dist/clients/skills-resolver.js +38 -2
  90. package/dist/clients/test-runner-client.js +6 -41
  91. package/dist/clients/test-runner-delivery.js +43 -0
  92. package/dist/clients/tool-agreement.js +423 -0
  93. package/dist/clients/tool-config.js +50 -0
  94. package/dist/clients/tool-cwd.js +47 -76
  95. package/dist/clients/tool-policy.js +163 -7
  96. package/dist/clients/tree-sitter-client.js +120 -0
  97. package/dist/clients/tree-sitter-query-loader.js +127 -189
  98. package/dist/clients/widget-state.js +245 -1
  99. package/dist/clients/word-index.js +5 -2
  100. package/dist/clients/write-ordering-guard.js +7 -0
  101. package/dist/index.js +26084 -22436
  102. package/dist/mcp/server.js +5 -2
  103. package/dist/tools/lens-diagnostic-mark.js +20 -2
  104. package/dist/tools/lens-diagnostics.js +251 -69
  105. package/dist/tools/lsp-diagnostics.js +276 -77
  106. package/docs/audit1.md +5 -5
  107. package/docs/dependencies.md +11 -0
  108. package/docs/dispositions.md +52 -0
  109. package/docs/environment-variables.md +2 -2
  110. package/docs/features.md +4 -4
  111. package/docs/globalconfig.md +3 -1
  112. package/docs/language-coverage.md +1 -0
  113. package/docs/pi-lens-fixer.md +53 -94
  114. package/docs/pi-lens-investigator.md +43 -38
  115. package/docs/pi-lens-retro.md +97 -0
  116. package/docs/pi-lens-reviewer.md +53 -66
  117. package/docs/release-qa-baseline.md +22 -0
  118. package/docs/settings.md +7 -4
  119. package/docs/tree-sitter_rules_catalog.md +1 -1
  120. package/docs/usage.md +2 -0
  121. package/package.json +1 -1
  122. package/rules/tree-sitter-queries/typescript/sql-injection.yml +24 -12
  123. package/scripts/analyze-pi-lens-logs.mjs +40 -0
  124. package/skills/pi-lens-ast-grep/SKILL.md +15 -0
  125. package/skills/pi-lens-lsp-navigation/SKILL.md +7 -0
@@ -401,7 +401,7 @@ async function fileView(absolute, resolved, homeDir, workspaceCwd, lspConfig) {
401
401
  resolvedCwd: homeRelativePath(resolveToolCwd("runner", id, absolute, {
402
402
  cwd: workspaceCwd,
403
403
  suppressTelemetry: true,
404
- }), homeDir),
404
+ }).cwd, homeDir),
405
405
  }));
406
406
  return {
407
407
  path: homeRelativePath(absolute, homeDir),
@@ -211,6 +211,48 @@ function inCaptureWindow(fn) {
211
211
  function isCaptureSeam(prop) {
212
212
  return (prop === "on" || (typeof prop === "string" && prop.startsWith("register")));
213
213
  }
214
+ /**
215
+ * True for a host SCHEMA value that happens to be callable.
216
+ *
217
+ * A tool definition's `parameters` is DATA the host reads, not a pi-lens
218
+ * callback — but the schema builder pi-lens imports is host-provided
219
+ * (`clients/deps/typebox.ts`: the host resolves the bare `typebox`
220
+ * specifier), and on a host whose builders are ArkType-style the schema is a
221
+ * FUNCTION. `@oh-my-pi/pi-coding-agent` rewrites an extension's `typebox`
222
+ * import to its own `src/extensibility/legacy-typebox.ts`, which re-exports
223
+ * `@oh-my-pi/omptype`'s builders; those return a callable whose prototype
224
+ * carries `toJsonSchema`/`assert`. `inCaptureWindow` returns a plain rest
225
+ * wrapper with neither, so the host falls off its ark branch, the wire schema
226
+ * degrades to `undefined`, and every xd:// device renders `type Args =
227
+ * unknown` and rejects every call with `root: schema must be an object or
228
+ * boolean` (#3195).
229
+ *
230
+ * The predicate IS the host's own, copied from `@oh-my-pi/pi-ai@18.2.4`
231
+ * `src/utils/schema/wire.ts:21-27` (`isArkSchema`) — the same test the host's
232
+ * `applyToolProxy` (`@oh-my-pi/pi-coding-agent@18.2.4`
233
+ * `src/extensibility/tool-proxy.ts:26`) uses to leave a callable schema
234
+ * unbound for exactly this hazard ("`bind()` … drops the schema surface …
235
+ * so a bound schema later stringifies to `undefined`"). Matching it means
236
+ * pi-lens declines to wrap precisely the values the host reads as schemas,
237
+ * and still wraps everything else.
238
+ * `tests/clients/pi-host-callable-schema-contract.test.ts` asserts against
239
+ * that upstream predicate (and against a fixture measured from a real
240
+ * `@oh-my-pi/omptype@18.2.4` schema), not against this copy of it.
241
+ *
242
+ * Deliberately NOT an allow-list of handler keys (`execute`, `renderResult`,
243
+ * `handler` — the measured set today): that is `isCaptureSeam`'s #1434 S1a
244
+ * mistake one level down (defect shape 34, spelling enumerator). The host's
245
+ * own `ToolDefinition` already declares `onSession`, `renderCall` and
246
+ * `shellEnv`, and `registerCommand` takes `getArgumentCompletions`; the day
247
+ * pi-lens adopts one of those, an allow-list drops its console capture
248
+ * SILENTLY — the #1333 regression this seam exists to prevent — while this
249
+ * predicate keeps wrapping it.
250
+ */
251
+ function isCallableSchema(value) {
252
+ return (typeof value === "function" &&
253
+ typeof value.toJsonSchema === "function" &&
254
+ typeof value.assert === "function");
255
+ }
214
256
  /**
215
257
  * Wrap every function value found in `value`, in place. Handles the two
216
258
  * shapes a `register*`/`on` call takes today: a function passed directly
@@ -218,6 +260,11 @@ function isCaptureSeam(prop) {
218
260
  * function ONE level inside a plain options/definition object
219
261
  * (`options.handler`, `tool.execute`).
220
262
  *
263
+ * Only the property walk screens for a callable schema (#3195): every
264
+ * function pi-lens passes as a register argument DIRECTLY is a handler
265
+ * (`on`, `registerMessageRenderer`), measured across the whole activation,
266
+ * so a schema can only ever arrive as a property of a definition object.
267
+ *
221
268
  * Deliberately NOT recursive past that one level (#1434 perf review): a tool
222
269
  * definition's `parameters`/`schema` is a large, deeply nested TypeBox object
223
270
  * that holds no functions, but walking its full tree on every `registerTool`
@@ -249,7 +296,7 @@ function wrapFunctionsInPlace(value) {
249
296
  const obj = value;
250
297
  for (const key of Object.keys(obj)) {
251
298
  const propValue = obj[key];
252
- if (typeof propValue === "function") {
299
+ if (typeof propValue === "function" && !isCallableSchema(propValue)) {
253
300
  assignWrapped(obj, key, propValue);
254
301
  }
255
302
  }
@@ -4,7 +4,10 @@
4
4
  * Centralized file type detection to avoid duplication across clients.
5
5
  * Maps file extensions and paths to semantic file kinds.
6
6
  */
7
- import { basename, extname } from "node:path";
7
+ import { basename, extname, win32 } from "node:path";
8
+ import * as path from "node:path";
9
+ import { isWindowsPath } from "./path-utils.js";
10
+ import { findNearestDirWithMarker } from "./workspace-topology.js";
8
11
  // --- Extension Maps ---
9
12
  export const KIND_EXTENSIONS = {
10
13
  clojure: [".clj", ".cljc", ".cljs", ".edn"],
@@ -40,6 +43,9 @@ export const KIND_EXTENSIONS = {
40
43
  ".cu",
41
44
  // HIP
42
45
  ".hip",
46
+ // CUDA/HIP header convention; clang's driver table omits `.cuh`, but
47
+ // projects use it for C++ headers that must remain in the cxx kind (#2986).
48
+ ".cuh",
43
49
  // Objective-C
44
50
  ".m",
45
51
  ".mm",
@@ -179,6 +185,22 @@ export const SPECIAL_FILENAMES = [
179
185
  })),
180
186
  ];
181
187
  // --- Detection Functions ---
188
+ /**
189
+ * Helm renders YAML-looking files as Go templates before they become YAML.
190
+ * Require the shared chart-root marker before promoting a YAML template, so
191
+ * ordinary repositories can use a `templates/` directory without entering
192
+ * Helm dispatch. `.tpl` remains extension-classified below.
193
+ */
194
+ export function isHelmYamlTemplatePath(filePath) {
195
+ const templatesSegment = isWindowsPath(filePath)
196
+ ? /[\\/]templates[\\/]/i
197
+ : /\/templates\//i;
198
+ if (!templatesSegment.test(filePath) || !/\.ya?ml$/i.test(filePath)) {
199
+ return false;
200
+ }
201
+ const pathApi = isWindowsPath(filePath) ? win32 : path;
202
+ return Boolean(findNearestDirWithMarker(pathApi.dirname(filePath), "chartYamlPath"));
203
+ }
182
204
  /**
183
205
  * Detect the file kind from a file path.
184
206
  * Returns the semantic file kind or undefined if unknown.
@@ -187,6 +209,9 @@ export function detectFileKind(filePath) {
187
209
  if (!filePath || typeof filePath !== "string") {
188
210
  return undefined;
189
211
  }
212
+ if (isHelmYamlTemplatePath(filePath)) {
213
+ return "helm-template";
214
+ }
190
215
  // Check special filenames first
191
216
  const base = basename(filePath);
192
217
  for (const { pattern, kind } of SPECIAL_FILENAMES) {
@@ -34,6 +34,24 @@ function isTypeStub(content) {
34
34
  /^export\s+type\s+\{/.test(l));
35
35
  return typeOnlyLines.length / lines.length >= 0.75;
36
36
  }
37
+ // Shared filename predicate (#2928). The parity test iterates the runner's
38
+ // discovery table; additional conventions are pinned in file-role.test.ts.
39
+ // Keep the case-preserved basename for CamelCase suffixes (contest.java is source).
40
+ function testFileNameMatches(rawBase, base) {
41
+ return (base.includes(".test.") ||
42
+ base.includes(".spec.") ||
43
+ base.includes("_test.") ||
44
+ base.includes("_spec.") ||
45
+ base.startsWith("test_") ||
46
+ base.startsWith("spec_") ||
47
+ /(Test|Tests|TestCase)\.(java|kt|kts|cs|fs|php)$/.test(rawBase));
48
+ }
49
+ /** Filename-only classification for consumers with their own directory policy. */
50
+ export function isTestFileName(filePath) {
51
+ const windowsShaped = isWindowsPath(filePath);
52
+ const rawBase = windowsShaped ? win32.basename(filePath) : basename(filePath);
53
+ return testFileNameMatches(rawBase, rawBase.toLowerCase());
54
+ }
37
55
  /**
38
56
  * Classify the structural role of a file. Pass `content` for higher
39
57
  * accuracy (enables generated-code detection, barrel detection, etc.).
@@ -57,27 +75,9 @@ export function detectFileRole(filePath, content) {
57
75
  .replace(/\\/g, "/")
58
76
  .toLowerCase();
59
77
  // --- Test ---
60
- // Suffix conventions informed by the test-runner table
61
- // (SOURCE_TO_TEST_PATTERNS + RUNNERS kinds in
62
- // clients/test-runner-client.ts); hand-written, not iterated from it —
63
- // see #2928 for the single-classifier fold:
64
- // - `_test.` infix: Go (`*_test.go`), pytest (`*_test.py`), ExUnit
65
- // (`*_test.exs`), Dart (`*_test.dart`). The underscore anchor keeps
66
- // this precise: `contest.py` and `latest.ts` do not match.
67
- // - `_spec.` infix: RSpec (`*_spec.rb`).
68
- // - CamelCase `*Test(s).<ext>` suffix, matched case-SENSITIVELY on the
69
- // raw basename: JUnit/Surefire (`*Test.java`, `*TestCase.java`),
70
- // Kotlin (`*Test.kt`), dotnet (`*Test.cs`/`*Tests.cs`), PHPUnit
71
- // (`*Test.php`). Case matters: Surefire's `*Test.java` does not match
72
- // `contest.java`, so neither do we.
73
- if (base.includes(".test.") ||
74
- base.includes(".spec.") ||
75
- base.includes("_test.") ||
76
- base.includes("_spec.") ||
77
- base.startsWith("test_") ||
78
- base.startsWith("spec_") ||
79
- /(Test|Tests|TestCase)\.(java|kt|kts|cs|fs|php)$/.test(rawBase) ||
80
- dir.includes("/__tests__/") ||
78
+ // #2928: dirname has no trailing slash; match direct __tests__ children too.
79
+ if (testFileNameMatches(rawBase, base) ||
80
+ /(^|[\\/])__tests__([\\/]|$)/.test(dir) ||
81
81
  dir.includes("/test/") ||
82
82
  dir.includes("/tests/") ||
83
83
  dir.includes("/spec/") ||
@@ -2,9 +2,11 @@
2
2
  * Shared file path utilities for pi-lens
3
3
  */
4
4
  import * as fs from "node:fs";
5
+ import { createHash } from "node:crypto";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
7
8
  import { Minimatch } from "./deps/minimatch.js";
9
+ import { detectFileRole } from "./file-role.js";
8
10
  import { FRESHNESS_CADENCE_MS } from "./freshness-cadence.js";
9
11
  import { isInSpawnTimeoutCooldown, noteSpawnTimeout, } from "./spawn-timeout-cooldown.js";
10
12
  import { collectTrackedFiles, getTrackedFilesSnapshot, } from "./git-tracked-ignore.js";
@@ -22,7 +24,7 @@ import { safeSpawnAsync } from "./safe-spawn.js";
22
24
  * Override: set PILENS_DATA_DIR=/some/path — each project gets its own
23
25
  * subdirectory named after a sanitized form of its absolute path, e.g.
24
26
  * PILENS_DATA_DIR=~/.pi-lens/projects
25
- * → ~/.pi-lens/projects/home-user-myapp/
27
+ * → ~/.pi-lens/projects/home-user-myapp-<8-hex-hash>/
26
28
  *
27
29
  * This keeps project folders clean and avoids creating .pi-lens folders
28
30
  * inside user projects.
@@ -34,14 +36,150 @@ export function getProjectDataDir(cwd) {
34
36
  return legacyProjectDir;
35
37
  }
36
38
  const base = configuredBase || path.join(getGlobalPiLensDir(), "projects");
37
- const normalized = normalizeFilePath(path.resolve(cwd));
38
- const slug = normalized
39
+ const resolvedBase = base.trim();
40
+ const memoKey = `${resolvedBase}\0${path.resolve(cwd)}`;
41
+ const cached = settledDataDirs.get(memoKey);
42
+ if (cached !== undefined)
43
+ return cached;
44
+ const canonical = canonicalProjectRoot(cwd);
45
+ const readable = projectDataDirReadableSlug(canonical.root);
46
+ const hash = projectDataDirRootHash(canonical.root);
47
+ const slug = `${readable || "default"}-${hash}`;
48
+ const dir = path.join(resolvedBase, slug);
49
+ const settled = settleProjectDataDir(resolvedBase, readable || "default", dir, memoKey, canonical.fallback);
50
+ return settled;
51
+ }
52
+ /**
53
+ * The human-readable half of the project data-dir slug: the absolute root
54
+ * with separators folded to dashes and anything else stripped.
55
+ *
56
+ * #2874: this form ALONE is not an identity — `src/pi-lens` and
57
+ * `src/pi/lens` fold to the same string. It is only ever combined with
58
+ * {@link projectDataDirRootHash}. Never use it as a directory name alone.
59
+ */
60
+ function projectDataDirReadableSlug(canonicalRoot) {
61
+ const normalized = normalizeFilePath(canonicalRoot);
62
+ return normalized
39
63
  .replace(/^[a-z]:/i, "") // strip Windows drive letter
40
64
  .replace(/\/+/g, "-") // separators → dashes
41
65
  .replace(/[^A-Za-z0-9-]/g, "") // strip anything else
42
66
  .replace(/^-+/, "") // trim leading dashes
43
67
  .replace(/-+$/, ""); // trim trailing dashes
44
- return path.join(base.trim(), slug || "default");
68
+ }
69
+ /**
70
+ * The canonical root is the resolved absolute path for both slug halves.
71
+ * `realpathSync` is probed only to expose a fallback boundary failure; using
72
+ * its result would let one root spelling change identity when the boundary
73
+ * recovers. The hash is the first 8 hex chars of SHA-256 over that root. A
74
+ * collision needs the same readable slug and the same 32-bit prefix, so the
75
+ * practical collision population is readable-slug twin pairs. Pinned on
76
+ * purpose because changing it renames every directory.
77
+ */
78
+ function canonicalProjectRoot(cwd) {
79
+ const resolved = path.resolve(cwd);
80
+ try {
81
+ // Probe the root, but keep the resolved spelling as the identity in both
82
+ // success and fallback cases. Resolving only on success would let a
83
+ // transient boundary failure choose a second data directory.
84
+ fs.realpathSync(resolved);
85
+ }
86
+ catch {
87
+ // Best-effort: the root may not exist yet.
88
+ return { root: resolved, fallback: true };
89
+ }
90
+ return { root: resolved, fallback: false };
91
+ }
92
+ function projectDataDirRootHash(canonicalRoot) {
93
+ return createHash("sha256").update(canonicalRoot).digest("hex").slice(0, 8);
94
+ }
95
+ const pendingDataDirMigrations = [];
96
+ const settledDataDirs = new Map();
97
+ export function resetProjectDataDirSessionState() {
98
+ pendingDataDirMigrations.splice(0);
99
+ }
100
+ /**
101
+ * One-time upgrade from a pre-#2874 slug directory to its hashed name.
102
+ * When the old directory exists and the new one does not, the state moves
103
+ * with one atomic `renameSync` (same parent, so same filesystem). When both
104
+ * exist the new one wins and nothing moves. Either case queues one
105
+ * {@link ProjectDataDirMigration} for the session-start drain, which emits
106
+ * the `data_dir_migrated` record. Best-effort throughout: on any failure the
107
+ * caller gets the pre-existing directory and behavior is unchanged.
108
+ *
109
+ * No ledger import here on purpose: `probe-home-state.ts` documents that a
110
+ * direct edge between this module and `degradation-ledger.ts` in either
111
+ * direction adds `no-client-cycles` violations, so the record is emitted by
112
+ * the drain site in `runtime-session.ts` instead.
113
+ */
114
+ function settleProjectDataDir(base, oldSlug, dir, memoKey, identityFallback) {
115
+ const oldDir = path.join(base, oldSlug);
116
+ const newExists = fs.existsSync(dir);
117
+ const oldExists = fs.existsSync(oldDir);
118
+ if (!oldExists) {
119
+ if (identityFallback && pendingDataDirMigrations.length < 32) {
120
+ pendingDataDirMigrations.push({
121
+ to: dir,
122
+ outcome: "identity-fallback",
123
+ });
124
+ }
125
+ // Steady state: nothing to migrate from.
126
+ settledDataDirs.set(memoKey, dir);
127
+ return dir;
128
+ }
129
+ if (!newExists) {
130
+ try {
131
+ fs.renameSync(oldDir, dir);
132
+ }
133
+ catch (error) {
134
+ if (error.code === "ENOENT") {
135
+ if (fs.existsSync(dir)) {
136
+ settledDataDirs.set(memoKey, dir);
137
+ return dir;
138
+ }
139
+ if (fs.existsSync(oldDir))
140
+ return oldDir;
141
+ return dir;
142
+ }
143
+ // Best-effort: retain the old directory and publish one bounded
144
+ // failure notice for this process's memo key.
145
+ if (pendingDataDirMigrations.length < 32) {
146
+ pendingDataDirMigrations.push({
147
+ to: dir,
148
+ outcome: "rename-failed",
149
+ });
150
+ }
151
+ settledDataDirs.set(memoKey, oldDir);
152
+ return oldDir;
153
+ }
154
+ }
155
+ // Either the rename just moved the state, or both directories already
156
+ // existed and the new one wins. Queue one migration for the drain.
157
+ settledDataDirs.set(memoKey, dir);
158
+ if (pendingDataDirMigrations.length < 32) {
159
+ pendingDataDirMigrations.push({
160
+ to: dir,
161
+ outcome: newExists ? "coexisting" : "renamed",
162
+ });
163
+ }
164
+ return dir;
165
+ }
166
+ /**
167
+ * Drain the queued {@link ProjectDataDirMigration}s, clearing the queue.
168
+ * Called once per session start by `handleSessionStart`; each queued outcome is
169
+ * therefore recorded at most once per session. Test-only callers must not rely
170
+ * on queue depth across drains.
171
+ */
172
+ export function drainProjectDataDirMigrations() {
173
+ return pendingDataDirMigrations.splice(0);
174
+ }
175
+ /**
176
+ * Test-only reset for the settled-directory memo. The memo is a pure
177
+ * performance cache (one `existsSync` pair saved per latched directory), so
178
+ * clearing it changes no observable behavior — it lets a test stage an
179
+ * old-slug directory AFTER learning the new name, then resolve again.
180
+ */
181
+ export function _resetProjectDataDirMemoForTests() {
182
+ settledDataDirs.clear();
45
183
  }
46
184
  /**
47
185
  * The project-data path to SHOW an agent or a human, for a file that
@@ -167,6 +305,14 @@ export const EXCLUDED_DIRS = [
167
305
  ".next",
168
306
  ".pi-lens",
169
307
  ".pi", // pi agent directory
308
+ // The SAME agent, rebranded: pi derives its config-dir name as
309
+ // `pkg.piConfig?.configDir || ".pi"` (pi-coding-agent 0.85.1,
310
+ // `dist/bundle/chunks/chunk-JVUZSMYM.js`), so a redistribution spells its
311
+ // `<configDir>/agent/sessions` history `.omp/agent/sessions` instead.
312
+ // #3112: a project-local one was walked entry-by-entry and spent the whole
313
+ // startup-scan entry budget, so a five-file project reported
314
+ // `too-many-entries` and never warmed its caches.
315
+ ".omp",
170
316
  ".ruff_cache", // Python linter cache
171
317
  ".worktrees",
172
318
  ".claude",
@@ -871,15 +1017,15 @@ export async function detectFileChangedAfterCommand(filePath, command, args, cwd
871
1017
  * Check if file path is a test/fixture/mock file.
872
1018
  * Used by secrets scanner, rate command, and dispatch runners
873
1019
  * to skip these files (false positives on fake credentials, etc).
1020
+ *
1021
+ * Delegate naming/directory classification to file-role (#2928). Fixtures,
1022
+ * mocks and test helpers remain this skip gate's separate policy.
874
1023
  */
875
1024
  export function isTestFile(filePath) {
1025
+ if (detectFileRole(filePath) === "test")
1026
+ return true;
876
1027
  const normalized = filePath.replace(/\\/g, "/");
877
- return (normalized.includes(".test.") ||
878
- normalized.includes(".spec.") ||
879
- normalized.includes("/test/") ||
880
- normalized.includes("/tests/") ||
881
- normalized.includes("__tests__/") ||
882
- normalized.includes("test-utils") ||
1028
+ return (normalized.includes("test-utils") ||
883
1029
  normalized.startsWith("test-") ||
884
1030
  normalized.includes(".fixture.") ||
885
1031
  normalized.includes(".mock."));
@@ -270,25 +270,64 @@ export const DELIVERY_SURFACES = {
270
270
  // drifting edit already re-touched the file — a fresh pending pair
271
271
  // supersedes this one), with both drop arms counted in the
272
272
  // `late_auxiliary_findings` latency record rather than silenced.
273
+ //
274
+ // #3102: freshness is only half the gate. The survivors also take the shared
275
+ // `clients/dispatch/finding-policy.ts` stack (inline `pi-lens-ignore` →
276
+ // stored dispositions → `.pi-lens.json` rule policy) the per-edit dispatcher,
277
+ // `mode=full` and the `source=lsp` probe lane apply — without it a finding
278
+ // the agent marked `false-positive` re-reported on every drain. Per #3088
279
+ // round-2 F4, the evidence is the CALL TEXT of that filter, which exists
280
+ // nowhere else in this file: a bare `applyFindingPolicy` would also be
281
+ // satisfied by an import line or an unrelated caller.
273
282
  "runtime-turn:late-auxiliary-findings": gated(RUNTIME_TURN_FILE, "Turn-end late-auxiliary LSP findings (collect-later probe of aux " +
274
- "client caches whose grace window expired).", ["gateFindingsByPathFreshness"], ['store: "late-auxiliary-findings"']),
283
+ "client caches whose grace window expired).", ["gateFindingsByPathFreshness", "applyFindingPolicy"], ['store: "late-auxiliary-findings"', "applyFindingPolicy(gate.live, {"]),
275
284
  "runtime-turn:late-runner-findings": gated(RUNTIME_TURN_FILE, "Turn-end CLI runner findings collected after the post-write path.", ["gateFindingsByPathFreshness"], ['store: "late-runner-findings"'], { evidenceMin: 2 }),
276
285
  "runtime-turn:cascade-blocker": labeled(RUNTIME_TURN_FILE, "Turn-end 🧪 cascade neighbor blocker.", "Cascade results settle synchronously this turn where possible " +
277
286
  "(`settleCascadeRuns`), but an unsettled compute can carry over to a " +
278
- "later turn (bounded by a carry cap) — this round does not freshness-" +
279
- "gate that carry-over window.", "live", [], {
280
- status: "partial",
281
- partialReason: "A carried-over cascade result (run.carriedTurns > 0) is rendered " +
282
- "without an age label. Follow-up: surface carriedTurns as an explicit " +
283
- "label when > 0, or route through formatCacheAgeLabel using the run's " +
284
- "own timestamp.",
285
- }),
287
+ "later turn (bounded by a carry cap) — a carried-over result renders " +
288
+ "with an explicit `(carried N turns · scanned Xm ago)` label (#3167, " +
289
+ "#3168 F3), so the agent can tell it from a fresh observation.", "live", ["cascadeCarrySuffix("], { evidenceMin: 1 }),
286
290
  "runtime-turn:cascade-coverage-advisory": labeled(RUNTIME_TURN_FILE, "Turn-end cascade-coverage-gap advisories (graph/binding/budget).", "Explains what the cascade check could NOT confirm this turn — not a " +
287
291
  "finding with a cited path, an absence-of-coverage disclosure computed " +
288
- "from this turn's own indeterminate-run list.", "live", [], {
289
- status: "partial",
290
- partialReason: "Same cascade carry-over caveat as runtime-turn:cascade-blocker.",
291
- }),
292
+ "from this turn's own indeterminate-run list. An advisory computed from " +
293
+ "a carried indeterminate run is labeled `(carried N turns · <age>)` " +
294
+ "(#3167, #3168 F4 — dropped on mixed carried/fresh buckets). The age " +
295
+ "half reads `scan age unknown` on this surface today: no indeterminate-" +
296
+ "run producer stamps `observedAt` (only the resolved-found plumb does, " +
297
+ "`clients/cascade-format.ts`), and one unstamped carried run collapses " +
298
+ "the whole bucket's age rather than claiming the stamped runs' (#3168 " +
299
+ "F13). The carry COUNT is always real.", "live", ["withCarryLabel("], { evidenceMin: 3 }),
300
+ // #3102: the cold-neighbour cascade run is BUILT here, in the quiet-window
301
+ // reconcile (`onResolvedFound` in index.ts), a turn earlier than the
302
+ // `runtime-turn:cascade-blocker` render that finally carries it — so it is
303
+ // its own delivery lane, and it was the one that rendered raw LSP findings
304
+ // with no policy filter at all. The evidence is the CALL TEXT of the shared
305
+ // filter (#3088 round-2 F4): a bare `applyFindingPolicy` would also be
306
+ // satisfied by the import line.
307
+ "cascade-format:resolved-found-run": gated("clients/cascade-format.ts", "Cold-neighbour ERROR diagnostics formatted into the turn-end cascade " +
308
+ "run by `buildResolvedFoundCascadeRun` (quiet-window reconcile of a " +
309
+ "cascade touch that skipped its in-lane wait).", ["applyFindingPolicy"], ["applyFindingPolicy(retained, {"]),
310
+ // #3157: the IN-LANE cascade — a different delivery lane from the
311
+ // quiet-window run above, and the one #3102's sweep cleared WRONGLY. Its four
312
+ // display sites (passive cold snapshot, fresh touch, touch-error fallback,
313
+ // degraded fallback) assemble `CascadeNeighborResult.diagnostics` and reach
314
+ // the agent through `formatCascadeNeighborDiagnostics` without ever entering
315
+ // the dispatcher's `applyOutputFilters` pipeline: the sweep's file-level
316
+ // verdict ("the per-edit dispatch path, which already filters in
317
+ // dispatcher.ts") was true only of the per-edit RUNNER output in
318
+ // `runners/lsp.ts`, which is a different call site in a different file.
319
+ //
320
+ // Evidence is the CALL TEXT (#3088 round-2 F4), counted: `cascadeDisplay(` is
321
+ // the one seam all four sites route through, so `evidenceMin: 4` is the count
322
+ // of display sites in the file and dropping ANY of them back to a raw
323
+ // `convertLspDiagnostics` reds this suite. Identity-stubbing the callee
324
+ // instead is caught behaviourally — it reds twelve cases in
325
+ // `tests/clients/inlane-cascade-finding-policy.test.ts`, which is a stronger
326
+ // signal than the R2 proximity heuristic. Residual, stated: a FIFTH display
327
+ // site that open-codes the conversion keeps the count at 4 and is caught by
328
+ // review, not by this row.
329
+ "dispatch-integration:in-lane-cascade": gated("clients/dispatch/integration.ts", "In-lane per-edit cascade neighbour ERROR diagnostics, rendered into the " +
330
+ "turn-end cascade block by `computeCascadeForFile`.", ["applyCascadeDisplayPolicy"], ["cascadeDisplay("], { evidenceMin: 4 }),
292
331
  "runtime-turn:call-graph-advisory": labeled(RUNTIME_TURN_FILE, "Turn-end 📊 call-graph impact advisory.", "`runtime.callGraph` is a session-lifetime in-memory structure; this " +
293
332
  "round does not verify or gate ITS OWN freshness policy (how/when it " +
294
333
  "incorporates edits made earlier in the session).", "live", [], {
@@ -296,13 +335,15 @@ export const DELIVERY_SURFACES = {
296
335
  partialReason: "call-graph freshness/invalidation policy is out of this PR's scope — " +
297
336
  "tracked as a follow-up, not silently assumed fresh.",
298
337
  }),
299
- "lens-diagnostics:mode-full": gated(LENS_DIAGNOSTICS_FILE, "`lens_diagnostics mode=full` report.", [
300
- "applyDispositions",
301
- "applyRulePolicy",
302
- "reconcileProjectDiagnosticsSnapshot",
303
- ], [
304
- "applyDispositions(",
305
- "applyRulePolicy(",
338
+ // #3088 round 2 F4: this row used to name `applyDispositions`/`applyRulePolicy`
339
+ // with whole-file evidence literals. Once #3088 folded mode=full's stack onto
340
+ // `applyFindingPolicy`, those two literals survived only in UNRELATED
341
+ // mode=delta helpers in the same file, so deleting the entire policy stack out
342
+ // of `applyInlineSuppressionsToSummaries` left this gate — and the ratchet —
343
+ // green while five behaviour cases redded. The evidence is now the call text
344
+ // of the merge's own filter, which exists nowhere else in the file.
345
+ "lens-diagnostics:mode-full": gated(LENS_DIAGNOSTICS_FILE, "`lens_diagnostics mode=full` report.", ["applyFindingPolicy", "reconcileProjectDiagnosticsSnapshot"], [
346
+ "applyFindingPolicy(summary.diagnostics, {",
306
347
  "reconcileProjectDiagnosticsSnapshot(",
307
348
  ]),
308
349
  "lens-diagnostics:mode-all": gated(LENS_DIAGNOSTICS_FILE, "`lens_diagnostics mode=all` report (widget-state read).", ["reconcileStaleWidgetFiles", "reconcileStaleWidgetDependencyBlockers"], ["reconcileStaleWidgetFiles(", "reconcileStaleWidgetDependencyBlockers("]),
@@ -342,13 +383,18 @@ export const DELIVERY_SURFACES = {
342
383
  "the shared workspace-diagnostics cache: createWorkspaceDiagnosticsCacheContext " +
343
384
  "is the tool's entry, and its lookup() applies the #671/#672 freshness stack " +
344
385
  "(own-file mtime + reverse-dependency mtimes via isEntryFresh) plus the " +
345
- "#1095 content binding.", [
386
+ "#1095 content binding. #3088: every route out of this tool — fresh, " +
387
+ "cache replay, single file, directory — also passes applyProbeFindingPolicy, " +
388
+ "the stored-disposition / .pi-lens.json rule-policy / inline pi-lens-ignore " +
389
+ "stack the per-edit dispatch path and mode=full apply.", [
346
390
  "createWorkspaceDiagnosticsCacheContext",
347
391
  "isEntryFresh",
348
392
  "cacheCtx.lookup",
393
+ "applyProbeFindingPolicy",
349
394
  ], [
350
395
  "createWorkspaceDiagnosticsCacheContext(resolvedCwd)",
351
396
  "cacheCtx.lookup(file, scopeKey)",
397
+ "applyProbeFindingPolicy(",
352
398
  ]),
353
399
  "git-guard:commit-blocked": labeled("clients/git-guard.ts", "Git-guard commit/push 🔴 COMMIT BLOCKED verdict (--lens-guard).", "Synchronous preflight rejection returned inline with the failed git " +
354
400
  "command — no stored state is delivered, so nothing can go stale between " +
@@ -15,6 +15,7 @@ import * as path from "node:path";
15
15
  import { recordFormatter } from "./widget-state.js";
16
16
  import { FileTime } from "./file-time.js";
17
17
  import { loadFormatters } from "./formatters-lazy.js";
18
+ import { bounded } from "./deadline-utils.js";
18
19
  // --- Configuration ---
19
20
  /** Reserved for future batching; formatting currently runs one selected formatter per file. */
20
21
  const DEFAULT_FORMATTER_CONCURRENCY = 1;
@@ -69,7 +70,7 @@ export class FormatService {
69
70
  };
70
71
  }
71
72
  // Run formatters with limited concurrency
72
- const results = await this.runFormattersWithConcurrency(absolutePath, formatters);
73
+ const results = await this.runFormattersWithConcurrency(absolutePath, formatters, DEFAULT_FORMATTER_CONCURRENCY, options.signal, options.budgetMs, options.hook);
73
74
  // Record new file state after formatting
74
75
  this.fileTime.read(absolutePath);
75
76
  for (const [index, result] of results.entries()) {
@@ -84,7 +85,7 @@ export class FormatService {
84
85
  name: formatters[i].name,
85
86
  success: r.success,
86
87
  changed: r.changed,
87
- error: r.error,
88
+ ...(r.error === undefined ? {} : { error: r.error }),
88
89
  outcome: r.outcome,
89
90
  })),
90
91
  anyChanged,
@@ -94,25 +95,46 @@ export class FormatService {
94
95
  /**
95
96
  * Run the selected formatter with timeout protection.
96
97
  */
97
- async runFormattersWithConcurrency(filePath, formatters, _concurrency = DEFAULT_FORMATTER_CONCURRENCY) {
98
+ async runFormattersWithConcurrency(filePath, formatters, _concurrency = DEFAULT_FORMATTER_CONCURRENCY, signal, budgetMs = 30_000, hook = "tool_result_edit") {
98
99
  const results = [];
100
+ const startedAt = Date.now();
99
101
  for (const formatter of formatters) {
100
- // #1097: keep the timer handle so it can be cleared once the race
101
- // settles. An uncleared, REF'D 30s setTimeout that outlives a fast
102
- // `formatFile` win would keep a one-shot `pi --print` process alive for
103
- // up to 30s after completion (same uncleared-race-timeout class as the
104
- // LSP client-wait leak fixed in clients/lsp/index.ts).
105
- let formatTimer;
102
+ const remainingMs = budgetMs - (Date.now() - startedAt);
103
+ if (remainingMs <= 0) {
104
+ results.push({
105
+ success: false,
106
+ changed: false,
107
+ outcome: "failed",
108
+ error: `Formatter aggregate budget exhausted before ${formatter.name}`,
109
+ });
110
+ continue;
111
+ }
112
+ // The shared bound owns both the aggregate timer and abort listener.
113
+ // This keeps one total formatter budget per file instead of re-arming a
114
+ // 30s timer for every formatter.
106
115
  try {
107
- const timeoutMs = 30000;
108
- const timeoutPromise = new Promise((_, reject) => {
109
- formatTimer = setTimeout(() => reject(new Error(`Formatter ${formatter.name} timed out after ${timeoutMs}ms`)), timeoutMs);
116
+ const result = await bounded(loadFormatters().then(({ formatFile }) => formatFile(filePath, formatter)), {
117
+ ms: remainingMs,
118
+ signal,
119
+ hook,
120
+ label: "formatter-aggregate",
110
121
  });
111
- const result = await Promise.race([
112
- loadFormatters().then(({ formatFile }) => formatFile(filePath, formatter)),
113
- timeoutPromise,
114
- ]);
115
- results.push(result);
122
+ if (result)
123
+ results.push(result);
124
+ else if (signal?.aborted) {
125
+ // Caller cancellation is intentional. Do not turn Escape into a
126
+ // formatter failure or requeue record.
127
+ break;
128
+ }
129
+ else {
130
+ results.push({
131
+ success: false,
132
+ changed: false,
133
+ outcome: "failed",
134
+ error: `Formatter ${formatter.name} exceeded aggregate budget`,
135
+ });
136
+ break;
137
+ }
116
138
  }
117
139
  catch (error) {
118
140
  // A timeout or thrown error here is a real failure (#2413): the
@@ -125,10 +147,6 @@ export class FormatService {
125
147
  error: error instanceof Error ? error.message : String(error),
126
148
  });
127
149
  }
128
- finally {
129
- if (formatTimer)
130
- clearTimeout(formatTimer);
131
- }
132
150
  }
133
151
  return results;
134
152
  }