pi-lens 3.8.67 → 3.8.69
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 +58 -0
- package/README.md +57 -38
- package/dist/clients/agent-nudge.js +262 -0
- package/dist/clients/biome-client.js +2 -2
- package/dist/clients/bus-publish.js +110 -0
- package/dist/clients/deps/ast-grep-napi.js +20 -1
- package/dist/clients/deps/pi-tui.js +4 -1
- package/dist/clients/deps/typebox.js +5 -2
- package/dist/clients/deps/web-tree-sitter.js +22 -1
- package/dist/clients/diagnostic-logger.js +2 -2
- package/dist/clients/diagnostics-publish.js +180 -0
- package/dist/clients/dispatch/dispatcher.js +2 -0
- package/dist/clients/dispatch/integration.js +153 -2
- package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
- package/dist/clients/file-utils.js +20 -7
- package/dist/clients/installer/index.js +32 -1
- package/dist/clients/instance-reaper.js +516 -0
- package/dist/clients/instance-registry.js +238 -0
- package/dist/clients/jscpd-client.js +2 -2
- package/dist/clients/lens-config.js +17 -0
- package/dist/clients/lens-engine.js +44 -10
- package/dist/clients/lsp/cascade-tier.js +254 -0
- package/dist/clients/lsp/client.js +81 -3
- package/dist/clients/lsp/index.js +3 -0
- package/dist/clients/lsp/launch.js +2 -0
- package/dist/clients/lsp/server-strategies.js +71 -0
- package/dist/clients/lsp/server.js +119 -6
- package/dist/clients/mcp/analyze.js +110 -1
- package/dist/clients/module-report.js +163 -18
- package/dist/clients/path-utils.js +25 -0
- package/dist/clients/persist-debounce.js +63 -0
- package/dist/clients/pipeline.js +82 -2
- package/dist/clients/project-diagnostics/extractors.js +30 -4
- package/dist/clients/project-snapshot.js +7 -2
- package/dist/clients/quiet-window.js +168 -0
- package/dist/clients/recent-touches.js +233 -0
- package/dist/clients/review-graph/builder.js +20 -2
- package/dist/clients/runtime-agent-end.js +51 -1
- package/dist/clients/runtime-coordinator.js +21 -0
- package/dist/clients/runtime-session.js +154 -48
- package/dist/clients/runtime-tool-result.js +46 -0
- package/dist/clients/runtime-turn.js +9 -0
- package/dist/clients/session-lifecycle.js +252 -0
- package/dist/clients/sgconfig.js +246 -38
- package/dist/clients/slow-fs.js +137 -0
- package/dist/clients/source-filter.js +62 -14
- package/dist/clients/subagent-mode.js +87 -0
- package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
- package/dist/clients/tui-fit.js +54 -0
- package/dist/clients/turn-summary-render.js +72 -0
- package/dist/clients/turn-summary.js +132 -0
- package/dist/clients/widget-state.js +27 -30
- package/dist/clients/word-index.js +296 -1
- package/dist/index.js +62809 -1633
- package/dist/mcp/build-staleness.js +123 -0
- package/dist/mcp/server.js +377 -43
- package/dist/tools/ast-grep-search.js +1 -1
- package/dist/tools/lens-diagnostics.js +42 -12
- package/dist/tools/lsp-diagnostics.js +117 -4
- package/dist/tools/module-report.js +14 -11
- package/dist/tools/symbol-search.js +110 -0
- package/package.json +3 -2
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
- package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
- package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
- package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
- package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
- package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
- package/dist/clients/tree-sitter-fixer.js +0 -127
|
@@ -116,6 +116,7 @@ function readFilePrefix(filePath, maxBytes = 4096) {
|
|
|
116
116
|
}
|
|
117
117
|
export function createDispatchContext(filePath, cwd, pi, facts, blockingOnly, modifiedRanges) {
|
|
118
118
|
const absoluteFilePath = resolveRunnerPath(cwd, filePath);
|
|
119
|
+
const normalizedProjectRoot = normalizeMapKey(path.resolve(cwd));
|
|
119
120
|
const normalizedCwd = normalizeMapKey(resolveLanguageRootForFile(absoluteFilePath, cwd));
|
|
120
121
|
const normalizedFilePath = normalizeMapKey(absoluteFilePath);
|
|
121
122
|
const kind = detectFileKind(normalizedFilePath);
|
|
@@ -123,6 +124,7 @@ export function createDispatchContext(filePath, cwd, pi, facts, blockingOnly, mo
|
|
|
123
124
|
const projectConfig = loadPiLensProjectConfig(normalizedCwd);
|
|
124
125
|
return {
|
|
125
126
|
filePath: normalizedFilePath,
|
|
127
|
+
projectRoot: normalizedProjectRoot,
|
|
126
128
|
cwd: normalizedCwd,
|
|
127
129
|
kind,
|
|
128
130
|
fileRole,
|
|
@@ -14,10 +14,12 @@ import { TOOL_PLANS } from "./plan.js";
|
|
|
14
14
|
// Re-export latency tracking types and functions
|
|
15
15
|
export { clearLatencyReports, formatLatencyReport, getLatencyReports };
|
|
16
16
|
import * as nodeFs from "node:fs";
|
|
17
|
+
import * as nodePath from "node:path";
|
|
17
18
|
import { fileURLToPath } from "node:url";
|
|
18
19
|
import { formatCascadeNeighborDiagnostics } from "../cascade-format.js";
|
|
19
20
|
import { logCascade } from "../cascade-logger.js";
|
|
20
21
|
import { getDiagnosticTracker } from "../diagnostic-tracker.js";
|
|
22
|
+
import { classifyCascadeWaitTier, isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
|
|
21
23
|
import { getServersForFileWithConfig } from "../lsp/config.js";
|
|
22
24
|
import { getLSPService } from "../lsp/index.js";
|
|
23
25
|
import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
|
|
@@ -28,6 +30,7 @@ import { buildOrUpdateGraph, computeImpactCascade, computeTransitiveImpact, form
|
|
|
28
30
|
import { clearModuleGraphCache } from "../review-graph/workspace-modules.js";
|
|
29
31
|
import { RUNTIME_CONFIG } from "../runtime-config.js";
|
|
30
32
|
import { findCompiledClassesDir, hasJavaBuildDescriptor, } from "../tool-policy.js";
|
|
33
|
+
import { removeWordIndexDocument, updateWordIndexDocument, WORD_INDEX_MAX_BYTES, } from "../word-index.js";
|
|
31
34
|
// Register fact providers. All register eagerly here (the dispatch entry) — the
|
|
32
35
|
// tree-sitter-backed providers included, since the parsing stack loads
|
|
33
36
|
// `web-tree-sitter` lazily inside client.init(), not at module import, so it
|
|
@@ -355,7 +358,11 @@ export function clearReverseDepsIndexCache() {
|
|
|
355
358
|
const CASCADE_TRANSITIVE_DEPTH = Math.max(1, Number.parseInt(process.env.PI_LENS_CASCADE_TRANSITIVE_DEPTH ?? "2", 10) || 2);
|
|
356
359
|
const CASCADE_NEIGHBOUR_BUDGET = Math.max(MAX_FILES, Number.parseInt(process.env.PI_LENS_CASCADE_NEIGHBOUR_BUDGET ?? "40", 10) ||
|
|
357
360
|
40);
|
|
358
|
-
|
|
361
|
+
// Exported (not just module-local) so the MCP warm-analyze seam
|
|
362
|
+
// (clients/mcp/analyze.ts, #536) can gate its own buildOrUpdateGraph call on
|
|
363
|
+
// the SAME file-kind eligibility this cascade path uses — one source of truth
|
|
364
|
+
// for "does this language get graph nodes at all", never a second hardcoded copy.
|
|
365
|
+
export const CASCADE_GRAPH_KINDS = new Set([
|
|
359
366
|
"jsts",
|
|
360
367
|
"python",
|
|
361
368
|
"go",
|
|
@@ -392,8 +399,67 @@ function isIgnoredCascadeNeighbor(filePath, cwd) {
|
|
|
392
399
|
return false;
|
|
393
400
|
}
|
|
394
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* #348 phase 2 per-edit seam: update the warm in-memory word index for one
|
|
404
|
+
* file, mirroring the review graph's per-edit rebuild at the same call site.
|
|
405
|
+
*
|
|
406
|
+
* Rules (each a documented, deliberate simplicity choice, not an oversight):
|
|
407
|
+
* - `wordIndex` null (no index loaded yet) ⇒ no-op. Cold-session handoff is
|
|
408
|
+
* OWNED by phase 1's lifecycle/background build, never invented here — an
|
|
409
|
+
* edit arriving before that build finishes just doesn't update anything;
|
|
410
|
+
* the eventual full build already reflects every file on disk, incl. this
|
|
411
|
+
* edit, so nothing is lost, only delayed.
|
|
412
|
+
* - `wordIndex.forward` undefined (pre-phase-2 index shape, e.g. a snapshot
|
|
413
|
+
* persisted before this feature or one still using the old serialized
|
|
414
|
+
* shape) ⇒ no-op. `updateWordIndexDocument` already refuses to mutate a
|
|
415
|
+
* forward-index-less index (see its doc comment) — this is the same rule
|
|
416
|
+
* surfaced one layer up so the caller isn't left guessing why nothing
|
|
417
|
+
* happened. The NEXT full rebuild (session-start lifecycle) installs a
|
|
418
|
+
* forward-index-bearing index that later edits CAN update incrementally.
|
|
419
|
+
* - `content` undefined (pipeline couldn't read the file — deleted, or a
|
|
420
|
+
* transient race) ⇒ no-op. Deletions aren't plumbed at this seam (this
|
|
421
|
+
* call site only ever sees the edited file's post-write content, never a
|
|
422
|
+
* delete event) — a removed file ages out at the next full rebuild, same
|
|
423
|
+
* scope boundary the review graph accepts for deletes.
|
|
424
|
+
* - File over the shared `WORD_INDEX_MAX_BYTES` cap ⇒ removed/absent from
|
|
425
|
+
* the index (never partially indexed) — same cap phase 1's build path
|
|
426
|
+
* enforces via `collectWordIndexDocs`.
|
|
427
|
+
* - On a successful update, `onUpdated` fires so the caller can schedule a
|
|
428
|
+
* debounced persist (never a synchronous write per edit — same #260
|
|
429
|
+
* discipline as the graph).
|
|
430
|
+
*
|
|
431
|
+
* Race safety against a build-in-progress: this function body is entirely
|
|
432
|
+
* synchronous (no `await` anywhere in it) and is called synchronously at
|
|
433
|
+
* `computeCascadeForFile`'s entry, before its own `await buildOrUpdateGraph`.
|
|
434
|
+
* Node is single-threaded, so two overlapping cascades (#450's unawaited
|
|
435
|
+
* concurrency) can never interleave mid-mutation here — each call runs to
|
|
436
|
+
* completion in one turn. The only cross-build hazard is a full session-start
|
|
437
|
+
* rebuild REPLACING `runtime.wordIndex` with a new object between the caller
|
|
438
|
+
* reading `runtime.wordIndex` (in runtime-tool-result.ts, also synchronous)
|
|
439
|
+
* and this function receiving it — in that case this call simply mutates
|
|
440
|
+
* whichever index object it was handed (old or new), and the other one is
|
|
441
|
+
* abandoned/superseded, never corrupted. No queue, no lock: the simplest rule
|
|
442
|
+
* that is still provably correct.
|
|
443
|
+
*/
|
|
444
|
+
function updateWordIndexForCascade(args) {
|
|
445
|
+
const { wordIndex, filePath, content, onUpdated, dbg } = args;
|
|
446
|
+
if (!wordIndex || !wordIndex.forward)
|
|
447
|
+
return;
|
|
448
|
+
if (content === undefined)
|
|
449
|
+
return;
|
|
450
|
+
const byteLength = Buffer.byteLength(content, "utf-8");
|
|
451
|
+
if (byteLength > WORD_INDEX_MAX_BYTES) {
|
|
452
|
+
removeWordIndexDocument(wordIndex, filePath);
|
|
453
|
+
dbg?.(`word-index per-edit: dropped ${filePath} (over size cap)`);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
updateWordIndexDocument(wordIndex, { path: filePath, content });
|
|
457
|
+
dbg?.(`word-index per-edit: updated ${filePath}`);
|
|
458
|
+
}
|
|
459
|
+
onUpdated?.(wordIndex);
|
|
460
|
+
}
|
|
395
461
|
export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
396
|
-
const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState } = options;
|
|
462
|
+
const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState, fileContent, wordIndex, onWordIndexUpdated, } = options;
|
|
397
463
|
ensureCascadeTurnScope(turnSeq);
|
|
398
464
|
if (hasBlockers) {
|
|
399
465
|
logCascade({
|
|
@@ -425,6 +491,30 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
|
425
491
|
// B10: record this file as a primary edit so later cascade calls in the same
|
|
426
492
|
// turn won't show it as a neighbor.
|
|
427
493
|
primaryFilesThisTurn.add(normalizedFileKey);
|
|
494
|
+
// #348 phase 2: warm per-edit word-index maintenance, review-graph style —
|
|
495
|
+
// update at the SAME seam as the graph rebuild below, using content the
|
|
496
|
+
// pipeline already read (no extra I/O). See computeCascadeForFile's
|
|
497
|
+
// `wordIndex`/`fileContent` doc comments for the cold/no-forward-index
|
|
498
|
+
// no-op rules.
|
|
499
|
+
//
|
|
500
|
+
// Deliberately keyed by `path.resolve(filePath)`, NOT `normalizedFile`
|
|
501
|
+
// (which is `normalizeMapKey`'d — realpath-canonicalized + lowercased on
|
|
502
|
+
// Windows). The word index's OWN keys come from `collectWordIndexDocs` →
|
|
503
|
+
// `collectSourceFilesAsync`'s file walk, which yields plain
|
|
504
|
+
// `path.resolve()`-joined paths (native separators, on-disk casing as
|
|
505
|
+
// reported by the walk, no realpath call). Keying this update with the
|
|
506
|
+
// cascade's normalized key would silently create a SECOND, orphaned entry
|
|
507
|
+
// next to the walker's original-cased entry instead of replacing it —
|
|
508
|
+
// exactly the kind of divergence the equivalence-property test is meant to
|
|
509
|
+
// catch, so the key shape here must match the build path's, not the
|
|
510
|
+
// cascade/graph's own (different) normalization scheme.
|
|
511
|
+
updateWordIndexForCascade({
|
|
512
|
+
wordIndex,
|
|
513
|
+
filePath: nodePath.resolve(filePath),
|
|
514
|
+
content: fileContent,
|
|
515
|
+
onUpdated: onWordIndexUpdated,
|
|
516
|
+
dbg,
|
|
517
|
+
});
|
|
428
518
|
let impact = {
|
|
429
519
|
filePath: normalizedFile,
|
|
430
520
|
changedSymbols: [],
|
|
@@ -828,6 +918,67 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
|
|
|
828
918
|
}
|
|
829
919
|
// A6: async read to avoid blocking event loop on network-mounted drives
|
|
830
920
|
const content = await nodeFs.promises.readFile(neighborPath, "utf8");
|
|
921
|
+
// #458: tier-aware cascade-lane wait. A Tier-3 (push-only,
|
|
922
|
+
// silent-on-clean — typescript is the lone core-set instance today)
|
|
923
|
+
// primary can never give this in-lane wait an affirmative clean
|
|
924
|
+
// signal, so the budget is pure cost. Fire the touch (didOpen/
|
|
925
|
+
// didChange still happens — the server starts real work) and record
|
|
926
|
+
// it as outstanding for the agent_settled quiet window to reconcile
|
|
927
|
+
// instead of waiting here. Ambiguous/missing capability data always
|
|
928
|
+
// classifies as "waits" (today's behavior) — see cascade-tier.ts.
|
|
929
|
+
// The whole attempt is try/caught: any surprise (a service shape
|
|
930
|
+
// that doesn't expose getCapabilitySnapshots/getClientForFile, a
|
|
931
|
+
// thrown rejection) falls through to the existing full-wait path
|
|
932
|
+
// below rather than skip the wait on a failure.
|
|
933
|
+
if (isTierAwareCascadeEnabled()) {
|
|
934
|
+
try {
|
|
935
|
+
const snapshots = (await lspService.getCapabilitySnapshots?.(neighborPath)) ?? [];
|
|
936
|
+
const tier = classifyCascadeWaitTier(lspService, neighborPath, snapshots);
|
|
937
|
+
if (tier === "tier3-silent") {
|
|
938
|
+
const spawnedForTouch = await lspService.getClientForFile(neighborPath);
|
|
939
|
+
if (spawnedForTouch) {
|
|
940
|
+
// Sampled BEFORE the touchFile notify: a publish landing
|
|
941
|
+
// in the notify→record gap must read as post-touch at
|
|
942
|
+
// reconcile time, never be misclassified as pre-touch
|
|
943
|
+
// (the reconcile compares this against the client's
|
|
944
|
+
// PER-FILE publish timestamp — see cascade-tier.ts).
|
|
945
|
+
const touchedAt = Date.now();
|
|
946
|
+
await lspService.touchFile(neighborPath, content, {
|
|
947
|
+
diagnostics: "none",
|
|
948
|
+
collectDiagnostics: false,
|
|
949
|
+
silent: true,
|
|
950
|
+
source: "cascade",
|
|
951
|
+
clientScope: "primary",
|
|
952
|
+
});
|
|
953
|
+
recordOutstandingCascadeTouch({
|
|
954
|
+
filePath: neighborPath,
|
|
955
|
+
serverId: spawnedForTouch.client.serverId,
|
|
956
|
+
touchedAt,
|
|
957
|
+
});
|
|
958
|
+
const durationMs = Date.now() - neighborStart;
|
|
959
|
+
logCascade({
|
|
960
|
+
phase: "cascade_tier3_skip",
|
|
961
|
+
filePath,
|
|
962
|
+
neighborFile: neighborPath,
|
|
963
|
+
durationMs,
|
|
964
|
+
lspServerCount: configuredServerCount,
|
|
965
|
+
coldSnapshot: isColdSnapshot,
|
|
966
|
+
metadata: { serverId: spawnedForTouch.client.serverId },
|
|
967
|
+
});
|
|
968
|
+
// Deliberately NOT cached as clean/diagnosed — the wait was
|
|
969
|
+
// skipped, not resolved, so neither neighborTouchCache nor
|
|
970
|
+
// recentlyCleanNeighborCache may treat this as a real answer
|
|
971
|
+
// (#240 doctrine). Return undefined: the degraded-fallback
|
|
972
|
+
// path below still has a chance to surface a passive/stale
|
|
973
|
+
// snapshot, same as any other "no fresh data this touch" case.
|
|
974
|
+
return undefined;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch (tierErr) {
|
|
979
|
+
dbg?.(`cascade tier-aware skip attempt failed for ${neighborPath}, falling back to full wait: ${tierErr}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
831
982
|
// Open with silent=true (suppresses didChangeWatchedFiles rechecks, C2)
|
|
832
983
|
// and collect diagnostics from the same touched clients.
|
|
833
984
|
// Cold-snapshot neighbors (autoPropagate LSP, server warm) use a tighter
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { loadAstGrepNapi, } from "../../deps/ast-grep-napi.js";
|
|
12
|
-
import {
|
|
12
|
+
import { getAstGrepRuleSources, } from "../../sgconfig.js";
|
|
13
13
|
import { hasEslintConfig } from "../../tool-policy.js";
|
|
14
14
|
import { enabledAuxiliaryLspServerIds } from "../auxiliary-lsp.js";
|
|
15
15
|
import { classifyDefect } from "../diagnostic-taxonomy.js";
|
|
16
16
|
import { PRIORITY } from "../priorities.js";
|
|
17
|
-
import { calculateRuleComplexity, isOverlyBroadPattern, isStructuredRule, loadYamlRules, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
|
|
17
|
+
import { calculateRuleComplexity, isOverlyBroadPattern, isStructuredRule, loadYamlRules, loadYamlRulesFresh, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
|
|
18
18
|
// Lazy load the napi package
|
|
19
19
|
let sg;
|
|
20
20
|
let sgLoadAttempted = false;
|
|
@@ -122,6 +122,40 @@ export function getLang(filePath, sgModule) {
|
|
|
122
122
|
return undefined;
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
function duplicateRuleIds(rules) {
|
|
126
|
+
const counts = new Map();
|
|
127
|
+
for (const rule of rules) {
|
|
128
|
+
counts.set(rule.id, (counts.get(rule.id) ?? 0) + 1);
|
|
129
|
+
}
|
|
130
|
+
return Array.from(counts)
|
|
131
|
+
.filter(([, count]) => count > 1)
|
|
132
|
+
.map(([id]) => id)
|
|
133
|
+
.sort((a, b) => a.localeCompare(b));
|
|
134
|
+
}
|
|
135
|
+
function appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicateIds, source, filePath, maxTotalDiagnostics) {
|
|
136
|
+
const sourceLabel = `${source.origin} ${source.tier} rules`;
|
|
137
|
+
for (const ruleId of duplicateIds) {
|
|
138
|
+
diagnostics.push({
|
|
139
|
+
id: `ast-grep-napi-config-duplicate-${source.origin}-${source.tier}-${ruleId}`,
|
|
140
|
+
message: `Duplicate ast-grep rule id "${ruleId}" in ${sourceLabel}`,
|
|
141
|
+
filePath,
|
|
142
|
+
line: 1,
|
|
143
|
+
column: 1,
|
|
144
|
+
severity: "error",
|
|
145
|
+
semantic: "blocking",
|
|
146
|
+
tool: "ast-grep-napi",
|
|
147
|
+
rule: ruleId,
|
|
148
|
+
defectClass: "correctness",
|
|
149
|
+
fixable: false,
|
|
150
|
+
autoFixAvailable: false,
|
|
151
|
+
fixSuggestion: `Give every rule in ${sourceLabel} a unique id`,
|
|
152
|
+
});
|
|
153
|
+
seenRuleIds.add(ruleId);
|
|
154
|
+
if (diagnostics.length >= maxTotalDiagnostics)
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
125
159
|
/**
|
|
126
160
|
* Run the shipped ast-grep YAML ruleset against a parsed file via napi's native
|
|
127
161
|
* engine, applying the same suppression policy (linter/tree-sitter overlap,
|
|
@@ -138,26 +172,34 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
|
|
|
138
172
|
const diagnostics = [];
|
|
139
173
|
const seenRuleIds = new Set();
|
|
140
174
|
const suppressLinterOverlap = kind === "jsts" && hasEslintConfig(cwd);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
resolvePackagePath(import.meta.url, "rules", "ast-grep-rules"),
|
|
146
|
-
];
|
|
147
|
-
for (const ruleDir of ruleDirs) {
|
|
175
|
+
// Shared with the raw sgconfig materializer so both surfaces walk the same
|
|
176
|
+
// workspace-rooted sources in the same precedence order.
|
|
177
|
+
const ruleSources = getAstGrepRuleSources(options.projectRoot ?? cwd);
|
|
178
|
+
for (const source of ruleSources) {
|
|
148
179
|
let rules;
|
|
149
180
|
try {
|
|
150
|
-
rules
|
|
181
|
+
// Project rules are mutable during a session, so their cache fingerprints
|
|
182
|
+
// relative paths and contents. Bundled catalogs are immutable per install.
|
|
183
|
+
const loader = source.origin === "project" ? loadYamlRulesFresh : loadYamlRules;
|
|
184
|
+
rules = loader(source.dir);
|
|
151
185
|
}
|
|
152
186
|
catch {
|
|
153
187
|
continue;
|
|
154
188
|
}
|
|
189
|
+
const duplicates = duplicateRuleIds(rules);
|
|
190
|
+
if (appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicates, source, filePath, maxTotalDiagnostics)) {
|
|
191
|
+
return diagnostics;
|
|
192
|
+
}
|
|
193
|
+
const duplicateSet = new Set(duplicates);
|
|
155
194
|
for (const rule of rules) {
|
|
156
|
-
|
|
157
|
-
|
|
195
|
+
if (duplicateSet.has(rule.id))
|
|
196
|
+
continue;
|
|
197
|
+
// Cross-layer collisions keep the first (higher-precedence) source.
|
|
158
198
|
if (seenRuleIds.has(rule.id))
|
|
159
199
|
continue;
|
|
160
200
|
seenRuleIds.add(rule.id);
|
|
201
|
+
if (blockingOnly && rule.severity !== "error")
|
|
202
|
+
continue;
|
|
161
203
|
if (suppressLinterOverlap &&
|
|
162
204
|
LINTER_OVERLAP.has(normalizeRuleId(rule.id)) &&
|
|
163
205
|
!NON_SUPPRESSIBLE.has(normalizeRuleId(rule.id))) {
|
|
@@ -310,7 +352,10 @@ const astGrepNapiRunner = {
|
|
|
310
352
|
catch {
|
|
311
353
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
312
354
|
}
|
|
313
|
-
const diagnostics = evaluateAstGrepRules(ctx.filePath, rootNode, ctx.cwd, ctx.kind, {
|
|
355
|
+
const diagnostics = evaluateAstGrepRules(ctx.filePath, rootNode, ctx.cwd, ctx.kind, {
|
|
356
|
+
blockingOnly: ctx.blockingOnly,
|
|
357
|
+
projectRoot: ctx.projectRoot,
|
|
358
|
+
});
|
|
314
359
|
const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
|
|
315
360
|
let semantic = "none";
|
|
316
361
|
if (hasBlocking) {
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* (has/any/all/not/regex).
|
|
7
7
|
*
|
|
8
8
|
* Features:
|
|
9
|
-
* -
|
|
9
|
+
* - Mtime caching for bundled rules; content/path caching for project rules
|
|
10
10
|
* - Severity filtering (error-only for blocking mode)
|
|
11
11
|
* - Complexity scoring for performance optimization
|
|
12
12
|
* - Overly broad pattern detection
|
|
13
13
|
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
14
15
|
import * as fs from "node:fs";
|
|
15
16
|
import * as path from "node:path";
|
|
16
17
|
import yaml from "../../deps/js-yaml.js";
|
|
@@ -29,41 +30,89 @@ export const MAX_BLOCKING_RULE_COMPLEXITY = 8;
|
|
|
29
30
|
// --- Caches ---
|
|
30
31
|
const rulesCache = new Map();
|
|
31
32
|
const blockingRulesCache = new Map();
|
|
33
|
+
const contentRulesCache = new Map();
|
|
34
|
+
const contentBlockingRulesCache = new Map();
|
|
32
35
|
// --- Public API ---
|
|
33
36
|
export function clearRulesCache() {
|
|
34
37
|
rulesCache.clear();
|
|
35
38
|
blockingRulesCache.clear();
|
|
39
|
+
contentRulesCache.clear();
|
|
40
|
+
contentBlockingRulesCache.clear();
|
|
36
41
|
}
|
|
37
42
|
export function loadYamlRules(ruleDir, severityFilter) {
|
|
38
43
|
return getCachedRules(ruleDir, severityFilter);
|
|
39
44
|
}
|
|
40
|
-
|
|
45
|
+
function findYamlRuleFiles(ruleDir) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = fs
|
|
49
|
+
.readdirSync(ruleDir, { withFileTypes: true })
|
|
50
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const files = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const full = path.join(ruleDir, entry.name);
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
files.push(...findYamlRuleFiles(full));
|
|
60
|
+
}
|
|
61
|
+
else if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) {
|
|
62
|
+
files.push(full);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
67
|
+
function loadYamlRuleFiles(files, severityFilter) {
|
|
41
68
|
const rules = [];
|
|
42
|
-
if (!fs.existsSync(ruleDir))
|
|
43
|
-
return rules;
|
|
44
|
-
const files = fs.readdirSync(ruleDir).filter((f) => f.endsWith(".yml"));
|
|
45
69
|
for (const file of files) {
|
|
46
70
|
let content;
|
|
47
71
|
try {
|
|
48
|
-
content = fs.readFileSync(
|
|
72
|
+
content = fs.readFileSync(file, "utf-8");
|
|
49
73
|
}
|
|
50
74
|
catch {
|
|
51
|
-
continue;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const documents = content.split(/^---\s*$/m).filter((doc) => doc.trim());
|
|
78
|
+
for (const document of documents) {
|
|
79
|
+
const rule = parseSimpleYaml(document.trim());
|
|
80
|
+
if (!rule?.id)
|
|
81
|
+
continue;
|
|
82
|
+
if (severityFilter && rule.severity !== severityFilter)
|
|
83
|
+
continue;
|
|
84
|
+
rules.push(rule);
|
|
52
85
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
86
|
+
}
|
|
87
|
+
return rules;
|
|
88
|
+
}
|
|
89
|
+
export function loadYamlRulesUncached(ruleDir, severityFilter) {
|
|
90
|
+
return loadYamlRuleFiles(findYamlRuleFiles(ruleDir), severityFilter);
|
|
91
|
+
}
|
|
92
|
+
/** Content/path-aware cache used for mutable project-owned rule trees. */
|
|
93
|
+
export function loadYamlRulesFresh(ruleDir, severityFilter) {
|
|
94
|
+
const files = findYamlRuleFiles(ruleDir);
|
|
95
|
+
const hash = createHash("sha256");
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
hash.update(path.relative(ruleDir, file));
|
|
98
|
+
hash.update("\0");
|
|
99
|
+
try {
|
|
100
|
+
hash.update(fs.readFileSync(file));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
hash.update("missing");
|
|
65
104
|
}
|
|
105
|
+
hash.update("\0");
|
|
66
106
|
}
|
|
107
|
+
const signature = hash.digest("hex");
|
|
108
|
+
const cache = severityFilter === "error"
|
|
109
|
+
? contentBlockingRulesCache
|
|
110
|
+
: contentRulesCache;
|
|
111
|
+
const cached = cache.get(ruleDir);
|
|
112
|
+
if (cached?.signature === signature)
|
|
113
|
+
return cached.rules;
|
|
114
|
+
const rules = loadYamlRuleFiles(files, severityFilter);
|
|
115
|
+
cache.set(ruleDir, { rules, signature });
|
|
67
116
|
return rules;
|
|
68
117
|
}
|
|
69
118
|
export function getCachedRules(ruleDir, severityFilter) {
|
|
@@ -30,7 +30,7 @@ export function getProjectDataDir(cwd) {
|
|
|
30
30
|
if (!configuredBase && fs.existsSync(legacyProjectDir)) {
|
|
31
31
|
return legacyProjectDir;
|
|
32
32
|
}
|
|
33
|
-
const base = configuredBase || path.join(
|
|
33
|
+
const base = configuredBase || path.join(getGlobalPiLensDir(), "projects");
|
|
34
34
|
const normalized = normalizeFilePath(path.resolve(cwd));
|
|
35
35
|
const slug = normalized
|
|
36
36
|
.replace(/^[a-z]:/i, "") // strip Windows drive letter
|
|
@@ -41,18 +41,31 @@ export function getProjectDataDir(cwd) {
|
|
|
41
41
|
return path.join(base.trim(), slug || "default");
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
|
-
* Machine-global pi-lens directory: `~/.pi-lens
|
|
44
|
+
* Machine-global pi-lens directory: `~/.pi-lens/` by default.
|
|
45
45
|
*
|
|
46
46
|
* Used for logs (latency, cascade, read-guard, tree-sitter, actionable-warnings,
|
|
47
|
-
* sessionstart), tool binaries (`~/.pi-lens/tools/`, `~/.pi-lens/bin/`),
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* sessionstart), tool binaries (`~/.pi-lens/tools/`, `~/.pi-lens/bin/`), the
|
|
48
|
+
* cross-process instance registry (`instances.json`, #449/#525), the
|
|
49
|
+
* auto-install probe cache, and other state that is intentionally NOT
|
|
50
|
+
* project-scoped — it spans every project pi-lens has touched.
|
|
51
|
+
*
|
|
52
|
+
* Override: set `PI_LENS_HOME=/some/path` to relocate this ENTIRE root (every
|
|
53
|
+
* caller below routes through this one function, so one env var covers all of
|
|
54
|
+
* them — see #525). Tests MUST set this to a per-worker temp dir in
|
|
55
|
+
* `tests/support/vitest-setup.ts` rather than mocking each caller separately;
|
|
56
|
+
* otherwise a test that exercises `registerInstance`/`sweepOrphans` or any
|
|
57
|
+
* logger writes into the developer's REAL `~/.pi-lens` (dogfooded live: a
|
|
58
|
+
* test-fixture instance survived in the real `instances.json` for 17h).
|
|
50
59
|
*
|
|
51
60
|
* Distinct from `getProjectDataDir(cwd)`, which respects `PILENS_DATA_DIR`
|
|
52
|
-
* and produces per-project subdirectories. Callers writing
|
|
53
|
-
* snapshots, or worklogs should use `getProjectDataDir(cwd)`
|
|
61
|
+
* (project-scoped) and produces per-project subdirectories. Callers writing
|
|
62
|
+
* project caches, snapshots, or worklogs should use `getProjectDataDir(cwd)`
|
|
63
|
+
* instead — `PI_LENS_HOME` is the MACHINE-scoped sibling of that override.
|
|
54
64
|
*/
|
|
55
65
|
export function getGlobalPiLensDir() {
|
|
66
|
+
const override = process.env.PI_LENS_HOME?.trim();
|
|
67
|
+
if (override)
|
|
68
|
+
return path.resolve(override);
|
|
56
69
|
return path.join(os.homedir(), ".pi-lens");
|
|
57
70
|
}
|
|
58
71
|
/**
|
|
@@ -1036,11 +1036,41 @@ export const TOOLS = [
|
|
|
1036
1036
|
// bare binary — no binaryInArchive
|
|
1037
1037
|
},
|
|
1038
1038
|
},
|
|
1039
|
+
{
|
|
1040
|
+
// Expert ships a bare native binary per platform on GitHub releases. Its
|
|
1041
|
+
// `--stdio` flag is required to start the LSP transport. Windows arm64 uses
|
|
1042
|
+
// the x64 binary through Windows' built-in x64 emulation.
|
|
1043
|
+
id: "expert",
|
|
1044
|
+
name: "Expert",
|
|
1045
|
+
checkCommand: "expert",
|
|
1046
|
+
checkArgs: ["--version"],
|
|
1047
|
+
installStrategy: "github",
|
|
1048
|
+
binaryName: "expert",
|
|
1049
|
+
github: {
|
|
1050
|
+
repo: "expert-lsp/expert",
|
|
1051
|
+
assetMatch: (platform, arch) => {
|
|
1052
|
+
if (arch !== "x64" && arch !== "arm64")
|
|
1053
|
+
return undefined;
|
|
1054
|
+
if (platform === "linux")
|
|
1055
|
+
return arch === "arm64"
|
|
1056
|
+
? "expert_linux_arm64"
|
|
1057
|
+
: "expert_linux_amd64";
|
|
1058
|
+
if (platform === "darwin")
|
|
1059
|
+
return arch === "arm64"
|
|
1060
|
+
? "expert_darwin_arm64"
|
|
1061
|
+
: "expert_darwin_amd64";
|
|
1062
|
+
if (platform === "win32")
|
|
1063
|
+
return "expert_windows_amd64.exe";
|
|
1064
|
+
return undefined;
|
|
1065
|
+
},
|
|
1066
|
+
// bare binary — no binaryInArchive
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1039
1069
|
];
|
|
1040
1070
|
const ensureInFlight = new Map();
|
|
1041
1071
|
// Session-lifetime cache: once a tool path is resolved, skip the process-spawn check on subsequent calls.
|
|
1042
1072
|
const resolvedPathCache = new Map();
|
|
1043
|
-
const PROBE_CACHE_PATH = path.join(
|
|
1073
|
+
const PROBE_CACHE_PATH = path.join(getGlobalPiLensDir(), "probe-cache.json");
|
|
1044
1074
|
const PROBE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1045
1075
|
let _probeCache = null;
|
|
1046
1076
|
let _probeCacheDirty = false;
|
|
@@ -2735,6 +2765,7 @@ export const GITHUB_TOOLS = [
|
|
|
2735
2765
|
"clojure-lsp",
|
|
2736
2766
|
"gleam",
|
|
2737
2767
|
"marksman",
|
|
2768
|
+
"expert",
|
|
2738
2769
|
];
|
|
2739
2770
|
/**
|
|
2740
2771
|
* Resolve the GitHub asset filename substring for a tool on a given platform/arch.
|