pi-lens 3.8.71 → 3.8.73

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 (83) hide show
  1. package/CHANGELOG.md +152 -1
  2. package/README.md +31 -22
  3. package/config/markdownlint/core.json +6 -0
  4. package/dist/clients/atomic-write.js +83 -0
  5. package/dist/clients/config-enabled-shape.js +39 -0
  6. package/dist/clients/deps/js-yaml.js +1 -1
  7. package/dist/clients/diagnostic-dispositions.js +64 -2
  8. package/dist/clients/dispatch/integration.js +2 -1
  9. package/dist/clients/dispatch/runners/ast-grep-napi.js +60 -7
  10. package/dist/clients/dispatch/runners/lsp.js +54 -25
  11. package/dist/clients/dispatch/runners/markdownlint.js +7 -4
  12. package/dist/clients/dispatch/runners/pyright.js +1 -0
  13. package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
  14. package/dist/clients/env-utils.js +38 -0
  15. package/dist/clients/file-utils.js +21 -3
  16. package/dist/clients/instance-registry.js +32 -21
  17. package/dist/clients/jscpd-client.js +52 -39
  18. package/dist/clients/language-profile.js +12 -19
  19. package/dist/clients/lens-config.js +106 -27
  20. package/dist/clients/lens-engine.js +129 -3
  21. package/dist/clients/lsp/cascade-tier.js +1 -38
  22. package/dist/clients/lsp/client.js +3 -1
  23. package/dist/clients/lsp/index.js +707 -109
  24. package/dist/clients/lsp/server-strategies.js +3 -224
  25. package/dist/clients/lsp/server.js +1 -1
  26. package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
  27. package/dist/clients/lsp/wait-policy/classification.js +63 -0
  28. package/dist/clients/lsp/wait-policy/index.js +10 -0
  29. package/dist/clients/lsp/wait-policy/strategies.js +244 -0
  30. package/dist/clients/lsp-budget.js +54 -16
  31. package/dist/clients/mcp/analyze.js +1 -1
  32. package/dist/clients/mcp/host-shim.js +12 -8
  33. package/dist/clients/mcp/ipc.js +118 -0
  34. package/dist/clients/mcp/session.js +2 -2
  35. package/dist/clients/pipeline.js +11 -6
  36. package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
  37. package/dist/clients/project-diagnostics/scanner.js +37 -6
  38. package/dist/clients/project-lens-config.js +167 -3
  39. package/dist/clients/project-report.js +627 -0
  40. package/dist/clients/project-scale.js +239 -0
  41. package/dist/clients/project-scan-policy.js +11 -1
  42. package/dist/clients/recent-touches.js +6 -11
  43. package/dist/clients/review-graph/builder.js +170 -13
  44. package/dist/clients/review-graph/import-resolvers.js +149 -13
  45. package/dist/clients/review-graph/tsconfig-paths.js +242 -0
  46. package/dist/clients/review-graph/workspace-modules.js +21 -9
  47. package/dist/clients/rules-scanner.js +15 -2
  48. package/dist/clients/runtime-agent-end.js +104 -17
  49. package/dist/clients/runtime-config.js +4 -0
  50. package/dist/clients/runtime-coordinator.js +50 -1
  51. package/dist/clients/runtime-session.js +41 -3
  52. package/dist/clients/runtime-tool-result.js +4 -3
  53. package/dist/clients/runtime-turn.js +4 -1
  54. package/dist/clients/safe-spawn.js +264 -40
  55. package/dist/clients/session-lifecycle.js +36 -0
  56. package/dist/clients/session-state-store.js +8 -3
  57. package/dist/clients/source-filter.js +136 -61
  58. package/dist/clients/source-walker.js +89 -0
  59. package/dist/clients/startup-scan.js +189 -111
  60. package/dist/clients/subagent-mode.js +14 -3
  61. package/dist/clients/todo-scanner.js +5 -1
  62. package/dist/clients/tool-policy.js +26 -4
  63. package/dist/clients/warm-attach.js +204 -0
  64. package/dist/clients/word-index.js +36 -5
  65. package/dist/clients/workspace-topology.js +266 -0
  66. package/dist/index.js +15205 -11330
  67. package/dist/mcp/server.js +125 -10
  68. package/dist/tools/ast-grep-search.js +21 -0
  69. package/dist/tools/lens-diagnostic-mark.js +198 -7
  70. package/dist/tools/lens-diagnostics.js +119 -21
  71. package/dist/tools/lsp-diagnostics.js +143 -10
  72. package/dist/tools/lsp-navigation.js +11 -2
  73. package/dist/tools/project-report.js +94 -0
  74. package/dist/tools/symbol-search.js +32 -8
  75. package/docs/environment-variables.md +10 -0
  76. package/docs/features.md +7 -1
  77. package/docs/globalconfig.md +70 -0
  78. package/docs/servercapabilities.md +1 -1
  79. package/docs/usage.md +13 -2
  80. package/package.json +8 -9
  81. package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
  82. package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
  83. package/scripts/download-grammars.js +1 -0
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
+ import { writeFileAtomic, writeFileAtomicAsync } from "./atomic-write.js";
25
26
  import { getGlobalPiLensDir } from "./file-utils.js";
