feat: VS Code拡張 posimai-guard-ext v0.1.0 を追加

Gemini直接呼び出し(Vercel経由なし)でAIコードセキュリティスキャンを実行。
APIキーはOS keychain(SecretStorage)に安全保存。Claudeオプション対応。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-04-12 17:46:00 +09:00
parent 6d0df5faae
commit 9e6178791f
16 changed files with 3799 additions and 0 deletions

View File

@ -0,0 +1,7 @@
.vscode/**
node_modules/**
src/**
tsconfig.json
**/*.map
**/*.ts
!dist/**

21
posimai-guard-ext/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 posimai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

2912
posimai-guard-ext/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,83 @@
{
"name": "posimai-guard",
"displayName": "Guard — AIコードセキュリティスキャナー",
"description": "AIが生成したコードのセキュリティリスクをワークスペース内で直接検出します",
"version": "0.1.0",
"publisher": "posimai",
"engines": { "vscode": "^1.85.0" },
"categories": ["Linters", "Other"],
"icon": "media/icon.png",
"license": "MIT",
"repository": { "type": "git", "url": "https://github.com/posimai/posimai-guard-ext" },
"main": "./dist/extension.js",
"activationEvents": [],
"contributes": {
"commands": [
{
"command": "guard.scanWorkspace",
"title": "Guard: ワークスペースをスキャン",
"icon": "$(shield)"
},
{
"command": "guard.scanFile",
"title": "Guard: このファイルをスキャン"
},
{
"command": "guard.setApiKeys",
"title": "Guard: APIキーを設定"
},
{
"command": "guard.clearDiagnostics",
"title": "Guard: 診断をクリア"
}
],
"menus": {
"editor/title": [
{
"command": "guard.scanFile",
"group": "navigation"
}
],
"commandPalette": [
{ "command": "guard.scanWorkspace" },
{ "command": "guard.scanFile" },
{ "command": "guard.setApiKeys" },
{ "command": "guard.clearDiagnostics" }
]
},
"configuration": {
"title": "Guard",
"properties": {
"guard.excludeDirs": {
"type": "array",
"default": ["node_modules", ".next", ".git", "dist", "build", "out", ".turbo", ".cache", "coverage", "__pycache__", ".venv", "venv"],
"description": "スキャンから除外するディレクトリ名"
},
"guard.maxFiles": {
"type": "number",
"default": 80,
"description": "一度にスキャンする最大ファイル数"
},
"guard.model": {
"type": "string",
"enum": ["gemini", "claude", "both"],
"default": "gemini",
"description": "使用するAIモデルbothは両方使って精度向上"
}
}
}
},
"scripts": {
"build": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --platform=node --target=node18 --sourcemap",
"watch": "npm run build -- --watch",
"package": "vsce package --no-dependencies",
"vscode:prepublish": "npm run build -- --minify"
},
"devDependencies": {
"@types/node": "^18",
"@types/vscode": "^1.85.0",
"@vscode/vsce": "^2.24.0",
"esbuild": "^0.20.0",
"typescript": "^5"
}
}

Binary file not shown.

View File

@ -0,0 +1,87 @@
import * as vscode from 'vscode';
import { readSingleFile } from '../scanner/fileReader';
import { scanWithGemini } from '../scanner/geminiClient';
import { scanWithClaude } from '../scanner/claudeClient';
import { issuesToDiagnostics } from '../ui/diagnostics';
import { ResultsPanel } from '../ui/resultsPanel';
import { ScanIssue } from '../scanner/prompt';
export async function scanFile(
context: vscode.ExtensionContext,
collection: vscode.DiagnosticCollection,
): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage('Guard: アクティブなエディタがありません');
return;
}
const geminiKey = await context.secrets.get('guard.geminiKey');
if (!geminiKey) {
const action = await vscode.window.showErrorMessage(
'Guard: Gemini API キーが未設定です',
'APIキーを設定',
);
if (action === 'APIキーを設定') {
await vscode.commands.executeCommand('guard.setApiKeys');
}
return;
}
const config = vscode.workspace.getConfiguration('guard');
const model: string = config.get('model') ?? 'gemini';
const files = readSingleFile(editor.document);
const fileName = files[0].name;
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Guard: ${fileName} をスキャン中...`,
cancellable: false,
},
async () => {
let issues: ScanIssue[] = [];
try {
issues = await scanWithGemini(files, geminiKey);
} catch (err) {
vscode.window.showErrorMessage(`Guard: Gemini スキャンに失敗しました: ${String(err)}`);
return;
}
if (model === 'claude' || model === 'both') {
const claudeKey = await context.secrets.get('guard.claudeKey');
if (claudeKey) {
try {
const claudeIssues = await scanWithClaude(files, claudeKey);
const seen = new Set(issues.map(i => `${i.file}::${i.title}`));
for (const ci of claudeIssues) {
if (!seen.has(`${ci.file}::${ci.title}`)) {
issues.push(ci);
seen.add(`${ci.file}::${ci.title}`);
}
}
} catch {
// optional
}
}
}
issuesToDiagnostics(issues, collection);
ResultsPanel.show(context, issues, fileName);
const danger = issues.filter(i => i.severity === 'danger').length;
const warning = issues.filter(i => i.severity === 'warning').length;
const msg = `Guard: ${issues.length} 件検出 (危険 ${danger} / 警告 ${warning})`;
if (danger > 0) {
vscode.window.showErrorMessage(msg);
} else if (warning > 0) {
vscode.window.showWarningMessage(msg);
} else {
vscode.window.showInformationMessage(msg);
}
},
);
}

View File

@ -0,0 +1,97 @@
import * as vscode from 'vscode';
import { readWorkspaceFiles } from '../scanner/fileReader';
import { scanWithGemini } from '../scanner/geminiClient';
import { scanWithClaude } from '../scanner/claudeClient';
import { issuesToDiagnostics } from '../ui/diagnostics';
import { ResultsPanel } from '../ui/resultsPanel';
import { ScanIssue } from '../scanner/prompt';
export async function scanWorkspace(
context: vscode.ExtensionContext,
collection: vscode.DiagnosticCollection,
): Promise<void> {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) {
vscode.window.showWarningMessage('Guard: ワークスペースが開かれていません');
return;
}
const geminiKey = await context.secrets.get('guard.geminiKey');
if (!geminiKey) {
const action = await vscode.window.showErrorMessage(
'Guard: Gemini API キーが未設定です',
'APIキーを設定',
);
if (action === 'APIキーを設定') {
await vscode.commands.executeCommand('guard.setApiKeys');
}
return;
}
const config = vscode.workspace.getConfiguration('guard');
const maxFiles: number = config.get('maxFiles') ?? 80;
const model: string = config.get('model') ?? 'gemini';
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Guard: スキャン中...',
cancellable: false,
},
async progress => {
progress.report({ message: 'ファイルを読み込んでいます...' });
const files = readWorkspaceFiles(maxFiles);
if (files.length === 0) {
vscode.window.showInformationMessage('Guard: スキャン対象のファイルが見つかりませんでした');
return;
}
const sourceName = folders[0].name;
let issues: ScanIssue[] = [];
try {
progress.report({ message: `Gemini でスキャン中... (${files.length} ファイル)` });
issues = await scanWithGemini(files, geminiKey);
} catch (err) {
vscode.window.showErrorMessage(`Guard: Gemini スキャンに失敗しました: ${String(err)}`);
return;
}
if (model === 'claude' || model === 'both') {
const claudeKey = await context.secrets.get('guard.claudeKey');
if (claudeKey) {
try {
progress.report({ message: 'Claude でスキャン中...' });
const claudeIssues = await scanWithClaude(files, claudeKey);
// Merge: dedup by file + title
const seen = new Set(issues.map(i => `${i.file}::${i.title}`));
for (const ci of claudeIssues) {
if (!seen.has(`${ci.file}::${ci.title}`)) {
issues.push(ci);
seen.add(`${ci.file}::${ci.title}`);
}
}
} catch {
// Claude is optional — silent fail
}
}
}
issuesToDiagnostics(issues, collection);
ResultsPanel.show(context, issues, sourceName);
const danger = issues.filter(i => i.severity === 'danger').length;
const warning = issues.filter(i => i.severity === 'warning').length;
const msg = `Guard: ${issues.length} 件検出 (危険 ${danger} / 警告 ${warning})`;
if (danger > 0) {
vscode.window.showErrorMessage(msg);
} else if (warning > 0) {
vscode.window.showWarningMessage(msg);
} else {
vscode.window.showInformationMessage(msg);
}
},
);
}

View File

@ -0,0 +1,75 @@
import * as vscode from 'vscode';
import { scanWorkspace } from './commands/scanWorkspace';
import { scanFile } from './commands/scanFile';
let diagnosticCollection: vscode.DiagnosticCollection;
export function activate(context: vscode.ExtensionContext): void {
diagnosticCollection = vscode.languages.createDiagnosticCollection('guard');
context.subscriptions.push(diagnosticCollection);
// Status bar item
const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusBar.text = '$(shield) Guard';
statusBar.tooltip = 'Guard: ワークスペースをスキャン';
statusBar.command = 'guard.scanWorkspace';
statusBar.show();
context.subscriptions.push(statusBar);
// Commands
context.subscriptions.push(
vscode.commands.registerCommand('guard.scanWorkspace', () =>
scanWorkspace(context, diagnosticCollection),
),
vscode.commands.registerCommand('guard.scanFile', () =>
scanFile(context, diagnosticCollection),
),
vscode.commands.registerCommand('guard.setApiKeys', async () => {
const geminiKey = await vscode.window.showInputBox({
title: 'Guard: Gemini API キー',
prompt: 'Google AI Studio で取得した Gemini API キーを入力してください',
password: true,
placeHolder: 'AIza...',
ignoreFocusOut: true,
});
if (geminiKey !== undefined) {
if (geminiKey.trim()) {
await context.secrets.store('guard.geminiKey', geminiKey.trim());
vscode.window.showInformationMessage('Guard: Gemini API キーを保存しました');
} else {
await context.secrets.delete('guard.geminiKey');
vscode.window.showInformationMessage('Guard: Gemini API キーを削除しました');
}
}
const claudeKey = await vscode.window.showInputBox({
title: 'Guard: Anthropic API キー(任意)',
prompt: '設定するとGeminiに加えてClaudeでもスキャンし、見落とし率が下がります任意',
password: true,
placeHolder: 'sk-ant-...',
ignoreFocusOut: true,
});
if (claudeKey !== undefined) {
if (claudeKey.trim()) {
await context.secrets.store('guard.claudeKey', claudeKey.trim());
vscode.window.showInformationMessage('Guard: Anthropic API キーを保存しました');
} else {
await context.secrets.delete('guard.claudeKey');
}
}
}),
vscode.commands.registerCommand('guard.clearDiagnostics', () => {
diagnosticCollection.clear();
vscode.window.showInformationMessage('Guard: 診断をクリアしました');
}),
);
}
export function deactivate(): void {
diagnosticCollection?.dispose();
}

View File

@ -0,0 +1,56 @@
import * as https from 'https';
import { SYSTEM_PROMPT, buildFileContext, parseIssues, ScanIssue } from './prompt';
const MODEL = 'claude-sonnet-4-6';
function httpsPost(url: string, body: string, headers: Record<string, string>): Promise<string> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
};
const req = https.request(options, res => {
const chunks: Buffer[] = [];
res.on('data', d => chunks.push(d));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8');
if (res.statusCode === 401) {
reject(new Error('INVALID_KEY'));
} else if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
} else {
resolve(text);
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
export async function scanWithClaude(
files: { name: string; content: string }[],
apiKey: string,
): Promise<ScanIssue[]> {
const fileContext = buildFileContext(files);
const body = JSON.stringify({
model: MODEL,
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: fileContext }],
});
const raw = await httpsPost('https://api.anthropic.com/v1/messages', body, {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
});
const json = JSON.parse(raw);
const text: string = json?.content?.[0]?.text ?? '{"issues":[]}';
return parseIssues(text);
}

View File

@ -0,0 +1,119 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
export type ScannableFile = { name: string; content: string };
const SCAN_EXTENSIONS = new Set([
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
'.py', '.go', '.rs', '.php', '.rb', '.java', '.kt', '.swift',
'.json', '.yaml', '.yml', '.toml', '.env',
'.sh', '.bash', '.zsh',
'.dockerfile', '.conf', '.nginx',
]);
const EXCLUDE_FILES = new Set([
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb',
'composer.lock', 'Cargo.lock', 'poetry.lock', 'Pipfile.lock',
]);
const EXACT_NAMES = new Set([
'Dockerfile', 'dockerfile', '.gitignore', '.dockerignore',
'Makefile', 'Procfile', 'nginx.conf', 'Caddyfile',
]);
const MAX_FILE_SIZE = 100_000;
const MAX_BUCKET = 320;
function shouldInclude(fileName: string): boolean {
if (EXCLUDE_FILES.has(fileName)) return false;
if (EXACT_NAMES.has(fileName)) return true;
if (fileName.startsWith('.env')) return true;
if (fileName.startsWith('docker-compose')) return true;
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase();
return SCAN_EXTENSIONS.has(ext);
}
function scanPriority(relativePath: string): number {
const p = relativePath.replace(/\\/g, '/').toLowerCase();
if (p.includes('route.ts') || p.includes('route.js')) return 100;
if (p.includes('middleware.ts') || p.includes('middleware.js')) return 95;
if (p.includes('next.config')) return 92;
if (p.includes('/.env') || p.endsWith('.env') || p.includes('.env.')) return 88;
if (p.includes('docker-compose') || p.endsWith('/dockerfile')) return 82;
if (p.includes('vercel.json')) return 78;
if (p.includes('/api/')) return 72;
if (p.includes('server.ts') || p.includes('server.js')) return 68;
return 0;
}
function collectFiles(
dir: string,
root: string,
excludeDirs: Set<string>,
results: ScannableFile[],
): void {
if (results.length >= MAX_BUCKET) return;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (results.length >= MAX_BUCKET) break;
if (entry.isDirectory()) {
if (excludeDirs.has(entry.name)) continue;
collectFiles(path.join(dir, entry.name), root, excludeDirs, results);
} else if (entry.isFile()) {
if (!shouldInclude(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
try {
const stat = fs.statSync(fullPath);
if (stat.size > MAX_FILE_SIZE) continue;
const content = fs.readFileSync(fullPath, 'utf-8');
const relativePath = path.relative(root, fullPath).replace(/\\/g, '/');
results.push({ name: relativePath, content });
} catch {
// skip unreadable files
}
}
}
}
export function readWorkspaceFiles(maxFiles: number): ScannableFile[] {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return [];
const config = vscode.workspace.getConfiguration('guard');
const excludeDirsArr: string[] = config.get('excludeDirs') ?? [
'node_modules', '.next', '.git', 'dist', 'build', 'out',
'.turbo', '.cache', 'coverage', '__pycache__', '.venv', 'venv',
];
const excludeDirs = new Set(excludeDirsArr);
const all: ScannableFile[] = [];
for (const folder of folders) {
collectFiles(folder.uri.fsPath, folder.uri.fsPath, excludeDirs, all);
if (all.length >= MAX_BUCKET) break;
}
const sorted = [...all].sort((a, b) => {
const d = scanPriority(b.name) - scanPriority(a.name);
return d !== 0 ? d : a.name.localeCompare(b.name);
});
return sorted.slice(0, maxFiles);
}
export function readSingleFile(document: vscode.TextDocument): ScannableFile[] {
const folders = vscode.workspace.workspaceFolders;
const root = folders?.[0]?.uri.fsPath ?? '';
const relativePath = root
? path.relative(root, document.uri.fsPath).replace(/\\/g, '/')
: path.basename(document.uri.fsPath);
return [{ name: relativePath, content: document.getText() }];
}

View File

@ -0,0 +1,77 @@
import * as https from 'https';
import { buildFileContext, parseIssues, ScanIssue } from './prompt';
const MODEL = 'gemini-2.5-flash';
const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
const RESPONSE_SCHEMA = {
type: 'object',
properties: {
issues: {
type: 'array',
items: {
type: 'object',
properties: {
severity: { type: 'string', enum: ['danger', 'warning', 'info'] },
title: { type: 'string' },
description: { type: 'string' },
file: { type: 'string' },
line: { type: 'integer', nullable: true },
fix: { type: 'string', nullable: true },
},
required: ['severity', 'title', 'description', 'file', 'line', 'fix'],
},
},
},
required: ['issues'],
};
function httpsPost(url: string, body: string, headers: Record<string, string>): Promise<string> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
};
const req = https.request(options, res => {
const chunks: Buffer[] = [];
res.on('data', d => chunks.push(d));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8');
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
} else {
resolve(text);
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
export async function scanWithGemini(
files: { name: string; content: string }[],
apiKey: string,
): Promise<ScanIssue[]> {
const { SYSTEM_PROMPT } = await import('./prompt');
const fileContext = buildFileContext(files);
const body = JSON.stringify({
system_instruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents: [{ role: 'user', parts: [{ text: fileContext }] }],
generationConfig: {
responseMimeType: 'application/json',
responseSchema: RESPONSE_SCHEMA,
temperature: 0.1,
},
});
const raw = await httpsPost(`${API_URL}?key=${apiKey}`, body, {});
const json = JSON.parse(raw);
const text: string = json?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{"issues":[]}';
return parseIssues(text);
}

View File

@ -0,0 +1,87 @@
export const SYSTEM_PROMPT = `あなたはAI生成コードのセキュリティとコード品質の専門家です。
AI生成コードに特有のリスク
A: コード品質
1. APIキー
2. SQLインジェクションリスク
3. CORSAccess-Control-Allow-Origin: *
4. TODOプレースホルダーで認証をスキップしている箇所
5. APIエンドポイントのレートリミット未実装
6. target="_blank" rel="noopener"
7. error.message
8. localhostURL
9. SQLや外部APIにそのまま渡す箇所
10. package.json: 既知の脆弱バージョン@latest固定なし
B: 設定ファイルvercel.json / next.config.*
11. X-Frame-OptionsX-Content-Type-OptionsCSP
12. next.config.* dangerouslyAllowBrowserignoreBuildErrors
13. rewrites/redirects
C: コンテナCI/CDdocker-compose / Dockerfile / .github/workflows
14. docker-compose.yml root実行hardcodedパスワード
15. Dockerfile rootで実行latest
16. GitHub Actions permissions
D: 認証
17. JWT localStorage XSS
18.
19.
20. bcrypt/argon2
E: ファイルアップロード
21.
22.
23. SSRF: ユーザー指定URLを検証なしでfetch
24. XML/SVG/HTMLファイルのアップロード許可
F: 環境変数
25. .env
26. NEXT_PUBLIC_
27. 使
- JSONのみ
- severity: danger / warning / info
- title: 日本語25
- description: 120文字以内
- fix: 修正後のコードスニペット15null
- file: 該当ファイル名
- line: 該当行番号null
:
{"issues":[{"severity":"danger","title":"...","description":"...","file":"...","line":12,"fix":"..."}]}`;
const CONFIG_PATTERNS = /\.(ya?ml|toml|json|conf|nginx|dockerfile)$|^Dockerfile$|^docker-compose|^\.env|^\.gitignore|^Caddyfile/i;
export function buildFileContext(files: { name: string; content: string }[]): string {
return files
.slice(0, 50)
.map(f => {
const limit = CONFIG_PATTERNS.test(f.name) ? 6000 : 4000;
return `=== ${f.name} ===\n${f.content.slice(0, limit)}`;
})
.join('\n\n');
}
export function parseIssues(raw: string): ScanIssue[] {
try {
const cleaned = raw.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
return JSON.parse(cleaned).issues ?? [];
} catch {
const match = raw.match(/\{[\s\S]*\}/);
if (match) {
try { return JSON.parse(match[0]).issues ?? []; } catch { return []; }
}
return [];
}
}
export interface ScanIssue {
severity: 'danger' | 'warning' | 'info';
title: string;
description: string;
file: string;
line: number | null;
fix: string | null;
}

View File

@ -0,0 +1,47 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { ScanIssue } from '../scanner/prompt';
export function issuesToDiagnostics(
issues: ScanIssue[],
collection: vscode.DiagnosticCollection,
): void {
collection.clear();
const folders = vscode.workspace.workspaceFolders;
const root = folders?.[0]?.uri.fsPath ?? '';
// Group by file
const byFile = new Map<string, ScanIssue[]>();
for (const issue of issues) {
const key = issue.file;
if (!byFile.has(key)) byFile.set(key, []);
byFile.get(key)!.push(issue);
}
for (const [relativePath, fileIssues] of byFile) {
const absPath = root ? path.join(root, relativePath) : relativePath;
const uri = vscode.Uri.file(absPath);
const diagnostics: vscode.Diagnostic[] = fileIssues.map(issue => {
const lineNum = issue.line != null ? Math.max(0, issue.line - 1) : 0;
const range = new vscode.Range(lineNum, 0, lineNum, 999);
const severity =
issue.severity === 'danger'
? vscode.DiagnosticSeverity.Error
: issue.severity === 'warning'
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information;
const diag = new vscode.Diagnostic(range, `[Guard] ${issue.title}: ${issue.description}`, severity);
diag.source = 'Guard';
if (issue.fix) {
diag.code = { value: 'view-fix', target: vscode.Uri.parse('https://github.com') };
}
return diag;
});
collection.set(uri, diagnostics);
}
}

View File

@ -0,0 +1,116 @@
import * as vscode from 'vscode';
import { ScanIssue } from '../scanner/prompt';
export class ResultsPanel {
private static _current: ResultsPanel | undefined;
private readonly _panel: vscode.WebviewPanel;
private _disposables: vscode.Disposable[] = [];
static show(context: vscode.ExtensionContext, issues: ScanIssue[], source: string): void {
if (ResultsPanel._current) {
ResultsPanel._current._panel.reveal(vscode.ViewColumn.Beside);
ResultsPanel._current._update(issues, source);
return;
}
ResultsPanel._current = new ResultsPanel(context, issues, source);
}
private constructor(
context: vscode.ExtensionContext,
issues: ScanIssue[],
source: string,
) {
this._panel = vscode.window.createWebviewPanel(
'guardResults',
'Guard スキャン結果',
vscode.ViewColumn.Beside,
{ enableScripts: false, retainContextWhenHidden: true },
);
this._panel.onDidDispose(() => {
ResultsPanel._current = undefined;
this._disposables.forEach(d => d.dispose());
}, null, this._disposables);
this._update(issues, source);
}
private _update(issues: ScanIssue[], source: string): void {
this._panel.title = `Guard: ${source}`;
this._panel.webview.html = buildHtml(issues, source);
}
}
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildHtml(issues: ScanIssue[], source: string): string {
const danger = issues.filter(i => i.severity === 'danger');
const warning = issues.filter(i => i.severity === 'warning');
const info = issues.filter(i => i.severity === 'info');
const badge = (label: string, count: number, color: string) =>
count > 0
? `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:${color};color:#fff;margin-right:6px">${label} ${count}</span>`
: '';
const issueHtml = (issue: ScanIssue) => {
const color =
issue.severity === 'danger' ? '#e53e3e' :
issue.severity === 'warning' ? '#d69e2e' : '#3182ce';
const bg =
issue.severity === 'danger' ? '#fff5f5' :
issue.severity === 'warning' ? '#fffff0' : '#ebf8ff';
const fixSection = issue.fix
? `<pre style="margin:8px 0 0;padding:10px;background:#1a1a2e;color:#e2e8f0;border-radius:4px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all">${esc(issue.fix)}</pre>`
: '';
const location = issue.line != null ? `${esc(issue.file)}:${issue.line}` : esc(issue.file);
return `
<div style="margin-bottom:10px;border:1px solid ${color}33;border-left:3px solid ${color};border-radius:4px;padding:12px 14px;background:${bg}">
<div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:6px">
<span style="color:${color};font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;flex-shrink:0;padding-top:1px">${esc(issue.severity)}</span>
<span style="font-weight:600;font-size:13px;color:#1a202c">${esc(issue.title)}</span>
</div>
<div style="font-size:12px;color:#4a5568;margin-bottom:6px;line-height:1.5">${esc(issue.description)}</div>
<div style="font-size:11px;color:#718096;font-family:monospace">${location}</div>
${fixSection}
</div>`;
};
const allHtml = [...danger, ...warning, ...info].map(issueHtml).join('');
return `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f7fafc; color: #2d3748; padding: 20px; }
h1 { font-size: 15px; font-weight: 700; margin-bottom: 4px; color: #1a202c; }
.meta { font-size: 11px; color: #718096; margin-bottom: 16px; }
.summary { margin-bottom: 20px; }
.empty { text-align: center; padding: 40px; color: #718096; font-size: 14px; }
</style>
</head>
<body>
<h1>Guard </h1>
<div class="meta">${esc(source)} ${issues.length} </div>
<div class="summary">
${badge('危険', danger.length, '#e53e3e')}
${badge('警告', warning.length, '#d69e2e')}
${badge('情報', info.length, '#3182ce')}
</div>
${issues.length === 0
? '<div class="empty">問題は検出されませんでした</div>'
: allHtml
}
</body>
</html>`;
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}