diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 2eace2e..b1611d2 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -2,4 +2,10 @@ import { defineConfig } from '@vscode/test-cli'; export default defineConfig({ files: 'out/tests/**/*.test.js', + srcDir: 'src', + coverage: { + reporter: ['text-summary', 'html'], + include: ['out/src/**/*.js'], + exclude: ['out/tests/**', 'out/src/types/**'], + }, }); diff --git a/package-lock.json b/package-lock.json index c96d3ca..d8b85f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-code-reviewer", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-code-reviewer", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "@eslint/js": "^9.39.3", "eslint": "^9.39.3", diff --git a/package.json b/package.json index fc97337..359124f 100644 --- a/package.json +++ b/package.json @@ -336,7 +336,8 @@ "pretest": "npm run compile && npm run lint", "lint": "eslint src", "compile:test": "tsc -p ./tsconfig.test.json", - "test": "npm run compile:test && vscode-test" + "test": "npm run compile:test && vscode-test", + "test:coverage": "npm run compile && npm run compile:test && vscode-test --coverage --coverage-output tests/coverage" }, "dependencies": { "@eslint/js": "^9.39.3", diff --git a/src/scope/method-extractor.ts b/src/scope/method-extractor.ts index fa6b96b..018421c 100644 --- a/src/scope/method-extractor.ts +++ b/src/scope/method-extractor.ts @@ -199,7 +199,7 @@ function fallbackRegexSymbols(document: vscode.TextDocument): MethodSymbol[] { pattern.lastIndex = 0; let match: RegExpExecArray | null; while ((match = pattern.exec(text)) !== null) { - const name = match[1] ?? match[2]; + const name = match[2] ?? match[1]; if (!name) { continue; } const start = document.positionAt(match.index); const key = `${name}@${start.line}`; diff --git a/src/utils/mockDocument.ts b/src/utils/mockDocument.ts index e478d39..81a2729 100644 --- a/src/utils/mockDocument.ts +++ b/src/utils/mockDocument.ts @@ -4,6 +4,19 @@ export function mockDocument(code: string, language: string, fileName?: string): const lines = code.split('\n'); const uri = vscode.Uri.parse('untitled:virtual'); const ext = language === 'javascript' ? 'js' : language === 'typescript' ? 'ts' : language === 'css' ? 'css' : 'java'; + const offsetAt = (p: vscode.Position): number => { + let offset = 0; + for (let i = 0; i < p.line; i++) offset += lines[i].length + 1; + return offset + p.character; + }; + const positionAt = (offset: number): vscode.Position => { + let remaining = offset; + for (let i = 0; i < lines.length; i++) { + if (remaining <= lines[i].length) return new vscode.Position(i, remaining); + remaining -= lines[i].length + 1; + } + return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); + }; return { uri, fileName: fileName ?? `untitled.${ext}`, @@ -14,7 +27,10 @@ export function mockDocument(code: string, language: string, fileName?: string): isClosed: false, eol: vscode.EndOfLine.LF, lineCount: lines.length, - getText: () => code, + getText: (range?: vscode.Range) => { + if (!range) { return code; } + return code.slice(offsetAt(range.start), offsetAt(range.end)); + }, lineAt: (arg: number | vscode.Position) => { const line = typeof arg === 'number' ? arg : arg.line; const text = lines[line] ?? ''; @@ -27,19 +43,8 @@ export function mockDocument(code: string, language: string, fileName?: string): isEmptyOrWhitespace: text.trim().length === 0, }; }, - offsetAt: (p: vscode.Position) => { - let offset = 0; - for (let i = 0; i < p.line; i++) offset += lines[i].length + 1; - return offset + p.character; - }, - positionAt: (offset: number) => { - let remaining = offset; - for (let i = 0; i < lines.length; i++) { - if (remaining <= lines[i].length) return new vscode.Position(i, remaining); - remaining -= lines[i].length + 1; - } - return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); - }, + offsetAt, + positionAt, getWordRangeAtPosition: () => undefined, validateRange: (r: vscode.Range) => r, validatePosition: (p: vscode.Position) => p, diff --git a/tests/coverage/base.css b/tests/coverage/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/tests/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/tests/coverage/block-navigation.js b/tests/coverage/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/tests/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/tests/coverage/favicon.png b/tests/coverage/favicon.png new file mode 100644 index 0000000..c1525b8 Binary files /dev/null and b/tests/coverage/favicon.png differ diff --git a/tests/coverage/index.html b/tests/coverage/index.html new file mode 100644 index 0000000..1d28e82 --- /dev/null +++ b/tests/coverage/index.html @@ -0,0 +1,386 @@ + + + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 48.24% + Statements + 5570/11546 +
+ + +
+ 77.97% + Branches + 524/672 +
+ + +
+ 43.66% + Functions + 155/355 +
+ + +
+ 48.24% + Lines + 5570/11546 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
src +
+
76.38%110/14446.66%7/1580%4/576.38%110/144
src/activation +
+
14.03%122/869100%1/110%1/1014.03%122/869
src/adapters +
+
43.73%363/83073.46%36/4933.33%14/4243.73%363/830
src/ai +
+
20.52%173/84375.86%22/2931.03%9/2920.52%173/843
src/ai/providers +
+
97.55%239/24583.72%36/43100%10/1097.55%239/245
src/config +
+
68.03%83/122100%11/1145.83%11/2468.03%83/122
src/diagnostics +
+
90.56%48/53100%13/1375%6/890.56%48/53
src/fix +
+
73.11%680/93067.48%110/16369.38%34/4973.11%680/930
src/i18n +
+
99.79%1434/143791.66%11/1280%4/599.79%1434/1437
src/jsp +
+
100%77/7792.85%13/14100%1/1100%77/77
src/merger +
+
95.78%159/16686.84%33/38100%4/495.78%159/166
src/orchestrator +
+
45.71%48/105100%2/228.57%2/745.71%48/105
src/panel +
+
11.88%68/572100%2/210.52%2/1911.88%68/572
src/rules +
+
30.73%695/226183.57%117/14051.16%22/4330.73%695/2261
src/rules/converters +
+
72.69%647/89092.85%13/1434.48%10/2972.69%647/890
src/scope +
+
78.39%225/28770.37%57/8166.66%10/1578.39%225/287
src/services +
+
31.36%69/220100%1/17.69%1/1331.36%69/220
src/utils +
+
72.63%146/20190%36/4053.84%7/1372.63%146/201
src/views +
+
14.21%184/129475%3/410.34%3/2914.21%184/1294
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/prettify.css b/tests/coverage/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/tests/coverage/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/tests/coverage/prettify.js b/tests/coverage/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/tests/coverage/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/tests/coverage/sort-arrow-sprite.png b/tests/coverage/sort-arrow-sprite.png new file mode 100644 index 0000000..6ed6831 Binary files /dev/null and b/tests/coverage/sort-arrow-sprite.png differ diff --git a/tests/coverage/sorter.js b/tests/coverage/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/tests/coverage/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/tests/coverage/src/activation/commands.ts.html b/tests/coverage/src/activation/commands.ts.html new file mode 100644 index 0000000..5c17ae4 --- /dev/null +++ b/tests/coverage/src/activation/commands.ts.html @@ -0,0 +1,2692 @@ + + + + + + Code coverage report for src/activation/commands.ts + + + + + + + + + +
+
+

All files / src/activation commands.ts

+
+ +
+ 14.03% + Statements + 122/869 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 10% + Functions + 1/10 +
+ + +
+ 14.03% + Lines + 122/869 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +8701x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +1x + 
import * as vscode from 'vscode';
+import { Orchestrator } from '../orchestrator/orchestrator';
+import { runAIReview, runMethodReview } from '../ai/engine';
+import { loadActiveRules } from '../rules/yaml-parser';
+import { filterAndSummarize, filterForDocument } from '../rules/rule-filter';
+import { mergeResults, MergedReport } from '../merger/merger';
+import { reportToMarkdown } from '../utils/report';
+import { getApiKey, getAIProvider, getAIBaseUrl, getAIModel, getAITemperature, getAITimeout, getAIMaxTokens, getFixMaxIterations } from '../config';
+import { ReviewPanel } from '../panel/webview';
+import { t } from '../i18n/messages';
+import { exportTemplate } from '../rules/export-service';
+import { extractMethodScope } from '../scope/method-extractor';
+import { ReviewStatusCache } from '../scope/status-cache';
+import { MethodCodeLensProvider } from '../views/codeLensProvider';
+import { DiagnosticMarkers, isMarkersEnabled } from '../diagnostics/diagnosticMarkers';
+import { fixDiagnostic, type FixResult, type AppliedFix } from '../fix/fixEngine';
+import { aiFixDiagnostic } from '../fix/aiFixEngine';
+import { aiFixReviewIssue } from '../fix/customFixEngine';
+import type { ReviewIssueInput } from '../fix/fixPrompt';
+import { FixSessionManager } from '../fix/fixSession';
+import { registerFixPreviewProvider, openPreviewDiff, closePreviewEditor, applyNewText } from '../fix/fixPreview';
+import { FixPendingStore } from '../fix/fixPending';
+import { mockDocument } from '../utils/mockDocument';
+import { createProvider } from '../ai/factory';
+import type { AIProvider } from '../ai/providers/base';
+import type { LinterAdapter, LinterDiagnostic, CustomRule } from '../types';
+ 
+let currentReport: MergedReport | null = null;
+ 
+function resolveFixDocument(
+  report: MergedReport | null,
+  active: vscode.TextEditor | undefined,
+  origin?: 'hover' | 'panel'
+): vscode.TextDocument | undefined {
+  if (origin === 'hover') { return active?.document; }
+  if (report) {
+    return vscode.workspace.textDocuments.find(d => d.uri.fsPath === report.filePath);
+  }
+  return active?.document;
+}
+ 
+async function createFixProvider(context: vscode.ExtensionContext): Promise<AIProvider | null> {
+  const apiKey = await getApiKey(context);
+  if (!apiKey) { return null; }
+  try {
+    return createProvider(getAIProvider(), apiKey, getAIBaseUrl(), context.extensionUri);
+  } catch (err) {
+    console.error('[code-reviewer] create fix provider failed:', err);
+    return null;
+  }
+}
+ 
+async function resolveFix(
+  context: vscode.ExtensionContext,
+  document: vscode.TextDocument,
+  workingDir: string,
+  adapter: LinterAdapter,
+  diag: LinterDiagnostic,
+  maxIterations: number,
+  dryRun?: boolean
+): Promise<FixResult> {
+  if (diag.fix) {
+    return fixDiagnostic(document, workingDir, adapter, diag, maxIterations, dryRun);
+  }
+  const provider = await createFixProvider(context);
+  if (!provider) {
+    return { success: false, attempts: 0, message: 'ai-unavailable', appliedFixes: [] };
+  }
+  return aiFixDiagnostic(document, workingDir, adapter, diag, maxIterations, provider, {
+    model: getAIModel(),
+    temperature: getAITemperature(),
+    maxTokens: getAIMaxTokens(),
+    timeoutMs: getAITimeout() * 1000,
+  }, dryRun);
+}
+ 
+async function resolveReviewIssueFix(
+  context: vscode.ExtensionContext,
+  document: vscode.TextDocument,
+  diag: ReviewIssueInput,
+  maxIterations: number,
+  dryRun?: boolean
+): Promise<FixResult> {
+  const provider = await createFixProvider(context);
+  if (!provider) {
+    return { success: false, attempts: 0, message: 'ai-unavailable', appliedFixes: [] };
+  }
+  return aiFixReviewIssue(document, diag, maxIterations, provider, {
+    model: getAIModel(),
+    temperature: getAITemperature(),
+    maxTokens: getAIMaxTokens(),
+    timeoutMs: getAITimeout() * 1000,
+  }, dryRun);
+}
+ 
+function findCustomIssue(ruleId?: string, line?: number): ReviewIssueInput | undefined {
+  if (!currentReport) { return undefined; }
+  const d = currentReport.customRuleDiagnostics.find(d =>
+    d.ruleId === ruleId && (line === undefined || d.range.start.line === line)
+  );
+  if (!d) { return undefined; }
+  return {
+    ruleId: d.ruleId,
+    line: d.range.start.line,
+    message: d.message,
+    suggestion: d.suggestion,
+    fix: d.aiFix,
+  };
+}
+ 
+function findAIIssue(ruleId?: string, line?: number): ReviewIssueInput | undefined {
+  if (!currentReport) { return undefined; }
+  const f = currentReport.aiFindings.find(f =>
+    f.ruleId === ruleId && (line === undefined || f.line === line)
+  );
+  if (!f) { return undefined; }
+  return {
+    ruleId: f.ruleId,
+    line: f.line,
+    message: f.title,
+    suggestion: f.suggestion,
+    fix: f.fix,
+  };
+}
+ 
+function enrichLinterDiagnostic(diag: LinterDiagnostic): LinterDiagnostic {
+  if (diag.aiFix || !currentReport) { return diag; }
+  const reportDiag = currentReport.linterDiagnostics.find(d =>
+    d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line
+  );
+  if (reportDiag?.aiFix) {
+    return { ...diag, aiFix: reportDiag.aiFix };
+  }
+  return diag;
+}
+ 
+async function refreshAfterFix(
+  document: vscode.TextDocument,
+  orchestrator: Orchestrator,
+  markers: DiagnosticMarkers,
+  codeLensProvider: MethodCodeLensProvider,
+  extensionUri: vscode.Uri,
+  fixSession: FixSessionManager
+): Promise<void> {
+  const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
+  const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
+  const result = await orchestrator.runStaticAnalysis(document, workingDir);
+  if (isMarkersEnabled()) {
+    markers.apply(document.uri, result.diagnostics);
+  }
+  codeLensProvider.refresh();
+
+  if (currentReport && currentReport.filePath === document.uri.fsPath) {
+    currentReport = mergeResults({
+      staticDiagnostics: result.diagnostics,
+      customRuleResults: currentReport.customRuleDiagnostics.map(d => ({
+        ruleId: d.ruleId,
+        severity: d.severity,
+        message: d.message,
+        suggestion: d.suggestion,
+        fix: d.aiFix,
+        line: d.range.start.line + 1,
+      })),
+      translatedDiagnostics: currentReport.translatedDiagnostics,
+      aiFindings: currentReport.aiFindings,
+      errors: result.errors,
+      degraded: currentReport.degraded,
+      startTime: Date.now(),
+      filePath: document.uri.fsPath,
+      language: document.languageId,
+      adapterIds: result.adapterIds,
+      aiFixAvailable: currentReport.aiFixAvailable,
+      customRuleFilterInfo: currentReport.customRuleFilterInfo,
+      code: document.getText(),
+    });
+    const panel = ReviewPanel.createOrShow(extensionUri);
+    panel.setFixSession(fixSession);
+    panel.update(currentReport);
+  }
+}
+ 
+async function openSetupPanel(): Promise<void> {
+  try {
+    await vscode.commands.executeCommand('workbench.view.extension.code-reviewer');
+  } catch {
+    const action = await vscode.window.showErrorMessage(
+      t('setup.openSetupFail'),
+      t('setup.openSettingsJson')
+    );
+    if (action === t('setup.openSettingsJson')) {
+      await vscode.commands.executeCommand('workbench.action.openSettingsJson');
+    }
+  }
+}
+ 
+export function registerCommands(
+  context: vscode.ExtensionContext,
+  orchestrator: Orchestrator,
+  codeLensProvider: MethodCodeLensProvider,
+  statusCache: ReviewStatusCache,
+  markers: DiagnosticMarkers,
+  fixSession: FixSessionManager,
+  pendingStore: FixPendingStore,
+): void {
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.review', async () => {
+      const editor = vscode.window.activeTextEditor;
+      if (!editor) {
+        vscode.window.showWarningMessage(t('review.noEditor'));
+        return;
+      }
+
+      const document = editor.document;
+      fixSession.clear(document.uri);
+      pendingStore.clear(document.fileName);
+      const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
+      const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
+
+      await vscode.window.withProgress({
+        location: vscode.ProgressLocation.Notification,
+        title: t('review.running'),
+        cancellable: false,
+      }, async (progress) => {
+        progress.report({ message: t('review.staticAnalysis') });
+
+        const startTime = Date.now();
+        const staticResult = await orchestrator.runStaticAnalysis(document, workingDir);
+
+        progress.report({ message: t('review.aiReview') });
+
+        const allRules = loadActiveRules(workspaceRoot);
+        const filterResult = filterAndSummarize(allRules, document);
+        const code = document.getText();
+        const aiResult = await runAIReview(context, code, staticResult.diagnostics, filterResult.relevant);
+
+        const aiFixAvailable = !!(await getApiKey(context));
+
+        currentReport = mergeResults({
+          staticDiagnostics: staticResult.diagnostics,
+          customRuleResults: aiResult.customRuleResults,
+          translatedDiagnostics: aiResult.translatedDiagnostics,
+          aiFindings: aiResult.findings,
+          errors: [...staticResult.errors, ...(aiResult.error ? [aiResult.error] : [])],
+          degraded: aiResult.degraded,
+          startTime,
+          filePath: document.uri.fsPath,
+          language: document.languageId,
+          adapterIds: staticResult.adapterIds,
+          aiFixAvailable,
+          customRuleFilterInfo: {
+            totalActive: allRules.length,
+            injected: filterResult.relevant.length,
+            filteredOut: filterResult.filteredOut.length,
+            skippedRequestA: filterResult.skippedRequestA,
+          },
+          code: document.getText(),
+        });
+
+        const panel = ReviewPanel.createOrShow(context.extensionUri);
+        panel.setFixSession(fixSession);
+        panel.update(currentReport);
+
+        if (isMarkersEnabled()) {
+          markers.apply(document.uri, currentReport.linterDiagnostics);
+        }
+      });
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.reviewSelection', async () => {
+      const editor = vscode.window.activeTextEditor;
+      if (!editor) { return; }
+
+      const selection = editor.selection;
+      if (selection.isEmpty) {
+        vscode.window.showWarningMessage(t('review.noSelection'));
+        return;
+      }
+
+      const code = editor.document.getText(selection);
+      const apiKey = await getApiKey(context);
+      if (!apiKey) {
+        vscode.window.showWarningMessage(t('review.needApiKey'));
+        return;
+      }
+
+      const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
+      const customRules = loadActiveRules(workspaceRoot);
+
+      await vscode.window.withProgress({
+        location: vscode.ProgressLocation.Notification,
+        title: t('review.reviewingSelection'),
+        cancellable: false,
+      }, async () => {
+        const aiResult = await runAIReview(context, code, [], customRules);
+        vscode.window.showInformationMessage(
+          t('review.selectionComplete', { 0: String(aiResult.customRuleResults.length + aiResult.findings.length) })
+        );
+      });
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.reviewMethod', async (symbolRange?: vscode.Range) => {
+      const editor = vscode.window.activeTextEditor;
+      if (!editor) { return; }
+
+      const document = editor.document;
+      const workspaceRoot = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath;
+
+      const targetRange = symbolRange ?? new vscode.Range(editor.selection.active, editor.selection.active);
+
+      const scope = await extractMethodScope(document, targetRange);
+      if (!scope) {
+        vscode.window.showWarningMessage(t('methodReview.noMethod'));
+        return;
+      }
+
+      let customRules: CustomRule[] = [];
+      if (workspaceRoot) {
+        const allRules = loadActiveRules(workspaceRoot);
+        customRules = filterForDocument(allRules, document);
+      }
+
+      await vscode.window.withProgress({
+        location: vscode.ProgressLocation.Notification,
+        title: t('methodReview.running', { 0: scope.name }),
+        cancellable: false,
+      }, async () => {
+        const result = await runMethodReview(context, scope, customRules);
+
+        const methodLine = scope.range.start.line;
+        const totalIssues = result.customRuleResults.length + result.findings.length;
+        currentReport = mergeResults({
+          staticDiagnostics: [],
+          customRuleResults: result.customRuleResults.map(r => ({
+            ...r,
+            line: r.line + methodLine,
+          })),
+          translatedDiagnostics: [],
+          aiFindings: result.findings.map(f => ({
+            ...f,
+            line: f.line + methodLine,
+          })),
+          errors: result.error ? [result.error] : [],
+          degraded: result.degraded,
+          startTime: Date.now(),
+          filePath: document.uri.fsPath,
+          language: document.languageId,
+          adapterIds: [],
+          aiFixAvailable: false,
+          customRuleFilterInfo: undefined,
+          code: document.getText(),
+        });
+
+        statusCache.set(document.uri, scope.name, totalIssues);
+        codeLensProvider.refresh();
+
+        const panel = ReviewPanel.createOrShow(context.extensionUri);
+        panel.setFixSession(fixSession);
+        panel.update(currentReport);
+
+        vscode.window.showInformationMessage(
+          t('methodReview.complete', { 0: String(totalIssues) })
+        );
+      });
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.openPanel', () => {
+      ReviewPanel.createOrShow(context.extensionUri);
+      if (currentReport) {
+        const panel = ReviewPanel.createOrShow(context.extensionUri);
+        panel.setFixSession(fixSession);
+        panel.update(currentReport);
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.exportReport', async () => {
+      if (!currentReport) {
+        vscode.window.showWarningMessage(t('export.needRunFirst'));
+        return;
+      }
+      const markdown = reportToMarkdown(currentReport);
+      const pick = await vscode.window.showQuickPick([
+        { label: t('export.copyToClipboard'), description: t('export.copyDescription') },
+        { label: t('export.downloadMarkdown'), description: t('export.saveDescription') },
+      ], { placeHolder: t('export.selectMethod') });
+      if (!pick) { return; }
+      if (pick.label === t('export.copyToClipboard')) {
+        await vscode.env.clipboard.writeText(markdown);
+        vscode.window.showInformationMessage(t('export.copied'));
+      } else {
+        const fileName = currentReport.filePath.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') ?? 'review-report';
+        const defaultUri = vscode.workspace.workspaceFolders?.[0]
+          ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, `${fileName}-review.md`)
+          : undefined;
+        const uri = await vscode.window.showSaveDialog({
+          defaultUri,
+          filters: { 'Markdown': ['md'] },
+          title: t('export.saveDialogTitle'),
+        });
+        if (!uri) { return; }
+        await vscode.workspace.fs.writeFile(uri, Buffer.from(markdown, 'utf-8'));
+        vscode.window.showInformationMessage(t('export.saved', { 0: uri.fsPath }));
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.addCustomRule', async () => {
+      const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+      if (!workspaceRoot) {
+        vscode.window.showWarningMessage(t('setup.noWorkspace'));
+        return;
+      }
+      await openSetupPanel();
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.fixIssue', async (payload?: { line?: number; ruleId?: string; source?: string; origin?: 'hover' | 'panel' }) => {
+      try {
+        const source = payload?.source === 'custom' || payload?.source === 'ai' ? payload.source : 'linter';
+        const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, payload?.origin);
+        if (!document) { console.log('[code-reviewer] fixIssue: no target document'); return; }
+
+        const isPreview = payload?.origin === 'panel';
+        const maxIterations = getFixMaxIterations();
+
+        if (source === 'custom' || source === 'ai') {
+          const reviewDiag = source === 'custom'
+            ? findCustomIssue(payload?.ruleId, payload?.line)
+            : findAIIssue(payload?.ruleId, payload?.line);
+          if (!reviewDiag) {
+            vscode.window.showWarningMessage(t('fix.noFix'));
+            return;
+          }
+          const result = await vscode.window.withProgress({
+            location: vscode.ProgressLocation.Notification,
+            title: t('fix.aiRunning'),
+            cancellable: false,
+          }, () => resolveReviewIssueFix(context, document, reviewDiag, maxIterations, isPreview));
+          if (!result.success) {
+            const msg = result.message === 'ai-unavailable'
+              ? t('fix.noAI')
+              : t('fix.aiFailed', { 0: result.message ?? '' });
+            vscode.window.showWarningMessage(msg);
+            if (payload?.ruleId) {
+              ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${payload.ruleId}@${payload.line ?? -1}`, on: false });
+            }
+            return;
+          }
+
+          if (isPreview) {
+            const newText = result.newText ?? document.getText();
+            const key = `${reviewDiag.ruleId}@${reviewDiag.line}`;
+            const existing = pendingStore.getSingle(document.fileName, key);
+            if (existing?.diffUri) {
+              await closePreviewEditor(existing.diffUri);
+            }
+            const diffUri = await openPreviewDiff({
+              originalText: document.getText(),
+              newText,
+              title: `${t('fix.previewTitle')}: ${reviewDiag.ruleId} @ ${reviewDiag.line + 1}`,
+              fileName: document.fileName,
+            });
+            pendingStore.setSingle({
+              key,
+              ruleId: reviewDiag.ruleId,
+              line: reviewDiag.line,
+              filePath: document.fileName,
+              source,
+              originalText: document.getText(),
+              newText,
+              appliedFixes: result.appliedFixes,
+              diffUri,
+            });
+            ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: true });
+            return;
+          }
+
+          fixSession.recordFixes(document.uri, reviewDiag.ruleId, reviewDiag.line, result.appliedFixes, source);
+          await document.save();
+          await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
+          vscode.window.showInformationMessage(t('fix.applied'));
+          return;
+        }
+
+        const cached = orchestrator.getAnalysisResult(document.uri);
+        if (!cached) { console.log(`[code-reviewer] fixIssue: no cached analysis for ${document.uri.toString()}`); return; }
+
+        const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
+        const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
+        const adapter = orchestrator.getAdapter(cached.adapterId);
+        if (!adapter) { console.log(`[code-reviewer] fixIssue: adapter not found: ${cached.adapterId}`); return; }
+
+        const line = payload?.line;
+        const ruleId = payload?.ruleId;
+        const diag = cached.diagnostics.find(d =>
+          d.ruleId === ruleId && (line === undefined || d.range.start.line === line)
+        ) ?? cached.diagnostics.find(d => d.fix);
+
+        if (!diag) {
+          vscode.window.showWarningMessage(t('fix.noFix'));
+          return;
+        }
+
+        const needsAi = !diag.fix;
+        const result = await vscode.window.withProgress({
+          location: vscode.ProgressLocation.Notification,
+          title: needsAi ? t('fix.aiRunning') : t('fix.running'),
+          cancellable: false,
+        }, () => resolveFix(context, document, workingDir, adapter, enrichLinterDiagnostic(diag), maxIterations, isPreview));
+        if (!result.success) {
+          const msg = diag.fix
+            ? t('fix.failed', { 0: result.message ?? '' })
+            : (result.message === 'ai-unavailable'
+              ? t('fix.noAI')
+              : t('fix.aiFailed', { 0: result.message ?? '' }));
+          vscode.window.showWarningMessage(msg);
+          if (ruleId) {
+            ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${ruleId}@${line ?? -1}`, on: false });
+          }
+          return;
+        }
+
+        if (isPreview) {
+          const newText = result.newText ?? document.getText();
+          const key = `${diag.ruleId}@${diag.range.start.line}`;
+          const existing = pendingStore.getSingle(document.fileName, key);
+          if (existing?.diffUri) {
+            await closePreviewEditor(existing.diffUri);
+          }
+          const diffUri = await openPreviewDiff({
+            originalText: document.getText(),
+            newText,
+            title: `${t('fix.previewTitle')}: ${diag.ruleId} @ ${diag.range.start.line + 1}`,
+            fileName: document.fileName,
+          });
+          pendingStore.setSingle({
+            key,
+            ruleId: diag.ruleId,
+            line: diag.range.start.line,
+            filePath: document.fileName,
+            source: 'linter',
+            originalText: document.getText(),
+            newText,
+            appliedFixes: result.appliedFixes,
+            diffUri,
+          });
+          ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: true });
+          return;
+        }
+
+        if (payload?.origin !== 'hover') {
+          fixSession.recordFixes(document.uri, diag.ruleId, diag.range.start.line, result.appliedFixes);
+        }
+        await document.save();
+        await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
+        vscode.window.showInformationMessage(t('fix.applied'));
+      } catch (err) {
+        console.error('[code-reviewer] fixIssue failed:', err);
+        vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
+        if (payload?.ruleId) {
+          ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${payload.ruleId}@${payload.line ?? -1}`, on: false });
+        }
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.fixAll', async (payload?: { source?: 'linter' | 'custom' | 'ai' }) => {
+      try {
+        const source = payload?.source === 'custom' || payload?.source === 'ai' ? payload.source : 'linter';
+        const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
+        if (!document) { console.log('[code-reviewer] fixAll: no target document'); return; }
+        const maxIterations = getFixMaxIterations();
+        const aiAvailable = !!(await getApiKey(context));
+
+        if (source === 'custom' || source === 'ai') {
+          if (!aiAvailable) {
+            vscode.window.showWarningMessage(t('fix.noAI'));
+            return;
+          }
+          const issues: ReviewIssueInput[] = source === 'custom'
+            ? (currentReport?.customRuleDiagnostics ?? []).map(d => ({
+                ruleId: d.ruleId,
+                line: d.range.start.line,
+                message: d.message,
+                suggestion: d.suggestion,
+                fix: d.aiFix,
+              }))
+            : (currentReport?.aiFindings ?? []).map(f => ({
+                ruleId: f.ruleId,
+                line: f.line,
+                message: f.title,
+                suggestion: f.suggestion,
+                fix: f.fix,
+              }));
+          if (issues.length === 0) {
+            vscode.window.showInformationMessage(t('fix.noFix'));
+            return;
+          }
+
+          let currentText = document.getText();
+          const results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[] = [];
+          let success = 0;
+          let skipped = 0;
+          await vscode.window.withProgress({
+            location: vscode.ProgressLocation.Notification,
+            title: t('fix.running'),
+            cancellable: false,
+          }, async (progress) => {
+            for (let i = 0; i < issues.length; i++) {
+              const issue = issues[i];
+              progress.report({ message: `${t('fix.progress')} ${i + 1}/${issues.length}` });
+              const mock = mockDocument(currentText, document.languageId, document.fileName);
+              const result = await resolveReviewIssueFix(context, mock, issue, maxIterations, true);
+              if (result.success && result.newText && result.newText !== currentText) {
+                results.push({ ruleId: issue.ruleId, line: issue.line, appliedFixes: result.appliedFixes });
+                currentText = result.newText;
+                success++;
+              } else {
+                skipped++;
+              }
+            }
+          });
+
+          if (success === 0) {
+            vscode.window.showWarningMessage(t('fix.failed', { 0: 'no-fix-applied' }));
+            ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
+            return;
+          }
+
+          const batch = pendingStore.getBatch(document.fileName);
+          if (batch?.diffUri) {
+            await closePreviewEditor(batch.diffUri);
+          }
+          const diffUri = await openPreviewDiff({
+            originalText: document.getText(),
+            newText: currentText,
+            title: t('fix.previewTitle'),
+            fileName: document.fileName,
+          });
+          pendingStore.setBatch({
+            filePath: document.fileName,
+            source,
+            originalText: document.getText(),
+            newText: currentText,
+            results,
+            diffUri,
+          });
+          ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: true });
+          return;
+        }
+
+        const cached = orchestrator.getAnalysisResult(document.uri);
+        if (!cached) { console.log(`[code-reviewer] fixAll: no cached analysis for ${document.uri.toString()}`); return; }
+
+        const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
+        const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
+        const adapter = orchestrator.getAdapter(cached.adapterId);
+        if (!adapter) { console.log(`[code-reviewer] fixAll: adapter not found: ${cached.adapterId}`); return; }
+
+        const fixables = cached.diagnostics.filter(d => d.fix || (aiAvailable && !d.ruleId.startsWith('sqlfluff:')));
+        if (fixables.length === 0) {
+          vscode.window.showInformationMessage(t('fix.noFix'));
+          return;
+        }
+
+        let currentText = document.getText();
+        const results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[] = [];
+        let success = 0;
+        let skipped = 0;
+        await vscode.window.withProgress({
+          location: vscode.ProgressLocation.Notification,
+          title: t('fix.running'),
+          cancellable: false,
+        }, async (progress) => {
+          for (let i = 0; i < fixables.length; i++) {
+            const diag = fixables[i];
+            progress.report({ message: `${t('fix.progress')} ${i + 1}/${fixables.length}` });
+            const mock = mockDocument(currentText, document.languageId, document.fileName);
+            const fresh = await adapter.check(mock, workingDir);
+            const freshDiag = fresh.diagnostics.find(d =>
+              d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line
+            ) ?? fresh.diagnostics.find(d => d.ruleId === diag.ruleId);
+            if (!freshDiag) { skipped++; continue; }
+            const result = await resolveFix(context, mock, workingDir, adapter, enrichLinterDiagnostic(freshDiag), maxIterations, true);
+            if (result.success && result.newText && result.newText !== currentText) {
+              results.push({ ruleId: freshDiag.ruleId, line: freshDiag.range.start.line, appliedFixes: result.appliedFixes });
+              currentText = result.newText;
+              success++;
+            } else {
+              skipped++;
+            }
+          }
+        });
+
+        if (success === 0) {
+          vscode.window.showWarningMessage(t('fix.failed', { 0: 'no-fix-applied' }));
+          ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
+          return;
+        }
+
+        const batch = pendingStore.getBatch(document.fileName);
+        if (batch?.diffUri) {
+          await closePreviewEditor(batch.diffUri);
+        }
+        const diffUri = await openPreviewDiff({
+          originalText: document.getText(),
+          newText: currentText,
+          title: t('fix.previewTitle'),
+          fileName: document.fileName,
+        });
+        pendingStore.setBatch({
+          filePath: document.fileName,
+          source: 'linter',
+          originalText: document.getText(),
+          newText: currentText,
+          results,
+          diffUri,
+        });
+        ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: true });
+      } catch (err) {
+        console.error('[code-reviewer] fixAll failed:', err);
+        vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
+        ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.undoFix', async (payload?: { line?: number; ruleId?: string; source?: string }) => {
+      const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
+      if (!document) { console.log('[code-reviewer] undoFix: no target document'); return; }
+      const line = payload?.line ?? -1;
+      const ruleId = payload?.ruleId ?? '';
+      if (!ruleId) { return; }
+      const key = `${ruleId}@${line}`;
+      const ok = await fixSession.undo(document, key);
+      if (ok) {
+        await document.save();
+        await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
+        vscode.window.showInformationMessage(t('fix.undone'));
+      } else {
+        vscode.window.showWarningMessage(t('fix.undoFailed'));
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.openSetup', async () => {
+      await openSetupPanel();
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.applyFixPreview', async (payload?: { line?: number; ruleId?: string }) => {
+      try {
+        const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
+        if (!document) { console.log('[code-reviewer] applyFixPreview: no target document'); return; }
+        const ruleId = payload?.ruleId ?? '';
+        const line = payload?.line ?? -1;
+        if (!ruleId) { return; }
+        const key = `${ruleId}@${line}`;
+        const pending = pendingStore.getSingle(document.fileName, key);
+        if (!pending) { return; }
+
+        const applied = await applyNewText(document, pending.newText);
+        if (!applied) {
+          vscode.window.showWarningMessage(t('fix.failed', { 0: 'apply-failed' }));
+          return;
+        }
+        fixSession.recordFixes(document.uri, pending.ruleId, pending.line, pending.appliedFixes, pending.source);
+        if (pending.diffUri) {
+          await closePreviewEditor(pending.diffUri);
+        }
+        pendingStore.deleteSingle(document.fileName, key);
+        await document.save();
+        await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
+        vscode.window.showInformationMessage(t('fix.applied'));
+      } catch (err) {
+        console.error('[code-reviewer] applyFixPreview failed:', err);
+        vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.cancelFixPreview', async (payload?: { line?: number; ruleId?: string }) => {
+      try {
+        const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
+        if (!document) { console.log('[code-reviewer] cancelFixPreview: no target document'); return; }
+        const ruleId = payload?.ruleId ?? '';
+        const line = payload?.line ?? -1;
+        if (!ruleId) { return; }
+        const key = `${ruleId}@${line}`;
+        const pending = pendingStore.getSingle(document.fileName, key);
+        if (!pending) { return; }
+        if (pending.diffUri) {
+          await closePreviewEditor(pending.diffUri);
+        }
+        pendingStore.deleteSingle(document.fileName, key);
+        ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: false });
+      } catch (err) {
+        console.error('[code-reviewer] cancelFixPreview failed:', err);
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.applyAllPreview', async () => {
+      try {
+        const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
+        if (!document) { console.log('[code-reviewer] applyAllPreview: no target document'); return; }
+        const batch = pendingStore.getBatch(document.fileName);
+        if (!batch) { return; }
+
+        const applied = await applyNewText(document, batch.newText);
+        if (!applied) {
+          vscode.window.showWarningMessage(t('fix.failed', { 0: 'apply-failed' }));
+          return;
+        }
+        for (const r of batch.results) {
+          fixSession.recordFixes(document.uri, r.ruleId, r.line, r.appliedFixes, batch.source);
+        }
+        if (batch.diffUri) {
+          await closePreviewEditor(batch.diffUri);
+        }
+        pendingStore.deleteBatch(document.fileName);
+        await document.save();
+        await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
+        vscode.window.showInformationMessage(t('fix.allComplete', { 0: String(batch.results.length), 1: String(0) }));
+      } catch (err) {
+        console.error('[code-reviewer] applyAllPreview failed:', err);
+        vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.cancelAllPreview', async () => {
+      try {
+        const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
+        if (!document) { console.log('[code-reviewer] cancelAllPreview: no target document'); return; }
+        const batch = pendingStore.getBatch(document.fileName);
+        if (!batch) { return; }
+        if (batch.diffUri) {
+          await closePreviewEditor(batch.diffUri);
+        }
+        pendingStore.deleteBatch(document.fileName);
+        ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
+      } catch (err) {
+        console.error('[code-reviewer] cancelAllPreview failed:', err);
+      }
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.commands.registerCommand('codeReviewer.exportTemplate', () => exportTemplate())
+  );
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/activation/index.html b/tests/coverage/src/activation/index.html new file mode 100644 index 0000000..db2c785 --- /dev/null +++ b/tests/coverage/src/activation/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/activation + + + + + + + + + +
+
+

All files src/activation

+
+ +
+ 14.03% + Statements + 122/869 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 10% + Functions + 1/10 +
+ + +
+ 14.03% + Lines + 122/869 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
commands.ts +
+
14.03%122/869100%1/110%1/1014.03%122/869
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/adapters/eslint.ts.html b/tests/coverage/src/adapters/eslint.ts.html new file mode 100644 index 0000000..b738037 --- /dev/null +++ b/tests/coverage/src/adapters/eslint.ts.html @@ -0,0 +1,571 @@ + + + + + + Code coverage report for src/adapters/eslint.ts + + + + + + + + + +
+
+

All files / src/adapters eslint.ts

+
+ +
+ 85.8% + Statements + 139/162 +
+ + +
+ 61.29% + Branches + 19/31 +
+ + +
+ 100% + Functions + 7/7 +
+ + +
+ 85.8% + Lines + 139/162 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +1632x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +30x +30x +180x +180x +  +  +180x +30x +30x +2x +2x +2x +2x +2x +15x +15x +15x +  +  +  +  +  +15x +15x +15x +  +  +15x +15x +15x +  +  +15x +15x +15x +2x +2x +2x +2x +2x +2x +2x +2x +15x +1x +3x +3x +1x +1x +1x +1x +1x +1x +1x +1x +1x +15x +15x +2x +2x +10x +10x +2x +2x +15x +15x +15x +  +  +  +  +  +  +15x +15x +15x +15x +15x +15x +15x +15x +15x +15x +15x +15x +15x +15x +39x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +37x +37x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +39x +15x +15x +15x +15x +  +  +  +  +  +  +15x +2x + 
import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import { ESLint } from 'eslint';
+import js from '@eslint/js';
+import ts from 'typescript-eslint';
+import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
+import { getEslintConfigPath } from '../config';
+import { t } from '../i18n/messages';
+import { eslintExtraRules, eslintExtraTsRules } from '../rules/builtin-rules';
+ 
+const TS_FILES = ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'];
+ 
+const JS_FILES = ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'];
+ 
+const PROJECT_CONFIG_FILES = [
+  'eslint.config.js',
+  'eslint.config.mjs',
+  'eslint.config.cjs',
+  'eslint.config.ts',
+  'eslint.config.mts',
+  'eslint.config.cts',
+];
+ 
+const LEGACY_CONFIG_FILES = [
+  '.eslintrc.js',
+  '.eslintrc.cjs',
+  '.eslintrc.json',
+  '.eslintrc.yaml',
+  '.eslintrc.yml',
+  '.eslintrc',
+];
+ 
+function findConfigFile(dir: string, names: string[]): string | null {
+  for (const name of names) {
+    const p = path.join(dir, name);
+    if (fs.existsSync(p)) {
+      return p;
+    }
+  }
+  return null;
+}
+ 
+type EslintConfigResult =
+  | { kind: 'use'; config: { overrideConfigFile?: string | true; overrideConfig?: any[] } }
+  | { kind: 'legacy'; path: string };
+ 
+function resolveEslintConfig(workingDir: string): EslintConfigResult {
+  const globalPath = getEslintConfigPath();
+  if (globalPath && globalPath.trim() !== '') {
+    const abs = path.isAbsolute(globalPath) ? globalPath : path.resolve(workingDir, globalPath);
+    if (fs.existsSync(abs)) {
+      return { kind: 'use', config: { overrideConfigFile: abs } };
+    }
+  }
+ 
+  const projectConfig = findConfigFile(workingDir, PROJECT_CONFIG_FILES);
+  if (projectConfig) {
+    return { kind: 'use', config: { overrideConfigFile: projectConfig } };
+  }
+ 
+  const legacyConfig = findConfigFile(workingDir, LEGACY_CONFIG_FILES);
+  if (legacyConfig) {
+    return { kind: 'legacy', path: legacyConfig };
+  }
+ 
+  return { kind: 'use', config: { overrideConfigFile: true, overrideConfig: ESLintAdapter.getDefaultConfig() } };
+}
+ 
+export class ESLintAdapter implements LinterAdapter {
+  id = 'eslint';
+  supportedLanguages = ['javascript', 'typescript'];
+ 
+  private static defaultConfig: any[] | null = null;
+ 
+  public static getDefaultConfig(): any[] {
+    if (!ESLintAdapter.defaultConfig) {
+      const tsConfigs = ts.configs.recommended.map(cfg => ({
+        ...cfg,
+        files: (cfg as { files?: string[] }).files ?? TS_FILES,
+      }));
+      ESLintAdapter.defaultConfig = [
+        js.configs.recommended,
+        { files: JS_FILES, languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } },
+        ...tsConfigs,
+        { rules: eslintExtraRules },
+        { files: TS_FILES, rules: eslintExtraTsRules },
+      ];
+    }
+    return ESLintAdapter.defaultConfig;
+  }
+ 
+  isAvailable(): boolean {
+    return true;
+  }
+ 
+  async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
+    try {
+      const resolved = resolveEslintConfig(workingDir);
+      if (resolved.kind === 'legacy') {
+        return {
+          diagnostics: [],
+          status: 'execution-failed',
+          errorMessage: t('adapter.eslintLegacyConfig', { 0: resolved.path }),
+        };
+      }
+ 
+      const engine = new ESLint({
+        cwd: workingDir,
+        ...resolved.config,
+      });
+      const ext = document.languageId === 'typescript' ? 'ts' : 'js';
+      const isVirtual = document.uri.scheme === 'untitled';
+      const results = await engine.lintText(document.getText(), {
+        filePath: isVirtual ? `untitled.${ext}` : document.fileName,
+      });
+ 
+      const diagnostics: LinterDiagnostic[] = [];
+      for (const result of results) {
+        for (const msg of result.messages) {
+          if (msg.ruleId === null) {
+            if (!msg.fatal) { continue; }
+            diagnostics.push({
+              severity: 'error',
+              ruleId: 'eslint:parse-error',
+              message: msg.message,
+              range: new vscode.Range(
+                msg.line - 1,
+                msg.column - 1,
+                (msg.endLine ?? msg.line) - 1,
+                (msg.endColumn ?? msg.column) - 1
+              ),
+            });
+            continue;
+          }
+ 
+          diagnostics.push({
+            severity: msg.severity === 2 ? 'error' : 'warning',
+            ruleId: `eslint:${msg.ruleId}`,
+            message: msg.message,
+            range: new vscode.Range(
+              msg.line - 1,
+              msg.column - 1,
+              (msg.endLine ?? msg.line) - 1,
+              (msg.endColumn ?? msg.column) - 1
+            ),
+            suggestion: msg.fix?.text,
+            fix: msg.fix ? { range: [msg.fix.range[0], msg.fix.range[1]], text: msg.fix.text } : undefined,
+          });
+        }
+      }
+ 
+      return { diagnostics, status: 'ok' };
+    } catch (error) {
+      return {
+        diagnostics: [],
+        status: 'execution-failed',
+        errorMessage: error instanceof Error ? error.message : String(error),
+      };
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/adapters/index.html b/tests/coverage/src/adapters/index.html new file mode 100644 index 0000000..f7f6547 --- /dev/null +++ b/tests/coverage/src/adapters/index.html @@ -0,0 +1,176 @@ + + + + + + Code coverage report for src/adapters + + + + + + + + + +
+
+

All files src/adapters

+
+ +
+ 43.73% + Statements + 363/830 +
+ + +
+ 73.46% + Branches + 36/49 +
+ + +
+ 33.33% + Functions + 14/42 +
+ + +
+ 43.73% + Lines + 363/830 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
eslint.ts +
+
85.8%139/16261.29%19/31100%7/785.8%139/162
jsp.ts +
+
30.35%34/112100%1/120%1/530.35%34/112
pmd.ts +
+
22.67%39/172100%1/17.69%1/1322.67%39/172
sqlfluff.ts +
+
37.35%96/25793.33%14/1536.36%4/1137.35%96/257
stylelint.ts +
+
43.3%55/127100%1/116.66%1/643.3%55/127
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/adapters/jsp.ts.html b/tests/coverage/src/adapters/jsp.ts.html new file mode 100644 index 0000000..e5c1633 --- /dev/null +++ b/tests/coverage/src/adapters/jsp.ts.html @@ -0,0 +1,421 @@ + + + + + + Code coverage report for src/adapters/jsp.ts + + + + + + + + + +
+
+

All files / src/adapters jsp.ts

+
+ +
+ 30.35% + Statements + 34/112 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 20% + Functions + 1/5 +
+ + +
+ 30.35% + Lines + 34/112 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +1131x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +1x +1x +  +  +1x + 
import * as vscode from 'vscode';
+import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
+import { PmdAdapter } from './pmd';
+import { ESLintAdapter } from './eslint';
+import { StylelintAdapter } from './stylelint';
+import { extractJspSections, type JspSection } from '../jsp/jsp-extractor';
+import { getLinterForLanguage } from '../config';
+import { mockDocument } from '../utils/mockDocument';
+ 
+const WRAP_TEMPLATES: Record<NonNullable<JspSection['scriptletKind']>, {
+  header: string;
+  footer: string;
+  headerLines: number;
+}> = {
+  statement: { header: 'package jsp;\nclass JspScriptlet {\n  void run() {\n', footer: '\n  }\n}', headerLines: 3 },
+  expression: { header: 'package jsp;\nclass JspScriptlet {\n  Object run() {\n    return\n', footer: '\n  }\n}', headerLines: 4 },
+  declaration: { header: 'package jsp;\nclass JspScriptlet {\n', footer: '\n}', headerLines: 2 },
+};
+ 
+function wrapJavaSection(section: JspSection): { code: string; headerLines: number } {
+  if (section.language !== 'java' || !section.scriptletKind) {
+    return { code: section.code, headerLines: 0 };
+  }
+  const tmpl = WRAP_TEMPLATES[section.scriptletKind];
+  let body = section.code;
+  if (section.scriptletKind === 'expression' && body.trim() !== '' && !body.trim().endsWith(';')) {
+    body += ';';
+  }
+  return { code: tmpl.header + body + tmpl.footer, headerLines: tmpl.headerLines };
+}
+ 
+export class JspAdapter implements LinterAdapter {
+  id = 'jsp';
+  supportedLanguages = ['jsp', 'html'];
+ 
+  private pmdAdapter = new PmdAdapter();
+  private eslintAdapter = new ESLintAdapter();
+  private stylelintAdapter = new StylelintAdapter();
+ 
+  async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
+    const allDiagnostics: LinterDiagnostic[] = [];
+    const errors: string[] = [];
+
+    const jsEnabled = getLinterForLanguage('javascript') !== '';
+    const cssEnabled = getLinterForLanguage('css') !== '';
+    const javaEnabled = getLinterForLanguage('java') !== '';
+
+    const pmdResult = await this.pmdAdapter.checkJsp(document, workingDir);
+    allDiagnostics.push(...pmdResult.diagnostics);
+    if (pmdResult.status !== 'ok') {
+      errors.push(`PMD: ${pmdResult.errorMessage ?? pmdResult.status}`);
+    }
+
+    const sections = extractJspSections(document.getText());
+
+    for (const section of sections) {
+      const isEnabled = (section.language === 'javascript' && jsEnabled)
+        || (section.language === 'css' && cssEnabled)
+        || (section.language === 'java' && javaEnabled);
+      if (!isEnabled) { continue; }
+
+      const adapter = this.getAdapter(section.language);
+      if (!adapter) { continue; }
+
+      try {
+        const { code, headerLines } = wrapJavaSection(section);
+        const result = await adapter.check(mockDocument(code, section.language), workingDir);
+
+        for (const diag of result.diagnostics) {
+          const startLine = diag.range.start.line - headerLines;
+          const endLine = diag.range.end.line - headerLines;
+          if (startLine < 0) { continue; }
+          const adjustedRange = new vscode.Range(
+            startLine + section.lineOffset,
+            diag.range.start.character,
+            endLine + section.lineOffset,
+            diag.range.end.character,
+          );
+          allDiagnostics.push({ ...diag, range: adjustedRange });
+        }
+
+        if (result.status !== 'ok') {
+          errors.push(`${section.language}: ${result.errorMessage ?? result.status}`);
+        }
+      } catch (err) {
+        errors.push(`${section.language}: ${err instanceof Error ? err.message : String(err)}`);
+      }
+    }
+
+    const hasErrors = errors.length > 0;
+    const hasUnavailable = errors.some(e => e.includes('未安装') || e.includes('tool-unavailable'));
+
+    return {
+      diagnostics: allDiagnostics,
+      status: hasErrors ? (hasUnavailable ? 'tool-unavailable' : 'execution-failed') : 'ok',
+      errorMessage: errors.join('; '),
+    };
+  }
+ 
+  private getAdapter(language: string): LinterAdapter | null {
+    switch (language) {
+      case 'javascript': return this.eslintAdapter;
+      case 'css': return this.stylelintAdapter;
+      case 'java': return this.pmdAdapter;
+      default: return null;
+    }
+  }
+ 
+  isAvailable(): boolean {
+    return true;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/adapters/pmd.ts.html b/tests/coverage/src/adapters/pmd.ts.html new file mode 100644 index 0000000..bedc59a --- /dev/null +++ b/tests/coverage/src/adapters/pmd.ts.html @@ -0,0 +1,601 @@ + + + + + + Code coverage report for src/adapters/pmd.ts + + + + + + + + + +
+
+

All files / src/adapters pmd.ts

+
+ +
+ 22.67% + Statements + 39/172 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 7.69% + Functions + 1/13 +
+ + +
+ 22.67% + Lines + 39/172 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +1731x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x +2x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +1x +1x +  +  +  +  +  +  +  +1x + 
import * as vscode from 'vscode';
+import * as path from 'path';
+import { existsSync } from 'fs';
+import { execSync, spawn } from 'child_process';
+import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
+import { getPMDJarPath, getPMDRulesetPath, getPMDJspRulesetPath } from '../config';
+import { t } from '../i18n/messages';
+import { AuxClasspathResolver } from '../services/auxClasspath';
+ 
+export class PmdAdapter implements LinterAdapter {
+  id = 'pmd';
+  supportedLanguages = ['java'];
+  private pmdDir: string | null = null;
+  private auxResolver = new AuxClasspathResolver();
+ 
+  private resolvePmdDir(): string {
+    if (this.pmdDir) { return this.pmdDir; }
+
+    const jarPath = getPMDJarPath();
+    if (jarPath && jarPath.trim() !== '') {
+      if (existsSync(path.join(jarPath, 'PmdRunner.class'))) {
+        this.pmdDir = jarPath;
+        return this.pmdDir;
+      }
+    }
+
+    const candidates: string[] = [];
+    try {
+      const ext = vscode.extensions.getExtension?.('vscode-code-reviewer');
+      if (ext?.extensionPath) {
+        candidates.push(path.join(ext.extensionPath, 'jars', 'pmd'));
+        candidates.push(path.join(ext.extensionPath, 'out', 'jars', 'pmd'));
+      }
+    } catch { /* ignore */ }
+    candidates.push(path.join(path.resolve(__dirname, '..'), 'jars', 'pmd'));
+    candidates.push(path.join(path.resolve(__dirname, '..', '..'), 'jars', 'pmd'));
+    for (const dir of candidates) {
+      if (existsSync(path.join(dir, 'PmdRunner.class'))) {
+        this.pmdDir = dir;
+        return dir;
+      }
+    }
+    this.pmdDir = candidates[0];
+    return this.pmdDir;
+  }
+ 
+  private getPmdLibClasspath(): string {
+    return path.join(this.resolvePmdDir(), 'lib', '*');
+  }
+ 
+  private getPmdRunnerClasspath(): string {
+    return this.resolvePmdDir();
+  }
+ 
+  async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
+    return this.run(document, workingDir, false);
+  }
+ 
+  async checkJsp(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
+    return this.run(document, workingDir, true);
+  }
+ 
+  private async run(document: vscode.TextDocument, workingDir: string, isJsp: boolean): Promise<AdapterResult> {
+    try {
+      const ruleset = isJsp ? this.resolveJspRuleset() : this.resolveJavaRuleset(workingDir);
+      const classpath = `${this.getPmdLibClasspath()};${this.getPmdRunnerClasspath()}`;
+
+      const isVirtual = document.uri.scheme === 'untitled';
+      const fileArg = isVirtual ? '-' : document.uri.fsPath;
+
+      const javaArgs = ['-cp', classpath, 'PmdRunner', fileArg, ruleset, isJsp ? 'jsp' : 'java'];
+      const auxInfo = await this.auxResolver.resolve(workingDir);
+      if (auxInfo.error) {
+        console.warn(`[code-reviewer] PMD aux classpath resolve failed: ${auxInfo.error}`);
+      }
+      const result = await this.execPmd(javaArgs, isVirtual ? document.getText() : null, workingDir, auxInfo.classpath);
+
+      const diagnostics = this.parsePmdOutput(result);
+      return { diagnostics, status: 'ok' };
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      if (message.includes('ENOENT') || message.includes('java not found') || message.includes('Cannot find')) {
+        return { diagnostics: [], status: 'tool-unavailable', errorMessage: t('adapter.javaNotInstalled') };
+      }
+      return { diagnostics: [], status: 'execution-failed', errorMessage: message };
+    }
+  }
+ 
+  private resolveJavaRuleset(workingDir: string): string {
+    const globalRuleset = getPMDRulesetPath();
+    if (globalRuleset && globalRuleset.trim() !== '') {
+      return globalRuleset;
+    }
+    const projectRuleset = path.join(workingDir, 'ruleset.xml');
+    return existsSync(projectRuleset)
+      ? projectRuleset
+      : path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
+  }
+ 
+  private resolveJspRuleset(): string {
+    const globalRuleset = getPMDJspRulesetPath();
+    return globalRuleset && globalRuleset.trim() !== ''
+      ? globalRuleset
+      : path.join(this.getPmdRunnerClasspath(), 'pmd-jsp-ruleset.xml');
+  }
+ 
+  private execPmd(args: string[], stdinInput: string | null, cwd: string, auxClasspath: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+      const env: NodeJS.ProcessEnv = { ...process.env };
+      if (auxClasspath && auxClasspath.trim() !== '') {
+        env.PMD_AUXCP = auxClasspath;
+      }
+      const proc = spawn('java', args, { cwd, env });
+      let stdout = '';
+      let stderr = '';
+      proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
+      proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
+      proc.on('close', (code) => {
+        if (code === 0 || code === 4) {
+          resolve(stdout);
+        } else {
+          reject(new Error(stderr || `PMD exited with code ${code}`));
+        }
+      });
+      proc.on('error', reject);
+      if (stdinInput !== null) {
+        proc.stdin.write(stdinInput);
+        proc.stdin.end();
+      }
+    });
+  }
+ 
+  private parsePmdOutput(output: string): LinterDiagnostic[] {
+    if (!output.trim()) { return []; }
+    try {
+      const data = JSON.parse(output);
+      const diagnostics: LinterDiagnostic[] = [];
+      for (const file of data.files ?? []) {
+        for (const violation of file.violations ?? []) {
+          const line = Math.max(0, (violation.beginline ?? 1) - 1);
+          const col = Math.max(0, (violation.begincolumn ?? 1) - 1);
+          const endCol = Math.max(col, (violation.endcolumn ?? col + 1) - 1);
+          const range = new vscode.Range(line, col, line, endCol);
+          diagnostics.push({
+            severity: this.mapPriority(violation.priority),
+            ruleId: `pmd:${violation.rule}`,
+            message: violation.description ?? '',
+            range,
+          });
+        }
+      }
+      return diagnostics;
+    } catch {
+      return [];
+    }
+  }
+ 
+  private mapPriority(priority: number): 'error' | 'warning' | 'info' {
+    if (priority <= 2) { return 'error'; }
+    if (priority === 3) { return 'warning'; }
+    return 'info';
+  }
+ 
+  isAvailable(): boolean {
+    try {
+      execSync('java -version 2>&1', { stdio: 'ignore' });
+      return true;
+    } catch {
+      return false;
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/adapters/sqlfluff.ts.html b/tests/coverage/src/adapters/sqlfluff.ts.html new file mode 100644 index 0000000..a0f1375 --- /dev/null +++ b/tests/coverage/src/adapters/sqlfluff.ts.html @@ -0,0 +1,856 @@ + + + + + + Code coverage report for src/adapters/sqlfluff.ts + + + + + + + + + +
+
+

All files / src/adapters sqlfluff.ts

+
+ +
+ 37.35% + Statements + 96/257 +
+ + +
+ 93.33% + Branches + 14/15 +
+ + +
+ 36.36% + Functions + 4/11 +
+ + +
+ 37.35% + Lines + 96/257 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +2582x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +152x +150x +150x +152x +2x +2x +2x +  +  +  +  +  +2x +4x +4x +4x +4x +4x +1x +1x +4x +4x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +24x +24x +24x +24x +2x +6x +6x +6x +6x +6x +6x +6x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +2x +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x + 
import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { spawn } from 'child_process';
+import type { LinterAdapter, AdapterResult, LinterDiagnostic, Severity } from './adapter';
+import { getSqlFluffConfigFile, getSqlFluffDialect } from '../config';
+import { t } from '../i18n/messages';
+import staticRules from '../rules/static-rules.json';
+import { BUILTIN_SQLFLUFF_RULES, buildBuiltinSqlfluffConfig } from '../rules/builtin-rules';
+ 
+const DIALECT_MAP: Record<string, string> = {
+  sql: 'oracle',
+  plsql: 'oracle',
+};
+ 
+export const SUPPORTED_DIALECTS = [
+  'ansi', 'athena', 'bigquery', 'clickhouse', 'databricks', 'db2', 'doris',
+  'duckdb', 'exasol', 'flink', 'greenplum', 'hive', 'impala', 'mariadb',
+  'materialize', 'mysql', 'oracle', 'postgres', 'redshift', 'snowflake',
+  'soql', 'sparksql', 'sqlite', 'starrocks', 'teradata', 'trino', 'tsql', 'vertica',
+];
+ 
+interface RuleEntry { id: string; description: string; tier?: string; }
+ 
+const tierMap = new Map<string, string>();
+try {
+  const sqlfluffRules = (staticRules as any).rules?.['sqlfluff'] as RuleEntry[] | undefined;
+  if (sqlfluffRules) {
+    for (const rule of sqlfluffRules) {
+      if (rule.id && rule.tier) {
+        tierMap.set(rule.id.replace('sqlfluff/', ''), rule.tier);
+      }
+    }
+  }
+} catch {}
+ 
+function tierToSeverity(tier: string | undefined): Severity {
+  if (tier === 'P0' || tier === 'P1') { return 'error'; }
+  if (tier === 'P2') { return 'warning'; }
+  return 'warning';
+}
+ 
+export function buildPRSMessage(description: string, dialect: string): string {
+  const match = /Found unparsable section: '([\s\S]*)'/.exec(description);
+  let fragment = match ? match[1] : description;
+  fragment = fragment.replace(/\n/g, '\\n');
+  if (fragment.length > 80) {
+    fragment = fragment.slice(0, 80) + '...';
+  }
+  return t('adapter.sqlfluffPRS', { 0: dialect, 1: fragment });
+}
+ 
+function findProjectSqlFluffConfig(workspaceRoot: string): string | undefined {
+  const candidates: Array<{ file: string; marker: string | null }> = [
+    { file: '.sqlfluff', marker: null },
+    { file: 'setup.cfg', marker: '[sqlfluff]' },
+    { file: 'tox.ini', marker: '[sqlfluff]' },
+    { file: 'pep8.ini', marker: '[sqlfluff]' },
+    { file: 'pyproject.toml', marker: '[tool.sqlfluff]' },
+  ];
+  for (const candidate of candidates) {
+    const filePath = path.join(workspaceRoot, candidate.file);
+    if (!fs.existsSync(filePath)) { continue; }
+    if (candidate.marker === null) { return filePath; }
+    const content = fs.readFileSync(filePath, 'utf-8');
+    if (content.includes(candidate.marker)) { return filePath; }
+  }
+  return undefined;
+}
+ 
+function readDialectFromConfigFile(filePath: string): string | undefined {
+  try {
+    const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
+    let inSection = false;
+    for (const line of lines) {
+      const trimmed = line.trim();
+      if (/^\[(tool\.)?sqlfluff\]\s*$/.test(trimmed)) {
+        inSection = true;
+        continue;
+      }
+      if (!inSection) { continue; }
+      if (/^\[/.test(trimmed)) { break; }
+      const match = /^dialect\s*[:=]\s*"?([A-Za-z0-9_]+)"?/.exec(trimmed);
+      if (match) { return match[1]; }
+    }
+  } catch {}
+  return undefined;
+}
+ 
+export type SqlFluffDialectSource = 'explicit' | 'global' | 'project' | 'builtin';
+ 
+export interface SqlFluffDialectInfo {
+  dialect: string;
+  source: SqlFluffDialectSource;
+}
+ 
+export function resolveSqlFluffDialect(workspaceRoot: string): SqlFluffDialectInfo {
+  const explicit = getSqlFluffDialect();
+  if (explicit && SUPPORTED_DIALECTS.includes(explicit)) {
+    return { dialect: explicit, source: 'explicit' };
+  }
+
+  const globalConfig = getSqlFluffConfigFile();
+  if (globalConfig && globalConfig.trim() !== '') {
+    return { dialect: readDialectFromConfigFile(globalConfig) ?? 'ansi', source: 'global' };
+  }
+
+  const projectConfig = findProjectSqlFluffConfig(workspaceRoot);
+  if (projectConfig) {
+    return { dialect: readDialectFromConfigFile(projectConfig) ?? 'ansi', source: 'project' };
+  }
+
+  return { dialect: 'oracle', source: 'builtin' };
+}
+ 
+interface SqlFluffViolation {
+  start_line_no?: number | null;
+  start_line_pos?: number | null;
+  end_line_no?: number | null;
+  end_line_pos?: number | null;
+  line_no?: number | null;
+  line_pos?: number | null;
+  code: string;
+  description: string;
+}
+ 
+function sanitizePosition(value: number | null | undefined, fallback: number): number {
+  const n = Number(value);
+  return Number.isFinite(n) && n >= 1 ? n : fallback;
+}
+ 
+export function resolveSqlFluffRange(v: SqlFluffViolation): [number, number, number, number] {
+  const startLine = sanitizePosition(v.start_line_no ?? v.line_no, 1);
+  const startPos = sanitizePosition(v.start_line_pos ?? v.line_pos, 1);
+  const endLine = sanitizePosition(v.end_line_no, startLine);
+  const endPos = sanitizePosition(v.end_line_pos, startPos);
+  return [startLine - 1, startPos - 1, endLine - 1, endPos - 1];
+}
+ 
+interface SqlFluffResult {
+  filepath: string;
+  violations: SqlFluffViolation[];
+}
+ 
+function runSqlfluff(code: string, cwd: string, configPath?: string, dialect?: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const args = ['lint', '--format', 'json'];
+    if (dialect) {
+      args.push('--dialect', dialect);
+    }
+    if (configPath) {
+      args.push('--config', configPath);
+    }
+    args.push('-');
+    const child = spawn('sqlfluff', args, {
+      cwd,
+      timeout: 30000,
+    });
+
+    let stdout = '';
+    let stderr = '';
+
+    child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
+    child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
+
+    child.on('error', (err: NodeJS.ErrnoException) => {
+      if (err.code === 'ENOENT') {
+        reject(new Error('tool-unavailable'));
+      } else {
+        reject(err);
+      }
+    });
+
+    child.on('close', (code: number | null) => {
+      if (stdout) {
+        resolve(stdout);
+      } else {
+        reject(new Error(stderr || `sqlfluff exited with code ${code}`));
+      }
+    });
+
+    child.stdin.write(code);
+    child.stdin.end();
+  });
+}
+ 
+export class SqlFluffAdapter implements LinterAdapter {
+  id = 'sqlfluff';
+  supportedLanguages = ['sql', 'plsql'];
+ 
+  isAvailable(): boolean {
+    return true;
+  }
+ 
+  async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
+    const languageId = document.languageId;
+    const fallbackDialect = DIALECT_MAP[languageId] ?? 'ansi';
+
+    const explicitDialect = getSqlFluffDialect();
+    const cliDialect = explicitDialect && SUPPORTED_DIALECTS.includes(explicitDialect)
+      ? explicitDialect
+      : undefined;
+    const effectiveDialect = cliDialect ?? fallbackDialect;
+
+    let configPath: string | undefined;
+    let tempConfigPath: string | undefined;
+
+    const globalConfig = getSqlFluffConfigFile();
+    if (globalConfig && globalConfig.trim() !== '') {
+      configPath = globalConfig;
+    } else if (findProjectSqlFluffConfig(workingDir)) {
+    } else {
+      tempConfigPath = path.join(os.tmpdir(), `vscode-code-reviewer-sqlfluff-${Date.now()}.cfg`);
+      fs.writeFileSync(tempConfigPath, buildBuiltinSqlfluffConfig(cliDialect ?? fallbackDialect), 'utf-8');
+      configPath = tempConfigPath;
+    }
+
+    try {
+      const stdout = await runSqlfluff(document.getText(), workingDir, configPath, cliDialect);
+      const results: SqlFluffResult[] = JSON.parse(stdout);
+      const diagnostics: LinterDiagnostic[] = [];
+
+      for (const result of results) {
+        for (const v of result.violations) {
+          const isPRS = v.code === 'PRS';
+          diagnostics.push({
+            severity: isPRS ? 'error' : tierToSeverity(tierMap.get(v.code)),
+            ruleId: `sqlfluff:${v.code}`,
+            message: isPRS ? buildPRSMessage(v.description, effectiveDialect) : v.description,
+            range: new vscode.Range(...resolveSqlFluffRange(v)),
+          });
+        }
+      }
+
+      return { diagnostics, status: 'ok' };
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      if (message === 'tool-unavailable') {
+        return {
+          diagnostics: [],
+          status: 'tool-unavailable',
+          errorMessage: t('adapter.sqlfluffNotInstalled'),
+        };
+      }
+      return {
+        diagnostics: [],
+        status: 'execution-failed',
+        errorMessage: message,
+      };
+    } finally {
+      if (tempConfigPath) {
+        try { fs.unlinkSync(tempConfigPath); } catch {}
+      }
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/adapters/stylelint.ts.html b/tests/coverage/src/adapters/stylelint.ts.html new file mode 100644 index 0000000..3994843 --- /dev/null +++ b/tests/coverage/src/adapters/stylelint.ts.html @@ -0,0 +1,466 @@ + + + + + + Code coverage report for src/adapters/stylelint.ts + + + + + + + + + +
+
+

All files / src/adapters stylelint.ts

+
+ +
+ 43.3% + Statements + 55/127 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 16.66% + Functions + 1/6 +
+ + +
+ 43.3% + Lines + 55/127 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +1282x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +2x +2x +2x +3x +3x +2x +2x +2x +  +  +  +  +  +  +2x +2x +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x + 
import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import type { LinterAdapter, AdapterResult, LinterDiagnostic, Severity } from './adapter';
+import { getStylelintConfigPath } from '../config';
+import { stylelintExtraRules } from '../rules/builtin-rules';
+ 
+const CONFIG_FILE_NAMES = [
+  '.stylelintrc',
+  '.stylelintrc.json',
+  '.stylelintrc.yaml',
+  '.stylelintrc.yml',
+  '.stylelintrc.js',
+  'stylelint.config.js',
+  'stylelint.config.mjs',
+  'stylelint.config.cjs',
+];
+ 
+async function getDefaultConfig(): Promise<Record<string, unknown>> {
+  const mod = await import('stylelint-config-recommended');
+  const recommendedConfig = (mod.default ?? mod) as Record<string, unknown>;
+  const recommendedRules = (recommendedConfig.rules ?? {}) as Record<string, unknown>;
+  return {
+    ...recommendedConfig,
+    rules: {
+      ...recommendedRules,
+      ...stylelintExtraRules,
+    },
+  };
+}
+ 
+interface LinterOptions {
+  code?: string;
+  codeFilename?: string;
+  cwd?: string;
+  config?: Record<string, unknown>;
+  configFile?: string;
+}
+ 
+interface LinterResult {
+  results: Array<{
+    warnings: Array<{
+      line: number;
+      column: number;
+      endLine?: number;
+      endColumn?: number;
+      rule: string;
+      severity: string;
+      text: string;
+      fix?: { range: [number, number]; text: string };
+    }>;
+  }>;
+}
+ 
+function hasExternalConfig(dir: string): boolean {
+  try {
+    return CONFIG_FILE_NAMES.some(name => fs.existsSync(path.join(dir, name)));
+  } catch {
+    return false;
+  }
+}
+ 
+export class StylelintAdapter implements LinterAdapter {
+  id = 'stylelint';
+  supportedLanguages = ['css'];
+ 
+  private _module: { lint: (opts: LinterOptions) => Promise<LinterResult> } | undefined;
+ 
+  private async getModule(): Promise<{ lint: (opts: LinterOptions) => Promise<LinterResult> }> {
+    if (!this._module) {
+      const mod = await import('stylelint');
+      this._module = (mod.default ?? mod) as { lint: (opts: LinterOptions) => Promise<LinterResult> };
+    }
+    return this._module;
+  }
+ 
+  isAvailable(): boolean {
+    return true;
+  }
+ 
+  async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
+    try {
+      const stylelint = await this.getModule();
+
+      const lintOptions: LinterOptions = {
+        code: document.getText(),
+        codeFilename: document.fileName,
+        cwd: workingDir,
+      };
+
+      const globalPath = getStylelintConfigPath();
+      if (globalPath && globalPath.trim() !== '') {
+        lintOptions.configFile = globalPath;
+      } else if (!hasExternalConfig(workingDir)) {
+        lintOptions.config = await getDefaultConfig();
+      }
+
+      const result = await stylelint.lint(lintOptions);
+
+      const diagnostics: LinterDiagnostic[] = [];
+      for (const res of result.results) {
+        for (const w of res.warnings) {
+          diagnostics.push({
+            severity: w.severity as Severity,
+            ruleId: `stylelint:${w.rule}`,
+            message: w.text,
+            range: new vscode.Range(
+              w.line - 1,
+              w.column - 1,
+              (w.endLine ?? w.line) - 1,
+              (w.endColumn ?? w.column) - 1
+            ),
+            fix: w.fix ? { range: [w.fix.range[0], w.fix.range[1]], text: w.fix.text } : undefined,
+          });
+        }
+      }
+
+      return { diagnostics, status: 'ok' };
+    } catch (error) {
+      return {
+        diagnostics: [],
+        status: 'execution-failed',
+        errorMessage: error instanceof Error ? error.message : String(error),
+      };
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/engine.ts.html b/tests/coverage/src/ai/engine.ts.html new file mode 100644 index 0000000..e01636e --- /dev/null +++ b/tests/coverage/src/ai/engine.ts.html @@ -0,0 +1,2200 @@ + + + + + + Code coverage report for src/ai/engine.ts + + + + + + + + + +
+
+

All files / src/ai engine.ts

+
+ +
+ 10.63% + Statements + 75/705 +
+ + +
+ 80% + Branches + 8/10 +
+ + +
+ 11.11% + Functions + 2/18 +
+ + +
+ 10.63% + Lines + 75/705 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +7062x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +2x +  +  +  +  +  +2x +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +20x +20x +20x +2x +2x +18x +18x +20x +  +  +18x +18x +18x +20x +  +  +  +  +  +  +  +20x +2x +2x +20x +20x +20x +20x +20x +20x +20x +20x +3x +2x +2x +1x +1x +20x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +2x +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import * as vscode from 'vscode';
+import { EmptyContentError } from './providers/base';
+import type { AIProvider, ChatOptions } from './providers/base';
+import { createProvider } from './factory';
+import { getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage, getApiKey } from '../config';
+import type { LinterDiagnostic, CustomRule } from '../types';
+import type {
+  AIEngineResult,
+  CustomRuleResult,
+  TranslatedDiagnostic,
+  AIFinding,
+  MethodFinding,
+  MethodReviewResult,
+} from './schema';
+import type { MethodScope } from '../scope/method-extractor';
+import { t, getLanguage } from '../i18n/messages';
+ 
+function buildCustomRulePrompt(rules: CustomRule[]): string {
+  return rules.map(r =>
+    `- [${r.id}] (${r.severity}) ${r.description}`
+  ).join('\n');
+}
+ 
+function buildLinterDiagnosticsPrompt(diagnostics: LinterDiagnostic[]): string {
+  return diagnostics.map(d =>
+    `- [${d.ruleId}] L${d.range.start.line + 1}: ${d.message}`
+  ).join('\n');
+}
+ 
+function addLineNumbers(code: string): string {
+  return code.split('\n').map((line, i) => `${String(i + 1).padStart(4, ' ')}| ${line}`).join('\n');
+}
+ 
+function repairJsonEscapes(str: string): string {
+  let inString = false;
+  let out = '';
+  for (let i = 0; i < str.length; i++) {
+    const ch = str[i];
+    if (ch === '\\') {
+      out += ch;
+      if (i + 1 < str.length) { out += str[++i]; }
+    } else if (ch === '"') {
+      if (!inString) {
+        inString = true;
+        out += ch;
+      } else {
+        let j = i + 1;
+        while (j < str.length && str[j] === ' ') { j++; }
+        if (j < str.length && ':,\]}'.includes(str[j])) {
+          inString = false;
+          out += ch;
+        } else {
+          out += '\\"';
+        }
+      }
+    } else {
+      out += ch;
+    }
+  }
+  return out;
+}
+ 
+export function parseJsonResponse(raw: string): object {
+  const trimmed = raw.trim();
+  if (trimmed === '') {
+    throw new Error(t('engine.emptyResponse'));
+  }
+  const start = trimmed.indexOf('{');
+  const end = trimmed.lastIndexOf('}');
+  if (start === -1 || end === -1) {
+    throw new Error(t('engine.jsonNotFound', { 0: trimmed.slice(0, 200) }));
+  }
+  let jsonStr = trimmed.substring(start, end + 1);
+  try {
+    return JSON.parse(jsonStr);
+  } catch {
+    jsonStr = repairJsonEscapes(jsonStr);
+    try {
+      return JSON.parse(jsonStr);
+    } catch {
+      throw new Error(t('engine.jsonParseFail', { 0: jsonStr.slice(0, 200) }));
+    }
+  }
+}
+ 
+export async function chatWithRetry(
+  provider: AIProvider,
+  systemPrompt: string,
+  userPrompt: string,
+  options: ChatOptions
+): Promise<string> {
+  try {
+    return await provider.chat(systemPrompt, userPrompt, options);
+  } catch (err) {
+    if (err instanceof EmptyContentError) {
+      return provider.chat(systemPrompt, userPrompt, options);
+    }
+    throw err;
+  }
+}
+ 
+function buildCustomRuleSystemPrompt(): string {
+  const lang = getLanguage();
+  if (lang === 'ja') {
+    return `あなたはコードルールレビュアーです。以下のカスタムルールに違反しているかどうかのみを評価してください。
+意味を理解し、テキストの一致ではなく判断してください。
+JSONのみを出力、形式:
+{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明", "suggestion": "具体的な修正提案", "fix": { "originalText": "置換対象のコード原文(コードコンテキスト内に完全一致すること、行番号プレフィックスなし)", "newText": "修正後のコード片" } }] }
+各違反に対して必ず実行可能な "suggestion" を含め、可能な場合は適用可能な "fix" も提供してください。
+ルールに違反していない場合は空の配列を返してください。
+
+出力言語:ja`;
+  }
+  if (lang === 'en') {
+    return `You are a code rule reviewer. Only evaluate whether the following custom rules are violated.
+Understand semantics, not text matching.
+Output JSON only, format:
+{ "customRuleResults": [{ "ruleId": "rule id", "line": line number, "severity": "error|warning|info", "message": "violation description", "suggestion": "concrete fix suggestion", "fix": { "originalText": "the exact code snippet to replace (must exist verbatim in the code context, without the line-number prefix)", "newText": "the fixed code snippet" } }] }
+Always include a concrete actionable "suggestion" for each violation, and provide an applicable "fix" snippet when possible.
+If no rules are violated, return an empty array.
+
+Output language: en`;
+  }
+  return `你是代码规则审查员,只评估以下自定义规则是否被违反。
+理解语义而非文本匹配。
+仅输出 JSON,格式:
+{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述", "suggestion": "具体的修复建议", "fix": { "originalText": "待替换的代码原文(必须在代码上下文中逐字存在,不含行号前缀)", "newText": "修复后的代码片段" } }] }
+每条违规都必须给出可执行的 "suggestion" 修复建议,并尽量提供可应用的 "fix" 修复片段。
+如果没有违反任何规则,返回空数组。
+
+输出语言:zh-CN`;
+}
+ 
+function buildDeepReviewSystemPrompt(): string {
+  const lang = getLanguage();
+  if (lang === 'ja') {
+    return `あなたはシニアコードレビュー専門家です。2つのタスクを実行してください:
+1. 英語の静的解析結果を出力言語に翻訳し、修正提案を追加する
+2. コードを詳細にレビューし、静的解析でカバーされていない問題を発見する
+重点分野:セキュリティ脆弱性、ロジックエラー、パフォーマンス問題、設計欠陥
+静的解析ですでに報告された問題を重複しないでください。
+
+translatedDiagnosticsの要件:
+- 下記の「静的解析結果」に列挙された各診断に対して1件ずつ翻訳を返してください。件数と順序を一致させ、欠落させないでください
+- "originalRuleId" はリスト内のルールID(eslint: 等のプレフィックスを含む)をそのままコピーし、書き換えないでください
+- "translatedMessage" と "translatedSuggestion" は両方必須で、空にしないでください
+- "translatedSuggestion" は具体的で実行可能な修正提案(例:この書き方に置き換える)を示してください
+- 可能な場合は各診断に適用可能な "fix" を提供してください
+
+findingsの要件:
+- 可能な場合は各発見に適用可能な "fix" を提供してください
+- "fix.originalText" は提供されたコード内に逐語的に存在すること(行番号プレフィックスなし)
+
+JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。
+形式:
+{
+  "translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案", "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" } }],
+  "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "line": 行番号, "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" } }]
+}
+
+出力言語:ja`;
+  }
+  if (lang === 'en') {
+    return `You are a senior code review expert. Complete two tasks:
+1. Translate English static analysis results into the output language and add fix suggestions
+2. Deeply review the code to find issues not covered by static analysis
+Focus on: security vulnerabilities, logic errors, performance issues, design flaws
+Do not duplicate issues already reported by static analysis.
+
+translatedDiagnostics requirements:
+- Return exactly one translation for every diagnostic listed in "Static Analysis Results", same count and order, do not omit any
+- "originalRuleId" must be copied verbatim from the listed rule IDs (keep prefixes like eslint:), do not rewrite
+- "translatedMessage" and "translatedSuggestion" are both required and must not be empty
+- "translatedSuggestion" should be a concrete actionable fix suggestion (e.g. what to replace it with), not just a replacement snippet
+- Provide an applicable "fix" snippet for each diagnostic when possible
+
+findings requirements:
+- Provide an applicable "fix" snippet for each finding when possible
+- "fix.originalText" must exist verbatim in the provided code (without the line-number prefix)
+
+Output JSON only. Double quotes in strings must be escaped with \\".
+Format:
+{
+  "translatedDiagnostics": [{ "originalRuleId": "original id", "translatedMessage": "translated message", "translatedSuggestion": "suggestion", "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" } }],
+  "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "title", "description": "description", "suggestion": "suggestion", "line": line number, "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" } }]
+}
+
+Output language: en`;
+  }
+  return `你是资深代码审查专家,完成两个任务:
+1. 将英文静态分析结果翻译为输出语言,并补充修复建议
+2. 深度审查代码,发现静态分析未覆盖的问题
+重点:安全漏洞、逻辑错误、性能问题、设计缺陷
+不要重复静态分析已报告的问题。
+
+translatedDiagnostics 要求:
+- 必须为"静态分析结果"中列出的每一条诊断都返回一条翻译,条数与顺序一致,不得遗漏
+- "originalRuleId" 必须原样复制列表中的规则 ID(保留 eslint: 等前缀),不得改写
+- "translatedMessage" 与 "translatedSuggestion" 均为必填字段,不得为空
+- "translatedSuggestion" 给出具体可执行的修复建议(如应替换成什么写法),不要只给替换片段
+- 尽量为每条诊断提供 "fix" 可应用修复片段
+
+findings 要求:
+- 尽量为每条发现提供 "fix" 可应用修复片段
+- "fix.originalText" 必须在提供的代码中逐字存在(不含行号前缀)
+
+仅输出 JSON,字符串中的双引号必须用 \\" 转义。
+格式:
+{
+  "translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议", "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" } }],
+  "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "line": 行号, "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" } }]
+}
+
+输出语言:zh-CN`;
+}
+ 
+function buildUserPromptCustomRules(customRules: CustomRule[], numberedCode: string): string {
+  const lang = getLanguage();
+  const label = lang === 'ja' ? 'カスタムルール' : lang === 'en' ? 'Custom Rules' : '自定义规则';
+  const codeLabel = lang === 'ja' ? 'コード(行番号付き)' : lang === 'en' ? 'Code (with line numbers)' : '代码(带行号)';
+  return `## ${label}\n${buildCustomRulePrompt(customRules)}\n\n## ${codeLabel}\n${numberedCode}`;
+}
+ 
+function buildUserPromptDeepReview(numberedCode: string, staticDiagnostics: LinterDiagnostic[]): string {
+  const lang = getLanguage();
+  const codeLabel = lang === 'ja' ? 'コード(行番号付き)' : lang === 'en' ? 'Code (with line numbers)' : '代码(带行号)';
+  const resultLabel = lang === 'ja' ? '静的解析結果' : lang === 'en' ? 'Static Analysis Results' : '静态分析结果(英文)';
+  return `## ${codeLabel}\n${numberedCode}\n\n## ${resultLabel}\n${buildLinterDiagnosticsPrompt(staticDiagnostics)}`;
+}
+ 
+export async function runAIReview(
+  context: vscode.ExtensionContext,
+  code: string,
+  staticDiagnostics: LinterDiagnostic[],
+  customRules: CustomRule[]
+): Promise<AIEngineResult> {
+  const apiKey = await getApiKey(context);
+
+  if (!apiKey) {
+    return {
+      customRuleResults: [],
+      translatedDiagnostics: [],
+      findings: [],
+      degraded: true,
+      error: t('adapter.noApiKey'),
+    };
+  }
+
+  const providerId = getAIProvider();
+  const baseUrl = getAIBaseUrl();
+
+  let provider: AIProvider;
+  try {
+    provider = createProvider(providerId, apiKey, baseUrl, context.extensionUri);
+  } catch (err) {
+    return {
+      customRuleResults: [],
+      translatedDiagnostics: [],
+      findings: [],
+      degraded: true,
+      error: t('adapter.createProviderFail', { 0: err instanceof Error ? err.message : String(err) }),
+    };
+  }
+
+  const options = {
+    model: getAIModel(),
+    temperature: getAITemperature(),
+    maxTokens: getAIMaxTokens(),
+    timeoutMs: getAITimeout() * 1000,
+  };
+
+  const numberedCode = addLineNumbers(code);
+
+  const requestA =
+    customRules.length > 0
+      ? chatWithRetry(
+          provider,
+          buildCustomRuleSystemPrompt(),
+          buildUserPromptCustomRules(customRules, numberedCode),
+          options
+        )
+      : Promise.resolve('{}');
+
+  const requestB = chatWithRetry(
+    provider,
+    buildDeepReviewSystemPrompt(),
+    buildUserPromptDeepReview(numberedCode, staticDiagnostics),
+    options
+  );
+
+  const [resultA, resultB] = await Promise.allSettled([requestA, requestB]);
+
+  const errors: string[] = [];
+
+  let customRuleResults: CustomRuleResult[] = [];
+  if (resultA.status === 'fulfilled') {
+    try {
+      const parsed = parseJsonResponse(resultA.value) as { customRuleResults?: CustomRuleResult[] };
+      customRuleResults = (parsed.customRuleResults ?? []).map(r => ({
+        ...r,
+        ruleId: `custom:${r.ruleId}`,
+      }));
+    } catch (e) {
+      errors.push(t('adapter.customRuleParseFail', { 0: e instanceof Error ? e.message : String(e) }));
+    }
+  } else {
+    errors.push(t('adapter.customRuleRequestFail', { 0: resultA.reason }));
+  }
+
+  let translatedDiagnostics: TranslatedDiagnostic[] = [];
+  let findings: AIFinding[] = [];
+  if (resultB.status === 'fulfilled') {
+    try {
+      const parsed = parseJsonResponse(resultB.value) as {
+        translatedDiagnostics?: TranslatedDiagnostic[];
+        findings?: AIFinding[];
+      };
+      translatedDiagnostics = parsed.translatedDiagnostics ?? [];
+      findings = parsed.findings ?? [];
+    } catch (e) {
+      errors.push(t('adapter.aiReviewParseFail', { 0: e instanceof Error ? e.message : String(e) }));
+    }
+  } else {
+    errors.push(t('adapter.aiReviewRequestFail', { 0: resultB.reason }));
+  }
+
+  const degraded = errors.length > 0;
+  return {
+    customRuleResults,
+    translatedDiagnostics,
+    findings,
+    degraded,
+    error: errors.join('; '),
+  };
+}
+ 
+export async function runMethodReview(
+  context: vscode.ExtensionContext,
+  scope: MethodScope,
+  customRules: CustomRule[]
+): Promise<MethodReviewResult> {
+  const apiKey = await getApiKey(context);
+  if (!apiKey) {
+    return {
+      customRuleResults: [],
+      findings: [],
+      degraded: true,
+      error: t('adapter.noApiKey'),
+    };
+  }
+
+  const providerId = getAIProvider();
+  const baseUrl = getAIBaseUrl();
+
+  let provider: AIProvider;
+  try {
+    provider = createProvider(providerId, apiKey, baseUrl, context.extensionUri);
+  } catch (err) {
+    return {
+      customRuleResults: [],
+      findings: [],
+      degraded: true,
+      error: t('adapter.createProviderFail', { 0: err instanceof Error ? err.message : String(err) }),
+    };
+  }
+
+  const options = {
+    model: getAIModel(),
+    temperature: getAITemperature(),
+    maxTokens: getAIMaxTokens(),
+    timeoutMs: getAITimeout() * 1000,
+  };
+
+  const numberedCode = addLineNumbers(scope.code);
+  const hasRules = customRules.length > 0;
+
+  let response: string;
+  try {
+    response = await chatWithRetry(
+      provider,
+      buildMethodReviewSystemPrompt(hasRules),
+      buildMethodUserPrompt(scope, numberedCode, customRules),
+      options
+    );
+  } catch (err) {
+    return {
+      customRuleResults: [],
+      findings: [],
+      degraded: true,
+      error: t('adapter.aiReviewRequestFail', { 0: err instanceof Error ? err.message : String(err) }),
+    };
+  }
+
+  const errors: string[] = [];
+  let customRuleResults: CustomRuleResult[] = [];
+  let findings: MethodFinding[] = [];
+
+  try {
+    const parsed = parseJsonResponse(response) as {
+      customRuleResults?: CustomRuleResult[];
+      findings?: MethodFinding[];
+    };
+    customRuleResults = (parsed.customRuleResults ?? []).map(r => {
+      const id = String(r.ruleId ?? '');
+      return { ...r, ruleId: id.startsWith('custom:') ? id : `custom:${id}` };
+    });
+    findings = (parsed.findings ?? []).map(f => {
+      const id = String(f.ruleId ?? '');
+      return { ...f, ruleId: id.startsWith('method:') ? id : `method:${id}` };
+    });
+  } catch (e) {
+    errors.push(t('adapter.aiReviewParseFail', { 0: e instanceof Error ? e.message : String(e) }));
+  }
+
+  return {
+    customRuleResults,
+    findings,
+    degraded: errors.length > 0,
+    error: errors.join('; ') || undefined,
+  };
+}
+ 
+function buildMethodReviewSystemPrompt(hasRules: boolean): string {
+  const lang = getLanguage();
+  if (lang === 'ja') {
+    return buildMethodSystemPromptJa(hasRules);
+  }
+  if (lang === 'en') {
+    return buildMethodSystemPromptEn(hasRules);
+  }
+  return buildMethodSystemPromptZh(hasRules);
+}
+ 
+function buildMethodSystemPromptEn(hasRules: boolean): string {
+  const ruleSection = hasRules
+    ? `## Task 1: Custom Rule Matching
+Evaluate whether the method violates any of the provided custom rules.
+Understand semantics, not text matching.
+Report violations in "customRuleResults".\n\n`
+    : '';
+  const ruleOutput = hasRules
+    ? `  "customRuleResults": [
+    {
+      "ruleId": "original rule id",
+      "line": line_number,
+      "severity": "error|warning|info",
+      "message": "violation description",
+      "suggestion": "concrete fix suggestion",
+      "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" }
+    }
+  ],\n`
+    : '';
+  return `You are a senior code review expert reviewing a single method.
+There is no static analysis before you — you handle rule matching AND deep review.
+
+${ruleSection}## Review Strategy: Path Enumeration
+- Walk through every if/else/switch branch, note coverage and gaps
+- Enumerate boundary values for every parameter (null, empty collection, extreme values, wrong types)
+- Check every throw/catch path for proper fallback strategy
+- Trace the method's role in its call chain
+
+## Required Dimensions (do not skip any)
+A. Correctness: branch coverage, boundary conditions, exception path completeness
+B. Security: input validation, injection risk, permission check, sensitive data leakage
+C. Design: single responsibility, parameter design, return value contract, call chain adaptation
+D. Convention: naming, cyclomatic complexity, magic numbers, missing comments
+E. Performance: time/space complexity, resource leaks, unnecessary computation
+F. Testability: side effect isolation, dependency mockability, deterministic output
+
+## Call Chain Analysis
+- Check whether callers' arguments match this method's expectations
+- Check whether this method's return value is correctly handled by callers
+- Check whether exceptions are caught or declared by callers
+
+Provide an applicable "fix" snippet for each finding when possible.
+"fix.originalText" must exist verbatim in the provided method code (without the line-number prefix).
+
+Output JSON only. Double quotes in strings must be escaped with \\".
+Format:
+{
+${ruleOutput}  "findings": [
+    {
+      "ruleId": "method-boundary-null",
+      "severity": "error|warning|info",
+      "category": "correctness|security|design|convention|performance|testability",
+      "title": "issue title",
+      "description": "detailed description",
+      "suggestion": "fix suggestion",
+      "line": line_number,
+      "path": "trigger path description, e.g. if(order==null) -> NPE on .getId()",
+      "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" }
+    }
+  ]
+}
+If no issues found, return empty arrays.
+
+Output language: en`;
+}
+ 
+function buildMethodSystemPromptZh(hasRules: boolean): string {
+  const ruleSection = hasRules
+    ? `## 任务一:自定义规则匹配
+评估方法是否违反了提供的自定义规则。
+理解语义,而非文本匹配。
+在 "customRuleResults" 中报告违规。\n\n`
+    : '';
+  const ruleOutput = hasRules
+    ? `  "customRuleResults": [
+    {
+      "ruleId": "原始规则 ID",
+      "line": 行号,
+      "severity": "error|warning|info",
+      "message": "违规描述",
+      "suggestion": "具体的修复建议",
+      "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" }
+    }
+  ],\n`
+    : '';
+  return `你是资深代码审查专家,正在审查单个方法。
+没有静态分析的前置过滤——你同时负责规则匹配和深度审查。
+
+${ruleSection}## 审查策略:逐路径枚举
+- 遍历每个 if/else/switch 分支,标注覆盖与遗漏
+- 枚举每个入参的边界值(null、空集合、极值、错误类型)
+- 检查每个 throw/catch 路径的降级策略
+- 追踪方法在调用链中的角色
+
+## 必须覆盖的维度(不可跳过)
+A. 正确性:分支覆盖、边界条件、异常路径完整性
+B. 安全性:输入校验、注入风险、权限检查、敏感信息泄露
+C. 设计:职责单一性、参数设计合理性、返回值契约、调用链适配
+D. 规范:命名、圈复杂度、魔法数字、注释缺失
+E. 性能:时间/空间复杂度、资源泄漏、不必要的计算
+F. 可测试性:副作用隔离、依赖可 Mock 性、确定性输出
+
+## 调用链分析
+- 检查调用者传入的参数是否符合本方法预期
+- 检查本方法的返回值是否被调用者正确处理
+- 检查异常是否被调用者捕获或声明
+
+尽可能为每条发现提供可应用的 "fix" 修复片段。
+"fix.originalText" 必须在提供的方法代码中逐字存在(不含行号前缀)。
+
+输出 JSON,字符串中的双引号必须用 \\" 转义。
+格式:
+{
+${ruleOutput}  "findings": [
+    {
+      "ruleId": "method-boundary-null",
+      "severity": "error|warning|info",
+      "category": "correctness|security|design|convention|performance|testability",
+      "title": "问题标题",
+      "description": "详细描述",
+      "suggestion": "修复建议",
+      "line": 行号,
+      "path": "触发路径描述,如 if(order==null) -> NPE on .getId()",
+      "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" }
+    }
+  ]
+}
+如果未发现问题,返回空数组。
+
+输出语言:zh-CN`;
+}
+ 
+function buildMethodSystemPromptJa(hasRules: boolean): string {
+  const ruleSection = hasRules
+    ? `## タスク1:カスタムルールマッチング
+提供されたカスタムルールの違反があるか評価してください。
+意味を理解し、テキストの一致ではなく判断してください。
+違反を "customRuleResults" で報告してください。\n\n`
+    : '';
+  const ruleOutput = hasRules
+    ? `  "customRuleResults": [
+    {
+      "ruleId": "元のルールID",
+      "line": 行番号,
+      "severity": "error|warning|info",
+      "message": "違反の説明",
+      "suggestion": "具体的な修正提案",
+      "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" }
+    }
+  ],\n`
+    : '';
+  return `あなたはシニアコードレビュー専門家です。単一のメソッドをレビューしています。
+事前の静的解析はありません——あなたがルールマッチングと詳細レビューの両方を担当します。
+
+${ruleSection}## レビュー戦略:パス列挙
+- すべての if/else/switch 分岐を辿り、カバレッジと漏れを確認
+- すべての引数の境界値(null、空コレクション、極値、誤った型)を列挙
+- すべての throw/catch パスのフォールバック戦略を確認
+- コールチェーンにおけるメソッドの役割を追跡
+
+## 必須カバレッジ(スキップ不可)
+A. 正しさ:分岐カバレッジ、境界条件、例外パスの完全性
+B. セキュリティ:入力検証、インジェクションリスク、権限チェック、機密情報漏洩
+C. 設計:単一責任、パラメータ設計、戻り値契約、コールチェーン適合
+D. 規約:命名、循環的複雑度、マジックナンバー、コメント欠落
+E. パフォーマンス:時間/空間複雑度、リソースリーク、不要な計算
+F. テスタビリティ:副作用の分離、依存のモック化容易性、決定的出力
+
+## コールチェーン分析
+- 呼び出し元の引数がこのメソッドの期待と一致しているか確認
+- このメソッドの戻り値が呼び出し元で正しく処理されているか確認
+- 例外が呼び出し元でキャッチまたは宣言されているか確認
+
+可能な場合は各発見に適用可能な "fix" を提供してください。
+"fix.originalText" は提供されたメソッドコード内に逐語的に存在すること(行番号プレフィックスなし)。
+
+JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。
+形式:
+{
+${ruleOutput}  "findings": [
+    {
+      "ruleId": "method-boundary-null",
+      "severity": "error|warning|info",
+      "category": "correctness|security|design|convention|performance|testability",
+      "title": "問題のタイトル",
+      "description": "詳細な説明",
+      "suggestion": "修正提案",
+      "line": 行番号,
+      "path": "トリガーパス説明、例: if(order==null) -> .getId() で NPE",
+      "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" }
+    }
+  ]
+}
+問題がない場合は空配列を返してください。
+
+出力言語:ja`;
+}
+ 
+interface MethodPromptLabels {
+  signature: string;
+  code: string;
+  rule: string;
+  chain: string;
+  role: string;
+  callers: string;
+  callees: string;
+  none: string;
+}
+ 
+function getMethodPromptLabels(lang: string): MethodPromptLabels {
+  if (lang === 'ja') {
+    return {
+      signature: 'メソッド署名',
+      code: 'メソッドコード(行番号付き)',
+      rule: 'マッチングするカスタムルール',
+      chain: 'コールチェーンコンテキスト',
+      role: '業務フローでの役割',
+      callers: '呼び出し元',
+      callees: '呼び出し先',
+      none: '(なし)',
+    };
+  }
+  if (lang === 'en') {
+    return {
+      signature: 'Method Signature',
+      code: 'Method Code (with line numbers)',
+      rule: 'Custom Rules to Match',
+      chain: 'Call Chain Context',
+      role: 'Role in Business Flow',
+      callers: 'Callers',
+      callees: 'Callees',
+      none: '(none)',
+    };
+  }
+  return {
+    signature: '方法签名',
+    code: '方法代码(带行号)',
+    rule: '需匹配的自定义规则',
+    chain: '调用链上下文',
+    role: '业务流中的角色',
+    callers: '调用者',
+    callees: '被调用者',
+    none: '(无)',
+  };
+}
+ 
+function buildMethodUserPrompt(
+  scope: MethodScope,
+  numberedCode: string,
+  customRules: CustomRule[]
+): string {
+  const labels = getMethodPromptLabels(getLanguage());
+
+  let ruleBlock = '';
+  if (customRules.length > 0) {
+    const ruleLines = customRules
+      .map((r, i) => `${i + 1}. [${r.id}] (${r.severity}) ${r.description}\n   ${r.message}`)
+      .join('\n');
+    ruleBlock = `\n## ${labels.rule}\n${ruleLines}\n`;
+  }
+
+  return `## ${labels.signature}
+${scope.signature}
+
+## ${labels.code}
+${numberedCode}
+${ruleBlock}
+## ${labels.chain}
+${labels.role}: ${scope.role}
+${labels.callers}: ${scope.callers.length > 0 ? scope.callers.join(', ') : labels.none}
+${labels.callees}: ${scope.callees.length > 0 ? scope.callees.join(', ') : labels.none}`;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/factory.ts.html b/tests/coverage/src/ai/factory.ts.html new file mode 100644 index 0000000..98b3e7f --- /dev/null +++ b/tests/coverage/src/ai/factory.ts.html @@ -0,0 +1,238 @@ + + + + + + Code coverage report for src/ai/factory.ts + + + + + + + + + +
+
+

All files / src/ai factory.ts

+
+ +
+ 43.13% + Statements + 22/51 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 43.13% + Lines + 22/51 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +522x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +2x +  +  +  +  +  +2x +2x + 
import * as vscode from 'vscode';
+import type { AIProvider } from './providers/base';
+import type { ProviderMeta, ProviderProtocol } from './types';
+import { OpenAICompatibleProvider } from './providers/openai-compatible';
+import { GeminiProvider } from './providers/gemini';
+import { ClaudeProvider } from './providers/claude';
+import {
+  getProviderById,
+  getAllProviderMeta as getAllProviderMetaFromRegistry,
+  invalidateProviderCache,
+} from './registry';
+ 
+const PROTOCOL_MAP: Record<ProviderProtocol, new (...args: any[]) => AIProvider> = {
+  'openai-compatible': OpenAICompatibleProvider,
+  gemini: GeminiProvider,
+  claude: ClaudeProvider,
+};
+ 
+export function createProvider(
+  providerId: string,
+  apiKey: string,
+  baseUrl: string,
+  extensionUri: vscode.Uri
+): AIProvider {
+  const config = getProviderById(extensionUri, providerId);
+  if (!config) {
+    throw new Error(`未知的 Provider: ${providerId}`);
+  }
+
+  const Cls = PROTOCOL_MAP[config.protocol];
+  if (!Cls) {
+    throw new Error(`未知的协议类型: ${config.protocol}`);
+  }
+
+  if (config.protocol === 'openai-compatible') {
+    return new Cls(apiKey, baseUrl, config.id, config.name);
+  }
+  return new Cls(apiKey, baseUrl);
+}
+ 
+export function getProviderModels(extensionUri: vscode.Uri, providerId: string): string[] {
+  return getProviderById(extensionUri, providerId)?.models ?? [];
+}
+ 
+export function getAllProviderMeta(
+  extensionUri: vscode.Uri
+): Record<string, ProviderMeta> {
+  return getAllProviderMetaFromRegistry(extensionUri);
+}
+ 
+export { invalidateProviderCache };
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/index.html b/tests/coverage/src/ai/index.html new file mode 100644 index 0000000..33f1d4f --- /dev/null +++ b/tests/coverage/src/ai/index.html @@ -0,0 +1,146 @@ + + + + + + Code coverage report for src/ai + + + + + + + + + +
+
+

All files src/ai

+
+ +
+ 20.52% + Statements + 173/843 +
+ + +
+ 75.86% + Branches + 22/29 +
+ + +
+ 31.03% + Functions + 9/29 +
+ + +
+ 20.52% + Lines + 173/843 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
engine.ts +
+
10.63%75/70580%8/1011.11%2/1810.63%75/705
factory.ts +
+
43.13%22/51100%0/00%0/443.13%22/51
registry.ts +
+
87.35%76/8773.68%14/19100%7/787.35%76/87
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/providers/base.ts.html b/tests/coverage/src/ai/providers/base.ts.html new file mode 100644 index 0000000..ebdce71 --- /dev/null +++ b/tests/coverage/src/ai/providers/base.ts.html @@ -0,0 +1,175 @@ + + + + + + Code coverage report for src/ai/providers/base.ts + + + + + + + + + +
+
+

All files / src/ai/providers base.ts

+
+ +
+ 100% + Statements + 30/30 +
+ + +
+ 100% + Branches + 3/3 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 100% + Lines + 30/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +312x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +21x +21x +21x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +8x +8x +8x +2x + 
export interface ChatOptions {
+  model: string;
+  temperature: number;
+  maxTokens: number;
+  timeoutMs: number;
+  seed?: number;
+}
+ 
+export abstract class AIProvider {
+  abstract id: string;
+  abstract name: string;
+ 
+  constructor(
+    protected apiKey: string,
+    protected baseUrl: string
+  ) {}
+ 
+  abstract chat(
+    systemPrompt: string,
+    userPrompt: string,
+    options: ChatOptions
+  ): Promise<string>;
+}
+ 
+export class EmptyContentError extends Error {
+  constructor(detail: string) {
+    super(detail);
+    this.name = 'EmptyContentError';
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/providers/claude.ts.html b/tests/coverage/src/ai/providers/claude.ts.html new file mode 100644 index 0000000..cd2acf5 --- /dev/null +++ b/tests/coverage/src/ai/providers/claude.ts.html @@ -0,0 +1,277 @@ + + + + + + Code coverage report for src/ai/providers/claude.ts + + + + + + + + + +
+
+

All files / src/ai/providers claude.ts

+
+ +
+ 96.87% + Statements + 62/64 +
+ + +
+ 88.88% + Branches + 8/9 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 96.87% + Lines + 62/64 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +652x +2x +2x +2x +2x +2x +2x +2x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +1x +1x +1x +1x +  +  +2x +2x +2x +2x +2x +2x +2x +3x +3x +1x +1x +1x +1x +1x +1x +1x +1x +1x +3x +3x +3x +2x + 
import { AIProvider, ChatOptions, EmptyContentError } from './base';
+import { t } from '../../i18n/messages';
+ 
+export class ClaudeProvider extends AIProvider {
+  id = 'claude';
+  name = 'Anthropic Claude';
+ 
+  async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
+    const url = `${this.baseUrl}/messages`;
+ 
+    const body = JSON.stringify({
+      model: options.model,
+      max_tokens: options.maxTokens,
+      temperature: options.temperature,
+      system: systemPrompt,
+      messages: [
+        { role: 'user', content: userPrompt },
+      ],
+    });
+ 
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
+ 
+    try {
+      const response = await fetch(url, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'x-api-key': this.apiKey,
+          'anthropic-version': '2023-06-01',
+        },
+        body,
+        signal: controller.signal,
+      });
+ 
+      if (!response.ok) {
+        const errorText = await response.text();
+        if (response.status === 401) {
+          throw new Error('API Key 无效,请重新设置');
+        }
+        throw new Error(`Claude API 请求失败 (${response.status}): ${errorText}`);
+      }
+ 
+      const data = await response.json() as {
+        content?: Array<{ text?: string }>;
+        stop_reason?: string;
+        error?: { message?: string };
+      };
+ 
+      const text = data.content?.[0]?.text;
+      if (text === undefined || text === null || text.trim() === '') {
+        const parts = [`stop_reason=${data.stop_reason ?? 'unknown'}`];
+        if (data.error?.message) {
+          parts.push(data.error.message);
+        }
+        throw new EmptyContentError(t('adapter.emptyContent', { 0: parts.join(', ') }));
+      }
+ 
+      return text;
+    } finally {
+      clearTimeout(timeout);
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/providers/gemini.ts.html b/tests/coverage/src/ai/providers/gemini.ts.html new file mode 100644 index 0000000..a408b43 --- /dev/null +++ b/tests/coverage/src/ai/providers/gemini.ts.html @@ -0,0 +1,286 @@ + + + + + + Code coverage report for src/ai/providers/gemini.ts + + + + + + + + + +
+
+

All files / src/ai/providers gemini.ts

+
+ +
+ 97.01% + Statements + 65/67 +
+ + +
+ 66.66% + Branches + 8/12 +
+ + +
+ 100% + Functions + 2/2 +
+ + +
+ 97.01% + Lines + 65/67 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +682x +2x +2x +2x +2x +2x +2x +2x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +1x +1x +1x +2x +2x +2x +2x +2x +2x +2x +2x +2x +3x +3x +1x +1x +1x +1x +1x +  +  +1x +1x +1x +1x +1x +3x +3x +3x +2x + 
import { AIProvider, ChatOptions, EmptyContentError } from './base';
+import { t } from '../../i18n/messages';
+ 
+export class GeminiProvider extends AIProvider {
+  id = 'gemini';
+  name = 'Google Gemini';
+ 
+  async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
+    const url = `${this.baseUrl}/models/${options.model}:generateContent?key=${this.apiKey}`;
+ 
+    const body = JSON.stringify({
+      systemInstruction: {
+        parts: [{ text: systemPrompt }],
+      },
+      contents: [
+        {
+          parts: [{ text: userPrompt }],
+        },
+      ],
+      generationConfig: {
+        temperature: options.temperature,
+        maxOutputTokens: options.maxTokens,
+      },
+    });
+ 
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
+ 
+    try {
+      const response = await fetch(url, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body,
+        signal: controller.signal,
+      });
+ 
+      if (!response.ok) {
+        const errorText = await response.text();
+        throw new Error(`Gemini API 请求失败 (${response.status}): ${errorText}`);
+      }
+ 
+      const data = await response.json() as {
+        candidates?: Array<{
+          content?: { parts?: Array<{ text?: string }> };
+        }>;
+        promptFeedback?: { blockReason?: string };
+        error?: { message?: string };
+      };
+ 
+      const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
+      if (text === undefined || text === null || text.trim() === '') {
+        const parts = [
+          `candidates=${data.candidates?.length ?? 0}`,
+          `blockReason=${data.promptFeedback?.blockReason ?? 'none'}`,
+        ];
+        if (data.error?.message) {
+          parts.push(data.error.message);
+        }
+        throw new EmptyContentError(t('adapter.emptyContent', { 0: parts.join(', ') }));
+      }
+ 
+      return text;
+    } finally {
+      clearTimeout(timeout);
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/providers/index.html b/tests/coverage/src/ai/providers/index.html new file mode 100644 index 0000000..eb1fcda --- /dev/null +++ b/tests/coverage/src/ai/providers/index.html @@ -0,0 +1,161 @@ + + + + + + Code coverage report for src/ai/providers + + + + + + + + + +
+
+

All files src/ai/providers

+
+ +
+ 97.55% + Statements + 239/245 +
+ + +
+ 83.72% + Branches + 36/43 +
+ + +
+ 100% + Functions + 10/10 +
+ + +
+ 97.55% + Lines + 239/245 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
base.ts +
+
100%30/30100%3/3100%3/3100%30/30
claude.ts +
+
96.87%62/6488.88%8/9100%2/296.87%62/64
gemini.ts +
+
97.01%65/6766.66%8/12100%2/297.01%65/67
openai-compatible.ts +
+
97.61%82/8489.47%17/19100%3/397.61%82/84
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/providers/openai-compatible.ts.html b/tests/coverage/src/ai/providers/openai-compatible.ts.html new file mode 100644 index 0000000..eb0fb02 --- /dev/null +++ b/tests/coverage/src/ai/providers/openai-compatible.ts.html @@ -0,0 +1,337 @@ + + + + + + Code coverage report for src/ai/providers/openai-compatible.ts + + + + + + + + + +
+
+

All files / src/ai/providers openai-compatible.ts

+
+ +
+ 97.61% + Statements + 82/84 +
+ + +
+ 89.47% + Branches + 17/19 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 97.61% + Lines + 82/84 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +852x +2x +2x +2x +2x +2x +2x +2x +6x +6x +6x +6x +2x +2x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +  +  +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +2x +2x +1x +1x +1x +1x +4x +4x +4x +4x +4x +4x +4x +4x +4x +6x +6x +3x +3x +2x +2x +1x +1x +3x +3x +3x +1x +1x +1x +1x +1x +1x +1x +6x +6x +6x +2x + 
import { AIProvider, ChatOptions, EmptyContentError } from './base';
+import { t } from '../../i18n/messages';
+ 
+export class OpenAICompatibleProvider extends AIProvider {
+  id: string;
+  name: string;
+ 
+  constructor(apiKey: string, baseUrl: string, id: string, name: string) {
+    super(apiKey, baseUrl);
+    this.id = id;
+    this.name = name;
+  }
+ 
+  async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
+    const url = `${this.baseUrl}/chat/completions`;
+ 
+    const bodyObj: Record<string, unknown> = {
+      model: options.model,
+      max_tokens: options.maxTokens,
+      temperature: options.temperature,
+      messages: [
+        { role: 'system', content: systemPrompt },
+        { role: 'user', content: userPrompt },
+      ],
+    };
+ 
+    if (options.seed !== undefined) {
+      bodyObj.seed = options.seed;
+    }
+ 
+    const body = JSON.stringify(bodyObj);
+ 
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
+ 
+    try {
+      const response = await fetch(url, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${this.apiKey}`,
+        },
+        body,
+        signal: controller.signal,
+      });
+ 
+      if (!response.ok) {
+        const errorText = await response.text();
+        if (response.status === 401) {
+          throw new Error(t('adapter.invalidApiKey'));
+        }
+        throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
+      }
+ 
+      const data = await response.json() as {
+        choices?: Array<{
+          message?: { content?: string | null };
+          finish_reason?: string | null;
+        }>;
+        error?: { message?: string };
+      };
+ 
+      const content = data.choices?.[0]?.message?.content;
+      if (content === undefined || content === null || content.trim() === '') {
+        const finish = data.choices?.[0]?.finish_reason ?? 'unknown';
+        if (finish === 'length') {
+          throw new EmptyContentError(t('adapter.maxTokensTruncated', { 0: String(options.maxTokens) }));
+        }
+        const parts = [
+          `finish_reason=${finish}`,
+          `choices=${data.choices?.length ?? 0}`,
+        ];
+        if (data.error?.message) {
+          parts.push(data.error.message);
+        }
+        throw new EmptyContentError(t('adapter.emptyContent', { 0: parts.join(', ') }));
+      }
+ 
+      return content;
+    } finally {
+      clearTimeout(timeout);
+    }
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/ai/registry.ts.html b/tests/coverage/src/ai/registry.ts.html new file mode 100644 index 0000000..bde3b52 --- /dev/null +++ b/tests/coverage/src/ai/registry.ts.html @@ -0,0 +1,346 @@ + + + + + + Code coverage report for src/ai/registry.ts + + + + + + + + + +
+
+

All files / src/ai registry.ts

+
+ +
+ 87.35% + Statements + 76/87 +
+ + +
+ 73.68% + Branches + 14/19 +
+ + +
+ 100% + Functions + 7/7 +
+ + +
+ 87.35% + Lines + 76/87 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +882x +2x +2x +2x +2x +2x +2x +8x +8x +8x +8x +8x +8x +8x +2x +2x +8x +2x +8x +8x +8x +  +  +  +  +  +  +  +8x +8x +  +  +8x +2x +8x +8x +8x +8x +8x +8x +8x +6x +6x +8x +8x +  +  +8x +8x +8x +2x +11x +11x +9x +11x +1x +1x +8x +8x +8x +8x +8x +8x +2x +9x +9x +9x +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x + 
import * as vscode from 'vscode';
+import * as fs from 'fs';
+import * as path from 'path';
+import type { ProviderConfig, ProvidersFile, ProviderMeta } from './types';
+ 
+let cachedProviders: ProviderConfig[] | null = null;
+ 
+function loadBuiltinProviders(extensionUri: vscode.Uri): ProviderConfig[] {
+  const filePath = vscode.Uri.joinPath(extensionUri, 'providers.json').fsPath;
+  try {
+    const raw = fs.readFileSync(filePath, 'utf-8');
+    const data = JSON.parse(raw) as ProvidersFile;
+    return data.providers ?? [];
+  } catch {
+    return [];
+  }
+}
+ 
+function loadUserProviders(): ProviderConfig[] {
+  const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+  if (!workspaceRoot) { return []; }
+
+  const filePath = path.join(workspaceRoot, '.code-review', 'providers.json');
+  if (!fs.existsSync(filePath)) { return []; }
+
+  try {
+    const raw = fs.readFileSync(filePath, 'utf-8');
+    const data = JSON.parse(raw) as ProvidersFile;
+    return data.providers ?? [];
+  } catch {
+    return [];
+  }
+}
+ 
+function mergeProviders(
+  builtin: ProviderConfig[],
+  user: ProviderConfig[]
+): ProviderConfig[] {
+  const map = new Map<string, ProviderConfig>();
+ 
+  for (const p of builtin) {
+    map.set(p.id, p);
+  }
+ 
+  for (const p of user) {
+    map.set(p.id, p);
+  }
+ 
+  return Array.from(map.values());
+}
+ 
+export function getProviders(extensionUri?: vscode.Uri): ProviderConfig[] {
+  if (cachedProviders) { return cachedProviders; }
+ 
+  if (!extensionUri) {
+    return [];
+  }
+ 
+  const builtin = loadBuiltinProviders(extensionUri);
+  const user = loadUserProviders();
+  cachedProviders = mergeProviders(builtin, user);
+  return cachedProviders;
+}
+ 
+export function invalidateProviderCache(): void {
+  cachedProviders = null;
+}
+ 
+export function getProviderById(
+  extensionUri: vscode.Uri,
+  id: string
+): ProviderConfig | undefined {
+  return getProviders(extensionUri).find(p => p.id === id);
+}
+ 
+export function getAllProviderMeta(
+  extensionUri: vscode.Uri
+): Record<string, ProviderMeta> {
+  const result: Record<string, ProviderMeta> = {};
+  for (const p of getProviders(extensionUri)) {
+    result[p.id] = {
+      name: p.name,
+      models: p.models,
+    };
+  }
+  return result;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/config/ai.ts.html b/tests/coverage/src/config/ai.ts.html new file mode 100644 index 0000000..677fb39 --- /dev/null +++ b/tests/coverage/src/config/ai.ts.html @@ -0,0 +1,205 @@ + + + + + + Code coverage report for src/config/ai.ts + + + + + + + + + +
+
+

All files / src/config ai.ts

+
+ +
+ 100% + Statements + 40/40 +
+ + +
+ 100% + Branches + 8/8 +
+ + +
+ 100% + Functions + 8/8 +
+ + +
+ 100% + Lines + 40/40 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +412x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +1x +1x +1x +2x +1x +1x +1x +2x +1x +1x +1x +2x +3x +3x +3x +2x +1x +1x +1x +1x +1x +1x +1x +1x + 
import * as vscode from 'vscode';
+ 
+const ROOT = 'vscode-code-reviewer';
+ 
+export function getAIProvider(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('ai.provider', 'deepseek');
+}
+ 
+export function getAIModel(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('ai.model', 'deepseek-chat');
+}
+ 
+export function getAIBaseUrl(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('ai.baseUrl', 'https://api.deepseek.com/v1');
+}
+ 
+export function getAITemperature(): number {
+  return vscode.workspace.getConfiguration(ROOT).get<number>('ai.temperature', 0.2);
+}
+ 
+export function getAITimeout(): number {
+  return vscode.workspace.getConfiguration(ROOT).get<number>('ai.timeout', 300);
+}
+ 
+export function getAIMaxTokens(): number {
+  return vscode.workspace.getConfiguration(ROOT).get<number>('ai.maxTokens', 8192);
+}
+ 
+export function getAIOutputLanguage(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('ai.outputLanguage', 'zh-CN');
+}
+ 
+export function getAIConfig() {
+  return {
+    provider: getAIProvider(),
+    model: getAIModel(),
+    baseUrl: getAIBaseUrl(),
+    outputLanguage: getAIOutputLanguage(),
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/config/fixer.ts.html b/tests/coverage/src/config/fixer.ts.html new file mode 100644 index 0000000..ce9bf40 --- /dev/null +++ b/tests/coverage/src/config/fixer.ts.html @@ -0,0 +1,106 @@ + + + + + + Code coverage report for src/config/fixer.ts + + + + + + + + + +
+
+

All files / src/config fixer.ts

+
+ +
+ 100% + Statements + 7/7 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 7/7 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +82x +2x +2x +2x +1x +1x +1x + 
import * as vscode from 'vscode';
+ 
+const ROOT = 'vscode-code-reviewer';
+ 
+export function getFixMaxIterations(): number {
+  return vscode.workspace.getConfiguration(ROOT).get<number>('fixer.maxIterations', 3);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/config/index.html b/tests/coverage/src/config/index.html new file mode 100644 index 0000000..d3a39f2 --- /dev/null +++ b/tests/coverage/src/config/index.html @@ -0,0 +1,176 @@ + + + + + + Code coverage report for src/config + + + + + + + + + +
+
+

All files src/config

+
+ +
+ 68.03% + Statements + 83/122 +
+ + +
+ 100% + Branches + 11/11 +
+ + +
+ 45.83% + Functions + 11/24 +
+ + +
+ 68.03% + Lines + 83/122 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
ai.ts +
+
100%40/40100%8/8100%8/8100%40/40
fixer.ts +
+
100%7/7100%1/1100%1/1100%7/7
index.ts +
+
100%4/4100%0/0100%0/0100%4/4
linter.ts +
+
41.17%21/51100%2/218.18%2/1141.17%21/51
secret.ts +
+
55%11/20100%0/00%0/455%11/20
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/config/index.ts.html b/tests/coverage/src/config/index.ts.html new file mode 100644 index 0000000..cae913e --- /dev/null +++ b/tests/coverage/src/config/index.ts.html @@ -0,0 +1,97 @@ + + + + + + Code coverage report for src/config/index.ts + + + + + + + + + +
+
+

All files / src/config index.ts

+
+ +
+ 100% + Statements + 4/4 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 100% + Functions + 0/0 +
+ + +
+ 100% + Lines + 4/4 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +52x +2x +2x +2x + 
export * from './ai';
+export * from './linter';
+export * from './fixer';
+export * from './secret';
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/config/linter.ts.html b/tests/coverage/src/config/linter.ts.html new file mode 100644 index 0000000..416d48b --- /dev/null +++ b/tests/coverage/src/config/linter.ts.html @@ -0,0 +1,238 @@ + + + + + + Code coverage report for src/config/linter.ts + + + + + + + + + +
+
+

All files / src/config linter.ts

+
+ +
+ 41.17% + Statements + 21/51 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 18.18% + Functions + 2/11 +
+ + +
+ 41.17% + Lines + 21/51 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +522x +2x +2x +2x +2x +2x +2x +2x +  +  +  +2x +  +  +  +2x +  +  +  +2x +  +  +  +2x +  +  +  +2x +  +  +  +2x +15x +15x +15x +2x +  +  +  +2x +  +  +  +2x +2x +  +  +  +  +  +  + 
import * as vscode from 'vscode';
+ 
+const ROOT = 'vscode-code-reviewer';
+ 
+export function getLinterForLanguage(language: string): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>(`linters.${language}`, '');
+}
+ 
+export function getPMDJarPath(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jarPath', '');
+}
+ 
+export function getPMDRulesetPath(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.rulesetPath', '');
+}
+ 
+export function getPMDJspRulesetPath(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jspRulesetPath', '');
+}
+ 
+export function getPMDAutoAuxClasspath(): boolean {
+  return vscode.workspace.getConfiguration(ROOT).get<boolean>('pmd.autoAuxClasspath', true);
+}
+ 
+export function getSqlFluffConfigFile(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('sqlfluff.configFile', '');
+}
+ 
+export function getSqlFluffDialect(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('sqlfluff.dialect', '');
+}
+ 
+export function getEslintConfigPath(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('linters.eslintConfigPath', '');
+}
+ 
+export function getStylelintConfigPath(): string {
+  return vscode.workspace.getConfiguration(ROOT).get<string>('linters.stylelintConfigPath', '');
+}
+ 
+export function isAdapterEnabled(adapterId: string): boolean {
+  return vscode.workspace.getConfiguration(ROOT).get<boolean>(`linter.${adapterId}.enabled`, true);
+}
+ 
+export async function setAdapterEnabled(adapterId: string, enabled: boolean): Promise<void> {
+  await vscode.workspace.getConfiguration(ROOT).update(
+    `linter.${adapterId}.enabled`,
+    enabled,
+    vscode.ConfigurationTarget.Global
+  );
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/config/secret.ts.html b/tests/coverage/src/config/secret.ts.html new file mode 100644 index 0000000..cbd5d70 --- /dev/null +++ b/tests/coverage/src/config/secret.ts.html @@ -0,0 +1,145 @@ + + + + + + Code coverage report for src/config/secret.ts + + + + + + + + + +
+
+

All files / src/config secret.ts

+
+ +
+ 55% + Statements + 11/20 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 55% + Lines + 11/20 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +212x +2x +2x +2x +2x +  +  +2x +2x +  +  +2x +2x +  +  +2x +2x +  +  +  + 
import * as vscode from 'vscode';
+ 
+const SECRET_KEY = 'vscode-code-reviewer.apiKey';
+ 
+export async function getApiKey(context: vscode.ExtensionContext): Promise<string | undefined> {
+  return context.secrets.get(SECRET_KEY);
+}
+ 
+export async function setApiKey(context: vscode.ExtensionContext, value: string): Promise<void> {
+  await context.secrets.store(SECRET_KEY, value);
+}
+ 
+export async function deleteApiKey(context: vscode.ExtensionContext): Promise<void> {
+  await context.secrets.delete(SECRET_KEY);
+}
+ 
+export async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise<boolean> {
+  const key = await getApiKey(context);
+  return !!key;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/diagnostics/diagnosticMarkers.ts.html b/tests/coverage/src/diagnostics/diagnosticMarkers.ts.html new file mode 100644 index 0000000..1829b33 --- /dev/null +++ b/tests/coverage/src/diagnostics/diagnosticMarkers.ts.html @@ -0,0 +1,244 @@ + + + + + + Code coverage report for src/diagnostics/diagnosticMarkers.ts + + + + + + + + + +
+
+

All files / src/diagnostics diagnosticMarkers.ts

+
+ +
+ 90.56% + Statements + 48/53 +
+ + +
+ 100% + Branches + 13/13 +
+ + +
+ 75% + Functions + 6/8 +
+ + +
+ 90.56% + Lines + 48/53 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +542x +2x +2x +2x +2x +  +  +  +2x +7x +7x +7x +6x +6x +6x +6x +1x +1x +2x +6x +6x +7x +7x +7x +7x +5x +7x +7x +7x +7x +7x +6x +6x +2x +2x +2x +2x +2x +1x +1x +2x +2x +  +  +2x +2x +37x +37x +2x +2x +2x +2x +2x + 
import * as vscode from 'vscode';
+import type { LinterDiagnostic } from '../types';
+ 
+const PLUGIN_NAME = 'Code Purifier';
+ 
+export function isMarkersEnabled(): boolean {
+  return vscode.workspace.getConfiguration('vscode-code-reviewer').get<boolean>('markers.enabled', true);
+}
+ 
+function formatDiagnosticMessage(d: LinterDiagnostic): string {
+  const sepIndex = d.ruleId.indexOf(':');
+  if (sepIndex > 0) {
+    const linter = d.ruleId.slice(0, sepIndex);
+    const rule = d.ruleId.slice(sepIndex + 1);
+    return `[${PLUGIN_NAME} · ${linter}] ${rule}: ${d.message}`;
+  }
+  return `[${PLUGIN_NAME}] ${d.ruleId}: ${d.message}`;
+}
+ 
+export function toVscodeDiagnostics(diagnostics: LinterDiagnostic[]): vscode.Diagnostic[] {
+  return diagnostics.map(d => {
+    const severity =
+      d.severity === 'error'
+        ? vscode.DiagnosticSeverity.Error
+        : d.severity === 'warning'
+          ? vscode.DiagnosticSeverity.Warning
+          : vscode.DiagnosticSeverity.Information;
+    const diag = new vscode.Diagnostic(d.range, formatDiagnosticMessage(d), severity);
+    diag.source = PLUGIN_NAME;
+    diag.code = d.ruleId;
+    return diag;
+  });
+}
+ 
+export class DiagnosticMarkers {
+  private collection: vscode.DiagnosticCollection;
+ 
+  constructor() {
+    this.collection = vscode.languages.createDiagnosticCollection('codeReviewer');
+  }
+ 
+  apply(uri: vscode.Uri, diagnostics: LinterDiagnostic[]): void {
+    this.collection.set(uri, toVscodeDiagnostics(diagnostics));
+  }
+ 
+  clear(uri: vscode.Uri): void {
+    this.collection.delete(uri);
+  }
+ 
+  dispose(): void {
+    this.collection.dispose();
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/diagnostics/index.html b/tests/coverage/src/diagnostics/index.html new file mode 100644 index 0000000..146e83c --- /dev/null +++ b/tests/coverage/src/diagnostics/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/diagnostics + + + + + + + + + +
+
+

All files src/diagnostics

+
+ +
+ 90.56% + Statements + 48/53 +
+ + +
+ 100% + Branches + 13/13 +
+ + +
+ 75% + Functions + 6/8 +
+ + +
+ 90.56% + Lines + 48/53 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
diagnosticMarkers.ts +
+
90.56%48/53100%13/1375%6/890.56%48/53
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/extension.ts.html b/tests/coverage/src/extension.ts.html new file mode 100644 index 0000000..f3c5164 --- /dev/null +++ b/tests/coverage/src/extension.ts.html @@ -0,0 +1,517 @@ + + + + + + Code coverage report for src/extension.ts + + + + + + + + + +
+
+

All files / src extension.ts

+
+ +
+ 76.38% + Statements + 110/144 +
+ + +
+ 46.66% + Branches + 7/15 +
+ + +
+ 80% + Functions + 4/5 +
+ + +
+ 76.38% + Lines + 110/144 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +1451x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +26x +26x +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +1x +  +  +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +11x +11x +11x +11x +1x +1x +1x +1x +1x +31x +  +1x +1x +1x +1x +1x +26x +26x +1x +1x +1x +1x +1x +1x +1x +  +1x +1x +1x +1x +1x +  +  +  +  +1x +1x +1x +1x +1x +1x +  +  +1x +1x +1x +1x + 
import * as vscode from 'vscode';
+import { Orchestrator } from './orchestrator/orchestrator';
+import { registerCommands } from './activation/commands';
+import { SetupViewProvider } from './views/setupView';
+import { setLanguage, t, type Language } from './i18n/messages';
+import { getAIOutputLanguage } from './config';
+import { ReviewStatusCache } from './scope/status-cache';
+import { MethodCodeLensProvider } from './views/codeLensProvider';
+import { DiagnosticMarkers, isMarkersEnabled } from './diagnostics/diagnosticMarkers';
+import { FixCodeActionProvider } from './fix/codeActionProvider';
+import { FixSessionManager } from './fix/fixSession';
+import { FixPendingStore } from './fix/fixPending';
+import { registerFixPreviewProvider } from './fix/fixPreview';
+ 
+let orchestrator: Orchestrator;
+let markers: DiagnosticMarkers;
+ 
+async function runStaticAndApply(document: vscode.TextDocument): Promise<void> {
+  try {
+    const version = document.version;
+    const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
+    const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
+    const result = await orchestrator.runStaticAnalysis(document, workingDir);
+    const current = vscode.workspace.textDocuments.find(d => d.uri.toString() === document.uri.toString());
+    if (!current || current.version !== version) { return; }
+    markers.apply(document.uri, result.diagnostics);
+  } catch (err) {
+    console.error('[code-reviewer] static analysis failed:', err);
+  }
+}
+ 
+const analysisTimers = new Map<string, NodeJS.Timeout>();
+ 
+function scheduleAnalysis(document: vscode.TextDocument, delay: number): void {
+  if (document.uri.scheme !== 'file' || !isMarkersEnabled()) { return; }
+  const key = document.uri.toString();
+  const existing = analysisTimers.get(key);
+  if (existing) { clearTimeout(existing); }
+
+  const timer = setTimeout(async () => {
+    analysisTimers.delete(key);
+    await runStaticAndApply(document);
+  }, delay);
+
+  analysisTimers.set(key, timer);
+}
+ 
+async function analyzeOpenDocuments(): Promise<void> {
+  const docs = vscode.workspace.textDocuments;
+  const active = vscode.window.activeTextEditor?.document;
+  const ordered = [...docs].sort((a, b) => a === active ? -1 : b === active ? 1 : 0);
+  for (const doc of ordered) {
+    if (doc.uri.scheme !== 'file' || !isMarkersEnabled()) { continue; }
+    await runStaticAndApply(doc);
+  }
+}
+ 
+export function activate(context: vscode.ExtensionContext) {
+  const lang = getAIOutputLanguage() as Language;
+  setLanguage(lang);
+  console.log(t('extension.activated'));
+ 
+  orchestrator = new Orchestrator();
+  markers = new DiagnosticMarkers();
+  context.subscriptions.push(markers);
+ 
+  const setupProvider = new SetupViewProvider(context);
+  context.subscriptions.push(
+    vscode.window.registerWebviewViewProvider('codeReviewer.setupView', setupProvider)
+  );
+ 
+  const statusCache = new ReviewStatusCache();
+  const codeLensProvider = new MethodCodeLensProvider(statusCache);
+ 
+  const fixSession = new FixSessionManager();
+  const pendingStore = new FixPendingStore();
+  registerFixPreviewProvider(context);
+ 
+  context.subscriptions.push(
+    vscode.languages.registerCodeActionsProvider(
+      { scheme: 'file' },
+      new FixCodeActionProvider(orchestrator),
+      { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }
+    )
+  );
+ 
+  void analyzeOpenDocuments();
+ 
+  context.subscriptions.push(
+    vscode.languages.registerCodeLensProvider(
+      { scheme: 'file' },
+      codeLensProvider
+    )
+  );
+ 
+  context.subscriptions.push(
+    vscode.workspace.onDidCloseTextDocument((document) => {
+      statusCache.clearDocument(document.uri);
+      markers.clear(document.uri);
+      fixSession.clear(document.uri);
+      pendingStore.clear(document.fileName);
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.workspace.onDidOpenTextDocument((document) => {
+      if (document.uri.scheme !== 'file' || !isMarkersEnabled()) { return; }
+      void runStaticAndApply(document);
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.workspace.onDidChangeTextDocument((event) => {
+      markers.clear(event.document.uri);
+      scheduleAnalysis(event.document, 1000);
+    })
+  );
+ 
+  registerCommands(context, orchestrator, codeLensProvider, statusCache, markers, fixSession, pendingStore);
+ 
+  context.subscriptions.push(
+    vscode.workspace.onDidSaveTextDocument((document) => {
+      scheduleAnalysis(document, 500);
+    })
+  );
+ 
+  context.subscriptions.push(
+    vscode.workspace.onDidChangeConfiguration(e => {
+      if (e.affectsConfiguration('vscode-code-reviewer.ai.outputLanguage')) {
+        const newLang = getAIOutputLanguage() as Language;
+        setLanguage(newLang);
+      }
+    })
+  );
+}
+ 
+export function deactivate() {
+  for (const timer of analysisTimers.values()) {
+    clearTimeout(timer);
+  }
+  analysisTimers.clear();
+  markers?.dispose();
+  orchestrator = undefined!;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/aiFixEngine.ts.html b/tests/coverage/src/fix/aiFixEngine.ts.html new file mode 100644 index 0000000..2023283 --- /dev/null +++ b/tests/coverage/src/fix/aiFixEngine.ts.html @@ -0,0 +1,616 @@ + + + + + + Code coverage report for src/fix/aiFixEngine.ts + + + + + + + + + +
+
+

All files / src/fix aiFixEngine.ts

+
+ +
+ 72.88% + Statements + 129/177 +
+ + +
+ 69.23% + Branches + 27/39 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 72.88% + Lines + 129/177 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +1782x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +6x +6x +6x +6x +6x +6x +4x +6x +  +  +5x +5x +5x +5x +1x +1x +2x +3x +3x +3x +3x +3x +3x +3x +3x +1x +1x +1x +1x +1x +2x +2x +2x +2x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +4x +4x +5x +5x +5x +1x +1x +4x +4x +5x +1x +1x +3x +3x +3x +5x +  +  +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +5x +2x +2x +2x +5x +  +  +  +5x +2x +4x +  +  +2x +4x +  +  +2x +4x +  +  +2x +2x +2x +2x +2x +2x +2x +2x +4x +  +  +2x +2x +2x + 
import * as vscode from 'vscode';
+import type { AIProvider, ChatOptions } from '../ai/providers/base';
+import { chatWithRetry, parseJsonResponse } from '../ai/engine';
+import type { LinterAdapter, LinterDiagnostic } from '../types';
+import { mockDocument } from '../utils/mockDocument';
+import { buildFixSystemPrompt, buildFixUserPrompt, buildFixContext, type ReviewIssueInput } from './fixPrompt';
+import type { AppliedFix, FixResult } from './fixEngine';
+ 
+interface AiCodeFix {
+  originalText: string;
+  newText: string;
+}
+ 
+async function requestFix(
+  provider: AIProvider,
+  options: ChatOptions,
+  diag: LinterDiagnostic,
+  context: string
+): Promise<AiCodeFix | null> {
+  const issueInput: ReviewIssueInput = {
+    ruleId: diag.ruleId,
+    line: diag.range.start.line,
+    message: diag.message,
+    suggestion: diag.suggestion,
+  };
+  const attempt = async (): Promise<AiCodeFix | null> => {
+    try {
+      const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(issueInput, context), options);
+      const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
+      const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
+      const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
+      if (originalText.trim() === '') { return null; }
+      return { originalText, newText };
+    } catch {
+      return null;
+    }
+  };
+ 
+  const first = await attempt();
+  if (first) { return first; }
+  return attempt();
+}
+ 
+function sameRuleAtRegion(
+  diagnostics: LinterDiagnostic[],
+  ruleId: string,
+  start: number,
+  end: number,
+  mock: vscode.TextDocument
+): boolean {
+  for (const d of diagnostics) {
+    if (d.ruleId !== ruleId) { continue; }
+    const dStart = mock.offsetAt(d.range.start);
+    const dEnd = mock.offsetAt(d.range.end);
+    if (dStart < end && dEnd > start) { return true; }
+  }
+  return false;
+}
+ 
+export async function aiFixDiagnostic(
+  document: vscode.TextDocument,
+  workingDir: string,
+  adapter: LinterAdapter,
+  diag: LinterDiagnostic,
+  maxIterations: number,
+  provider: AIProvider,
+  options: ChatOptions,
+  dryRun?: boolean
+): Promise<FixResult> {
+  const originalText = document.getText();
+  let currentText = originalText;
+  const appliedFixes: AppliedFix[] = [];
+  let converged = false;
+ 
+  const pre = diag.aiFix;
+  if (pre?.originalText && pre?.newText) {
+    const startIndex = currentText.indexOf(pre.originalText);
+    if (startIndex !== -1) {
+      const endIndex = startIndex + pre.originalText.length;
+      const nextText = currentText.slice(0, startIndex) + pre.newText + currentText.slice(endIndex);
+      if (nextText !== currentText) {
+        appliedFixes.push({
+          originalText: pre.originalText,
+          newText: pre.newText,
+          line: diag.range.start.line,
+        });
+        currentText = nextText;
+        converged = true;
+      }
+    }
+  }
+ 
+  if (converged) {
+    if (currentText === originalText) {
+      return { success: true, attempts: 0, appliedFixes };
+    }
+    if (dryRun) {
+      return { success: true, attempts: 0, appliedFixes, newText: currentText };
+    }
+    const edit = new vscode.WorkspaceEdit();
+    const fullRange = new vscode.Range(
+      document.positionAt(0),
+      document.positionAt(originalText.length)
+    );
+    edit.replace(document.uri, fullRange, currentText);
+    const applied = await vscode.workspace.applyEdit(edit);
+    if (!applied) {
+      return { success: false, attempts: 0, message: 'apply-failed', appliedFixes };
+    }
+    return { success: true, attempts: 0, appliedFixes };
+  }
+ 
+  for (let round = 1; round <= maxIterations; round++) {
+    const context = buildFixContext(currentText, diag.range.start.line);
+    const fix = await requestFix(provider, options, diag, context);
+    if (!fix || fix.originalText.trim() === '') {
+      return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
+    }
+ 
+    const startIndex = currentText.indexOf(fix.originalText);
+    if (startIndex === -1) {
+      return { success: false, attempts: round, message: 'ai-match-failed', appliedFixes };
+    }
+ 
+    const endIndex = startIndex + fix.originalText.length;
+    const nextText = currentText.slice(0, startIndex) + fix.newText + currentText.slice(endIndex);
+    if (nextText === currentText) {
+      return { success: false, attempts: round, message: 'no-change', appliedFixes };
+    }
+ 
+    appliedFixes.push({
+      originalText: fix.originalText,
+      newText: fix.newText,
+      line: diag.range.start.line,
+    });
+    currentText = nextText;
+ 
+    try {
+      const mock = mockDocument(currentText, document.languageId, document.fileName);
+      const verify = await adapter.check(mock, workingDir);
+      const fixedStart = startIndex;
+      const fixedEnd = startIndex + fix.newText.length;
+      if (!sameRuleAtRegion(verify.diagnostics, diag.ruleId, fixedStart, fixedEnd, mock)) {
+        converged = true;
+        break;
+      }
+    } catch {
+      converged = false;
+      break;
+    }
+  }
+ 
+  if (!converged) {
+    return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
+  }
+ 
+  if (currentText === originalText) {
+    return { success: true, attempts: 0, appliedFixes };
+  }
+ 
+  if (dryRun) {
+    return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
+  }
+ 
+  const edit = new vscode.WorkspaceEdit();
+  const fullRange = new vscode.Range(
+    document.positionAt(0),
+    document.positionAt(originalText.length)
+  );
+  edit.replace(document.uri, fullRange, currentText);
+  const applied = await vscode.workspace.applyEdit(edit);
+  if (!applied) {
+    return { success: false, attempts: maxIterations, message: 'apply-failed', appliedFixes };
+  }
+ 
+  return { success: true, attempts: maxIterations, appliedFixes };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/codeActionProvider.ts.html b/tests/coverage/src/fix/codeActionProvider.ts.html new file mode 100644 index 0000000..6dc43ab --- /dev/null +++ b/tests/coverage/src/fix/codeActionProvider.ts.html @@ -0,0 +1,214 @@ + + + + + + Code coverage report for src/fix/codeActionProvider.ts + + + + + + + + + +
+
+

All files / src/fix codeActionProvider.ts

+
+ +
+ 18.6% + Statements + 8/43 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 66.66% + Functions + 2/3 +
+ + +
+ 18.6% + Lines + 8/43 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +441x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
import * as vscode from 'vscode';
+import type { Orchestrator } from '../orchestrator/orchestrator';
+ 
+export class FixCodeActionProvider implements vscode.CodeActionProvider {
+  constructor(private orchestrator: Orchestrator) {}
+ 
+  provideCodeActions(
+    document: vscode.TextDocument,
+    _range: vscode.Range,
+    context: vscode.CodeActionContext,
+    _token: vscode.CancellationToken
+  ): vscode.CodeAction[] {
+    const cached = this.orchestrator.getAnalysisResult(document.uri);
+    if (!cached) { return []; }
+
+    const actions: vscode.CodeAction[] = [];
+    for (const diag of cached.diagnostics) {
+      if (!diag.fix) { continue; }
+      const overlapsContext = context.diagnostics.some(d =>
+        diag.range.intersection(d.range)
+      );
+      if (!overlapsContext) { continue; }
+
+      const action = new vscode.CodeAction(
+        `Code Purifier: 修复 ${diag.ruleId}`,
+        vscode.CodeActionKind.QuickFix
+      );
+      action.command = {
+        command: 'codeReviewer.fixIssue',
+        title: '修复',
+        arguments: [{
+          line: diag.range.start.line,
+          ruleId: diag.ruleId,
+          source: 'linter',
+          origin: 'hover',
+        }],
+      };
+      action.diagnostics = [...context.diagnostics];
+      actions.push(action);
+    }
+    return actions;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/customFixEngine.ts.html b/tests/coverage/src/fix/customFixEngine.ts.html new file mode 100644 index 0000000..3760231 --- /dev/null +++ b/tests/coverage/src/fix/customFixEngine.ts.html @@ -0,0 +1,652 @@ + + + + + + Code coverage report for src/fix/customFixEngine.ts + + + + + + + + + +
+
+

All files / src/fix customFixEngine.ts

+
+ +
+ 61.9% + Statements + 117/189 +
+ + +
+ 61.11% + Branches + 22/36 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 61.9% + Lines + 117/189 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +1902x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +6x +6x +6x +6x +6x +6x +6x +7x +7x +7x +7x +7x +7x +5x +7x +  +  +6x +6x +6x +6x +1x +1x +2x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +4x +  +  +4x +2x +2x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +5x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +5x +5x +6x +6x +6x +1x +1x +1x +5x +5x +6x +1x +1x +1x +4x +4x +4x +6x +  +  +  +4x +4x +4x +4x +4x +4x +4x +4x +4x +6x +6x +2x +2x +2x +6x +3x +5x +1x +1x +1x +  +  +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +5x +  +  +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import * as vscode from 'vscode';
+import type { AIProvider, ChatOptions } from '../ai/providers/base';
+import { chatWithRetry, parseJsonResponse } from '../ai/engine';
+import { buildFixSystemPrompt, buildFixUserPrompt, buildFixContext, buildVerifySystemPrompt, buildVerifyUserPrompt, type ReviewIssueInput } from './fixPrompt';
+import type { AppliedFix, FixResult } from './fixEngine';
+ 
+interface AiCodeFix {
+  originalText: string;
+  newText: string;
+}
+ 
+interface AiVerifyResult {
+  fixed: boolean;
+  reason?: string;
+}
+ 
+async function requestFix(
+  provider: AIProvider,
+  options: ChatOptions,
+  diag: ReviewIssueInput,
+  context: string
+): Promise<AiCodeFix | null> {
+  const attempt = async (): Promise<AiCodeFix | null> => {
+    try {
+      const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(diag, context), options);
+      const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
+      const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
+      const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
+      if (originalText.trim() === '') { return null; }
+      return { originalText, newText };
+    } catch {
+      return null;
+    }
+  };
+ 
+  const first = await attempt();
+  if (first) { return first; }
+  return attempt();
+}
+ 
+async function verifyFixed(
+  provider: AIProvider,
+  options: ChatOptions,
+  diag: ReviewIssueInput,
+  code: string
+): Promise<AiVerifyResult> {
+  try {
+    const response = await chatWithRetry(provider, buildVerifySystemPrompt(), buildVerifyUserPrompt(diag, code), options);
+    const parsed = parseJsonResponse(response) as Partial<AiVerifyResult> & { fixed?: unknown };
+    const f = parsed.fixed;
+    const fixedValue = typeof f === 'string' ? f : String(f);
+    return { fixed: f === true || fixedValue === 'true', reason: parsed.reason };
+  } catch {
+    return { fixed: false };
+  }
+}
+ 
+export async function aiFixReviewIssue(
+  document: vscode.TextDocument,
+  diag: ReviewIssueInput,
+  maxIterations: number,
+  provider: AIProvider,
+  options: ChatOptions,
+  dryRun?: boolean
+): Promise<FixResult> {
+  const originalText = document.getText();
+  let currentText = originalText;
+  const appliedFixes: AppliedFix[] = [];
+  let converged = false;
+ 
+  const pre = diag.fix;
+  if (pre?.originalText && pre?.newText) {
+    const startIndex = currentText.indexOf(pre.originalText);
+    if (startIndex !== -1) {
+      const endIndex = startIndex + pre.originalText.length;
+      const nextText = currentText.slice(0, startIndex) + pre.newText + currentText.slice(endIndex);
+      if (nextText !== currentText) {
+        appliedFixes.push({
+          originalText: pre.originalText,
+          newText: pre.newText,
+          line: diag.line,
+        });
+        currentText = nextText;
+        converged = true;
+      }
+    }
+  }
+ 
+  if (converged) {
+    if (currentText === originalText) {
+      return { success: true, attempts: 0, appliedFixes };
+    }
+    if (dryRun) {
+      return { success: true, attempts: 0, appliedFixes, newText: currentText };
+    }
+    const edit = new vscode.WorkspaceEdit();
+    const fullRange = new vscode.Range(
+      document.positionAt(0),
+      document.positionAt(originalText.length)
+    );
+    edit.replace(document.uri, fullRange, currentText);
+    const applied = await vscode.workspace.applyEdit(edit);
+    if (!applied) {
+      return { success: false, attempts: 0, message: 'apply-failed', appliedFixes };
+    }
+    return { success: true, attempts: 0, appliedFixes };
+  }
+ 
+  for (let round = 1; round <= maxIterations; round++) {
+    const context = buildFixContext(currentText, diag.line);
+    const fix = await requestFix(provider, options, diag, context);
+    if (!fix || fix.originalText.trim() === '') {
+      console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'ai-no-fix');
+      return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
+    }
+ 
+    const startIndex = currentText.indexOf(fix.originalText);
+    if (startIndex === -1) {
+      console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'ai-match-failed');
+      return { success: false, attempts: round, message: 'ai-match-failed', appliedFixes };
+    }
+ 
+    const endIndex = startIndex + fix.originalText.length;
+    const nextText = currentText.slice(0, startIndex) + fix.newText + currentText.slice(endIndex);
+    if (nextText === currentText) {
+      console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'no-change');
+      return { success: false, attempts: round, message: 'no-change', appliedFixes };
+    }
+ 
+    appliedFixes.push({
+      originalText: fix.originalText,
+      newText: fix.newText,
+      line: diag.line,
+    });
+    currentText = nextText;
+ 
+    const verify = await verifyFixed(provider, options, diag, currentText);
+    console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'applied', fix.newText.slice(0, 60), 'verify', verify.fixed, verify.reason ?? '');
+    if (verify.fixed) {
+      converged = true;
+      break;
+    }
+  }
+ 
+  if (!converged) {
+    if (appliedFixes.length > 0) {
+      console.log('[code-reviewer] review-fix', diag.ruleId, 'accept-last-fix', appliedFixes.length);
+      if (currentText === originalText) {
+        return { success: true, attempts: 0, appliedFixes };
+      }
+      if (dryRun) {
+        return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
+      }
+      const edit = new vscode.WorkspaceEdit();
+      const fullRange = new vscode.Range(
+        document.positionAt(0),
+        document.positionAt(originalText.length)
+      );
+      edit.replace(document.uri, fullRange, currentText);
+      const applied = await vscode.workspace.applyEdit(edit);
+      if (!applied) {
+        return { success: false, attempts: maxIterations, message: 'apply-failed', appliedFixes };
+      }
+      return { success: true, attempts: maxIterations, appliedFixes };
+    }
+    return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
+  }
+ 
+  if (currentText === originalText) {
+    return { success: true, attempts: 0, appliedFixes };
+  }
+ 
+  if (dryRun) {
+    return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
+  }
+
+  const edit = new vscode.WorkspaceEdit();
+  const fullRange = new vscode.Range(
+    document.positionAt(0),
+    document.positionAt(originalText.length)
+  );
+  edit.replace(document.uri, fullRange, currentText);
+  const applied = await vscode.workspace.applyEdit(edit);
+  if (!applied) {
+    return { success: false, attempts: maxIterations, message: 'apply-failed', appliedFixes };
+  }
+
+  return { success: true, attempts: maxIterations, appliedFixes };
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/fixEngine.ts.html b/tests/coverage/src/fix/fixEngine.ts.html new file mode 100644 index 0000000..332c9a7 --- /dev/null +++ b/tests/coverage/src/fix/fixEngine.ts.html @@ -0,0 +1,508 @@ + + + + + + Code coverage report for src/fix/fixEngine.ts + + + + + + + + + +
+
+

All files / src/fix fixEngine.ts

+
+ +
+ 89.36% + Statements + 126/141 +
+ + +
+ 68.75% + Branches + 22/32 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 89.36% + Lines + 126/141 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +1422x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +3x +3x +3x +3x +3x +3x +3x +3x +10x +3x +10x +2x +2x +2x +10x +3x +3x +2x +2x +2x +2x +2x +2x +2x +2x +6x +1x +6x +6x +2x +2x +2x +2x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +  +  +3x +3x +3x +1x +1x +2x +2x +2x +2x +2x +3x +  +  +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +3x +  +  +  +2x +2x +2x +2x +3x +2x +3x +  +  +2x +3x +  +  +2x +3x +  +  +2x +2x +2x +2x +2x +2x +2x +2x +3x +  +  +2x +2x +2x + 
import * as vscode from 'vscode';
+import type { LinterAdapter, LinterDiagnostic } from '../types';
+import { mockDocument } from '../utils/mockDocument';
+ 
+export interface AppliedFix {
+  originalText: string;
+  newText: string;
+  line: number;
+}
+ 
+export interface FixResult {
+  success: boolean;
+  attempts: number;
+  message?: string;
+  appliedFixes: AppliedFix[];
+  newText?: string;
+}
+ 
+function applyFixToText(text: string, fix: { range: [number, number]; text: string }): string {
+  const [start, end] = fix.range;
+  if (start < 0 || end < start || end > text.length) { return text; }
+  return text.slice(0, start) + fix.text + text.slice(end);
+}
+ 
+function findClosestFixable(
+  diagnostics: LinterDiagnostic[],
+  ruleId: string,
+  line: number
+): LinterDiagnostic | null {
+  let best: LinterDiagnostic | null = null;
+  let bestDist = Number.MAX_SAFE_INTEGER;
+  for (const d of diagnostics) {
+    if (d.ruleId !== ruleId || !d.fix) { continue; }
+    const dist = Math.abs(d.range.start.line - line);
+    if (dist < bestDist) {
+      bestDist = dist;
+      best = d;
+    }
+  }
+  return best;
+}
+ 
+function issueStillExists(
+  diagnostics: LinterDiagnostic[],
+  ruleId: string,
+  fixedStart: number,
+  fixedEnd: number
+): boolean {
+  for (const d of diagnostics) {
+    if (d.ruleId !== ruleId || !d.fix) { continue; }
+    const [s, e] = d.fix.range;
+    if (s < fixedEnd && e > fixedStart) { return true; }
+  }
+  return false;
+}
+ 
+export async function fixDiagnostic(
+  document: vscode.TextDocument,
+  workingDir: string,
+  adapter: LinterAdapter,
+  diag: LinterDiagnostic,
+  maxIterations: number,
+  dryRun?: boolean
+): Promise<FixResult> {
+  const originalText = document.getText();
+  let currentText = originalText;
+  let prevLine = diag.range.start.line;
+  let converged = false;
+  const appliedFixes: AppliedFix[] = [];
+ 
+  for (let round = 1; round <= maxIterations; round++) {
+    let result;
+    try {
+      const mock = mockDocument(currentText, document.languageId, document.fileName);
+      result = await adapter.check(mock, workingDir);
+    } catch {
+      return { success: false, attempts: round, message: 'lint-execution-failed', appliedFixes };
+    }
+ 
+    const target = findClosestFixable(result.diagnostics, diag.ruleId, prevLine);
+    if (!target) {
+      return { success: false, attempts: round, message: 'not-autofixable', appliedFixes };
+    }
+ 
+    const fix = target.fix!;
+    const [start, end] = fix.range;
+    const originalFragment = currentText.slice(start, end);
+    const nextText = applyFixToText(currentText, fix);
+    if (nextText === currentText) {
+      return { success: false, attempts: round, message: 'no-change', appliedFixes };
+    }
+ 
+    appliedFixes.push({
+      originalText: originalFragment,
+      newText: fix.text,
+      line: target.range.start.line,
+    });
+    currentText = nextText;
+    prevLine = target.range.start.line;
+ 
+    const fixedStart = start;
+    const fixedEnd = start + fix.text.length;
+ 
+    let verify;
+    try {
+      verify = await adapter.check(mockDocument(currentText, document.languageId, document.fileName), workingDir);
+    } catch {
+      converged = false;
+      break;
+    }
+    if (!issueStillExists(verify.diagnostics, diag.ruleId, fixedStart, fixedEnd)) {
+      converged = true;
+      break;
+    }
+  }
+ 
+  if (!converged) {
+    return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
+  }
+ 
+  if (currentText === originalText) {
+    return { success: true, attempts: 0, appliedFixes };
+  }
+ 
+  if (dryRun) {
+    return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
+  }
+ 
+  const edit = new vscode.WorkspaceEdit();
+  const fullRange = new vscode.Range(
+    document.positionAt(0),
+    document.positionAt(originalText.length)
+  );
+  edit.replace(document.uri, fullRange, currentText);
+  const applied = await vscode.workspace.applyEdit(edit);
+  if (!applied) {
+    return { success: false, attempts: maxIterations, message: 'apply-failed', appliedFixes };
+  }
+ 
+  return { success: true, attempts: maxIterations, appliedFixes };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/fixPending.ts.html b/tests/coverage/src/fix/fixPending.ts.html new file mode 100644 index 0000000..69a927e --- /dev/null +++ b/tests/coverage/src/fix/fixPending.ts.html @@ -0,0 +1,316 @@ + + + + + + Code coverage report for src/fix/fixPending.ts + + + + + + + + + +
+
+

All files / src/fix fixPending.ts

+
+ +
+ 70.12% + Statements + 54/77 +
+ + +
+ 75% + Branches + 3/4 +
+ + +
+ 27.27% + Functions + 3/11 +
+ + +
+ 70.12% + Lines + 54/77 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +781x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +22x +22x +22x +1x +1x +1x +1x +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +11x +11x +  +  +11x +11x +1x + 
import * as vscode from 'vscode';
+import type { AppliedFix } from './fixEngine';
+ 
+export interface PendingFix {
+  key: string;
+  ruleId: string;
+  line: number;
+  filePath: string;
+  source: 'linter' | 'custom' | 'ai';
+  originalText: string;
+  newText: string;
+  appliedFixes: AppliedFix[];
+  diffUri?: vscode.Uri;
+}
+ 
+export interface PendingBatch {
+  filePath: string;
+  source: 'linter' | 'custom' | 'ai';
+  originalText: string;
+  newText: string;
+  results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[];
+  diffUri?: vscode.Uri;
+}
+ 
+function fileKey(filePath: string): string {
+  return `file:${filePath}`;
+}
+ 
+export class FixPendingStore {
+  private singles = new Map<string, PendingFix>();
+  private batches = new Map<string, PendingBatch>();
+ 
+  setSingle(fix: PendingFix): void {
+    this.singles.set(fileKey(fix.filePath) + '|' + fix.key, fix);
+  }
+ 
+  getSingle(filePath: string, key: string): PendingFix | undefined {
+    return this.singles.get(fileKey(filePath) + '|' + key);
+  }
+ 
+  hasSingle(filePath: string, key: string): boolean {
+    return this.singles.has(fileKey(filePath) + '|' + key);
+  }
+ 
+  deleteSingle(filePath: string, key: string): void {
+    this.singles.delete(fileKey(filePath) + '|' + key);
+  }
+ 
+  singleKeys(filePath: string): string[] {
+    const prefix = fileKey(filePath) + '|';
+    const keys: string[] = [];
+    for (const k of this.singles.keys()) {
+      if (k.startsWith(prefix)) { keys.push(k.slice(prefix.length)); }
+    }
+    return keys;
+  }
+ 
+  setBatch(batch: PendingBatch): void {
+    this.batches.set(fileKey(batch.filePath), batch);
+  }
+ 
+  getBatch(filePath: string): PendingBatch | undefined {
+    return this.batches.get(fileKey(filePath));
+  }
+ 
+  deleteBatch(filePath: string): void {
+    this.batches.delete(fileKey(filePath));
+  }
+ 
+  clear(filePath: string): void {
+    const prefix = fileKey(filePath) + '|';
+    for (const k of this.singles.keys()) {
+      if (k.startsWith(prefix)) { this.singles.delete(k); }
+    }
+    this.batches.delete(fileKey(filePath));
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/fixPreview.ts.html b/tests/coverage/src/fix/fixPreview.ts.html new file mode 100644 index 0000000..70605e8 --- /dev/null +++ b/tests/coverage/src/fix/fixPreview.ts.html @@ -0,0 +1,325 @@ + + + + + + Code coverage report for src/fix/fixPreview.ts + + + + + + + + + +
+
+

All files / src/fix fixPreview.ts

+
+ +
+ 42.5% + Statements + 34/80 +
+ + +
+ 66.66% + Branches + 2/3 +
+ + +
+ 25% + Functions + 2/8 +
+ + +
+ 42.5% + Lines + 34/80 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +811x +1x +1x +1x +1x +1x +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import * as vscode from 'vscode';
+ 
+const scheme = 'codeReviewerPreview';
+ 
+class FixPreviewContentProvider implements vscode.TextDocumentContentProvider {
+  private texts = new Map<string, string>();
+ 
+  provideTextDocumentContent(uri: vscode.Uri): string {
+    return this.texts.get(uri.toString()) ?? '';
+  }
+ 
+  set(uri: vscode.Uri, text: string): void {
+    this.texts.set(uri.toString(), text);
+  }
+ 
+  clear(uri: vscode.Uri): void {
+    this.texts.delete(uri.toString());
+  }
+}
+ 
+let previewProvider: FixPreviewContentProvider | null = null;
+ 
+export function registerFixPreviewProvider(context: vscode.ExtensionContext): void {
+  if (previewProvider) { return; }
+  previewProvider = new FixPreviewContentProvider();
+  context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(scheme, previewProvider));
+}
+ 
+export interface PreviewRequest {
+  originalText: string;
+  newText: string;
+  title: string;
+  fileName: string;
+}
+ 
+export async function openPreviewDiff(req: PreviewRequest): Promise<vscode.Uri | undefined> {
+  if (!previewProvider) { return undefined; }
+  const stamp = Date.now();
+  const originalUri = vscode.Uri.parse(`${scheme}://original/${encodeURIComponent(req.fileName)}-${stamp}`);
+  const newUri = vscode.Uri.parse(`${scheme}://new/${encodeURIComponent(req.fileName)}-${stamp}`);
+  previewProvider.set(originalUri, req.originalText);
+  previewProvider.set(newUri, req.newText);
+
+  await vscode.commands.executeCommand('vscode.diff', originalUri, newUri, req.title, {
+    viewColumn: vscode.ViewColumn.Beside,
+    preserveFocus: true,
+  });
+  return newUri;
+}
+ 
+export async function closePreviewEditor(uri: vscode.Uri): Promise<void> {
+  if (!previewProvider) { return; }
+  for (const group of vscode.window.tabGroups.all) {
+    for (const tab of group.tabs) {
+      if (tab.input instanceof vscode.TabInputTextDiff) {
+        const modified = tab.input.modified;
+        if (modified.toString() === uri.toString()) {
+          await vscode.window.tabGroups.close(tab);
+        }
+      }
+    }
+  }
+  previewProvider.clear(uri);
+}
+ 
+export async function applyNewText(
+  document: vscode.TextDocument,
+  newText: string
+): Promise<boolean> {
+  const originalText = document.getText();
+  if (newText === originalText) { return true; }
+
+  const edit = new vscode.WorkspaceEdit();
+  const fullRange = new vscode.Range(
+    document.positionAt(0),
+    document.positionAt(originalText.length)
+  );
+  edit.replace(document.uri, fullRange, newText);
+  return vscode.workspace.applyEdit(edit);
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/fixPrompt.ts.html b/tests/coverage/src/fix/fixPrompt.ts.html new file mode 100644 index 0000000..41b3aeb --- /dev/null +++ b/tests/coverage/src/fix/fixPrompt.ts.html @@ -0,0 +1,361 @@ + + + + + + Code coverage report for src/fix/fixPrompt.ts + + + + + + + + + +
+
+

All files / src/fix fixPrompt.ts

+
+ +
+ 100% + Statements + 92/92 +
+ + +
+ 61.53% + Branches + 16/26 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 92/92 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +932x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +16x +16x +16x +1x +1x +1x +1x +1x +1x +1x +1x +16x +1x +1x +1x +1x +1x +1x +1x +1x +14x +14x +14x +14x +14x +14x +14x +14x +2x +16x +16x +16x +16x +16x +16x +16x +16x +16x +8x +8x +16x +16x +16x +2x +13x +13x +13x +13x +13x +13x +39x +39x +13x +13x +2x +7x +7x +7x +1x +1x +1x +1x +7x +1x +1x +1x +1x +5x +5x +5x +5x +2x +5x +5x +5x +5x +5x +5x +5x +5x +5x + 
import type { LinterDiagnostic, AiFixSnippet } from '../types';
+import { getLanguage } from '../i18n/messages';
+ 
+export interface ReviewIssueInput {
+  ruleId: string;
+  line: number;
+  message: string;
+  suggestion?: string;
+  fix?: AiFixSnippet;
+}
+ 
+export function buildFixSystemPrompt(): string {
+  const lang = getLanguage();
+  if (lang === 'ja') {
+    return `あなたはシニアコード修正の専門家です。与えられた問題とコードコンテキストから、最小で正しい修正を生成してください。
+JSONのみを出力:{ "originalText": "置換対象の原文(コードコンテキスト内に完全一致すること)", "newText": "修正後の新コード" }
+要件:
+- originalText は提供されたコードコンテキスト内に逐語的に存在し、正確な空白・インデントも含むこと
+- 指定された問題のみ修正し、無関係なコードは変更しないこと
+- コードスタイルとインデントを維持すること
+- 必ず修正コードを出力すること。空の修正を出力しないこと。問題を完全に解消できない場合でも、問題を緩和・改善する最小のコード片を出力すること`;
+  }
+  if (lang === 'en') {
+    return `You are a senior code fixer. Given the code issue and context, produce the minimal correct fix.
+Output JSON only: { "originalText": "the exact original snippet to replace (must be found verbatim in the code context)", "newText": "the fixed replacement snippet" }
+Requirements:
+- originalText must exist verbatim in the provided code context, including exact whitespace and indentation
+- Fix only the reported issue; do not modify unrelated code
+- Preserve code style and indentation
+- Always output a fix snippet; never output an empty fix. Even if the issue cannot be fully resolved, output the minimal snippet that mitigates or improves it`;
+  }
+  return `你是资深代码修复专家。根据给定的代码问题与上下文,给出最小且正确的修复。
+仅输出 JSON:{ "originalText": "需要被替换的原文片段(必须在代码上下文中逐字存在,含精确的前后空白与缩进)", "newText": "修复后的新代码片段" }
+要求:
+- originalText 必须在提供的代码上下文中逐字存在,包含精确的缩进与前后空白
+- 只修复指定问题,不要改动无关代码
+- 保持代码风格与缩进
+- 必须输出修复片段,禁止输出空修复;即使无法完全消除问题,也要给出能缓解/改善问题的最小代码片段`;
+}
+ 
+export function buildFixUserPrompt(diag: ReviewIssueInput, context: string): string {
+  const lang = getLanguage();
+  const issueLabel = lang === 'ja' ? '問題' : lang === 'en' ? 'Issue' : '问题';
+  const suggestionLabel = lang === 'ja' ? '参考提案' : lang === 'en' ? 'Reference suggestion' : '参考建议';
+  const contextLabel = lang === 'ja' ? 'コードコンテキスト(行番号付き)' : lang === 'en' ? 'Code context (with line numbers)' : '代码上下文(带行号)';
+ 
+  const parts: string[] = [];
+  parts.push(`## ${issueLabel}\n[${diag.ruleId}] ${diag.message}`);
+  if (diag.suggestion && diag.suggestion.trim() !== '') {
+    parts.push(`## ${suggestionLabel}\n${diag.suggestion}`);
+  }
+  parts.push(`## ${contextLabel}\n${context}`);
+  return parts.join('\n\n');
+}
+ 
+export function buildFixContext(code: string, line: number): string {
+  const lines = code.split('\n');
+  const start = Math.max(0, line - 6);
+  const end = Math.min(lines.length - 1, line + 6);
+  const out: string[] = [];
+  for (let i = start; i <= end; i++) {
+    out.push(`${String(i + 1).padStart(4, ' ')}| ${lines[i]}`);
+  }
+  return out.join('\n');
+}
+ 
+export function buildVerifySystemPrompt(): string {
+  const lang = getLanguage();
+  if (lang === 'ja') {
+    return `あなたはコードレビュアーです。修正後のコードに指定された問題がまだ存在するか確認してください。
+JSONのみを出力:{ "fixed": true|false, "reason": "まだ残る場合の理由" }
+問題が完全に解消されていれば "fixed": true、まだ残っていれば "fixed": false を返してください。`;
+  }
+  if (lang === 'en') {
+    return `You are a code reviewer. Check whether the reported issue still exists in the fixed code.
+Output JSON only: { "fixed": true|false, "reason": "reason if it still remains" }
+Return "fixed": true if the issue is fully resolved, otherwise "fixed": false.`;
+  }
+  return `你是代码审查员。检查修复后的代码中指定问题是否仍然存在。
+仅输出 JSON:{ "fixed": true|false, "reason": "如果问题仍存在的原因" }
+问题已完全解决返回 "fixed": true,仍存在返回 "fixed": false。`;
+}
+ 
+export function buildVerifyUserPrompt(diag: ReviewIssueInput, code: string): string {
+  const lang = getLanguage();
+  const issueLabel = lang === 'ja' ? '問題' : lang === 'en' ? 'Issue' : '问题';
+  const codeLabel = lang === 'ja' ? '修正後コード' : lang === 'en' ? 'Fixed code' : '修复后代码';
+  const parts: string[] = [];
+  parts.push(`## ${issueLabel}\n[${diag.ruleId}] ${diag.message}`);
+  parts.push(`## ${codeLabel}\n${code}`);
+  return parts.join('\n\n');
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/fixSession.ts.html b/tests/coverage/src/fix/fixSession.ts.html new file mode 100644 index 0000000..6784750 --- /dev/null +++ b/tests/coverage/src/fix/fixSession.ts.html @@ -0,0 +1,478 @@ + + + + + + Code coverage report for src/fix/fixSession.ts + + + + + + + + + +
+
+

All files / src/fix fixSession.ts

+
+ +
+ 91.6% + Statements + 120/131 +
+ + +
+ 76.19% + Branches + 16/21 +
+ + +
+ 100% + Functions + 10/10 +
+ + +
+ 91.6% + Lines + 120/131 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +1322x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +3x +3x +3x +2x +2x +2x +2x +2x +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +1x +1x +  +1x +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +6x +6x +2x +2x +3x +3x +2x +2x +3x +3x +2x +2x +3x +3x +3x +5x +5x +3x +3x +2x +2x +12x +12x +2x +2x +12x +2x +2x +3x +3x +3x +3x +1x +3x +2x +2x +2x +2x +2x +2x +2x +2x +3x +3x +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x + 
import * as vscode from 'vscode';
+import type { AppliedFix } from './fixEngine';
+ 
+export interface FixedEntry {
+  key: string;
+  ruleId: string;
+  line: number;
+  fixes: AppliedFix[];
+  source: 'linter' | 'custom' | 'ai';
+}
+ 
+function keyOf(ruleId: string, line: number): string {
+  return `${ruleId}@${line}`;
+}
+ 
+interface LocatedEdit {
+  start: number;
+  end: number;
+  text: string;
+}
+ 
+function locateNewText(document: vscode.TextDocument, fix: AppliedFix): number {
+  const text = document.getText();
+  const lines = text.split('\n');
+  let index = -1;
+ 
+  if (fix.newText !== '') {
+    const firstLineOfNew = fix.newText.split('\n')[0];
+    if (fix.line >= 0 && fix.line < lines.length && lines[fix.line].includes(firstLineOfNew)) {
+      const offset = document.offsetAt(new vscode.Position(fix.line, 0));
+      index = text.indexOf(fix.newText, offset);
+    }
+    if (index === -1) {
+      index = text.indexOf(fix.newText);
+    }
+    return index;
+  }
+
+  if (fix.line >= 0 && fix.line < lines.length) {
+    const offset = document.offsetAt(new vscode.Position(fix.line, 0));
+    const lineEnd = text.indexOf('\n', offset);
+    const end = lineEnd === -1 ? text.length : lineEnd;
+    index = offset + lines[fix.line].search(/\S|$/);
+    if (index > end) { index = offset; }
+  }
+  return index;
+}
+ 
+export class FixSessionManager {
+  private fixedEntries = new Map<string, FixedEntry>();
+ 
+  add(uri: vscode.Uri, entry: FixedEntry): void {
+    this.fixedEntries.set(uri.toString() + '|' + entry.key, entry);
+  }
+ 
+  get(uri: vscode.Uri, key: string): FixedEntry | undefined {
+    return this.fixedEntries.get(uri.toString() + '|' + key);
+  }
+ 
+  has(uri: vscode.Uri, key: string): boolean {
+    return this.fixedEntries.has(uri.toString() + '|' + key);
+  }
+ 
+  getEntries(uri: vscode.Uri): FixedEntry[] {
+    const prefix = uri.toString() + '|';
+    const result: FixedEntry[] = [];
+    for (const [k, v] of this.fixedEntries) {
+      if (k.startsWith(prefix)) { result.push(v); }
+    }
+    return result;
+  }
+ 
+  clear(uri: vscode.Uri): void {
+    const prefix = uri.toString() + '|';
+    for (const k of this.fixedEntries.keys()) {
+      if (k.startsWith(prefix)) { this.fixedEntries.delete(k); }
+    }
+  }
+ 
+  recordFixes(uri: vscode.Uri, ruleId: string, line: number, fixes: AppliedFix[], source: 'linter' | 'custom' | 'ai' = 'linter'): string {
+    const key = keyOf(ruleId, line);
+    const fullKey = uri.toString() + '|' + key;
+    const existing = this.fixedEntries.get(fullKey);
+    if (existing) {
+      existing.fixes.push(...fixes);
+    } else {
+      this.fixedEntries.set(fullKey, {
+        key,
+        ruleId,
+        line,
+        fixes: [...fixes],
+        source,
+      });
+    }
+    return key;
+  }
+ 
+  async undo(document: vscode.TextDocument, key: string): Promise<boolean> {
+    const entry = this.fixedEntries.get(document.uri.toString() + '|' + key);
+    if (!entry || entry.fixes.length === 0) { return false; }
+ 
+    const edits: LocatedEdit[] = [];
+    for (let i = entry.fixes.length - 1; i >= 0; i--) {
+      const fix = entry.fixes[i];
+      const index = locateNewText(document, fix);
+      if (index === -1) { return false; }
+      edits.push({
+        start: index,
+        end: index + fix.newText.length,
+        text: fix.originalText,
+      });
+    }
+ 
+    const workspaceEdit = new vscode.WorkspaceEdit();
+    for (const e of edits) {
+      workspaceEdit.replace(
+        document.uri,
+        new vscode.Range(
+          document.positionAt(e.start),
+          document.positionAt(e.end)
+        ),
+        e.text
+      );
+    }
+    const applied = await vscode.workspace.applyEdit(workspaceEdit);
+    if (applied) {
+      this.fixedEntries.delete(document.uri.toString() + '|' + key);
+    }
+    return applied;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/fix/index.html b/tests/coverage/src/fix/index.html new file mode 100644 index 0000000..e265a33 --- /dev/null +++ b/tests/coverage/src/fix/index.html @@ -0,0 +1,221 @@ + + + + + + Code coverage report for src/fix + + + + + + + + + +
+
+

All files src/fix

+
+ +
+ 73.11% + Statements + 680/930 +
+ + +
+ 67.48% + Branches + 110/163 +
+ + +
+ 69.38% + Functions + 34/49 +
+ + +
+ 73.11% + Lines + 680/930 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
aiFixEngine.ts +
+
72.88%129/17769.23%27/39100%4/472.88%129/177
codeActionProvider.ts +
+
18.6%8/43100%2/266.66%2/318.6%8/43
customFixEngine.ts +
+
61.9%117/18961.11%22/36100%4/461.9%117/189
fixEngine.ts +
+
89.36%126/14168.75%22/32100%4/489.36%126/141
fixPending.ts +
+
70.12%54/7775%3/427.27%3/1170.12%54/77
fixPreview.ts +
+
42.5%34/8066.66%2/325%2/842.5%34/80
fixPrompt.ts +
+
100%92/9261.53%16/26100%5/5100%92/92
fixSession.ts +
+
91.6%120/13176.19%16/21100%10/1091.6%120/131
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/i18n/index.html b/tests/coverage/src/i18n/index.html new file mode 100644 index 0000000..79cc378 --- /dev/null +++ b/tests/coverage/src/i18n/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/i18n + + + + + + + + + +
+
+

All files src/i18n

+
+ +
+ 99.79% + Statements + 1434/1437 +
+ + +
+ 91.66% + Branches + 11/12 +
+ + +
+ 80% + Functions + 4/5 +
+ + +
+ 99.79% + Lines + 1434/1437 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
messages.ts +
+
99.79%1434/143791.66%11/1280%4/599.79%1434/1437
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/i18n/messages.ts.html b/tests/coverage/src/i18n/messages.ts.html new file mode 100644 index 0000000..06df3b4 --- /dev/null +++ b/tests/coverage/src/i18n/messages.ts.html @@ -0,0 +1,4396 @@ + + + + + + Code coverage report for src/i18n/messages.ts + + + + + + + + + +
+
+

All files / src/i18n messages.ts

+
+ +
+ 99.79% + Statements + 1434/1437 +
+ + +
+ 91.66% + Branches + 11/12 +
+ + +
+ 80% + Functions + 4/5 +
+ + +
+ 99.79% + Lines + 1434/1437 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +1239 +1240 +1241 +1242 +1243 +1244 +1245 +1246 +1247 +1248 +1249 +1250 +1251 +1252 +1253 +1254 +1255 +1256 +1257 +1258 +1259 +1260 +1261 +1262 +1263 +1264 +1265 +1266 +1267 +1268 +1269 +1270 +1271 +1272 +1273 +1274 +1275 +1276 +1277 +1278 +1279 +1280 +1281 +1282 +1283 +1284 +1285 +1286 +1287 +1288 +1289 +1290 +1291 +1292 +1293 +1294 +1295 +1296 +1297 +1298 +1299 +1300 +1301 +1302 +1303 +1304 +1305 +1306 +1307 +1308 +1309 +1310 +1311 +1312 +1313 +1314 +1315 +1316 +1317 +1318 +1319 +1320 +1321 +1322 +1323 +1324 +1325 +1326 +1327 +1328 +1329 +1330 +1331 +1332 +1333 +1334 +1335 +1336 +1337 +1338 +1339 +1340 +1341 +1342 +1343 +1344 +1345 +1346 +1347 +1348 +1349 +1350 +1351 +1352 +1353 +1354 +1355 +1356 +1357 +1358 +1359 +1360 +1361 +1362 +1363 +1364 +1365 +1366 +1367 +1368 +1369 +1370 +1371 +1372 +1373 +1374 +1375 +1376 +1377 +1378 +1379 +1380 +1381 +1382 +1383 +1384 +1385 +1386 +1387 +1388 +1389 +1390 +1391 +1392 +1393 +1394 +1395 +1396 +1397 +1398 +1399 +1400 +1401 +1402 +1403 +1404 +1405 +1406 +1407 +1408 +1409 +1410 +1411 +1412 +1413 +1414 +1415 +1416 +1417 +1418 +1419 +1420 +1421 +1422 +1423 +1424 +1425 +1426 +1427 +1428 +1429 +1430 +1431 +1432 +1433 +1434 +1435 +1436 +1437 +14382x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +924x +924x +924x +924x +32x +63x +63x +32x +924x +924x +2x +40x +40x +19x +19x +19x +2x +  +  +  +2x +62x +62x +62x +2x +1x +1x +1x + 
import * as vscode from 'vscode';
+ 
+export type Language = 'zh-CN' | 'en' | 'ja';
+ 
+const defaultLang: Language = 'zh-CN';
+ 
+const messages: Record<string, Record<Language, string>> = {
+  'review.noEditor': {
+    'zh-CN': '请先打开一个文件',
+    en: 'Please open a file first',
+    ja: '最初にファイルを開いてください',
+  },
+  'review.noSelection': {
+    'zh-CN': '请先选中要审查的代码',
+    en: 'Please select code to review first',
+    ja: 'レビューするコードを選択してください',
+  },
+  'review.needApiKey': {
+    'zh-CN': '请先在设置面板中配置 API Key',
+    en: 'Please configure API Key in Setup first',
+    ja: '最初に設定パネルでAPIキーを設定してください',
+  },
+  'review.running': {
+    'zh-CN': '正在审查...',
+    en: 'Reviewing...',
+    ja: 'レビュー中...',
+  },
+  'review.staticAnalysis': {
+    'zh-CN': '运行静态分析...',
+    en: 'Running static analysis...',
+    ja: '静的解析を実行中...',
+  },
+  'review.aiReview': {
+    'zh-CN': '运行 AI 审查...',
+    en: 'Running AI review...',
+    ja: 'AIレビューを実行中...',
+  },
+  'review.reviewingSelection': {
+    'zh-CN': '审查选中代码...',
+    en: 'Reviewing selected code...',
+    ja: '選択したコードをレビュー中...',
+  },
+  'review.selectionComplete': {
+    'zh-CN': '选中代码审查完成: {0} 个问题',
+    en: 'Selection review complete: {0} issue(s)',
+    ja: '選択コードのレビュー完了: {0} 件の問題',
+  },
+  'fix.noFix': {
+    'zh-CN': '该问题无法自动修复',
+    en: 'This issue cannot be auto-fixed',
+    ja: 'この問題は自動修正できません',
+  },
+  'fix.failed': {
+    'zh-CN': '修复失败: {0}',
+    en: 'Fix failed: {0}',
+    ja: '修正に失敗しました: {0}',
+  },
+  'fix.applied': {
+    'zh-CN': '修复完成',
+    en: 'Fix applied',
+    ja: '修正を適用しました',
+  },
+  'fix.running': {
+    'zh-CN': '批量修复中...',
+    en: 'Fixing all issues...',
+    ja: '一括修正中...',
+  },
+  'fix.progress': {
+    'zh-CN': '修复进度',
+    en: 'Fix progress',
+    ja: '修正進捗',
+  },
+  'fix.allComplete': {
+    'zh-CN': '批量修复完成:成功 {0},跳过 {1}',
+    en: 'Batch fix complete: {0} fixed, {1} skipped',
+    ja: '一括修正完了: {0} 成功、{1} スキップ',
+  },
+  'fix.undone': {
+    'zh-CN': '已撤销修复',
+    en: 'Fix undone',
+    ja: '修正を取り消しました',
+  },
+  'fix.undoFailed': {
+    'zh-CN': '撤销失败,代码可能已被手动修改',
+    en: 'Undo failed, the code may have been modified manually',
+    ja: '取り消しに失敗しました。コードが手動で変更された可能性があります',
+  },
+  'fix.aiRunning': {
+    'zh-CN': 'AI 修复中...',
+    en: 'AI fixing...',
+    ja: 'AI修正中...',
+  },
+  'fix.aiFailed': {
+    'zh-CN': 'AI 修复失败: {0}',
+    en: 'AI fix failed: {0}',
+    ja: 'AI修正に失敗しました: {0}',
+  },
+  'fix.noAI': {
+    'zh-CN': '该问题需要 AI 修复,请先在设置面板配置 AI',
+    en: 'This issue needs AI fix, please configure AI in the Setup panel first',
+    ja: 'この問題はAI修正が必要です。設定パネルでAIを設定してください',
+  },
+  'fix.confirmApply': {
+    'zh-CN': '确认应用修复?',
+    en: 'Confirm applying the fix?',
+    ja: '修正を適用しますか?',
+  },
+  'fix.apply': {
+    'zh-CN': '应用',
+    en: 'Apply',
+    ja: '適用',
+  },
+  'fix.cancel': {
+    'zh-CN': '取消',
+    en: 'Cancel',
+    ja: 'キャンセル',
+  },
+  'fix.previewTitle': {
+    'zh-CN': '修复预览',
+    en: 'Fix preview',
+    ja: '修正プレビュー',
+  },
+ 
+  'export.needRunFirst': {
+    'zh-CN': '请先运行完整审查生成报告',
+    en: 'Run a full review first to generate a report',
+    ja: '最初に完全レビューを実行してレポートを生成してください',
+  },
+  'export.copyToClipboard': {
+    'zh-CN': '📋 复制到剪贴板',
+    en: '📋 Copy to Clipboard',
+    ja: '📋 クリップボードにコピー',
+  },
+  'export.copyDescription': {
+    'zh-CN': '将报告内容以 Markdown 格式复制到剪贴板',
+    en: 'Copy report in Markdown format to clipboard',
+    ja: 'レポートをMarkdown形式でクリップボードにコピー',
+  },
+  'export.downloadMarkdown': {
+    'zh-CN': '📄 下载 Markdown 文件',
+    en: '📄 Download Markdown File',
+    ja: '📄 Markdownファイルをダウンロード',
+  },
+  'export.saveDescription': {
+    'zh-CN': '将报告保存为 .md 文件',
+    en: 'Save report as .md file',
+    ja: 'レポートを.mdファイルとして保存',
+  },
+  'export.selectMethod': {
+    'zh-CN': '选择导出方式',
+    en: 'Select export method',
+    ja: 'エクスポート方法を選択',
+  },
+  'export.copied': {
+    'zh-CN': '报告已复制到剪贴板',
+    en: 'Report copied to clipboard',
+    ja: 'レポートをクリップボードにコピーしました',
+  },
+  'export.saveDialogTitle': {
+    'zh-CN': '保存审查报告',
+    en: 'Save Review Report',
+    ja: 'レビューレポートを保存',
+  },
+  'export.saved': {
+    'zh-CN': '报告已保存到 {0}',
+    en: 'Report saved to {0}',
+    ja: 'レポートを {0} に保存しました',
+  },
+ 
+  'setup.noWorkspace': {
+    'zh-CN': '请先打开工作区',
+    en: 'Please open a workspace first',
+    ja: '最初にワークスペースを開いてください',
+  },
+  'setup.manageRulesHint': {
+    'zh-CN': '请在设置面板中管理自定义规则',
+    en: 'Manage custom rules in the Setup panel',
+    ja: '設定パネルでカスタムルールを管理してください',
+  },
+  'setup.openSetupFail': {
+    'zh-CN': '无法打开设置面板',
+    en: 'Cannot open setup panel',
+    ja: '設定パネルを開けません',
+  },
+  'setup.openSettingsJson': {
+    'zh-CN': '打开设置 (JSON)',
+    en: 'Open Settings (JSON)',
+    ja: '設定を開く (JSON)',
+  },
+  'setup.setApiKeyFirst': {
+    'zh-CN': '请先设置 API Key',
+    en: 'Please set API Key first',
+    ja: '最初にAPIキーを設定してください',
+  },
+  'setup.setBaseUrlFirst': {
+    'zh-CN': '请先设置 Base URL',
+    en: 'Please set Base URL first',
+    ja: '最初にベースURLを設定してください',
+  },
+  'setup.testSuccess': {
+    'zh-CN': '✓ 连接成功',
+    en: '✓ Connection successful',
+    ja: '✓ 接続成功',
+  },
+  'setup.testFail': {
+    'zh-CN': '✗ 连接失败: {0}',
+    en: '✗ Connection failed: {0}',
+    ja: '✗ 接続失敗: {0}',
+  },
+  'setup.emptyResponse': {
+    'zh-CN': 'AI 返回空响应,请检查模型配置',
+    en: 'AI returned an empty response, please check the model configuration',
+    ja: 'AIが空の応答を返しました。モデル設定を確認してください',
+  },
+  'setup.selectRuleFile': {
+    'zh-CN': '选择规则文件',
+    en: 'Select rule file',
+    ja: 'ルールファイルを選択',
+  },
+  'setup.fileExists': {
+    'zh-CN': '文件 {0} 已存在',
+    en: 'File {0} already exists',
+    ja: 'ファイル {0} は既に存在します',
+  },
+  'setup.importSuccess': {
+    'zh-CN': '规则文件已导入: {0}',
+    en: 'Rule file imported: {0}',
+    ja: 'ルールファイルをインポートしました: {0}',
+  },
+  'setup.importing': {
+    'zh-CN': '正在导入自定义规则...',
+    en: 'Importing custom rules...',
+    ja: 'カスタムルールをインポート中...',
+  },
+  'setup.importCancelled': {
+    'zh-CN': '导入已取消',
+    en: 'Import cancelled',
+    ja: 'インポートをキャンセルしました',
+  },
+  'setup.importDedupResult': {
+    'zh-CN': '规则已导入: {0}({1} 条,{2} 条完全重复已注释,{3} 条部分重叠已标注)',
+    en: 'Rules imported: {0} ({1} total, {2} exact duplicate(s) commented, {3} partial overlap(s) marked)',
+    ja: 'ルールをインポートしました: {0}({1} 件、{2} 件の完全重複をコメントアウト、{3} 件の部分重複をマーク)',
+  },
+  'setup.importFail': {
+    'zh-CN': '规则导入失败: {0}',
+    en: 'Rule import failed: {0}',
+    ja: 'ルールのインポートに失敗しました: {0}',
+  },
+  'setup.header': {
+    'zh-CN': '净码特工 · 代码审查 · 设置',
+    en: 'Code Purifier · Setup',
+    ja: 'コードピュリファイア · 設定',
+  },
+  'setup.quickStart': {
+    'zh-CN': '快速开始',
+    en: 'Quick Start',
+    ja: 'クイックスタート',
+  },
+  'setup.gettingStarted': {
+    'zh-CN': '三步开始使用',
+    en: '3 Steps to Get Started',
+    ja: '3ステップで使い始める',
+  },
+  'setup.step1': {
+    'zh-CN': '插件已内置 <b>Linter 静态分析</b>,开箱即用,也可在共通规则中配置项目/全局规则',
+    en: 'The extension includes <b>built-in Linter static analysis</b>, ready to use out of the box, and you can also configure project/global rules in Common Rules',
+    ja: '拡張機能には<b>Linter静的解析</b>が組み込まれており、そのまま利用できるほか、共通ルールでプロジェクト/グローバルルールも設定できます',
+  },
+  'setup.step2': {
+    'zh-CN': '在 <b>自定义规则</b> 标签页导入团队编码规范,增强审查(可选)',
+    en: 'Import team coding standards in the <b>Custom Rules</b> tab to enhance reviews (optional)',
+    ja: '<b>カスタムルール</b>タブでチームのコーディング規約をインポート(任意)',
+  },
+  'setup.step3': {
+    'zh-CN': '配置 <b>AI 模型与 API Key</b> 并<b>保存并测试连接</b>,启用 AI 深度审查',
+    en: 'Configure <b>AI model & API Key</b> and <b>save & test the connection</b> to enable deep AI review',
+    ja: '<b>AIモデルとAPIキー</b>を設定し<b>保存して接続テスト</b>、AI詳細レビューを有効化',
+  },
+  'setup.step3Hint': {
+    'zh-CN': '按 <b>Ctrl + Shift + R</b> 触发完整审核,结果实时显示在<b>审核结果报告</b>页面',
+    en: 'Press <b>Ctrl + Shift + R</b> to run a full review, results appear in the <b>Review Report</b> panel',
+    ja: '<b>Ctrl + Shift + R</b> で完全なレビューを実行、結果は<b>レビューレポート</b>に表示',
+  },
+  'setup.aiConnectionConfig': {
+    'zh-CN': 'AI 连接配置',
+    en: 'AI Connection Config',
+    ja: 'AI接続設定',
+  },
+  'setup.engineSection': {
+    'zh-CN': '审核引擎',
+    en: 'Review Engines',
+    ja: 'レビューエンジン',
+  },
+  'setup.commonRules': {
+    'zh-CN': '共通规则',
+    en: 'Common Rules',
+    ja: '共通ルール',
+  },
+  'setup.linterStatic': {
+    'zh-CN': 'Linter 静态分析',
+    en: 'Linter Static Analysis',
+    ja: 'リンター静的解析',
+  },
+  'setup.customRules': {
+    'zh-CN': '自定义规则',
+    en: 'Custom Rules',
+    ja: 'カスタムルール',
+  },
+  'setup.teamCoding': {
+    'zh-CN': '团队编码规范',
+    en: 'Team Coding Standards',
+    ja: 'チームコーディング規約',
+  },
+  'setup.aiReview': {
+    'zh-CN': 'AI 审核',
+    en: 'AI Review',
+    ja: 'AIレビュー',
+  },
+  'setup.deepReview': {
+    'zh-CN': '深度代码审查',
+    en: 'Deep Code Review',
+    ja: '詳細コードレビュー',
+  },
+  'setup.aiConfig': {
+    'zh-CN': 'AI 模型配置',
+    en: 'AI Model Configuration',
+    ja: 'AIモデル設定',
+  },
+  'setup.provider': {
+    'zh-CN': '模型提供商',
+    en: 'Model Provider',
+    ja: 'モデルプロバイダー',
+  },
+  'setup.notConfigured': {
+    'zh-CN': '未配置',
+    en: 'Not configured',
+    ja: '未設定',
+  },
+  'setup.model': {
+    'zh-CN': '模型名称',
+    en: 'Model Name',
+    ja: 'モデル名',
+  },
+  'setup.modelPlaceholder': {
+    'zh-CN': '输入或选择模型名',
+    en: 'Enter or select model name',
+    ja: 'モデル名を入力または選択',
+  },
+  'setup.modelHint': {
+    'zh-CN': '建议使用支持结构化输出的模型。',
+    en: 'Use a model that supports structured output.',
+    ja: '構造化出力をサポートするモデルの使用を推奨。',
+  },
+  'setup.apiKey': {
+    'zh-CN': 'API Key',
+    en: 'API Key',
+    ja: 'APIキー',
+  },
+  'setup.baseUrl': {
+    'zh-CN': 'Base URL',
+    en: 'Base URL',
+    ja: 'ベースURL',
+  },
+  'setup.keyStorageHint': {
+    'zh-CN': 'Key 仅存储在本地 VS Code 安全存储中。',
+    en: 'Key is stored locally in VS Code secure storage.',
+    ja: 'キーはVS Codeの安全なストレージにのみ保存されます。',
+  },
+  'setup.outputLang': {
+    'zh-CN': '语言',
+    en: 'Language',
+    ja: '言語',
+  },
+  'setup.outputLangHint': {
+    'zh-CN': '更改插件的显示语言',
+    en: 'Change plugin display language',
+    ja: 'プラグインの表示言語を変更する',
+  },
+  'setup.customRulesSection': {
+    'zh-CN': '自定义规则',
+    en: 'Custom Rules',
+    ja: 'カスタムルール',
+  },
+  'setup.ruleList': {
+    'zh-CN': '规则列表',
+    en: 'Rule List',
+    ja: 'ルール一覧',
+  },
+  'setup.ruleCount': {
+    'zh-CN': '{0} 条',
+    en: '{0} rule(s)',
+    ja: '{0} 件',
+  },
+  'setup.ruleNamePlaceholder': {
+    'zh-CN': '输入规则名称...',
+    en: 'Enter rule name...',
+    ja: 'ルール名を入力...',
+  },
+  'setup.add': {
+    'zh-CN': '+ 添加',
+    en: '+ Add',
+    ja: '+ 追加',
+  },
+  'setup.ruleNameHint': {
+    'zh-CN': '建议使用英文名称,无需输入 .yaml 后缀(如 security-rules)',
+    en: 'Use English names, no .yaml suffix needed (e.g. security-rules)',
+    ja: '英語名を使用してください、.yaml拡張子は不要です(例: security-rules)',
+  },
+  'setup.ruleNameRequired': {
+    'zh-CN': '请输入规则名称',
+    en: 'Please enter a rule name',
+    ja: 'ルール名を入力してください',
+  },
+  'setup.reset': {
+    'zh-CN': '重置',
+    en: 'Reset',
+    ja: 'リセット',
+  },
+  'setup.saveAndTest': {
+    'zh-CN': '保存并测试连接',
+    en: 'Save & Test Connection',
+    ja: '保存して接続テスト',
+  },
+  'setup.configured': {
+    'zh-CN': '已配置',
+    en: 'Configured',
+    ja: '設定済み',
+  },
+  'setup.connected': {
+    'zh-CN': '✓ 已连接',
+    en: '✓ Connected',
+    ja: '✓ 接続済み',
+  },
+  'setup.retry': {
+    'zh-CN': '✗ 重试',
+    en: '✗ Retry',
+    ja: '✗ 再試行',
+  },
+  'setup.ruleCountFormat': {
+    'zh-CN': '{0} 个文件',
+    en: '{0} file(s)',
+    ja: '{0} ファイル',
+  },
+  'setup.noRuleFiles': {
+    'zh-CN': '暂无规则文件',
+    en: 'No rule files',
+    ja: 'ルールファイルなし',
+  },
+  'setup.adapter.subtitle': {
+    'zh-CN': '静态分析适配器',
+    en: 'Static Analysis Adapters',
+    ja: '静的解析アダプター',
+  },
+  'setup.adapter.modeBuiltin': {
+    'zh-CN': '内置规则',
+    en: 'Built-in Rules',
+    ja: '組み込みルール',
+  },
+  'setup.adapter.modeProject': {
+    'zh-CN': '项目配置',
+    en: 'Project Config',
+    ja: 'プロジェクト設定',
+  },
+  'setup.adapter.modeGlobal': {
+    'zh-CN': '全局配置',
+    en: 'Global Config',
+    ja: 'グローバル設定',
+  },
+  'setup.adapter.modeLegend': {
+    'zh-CN': '插件按 内置<全局<项目的优先级自动选择配置来源',
+    en: 'Auto-selects config by priority: Built-in < Global < Project',
+    ja: '優先順位に従って自動選択: 組み込み < グローバル < プロジェクト',
+  },
+  'setup.adapter.modeLegendHelp': {
+    'zh-CN': '三种配置来源的含义:\n· 内置规则:插件自带的规则集,开箱即用,无需配置\n· 全局配置:在 VS Code 设置中指定的文件路径,对所有项目生效\n· 项目配置:项目根目录下的配置文件,仅对当前项目生效\n\n优先级:内置 < 全局 < 项目,插件自动选择优先级最高的可用配置',
+    en: 'What the three config sources mean:\n· Built-in rules: bundled with the extension, work out of the box\n· Global config: a file path set in VS Code settings, applies to all projects\n· Project config: a config file in the project root, applies to the current project only\n\nPriority: Built-in < Global < Project; the highest-priority available source is used',
+    ja: '3つの設定ソースの意味:\n· 組み込みルール:拡張機能に同梱のルール、設定不要ですぐに使用可\n· グローバル設定:VS Code設定で指定したファイルパス、全プロジェクトに適用\n· プロジェクト設定:プロジェクト直下の設定ファイル、現在のプロジェクトのみに適用\n\n優先度:組み込み < グローバル < プロジェクト、利用可能な中で最優先のものを自動選択',
+  },
+  'setup.adapter.configYes': {
+    'zh-CN': '已配置',
+    en: 'Configured',
+    ja: '設定済み',
+  },
+  'setup.adapter.configNo': {
+    'zh-CN': '未配置',
+    en: 'Not configured',
+    ja: '未設定',
+  },
+  'setup.adapter.langLabel': {
+    'zh-CN': '可审查的语言:',
+    en: 'Languages:',
+    ja: '対応言語:',
+  },
+  'setup.adapter.sqlfluffDialectLabel': {
+    'zh-CN': '方言',
+    en: 'Dialect',
+    ja: '方言',
+  },
+  'setup.adapter.tooltipTab': {
+    'zh-CN': '点击展开/收起静态分析适配器',
+    en: 'Click to expand/collapse static analysis adapters',
+    ja: 'クリックで静的解析アダプターを展開/折りたたむ',
+  },
+  'setup.adapter.btnCreateConfig': {
+    'zh-CN': '创建项目配置',
+    en: 'Create Project Config',
+    ja: 'プロジェクト設定を作成',
+  },
+  'setup.adapter.btnEditGlobal': {
+    'zh-CN': '修改全局设置',
+    en: 'Modify Global Settings',
+    ja: 'グローバル設定を変更',
+  },
+  'setup.adapter.tooltipCreate': {
+    'zh-CN': '在项目根目录创建 {0}',
+    en: 'Create {0} in project root',
+    ja: 'プロジェクトルートに {0} を作成',
+  },
+  'setup.adapter.tooltipEdit': {
+    'zh-CN': '修改 VS Code 设置中的全局参数',
+    en: 'Modify global parameters in VS Code settings',
+    ja: 'VS Code設定のグローバルパラメータを変更',
+  },
+  'setup.adapter.toggleEnable': {
+    'zh-CN': '启用 {0} 适配器',
+    en: 'Enable {0} adapter',
+    ja: '{0} アダプターを有効化',
+  },
+  'setup.adapter.toggleDisable': {
+    'zh-CN': '禁用 {0} 适配器',
+    en: 'Disable {0} adapter',
+    ja: '{0} アダプターを無効化',
+  },
+  'setup.template.pmdBestPractices': {
+    'zh-CN': 'Java 最佳实践(如:避免空 catch、关闭流等)',
+    en: 'Java best practices (avoid empty catch, close streams, etc.)',
+    ja: 'Javaベストプラクティス(空のcatch回避、ストリームクローズ等)',
+  },
+  'setup.template.pmdCodeStyle': {
+    'zh-CN': 'Java 代码风格(如:命名规范、花括号位置等)',
+    en: 'Java code style (naming conventions, brace placement, etc.)',
+    ja: 'Javaコードスタイル(命名規則、ブレース位置等)',
+  },
+  'setup.template.sqlfluffDialect': {
+    'zh-CN': '数据库方言:postgres / mysql / bigquery / snowflake 等',
+    en: 'Database dialect: postgres / mysql / bigquery / snowflake etc.',
+    ja: 'データベース方言:postgres / mysql / bigquery / snowflake など',
+  },
+  'setup.template.sqlfluffRules': {
+    'zh-CN': 'all = 启用全部规则,也可指定规则名逗号分隔',
+    en: 'all = enable all rules, or specify rule names separated by commas',
+    ja: 'all = すべてのルールを有効、ルール名をカンマ区切りで指定可',
+  },
+  'setup.template.eslintComment1': {
+    'zh-CN': '未使用的变量 → 警告',
+    en: 'Unused variables → warning',
+    ja: '未使用変数 → 警告',
+  },
+  'setup.template.eslintComment2': {
+    'zh-CN': '允许使用 console',
+    en: 'Allow console',
+    ja: 'consoleを許可',
+  },
+  'setup.template.eslintComment3': {
+    'zh-CN': '强制分号',
+    en: 'Enforce semicolons',
+    ja: 'セミコロンを強制',
+  },
+  'setup.template.stylelintComment1': {
+    'zh-CN': '缩进 2 空格',
+    en: 'Indentation: 2 spaces',
+    ja: 'インデント: 2スペース',
+  },
+  'setup.template.stylelintComment2': {
+    'zh-CN': '禁止空规则',
+    en: 'No empty rules',
+    ja: '空ルールを禁止',
+  },
+  'setup.adapter.pmdLanguages': {
+    'zh-CN': 'Java(含 JSP 中的 Java 代码,例如<% ... %>)',
+    en: 'Java (including Java code in JSP, e.g. <% ... %>)',
+    ja: 'Java(JSP内のJavaコードを含む、例: <% ... %>)',
+  },
+  'setup.adapter.pmdHelp': {
+    'zh-CN': 'PMD 由 Java 编写,需先安装 Java 运行环境(JDK 8+),否则无法执行。\n\n插件已内置一套 PMD 规则集(7 类 274 条),安装 Java 后即可开箱使用,无需额外配置。\n点击「创建项目配置」,会在项目根目录生成 ruleset.xml,内容与插件内置规则集完全一致(含每类规则的说明注释),方便查看与调整审查范围。\n保存文件即自动生效,无需重启。\n\n若多个项目共用同一份规则,可点「修改全局设置」,在 pmd.rulesetPath 中填写该文件路径',
+    en: 'PMD is written in Java, so a Java runtime (JDK 8+) must be installed first.\n\nThe extension ships a built-in PMD ruleset (7 categories, 274 rules), usable right after installing Java.\nClick "Create Project Config" to generate ruleset.xml in the project root, identical to the built-in ruleset (including per-category comment descriptions) for review and adjustment.\nChanges take effect on save, no restart needed.\n\nTo share one ruleset across projects, click "Modify Global Settings" and set pmd.rulesetPath',
+    ja: 'PMDはJava製のため、まずJava実行環境(JDK 8+)のインストールが必要です。\n\n拡張機能にはPMDルールセット(7カテゴリ・274ルール)が同梱されており、Java導入後すぐに利用できます。\n「プロジェクト設定を作成」をクリックすると、プロジェクト直下にruleset.xmlが生成されます。内容は組み込みルールセットと完全一致し(カテゴリごとの説明コメント付き)、確認・調整が可能です。\n保存後すぐに反映され、再起動は不要です。\n\n複数プロジェクトで同じルールを共有する場合、「グローバル設定を変更」でpmd.rulesetPathを設定してください',
+  },
+  'setup.adapter.sqlLanguages': {
+    'zh-CN': 'SQL',
+    en: 'SQL',
+    ja: 'SQL',
+  },
+  'setup.adapter.sqlHelp': {
+    'zh-CN': 'SQLFluff 是 Python 命令,需先安装 Python 环境与 sqlfluff(pip install sqlfluff),否则无法执行。\n\n插件已内置一套 SQLFluff 精选规则(26 项),安装后即可开箱使用,无需额外配置。\n点击「创建项目配置」,会在项目根目录生成 .sqlfluff,内容与插件内置配置一致(含每条规则的说明注释,以及 rules = all 的启用提示),方便查看与调整。\n保存文件即自动生效,无需重启。\n\n若多个项目共用同一份配置,可点「修改全局设置」填写全局路径',
+    en: 'SQLFluff is a Python command; install Python and sqlfluff (pip install sqlfluff) first.\n\nThe extension ships built-in SQLFluff curated rules (26 items), usable right after install.\nClick "Create Project Config" to generate .sqlfluff in the project root, identical to the built-in config (including per-rule comment descriptions and a rules = all hint) for review and adjustment.\nChanges take effect on save, no restart needed.\n\nTo share one config across projects, click "Modify Global Settings" and set the global path',
+    ja: 'SQLFluffはPython製コマンドのため、まずPython環境とsqlfluff(pip install sqlfluff)が必要です。\n\n拡張機能にはSQLFluff精選ルール(26項目)が同梱されており、インストール後すぐに利用できます。\n「プロジェクト設定を作成」をクリックすると、プロジェクト直下に.sqlfluffが生成されます。内容は組み込み設定と一致し(ルールごとの説明コメントとrules = allのヒント付き)、確認・調整が可能です。\n保存後すぐに反映され、再起動は不要です。\n\n複数プロジェクトで同じ設定を共有する場合、「グローバル設定を変更」でパスを設定してください',
+  },
+  'setup.adapter.eslintLanguages': {
+    'zh-CN': 'JS, TS, JSX, TSX(含 JSP 中的 JavaScript 代码,例如<script>)',
+    en: 'JS, TS, JSX, TSX (including JavaScript in JSP, e.g. <script>)',
+    ja: 'JS, TS, JSX, TSX(JSP内のJavaScriptコードを含む、例: <script>)',
+  },
+  'setup.adapter.eslintHelp': {
+    'zh-CN': 'ESLint 需项目先安装 eslint(npm install eslint),否则无法解析规则文件。\n\n插件已内置一套 ESLint 规则(基于 recommended),开箱即用,无需额外配置。\n点击「创建项目配置」,会在项目根目录生成 eslint.config.js,内容与插件内置规则一致(92 条 JS 规则,含逐条说明注释),零依赖即可运行。\n注意:生成文件仅包含 JS 内置规则;TS 项目如需 TS 专项规则,请自行安装 typescript-eslint 并按文件头注释示例追加。\n若多个项目共用同一份规则,可点「修改全局设置」,在 eslintConfigPath 中填写该文件路径',
+    en: 'ESLint must be installed in the project first (npm install eslint).\n\nThe extension ships built-in ESLint rules (based on recommended), usable out of the box.\nClick "Create Project Config" to generate eslint.config.js in the project root, identical to the built-in rules (92 JS rules with per-rule comment descriptions), running with zero extra dependencies.\nNote: the generated file only contains JS built-in rules; for TypeScript-specific rules, install typescript-eslint and follow the example in the file header comment.\nTo share one config across projects, click "Modify Global Settings" and set eslintConfigPath',
+    ja: 'プロジェクトで先にeslintをインストール(npm install eslint)しておく必要があります。\n\n拡張機能にはESLintルール(recommendedベース)が同梱されており、設定不要で利用できます。\n「プロジェクト設定を作成」をクリックすると、プロジェクト直下にeslint.config.jsが生成されます。内容は組み込みルールと一致し(JSルール92件・各ルールに説明コメント付き)、追加依存なしで動作します。\n注: 生成ファイルにはJS組み込みルールのみ含まれます。TS専用ルールが必要な場合は、typescript-eslintをインストールし、ファイル先頭のコメント例に従って追記してください。\n複数プロジェクトで同じルールを共有する場合、「グローバル設定を変更」でeslintConfigPathを設定してください',
+  },
+  'setup.adapter.stylelintLanguages': {
+    'zh-CN': 'CSS, SCSS, Less(含 JSP 中的 CSS 代码,例如<style>)',
+    en: 'CSS, SCSS, Less (including CSS in JSP, e.g. <style>)',
+    ja: 'CSS, SCSS, Less(JSP内のCSSコードを含む、例: <style>)',
+  },
+  'setup.adapter.stylelintHelp': {
+    'zh-CN': 'Stylelint 需项目先安装 stylelint(npm install stylelint),否则无法解析规则文件。\n\n插件已内置一套 Stylelint 规则(基于 recommended),开箱即用,无需额外配置。\n点击「创建项目配置」,会在项目根目录生成 .stylelintrc.js,内容与插件内置规则一致(68 条,含逐条说明注释),零依赖即可运行。\n若多个项目共用同一份规则,可点「修改全局设置」,在 stylelintConfigPath 中填写该文件路径',
+    en: 'Stylelint must be installed in the project first (npm install stylelint).\n\nThe extension ships built-in Stylelint rules (based on recommended), usable out of the box.\nClick "Create Project Config" to generate .stylelintrc.js in the project root, identical to the built-in rules (68 rules with per-rule comment descriptions), running with zero extra dependencies.\nTo share one config across projects, click "Modify Global Settings" and set stylelintConfigPath',
+    ja: 'プロジェクトで先にstylelintをインストール(npm install stylelint)しておく必要があります。\n\n拡張機能にはStylelintルール(recommendedベース)が同梱されており、設定不要で利用できます。\n「プロジェクト設定を作成」をクリックすると、プロジェクト直下に.stylelintrc.jsが生成されます。内容は組み込みルールと一致し(68件・各ルールに説明コメント付き)、追加依存なしで動作します。\n複数プロジェクトで同じルールを共有する場合、「グローバル設定を変更」でstylelintConfigPathを設定してください',
+  },
+  'setup.aiReviewStatusCapability': {
+    'zh-CN': '审查能力',
+    en: 'Capability',
+    ja: 'レビュー機能',
+  },
+  'setup.aiReviewCapability': {
+    'zh-CN': '修改建议 · 自定义规则审查 · 深度代码审查',
+    en: 'Fix Suggestions · Custom Rules Review · Deep Code Review',
+    ja: '修正提案 · カスタムルールレビュー · 詳細コードレビュー',
+  },
+  'setup.notConnected': {
+    'zh-CN': '未连接',
+    en: 'Not connected',
+    ja: '未接続',
+  },
+  'importPreview.title': {
+    'zh-CN': '规则导入预览',
+    en: 'Rule Import Preview',
+    ja: 'ルールインポートプレビュー',
+  },
+  'importPreview.source': {
+    'zh-CN': '来源:{0} · 检测到 {1} 条规则',
+    en: 'Source: {0} · {1} rule(s) detected',
+    ja: 'ソース: {0} · {1} 件のルールを検出',
+  },
+  'importPreview.keep': {
+    'zh-CN': '保留',
+    en: 'Keep',
+    ja: '保持',
+  },
+  'importPreview.comment': {
+    'zh-CN': '注释',
+    en: 'Comment Out',
+    ja: 'コメントアウト',
+  },
+  'importPreview.cancel': {
+    'zh-CN': '取消',
+    en: 'Cancel',
+    ja: 'キャンセル',
+  },
+  'importPreview.confirm': {
+    'zh-CN': '确认导入',
+    en: 'Confirm Import',
+    ja: 'インポートを確認',
+  },
+  'import.customRulePrefix': {
+    'zh-CN': '自定义规则',
+    en: 'Custom rule',
+    ja: 'カスタムルール',
+  },
+  'import.duplicateOf': {
+    'zh-CN': '重复:{0}',
+    en: 'Duplicate: {0}',
+    ja: '重複:{0}',
+  },
+  'import.overlapWith': {
+    'zh-CN': '与 {0} 部分重叠',
+    en: 'Overlap with {0}',
+    ja: '{0} と部分的に重複',
+  },
+  'import.overlapReason': {
+    'zh-CN': '重叠原因:{0}',
+    en: 'Overlap reason: {0}',
+    ja: '重複理由:{0}',
+  },
+  'import.dupExactTitle': {
+    'zh-CN': '完全重复',
+    en: 'Exact duplicate',
+    ja: '完全重複',
+  },
+  'import.dupOverlapTitle': {
+    'zh-CN': '部分重叠',
+    en: 'Partial overlap',
+    ja: '部分的重複',
+  },
+  'import.dupExactText': {
+    'zh-CN': '与规则「{0}」完全重复',
+    en: 'Exact duplicate of rule "{0}"',
+    ja: 'ルール「{0}」と完全重複',
+  },
+  'import.dupOverlapText': {
+    'zh-CN': '与规则「{0}」部分重叠',
+    en: 'Partially overlaps rule "{0}"',
+    ja: 'ルール「{0}」と部分的に重複',
+  },
+  'import.dupDescriptionLabel': {
+    'zh-CN': 'description:{0}',
+    en: 'description: {0}',
+    ja: 'description:{0}',
+  },
+  'import.dupExactHint': {
+    'zh-CN': '默认将注释导入,点击「保留」可恢复',
+    en: 'Imported as commented out by default; click "Keep" to restore',
+    ja: 'デフォルトでコメントアウトとしてインポートされます。「保持」をクリックすると復元します',
+  },
+  'import.badgeRestored': {
+    'zh-CN': '已恢复',
+    en: 'Restored',
+    ja: '復元済み',
+  },
+  'import.badgeWillComment': {
+    'zh-CN': '将注释',
+    en: 'Will comment out',
+    ja: 'コメントアウト予定',
+  },
+  'import.idLabel': {
+    'zh-CN': 'id(规则唯一标识)',
+    en: 'id (unique identifier)',
+    ja: 'id(一意識別子)',
+  },
+  'import.severityLabel': {
+    'zh-CN': 'severity(严重级别)',
+    en: 'severity',
+    ja: 'severity(重要度)',
+  },
+  'import.descriptionLabel': {
+    'zh-CN': 'description(规则描述)',
+    en: 'description',
+    ja: 'description(ルール説明)',
+  },
+  'import.messageLabel': {
+    'zh-CN': 'message(触发提示消息)',
+    en: 'message',
+    ja: 'message(トリガーメッセージ)',
+  },
+  'import.languagesLabel': {
+    'zh-CN': 'languages(适用语言)',
+    en: 'languages',
+    ja: 'languages(対象言語)',
+  },
+  'import.excludeLanguagesLabel': {
+    'zh-CN': 'excludeLanguages(排除语言)',
+    en: 'excludeLanguages',
+    ja: 'excludeLanguages(除外言語)',
+  },
+  'import.tagPlaceholder': {
+    'zh-CN': '输入语言名,Enter 添加',
+    en: 'Type language name, Enter to add',
+    ja: '言語名を入力、Enterで追加',
+  },
+  'import.ruleCount': {
+    'zh-CN': '{0} 条',
+    en: '{0}',
+    ja: '{0} 件',
+  },
+  'import.exactDuplicate': {
+    'zh-CN': '⛔ 完全重复 {0} 条',
+    en: '⛔ Exact duplicate {0}',
+    ja: '⛔ 完全重複 {0} 件',
+  },
+  'import.overlapDuplicate': {
+    'zh-CN': '⚠️ 部分重叠 {0} 条',
+    en: '⚠️ Partial overlap {0}',
+    ja: '⚠️ 部分的重複 {0} 件',
+  },
+  'import.noDuplicate': {
+    'zh-CN': '✅ 无重复 {0} 条',
+    en: '✅ No duplicate {0}',
+    ja: '✅ 重複なし {0} 件',
+  },
+  'import.statusBar': {
+    'zh-CN': '将保留 {0} 条规则,注释 {1} 条规则',
+    en: 'Keep {0} rule(s), comment out {1} rule(s)',
+    ja: '{0} 件保持、{1} 件コメントアウト',
+  },
+  'import.editedHint': {
+    'zh-CN': ' · 已编辑 {0} 条规则',
+    en: ' · Edited {0} rule(s)',
+    ja: ' · {0} 件を編集済み',
+  },
+  'import.sectionExact': {
+    'zh-CN': '完全重复',
+    en: 'Exact Duplicate',
+    ja: '完全重複',
+  },
+  'import.sectionOverlap': {
+    'zh-CN': '部分重叠',
+    en: 'Partial Overlap',
+    ja: '部分的重複',
+  },
+  'import.sectionNone': {
+    'zh-CN': '无重复',
+    en: 'No Duplicate',
+    ja: '重複なし',
+  },
+  'import.validationDescEmpty': {
+    'zh-CN': '规则 "{0}" 的 description 不能为空',
+    en: 'Description of rule "{0}" cannot be empty',
+    ja: 'ルール "{0}" の description は必須です',
+  },
+  'import.validationMsgEmpty': {
+    'zh-CN': '规则 "{0}" 的 message 不能为空',
+    en: 'Message of rule "{0}" cannot be empty',
+    ja: 'ルール "{0}" の message は必須です',
+  },
+  'import.placeholderIdHint': {
+    'zh-CN': '占位 ID,请修改为有意义的标识',
+    en: 'Placeholder ID, please change to a meaningful identifier',
+    ja: 'プレースホルダーID、意味のある識別子に変更してください',
+  },
+  'import.validationIdEmpty': {
+    'zh-CN': '规则 id 不能为空',
+    en: 'Rule id cannot be empty',
+    ja: 'ルールIDは必須です',
+  },
+  'import.idMissing': {
+    'zh-CN': 'id 缺失,请补充',
+    en: 'id missing, please fill in',
+    ja: 'id がありません、入力してください',
+  },
+  'import.severityMissing': {
+    'zh-CN': 'severity 缺失',
+    en: 'severity missing',
+    ja: 'severity が未設定',
+  },
+  'import.severitySelectHint': {
+    'zh-CN': '请选择 severity',
+    en: 'Select severity',
+    ja: 'severity を選択',
+  },
+ 
+  'report.panelTitle': {
+    'zh-CN': '净码特工 · 代码审查报告',
+    en: 'Code Purifier · Review Report',
+    ja: 'コードピュリファイア · レビューレポート',
+  },
+  'report.totalIssues': {
+    'zh-CN': '总计问题',
+    en: 'Total Issues',
+    ja: '問題の総数',
+  },
+  'report.errors': {
+    'zh-CN': '错误',
+    en: 'Errors',
+    ja: 'エラー',
+  },
+  'report.warnings': {
+    'zh-CN': '警告',
+    en: 'Warnings',
+    ja: '警告',
+  },
+  'report.info': {
+    'zh-CN': '建议',
+    en: 'Info',
+    ja: '情報',
+  },
+  'report.fixAll': {
+    'zh-CN': '全部修复',
+    en: 'Fix All',
+    ja: 'すべて修正',
+  },
+  'report.fixAllApply': {
+    'zh-CN': '全部应用',
+    en: 'Apply All',
+    ja: 'すべて適用',
+  },
+  'report.fixLabel': {
+    'zh-CN': '修复',
+    en: 'Fix',
+    ja: '修正',
+  },
+  'report.fixAILabel': {
+    'zh-CN': 'AI 修复',
+    en: 'AI Fix',
+    ja: 'AI修正',
+  },
+  'report.fixPreview': {
+    'zh-CN': '修复预览',
+    en: 'Fix Preview',
+    ja: '修正プレビュー',
+  },
+  'report.fixUnavailable': {
+    'zh-CN': '无预生成修复,点击修复将实时生成',
+    en: 'No pre-generated fix, click Fix to generate on demand',
+    ja: '事前生成された修正がありません。修正ボタンで生成します',
+  },
+  'report.fixedIssues': {
+    'zh-CN': '已修复',
+    en: 'Fixed',
+    ja: '修正済み',
+  },
+  'report.fixedLabel': {
+    'zh-CN': '已修复',
+    en: 'Fixed',
+    ja: '修正済み',
+  },
+  'report.undoFix': {
+    'zh-CN': '撤销',
+    en: 'Undo',
+    ja: '取り消し',
+  },
+  'report.rerun': {
+    'zh-CN': '重新审查',
+    en: 'Re-run Review',
+    ja: '再レビュー',
+  },
+  'report.export': {
+    'zh-CN': '导出报告',
+    en: 'Export Report',
+    ja: 'レポートをエクスポート',
+  },
+  'report.sourceLinter': {
+    'zh-CN': 'Linter',
+    en: 'Linter',
+    ja: 'リンター',
+  },
+  'report.sourceCustom': {
+    'zh-CN': '自定义',
+    en: 'Custom',
+    ja: 'カスタム',
+  },
+  'report.sourceAI': {
+    'zh-CN': 'AI',
+    en: 'AI',
+    ja: 'AI',
+  },
+  'report.noIssues': {
+    'zh-CN': '未发现任何问题',
+    en: 'No issues found',
+    ja: '問題は見つかりませんでした',
+  },
+  'report.noRuleViolations': {
+    'zh-CN': '未发现规则违规',
+    en: 'No rule violations found',
+    ja: 'ルール違反は見つかりませんでした',
+  },
+  'report.noAIFindings': {
+    'zh-CN': '无 AI 审查建议',
+    en: 'No AI review findings',
+    ja: 'AIレビューによる指摘はありません',
+  },
+  'report.skipCustomRules': {
+    'zh-CN': '当前文件语言无匹配的自定义规则,已跳过规则评估',
+    en: 'No custom rules match this file language, rule evaluation skipped',
+    ja: 'このファイル言語に一致するカスタムルールがありません、ルール評価をスキップしました',
+  },
+  'report.executionErrors': {
+    'zh-CN': '执行错误',
+    en: 'Execution Errors',
+    ja: '実行エラー',
+  },
+  'report.degradedBanner': {
+    'zh-CN': '部分 AI 功能不可用,报告已降级',
+    en: 'Some AI features unavailable, report degraded',
+    ja: '一部のAI機能が利用できません、レポートは縮退しています',
+  },
+ 
+  'report.title': {
+    'zh-CN': '代码审查报告',
+    en: 'Code Review Report',
+    ja: 'コードレビューレポート',
+  },
+  'report.file': {
+    'zh-CN': '文件',
+    en: 'File',
+    ja: 'ファイル',
+  },
+  'report.language': {
+    'zh-CN': '语言',
+    en: 'Language',
+    ja: '言語',
+  },
+  'report.duration': {
+    'zh-CN': '耗时',
+    en: 'Duration',
+    ja: '所要時間',
+  },
+  'report.tools': {
+    'zh-CN': '分析工具',
+    en: 'Analysis Tools',
+    ja: '解析ツール',
+  },
+  'report.totalSummary': {
+    'zh-CN': '总计: {0} | 错误: {1} | 警告: {2} | 建议: {3}',
+    en: 'Total: {0} | Errors: {1} | Warnings: {2} | Info: {3}',
+    ja: '合計: {0} | エラー: {1} | 警告: {2} | 情報: {3}',
+  },
+  'report.staticSection': {
+    'zh-CN': '静态分析 · {0} 个问题',
+    en: 'Static Analysis · {0} issue(s)',
+    ja: '静的解析 · {0} 件の問題',
+  },
+  'report.customSection': {
+    'zh-CN': '自定义规则 · {0} 个问题',
+    en: 'Custom Rules · {0} issue(s)',
+    ja: 'カスタムルール · {0} 件の問題',
+  },
+  'report.aiSection': {
+    'zh-CN': 'AI 审查 · {0} 条建议',
+    en: 'AI Review · {0} finding(s)',
+    ja: 'AIレビュー · {0} 件の指摘',
+  },
+  'report.suggestion': {
+    'zh-CN': '建议',
+    en: 'Suggestion',
+    ja: '提案',
+  },
+  'report.noProblems': {
+    'zh-CN': '未发现问题',
+    en: 'No issues found',
+    ja: '問題は見つかりませんでした',
+  },
+  'report.issuesCount': {
+    'zh-CN': '{0} 个问题',
+    en: '{0} issue(s)',
+    ja: '{0} 件の問題',
+  },
+  'report.itemsCount': {
+    'zh-CN': '{0} 条',
+    en: '{0} item(s)',
+    ja: '{0} 件',
+  },
+  'report.injectedCount': {
+    'zh-CN': '(注入 {0}/{1} 条)',
+    en: '(injected {0}/{1})',
+    ja: '(注入 {0}/{1} 件)',
+  },
+  'report.injectedRules': {
+    'zh-CN': '(注入 {0}/{1} 条规则)',
+    en: '(injected {0}/{1} rules)',
+    ja: '(注入 {0}/{1} 件ルール)',
+  },
+ 
+  'import.unsupportedFormat': {
+    'zh-CN': '不支持的文件格式: {0}',
+    en: 'Unsupported file format: {0}',
+    ja: 'サポートされていないファイル形式: {0}',
+  },
+  'import.conversionFailed': {
+    'zh-CN': '转换失败,未生成规则内容',
+    en: 'Conversion failed, no rule content generated',
+    ja: '変換に失敗しました、ルール内容が生成されませんでした',
+  },
+  'import.emptyFile': {
+    'zh-CN': '所选文件为空',
+    en: 'Selected file is empty',
+    ja: '選択したファイルは空です',
+  },
+  'import.needApiKey': {
+    'zh-CN': '请先在设置面板中配置 API Key',
+    en: 'Please configure API Key in Setup first',
+    ja: '最初に設定パネルでAPIキーを設定してください',
+  },
+  'import.timeout': {
+    'zh-CN': 'AI 生成规则超时,请检查网络或增大 ai.timeout 配置',
+    en: 'AI rule generation timed out, check network or increase ai.timeout',
+    ja: 'AIルール生成がタイムアウトしました、ネットワークを確認するかai.timeoutを増やしてください',
+  },
+  'import.aiFail': {
+    'zh-CN': 'AI 生成规则失败: {0}',
+    en: 'AI rule generation failed: {0}',
+    ja: 'AIルール生成に失敗しました: {0}',
+  },
+  'import.emptyResponse': {
+    'zh-CN': 'AI 返回内容为空',
+    en: 'AI returned empty response',
+    ja: 'AIが空の応答を返しました',
+  },
+  'import.excelReadFail': {
+    'zh-CN': '读取 Excel 文件失败: {0}',
+    en: 'Failed to read Excel file: {0}',
+    ja: 'Excelファイルの読み込みに失敗しました: {0}',
+  },
+  'import.excelEmpty': {
+    'zh-CN': 'Excel 文件没有工作表',
+    en: 'Excel file has no worksheets',
+    ja: 'Excelファイルにワークシートがありません',
+  },
+  'import.excelNoData': {
+    'zh-CN': 'Excel 工作表中没有数据',
+    en: 'No data in Excel worksheet',
+    ja: 'Excelワークシートにデータがありません',
+  },
+  'import.docxReadFail': {
+    'zh-CN': '读取 Word 文件失败: {0}',
+    en: 'Failed to read Word file: {0}',
+    ja: 'Wordファイルの読み込みに失敗しました: {0}',
+  },
+  'import.docxEmpty': {
+    'zh-CN': 'Word 文件中没有可提取的文本内容',
+    en: 'No extractable text in Word file',
+    ja: 'Wordファイルに抽出可能なテキストがありません',
+  },
+  'import.pptxReadFail': {
+    'zh-CN': '读取 PowerPoint 文件失败: {0}',
+    en: 'Failed to read PowerPoint file: {0}',
+    ja: 'PowerPointファイルの読み込みに失敗しました: {0}',
+  },
+  'import.pptxEmpty': {
+    'zh-CN': 'PowerPoint 文件中没有可提取的文本内容',
+    en: 'No extractable text in PowerPoint file',
+    ja: 'PowerPointファイルに抽出可能なテキストがありません',
+  },
+ 
+  'yaml.duplicateExact': {
+    'zh-CN': '# [DUPLICATE: exact] 重复 {0}(检测目标完全一致)',
+    en: '# [DUPLICATE: exact] duplicate of {0} (identical detection target)',
+    ja: '# [DUPLICATE: exact] {0} と重複(検出対象が完全に一致)',
+  },
+  'yaml.duplicateOverlap': {
+    'zh-CN': '# [DUPLICATE: overlap] 与 {0} 部分重叠',
+    en: '# [DUPLICATE: overlap] overlap with {0}',
+    ja: '# [DUPLICATE: overlap] {0} と部分的に重複',
+  },
+  'yaml.overlapReason': {
+    'zh-CN': '# 重叠原因:{0}',
+    en: '# Overlap reason: {0}',
+    ja: '# 重複理由:{0}',
+  },
+  'yaml.manualComment': {
+    'zh-CN': '# [手动注释] 用户选择不启用此规则',
+    en: '# [manual] User chose not to enable this rule',
+    ja: '# [手動コメント] ユーザーがこのルールを有効にしないことを選択',
+  },
+  'yaml.enableHint': {
+    'zh-CN': '# 如需启用,删除以下每行开头的 # 即可',
+    en: '# To enable, remove the leading # from each line below',
+    ja: '# 有効にするには、以下の各行の先頭の # を削除してください',
+  },
+  'yaml.duplicateExactCustom': {
+    'zh-CN': '# [DUPLICATE: exact] 重复自定义规则 {0}(检测目标完全一致)',
+    en: '# [DUPLICATE: exact] duplicate of custom rule {0} (identical detection target)',
+    ja: '# [DUPLICATE: exact] カスタムルール {0} と重複(検出対象が完全に一致)',
+  },
+  'yaml.duplicateExactGeneric': {
+    'zh-CN': '# [DUPLICATE: exact] 重复 {0}(检测目标完全一致)',
+    en: '# [DUPLICATE: exact] duplicate of {0} (identical detection target)',
+    ja: '# [DUPLICATE: exact] {0} と重複(検出対象が完全に一致)',
+  },
+  'yaml.duplicateOverlapCustom': {
+    'zh-CN': '# [DUPLICATE: {0}] 与自定义规则 {1} 部分重叠',
+    en: '# [DUPLICATE: {0}] overlap with custom rule {1}',
+    ja: '# [DUPLICATE: {0}] カスタムルール {1} と部分的に重複',
+  },
+  'yaml.duplicateOverlapGeneric': {
+    'zh-CN': '# [DUPLICATE: {0}] 与 {1} 部分重叠',
+    en: '# [DUPLICATE: {0}] overlap with {1}',
+    ja: '# [DUPLICATE: {0}] {1} と部分的に重複',
+  },
+ 
+  'engine.jsonNotFound': {
+    'zh-CN': '响应中未找到 JSON。原始响应(前200字符):{0}',
+    en: 'JSON not found in response. Raw response (first 200 chars): {0}',
+    ja: 'レスポンスにJSONが見つかりません。生のレスポンス(先頭200文字):{0}',
+  },
+  'engine.jsonParseFail': {
+    'zh-CN': 'JSON 解析失败。原始响应(前200字符):{0}',
+    en: 'JSON parse failed. Raw response (first 200 chars): {0}',
+    ja: 'JSON解析に失敗しました。生のレスポンス(先頭200文字):{0}',
+  },
+  'engine.emptyResponse': {
+    'zh-CN': 'AI 返回空响应',
+    en: 'AI returned an empty response',
+    ja: 'AIが空の応答を返しました',
+  },
+ 
+  'adapter.javaNotInstalled': {
+    'zh-CN': 'Java 11+ 未安装或不在 PATH 中',
+    en: 'Java 11+ not installed or not in PATH',
+    ja: 'Java 11+ がインストールされていないかPATHにありません',
+  },
+  'adapter.sqlfluffNotInstalled': {
+    'zh-CN': 'sqlfluff 未安装,请执行 pip install sqlfluff',
+    en: 'sqlfluff not installed, run: pip install sqlfluff',
+    ja: 'sqlfluffがインストールされていません、pip install sqlfluff を実行してください',
+  },
+  'adapter.sqlfluffPRS': {
+    'zh-CN': 'SQL 解析失败(当前方言:{0}),可能是方言不匹配或语法错误。请在设置中配置 sqlfluff.dialect 或添加项目 .sqlfluff 指定正确方言。无法解析片段:{1}',
+    en: 'Failed to parse SQL (current dialect: {0}). Possible dialect mismatch or syntax error. Configure sqlfluff.dialect in settings or add a project .sqlfluff. Unparsable fragment: {1}',
+    ja: 'SQLの解析に失敗しました(現在の方言:{0})。方言の不一致または構文エラーの可能性があります。設定で sqlfluff.dialect を構成するか、プロジェクトに .sqlfluff を追加してください。解析不能な断片:{1}',
+  },
+  'adapter.invalidApiKey': {
+    'zh-CN': 'API Key 无效,请重新设置',
+    en: 'Invalid API Key, please reconfigure',
+    ja: 'APIキーが無効です、再設定してください',
+  },
+  'adapter.noApiKey': {
+    'zh-CN': '未配置 API Key',
+    en: 'API Key not configured',
+    ja: 'APIキーが設定されていません',
+  },
+  'adapter.createProviderFail': {
+    'zh-CN': '创建 Provider 失败: {0}',
+    en: 'Failed to create provider: {0}',
+    ja: 'プロバイダーの作成に失敗しました: {0}',
+  },
+  'adapter.customRuleParseFail': {
+    'zh-CN': '自定义规则响应解析失败: {0}',
+    en: 'Custom rule response parse failed: {0}',
+    ja: 'カスタムルール応答の解析に失敗しました: {0}',
+  },
+  'adapter.customRuleRequestFail': {
+    'zh-CN': '自定义规则请求失败: {0}',
+    en: 'Custom rule request failed: {0}',
+    ja: 'カスタムルールリクエストに失敗しました: {0}',
+  },
+  'adapter.aiReviewParseFail': {
+    'zh-CN': 'AI 审查响应解析失败: {0}',
+    en: 'AI review response parse failed: {0}',
+    ja: 'AIレビュー応答の解析に失敗しました: {0}',
+  },
+  'adapter.aiReviewRequestFail': {
+    'zh-CN': 'AI 审查请求失败: {0}',
+    en: 'AI review request failed: {0}',
+    ja: 'AIレビューリクエストに失敗しました: {0}',
+  },
+  'adapter.eslintLegacyConfig': {
+    'zh-CN': '项目使用旧版 .eslintrc 配置({0}),ESLint v9 已不支持。请迁移到 eslint.config.js 或删除该文件以使用内置规则',
+    en: 'Project uses legacy .eslintrc config ({0}), which is not supported by ESLint v9. Please migrate to eslint.config.js or remove the file to use built-in rules',
+    ja: 'プロジェクトは旧形式の.eslintrc設定({0})を使用しています。ESLint v9ではサポートされていません。eslint.config.jsへの移行、またはファイル削除で組み込みルールを使用してください',
+  },
+  'adapter.emptyContent': {
+    'zh-CN': 'AI 返回空内容({0})',
+    en: 'AI returned empty content ({0})',
+    ja: 'AIが空のコンテンツを返しました({0})',
+  },
+  'adapter.maxTokensTruncated': {
+    'zh-CN': 'AI 输出被 max_tokens 截断(当前 {0}),请调大设置 ai.maxTokens 或更换模型',
+    en: 'AI output was truncated by max_tokens (current: {0}). Please increase ai.maxTokens or switch to a different model',
+    ja: 'AI出力がmax_tokensで打ち切られました(現在 {0})。ai.maxTokensを増やすか、モデルを変更してください',
+  },
+ 
+  'extension.activated': {
+    'zh-CN': '净码特工 · Code Purifier 已激活',
+    en: 'Code Purifier activated',
+    ja: 'コードピュリファイアが有効化されました',
+  },
+ 
+  'lang.zhCN': {
+    'zh-CN': '中文(简体)',
+    en: 'Chinese (Simplified)',
+    ja: '中国語(簡体字)',
+  },
+  'lang.en': {
+    'zh-CN': 'English',
+    en: 'English',
+    ja: 'English',
+  },
+  'lang.ja': {
+    'zh-CN': '日本語',
+    en: '日本語',
+    ja: '日本語',
+  },
+  'setup.fromTemplate': {
+    'zh-CN': '从模板导入',
+    en: 'Import from template',
+    ja: 'テンプレートからインポート',
+  },
+  'setup.exportTemplate': {
+    'zh-CN': '↓ 导出模板',
+    en: '↓ Export Template',
+    ja: '↓ テンプレートをエクスポート',
+  },
+  'setup.selectTemplateFile': {
+    'zh-CN': '选择模板文件',
+    en: 'Select template file',
+    ja: 'テンプレートファイルを選択',
+  },
+  'setup.importingTemplate': {
+    'zh-CN': '正在导入模板...',
+    en: 'Importing template...',
+    ja: 'テンプレートをインポート中...',
+  },
+  'import.template.badFormat': {
+    'zh-CN': '文件格式错误,请使用 Excel 模板(.xlsx/.xls)',
+    en: 'Bad file format, please use Excel template (.xlsx/.xls)',
+    ja: 'ファイル形式エラー、Excel テンプレートを使用してください',
+  },
+  'import.template.empty': {
+    'zh-CN': '文件为空',
+    en: 'File is empty',
+    ja: 'ファイルが空です',
+  },
+  'import.template.notTemplate': {
+    'zh-CN': '不是模板文件,缺少列: {0}',
+    en: 'Not a template file, missing columns: {0}',
+    ja: 'テンプレートファイルではありません、欠損列: {0}',
+  },
+  'import.template.skipped': {
+    'zh-CN': '已跳过 {0} 行空数据',
+    en: 'Skipped {0} empty rows',
+    ja: '{0} 行の空データをスキップしました',
+  },
+  'import.sectionInvalid': {
+    'zh-CN': '错误规则',
+    en: 'Error Rules',
+    ja: 'エラー行',
+  },
+  'import.cannotImport': {
+    'zh-CN': '需修复后点击添加',
+    en: 'Fix then click Add',
+    ja: '修正して「追加」をクリック',
+  },
+  'import.dedupFailed': {
+    'zh-CN': 'AI 去重失败,规则将不带去重标记导入',
+    en: 'AI dedup failed, rules imported without dedup marks',
+    ja: 'AI 重複排除失敗、重複マークなしでインポート',
+  },
+  'import.emptyValidRules': {
+    'zh-CN': '无有效规则可导入,请修改文件后重新导入',
+    en: 'No valid rules to import, please fix the file and retry',
+    ja: '有効なルールがありません、ファイルを修正して再インポートしてください',
+  },
+  'import.issuePrefix': {
+    'zh-CN': '⚠',
+    en: '⚠',
+    ja: '⚠',
+  },
+  'import.add': {
+    'zh-CN': '添加',
+    en: 'Add',
+    ja: '追加',
+  },
+  'import.adding': {
+    'zh-CN': '校验并去重中...',
+    en: 'Validating & deduping...',
+    ja: '検証・重複排除中...',
+  },
+  'import.idConflict': {
+    'zh-CN': 'id {0} 与已有规则重复,请修改 id',
+    en: 'id {0} conflicts with an existing rule, change the id',
+    ja: 'id {0} が既存ルールと重複、id を変更してください',
+  },
+  'import.addDedupFallback': {
+    'zh-CN': 'AI 去重失败,已以无重复方式添加',
+    en: 'AI dedup failed, added as no-duplicate',
+    ja: 'AI 重複排除失敗、重複なしとして追加',
+  },
+  'import.validationSeverityInvalid': {
+    'zh-CN': 'severity 非法',
+    en: 'Invalid severity',
+    ja: 'severity が不正です',
+  },
+  'exportTemplate.saveLabel': {
+    'zh-CN': '导出模板',
+    en: 'Export Template',
+    ja: 'エクスポート',
+  },
+  'exportTemplate.success': {
+    'zh-CN': '模板已导出',
+    en: 'Template exported',
+    ja: 'テンプレートをエクスポートしました',
+  },
+  'exportTemplate.fail': {
+    'zh-CN': '导出失败:{0}',
+    en: 'Export failed: {0}',
+    ja: 'エクスポート失敗: {0}',
+  },
+  'exportTemplate.openFolder': {
+    'zh-CN': '打开文件夹',
+    en: 'Reveal in Folder',
+    ja: 'フォルダを開く',
+  },
+ 
+  'codelens.reviewMethod': {
+    'zh-CN': '🔍 Code Purifier: 审查此方法',
+    en: '🔍 Code Purifier: Review This Method',
+    ja: '🔍 Code Purifier: このメソッドを審査',
+  },
+  'codelens.reviewedClean': {
+    'zh-CN': '✓ Code Purifier: 已审查(无问题)',
+    en: '✓ Code Purifier: Reviewed (No Issues)',
+    ja: '✓ Code Purifier: 審査済み(問題なし)',
+  },
+  'codelens.reviewedWithIssues': {
+    'zh-CN': '✓ Code Purifier: 已审查({0} 个问题)',
+    en: '✓ Code Purifier: Reviewed ({0} Issues)',
+    ja: '✓ Code Purifier: 審査済み({0} 件の問題)',
+  },
+  'methodReview.running': {
+    'zh-CN': 'Code Purifier 正在审查方法:{0}',
+    en: 'Code Purifier: Reviewing method: {0}',
+    ja: 'Code Purifier がメソッドを審査中:{0}',
+  },
+  'methodReview.noMethod': {
+    'zh-CN': '当前位置未检测到方法',
+    en: 'No method detected at current position',
+    ja: '現在位置でメソッドが検出されませんでした',
+  },
+  'methodReview.complete': {
+    'zh-CN': '方法审查完成,发现 {0} 个问题',
+    en: 'Method review complete, {0} issues found',
+    ja: 'メソッド審査完了、{0} 件の問題を発見',
+  },
+};
+ 
+let currentLang: Language = defaultLang;
+ 
+const languageChangeEmitter = new vscode.EventEmitter<Language>();
+ 
+export function t(key: string, vars?: Record<string, string | number>): string {
+  const msg = messages[key]?.[currentLang] ?? messages[key]?.[defaultLang];
+  let result = msg ?? key;
+  if (vars) {
+    for (const [k, v] of Object.entries(vars)) {
+      result = result.replace(`{${k}}`, String(v));
+    }
+  }
+  return result;
+}
+ 
+export function setLanguage(lang: Language): void {
+  if (currentLang === lang) { return; }
+  currentLang = lang;
+  languageChangeEmitter.fire(lang);
+}
+ 
+export function onLanguageChange(listener: (lang: Language) => void): vscode.Disposable {
+  return languageChangeEmitter.event(listener);
+}
+ 
+export function getLanguage(): Language {
+  return currentLang;
+}
+ 
+export function getMessageKeys(): string[] {
+  return Object.keys(messages);
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/index.html b/tests/coverage/src/index.html new file mode 100644 index 0000000..24c0dcf --- /dev/null +++ b/tests/coverage/src/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+ +
+ 76.38% + Statements + 110/144 +
+ + +
+ 46.66% + Branches + 7/15 +
+ + +
+ 80% + Functions + 4/5 +
+ + +
+ 76.38% + Lines + 110/144 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
extension.ts +
+
76.38%110/14446.66%7/1580%4/576.38%110/144
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/jsp/index.html b/tests/coverage/src/jsp/index.html new file mode 100644 index 0000000..a2e048c --- /dev/null +++ b/tests/coverage/src/jsp/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/jsp + + + + + + + + + +
+
+

All files src/jsp

+
+ +
+ 100% + Statements + 77/77 +
+ + +
+ 92.85% + Branches + 13/14 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 77/77 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
jsp-extractor.ts +
+
100%77/7792.85%13/14100%1/1100%77/77
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/jsp/jsp-extractor.ts.html b/tests/coverage/src/jsp/jsp-extractor.ts.html new file mode 100644 index 0000000..564bf27 --- /dev/null +++ b/tests/coverage/src/jsp/jsp-extractor.ts.html @@ -0,0 +1,316 @@ + + + + + + Code coverage report for src/jsp/jsp-extractor.ts + + + + + + + + + +
+
+

All files / src/jsp jsp-extractor.ts

+
+ +
+ 100% + Statements + 77/77 +
+ + +
+ 92.85% + Branches + 13/14 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 77/77 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +782x +2x +2x +2x +2x +2x +2x +2x +2x +9x +9x +9x +9x +9x +9x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +9x +9x +9x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +9x +9x +9x +8x +8x +8x +8x +8x +8x +8x +2x +2x +8x +1x +1x +8x +1x +1x +5x +4x +4x +4x +6x +8x +6x +6x +6x +6x +6x +6x +6x +6x +6x +6x +9x +9x +9x + 
export interface JspSection {
+  language: 'javascript' | 'css' | 'java';
+  code: string;
+  lineOffset: number;
+  sourceStart: number;
+  sourceEnd: number;
+  scriptletKind?: 'statement' | 'declaration' | 'expression';
+}
+ 
+export function extractJspSections(content: string): JspSection[] {
+  const sections: JspSection[] = [];
+ 
+  const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
+  let match: RegExpExecArray | null;
+  while ((match = scriptRegex.exec(content)) !== null) {
+    const code = match[1];
+    const beforeMatch = content.substring(0, match.index);
+    const lineOffset = beforeMatch.split('\n').length - 1;
+    sections.push({
+      language: 'javascript',
+      code,
+      lineOffset,
+      sourceStart: match.index,
+      sourceEnd: match.index + match[0].length,
+    });
+  }
+ 
+  const styleRegex = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
+  while ((match = styleRegex.exec(content)) !== null) {
+    const code = match[1];
+    const beforeMatch = content.substring(0, match.index);
+    const lineOffset = beforeMatch.split('\n').length - 1;
+    sections.push({
+      language: 'css',
+      code,
+      lineOffset,
+      sourceStart: match.index,
+      sourceEnd: match.index + match[0].length,
+    });
+  }
+ 
+  const jspTagRegex = /<%--([\s\S]*?)--%>|<%!([\s\S]*?)%>|<%=([\s\S]*?)%>|<%@([\s\S]*?)%>|<%([\s\S]*?)%>/g;
+  while ((match = jspTagRegex.exec(content)) !== null) {
+    const beforeMatch = content.substring(0, match.index);
+    const lineOffset = beforeMatch.split('\n').length - 1;
+ 
+    let code: string | undefined;
+    let scriptletKind: JspSection['scriptletKind'] | undefined;
+ 
+    if (match[1] !== undefined || match[4] !== undefined) {
+      continue;
+    }
+    if (match[2] !== undefined) {
+      code = match[2];
+      scriptletKind = 'declaration';
+    } else if (match[3] !== undefined) {
+      code = match[3];
+      scriptletKind = 'expression';
+    } else if (match[5] !== undefined) {
+      code = match[5];
+      scriptletKind = 'statement';
+    }
+ 
+    if (code === undefined) { continue; }
+ 
+    sections.push({
+      language: 'java',
+      code,
+      lineOffset,
+      sourceStart: match.index,
+      sourceEnd: match.index + match[0].length,
+      scriptletKind,
+    });
+  }
+ 
+  return sections;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/merger/index.html b/tests/coverage/src/merger/index.html new file mode 100644 index 0000000..86ed789 --- /dev/null +++ b/tests/coverage/src/merger/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/merger + + + + + + + + + +
+
+

All files src/merger

+
+ +
+ 95.78% + Statements + 159/166 +
+ + +
+ 86.84% + Branches + 33/38 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 95.78% + Lines + 159/166 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
merger.ts +
+
95.78%159/16686.84%33/38100%4/495.78%159/166
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/merger/merger.ts.html b/tests/coverage/src/merger/merger.ts.html new file mode 100644 index 0000000..cc822a8 --- /dev/null +++ b/tests/coverage/src/merger/merger.ts.html @@ -0,0 +1,583 @@ + + + + + + Code coverage report for src/merger/merger.ts + + + + + + + + + +
+
+

All files / src/merger merger.ts

+
+ +
+ 95.78% + Statements + 159/166 +
+ + +
+ 86.84% + Branches + 33/38 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 95.78% + Lines + 159/166 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +1672x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +25x +25x +25x +45x +22x +22x +22x +45x +25x +25x +25x +2x +26x +26x +12x +5x +5x +12x +21x +26x +4x +3x +3x +4x +18x +18x +2x +39x +39x +24x +24x +12x +39x +39x +2x +13x +13x +13x +13x +13x +3x +3x +3x +3x +3x +3x +13x +13x +13x +13x +13x +13x +13x +13x +26x +26x +26x +  +  +  +  +  +26x +  +  +26x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x + 
import * as vscode from 'vscode';
+import type { LinterDiagnostic, Severity, AiFixSnippet } from '../types';
+import type { TranslatedDiagnostic, CustomRuleResult, AIFinding } from '../ai/schema';
+ 
+export interface MergedReport {
+  linterDiagnostics: LinterDiagnostic[];
+  customRuleDiagnostics: LinterDiagnostic[];
+  translatedDiagnostics: TranslatedDiagnostic[];
+  aiFindings: AIFinding[];
+  linterCount: number;
+  customRuleCount: number;
+  aiCount: number;
+  errors: string[];
+  degraded: boolean;
+  duration: number;
+  filePath: string;
+  language: string;
+  adapterNames: string[];
+  fixableLinterIndices: number[];
+  aiFixableLinterIndices: number[];
+  fixableCustomIndices: number[];
+  aiFixAvailable: boolean;
+  customRuleFilterInfo?: {
+    totalActive: number;
+    injected: number;
+    filteredOut: number;
+    skippedRequestA: boolean;
+  };
+}
+ 
+interface MergeInput {
+  staticDiagnostics: LinterDiagnostic[];
+  customRuleResults: CustomRuleResult[];
+  translatedDiagnostics: TranslatedDiagnostic[];
+  aiFindings: AIFinding[];
+  errors: string[];
+  degraded: boolean;
+  startTime: number;
+  filePath: string;
+  language: string;
+  adapterIds: string[];
+  aiFixAvailable?: boolean;
+  code?: string;
+  customRuleFilterInfo?: {
+    totalActive: number;
+    injected: number;
+    filteredOut: number;
+    skippedRequestA: boolean;
+  };
+}
+ 
+const SEVERITY_RANK: Record<string, number> = { error: 0, warning: 1, info: 2 };
+ 
+const RULE_NAMESPACE_PREFIXES = ['eslint:', 'stylelint:', 'sqlfluff:', 'pmd:', 'custom:', 'method:'];
+ 
+function normalizeRuleId(id: string): string {
+  let result = id.trim();
+  for (const p of RULE_NAMESPACE_PREFIXES) {
+    if (result.startsWith(p)) {
+      result = result.slice(p.length);
+      break;
+    }
+  }
+  const segments = result.split(/[:/]/);
+  return segments[segments.length - 1];
+}
+ 
+function findTranslation(pool: TranslatedDiagnostic[], ruleId: string): TranslatedDiagnostic | undefined {
+  for (let i = 0; i < pool.length; i++) {
+    if (pool[i].originalRuleId === ruleId) {
+      return pool.splice(i, 1)[0];
+    }
+  }
+  const norm = normalizeRuleId(ruleId);
+  for (let i = 0; i < pool.length; i++) {
+    if (normalizeRuleId(pool[i].originalRuleId) === norm) {
+      return pool.splice(i, 1)[0];
+    }
+  }
+  return undefined;
+}
+ 
+function sortBySeverityAndLine<T extends { severity: string }>(items: T[], lineOf: (item: T) => number): T[] {
+  return [...items].sort((a, b) => {
+    const rankDiff = (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3);
+    if (rankDiff !== 0) { return rankDiff; }
+    return lineOf(a) - lineOf(b);
+  });
+}
+ 
+export function mergeResults(input: MergeInput): MergedReport {
+  const code = input.code ?? '';
+ 
+  const customRuleDiagnostics: LinterDiagnostic[] = sortBySeverityAndLine(
+    input.customRuleResults.map(r => ({
+      severity: r.severity as Severity,
+      ruleId: r.ruleId,
+      message: r.message,
+      suggestion: r.suggestion,
+      aiFix: r.fix as AiFixSnippet | undefined,
+      range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
+    })),
+    d => d.range.start.line
+  );
+ 
+  const translationPool = [...input.translatedDiagnostics];
+ 
+  const linterDiagnostics = sortBySeverityAndLine(
+    input.staticDiagnostics.map(d => {
+      const td = findTranslation(translationPool, d.ruleId);
+      let fixed = td ? { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion } : d;
+      if (fixed.fix && code.length > 0 && !fixed.fix.originalText) {
+        const [start, end] = fixed.fix.range;
+        if (start >= 0 && end >= start && end <= code.length) {
+          fixed = { ...fixed, fix: { ...fixed.fix, originalText: code.slice(start, end) } };
+        }
+      }
+      if (td?.fix) {
+        fixed = { ...fixed, aiFix: td.fix as AiFixSnippet };
+      }
+      return fixed;
+    }),
+    d => d.range.start.line
+  );
+ 
+  const aiFindings = sortBySeverityAndLine(
+    input.aiFindings.map(f => ({ ...f, line: Math.max(0, f.line - 1) })),
+    f => f.line
+  );
+ 
+  const linterCount = linterDiagnostics.length;
+  const customRuleCount = customRuleDiagnostics.length;
+  const aiCount = aiFindings.length;
+ 
+  const fixableLinterIndices = linterDiagnostics
+    .map((d, i) => (d.fix ? i : -1))
+    .filter(i => i !== -1);
+ 
+  const aiFixAvailable = !!input.aiFixAvailable;
+  const aiFixableLinterIndices = linterDiagnostics
+    .map((d, i) => (!d.fix && aiFixAvailable && !d.ruleId.startsWith('sqlfluff:') ? i : -1))
+    .filter(i => i !== -1);
+ 
+  const fixableCustomIndices: number[] = [];
+ 
+  return {
+    linterDiagnostics,
+    customRuleDiagnostics,
+    translatedDiagnostics: input.translatedDiagnostics,
+    aiFindings,
+    linterCount,
+    customRuleCount,
+    aiCount,
+    errors: input.errors,
+    degraded: input.degraded,
+    duration: Date.now() - input.startTime,
+    filePath: input.filePath,
+    language: input.language,
+    adapterNames: input.adapterIds,
+    fixableLinterIndices,
+    aiFixableLinterIndices,
+    fixableCustomIndices,
+    aiFixAvailable,
+    customRuleFilterInfo: input.customRuleFilterInfo,
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/orchestrator/index.html b/tests/coverage/src/orchestrator/index.html new file mode 100644 index 0000000..370936a --- /dev/null +++ b/tests/coverage/src/orchestrator/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/orchestrator + + + + + + + + + +
+
+

All files src/orchestrator

+
+ +
+ 45.71% + Statements + 48/105 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 28.57% + Functions + 2/7 +
+ + +
+ 45.71% + Lines + 48/105 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
orchestrator.ts +
+
45.71%48/105100%2/228.57%2/745.71%48/105
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/orchestrator/orchestrator.ts.html b/tests/coverage/src/orchestrator/orchestrator.ts.html new file mode 100644 index 0000000..3b36146 --- /dev/null +++ b/tests/coverage/src/orchestrator/orchestrator.ts.html @@ -0,0 +1,400 @@ + + + + + + Code coverage report for src/orchestrator/orchestrator.ts + + + + + + + + + +
+
+

All files / src/orchestrator orchestrator.ts

+
+ +
+ 45.71% + Statements + 48/105 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 28.57% + Functions + 2/7 +
+ + +
+ 45.71% + Lines + 48/105 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +1061x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x +1x +  +  +1x + 
import * as vscode from 'vscode';
+import type { LinterAdapter, LinterDiagnostic } from '../types';
+import { getLinterForLanguage, isAdapterEnabled } from '../config';
+import { ESLintAdapter } from '../adapters/eslint';
+import { PmdAdapter } from '../adapters/pmd';
+import { StylelintAdapter } from '../adapters/stylelint';
+import { SqlFluffAdapter } from '../adapters/sqlfluff';
+import { JspAdapter } from '../adapters/jsp';
+ 
+export interface StaticAnalysisResult {
+  diagnostics: LinterDiagnostic[];
+  errors: string[];
+  adapterIds: string[];
+  duration: number;
+}
+ 
+export interface CachedAnalysis {
+  diagnostics: LinterDiagnostic[];
+  adapterId: string;
+  workingDir: string;
+}
+ 
+export class Orchestrator {
+  private adapters: LinterAdapter[];
+  private analysisCache = new Map<string, CachedAnalysis>();
+ 
+  constructor() {
+    // 单语言单 linter 设计:按 linters.<language> 单选配置分派一个适配器;
+    // 需多引擎的文件走组合适配器(如 JspAdapter = PMD + ESLint + Stylelint)。
+    this.adapters = [
+      new ESLintAdapter(),
+      new PmdAdapter(),
+      new StylelintAdapter(),
+      new SqlFluffAdapter(),
+      new JspAdapter(),
+    ];
+  }
+ 
+  async runStaticAnalysis(
+    document: vscode.TextDocument,
+    workingDir: string
+  ): Promise<StaticAnalysisResult> {
+    const startTime = Date.now();
+    const languageId = document.languageId;
+
+    const selectedLinter = getLinterForLanguage(languageId);
+    if (!selectedLinter) {
+      return { diagnostics: [], errors: [], adapterIds: [], duration: 0 };
+    }
+
+    const adapter = this.adapters.find(a => a.id === selectedLinter);
+    if (!adapter) {
+      return {
+        diagnostics: [],
+        errors: [`未找到适配器: ${selectedLinter}`],
+        adapterIds: [],
+        duration: Date.now() - startTime,
+      };
+    }
+
+    if (!isAdapterEnabled(adapter.id)) {
+      return {
+        diagnostics: [],
+        errors: [],
+        adapterIds: [],
+        duration: Date.now() - startTime,
+      };
+    }
+
+    const result = await adapter.check(document, workingDir);
+    const errors: string[] = [];
+    if (result.status !== 'ok') {
+      errors.push(`[${adapter.id}] ${result.errorMessage ?? result.status}`);
+    }
+
+    this.analysisCache.set(document.uri.toString(), {
+      diagnostics: result.diagnostics,
+      adapterId: adapter.id,
+      workingDir,
+    });
+
+    return {
+      diagnostics: result.diagnostics,
+      errors,
+      adapterIds: [adapter.id],
+      duration: Date.now() - startTime,
+    };
+  }
+ 
+  setAnalysisResult(uri: vscode.Uri, result: CachedAnalysis): void {
+    this.analysisCache.set(uri.toString(), result);
+  }
+ 
+  getAnalysisResult(uri: vscode.Uri): CachedAnalysis | undefined {
+    return this.analysisCache.get(uri.toString());
+  }
+ 
+  clearAnalysisResult(uri: vscode.Uri): void {
+    this.analysisCache.delete(uri.toString());
+  }
+ 
+  getAdapter(adapterId: string): LinterAdapter | undefined {
+    return this.adapters.find(a => a.id === adapterId);
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/panel/index.html b/tests/coverage/src/panel/index.html new file mode 100644 index 0000000..c1ae22e --- /dev/null +++ b/tests/coverage/src/panel/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/panel + + + + + + + + + +
+
+

All files src/panel

+
+ +
+ 11.88% + Statements + 68/572 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 10.52% + Functions + 2/19 +
+ + +
+ 11.88% + Lines + 68/572 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
webview.ts +
+
11.88%68/572100%2/210.52%2/1911.88%68/572
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/panel/webview.ts.html b/tests/coverage/src/panel/webview.ts.html new file mode 100644 index 0000000..4ecfb18 --- /dev/null +++ b/tests/coverage/src/panel/webview.ts.html @@ -0,0 +1,1801 @@ + + + + + + Code coverage report for src/panel/webview.ts + + + + + + + + + +
+
+

All files / src/panel webview.ts

+
+ +
+ 11.88% + Statements + 68/572 +
+ + +
+ 100% + Branches + 2/2 +
+ + +
+ 10.52% + Functions + 2/19 +
+ + +
+ 11.88% + Lines + 68/572 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +5731x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +1x +1x +  +  +  +  +  +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +1x + 
import * as vscode from 'vscode';
+import { MergedReport } from '../merger/merger';
+import { t, onLanguageChange, getLanguage } from '../i18n/messages';
+import type { FixSessionManager } from '../fix/fixSession';
+import { computeLineDiff } from '../utils/diff';
+ 
+interface PanelMessage {
+  type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo' | 'applyFix' | 'cancelFix' | 'applyAll' | 'cancelAll';
+  line?: number;
+  ruleId?: string;
+  source?: 'linter' | 'custom' | 'ai';
+  origin?: 'hover' | 'panel';
+}
+ 
+function esc(str: string): string {
+  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&quot;').replace(/"/g, '&quot;');
+}
+ 
+function svgIcon(): string {
+  return `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#61AFEF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 14l2 2 4-4"/></svg>`;
+}
+ 
+const SVG_HEADER_ICON = svgIcon();
+ 
+const BADGE_CLASS: Record<string, string> = { linter: 'badge-linter', custom: 'badge-custom', ai: 'badge-ai' };
+ 
+interface FixedEntryView {
+  ruleId: string;
+  line: number;
+  key: string;
+  source: 'linter' | 'custom' | 'ai';
+}
+ 
+function badgeHtml(source: string): string {
+  let label: string;
+  switch (source) {
+    case 'linter': label = t('report.sourceLinter'); break;
+    case 'custom': label = t('report.sourceCustom'); break;
+    case 'ai': label = t('report.sourceAI'); break;
+    default: label = source;
+  }
+  return `<span class="item-badge ${BADGE_CLASS[source] || 'badge-linter'}">${label}</span>`;
+}
+ 
+function severityClass(severity: string): string {
+  switch (severity) {
+    case 'error': return 'severity-error';
+    case 'warning': return 'severity-warning';
+    case 'info': return 'severity-info';
+    default: return 'severity-info';
+  }
+}
+ 
+export class ReviewPanel {
+  public static currentPanel: ReviewPanel | undefined;
+  private readonly panel: vscode.WebviewPanel;
+  private disposables: vscode.Disposable[] = [];
+  private currentReport: MergedReport | null = null;
+  private fixSession: FixSessionManager | null = null;
+  private readonly scriptUri: vscode.Uri;
+ 
+  private constructor(
+    private readonly extensionUri: vscode.Uri,
+    column: vscode.ViewColumn
+  ) {
+    this.panel = vscode.window.createWebviewPanel(
+      'codeReviewer.reviewPanel',
+      t('report.panelTitle'),
+      column,
+      {
+        enableScripts: true,
+        retainContextWhenHidden: true,
+        localResourceRoots: [this.extensionUri],
+      }
+    );
+
+    this.scriptUri = this.panel.webview.asWebviewUri(
+      vscode.Uri.joinPath(this.extensionUri, 'out', 'webview', 'reviewPanel.js')
+    );
+
+    this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
+
+    this.panel.webview.onDidReceiveMessage(
+      (message: PanelMessage) => this.handleMessage(message),
+      null,
+      this.disposables
+    );
+
+    this.disposables.push(
+      onLanguageChange(() => {
+        this.panel.title = t('report.panelTitle');
+        if (this.currentReport) {
+          this.panel.webview.html = this.buildHtml(this.currentReport);
+        }
+      })
+    );
+  }
+ 
+  static createOrShow(extensionUri: vscode.Uri, column?: vscode.ViewColumn): ReviewPanel {
+    if (ReviewPanel.currentPanel) {
+      ReviewPanel.currentPanel.panel.reveal(column);
+      return ReviewPanel.currentPanel;
+    }
+
+    ReviewPanel.currentPanel = new ReviewPanel(extensionUri, column ?? vscode.ViewColumn.Two);
+    return ReviewPanel.currentPanel;
+  }
+ 
+  update(report: MergedReport): void {
+    this.currentReport = report;
+    this.panel.webview.html = this.buildHtml(report);
+  }
+ 
+  setFixSession(session: FixSessionManager): void {
+    this.fixSession = session;
+    if (this.currentReport) {
+      this.panel.webview.html = this.buildHtml(this.currentReport);
+    }
+  }
+ 
+  postMessage(message: unknown): void {
+    this.panel.webview.postMessage(message);
+  }
+ 
+  private buildHtml(report: MergedReport): string {
+    const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
+
+    const linterErrors = report.linterDiagnostics.filter(d => d.severity === 'error').length;
+    const linterWarnings = report.linterDiagnostics.filter(d => d.severity === 'warning').length;
+    const linterInfos = report.linterDiagnostics.filter(d => d.severity === 'info').length;
+    const customErrors = report.customRuleDiagnostics.filter(d => d.severity === 'error').length;
+    const customWarnings = report.customRuleDiagnostics.filter(d => d.severity === 'warning').length;
+    const customInfos = report.customRuleDiagnostics.filter(d => d.severity === 'info').length;
+    const aiErrors = report.aiFindings.filter(f => f.severity === 'error').length;
+    const aiWarnings = report.aiFindings.filter(f => f.severity === 'warning').length;
+    const aiInfos = report.aiFindings.filter(f => f.severity === 'info').length;
+
+    const total = report.linterCount + report.customRuleCount + report.aiCount;
+    const totalErrors = linterErrors + customErrors + aiErrors;
+    const totalWarnings = linterWarnings + customWarnings + aiWarnings;
+    const totalInfos = linterInfos + customInfos + aiInfos;
+
+    const errorBox = report.errors.length > 0
+      ? `<div class="errors-box"><div class="errors-box-title">✖ ${t('report.executionErrors')}</div>${report.errors.map(e => `<div class="errors-box-item">${esc(e)}</div>`).join('')}</div>`
+      : '';
+
+    const banner = report.errors.length > 0
+      ? `<div class="banner banner-error">⚠ ${t('report.degradedBanner')}</div>`
+      : report.degraded
+        ? `<div class="banner banner-warning">⚠ ${t('report.degradedBanner')}</div>`
+        : '';
+
+    const fixableLinterSet = new Set(report.fixableLinterIndices);
+    const aiFixableLinterSet = new Set(report.aiFixableLinterIndices);
+    const fixableCustomSet = new Set(report.fixableCustomIndices);
+
+    const fixedEntries = this.fixSession?.getEntries(vscode.Uri.file(report.filePath)) ?? [];
+
+    const tabCount = (e: number, w: number, i: number) => {
+      const pts: string[] = [];
+      if (e > 0) { pts.push(`<span class="tab-count tab-count-error">${e}</span>`); }
+      if (w > 0) { pts.push(`<span class="tab-count tab-count-warning">${w}</span>`); }
+      if (i > 0) { pts.push(`<span class="tab-count tab-count-info">${i}</span>`); }
+      return pts.join(' ');
+    };
+
+    const linterToolName = report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : t('report.sourceLinter');
+
+    const customFilterInfo = report.customRuleFilterInfo;
+    const customFilterLabel = customFilterInfo
+      ? t('report.injectedCount', { 0: customFilterInfo.injected, 1: customFilterInfo.totalActive })
+      : '';
+
+    return `<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>${t('report.title')}</title>
+<style>
+  * { margin: 0; padding: 0; box-sizing: border-box; }
+  body { font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-editor-background); }
+
+  .header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 12px; border-bottom: 1px solid var(--vscode-panel-border); }
+  .header h2 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; color: var(--vscode-foreground); }
+  .header .meta { color: var(--vscode-descriptionForeground); font-size: 12px; margin-top: 2px; }
+
+  .banner { margin: 12px 16px 0; padding: 8px 12px; border-radius: 6px; font-size: 12px; display: flex; align-items: center; gap: 6px; }
+  .banner-warning { background: rgba(200,160,0,0.15); border: 1px solid rgba(200,160,0,0.35); color: #cca700; }
+  .banner-error { background: rgba(248,81,73,0.15); border: 1px solid rgba(248,81,73,0.35); color: #f48771; }
+
+  .errors-box { margin: 12px 16px 0; padding: 10px 12px; background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.3); border-radius: 6px; }
+  .errors-box-title { font-weight: 600; color: #f48771; font-size: 12px; margin-bottom: 4px; }
+  .errors-box-item { font-size: 12px; color: #f48771; padding: 2px 0; }
+
+  .summary { display: flex; gap: 8px; padding: 12px 16px 0; flex-wrap: wrap; }
+  .stat-card { flex: 1; min-width: 100px; padding: 10px 12px; border: 1px solid var(--vscode-panel-border); border-radius: 8px; background: var(--vscode-sideBar-background); text-align: center; }
+  .stat-card .num { font-size: 24px; font-weight: 700; line-height: 1.2; }
+  .stat-card .label { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 1px; }
+  .stat-error .num { color: #E06C75; }
+  .stat-warning .num { color: #D19A66; }
+  .stat-info .num { color: #61AFEF; }
+  .stat-total .num { color: var(--vscode-foreground); }
+
+  .tab-bar { display: flex; align-items: stretch; margin: 16px 16px 0; border-bottom: 1px solid var(--vscode-panel-border); }
+  .tab { position: relative; display: flex; align-items: center; gap: 6px; padding: 8px 16px; font-size: 13px; font-weight: 500; color: var(--vscode-descriptionForeground); cursor: pointer; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; user-select: none; white-space: nowrap; background: none; border-top: none; border-left: none; border-right: none; font-family: var(--vscode-font-family); }
+  .tab:hover { color: var(--vscode-foreground); }
+  .tab.active { color: var(--vscode-foreground); border-bottom-color: #7C3AED; }
+  .tab-count { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; font-size: 11px; font-weight: 500; line-height: 1; }
+  .tab-count-error { background: rgba(224,108,117,0.2); color: #E06C75; }
+  .tab-count-warning { background: rgba(209,154,102,0.2); color: #D19A66; }
+  .tab-count-info { background: rgba(97,175,239,0.2); color: #61AFEF; }
+
+  .tab-content { display: none; padding: 8px 16px 20px; }
+  .tab-content.active { display: block; }
+
+  .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; padding-top: 12px; }
+  .section-header:first-child { padding-top: 0; }
+  .section-header-title { font-size: 12px; font-weight: 600; color: var(--vscode-descriptionForeground); text-transform: uppercase; letter-spacing: .03em; }
+
+  .item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-top: 6px; border: 1px solid var(--vscode-panel-border); border-radius: 8px; background: var(--vscode-sideBar-background); cursor: pointer; transition: border-color .15s, background .15s; }
+  .item:first-child { margin-top: 0; }
+  .item:hover { border-color: var(--vscode-focusBorder); background: var(--vscode-list-hoverBackground); }
+  .item.expanded { border-color: var(--vscode-focusBorder); }
+
+  .item-severity { flex-shrink: 0; width: 5px; align-self: stretch; border-radius: 3px; margin: -10px 0 -10px -12px; border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
+  .item-severity-error { background: #E06C75; }
+  .item-severity-warning { background: #D19A66; }
+  .item-severity-info { background: #61AFEF; }
+
+  .item-body { flex: 1; min-width: 0; }
+  .item-row1 { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; }
+
+  .item-icon { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+  .icon-error { background: #E06C75; }
+  .icon-warning { background: #D19A66; }
+  .icon-info { background: #61AFEF; }
+
+  .item-badge { display: inline-flex; align-items: center; padding: 0 7px; height: 20px; border-radius: 5px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; flex-shrink: 0; line-height: 1; }
+  .badge-linter { background: rgba(139,148,158,0.15); color: var(--vscode-descriptionForeground); }
+  .badge-custom { background: rgba(191,133,255,0.15); color: #ce93d8; }
+  .badge-ai { background: rgba(79,195,247,0.15); color: #4dd0e1; }
+
+  .item-message { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--vscode-foreground); font-size: 14px; }
+  .item-line { flex-shrink: 0; font-size: 11px; font-weight: 600; color: var(--vscode-descriptionForeground); font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; background: rgba(139,148,158,0.08); padding: 1px 6px; border-radius: 4px; line-height: 20px; cursor: pointer; }
+  .item-line:hover { background: rgba(139,148,158,0.2); }
+  .item-rule { flex-shrink: 0; font-size: 12px; color: var(--vscode-descriptionForeground); font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .item-fix { flex-shrink: 0; padding: 2px 8px; border: 1px solid var(--vscode-panel-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border-radius: 4px; cursor: pointer; font-size: 11px; transition: background .15s; line-height: 18px; }
+  .item-fix:hover { background: var(--vscode-button-secondaryHoverBackground); }
+  .item-fix:disabled { opacity: .4; cursor: not-allowed; }
+  .item-fixed { border-color: rgba(87,171,90,0.4); background: rgba(87,171,90,0.08); }
+  .item-fixed:hover { border-color: rgba(87,171,90,0.6); }
+  .item-severity-fixed { background: #57ab5a; }
+  .icon-fixed { background: #57ab5a; }
+  .item-undo { flex-shrink: 0; padding: 2px 8px; border: 1px solid rgba(87,171,90,0.5); background: rgba(87,171,90,0.15); color: #57ab5a; border-radius: 4px; cursor: pointer; font-size: 11px; line-height: 18px; }
+  .item-undo:hover { background: rgba(87,171,90,0.25); }
+
+  .item-detail { display: none; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--vscode-panel-border); }
+  .item.expanded .item-detail { display: block; animation: fadeSlideIn .2s ease; }
+  @keyframes fadeSlideIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
+  .detail-text { color: var(--vscode-descriptionForeground); font-size: 13px; line-height: 1.7; }
+  .detail-text code { font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; }
+  .detail-suggestion { margin-top: 8px; padding: 8px 12px; background: rgba(97,175,239,0.08); border: 1px solid rgba(97,175,239,0.2); border-radius: 6px; font-size: 13px; color: #79c0ff; }
+  .detail-no-fix { margin-top: 4px; padding: 8px 12px; background: rgba(139,148,158,0.08); border: 1px dashed var(--vscode-panel-border); border-radius: 6px; font-size: 12px; color: var(--vscode-descriptionForeground); }
+  .detail-original { margin-top: 6px; font-size: 12px; color: var(--vscode-descriptionForeground); font-style: italic; }
+  .detail-category { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: rgba(139,148,158,0.1); color: var(--vscode-descriptionForeground); margin-top: 6px; }
+
+  .detail-fix-title { margin: 8px 0 4px; font-size: 11px; font-weight: 600; color: var(--vscode-descriptionForeground); text-transform: uppercase; letter-spacing: .03em; }
+  .fix-diff { margin-top: 4px; border: 1px solid var(--vscode-panel-border); border-radius: 6px; overflow: hidden; }
+  .diff-line { display: flex; align-items: flex-start; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; line-height: 1.6; padding: 1px 8px; white-space: pre-wrap; word-break: break-all; }
+  .diff-marker { flex-shrink: 0; width: 16px; color: var(--vscode-descriptionForeground); user-select: none; }
+  .diff-text { flex: 1; min-width: 0; }
+  .diff-same { color: var(--vscode-foreground); }
+  .diff-del { background: rgba(224,108,117,0.15); color: #E06C75; }
+  .diff-add { background: rgba(87,171,90,0.15); color: #57ab5a; }
+
+  .empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 20px; color: var(--vscode-descriptionForeground); text-align: center; font-style: italic; font-size: 13px; }
+
+  .actions { display: flex; gap: 8px; padding: 16px 16px 20px; border-top: 1px solid var(--vscode-panel-border); }
+  .btn { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px; border: 1px solid var(--vscode-panel-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border-radius: 6px; cursor: pointer; font-size: 12px; transition: background .15s, border-color .15s; font-family: var(--vscode-font-family); }
+  .btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
+  .btn-primary { background: #7C3AED; color: #fff; border-color: #7C3AED; }
+  .btn-primary:hover { background: #6D28D9; }
+</style>
+</head>
+<body>
+
+<div class="header">
+  <div>
+    <h2>${SVG_HEADER_ICON} ${t('report.panelTitle')}</h2>
+    <div class="meta">${esc(fileName)} · ${esc(report.language)} · ${(report.duration / 1000).toFixed(1)}s</div>
+  </div>
+</div>
+
+${banner}
+${errorBox}
+
+<div class="summary">
+  <div class="stat-card stat-total"><div class="num">${total}</div><div class="label">${t('report.totalIssues')}</div></div>
+  <div class="stat-card stat-error"><div class="num">${totalErrors}</div><div class="label">${t('report.errors')}</div></div>
+  <div class="stat-card stat-warning"><div class="num">${totalWarnings}</div><div class="label">${t('report.warnings')}</div></div>
+  <div class="stat-card stat-info"><div class="num">${totalInfos}</div><div class="label">${t('report.info')}</div></div>
+</div>
+
+<div class="tab-bar">
+  <button class="tab active" data-tab="linter" onclick="switchTab('linter')">🔧 ${report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : t('report.sourceLinter')} ${tabCount(linterErrors, linterWarnings, linterInfos)}</button>
+  <button class="tab" data-tab="custom" onclick="switchTab('custom')">📋 ${t('report.sourceCustom')} ${tabCount(customErrors, customWarnings, customInfos)} <span style="font-size:11px;color:var(--vscode-descriptionForeground);">${customFilterLabel}</span></button>
+  <button class="tab" data-tab="ai" onclick="switchTab('ai')">🤖 ${t('report.sourceAI')} ${tabCount(aiErrors, aiWarnings, aiInfos)}</button>
+</div>
+
+<div class="tab-content active" id="tab-linter">
+  ${this.buildLinterList(report, fixableLinterSet, aiFixableLinterSet, fixedEntries)}
+</div>
+<div class="tab-content" id="tab-custom">
+  ${this.buildCustomList(report, fixedEntries)}
+</div>
+<div class="tab-content" id="tab-ai">
+  ${this.buildAIList(report, fixedEntries)}
+</div>
+
+<div class="actions">
+  <button class="btn btn-primary" onclick="send('rerun')">🔄 ${t('report.rerun')}</button>
+  <button class="btn" onclick="send('export')">📄 ${t('report.export')}</button>
+</div>
+</div>
+
+<script src="${this.scriptUri}"></script>
+</body>
+</html>`;
+  }
+ 
+  private buildLinterList(report: MergedReport, fixableSet: Set<number>, aiFixableSet: Set<number>, fixedEntries: FixedEntryView[]): string {
+    if (report.linterDiagnostics.length === 0 && fixedEntries.length === 0) {
+      return `<div class="empty">${t('report.noIssues')}</div>`;
+    }
+    const toolName = report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : t('report.sourceLinter');
+    const hasFixable = fixableSet.size > 0 || aiFixableSet.size > 0;
+    const fixAllBtn = hasFixable
+      ? `<button class="btn" data-fix-all-btn onclick="send('fixAll')">${t('report.fixAll')}</button>`
+        + `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
+        + `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
+      : '';
+    let html = `<div class="section-header"><span class="section-header-title">${esc(toolName)} · ${t('report.issuesCount', { 0: report.linterCount })}</span>${fixAllBtn}</div>`;
+    if (report.linterDiagnostics.length === 0) {
+      html += `<div class="empty">${t('report.noIssues')}</div>`;
+    } else {
+      html += report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i), aiFixableSet.has(i), undefined, true, this.buildFixDiffHtml(d.aiFix?.originalText ?? d.fix?.originalText, d.aiFix?.newText ?? d.fix?.text))).join('');
+    }
+    const linterFixed = fixedEntries.filter(f => f.source === 'linter');
+    if (linterFixed.length > 0) {
+      html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: linterFixed.length })}</span></div>`;
+      html += linterFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'linter')).join('');
+    }
+    return html;
+  }
+ 
+  private buildFixedItem(ruleId: string, line: number, key: string, source: 'linter' | 'custom' | 'ai'): string {
+    return `<div class="item item-fixed">
+  <div class="item-severity item-severity-fixed"></div>
+  <div class="item-body">
+    <div class="item-row1">
+      <span class="item-icon icon-fixed"></span>
+      ${badgeHtml(source)}
+      <span class="item-rule">${esc(ruleId)}</span>
+      <span class="item-message">${t('report.fixedLabel')}</span>
+      <button class="item-undo" onclick="event.stopPropagation();send('undo', ${line}, '${esc(ruleId)}', '${source}')">↩ ${t('report.undoFix')}</button>
+    </div>
+  </div>
+</div>`;
+  }
+ 
+  private buildCustomList(report: MergedReport, fixedEntries: FixedEntryView[]): string {
+    const filterInfo = report.customRuleFilterInfo;
+    if (filterInfo?.skippedRequestA) {
+      return `<div class="empty">${t('report.skipCustomRules')}</div>`;
+    }
+    const customFixed = fixedEntries.filter(f => f.source === 'custom');
+    const fixedKeys = new Set(customFixed.map(f => `${f.ruleId}@${f.line}`));
+    const remaining = report.customRuleDiagnostics.filter(d => !fixedKeys.has(`${d.ruleId}@${d.range.start.line}`));
+    if (remaining.length === 0 && customFixed.length === 0) {
+      return `<div class="empty">${t('report.noRuleViolations')}</div>`;
+    }
+    const filterLabel = filterInfo
+      ? t('report.injectedRules', { 0: filterInfo.injected, 1: filterInfo.totalActive })
+      : '';
+    const hasFixable = remaining.length > 0;
+    const fixAllBtn = hasFixable
+      ? `<button class="btn" data-fix-all-btn onclick="send('fixAll', undefined, undefined, 'custom')">${t('report.fixAll')}</button>`
+        + `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
+        + `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
+      : '';
+    let html = `<div class="section-header"><span class="section-header-title">${t('report.sourceCustom')} · ${t('report.issuesCount', { 0: remaining.length })}${filterLabel}</span>${fixAllBtn}</div>`;
+    if (remaining.length === 0) {
+      html += `<div class="empty">${t('report.noRuleViolations')}</div>`;
+    } else {
+      html += remaining.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false, true, undefined, true, this.buildFixDiffHtml(d.aiFix?.originalText, d.aiFix?.newText))).join('');
+    }
+    if (customFixed.length > 0) {
+      html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: customFixed.length })}</span></div>`;
+      html += customFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'custom')).join('');
+    }
+    return html;
+  }
+ 
+  private buildAIList(report: MergedReport, fixedEntries: FixedEntryView[]): string {
+    const aiFixed = fixedEntries.filter(f => f.source === 'ai');
+    const fixedKeys = new Set(aiFixed.map(f => `${f.ruleId}@${f.line}`));
+    const remaining = report.aiFindings.filter(f => !fixedKeys.has(`${f.ruleId}@${f.line}`));
+    if (remaining.length === 0 && aiFixed.length === 0) {
+      return `<div class="empty">${t('report.noAIFindings')}</div>`;
+    }
+    const hasFixable = remaining.length > 0;
+    const fixAllBtn = hasFixable
+      ? `<button class="btn" data-fix-all-btn onclick="send('fixAll', undefined, undefined, 'ai')">${t('report.fixAll')}</button>`
+        + `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
+        + `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
+      : '';
+    const parts: string[] = [`<div class="section-header"><span class="section-header-title">${t('report.sourceAI')} · ${t('report.itemsCount', { 0: remaining.length })}</span>${fixAllBtn}</div>`];
+    if (remaining.length === 0) {
+      parts.push(`<div class="empty">${t('report.noAIFindings')}</div>`);
+    } else {
+      for (const f of remaining) {
+        const details: string[] = [];
+        const path = (f as { path?: string }).path;
+        if (path) {
+          details.push(`<div class="detail-text">🔗 ${esc(path)}</div>`);
+        }
+        details.push(`<div class="detail-text">${esc(f.description)}</div>`);
+        if (f.category) {
+          details.push(`<span class="detail-category">🎯 ${esc(f.category)}</span>`);
+        }
+        if (f.suggestion) {
+          details.push(`<div class="detail-suggestion">💡 ${esc(f.suggestion)}</div>`);
+        }
+        parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, true, details.join(''), true, this.buildFixDiffHtml(f.fix?.originalText, f.fix?.newText)));
+      }
+    }
+    if (aiFixed.length > 0) {
+      parts.push(`<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: aiFixed.length })}</span></div>`);
+      parts.push(aiFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'ai')).join(''));
+    }
+    return parts.join('');
+  }
+ 
+  private buildIssueItem(
+    severity: string,
+    ruleId: string,
+    message: string,
+    line: number,
+    source: string,
+    suggestion?: string,
+    fixable?: boolean,
+    aiFixable?: boolean,
+    detailHtml?: string,
+    expandable: boolean = true,
+    fixDiffHtml: string = ''
+  ): string {
+    const sevCls = severityClass(severity);
+    const lineNum = Number.isFinite(line) ? line + 1 : '?';
+    const parts: string[] = [];
+
+    parts.push(`<div class="item"${expandable ? ' onclick="toggleItem(this)"' : ''}>`);
+    parts.push(`<div class="item-severity item-severity-${sevCls.replace('severity-', '')}"></div>`);
+    parts.push('<div class="item-body">');
+    parts.push('<div class="item-row1">');
+    parts.push(`<span class="item-icon icon-${sevCls.replace('severity-', '')}"></span>`);
+    parts.push(badgeHtml(source));
+    parts.push(`<span class="item-rule">${esc(ruleId)}</span>`);
+    parts.push(`<span class="item-message">${esc(message)}</span>`);
+    parts.push(`<span class="item-line" onclick="event.stopPropagation();send('navigate', ${line}, '${esc(ruleId)}', '${source}')">L${lineNum}</span>`);
+    if (fixable) {
+      parts.push(`<button class="item-fix" data-fix-key="${esc(ruleId)}@${line}" data-label="🔧 ${esc(t('report.fixLabel'))}" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🔧 ${t('report.fixLabel')}</button>`);
+      parts.push(`<button class="item-fix btn-apply" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('applyFix', ${line}, '${esc(ruleId)}', '${source}')">✅ ${t('fix.apply')}</button>`);
+      parts.push(`<button class="item-fix btn-cancel" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('cancelFix', ${line}, '${esc(ruleId)}', '${source}')">✖ ${t('fix.cancel')}</button>`);
+    } else if (aiFixable) {
+      parts.push(`<button class="item-fix" data-fix-key="${esc(ruleId)}@${line}" data-label="🤖 ${esc(t('report.fixAILabel'))}" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🤖 ${t('report.fixAILabel')}</button>`);
+      parts.push(`<button class="item-fix btn-apply" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('applyFix', ${line}, '${esc(ruleId)}', '${source}')">✅ ${t('fix.apply')}</button>`);
+      parts.push(`<button class="item-fix btn-cancel" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('cancelFix', ${line}, '${esc(ruleId)}', '${source}')">✖ ${t('fix.cancel')}</button>`);
+    }
+    parts.push('</div>');
+
+    const fixPlaceholder = (fixable || aiFixable) && !fixDiffHtml
+      ? `<div class="detail-no-fix">${esc(t('report.fixUnavailable'))}</div>`
+      : '';
+
+    if (expandable && (detailHtml || (suggestion && suggestion !== message) || fixDiffHtml || fixPlaceholder)) {
+      parts.push('<div class="item-detail">');
+      if (fixDiffHtml) {
+        parts.push(`<div class="detail-fix-title">${t('report.fixPreview')}</div>`);
+        parts.push(fixDiffHtml);
+      } else if (fixPlaceholder) {
+        parts.push(`<div class="detail-fix-title">${t('report.fixPreview')}</div>`);
+        parts.push(fixPlaceholder);
+      }
+      if (detailHtml) {
+        parts.push(detailHtml);
+      } else if (suggestion && suggestion !== message) {
+        parts.push(`<div class="detail-suggestion">💡 ${esc(suggestion)}</div>`);
+      }
+      parts.push('</div>');
+    }
+
+    parts.push('</div>');
+    parts.push('</div>');
+    return parts.join('');
+  }
+ 
+  private buildFixDiffHtml(originalText?: string, newText?: string): string {
+    if (!originalText || !newText || originalText === newText) { return ''; }
+    const lines = computeLineDiff(originalText, newText);
+    return `<div class="fix-diff">${lines.map(l =>
+      `<div class="diff-line diff-${l.type}"><span class="diff-marker">${l.type === 'del' ? '-' : l.type === 'add' ? '+' : ' '}</span><span class="diff-text">${esc(l.text) || ' '}</span></div>`
+    ).join('')}</div>`;
+  }
+ 
+  private async handleMessage(message: PanelMessage): Promise<void> {
+    switch (message.type) {
+      case 'navigate':
+        if (message.line !== undefined && this.currentReport) {
+          const report = this.currentReport;
+          const uri = vscode.Uri.file(report.filePath);
+          const existing = vscode.window.visibleTextEditors.find(
+            e => e.document.uri.fsPath === report.filePath
+          );
+          const showOptions: vscode.TextDocumentShowOptions =
+            existing && existing.viewColumn !== undefined
+              ? { viewColumn: existing.viewColumn }
+              : { preview: true };
+          const editor = await vscode.window.showTextDocument(uri, showOptions);
+          const line = Math.max(0, message.line);
+          const range = new vscode.Range(line, 0, line, 0);
+          editor.selection = new vscode.Selection(range.start, range.end);
+          editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
+        }
+        break;
+      case 'rerun':
+        vscode.commands.executeCommand('codeReviewer.review');
+        break;
+      case 'export':
+        vscode.commands.executeCommand('codeReviewer.exportReport');
+        break;
+      case 'fix':
+        vscode.commands.executeCommand('codeReviewer.fixIssue', { ...message, origin: 'panel' });
+        break;
+      case 'fixAll':
+        vscode.commands.executeCommand('codeReviewer.fixAll', { source: message.source ?? 'linter' });
+        break;
+      case 'undo':
+        vscode.commands.executeCommand('codeReviewer.undoFix', message);
+        break;
+      case 'applyFix':
+        vscode.commands.executeCommand('codeReviewer.applyFixPreview', message);
+        break;
+      case 'cancelFix':
+        vscode.commands.executeCommand('codeReviewer.cancelFixPreview', message);
+        break;
+      case 'applyAll':
+        vscode.commands.executeCommand('codeReviewer.applyAllPreview');
+        break;
+      case 'cancelAll':
+        vscode.commands.executeCommand('codeReviewer.cancelAllPreview');
+        break;
+    }
+  }
+ 
+  dispose(): void {
+    ReviewPanel.currentPanel = undefined;
+    this.panel.dispose();
+    for (const d of this.disposables) { d.dispose(); }
+    this.disposables = [];
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/builtin-rules.ts.html b/tests/coverage/src/rules/builtin-rules.ts.html new file mode 100644 index 0000000..9e63d26 --- /dev/null +++ b/tests/coverage/src/rules/builtin-rules.ts.html @@ -0,0 +1,1216 @@ + + + + + + Code coverage report for src/rules/builtin-rules.ts + + + + + + + + + +
+
+

All files / src/rules builtin-rules.ts

+
+ +
+ 59.15% + Statements + 223/377 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/11 +
+ + +
+ 59.15% + Lines + 223/377 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +3782x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import * as fs from 'fs';
+import * as path from 'path';
+import * as vscode from 'vscode';
+import js from '@eslint/js';
+import staticRules from './static-rules.json';
+import type { Language } from '../i18n/messages';
+ 
+export const eslintExtraRules: Record<string, 'error' | 'warn'> = {
+  'eqeqeq': 'error',
+  'no-eq-null': 'error',
+  'no-self-compare': 'error',
+  'no-promise-executor-return': 'error',
+  'no-shadow': 'error',
+  'no-unassigned-vars': 'error',
+  'no-useless-assignment': 'error',
+  'block-scoped-var': 'error',
+  'default-case': 'error',
+  'default-case-last': 'error',
+  'no-unmodified-loop-condition': 'error',
+  'no-unreachable-loop': 'error',
+  'no-eval': 'error',
+  'no-extend-native': 'error',
+  'no-var': 'error',
+  'no-await-in-loop': 'warn',
+  'prefer-template': 'warn',
+  'prefer-object-spread': 'warn',
+  'prefer-rest-params': 'warn',
+  'prefer-spread': 'warn',
+  'prefer-object-has-own': 'warn',
+  'no-useless-concat': 'warn',
+  'no-useless-return': 'warn',
+  'no-useless-computed-key': 'warn',
+  'no-useless-rename': 'warn',
+  'no-param-reassign': 'warn',
+  'no-return-assign': 'error',
+  'no-throw-literal': 'error',
+  'camelcase': 'warn',
+  'new-cap': 'warn',
+  'no-array-constructor': 'error',
+};
+ 
+export const eslintExtraTsRules: Record<string, 'error' | 'warn' | 'off'> = {
+  '@typescript-eslint/no-non-null-assertion': 'error',
+  '@typescript-eslint/no-dynamic-delete': 'error',
+  '@typescript-eslint/no-useless-empty-export': 'error',
+  '@typescript-eslint/consistent-type-imports': 'error',
+  '@typescript-eslint/unified-signatures': 'error',
+  '@typescript-eslint/no-extraneous-class': 'warn',
+  '@typescript-eslint/no-useless-constructor': 'warn',
+  '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error',
+  '@typescript-eslint/no-invalid-void-type': 'warn',
+  '@typescript-eslint/prefer-literal-enum-member': 'warn',
+  '@typescript-eslint/prefer-enum-initializers': 'warn',
+  'no-shadow': 'off',
+  '@typescript-eslint/no-shadow': 'error',
+  'no-array-constructor': 'off',
+};
+ 
+export const stylelintExtraRules: Record<string, unknown> = {
+  'color-no-invalid-hex': true,
+  'function-linear-gradient-no-nonstandard-direction': true,
+  'function-no-unknown': true,
+  'unit-no-unknown': true,
+  'no-unknown-animations': true,
+  'no-unknown-custom-media': true,
+  'no-unknown-custom-properties': true,
+ 
+  'at-rule-no-vendor-prefix': true,
+  'media-feature-name-no-vendor-prefix': true,
+  'property-no-vendor-prefix': true,
+  'selector-no-vendor-prefix': true,
+  'value-no-vendor-prefix': true,
+ 
+  'color-hex-length': 'short',
+  'color-function-notation': 'modern',
+  'length-zero-no-unit': true,
+  'selector-pseudo-element-colon-notation': 'double',
+  'import-notation': 'string',
+  'alpha-value-notation': 'number',
+  'hue-degree-notation': 'angle',
+  'keyframe-selector-notation': 'percentage',
+ 
+  'declaration-block-no-redundant-longhand-properties': true,
+  'shorthand-property-no-redundant-values': true,
+  'block-no-redundant-nested-style-rules': true,
+ 
+  'color-named': 'never',
+  'font-family-name-quotes': 'always-where-required',
+ 
+  'number-max-precision': 4,
+  'comment-whitespace-inside': 'always',
+};
+ 
+export const BUILTIN_SQLFLUFF_RULES =
+  'core,AM03,AM05,AM08,CV01,CV02,CV06,CV08,CV12,LT13,LT14,LT15,ST01,ST02,ST04,ST05,ST06,ST07,ST09,ST10,ST11,ST12,RF02,RF04,RF05,RF06';
+ 
+export function buildBuiltinSqlfluffConfig(dialect: string): string {
+  return `[sqlfluff]
+rules = ${BUILTIN_SQLFLUFF_RULES}
+dialect = ${dialect}
+max_line_length = 80
+indent_unit = space
+tab_space_size = 4
+
+[sqlfluff:rules:aliasing.length]
+max_alias_length = 30
+`;
+}
+ 
+interface StaticRuleEntry {
+  id: string;
+  description?: string;
+  descriptionZh?: string;
+  descriptionJa?: string;
+}
+ 
+export function getRuleDescription(linter: string, ruleId: string, lang: Language): string | undefined {
+  const section = staticRules.rules[linter as keyof typeof staticRules.rules] as StaticRuleEntry[] | undefined;
+  if (!section) { return undefined; }
+  const entry = section.find(r => r.id === `${linter}/${ruleId}`);
+  if (!entry) { return undefined; }
+  if (lang === 'zh-CN') { return entry.descriptionZh ?? entry.description; }
+  if (lang === 'ja') { return entry.descriptionJa ?? entry.description; }
+  return entry.description;
+}
+ 
+const ESLINT_RULES_URL = 'https://eslint.org/docs/latest/rules/';
+const STYLELINT_RULES_URL = 'https://stylelint.io/user-guide/rules/';
+const SQLFLUFF_RULES_URL = 'https://docs.sqlfluff.com/en/stable/reference/rules.html';
+ 
+interface HeaderLabels {
+  projectConfig: (name: string) => string;
+  howToAdd: string;
+  addExample: string;
+  addExampleStyle: string;
+  allRules: string;
+  dialect: string;
+  enableAll: string;
+  ruleList: string;
+  excludePrefix: string;
+}
+ 
+const HEADER: Record<Language, HeaderLabels> = {
+  'zh-CN': {
+    projectConfig: name => `项目配置 — 与插件内置 ${name} 规则一致(由插件生成)`,
+    howToAdd: '如何添加规则:在 rules 中新增一行',
+    addExample: "如 'rule-name': 'warn' 或 'rule-name': ['error', 'always']",
+    addExampleStyle: "如 'indentation': 2 或 'rule-name': true",
+    allRules: '全部可用规则:',
+    dialect: '数据库方言:postgres / mysql / bigquery / snowflake 等',
+    enableAll: '想启用全部规则时改为 → rules = all',
+    ruleList: '内置精选规则清单:',
+    excludePrefix: '排除:',
+  },
+  en: {
+    projectConfig: name => `Project config — matches the extension's built-in ${name} rules (generated by the extension)`,
+    howToAdd: 'To add a rule: add a line in rules',
+    addExample: "e.g. 'rule-name': 'warn' or 'rule-name': ['error', 'always']",
+    addExampleStyle: "e.g. 'indentation': 2 or 'rule-name': true",
+    allRules: 'All available rules:',
+    dialect: 'Database dialect: postgres / mysql / bigquery / snowflake etc.',
+    enableAll: 'Change to rules = all to enable all rules',
+    ruleList: 'Built-in curated rule list:',
+    excludePrefix: 'Excluded: ',
+  },
+  ja: {
+    projectConfig: name => `プロジェクト設定 — 拡張機能の組み込み${name}ルールと一致(拡張機能が生成)`,
+    howToAdd: 'ルールを追加するには: rulesに1行追加します',
+    addExample: "例: 'rule-name': 'warn' または 'rule-name': ['error', 'always']",
+    addExampleStyle: "例: 'indentation': 2 または 'rule-name': true",
+    allRules: '利用可能な全ルール: ',
+    dialect: 'データベース方言: postgres / mysql / bigquery / snowflake など',
+    enableAll: '全ルールを有効にする場合は rules = all に変更',
+    ruleList: '組み込みの精選ルール一覧:',
+    excludePrefix: '除外:',
+  },
+};
+ 
+const TS_NOTE: Record<Language, string[]> = {
+  'zh-CN': [
+    '注意:本文件仅包含 JS 内置规则;TS 项目如需 TS 专项规则,',
+    '      请安装 typescript-eslint 后自行追加,例如:',
+    "      const ts = require('typescript-eslint');",
+    '      module.exports = [ ...本文件配置, ...ts.configs.recommended ];',
+  ],
+  en: [
+    'Note: this file only contains JS built-in rules. For TypeScript-specific rules,',
+    '      install typescript-eslint and extend, e.g.:',
+    "      const ts = require('typescript-eslint');",
+    '      module.exports = [ ...this config, ...ts.configs.recommended ];',
+  ],
+  ja: [
+    '注: このファイルはJS組み込みルールのみです。TS専用ルールが必要な場合は、',
+    '      typescript-eslintをインストールして追記してください。例:',
+    "      const ts = require('typescript-eslint');",
+    '      module.exports = [ ...本設定, ...ts.configs.recommended ];',
+  ],
+};
+ 
+const PMD_CATEGORY_COMMENTS: Record<string, Record<Language, string>> = {
+  bestpractices: {
+    'zh-CN': '最佳实践类(避免空 catch、关闭流等)',
+    en: 'Best practices (avoid empty catch, close streams, etc.)',
+    ja: 'ベストプラクティス(空のcatch回避、ストリームクローズ等)',
+  },
+  codestyle: {
+    'zh-CN': '代码风格类(命名规范、花括号位置等)',
+    en: 'Code style (naming conventions, brace placement, etc.)',
+    ja: 'コードスタイル(命名規則、ブレース位置等)',
+  },
+  design: {
+    'zh-CN': '设计类(过度耦合、复杂度、设计缺陷等)',
+    en: 'Design (over-coupling, complexity, design flaws, etc.)',
+    ja: '設計(過度な結合、複雑度、設計上の欠陥等)',
+  },
+  errorprone: {
+    'zh-CN': '易错类(空 catch、错误处理遗漏等)',
+    en: 'Error-prone (empty catch, missed error handling, etc.)',
+    ja: 'エラーを起こしやすい(空のcatch、エラー処理の見落とし等)',
+  },
+  multithreading: {
+    'zh-CN': '多线程类(线程使用、并发问题等)',
+    en: 'Multithreading (thread usage, concurrency issues, etc.)',
+    ja: 'マルチスレッド(スレッド使用、並行性の問題等)',
+  },
+  performance: {
+    'zh-CN': '性能类(重复对象创建、低效操作等)',
+    en: 'Performance (repeated object creation, inefficient operations, etc.)',
+    ja: 'パフォーマンス(オブジェクトの再生成、非効率な操作等)',
+  },
+  security: {
+    'zh-CN': '安全类(不安全的编码实践等)',
+    en: 'Security (unsafe coding practices, etc.)',
+    ja: 'セキュリティ(安全でないコーディング慣行等)',
+  },
+};
+ 
+function extractPmdVersion(): string {
+  const v = staticRules.linterVersion.pmd as string | undefined;
+  const m = v?.match(/(\d+\.\d+\.\d+)/);
+  return m ? m[1] : 'latest';
+}
+ 
+function resolvePmdRulesetPath(): string {
+  const candidates: string[] = [];
+  try {
+    const ext = vscode.extensions.getExtension?.('vscode-code-reviewer');
+    if (ext?.extensionPath) {
+      candidates.push(path.join(ext.extensionPath, 'jars', 'pmd', 'pmd-java-ruleset.xml'));
+    }
+  } catch { /* ignore */ }
+  let dir = __dirname;
+  for (let i = 0; i < 5; i++) {
+    candidates.push(path.join(dir, 'jars', 'pmd', 'pmd-java-ruleset.xml'));
+    dir = path.dirname(dir);
+  }
+  const found = candidates.find(p => fs.existsSync(p));
+  if (found) { return found; }
+  throw new Error(`PMD bundled ruleset not found: ${candidates.join(', ')}`);
+}
+ 
+export function buildEslintProjectConfigText(lang: Language): string {
+  const l = HEADER[lang];
+  const jsRules = js.configs.recommended.rules as Record<string, unknown>;
+  const rules: Array<[string, unknown]> = [...Object.entries(jsRules), ...Object.entries(eslintExtraRules)];
+  const body = rules.map(([id, sev]) => {
+    const desc = getRuleDescription('eslint', id, lang);
+    return `      '${id}': ${JSON.stringify(sev)},${desc ? ` // ${desc}` : ''}`;
+  });
+  const lines = [
+    '/* global module */',
+    '// ============================================================',
+    `// ${l.projectConfig('ESLint')}`,
+    `// ${l.howToAdd}`,
+    `//   ${l.addExample}`,
+    `// ${l.allRules}${ESLINT_RULES_URL}`,
+    ...TS_NOTE[lang].map(n => `// ${n}`),
+    '// ============================================================',
+    'module.exports = [',
+    '  {',
+    '    rules: {',
+    ...body,
+    '    },',
+    '  },',
+    '];',
+    '',
+  ];
+  return lines.join('\n');
+}
+ 
+export async function buildStylelintProjectConfigText(lang: Language): Promise<string> {
+  const l = HEADER[lang];
+  const rec = (await import('stylelint-config-recommended')).default;
+  const rules: Record<string, unknown> = { ...rec.rules, ...stylelintExtraRules };
+  const body = Object.entries(rules).map(([id, val]) => {
+    const desc = getRuleDescription('stylelint', id, lang);
+    return `    '${id}': ${JSON.stringify(val)},${desc ? ` // ${desc}` : ''}`;
+  });
+  const lines = [
+    '/* global module */',
+    '// ============================================================',
+    `// ${l.projectConfig('Stylelint')}`,
+    `// ${l.howToAdd}`,
+    `//   ${l.addExampleStyle}`,
+    `// ${l.allRules}${STYLELINT_RULES_URL}`,
+    '// ============================================================',
+    'module.exports = {',
+    '  rules: {',
+    ...body,
+    '  },',
+    '};',
+    '',
+  ];
+  return lines.join('\n');
+}
+ 
+export function buildSqlfluffProjectConfigText(dialect: string, lang: Language): string {
+  const l = HEADER[lang];
+  const ruleIds = BUILTIN_SQLFLUFF_RULES.split(',');
+  const ruleComments = ruleIds.map(id => {
+    const desc = getRuleDescription('sqlfluff', id, lang);
+    return `# ${id}  ${desc ?? ''}`;
+  });
+  const lines = [
+    '[sqlfluff]',
+    `# ${l.dialect}`,
+    `dialect = ${dialect}`,
+    `# ${l.enableAll}`,
+    `# ${l.ruleList}`,
+    ...ruleComments,
+    `# ${l.allRules}${SQLFLUFF_RULES_URL}`,
+    `rules = ${BUILTIN_SQLFLUFF_RULES}`,
+    'max_line_length = 80',
+    'indent_unit = space',
+    'tab_space_size = 4',
+    '',
+    '[sqlfluff:rules:aliasing.length]',
+    'max_alias_length = 30',
+    '',
+  ];
+  return lines.join('\n');
+}
+ 
+export async function buildPmdProjectRulesetText(lang: Language): Promise<string> {
+  const l = HEADER[lang];
+  const rulesetPath = resolvePmdRulesetPath();
+  const content = await fs.promises.readFile(rulesetPath, 'utf-8');
+  const allRulesUrl = `https://docs.pmd-code.org/pmd-doc-${extractPmdVersion()}/pmd_rules_java.html`;
+  const out: string[] = [];
+  for (const line of content.split('\n')) {
+    if (/<description>/.test(line)) {
+      out.push(line);
+      out.push(`    <!-- ${l.allRules}${allRulesUrl} -->`);
+      continue;
+    }
+    const ruleRefMatch = line.match(/<rule ref="category\/java\/(\w+)\.xml"/);
+    if (ruleRefMatch) {
+      const cat = ruleRefMatch[1];
+      const comment = PMD_CATEGORY_COMMENTS[cat]?.[lang] ?? cat;
+      out.push(`    <!-- ${comment} -->`);
+      out.push(line);
+      continue;
+    }
+    const excludeMatch = line.match(/<exclude name="([A-Za-z0-9_]+)"\/>/);
+    if (excludeMatch) {
+      const name = excludeMatch[1];
+      const desc = getRuleDescription('pmd', name, lang);
+      if (desc) {
+        out.push(`        <!-- ${l.excludePrefix}${desc} -->`);
+      }
+      out.push(line);
+      continue;
+    }
+    out.push(line);
+  }
+  return out.join('\n');
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/dedup-prompt.ts.html b/tests/coverage/src/rules/converters/dedup-prompt.ts.html new file mode 100644 index 0000000..1d95670 --- /dev/null +++ b/tests/coverage/src/rules/converters/dedup-prompt.ts.html @@ -0,0 +1,439 @@ + + + + + + Code coverage report for src/rules/converters/dedup-prompt.ts + + + + + + + + + +
+
+

All files / src/rules/converters dedup-prompt.ts

+
+ +
+ 100% + Statements + 118/118 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 100% + Functions + 1/1 +
+ + +
+ 100% + Lines + 118/118 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +1192x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + 
import type { CustomRule } from '../../types';
+import { getLanguage, type Language } from '../../i18n/messages';
+import { buildKnownRulesSection } from './known-rules';
+ 
+export function buildDedupOnlyPrompt(
+  yamlContent: string,
+  existingRules: CustomRule[],
+): { system: string; user: string } {
+  const lang = getLanguage();
+  const s = PROMPTS[lang];
+ 
+  const system = [
+    s.role,
+    s.taskTitle,
+    s.taskLines.join('\n'),
+    s.rulesTitle,
+    s.rulesLines.join('\n'),
+    s.constraintTitle,
+    s.constraintLines.join('\n'),
+    buildKnownRulesSection(existingRules),
+  ].join('\n\n');
+ 
+  const user = s.userPrefix + '\n\n' + yamlContent;
+  return { system, user };
+}
+ 
+const PROMPTS: Record<Language, {
+  role: string;
+  taskTitle: string;
+  taskLines: string[];
+  rulesTitle: string;
+  rulesLines: string[];
+  constraintTitle: string;
+  constraintLines: string[];
+  userPrefix: string;
+}> = {
+  'zh-CN': {
+    role: '你是规则去重判定助手。你只输出 YAML,不输出任何解释。',
+    taskTitle: '## 任务',
+    taskLines: [
+      '下面是已标准化的规则 YAML。你只负责对照"内置静态分析规则与已导入的自定义规则"为每条规则标注去重字段。',
+      '为每条规则补充以下字段(如果无重复则标注 none):',
+      '- duplicateOf: 重复的规则 ID(如 eslint/no-console、custom/my-rule)',
+      '- duplicateLevel: exact(完全相同)/ overlap(部分重叠)/ none(无重复)',
+      '- duplicateReason: 仅 overlap 时必填,简要说明重叠原因',
+    ],
+    rulesTitle: '## 判定规则',
+    rulesLines: [
+      '1. exact: id 完全相同,或 description + message 语义完全一致',
+      '2. overlap: 检测目标/场景部分重叠,但并非完全相同',
+      '3. none: 与现有规则无冲突',
+    ],
+    constraintTitle: '## ⚠️ 严格约束(必须遵守)',
+    constraintLines: [
+      '1. 严禁修改任何已有字段的值(id、severity、description、message、languages、excludeLanguages)',
+      '2. 严禁添加新规则,严禁删除或合并规则',
+      '3. 规则数量必须与输入完全一致,顺序必须与输入完全一致',
+      '4. 你只允许添加三个字段: duplicateOf、duplicateLevel、duplicateReason',
+      '5. 如果某条规则与现有规则无任何重复,设置 duplicateLevel: none 即可,不需要补充 duplicateOf',
+      '6. 输出纯 YAML,不要用 markdown 代码块包裹',
+    ],
+    userPrefix: '## 待去重的规则 YAML',
+  },
+  'en': {
+    role: 'You are a rule deduplication assistant. Output YAML only, no explanations.',
+    taskTitle: '## Task',
+    taskLines: [
+      'Below is standardized rule YAML. Only annotate dedup fields by comparing against the "built-in static analysis rules and existing custom rules".',
+      'For each rule, add (mark none if no conflict):',
+      '- duplicateOf: duplicated rule ID (e.g. eslint/no-console, custom/my-rule)',
+      '- duplicateLevel: exact / overlap / none',
+      '- duplicateReason: required only for overlap',
+    ],
+    rulesTitle: '## Judgement Rules',
+    rulesLines: [
+      '1. exact: identical id, or semantically identical description+message',
+      '2. overlap: partially overlapping target/scenario',
+      '3. none: no conflict with existing rules',
+    ],
+    constraintTitle: '## ⚠️ Strict Constraints (MUST follow)',
+    constraintLines: [
+      '1. Do NOT modify any existing field values (id, severity, description, message, languages, excludeLanguages)',
+      '2. Do NOT add, delete, or merge rules',
+      '3. Rule count and order must exactly match the input',
+      '4. Only add three fields: duplicateOf, duplicateLevel, duplicateReason',
+      '5. If a rule has no duplication, set duplicateLevel: none without duplicateOf',
+      '6. Output pure YAML, do NOT wrap in markdown code fences',
+    ],
+    userPrefix: '## YAML to deduplicate',
+  },
+  'ja': {
+    role: 'あなたはルール重複判定アシスタントです。YAML のみ出力し、説明は不要です。',
+    taskTitle: '## タスク',
+    taskLines: [
+      '以下は標準化されたルール YAML です。組み込みの静的解析ルールと既存カスタムルールに照合し、重複フィールドのみ注釈してください。',
+      '各ルールに以下を追加(重複がない場合は none と表記):',
+      '- duplicateOf: 重複ルール ID(例: eslint/no-console, custom/my-rule)',
+      '- duplicateLevel: exact / overlap / none',
+      '- duplicateReason: overlap 時のみ必須',
+    ],
+    rulesTitle: '## 判定ルール',
+    rulesLines: [
+      '1. exact: ID が同一、または description+message が意味的に完全一致',
+      '2. overlap: 検出対象/シナリオが部分重複',
+      '3. none: 既存ルールと競合なし',
+    ],
+    constraintTitle: '## ⚠️ 厳格な制約(必ず遵守)',
+    constraintLines: [
+      '1. 既存フィールド(id, severity, description, message, languages, excludeLanguages)の値を一切変更しない',
+      '2. ルールの追加、削除、統合を一切行わない',
+      '3. ルールの数と順序は入力と完全に一致させる',
+      '4. 追加できるフィールドは duplicateOf, duplicateLevel, duplicateReason のみ',
+      '5. 重複がないルールは duplicateLevel: none とし、duplicateOf は付けない',
+      '6. 純粋な YAML を出力し、markdown コードブロックで囲まない',
+    ],
+    userPrefix: '## 重複排除対象の YAML',
+  },
+};
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/docx-converter.ts.html b/tests/coverage/src/rules/converters/docx-converter.ts.html new file mode 100644 index 0000000..6a61272 --- /dev/null +++ b/tests/coverage/src/rules/converters/docx-converter.ts.html @@ -0,0 +1,175 @@ + + + + + + Code coverage report for src/rules/converters/docx-converter.ts + + + + + + + + + +
+
+

All files / src/rules/converters docx-converter.ts

+
+ +
+ 43.33% + Statements + 13/30 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 50% + Functions + 1/2 +
+ + +
+ 43.33% + Lines + 13/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +311x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
import * as vscode from 'vscode';
+import * as mammoth from 'mammoth';
+import { RuleConverter } from './converter';
+import { convertContentWithAI } from '../import-service';
+import { buildSystemPrompt } from './prompt-builder';
+import type { CustomRule } from '../../types';
+import { t } from '../../i18n/messages';
+ 
+export class DocxConverter implements RuleConverter {
+  supportedExtensions = ['.docx'];
+ 
+  async convert(srcPath: string, context: vscode.ExtensionContext, existingRules?: CustomRule[]): Promise<string | null> {
+    let result: mammoth.Result;
+    try {
+      result = await mammoth.extractRawText({ path: srcPath });
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      vscode.window.showErrorMessage(t('import.docxReadFail', { 0: msg }));
+      return null;
+    }
+
+    const content = result.value.trim();
+    if (!content) {
+      vscode.window.showErrorMessage(t('import.docxEmpty'));
+      return null;
+    }
+
+    return convertContentWithAI(content, context, buildSystemPrompt('freeform', existingRules));
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/excel-converter.ts.html b/tests/coverage/src/rules/converters/excel-converter.ts.html new file mode 100644 index 0000000..8dedd9e --- /dev/null +++ b/tests/coverage/src/rules/converters/excel-converter.ts.html @@ -0,0 +1,277 @@ + + + + + + Code coverage report for src/rules/converters/excel-converter.ts + + + + + + + + + +
+
+

All files / src/rules/converters excel-converter.ts

+
+ +
+ 21.87% + Statements + 14/64 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 33.33% + Functions + 1/3 +
+ + +
+ 21.87% + Lines + 14/64 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +651x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
import * as vscode from 'vscode';
+import * as XLSX from 'xlsx';
+import { RuleConverter } from './converter';
+import { convertContentWithAI } from '../import-service';
+import { buildSystemPrompt } from './prompt-builder';
+import type { CustomRule } from '../../types';
+import { t } from '../../i18n/messages';
+ 
+function buildMarkdownTable(rows: Record<string, unknown>[], sheetName: string): string {
+  const keys = Object.keys(rows[0]);
+  const header = `| ${keys.join(' | ')} |`;
+  const separator = `| ${keys.map(() => '---').join(' | ')} |`;
+  const dataLines = rows.map(row => {
+    const cells = keys.map(k => String(row[k] ?? ''));
+    return `| ${cells.join(' | ')} |`;
+  });
+  return [`## ${sheetName}`, header, separator, ...dataLines].join('\n');
+}
+ 
+export class ExcelConverter implements RuleConverter {
+  supportedExtensions = ['.xlsx', '.xls'];
+ 
+  async convert(srcPath: string, context: vscode.ExtensionContext, existingRules?: CustomRule[]): Promise<string | null> {
+    let workbook: XLSX.WorkBook;
+    try {
+      workbook = XLSX.readFile(srcPath);
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      vscode.window.showErrorMessage(t('import.excelReadFail', { 0: msg }));
+      return null;
+    }
+
+    if (workbook.SheetNames.length === 0) {
+      vscode.window.showErrorMessage(t('import.excelEmpty'));
+      return null;
+    }
+
+    let parts: string[];
+    try {
+      parts = [];
+      for (const sheetName of workbook.SheetNames) {
+        const sheet = workbook.Sheets[sheetName];
+        if (!sheet) { continue; }
+        const rows = XLSX.utils.sheet_to_json<Record<string, string>>(sheet);
+        if (rows.length === 0) {
+          continue;
+        }
+        parts.push(buildMarkdownTable(rows, sheetName));
+      }
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      vscode.window.showErrorMessage(t('import.excelReadFail', { 0: msg }));
+      return null;
+    }
+
+    if (parts.length === 0) {
+      vscode.window.showErrorMessage(t('import.excelNoData'));
+      return null;
+    }
+
+    const combined = parts.join('\n\n');
+    return convertContentWithAI(combined, context, buildSystemPrompt('spreadsheet', existingRules));
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/index.html b/tests/coverage/src/rules/converters/index.html new file mode 100644 index 0000000..7ce8614 --- /dev/null +++ b/tests/coverage/src/rules/converters/index.html @@ -0,0 +1,251 @@ + + + + + + Code coverage report for src/rules/converters + + + + + + + + + +
+
+

All files src/rules/converters

+
+ +
+ 72.69% + Statements + 647/890 +
+ + +
+ 92.85% + Branches + 13/14 +
+ + +
+ 34.48% + Functions + 10/29 +
+ + +
+ 72.69% + Lines + 647/890 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
dedup-prompt.ts +
+
100%118/118100%1/1100%1/1100%118/118
docx-converter.ts +
+
43.33%13/30100%1/150%1/243.33%13/30
excel-converter.ts +
+
21.87%14/64100%1/133.33%1/321.87%14/64
known-rules.ts +
+
100%57/5785.71%6/742.85%3/7100%57/57
md-converter.ts +
+
80%12/15100%1/150%1/280%12/15
pptx-converter.ts +
+
43.33%13/30100%1/150%1/243.33%13/30
prompt-builder.ts +
+
88.16%380/431100%0/00%0/588.16%380/431
template-converter.ts +
+
15.25%18/118100%0/00%0/315.25%18/118
txt-converter.ts +
+
80%12/15100%1/150%1/280%12/15
yaml-converter.ts +
+
83.33%10/12100%1/150%1/283.33%10/12
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/known-rules.ts.html b/tests/coverage/src/rules/converters/known-rules.ts.html new file mode 100644 index 0000000..77cb7f7 --- /dev/null +++ b/tests/coverage/src/rules/converters/known-rules.ts.html @@ -0,0 +1,256 @@ + + + + + + Code coverage report for src/rules/converters/known-rules.ts + + + + + + + + + +
+
+

All files / src/rules/converters known-rules.ts

+
+ +
+ 100% + Statements + 57/57 +
+ + +
+ 85.71% + Branches + 6/7 +
+ + +
+ 42.85% + Functions + 3/7 +
+ + +
+ 100% + Lines + 57/57 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +582x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +3x +3x +3x +3x +3x +3x +18x +18x +1716x +1716x +18x +18x +3x +3x +2x +2x +2x +2x +2x +2x +3x +3x +3x +3x +3x + 
import staticRules from '../static-rules.json';
+import type { CustomRule } from '../../types';
+import { getLanguage, type Language } from '../../i18n/messages';
+ 
+interface KnownRulesLabels {
+  header: string;
+  linterLabel: (name: string, count: number) => string;
+  customLabel: (count: number) => string;
+  footer: string;
+}
+ 
+const LABELS: Record<Language, KnownRulesLabels> = {
+  'zh-CN': {
+    header: '## 已知规则清单(用于重复检测)',
+    linterLabel: (name, count) => `### ${name} (${count} 条)`,
+    customLabel: (count) => `### 已导入的自定义规则 (${count} 条)`,
+    footer: '判定时请精确匹配上述规则 ID,而非模糊匹配分类。',
+  },
+  en: {
+    header: '## Known Rules (for duplicate detection)',
+    linterLabel: (name, count) => `### ${name} (${count} rules)`,
+    customLabel: (count) => `### Imported custom rules (${count} rules)`,
+    footer: 'Match exactly by rule ID above, not by fuzzy category matching.',
+  },
+  ja: {
+    header: '## 既知ルール一覧(重複検出用)',
+    linterLabel: (name, count) => `### ${name}(${count} 件)`,
+    customLabel: (count) => `### インポート済みカスタムルール(${count} 件)`,
+    footer: '上記ルールIDで正確にマッチングしてください。曖昧なカテゴリマッチングは避けてください。',
+  },
+};
+ 
+export function buildKnownRulesSection(existingCustomRules?: CustomRule[]): string {
+  const lang = getLanguage();
+  const l = LABELS[lang] ?? LABELS['zh-CN'];
+  const lines: string[] = [l.header];
+ 
+  for (const [linter, rules] of Object.entries(staticRules.rules)) {
+    lines.push(l.linterLabel(linter, rules.length));
+    for (const rule of rules) {
+      lines.push(`- ${rule.id}: ${rule.description}`);
+    }
+    lines.push('');
+  }
+ 
+  if (existingCustomRules && existingCustomRules.length > 0) {
+    lines.push(l.customLabel(existingCustomRules.length));
+    for (const rule of existingCustomRules) {
+      lines.push(`- custom/${rule.id}: ${rule.description}`);
+    }
+    lines.push('');
+  }
+ 
+  lines.push(l.footer);
+ 
+  return lines.join('\n');
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/md-converter.ts.html b/tests/coverage/src/rules/converters/md-converter.ts.html new file mode 100644 index 0000000..5d00151 --- /dev/null +++ b/tests/coverage/src/rules/converters/md-converter.ts.html @@ -0,0 +1,130 @@ + + + + + + Code coverage report for src/rules/converters/md-converter.ts + + + + + + + + + +
+
+

All files / src/rules/converters md-converter.ts

+
+ +
+ 80% + Statements + 12/15 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 50% + Functions + 1/2 +
+ + +
+ 80% + Lines + 12/15 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +161x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +1x + 
import * as fs from 'fs';
+import * as vscode from 'vscode';
+import { RuleConverter } from './converter';
+import { convertContentWithAI } from '../import-service';
+import { buildSystemPrompt } from './prompt-builder';
+import type { CustomRule } from '../../types';
+ 
+export class MdConverter implements RuleConverter {
+  supportedExtensions = ['.md'];
+ 
+  async convert(srcPath: string, context: vscode.ExtensionContext, existingRules?: CustomRule[]): Promise<string | null> {
+    const content = fs.readFileSync(srcPath, 'utf-8');
+    return convertContentWithAI(content, context, buildSystemPrompt('freeform', existingRules));
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/pptx-converter.ts.html b/tests/coverage/src/rules/converters/pptx-converter.ts.html new file mode 100644 index 0000000..0989536 --- /dev/null +++ b/tests/coverage/src/rules/converters/pptx-converter.ts.html @@ -0,0 +1,175 @@ + + + + + + Code coverage report for src/rules/converters/pptx-converter.ts + + + + + + + + + +
+
+

All files / src/rules/converters pptx-converter.ts

+
+ +
+ 43.33% + Statements + 13/30 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 50% + Functions + 1/2 +
+ + +
+ 43.33% + Lines + 13/30 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +311x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x + 
import * as vscode from 'vscode';
+import { OfficeParser, type OfficeParserAST } from 'officeparser';
+import { RuleConverter } from './converter';
+import { convertContentWithAI } from '../import-service';
+import { buildSystemPrompt } from './prompt-builder';
+import type { CustomRule } from '../../types';
+import { t } from '../../i18n/messages';
+ 
+export class PptxConverter implements RuleConverter {
+  supportedExtensions = ['.pptx'];
+ 
+  async convert(srcPath: string, context: vscode.ExtensionContext, existingRules?: CustomRule[]): Promise<string | null> {
+    let ast: OfficeParserAST;
+    try {
+      ast = await OfficeParser.parseOffice(srcPath);
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      vscode.window.showErrorMessage(t('import.pptxReadFail', { 0: msg }));
+      return null;
+    }
+
+    const text = ast.toText().trim();
+    if (!text) {
+      vscode.window.showErrorMessage(t('import.pptxEmpty'));
+      return null;
+    }
+
+    return convertContentWithAI(text, context, buildSystemPrompt('freeform', existingRules));
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/prompt-builder.ts.html b/tests/coverage/src/rules/converters/prompt-builder.ts.html new file mode 100644 index 0000000..6cb5bb4 --- /dev/null +++ b/tests/coverage/src/rules/converters/prompt-builder.ts.html @@ -0,0 +1,1378 @@ + + + + + + Code coverage report for src/rules/converters/prompt-builder.ts + + + + + + + + + +
+
+

All files / src/rules/converters prompt-builder.ts

+
+ +
+ 88.16% + Statements + 380/431 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/5 +
+ + +
+ 88.16% + Lines + 380/431 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +4321x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import type { CustomRule } from '../../types';
+import { getLanguage } from '../../i18n/messages';
+import { buildKnownRulesSection } from './known-rules';
+ 
+export type PromptInputType = 'freeform' | 'spreadsheet';
+ 
+type Lang = 'zh-CN' | 'en' | 'ja';
+ 
+interface PromptStrings {
+  role: (inputDesc: string) => string;
+  inputToleranceTitle: string;
+  inputToleranceLines: string[];
+  nonRuleFilterTitle: string;
+  nonRuleFilterLines: string[];
+  fieldDefsTitle: string;
+  fieldDefsLines: string[];
+  languageRulesTitle: string;
+  languageRulesLines: string[];
+  exampleTitle: string;
+  example1Input: string;
+  example1Output: string;
+  example2Input: string;
+  example2Output: string;
+  staticAnalysisTitle: string;
+  staticAnalysisLines: string[];
+  finalInstruction: string;
+  outputLang: string;
+}
+ 
+const p: Record<Lang, PromptStrings> = {
+  'zh-CN': {
+    role: (inputDesc) => `你是一个代码审查规则转换器。将用户提供的${inputDesc},转换为结构化的 YAML 格式,用于代码审查工具。`,
+    inputToleranceTitle: '## 输入容忍说明',
+    inputToleranceLines: [
+      '用户输入可能有多种形态,你必须接受并处理以下任一形式:',
+      '- 自然语言段落(一段或多段话描述规则)',
+      '- 无序列表(每条规则一行或一段)',
+      '- 表格(列名不固定,从语义推断)',
+      '- 混合形式(段落 + 列表 + 表格组合)',
+      '',
+      '不得因输入格式非标准而拒绝转换。应主动从松散描述中提取规则语义。',
+      '',
+      '- 按输入文档的段落或列表项顺序处理,保持原文顺序输出',
+      '- 不要合并或拆分原文中已是独立条目的规则',
+      '- 一个段落包含多条规则时才拆分,单条规则不要拆成多条',
+    ],
+    nonRuleFilterTitle: '## 非规则内容过滤',
+    nonRuleFilterLines: [
+      '用户输入中可能混入项目介绍、背景说明、代码示例、章节标题等非规则内容。你必须:',
+      '- 识别并跳过非规则内容,仅将真正的编码规则转为 YAML 条目',
+      '- 代码示例、项目介绍等仅作为理解规则语义的上下文,自身不输出为规则',
+      '- 若某段内容无法判断为规则(既无规则意图也无违反提示),直接忽略,不强行转换',
+    ],
+    fieldDefsTitle: '## 字段定义',
+    fieldDefsLines: [
+      '每条规则需要包含以下字段:',
+      '- id: 规则唯一标识(kebab-case 英文,语义化、简短,如 no-console-log、avoid-magic-number)',
+      '  **必须**基于规则描述内容自动生成语义化的 id',
+      '  即使输入中无显式 id 标识,也必须根据 description/message 的语义推断出合适的 id',
+      '  多条规则之间 id 不得重复',
+      '  id 只能使用 description/message 中已有的英文单词或短语,转换为 kebab-case',
+      '  不要自行创造原文中没有的英文词汇',
+      '  如果输入全中文,从语义提取核心关键词翻译为简短英文(2-4 个词)',
+      '- severity: 严重级别(error / warning / info)',
+      '  **必须**输出。按规则语义推断:',
+      '  error: 会导致 bug / 安全问题 / 数据损坏',
+      '  warning: 潜在问题 / 不良实践',
+      '  info: 风格 / 可读性建议',
+      '  即使输入中无显式严重级别,也必须根据规则后果的严重程度推断',
+      '  如果无法从输入中确定严重级别,默认填 warning',
+      '  只有明确涉及安全、数据泄露、崩溃风险时才填 error',
+      '- description: 规则简短描述',
+      '  **必须**输出。若输入中不明显,从 message 的内容反向推导出简短描述',
+      '- message: 违反时的提示消息',
+      '  **必须**输出。若输入中不明显,从 description 的内容推导出违反提示',
+      '  description 与 message 语义可相近,无需强行区分口吻,但两者都必须填写',
+      '- languages: 适用语言数组(可选,如 [javascript, typescript])',
+      '- excludeLanguages: 明确排除的语言数组(可选,如 [css, sql])',
+      '- duplicateOf: 重复的规则 ID(linter 如 eslint/no-console;自定义如 custom/my-rule)',
+      '- duplicateLevel: 重复程度(exact / overlap / none)',
+      '- duplicateReason: 重复/重叠原因说明(overlap 档必填)',
+    ],
+    languageRulesTitle: '## 语言字段规则(严格遵守)',
+    languageRulesLines: [
+      '对于每条规则的 languages 字段:',
+      '1. 规则描述中含明确语言关键词(如 "Java"、"JavaScript"、"CSS")→ 使用 languages 白名单',
+      '2. 规则适用于大多数语言,只有少数不适用 → 使用 excludeLanguages 黑名单',
+      '3. 无法确定适用语言,或规则为通用规范 → languages 与 excludeLanguages 均留空',
+      '4. languages 和 excludeLanguages 不可同时非空',
+      '5. 语言名使用小写:java, javascript, typescript, css, sql, plsql, jsp',
+      '6. 严禁猜测。留空比猜测错误更安全。',
+    ],
+    exampleTitle: '## 输入输出示例',
+    example1Input: '不要用 console.log,生产环境会泄露信息。还有不要留下未使用的变量,看着乱。',
+    example1Output: [
+      '- id: no-console-log',
+      '  severity: warning',
+      '  description: 禁止使用 console.log',
+      '  message: 请使用 logger 工具替代 console.log',
+      '  duplicateLevel: none',
+      '- id: no-unused-vars',
+      '  severity: warning',
+      '  description: 禁止未使用的变量',
+      '  message: 未使用的变量应删除或注释',
+      '  duplicateOf: eslint/no-unused-vars',
+      '  duplicateLevel: exact',
+    ].join('\n'),
+    example2Input: [
+      '本项目是一个电商后台管理系统,主要使用 Java + Spring Boot 开发。',
+      '代码规范要求:Service 层方法必须有日志记录,方便排查问题。',
+      '示例代码:',
+      '  public void createOrder(Order order) { ... }',
+      '另外,Controller 层返回值统一用 Result 包装,不要直接返回 Map。',
+    ].join('\n'),
+    example2Output: [
+      '- id: require-service-logging',
+      '  severity: warning',
+      '  description: Service 层方法必须有日志记录',
+      '  message: Service 方法缺少日志记录,请补充以便排查问题',
+      '  languages: [java]',
+      '  duplicateLevel: none',
+      '- id: require-result-wrapper',
+      '  severity: warning',
+      '  description: Controller 返回值必须用 Result 包装',
+      '  message: 请用 Result 包装返回值,不要直接返回 Map',
+      '  languages: [java]',
+      '  duplicateLevel: none',
+    ].join('\n'),
+    staticAnalysisTitle: '## 静态分析重复检测',
+    staticAnalysisLines: [
+      '对于每条规则,判断其检测目标与触发条件是否与上述某个 linter 规则或自定义规则重复:',
+      '',
+      '- **exact**:检测目标与触发条件完全一致(会报出同样的问题行)→ 输出 duplicateOf + duplicateLevel: exact',
+      '- **overlap**:检测目标相同,但本规则有额外要求或更窄范围 → 输出 duplicateOf + duplicateLevel: overlap + duplicateReason',
+      '- **none**:检测目标不同 → 输出 duplicateLevel: none',
+      '',
+      '仅"话题相似"不算重复。例如:',
+      '- "未使用变量应删除" → exact(重复 eslint/no-unused-vars)',
+      '- "禁止在 console.log 中输出敏感信息" → none(检测目标不同)',
+      '',
+      '仅输出 YAML,不要额外说明。',
+    ],
+    finalInstruction: '只输出 YAML 内容,不要输出 markdown 代码块标记,不要输出解释性文字',
+    outputLang: '输出语言:zh-CN',
+  },
+ 
+  en: {
+    role: (inputDesc) => `You are a code review rule converter. Convert the ${inputDesc} provided by the user into structured YAML format for a code review tool. All descriptions and messages must be in English.`,
+    inputToleranceTitle: '## Input Tolerance',
+    inputToleranceLines: [
+      'User input may come in various forms. You must accept and process any of the following:',
+      '- Natural language paragraphs (one or more paragraphs describing rules)',
+      '- Unordered lists (one rule per line or paragraph)',
+      '- Tables (column names may vary; infer from semantics)',
+      '- Mixed forms (paragraphs + lists + tables)',
+      '',
+      'Do not reject conversion due to non-standard input format. Actively extract rule semantics from loose descriptions.',
+      '',
+      '- Process in the order of the input document paragraphs or list items, preserving original order',
+      '- Do not merge or split entries that are already independent rules in the original text',
+      '- Only split when a single paragraph contains multiple rules; do not split a single rule into multiple',
+    ],
+    nonRuleFilterTitle: '## Non-Rule Content Filtering',
+    nonRuleFilterLines: [
+      'User input may contain project introductions, background info, code examples, section titles, etc. You must:',
+      '- Identify and skip non-rule content; only convert actual coding rules into YAML entries',
+      '- Code examples, project descriptions etc. serve only as context for understanding rule semantics; do not output them as rules',
+      '- If content cannot be identified as a rule (no rule intent or violation hint), ignore it; do not force conversion',
+    ],
+    fieldDefsTitle: '## Field Definitions',
+    fieldDefsLines: [
+      'Each rule must include the following fields:',
+      '- id: Unique rule identifier (kebab-case English, semantic and concise, e.g., no-console-log, avoid-magic-number)',
+      '  **Must** generate a semantic id based on the rule description content',
+      '  Even if no explicit id is present in the input, infer a suitable id from the description/message semantics',
+      '  IDs must not be duplicated across rules',
+      '  id must use only English words or phrases already present in description/message, converted to kebab-case',
+      '  Do not invent English words not found in the original text',
+      '  If input is entirely in Chinese, extract core semantic keywords and translate to short English (2-4 words)',
+      '- severity: Severity level (error / warning / info)',
+      '  **Must** output. Infer based on rule semantics:',
+      '  error: causes bugs / security issues / data corruption',
+      '  warning: potential issues / bad practices',
+      '  info: style / readability suggestions',
+      '  Even if no explicit severity is given, infer from the rule\'s impact',
+      '  If severity cannot be determined from input, default to warning',
+      '  Only use error when the rule clearly involves security, data leakage, or crash risk',
+      '- description: Short rule description',
+      '  **Must** output. If not obvious from input, derive from message content',
+      '- message: Violation message',
+      '  **Must** output. If not obvious from input, derive from description content',
+      '  description and message may be semantically similar; no need to force different tones, but both must be filled',
+      '- languages: Applicable language array (optional, e.g., [javascript, typescript])',
+      '- excludeLanguages: Explicitly excluded language array (optional, e.g., [css, sql])',
+      '- duplicateOf: Duplicate rule ID (linter e.g., eslint/no-console; custom e.g., custom/my-rule)',
+      '- duplicateLevel: Duplicate level (exact / overlap / none)',
+      '- duplicateReason: Duplicate/overlap reason (required for overlap)',
+    ],
+    languageRulesTitle: '## Language Field Rules (Strict)',
+    languageRulesLines: [
+      'For each rule\'s languages field:',
+      '1. If rule description mentions specific languages (e.g., "Java", "JavaScript", "CSS") → use languages whitelist',
+      '2. If rule applies to most languages, with few exceptions → use excludeLanguages blacklist',
+      '3. If applicable language cannot be determined, or rule is general → leave both languages and excludeLanguages empty',
+      '4. languages and excludeLanguages must not both be non-empty simultaneously',
+      '5. Use lowercase language names: java, javascript, typescript, css, sql, plsql, jsp',
+      '6. Never guess. Leaving empty is safer than guessing incorrectly.',
+    ],
+    exampleTitle: '## Input/Output Examples',
+    example1Input: 'Do not use console.log, it leaks information in production. Also do not leave unused variables, they look messy.',
+    example1Output: [
+      '- id: no-console-log',
+      '  severity: warning',
+      '  description: Forbid using console.log',
+      '  message: Use a logger tool instead of console.log',
+      '  duplicateLevel: none',
+      '- id: no-unused-vars',
+      '  severity: warning',
+      '  description: Forbid unused variables',
+      '  message: Unused variables should be deleted or commented out',
+      '  duplicateOf: eslint/no-unused-vars',
+      '  duplicateLevel: exact',
+    ].join('\n'),
+    example2Input: [
+      'This project is an e-commerce backend, mainly using Java + Spring Boot.',
+      'Coding rules: Service layer methods must have logging for debugging.',
+      'Example code:',
+      '  public void createOrder(Order order) { ... }',
+      'Also, Controller layer return values should use Result wrapper, do not return Map directly.',
+    ].join('\n'),
+    example2Output: [
+      '- id: require-service-logging',
+      '  severity: warning',
+      '  description: Service layer methods must have logging',
+      '  message: Service method missing logging, add for debugging',
+      '  languages: [java]',
+      '  duplicateLevel: none',
+      '- id: require-result-wrapper',
+      '  severity: warning',
+      '  description: Controller return values must use Result wrapper',
+      '  message: Use Result wrapper for return values, do not return Map directly',
+      '  languages: [java]',
+      '  duplicateLevel: none',
+    ].join('\n'),
+    staticAnalysisTitle: '## Static Analysis Duplicate Detection',
+    staticAnalysisLines: [
+      'For each rule, determine whether its detection target and trigger conditions duplicate any linter rule or custom rule above:',
+      '',
+      '- **exact**: Detection target and trigger conditions are completely identical (would flag the same line) → output duplicateOf + duplicateLevel: exact',
+      '- **overlap**: Same detection target but this rule has additional requirements or narrower scope → output duplicateOf + duplicateLevel: overlap + duplicateReason',
+      '- **none**: Different detection targets → output duplicateLevel: none',
+      '',
+      '"Same topic" alone does not count as duplicate. For example:',
+      '- "Unused variables should be deleted" → exact (duplicate of eslint/no-unused-vars)',
+      '- "Do not output sensitive info in console.log" → none (different detection target)',
+      '',
+      'Output YAML only, no extra explanation.',
+    ],
+    finalInstruction: 'All descriptions and messages must be written in English.\nOutput YAML only, no markdown code fences, no explanatory text',
+    outputLang: 'Output language: en',
+  },
+ 
+  ja: {
+    role: (inputDesc) => `あなたはコードレビュールール変換ツールです。ユーザーが提供した${inputDesc}を、コードレビューツール用の構造化YAML形式に変換してください。すべての説明とメッセージは日本語で出力してください。`,
+    inputToleranceTitle: '## 入力許容について',
+    inputToleranceLines: [
+      'ユーザー入力は様々な形式である可能性があります。以下の形式を受け入れ、処理する必要があります:',
+      '- 自然言語の段落(1つ以上の段落でルールを記述)',
+      '- 順不同リスト(各ルールが1行または1段落)',
+      '- テーブル(列名は固定されていません。意味から推測してください)',
+      '- 混合形式(段落 + リスト + テーブルの組み込み)',
+      '',
+      '非標準的な入力形式であっても変換を拒否してはいけません。緩やかな記述からルールの意味を積極的に抽出してください。',
+      '',
+      '- 入力ドキュメントの段落またはリスト項目の順序で処理し、原文の順序を保持する',
+      '- 原文ですでに独立したエントリであるルールを結合または分割しない',
+      '- 単一の段落に複数のルールが含まれる場合のみ分割し、単一ルールを複数に分割しない',
+    ],
+    nonRuleFilterTitle: '## 非ルールコンテンツのフィルタリング',
+    nonRuleFilterLines: [
+      'ユーザー入力には、プロジェクト紹介、背景説明、コード例、セクションタイトルなどの非ルールコンテンツが混入している可能性があります。以下を行う必要があります:',
+      '- 非ルールコンテンツを識別してスキップし、実際のコーディングルールのみをYAMLエントリに変換する',
+      '- コード例やプロジェクト紹介などはルール意味理解のコンテキストとしてのみ使用し、これら自体をルールとして出力しない',
+      '- ルールと判断できない内容(ルール意図も違反のヒントもない場合)は無視し、無理に変換しない',
+    ],
+    fieldDefsTitle: '## フィールド定義',
+    fieldDefsLines: [
+      '各ルールには以下のフィールドが必要です:',
+      '- id: ルールの一意識別子(kebab-caseの英語、意味的で簡潔、例:no-console-log、avoid-magic-number)',
+      '  **必須** ルール説明内容に基づいて意味的なidを自動生成する',
+      '  入力に明示的なidがない場合でも、description/messageの意味から適切なidを推測する',
+      '  複数ルール間でidが重複してはいけない',
+      '  idはdescription/messageにすでに存在する英単語またはフレーズのみを使用し、kebab-caseに変換する',
+      '  原文にない英単語を独自に作成しない',
+      '  入力がすべて日本語の場合は、セマンティクスから核心キーワードを抽出し、短い英語(2〜4語)に翻訳する',
+      '- severity: 重大度(error / warning / info)',
+      '  **必須**で出力。ルールの意味に従って推測:',
+      '  error: バグ/セキュリティ問題/データ破損を引き起こす',
+      '  warning: 潜在的な問題/悪い慣行',
+      '  info: スタイル/可読性の提案',
+      '  入力に明示的な重大度がない場合でも、ルールの影響の重大さから推測する',
+      '  入力から重大度を判断できない場合は、デフォルトでwarningとする',
+      '  セキュリティ、データ漏洩、クラッシュリスクに明確に関連する場合のみerrorとする',
+      '- description: ルールの簡単な説明',
+      '  **必須**で出力。入力で不明確な場合、messageの内容から逆算して短い説明を導出',
+      '- message: 違反時のメッセージ',
+      '  **必須**で出力。入力で不明確な場合、descriptionの内容から違反メッセージを導出',
+      '  descriptionとmessageは意味的に近くても構いません。口調を無理に区別する必要はありませんが、両方とも必須です',
+      '- languages: 対象言語配列(オプション、例:[javascript, typescript])',
+      '- excludeLanguages: 明示的に除外する言語配列(オプション、例:[css, sql])',
+      '- duplicateOf: 重複するルールID(リンター例:eslint/no-console、カスタム例:custom/my-rule)',
+      '- duplicateLevel: 重複レベル(exact / overlap / none)',
+      '- duplicateReason: 重複/重複理由の説明(overlapの場合は必須)',
+    ],
+    languageRulesTitle: '## 言語フィールドルール(厳守)',
+    languageRulesLines: [
+      '各ルールのlanguagesフィールドについて:',
+      '1. ルール説明に明確な言語キーワードがある場合(例:「Java」「JavaScript」「CSS」)→ languagesにホワイトリストを使用',
+      '2. ルールがほとんどの言語に適用され、一部のみ適用外の場合 → excludeLanguagesにブラックリストを使用',
+      '3. 適用言語が判断できない場合、またはルールが汎用の場合 → languagesとexcludeLanguagesの両方を空にする',
+      '4. languagesとexcludeLanguagesは同時に空であってはいけない',
+      '5. 言語名は小文字を使用:java, javascript, typescript, css, sql, plsql, jsp',
+      '6. 推測は厳禁。空のままにする方が誤った推測より安全です。',
+    ],
+    exampleTitle: '## 入出力例',
+    example1Input: 'console.logは本番環境で情報漏洩するため使用しないでください。また、未使用の変数は残さないでください。散らかって見えます。',
+    example1Output: [
+      '- id: no-console-log',
+      '  severity: warning',
+      '  description: console.logの使用禁止',
+      '  message: loggerツールを使用してconsole.logを代替してください',
+      '  duplicateLevel: none',
+      '- id: no-unused-vars',
+      '  severity: warning',
+      '  description: 未使用変数の禁止',
+      '  message: 未使用の変数は削除またはコメントアウトしてください',
+      '  duplicateOf: eslint/no-unused-vars',
+      '  duplicateLevel: exact',
+    ].join('\n'),
+    example2Input: [
+      '本プロジェクトはECサイト管理システムで、主にJava + Spring Bootを使用しています。',
+      'コード規約:Service層のメソッドには必ずログ記録が必要です。問題調査のためです。',
+      'コード例:',
+      '  public void createOrder(Order order) { ... }',
+      'また、Controller層の戻り値は統一してResultでラップし、Mapを直接返さないでください。',
+    ].join('\n'),
+    example2Output: [
+      '- id: require-service-logging',
+      '  severity: warning',
+      '  description: Service層メソッドにはログ記録が必須',
+      '  message: Serviceメソッドにログ記録がありません。問題調査のため追加してください',
+      '  languages: [java]',
+      '  duplicateLevel: none',
+      '- id: require-result-wrapper',
+      '  severity: warning',
+      '  description: Controllerの戻り値はResultでラップすること',
+      '  message: Resultで戻り値をラップし、Mapを直接返さないでください',
+      '  languages: [java]',
+      '  duplicateLevel: none',
+    ].join('\n'),
+    staticAnalysisTitle: '## 静的解析重複検出',
+    staticAnalysisLines: [
+      '各ルールについて、その検出対象とトリガー条件が上記のリンタールールまたはカスタムルールと重複するか判断:',
+      '',
+      '- **exact**: 検出対象とトリガー条件が完全に一致(同じ問題行を報告する)→ duplicateOf + duplicateLevel: exact を出力',
+      '- **overlap**: 検出対象は同じだが、このルールに追加要件やより狭い範囲がある → duplicateOf + duplicateLevel: overlap + duplicateReason を出力',
+      '- **none**: 検出対象が異なる → duplicateLevel: none を出力',
+      '',
+      '単に「トピックが類似している」だけでは重複とみなされません。例:',
+      '- 「未使用変数は削除すべき」→ exact(eslint/no-unused-varsと重複)',
+      '- 「console.logで機密情報を出力しない」→ none(検出対象が異なる)',
+      '',
+      'YAMLのみを出力し、追加説明は不要です。',
+    ],
+    finalInstruction: 'すべてのdescriptionとmessageは日本語で出力してください。\nYAML のみ出力、マークダウンコードブロックなし、説明テキストなし',
+    outputLang: '出力言語:ja',
+  },
+};
+ 
+function getLang(): Lang {
+  const lang = getLanguage();
+  if (lang === 'en' || lang === 'ja') { return lang; }
+  return 'zh-CN';
+}
+ 
+export function buildSystemPrompt(inputType: PromptInputType, existingRules?: CustomRule[]): string {
+  const lang = getLang();
+  const s = p[lang];
+
+  const inputDesc = inputType === 'spreadsheet'
+    ? (lang === 'zh-CN' ? '表格规则数据' : lang === 'ja' ? '表形式のルールデータ' : 'spreadsheet rule data')
+    : (lang === 'zh-CN' ? '自然语言规则描述' : lang === 'ja' ? '自然言語のルール記述' : 'natural language rule description');
+
+  const parts: string[] = [
+    s.role(inputDesc),
+    '',
+    s.inputToleranceTitle,
+    ...s.inputToleranceLines,
+    '',
+    s.nonRuleFilterTitle,
+    ...s.nonRuleFilterLines,
+    '',
+    s.fieldDefsTitle,
+    ...s.fieldDefsLines,
+    '',
+    s.languageRulesTitle,
+    ...s.languageRulesLines,
+    '',
+    s.exampleTitle,
+    '',
+    `${lang === 'zh-CN' ? '示例 1 — 松散段落输入:' : lang === 'ja' ? '例1 — 緩やかな段落入力:' : 'Example 1 — Loose paragraph input:'}`,
+    `${lang === 'zh-CN' ? '输入' : lang === 'ja' ? '入力' : 'Input'}:${s.example1Input}`,
+    `${lang === 'zh-CN' ? '输出' : lang === 'ja' ? '出力' : 'Output'}:`,
+    s.example1Output,
+    '',
+    `${lang === 'zh-CN' ? '示例 2 — 含非规则内容的混合输入:' : lang === 'ja' ? '例2 — 非ルールコンテンツを含む混合入力:' : 'Example 2 — Mixed input with non-rule content:'}`,
+    `${lang === 'zh-CN' ? '输入' : lang === 'ja' ? '入力' : 'Input'}:${s.example2Input}`,
+    `${lang === 'zh-CN' ? '输出' : lang === 'ja' ? '出力' : 'Output'}:`,
+    s.example2Output,
+    '',
+    buildKnownRulesSection(existingRules),
+    '',
+    s.staticAnalysisTitle,
+    ...s.staticAnalysisLines,
+    '',
+    s.finalInstruction,
+    s.outputLang,
+  ];
+
+  return parts.join('\n');
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/template-converter.ts.html b/tests/coverage/src/rules/converters/template-converter.ts.html new file mode 100644 index 0000000..bf0ac09 --- /dev/null +++ b/tests/coverage/src/rules/converters/template-converter.ts.html @@ -0,0 +1,439 @@ + + + + + + Code coverage report for src/rules/converters/template-converter.ts + + + + + + + + + +
+
+

All files / src/rules/converters template-converter.ts

+
+ +
+ 15.25% + Statements + 18/118 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/3 +
+ + +
+ 15.25% + Lines + 18/118 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +1192x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import * as path from 'path';
+import * as XLSX from 'xlsx';
+import type { ImportableRule, ValidationIssue } from '../import-types';
+import type { Severity } from '../../types';
+import { t } from '../../i18n/messages';
+ 
+const REQUIRED_HEADERS = ['id', 'severity', 'description', 'message'];
+const VALID_SEVERITY = ['error', 'warning', 'info'];
+ 
+function splitList(v: unknown): string[] {
+  const s = String(v ?? '').trim();
+  if (!s) { return []; }
+  return s.split(/[,;、\n]/).map(x => x.trim()).filter(Boolean);
+}
+ 
+export interface TemplateParseResult {
+  rules: ImportableRule[];
+  validRules: ImportableRule[];
+  yamlContent: string;
+  skippedCount: number;
+}
+ 
+export function parseTemplate(srcPath: string): TemplateParseResult {
+  const ext = path.extname(srcPath).toLowerCase();
+  if (!['.xlsx', '.xls'].includes(ext)) {
+    throw new Error(t('import.template.badFormat'));
+  }
+  let wb: XLSX.WorkBook;
+  try { wb = XLSX.readFile(srcPath); }
+  catch { throw new Error(t('import.template.badFormat')); }
+
+  const sheet = wb.Sheets[wb.SheetNames[0]];
+  const rows = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: '' });
+  if (rows.length === 0) {
+    throw new Error(t('import.template.empty'));
+  }
+  const header = Object.keys(rows[0]).map(k => k.trim().toLowerCase());
+  const missing = REQUIRED_HEADERS.filter(h => !header.includes(h));
+  if (missing.length > 0) {
+    throw new Error(t('import.template.notTemplate', { 0: missing.join(', ') }));
+  }
+
+  const totalRows = rows.length;
+  const rules: ImportableRule[] = rows
+    .map((r, idx) => ({ r, rowNo: idx + 2 }))
+    .filter(({ r }) => {
+      const id = String(r.id ?? '').trim();
+      const description = String(r.description ?? '').trim();
+      const message = String(r.message ?? '').trim();
+      return id !== '' || description !== '' || message !== '';
+    })
+    .map(({ r, rowNo }) => {
+      const issues: ValidationIssue[] = [];
+
+      const rawId = String(r.id ?? '').trim();
+      let id = rawId;
+      let idPlaceholder = false;
+      if (!rawId) {
+        id = `rule-${rowNo}`;
+        idPlaceholder = true;
+        issues.push({ field: 'id', severity: 'error', message: t('import.idMissing') });
+      }
+
+      const sevRaw = String(r.severity ?? '').trim().toLowerCase();
+      const originalSeverity = String(r.severity ?? '').trim();
+      const severity: Severity = VALID_SEVERITY.includes(sevRaw) ? (sevRaw as Severity) : 'warning';
+      if (!VALID_SEVERITY.includes(sevRaw)) {
+        const detail = originalSeverity ? `: "${originalSeverity}"` : '';
+        issues.push({ field: 'severity', severity: 'warning', message: `${t('import.validationSeverityInvalid')}${detail}` });
+      }
+
+      const description = String(r.description ?? '').trim();
+      if (!description) {
+        issues.push({ field: 'description', severity: 'error', message: 'description 为空' });
+      }
+
+      const message = String(r.message ?? '').trim();
+      if (!message) {
+        issues.push({ field: 'message', severity: 'error', message: 'message 为空' });
+      }
+
+      return {
+        id,
+        severity,
+        description,
+        message,
+        languages: splitList(r.languages),
+        excludeLanguages: splitList(r.excludeLanguages),
+        rowNumber: rowNo,
+        originalSeverity,
+        idPlaceholder,
+        validationIssues: issues.length > 0 ? issues : undefined,
+      };
+    });
+
+  const validRules = rules.filter(r => !r.validationIssues);
+  const yamlContent = buildYaml(validRules);
+  const skippedCount = totalRows - rules.length;
+
+  return { rules, validRules, yamlContent, skippedCount };
+}
+ 
+function buildYaml(rules: ImportableRule[]): string {
+  const lines: string[] = [];
+  for (const r of rules) {
+    lines.push(`- id: ${r.id}`);
+    lines.push(`  severity: ${r.severity}`);
+    lines.push(`  description: ${r.description}`);
+    lines.push(`  message: ${r.message}`);
+    if (r.languages?.length) {
+      lines.push(`  languages: [${r.languages.join(', ')}]`);
+    }
+    if (r.excludeLanguages?.length) {
+      lines.push(`  excludeLanguages: [${r.excludeLanguages.join(', ')}]`);
+    }
+  }
+  return lines.join('\n');
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/txt-converter.ts.html b/tests/coverage/src/rules/converters/txt-converter.ts.html new file mode 100644 index 0000000..92ff666 --- /dev/null +++ b/tests/coverage/src/rules/converters/txt-converter.ts.html @@ -0,0 +1,130 @@ + + + + + + Code coverage report for src/rules/converters/txt-converter.ts + + + + + + + + + +
+
+

All files / src/rules/converters txt-converter.ts

+
+ +
+ 80% + Statements + 12/15 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 50% + Functions + 1/2 +
+ + +
+ 80% + Lines + 12/15 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +161x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +1x + 
import * as fs from 'fs';
+import * as vscode from 'vscode';
+import { RuleConverter } from './converter';
+import { convertContentWithAI } from '../import-service';
+import { buildSystemPrompt } from './prompt-builder';
+import type { CustomRule } from '../../types';
+ 
+export class TxtConverter implements RuleConverter {
+  supportedExtensions = ['.txt'];
+ 
+  async convert(srcPath: string, context: vscode.ExtensionContext, existingRules?: CustomRule[]): Promise<string | null> {
+    const content = fs.readFileSync(srcPath, 'utf-8');
+    return convertContentWithAI(content, context, buildSystemPrompt('freeform', existingRules));
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/converters/yaml-converter.ts.html b/tests/coverage/src/rules/converters/yaml-converter.ts.html new file mode 100644 index 0000000..35bdde8 --- /dev/null +++ b/tests/coverage/src/rules/converters/yaml-converter.ts.html @@ -0,0 +1,121 @@ + + + + + + Code coverage report for src/rules/converters/yaml-converter.ts + + + + + + + + + +
+
+

All files / src/rules/converters yaml-converter.ts

+
+ +
+ 83.33% + Statements + 10/12 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 50% + Functions + 1/2 +
+ + +
+ 83.33% + Lines + 10/12 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +131x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +1x + 
import * as fs from 'fs';
+import * as vscode from 'vscode';
+import { RuleConverter } from './converter';
+import type { CustomRule } from '../../types';
+ 
+export class YamlConverter implements RuleConverter {
+  supportedExtensions = ['.yaml', '.yml'];
+ 
+  async convert(srcPath: string, _context: vscode.ExtensionContext, _existingRules?: CustomRule[]): Promise<string | null> {
+    return fs.readFileSync(srcPath, 'utf-8');
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/export-service.ts.html b/tests/coverage/src/rules/export-service.ts.html new file mode 100644 index 0000000..f71a3a9 --- /dev/null +++ b/tests/coverage/src/rules/export-service.ts.html @@ -0,0 +1,265 @@ + + + + + + Code coverage report for src/rules/export-service.ts + + + + + + + + + +
+
+

All files / src/rules export-service.ts

+
+ +
+ 45% + Statements + 27/60 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 45% + Lines + 27/60 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +611x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import * as XLSX from 'xlsx';
+import * as vscode from 'vscode';
+import { t } from '../i18n/messages';
+ 
+const RULES_HEADER = ['id', 'severity', 'description', 'message', 'languages', 'excludeLanguages'];
+const RULES_EXAMPLE = [
+  'no-todo', 'warning', '禁止提交 TODO 注释',
+  '发现 TODO 注释,请清理后提交', 'javascript,typescript', '',
+];
+ 
+const GUIDE_AOA: string[][] = [
+  ['字段', '含义', '取值/格式', '示例'],
+  ['id', '规则唯一标识', '小写字母、数字、连字符,全局唯一', 'no-todo'],
+  ['severity', '严重级别', 'error / warning / info', 'warning'],
+  ['description', '规则简述(给人看)', '自由文本', '禁止提交 TODO 注释'],
+  ['message', '命中时展示给开发者的提示语', '自由文本', '发现 TODO 注释,请清理后提交'],
+  ['languages', '生效的语言', '逗号分隔,留空表示对所有语言生效', 'javascript,typescript'],
+  ['excludeLanguages', '排除的语言', '逗号分隔,可留空', ''],
+  ['', '', '', ''],
+  ['填写说明', '', '', ''],
+  ['1. severity 仅接受 error / warning / info 三个值', '', '', ''],
+  ['2. languages / excludeLanguages 多值用英文逗号分隔', '', '', ''],
+  ['3. 示例行可删除,仅作填写参考', '', '', ''],
+  ['4. 该模板可直接用于「使用模板文件导入」功能回环校验', '', '', ''],
+];
+ 
+export async function exportTemplate(): Promise<void> {
+  const uri = await vscode.window.showSaveDialog({
+    defaultUri: vscode.Uri.file('code-review-rules-template.xlsx'),
+    filters: { 'Excel': ['xlsx'] },
+    saveLabel: t('exportTemplate.saveLabel'),
+  });
+  if (!uri) { return; }
+
+  const wb = XLSX.utils.book_new();
+
+  const wsRules = XLSX.utils.aoa_to_sheet([RULES_HEADER, RULES_EXAMPLE]);
+  wsRules['!cols'] = [
+    { wch: 16 }, { wch: 10 }, { wch: 32 }, { wch: 40 }, { wch: 24 }, { wch: 20 },
+  ];
+  XLSX.utils.book_append_sheet(wb, wsRules, '规则');
+
+  const wsGuide = XLSX.utils.aoa_to_sheet(GUIDE_AOA);
+  wsGuide['!cols'] = [{ wch: 22 }, { wch: 28 }, { wch: 42 }, { wch: 36 }];
+  XLSX.utils.book_append_sheet(wb, wsGuide, '说明');
+
+  try {
+    XLSX.writeFile(wb, uri.fsPath);
+    const openFolder = t('exportTemplate.openFolder');
+    const choice = await vscode.window.showInformationMessage(
+      t('exportTemplate.success'), openFolder,
+    );
+    if (choice === openFolder) {
+      vscode.commands.executeCommand('revealFileInOS', uri);
+    }
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    vscode.window.showErrorMessage(t('exportTemplate.fail', { 0: msg }));
+  }
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/import-preview.ts.html b/tests/coverage/src/rules/import-preview.ts.html new file mode 100644 index 0000000..d2e2292 --- /dev/null +++ b/tests/coverage/src/rules/import-preview.ts.html @@ -0,0 +1,3544 @@ + + + + + + Code coverage report for src/rules/import-preview.ts + + + + + + + + + +
+
+

All files / src/rules import-preview.ts

+
+ +
+ 1.99% + Statements + 23/1153 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/4 +
+ + +
+ 1.99% + Lines + 23/1153 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +11541x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import * as vscode from 'vscode';
+import type { ConversionResult, PreviewDecision, ImportableRule, ValidationIssue } from './import-types';
+import { dedupSingleRule } from './import-service';
+import { loadActiveRules } from './yaml-parser';
+import staticRules from './static-rules.json';
+import { t, getLanguage } from '../i18n/messages';
+ 
+export async function showImportPreview(
+  result: ConversionResult,
+  context: vscode.ExtensionContext,
+): Promise<PreviewDecision | null> {
+  return new Promise((resolve) => {
+    const panel = vscode.window.createWebviewPanel(
+      'ruleImportPreview',
+      t('importPreview.title'),
+      vscode.ViewColumn.Active,
+      { enableScripts: true },
+    );
+
+    const keepRule: Record<string, boolean> = {};
+    for (const rule of result.rules) {
+      if (!rule.validationIssues?.length) {
+        keepRule[rule.id] = rule.duplicateLevel !== 'exact';
+      }
+    }
+
+    panel.webview.html = renderPreviewHtml(result, keepRule);
+
+    panel.webview.onDidReceiveMessage(async (msg) => {
+      if (msg.type === 'toggleRule') {
+        keepRule[msg.ruleId] = msg.keep;
+      } else if (msg.type === 'addErrorRule') {
+        await handleAddErrorRule(msg, result, keepRule, context, panel);
+      } else if (msg.type === 'confirm') {
+        resolve({
+          keepRule,
+          confirmed: true,
+          editedRules: msg.editedRules as ImportableRule[] | undefined,
+        });
+        panel.dispose();
+      } else if (msg.type === 'cancel') {
+        resolve(null);
+        panel.dispose();
+      }
+    });
+
+    panel.onDidDispose(() => resolve(null));
+  });
+}
+ 
+interface RuleValidationError {
+  field: 'id' | 'severity' | 'description' | 'message';
+  message: string;
+}
+ 
+function validateRule(rule: ImportableRule): RuleValidationError | null {
+  if (!rule.id || !rule.id.trim()) {
+    return { field: 'id', message: t('import.validationIdEmpty') };
+  }
+  if (!['error', 'warning', 'info'].includes(rule.severity)) {
+    return { field: 'severity', message: t('import.validationSeverityInvalid') };
+  }
+  if (!rule.description || !rule.description.trim()) {
+    return { field: 'description', message: t('import.validationDescEmpty', { 0: rule.id }) };
+  }
+  if (!rule.message || !rule.message.trim()) {
+    return { field: 'message', message: t('import.validationMsgEmpty', { 0: rule.id }) };
+  }
+  return null;
+}
+ 
+async function handleAddErrorRule(
+  msg: {
+    ruleId: string;
+    rule: ImportableRule;
+  },
+  result: ConversionResult,
+  keepRule: Record<string, boolean>,
+  context: vscode.ExtensionContext,
+  panel: vscode.WebviewPanel,
+): Promise<void> {
+  const rule = msg.rule;
+
+  const validationError = validateRule(rule);
+  if (validationError) {
+    panel.webview.postMessage({
+      type: 'addError',
+      ruleId: msg.ruleId,
+      field: validationError.field,
+      message: validationError.message,
+    });
+    return;
+  }
+
+  const original = result.rules.find(r => r.id === msg.ruleId);
+  if (original?.idPlaceholder && rule.id === msg.ruleId) {
+    panel.webview.postMessage({
+      type: 'addError',
+      ruleId: msg.ruleId,
+      field: 'id',
+      message: t('import.idMissing'),
+    });
+    return;
+  }
+
+  const conflict = result.rules.some(r =>
+    !r.validationIssues?.length && r.id.toLowerCase() === rule.id.toLowerCase()
+  );
+  if (conflict) {
+    panel.webview.postMessage({
+      type: 'addError',
+      ruleId: msg.ruleId,
+      field: 'id',
+      message: t('import.idConflict', { 0: rule.id }),
+    });
+    return;
+  }
+
+  const dedup = await dedupSingleRule(rule, context);
+  const level = dedup?.duplicateLevel ?? 'none';
+  const dedupFailed = !dedup;
+
+  const idx = result.rules.findIndex(r => r.id === msg.ruleId);
+  if (idx >= 0) {
+    result.rules[idx] = {
+      ...result.rules[idx],
+      id: rule.id,
+      severity: rule.severity,
+      description: rule.description,
+      message: rule.message,
+      languages: rule.languages,
+      excludeLanguages: rule.excludeLanguages,
+      duplicateLevel: level,
+      duplicateOf: dedup?.duplicateOf,
+      duplicateReason: dedup?.duplicateReason,
+      validationIssues: undefined,
+    };
+  }
+
+  keepRule[rule.id] = level !== 'exact';
+
+  panel.webview.postMessage({
+    type: 'ruleAdded',
+    ruleId: msg.ruleId,
+    id: rule.id,
+    duplicateLevel: level,
+    duplicateOf: dedup?.duplicateOf,
+    duplicateReason: dedup?.duplicateReason,
+    dedupFailed,
+  });
+}
+ 
+const SEVERITY_OPTIONS = ['error', 'warning', 'info'];
+const SEVERITY_COLORS: Record<string, string> = {
+  error: '#f48771',
+  warning: '#d29922',
+  info: '#58a6ff',
+};
+ 
+function renderPreviewHtml(
+  result: ConversionResult,
+  keepRule: Record<string, boolean>,
+): string {
+  const errorRules = result.rules.filter(r => r.validationIssues?.length);
+  const cleanRules = result.rules.filter(r => !r.validationIssues?.length);
+
+  const exactRules = cleanRules.filter(r => r.duplicateLevel === 'exact');
+  const overlapRules = cleanRules.filter(r => r.duplicateLevel === 'overlap');
+  const noneRules = cleanRules.filter(
+    r => r.duplicateLevel !== 'exact' && r.duplicateLevel !== 'overlap'
+  );
+
+  const totalKept = Object.values(keepRule).filter(Boolean).length;
+  const totalCommented = Object.values(keepRule).filter(v => !v).length;
+
+  const skippedHint = result.skippedCount
+    ? `<div class="summary-bar" style="border-color:rgba(88,166,255,0.3);color:#58a6ff;">${t('import.template.skipped', { 0: String(result.skippedCount) })}</div>`
+    : '';
+
+  function dupLabel(dupOf: string | undefined): string {
+    return dupOf?.startsWith('custom/')
+      ? `${t('import.customRulePrefix')} ${dupOf.slice(7)}`
+      : (dupOf ?? 'unknown');
+  }
+
+  const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+  const customRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
+
+  function resolveDupDescription(dupOf: string | undefined): string | undefined {
+    if (!dupOf) { return undefined; }
+    if (dupOf.startsWith('custom/')) {
+      const id = dupOf.slice(7);
+      return customRules.find(r => r.id === id)?.description;
+    }
+    const slash = dupOf.indexOf('/');
+    const linter = slash > 0 ? dupOf.slice(0, slash) : '';
+    const linterRules = (staticRules.rules as Record<string, Array<{ id: string; description: string; descriptionZh?: string; descriptionJa?: string }>>)[linter];
+    const rule = linterRules?.find(r => r.id === dupOf);
+    if (!rule) { return undefined; }
+    const lang = getLanguage();
+    if (lang === 'zh-CN' && rule.descriptionZh) {
+      return `${rule.description} (${rule.descriptionZh})`;
+    }
+    if (lang === 'ja' && rule.descriptionJa) {
+      return `${rule.description} (${rule.descriptionJa})`;
+    }
+    return rule.description;
+  }
+
+  function renderRuleCard(rule: ImportableRule, isError = false): string {
+    const kept = keepRule[rule.id];
+    const sevIssue = !!rule.validationIssues?.some(i => i.field === 'severity');
+    const color = sevIssue ? '#f48771' : (SEVERITY_COLORS[rule.severity] || '#8b949e');
+
+    let duplicateInfo = '';
+    let statusBadge = '';
+    if (!isError) {
+      if (rule.duplicateLevel === 'exact') {
+        const dupDesc = resolveDupDescription(rule.duplicateOf);
+        duplicateInfo = `
+          <div class="dup-banner dup-exact">
+            <div class="dup-banner-title">${t('import.dupExactTitle')}</div>
+            <div class="dup-banner-text">${t('import.dupExactText', { 0: dupLabel(rule.duplicateOf) })}</div>
+            ${dupDesc ? `<div class="dup-banner-desc">${t('import.dupDescriptionLabel', { 0: dupDesc })}</div>` : ''}
+            <div class="dup-banner-hint">${t('import.dupExactHint')}</div>
+          </div>
+        `;
+        statusBadge = `<span class="badge badge-exact">${kept ? t('import.badgeRestored') : t('import.badgeWillComment')}</span>`;
+      } else if (rule.duplicateLevel === 'overlap') {
+        const dupDesc = resolveDupDescription(rule.duplicateOf);
+        duplicateInfo = `
+          <div class="dup-banner dup-overlap">
+            <div class="dup-banner-title">${t('import.dupOverlapTitle')}</div>
+            <div class="dup-banner-text">${t('import.dupOverlapText', { 0: dupLabel(rule.duplicateOf) })}</div>
+            ${dupDesc ? `<div class="dup-banner-desc">${t('import.dupDescriptionLabel', { 0: dupDesc })}</div>` : ''}
+            ${rule.duplicateReason ? `<div class="dup-banner-reason">${t('import.overlapReason', { 0: rule.duplicateReason })}</div>` : ''}
+          </div>
+        `;
+        statusBadge = `<span class="badge badge-overlap">${kept ? t('importPreview.keep') : t('importPreview.comment')}</span>`;
+      } else {
+        statusBadge = `<span class="badge badge-none">${t('importPreview.keep')}</span>`;
+      }
+    } else {
+      statusBadge = `<span class="badge" style="background:rgba(248,81,73,0.15);color:#f48771;">${t('import.cannotImport')}</span>`;
+    }
+
+    const tagDisplay = (tags: string[] | undefined) => tags && tags.length > 0 ? tags.map(tag => `<span class="tag" data-value="${tag}">${tag}<span class="tag-remove" data-tag="${tag}">×</span></span>`).join('') : '';
+
+    const issueByField = new Map<string, ValidationIssue>();
+    if (isError) {
+      for (const i of rule.validationIssues || []) {
+        if (!issueByField.has(i.field)) {
+          issueByField.set(i.field, i);
+        }
+      }
+    }
+    const issueCls = (field: string) => issueByField.has(field) ? ' field-error' : '';
+    const inputCls = (field: string) => issueByField.has(field) ? 'field-error-input ' : '';
+    const issueMsg = (field: string) => issueByField.has(field)
+      ? `<div class="field-error-msg">${t('import.issuePrefix')} ${issueByField.get(field)!.message}</div>`
+      : '';
+
+    const actionArea = isError
+      ? `<button class="add-btn" data-addbtn="${rule.id}" onclick="event.stopPropagation();addErrorRule(this)">${t('import.add')}</button>`
+      : `<div class="keep-toggle">
+          <button class="toggle-btn ${kept ? 'active' : ''}" data-action="keep" onclick="event.stopPropagation();toggleKeep(this, true)">${t('importPreview.keep')}</button>
+          <button class="toggle-btn ${!kept ? 'active' : ''}" data-action="comment" onclick="event.stopPropagation();toggleKeep(this, false)">${t('importPreview.comment')}</button>
+        </div>`;
+
+    const bodyDisplay = isError ? 'block' : 'none';
+    const expandIcon = '▼';
+
+    return `
+      <div class="rule-card${isError ? ' expanded' : ''}" data-ruleid="${rule.id}"${isError ? ' data-error="true"' : ''}>
+        <div class="rule-card-header" onclick="toggleCard(this)">
+          <div class="rule-card-summary">
+            <input class="rule-id-input${/^rule-\d+$/.test(rule.id) ? ' placeholder-id' : ''}" value="${rule.id}" onchange="syncId(this)" onclick="event.stopPropagation()">
+            <span class="rule-severity-tag" style="background:${color}20;color:${color};border:1px solid ${color}40;">${sevIssue ? (rule.originalSeverity || t('import.severityMissing')) : rule.severity}</span>
+            <span class="rule-desc-preview">${rule.description}</span>
+          </div>
+          <div class="rule-card-meta">
+            ${statusBadge}
+            <span class="expand-icon">${expandIcon}</span>
+          </div>
+        </div>
+
+        <div class="rule-card-body" id="body-${rule.id}" style="display:${bodyDisplay};">
+          <div class="edit-header">
+            <div class="id-row">
+              <span class="id-display-label">${t('import.idLabel')}</span>
+              <input class="id-display-input${/^rule-\d+$/.test(rule.id) ? ' placeholder-id' : ''}" value="${rule.id}" onchange="syncId(this)">
+              ${/^rule-\d+$/.test(rule.id) ? `<span class="placeholder-hint">${t('import.placeholderIdHint')}</span>` : ''}
+            </div>
+            ${actionArea}
+          </div>
+
+          ${duplicateInfo}
+
+          <div class="edit-field${issueCls('severity')}">
+            <label>${t('import.severityLabel')}</label>
+            <select class="${inputCls('severity')}" onchange="updateRule(this,'severity',this.value)">
+              ${sevIssue ? `<option value="" disabled selected>${t('import.severitySelectHint')}</option>` : ''}
+              ${SEVERITY_OPTIONS.map(s => `<option value="${s}" ${!sevIssue && s === rule.severity ? 'selected' : ''}>${s}</option>`).join('')}
+            </select>
+            ${issueMsg('severity')}
+          </div>
+
+          <div class="edit-field${issueCls('description')}">
+            <label>${t('import.descriptionLabel')}</label>
+            <textarea rows="2" class="${inputCls('description')}" onchange="updateRule(this,'description',this.value)">${rule.description}</textarea>
+            ${issueMsg('description')}
+          </div>
+
+          <div class="edit-field${issueCls('message')}">
+            <label>${t('import.messageLabel')}</label>
+            <textarea rows="2" class="${inputCls('message')}" onchange="updateRule(this,'message',this.value)">${rule.message}</textarea>
+            ${issueMsg('message')}
+          </div>
+
+          <div class="edit-field">
+            <label>${t('import.languagesLabel')}</label>
+            <div class="tag-input-wrapper">
+              <div class="tag-list" data-ruleid="${rule.id}" data-field="languages">
+                ${tagDisplay(rule.languages)}
+              </div>
+              <input class="tag-input" data-ruleid="${rule.id}" data-field="languages" placeholder="${t('import.tagPlaceholder')}" value="">
+            </div>
+          </div>
+
+          <div class="edit-field">
+            <label>${t('import.excludeLanguagesLabel')}</label>
+            <div class="tag-input-wrapper">
+              <div class="tag-list" data-ruleid="${rule.id}" data-field="excludeLanguages">
+                ${tagDisplay(rule.excludeLanguages)}
+              </div>
+              <input class="tag-input" data-ruleid="${rule.id}" data-field="excludeLanguages" placeholder="${t('import.tagPlaceholder')}" value="">
+            </div>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  function renderErrorSection(rules: ImportableRule[]): string {
+    const sectionId = 'section-error';
+    return `
+      <div class="section-wrapper expanded" data-section-wrap="error" style="margin-bottom:12px;${rules.length === 0 ? 'display:none;' : ''}">
+        <div class="section-header" onclick="toggleSection('${sectionId}')">
+          <span style="font-size:14px;">🚫</span>
+          <span class="section-title" data-section-title="error"></span>
+          <span class="section-arrow">▼</span>
+        </div>
+        <div id="${sectionId}" data-section="error" style="display:block;">
+          ${rules.map(r => renderRuleCard(r, true)).join('')}
+        </div>
+      </div>
+    `;
+  }
+
+  function renderSection(title: string, icon: string, key: string, rules: ImportableRule[]): string {
+    const sectionId = `section-${key}`;
+    const count = rules.length;
+    const show = rules.some(r => keepRule[r.id] !== undefined);
+    return `
+      <div class="section-wrapper${show ? ' expanded' : ''}" data-section-wrap="${key}" style="margin-bottom:12px;${count === 0 ? 'display:none;' : ''}">
+        <div class="section-header" onclick="toggleSection('${sectionId}')">
+          <span style="font-size:14px;">${icon}</span>
+          <span class="section-title" data-section-title="${key}"></span>
+          <span class="section-arrow">▼</span>
+        </div>
+        <div id="${sectionId}" data-section="${key}" style="display:${show ? 'block' : 'none'};">
+          ${rules.map(r => renderRuleCard(r)).join('')}
+        </div>
+      </div>
+    `;
+  }
+
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<style>
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body {
+  font-family: var(--vscode-font-family);
+  font-size: var(--vscode-font-size);
+  color: var(--vscode-foreground);
+  background: var(--vscode-editor-background);
+  padding: 16px; line-height: 1.5;
+}
+.header {
+  border-bottom: 1px solid var(--vscode-panel-border);
+  padding-bottom: 12px; margin-bottom: 12px;
+}
+.header-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
+.header-sub { font-size: 12px; color: var(--vscode-descriptionForeground); }
+.summary {
+  display: flex; gap: 12px; margin-bottom: 16px;
+}
+.summary-item {
+  padding: 8px 12px; border-radius: 6px; font-size: 12px;
+  border: 1px solid var(--vscode-panel-border);
+  background: var(--vscode-sideBar-background, var(--vscode-editor-background));
+}
+.summary-bar {
+  margin-bottom: 16px; padding: 8px 12px; border-radius: 6px;
+  font-size: 12px; background: rgba(139,92,246,0.1);
+  border: 1px solid rgba(139,92,246,0.3); color: #a78bfa;
+}
+.summary-bar.warn {
+  border-color: rgba(210,153,34,0.3); color: #d29922;
+  background: rgba(210,153,34,0.1);
+}
+.actions {
+  display: flex; gap: 8px; padding-top: 12px;
+  border-top: 1px solid var(--vscode-panel-border);
+  justify-content: flex-end;
+}
+.btn {
+  padding: 6px 16px; border: 1px solid var(--vscode-panel-border);
+  background: var(--vscode-button-secondaryBackground);
+  color: var(--vscode-button-secondaryForeground);
+  border-radius: 6px; cursor: pointer; font-size: 12px;
+}
+.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
+.btn-primary { background: #7c3aed; color: #fff; border-color: #7c3aed; }
+.btn-primary:hover { background: #8b5cf6; }
+.section-header {
+  display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
+  cursor: pointer; padding: 4px 0;
+}
+.section-title { font-weight: 600; font-size: 13px; }
+.section-arrow {
+  display: inline-block;
+  font-size: 10px;
+  color: var(--vscode-descriptionForeground);
+  transform: rotate(-90deg);
+  transition: transform 0.15s ease;
+}
+.rule-card {
+  border: 1px solid var(--vscode-panel-border);
+  border-radius: 8px; margin-bottom: 8px; overflow: hidden;
+}
+.rule-card-header {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 10px 12px; cursor: pointer; gap: 8px;
+}
+.rule-card-header:hover { background: var(--vscode-list-hoverBackground); }
+.rule-card-summary {
+  display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
+}
+.rule-id-input {
+  font-family: monospace; font-size: 13px; font-weight: 600; white-space: nowrap;
+  background: transparent;
+  border: 1px solid transparent;
+  border-radius: 3px;
+  color: var(--vscode-foreground);
+  padding: 1px 4px; outline: none;
+  width: auto; min-width: 120px;
+}
+.rule-id-input:focus {
+  border-color: var(--vscode-focusBorder, #7c3aed);
+  background: var(--vscode-input-background);
+}
+.rule-id-input.placeholder-id {
+  border-color: #f59e0b !important;
+  box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.3);
+}
+.rule-severity-tag {
+  padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; white-space: nowrap;
+}
+.rule-desc-preview {
+  font-size: 12px; color: var(--vscode-descriptionForeground);
+  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.rule-card-meta {
+  display: flex; align-items: center; gap: 8px; flex-shrink: 0;
+}
+.expand-icon {
+  display: inline-block;
+  font-size: 10px;
+  color: var(--vscode-descriptionForeground);
+  transform: rotate(-90deg);
+  transition: transform 0.15s ease;
+}
+.rule-card.expanded .expand-icon,
+.section-wrapper.expanded .section-arrow {
+  transform: rotate(0deg);
+}
+.badge {
+  padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;
+}
+.badge-exact { background: rgba(248,81,73,0.15); color: #f48771; }
+.badge-overlap { background: rgba(210,153,34,0.15); color: #d29922; }
+.badge-none { background: rgba(35,134,54,0.15); color: #3fb950; }
+.rule-card-body {
+  padding: 0 12px 12px; border-top: 1px solid var(--vscode-panel-border);
+}
+.edit-header {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 10px 0 8px;
+}
+.id-row {
+  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+}
+.id-row .field-error-msg {
+  flex-basis: 100%; margin-left: 0;
+}
+.keep-toggle { display: flex; gap: 4px; }
+.toggle-btn {
+  padding: 3px 12px; border-radius: 4px; cursor: pointer; font-size: 11px;
+  border: 1px solid var(--vscode-panel-border);
+  background: transparent; color: var(--vscode-foreground);
+}
+.toggle-btn.active {
+  background: rgba(35,134,54,0.15); color: #3fb950; border-color: rgba(35,134,54,0.3);
+}
+.toggle-btn.active[data-action="comment"] {
+  background: rgba(248,81,73,0.15); color: #f48771; border-color: rgba(248,81,73,0.3);
+}
+.add-btn {
+  padding: 4px 14px; border-radius: 4px; cursor: pointer; font-size: 11px;
+  border: 1px solid rgba(35,134,54,0.4);
+  background: rgba(35,134,54,0.15); color: #3fb950;
+}
+.add-btn:hover { background: rgba(35,134,54,0.25); }
+.add-btn:disabled { opacity: 0.6; cursor: not-allowed; }
+.field-error-input {
+  border-color: rgba(248,81,73,0.7) !important;
+  box-shadow: 0 0 0 1px rgba(248,81,73,0.25);
+}
+.field-error-msg {
+  color: #f48771; font-size: 11px; margin-top: 4px;
+}
+.edit-field { margin-top: 10px; }
+.edit-field label {
+  display: block; font-size: 11px; font-weight: 600;
+  color: var(--vscode-descriptionForeground); margin-bottom: 4px;
+  text-transform: uppercase; letter-spacing: 0.5px;
+}
+.id-display-label {
+  font-size: 11px; font-weight: 600;
+  color: var(--vscode-descriptionForeground);
+  text-transform: uppercase; letter-spacing: 0.5px;
+}
+.id-display-input {
+  font-family: monospace; font-size: 13px; font-weight: 600;
+  background: var(--vscode-input-background);
+  border: 1px solid var(--vscode-panel-border);
+  border-radius: 3px;
+  color: var(--vscode-input-foreground);
+  padding: 4px 8px; outline: none;
+  width: auto; min-width: 200px;
+}
+.id-display-input:focus {
+  border-color: var(--vscode-focusBorder, #7c3aed);
+}
+.id-display-input.placeholder-id {
+  border-color: #f59e0b !important;
+  box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.3);
+}
+.placeholder-hint {
+  color: #d29922; font-size: 11px;
+}
+.edit-field select, .edit-field textarea {
+  width: 100%; padding: 6px 8px; border-radius: 4px;
+  border: 1px solid var(--vscode-panel-border);
+  background: var(--vscode-input-background);
+  color: var(--vscode-input-foreground);
+  font-family: var(--vscode-font-family);
+  font-size: var(--vscode-font-size);
+}
+.edit-field textarea { resize: vertical; }
+.tag-input-wrapper {
+  border: 1px solid var(--vscode-panel-border);
+  border-radius: 4px; padding: 4px 6px;
+  background: var(--vscode-input-background);
+  display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
+}
+.tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
+.tag {
+  display: inline-flex; align-items: center; gap: 3px;
+  padding: 1px 6px; border-radius: 3px; font-size: 11px;
+  background: rgba(88,166,255,0.15); color: #58a6ff;
+  border: 1px solid rgba(88,166,255,0.3);
+}
+.tag-remove {
+  cursor: pointer; font-size: 13px; line-height: 1; opacity: 0.7;
+}
+.tag-remove:hover { opacity: 1; }
+.tag-input {
+  border: none; outline: none; flex: 1; min-width: 80px;
+  background: transparent; color: var(--vscode-input-foreground);
+  font-size: 12px; padding: 2px 0;
+}
+.tag-input::placeholder { color: var(--vscode-input-placeholderForeground); }
+.validation-error {
+  padding: 8px 12px; margin-bottom: 12px; border-radius: 6px;
+  background: rgba(248,81,73,0.15); color: #f48771;
+  border: 1px solid rgba(248,81,73,0.3); font-size: 12px;
+}
+.dup-banner {
+  border-radius: 6px; padding: 8px 12px; margin-top: 10px;
+  font-size: 12px; line-height: 1.6;
+}
+.dup-exact {
+  background: rgba(248,81,73,0.1);
+  border: 1px solid rgba(248,81,73,0.3);
+  border-left: 3px solid #f48771;
+}
+.dup-overlap {
+  background: rgba(210,153,34,0.1);
+  border: 1px solid rgba(210,153,34,0.3);
+  border-left: 3px solid #d29922;
+}
+.dup-banner-title { font-weight: 600; }
+.dup-exact .dup-banner-title { color: #f48771; }
+.dup-overlap .dup-banner-title { color: #d29922; }
+.dup-banner-desc { color: var(--vscode-foreground); }
+.dup-banner-reason { color: #d29922; }
+.dup-banner-hint { color: var(--vscode-descriptionForeground); }
+</style>
+</head>
+<body>
+<div class="header">
+   <div class="header-title">${t('importPreview.title')}</div>
+   <div class="header-sub">${t('importPreview.source', { 0: result.sourceFileName, 1: String(result.rules.length) })}</div>
+</div>
+<div class="summary">
+  <div class="summary-item" style="border-color:rgba(248,81,73,0.3);color:#f48771;">${t('import.exactDuplicate', { 0: `<span id="count-exact">${exactRules.length}</span>` })}</div>
+  <div class="summary-item" style="border-color:rgba(210,153,34,0.3);color:#d29922;">${t('import.overlapDuplicate', { 0: `<span id="count-overlap">${overlapRules.length}</span>` })}</div>
+  <div class="summary-item" style="border-color:rgba(35,134,54,0.3);color:#3fb950;">${t('import.noDuplicate', { 0: `<span id="count-none">${noneRules.length}</span>` })}</div>
+</div>
+<div class="summary-bar" id="statusBar">
+  ${t('import.statusBar', { 0: `<b id="keepCount">${totalKept}</b>`, 1: `<b id="commentCount">${totalCommented}</b>` })}
+  <span id="editHint" style="display:none;">${t('import.editedHint', { 0: '<b id="editCount">0</b>' })}</span>
+</div>
+<div id="addHint" class="summary-bar warn" style="display:none;"></div>
+
+<div id="validationError" class="validation-error" style="display:none;"></div>
+
+${skippedHint}
+${renderErrorSection(errorRules)}
+${renderSection(t('import.sectionExact'), '⛔', 'exact', exactRules)}
+${renderSection(t('import.sectionOverlap'), '⚠️', 'overlap', overlapRules)}
+${renderSection(t('import.sectionNone'), '✅', 'none', noneRules)}
+
+<div id="emptyValidHint" class="validation-error" style="display:none;">${t('import.emptyValidRules')}</div>
+
+<div class="actions">
+  <button class="btn" onclick="cancel()">${t('importPreview.cancel')}</button>
+  <button class="btn btn-primary" id="confirmBtn" onclick="doConfirm()">${t('importPreview.confirm')}</button>
+</div>
+
+<script>
+const vscode = acquireVsCodeApi();
+const editedRules = {};
+let addedRules = 0;
+let addingRuleId = null;
+const VALIDATION_DESC_EMPTY = ${JSON.stringify(t('import.validationDescEmpty'))};
+const VALIDATION_MSG_EMPTY = ${JSON.stringify(t('import.validationMsgEmpty'))};
+const VALIDATION_ID_EMPTY = ${JSON.stringify(t('import.validationIdEmpty'))};
+const ADD_TEXT = ${JSON.stringify(t('import.add'))};
+const ADDING_TEXT = ${JSON.stringify(t('import.adding'))};
+const KEEP_TEXT = ${JSON.stringify(t('importPreview.keep'))};
+const COMMENT_TEXT = ${JSON.stringify(t('importPreview.comment'))};
+const WILL_COMMENT_TEXT = ${JSON.stringify(t('import.badgeWillComment'))};
+const DEDUP_FALLBACK_TEXT = ${JSON.stringify(t('import.addDedupFallback'))};
+const CUSTOM_PREFIX = ${JSON.stringify(t('import.customRulePrefix'))};
+const DUPLICATE_OF_TEXT = ${JSON.stringify(t('import.duplicateOf'))};
+const OVERLAP_WITH_TEXT = ${JSON.stringify(t('import.overlapWith'))};
+const OVERLAP_REASON_TEXT = ${JSON.stringify(t('import.overlapReason'))};
+const RULE_COUNT_TEMPLATE = ${JSON.stringify(t('import.ruleCount'))};
+const SECTION_TITLES = {
+  error: ${JSON.stringify(t('import.sectionInvalid'))},
+  exact: ${JSON.stringify(t('import.sectionExact'))},
+  overlap: ${JSON.stringify(t('import.sectionOverlap'))},
+  none: ${JSON.stringify(t('import.sectionNone'))},
+};
+
+function setSectionTitle(key, count) {
+  const el = document.querySelector('[data-section-title="' + key + '"]');
+  if (el) {
+    el.textContent = SECTION_TITLES[key] + '(' + RULE_COUNT_TEMPLATE.replace('{0}', count) + ')';
+  }
+}
+
+function fmt(tpl, v) {
+  return tpl.replace('{0}', v);
+}
+
+function syncId(el) {
+  const card = el.closest('.rule-card');
+  if (!card) return;
+  const originalId = card.dataset.ruleid;
+  const newValue = el.value;
+  const headerInput = card.querySelector('.rule-id-input');
+  const panelInput = card.querySelector('.id-display-input');
+  if (headerInput) headerInput.value = newValue;
+  if (panelInput) panelInput.value = newValue;
+
+  if (!editedRules[originalId]) editedRules[originalId] = {};
+  editedRules[originalId].id = newValue;
+  updateEditHint();
+
+  const isPlaceholder = /^rule-\\d+$/.test(newValue);
+  [headerInput, panelInput].forEach(input => {
+    if (input) {
+      input.classList.toggle('placeholder-id', isPlaceholder);
+    }
+  });
+}
+
+function toggleCard(el) {
+  const card = el.closest('.rule-card');
+  if (!card) return;
+  const body = card.querySelector('.rule-card-body');
+  if (body.style.display === 'none') {
+    body.style.display = 'block';
+    card.classList.add('expanded');
+  } else {
+    body.style.display = 'none';
+    card.classList.remove('expanded');
+  }
+}
+
+function toggleSection(id) {
+  const el = document.getElementById(id);
+  const wrap = el.closest('.section-wrapper');
+  if (el.style.display === 'none') {
+    el.style.display = 'block';
+    wrap.classList.add('expanded');
+  } else {
+    el.style.display = 'none';
+    wrap.classList.remove('expanded');
+  }
+}
+
+function toggleKeep(el, keep) {
+  const card = el.closest('.rule-card');
+  if (!card) return;
+  const ruleId = card.dataset.ruleid;
+  vscode.postMessage({ type: 'toggleRule', ruleId, keep });
+  const btns = card.querySelectorAll('.toggle-btn');
+  btns.forEach(b => b.classList.toggle('active', (keep && b.dataset.action === 'keep') || (!keep && b.dataset.action === 'comment')));
+  updateSummary();
+}
+
+function updateRule(el, field, value) {
+  const card = el.closest('.rule-card');
+  if (!card) return;
+  const ruleId = card.dataset.ruleid;
+  if (!editedRules[ruleId]) {
+    editedRules[ruleId] = {};
+  }
+  editedRules[ruleId][field] = value;
+  updateEditHint();
+}
+
+function collectCardRule(card) {
+  if (!card) return null;
+  const idInput = card.querySelector('.id-display-input');
+  const severityEl = card.querySelector('.edit-field select');
+  const textareas = card.querySelectorAll('.edit-field textarea');
+  const descEl = textareas[0];
+  const msgEl = textareas[1];
+  const langList = card.querySelector('.tag-list[data-field="languages"]');
+  const exclList = card.querySelector('.tag-list[data-field="excludeLanguages"]');
+
+  return {
+    id: idInput ? idInput.value.trim() : card.dataset.ruleid,
+    severity: severityEl ? severityEl.value : 'warning',
+    description: descEl ? descEl.value : '',
+    message: msgEl ? msgEl.value : '',
+    languages: langList ? Array.from(langList.querySelectorAll('.tag')).map(tag => tag.dataset.value) : [],
+    excludeLanguages: exclList ? Array.from(exclList.querySelectorAll('.tag')).map(tag => tag.dataset.value) : [],
+  };
+}
+
+function collectEditedRules() {
+  const result = [];
+  document.querySelectorAll('.rule-card').forEach(card => {
+    if (card.hasAttribute('data-error')) { return; }
+    const rule = collectCardRule(card);
+    if (rule) { result.push(rule); }
+  });
+  return result;
+}
+
+function addErrorRule(el) {
+  if (addingRuleId) { return; }
+  const card = el.closest('.rule-card');
+  if (!card) return;
+  const btn = el;
+  if (btn.disabled) { return; }
+  btn.disabled = true;
+  btn.textContent = ADDING_TEXT;
+  const ruleId = card.dataset.ruleid;
+  addingRuleId = ruleId;
+  const rule = collectCardRule(card);
+  if (!rule) {
+    addingRuleId = null;
+    btn.disabled = false;
+    btn.textContent = ADD_TEXT;
+    return;
+  }
+  vscode.postMessage({ type: 'addErrorRule', ruleId, rule });
+}
+
+function fieldElement(card, field) {
+  if (field === 'id') return card.querySelector('.id-display-input');
+  if (field === 'severity') return card.querySelector('.edit-field select');
+  const tas = card.querySelectorAll('.edit-field textarea');
+  return field === 'description' ? (tas[0] || null) : (tas[1] || null);
+}
+
+function setFieldError(card, field, message) {
+  const el = fieldElement(card, field);
+  if (!el) return;
+  el.classList.add('field-error-input');
+  const wrap = el.closest('.edit-field, .id-row');
+  if (!wrap) return;
+  wrap.classList.add('field-error');
+  let msg = wrap.querySelector('.field-error-msg');
+  if (!msg) {
+    msg = document.createElement('div');
+    msg.className = 'field-error-msg';
+    wrap.appendChild(msg);
+  }
+  msg.textContent = '⚠ ' + message;
+}
+
+function clearFieldError(card, field) {
+  const el = fieldElement(card, field);
+  if (!el) return;
+  el.classList.remove('field-error-input');
+  const wrap = el.closest('.edit-field, .id-row');
+  if (wrap) {
+    wrap.classList.remove('field-error');
+    const msg = wrap.querySelector('.field-error-msg');
+    if (msg) { msg.remove(); }
+  }
+}
+
+function clearCardFieldErrors(card) {
+  card.querySelectorAll('.field-error-input').forEach(function (el) {
+    el.classList.remove('field-error-input');
+  });
+  card.querySelectorAll('.field-error').forEach(function (wrap) {
+    wrap.classList.remove('field-error');
+    const msg = wrap.querySelector('.field-error-msg');
+    if (msg) { msg.remove(); }
+  });
+}
+
+function liveClear(event) {
+  const card = event.target.closest('.rule-card');
+  if (!card || !card.hasAttribute('data-error')) return;
+  const target = event.target;
+  if (target.classList.contains('id-display-input') || target.classList.contains('rule-id-input')) {
+    if (target.value.trim()) { clearFieldError(card, 'id'); }
+  } else if (target.tagName === 'SELECT') {
+    clearFieldError(card, 'severity');
+  } else if (target.tagName === 'TEXTAREA') {
+    const tas = card.querySelectorAll('.edit-field textarea');
+    const field = tas[0] === target ? 'description' : (tas[1] === target ? 'message' : null);
+    if (field && target.value.trim()) { clearFieldError(card, field); }
+  }
+}
+
+function showCardError(ruleId, field, message) {
+  const card = document.querySelector('.rule-card[data-ruleid="' + ruleId + '"]');
+  if (card && field) {
+    setFieldError(card, field, message);
+  }
+  const btn = document.querySelector('[data-addbtn="' + ruleId + '"]');
+  if (btn) {
+    btn.disabled = false;
+    btn.textContent = ADD_TEXT;
+  }
+  addingRuleId = null;
+}
+
+function dupInfoHtml(level, dupOf, reason) {
+  const prefix = dupOf && dupOf.startsWith('custom/')
+    ? CUSTOM_PREFIX + ' ' + dupOf.slice(7)
+    : (dupOf || 'unknown');
+  if (level === 'exact') {
+    return '<div style="color:#8b949e;font-size:12px;margin-top:4px;">' + fmt(DUPLICATE_OF_TEXT, prefix) + '</div>';
+  }
+  if (level === 'overlap') {
+    let html = '<div style="color:#d29922;font-size:12px;margin-top:4px;">' + fmt(OVERLAP_WITH_TEXT, prefix) + '</div>';
+    if (reason) {
+      html += '<div style="color:#8b949e;font-size:12px;margin-top:2px;">' + fmt(OVERLAP_REASON_TEXT, reason) + '</div>';
+    }
+    return html;
+  }
+  return '';
+}
+
+function moveCardToSection(msg) {
+  const card = document.querySelector('.rule-card[data-ruleid="' + msg.ruleId + '"]');
+  if (!card) return;
+
+  card.dataset.ruleid = msg.id;
+  const headerInput = card.querySelector('.rule-id-input');
+  const panelInput = card.querySelector('.id-display-input');
+  if (headerInput) headerInput.value = msg.id;
+  if (panelInput) panelInput.value = msg.id;
+  const isPlaceholder = /^rule-\\d+$/.test(msg.id);
+  [headerInput, panelInput].forEach(el => {
+    if (el) el.classList.toggle('placeholder-id', isPlaceholder);
+  });
+
+  card.removeAttribute('data-error');
+  clearCardFieldErrors(card);
+
+  const addBtn = card.querySelector('.add-btn');
+  if (addBtn) { addBtn.remove(); }
+
+  const kept = msg.duplicateLevel !== 'exact';
+  const toggle = document.createElement('div');
+  toggle.className = 'keep-toggle';
+
+  function makeToggleBtn(action, active, label) {
+    const btn = document.createElement('button');
+    btn.className = 'toggle-btn' + (active ? ' active' : '');
+    btn.dataset.action = action;
+    btn.textContent = label;
+    btn.addEventListener('click', function (ev) {
+      ev.stopPropagation();
+      toggleKeep(this, action === 'keep');
+    });
+    return btn;
+  }
+
+  toggle.appendChild(makeToggleBtn('keep', kept, KEEP_TEXT));
+  toggle.appendChild(makeToggleBtn('comment', !kept, COMMENT_TEXT));
+  card.querySelector('.edit-header').appendChild(toggle);
+
+  const meta = card.querySelector('.rule-card-meta');
+  const oldBadge = meta.querySelector('.badge');
+  if (oldBadge) { oldBadge.remove(); }
+  const badge = document.createElement('span');
+  badge.className = 'badge';
+  if (msg.duplicateLevel === 'exact') {
+    badge.classList.add('badge-exact');
+    badge.textContent = WILL_COMMENT_TEXT;
+  } else if (msg.duplicateLevel === 'overlap') {
+    badge.classList.add('badge-overlap');
+    badge.textContent = KEEP_TEXT;
+  } else {
+    badge.classList.add('badge-none');
+    badge.textContent = KEEP_TEXT;
+  }
+  const icon = meta.querySelector('.expand-icon');
+  meta.insertBefore(badge, icon);
+
+  const body = card.querySelector('.rule-card-body');
+  const infoHtml = dupInfoHtml(msg.duplicateLevel, msg.duplicateOf, msg.duplicateReason);
+  if (infoHtml) {
+    const infoDiv = document.createElement('div');
+    infoDiv.innerHTML = infoHtml;
+    const firstField = body.querySelector('.edit-field');
+    body.insertBefore(infoDiv, firstField);
+  }
+
+  const section = msg.duplicateLevel === 'exact'
+    ? 'exact'
+    : (msg.duplicateLevel === 'overlap' ? 'overlap' : 'none');
+  const target = document.querySelector('[data-section="' + section + '"]');
+  if (target) {
+    const wrap = target.closest('[data-section-wrap]');
+    if (wrap) {
+      wrap.style.display = '';
+      wrap.classList.add('expanded');
+    }
+    target.style.display = 'block';
+    target.appendChild(card);
+  }
+
+  addedRules++;
+  if (msg.dedupFailed) {
+    showAddHint(DEDUP_FALLBACK_TEXT);
+  }
+  updateSectionCounts();
+  updateSummary();
+  updateEditHint();
+}
+
+function showAddHint(text) {
+  const el = document.getElementById('addHint');
+  el.textContent = text;
+  el.style.display = 'block';
+  setTimeout(function () { el.style.display = 'none'; }, 5000);
+}
+
+function updateSectionCounts() {
+  const sections = ['error', 'exact', 'overlap', 'none'];
+  const counts = {};
+  let totalValid = 0;
+  for (const key of sections) {
+    const container = document.querySelector('[data-section="' + key + '"]');
+    const count = container ? container.querySelectorAll('.rule-card').length : 0;
+    counts[key] = count;
+    if (key !== 'error') { totalValid += count; }
+    setSectionTitle(key, count);
+    if (container) {
+      const wrap = container.closest('[data-section-wrap]');
+      if (wrap) { wrap.style.display = count > 0 ? '' : 'none'; }
+    }
+  }
+  document.getElementById('count-exact').textContent = counts.exact;
+  document.getElementById('count-overlap').textContent = counts.overlap;
+  document.getElementById('count-none').textContent = counts.none;
+
+  const btn = document.getElementById('confirmBtn');
+  const hint = document.getElementById('emptyValidHint');
+  if (totalValid === 0) {
+    btn.disabled = true;
+    btn.style.opacity = '0.5';
+    btn.style.cursor = 'not-allowed';
+    hint.style.display = 'block';
+  } else {
+    btn.disabled = false;
+    btn.style.opacity = '';
+    btn.style.cursor = '';
+    hint.style.display = 'none';
+  }
+}
+
+window.addEventListener('message', function (e) {
+  const msg = e.data;
+  if (!msg) { return; }
+  if (msg.type === 'addError') {
+    showCardError(msg.ruleId, msg.field, msg.message);
+  } else if (msg.type === 'ruleAdded') {
+    moveCardToSection(msg);
+  }
+});
+
+function validate() {
+  const rules = collectEditedRules();
+  for (const rule of rules) {
+    if (!rule.id || !rule.id.trim()) {
+      return VALIDATION_ID_EMPTY;
+    }
+    if (!rule.description || !rule.description.trim()) {
+      return VALIDATION_DESC_EMPTY.replace('{0}', rule.id);
+    }
+    if (!rule.message || !rule.message.trim()) {
+      return VALIDATION_MSG_EMPTY.replace('{0}', rule.id);
+    }
+  }
+  return null;
+}
+
+function doConfirm() {
+  const err = validate();
+  if (err) {
+    const errEl = document.getElementById('validationError');
+    errEl.textContent = err;
+    errEl.style.display = 'block';
+    return;
+  }
+  const edited = collectEditedRules();
+  const hasEdits = Object.keys(editedRules).length > 0;
+  const withData = (hasEdits || addedRules > 0) ? edited : undefined;
+  vscode.postMessage({ type: 'confirm', editedRules: withData });
+}
+
+function cancel() {
+  vscode.postMessage({ type: 'cancel' });
+}
+
+function updateSummary() {
+  let keepCount = 0, commentCount = 0;
+  document.querySelectorAll('.rule-card').forEach(card => {
+    if (card.hasAttribute('data-error')) { return; }
+    const keepBtns = card.querySelectorAll('.toggle-btn');
+    let isKept = true;
+    keepBtns.forEach(b => {
+      if (b.classList.contains('active') && b.dataset.action === 'comment') isKept = false;
+    });
+    if (isKept) keepCount++; else commentCount++;
+  });
+  document.getElementById('keepCount').textContent = keepCount;
+  document.getElementById('commentCount').textContent = commentCount;
+}
+
+function updateEditHint() {
+  const count = Object.keys(editedRules).length;
+  const hint = document.getElementById('editHint');
+  const countEl = document.getElementById('editCount');
+  if (count > 0) {
+    hint.style.display = 'inline';
+    countEl.textContent = count;
+  } else {
+    hint.style.display = 'none';
+  }
+}
+
+document.addEventListener('keydown', function(e) {
+  if (e.target.classList.contains('tag-input') && e.key === 'Enter') {
+    e.preventDefault();
+    const input = e.target;
+    const val = input.value.trim();
+    if (!val) return;
+    const ruleId = input.dataset.ruleid;
+    const field = input.dataset.field;
+    const list = input.parentElement.querySelector('.tag-list');
+
+    const existing = list.querySelectorAll('.tag');
+    const exists = Array.from(existing).some(tag => tag.dataset.value === val);
+    if (exists) { input.value = ''; return; }
+
+    const tag = document.createElement('span');
+    tag.className = 'tag';
+    tag.dataset.value = val;
+    tag.innerHTML = val + '<span class="tag-remove">×</span>';
+    list.appendChild(tag);
+    input.value = '';
+
+    const tags = Array.from(list.querySelectorAll('.tag')).map(tag => tag.dataset.value);
+    if (!editedRules[ruleId]) editedRules[ruleId] = {};
+    editedRules[ruleId][field] = tags;
+    updateEditHint();
+  }
+});
+
+document.addEventListener('click', function(e) {
+  if (e.target.classList.contains('tag-remove')) {
+    const tag = e.target.closest('.tag');
+    const list = tag.closest('.tag-list');
+    const ruleId = list.dataset.ruleid;
+    const field = list.dataset.field;
+    tag.remove();
+    const remaining = Array.from(list.querySelectorAll('.tag')).map(tag => tag.dataset.value);
+    if (!editedRules[ruleId]) editedRules[ruleId] = {};
+    editedRules[ruleId][field] = remaining;
+    updateEditHint();
+  }
+});
+
+document.addEventListener('input', liveClear);
+document.addEventListener('change', liveClear);
+
+updateSectionCounts();
+</script>
+</body>
+</html>`;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/import-service.ts.html b/tests/coverage/src/rules/import-service.ts.html new file mode 100644 index 0000000..e183179 --- /dev/null +++ b/tests/coverage/src/rules/import-service.ts.html @@ -0,0 +1,1609 @@ + + + + + + Code coverage report for src/rules/import-service.ts + + + + + + + + + +
+
+

All files / src/rules import-service.ts

+
+ +
+ 51.77% + Statements + 263/508 +
+ + +
+ 79.06% + Branches + 68/86 +
+ + +
+ 72.22% + Functions + 13/18 +
+ + +
+ 51.77% + Lines + 263/508 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +5092x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +79x +79x +79x +79x +2x +16x +16x +16x +16x +16x +84x +84x +80x +84x +21x +21x +21x +21x +21x +21x +21x +  +  +  +21x +21x +21x +21x +84x +59x +59x +59x +59x +59x +  +59x +1x +2x +1x +59x +58x +58x +59x +59x +84x +16x +16x +16x +16x +2x +16x +16x +16x +16x +16x +16x +16x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +20x +16x +16x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +18x +18x +18x +18x +18x +18x +5x +5x +13x +13x +2x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +5x +1x +1x +5x +  +  +5x +5x +4x +5x +1x +1x +1x +  +1x +  +  +  +  +1x +1x +1x +1x +1x +4x +4x +1x +5x +5x +5x +5x +5x +5x +2x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +13x +32x +13x +32x +19x +19x +19x +11x +11x +69x +57x +11x +11x +19x +8x +8x +8x +8x +5x +1x +5x +4x +4x +8x +3x +  +3x +3x +3x +3x +3x +8x +8x +57x +57x +8x +19x +32x +32x +13x +13x +126x +126x +19x +19x +19x +126x +107x +107x +  +  +126x +13x +13x +13x +13x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +2x +2x +2x +6x +8x +8x +6x +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import * as vscode from 'vscode';
+import * as path from 'path';
+import * as fs from 'fs';
+import { getApiKey } from '../config/secret';
+import { getAIConfig, getAIMaxTokens, getAITimeout } from '../config/ai';
+import { createProvider } from '../ai/factory';
+import { RuleConverter } from './converters/converter';
+import { loadActiveRules } from './yaml-parser';
+import { parseTemplate } from './converters/template-converter';
+import { buildDedupOnlyPrompt } from './converters/dedup-prompt';
+import type { ConversionResult, ImportableRule, PreviewDecision } from './import-types';
+import { t, getLanguage } from '../i18n/messages';
+ 
+interface ParsedYamlItem {
+  id?: string;
+  severity?: string;
+  description?: string;
+  message?: string;
+  languages?: string[];
+  excludeLanguages?: string[];
+  duplicateOf?: string;
+  duplicateLevel?: string;
+  duplicateReason?: string;
+  [key: string]: unknown;
+}
+ 
+function stripQuotes(raw: string): string {
+  const m = raw.match(/^(['"])(.*)\1$/);
+  return m ? m[2] : raw;
+}
+ 
+function parseSimpleYaml(content: string): ParsedYamlItem[] {
+  const items: ParsedYamlItem[] = [];
+  let current: ParsedYamlItem | null = null;
+ 
+  for (const line of content.split('\n')) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#')) { continue; }
+ 
+    if (trimmed.startsWith('- ')) {
+      if (current) { items.push(current); }
+      current = {};
+      const indentMatch = trimmed.match(/^- (\w[\w-]*)\s*:\s*(.*)$/);
+      if (indentMatch) {
+        const key = indentMatch[1];
+        const raw = indentMatch[2].trim();
+        if (raw.startsWith('[') && raw.endsWith(']')) {
+          current[key] = raw.slice(1, -1).split(',').map(s =>
+            s.trim().replace(/^['"]|['"]$/g, '')
+          );
+        } else {
+          current[key] = stripQuotes(raw);
+        }
+      }
+    } else if (current) {
+      const propMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
+      if (propMatch) {
+        const key = propMatch[1];
+        const raw = propMatch[2].trim();
+        if (!raw || raw === '[]') {
+          current[key] = [];
+        } else if (raw.startsWith('[') && raw.endsWith(']')) {
+          current[key] = raw.slice(1, -1).split(',').map(s =>
+            s.trim().replace(/^['"]|['"]$/g, '')
+          );
+        } else {
+          current[key] = stripQuotes(raw);
+        }
+      }
+    }
+  }
+  if (current) { items.push(current); }
+ 
+  return items;
+}
+ 
+export function parseImportableYaml(content: string): ImportableRule[] {
+  const items = parseSimpleYaml(content);
+  const validSeverities = new Set(['error', 'warning', 'info']);
+ 
+  return items
+    .filter(item => item.description || item.message)
+    .map((item, idx) => ({
+      id: (item.id && item.id.trim()) ? item.id.trim() : `rule-${idx + 1}`,
+      severity: item.severity && validSeverities.has(item.severity)
+        ? item.severity as 'error' | 'warning' | 'info'
+        : 'warning',
+      description: (item.description ?? item.message ?? ''),
+      message: (item.message ?? item.description ?? ''),
+      languages: item.languages as string[] | undefined,
+      excludeLanguages: item.excludeLanguages as string[] | undefined,
+      duplicateOf: item.duplicateOf as string | undefined,
+      duplicateLevel: item.duplicateLevel as 'exact' | 'overlap' | 'none' | undefined,
+      duplicateReason: item.duplicateReason as string | undefined,
+    }));
+}
+ 
+export interface DedupResult {
+  duplicateLevel: 'exact' | 'overlap' | 'none';
+  duplicateOf?: string;
+  duplicateReason?: string;
+}
+ 
+export async function dedupSingleRule(
+  rule: ImportableRule,
+  context: vscode.ExtensionContext,
+): Promise<DedupResult | null> {
+  const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+  const existingRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
+
+  const singleYaml = [
+    `- id: ${rule.id}`,
+    `  severity: ${rule.severity}`,
+    `  description: ${rule.description}`,
+    `  message: ${rule.message}`,
+    ...(rule.languages?.length ? [`  languages: [${rule.languages.join(', ')}]`] : []),
+    ...(rule.excludeLanguages?.length ? [`  excludeLanguages: [${rule.excludeLanguages.join(', ')}]`] : []),
+  ].join('\n');
+
+  const { system, user } = buildDedupOnlyPrompt(singleYaml, existingRules);
+
+  for (let attempt = 0; attempt < 2; attempt++) {
+    const out = await convertContentWithAI(user, context, system, true);
+    if (!out) { continue; }
+    const parsed = parseImportableYaml(out);
+    if (parsed.length === 0) { continue; }
+    const r = parsed[0];
+    return {
+      duplicateLevel: r.duplicateLevel ?? 'none',
+      duplicateOf: r.duplicateOf,
+      duplicateReason: r.duplicateReason,
+    };
+  }
+  return null;
+}
+ 
+export function buildFinalYaml(
+  yamlContent: string,
+  rules: ImportableRule[],
+  decision: PreviewDecision,
+): string {
+  if (decision.editedRules && decision.editedRules.length > 0) {
+    return renderRulesToYaml(decision.editedRules, decision);
+  }
+  return buildFinalYamlFromRaw(yamlContent, rules, decision);
+}
+ 
+function renderRulesToYaml(
+  rules: ImportableRule[],
+  decision: PreviewDecision,
+): string {
+  const lines: string[] = [];
+ 
+  for (const rule of rules) {
+    const keep = decision.keepRule[rule.id] ?? rule.duplicateLevel !== 'exact';
+ 
+    const ruleLines: string[] = [];
+    ruleLines.push(`- id: ${rule.id}`);
+    ruleLines.push(`  severity: ${rule.severity}`);
+    ruleLines.push(`  description: ${rule.description}`);
+    ruleLines.push(`  message: ${rule.message}`);
+    if (rule.languages && rule.languages.length > 0) {
+      ruleLines.push(`  languages: [${rule.languages.join(', ')}]`);
+    }
+    if (rule.excludeLanguages && rule.excludeLanguages.length > 0) {
+      ruleLines.push(`  excludeLanguages: [${rule.excludeLanguages.join(', ')}]`);
+    }
+ 
+    if (keep) {
+      lines.push(...ruleLines);
+    } else {
+      const dupLevel = rule.duplicateLevel ?? 'none';
+      const dupOf = rule.duplicateOf ?? 'manual';
+      if (dupLevel === 'exact') {
+        lines.push(t('yaml.duplicateExact', { 0: dupOf }));
+      } else if (dupLevel === 'overlap') {
+        lines.push(t('yaml.duplicateOverlap', { 0: dupOf }));
+        if (rule.duplicateReason) {
+          lines.push(t('yaml.overlapReason', { 0: rule.duplicateReason }));
+        }
+      } else {
+        lines.push(t('yaml.manualComment'));
+      }
+      lines.push(t('yaml.enableHint'));
+      for (const rl of ruleLines) {
+        lines.push(`# ${rl}`);
+      }
+    }
+ 
+    lines.push('');
+  }
+ 
+  return lines.join('\n');
+}
+ 
+function buildFinalYamlFromRaw(
+  yamlContent: string,
+  rules: ImportableRule[],
+  decision: PreviewDecision,
+): string {
+  const defaultKeep = (rule: ImportableRule) => rule.duplicateLevel !== 'exact';
+  const shouldKeep = (rule: ImportableRule) =>
+    decision.keepRule[rule.id] ?? defaultKeep(rule);
+ 
+  const lines = yamlContent.split('\n');
+  const output: string[] = [];
+  let currentRuleId: string | null = null;
+  let ruleLines: string[] = [];
+ 
+  function flushRule(): void {
+    if (currentRuleId === null) {
+      output.push(...ruleLines);
+    } else {
+      const rule = rules.find(r => r.id === currentRuleId);
+      const keep = rule ? shouldKeep(rule) : true;
+      if (keep) {
+        const filtered = ruleLines.filter(
+          line => !line.trimStart().startsWith('duplicateOf:') &&
+                  !line.trimStart().startsWith('duplicateLevel:') &&
+                  !line.trimStart().startsWith('duplicateReason:')
+        );
+        output.push(...filtered);
+      } else {
+        const level = rule?.duplicateLevel ?? 'exact';
+        const dupInfo = rule?.duplicateOf ?? 'unknown';
+        const reason = rule?.duplicateReason ?? '';
+        if (level === 'exact') {
+          if (dupInfo.startsWith('custom/')) {
+            output.push(t('yaml.duplicateExactCustom', { 0: dupInfo.slice(7) }));
+          } else {
+            output.push(t('yaml.duplicateExactGeneric', { 0: dupInfo }));
+          }
+        } else {
+          if (dupInfo.startsWith('custom/')) {
+            output.push(t('yaml.duplicateOverlapCustom', { 0: level, 1: dupInfo.slice(7) }));
+          } else {
+            output.push(t('yaml.duplicateOverlapGeneric', { 0: level, 1: dupInfo }));
+          }
+          if (reason) { output.push(t('yaml.overlapReason', { 0: reason })); }
+        }
+        output.push(t('yaml.enableHint'));
+        for (const line of ruleLines) {
+          output.push(line.trim() ? `# ${line}` : '#');
+        }
+      }
+    }
+    ruleLines = [];
+  }
+ 
+  for (const line of lines) {
+    const ruleStart = line.match(/^-\s+id:\s*(.+)/);
+    if (ruleStart) {
+      flushRule();
+      currentRuleId = ruleStart[1].trim();
+      ruleLines = [line];
+    } else if (currentRuleId) {
+      ruleLines.push(line);
+    } else {
+      ruleLines.push(line);
+    }
+  }
+  flushRule();
+ 
+  return output.join('\n');
+}
+ 
+function normalizeRuleIds(rules: ImportableRule[]): ImportableRule[] {
+  const seen = new Set<string>();
+  return rules.map((rule, idx) => {
+    let id = rule.id
+      .toLowerCase()
+      .replace(/[^a-z0-9-]/g, '-')
+      .replace(/-+/g, '-')
+      .replace(/^-|-$/g, '');
+
+    if (!id) {
+      id = `rule-${idx + 1}`;
+    }
+
+    if (seen.has(id)) {
+      let suffix = 2;
+      while (seen.has(`${id}-${suffix}`)) { suffix++; }
+      id = `${id}-${suffix}`;
+    }
+    seen.add(id);
+
+    return { ...rule, id };
+  });
+}
+ 
+export class ImportService {
+  private converters: Map<string, RuleConverter> = new Map();
+ 
+  registerConverter(converter: RuleConverter): void {
+    for (const ext of converter.supportedExtensions) {
+      this.converters.set(ext, converter);
+    }
+  }
+ 
+  async convert(
+    srcPath: string,
+    context: vscode.ExtensionContext,
+  ): Promise<ConversionResult> {
+    const ext = path.extname(srcPath).toLowerCase();
+    const converter = this.converters.get(ext);
+    if (!converter) {
+      throw new Error(t('import.unsupportedFormat', { 0: ext }));
+    }
+
+    const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+    const existingRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
+ 
+    const yamlContent = await converter.convert(srcPath, context, existingRules);
+    if (!yamlContent) {
+      throw new Error(t('import.conversionFailed'));
+    }
+
+    const parsedRules = parseImportableYaml(yamlContent);
+    const rules = normalizeRuleIds(parsedRules);
+    const exactCount = rules.filter(r => r.duplicateLevel === 'exact').length;
+    const overlapCount = rules.filter(r => r.duplicateLevel === 'overlap').length;
+
+    return {
+      rules,
+      yamlContent,
+      sourceFileName: path.basename(srcPath),
+      exactCount,
+      overlapCount,
+    };
+  }
+ 
+  async importTemplate(
+    srcPath: string,
+    name: string,
+    context: vscode.ExtensionContext,
+  ): Promise<ConversionResult> {
+    const { rules, validRules, yamlContent, skippedCount } = parseTemplate(srcPath);
+
+    let dedupedValidRules: ImportableRule[] = validRules;
+    if (validRules.length > 0) {
+      const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+      const existingRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
+      const { system, user } = buildDedupOnlyPrompt(yamlContent, existingRules);
+      const dedupedYaml = await convertContentWithAI(user, context, system);
+      if (dedupedYaml) {
+        dedupedValidRules = parseImportableYaml(dedupedYaml);
+      } else {
+        vscode.window.showWarningMessage(t('import.dedupFailed'));
+      }
+    }
+
+    const errorRules = rules.filter(r => r.validationIssues?.length);
+    const allRules = [...dedupedValidRules, ...errorRules];
+
+    const exactCount = dedupedValidRules.filter(r => r.duplicateLevel === 'exact').length;
+    const overlapCount = dedupedValidRules.filter(r => r.duplicateLevel === 'overlap').length;
+
+    return {
+      rules: allRules,
+      yamlContent,
+      sourceFileName: path.basename(srcPath),
+      exactCount,
+      overlapCount,
+      skippedCount,
+      errorCount: errorRules.length,
+    };
+  }
+ 
+  applyConversion(
+    result: ConversionResult,
+    decision: PreviewDecision,
+    targetPath: string,
+  ): void {
+    const finalYaml = buildFinalYaml(result.yamlContent, result.rules, decision);
+    const dir = path.dirname(targetPath);
+    if (!fs.existsSync(dir)) {
+      fs.mkdirSync(dir, { recursive: true });
+    }
+    fs.writeFileSync(targetPath, finalYaml, 'utf-8');
+  }
+}
+ 
+function buildFallbackPrompt(): string {
+  const lang = getLanguage();
+  if (lang === 'ja') {
+    return `あなたはコードレビュールール変換ツールです。ユーザーが提供した自然言語のルール記述を、構造化されたYAML形式に変換してください。
+
+各ルールには以下のフィールドが必要です:
+- id: ルールの一意識別子(kebab-case英語)
+- severity: 重要度(error / warning / info)
+- description: ルールの簡単な説明
+- message: 違反時のメッセージ
+- languages: 対象言語配列(オプション、例:[javascript, typescript])
+
+出力形式の例:
+- id: no-console-log
+  severity: warning
+  description: console.logの使用禁止
+  message: loggerツールを使用してconsole.logを代替してください
+  languages: [javascript, typescript]
+
+YAMLのみを出力し、追加説明は不要です。
+
+出力言語:ja`;
+  }
+  if (lang === 'en') {
+    return `You are a code review rule converter. Convert the natural language rule description provided by the user into structured YAML format for a code review tool.
+
+Each rule must include the following fields:
+- id: Unique rule identifier (kebab-case English)
+- severity: Severity level (error / warning / info)
+- description: Short rule description
+- message: Violation message
+- languages: Applicable language array (optional, e.g., [javascript, typescript])
+
+Output format example:
+- id: no-console-log
+  severity: warning
+  description: Forbid using console.log
+  message: Use a logger tool instead of console.log
+  languages: [javascript, typescript]
+
+Output YAML only, no extra explanation.
+
+Output language: en`;
+  }
+  return `你是一个代码审查规则转换器。将用户提供的自然语言规则描述,转换为结构化的 YAML 格式,用于代码审查工具。
+
+每条规则需要包含以下字段:
+- id: 规则唯一标识(kebab-case 英文)
+- severity: 严重级别(error / warning / info)
+- description: 规则简短描述
+- message: 违反时的提示消息
+- languages: 适用语言数组(可选,如 [javascript, typescript])
+
+输出格式示例:
+- id: no-console-log
+  severity: warning
+  description: 禁止使用 console.log
+  message: 请使用 logger 工具替代 console.log
+  languages: [javascript, typescript]
+
+仅输出 YAML,不要额外说明。
+
+输出语言:zh-CN`;
+}
+ 
+export async function convertContentWithAI(
+  content: string,
+  context: vscode.ExtensionContext,
+  systemPrompt?: string,
+  quiet?: boolean,
+): Promise<string | null> {
+  if (!content.trim()) {
+    if (!quiet) {
+      vscode.window.showErrorMessage(t('import.emptyFile'));
+    }
+    return null;
+  }
+
+  const apiKey = await getApiKey(context);
+  if (!apiKey) {
+    if (!quiet) {
+      vscode.window.showErrorMessage(t('import.needApiKey'));
+    }
+    return null;
+  }
+
+  const config = getAIConfig();
+  const provider = createProvider(config.provider, apiKey, config.baseUrl, context.extensionUri);
+
+  const prompt = systemPrompt ?? buildFallbackPrompt();
+
+  let yamlOutput: string;
+  try {
+    yamlOutput = await provider.chat(prompt, content, {
+      model: config.model,
+      temperature: 0,
+      maxTokens: getAIMaxTokens(),
+      timeoutMs: getAITimeout() * 1000,
+      seed: 42,
+    });
+  } catch (err) {
+    if (!quiet) {
+      if (err instanceof DOMException && err.name === 'AbortError') {
+        vscode.window.showErrorMessage(t('import.timeout'));
+      } else {
+        const msg = err instanceof Error ? err.message : String(err);
+        vscode.window.showErrorMessage(t('import.aiFail', { 0: msg }));
+      }
+    }
+    return null;
+  }
+
+  const cleaned = yamlOutput
+    .replace(/```(yaml|yml)?\s*/gi, '')
+    .replace(/```\s*$/gm, '')
+    .trim();
+
+  if (!cleaned) {
+    if (!quiet) {
+      vscode.window.showErrorMessage(t('import.emptyResponse'));
+    }
+    return null;
+  }
+
+  return cleaned;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/index.html b/tests/coverage/src/rules/index.html new file mode 100644 index 0000000..0741e2d --- /dev/null +++ b/tests/coverage/src/rules/index.html @@ -0,0 +1,191 @@ + + + + + + Code coverage report for src/rules + + + + + + + + + +
+
+

All files src/rules

+
+ +
+ 30.73% + Statements + 695/2261 +
+ + +
+ 83.57% + Branches + 117/140 +
+ + +
+ 51.16% + Functions + 22/43 +
+ + +
+ 30.73% + Lines + 695/2261 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
builtin-rules.ts +
+
59.15%223/377100%0/00%0/1159.15%223/377
export-service.ts +
+
45%27/60100%0/00%0/145%27/60
import-preview.ts +
+
1.99%23/1153100%0/00%0/41.99%23/1153
import-service.ts +
+
51.77%263/50879.06%68/8672.22%13/1851.77%263/508
rule-filter.ts +
+
100%68/68100%23/23100%5/5100%68/68
yaml-parser.ts +
+
95.78%91/9583.87%26/31100%4/495.78%91/95
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/rule-filter.ts.html b/tests/coverage/src/rules/rule-filter.ts.html new file mode 100644 index 0000000..34489c4 --- /dev/null +++ b/tests/coverage/src/rules/rule-filter.ts.html @@ -0,0 +1,289 @@ + + + + + + Code coverage report for src/rules/rule-filter.ts + + + + + + + + + +
+
+

All files / src/rules rule-filter.ts

+
+ +
+ 100% + Statements + 68/68 +
+ + +
+ 100% + Branches + 23/23 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 68/68 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +692x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +13x +13x +13x +13x +13x +2x +3x +3x +3x +2x +82x +82x +12x +6x +6x +12x +82x +29x +29x +47x +47x +2x +15x +15x +15x +15x +15x +15x +2x +2x +13x +13x +13x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x + 
import * as vscode from 'vscode';
+import type { CustomRule } from '../types';
+ 
+const LANGUAGE_ALIASES: Record<string, string[]> = {
+  typescriptreact: ['typescript', 'typescriptreact', 'tsx'],
+  javascriptreact: ['javascript', 'javascriptreact', 'jsx'],
+};
+ 
+const LANGUAGE_GROUPS: Record<string, string[]> = {
+  sql: ['sql', 'plsql'],
+  plsql: ['sql', 'plsql'],
+};
+ 
+const JSP_SUB_LANGUAGES = ['java', 'javascript', 'typescript', 'css', 'jsp', 'html'];
+const JSP_EXTENSIONS = ['.jsp', '.jspx'];
+ 
+function expandLanguageId(languageId: string): string[] {
+  const aliases = LANGUAGE_ALIASES[languageId] ?? [languageId];
+  const groups = LANGUAGE_GROUPS[languageId] ?? [];
+  return [...new Set([...aliases, ...groups, languageId])];
+}
+ 
+function isJspFile(document: vscode.TextDocument): boolean {
+  return JSP_EXTENSIONS.some(ext => document.fileName.toLowerCase().endsWith(ext));
+}
+ 
+function matchesLanguage(rule: CustomRule, expandedLangs: string[]): boolean {
+  if (rule.excludeLanguages && rule.excludeLanguages.length > 0) {
+    if (rule.excludeLanguages.some(l => expandedLangs.includes(l))) {
+      return false;
+    }
+  }
+  if (!rule.languages || rule.languages.length === 0) {
+    return true;
+  }
+  return rule.languages.some(l => expandedLangs.includes(l));
+}
+ 
+export function filterForDocument(
+  rules: CustomRule[],
+  document: vscode.TextDocument,
+): CustomRule[] {
+  const langId = document.languageId;
+  if (langId === 'html' && isJspFile(document)) {
+    return rules.filter(rule => matchesLanguage(rule, JSP_SUB_LANGUAGES));
+  }
+  const expandedLangs = expandLanguageId(langId);
+  return rules.filter(rule => matchesLanguage(rule, expandedLangs));
+}
+ 
+export interface FilterResult {
+  relevant: CustomRule[];
+  filteredOut: CustomRule[];
+  skippedRequestA: boolean;
+}
+ 
+export function filterAndSummarize(
+  rules: CustomRule[],
+  document: vscode.TextDocument,
+): FilterResult {
+  const relevant = filterForDocument(rules, document);
+  const filteredOut = rules.filter(r => !relevant.includes(r));
+  return {
+    relevant,
+    filteredOut,
+    skippedRequestA: relevant.length === 0,
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/rules/yaml-parser.ts.html b/tests/coverage/src/rules/yaml-parser.ts.html new file mode 100644 index 0000000..3eec14a --- /dev/null +++ b/tests/coverage/src/rules/yaml-parser.ts.html @@ -0,0 +1,370 @@ + + + + + + Code coverage report for src/rules/yaml-parser.ts + + + + + + + + + +
+
+

All files / src/rules yaml-parser.ts

+
+ +
+ 95.78% + Statements + 91/95 +
+ + +
+ 83.87% + Branches + 26/31 +
+ + +
+ 100% + Functions + 4/4 +
+ + +
+ 95.78% + Lines + 91/95 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +962x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +11x +11x +11x +11x +2x +3x +3x +3x +3x +3x +13x +13x +12x +13x +3x +3x +3x +3x +3x +3x +3x +  +  +  +3x +3x +3x +3x +13x +9x +9x +9x +9x +9x +  +9x +1x +1x +1x +9x +8x +8x +9x +9x +13x +3x +3x +3x +3x +2x +3x +3x +3x +2x +2x +2x +3x +3x +3x +3x +3x +2x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +3x +2x +2x +2x +2x +2x +2x +1x +1x + 
import * as fs from 'fs';
+import * as path from 'path';
+import type { CustomRule, Severity } from '../types';
+ 
+interface RuleYamlItem {
+  id: string;
+  severity: string;
+  description: string;
+  message: string;
+  languages?: string[];
+  excludeLanguages?: string[];
+}
+ 
+function stripQuotes(raw: string): string {
+  const m = raw.match(/^(['"])(.*)\1$/);
+  return m ? m[2] : raw;
+}
+ 
+function parseYamlSimple(content: string): object[] {
+  const items: Array<Record<string, unknown>> = [];
+  let current: Record<string, unknown> | null = null;
+ 
+  for (const line of content.split('\n')) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#')) { continue; }
+ 
+    if (trimmed.startsWith('- ')) {
+      if (current) { items.push(current); }
+      current = {};
+      const indentMatch = trimmed.match(/^- (\w[\w-]*)\s*:\s*(.*)$/);
+      if (indentMatch) {
+        const key = indentMatch[1];
+        const raw = indentMatch[2].trim();
+        if (raw.startsWith('[') && raw.endsWith(']')) {
+          current[key] = raw.slice(1, -1).split(',').map(s =>
+            s.trim().replace(/^['"]|['"]$/g, '')
+          );
+        } else {
+          current[key] = stripQuotes(raw);
+        }
+      }
+    } else if (current) {
+      const propMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
+      if (propMatch) {
+        const key = propMatch[1];
+        const raw = propMatch[2].trim();
+        if (!raw || raw === '[]') {
+          current[key] = [];
+        } else if (raw.startsWith('[') && raw.endsWith(']')) {
+          current[key] = raw.slice(1, -1).split(',').map(s =>
+            s.trim().replace(/^['"]|['"]$/g, '')
+          );
+        } else {
+          current[key] = stripQuotes(raw);
+        }
+      }
+    }
+  }
+  if (current) { items.push(current); }
+ 
+  return items;
+}
+ 
+export function loadActiveRules(workspaceRoot: string): CustomRule[] {
+  const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
+  if (!fs.existsSync(rulesDir)) { return []; }
+ 
+  const allRules: CustomRule[] = [];
+  const files = fs.readdirSync(rulesDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
+  for (const file of files) {
+    const content = fs.readFileSync(path.join(rulesDir, file), 'utf-8');
+    const items = parseYamlSimple(content) as RuleYamlItem[];
+    for (const item of items) {
+      if (!item.id || !item.severity || !item.description || !item.message) { continue; }
+      const severity = (['error', 'warning', 'info'].includes(item.severity)
+        ? (item.severity as Severity)
+        : 'warning');
+      allRules.push({
+        id: item.id,
+        severity,
+        description: item.description,
+        message: item.message,
+        languages: item.languages,
+        excludeLanguages: item.excludeLanguages,
+      });
+    }
+  }
+  return allRules;
+}
+ 
+export function listRuleFiles(workspaceRoot: string): string[] {
+  const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
+  if (!fs.existsSync(rulesDir)) { return []; }
+  return fs.readdirSync(rulesDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/scope/index.html b/tests/coverage/src/scope/index.html new file mode 100644 index 0000000..5e83b21 --- /dev/null +++ b/tests/coverage/src/scope/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/scope + + + + + + + + + +
+
+

All files src/scope

+
+ +
+ 78.39% + Statements + 225/287 +
+ + +
+ 70.37% + Branches + 57/81 +
+ + +
+ 66.66% + Functions + 10/15 +
+ + +
+ 78.39% + Lines + 225/287 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
method-extractor.ts +
+
81.27%204/25170.51%55/7880%8/1081.27%204/251
status-cache.ts +
+
58.33%21/3666.66%2/340%2/558.33%21/36
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/scope/method-extractor.ts.html b/tests/coverage/src/scope/method-extractor.ts.html new file mode 100644 index 0000000..27470ab --- /dev/null +++ b/tests/coverage/src/scope/method-extractor.ts.html @@ -0,0 +1,838 @@ + + + + + + Code coverage report for src/scope/method-extractor.ts + + + + + + + + + +
+
+

All files / src/scope method-extractor.ts

+
+ +
+ 81.27% + Statements + 204/251 +
+ + +
+ 70.51% + Branches + 55/78 +
+ + +
+ 80% + Functions + 8/10 +
+ + +
+ 81.27% + Lines + 204/251 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +2522x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +2x +  +  +  +  +  +  +  +2x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +11x +11x +11x +11x +11x +11x +11x +  +  +11x +11x +  +  +  +  +  +  +  +  +11x +11x +11x +2x +2x +8x +8x +8x +8x +8x +7x +7x +8x +7x +7x +8x +15x +15x +15x +15x +15x +15x +15x +7x +7x +7x +8x +7x +7x +7x +7x +8x +15x +8x +15x +1x +1x +8x +15x +1x +1x +15x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +2x +7x +7x +7x +7x +7x +7x +  +  +7x +7x +7x +2x +15x +15x +15x +2x +7x +7x +7x +7x +7x +7x +7x +  +  +7x +7x +7x +7x +7x +7x +7x +2x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +3x +3x +2x +11x +11x +11x +11x +11x +9x +9x +11x +1x +1x +11x +11x +11x +11x +19x +19x +19x +20x +20x +20x +20x +20x +20x +20x +20x +19x +11x +11x +2x +15x +15x +15x +15x +15x +15x +15x +15x +684x +684x +2x +2x +684x +1x +682x +1x +1x +1x +1x +  +  +  +  +  +  +681x +19x +680x +19x +19x +15x +15x +19x +668x +668x +  +  +  + 
import * as vscode from 'vscode';
+ 
+export interface MethodScope {
+  name: string;
+  range: vscode.Range;
+  code: string;
+  signature: string;
+  callers: string[];
+  callees: string[];
+  role: string;
+}
+ 
+export interface MethodSymbol {
+  name: string;
+  range: vscode.Range;
+  containerName?: string;
+}
+ 
+interface RawDocSymbol {
+  name: string;
+  kind: vscode.SymbolKind;
+  range?: vscode.Range;
+  children?: RawDocSymbol[];
+  location?: vscode.Location;
+}
+ 
+const CONTAINER_KINDS = new Set([
+  vscode.SymbolKind.Class,
+  vscode.SymbolKind.Interface,
+  vscode.SymbolKind.Namespace,
+  vscode.SymbolKind.Module,
+  vscode.SymbolKind.Object,
+  vscode.SymbolKind.Struct,
+  vscode.SymbolKind.Enum,
+  vscode.SymbolKind.Package,
+]);
+ 
+function isMethodKind(kind: vscode.SymbolKind): boolean {
+  return (
+    kind === vscode.SymbolKind.Function ||
+    kind === vscode.SymbolKind.Method ||
+    kind === vscode.SymbolKind.Constructor
+  );
+}
+ 
+function collectSymbols(symbol: RawDocSymbol, containerName: string | undefined, out: MethodSymbol[]): void {
+  const kind = symbol.kind;
+  if (isMethodKind(kind)) {
+    const range = symbol.range ?? symbol.location?.range;
+    if (range) {
+      out.push({ name: symbol.name, range, containerName });
+    }
+  }
+  const nextContainer = CONTAINER_KINDS.has(kind)
+    ? containerName
+      ? `${containerName}.${symbol.name}`
+      : symbol.name
+    : containerName;
+  for (const child of symbol.children ?? []) {
+    collectSymbols(child, nextContainer, out);
+  }
+}
+ 
+export async function getMethodSymbols(document: vscode.TextDocument): Promise<MethodSymbol[]> {
+  let raw: RawDocSymbol[] | undefined;
+  try {
+    raw = await vscode.commands.executeCommand<RawDocSymbol[]>(
+      'vscode.executeDocumentSymbolProvider',
+      document.uri
+    );
+  } catch {
+    raw = undefined;
+  }
+ 
+  if (raw && raw.length > 0) {
+    const symbols: MethodSymbol[] = [];
+    for (const symbol of raw) {
+      collectSymbols(symbol, undefined, symbols);
+    }
+    if (symbols.length > 0) {
+      return symbols;
+    }
+  }
+ 
+  return fallbackRegexSymbols(document);
+}
+ 
+export async function extractMethodScope(
+  document: vscode.TextDocument,
+  range: vscode.Range
+): Promise<MethodScope | null> {
+  const symbols = await getMethodSymbols(document);
+  if (symbols.length === 0) { return null; }
+ 
+  const target = findTargetSymbol(symbols, range);
+  if (!target) { return null; }
+ 
+  const ranges = new Map<MethodSymbol, vscode.Range>();
+  for (const symbol of symbols) {
+    ranges.set(
+      symbol,
+      symbol.range.isEmpty
+        ? expandToMethodBody(document, symbol.range.start.line)
+        : symbol.range
+    );
+  }
+ 
+  const targetRange = ranges.get(target)!;
+  const code = document.getText(targetRange);
+  if (!code.trim()) { return null; }
+ 
+  const callers: string[] = [];
+  const callees: string[] = [];
+  const targetPattern = new RegExp(`\\b${escapeRegExp(target.name)}\\s*\\(`);
+  for (const symbol of symbols) {
+    if (symbol === target) { continue; }
+    const otherCode = document.getText(ranges.get(symbol)!);
+    if (otherCode && targetPattern.test(otherCode)) {
+      callers.push(symbol.name);
+    }
+    const otherPattern = new RegExp(`\\b${escapeRegExp(symbol.name)}\\s*\\(`);
+    if (code && otherPattern.test(code)) {
+      callees.push(symbol.name);
+    }
+  }
+ 
+  return {
+    name: target.name,
+    range: targetRange,
+    code,
+    signature: extractSignature(code, target.name),
+    callers,
+    callees,
+    role: inferRole(target),
+  };
+}
+ 
+function findTargetSymbol(symbols: MethodSymbol[], range: vscode.Range): MethodSymbol | null {
+  const containing = symbols.filter(s => s.range.contains(range));
+  if (containing.length === 0) { return null; }
+  let best = containing[0];
+  for (const symbol of containing) {
+    if (symbol.range.start.isAfter(best.range.start)) {
+      best = symbol;
+    }
+  }
+  return best;
+}
+ 
+function escapeRegExp(str: string): string {
+  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+ 
+function extractSignature(code: string, fallbackName: string): string {
+  const lines = code.split('\n');
+  const sigLines: string[] = [];
+  for (const line of lines) {
+    const trimmed = line.trim();
+    if (!trimmed) { continue; }
+    if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('*/')) {
+      continue;
+    }
+    sigLines.push(trimmed);
+    if (trimmed.includes('{') || trimmed.endsWith(')')) { break; }
+  }
+  let sig = sigLines.join(' ').replace(/\{.*$/, '').trim();
+  sig = sig.replace(/\s{2,}/g, ' ');
+  return sig || fallbackName;
+}
+ 
+function inferRole(symbol: MethodSymbol): string {
+  const container = symbol.containerName ?? '';
+  const name = symbol.name.toLowerCase();
+  if (container.includes('Controller')) { return 'HTTP 请求处理入口'; }
+  if (container.includes('Service')) { return '业务逻辑处理'; }
+  if (container.includes('Repository') || container.includes('Dao')) { return '数据访问'; }
+  if (name.startsWith('get') || name.startsWith('set') || name.startsWith('is')) { return '属性访问器'; }
+  if (name.startsWith('init') || name.startsWith('on')) { return '生命周期回调'; }
+  if (name.startsWith('handle') || name.startsWith('process')) { return '流程处理'; }
+  if (name.startsWith('build') || name.startsWith('create')) { return '工厂/构建'; }
+  if (name.startsWith('parse') || name.startsWith('convert') || name.startsWith('transform')) { return '数据转换'; }
+  return '通用方法';
+}
+ 
+function fallbackRegexSymbols(document: vscode.TextDocument): MethodSymbol[] {
+  const text = document.getText();
+  const lang = document.languageId;
+  const patterns: RegExp[] = [];
+  if (['typescript', 'javascript', 'typescriptreact', 'javascriptreact'].includes(lang)) {
+    patterns.push(/(?:async\s+)?function\s+(\w+)/g);
+    patterns.push(/(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:function\s*)?\(/g);
+  } else if (['java', 'kotlin', 'go'].includes(lang)) {
+    patterns.push(/((?:public|private|protected|static)\s+)*\w+(?:<[^>]+>)?\s+(\w+)\s*\(/g);
+  }
+ 
+  const symbols: MethodSymbol[] = [];
+  const seen = new Set<string>();
+  for (const pattern of patterns) {
+    pattern.lastIndex = 0;
+    let match: RegExpExecArray | null;
+    while ((match = pattern.exec(text)) !== null) {
+      const name = match[2] ?? match[1];
+      if (!name) { continue; }
+      const start = document.positionAt(match.index);
+      const key = `${name}@${start.line}`;
+      if (seen.has(key)) { continue; }
+      seen.add(key);
+      symbols.push({ name, range: new vscode.Range(start, start) });
+    }
+  }
+  return symbols;
+}
+ 
+function expandToMethodBody(document: vscode.TextDocument, startLine: number): vscode.Range {
+  const start = new vscode.Position(startLine, 0);
+  const text = document.getText();
+  const startOffset = document.offsetAt(start);
+  let depth = 0;
+  let inString: string | null = null;
+  let i = startOffset;
+  while (i < text.length) {
+    const ch = text[i];
+    if (inString) {
+      if (ch === '\\') { i += 2; continue; }
+      if (ch === inString) { inString = null; }
+    } else if (ch === '"' || ch === "'" || ch === '`') {
+      inString = ch;
+    } else if (ch === '/') {
+      if (text[i + 1] === '/') {
+        while (i < text.length && text[i] !== '\n') { i++; }
+        continue;
+      }
+      if (text[i + 1] === '*') {
+        i += 2;
+        while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) { i++; }
+        i += 2;
+        continue;
+      }
+    } else if (ch === '{') {
+      depth++;
+    } else if (ch === '}') {
+      depth--;
+      if (depth === 0) {
+        return new vscode.Range(start, document.positionAt(i + 1));
+      }
+    }
+    i++;
+  }
+  const lastLine = document.lineCount - 1;
+  return new vscode.Range(start, document.lineAt(lastLine).range.end);
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/scope/status-cache.ts.html b/tests/coverage/src/scope/status-cache.ts.html new file mode 100644 index 0000000..86c1ea0 --- /dev/null +++ b/tests/coverage/src/scope/status-cache.ts.html @@ -0,0 +1,193 @@ + + + + + + Code coverage report for src/scope/status-cache.ts + + + + + + + + + +
+
+

All files / src/scope status-cache.ts

+
+ +
+ 58.33% + Statements + 21/36 +
+ + +
+ 66.66% + Branches + 2/3 +
+ + +
+ 40% + Functions + 2/5 +
+ + +
+ 58.33% + Lines + 21/36 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +371x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +1x +1x +  +  +  +  +  +  +1x +1x +11x +11x +  +  +  +  +11x +1x +1x +  +  +1x + 
import * as vscode from 'vscode';
+ 
+export interface ReviewStatus {
+  issueCount: number;
+  timestamp: number;
+}
+ 
+export class ReviewStatusCache {
+  private cache = new Map<string, ReviewStatus>();
+ 
+  get(uri: vscode.Uri, methodName: string): ReviewStatus | null {
+    const key = this.buildKey(uri, methodName);
+    return this.cache.get(key) ?? null;
+  }
+ 
+  set(uri: vscode.Uri, methodName: string, issueCount: number): void {
+    const key = this.buildKey(uri, methodName);
+    this.cache.set(key, {
+      issueCount,
+      timestamp: Date.now(),
+    });
+  }
+ 
+  clearDocument(uri: vscode.Uri): void {
+    const prefix = uri.toString() + '::';
+    for (const key of this.cache.keys()) {
+      if (key.startsWith(prefix)) {
+        this.cache.delete(key);
+      }
+    }
+  }
+ 
+  private buildKey(uri: vscode.Uri, methodName: string): string {
+    return uri.toString() + '::' + methodName;
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/services/auxClasspath.ts.html b/tests/coverage/src/services/auxClasspath.ts.html new file mode 100644 index 0000000..cded531 --- /dev/null +++ b/tests/coverage/src/services/auxClasspath.ts.html @@ -0,0 +1,745 @@ + + + + + + Code coverage report for src/services/auxClasspath.ts + + + + + + + + + +
+
+

All files / src/services auxClasspath.ts

+
+ +
+ 31.36% + Statements + 69/220 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 7.69% + Functions + 1/13 +
+ + +
+ 31.36% + Lines + 69/220 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +2211x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +1x +1x +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +1x +1x +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  + 
import { existsSync, statSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
+import { join } from 'path';
+import { tmpdir } from 'os';
+import { spawn, type ChildProcess } from 'child_process';
+import { getPMDAutoAuxClasspath } from '../config';
+ 
+export interface AuxClasspathInfo {
+  classpath: string;
+  source?: 'maven' | 'gradle';
+  error?: string;
+}
+ 
+interface BuildTarget {
+  kind: 'maven' | 'gradle';
+  path: string;
+}
+ 
+interface CacheEntry {
+  buildFile: string;
+  mtimeMs: number;
+  info: AuxClasspathInfo;
+}
+ 
+const BUILD_TIMEOUT = 120_000;
+ 
+const GRADLE_INIT_SCRIPT = `
+gradle.allprojects { proj ->
+    if (proj == gradle.rootProject) {
+        proj.tasks.register('_printRuntimeClasspath') {
+            doLast {
+                if (proj.plugins.hasPlugin('java')) {
+                    def main = proj.sourceSets.findByName('main')
+                    if (main != null) {
+                        println main.runtimeClasspath.asPath
+                    }
+                }
+            }
+        }
+    }
+}
+`;
+ 
+export class AuxClasspathResolver {
+  private cache = new Map<string, CacheEntry>();
+  private inflight = new Map<string, Promise<AuxClasspathInfo>>();
+ 
+  async resolve(workingDir: string): Promise<AuxClasspathInfo> {
+    if (!getPMDAutoAuxClasspath()) {
+      return { classpath: '' };
+    }
+    const build = this.detectBuildFile(workingDir);
+    if (!build) {
+      return { classpath: '' };
+    }
+    const mtime = this.statMtime(build.path);
+    const cached = this.cache.get(workingDir);
+    if (cached && cached.buildFile === build.path && cached.mtimeMs === mtime) {
+      return cached.info;
+    }
+    const pending = this.inflight.get(workingDir);
+    if (pending) {
+      return pending;
+    }
+    const running = this.runBuild(workingDir, build)
+      .then((info) => {
+        this.cache.set(workingDir, { buildFile: build.path, mtimeMs: mtime, info });
+        return info;
+      })
+      .finally(() => {
+        this.inflight.delete(workingDir);
+      });
+    this.inflight.set(workingDir, running);
+    return running;
+  }
+ 
+  clear(workingDir?: string): void {
+    if (workingDir) {
+      this.cache.delete(workingDir);
+      this.inflight.delete(workingDir);
+    } else {
+      this.cache.clear();
+      this.inflight.clear();
+    }
+  }
+ 
+  private detectBuildFile(workingDir: string): BuildTarget | null {
+    const candidates: BuildTarget[] = [
+      { kind: 'maven', path: join(workingDir, 'pom.xml') },
+      { kind: 'gradle', path: join(workingDir, 'build.gradle') },
+      { kind: 'gradle', path: join(workingDir, 'build.gradle.kts') },
+    ];
+    for (const candidate of candidates) {
+      if (existsSync(candidate.path)) {
+        return candidate;
+      }
+    }
+    return null;
+  }
+ 
+  private statMtime(file: string): number {
+    try {
+      return statSync(file).mtimeMs;
+    } catch {
+      return 0;
+    }
+  }
+ 
+  private runBuild(workingDir: string, build: BuildTarget): Promise<AuxClasspathInfo> {
+    if (build.kind === 'maven') {
+      return this.runMaven(workingDir);
+    }
+    return this.runGradle(workingDir);
+  }
+ 
+  private async runMaven(workingDir: string): Promise<AuxClasspathInfo> {
+    const outputFile = join(tmpdir(), `pmd-auxcp-${process.pid}-${Date.now()}.txt`);
+    const cmd = this.resolveTool(workingDir, ['mvnw.cmd', 'mvnw'], isWindows() ? 'mvn.cmd' : 'mvn');
+    try {
+      const args = ['-B', '-q', `-Dmdep.outputFile=${outputFile}`, 'dependency:build-classpath'];
+      await this.exec(cmd, args, workingDir);
+      const cp = existsSync(outputFile) ? readFileSync(outputFile, 'utf8').trim() : '';
+      return { classpath: cp, source: 'maven' };
+    } catch (err) {
+      return { classpath: '', source: 'maven', error: err instanceof Error ? err.message : String(err) };
+    } finally {
+      if (existsSync(outputFile)) {
+        unlinkSync(outputFile);
+      }
+    }
+  }
+ 
+  private async runGradle(workingDir: string): Promise<AuxClasspathInfo> {
+    const cmd = this.resolveTool(workingDir, ['gradlew.bat', 'gradlew'], isWindows() ? 'gradle.bat' : 'gradle');
+    const initScript = join(tmpdir(), `pmd-auxcp-${process.pid}-${Date.now()}.gradle`);
+    writeFileSync(initScript, GRADLE_INIT_SCRIPT, 'utf8');
+    try {
+      const args = ['-q', '-I', initScript, '_printRuntimeClasspath'];
+      const output = await this.exec(cmd, args, workingDir);
+      const cp = this.lastNonEmptyLine(output);
+      return { classpath: cp, source: 'gradle' };
+    } catch (err) {
+      return { classpath: '', source: 'gradle', error: err instanceof Error ? err.message : String(err) };
+    } finally {
+      if (existsSync(initScript)) {
+        unlinkSync(initScript);
+      }
+    }
+  }
+ 
+  private resolveTool(workingDir: string, wrapperNames: string[], systemName: string): string {
+    for (const name of wrapperNames) {
+      const wrapper = join(workingDir, name);
+      if (existsSync(wrapper)) {
+        return wrapper;
+      }
+    }
+    return systemName;
+  }
+ 
+  private quoteCmdArg(arg: string): string {
+    if (/^[\w.,:=/@%\\+-]+$/.test(arg)) {
+      return arg;
+    }
+    return `"${arg.replace(/"/g, '""')}"`;
+  }
+ 
+  private lastNonEmptyLine(output: string): string {
+    const lines = output.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
+    return lines.length > 0 ? lines[lines.length - 1] : '';
+  }
+ 
+  private exec(cmd: string, args: string[], cwd: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+      let proc: ChildProcess | null = null;
+      let stdout = '';
+      let stderr = '';
+      const timer = setTimeout(() => {
+        if (proc) {
+          proc.kill();
+        }
+        reject(new Error(`Command timed out after ${BUILD_TIMEOUT}ms: ${cmd}`));
+      }, BUILD_TIMEOUT);
+      try {
+        if (isWindows() && /\.(cmd|bat)$/i.test(cmd)) {
+          const inner = `${this.quoteCmdArg(cmd)} ${args.map((arg) => this.quoteCmdArg(arg)).join(' ')}`;
+          proc = spawn('cmd.exe', ['/d', '/s', '/c', `"${inner}"`], {
+            cwd,
+            windowsHide: true,
+            windowsVerbatimArguments: true,
+          });
+        } else {
+          proc = spawn(cmd, args, { cwd, windowsHide: true });
+        }
+      } catch (err) {
+        clearTimeout(timer);
+        reject(err);
+        return;
+      }
+      const child = proc;
+      child.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); });
+      child.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); });
+      child.on('error', (err) => {
+        clearTimeout(timer);
+        reject(err);
+      });
+      child.on('close', (code) => {
+        clearTimeout(timer);
+        if (code === 0) {
+          resolve(stdout);
+        } else {
+          reject(new Error(`${cmd} exited with code ${code}: ${(stderr || stdout).trim()}`));
+        }
+      });
+    });
+  }
+}
+ 
+function isWindows(): boolean {
+  return process.platform === 'win32';
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/services/index.html b/tests/coverage/src/services/index.html new file mode 100644 index 0000000..a4bc1de --- /dev/null +++ b/tests/coverage/src/services/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for src/services + + + + + + + + + +
+
+

All files src/services

+
+ +
+ 31.36% + Statements + 69/220 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 7.69% + Functions + 1/13 +
+ + +
+ 31.36% + Lines + 69/220 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
auxClasspath.ts +
+
31.36%69/220100%1/17.69%1/1331.36%69/220
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/utils/diff.ts.html b/tests/coverage/src/utils/diff.ts.html new file mode 100644 index 0000000..e4edec8 --- /dev/null +++ b/tests/coverage/src/utils/diff.ts.html @@ -0,0 +1,232 @@ + + + + + + Code coverage report for src/utils/diff.ts + + + + + + + + + +
+
+

All files / src/utils diff.ts

+
+ +
+ 10.2% + Statements + 5/49 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/1 +
+ + +
+ 10.2% + Lines + 5/49 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +501x +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
export interface DiffLine {
+  type: 'del' | 'add' | 'same';
+  text: string;
+}
+ 
+export function computeLineDiff(originalText: string, newText: string): DiffLine[] {
+  const a = originalText.split('\n');
+  const b = newText.split('\n');
+
+  const n = a.length;
+  const m = b.length;
+  const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
+
+  for (let i = n - 1; i >= 0; i--) {
+    for (let j = m - 1; j >= 0; j--) {
+      if (a[i] === b[j]) {
+        lcs[i][j] = lcs[i + 1][j + 1] + 1;
+      } else {
+        lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
+      }
+    }
+  }
+
+  const out: DiffLine[] = [];
+  let i = 0;
+  let j = 0;
+  while (i < n && j < m) {
+    if (a[i] === b[j]) {
+      out.push({ type: 'same', text: a[i] });
+      i++;
+      j++;
+    } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
+      out.push({ type: 'del', text: a[i] });
+      i++;
+    } else {
+      out.push({ type: 'add', text: b[j] });
+      j++;
+    }
+  }
+  while (i < n) {
+    out.push({ type: 'del', text: a[i] });
+    i++;
+  }
+  while (j < m) {
+    out.push({ type: 'add', text: b[j] });
+    j++;
+  }
+  return out;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/utils/index.html b/tests/coverage/src/utils/index.html new file mode 100644 index 0000000..3fbfa84 --- /dev/null +++ b/tests/coverage/src/utils/index.html @@ -0,0 +1,146 @@ + + + + + + Code coverage report for src/utils + + + + + + + + + +
+
+

All files src/utils

+
+ +
+ 72.63% + Statements + 146/201 +
+ + +
+ 90% + Branches + 36/40 +
+ + +
+ 53.84% + Functions + 7/13 +
+ + +
+ 72.63% + Lines + 146/201 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
diff.ts +
+
10.2%5/49100%0/00%0/110.2%5/49
mockDocument.ts +
+
79.24%42/5381.25%13/1644.44%4/979.24%42/53
report.ts +
+
100%99/9995.83%23/24100%3/3100%99/99
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/utils/mockDocument.ts.html b/tests/coverage/src/utils/mockDocument.ts.html new file mode 100644 index 0000000..3068b17 --- /dev/null +++ b/tests/coverage/src/utils/mockDocument.ts.html @@ -0,0 +1,244 @@ + + + + + + Code coverage report for src/utils/mockDocument.ts + + + + + + + + + +
+
+

All files / src/utils mockDocument.ts

+
+ +
+ 79.24% + Statements + 42/53 +
+ + +
+ 81.25% + Branches + 13/16 +
+ + +
+ 44.44% + Functions + 4/9 +
+ + +
+ 79.24% + Lines + 42/53 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +542x +2x +17x +17x +17x +17x +17x +47x +47x +47x +17x +17x +35x +35x +97x +62x +62x +  +17x +17x +17x +17x +17x +17x +17x +17x +17x +17x +17x +17x +49x +15x +17x +17x +  +  +  +  +  +  +  +  +  +  +17x +17x +17x +17x +17x +17x +17x +17x +17x + 
import * as vscode from 'vscode';
+ 
+export function mockDocument(code: string, language: string, fileName?: string): vscode.TextDocument {
+  const lines = code.split('\n');
+  const uri = vscode.Uri.parse('untitled:virtual');
+  const ext = language === 'javascript' ? 'js' : language === 'typescript' ? 'ts' : language === 'css' ? 'css' : 'java';
+  const offsetAt = (p: vscode.Position): number => {
+    let offset = 0;
+    for (let i = 0; i < p.line; i++) offset += lines[i].length + 1;
+    return offset + p.character;
+  };
+  const positionAt = (offset: number): vscode.Position => {
+    let remaining = offset;
+    for (let i = 0; i < lines.length; i++) {
+      if (remaining <= lines[i].length) return new vscode.Position(i, remaining);
+      remaining -= lines[i].length + 1;
+    }
+    return new vscode.Position(lines.length - 1, lines[lines.length - 1].length);
+  };
+  return {
+    uri,
+    fileName: fileName ?? `untitled.${ext}`,
+    isUntitled: true,
+    languageId: language,
+    version: 1,
+    isDirty: false,
+    isClosed: false,
+    eol: vscode.EndOfLine.LF,
+    lineCount: lines.length,
+    getText: (range?: vscode.Range) => {
+      if (!range) { return code; }
+      return code.slice(offsetAt(range.start), offsetAt(range.end));
+    },
+    lineAt: (arg: number | vscode.Position) => {
+      const line = typeof arg === 'number' ? arg : arg.line;
+      const text = lines[line] ?? '';
+      return {
+        lineNumber: line,
+        text,
+        range: new vscode.Range(line, 0, line, text.length),
+        rangeIncludingLineBreak: new vscode.Range(line, 0, line, text.length),
+        firstNonWhitespaceCharacterIndex: text.search(/\S|$/),
+        isEmptyOrWhitespace: text.trim().length === 0,
+      };
+    },
+    offsetAt,
+    positionAt,
+    getWordRangeAtPosition: () => undefined,
+    validateRange: (r: vscode.Range) => r,
+    validatePosition: (p: vscode.Position) => p,
+    save: () => Promise.resolve(false),
+  } as unknown as vscode.TextDocument;
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/utils/report.ts.html b/tests/coverage/src/utils/report.ts.html new file mode 100644 index 0000000..521691d --- /dev/null +++ b/tests/coverage/src/utils/report.ts.html @@ -0,0 +1,382 @@ + + + + + + Code coverage report for src/utils/report.ts + + + + + + + + + +
+
+

All files / src/utils report.ts

+
+ +
+ 100% + Statements + 99/99 +
+ + +
+ 95.83% + Branches + 23/24 +
+ + +
+ 100% + Functions + 3/3 +
+ + +
+ 100% + Lines + 99/99 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +1002x +2x +2x +7x +7x +7x +7x +7x +7x +7x +7x +2x +7x +7x +7x +2x +7x +7x +7x +7x +7x +7x +7x +7x +7x +6x +6x +7x +1x +1x +1x +7x +1x +1x +1x +1x +1x +1x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +7x +1x +1x +1x +4x +4x +4x +1x +1x +4x +1x +1x +7x +7x +1x +1x +1x +1x +1x +1x +1x +1x +7x +7x +1x +1x +1x +2x +2x +2x +2x +2x +2x +2x +1x +1x +7x +7x +4x +4x +4x +7x +7x +7x + 
import { MergedReport } from '../merger/merger';
+import { t } from '../i18n/messages';
+ 
+function severityEmoji(severity: string): string {
+  switch (severity) {
+    case 'error': return '🔴';
+    case 'warning': return '🟡';
+    case 'info': return '🔵';
+    default: return '⚪';
+  }
+}
+ 
+function formatLine(line: number): string {
+  return Number.isFinite(line) ? `L${line + 1}` : 'L?';
+}
+ 
+export function reportToMarkdown(report: MergedReport): string {
+  const lines: string[] = [];
+ 
+  lines.push(`# ${t('report.title')}`);
+  lines.push('');
+  lines.push(`**${t('report.file')}:** \`${report.filePath}\``);
+  lines.push(`**${t('report.language')}:** ${report.language}`);
+  lines.push(`**${t('report.duration')}:** ${(report.duration / 1000).toFixed(1)}s`);
+  if (report.adapterNames.length > 0) {
+    lines.push(`**${t('report.tools')}:** ${report.adapterNames.join(', ')}`);
+  }
+  if (report.degraded) {
+    lines.push('');
+    lines.push(`> ⚠️ ${t('report.degradedBanner')}`);
+  }
+  if (report.errors.length > 0) {
+    lines.push('');
+    lines.push(`## ${t('report.errors')}`);
+    for (const err of report.errors) {
+      lines.push(`- ${err}`);
+    }
+  }
+ 
+  lines.push('');
+  lines.push('---');
+  lines.push('');
+ 
+  const total = report.linterCount + report.customRuleCount + report.aiCount;
+  const errors = report.linterDiagnostics.filter(d => d.severity === 'error').length
+    + report.customRuleDiagnostics.filter(d => d.severity === 'error').length
+    + report.aiFindings.filter(f => f.severity === 'error').length;
+  const warnings = report.linterDiagnostics.filter(d => d.severity === 'warning').length
+    + report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
+    + report.aiFindings.filter(f => f.severity === 'warning').length;
+  const infos = total - errors - warnings;
+ 
+  lines.push(t('report.totalSummary', { 0: String(total), 1: String(errors), 2: String(warnings), 3: String(infos) }));
+  lines.push('');
+ 
+  if (report.linterDiagnostics.length > 0) {
+    lines.push(t('report.staticSection', { 0: String(report.linterCount) }));
+    lines.push('');
+    for (const diag of report.linterDiagnostics) {
+      lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
+      lines.push(`  ${diag.message}`);
+      if (diag.suggestion) {
+        lines.push(`  ${t('report.suggestion')}: ${diag.suggestion}`);
+      }
+    }
+    lines.push('');
+  }
+ 
+  if (report.customRuleDiagnostics.length > 0) {
+    lines.push(t('report.customSection', { 0: String(report.customRuleCount) }));
+    lines.push('');
+    for (const diag of report.customRuleDiagnostics) {
+      lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
+      lines.push(`  ${diag.message}`);
+    }
+    lines.push('');
+  }
+ 
+  if (report.aiFindings.length > 0) {
+    lines.push(t('report.aiSection', { 0: String(report.aiCount) }));
+    lines.push('');
+    for (const finding of report.aiFindings) {
+      lines.push(`- ${severityEmoji(finding.severity)} [AI] [${finding.category}] \`${finding.ruleId}\` ${formatLine(finding.line)}`);
+      lines.push(`  **${finding.title}**`);
+      lines.push(`  ${finding.description}`);
+      if (finding.suggestion) {
+        lines.push(`  ${t('report.suggestion')}: ${finding.suggestion}`);
+      }
+    }
+    lines.push('');
+  }
+ 
+  if (total === 0) {
+    lines.push(`✅ ${t('report.noProblems')}`);
+    lines.push('');
+  }
+ 
+  return lines.join('\n');
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/views/codeLensProvider.ts.html b/tests/coverage/src/views/codeLensProvider.ts.html new file mode 100644 index 0000000..8286d7a --- /dev/null +++ b/tests/coverage/src/views/codeLensProvider.ts.html @@ -0,0 +1,253 @@ + + + + + + Code coverage report for src/views/codeLensProvider.ts + + + + + + + + + +
+
+

All files / src/views codeLensProvider.ts

+
+ +
+ 30.35% + Statements + 17/56 +
+ + +
+ 100% + Branches + 1/1 +
+ + +
+ 25% + Functions + 1/4 +
+ + +
+ 30.35% + Lines + 17/56 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +571x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +1x + 
import * as vscode from 'vscode';
+import { getMethodSymbols } from '../scope/method-extractor';
+import { ReviewStatusCache, type ReviewStatus } from '../scope/status-cache';
+import { t } from '../i18n/messages';
+ 
+export class MethodCodeLensProvider implements vscode.CodeLensProvider {
+  private _onDidChangeCodeLenses: vscode.EventEmitter<void> = new vscode.EventEmitter<void>();
+  readonly onDidChangeCodeLenses: vscode.Event<void> = this._onDidChangeCodeLenses.event;
+ 
+  constructor(private statusCache: ReviewStatusCache) {}
+ 
+  refresh(): void {
+    this._onDidChangeCodeLenses.fire();
+  }
+ 
+  async provideCodeLenses(
+    document: vscode.TextDocument,
+    token: vscode.CancellationToken
+  ): Promise<vscode.CodeLens[]> {
+    const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
+    const enabled = config.get<boolean>('codelens.enabled', true);
+    if (!enabled) { return []; }
+
+    const languages = config.get<string[]>('codelens.languages', [
+      'typescript', 'javascript', 'java', 'python'
+    ]);
+    if (!languages.includes(document.languageId)) { return []; }
+
+    const symbols = await getMethodSymbols(document);
+    if (symbols.length === 0) { return []; }
+    if (symbols.length > 50) { return []; }
+
+    const lenses: vscode.CodeLens[] = [];
+    for (const symbol of symbols) {
+      const status = this.statusCache.get(document.uri, symbol.name);
+      const title = this.buildLensTitle(status);
+      const line = symbol.range.start.line;
+      lenses.push(new vscode.CodeLens(new vscode.Range(line, 0, line, 0), {
+        command: 'codeReviewer.reviewMethod',
+        title,
+        arguments: [symbol.range],
+      }));
+    }
+    return lenses;
+  }
+ 
+  private buildLensTitle(status: ReviewStatus | null): string {
+    if (!status) {
+      return t('codelens.reviewMethod');
+    }
+    if (status.issueCount === 0) {
+      return t('codelens.reviewedClean');
+    }
+    return t('codelens.reviewedWithIssues', { 0: String(status.issueCount) });
+  }
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/views/index.html b/tests/coverage/src/views/index.html new file mode 100644 index 0000000..56f05e3 --- /dev/null +++ b/tests/coverage/src/views/index.html @@ -0,0 +1,131 @@ + + + + + + Code coverage report for src/views + + + + + + + + + +
+
+

All files src/views

+
+ +
+ 14.21% + Statements + 184/1294 +
+ + +
+ 75% + Branches + 3/4 +
+ + +
+ 10.34% + Functions + 3/29 +
+ + +
+ 14.21% + Lines + 184/1294 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
codeLensProvider.ts +
+
30.35%17/56100%1/125%1/430.35%17/56
setupView.ts +
+
13.48%167/123866.66%2/38%2/2513.48%167/1238
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/coverage/src/views/setupView.ts.html b/tests/coverage/src/views/setupView.ts.html new file mode 100644 index 0000000..4203ce0 --- /dev/null +++ b/tests/coverage/src/views/setupView.ts.html @@ -0,0 +1,3799 @@ + + + + + + Code coverage report for src/views/setupView.ts + + + + + + + + + +
+
+

All files / src/views setupView.ts

+
+ +
+ 13.48% + Statements + 167/1238 +
+ + +
+ 66.66% + Branches + 2/3 +
+ + +
+ 8% + Functions + 2/25 +
+ + +
+ 13.48% + Lines + 167/1238 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +12391x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +1x +  +  +  +  +  +1x +1x +1x +1x +  +  +  +1x +1x +1x +  +  +  +  +  +  +  +1x +1x +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +1x +1x +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +1x +  +  +  +  +1x +  +  +  +  + 
import * as vscode from 'vscode';
+import * as path from 'path';
+import * as fs from 'fs';
+import { execSync } from 'child_process';
+import { getAIProvider, getAIModel, getAIOutputLanguage, getAIConfig, getAIMaxTokens } from '../config/ai';
+import { getApiKey, setApiKey } from '../config/secret';
+import { createProvider, getAllProviderMeta, getProviderModels, invalidateProviderCache } from '../ai/factory';
+import { listRuleFiles } from '../rules/yaml-parser';
+import { ImportService } from '../rules/import-service';
+import { showImportPreview } from '../rules/import-preview';
+import { exportTemplate as exportTemplateService } from '../rules/export-service';
+import { YamlConverter } from '../rules/converters/yaml-converter';
+import { MdConverter } from '../rules/converters/md-converter';
+import { TxtConverter } from '../rules/converters/txt-converter';
+import { ExcelConverter } from '../rules/converters/excel-converter';
+import { DocxConverter } from '../rules/converters/docx-converter';
+import { PptxConverter } from '../rules/converters/pptx-converter';
+import { t, getLanguage, onLanguageChange } from '../i18n/messages';
+import { getEslintConfigPath, getStylelintConfigPath, getPMDRulesetPath, getSqlFluffConfigFile, isAdapterEnabled, setAdapterEnabled } from '../config/linter';
+import { resolveSqlFluffDialect, type SqlFluffDialectSource } from '../adapters/sqlfluff';
+import {
+  buildEslintProjectConfigText,
+  buildStylelintProjectConfigText,
+  buildSqlfluffProjectConfigText,
+  buildPmdProjectRulesetText,
+} from '../rules/builtin-rules';
+ 
+type ConfigMode = 'builtin' | 'project' | 'global';
+ 
+type DependencyStatus = 'ready' | 'missing' | 'none';
+ 
+interface AdapterConfigStatus {
+  id: string;
+  name: string;
+  enabled: boolean;
+  configMode: ConfigMode;
+  dependencyStatus: DependencyStatus;
+  dependencyLabel?: string;
+  configured: boolean;
+  languages: string;
+  projectConfigFileName: string;
+  settingsTarget: string;
+  sqlfluffDialect?: string;
+  sqlfluffDialectSource?: SqlFluffDialectSource;
+}
+ 
+const SQLFLUFF_CONFIG_DIALECT = 'mysql';
+ 
+const ADAPTER_METADATA: Record<string, {
+  name: string;
+  projectConfigFileName: string;
+  settingsTarget: string;
+  hasExternalDependency: boolean;
+  dependencyLabel?: string;
+  configFileTemplate: () => string | Promise<string>;
+  i18nKey: string;
+}> = {
+  pmd: {
+    name: 'PMD',
+    projectConfigFileName: 'ruleset.xml',
+    settingsTarget: 'vscode-code-reviewer.pmd',
+    hasExternalDependency: true,
+    dependencyLabel: 'Java',
+    configFileTemplate: () => buildPmdProjectRulesetText(getLanguage()),
+    i18nKey: 'pmd',
+  },
+  'sqlfluff': {
+    name: 'SQLFluff',
+    projectConfigFileName: '.sqlfluff',
+    settingsTarget: 'vscode-code-reviewer.sqlfluff',
+    hasExternalDependency: true,
+    dependencyLabel: 'Python + sqlfluff',
+    configFileTemplate: () => buildSqlfluffProjectConfigText(SQLFLUFF_CONFIG_DIALECT, getLanguage()),
+    i18nKey: 'sql',
+  },
+  eslint: {
+    name: 'ESLint',
+    projectConfigFileName: 'eslint.config.js',
+    settingsTarget: 'vscode-code-reviewer.linters',
+    hasExternalDependency: false,
+    configFileTemplate: () => buildEslintProjectConfigText(getLanguage()),
+    i18nKey: 'eslint',
+  },
+  stylelint: {
+    name: 'Stylelint',
+    projectConfigFileName: '.stylelintrc.js',
+    settingsTarget: 'vscode-code-reviewer.linters',
+    hasExternalDependency: false,
+    configFileTemplate: () => buildStylelintProjectConfigText(getLanguage()),
+    i18nKey: 'stylelint',
+  },
+};
+ 
+const PROJECT_CONFIG_FILES: Record<string, string[]> = {
+  eslint: ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts', 'eslint.config.cts'],
+  stylelint: ['.stylelintrc.js', '.stylelintrc.json', '.stylelintrc.yaml', '.stylelintrc.yml', '.stylelintrc', 'stylelint.config.js', 'stylelint.config.mjs', 'stylelint.config.cjs'],
+  pmd: ['ruleset.xml'],
+  'sqlfluff': ['.sqlfluff'],
+};
+ 
+const GLOBAL_CONFIG_GETTERS: Record<string, () => string> = {
+  eslint: getEslintConfigPath,
+  stylelint: getStylelintConfigPath,
+  pmd: getPMDRulesetPath,
+  'sqlfluff': getSqlFluffConfigFile,
+};
+ 
+export class SetupViewProvider implements vscode.WebviewViewProvider {
+  private _view?: vscode.WebviewView;
+  public connectionTested = false;
+  public connectionSuccess = false;
+  private importService = new ImportService();
+  private _providers: Record<string, { name: string; models: string[] }> = {};
+  private _config: { provider: string; model: string; outputLanguage: string; baseUrl: string } = { provider: '', model: '', outputLanguage: 'zh-CN', baseUrl: '' };
+  private _scriptUri: vscode.Uri | null = null;
+ 
+  constructor(private context: vscode.ExtensionContext) {
+    this.restoreConnectionState();
+    this.importService.registerConverter(new YamlConverter());
+    this.importService.registerConverter(new MdConverter());
+    this.importService.registerConverter(new TxtConverter());
+    this.importService.registerConverter(new ExcelConverter());
+    this.importService.registerConverter(new DocxConverter());
+    this.importService.registerConverter(new PptxConverter());
+  }
+ 
+  private async getConfigFingerprint(): Promise<string> {
+    const cfg = getAIConfig();
+    const hasApiKey = await isApiKeyConfigured(this.context);
+    const hasBaseUrl = isBaseUrlConfigured();
+    return `${cfg.provider}|${cfg.model}|${hasApiKey}|${hasBaseUrl}`;
+  }
+ 
+  private restoreConnectionState(): void {
+    const saved = this.context.globalState.get<{ tested: boolean; success: boolean; fingerprint: string }>('connectionState');
+    if (saved) {
+      this.connectionTested = saved.tested;
+      this.connectionSuccess = saved.success;
+    }
+  }
+ 
+  private async saveConnectionState(): Promise<void> {
+    const fingerprint = await this.getConfigFingerprint();
+    await this.context.globalState.update('connectionState', {
+      tested: this.connectionTested,
+      success: this.connectionSuccess,
+      fingerprint,
+    });
+  }
+ 
+  private async clearConnectionState(): Promise<void> {
+    this.connectionTested = false;
+    this.connectionSuccess = false;
+    await this.context.globalState.update('connectionState', undefined);
+  }
+ 
+  resolveWebviewView(
+    webviewView: vscode.WebviewView,
+    _context: vscode.WebviewViewResolveContext,
+    _token: vscode.CancellationToken,
+  ): void {
+    this._view = webviewView;
+
+    webviewView.webview.options = {
+      enableScripts: true,
+      localResourceRoots: [this.context.extensionUri],
+    };
+
+    const config = getAIConfig();
+    const providers = getAllProviderMeta(this.context.extensionUri);
+    const scriptUri = webviewView.webview.asWebviewUri(
+      vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview', 'setupView.js')
+    );
+    const aiConfig = getAIConfig();
+    this._providers = providers;
+    this._config = aiConfig;
+    this._scriptUri = scriptUri;
+    this.getConfigFingerprint().then(fingerprint => {
+      const saved = this.context.globalState.get<{ fingerprint: string }>('connectionState');
+      if (saved && saved.fingerprint !== fingerprint) {
+        this.clearConnectionState();
+        this.pushConfig();
+      }
+    });
+    webviewView.webview.html = this.getHtml(providers, aiConfig, scriptUri);
+
+    const langDisposable = onLanguageChange(() => {
+      const newConfig = getAIConfig();
+      this._config = newConfig;
+      if (this._view && this._scriptUri) {
+        this._view.webview.html = this.getHtml(this._providers, newConfig, this._scriptUri);
+      }
+    });
+
+    webviewView.webview.onDidReceiveMessage(async (msg) => {
+      switch (msg.type) {
+        case 'ready':
+          try {
+            await this.pushConfig();
+          } catch (err) {
+            console.error('pushConfig failed:', err);
+          }
+          break;
+        case 'setApiKey':
+          await setApiKey(this.context, msg.value);
+          await this.clearConnectionState();
+          await this.pushConfig();
+          break;
+        case 'setProvider': {
+          const cfg = vscode.workspace.getConfiguration('vscode-code-reviewer');
+          await cfg.update('ai.provider', msg.value, vscode.ConfigurationTarget.Global);
+          const models = getProviderModels(this.context.extensionUri, msg.value);
+          if (models.length > 0) {
+            await cfg.update('ai.model', models[0], vscode.ConfigurationTarget.Global);
+          }
+          await this.clearConnectionState();
+          await this.pushConfig();
+          break;
+        }
+        case 'setModel':
+          await vscode.workspace.getConfiguration('vscode-code-reviewer').update('ai.model', msg.value, vscode.ConfigurationTarget.Global);
+          await this.clearConnectionState();
+          await this.pushConfig();
+          break;
+        case 'setBaseUrl':
+          await vscode.workspace.getConfiguration('vscode-code-reviewer')
+            .update('ai.baseUrl', msg.value || undefined, vscode.ConfigurationTarget.Global);
+          await this.clearConnectionState();
+          await this.pushConfig();
+          break;
+        case 'setLanguage':
+          await vscode.workspace.getConfiguration('vscode-code-reviewer').update('ai.outputLanguage', msg.value, vscode.ConfigurationTarget.Global);
+          await this.pushConfig();
+          break;
+        case 'saveAndTest':
+          await this.testConnection();
+          break;
+        case 'deleteFile':
+          await this.deleteFile(msg.fileName);
+          await this.pushConfig();
+          break;
+        case 'addRule':
+          await this.addRule(msg.name, msg.useTemplateMode);
+          await this.pushConfig();
+          break;
+        case 'exportTemplate':
+          await this.exportTemplate();
+          break;
+        case 'reset':
+          await this.resetConfig();
+          await this.pushConfig();
+          break;
+        case 'openAdapterConfig':
+          await this.handleAdapterConfig(msg.adapterId);
+          await this.pushConfig();
+          break;
+        case 'openSettings':
+          await vscode.commands.executeCommand(
+            'workbench.action.openSettings',
+            msg.settingsTarget
+          );
+          break;
+        case 'toggleAdapter':
+          await setAdapterEnabled(msg.adapterId, msg.enabled);
+          break;
+      }
+    });
+
+    const configChangeDisposable = vscode.workspace.onDidChangeConfiguration((e) => {
+      if (
+        e.affectsConfiguration('vscode-code-reviewer.linter') ||
+        e.affectsConfiguration('vscode-code-reviewer.linters') ||
+        e.affectsConfiguration('vscode-code-reviewer.pmd') ||
+        e.affectsConfiguration('vscode-code-reviewer.sqlfluff')
+      ) {
+        this.pushConfig();
+      }
+    });
+
+    const watcher = vscode.workspace.createFileSystemWatcher(
+      '**/.code-review/providers.json'
+    );
+
+    watcher.onDidChange(() => {
+      invalidateProviderCache();
+      this.pushConfig();
+    });
+
+    watcher.onDidCreate(() => {
+      invalidateProviderCache();
+      this.pushConfig();
+    });
+
+    webviewView.onDidDispose(() => {
+      configChangeDisposable.dispose();
+      watcher.dispose();
+    });
+  }
+ 
+  private async pushConfig(): Promise<void> {
+    if (!this._view) {return;}
+
+    const config = getAIConfig();
+    const apiKeyConfigured = await isApiKeyConfigured(this.context);
+    const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
+    const ruleFiles = listRuleFiles(workspaceRoot);
+
+    const baseUrlConfigured = isBaseUrlConfigured();
+
+    this._view.webview.postMessage({
+      type: 'initConfig',
+      config: {
+        provider: config.provider,
+        model: config.model,
+        baseUrl: baseUrlConfigured ? config.baseUrl : '',
+        baseUrlConfigured,
+        language: config.outputLanguage,
+        apiKeyConfigured,
+      },
+      providers: getAllProviderMeta(this.context.extensionUri),
+      ruleFiles,
+      connectionTested: this.connectionTested,
+      connectionSuccess: this.connectionSuccess,
+      adapterStatus: this.collectAdapterStatus(),
+      i18n: {
+        configured: t('setup.configured'),
+        notConfigured: t('setup.notConfigured'),
+        connected: t('setup.connected'),
+        notConnected: t('setup.notConnected'),
+        retry: t('setup.retry'),
+        saveTest: t('setup.saveAndTest'),
+        ruleCountFormat: t('setup.ruleCountFormat'),
+        noRuleFiles: t('setup.noRuleFiles'),
+        modeBuiltin: t('setup.adapter.modeBuiltin'),
+        modeProject: t('setup.adapter.modeProject'),
+        modeGlobal: t('setup.adapter.modeGlobal'),
+        configYes: t('setup.adapter.configYes'),
+        configNo: t('setup.adapter.configNo'),
+        langLabel: t('setup.adapter.langLabel'),
+        sqlfluffDialectLabel: t('setup.adapter.sqlfluffDialectLabel'),
+        btnCreateConfig: t('setup.adapter.btnCreateConfig'),
+        btnEditGlobal: t('setup.adapter.btnEditGlobal'),
+        tooltipCreate: t('setup.adapter.tooltipCreate'),
+        tooltipEdit: t('setup.adapter.tooltipEdit'),
+        toggleEnable: t('setup.adapter.toggleEnable'),
+        toggleDisable: t('setup.adapter.toggleDisable'),
+        pmdHelp: t('setup.adapter.pmdHelp'),
+        sqlHelp: t('setup.adapter.sqlHelp'),
+        eslintHelp: t('setup.adapter.eslintHelp'),
+        stylelintHelp: t('setup.adapter.stylelintHelp'),
+      },
+    });
+  }
+ 
+  private async testConnection(): Promise<void> {
+    const apiKey = await getApiKey(this.context);
+    if (!apiKey) {
+      this._view?.webview.postMessage({ type: 'testResult', success: false, message: t('setup.setApiKeyFirst') });
+      return;
+    }
+
+    if (!isBaseUrlConfigured()) {
+      this._view?.webview.postMessage({ type: 'testResult', success: false, message: t('setup.setBaseUrlFirst') });
+      return;
+    }
+
+    const config = getAIConfig();
+
+    try {
+      const provider = createProvider(config.provider, apiKey, config.baseUrl, this.context.extensionUri);
+      const result = await provider.chat('回复 ok', 'ping', {
+        model: config.model,
+        temperature: 0,
+        maxTokens: getAIMaxTokens(),
+        timeoutMs: 15000,
+      });
+      if (!result || result.trim() === '') {
+        throw new Error(t('setup.emptyResponse'));
+      }
+      this.connectionTested = true;
+      this.connectionSuccess = true;
+      await this.saveConnectionState();
+      this._view?.webview.postMessage({ type: 'testResult', success: true, message: t('setup.testSuccess') });
+    } catch (err) {
+      this.connectionTested = true;
+      this.connectionSuccess = false;
+      await this.saveConnectionState();
+      const message = err instanceof Error ? err.message : String(err);
+      this._view?.webview.postMessage({ type: 'testResult', success: false, message: t('setup.testFail', { 0: message }) });
+    }
+
+    await this.pushConfig();
+  }
+ 
+  private async deleteFile(fileName: string): Promise<void> {
+    const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+    if (!workspaceRoot) {return;}
+
+    const filePath = path.join(workspaceRoot, '.code-review', 'rules', fileName);
+    if (fs.existsSync(filePath)) {
+      fs.unlinkSync(filePath);
+    }
+  }
+ 
+  private async addRule(name: string, useTemplateMode?: boolean): Promise<void> {
+    if (!name.trim()) { return; }
+
+    const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+    if (!workspaceRoot) { return; }
+
+    if (useTemplateMode) {
+      const result = await vscode.window.showOpenDialog({
+        canSelectMany: false,
+        openLabel: t('setup.selectTemplateFile'),
+        filters: { 'Excel': ['xlsx', 'xls'] },
+      });
+      if (!result || result.length === 0) { return; }
+
+      try {
+        const conversion = await vscode.window.withProgress({
+          location: vscode.ProgressLocation.Notification,
+          title: t('setup.importingTemplate'),
+        }, async () => {
+          return await this.importService.importTemplate(
+            result[0].fsPath, name, this.context,
+          );
+        });
+
+        const decision = await showImportPreview(conversion, this.context);
+        if (!decision || !decision.confirmed) {
+          vscode.window.showInformationMessage(t('setup.importCancelled'));
+          return;
+        }
+
+        const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
+        if (!fs.existsSync(rulesDir)) {
+          fs.mkdirSync(rulesDir, { recursive: true });
+        }
+        const yamlFileName = name.endsWith('.yaml') ? name : `${name}.yaml`;
+        const yamlPath = path.join(rulesDir, yamlFileName);
+        if (fs.existsSync(yamlPath)) {
+          vscode.window.showErrorMessage(t('setup.fileExists', { 0: yamlFileName }));
+          return;
+        }
+        this.importService.applyConversion(conversion, decision, yamlPath);
+        vscode.window.showInformationMessage(
+          t('setup.importDedupResult', { 0: yamlFileName, 1: String(conversion.rules.length), 2: String(conversion.exactCount), 3: String(conversion.overlapCount) })
+        );
+      } catch (err) {
+        const msg = err instanceof Error ? err.message : String(err);
+        vscode.window.showErrorMessage(msg);
+      }
+      return;
+    }
+
+    const result = await vscode.window.showOpenDialog({
+      canSelectMany: false,
+      openLabel: t('setup.selectRuleFile'),
+      filters: { '规则文件': ['yaml', 'yml', 'md', 'txt', 'xlsx', 'xls', 'docx', 'pptx'] },
+    });
+    if (!result || result.length === 0) { return; }
+
+    const srcPath = result[0].fsPath;
+    const ext = path.extname(srcPath).toLowerCase();
+
+    const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
+    if (!fs.existsSync(rulesDir)) {
+      fs.mkdirSync(rulesDir, { recursive: true });
+    }
+
+    const yamlFileName = name.endsWith('.yaml') ? name : `${name}.yaml`;
+    const yamlPath = path.join(rulesDir, yamlFileName);
+
+    if (fs.existsSync(yamlPath)) {
+      vscode.window.showErrorMessage(t('setup.fileExists', { 0: yamlFileName }));
+      return;
+    }
+
+    if (ext === '.yaml' || ext === '.yml') {
+      fs.copyFileSync(srcPath, yamlPath);
+      vscode.window.showInformationMessage(t('setup.importSuccess', { 0: yamlFileName }));
+    } else {
+      try {
+        const conversion = await vscode.window.withProgress({
+          location: vscode.ProgressLocation.Notification,
+          title: t('setup.importing'),
+        }, () => this.importService.convert(srcPath, this.context));
+
+      const decision = await showImportPreview(conversion, this.context);
+      if (!decision || !decision.confirmed) {
+        vscode.window.showInformationMessage(t('setup.importCancelled'));
+        return;
+      }
+
+      this.importService.applyConversion(conversion, decision, yamlPath);
+      vscode.window.showInformationMessage(
+        t('setup.importDedupResult', { 0: yamlFileName, 1: String(conversion.rules.length), 2: String(conversion.exactCount), 3: String(conversion.overlapCount) })
+      );
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      vscode.window.showErrorMessage(t('setup.importFail', { 0: msg }));
+    }
+    }
+  }
+ 
+  private async exportTemplate(): Promise<void> {
+    await exportTemplateService();
+  }
+ 
+  private async resetConfig(): Promise<void> {
+    const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
+    await config.update('ai.provider', undefined, vscode.ConfigurationTarget.Global);
+    await config.update('ai.model', undefined, vscode.ConfigurationTarget.Global);
+    await config.update('ai.outputLanguage', undefined, vscode.ConfigurationTarget.Global);
+    await this.deleteApiKey();
+    await this.clearConnectionState();
+  }
+ 
+  private async deleteApiKey(): Promise<void> {
+    await this.context.secrets.delete('vscode-code-reviewer.apiKey');
+  }
+ 
+  private getHtml(
+    providers: Record<string, { name: string; models: string[] }>,
+    config: { provider: string; model: string; outputLanguage: string; baseUrl: string },
+    scriptUri: vscode.Uri,
+  ): string {
+    return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<style>
+* { box-sizing: border-box; margin: 0; padding: 0; }
+html { background: var(--vscode-sideBar-background, var(--vscode-editor-background)); }
+body {
+  font-family: var(--vscode-font-family);
+  font-size: var(--vscode-font-size);
+  color: var(--vscode-foreground);
+  background: var(--vscode-sideBar-background, var(--vscode-editor-background));
+  line-height: 1.5;
+  padding: 12px;
+}
+.panel { width: 100%; }
+
+/* Header */
+.panel-header {
+  display: flex; align-items: center; gap: 8px;
+  padding: 10px 0 14px; font-size: 15px; font-weight: 600; color: var(--vscode-foreground);
+  border-bottom: 1px solid var(--vscode-panel-border); margin-bottom: 12px;
+}
+.panel-header-title {
+  display: flex; align-items: center; gap: 8px; flex: 1;
+}
+.panel-header .lang-select {
+  width: auto; min-width: 100px; padding: 2px 24px 2px 8px;
+  font-size: 11px; min-height: 24px; flex-shrink: 0;
+}
+
+/* Section */
+.section { margin-bottom: 16px; }
+.section-title {
+  font-size: 11px; font-weight: 700; text-transform: uppercase;
+  letter-spacing: .04em; color: var(--vscode-descriptionForeground); margin-bottom: 8px;
+}
+
+/* Getting Started */
+.getting-started {
+  background: var(--vscode-sideBar-background, var(--vscode-editor-background)); border: 1px solid var(--vscode-panel-border);
+  border-radius: 8px; padding: 12px;
+}
+.gs-title {
+  display: flex; align-items: center; gap: 8px;
+  font-size: 13px; font-weight: 600; color: var(--vscode-foreground);
+  margin-bottom: 10px;
+}
+.gs-title-dot {
+  width: 8px; height: 8px; border-radius: 50%; background: #8b5cf6;
+}
+.gs-steps { display: flex; flex-direction: column; gap: 10px; }
+.gs-step { display: flex; gap: 8px; font-size: 13px; color: var(--vscode-foreground); line-height: 1.6; }
+.gs-step-num {
+  flex-shrink: 0;
+  width: 20px; height: 20px; border-radius: 50%;
+  background: var(--vscode-panel-border); color: var(--vscode-descriptionForeground);
+  display: flex; align-items: center; justify-content: center;
+  font-size: 11px; font-weight: 700;
+  margin-top: 1px;
+}
+.gs-step-body { display: flex; flex-direction: column; gap: 4px; }
+.gs-step-hint { font-size: 11px; color: var(--vscode-descriptionForeground); }
+.gs-step-done .gs-step-num { background: #3fb950; color: #fff; }
+.gs-step-skip .gs-step-num { background: #f0883e; color: #fff; }
+
+/* Engines */
+.engines { display: flex; flex-direction: row; gap: 6px; }
+.engine-tab {
+  flex: 1; display: flex; flex-direction: column; gap: 4px;
+  padding: 10px; border: 1px solid var(--vscode-panel-border);
+  border-radius: 6px; background: var(--vscode-sideBar-background, var(--vscode-editor-background));
+}
+.engine-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.dot-purple { background: #8b5cf6; }
+.dot-amber { background: #d29922; }
+.dot-green { background: #3fb950; }
+.engine-label { font-size: 13px; color: var(--vscode-foreground); }
+.engine-desc { font-size: 11px; color: var(--vscode-descriptionForeground); }
+
+/* Card */
+.card {
+  background: var(--vscode-sideBar-background, var(--vscode-editor-background)); border: 1px solid var(--vscode-panel-border);
+  border-radius: 8px; padding: 10px 12px;
+}
+.card-row {
+  display: flex; align-items: center;
+  justify-content: space-between; margin-bottom: 6px;
+}
+.card-row:last-child { margin-bottom: 0; }
+.card-label { font-size: 12px; color: var(--vscode-descriptionForeground); }
+
+/* Badge */
+.badge {
+  display: inline-flex; align-items: center;
+  padding: 1px 8px; border-radius: 10px;
+  font-size: 11px; font-weight: 600; line-height: 18px;
+}
+.badge-configured { background: rgba(35, 134, 54, 0.15); color: #3fb950; }
+.badge-unconfigured { background: rgba(139, 148, 158, 0.12); color: var(--vscode-descriptionForeground); }
+
+/* Field */
+.field { margin-bottom: 8px; }
+.field:last-child { margin-bottom: 0; }
+.field-label {
+  display: block; font-size: 12px; color: var(--vscode-descriptionForeground); margin-bottom: 3px;
+}
+select, input[type="text"], input[type="password"] {
+  width: 100%; padding: 6px 10px;
+  background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
+  border-radius: 6px; color: var(--vscode-input-foreground);
+  font-size: 13px; font-family: inherit; outline: none;
+}
+select:focus, input:focus { border-color: #8b5cf6; }
+select {
+  appearance: none; min-height: 32px;
+  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b949e' viewBox='0 0 16 16'%3E%3Cpath d='M4.427 6.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 6H4.604a.25.25 0 0 0-.177.427z'/%3E%3C/svg%3E");
+  background-repeat: no-repeat; background-position: right 8px center;
+  padding-right: 30px; cursor: pointer;
+}
+input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground)); }
+.field-hint { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 4px; }
+.input-error { border-color: #f48771 !important; }
+.input-error:focus { border-color: #f48771 !important; }
+.error-hint { font-size: 11px; color: #f48771; margin-top: 4px; display: none; }
+.error-hint.show { display: block; }
+
+/* Input group */
+.input-group { display: flex; gap: 4px; }
+.input-group input { flex: 1; }
+.input-group .btn { flex-shrink: 0; }
+
+/* Buttons */
+.btn {
+  display: inline-flex; align-items: center; gap: 4px;
+  padding: 5px 12px; border: 1px solid var(--vscode-panel-border);
+  background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground);
+  border-radius: 6px; cursor: pointer; font-size: 12px;
+  transition: background .15s, border-color .15s; white-space: nowrap;
+  justify-content: center;
+}
+.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
+.btn-primary { background: #7c3aed; color: #fff; border-color: #7c3aed; }
+.btn-primary:hover { background: #8b5cf6; }
+.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
+.btn-sm { padding: 2px 8px; font-size: 11px; line-height: 20px; }
+
+/* Toggle switch */
+.switch {
+  position: relative; display: inline-flex; align-items: center;
+  width: 32px; height: 18px; flex-shrink: 0; cursor: pointer;
+}
+.switch input { display: none; }
+.switch-track {
+  width: 100%; height: 100%; border-radius: 9px;
+  background: var(--vscode-panel-border); transition: background .2s;
+}
+.switch input:checked + .switch-track { background: #7c3aed; }
+.switch-thumb {
+  position: absolute; top: 2px; left: 2px;
+  width: 14px; height: 14px; border-radius: 50%;
+  background: var(--vscode-foreground); transition: transform .2s;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.3);
+}
+.switch input:checked ~ .switch-thumb { transform: translateX(14px); }
+
+/* Rule item */
+.rule-item {
+  display: flex; align-items: center; gap: 8px;
+  padding: 6px 8px; margin-top: 4px;
+  border: 1px solid var(--vscode-panel-border); border-radius: 6px;
+  background: var(--vscode-sideBar-background, var(--vscode-editor-background));
+}
+.rule-item:first-child { margin-top: 0; }
+.rule-name {
+  flex: 1; font-size: 13px;
+  font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
+  color: var(--vscode-foreground); overflow: hidden;
+  text-overflow: ellipsis; white-space: nowrap;
+}
+.rule-del {
+  flex-shrink: 0; width: 20px; height: 20px; border-radius: 4px;
+  border: none; background: transparent; color: var(--vscode-descriptionForeground);
+  font-size: 14px; cursor: pointer;
+  display: flex; align-items: center; justify-content: center;
+  transition: color .15s, background .15s;
+}
+.rule-del:hover { color: #f48771; background: rgba(248,81,73,0.15); }
+
+/* Actions */
+.actions { display: flex; gap: 8px; padding-top: 12px; border-top: 1px solid var(--vscode-panel-border); }
+.actions .btn { flex: 1; justify-content: center; }
+
+/* Toast */
+.toast {
+  display: none; margin-bottom: 12px; padding: 8px 12px;
+  border-radius: 6px; font-size: 12px; text-align: center;
+  animation: fadeIn .2s ease;
+}
+@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
+.toast.show { display: block; }
+.toast-success { background: rgba(35, 134, 54, 0.15); border: 1px solid rgba(35, 134, 54, 0.3); color: #3fb950; }
+.toast-error { background: rgba(248, 81, 73, 0.15); border: 1px solid rgba(248, 81, 73, 0.3); color: #f48771; }
+
+/* Spinner */
+@keyframes spin { to { transform: rotate(360deg); } }
+.spinner {
+  display: inline-block; width: 14px; height: 14px;
+  border: 2px solid rgba(255,255,255,0.2);
+  border-top-color: #fff; border-radius: 50%;
+  animation: spin .6s linear infinite;
+}
+
+/* Adapter cards */
+.adapter-card {
+  background: var(--vscode-sideBar-background, var(--vscode-editor-background));
+  border: 1px solid var(--vscode-panel-border);
+  border-radius: 8px;
+  padding: 12px;
+  margin-bottom: 8px;
+  transition: opacity 0.2s;
+}
+.adapter-card.disabled { opacity: 0.45; }
+.adapter-card-header {
+  display: flex; align-items: center; justify-content: space-between;
+  margin-bottom: 6px; position: relative;
+}
+.adapter-card-name { font-size: 12px; font-weight: 600; color: var(--vscode-foreground); }
+.adapter-card-name-wrap { display: inline-flex; align-items: center; min-width: 0; }
+.adapter-toggle {
+  width: 30px; height: 16px; border-radius: 8px;
+  background: #3fb950; position: relative; cursor: pointer;
+  transition: background 0.2s;
+}
+.adapter-toggle.off { background: #3c3c3c; }
+.adapter-toggle::after {
+  content: ''; position: absolute; top: 2px; left: 2px;
+  width: 12px; height: 12px; border-radius: 50%; background: #fff;
+  transition: transform 0.2s;
+  transform: translateX(14px);
+}
+.adapter-toggle.off::after { transform: translateX(0); background: #ccc; }
+.adapter-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 5px; }
+.adapter-badge {
+  display: inline-flex; align-items: center; padding: 1px 5px;
+  border-radius: 3px; font-size: 9px; font-weight: 600;
+}
+.adapter-badge-info { background: rgba(139,92,246,0.15); color: #8b5cf6; }
+.adapter-badge-ok { background: rgba(63,185,80,0.15); color: #3fb950; }
+.adapter-badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
+.adapter-badge-error { background: rgba(248,81,73,0.15); color: #f48771; }
+.adapter-badge-explicit { background: rgba(56,139,253,0.15); color: #388bfd; }
+.adapter-languages { font-size: 10px; color: var(--vscode-descriptionForeground); margin-bottom: 4px; }
+.adapter-lang-label { color: var(--vscode-descriptionForeground); }
+.adapter-actions { display: flex; gap: 5px; }
+.adapter-btn {
+  padding: 2px 8px; border-radius: 3px; font-size: 10px;
+  border: 1px solid var(--vscode-panel-border); background: transparent;
+  color: var(--vscode-foreground); cursor: pointer;
+}
+.adapter-btn:hover { background: var(--vscode-panel-border); }
+
+/* Clickable engine tab */
+.engine-tab-top {
+  display: flex; justify-content: space-between; align-items: flex-start; width: 100%;
+}
+.engine-tab.clickable { cursor: pointer; user-select: none; position: relative; }
+.engine-tab.clickable:hover { border-color: var(--vscode-focusBorder); }
+[data-tooltip] { position: relative; }
+[data-tooltip]:hover::before {
+  content: attr(data-tooltip);
+  position: absolute; bottom: calc(100% + 14px); left: 50%;
+  transform: translateX(-50%);
+  background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
+  color: var(--vscode-foreground);
+  border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
+  border-radius: 4px; padding: 4px 8px;
+  font-size: 12px; white-space: nowrap;
+  z-index: 200; pointer-events: none;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
+}
+.adapter-toggle[data-tooltip]:hover::before {
+  top: 50%; right: calc(100% + 8px);
+  bottom: auto; left: auto; transform: translateY(-50%);
+}
+.adapter-btn[data-action="openAdapterConfig"][data-tooltip]:hover::before {
+  top: 50%; left: calc(100% + 8px);
+  bottom: auto; right: auto; transform: translateY(-50%);
+}
+.engine-indicator {
+  font-size: 9px; color: var(--vscode-descriptionForeground);
+  transition: transform .15s; flex-shrink: 0;
+}
+.engine-indicator.expanded { transform: rotate(90deg); }
+.engine-badge {
+  display: inline-flex; align-items: center;
+  padding: 1px 6px; border-radius: 8px;
+  background: rgba(139,92,246,0.15); color: #8b5cf6;
+  font-size: 10px; font-weight: 600;
+  white-space: nowrap; flex-shrink: 0;
+}
+.engine-tab-footer {
+  display: flex; align-items: center; gap: 6px;
+  justify-content: flex-end; width: 100%; margin-top: 6px;
+}
+.engine-common-rules-body {
+  margin-top: 8px; padding: 0 2px;
+}
+.engine-custom-rules-body,
+.engine-ai-review-body {
+  margin-top: 8px;
+  padding: 12px;
+  border: 1px solid var(--vscode-panel-border);
+  border-radius: 6px;
+}
+.ai-review-status-grid {
+  display: flex; flex-direction: column; gap: 4px;
+  margin-bottom: 10px;
+}
+.status-row {
+  display: flex; align-items: center; justify-content: space-between;
+  font-size: 12px;
+}
+.status-label { color: var(--vscode-descriptionForeground); }
+.status-value { color: var(--vscode-foreground); font-weight: 500; }
+
+.engine-subtitle {
+  font-size: 11px; font-weight: 600;
+  color: var(--vscode-descriptionForeground); margin-bottom: 8px;
+}
+.mode-legend {
+  display: flex; flex-wrap: wrap; gap: 6px 12px;
+  margin-bottom: 10px; padding: 6px 8px;
+  background: var(--vscode-sideBar-background, var(--vscode-editor-background));
+  border: 1px solid var(--vscode-panel-border); border-radius: 6px;
+  font-size: 10px; color: var(--vscode-descriptionForeground);
+  position: relative;
+}
+.mode-legend-item { display: inline-flex; align-items: center; gap: 4px; }
+.mode-legend-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
+.mode-legend-desc-row {
+  display: flex; align-items: center; justify-content: space-between;
+  gap: 4px; width: 100%;
+}
+.mode-legend-desc { font-size: 10px; opacity: 0.75; }
+
+/* Help icon & tooltip */
+.help-icon {
+  position: relative;
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 14px; height: 14px; margin-left: 4px;
+  border-radius: 50%;
+  border: 1px solid var(--vscode-descriptionForeground);
+  color: var(--vscode-descriptionForeground);
+  font-size: 9px; font-weight: 700; line-height: 1;
+  cursor: help; user-select: none; flex-shrink: 0;
+}
+.help-icon:hover { color: #8b5cf6; border-color: #8b5cf6; }
+.help-icon:hover::after {
+  content: attr(data-help);
+  position: absolute;
+  top: calc(100% + 6px); left: 0;
+  z-index: 200;
+  width: max-content; max-width: 280px;
+  background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
+  color: var(--vscode-foreground);
+  border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
+  border-radius: 4px;
+  padding: 8px 10px;
+  font-size: 11px; font-weight: 400; line-height: 1.6;
+  white-space: pre-line;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.4);
+}
+.mode-legend .help-icon:hover::after {
+  top: calc(100% + 8px); right: 0; left: auto;
+  max-width: 300px;
+}
+</style>
+</head>
+<body>
+
+<div class="panel">
+  <div class="panel-header">
+    <div class="panel-header-title">
+      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <circle cx="12" cy="12" r="3"/>
+        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
+      </svg>
+       ${t('setup.header')}
+    </div>
+    <select class="lang-select" id="languageSelect" onchange="postMsg('setLanguage', this.value)">
+      <option value="zh-CN"${config.outputLanguage === 'zh-CN' ? ' selected' : ''}>中文(简体)</option>
+      <option value="en"${config.outputLanguage === 'en' ? ' selected' : ''}>English</option>
+      <option value="ja"${config.outputLanguage === 'ja' ? ' selected' : ''}>日本語</option>
+    </select>
+  </div>
+
+  <!-- 1. 快速开始 -->
+  <div class="section">
+    <div class="section-title">${t('setup.quickStart')}</div>
+    <div class="getting-started">
+      <div class="gs-title">
+        <span class="gs-title-dot"></span>
+        ${t('setup.gettingStarted')}
+      </div>
+      <div class="gs-steps">
+        <div class="gs-step" data-step="1">
+          <span class="gs-step-num">1</span>
+          <span>${t('setup.step1')}</span>
+        </div>
+        <div class="gs-step" data-step="2">
+          <span class="gs-step-num">2</span>
+          <span>${t('setup.step2')}</span>
+        </div>
+        <div class="gs-step" data-step="3">
+          <span class="gs-step-num">3</span>
+          <div class="gs-step-body">
+            <span>${t('setup.step3')}</span>
+            <span class="gs-step-hint">${t('setup.step3Hint')}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 2. AI 连接配置 -->
+  <div class="section">
+    <div class="section-title">${t('setup.aiConnectionConfig')}</div>
+    <div class="card">
+      <div class="card-row">
+        <span class="card-label">${t('setup.provider')}</span>
+        <span class="badge" id="providerBadge">${t('setup.notConfigured')}</span>
+      </div>
+      <div class="field">
+        <select id="providerSelect">${Object.entries(providers).map(([id, meta]) =>
+          `<option value="${id}"${config.provider === id ? ' selected' : ''}>${meta.name}</option>`
+        ).join('\n          ')}</select>
+      </div>
+      <div class="field">
+        <label class="field-label">${t('setup.model')}</label>
+        <input type="text" id="modelInput"
+          value="${config.model || ''}"
+          placeholder="${t('setup.modelPlaceholder')}"
+          onchange="postMsg('setModel', this.value)">
+      </div>
+      <div class="field-hint">${t('setup.modelHint')}</div>
+      <div style="border-top:1px solid var(--vscode-panel-border);margin:10px -12px;"></div>
+      <div class="card-row">
+        <span class="card-label">${t('setup.apiKey')}</span>
+        <span class="badge" id="apiKeyBadge">${t('setup.notConfigured')}</span>
+      </div>
+      <div class="field">
+        <input type="password" id="apiKeyInput" placeholder="sk-..." onchange="postMsg('setApiKey', this.value)">
+      </div>
+      <div class="field">
+        <label class="field-label">${t('setup.baseUrl')}</label>
+        <input type="text" id="baseUrlInput" placeholder="https://api.deepseek.com/v1" onchange="postMsg('setBaseUrl', this.value)">
+      </div>
+      <div class="field-hint">${t('setup.keyStorageHint')}</div>
+    </div>
+  </div>
+
+  <!-- 3. 审核引擎 -->
+  <div class="section">
+    <div class="section-title">${t('setup.engineSection')}</div>
+    <div class="engines">
+      <div class="engine-tab clickable" id="tabCommonRules" data-tooltip="${t('setup.adapter.tooltipTab')}">
+        <div class="engine-tab-top">
+          <span class="engine-dot dot-purple"></span>
+          <span class="engine-badge" id="adapterCountBadge">0/4</span>
+        </div>
+        <div>
+          <div class="engine-label">${t('setup.commonRules')}</div>
+          <div class="engine-desc">${t('setup.linterStatic')}</div>
+        </div>
+        <div class="engine-tab-footer">
+          <span class="engine-indicator" id="commonRulesArrow">▶</span>
+        </div>
+      </div>
+      <div class="engine-tab clickable" id="tabCustomRules" data-tooltip="${t('setup.adapter.tooltipTab')}">
+        <div class="engine-tab-top">
+          <span class="engine-dot dot-amber"></span>
+          <span class="engine-badge" id="customRuleCountBadge" style="background:rgba(210,153,34,0.15);color:#d29922;">0</span>
+        </div>
+        <div>
+          <div class="engine-label">${t('setup.customRules')}</div>
+          <div class="engine-desc">${t('setup.teamCoding')}</div>
+        </div>
+        <div class="engine-tab-footer">
+          <span class="engine-indicator" id="customRulesArrow">▶</span>
+        </div>
+      </div>
+      <div class="engine-tab clickable" id="tabAIReview" data-tooltip="${t('setup.adapter.tooltipTab')}">
+        <div class="engine-tab-top">
+          <span class="engine-dot dot-green"></span>
+          <span class="engine-badge" id="aiReviewStatusBadge" style="background:rgba(139,148,158,0.12);color:var(--vscode-descriptionForeground);">${t('setup.notConfigured')}</span>
+        </div>
+        <div>
+          <div class="engine-label">${t('setup.aiReview')}</div>
+          <div class="engine-desc">${t('setup.deepReview')}</div>
+        </div>
+        <div class="engine-tab-footer">
+          <span class="engine-indicator" id="aiReviewArrow">▶</span>
+        </div>
+      </div>
+    </div>
+
+    <div class="engine-common-rules-body" id="commonRulesBody" style="display:none;">
+      <div class="engine-subtitle">${t('setup.adapter.subtitle')}</div>
+      <div class="mode-legend">
+        <span class="mode-legend-item"><span class="mode-legend-dot" style="background:#8b5cf6;"></span>${t('setup.adapter.modeBuiltin')}</span>
+        <span class="mode-legend-item"><span class="mode-legend-dot" style="background:#3fb950;"></span>${t('setup.adapter.modeProject')}</span>
+        <span class="mode-legend-item"><span class="mode-legend-dot" style="background:#d29922;"></span>${t('setup.adapter.modeGlobal')}</span>
+        <span class="mode-legend-desc-row">
+          <span class="mode-legend-desc">${t('setup.adapter.modeLegend')}</span>
+          <span class="help-icon" data-help="${t('setup.adapter.modeLegendHelp')}">?</span>
+        </span>
+      </div>
+      <div id="adapter-list"></div>
+    </div>
+
+    <div class="engine-custom-rules-body" id="customRulesBody" style="display:none;">
+      <div class="engine-subtitle">${t('setup.ruleList')}</div>
+      <div id="ruleListInEngine"></div>
+      <div class="field" style="margin-top:8px;">
+        <div class="input-group">
+          <input type="text" id="newRuleInput" placeholder="${t('setup.ruleNamePlaceholder')}">
+          <button class="btn btn-sm" style="background:#7c3aed;color:#fff;border-color:#7c3aed;" onclick="exportTemplate()">${t('setup.exportTemplate')}</button>
+          <button class="btn btn-sm" style="background:#7c3aed;color:#fff;border-color:#7c3aed;" onclick="addRule()">${t('setup.add')}</button>
+        </div>
+        <div class="error-hint" id="ruleNameError">${t('setup.ruleNameRequired')}</div>
+        <div style="display:flex;align-items:center;justify-content:space-between;margin-top:4px;">
+          <div class="field-hint" style="margin-top:0;">${t('setup.ruleNameHint')}</div>
+          <label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--vscode-descriptionForeground);cursor:pointer;white-space:nowrap;">
+            <input type="checkbox" id="useTemplateMode" style="width:auto;margin:0;">
+            使用模板文件导入
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <div class="engine-ai-review-body" id="aiReviewBody" style="display:none;">
+      <div class="ai-review-status-grid">
+        <div class="status-row">
+          <span class="status-label">${t('setup.provider')}</span>
+          <span class="status-value" id="aiProviderDisplay"></span>
+        </div>
+        <div class="status-row">
+          <span class="status-label">${t('setup.model')}</span>
+          <span class="status-value" id="aiModelDisplay"></span>
+        </div>
+        <div class="status-row">
+          <span class="status-label">连接状态</span>
+          <span class="status-value" id="aiConnectionDisplay"></span>
+        </div>
+      </div>
+      <div class="status-row">
+        <span class="status-label">${t('setup.aiReviewStatusCapability')}</span>
+        <span class="status-value">${t('setup.aiReviewCapability')}</span>
+      </div>
+    </div>
+  </div>
+
+  <!-- Actions -->
+  <div class="actions">
+    <button class="btn" onclick="postMsg('reset')">${t('setup.reset')}</button>
+    <button class="btn btn-primary" id="btnTest" onclick="postMsg('saveAndTest')">${t('setup.saveAndTest')}</button>
+  </div>
+
+  <!-- Toast -->
+  <div id="toast"></div>
+</div>
+
+<script id="setupViewData" type="application/json">${JSON.stringify({
+  providers,
+  provider: config.provider,
+  model: config.model,
+  baseUrl: config.baseUrl,
+})}</script>
+<script src="${scriptUri}"></script>
+</body>
+</html>`;
+  }
+ 
+  private detectConfigMode(adapterId: string): ConfigMode {
+    const globalPath = GLOBAL_CONFIG_GETTERS[adapterId]?.();
+    if (globalPath && globalPath.trim() !== '') {
+      return 'global';
+    }
+
+    const workspaceFolders = vscode.workspace.workspaceFolders;
+    if (workspaceFolders && workspaceFolders.length > 0) {
+      const rootPath = workspaceFolders[0].uri.fsPath;
+      const configFiles = PROJECT_CONFIG_FILES[adapterId] ?? [];
+      for (const fileName of configFiles) {
+        const filePath = path.join(rootPath, fileName);
+        if (fs.existsSync(filePath)) {
+          return 'project';
+        }
+      }
+    }
+
+    return 'builtin';
+  }
+ 
+  private checkJavaReady(): boolean {
+    try {
+      const result = execSync('java -version 2>&1', {
+        encoding: 'utf-8',
+        timeout: 5000,
+      });
+      return result.includes('version');
+    } catch {
+      return false;
+    }
+  }
+ 
+  private checkPythonReady(): boolean {
+    let pythonCmd = '';
+    for (const cmd of ['python3', 'python']) {
+      try {
+        execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
+        pythonCmd = cmd;
+        break;
+      } catch { continue; }
+    }
+    if (!pythonCmd) { return false; }
+
+    try {
+      execSync('sqlfluff --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
+      return true;
+    } catch {
+      return false;
+    }
+  }
+ 
+  private collectAdapterStatus(): AdapterConfigStatus[] {
+    const statuses: AdapterConfigStatus[] = [];
+
+    for (const [id, meta] of Object.entries(ADAPTER_METADATA)) {
+      const configMode = this.detectConfigMode(id);
+      const enabled = isAdapterEnabled(id);
+
+      let dependencyStatus: DependencyStatus = 'none';
+      if (meta.hasExternalDependency) {
+        if (id === 'pmd') {
+          dependencyStatus = this.checkJavaReady() ? 'ready' : 'missing';
+        } else if (id === 'sqlfluff') {
+          dependencyStatus = this.checkPythonReady() ? 'ready' : 'missing';
+        }
+      }
+
+      const configured = !meta.hasExternalDependency || configMode !== 'builtin' || dependencyStatus === 'ready';
+
+      const dialectInfo = id === 'sqlfluff'
+        ? resolveSqlFluffDialect(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '')
+        : undefined;
+
+      statuses.push({
+        id,
+        name: meta.name,
+        enabled,
+        configMode,
+        dependencyStatus,
+        dependencyLabel: meta.dependencyLabel,
+        configured,
+        languages: t(`setup.adapter.${meta.i18nKey}Languages`),
+        projectConfigFileName: meta.projectConfigFileName,
+        settingsTarget: meta.settingsTarget,
+        sqlfluffDialect: dialectInfo?.dialect,
+        sqlfluffDialectSource: dialectInfo?.source,
+      });
+    }
+
+    return statuses;
+  }
+ 
+  private async handleAdapterConfig(adapterId: string): Promise<void> {
+    const meta = ADAPTER_METADATA[adapterId];
+    if (!meta) { return; }
+
+    const workspaceFolders = vscode.workspace.workspaceFolders;
+    if (!workspaceFolders || workspaceFolders.length === 0) {
+      vscode.window.showWarningMessage('请先打开一个工作区文件夹');
+      return;
+    }
+
+    const rootPath = workspaceFolders[0].uri.fsPath;
+    const filePath = path.join(rootPath, meta.projectConfigFileName);
+
+    if (!fs.existsSync(filePath)) {
+      const content = await meta.configFileTemplate();
+      fs.writeFileSync(filePath, content, 'utf-8');
+      vscode.window.showInformationMessage(`配置文件已创建: ${meta.projectConfigFileName}`);
+    }
+
+    const doc = await vscode.workspace.openTextDocument(filePath);
+    await vscode.window.showTextDocument(doc);
+  }
+}
+ 
+async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise<boolean> {
+  const key = await context.secrets.get('vscode-code-reviewer.apiKey');
+  return !!key;
+}
+ 
+function isBaseUrlConfigured(): boolean {
+  const info = vscode.workspace.getConfiguration('vscode-code-reviewer').inspect<string>('ai.baseUrl');
+  return !!info?.globalValue || !!info?.workspaceValue;
+}
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/fix-prompt.test.ts b/tests/fix-prompt.test.ts new file mode 100644 index 0000000..e42ff3f --- /dev/null +++ b/tests/fix-prompt.test.ts @@ -0,0 +1,88 @@ +import * as assert from 'assert'; +import { setLanguage, getLanguage } from '../src/i18n/messages'; +import { + buildFixSystemPrompt, + buildFixUserPrompt, + buildFixContext, + buildVerifySystemPrompt, + buildVerifyUserPrompt, +} from '../src/fix/fixPrompt'; + +suite('FixPrompt Tests', () => { + let orig: string; + setup(() => { orig = getLanguage(); }); + teardown(() => { setLanguage(orig as 'zh-CN' | 'en' | 'ja'); }); + + test('buildFixSystemPrompt defaults to Chinese', () => { + setLanguage('zh-CN'); + assert.match(buildFixSystemPrompt(), /资深代码修复专家/); + }); + + test('buildFixSystemPrompt returns English variant', () => { + setLanguage('en'); + assert.match(buildFixSystemPrompt(), /senior code fixer/i); + }); + + test('buildFixSystemPrompt returns Japanese variant', () => { + setLanguage('ja'); + assert.match(buildFixSystemPrompt(), /シニア/); + }); + + test('buildFixUserPrompt includes issue, message and context', () => { + const prompt = buildFixUserPrompt( + { ruleId: 'eslint:eqeqeq', line: 3, message: 'Use ===' }, + '1| var a = b == c;' + ); + assert.match(prompt, /eslint:eqeqeq/); + assert.match(prompt, /Use ===/); + assert.match(prompt, /var a = b == c;/); + }); + + test('buildFixUserPrompt includes suggestion when present', () => { + const prompt = buildFixUserPrompt( + { ruleId: 'r', line: 1, message: 'm', suggestion: 'use let' }, + 'ctx' + ); + assert.match(prompt, /use let/); + }); + + test('buildFixUserPrompt omits suggestion when blank', () => { + setLanguage('zh-CN'); + const prompt = buildFixUserPrompt( + { ruleId: 'r', line: 1, message: 'm', suggestion: ' ' }, + 'ctx' + ); + assert.ok(!prompt.includes('参考建议')); + }); + + test('buildFixContext windows around line with padding', () => { + const code = Array.from({ length: 20 }, (_, i) => `line-${i + 1}`).join('\n'); + const ctx = buildFixContext(code, 10); + const lines = ctx.split('\n'); + assert.ok(lines.length >= 13); + assert.match(lines[0], /^\s+5\| line-5/); + assert.match(lines[lines.length - 1], /line-17/); + }); + + test('buildFixContext clamps at document boundaries', () => { + const ctx = buildFixContext('a\nb\nc\n', 0); + const lines = ctx.split('\n'); + assert.match(lines[0], /1\| a/); + assert.match(ctx, /3\| c/); + }); + + test('buildVerifySystemPrompt has language variants', () => { + setLanguage('zh-CN'); + assert.match(buildVerifySystemPrompt(), /代码审查员/); + setLanguage('en'); + assert.match(buildVerifySystemPrompt(), /code reviewer/i); + setLanguage('ja'); + assert.match(buildVerifySystemPrompt(), /レビュアー/); + }); + + test('buildVerifyUserPrompt includes issue and code', () => { + const prompt = buildVerifyUserPrompt({ ruleId: 'r', line: 0, message: 'no-var' }, 'var x = 1;'); + assert.match(prompt, /no-var/); + assert.match(prompt, /var x = 1;/); + }); +}); diff --git a/tests/import-service.test.ts b/tests/import-service.test.ts new file mode 100644 index 0000000..2ca8dd7 --- /dev/null +++ b/tests/import-service.test.ts @@ -0,0 +1,106 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { buildFinalYaml, ImportService, parseImportableYaml } from '../src/rules/import-service'; +import type { PreviewDecision, ImportableRule } from '../src/rules/import-types'; + +function rule(overrides: Partial): ImportableRule { + return { id: 'r', severity: 'warning', description: 'd', message: 'm', ...overrides }; +} + +suite('ImportService Tests', () => { + test('parseImportableYaml handles arrays and quoted ids', () => { + const yaml = [ + '- id: no-console', + ' severity: warning', + ' description: forbid console', + ' message: use logger', + ' languages: [javascript, typescript]', + '- id: "quoted-id"', + ' severity: info', + ' description: d', + ' message: m', + ].join('\n'); + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 2); + assert.deepStrictEqual(rules[0].languages, ['javascript', 'typescript']); + assert.strictEqual(rules[1].id, 'quoted-id'); + }); + + test('parseImportableYaml fills message from description and vice versa', () => { + const rules = parseImportableYaml('- id: a\n severity: error\n description: only-desc\n'); + assert.strictEqual(rules[0].message, 'only-desc'); + const rules2 = parseImportableYaml('- id: b\n severity: error\n message: only-msg\n'); + assert.strictEqual(rules2[0].description, 'only-msg'); + }); + + test('buildFinalYaml uses edited rules when present', () => { + const rules = [rule({ id: 'r1' })]; + const edited = [rule({ id: 'r1', severity: 'error', description: 'edited' })]; + const decision: PreviewDecision = { keepRule: { r1: true }, confirmed: true, editedRules: edited }; + const out = buildFinalYaml('- id: r1\n severity: warning\n description: d\n message: m\n', rules, decision); + assert.match(out, /severity: error/); + assert.match(out, /edited/); + }); + + test('buildFinalYaml comments exact duplicates and strips duplicate metadata', () => { + const yaml = [ + '- id: a', + ' severity: warning', + ' description: desc a', + ' message: msg a', + '- id: b', + ' severity: error', + ' description: desc b', + ' message: msg b', + ' duplicateOf: custom/x', + ' duplicateLevel: exact', + ' duplicateReason: same', + ].join('\n'); + const rules = parseImportableYaml(yaml); + const decision: PreviewDecision = { keepRule: { a: true, b: false }, confirmed: true }; + const out = buildFinalYaml(yaml, rules, decision); + assert.match(out, /- id: a/); + assert.match(out, /# - id: b/); + const dupLines = out.split('\n').filter(l => /duplicateOf:/.test(l)); + assert.ok(dupLines.length > 0); + assert.ok(dupLines.every(l => l.trimStart().startsWith('#')), 'duplicate metadata only appears in comments'); + }); + + test('buildFinalYaml keeps all when no overrides', () => { + const yaml = '- id: x\n severity: warning\n description: dx\n message: mx\n'; + const rules = parseImportableYaml(yaml); + const decision: PreviewDecision = { keepRule: {}, confirmed: true }; + const out = buildFinalYaml(yaml, rules, decision); + assert.match(out, /- id: x/); + assert.ok(!/# - id: x/.test(out)); + }); + + test('applyConversion writes final yaml to nested target path', () => { + const yaml = '- id: k\n severity: warning\n description: dk\n message: mk\n'; + const rules = parseImportableYaml(yaml); + const decision: PreviewDecision = { keepRule: { k: true }, confirmed: true }; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'import-svc-')); + const target = path.join(tmp, 'nested', 'custom-rules.yaml'); + try { + new ImportService().applyConversion( + { rules, yamlContent: yaml, sourceFileName: 'a.yaml', exactCount: 0, overlapCount: 0 }, + decision, + target, + ); + assert.ok(fs.existsSync(target)); + assert.match(fs.readFileSync(target, 'utf-8'), /- id: k/); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('convert throws for unsupported extension', async () => { + const svc = new ImportService(); + await assert.rejects( + () => svc.convert('/tmp/foo.xyz', {} as vscode.ExtensionContext), + ); + }); +}); diff --git a/tests/jsp-extractor.test.ts b/tests/jsp-extractor.test.ts new file mode 100644 index 0000000..2f32abc --- /dev/null +++ b/tests/jsp-extractor.test.ts @@ -0,0 +1,75 @@ +import * as assert from 'assert'; +import { extractJspSections } from '../src/jsp/jsp-extractor'; + +suite('JspExtractor Tests', () => { + test('extracts script block as javascript', () => { + const content = '\n\n'; + const sections = extractJspSections(content); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].language, 'javascript'); + assert.strictEqual(sections[0].code, '\nvar a = 1;\n'); + assert.strictEqual(sections[0].lineOffset, 1); + }); + + test('extracts style block as css', () => { + const content = ''; + const sections = extractJspSections(content); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].language, 'css'); + assert.strictEqual(sections[0].code, '\nbody { color: red; }\n'); + }); + + test('extracts statement scriptlet with line offset', () => { + const content = '

hi

\n<% int x = 1; %>\n

end

'; + const sections = extractJspSections(content); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].language, 'java'); + assert.strictEqual(sections[0].scriptletKind, 'statement'); + assert.strictEqual(sections[0].code, ' int x = 1; '); + assert.strictEqual(sections[0].lineOffset, 1); + }); + + test('extracts declaration scriptlet', () => { + const content = '<%! private int count = 0; %>'; + const sections = extractJspSections(content); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].scriptletKind, 'declaration'); + assert.strictEqual(sections[0].code, ' private int count = 0; '); + }); + + test('extracts expression scriptlet', () => { + const content = '<%= user.name %>'; + const sections = extractJspSections(content); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].scriptletKind, 'expression'); + assert.strictEqual(sections[0].code, ' user.name '); + }); + + test('skips directives and comments', () => { + const content = '<%@ page import="java.util.*" %>\n<%-- comment --%>\n<% int y = 2; %>'; + const sections = extractJspSections(content); + assert.strictEqual(sections.length, 1); + assert.strictEqual(sections[0].scriptletKind, 'statement'); + assert.strictEqual(sections[0].code, ' int y = 2; '); + }); + + test('reports sourceStart/sourceEnd positions', () => { + const content = 'a\n<% x(); %>'; + const sections = extractJspSections(content); + assert.strictEqual(sections.length, 1); + const slice = content.slice(sections[0].sourceStart, sections[0].sourceEnd); + assert.strictEqual(slice, '<% x(); %>'); + assert.strictEqual(sections[0].lineOffset, 1); + }); + + test('returns empty array for plain html', () => { + assert.deepStrictEqual(extractJspSections('

hello

'), []); + }); + + test('collects script, style and scriptlet sections', () => { + const content = '<% int a = 1; %>'; + const sections = extractJspSections(content); + assert.deepStrictEqual(sections.map(s => s.language), ['javascript', 'css', 'java']); + assert.strictEqual(sections[2].scriptletKind, 'statement'); + }); +}); diff --git a/tests/method-extractor.test.ts b/tests/method-extractor.test.ts new file mode 100644 index 0000000..8ce2455 --- /dev/null +++ b/tests/method-extractor.test.ts @@ -0,0 +1,129 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { mockDocument } from '../src/utils/mockDocument'; +import { extractMethodScope, getMethodSymbols } from '../src/scope/method-extractor'; + +suite('MethodExtractor Tests', () => { + test('fallback regex finds js function declarations and arrow functions', async () => { + const code = [ + 'function foo() { return 1; }', + 'const bar = () => 2;', + 'const baz = function(x) { return x; };', + ].join('\n'); + const doc = mockDocument(code, 'javascript'); + const symbols = await getMethodSymbols(doc); + const names = symbols.map(s => s.name); + assert.ok(names.includes('foo')); + assert.ok(names.includes('bar')); + assert.ok(names.includes('baz')); + }); + + test('fallback regex finds java methods', async () => { + const code = 'public class A {\n public void run() {}\n private int calc(int x) { return x; }\n}'; + const doc = mockDocument(code, 'java'); + const symbols = await getMethodSymbols(doc); + const names = symbols.map(s => s.name); + assert.ok(names.includes('run')); + assert.ok(names.includes('calc')); + }); + + test('fallback regex returns empty for unsupported language', async () => { + const doc = mockDocument('print("hi")', 'python'); + const symbols = await getMethodSymbols(doc); + assert.strictEqual(symbols.length, 0); + }); + + test('extractMethodScope returns code, signature and callers', async () => { + const code = [ + 'function processOrder(items) {', + ' var total = 0;', + ' for (var i = 0; i < items.length; i++) {', + ' total += items[i].amount;', + ' }', + ' return total;', + '}', + '', + 'function orderValidate(record) {', + ' return processOrder(record.items);', + '}', + ].join('\n'); + const doc = mockDocument(code, 'javascript'); + const scope = await extractMethodScope(doc, new vscode.Range(0, 0, 0, 0)); + assert.ok(scope); + assert.strictEqual(scope!.name, 'processOrder'); + assert.match(scope!.code, /function processOrder/); + assert.deepStrictEqual(scope!.callees, []); + assert.ok(scope!.callers.includes('orderValidate')); + assert.match(scope!.signature, /processOrder/); + }); + + test('extractMethodScope identifies callees', async () => { + const code = [ + 'function helper() { return 1; }', + 'function main() {', + ' return helper();', + '}', + ].join('\n'); + const doc = mockDocument(code, 'javascript'); + const scope = await extractMethodScope(doc, new vscode.Range(1, 0, 1, 0)); + assert.ok(scope); + assert.strictEqual(scope!.name, 'main'); + assert.deepStrictEqual(scope!.callees, ['helper']); + }); + + test('expandToMethodBody ignores braces inside strings and comments', async () => { + const code = [ + 'function tricky() {', + ' const s = "}";', + ' // }', + ' return s;', + '}', + ].join('\n'); + const doc = mockDocument(code, 'javascript'); + const scope = await extractMethodScope(doc, new vscode.Range(0, 0, 0, 0)); + assert.ok(scope); + assert.strictEqual(scope!.range.end.line, 4); + assert.match(scope!.code, /tricky/); + }); + + test('extractSignature strips leading comments', async () => { + const code = [ + '// legacy handler', + '/* block */', + 'function handle(input) {', + ' return input;', + '}', + ].join('\n'); + const doc = mockDocument(code, 'javascript'); + const scope = await extractMethodScope(doc, new vscode.Range(2, 0, 2, 0)); + assert.ok(scope); + assert.match(scope!.signature, /handle/); + assert.ok(!scope!.signature.includes('//')); + }); + + test('inferRole maps getter, process and generic names', async () => { + const code = [ + 'function getUser() { return {}; }', + 'function processOrder() { return 1; }', + 'function doThing() { return 1; }', + ].join('\n'); + const doc = mockDocument(code, 'javascript'); + const getter = await extractMethodScope(doc, new vscode.Range(0, 0, 0, 0)); + assert.ok(getter); + assert.strictEqual(getter!.name, 'getUser'); + assert.strictEqual(getter!.role, '属性访问器'); + const proc = await extractMethodScope(doc, new vscode.Range(1, 0, 1, 0)); + assert.ok(proc); + assert.strictEqual(proc!.name, 'processOrder'); + assert.strictEqual(proc!.role, '流程处理'); + const generic = await extractMethodScope(doc, new vscode.Range(2, 0, 2, 0)); + assert.ok(generic); + assert.strictEqual(generic!.role, '通用方法'); + }); + + test('extractMethodScope returns null when range matches no method', async () => { + const doc = mockDocument('var x = 1;\n', 'javascript'); + const scope = await extractMethodScope(doc, new vscode.Range(0, 0, 0, 0)); + assert.strictEqual(scope, null); + }); +}); diff --git a/tests/provider-chat.test.ts b/tests/provider-chat.test.ts new file mode 100644 index 0000000..4e6273e --- /dev/null +++ b/tests/provider-chat.test.ts @@ -0,0 +1,101 @@ +import * as assert from 'assert'; +import { OpenAICompatibleProvider } from '../src/ai/providers/openai-compatible'; +import { GeminiProvider } from '../src/ai/providers/gemini'; +import { ClaudeProvider } from '../src/ai/providers/claude'; +import { EmptyContentError } from '../src/ai/providers/base'; + +const opts = { model: 'm', temperature: 0.2, maxTokens: 100, timeoutMs: 5000 }; + +function makeResponse(ok: boolean, status: number, jsonData?: unknown, text?: string): Response { + return { + ok, + status, + json: async () => jsonData, + text: async () => text ?? '', + } as Response; +} + +suite('Provider Chat Tests', () => { + const origFetch = (globalThis as any).fetch; + let calls: Array<{ url: string; init: RequestInit }>; + + setup(() => { calls = []; }); + teardown(() => { (globalThis as any).fetch = origFetch; }); + + function stubFetch(fn: (url: string, init: RequestInit) => Response): void { + (globalThis as any).fetch = async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return fn(url, init); + }; + } + + test('openai chat returns content with bearer auth', async () => { + stubFetch(() => makeResponse(true, 200, { choices: [{ message: { content: 'FIX' }, finish_reason: 'stop' }] })); + const p = new OpenAICompatibleProvider('key', 'https://api.x.test/v1', 'deepseek', 'DeepSeek'); + assert.strictEqual(await p.chat('sys', 'user', opts), 'FIX'); + assert.match(calls[0].url, /\/chat\/completions$/); + assert.strictEqual((calls[0].init.headers as Record).Authorization, 'Bearer key'); + }); + + test('openai 401 throws invalid key error', async () => { + stubFetch(() => makeResponse(false, 401, undefined, 'bad key')); + const p = new OpenAICompatibleProvider('key', 'https://api.x.test/v1', 'deepseek', 'DeepSeek'); + await assert.rejects(() => p.chat('s', 'u', opts)); + }); + + test('openai truncation raises EmptyContentError', async () => { + stubFetch(() => makeResponse(true, 200, { choices: [{ message: { content: ' ' }, finish_reason: 'length' }] })); + const p = new OpenAICompatibleProvider('key', 'https://api.x.test/v1', 'deepseek', 'DeepSeek'); + await assert.rejects(() => p.chat('s', 'u', opts), EmptyContentError); + }); + + test('openai empty content raises EmptyContentError', async () => { + stubFetch(() => makeResponse(true, 200, { choices: [], error: { message: 'nope' } })); + const p = new OpenAICompatibleProvider('key', 'https://api.x.test/v1', 'deepseek', 'DeepSeek'); + await assert.rejects(() => p.chat('s', 'u', opts), EmptyContentError); + }); + + test('openai other status throws', async () => { + stubFetch(() => makeResponse(false, 500, undefined, 'boom')); + const p = new OpenAICompatibleProvider('key', 'https://api.x.test/v1', 'deepseek', 'DeepSeek'); + await assert.rejects(() => p.chat('s', 'u', opts)); + }); + + test('gemini chat returns text', async () => { + stubFetch(() => makeResponse(true, 200, { candidates: [{ content: { parts: [{ text: 'G' }] } }] })); + const p = new GeminiProvider('key', 'https://gemini.test'); + assert.strictEqual(await p.chat('s', 'u', opts), 'G'); + assert.match(calls[0].url, /generateContent/); + }); + + test('gemini empty content raises EmptyContentError', async () => { + stubFetch(() => makeResponse(true, 200, { candidates: [], promptFeedback: { blockReason: 'safety' } })); + const p = new GeminiProvider('key', 'https://gemini.test'); + await assert.rejects(() => p.chat('s', 'u', opts), EmptyContentError); + }); + + test('gemini non-ok throws', async () => { + stubFetch(() => makeResponse(false, 400, undefined, 'bad')); + const p = new GeminiProvider('key', 'https://gemini.test'); + await assert.rejects(() => p.chat('s', 'u', opts)); + }); + + test('claude chat returns text with api key header', async () => { + stubFetch(() => makeResponse(true, 200, { content: [{ text: 'C' }], stop_reason: 'end_turn' })); + const p = new ClaudeProvider('key', 'https://claude.test'); + assert.strictEqual(await p.chat('s', 'u', opts), 'C'); + assert.strictEqual((calls[0].init.headers as Record)['x-api-key'], 'key'); + }); + + test('claude 401 throws', async () => { + stubFetch(() => makeResponse(false, 401, undefined, 'bad')); + const p = new ClaudeProvider('key', 'https://claude.test'); + await assert.rejects(() => p.chat('s', 'u', opts)); + }); + + test('claude empty content raises EmptyContentError', async () => { + stubFetch(() => makeResponse(true, 200, { content: [], error: { message: 'x' } })); + const p = new ClaudeProvider('key', 'https://claude.test'); + await assert.rejects(() => p.chat('s', 'u', opts), EmptyContentError); + }); +}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 0000000..b3817e1 --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,85 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getProviders, invalidateProviderCache, getProviderById, getAllProviderMeta } from '../src/ai/registry'; +import type { ProviderConfig } from '../src/ai/types'; + +function cfg(overrides: Partial): ProviderConfig { + return { + id: 'x', + name: 'X', + protocol: 'openai-compatible', + defaultBaseUrl: 'https://api.x.test/v1', + models: ['m1'], + ...overrides, + }; +} + +function writeTempProviders(providers: ProviderConfig[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-test-')); + fs.writeFileSync(path.join(dir, 'providers.json'), JSON.stringify({ providers }), 'utf8'); + return dir; +} + +suite('Provider Registry Tests', () => { + setup(() => invalidateProviderCache()); + + test('returns empty when no extension uri', () => { + assert.deepStrictEqual(getProviders(), []); + }); + + test('loads builtin providers from extension providers.json', () => { + const dir = writeTempProviders([cfg({ id: 'a', name: 'A' })]); + const uri = vscode.Uri.file(dir); + const providers = getProviders(uri); + assert.strictEqual(providers.length, 1); + assert.strictEqual(providers[0].id, 'a'); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('caches providers across calls', () => { + const dir = writeTempProviders([cfg({ id: 'b', name: 'B' })]); + const uri = vscode.Uri.file(dir); + const first = getProviders(uri); + fs.rmSync(dir, { recursive: true, force: true }); + const second = getProviders(uri); + assert.strictEqual(second, first); + }); + + test('invalidateProviderCache forces reload', () => { + const dir = writeTempProviders([cfg({ id: 'c', name: 'C' })]); + const uri = vscode.Uri.file(dir); + getProviders(uri); + invalidateProviderCache(); + assert.strictEqual(getProviders(uri).length, 1); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('returns empty for missing or invalid providers.json', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-test2-')); + const uri = vscode.Uri.file(dir); + assert.deepStrictEqual(getProviders(uri), []); + fs.writeFileSync(path.join(dir, 'providers.json'), 'not json', 'utf8'); + invalidateProviderCache(); + assert.deepStrictEqual(getProviders(uri), []); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('getProviderById finds or misses', () => { + const dir = writeTempProviders([cfg({ id: 'x', name: 'X', models: ['m'] })]); + const uri = vscode.Uri.file(dir); + assert.strictEqual(getProviderById(uri, 'x')?.name, 'X'); + assert.strictEqual(getProviderById(uri, 'nope'), undefined); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('getAllProviderMeta returns name and models', () => { + const dir = writeTempProviders([cfg({ id: 'p1', name: 'P1', models: ['a', 'b'] })]); + const uri = vscode.Uri.file(dir); + const meta = getAllProviderMeta(uri); + assert.deepStrictEqual(meta.p1, { name: 'P1', models: ['a', 'b'] }); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/tests/report.test.ts b/tests/report.test.ts new file mode 100644 index 0000000..3367f72 --- /dev/null +++ b/tests/report.test.ts @@ -0,0 +1,131 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { reportToMarkdown } from '../src/utils/report'; +import type { MergedReport } from '../src/merger/merger'; +import type { LinterDiagnostic } from '../src/types'; +import type { AIFinding } from '../src/ai/schema'; + +function range(line: number): vscode.Range { + return new vscode.Range(line, 0, line, 0); +} + +function baseReport(overrides: Partial = {}): MergedReport { + return { + linterDiagnostics: [], + customRuleDiagnostics: [], + translatedDiagnostics: [], + aiFindings: [], + linterCount: 0, + customRuleCount: 0, + aiCount: 0, + errors: [], + degraded: false, + duration: 1200, + filePath: '/virtual/app.js', + language: 'javascript', + adapterNames: ['eslint'], + fixableLinterIndices: [], + aiFixableLinterIndices: [], + fixableCustomIndices: [], + aiFixAvailable: true, + ...overrides, + }; +} + +function linterDiag(overrides: Partial): LinterDiagnostic { + return { + severity: 'error', + ruleId: 'eslint:no-var', + message: 'Unexpected var', + range: range(2), + ...overrides, + }; +} + +function aiFinding(overrides: Partial): AIFinding { + return { + ruleId: 'ai:sec-1', + severity: 'error', + category: 'security', + title: 'XSS risk', + description: 'sanitize input', + suggestion: 'escape output', + line: 0, + ...overrides, + }; +} + +suite('Report Tests', () => { + test('renders header metadata', () => { + const md = reportToMarkdown(baseReport()); + assert.match(md, /\/virtual\/app\.js/); + assert.match(md, /javascript/); + assert.match(md, /1\.2s/); + assert.match(md, /eslint/); + }); + + test('renders empty report with no-problems banner', () => { + const md = reportToMarkdown(baseReport()); + assert.match(md, /✅/); + assert.ok(!md.includes('eslint:no-var')); + }); + + test('renders linter diagnostics with severity emoji and suggestion', () => { + const report = baseReport({ + linterDiagnostics: [ + linterDiag({}), + linterDiag({ severity: 'warning', ruleId: 'eslint:eqeqeq', message: 'Use ===', range: range(3), suggestion: 'use strict equality' }), + linterDiag({ severity: 'info', ruleId: 'eslint:prefer-template', message: 'prefer template', range: range(4) }), + linterDiag({ severity: 'fatal' as LinterDiagnostic['severity'], ruleId: 'eslint:unknown', message: 'other', range: range(5) }), + ], + linterCount: 4, + }); + const md = reportToMarkdown(report); + assert.match(md, /eslint:no-var/); + assert.match(md, /Unexpected var/); + assert.match(md, /🔴/); + assert.match(md, /🟡/); + assert.match(md, /🔵/); + assert.match(md, /⚪/); + assert.match(md, /use strict equality/); + }); + + test('renders custom rule diagnostics', () => { + const report = baseReport({ + customRuleDiagnostics: [ + { severity: 'warning', ruleId: 'custom:team-1', message: 'use strict', range: range(1) } as LinterDiagnostic, + ], + customRuleCount: 1, + }); + const md = reportToMarkdown(report); + assert.match(md, /custom:team-1/); + assert.match(md, /use strict/); + assert.match(md, /🟡/); + }); + + test('renders ai findings with category and suggestion', () => { + const report = baseReport({ + aiFindings: [aiFinding({}), aiFinding({ severity: 'warning', category: 'performance', ruleId: 'ai:perf-1', title: 'slow loop', description: 'nested loop', suggestion: 'hoist' })], + aiCount: 2, + }); + const md = reportToMarkdown(report); + assert.match(md, /\[AI\]/); + assert.match(md, /security/); + assert.match(md, /XSS risk/); + assert.match(md, /sanitize input/); + assert.match(md, /escape output/); + assert.match(md, /slow loop/); + }); + + test('renders degraded banner and errors section', () => { + const report = baseReport({ degraded: true, errors: ['[pmd] tool-unavailable'] }); + const md = reportToMarkdown(report); + assert.match(md, /⚠️/); + assert.match(md, /\[pmd\] tool-unavailable/); + }); + + test('omits tools line when no adapters', () => { + const md = reportToMarkdown(baseReport({ adapterNames: [] })); + assert.ok(!md.includes('eslint,')); + }); +}); diff --git a/tests/test-cases.md b/tests/test-cases.md index 5660911..3cce6e4 100644 --- a/tests/test-cases.md +++ b/tests/test-cases.md @@ -1,6 +1,6 @@ # 测试用例清单 -来源:`tests/*.test.ts` 中 `suite()` / `test()` 声明。共 **16 个测试文件、116 条用例**。 +来源:`tests/*.test.ts` 中 `suite()` / `test()` 声明。共 **24 个测试文件、179 条用例**。 ## 1. adapter.test.ts — Adapter Tests(6) @@ -198,6 +198,109 @@ | 115 | zero positions are clamped to valid values | 零值钳制 | | 116 | missing all positions produce a safe zero range | 全缺失安全范围 | +## 17. jsp-extractor.test.ts — JspExtractor Tests(9) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 117 | extracts script block as javascript | script 块提取 | +| 118 | extracts style block as css | style 块提取 | +| 119 | extracts statement scriptlet with line offset | 语句型 scriptlet | +| 120 | extracts declaration scriptlet | 声明型 scriptlet | +| 121 | extracts expression scriptlet | 表达式型 scriptlet | +| 122 | skips directives and comments | 指令/注释跳过 | +| 123 | reports sourceStart/sourceEnd positions | 源区间定位 | +| 124 | returns empty array for plain html | 纯 HTML 无段落 | +| 125 | collects script, style and scriptlet sections | 多段落收集 | + +## 18. report.test.ts — Report Tests(7) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 126 | renders header metadata | 报告头元数据 | +| 127 | renders empty report with no-problems banner | 空报告无问题提示 | +| 128 | renders linter diagnostics with severity emoji and suggestion | 严重级 emoji 与建议 | +| 129 | renders custom rule diagnostics | 自定义规则段 | +| 130 | renders ai findings with category and suggestion | AI 发现段 | +| 131 | renders degraded banner and errors section | 降级横幅与错误区 | +| 132 | omits tools line when no adapters | 无工具时不显示 | + +## 19. method-extractor.test.ts — MethodExtractor Tests(9) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 133 | fallback regex finds js function declarations and arrow functions | JS 方法兜底正则 | +| 134 | fallback regex finds java methods | Java 方法兜底正则 | +| 135 | fallback regex returns empty for unsupported language | 不支持语言返回空 | +| 136 | extractMethodScope returns code, signature and callers | 方法作用域+调用者 | +| 137 | extractMethodScope identifies callees | 被调用者识别 | +| 138 | expandToMethodBody ignores braces inside strings and comments | 括号配对(跳过字符串/注释) | +| 139 | extractSignature strips leading comments | 签名去注释 | +| 140 | inferRole maps getter, process and generic names | 角色推断 | +| 141 | extractMethodScope returns null when range matches no method | 无命中返回 null | + +## 20. fix-prompt.test.ts — FixPrompt Tests(10) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 142 | buildFixSystemPrompt defaults to Chinese | 中文系统提示词 | +| 143 | buildFixSystemPrompt returns English variant | 英文系统提示词 | +| 144 | buildFixSystemPrompt returns Japanese variant | 日文系统提示词 | +| 145 | buildFixUserPrompt includes issue, message and context | 用户提示词组装 | +| 146 | buildFixUserPrompt includes suggestion when present | 建议追加 | +| 147 | buildFixUserPrompt omits suggestion when blank | 空建议省略 | +| 148 | buildFixContext windows around line with padding | 上下文窗口+对齐 | +| 149 | buildFixContext clamps at document boundaries | 边界钳制 | +| 150 | buildVerifySystemPrompt has language variants | 校验系统提示词 | +| 151 | buildVerifyUserPrompt includes issue and code | 校验用户提示词 | + +## 21. registry.test.ts — Provider Registry Tests(7) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 152 | returns empty when no extension uri | 无 URI 返回空 | +| 153 | loads builtin providers from extension providers.json | 内置 Provider 加载 | +| 154 | caches providers across calls | 注册表缓存 | +| 155 | invalidateProviderCache forces reload | 缓存失效 | +| 156 | returns empty for missing or invalid providers.json | 缺失/非法文件 | +| 157 | getProviderById finds or misses | 按 ID 查询 | +| 158 | getAllProviderMeta returns name and models | Provider 元数据 | + +## 22. provider-chat.test.ts — Provider Chat Tests(11) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 159 | openai chat returns content with bearer auth | OpenAI 兼容协议成功 | +| 160 | openai 401 throws invalid key error | 401 无效密钥 | +| 161 | openai truncation raises EmptyContentError | token 截断空响应 | +| 162 | openai empty content raises EmptyContentError | 空内容错误 | +| 163 | openai other status throws | 其他状态报错 | +| 164 | gemini chat returns text | Gemini 协议成功 | +| 165 | gemini empty content raises EmptyContentError | Gemini 空响应 | +| 166 | gemini non-ok throws | Gemini 报错 | +| 167 | claude chat returns text with api key header | Claude 协议成功 | +| 168 | claude 401 throws | Claude 401 | +| 169 | claude empty content raises EmptyContentError | Claude 空响应 | + +## 23. import-service.test.ts — ImportService Tests(7) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 170 | parseImportableYaml handles arrays and quoted ids | YAML 数组/引号 | +| 171 | parseImportableYaml fills message from description and vice versa | 字段互填 | +| 172 | buildFinalYaml uses edited rules when present | 编辑规则分支 | +| 173 | buildFinalYaml comments exact duplicates and strips duplicate metadata | 精确重复注释 | +| 174 | buildFinalYaml keeps all when no overrides | 无覆盖全保留 | +| 175 | applyConversion writes final yaml to nested target path | 写入目标文件 | +| 176 | convert throws for unsupported extension | 不支持格式报错 | + +## 24. yaml-parser.test.ts — YamlParser Tests(3) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 177 | returns empty when rules dir missing | 目录缺失 | +| 178 | loads rules from yaml files with severity normalization | 规则加载与严重级归一 | +| 179 | skips incomplete rules and non-yaml files | 不完整规则/非 yaml 跳过 | + ## 统计 | 文件 | 用例数 | @@ -218,4 +321,12 @@ | rule-filter.test.ts | 15 | | sqlfluff-prs.test.ts | 4 | | sqlfluff-range.test.ts | 6 | -| **合计** | **116** | +| jsp-extractor.test.ts | 9 | +| report.test.ts | 7 | +| method-extractor.test.ts | 9 | +| fix-prompt.test.ts | 10 | +| registry.test.ts | 7 | +| provider-chat.test.ts | 11 | +| import-service.test.ts | 7 | +| yaml-parser.test.ts | 3 | +| **合计** | **179** | diff --git a/tests/test-execution-log.md b/tests/test-execution-log.md index 3b5027a..c1eecae 100644 --- a/tests/test-execution-log.md +++ b/tests/test-execution-log.md @@ -40,3 +40,20 @@ npm run compile # tsc 主工程 npm run compile:test # tsc 测试工程(out/tests) npm test # @vscode/test-cli 运行 out/tests/**/*.test.js ``` + +## 五、覆盖率执行记录(2026-08-27) + +| 项目 | 内容 | +|---|---| +| 执行日期 | 2026-08-27 | +| 测试命令 | `npm run test:coverage`(compile → compile:test → `vscode-test --coverage --coverage-output tests/coverage`) | +| 测试文件数 | 24 个 `*.test.ts` | +| 通过(passing) | **179** | +| 失败(failing) | 0 | +| 行/语句覆盖率 | **48.24%**(5570/11546) | +| 分支覆盖率 | 77.97%(524/672) | +| 函数覆盖率 | 43.66%(155/355) | +| 覆盖率报告 | `tests/coverage/index.html`(仅统计测试实际触达模块) | +| 新增覆盖模块 | jsp-extractor、report、method-extractor、fix-prompt、registry、ai/providers(三协议)、import-service、yaml-parser | + +> 覆盖率基线:2026-08-27 首跑 116 passing / 42.58% → 补测 63 条后 179 passing / 48.24%。 diff --git a/tests/yaml-parser.test.ts b/tests/yaml-parser.test.ts new file mode 100644 index 0000000..091560f --- /dev/null +++ b/tests/yaml-parser.test.ts @@ -0,0 +1,53 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { loadActiveRules, listRuleFiles } from '../src/rules/yaml-parser'; + +suite('YamlParser Tests', () => { + let root: string; + setup(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'yaml-test-')); }); + teardown(() => { fs.rmSync(root, { recursive: true, force: true }); }); + + test('returns empty when rules dir missing', () => { + assert.deepStrictEqual(loadActiveRules(root), []); + assert.deepStrictEqual(listRuleFiles(root), []); + }); + + test('loads rules from yaml files with severity normalization', () => { + const dir = path.join(root, '.code-review', 'rules'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'a.yaml'), [ + '- id: no-eval', + ' severity: error', + ' description: forbid eval', + ' message: no eval', + ' languages: [javascript]', + ].join('\n'), 'utf8'); + fs.writeFileSync(path.join(dir, 'b.yml'), [ + '- id: bad-severity', + ' severity: fatal', + ' description: d', + ' message: m', + ].join('\n'), 'utf8'); + + const rules = loadActiveRules(root); + assert.strictEqual(rules.length, 2); + const evalRule = rules.find(r => r.id === 'no-eval'); + assert.ok(evalRule); + assert.strictEqual(evalRule!.severity, 'error'); + assert.deepStrictEqual(evalRule!.languages, ['javascript']); + assert.strictEqual(rules.find(r => r.id === 'bad-severity')!.severity, 'warning'); + }); + + test('skips incomplete rules and non-yaml files', () => { + const dir = path.join(root, '.code-review', 'rules'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'partial.yaml'), '- id: no-msg\n severity: error\n description: d\n', 'utf8'); + fs.writeFileSync(path.join(dir, 'note.txt'), 'not yaml', 'utf8'); + + const rules = loadActiveRules(root); + assert.strictEqual(rules.length, 0); + assert.deepStrictEqual(listRuleFiles(root), ['partial.yaml']); + }); +});