26
27
  // #735: reuse the #449/#525 reaper's exact conservative liveness check
27
28
  // (`process.kill(pid, 0)`, ESRCH-only-means-dead) rather than inventing a
@@ -34,6 +35,7 @@ import { getGlobalPiLensDir } from "./file-utils.js";
34
35
  // import is actually invoked.
35
36
  import { realIsPidAlive } from "./instance-reaper.js";
36
37
  import { normalizeFilePath } from "./path-utils.js";
38
+ import { getSubagentIdentity, isSubagentSession, } from "./subagent-mode.js";
37
39
  function registryPath() {
38
40
  return path.join(getGlobalPiLensDir(), "instances.json");
39
41
  }
@@ -87,44 +89,32 @@ export async function readInstanceRegistry() {
87
89
  const file = await readRegistryAsync();
88
90
  return file.instances;
89
91
  }
90
- // --- Write (atomic tmp + rename, same pattern as review-graph/builder.ts) ---
92
+ // --- Write (atomic tmp + rename via clients/atomic-write.ts, #762) ---
91
93
  async function writeRegistryAsync(file) {
92
94
  const dir = getGlobalPiLensDir();
93
95
  const target = registryPath();
94
- const tmpPath = `${target}.tmp-${process.pid}`;
95
96
  try {
96
97
  await fs.promises.mkdir(dir, { recursive: true });
97
- await fs.promises.writeFile(tmpPath, JSON.stringify(file), "utf-8");
98
- await fs.promises.rename(tmpPath, target);
99
98
  }
100
99
  catch {
101
- // Best-effort observability substrate — a failed write just means this
100
+ // Best-effort observability substrate — a failed mkdir just means this
102
101
  // update is lost, never a thrown error for the caller.
103
- try {
104
- await fs.promises.rm(tmpPath, { force: true });
105
- }
106
- catch {
107
- // ignore
108
- }
102
+ return;
109
103
  }
104
+ // bestEffort (default): a failed write just means this update is lost,
105
+ // never a thrown error for the caller.
106
+ await writeFileAtomicAsync(target, JSON.stringify(file));
110
107
  }
111
108
  function writeRegistrySync(file) {
112
109
  const dir = getGlobalPiLensDir();
113
110
  const target = registryPath();
114
- const tmpPath = `${target}.tmp-${process.pid}`;
115
111
  try {
116
112
  fs.mkdirSync(dir, { recursive: true });
117
- fs.writeFileSync(tmpPath, JSON.stringify(file), "utf-8");
118
- fs.renameSync(tmpPath, target);
119
113
  }
120
114
  catch {
121
- try {
122
- fs.rmSync(tmpPath, { force: true });
123
- }
124
- catch {
125
- // ignore
126
- }
115
+ return;
127
116
  }
117
+ writeFileAtomic(target, JSON.stringify(file));
128
118
  }
129
119
  // --- Mutations (all read-modify-write whole file) ---
130
120
  /** Create/overwrite this process's entry. */
