pi-lens 4.2.0 → 4.3.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.
- package/CHANGELOG.md +543 -0
- package/README.md +45 -2
- package/dist/clients/actionable-warnings.js +44 -16
- package/dist/clients/advisory-provenance.js +187 -51
- package/dist/clients/cache-manager.js +64 -5
- package/dist/clients/cascade-format.js +24 -0
- package/dist/clients/child-unref.js +17 -8
- package/dist/clients/config-diagnostic-codes.js +2 -0
- package/dist/clients/config-locations.js +237 -9
- package/dist/clients/config-resolve.js +7 -1
- package/dist/clients/degradation-ledger.js +33 -3
- package/dist/clients/dependency-checker.js +87 -64
- package/dist/clients/diagnostic-dispositions.js +39 -3
- package/dist/clients/dispatch/finding-policy.js +118 -1
- package/dist/clients/dispatch/indent-detect.js +8 -2
- package/dist/clients/dispatch/inline-suppressions.js +23 -4
- package/dist/clients/dispatch/rule-ignores.js +14 -7
- package/dist/clients/dispatch/runners/actionlint.js +26 -28
- package/dist/clients/dispatch/runners/biome-check.js +38 -25
- package/dist/clients/dispatch/runners/cpp-check.js +32 -49
- package/dist/clients/dispatch/runners/credo.js +16 -14
- package/dist/clients/dispatch/runners/cue-vet.js +25 -7
- package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
- package/dist/clients/dispatch/runners/detekt.js +11 -4
- package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
- package/dist/clients/dispatch/runners/elixir-check.js +31 -46
- package/dist/clients/dispatch/runners/eslint.js +29 -46
- package/dist/clients/dispatch/runners/gleam-check.js +91 -11
- package/dist/clients/dispatch/runners/go-vet.js +16 -2
- package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
- package/dist/clients/dispatch/runners/hadolint.js +27 -15
- package/dist/clients/dispatch/runners/htmlhint.js +17 -8
- package/dist/clients/dispatch/runners/javac.js +31 -21
- package/dist/clients/dispatch/runners/ktlint.js +34 -60
- package/dist/clients/dispatch/runners/lsp.js +6 -5
- package/dist/clients/dispatch/runners/markdownlint.js +12 -5
- package/dist/clients/dispatch/runners/mypy.js +2 -1
- package/dist/clients/dispatch/runners/oxlint.js +22 -6
- package/dist/clients/dispatch/runners/php-lint.js +39 -16
- package/dist/clients/dispatch/runners/phpstan.js +1 -1
- package/dist/clients/dispatch/runners/rubocop.js +27 -18
- package/dist/clients/dispatch/runners/ruff.js +18 -19
- package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
- package/dist/clients/dispatch/runners/shellcheck.js +6 -2
- package/dist/clients/dispatch/runners/spellcheck.js +10 -3
- package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
- package/dist/clients/dispatch/runners/stylelint.js +10 -2
- package/dist/clients/dispatch/runners/swiftlint.js +11 -2
- package/dist/clients/dispatch/runners/taplo.js +12 -6
- package/dist/clients/dispatch/runners/tflint.js +30 -15
- package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
- package/dist/clients/dispatch/runners/trivy-config.js +8 -2
- package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
- package/dist/clients/dispatch/runners/vale.js +15 -3
- package/dist/clients/dispatch/runners/yamllint.js +12 -2
- package/dist/clients/dispatch/runners/zig-check.js +27 -39
- package/dist/clients/dispatch/suppress-writer.js +13 -3
- package/dist/clients/effective-config.js +3 -1
- package/dist/clients/extension-log.js +48 -1
- package/dist/clients/finding-delivery-gate.js +53 -23
- package/dist/clients/formatters.js +49 -11
- package/dist/clients/git-guard.js +247 -34
- package/dist/clients/gradle-ktfmt-style.js +121 -25
- package/dist/clients/inline-blocker-dispositions.js +186 -0
- package/dist/clients/install-diagnostics.js +19 -6
- package/dist/clients/installer/index.js +281 -32
- package/dist/clients/jscpd-client.js +8 -0
- package/dist/clients/latency-logger.js +8 -0
- package/dist/clients/lens-config.js +66 -8
- package/dist/clients/lsp/cascade-tier.js +3 -0
- package/dist/clients/lsp/client.js +139 -11
- package/dist/clients/lsp/config.js +9 -7
- package/dist/clients/lsp/content-limits.js +41 -0
- package/dist/clients/lsp/index.js +42 -8
- package/dist/clients/lsp/launch.js +23 -2
- package/dist/clients/lsp/server.js +106 -25
- package/dist/clients/lsp/sync-kind.js +14 -0
- package/dist/clients/lsp/wait-policy/strategies.js +105 -16
- package/dist/clients/mcp/analyze.js +9 -2
- package/dist/clients/mcp/ipc.js +232 -44
- package/dist/clients/mcp/review.js +53 -7
- package/dist/clients/opengrep-client.js +11 -6
- package/dist/clients/path-utils.js +51 -9
- package/dist/clients/persistent-reverify.js +271 -0
- package/dist/clients/pipeline.js +136 -67
- package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
- package/dist/clients/project-lens-config.js +11 -3
- package/dist/clients/review-graph/builder.js +34 -26
- package/dist/clients/review-graph/git-identity.js +16 -10
- package/dist/clients/ruff-client.js +31 -4
- package/dist/clients/runtime-coordinator.js +38 -4
- package/dist/clients/runtime-tool-call.js +16 -5
- package/dist/clients/runtime-tool-result.js +27 -12
- package/dist/clients/runtime-turn.js +571 -287
- package/dist/clients/safe-spawn.js +256 -65
- package/dist/clients/skills-resolver.js +38 -2
- package/dist/clients/spawn-output-cap.js +84 -1
- package/dist/clients/tree-sitter-client.js +119 -58
- package/dist/clients/turn-end/lane.js +51 -0
- package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
- package/dist/clients/turn-end/lanes/secrets.js +204 -0
- package/dist/clients/warm-attach.js +59 -0
- package/dist/clients/widget-state.js +294 -17
- package/dist/clients/workspace-topology.js +57 -3
- package/dist/clients/zizmor-config.js +26 -13
- package/dist/index.js +6342 -3995
- package/dist/mcp/analyze-cli.js +4 -0
- package/dist/mcp/server.js +33 -21
- package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
- package/dist/tools/lens-diagnostic-mark.js +14 -2
- package/dist/tools/lens-diagnostics.js +127 -33
- package/dist/tools/lsp-diagnostics.js +150 -12
- package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
- package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
- package/docs/adr/0003-git-guard-latch-writer.md +203 -0
- package/docs/adr/0004-disposition-policy-seam.md +38 -0
- package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
- package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
- package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
- package/docs/adr/0008-turn-end-lane-interface.md +165 -0
- package/docs/adr/0009-reported-path-attribution.md +114 -0
- package/docs/adr/README.md +26 -0
- package/docs/agent-tools.md +18 -1
- package/docs/ast-grep_rules_catalog.md +4 -3
- package/docs/configuration.md +55 -1
- package/docs/engineering-principles.md +202 -0
- package/docs/environment-variables.md +61 -4
- package/docs/features.md +2 -2
- package/docs/globalconfig.md +6 -1
- package/docs/language-coverage.md +17 -0
- package/docs/lsp-capability-matrix.md +98 -31
- package/docs/pi-lens-fixer.md +57 -3
- package/docs/pi-lens-investigator.md +9 -0
- package/docs/pi-lens-reviewer.md +16 -0
- package/docs/pi-lens-subagent.md +9 -0
- package/docs/public-api-stability.md +1 -0
- package/docs/release-qa-baseline.md +2 -0
- package/docs/servercapabilities.md +21 -11
- package/docs/settings.md +12 -3
- package/docs/subagent-compat.md +1 -1
- package/package.json +5 -3
- package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
- package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
- package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
- package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
- package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
- package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
- package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
- package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
- package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
- package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
- package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
- package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
- package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
- package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
- package/scripts/install-selftest.mjs +21 -17
- package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
|
@@ -3,6 +3,7 @@ import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
|
|
|
3
3
|
import { applyFindingPolicy, loadProjectRulePolicyMap, renderedRuleIdentities, } from "./dispatch/finding-policy.js";
|
|
4
4
|
import { detectFileRole } from "./file-role.js";
|
|
5
5
|
import { logLatency } from "./latency-logger.js";
|
|
6
|
+
import { formatCacheAgeLabel } from "./finding-delivery-gate.js";
|
|
6
7
|
import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
|
|
7
8
|
import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
|
|
8
9
|
export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
|
|
@@ -140,6 +141,12 @@ export function buildResolvedFoundCascadeRun(cwd, neighbor) {
|
|
|
140
141
|
},
|
|
141
142
|
neighborCount: 1,
|
|
142
143
|
diagnosticCount: diagnostics.length,
|
|
144
|
+
// #3168 F3: the #1444 publish stamp — the run's own observation time, so
|
|
145
|
+
// the carried-render age label states the real age instead of claiming
|
|
146
|
+
// no stamp exists.
|
|
147
|
+
...(neighbor.publishedAt !== undefined
|
|
148
|
+
? { observedAt: neighbor.publishedAt }
|
|
149
|
+
: {}),
|
|
143
150
|
};
|
|
144
151
|
}
|
|
145
152
|
/**
|
|
@@ -289,3 +296,20 @@ function readNeighborContent(filePath) {
|
|
|
289
296
|
return undefined;
|
|
290
297
|
}
|
|
291
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Fix B (#3167/#3168): the carry label for a cascade run re-rendered at a
|
|
301
|
+
* later turn_end. The carry is bounded to ONE turn (`RuntimeCoordinator.beginTurn`
|
|
302
|
+
* drops anything that would reach 2), so the honest label names the carry
|
|
303
|
+
* count. #3168 F3 corrected this docstring's earlier claim that no stamp
|
|
304
|
+
* exists: the run DOES carry one (`observedAt`, threaded from #1444's
|
|
305
|
+
* `publishedAt` through the resolved-found plumb), so the age half renders
|
|
306
|
+
* from it via `formatCacheAgeLabel`. The deferred-compute re-park arm has no
|
|
307
|
+
* stamp and correctly falls to the neutral wording. Returns `undefined` for
|
|
308
|
+
* non-carried runs: no label noise on fresh observations.
|
|
309
|
+
*/
|
|
310
|
+
export function cascadeCarrySuffix(carriedTurns, observedAt) {
|
|
311
|
+
if (carriedTurns === undefined || carriedTurns < 1)
|
|
312
|
+
return undefined;
|
|
313
|
+
const noun = carriedTurns === 1 ? "turn" : "turns";
|
|
314
|
+
return `(carried ${carriedTurns} ${noun} · ${formatCacheAgeLabel(observedAt)})`;
|
|
315
|
+
}
|
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
* new-code-duplication finding: two near-identical spawn→collect-stdout
|
|
12
12
|
* blocks in `clients/resource-sampler.ts` collapsed to one shared helper).
|
|
13
13
|
*
|
|
14
|
-
* Deliberately has NO imports beyond `node:child_process` types
|
|
14
|
+
* Deliberately has NO imports beyond `node:child_process` types and
|
|
15
|
+
* `spawn-output-cap.ts` (itself import-free, #3383), so both
|
|
15
16
|
* `clients/instance-reaper.ts` and `clients/resource-sampler.ts` (which
|
|
16
17
|
* `clients/safe-spawn.ts` itself depends on) can import this without risking
|
|
17
|
-
* a circular-import chain.
|
|
18
|
+
* a circular-import chain. That is also why the output bound below reports
|
|
19
|
+
* nothing to the degradation ledger: importing the ledger here would put
|
|
20
|
+
* `safe-spawn.ts`'s own dependency above it in the graph.
|
|
18
21
|
*/
|
|
19
22
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
23
|
+
import { createBoundedOutputSink } from "./spawn-output-cap.js";
|
|
20
24
|
/**
|
|
21
25
|
* Detach a best-effort, fire-and-forget child process from the event loop.
|
|
22
26
|
*
|
|
@@ -64,13 +68,18 @@ export function spawnCollectStdoutResult(command, args, options, collectOptions
|
|
|
64
68
|
try {
|
|
65
69
|
const child = nodeSpawn(command, args, options);
|
|
66
70
|
unrefChildAndPipes(child);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
// #3383: bounded, not `out += chunk`. The producer is a process-table
|
|
72
|
+
// query (`ps`, `wmic`, `powershell`) whose output nothing else limits,
|
|
73
|
+
// and the concatenation runs inside a `data` handler where V8's
|
|
74
|
+
// `RangeError: Invalid string length` would be an uncaught exception
|
|
75
|
+
// rather than this promise's result. A truncated table is the ending
|
|
76
|
+
// this contract already documents for a timed-out child: whatever was
|
|
77
|
+
// kept is parsed, and the caller reads `status`.
|
|
78
|
+
const out = createBoundedOutputSink();
|
|
79
|
+
child.stdout?.on("data", (chunk) => out.append(chunk));
|
|
71
80
|
child.once("error", (error) => settle({ stdout: "", status: "spawn-error", error }));
|
|
72
81
|
child.once("close", (code, signal) => settle(code === 0 && signal === null
|
|
73
|
-
? { stdout: out, status: "ok" }
|
|
82
|
+
? { stdout: out.text, status: "ok" }
|
|
74
83
|
: {
|
|
75
84
|
stdout: "",
|
|
76
85
|
status: "exit-error",
|
|
@@ -83,7 +92,7 @@ export function spawnCollectStdoutResult(command, args, options, collectOptions
|
|
|
83
92
|
// Settle only AFTER the child's fate is known. Killing and
|
|
84
93
|
// resolving in the same tick bounds the caller and abandons the
|
|
85
94
|
// child, which is the leak this timeout exists to prevent.
|
|
86
|
-
void terminateTimedOutChild(child, collectOptions.onTimeout).then((timeoutKill) => settle({ stdout: out, status: "timeout", timeoutKill }));
|
|
95
|
+
void terminateTimedOutChild(child, collectOptions.onTimeout).then((timeoutKill) => settle({ stdout: out.text, status: "timeout", timeoutKill }));
|
|
87
96
|
}, timeoutMs);
|
|
88
97
|
timer.unref();
|
|
89
98
|
}
|
|
@@ -173,6 +173,8 @@ export const CONFIG_DIAGNOSTIC_CODES = {
|
|
|
173
173
|
PILENS_CFG_0008: "config resolution failed; whole configuration ignored",
|
|
174
174
|
/** A tool key is unknown or names a required, non-disableable tool. */
|
|
175
175
|
PILENS_CFG_0009: "unknown or non-disableable tool config key ignored",
|
|
176
|
+
/** A lower-precedence supported global config file was shadowed by the winner. */
|
|
177
|
+
PILENS_CFG_0010: "lower-precedence global config file shadowed",
|
|
176
178
|
};
|
|
177
179
|
/** The shape every code in the namespace must match. */
|
|
178
180
|
export const CONFIG_DIAGNOSTIC_CODE_PATTERN = /^PILENS_CFG_\d{4}$/;
|
|
@@ -22,12 +22,17 @@
|
|
|
22
22
|
* exactly the registry's `kind: "file"` rows. Registry owns WHICH surfaces are
|
|
23
23
|
* deprecated; this module owns only their precedence.
|
|
24
24
|
*
|
|
25
|
-
* Pure data
|
|
25
|
+
* Pure data, one walk, and — as of the global-config-location PR (refs #2457,
|
|
26
|
+
* #2426) — the ONE existence probe and memoized resolution that decide which
|
|
27
|
+
* file supplies the global tier. The probe is a single `statSync` under the
|
|
28
|
+
* resolution memo; no other file reads, no state beyond that one memo.
|
|
26
29
|
*/
|
|
30
|
+
import * as fs from "node:fs";
|
|
27
31
|
import * as os from "node:os";
|
|
28
32
|
import * as path from "node:path";
|
|
29
33
|
import { DEPRECATED_CONFIG_SURFACES } from "./config-diagnostic-codes.js";
|
|
30
|
-
import {
|
|
34
|
+
import { errorClassName } from "./error-class.js";
|
|
35
|
+
import { isAtOrAboveHomeDir, isWindowsPath, normalizeFilePath, realpathOrResolve, walkUpDirs, } from "./path-utils.js";
|
|
31
36
|
/** The one canonical PROJECT config file. Never deprecated. */
|
|
32
37
|
export const CANONICAL_PROJECT_CONFIG_FILE = ".pi-lens.json";
|
|
33
38
|
/** Basename of the one canonical GLOBAL config file inside `~/.pi-lens/`. */
|
|
@@ -139,9 +144,167 @@ export function configSearchDirs(startDir, homeDir = os.homedir()) {
|
|
|
139
144
|
}
|
|
140
145
|
return dirs;
|
|
141
146
|
}
|
|
147
|
+
function defaultGlobalConfigExists(file) {
|
|
148
|
+
try {
|
|
149
|
+
return fs.statSync(file).isFile();
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
// ENOENT is the ordinary ABSENT answer the tier walk consumes. Any other
|
|
153
|
+
// stat failure (ENOTDIR when a file sits where a directory belongs,
|
|
154
|
+
// EACCES, ELOOP) propagates: the discriminated probe in
|
|
155
|
+
// `resolveGlobalConfigLocation` retains the errored tier instead of
|
|
156
|
+
// silently switching config sources (#3251 review H2, defect shape 48),
|
|
157
|
+
// and the load's read of the retained path is reported by the existing
|
|
158
|
+
// `PILENS_CFG_0001` seam.
|
|
159
|
+
if (error.code === "ENOENT")
|
|
160
|
+
return false;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
142
164
|
/**
|
|
143
|
-
* The
|
|
144
|
-
*
|
|
165
|
+
* The agent-dir config path for one `PI_CODING_AGENT_DIR` value, shared by the
|
|
166
|
+
* resolution and the recognized-location set so the two can never spell the
|
|
167
|
+
* same location differently.
|
|
168
|
+
*
|
|
169
|
+
* Shape-aware joining (defect shape 2): a Windows-shaped agent dir is joined
|
|
170
|
+
* with `path.win32` regardless of host — the host-default `path.resolve` on
|
|
171
|
+
* POSIX treats a drive-letter path as RELATIVE and mangles it, which made a
|
|
172
|
+
* case-folded Windows spelling unmatchable (#3251 review M1).
|
|
173
|
+
*/
|
|
174
|
+
function agentConfigPathFor(agentDir) {
|
|
175
|
+
return isWindowsPath(agentDir)
|
|
176
|
+
? path.win32.join(path.win32.resolve(agentDir), "extensions", "pi-lens.json")
|
|
177
|
+
: path.join(path.resolve(agentDir), "extensions", "pi-lens.json");
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Resolve which file supplies the global config tier (see
|
|
181
|
+
* `GlobalConfigLocationSource` for the order).
|
|
182
|
+
*
|
|
183
|
+
* Lives here rather than in `lens-config.ts` (#2426 review round 3, S1):
|
|
184
|
+
* this module is the one that already states "WHERE pi-lens config lives",
|
|
185
|
+
* and BOTH discovery seams that must refuse the resolved global path
|
|
186
|
+
* (`project-lens-config.ts`, `workspace-topology.ts`) already import from
|
|
187
|
+
* here — importing the resolver from `lens-config.ts` would close a cycle
|
|
188
|
+
* (`lens-config.ts` itself imports from `project-lens-config.ts`).
|
|
189
|
+
* `lens-config.ts` re-exports the accessors so its own, pre-existing import
|
|
190
|
+
* sites are unaffected.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveGlobalConfigLocation(options) {
|
|
193
|
+
// An options object means the caller OWNS the resolution context (a test
|
|
194
|
+
// or explicitly-scoped caller): the ambient host env must not leak into it,
|
|
195
|
+
// or a test that resolves-then-writes "under its own home" lands in the
|
|
196
|
+
// maintainer's real config (dogfooded live: ambient PI_CODING_AGENT_DIR
|
|
197
|
+
// redirected a suite's writeConfig into ~/.config/pi/agent/extensions/
|
|
198
|
+
// pi-lens.json). Owned contexts therefore ignore PI_CODING_AGENT_DIR
|
|
199
|
+
// entirely; only the production resolution (no options) walks the
|
|
200
|
+
// host-relative tier. PI_LENS_HOME does not participate in this resolution
|
|
201
|
+
// at all — the canonical default keeps its released homedir spelling, and
|
|
202
|
+
// the #2457 split-brain (which concerns the machine root, not the config
|
|
203
|
+
// file) stays a separate issue.
|
|
204
|
+
const owned = options !== undefined;
|
|
205
|
+
const homeDir = options?.homeDir ?? os.homedir();
|
|
206
|
+
const legacyDefault = path.join(homeDir, ".pi-lens", CANONICAL_GLOBAL_CONFIG_FILE);
|
|
207
|
+
// Byte-compatible with the released override: any non-empty value wins,
|
|
208
|
+
// untrimmed, exactly as before.
|
|
209
|
+
const override = process.env.PI_LENS_CONFIG_PATH;
|
|
210
|
+
if (override) {
|
|
211
|
+
return { path: path.resolve(override), source: "pi-lens-config-path" };
|
|
212
|
+
}
|
|
213
|
+
// The probe is DISCRIMINATED (#3251 review H2, defect shape 48): "error"
|
|
214
|
+
// (ENOTDIR when a file sits where a directory belongs, EACCES, ELOOP) is
|
|
215
|
+
// NOT absent — the errored tier is RETAINED, so the load's read of that
|
|
216
|
+
// same path fails and the existing `reportConfigReadFailure` seam
|
|
217
|
+
// (`PILENS_CFG_0001`) reports it. Absorbing the error as ABSENT would
|
|
218
|
+
// silently select a lower tier: different user settings applying with no
|
|
219
|
+
// diagnostic, and the read seam would only ever see the finally-read path,
|
|
220
|
+
// never the probe that failed.
|
|
221
|
+
let probeError;
|
|
222
|
+
const probe = (file) => {
|
|
223
|
+
try {
|
|
224
|
+
return (options?.exists ?? defaultGlobalConfigExists)(file)
|
|
225
|
+
? "present"
|
|
226
|
+
: "absent";
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
// The caught error is carried to the retained tier's resolution so
|
|
230
|
+
// the loader's retention record can name the error CLASS (never
|
|
231
|
+
// the message, per the #2431/#2451 rule).
|
|
232
|
+
probeError = error;
|
|
233
|
+
return "error";
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const agentDir = owned ? undefined : process.env.PI_CODING_AGENT_DIR?.trim();
|
|
237
|
+
const agentPath = agentDir ? agentConfigPathFor(agentDir) : undefined;
|
|
238
|
+
const legacyProbe = probe(legacyDefault);
|
|
239
|
+
if (legacyProbe === "present") {
|
|
240
|
+
if (agentPath !== undefined && probe(agentPath) === "present") {
|
|
241
|
+
return {
|
|
242
|
+
path: legacyDefault,
|
|
243
|
+
source: "legacy-default-existing",
|
|
244
|
+
shadowedPath: agentPath,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return { path: legacyDefault, source: "legacy-default-existing" };
|
|
248
|
+
}
|
|
249
|
+
if (legacyProbe === "error") {
|
|
250
|
+
// Fail closed at the location the probe could not evaluate: the file
|
|
251
|
+
// might exist, and switching sources would silently swap the user's
|
|
252
|
+
// settings. The subsequent read reports the failure (`PILENS_CFG_0001`),
|
|
253
|
+
// and the loader emits the probe-failed retention record.
|
|
254
|
+
return {
|
|
255
|
+
path: legacyDefault,
|
|
256
|
+
source: "legacy-default-unprobed",
|
|
257
|
+
existsProbeFailed: {
|
|
258
|
+
path: legacyDefault,
|
|
259
|
+
errorClassName: errorClassName(probeError),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (agentPath !== undefined) {
|
|
264
|
+
const agentProbe = probe(agentPath);
|
|
265
|
+
if (agentProbe === "present") {
|
|
266
|
+
return { path: agentPath, source: "pi-coding-agent-dir" };
|
|
267
|
+
}
|
|
268
|
+
if (agentProbe === "error") {
|
|
269
|
+
// Same rule one tier down: the agent-dir identity is retained, and
|
|
270
|
+
// the read of it reports the failure rather than the resolution
|
|
271
|
+
// silently falling to the canonical default.
|
|
272
|
+
return {
|
|
273
|
+
path: agentPath,
|
|
274
|
+
source: "pi-coding-agent-dir-unprobed",
|
|
275
|
+
existsProbeFailed: {
|
|
276
|
+
path: agentPath,
|
|
277
|
+
errorClassName: errorClassName(probeError),
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { path: legacyDefault, source: "canonical-default" };
|
|
283
|
+
}
|
|
284
|
+
let memoizedGlobalConfigResolution;
|
|
285
|
+
function globalConfigEnvFingerprint() {
|
|
286
|
+
return JSON.stringify([
|
|
287
|
+
process.env.PI_LENS_CONFIG_PATH,
|
|
288
|
+
process.env.PI_CODING_AGENT_DIR,
|
|
289
|
+
]);
|
|
290
|
+
}
|
|
291
|
+
export function getProductionGlobalConfigResolution() {
|
|
292
|
+
const fingerprint = globalConfigEnvFingerprint();
|
|
293
|
+
if (memoizedGlobalConfigResolution?.fingerprint !== fingerprint) {
|
|
294
|
+
memoizedGlobalConfigResolution = {
|
|
295
|
+
fingerprint,
|
|
296
|
+
resolution: resolveGlobalConfigLocation(),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return memoizedGlobalConfigResolution.resolution;
|
|
300
|
+
}
|
|
301
|
+
/** Test-only: drop the memoized production resolution so the next call re-probes. */
|
|
302
|
+
export function resetGlobalConfigLocationCache() {
|
|
303
|
+
memoizedGlobalConfigResolution = undefined;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The absolute path of the canonical GLOBAL config (see
|
|
307
|
+
* `GlobalConfigLocationSource` for the full resolution order).
|
|
145
308
|
*
|
|
146
309
|
* Lives here rather than in `lens-config.ts` (#2426 review round 3, S1),
|
|
147
310
|
* which is where it originated: this module is the one that already states
|
|
@@ -152,9 +315,74 @@ export function configSearchDirs(startDir, homeDir = os.homedir()) {
|
|
|
152
315
|
* `project-lens-config.ts`. `lens-config.ts` re-exports this so its own,
|
|
153
316
|
* pre-existing import sites are unaffected.
|
|
154
317
|
*/
|
|
155
|
-
export function getPiLensGlobalConfigPath(homeDir
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
318
|
+
export function getPiLensGlobalConfigPath(homeDir) {
|
|
319
|
+
return homeDir === undefined
|
|
320
|
+
? getProductionGlobalConfigResolution().path
|
|
321
|
+
: resolveGlobalConfigLocation({ homeDir }).path;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Whether `candidate` matches ANY location the global config tier recognizes —
|
|
325
|
+
* the resolved file, the legacy default, and the agent-dir target.
|
|
326
|
+
*
|
|
327
|
+
* The one predicate every project-config discovery seam consults before it
|
|
328
|
+
* adopts a candidate (#783's nested layering, the upward walk, and the
|
|
329
|
+
* workspace marker index): a file the global tier recognizes must not be
|
|
330
|
+
* discovered a second time as a project config — the discovery loader would
|
|
331
|
+
* full-validate it, ignore every global-only key in it, and advise the user
|
|
332
|
+
* to set those keys in the very file it is ignoring. The check covers the
|
|
333
|
+
* POTENTIAL set, not just whichever file currently won the read: a second
|
|
334
|
+
* recognized file that exists but lost the tier walk (the grandfathered
|
|
335
|
+
* legacy file beside an adopted agent-dir file, say) is equally not a project
|
|
336
|
+
* config.
|
|
337
|
+
*
|
|
338
|
+
* Comparison is alias-anchored on BOTH sides (#3251 review M1 round 2):
|
|
339
|
+
* each path is canonicalized through `realpathOrResolve` (resolving existing
|
|
340
|
+
* symlink aliases; a nonexistent path falls back to its resolved spelling —
|
|
341
|
+
* the documented fallback) and then `normalizeFilePath` (separator-agnostic,
|
|
342
|
+
* case-insensitive on Windows, casing-adoption). A symlinked-home alias of a
|
|
343
|
+
* recognized location is therefore the same identity as its real spelling,
|
|
344
|
+
* and a case-folded or separator-differing Windows spelling cannot dodge the
|
|
345
|
+
* refusal either.
|
|
346
|
+
*/
|
|
347
|
+
export function isResolvedGlobalConfigPath(candidate) {
|
|
348
|
+
return recognizedGlobalConfigPaths().has(canonicalPathIdentity(candidate));
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Canonical path identity for one candidate: realpath-anchored so an
|
|
352
|
+
* existing symlink alias resolves to the same identity as its real spelling
|
|
353
|
+
* (#3251 review M1 round 2), with `realpathOrResolve`'s documented
|
|
354
|
+
* nonexistent-path fallback (the resolved spelling, dot-folded and
|
|
355
|
+
* case-normalized by `normalizeFilePath` — which pays its own `realpath`
|
|
356
|
+
* only when the path exists). The discovery seams consult this predicate
|
|
357
|
+
* per config candidate, and the env-derived recognized set is canonicalized
|
|
358
|
+
* with the SAME helper once per env fingerprint, so both sides of the
|
|
359
|
+
* comparison share one identity function.
|
|
360
|
+
*/
|
|
361
|
+
export function canonicalPathIdentity(candidate) {
|
|
362
|
+
// Shape-aware (defect shape 2): a Windows-shaped path is resolved with
|
|
363
|
+
// `path.win32` — the host resolver on POSIX treats a drive-letter path as
|
|
364
|
+
// RELATIVE and mangles it before any comparison could run. On a real
|
|
365
|
+
// Windows host this arm is the native one, and `normalizeFilePath`'s
|
|
366
|
+
// Windows branch does the alias resolution itself (realpathSync.native
|
|
367
|
+
// plus canonical-casing adoption).
|
|
368
|
+
if (isWindowsPath(candidate)) {
|
|
369
|
+
return normalizeFilePath(path.win32.resolve(candidate));
|
|
370
|
+
}
|
|
371
|
+
return normalizeFilePath(realpathOrResolve(candidate));
|
|
372
|
+
}
|
|
373
|
+
let recognizedCache;
|
|
374
|
+
function recognizedGlobalConfigPaths() {
|
|
375
|
+
const fingerprint = globalConfigEnvFingerprint();
|
|
376
|
+
if (recognizedCache?.fingerprint !== fingerprint) {
|
|
377
|
+
const paths = new Set();
|
|
378
|
+
const override = process.env.PI_LENS_CONFIG_PATH;
|
|
379
|
+
if (override)
|
|
380
|
+
paths.add(canonicalPathIdentity(path.resolve(override)));
|
|
381
|
+
paths.add(canonicalPathIdentity(path.join(os.homedir(), ".pi-lens", CANONICAL_GLOBAL_CONFIG_FILE)));
|
|
382
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
383
|
+
if (agentDir)
|
|
384
|
+
paths.add(canonicalPathIdentity(agentConfigPathFor(agentDir)));
|
|
385
|
+
recognizedCache = { fingerprint, paths };
|
|
386
|
+
}
|
|
387
|
+
return recognizedCache.paths;
|
|
160
388
|
}
|
|
@@ -60,7 +60,7 @@ import * as fs from "node:fs";
|
|
|
60
60
|
import * as os from "node:os";
|
|
61
61
|
import * as path from "node:path";
|
|
62
62
|
import { DEPRECATED_CONFIG_SURFACES, } from "./config-diagnostic-codes.js";
|
|
63
|
-
import { CANONICAL_GLOBAL_CONFIG_FILE, CANONICAL_PROJECT_CONFIG_FILE, GLOBAL_CONFIG_LOCATIONS, LEGACY_ROOT_LSP_KEYS, LSP_NAMESPACE_KEY, PROJECT_CONFIG_LOCATIONS, configSearchDirs, } from "./config-locations.js";
|
|
63
|
+
import { CANONICAL_GLOBAL_CONFIG_FILE, CANONICAL_PROJECT_CONFIG_FILE, GLOBAL_CONFIG_LOCATIONS, isResolvedGlobalConfigPath, LEGACY_ROOT_LSP_KEYS, LSP_NAMESPACE_KEY, PROJECT_CONFIG_LOCATIONS, configSearchDirs, } from "./config-locations.js";
|
|
64
64
|
// The core's halves are imported directly rather than through
|
|
65
65
|
// `config-core/index.js` (#2426). The barrel re-exports the whole vocabulary
|
|
66
66
|
// including `process-spec.js`, which reaches `project-trust.js` and the
|
|
@@ -126,6 +126,12 @@ function collectDocuments(dir, locations, tier, onReadError) {
|
|
|
126
126
|
const documents = [];
|
|
127
127
|
for (const location of locations) {
|
|
128
128
|
const file = path.join(dir, location.relativePath);
|
|
129
|
+
// The global tier reads its file by absolute path; a candidate that IS
|
|
130
|
+
// that file is refused rather than adopted as a project/nested-project
|
|
131
|
+
// document, which would double-read and double-validate it
|
|
132
|
+
// (global-config-location PR, refs #2457).
|
|
133
|
+
if (tier !== "global" && isResolvedGlobalConfigPath(file))
|
|
134
|
+
continue;
|
|
129
135
|
const outcome = readConfigDocument(file);
|
|
130
136
|
if (outcome.status === "missing")
|
|
131
137
|
continue;
|
|
@@ -37,10 +37,21 @@ let ledgerGeneration = 0;
|
|
|
37
37
|
export function getDegradationLedgerGeneration() {
|
|
38
38
|
return ledgerGeneration;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Test-only population probe for bounded-container regressions. Keep this
|
|
42
|
+
* read-only and deliberately expose sizes, not the retained identities.
|
|
43
|
+
*/
|
|
44
|
+
export function _getDegradationLedgerStateForTests() {
|
|
45
|
+
return {
|
|
46
|
+
onceKeys: onceKeys.size,
|
|
47
|
+
tallies: tallies.size,
|
|
48
|
+
retainedEntries: [...groups.values()].reduce((total, group) => total + group.entries.length, 0),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
40
51
|
export function recordDegradation(record) {
|
|
41
52
|
try {
|
|
42
53
|
const kind = boundedKind(record.kind);
|
|
43
|
-
const subject =
|
|
54
|
+
const subject = subjectForLedger(record.subject);
|
|
44
55
|
const reason = truncateForLedger(record.reason);
|
|
45
56
|
let group = groups.get(kind);
|
|
46
57
|
if (!group) {
|
|
@@ -67,7 +78,7 @@ export function recordDegradation(record) {
|
|
|
67
78
|
export function recordDegradationOnce(record) {
|
|
68
79
|
try {
|
|
69
80
|
const kind = boundedKind(record.kind);
|
|
70
|
-
const subject =
|
|
81
|
+
const subject = subjectForLedger(record.subject);
|
|
71
82
|
const key = `${kind}\0${subject}`;
|
|
72
83
|
if (onceKeys.has(key))
|
|
73
84
|
return;
|
|
@@ -96,7 +107,7 @@ export function recordDegradationOnce(record) {
|
|
|
96
107
|
export function incrementDegradationCount(record) {
|
|
97
108
|
try {
|
|
98
109
|
const kind = boundedKind(record.kind);
|
|
99
|
-
const subject =
|
|
110
|
+
const subject = subjectForLedger(record.subject);
|
|
100
111
|
const reason = truncateForLedger(record.reason);
|
|
101
112
|
const key = `${kind}\0${subject}`;
|
|
102
113
|
const count = (tallies.get(key) ?? 0) + 1;
|
|
@@ -172,6 +183,25 @@ function boundLedgerMetadata(metadata) {
|
|
|
172
183
|
function isPowerOfTwo(value) {
|
|
173
184
|
return value > 0 && (value & (value - 1)) === 0;
|
|
174
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* A subject is an IDENTITY: it is the row's discriminator, the `filePath` of
|
|
188
|
+
* its durable latency row, and the text `renderDegradationLines` prints after
|
|
189
|
+
* the count. A blank one names nothing — `⚠ kind: 1 — : reason` — so blank and
|
|
190
|
+
* missing are the same case and both read `unknown` (#3389 verify round 2,
|
|
191
|
+
* F3395-02: a socket error carrying `code: ""` wrote an empty subject).
|
|
192
|
+
*
|
|
193
|
+
* This is deliberately NOT `normalizeForLedger`'s job, though every ledger
|
|
194
|
+
* field passes through it: that normalizer keeps falsy primitives on purpose
|
|
195
|
+
* (`tests/clients/ledger-bounds.test.ts` "keeps primitives, including falsy
|
|
196
|
+
* ones"), because a metadata VALUE of `""` or `0` is data, not absence. Only
|
|
197
|
+
* the identity fields fold blank into missing, and `0`/`false` still name
|
|
198
|
+
* something here too — the check reads the NORMALIZED text, never the caller's
|
|
199
|
+
* truthiness.
|
|
200
|
+
*/
|
|
201
|
+
function subjectForLedger(value) {
|
|
202
|
+
const subject = truncateForLedger(value);
|
|
203
|
+
return subject.trim() === "" ? "unknown" : subject;
|
|
204
|
+
}
|
|
175
205
|
function boundedKind(value) {
|
|
176
206
|
const kind = truncateForLedger(value);
|
|
177
207
|
if (groups.has(kind) || kind === OVERFLOW_KIND)
|