pi-lens 3.3.0 → 3.6.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 +91 -0
- package/README.md +175 -13
- package/clients/cache/rule-cache.js +72 -0
- package/clients/cache/rule-cache.ts +104 -0
- package/clients/dispatch/integration.js +48 -1
- package/clients/dispatch/integration.ts +60 -2
- package/clients/dispatch/plan.js +5 -2
- package/clients/dispatch/plan.ts +5 -2
- package/clients/dispatch/runners/ast-grep-napi.js +175 -56
- package/clients/dispatch/runners/ast-grep-napi.test.js +2 -1
- package/clients/dispatch/runners/ast-grep-napi.test.ts +2 -1
- package/clients/dispatch/runners/ast-grep-napi.ts +191 -79
- package/clients/dispatch/runners/similarity.js +1 -1
- package/clients/dispatch/runners/similarity.ts +2 -2
- package/clients/dispatch/runners/tree-sitter.js +137 -10
- package/clients/dispatch/runners/tree-sitter.ts +168 -13
- package/clients/dispatch/runners/ts-lsp.js +3 -2
- package/clients/dispatch/runners/ts-lsp.ts +3 -2
- package/clients/dispatch/runners/yaml-rule-parser.js +70 -2
- package/clients/dispatch/runners/yaml-rule-parser.ts +71 -2
- package/clients/dispatch/types.js +1 -1
- package/clients/dispatch/types.ts +1 -1
- package/clients/lsp/__tests__/service.test.js +3 -0
- package/clients/lsp/__tests__/service.test.ts +3 -0
- package/clients/lsp/client.js +42 -0
- package/clients/lsp/client.ts +79 -0
- package/clients/lsp/index.js +27 -0
- package/clients/lsp/index.ts +35 -0
- package/clients/lsp/launch.js +11 -6
- package/clients/lsp/launch.ts +11 -6
- package/clients/metrics-client.js +3 -160
- package/clients/metrics-client.tdr.test.js +78 -0
- package/clients/metrics-client.test.js +30 -43
- package/clients/metrics-client.test.ts +30 -54
- package/clients/metrics-client.ts +5 -219
- package/clients/metrics-history.js +33 -7
- package/clients/metrics-history.ts +47 -10
- package/clients/pipeline.js +272 -0
- package/clients/pipeline.ts +371 -0
- package/clients/sg-runner.js +21 -3
- package/clients/sg-runner.ts +22 -3
- package/clients/tree-sitter-client.js +23 -2
- package/clients/tree-sitter-client.ts +27 -2
- package/index.ts +604 -771
- package/package.json +1 -1
- package/rules/ast-grep-rules/rules/no-architecture-violation.yml +7 -4
- package/rules/ast-grep-rules/rules/no-single-char-var.yml +3 -3
- package/rules/ast-grep-rules/slop-patterns.yml +85 -62
- package/skills/ast-grep/SKILL.md +42 -1
- package/skills/lsp-navigation/SKILL.md +62 -0
- package/tsconfig.json +1 -1
- package/rules/ast-grep-rules/rules/no-console-log.yml +0 -10
- package/rules/ast-grep-rules/rules/no-default-export.yml +0 -19
package/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as nodeFs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
// RELOADED: Testing format/lsp flow on large file
|
|
5
6
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
7
|
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
@@ -12,7 +13,8 @@ import { BiomeClient } from "./clients/biome-client.js";
|
|
|
12
13
|
import { CacheManager } from "./clients/cache-manager.js";
|
|
13
14
|
import { ComplexityClient } from "./clients/complexity-client.js";
|
|
14
15
|
import { DependencyChecker } from "./clients/dependency-checker.js";
|
|
15
|
-
import {
|
|
16
|
+
import { resetDispatchBaselines } from "./clients/dispatch/integration.js";
|
|
17
|
+
import { extractFunctions } from "./clients/dispatch/runners/similarity.js";
|
|
16
18
|
import { createFileTime, FileTimeError } from "./clients/file-time.js";
|
|
17
19
|
import {
|
|
18
20
|
getFormatService,
|
|
@@ -28,6 +30,13 @@ import { logLatency } from "./clients/latency-logger.js";
|
|
|
28
30
|
import { getLSPService, resetLSPService } from "./clients/lsp/index.js";
|
|
29
31
|
import { MetricsClient } from "./clients/metrics-client.js";
|
|
30
32
|
import { captureSnapshot } from "./clients/metrics-history.js";
|
|
33
|
+
import { runPipeline } from "./clients/pipeline.js";
|
|
34
|
+
import {
|
|
35
|
+
buildProjectIndex,
|
|
36
|
+
findSimilarFunctions,
|
|
37
|
+
loadIndex,
|
|
38
|
+
type ProjectIndex,
|
|
39
|
+
} from "./clients/project-index.js";
|
|
31
40
|
import { RuffClient } from "./clients/ruff-client.js";
|
|
32
41
|
import {
|
|
33
42
|
formatRulesForPrompt,
|
|
@@ -36,7 +45,6 @@ import {
|
|
|
36
45
|
} from "./clients/rules-scanner.js";
|
|
37
46
|
import { RustClient } from "./clients/rust-client.js";
|
|
38
47
|
import { getSourceFiles } from "./clients/scan-utils.js";
|
|
39
|
-
import { formatSecrets, scanForSecrets } from "./clients/secrets-scanner.js";
|
|
40
48
|
import { TestRunnerClient } from "./clients/test-runner-client.js";
|
|
41
49
|
import { TodoScanner } from "./clients/todo-scanner.js";
|
|
42
50
|
import { TypeCoverageClient } from "./clients/type-coverage-client.js";
|
|
@@ -349,9 +357,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
349
357
|
`Total cognitive complexity: ${tdi.totalCognitive}`,
|
|
350
358
|
``,
|
|
351
359
|
`Debt breakdown:`,
|
|
352
|
-
` Maintainability: ${tdi.byCategory.maintainability}
|
|
353
|
-
`
|
|
360
|
+
` Maintainability: ${tdi.byCategory.maintainability}% (MI-based)`,
|
|
361
|
+
` Cognitive: ${tdi.byCategory.cognitive}%`,
|
|
354
362
|
` Nesting: ${tdi.byCategory.nesting}%`,
|
|
363
|
+
` Max Cyclomatic: ${tdi.byCategory.maxCyclomatic}% (worst function)`,
|
|
364
|
+
` Entropy: ${tdi.byCategory.entropy}% (code unpredictability)`,
|
|
355
365
|
``,
|
|
356
366
|
tdi.score <= 30
|
|
357
367
|
? "✅ Codebase is healthy!"
|
|
@@ -453,7 +463,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
453
463
|
name: "ast_grep_search",
|
|
454
464
|
label: "AST Search",
|
|
455
465
|
description:
|
|
456
|
-
"Search code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, NOT text search
|
|
466
|
+
"Search code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, NOT text search.\n\n" +
|
|
467
|
+
"✅ GOOD patterns (single AST node):\n" +
|
|
468
|
+
" - function $NAME() { $$$BODY } (function declaration)\n" +
|
|
469
|
+
" - fetchMetrics($ARGS) (function call)\n" +
|
|
470
|
+
' - import { $NAMES } from "$PATH" (import statement)\n' +
|
|
471
|
+
" - console.log($MSG) (method call)\n\n" +
|
|
472
|
+
"❌ BAD patterns (multiple nodes / raw text):\n" +
|
|
473
|
+
' - it"test name" (missing parens - use it($TEST))\n' +
|
|
474
|
+
" - console.log without args (incomplete code)\n" +
|
|
475
|
+
" - arbitrary text without code structure\n\n" +
|
|
476
|
+
"Always prefer specific patterns with context over bare identifiers. " +
|
|
477
|
+
"Use 'paths' to scope to specific files/folders. " +
|
|
478
|
+
"Use 'selector' to extract specific nodes (e.g., just the function name). " +
|
|
479
|
+
"Use 'context' to show surrounding lines.",
|
|
457
480
|
promptSnippet: "Use ast_grep_search for AST-aware code search",
|
|
458
481
|
parameters: Type.Object({
|
|
459
482
|
pattern: Type.String({
|
|
@@ -527,7 +550,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
527
550
|
name: "ast_grep_replace",
|
|
528
551
|
label: "AST Replace",
|
|
529
552
|
description:
|
|
530
|
-
"Replace code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, not text. Dry-run by default (use apply=true to apply).\n\
|
|
553
|
+
"Replace code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, not text. Dry-run by default (use apply=true to apply).\n\n" +
|
|
554
|
+
"✅ GOOD patterns (single AST node):\n" +
|
|
555
|
+
" - pattern='console.log($MSG)' rewrite='logger.info($MSG)'\n" +
|
|
556
|
+
" - pattern='var $X' rewrite='let $X'\n" +
|
|
557
|
+
" - pattern='function $NAME() { }' rewrite='' (delete)\n\n" +
|
|
558
|
+
"❌ BAD patterns (will error):\n" +
|
|
559
|
+
" - Raw text without code structure\n" +
|
|
560
|
+
' - Missing parentheses: use it($TEST) not it"text"\n' +
|
|
561
|
+
" - Incomplete code fragments\n\n" +
|
|
562
|
+
"Always use 'paths' to scope to specific files/folders. Dry-run first to preview changes.",
|
|
531
563
|
promptSnippet: "Use ast_grep_replace for AST-aware find-and-replace",
|
|
532
564
|
parameters: Type.Object({
|
|
533
565
|
pattern: Type.String({
|
|
@@ -612,7 +644,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
612
644
|
"- hover: Get type/doc info at a position\n" +
|
|
613
645
|
"- documentSymbol: List all symbols (functions/classes/vars) in a file\n" +
|
|
614
646
|
"- workspaceSymbol: Search symbols across the whole project\n" +
|
|
615
|
-
"- implementation: Jump to interface implementations\n
|
|
647
|
+
"- implementation: Jump to interface implementations\n" +
|
|
648
|
+
"- prepareCallHierarchy: Get callable item at position (for incoming/outgoing)\n" +
|
|
649
|
+
"- incomingCalls: Find all functions/methods that CALL this function\n" +
|
|
650
|
+
"- outgoingCalls: Find all functions/methods CALLED by this function\n\n" +
|
|
616
651
|
"Line and character are 1-based (as shown in editors).",
|
|
617
652
|
promptSnippet:
|
|
618
653
|
"Use lsp_navigation to find definitions, references, and hover info via LSP",
|
|
@@ -625,6 +660,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
625
660
|
Type.Literal("documentSymbol"),
|
|
626
661
|
Type.Literal("workspaceSymbol"),
|
|
627
662
|
Type.Literal("implementation"),
|
|
663
|
+
Type.Literal("prepareCallHierarchy"),
|
|
664
|
+
Type.Literal("incomingCalls"),
|
|
665
|
+
Type.Literal("outgoingCalls"),
|
|
628
666
|
],
|
|
629
667
|
{ description: "LSP operation to perform" },
|
|
630
668
|
),
|
|
@@ -648,6 +686,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
648
686
|
description: "Symbol name to search. Used by workspaceSymbol",
|
|
649
687
|
}),
|
|
650
688
|
),
|
|
689
|
+
callHierarchyItem: Type.Optional(
|
|
690
|
+
Type.Object(
|
|
691
|
+
{
|
|
692
|
+
name: Type.String(),
|
|
693
|
+
kind: Type.Number(),
|
|
694
|
+
uri: Type.String(),
|
|
695
|
+
range: Type.Object({
|
|
696
|
+
start: Type.Object({
|
|
697
|
+
line: Type.Number(),
|
|
698
|
+
character: Type.Number(),
|
|
699
|
+
}),
|
|
700
|
+
end: Type.Object({
|
|
701
|
+
line: Type.Number(),
|
|
702
|
+
character: Type.Number(),
|
|
703
|
+
}),
|
|
704
|
+
}),
|
|
705
|
+
selectionRange: Type.Object({
|
|
706
|
+
start: Type.Object({
|
|
707
|
+
line: Type.Number(),
|
|
708
|
+
character: Type.Number(),
|
|
709
|
+
}),
|
|
710
|
+
end: Type.Object({
|
|
711
|
+
line: Type.Number(),
|
|
712
|
+
character: Type.Number(),
|
|
713
|
+
}),
|
|
714
|
+
}),
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
description:
|
|
718
|
+
"Call hierarchy item. Required for incomingCalls/outgoingCalls",
|
|
719
|
+
},
|
|
720
|
+
),
|
|
721
|
+
),
|
|
651
722
|
}),
|
|
652
723
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
653
724
|
if (!pi.getFlag("lens-lsp")) {
|
|
@@ -734,6 +805,43 @@ export default function (pi: ExtensionAPI) {
|
|
|
734
805
|
lspChar,
|
|
735
806
|
);
|
|
736
807
|
break;
|
|
808
|
+
case "prepareCallHierarchy":
|
|
809
|
+
result = await lspService.prepareCallHierarchy(
|
|
810
|
+
filePath,
|
|
811
|
+
lspLine,
|
|
812
|
+
lspChar,
|
|
813
|
+
);
|
|
814
|
+
break;
|
|
815
|
+
case "incomingCalls":
|
|
816
|
+
if (!params.callHierarchyItem) {
|
|
817
|
+
return {
|
|
818
|
+
content: [
|
|
819
|
+
{
|
|
820
|
+
type: "text" as const,
|
|
821
|
+
text: "callHierarchyItem parameter required for incomingCalls",
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
isError: true,
|
|
825
|
+
details: {},
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
result = await lspService.incomingCalls(params.callHierarchyItem);
|
|
829
|
+
break;
|
|
830
|
+
case "outgoingCalls":
|
|
831
|
+
if (!params.callHierarchyItem) {
|
|
832
|
+
return {
|
|
833
|
+
content: [
|
|
834
|
+
{
|
|
835
|
+
type: "text" as const,
|
|
836
|
+
text: "callHierarchyItem parameter required for outgoingCalls",
|
|
837
|
+
},
|
|
838
|
+
],
|
|
839
|
+
isError: true,
|
|
840
|
+
details: {},
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
result = await lspService.outgoingCalls(params.callHierarchyItem);
|
|
844
|
+
break;
|
|
737
845
|
default:
|
|
738
846
|
result = [];
|
|
739
847
|
}
|
|
@@ -768,6 +876,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
768
876
|
let _cachedJscpdClones: import("./clients/jscpd-client.js").DuplicateClone[] =
|
|
769
877
|
[];
|
|
770
878
|
const cachedExports = new Map<string, string>(); // function name -> file path
|
|
879
|
+
let cachedProjectIndex: ProjectIndex | null = null; // similarity index for pre-write checks
|
|
771
880
|
const complexityBaselines: Map<
|
|
772
881
|
string,
|
|
773
882
|
import("./clients/complexity-client.js").FileComplexity
|
|
@@ -789,247 +898,298 @@ export default function (pi: ExtensionAPI) {
|
|
|
789
898
|
// Project rules scan result (from .claude/rules, .agents/rules, etc.)
|
|
790
899
|
let projectRulesScan: RuleScanResult = { rules: [], hasCustomRules: false };
|
|
791
900
|
|
|
901
|
+
// --- Register skills with pi ---
|
|
902
|
+
pi.on("resources_discover", async (_event, _ctx) => {
|
|
903
|
+
// Get the extension directory (where this file is located)
|
|
904
|
+
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
905
|
+
const skillsDir = path.join(extensionDir, "skills");
|
|
906
|
+
|
|
907
|
+
return {
|
|
908
|
+
skillPaths: [skillsDir],
|
|
909
|
+
};
|
|
910
|
+
});
|
|
911
|
+
|
|
792
912
|
// --- Events ---
|
|
793
913
|
|
|
794
914
|
pi.on("session_start", async (_event, ctx) => {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const tools: string[] = [];
|
|
812
|
-
tools.push("TypeScript LSP"); // Always available
|
|
813
|
-
if (biomeClient.isAvailable()) tools.push("Biome");
|
|
814
|
-
if (astGrepClient.isAvailable()) tools.push("ast-grep");
|
|
815
|
-
if (ruffClient.isAvailable()) tools.push("Ruff");
|
|
816
|
-
if (knipClient.isAvailable()) tools.push("Knip");
|
|
817
|
-
if (depChecker.isAvailable()) tools.push("Madge");
|
|
818
|
-
if (jscpdClient.isAvailable()) tools.push("jscpd");
|
|
819
|
-
if (typeCoverageClient.isAvailable()) tools.push("type-coverage");
|
|
820
|
-
|
|
821
|
-
log(`Active tools: ${tools.join(", ")}`);
|
|
822
|
-
dbg(`session_start tools: ${tools.join(", ")}`);
|
|
823
|
-
|
|
824
|
-
// Clean up stale TypeScript build caches before LSP starts.
|
|
825
|
-
// tsconfig.tsbuildinfo caches the full file list from the last build.
|
|
826
|
-
// If files have been deleted since then, the LSP reads the stale list
|
|
827
|
-
// and reports phantom "Cannot find module" cascade errors for files
|
|
828
|
-
// the agent never touched.
|
|
829
|
-
if (pi.getFlag("lens-lsp")) {
|
|
830
|
-
const cleaned = cleanStaleTsBuildInfo(ctx.cwd ?? process.cwd());
|
|
831
|
-
if (cleaned.length > 0) {
|
|
832
|
-
ctx.ui.notify(
|
|
833
|
-
`🧹 Deleted stale TypeScript build cache (${cleaned.map((f) => path.basename(f)).join(", ")}) — phantom errors suppressed.`,
|
|
834
|
-
"info",
|
|
835
|
-
);
|
|
836
|
-
dbg(`session_start: cleaned stale tsbuildinfo: ${cleaned.join(", ")}`);
|
|
915
|
+
try {
|
|
916
|
+
_verbose = !!pi.getFlag("lens-verbose");
|
|
917
|
+
dbg("session_start fired");
|
|
918
|
+
|
|
919
|
+
// Reset session state
|
|
920
|
+
metricsClient.reset();
|
|
921
|
+
complexityBaselines.clear();
|
|
922
|
+
resetDispatchBaselines();
|
|
923
|
+
cachedProjectIndex = null;
|
|
924
|
+
|
|
925
|
+
// Reset LSP service so the new session starts with fresh diagnostics.
|
|
926
|
+
// Without this, stale cascade errors from a previous session persist
|
|
927
|
+
// if the extension module stayed hot between reloads.
|
|
928
|
+
if (pi.getFlag("lens-lsp")) {
|
|
929
|
+
resetLSPService();
|
|
930
|
+
dbg("session_start: LSP service reset");
|
|
837
931
|
}
|
|
838
|
-
}
|
|
839
932
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
933
|
+
// Log available tools
|
|
934
|
+
const tools: string[] = [];
|
|
935
|
+
tools.push("TypeScript LSP"); // Always available
|
|
936
|
+
if (biomeClient.isAvailable()) tools.push("Biome");
|
|
937
|
+
if (astGrepClient.isAvailable()) tools.push("ast-grep");
|
|
938
|
+
if (ruffClient.isAvailable()) tools.push("Ruff");
|
|
939
|
+
if (knipClient.isAvailable()) tools.push("Knip");
|
|
940
|
+
if (depChecker.isAvailable()) tools.push("Madge");
|
|
941
|
+
if (jscpdClient.isAvailable()) tools.push("jscpd");
|
|
942
|
+
if (typeCoverageClient.isAvailable()) tools.push("type-coverage");
|
|
943
|
+
|
|
944
|
+
log(`Active tools: ${tools.join(", ")}`);
|
|
945
|
+
dbg(`session_start tools: ${tools.join(", ")}`);
|
|
946
|
+
|
|
947
|
+
// Clean up stale TypeScript build caches before LSP starts.
|
|
948
|
+
// tsconfig.tsbuildinfo caches the full file list from the last build.
|
|
949
|
+
// If files have been deleted since then, the LSP reads the stale list
|
|
950
|
+
// and reports phantom "Cannot find module" cascade errors for files
|
|
951
|
+
// the agent never touched.
|
|
952
|
+
if (pi.getFlag("lens-lsp")) {
|
|
953
|
+
const cleaned = cleanStaleTsBuildInfo(ctx.cwd ?? process.cwd());
|
|
954
|
+
if (cleaned.length > 0) {
|
|
955
|
+
ctx.ui.notify(
|
|
956
|
+
`🧹 Deleted stale TypeScript build cache (${cleaned.map((f) => path.basename(f)).join(", ")}) — phantom errors suppressed.`,
|
|
957
|
+
"info",
|
|
958
|
+
);
|
|
959
|
+
dbg(
|
|
960
|
+
`session_start: cleaned stale tsbuildinfo: ${cleaned.join(", ")}`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
856
964
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
965
|
+
// Pre-install TypeScript LSP if --lens-lsp flag is set (avoid delay on first use)
|
|
966
|
+
if (pi.getFlag("lens-lsp")) {
|
|
967
|
+
dbg("session_start: pre-installing TypeScript LSP...");
|
|
968
|
+
// Fire-and-forget: don't block session start, just warm up the cache
|
|
969
|
+
ensureTool("typescript-language-server")
|
|
970
|
+
.then((path) => {
|
|
971
|
+
if (path) {
|
|
972
|
+
dbg(`session_start: TypeScript LSP ready at ${path}`);
|
|
973
|
+
} else {
|
|
974
|
+
console.error("[lens] TypeScript LSP installation failed");
|
|
975
|
+
}
|
|
976
|
+
})
|
|
977
|
+
.catch((err) => {
|
|
978
|
+
console.error("[lens] TypeScript LSP pre-install error:", err);
|
|
979
|
+
});
|
|
980
|
+
}
|
|
860
981
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
982
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
983
|
+
projectRoot = cwd; // Module-level for architect client
|
|
984
|
+
dbg(`session_start cwd: ${cwd}`);
|
|
864
985
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
tools.push(`Test runner (${detectedRunner.runner})`);
|
|
869
|
-
}
|
|
870
|
-
if (goClient.isGoAvailable()) tools.push("Go (go vet)");
|
|
871
|
-
if (rustClient.isAvailable()) tools.push("Rust (cargo)");
|
|
872
|
-
log(`Active tools: ${tools.join(", ")}`);
|
|
873
|
-
dbg(`session_start tools: ${tools.join(", ")}`);
|
|
986
|
+
// Load architect rules if present
|
|
987
|
+
const hasArchitectRules = architectClient.loadConfig(cwd);
|
|
988
|
+
if (hasArchitectRules) tools.push("Architect rules");
|
|
874
989
|
|
|
875
|
-
|
|
990
|
+
// Log test runner if detected
|
|
991
|
+
const detectedRunner = testRunnerClient.detectRunner(cwd);
|
|
992
|
+
if (detectedRunner) {
|
|
993
|
+
tools.push(`Test runner (${detectedRunner.runner})`);
|
|
994
|
+
}
|
|
995
|
+
if (goClient.isGoAvailable()) tools.push("Go (go vet)");
|
|
996
|
+
if (rustClient.isAvailable()) tools.push("Rust (cargo)");
|
|
997
|
+
log(`Active tools: ${tools.join(", ")}`);
|
|
998
|
+
dbg(`session_start tools: ${tools.join(", ")}`);
|
|
876
999
|
|
|
877
|
-
|
|
878
|
-
// Shown on every session start to encourage fixing existing errors
|
|
879
|
-
parts.push(
|
|
880
|
-
"📌 Remember: If you find ANY errors (test failures, compile errors, lint issues) in this codebase, fix them — even if you didn't cause them. Don't skip errors as 'not my fault'.",
|
|
881
|
-
);
|
|
1000
|
+
const parts: string[] = [];
|
|
882
1001
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
if (projectRulesScan.hasCustomRules) {
|
|
886
|
-
const ruleCount = projectRulesScan.rules.length;
|
|
887
|
-
const sources = [...new Set(projectRulesScan.rules.map((r) => r.source))];
|
|
888
|
-
dbg(
|
|
889
|
-
`session_start: found ${ruleCount} project rule(s) from ${sources.join(", ")}`,
|
|
890
|
-
);
|
|
1002
|
+
// --- Error ownership reminder ---
|
|
1003
|
+
// Shown on every session start to encourage fixing existing errors
|
|
891
1004
|
parts.push(
|
|
892
|
-
|
|
1005
|
+
"📌 Remember: If you find ANY errors (test failures, compile errors, lint issues) in this codebase, fix them — even if you didn't cause them. Don't skip errors as 'not my fault'.",
|
|
893
1006
|
);
|
|
894
|
-
} else {
|
|
895
|
-
dbg("session_start: no project rules found");
|
|
896
|
-
}
|
|
897
1007
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
if (await knipClient.ensureAvailable()) {
|
|
906
|
-
const cached = cacheManager.readCache<ReturnType<KnipClient["analyze"]>>(
|
|
907
|
-
"knip",
|
|
908
|
-
cwd,
|
|
909
|
-
);
|
|
910
|
-
if (cached) {
|
|
1008
|
+
// Scan for project-specific rules (.claude/rules, .agents/rules, CLAUDE.md, etc.)
|
|
1009
|
+
projectRulesScan = scanProjectRules(cwd);
|
|
1010
|
+
if (projectRulesScan.hasCustomRules) {
|
|
1011
|
+
const ruleCount = projectRulesScan.rules.length;
|
|
1012
|
+
const sources = [
|
|
1013
|
+
...new Set(projectRulesScan.rules.map((r) => r.source)),
|
|
1014
|
+
];
|
|
911
1015
|
dbg(
|
|
912
|
-
`session_start
|
|
1016
|
+
`session_start: found ${ruleCount} project rule(s) from ${sources.join(", ")}`,
|
|
1017
|
+
);
|
|
1018
|
+
parts.push(
|
|
1019
|
+
`📋 Project rules found: ${ruleCount} file(s) in ${sources.join(", ")}. These apply alongside pi-lens defaults.`,
|
|
913
1020
|
);
|
|
914
|
-
const knipReport = knipClient.formatResult(cached.data);
|
|
915
|
-
if (knipReport) parts.push(knipReport);
|
|
916
1021
|
} else {
|
|
917
|
-
|
|
918
|
-
const knipResult = knipClient.analyze(cwd);
|
|
919
|
-
cacheManager.writeCache("knip", knipResult, cwd, {
|
|
920
|
-
scanDurationMs: Date.now() - startMs,
|
|
921
|
-
});
|
|
922
|
-
const knipReport = knipClient.formatResult(knipResult);
|
|
923
|
-
dbg(`session_start Knip scan done`);
|
|
924
|
-
if (knipReport) parts.push(knipReport);
|
|
1022
|
+
dbg("session_start: no project rules found");
|
|
925
1023
|
}
|
|
926
|
-
} else {
|
|
927
|
-
dbg(`session_start Knip: not available`);
|
|
928
|
-
}
|
|
929
1024
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
if
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1025
|
+
// TODO/FIXME scan — fast, no deps
|
|
1026
|
+
const todoResult = todoScanner.scanDirectory(cwd);
|
|
1027
|
+
const todoReport = todoScanner.formatResult(todoResult);
|
|
1028
|
+
dbg(`session_start TODO scan: ${todoResult.items.length} items`);
|
|
1029
|
+
if (todoReport) parts.push(todoReport);
|
|
1030
|
+
|
|
1031
|
+
// Dead code scan — use cache if fresh, auto-install if needed
|
|
1032
|
+
if (await knipClient.ensureAvailable()) {
|
|
1033
|
+
const cached = cacheManager.readCache<
|
|
1034
|
+
ReturnType<KnipClient["analyze"]>
|
|
1035
|
+
>("knip", cwd);
|
|
1036
|
+
if (cached) {
|
|
1037
|
+
dbg(
|
|
1038
|
+
`session_start Knip: cache hit (${Math.round((Date.now() - new Date(cached.meta.timestamp).getTime()) / 1000)}s ago)`,
|
|
1039
|
+
);
|
|
1040
|
+
const knipReport = knipClient.formatResult(cached.data);
|
|
1041
|
+
if (knipReport) parts.push(knipReport);
|
|
1042
|
+
} else {
|
|
1043
|
+
const startMs = Date.now();
|
|
1044
|
+
const knipResult = knipClient.analyze(cwd);
|
|
1045
|
+
cacheManager.writeCache("knip", knipResult, cwd, {
|
|
1046
|
+
scanDurationMs: Date.now() - startMs,
|
|
1047
|
+
});
|
|
1048
|
+
const knipReport = knipClient.formatResult(knipResult);
|
|
1049
|
+
dbg(`session_start Knip scan done`);
|
|
1050
|
+
if (knipReport) parts.push(knipReport);
|
|
1051
|
+
}
|
|
941
1052
|
} else {
|
|
942
|
-
|
|
943
|
-
const jscpdResult = jscpdClient.scan(cwd);
|
|
944
|
-
_cachedJscpdClones = jscpdResult.clones;
|
|
945
|
-
cacheManager.writeCache("jscpd", jscpdResult, cwd, {
|
|
946
|
-
scanDurationMs: Date.now() - startMs,
|
|
947
|
-
});
|
|
948
|
-
const jscpdReport = jscpdClient.formatResult(jscpdResult);
|
|
949
|
-
dbg(`session_start jscpd scan done`);
|
|
950
|
-
if (jscpdReport) parts.push(jscpdReport);
|
|
1053
|
+
dbg(`session_start Knip: not available`);
|
|
951
1054
|
}
|
|
952
|
-
} else {
|
|
953
|
-
dbg(`session_start jscpd: not available`);
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Note: type-coverage runs on-demand via /lens-booboo only (not at session_start)
|
|
957
1055
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1056
|
+
// Duplicate code detection — use cache if fresh, auto-install if needed
|
|
1057
|
+
if (await jscpdClient.ensureAvailable()) {
|
|
1058
|
+
const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
|
|
1059
|
+
"jscpd",
|
|
1060
|
+
cwd,
|
|
1061
|
+
);
|
|
1062
|
+
if (cached) {
|
|
1063
|
+
dbg(`session_start jscpd: cache hit`);
|
|
1064
|
+
_cachedJscpdClones = cached.data.clones;
|
|
1065
|
+
const jscpdReport = jscpdClient.formatResult(cached.data);
|
|
1066
|
+
if (jscpdReport) parts.push(jscpdReport);
|
|
1067
|
+
} else {
|
|
1068
|
+
const startMs = Date.now();
|
|
1069
|
+
const jscpdResult = jscpdClient.scan(cwd);
|
|
1070
|
+
_cachedJscpdClones = jscpdResult.clones;
|
|
1071
|
+
cacheManager.writeCache("jscpd", jscpdResult, cwd, {
|
|
1072
|
+
scanDurationMs: Date.now() - startMs,
|
|
1073
|
+
});
|
|
1074
|
+
const jscpdReport = jscpdClient.formatResult(jscpdResult);
|
|
1075
|
+
dbg(`session_start jscpd scan done`);
|
|
1076
|
+
if (jscpdReport) parts.push(jscpdReport);
|
|
1077
|
+
}
|
|
1078
|
+
} else {
|
|
1079
|
+
dbg(`session_start jscpd: not available`);
|
|
964
1080
|
}
|
|
965
|
-
}
|
|
966
1081
|
|
|
967
|
-
|
|
968
|
-
`session_start: scans complete (${parts.length} part(s)), cached for commands`,
|
|
969
|
-
);
|
|
1082
|
+
// Note: type-coverage runs on-demand via /lens-booboo only (not at session_start)
|
|
970
1083
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1084
|
+
// Scan for exported functions (cached for duplicate detection on write)
|
|
1085
|
+
if (await astGrepClient.ensureAvailable()) {
|
|
1086
|
+
const exports = await astGrepClient.scanExports(cwd, "typescript");
|
|
1087
|
+
dbg(`session_start exports scan: ${exports.size} functions found`);
|
|
1088
|
+
for (const [name, file] of exports) {
|
|
1089
|
+
cachedExports.set(name, file);
|
|
1090
|
+
}
|
|
975
1091
|
}
|
|
976
|
-
}
|
|
977
1092
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1093
|
+
// Build similarity index for pre-write structural duplicate detection.
|
|
1094
|
+
// Uses the same source files as the exports scan. The index is ~50ms
|
|
1095
|
+
// to query but seconds to build, so we do it once at session start.
|
|
1096
|
+
try {
|
|
1097
|
+
const existing = await loadIndex(cwd);
|
|
1098
|
+
if (existing && existing.entries.size > 0) {
|
|
1099
|
+
cachedProjectIndex = existing;
|
|
1100
|
+
dbg(
|
|
1101
|
+
`session_start: loaded project index from disk (${existing.entries.size} entries)`,
|
|
1102
|
+
);
|
|
1103
|
+
} else {
|
|
1104
|
+
const sourceFiles = getSourceFiles(cwd, true);
|
|
1105
|
+
const tsFiles = sourceFiles.filter(
|
|
1106
|
+
(f) => f.endsWith(".ts") || f.endsWith(".tsx"),
|
|
1107
|
+
);
|
|
1108
|
+
if (tsFiles.length > 0 && tsFiles.length <= 500) {
|
|
1109
|
+
cachedProjectIndex = await buildProjectIndex(cwd, tsFiles);
|
|
1110
|
+
dbg(
|
|
1111
|
+
`session_start: built project index (${cachedProjectIndex.entries.size} entries from ${tsFiles.length} files)`,
|
|
1112
|
+
);
|
|
1113
|
+
} else {
|
|
1114
|
+
dbg(
|
|
1115
|
+
`session_start: skipped project index (${tsFiles.length} files — ${tsFiles.length === 0 ? "none" : "too many"})`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
dbg(`session_start: project index build failed: ${err}`);
|
|
1002
1121
|
}
|
|
1003
1122
|
|
|
1004
|
-
// Update baseline
|
|
1005
|
-
errorDebtBaseline = {
|
|
1006
|
-
testsPassed: testsPassed,
|
|
1007
|
-
buildPassed: true,
|
|
1008
|
-
};
|
|
1009
|
-
} else if (errorDebtEnabled && detectedRunner) {
|
|
1010
|
-
// No pending check - establish fresh baseline
|
|
1011
|
-
dbg("session_start: establishing fresh error debt baseline");
|
|
1012
|
-
const testResult = testRunnerClient.runTestFile(
|
|
1013
|
-
".",
|
|
1014
|
-
cwd,
|
|
1015
|
-
detectedRunner.runner,
|
|
1016
|
-
detectedRunner.config,
|
|
1017
|
-
);
|
|
1018
|
-
const testsPassed = testResult.failed === 0 && !testResult.error;
|
|
1019
|
-
errorDebtBaseline = {
|
|
1020
|
-
testsPassed: testsPassed,
|
|
1021
|
-
buildPassed: true,
|
|
1022
|
-
};
|
|
1023
1123
|
dbg(
|
|
1024
|
-
`session_start
|
|
1124
|
+
`session_start: scans complete (${parts.length} part(s)), cached for commands`,
|
|
1025
1125
|
);
|
|
1126
|
+
|
|
1127
|
+
// Output the assembled parts to user
|
|
1128
|
+
if (parts.length > 0) {
|
|
1129
|
+
for (const part of parts) {
|
|
1130
|
+
ctx.ui.notify(part, "info");
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// --- Error debt: check if tests ran since last session ---
|
|
1135
|
+
// If files were modified in previous turn, run tests and check for regression
|
|
1136
|
+
const errorDebtEnabled = pi.getFlag("error-debt");
|
|
1137
|
+
const pendingDebt = cacheManager.readCache<{
|
|
1138
|
+
pendingCheck: boolean;
|
|
1139
|
+
baselineTestsPassed: boolean;
|
|
1140
|
+
}>("errorDebt", cwd);
|
|
1141
|
+
|
|
1142
|
+
if (
|
|
1143
|
+
errorDebtEnabled &&
|
|
1144
|
+
detectedRunner &&
|
|
1145
|
+
pendingDebt?.data?.pendingCheck
|
|
1146
|
+
) {
|
|
1147
|
+
dbg("session_start: running pending error debt check");
|
|
1148
|
+
const testResult = testRunnerClient.runTestFile(
|
|
1149
|
+
".",
|
|
1150
|
+
cwd,
|
|
1151
|
+
detectedRunner.runner,
|
|
1152
|
+
detectedRunner.config,
|
|
1153
|
+
);
|
|
1154
|
+
const testsPassed = testResult.failed === 0 && !testResult.error;
|
|
1155
|
+
const baselinePassed = pendingDebt.data.baselineTestsPassed;
|
|
1156
|
+
|
|
1157
|
+
// Regression detected!
|
|
1158
|
+
if (baselinePassed && !testsPassed) {
|
|
1159
|
+
const msg = `🔴 ERROR DEBT: Tests were passing but now failing (${testResult.failed} failure(s)). Fix before continuing.`;
|
|
1160
|
+
dbg(`session_start ERROR DEBT: ${msg}`);
|
|
1161
|
+
parts.push(msg);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Update baseline
|
|
1165
|
+
errorDebtBaseline = {
|
|
1166
|
+
testsPassed: testsPassed,
|
|
1167
|
+
buildPassed: true,
|
|
1168
|
+
};
|
|
1169
|
+
} else if (errorDebtEnabled && detectedRunner) {
|
|
1170
|
+
// No pending check - establish fresh baseline
|
|
1171
|
+
dbg("session_start: establishing fresh error debt baseline");
|
|
1172
|
+
const testResult = testRunnerClient.runTestFile(
|
|
1173
|
+
".",
|
|
1174
|
+
cwd,
|
|
1175
|
+
detectedRunner.runner,
|
|
1176
|
+
detectedRunner.config,
|
|
1177
|
+
);
|
|
1178
|
+
const testsPassed = testResult.failed === 0 && !testResult.error;
|
|
1179
|
+
errorDebtBaseline = {
|
|
1180
|
+
testsPassed: testsPassed,
|
|
1181
|
+
buildPassed: true,
|
|
1182
|
+
};
|
|
1183
|
+
dbg(
|
|
1184
|
+
`session_start error debt baseline: testsPassed=${errorDebtBaseline.testsPassed}`,
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
} catch (sessionErr) {
|
|
1188
|
+
dbg(`session_start crashed: ${sessionErr}`);
|
|
1189
|
+
dbg(`session_start crash stack: ${(sessionErr as Error).stack}`);
|
|
1026
1190
|
}
|
|
1027
1191
|
});
|
|
1028
1192
|
|
|
1029
|
-
// --- Pre-write proactive hints ---
|
|
1030
|
-
// Stored during tool_call, prepended to tool_result output so the agent sees them.
|
|
1031
|
-
const preWriteHints = new Map<string, string>();
|
|
1032
|
-
|
|
1033
1193
|
pi.on("tool_call", async (event, _ctx) => {
|
|
1034
1194
|
const filePath =
|
|
1035
1195
|
isToolCallEventType("write", event) || isToolCallEventType("edit", event)
|
|
@@ -1038,14 +1198,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1038
1198
|
|
|
1039
1199
|
if (!filePath) return;
|
|
1040
1200
|
|
|
1041
|
-
const _toolCallStart = Date.now();
|
|
1042
1201
|
dbg(
|
|
1043
1202
|
`tool_call fired for: ${filePath} (exists: ${nodeFs.existsSync(filePath)})`,
|
|
1044
1203
|
);
|
|
1045
1204
|
if (!nodeFs.existsSync(filePath)) return;
|
|
1046
1205
|
|
|
1047
|
-
// Record complexity baseline for
|
|
1048
|
-
|
|
1206
|
+
// Record complexity baseline for historical tracking (booboo/tdi).
|
|
1207
|
+
// Not shown inline — just captured for delta analysis.
|
|
1049
1208
|
if (
|
|
1050
1209
|
complexityClient.isSupportedFile(filePath) &&
|
|
1051
1210
|
!complexityBaselines.has(filePath)
|
|
@@ -1053,161 +1212,100 @@ export default function (pi: ExtensionAPI) {
|
|
|
1053
1212
|
const baseline = complexityClient.analyzeFile(filePath);
|
|
1054
1213
|
if (baseline) {
|
|
1055
1214
|
complexityBaselines.set(filePath, baseline);
|
|
1056
|
-
// Capture snapshot for historical tracking (async, non-blocking)
|
|
1057
1215
|
captureSnapshot(filePath, {
|
|
1058
1216
|
maintainabilityIndex: baseline.maintainabilityIndex,
|
|
1059
1217
|
cognitiveComplexity: baseline.cognitiveComplexity,
|
|
1060
1218
|
maxNestingDepth: baseline.maxNestingDepth,
|
|
1061
1219
|
linesOfCode: baseline.linesOfCode,
|
|
1220
|
+
maxCyclomatic: baseline.maxCyclomaticComplexity,
|
|
1221
|
+
entropy: baseline.codeEntropy,
|
|
1062
1222
|
});
|
|
1063
1223
|
}
|
|
1064
|
-
logLatency({
|
|
1065
|
-
type: "phase",
|
|
1066
|
-
toolName: event.toolName,
|
|
1067
|
-
filePath,
|
|
1068
|
-
phase: "complexity_baseline",
|
|
1069
|
-
durationMs: Date.now() - complexityBaselineStart,
|
|
1070
|
-
metadata: { hasBaseline: complexityBaselines.has(filePath) },
|
|
1071
|
-
});
|
|
1072
1224
|
}
|
|
1073
1225
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
if (
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
` L${d.range.start.line + 1}: ${d.message.split("\n")[0].substring(0, 80)}${fixHint}`,
|
|
1096
|
-
);
|
|
1226
|
+
// --- Pre-write duplicate detection ---
|
|
1227
|
+
// Check if new content redefines functions that already exist elsewhere.
|
|
1228
|
+
// Uses cachedExports (populated at session_start via ast-grep scan).
|
|
1229
|
+
if (isToolCallEventType("write", event) && cachedExports.size > 0) {
|
|
1230
|
+
const newContent = (event.input as { content?: string }).content;
|
|
1231
|
+
if (newContent) {
|
|
1232
|
+
const dupeWarnings: string[] = [];
|
|
1233
|
+
const exportRe =
|
|
1234
|
+
/export\s+(?:async\s+)?(?:function|class|const|let|type|interface)\s+(\w+)/g;
|
|
1235
|
+
let m: RegExpExecArray | null;
|
|
1236
|
+
while ((m = exportRe.exec(newContent))) {
|
|
1237
|
+
const name = m[1];
|
|
1238
|
+
const existingFile = cachedExports.get(name);
|
|
1239
|
+
if (
|
|
1240
|
+
existingFile &&
|
|
1241
|
+
path.resolve(existingFile) !== path.resolve(filePath)
|
|
1242
|
+
) {
|
|
1243
|
+
dupeWarnings.push(
|
|
1244
|
+
`\`${name}\` already exists in ${path.relative(projectRoot, existingFile)}`,
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1097
1247
|
}
|
|
1098
|
-
if (
|
|
1099
|
-
|
|
1248
|
+
if (dupeWarnings.length > 0) {
|
|
1249
|
+
return {
|
|
1250
|
+
block: true,
|
|
1251
|
+
reason: `🔴 STOP — Redefining existing export(s). Import instead:\n${dupeWarnings.map((w) => ` • ${w}`).join("\n")}`,
|
|
1252
|
+
};
|
|
1100
1253
|
}
|
|
1101
|
-
}
|
|
1102
|
-
_preCheckDuration += Date.now() - tsPreCheckStart;
|
|
1103
|
-
logLatency({
|
|
1104
|
-
type: "phase",
|
|
1105
|
-
toolName: event.toolName,
|
|
1106
|
-
filePath,
|
|
1107
|
-
phase: "ts_pre_check",
|
|
1108
|
-
durationMs: Date.now() - tsPreCheckStart,
|
|
1109
|
-
metadata: { errorCount: errorDiags.length, hintCount: hints.length },
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
1254
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1255
|
+
// --- Structural similarity check (Phase 7b) ---
|
|
1256
|
+
// If the project index was built at session_start, check new
|
|
1257
|
+
// functions against it for structural clones (~50ms).
|
|
1258
|
+
if (
|
|
1259
|
+
cachedProjectIndex &&
|
|
1260
|
+
cachedProjectIndex.entries.size > 0 &&
|
|
1261
|
+
/\.(ts|tsx)$/.test(filePath)
|
|
1262
|
+
) {
|
|
1263
|
+
try {
|
|
1264
|
+
const ts = await import("typescript");
|
|
1265
|
+
const sourceFile = ts.createSourceFile(
|
|
1266
|
+
filePath,
|
|
1267
|
+
newContent,
|
|
1268
|
+
ts.ScriptTarget.Latest,
|
|
1269
|
+
true,
|
|
1270
|
+
);
|
|
1271
|
+
const newFunctions = extractFunctions(sourceFile, newContent);
|
|
1272
|
+
const simWarnings: string[] = [];
|
|
1273
|
+
const relPath = path.relative(projectRoot, filePath);
|
|
1274
|
+
|
|
1275
|
+
for (const func of newFunctions) {
|
|
1276
|
+
if (func.transitionCount < 20) continue;
|
|
1277
|
+
const matches = findSimilarFunctions(
|
|
1278
|
+
func.matrix,
|
|
1279
|
+
cachedProjectIndex,
|
|
1280
|
+
0.8,
|
|
1281
|
+
1,
|
|
1130
1282
|
);
|
|
1131
|
-
for (const
|
|
1132
|
-
|
|
1133
|
-
|
|
1283
|
+
for (const match of matches) {
|
|
1284
|
+
// Skip self-matches
|
|
1285
|
+
if (match.targetId === `${relPath}:${func.name}`) continue;
|
|
1286
|
+
const pct = Math.round(match.similarity * 100);
|
|
1287
|
+
simWarnings.push(
|
|
1288
|
+
`\`${func.name}\` is ${pct}% similar to \`${match.targetName}\` in ${match.targetLocation}`,
|
|
1134
1289
|
);
|
|
1135
1290
|
}
|
|
1136
|
-
if (errorDiags.length > 3) {
|
|
1137
|
-
hints.push(` ... and ${errorDiags.length - 3} more`);
|
|
1138
|
-
}
|
|
1139
1291
|
}
|
|
1140
|
-
_preCheckDuration += Date.now() - lspPreCheckStart;
|
|
1141
|
-
logLatency({
|
|
1142
|
-
type: "phase",
|
|
1143
|
-
toolName: event.toolName,
|
|
1144
|
-
filePath,
|
|
1145
|
-
phase: "lsp_pre_check",
|
|
1146
|
-
durationMs: Date.now() - lspPreCheckStart,
|
|
1147
|
-
metadata: { hasLSP, errorCount: errorDiags?.length ?? 0 },
|
|
1148
|
-
});
|
|
1149
|
-
}
|
|
1150
|
-
})
|
|
1151
|
-
.catch((err) => {
|
|
1152
|
-
logLatency({
|
|
1153
|
-
type: "phase",
|
|
1154
|
-
toolName: event.toolName,
|
|
1155
|
-
filePath,
|
|
1156
|
-
phase: "lsp_pre_check",
|
|
1157
|
-
durationMs: Date.now() - lspPreCheckStart,
|
|
1158
|
-
status: "failed",
|
|
1159
|
-
metadata: { error: String(err) },
|
|
1160
|
-
});
|
|
1161
|
-
});
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// Note: ast-grep baseline removed - we use ast-grep-napi in dispatch instead
|
|
1165
|
-
// Note: biome baseline removed - auto-fix handles this in post-write
|
|
1166
1292
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1293
|
+
if (simWarnings.length > 0) {
|
|
1294
|
+
return {
|
|
1295
|
+
block: false,
|
|
1296
|
+
reason: `⚠️ Structural similarity detected — consider reusing existing code:\n${simWarnings.map((w) => ` • ${w}`).join("\n")}`,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
} catch {
|
|
1300
|
+
// Parsing failed — skip similarity check silently
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1173
1304
|
}
|
|
1174
1305
|
});
|
|
1175
1306
|
|
|
1176
1307
|
// Real-time feedback on file writes/edits
|
|
1177
1308
|
pi.on("tool_result", async (event) => {
|
|
1178
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1179
|
-
// LATENCY TRACKING: Comprehensive phase-based logging
|
|
1180
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1181
|
-
const toolResultStart = Date.now();
|
|
1182
|
-
const toolName = event.toolName;
|
|
1183
|
-
const phases: Array<{
|
|
1184
|
-
name: string;
|
|
1185
|
-
start: number;
|
|
1186
|
-
end?: number;
|
|
1187
|
-
duration?: number;
|
|
1188
|
-
}> = [];
|
|
1189
|
-
|
|
1190
|
-
function phaseStart(name: string) {
|
|
1191
|
-
phases.push({ name, start: Date.now() });
|
|
1192
|
-
}
|
|
1193
|
-
function phaseEnd(name: string, metadata?: Record<string, unknown>) {
|
|
1194
|
-
const p = phases.find((x) => x.name === name && !x.end);
|
|
1195
|
-
if (p) {
|
|
1196
|
-
p.end = Date.now();
|
|
1197
|
-
p.duration = p.end - p.start;
|
|
1198
|
-
if (filePath) {
|
|
1199
|
-
logLatency({
|
|
1200
|
-
type: "phase",
|
|
1201
|
-
toolName,
|
|
1202
|
-
filePath,
|
|
1203
|
-
phase: name,
|
|
1204
|
-
durationMs: p.duration,
|
|
1205
|
-
metadata,
|
|
1206
|
-
});
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
1309
|
// Track tool call for behavior analysis (all tool types)
|
|
1212
1310
|
const filePath = (event.input as { path?: string }).path;
|
|
1213
1311
|
const behaviorWarnings = agentBehaviorClient.recordToolCall(
|
|
@@ -1243,11 +1341,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1243
1341
|
}
|
|
1244
1342
|
// Record this write so future assertions know the agent has the current state
|
|
1245
1343
|
sessionFileTime.read(filePath);
|
|
1344
|
+
const toolResultStart = Date.now();
|
|
1246
1345
|
dbg(
|
|
1247
1346
|
`tool_result: tracking turn state for ${event.toolName} on ${filePath}`,
|
|
1248
1347
|
);
|
|
1249
|
-
phaseStart("total");
|
|
1250
|
-
phaseStart("turn_state_tracking");
|
|
1251
1348
|
|
|
1252
1349
|
// --- Track modified ranges in turn state for async jscpd/madge at turn_end ---
|
|
1253
1350
|
const cwd = projectRoot;
|
|
@@ -1292,344 +1389,73 @@ export default function (pi: ExtensionAPI) {
|
|
|
1292
1389
|
dbg(`turn state tracking error stack: ${(err as Error).stack}`);
|
|
1293
1390
|
}
|
|
1294
1391
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
// Record write for metrics (silent tracking)
|
|
1306
|
-
phaseEnd("turn_state_tracking");
|
|
1307
|
-
phaseStart("read_file");
|
|
1392
|
+
const turnStateMs = Date.now() - toolResultStart;
|
|
1393
|
+
logLatency({
|
|
1394
|
+
type: "phase",
|
|
1395
|
+
toolName: event.toolName,
|
|
1396
|
+
filePath,
|
|
1397
|
+
phase: "turn_state_tracking",
|
|
1398
|
+
durationMs: turnStateMs,
|
|
1399
|
+
});
|
|
1400
|
+
dbg(`tool_result fired for: ${filePath} (turn_state: ${turnStateMs}ms)`);
|
|
1308
1401
|
|
|
1309
|
-
let
|
|
1402
|
+
let result: { output: string; isError?: boolean };
|
|
1310
1403
|
try {
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
} catch (err) {
|
|
1314
|
-
void err;
|
|
1315
|
-
}
|
|
1316
|
-
phaseEnd("read_file");
|
|
1317
|
-
|
|
1318
|
-
// --- Auto-format on write (default enabled) ---
|
|
1319
|
-
phaseStart("format");
|
|
1320
|
-
// Runs detected formatters concurrently via Effect-TS
|
|
1321
|
-
let formatChanged = false;
|
|
1322
|
-
let formattersUsed: string[] = [];
|
|
1323
|
-
if (!pi.getFlag("no-autoformat") && fileContent) {
|
|
1324
|
-
const formatService = getFormatService();
|
|
1325
|
-
try {
|
|
1326
|
-
// Record file read to establish FileTime baseline before formatting
|
|
1327
|
-
// This prevents "modified externally" false positives when agent writes file
|
|
1328
|
-
formatService.recordRead(filePath);
|
|
1329
|
-
const result = await formatService.formatFile(filePath);
|
|
1330
|
-
formattersUsed = result.formatters.map((f) => f.name);
|
|
1331
|
-
if (result.anyChanged) {
|
|
1332
|
-
formatChanged = true;
|
|
1333
|
-
dbg(
|
|
1334
|
-
`autoformat: ${result.formatters.map((f) => `${f.name}(${f.changed ? "changed" : "unchanged"})`).join(", ")}`,
|
|
1335
|
-
);
|
|
1336
|
-
// Re-read content after formatting for downstream processing
|
|
1337
|
-
fileContent = nodeFs.readFileSync(filePath, "utf-8");
|
|
1338
|
-
}
|
|
1339
|
-
} catch (err) {
|
|
1340
|
-
dbg(`autoformat error: ${err}`);
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
phaseEnd("format", { formattersUsed, formatChanged });
|
|
1344
|
-
|
|
1345
|
-
// --- Publish file modified event to bus (Phase 1) ---
|
|
1346
|
-
// --- LSP integration (Phase 3) ---
|
|
1347
|
-
if (pi.getFlag("lens-lsp") && fileContent) {
|
|
1348
|
-
const lspService = getLSPService();
|
|
1349
|
-
lspService
|
|
1350
|
-
.hasLSP(filePath)
|
|
1351
|
-
.then(async (hasLSP) => {
|
|
1352
|
-
if (hasLSP) {
|
|
1353
|
-
// Open or update file in LSP
|
|
1354
|
-
if (event.toolName === "write") {
|
|
1355
|
-
await lspService.openFile(filePath, fileContent);
|
|
1356
|
-
} else {
|
|
1357
|
-
await lspService.updateFile(filePath, fileContent);
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
})
|
|
1361
|
-
.catch((err) => {
|
|
1362
|
-
dbg(`LSP error: ${err}`);
|
|
1363
|
-
});
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
// --- Secrets scan (blocking - must check before other linting) ---
|
|
1367
|
-
if (fileContent) {
|
|
1368
|
-
const secretFindings = scanForSecrets(fileContent, filePath);
|
|
1369
|
-
if (secretFindings.length > 0) {
|
|
1370
|
-
const secretsOutput = formatSecrets(secretFindings, filePath);
|
|
1371
|
-
const elapsed = Date.now() - toolResultStart;
|
|
1372
|
-
logLatency({
|
|
1373
|
-
type: "tool_result",
|
|
1374
|
-
toolName,
|
|
1404
|
+
result = await runPipeline(
|
|
1405
|
+
{
|
|
1375
1406
|
filePath,
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
// Apply fixes BEFORE dispatch so dispatch only reports remaining issues
|
|
1395
|
-
// Autofix is enabled by default, use --no-autofix to disable
|
|
1396
|
-
const noAutofix = pi.getFlag("no-autofix");
|
|
1397
|
-
const noAutofixBiome = pi.getFlag("no-autofix-biome");
|
|
1398
|
-
const noAutofixRuff = pi.getFlag("no-autofix-ruff");
|
|
1399
|
-
let fixedCount = 0;
|
|
1400
|
-
|
|
1401
|
-
if (!fixedThisTurn.has(filePath) && !noAutofix) {
|
|
1402
|
-
// Python: Ruff auto-fix (enabled by default)
|
|
1403
|
-
if (
|
|
1404
|
-
!noAutofixRuff &&
|
|
1405
|
-
(await ruffClient.ensureAvailable()) &&
|
|
1406
|
-
ruffClient.isPythonFile(filePath)
|
|
1407
|
-
) {
|
|
1408
|
-
const result = ruffClient.fixFile(filePath);
|
|
1409
|
-
if (result.success && result.fixed > 0) {
|
|
1410
|
-
fixedCount += result.fixed;
|
|
1411
|
-
fixedThisTurn.add(filePath);
|
|
1412
|
-
dbg(`autofix: ruff fixed ${result.fixed} issue(s) in ${filePath}`);
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// JS/TS/JSON: Biome auto-fix (enabled by default)
|
|
1417
|
-
if (
|
|
1418
|
-
!noAutofixBiome &&
|
|
1419
|
-
biomeClient.isAvailable() &&
|
|
1420
|
-
biomeClient.isSupportedFile(filePath)
|
|
1421
|
-
) {
|
|
1422
|
-
const result = biomeClient.fixFile(filePath);
|
|
1423
|
-
if (result.success && result.fixed > 0) {
|
|
1424
|
-
fixedCount += result.fixed;
|
|
1425
|
-
fixedThisTurn.add(filePath);
|
|
1426
|
-
dbg(`autofix: biome fixed ${result.fixed} issue(s) in ${filePath}`);
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
phaseEnd("autofix", { fixedCount, tools: ["ruff", "biome"] });
|
|
1431
|
-
|
|
1432
|
-
// --- Declarative dispatch: run all applicable lint tools ---
|
|
1433
|
-
phaseStart("dispatch_lint");
|
|
1434
|
-
// Phase 2: Replaced ~400 lines of if/else with unified dispatch system
|
|
1435
|
-
dbg(`dispatch: running lint tools for ${filePath}`);
|
|
1436
|
-
|
|
1437
|
-
const dispatchOutput = await dispatchLint(filePath, projectRoot, pi);
|
|
1438
|
-
|
|
1439
|
-
if (dispatchOutput) {
|
|
1440
|
-
lspOutput += `\n\n${dispatchOutput}`;
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
// Report autofix results
|
|
1444
|
-
if (fixedCount > 0) {
|
|
1445
|
-
lspOutput += `\n\n✅ Auto-fixed ${fixedCount} issue(s) in ${path.basename(filePath)}`;
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
// Warn agent if file was modified by auto-format or auto-fix
|
|
1449
|
-
// This ensures they know to re-read before next edit
|
|
1450
|
-
if (formatChanged || fixedCount > 0) {
|
|
1451
|
-
lspOutput += `\n\n⚠️ **File modified by auto-format/fix. Re-read before next edit.**`;
|
|
1407
|
+
cwd: projectRoot,
|
|
1408
|
+
toolName: event.toolName,
|
|
1409
|
+
getFlag: (name: string) => pi.getFlag(name),
|
|
1410
|
+
dbg,
|
|
1411
|
+
},
|
|
1412
|
+
{
|
|
1413
|
+
biomeClient,
|
|
1414
|
+
ruffClient,
|
|
1415
|
+
testRunnerClient,
|
|
1416
|
+
metricsClient,
|
|
1417
|
+
getFormatService,
|
|
1418
|
+
fixedThisTurn,
|
|
1419
|
+
},
|
|
1420
|
+
);
|
|
1421
|
+
} catch (pipelineErr) {
|
|
1422
|
+
dbg(`runPipeline crashed: ${pipelineErr}`);
|
|
1423
|
+
dbg(`runPipeline crash stack: ${(pipelineErr as Error).stack}`);
|
|
1424
|
+
return;
|
|
1452
1425
|
}
|
|
1453
|
-
phaseEnd("dispatch_lint", {
|
|
1454
|
-
hasOutput: !!dispatchOutput,
|
|
1455
|
-
});
|
|
1456
1426
|
|
|
1457
|
-
//
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
`test-runner: found test file ${testInfo.testFile} for ${filePath}`,
|
|
1467
|
-
);
|
|
1468
|
-
const detectedRunner = testRunnerClient.detectRunner(cwd);
|
|
1469
|
-
if (detectedRunner) {
|
|
1470
|
-
testRunnerRan = true;
|
|
1471
|
-
const testStart = Date.now();
|
|
1472
|
-
const testResult = testRunnerClient.runTestFile(
|
|
1473
|
-
testInfo.testFile,
|
|
1474
|
-
cwd,
|
|
1475
|
-
detectedRunner.runner,
|
|
1476
|
-
detectedRunner.config,
|
|
1477
|
-
);
|
|
1478
|
-
const testDuration = Date.now() - testStart;
|
|
1479
|
-
logLatency({
|
|
1480
|
-
type: "phase",
|
|
1481
|
-
toolName,
|
|
1482
|
-
filePath,
|
|
1483
|
-
phase: "test_runner",
|
|
1484
|
-
durationMs: testDuration,
|
|
1485
|
-
metadata: {
|
|
1486
|
-
testFile: testInfo.testFile,
|
|
1487
|
-
runner: detectedRunner.runner,
|
|
1488
|
-
success: !testResult?.error,
|
|
1489
|
-
},
|
|
1490
|
-
});
|
|
1491
|
-
if (testResult && !testResult.error) {
|
|
1492
|
-
const testOutput = testRunnerClient.formatResult(testResult);
|
|
1493
|
-
if (testOutput) {
|
|
1494
|
-
lspOutput += `\n\n${testOutput}`;
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
phaseEnd("test_runner", { found: testInfoFound, ran: testRunnerRan });
|
|
1501
|
-
|
|
1502
|
-
// Note: TypeScript diagnostics are handled by the ts-lsp dispatch runner above.
|
|
1503
|
-
// No inline TypeScriptClient check here — dispatch already covers it.
|
|
1504
|
-
|
|
1505
|
-
// --- Complexity tracking (post-write) ---
|
|
1506
|
-
phaseStart("complexity_check");
|
|
1507
|
-
if (complexityClient.isSupportedFile(filePath)) {
|
|
1508
|
-
const oldBaseline = complexityBaselines.get(filePath);
|
|
1509
|
-
const newComplexity = complexityClient.analyzeFile(filePath);
|
|
1510
|
-
if (oldBaseline && newComplexity) {
|
|
1511
|
-
const complexityDelta = {
|
|
1512
|
-
cognitive:
|
|
1513
|
-
newComplexity.cognitiveComplexity - oldBaseline.cognitiveComplexity,
|
|
1514
|
-
maintainability:
|
|
1515
|
-
newComplexity.maintainabilityIndex -
|
|
1516
|
-
oldBaseline.maintainabilityIndex,
|
|
1517
|
-
lines: newComplexity.linesOfCode - oldBaseline.linesOfCode,
|
|
1518
|
-
};
|
|
1519
|
-
// Warn if complexity significantly increased
|
|
1520
|
-
if (
|
|
1521
|
-
complexityDelta.cognitive > 3 ||
|
|
1522
|
-
complexityDelta.maintainability < -5
|
|
1523
|
-
) {
|
|
1524
|
-
lspOutput += `\n\n⚠️ Complexity increased: +${complexityDelta.cognitive} cognitive, ${complexityDelta.maintainability.toFixed(1)} maintainability`;
|
|
1525
|
-
}
|
|
1526
|
-
phaseEnd("complexity_check", {
|
|
1527
|
-
delta: complexityDelta,
|
|
1528
|
-
warned:
|
|
1529
|
-
complexityDelta.cognitive > 3 ||
|
|
1530
|
-
complexityDelta.maintainability < -5,
|
|
1531
|
-
});
|
|
1532
|
-
} else {
|
|
1533
|
-
phaseEnd("complexity_check", { delta: null, warned: false });
|
|
1534
|
-
}
|
|
1535
|
-
} else {
|
|
1536
|
-
phaseEnd("complexity_check", { skipped: true });
|
|
1427
|
+
// Secrets found — block immediately
|
|
1428
|
+
if (result.isError) {
|
|
1429
|
+
return {
|
|
1430
|
+
content: [
|
|
1431
|
+
...event.content,
|
|
1432
|
+
{ type: "text" as const, text: result.output },
|
|
1433
|
+
],
|
|
1434
|
+
isError: true,
|
|
1435
|
+
};
|
|
1537
1436
|
}
|
|
1538
1437
|
|
|
1539
|
-
//
|
|
1438
|
+
// Append behavior warnings
|
|
1439
|
+
let output = result.output;
|
|
1540
1440
|
if (behaviorWarnings.length > 0) {
|
|
1541
|
-
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
// --- Cascade diagnostics: check other files for errors (when --lens-lsp) ---
|
|
1545
|
-
if (pi.getFlag("lens-lsp") && !pi.getFlag("no-lsp")) {
|
|
1546
|
-
const MAX_CASCADE_FILES = 5;
|
|
1547
|
-
const MAX_DIAGNOSTICS_PER_FILE = 20;
|
|
1548
|
-
const cascadeStart = Date.now();
|
|
1549
|
-
|
|
1550
|
-
try {
|
|
1551
|
-
const lspService = getLSPService();
|
|
1552
|
-
const allDiags = await lspService.getAllDiagnostics();
|
|
1553
|
-
const normalizedEditedPath = path.resolve(filePath);
|
|
1554
|
-
const otherFileErrors: Array<{
|
|
1555
|
-
file: string;
|
|
1556
|
-
errors: import("./clients/lsp/client.js").LSPDiagnostic[];
|
|
1557
|
-
}> = [];
|
|
1558
|
-
|
|
1559
|
-
for (const [diagPath, diags] of allDiags) {
|
|
1560
|
-
if (path.resolve(diagPath) === normalizedEditedPath) continue; // Skip edited file (dispatch already covered it)
|
|
1561
|
-
const errors = diags.filter((d) => d.severity === 1);
|
|
1562
|
-
if (errors.length > 0) {
|
|
1563
|
-
otherFileErrors.push({ file: diagPath, errors });
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
if (otherFileErrors.length > 0) {
|
|
1568
|
-
lspOutput += `\n\n📐 Cascade errors detected in ${otherFileErrors.length} other file(s):`;
|
|
1569
|
-
for (const { file, errors } of otherFileErrors.slice(
|
|
1570
|
-
0,
|
|
1571
|
-
MAX_CASCADE_FILES,
|
|
1572
|
-
)) {
|
|
1573
|
-
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE);
|
|
1574
|
-
const suffix =
|
|
1575
|
-
errors.length > MAX_DIAGNOSTICS_PER_FILE
|
|
1576
|
-
? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
|
|
1577
|
-
: "";
|
|
1578
|
-
// Structured XML format (like OpenCode) for cleaner parsing
|
|
1579
|
-
lspOutput += `\n<diagnostics file="${file}">`;
|
|
1580
|
-
for (const e of limited) {
|
|
1581
|
-
const line = (e.range?.start?.line ?? 0) + 1;
|
|
1582
|
-
const col = (e.range?.start?.character ?? 0) + 1;
|
|
1583
|
-
const code = e.code ? ` [${e.code}]` : "";
|
|
1584
|
-
lspOutput += `\n ${code} (${line}:${col}) ${e.message.split("\n")[0].slice(0, 100)}`;
|
|
1585
|
-
}
|
|
1586
|
-
lspOutput += `${suffix}\n</diagnostics>`;
|
|
1587
|
-
}
|
|
1588
|
-
if (otherFileErrors.length > MAX_CASCADE_FILES) {
|
|
1589
|
-
lspOutput += `\n... and ${otherFileErrors.length - MAX_CASCADE_FILES} more files with errors`;
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
logLatency({
|
|
1594
|
-
type: "phase",
|
|
1595
|
-
toolName,
|
|
1596
|
-
filePath,
|
|
1597
|
-
phase: "cascade_diagnostics",
|
|
1598
|
-
durationMs: Date.now() - cascadeStart,
|
|
1599
|
-
metadata: { filesWithErrors: otherFileErrors.length },
|
|
1600
|
-
});
|
|
1601
|
-
} catch (err) {
|
|
1602
|
-
dbg(`cascade diagnostics error: ${err}`);
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
// LATENCY TRACKING: Log timing before returning
|
|
1607
|
-
const elapsed = Date.now() - toolResultStart;
|
|
1608
|
-
phaseEnd("total", { hasOutput: !!lspOutput });
|
|
1609
|
-
if (!lspOutput) {
|
|
1610
|
-
logLatency({
|
|
1611
|
-
type: "tool_result",
|
|
1612
|
-
toolName,
|
|
1613
|
-
filePath,
|
|
1614
|
-
durationMs: elapsed,
|
|
1615
|
-
result: "no_output",
|
|
1616
|
-
});
|
|
1617
|
-
return;
|
|
1441
|
+
output += `\n\n${agentBehaviorClient.formatWarnings(behaviorWarnings)}`;
|
|
1618
1442
|
}
|
|
1619
1443
|
|
|
1444
|
+
const totalMs = Date.now() - toolResultStart;
|
|
1620
1445
|
logLatency({
|
|
1621
1446
|
type: "tool_result",
|
|
1622
|
-
toolName,
|
|
1447
|
+
toolName: event.toolName,
|
|
1623
1448
|
filePath,
|
|
1624
|
-
durationMs:
|
|
1625
|
-
result: "completed",
|
|
1449
|
+
durationMs: totalMs,
|
|
1450
|
+
result: output ? "completed" : "no_output",
|
|
1626
1451
|
});
|
|
1627
1452
|
|
|
1453
|
+
if (!output) return;
|
|
1454
|
+
|
|
1628
1455
|
return {
|
|
1629
|
-
content: [...event.content, { type: "text" as const, text:
|
|
1456
|
+
content: [...event.content, { type: "text" as const, text: output }],
|
|
1630
1457
|
};
|
|
1631
1458
|
});
|
|
1632
|
-
|
|
1633
1459
|
// --- Inject project rules into system prompt ---
|
|
1634
1460
|
pi.on("before_agent_start", async (event) => {
|
|
1635
1461
|
if (!projectRulesScan.hasCustomRules) return;
|
|
@@ -1642,123 +1468,130 @@ export default function (pi: ExtensionAPI) {
|
|
|
1642
1468
|
|
|
1643
1469
|
// --- Turn end: batch jscpd/madge on collected files, then clear state ---
|
|
1644
1470
|
pi.on("turn_end", async (_event, ctx) => {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
if (files.length === 0) return;
|
|
1471
|
+
try {
|
|
1472
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1473
|
+
const turnState = cacheManager.readTurnState(cwd);
|
|
1474
|
+
const files = Object.keys(turnState.files);
|
|
1650
1475
|
|
|
1651
|
-
|
|
1652
|
-
`turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}`,
|
|
1653
|
-
);
|
|
1476
|
+
if (files.length === 0) return;
|
|
1654
1477
|
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
cacheManager.clearTurnState(cwd);
|
|
1659
|
-
return;
|
|
1660
|
-
}
|
|
1478
|
+
dbg(
|
|
1479
|
+
`turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}`,
|
|
1480
|
+
);
|
|
1661
1481
|
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
const jscpdFiles = cacheManager.getFilesForJscpd(cwd);
|
|
1667
|
-
if (jscpdFiles.length > 0) {
|
|
1668
|
-
dbg(`turn_end: jscpd scanning ${jscpdFiles.length} file(s)`);
|
|
1669
|
-
// Use full scan then filter — jscpd doesn't support per-file scanning
|
|
1670
|
-
const result = jscpdClient.scan(cwd);
|
|
1671
|
-
// Filter clones to only those intersecting modified ranges
|
|
1672
|
-
const jscpdFileSet = new Set(
|
|
1673
|
-
jscpdFiles.map((f) => path.resolve(cwd, f)),
|
|
1482
|
+
// Max cycles guard — force through after N turns with unresolved issues
|
|
1483
|
+
if (cacheManager.isMaxCyclesExceeded(cwd)) {
|
|
1484
|
+
dbg(
|
|
1485
|
+
"turn_end: max cycles exceeded, clearing state and forcing through",
|
|
1674
1486
|
);
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1487
|
+
cacheManager.clearTurnState(cwd);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const parts: string[] = [];
|
|
1492
|
+
|
|
1493
|
+
// jscpd: scan modified files, filter results to modified line ranges
|
|
1494
|
+
if (jscpdClient.isAvailable()) {
|
|
1495
|
+
const jscpdFiles = cacheManager.getFilesForJscpd(cwd);
|
|
1496
|
+
if (jscpdFiles.length > 0) {
|
|
1497
|
+
dbg(`turn_end: jscpd scanning ${jscpdFiles.length} file(s)`);
|
|
1498
|
+
// Use full scan then filter — jscpd doesn't support per-file scanning
|
|
1499
|
+
const result = jscpdClient.scan(cwd);
|
|
1500
|
+
// Filter clones to only those intersecting modified ranges
|
|
1501
|
+
const jscpdFileSet = new Set(
|
|
1502
|
+
jscpdFiles.map((f) => path.resolve(cwd, f)),
|
|
1684
1503
|
);
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1504
|
+
const filtered = result.clones.filter((clone) => {
|
|
1505
|
+
const resolvedA = path.resolve(clone.fileA);
|
|
1506
|
+
if (!jscpdFileSet.has(resolvedA)) return false;
|
|
1507
|
+
const relA = path.relative(cwd, resolvedA).replace(/\\/g, "/");
|
|
1508
|
+
const state = turnState.files[relA];
|
|
1509
|
+
if (!state) return false;
|
|
1510
|
+
return cacheManager.isLineInModifiedRange(
|
|
1511
|
+
clone.startA,
|
|
1512
|
+
state.modifiedRanges,
|
|
1513
|
+
);
|
|
1514
|
+
});
|
|
1515
|
+
if (filtered.length > 0) {
|
|
1516
|
+
let report = `🔴 New duplicates in modified code:\n`;
|
|
1517
|
+
for (const clone of filtered.slice(0, 5)) {
|
|
1518
|
+
report += ` ${path.basename(clone.fileA)}:${clone.startA} ↔ ${path.basename(clone.fileB)}:${clone.startB} (${clone.lines} lines)\n`;
|
|
1519
|
+
}
|
|
1520
|
+
parts.push(report);
|
|
1690
1521
|
}
|
|
1691
|
-
|
|
1522
|
+
// Update the global cache with fresh results
|
|
1523
|
+
_cachedJscpdClones = result.clones;
|
|
1524
|
+
cacheManager.writeCache("jscpd", result, cwd);
|
|
1692
1525
|
}
|
|
1693
|
-
// Update the global cache with fresh results
|
|
1694
|
-
_cachedJscpdClones = result.clones;
|
|
1695
|
-
cacheManager.writeCache("jscpd", result, cwd);
|
|
1696
1526
|
}
|
|
1697
|
-
}
|
|
1698
1527
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1528
|
+
// madge: only check files where imports changed
|
|
1529
|
+
if (await depChecker.ensureAvailable()) {
|
|
1530
|
+
const madgeFiles = cacheManager.getFilesForMadge(cwd);
|
|
1531
|
+
if (madgeFiles.length > 0) {
|
|
1532
|
+
dbg(
|
|
1533
|
+
`turn_end: madge checking ${madgeFiles.length} file(s) for circular deps`,
|
|
1534
|
+
);
|
|
1535
|
+
for (const file of madgeFiles) {
|
|
1536
|
+
const absPath = path.resolve(cwd, file);
|
|
1537
|
+
const depResult = depChecker.checkFile(absPath);
|
|
1538
|
+
if (depResult.hasCircular && depResult.circular.length > 0) {
|
|
1539
|
+
const circularDeps = depResult.circular
|
|
1540
|
+
.flatMap((d) => d.path)
|
|
1541
|
+
.filter((p: string) => !absPath.endsWith(path.basename(p)));
|
|
1542
|
+
const uniqueDeps = [...new Set(circularDeps)];
|
|
1543
|
+
if (uniqueDeps.length > 0) {
|
|
1544
|
+
parts.push(
|
|
1545
|
+
`🟡 Circular dependency in ${file}: imports ${uniqueDeps.join(", ")}`,
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1718
1548
|
}
|
|
1719
1549
|
}
|
|
1720
1550
|
}
|
|
1721
1551
|
}
|
|
1722
|
-
}
|
|
1723
1552
|
|
|
1724
|
-
|
|
1725
|
-
|
|
1553
|
+
// Increment turn cycle and persist
|
|
1554
|
+
cacheManager.incrementTurnCycle(cwd);
|
|
1726
1555
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1556
|
+
if (parts.length > 0) {
|
|
1557
|
+
dbg(`turn_end: ${parts.length} issue(s) found`);
|
|
1558
|
+
// Issues found — state persists so next turn re-checks.
|
|
1559
|
+
// After maxCycles, clearTurnState forces through.
|
|
1560
|
+
} else {
|
|
1561
|
+
// No issues — clear state for next batch of edits
|
|
1562
|
+
cacheManager.clearTurnState(cwd);
|
|
1563
|
+
}
|
|
1735
1564
|
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1565
|
+
// --- Error debt: trigger background test run for next session ---
|
|
1566
|
+
// We don't wait - just set a flag that tests should run at next session_start
|
|
1567
|
+
// This way tests run async (session_start is when agent is idle)
|
|
1568
|
+
if (errorDebtBaseline && files.length > 0) {
|
|
1569
|
+
dbg("turn_end: marking error debt check for next session");
|
|
1570
|
+
// Write a marker file - next session_start will pick this up
|
|
1571
|
+
cacheManager.writeCache(
|
|
1572
|
+
"errorDebt",
|
|
1573
|
+
{
|
|
1574
|
+
pendingCheck: true,
|
|
1575
|
+
baselineTestsPassed: errorDebtBaseline.testsPassed,
|
|
1576
|
+
},
|
|
1577
|
+
cwd,
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1751
1580
|
|
|
1752
|
-
|
|
1753
|
-
|
|
1581
|
+
// Clear fixed tracking so files can be fixed again on next turn
|
|
1582
|
+
fixedThisTurn.clear();
|
|
1754
1583
|
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1584
|
+
// --- LSP cleanup on turn end (Phase 3) ---
|
|
1585
|
+
// Only shutdown if no files are being actively edited
|
|
1586
|
+
if (pi.getFlag("lens-lsp") && files.length === 0) {
|
|
1587
|
+
resetLSPService();
|
|
1588
|
+
}
|
|
1760
1589
|
|
|
1761
|
-
|
|
1762
|
-
|
|
1590
|
+
// --- Format service cleanup ---
|
|
1591
|
+
resetFormatService();
|
|
1592
|
+
} catch (turnEndErr) {
|
|
1593
|
+
dbg(`turn_end crashed: ${turnEndErr}`);
|
|
1594
|
+
dbg(`turn_end crash stack: ${(turnEndErr as Error).stack}`);
|
|
1595
|
+
}
|
|
1763
1596
|
});
|
|
1764
1597
|
}
|