@@ -137,6 +127,15 @@ export async function registerInstance(projectRoot) {
137
127
  const now = new Date().toISOString();
138
128
  const others = file.instances.filter((entry) => entry.pid !== pid);
139
129
  const existing = file.instances.find((entry) => entry.pid === pid);
130
+ const identity = isSubagentSession() ? getSubagentIdentity() : undefined;
131
+ const subagent = identity
132
+ ? {
133
+ marker: identity.marker,
134
+ agentType: identity.agentName,
135
+ parentPid: identity.parentPid,
136
+ runId: identity.runId,
137
+ }
138
+ : undefined;
140
139
  others.push({
141
140
  pid,
142
141
  startedAt: existing?.startedAt ?? now,
@@ -145,6 +144,7 @@ export async function registerInstance(projectRoot) {
145
144
  lspChildCount: existing?.lspChildren?.length ?? 0,
146
145
  rssBytes: process.memoryUsage().rss,
147
146
  heartbeatAt: now,
147
+ ...(subagent ? { subagent } : {}),
148
148
  });
149
149
  await writeRegistryAsync({ instances: others });
150
150
  }
@@ -226,8 +226,19 @@ export async function recordLspChild(entry) {
226
226
  }
227
227
  await writeRegistryAsync(file);
228
228
  }
229
+ // LSP client shutdown intentionally does not await registry removal (the
230
+ // process-exiting path must stay non-blocking), but concurrent removals still
231
+ // need to serialize their read-modify-write sequence or siblings can be lost
232
+ // to last-writer-wins. Process kills remain fully concurrent; this queue only
233
+ // covers the small best-effort registry mutation.
234
+ let lspChildRemovalTail = Promise.resolve();
229
235
  /** Remove an LSP child (by pid) from this process's entry. */
230
- export async function removeLspChild(pid) {
236
+ export function removeLspChild(pid) {
237
+ const removal = lspChildRemovalTail.then(() => removeLspChildNow(pid));
238
+ lspChildRemovalTail = removal.catch(() => { });
239
+ return removal;
240
+ }
241
+ async function removeLspChildNow(pid) {
231
242
  if (!isInstanceRegistryEnabled())
232
243
  return;
233
244
  const selfPid = process.pid;
@@ -11,9 +11,12 @@ import * as fs from "node:fs";
11
11
  import { mkdtempSync } from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
- import { getExcludedDirGlobs, getGlobalPiLensDir, getProjectIgnoreGlobs, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
14
+ import { getExcludedDirGlobs, getGlobalPiLensDir, getProjectIgnoreGlobs, getProjectIgnoreMatcher, } from "./file-utils.js";
15
15
  import { findNodeToolBinary } from "./package-manager.js";
16
+ import { isAtOrAboveHomeDir } from "./path-utils.js";
17
+ import { getJscpdMaxEntriesDerived } from "./project-scale.js";
16
18
  import { safeSpawnAsync } from "./safe-spawn.js";
19
+ import { shouldRecurseIntoDir, walkTreeStackSync } from "./source-walker.js";
17
20
  const EMPTY_RESULT = {
18
21
  success: false,
19
22
  clones: [],
@@ -45,46 +48,42 @@ export class JscpdClient {
45
48
  * them.
46
49
  */
47
50
  hasSourceFilesRecursive(rootDir) {
48
- const stack = [rootDir];
49
51
  const ignoreMatcher = getProjectIgnoreMatcher(rootDir);
50
- let visited = 0;
51
- const MAX_ENTRIES = 6000;
52
- while (stack.length > 0 && visited < MAX_ENTRIES) {
53
- const dir = stack.pop();
54
- if (!dir)
55
- continue;
56
- let entries;
57
- try {
58
- entries = fs.readdirSync(dir, { withFileTypes: true });
59
- }
60
- catch {
61
- continue;
52
+ const state = { visited: 0 };
53
+ // #776: derived from the `maxProjectFiles` scale knob (project-scale.ts),
54
+ // reproducing this same 6,000 default at the default base.
55
+ const MAX_ENTRIES = getJscpdMaxEntriesDerived(rootDir);
56
+ // #761: the traversal loop, readdir-safety, and directory-recursion
57
+ // decision are the shared `walkTreeStackSync` engine; this client keeps
58
+ // its own per-entry policy (skip ALL symlinks — files too — before the
59
+ // dir/file branch, its own multi-language source regex, and a
60
+ // per-directory entry budget via `shouldStop`, matching the pre-#761
61
+ // `while (stack.length > 0 && visited < MAX_ENTRIES)` condition: the
62
+ // current directory's entry loop always finishes, but no further
63
+ // directory is popped once the budget is spent).
64
+ return walkTreeStackSync(rootDir, (entry, fullPath) => {
65
+ state.visited += 1;
66
+ if (entry.isSymbolicLink())
67
+ return "skip";
68
+ if (entry.isDirectory()) {
69
+ return shouldRecurseIntoDir(entry, fullPath, {
70
+ ignoreMatcher,
71
+ followSymlinks: true,
72
+ })
73
+ ? "recurse"
74
+ : "skip";
62
75
  }
63
- for (const entry of entries) {
64
- visited += 1;
65
- if (entry.isSymbolicLink())
66
- continue;
67
- if (entry.isDirectory()) {
68
- const fullPath = path.join(dir, entry.name);
69
- if (isExcludedDirName(entry.name))
70
- continue;
71
- if (ignoreMatcher.isIgnored(fullPath, true))
72
- continue;
73
- stack.push(fullPath);
74
- continue;
75
- }
76
- if (!entry.isFile())
77
- continue;
78
- if (ignoreMatcher.isIgnored(path.join(dir, entry.name), false))
79
- continue;
80
- if (/\.(ts|tsx|js|jsx|mjs|cjs|py|pyi|java|go|rs|rb|php|swift|kt|kts|dart|lua|scala|c|h|cpp|cc|cxx|hpp|hxx|cs|m|mm)$/.test(entry.name)) {
81
- if (entry.name.endsWith(".d.ts"))
82
- continue;
83
- return true;
84
- }
76
+ if (!entry.isFile())
77
+ return "skip";
78
+ if (ignoreMatcher.isIgnored(fullPath, false))
79
+ return "skip";
80
+ if (/\.(ts|tsx|js|jsx|mjs|cjs|py|pyi|java|go|rs|rb|php|swift|kt|kts|dart|lua|scala|c|h|cpp|cc|cxx|hpp|hxx|cs|m|mm)$/.test(entry.name)) {
81
+ if (entry.name.endsWith(".d.ts"))
82
+ return "skip";
83
+ return "stop";
85
84
  }
86
- }
87
- return false;
85
+ return "skip";
86
+ }, { shouldStop: () => state.visited >= MAX_ENTRIES });
88
87
  }
89
88
  /**
90
89
  * Check if jscpd is available, auto-install if not
@@ -144,9 +143,23 @@ export class JscpdClient {
144
143
  * Scan a directory for duplicate code blocks.
145
144
  * Uses a temp output dir to capture JSON report.
146
145
  * @param isTsProject - If true, excludes .js files (they're compiled artifacts in TS projects)
146
+ *
147
+ * Root contract (#747/#250): unlike `KnipClient`/`DeadCodeClient`, this
148
+ * client does NOT resolve a project root via `findNearestMarkerRoot`, so it
149
+ * has historically relied on every caller to guard the scan root itself. The
150
+ * `isAtOrAboveHomeDir` refusal below is belt-and-braces internal protection
151
+ * so a FUTURE caller that forgets that contract can't spawn a whole-$HOME
152
+ * jscpd walk (the observed OOM: a jscpd run from a WSL home reached 44 GB
153
+ * RSS). `options.homeDir` overrides `os.homedir()` for tests.
147
154
  */
148
- async scan(cwd, minLines = 5, minTokens = 50, isTsProject = false) {
155
+ async scan(cwd, minLines = 5, minTokens = 50, isTsProject = false, options = {}) {
149
156
  const targetDir = path.resolve(cwd);
157
+ // #747/#250: never walk from a cwd at/above $HOME — from there jscpd's
158
+ // tokenizer would run across every unrelated repo under home.
159
+ if (isAtOrAboveHomeDir(targetDir, options.homeDir)) {
160
+ this.log(`Refusing scan of unsafe root at/above home: ${targetDir}`);
161
+ return { ...EMPTY_RESULT };
162
+ }
150
163
  // Return early for non-existent or empty directories before probing/installing.
151
164
  if (!fs.existsSync(targetDir)) {
152
165
  return { ...EMPTY_RESULT };
@@ -5,6 +5,7 @@ import { getProjectIgnoreMatcher } from "./file-utils.js";
5
5
  import { LANGUAGE_POLICY, } from "./language-policy.js";
6
6
  import { getSourceFiles } from "./scan-utils.js";
7
7
  import { readDirEntriesSafe, shouldRecurseIntoDir } from "./source-walker.js";
8
+ import { findNearestDirWithAnyBasename } from "./workspace-topology.js";
8
9
  export const SUPPORTED_FILE_KINDS = [
9
10
  "jsts",
10
11
  "python",
@@ -114,24 +115,6 @@ const ROOT_MARKERS_BY_KIND = {
114
115
  nix: ["flake.nix"],
115
116
  toml: ["pyproject.toml", "Cargo.toml", "taplo.toml"],
116
117
  };
117
- function nearestRoot(start, markers) {
118
- let dir = path.resolve(start);
119
- const { root } = path.parse(dir);
120
- while (true) {
121
- for (const marker of markers) {
122
- if (fs.existsSync(path.join(dir, marker))) {
123
- return dir;
124
- }
125
- }
126
- if (dir === root)
127
- break;
128
- const parent = path.dirname(dir);
129
- if (parent === dir)
130
- break;
131
- dir = parent;
132
- }
133
- return undefined;
134
- }
135
118
  // Process-lifetime memo keyed on projectRoot. Only populated when the
136
119
  // caller did not pass an explicit `sourceFiles` array — the explicit-array
137
120
  // case is used by the warmup pipeline to inject pre-collected files and
@@ -225,7 +208,17 @@ export function resolveLanguageRootForFile(filePath, workspaceRoot) {
225
208
  if (!markers || markers.length === 0) {
226
209
  return path.resolve(workspaceRoot);
227
210
  }
228
- const found = nearestRoot(startDir, markers);
211
+ // #807: nearest-marker discovery now shares workspace-topology.ts's single
212
+ // walk/cache/invalidation seam instead of this file's own per-kind
213
+ // `existsSync` climb loop. `findNearestDirWithAnyBasename` additionally
214
+ // rejects a marker at/above `$HOME` (the pre-#807 loop didn't), matching
215
+ // the AGENTS.md walker invariant — harmless here because the
216
+ // workspace-relative check right below already discards any candidate
217
+ // outside `workspaceRoot`, and `workspaceRoot` is never at/above `$HOME`
218
+ // in practice. This keeps resolveLanguageRootForFile's OWN stricter
219
+ // workspace-relative policy check unchanged: the topology service only
220
+ // supplies discovery, not this function's policy.
221
+ const found = findNearestDirWithAnyBasename(startDir, markers);
229
222
  if (!found)
230
223
  return path.resolve(workspaceRoot);
231
224
  const workspace = path.resolve(workspaceRoot);
@@ -1,18 +1,38 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { parseEnabledShape } from "./config-enabled-shape.js";
5
+ import { findNestedProjectMutationValue, } from "./project-lens-config.js";
4
6
  export function getPiLensGlobalConfigPath(homeDir = os.homedir()) {
5
7
  const override = process.env.PI_LENS_CONFIG_PATH;
6
8
  if (override)
7
9
  return path.resolve(override);
8
10
  return path.join(homeDir, ".pi-lens", "config.json");
9
11
  }
12
+ const warnedInvalidGlobalConfigs = new Set();
13
+ /**
14
+ * Same warn-once-per-(path, reason) contract as project-lens-config.ts's
15
+ * `warnInvalidConfigOnce` — a malformed global config value is logged once
16
+ * and then treated as absent, rather than silently dropped (#792).
17
+ */
18
+ function warnInvalidGlobalConfigOnce(configPath, reason) {
19
+ const key = `${configPath}:${reason}`;
20
+ if (warnedInvalidGlobalConfigs.has(key))
21
+ return;
22
+ warnedInvalidGlobalConfigs.add(key);
23
+ console.error(`[pi-lens] ignoring invalid global config ${configPath}: ${reason}`);
24
+ }
25
+ /** For tests that need to force the warn-once cache to reset between cases. */
26
+ export function resetGlobalConfigWarnCache() {
27
+ warnedInvalidGlobalConfigs.clear();
28
+ }
10
29
  export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath()) {
11
30
  try {
12
31
  const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
13
32
  if (!parsed || typeof parsed !== "object")
14
33
  return undefined;
15
34
  const raw = parsed;
35
+ const warnInvalid = (reason) => warnInvalidGlobalConfigOnce(configPath, reason);
16
36
  const dispatchRaw = raw.dispatch;
17
37
  const dispatch = dispatchRaw && typeof dispatchRaw === "object"
18
38
  ? dispatchRaw
@@ -21,19 +41,17 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
21
41
  const widget = widgetRaw && typeof widgetRaw === "object"
22
42
  ? widgetRaw
23
43
  : undefined;
24
- const formatRaw = raw.format;
25
- const format = formatRaw && typeof formatRaw === "object"
26
- ? formatRaw
44
+ const format = parseEnabledShape(raw.format, "format", warnInvalid);
45
+ const autofix = parseEnabledShape(raw.autofix, "autofix", warnInvalid);
46
+ const formatModeRaw = raw.format;
47
+ const formatModeSource = formatModeRaw && typeof formatModeRaw === "object"
48
+ ? formatModeRaw
27
49
  : undefined;
28
50
  const actionableWarningsRaw = raw.actionableWarnings;
29
51
  const actionableWarnings = actionableWarningsRaw && typeof actionableWarningsRaw === "object"
30
52
  ? actionableWarningsRaw
31
53
  : undefined;
32
- const actionableWarningsAutoFixRaw = actionableWarnings?.autoFix;
33
- const actionableWarningsAutoFix = actionableWarningsAutoFixRaw &&
34
- typeof actionableWarningsAutoFixRaw === "object"
35
- ? actionableWarningsAutoFixRaw
36
- : undefined;
54
+ const actionableWarningsAutoFix = parseEnabledShape(actionableWarnings?.autoFix, "actionableWarnings.autoFix", warnInvalid);
37
55
  const contextInjectionRaw = raw.contextInjection;
38
56
  const contextInjection = contextInjectionRaw && typeof contextInjectionRaw === "object"
39
57
  ? contextInjectionRaw
@@ -42,8 +60,9 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
42
60
  const turnSummary = turnSummaryRaw && typeof turnSummaryRaw === "object"
43
61
  ? turnSummaryRaw
44
62
  : undefined;
45
- const formatMode = format?.mode === "immediate" || format?.mode === "deferred"
46
- ? format.mode
63
+ const formatMode = formatModeSource?.mode === "immediate" ||
64
+ formatModeSource?.mode === "deferred"
65
+ ? formatModeSource.mode
47
66
  : undefined;
48
67
  const ignore = Array.isArray(raw.ignore)
49
68
  ? raw.ignore.filter((p) => typeof p === "string")
@@ -66,10 +85,11 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
66
85
  : undefined,
67
86
  format: format
68
87
  ? {
69
- enabled: typeof format.enabled === "boolean" ? format.enabled : undefined,
88
+ enabled: format.enabled,
70
89
  mode: formatMode,
71
90
  }
72
91
  : undefined,
92
+ autofix: autofix ? { enabled: autofix.enabled } : undefined,
73
93
  actionableWarnings: actionableWarnings
74
94
  ? {
75
95
  enabled: typeof actionableWarnings.enabled === "boolean"
@@ -82,11 +102,7 @@ export function loadPiLensGlobalConfig(configPath = getPiLensGlobalConfigPath())
82
102
  ? actionableWarnings.deltaOnly
83
103
  : undefined,
84
104
  autoFix: actionableWarningsAutoFix
85
- ? {
86
- enabled: typeof actionableWarningsAutoFix.enabled === "boolean"
87
- ? actionableWarningsAutoFix.enabled
88
- : undefined,
89
- }
105
+ ? { enabled: actionableWarningsAutoFix.enabled }
90
106
  : undefined,
91
107
  }
92
108
  : undefined,
@@ -119,6 +135,9 @@ export function getGlobalWidgetDefaultVisible(configPath) {
119
135
  export function getGlobalAutoformatEnabled(configPath) {
120
136
  return loadPiLensGlobalConfig(configPath)?.format?.enabled !== false;
121
137
  }
138
+ export function getGlobalAutofixEnabled(configPath) {
139
+ return loadPiLensGlobalConfig(configPath)?.autofix?.enabled !== false;
140
+ }
122
141
  export function getGlobalImmediateFormatDefault(configPath) {
123
142
  return loadPiLensGlobalConfig(configPath)?.format?.mode === "immediate";
124
143
  }
@@ -128,32 +147,92 @@ export function getGlobalContextInjectionEnabled(configPath) {
128
147
  export function getGlobalTurnSummaryEnabled(configPath) {
129
148
  return loadPiLensGlobalConfig(configPath)?.turnSummary?.enabled === true;
130
149
  }
131
- export function resolvePiLensFlag(name, value, config) {
150
+ /**
151
+ * Resolve a flag AND report which config tier decided it — same precedence
152
+ * as {@link resolvePiLensFlag} (which now delegates here), just also
153
+ * returning the `source` so callers can log e.g.
154
+ * "(--no-autofix, source=project)" instead of a bare boolean (#792).
155
+ *
156
+ * Precedence (unchanged, maintainer decision — project wins over global,
157
+ * including re-enabling; only an explicit CLI disabling flag outranks
158
+ * project config): cli → project → global → default.
159
+ */
160
+ export function resolvePiLensFlagWithSource(name, value, config, projectConfig, editedFilePath, projectRoot) {
132
161
  if (value)
133
- return value;
162
+ return { value, source: "cli" };
163
+ const mutationFlag = name === "no-autoformat" ||
164
+ name === "no-autofix" ||
165
+ name === "lens-actionable-warning-autofix"
166
+ ? name
167
+ : undefined;
168
+ const nested = mutationFlag && editedFilePath && projectRoot
169
+ ? findNestedProjectMutationValue(mutationFlag, editedFilePath, projectRoot)
170
+ : undefined;
171
+ const nestedSource = nested
172
+ ? path.resolve(nested.dir) === path.resolve(projectRoot)
173
+ ? "project"
174
+ : `nested-project:${nested.dir}`
175
+ : undefined;
134
176
  if (name === "no-autoformat") {
135
- return config?.format?.enabled === false;
177
+ if (nested)
178
+ return { value: !nested.value, source: nestedSource };
179
+ if (projectConfig?.format?.enabled !== undefined) {
180
+ return { value: !projectConfig.format.enabled, source: "project" };
181
+ }
182
+ if (config?.format?.enabled === false) {
183
+ return { value: true, source: "global" };
184
+ }
185
+ return { value: false, source: "default" };
186
+ }
187
+ if (name === "no-autofix") {
188
+ if (nested)
189
+ return { value: !nested.value, source: nestedSource };
190
+ if (projectConfig?.autofix?.enabled !== undefined) {
191
+ return { value: !projectConfig.autofix.enabled, source: "project" };
192
+ }
193
+ if (config?.autofix?.enabled === false) {
194
+ return { value: true, source: "global" };
195
+ }
196
+ return { value: false, source: "default" };
136
197
  }
137
198
  if (name === "immediate-format") {
138
- return config?.format?.mode === "immediate";
199
+ const immediate = config?.format?.mode === "immediate";
200
+ return { value: immediate, source: immediate ? "global" : "default" };
139
201
  }
140
202
  if (name === "lens-actionable-warnings") {
141
- return config?.actionableWarnings?.enabled === true;
203
+ const enabled = config?.actionableWarnings?.enabled === true;
204
+ return { value: enabled, source: enabled ? "global" : "default" };
142
205
  }
143
206
  if (name === "lens-actionable-warning-actions") {
144
- return config?.actionableWarnings?.includeLspCodeActions === true;
207
+ const enabled = config?.actionableWarnings?.includeLspCodeActions === true;
208
+ return { value: enabled, source: enabled ? "global" : "default" };
145
209
  }
146
210
  if (name === "lens-actionable-warning-autofix") {
147
- return config?.actionableWarnings?.autoFix?.enabled === true;
211
+ if (nested)
212
+ return { value: nested.value, source: nestedSource };
213
+ if (projectConfig?.actionableWarnings?.autoFix?.enabled !== undefined) {
214
+ return {
215
+ value: projectConfig.actionableWarnings.autoFix.enabled,
216
+ source: "project",
217
+ };
218
+ }
219
+ const enabled = config?.actionableWarnings?.autoFix?.enabled === true;
220
+ return { value: enabled, source: enabled ? "global" : "default" };
148
221
  }
149
222
  if (name === "lens-actionable-warning-all") {
150
- return config?.actionableWarnings?.deltaOnly === false;
223
+ const all = config?.actionableWarnings?.deltaOnly === false;
224
+ return { value: all, source: all ? "global" : "default" };
151
225
  }
152
226
  if (name === "no-lens-context") {
153
- return config?.contextInjection?.enabled === false;
227
+ const disabled = config?.contextInjection?.enabled === false;
228
+ return { value: disabled, source: disabled ? "global" : "default" };
154
229
  }
155
230
  if (name === "lens-turn-summary") {
156
- return config?.turnSummary?.enabled === true;
231
+ const enabled = config?.turnSummary?.enabled === true;
232
+ return { value: enabled, source: enabled ? "global" : "default" };
157
233
  }
158
- return value;
234
+ return { value, source: "default" };
235
+ }
236
+ export function resolvePiLensFlag(name, value, config, projectConfig, editedFilePath, projectRoot) {
237
+ return resolvePiLensFlagWithSource(name, value, config, projectConfig, editedFilePath, projectRoot).value;
159
238
  }