Compare commits

...

11 Commits

Author SHA1 Message Date
posimai 4bb35568ab ci: move Linux build workflow to repo root .github/workflows/
Previous location (posimai-guard-app/.github/workflows/) was wrong —
GitHub Actions only reads .github/workflows/ at repository root.
Renamed to build-guard-linux.yml, added working-directory default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:32:42 +09:00
posimai e7f2a3a3ad ci: add GitHub Actions workflow for Linux build (.deb + .AppImage)
- Triggers on workflow_dispatch (manual) or version tag push (v*)
- Ubuntu 22.04 runner with Tauri prerequisites
- Uploads .deb and .AppImage as artifacts
- Creates GitHub Release on tag push

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:49:26 +09:00
posimai fb90a7b328 chore: update STATUS.md — Guard installers built, build method documented
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:13:22 +09:00
posimai 377a118273 chore: update STATUS.md — Guard release build complete, next steps updated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:10:00 +09:00
posimai 35bb426e84 fix(guard-app): remove unused Manager import (Rust warning)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:09:47 +09:00
posimai 54e50fabf1 docs(design): prohibit colored borderLeft on card lists
Card-type components must not use borderLeft for severity color.
Severity is expressed via icon + dim-bg badge only.
Navigation tree active state remains an exception.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:19:13 +09:00
posimai 251e5316eb docs: add posimai-bg.md aurora/background reusable reference, link from DESIGN.md 2026-04-12 22:20:37 +09:00
posimai 15257dfc71 feat: posimai-guard-app Tauri v2 desktop app scaffold (pending MSVC install) 2026-04-12 22:05:06 +09:00
posimai db0fd6a88e chore: *.vsix を .gitignore に追加、バイナリをgit管理外に
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:02:24 +09:00
posimai 43c650c2d8 feat(guard): Station風グリッド背景・バイナリオーロラ・severity色刷新
- バイナリ01が降るオーロラアニメーション追加(canvas, opacity 13%)
- グリッド背景 + 上部オレンジグロー(Station風)
- severity色: 赤→鮮明な赤(#FF453A) / 黄→Guard orange / info→violet(#A78BFA)
- IssueCard左ボーダーをseverity色に / ガラス質backdrop-filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:39:26 +09:00
posimai 9e6178791f 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>
2026-04-12 17:46:00 +09:00
34 changed files with 6989 additions and 2 deletions

73
.github/workflows/build-guard-linux.yml vendored Normal file
View File

@ -0,0 +1,73 @@
name: Build Guard Linux
on:
workflow_dispatch: # 手動実行
push:
tags:
- 'guard-v*' # guard-v0.1.0 など tag push で自動実行
jobs:
build-linux:
runs-on: ubuntu-22.04
defaults:
run:
working-directory: posimai-guard-app
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: Swatinem/rust-cache@v2
with:
workspaces: posimai-guard-app/src-tauri
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install npm dependencies
run: npm install
- name: Build Tauri (Linux)
run: npm run build
- name: Upload .deb
uses: actions/upload-artifact@v4
with:
name: posimai-guard-linux-deb
path: posimai-guard-app/src-tauri/target/release/bundle/deb/*.deb
if-no-files-found: warn
- name: Upload .AppImage
uses: actions/upload-artifact@v4
with:
name: posimai-guard-linux-appimage
path: posimai-guard-app/src-tauri/target/release/bundle/appimage/*.AppImage
if-no-files-found: warn
- name: Create GitHub Release (on tag)
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: |
posimai-guard-app/src-tauri/target/release/bundle/deb/*.deb
posimai-guard-app/src-tauri/target/release/bundle/appimage/*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6
.gitignore vendored
View File

@ -21,11 +21,17 @@ out/
dist/ dist/
build/ build/
# VS Code 拡張バイナリローカル成果物、git管理外
*.vsix
# 環境変数 # 環境変数
.env .env
.env.local .env.local
.env.*.local .env.*.local
# MCP 設定DB 認証情報を含む — git 管理外)
.mcp.json
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@ -14,6 +14,14 @@
| `ponshu-room` / `ponshu_room_lite` | 独自テーマ(和紙 x 墨 x 琥珀。Posimai デザインシステム適用外 | | `ponshu-room` / `ponshu_room_lite` | 独自テーマ(和紙 x 墨 x 琥珀。Posimai デザインシステム適用外 |
| `posimai-analytics` | BtoB ダッシュボード。TailwindCSS + React + ライトテーマで構築 | | `posimai-analytics` | BtoB ダッシュボード。TailwindCSS + React + ライトテーマで構築 |
## 背景スタイルStation / Guard 系)
Station・Guard・その他サイバー系アプリで使うダークブルー背景 + バイナリオーロラのコードは **`docs/posimai-bg.md`** にコピペ用で完備。
新規アプリに適用する場合は AI に「`docs/posimai-bg.md` の背景スタイルを適用して」と指示するだけでよい。
---
## `docs/design-system.md` との関係 ## `docs/design-system.md` との関係
`docs/design-system.md`**このファイルの補足・詳細版**として残存しているが、**AI が参照する正はこの DESIGN.md のみ**。二重参照した場合はこのファイルを優先すること。`docs/design-system.md` は将来的に廃止または統合予定。 `docs/design-system.md`**このファイルの補足・詳細版**として残存しているが、**AI が参照する正はこの DESIGN.md のみ**。二重参照した場合はこのファイルを優先すること。`docs/design-system.md` は将来的に廃止または統合予定。
@ -69,6 +77,7 @@
[data-app-id="posimai-atlas"] { --accent: #22D3EE; } /* Cyan */ [data-app-id="posimai-atlas"] { --accent: #22D3EE; } /* Cyan */
[data-app-id="posimai-dev"] { --accent: #A78BFA; } /* Violet */ [data-app-id="posimai-dev"] { --accent: #A78BFA; } /* Violet */
[data-app-id="posimai-journal"]{ --accent: #80CAEE; } /* Sky-Blue */ [data-app-id="posimai-journal"]{ --accent: #80CAEE; } /* Sky-Blue */
[data-app-id="posimai-guard"] { --accent: #F97316; } /* Amber-Orange */
/* NG: ルートへの直書き上書き */ /* NG: ルートへの直書き上書き */
:root { --accent: #22D3EE; } :root { --accent: #22D3EE; }
@ -80,6 +89,7 @@
| posimai-journal / posimai-site | `#80CAEE` Sky-Blue | 静かで知的な印象 | | posimai-journal / posimai-site | `#80CAEE` Sky-Blue | 静かで知的な印象 |
| posimai-atlas | `#22D3EE` Cyan | サイバー・ターミナル感。背景も `#0C1221` navy | | posimai-atlas | `#22D3EE` Cyan | サイバー・ターミナル感。背景も `#0C1221` navy |
| posimai-dev | `#A78BFA` Violet | コード・AI・ターミナルの融合 | | posimai-dev | `#A78BFA` Violet | コード・AI・ターミナルの融合 |
| posimai-guard | `#F97316` Amber-Orange | セキュリティ・警告の視覚的コンテキスト |
--- ---
@ -265,6 +275,31 @@
## 9. Do's and Don'tsコードレベル ## 9. Do's and Don'tsコードレベル
### カードの左縦罫線(禁止)
カード型リストIssueCard, CVECard, LicenseCard, IacCard など)の左辺に severity/状態カラーを使った縦罫線を付けてはいけない。
severity は内部のアイコンと dim-bg バッジで表現する。縦罫線はノイズになり "AIっぽいダサさ" の原因になる。
```tsx
// NG: 左罫線で severity を表現
<div style={{
border: '1px solid var(--border)',
borderLeft: `2px solid ${cfg.color}`, // ← 禁止
}}>
// OK: 左罫線なし。アイコン + dim-bg バッジで severity を示す
<div style={{
border: '1px solid var(--border)',
}}>
<div style={{ background: cfg.dim }}>
<Icon style={{ color: cfg.color }} />
</div>
```
例外: ファイルツリーのアクティブ選択状態(`borderLeft: '2px solid var(--accent)'`)は許可。ナビゲーション状態の表示であり、カードではないため。
---
### 色の使い方 ### 色の使い方
```html ```html

View File

@ -8,12 +8,28 @@
## 次にやること(優先順) ## 次にやること(優先順)
### Guard / Tauri
1. **Tauri アプリ動作確認** — デスクトップショートカットから起動、CodeViewer が WebView でも動くか確認
2. 将来: 絶対パス追跡 → 「VS Code で開く」ボタン実装tauriOpenPath 準備済み)
3. 将来: diff preview → apply → git commit フロー
### Guard ビルド方法メモ(次回再ビルド時)
```bash
cd /c/Users/maita/posimai-project/posimai-guard-app
export PATH="$PATH:/c/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/VC/Tools/MSVC/14.44.35207/bin/Hostx64/x64:/c/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64"
export LIB="C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.44.35207\lib\x64;..."
export INCLUDE="..."
node_modules/.bin/tauri build
# → target/release/bundle/nsis/Posimai Guard_0.1.0_x64-setup.exe
# → target/release/bundle/msi/Posimai Guard_0.1.0_x64_en-US.msi
```
### ビジネス化
1. **Eiji に Stripe sandbox テストをお願いする**(購入フロー確認) 1. **Eiji に Stripe sandbox テストをお願いする**(購入フロー確認)
2. **どのアプリに premium 機能を実装するか決める**(未決定・最重要) 2. **日本酒アプリを完成させて展開**mai 最優先
3. 特商法ページ記入mai 作業 — 事業者名・住所・電話番号) 3. 特商法ページ記入mai 作業 — 事業者名・住所・電話番号)
4. Store デザイン確定Eiji と A/B/C/D から選定) 4. Store デザイン確定Eiji と A/B/C/D から選定)
5. Stripe 本番モード切り替え(上記完了後) 5. Stripe 本番モード切り替え(上記完了後)
6. 各アプリへの `purchased` フラグ実装対象決定後・1アプリ30分
## ブロック中 ## ブロック中

268
docs/posimai-bg.md Normal file
View File

@ -0,0 +1,268 @@
# Posimai 背景スタイル — コピペ用リファレンス
新規アプリで Station/Guard と同じ背景を使いたい時はここからコピーする。
AI への指示は「`docs/posimai-bg.md` の背景スタイルを適用して」だけで OK。
---
## 何が入っているか
| 要素 | 内容 |
|------|------|
| 背景色 | `#0C1221`Termius 系ダークブルー) |
| グリッド線 | 48px 格子、極薄白 |
| 上部グロー | シアン+アクセントカラーのグラデーション |
| バイナリオーロラ | 0/1 が降るキャンバスアニメーションStation と同仕様) |
| フォント | InterUI + JetBrains Monoコード・キャンバス |
アクセントカラーだけ各アプリで差し替えるGuard は `#F97316`、Station は `#22D3EE` など)。
---
## 1. layout.tsx — フォント読み込み
```tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
weight: ['300', '400', '500', '600'],
display: 'swap',
variable: '--font-sans',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
weight: ['400', '500'],
display: 'swap',
variable: '--font-mono',
});
// body の className に両方渡す
<body className={`${inter.variable} ${jetbrainsMono.variable}`}>
```
---
## 2. globals.css — 背景スタイル一式
```css
@theme inline {
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
:root {
--bg: #0C1221;
--surface: #111827;
--surface2: #1A2332;
--border: #1F2D40;
/* アクセントカラーはアプリごとに変える */
--accent: #22D3EE; /* 例: Station はシアン */
--grid-line: rgba(255, 255, 255, 0.028);
}
body {
background: var(--bg);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* グリッド線 */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
/* 上部グロー(アクセントカラーを参照) */
body::after {
content: '';
position: fixed;
top: 0; left: 0; right: 0;
height: 480px;
background: radial-gradient(
ellipse 70% 45% at 50% -5%,
color-mix(in srgb, var(--accent) 8%, transparent) 0%,
transparent 70%
);
pointer-events: none;
z-index: 0;
}
/* コンテンツをオーロラの上に */
body > * {
position: relative;
z-index: 1;
}
```
---
## 3. BinaryAurora.tsx — コピペ用完全版
色帯BANDSの hue を変えるとアプリのカラーに合わせられる。
現在値は Station と同じシアン(185) / パープル(265) / グリーン(150)。
```tsx
'use client';
import { useEffect, useRef } from 'react';
export function BinaryAurora() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const FONT_SIZE = 14;
// 色帯: hue を変えてアプリのカラーに合わせる
// シアン/パープル/グリーンStation・Guard 共通)
const BANDS = [
{ hue: 185, sat: 90, x: 0.15, speed: 0.00018, phase: 0 },
{ hue: 265, sat: 80, x: 0.38, speed: 0.00013, phase: 1.5 },
{ hue: 185, sat: 85, x: 0.62, speed: 0.00020, phase: 3.0 },
{ hue: 150, sat: 70, x: 0.80, speed: 0.00015, phase: 4.2 },
];
type Col = { y: number; speed: number; len: number; chars: string[]; opacity: number };
let cols: Col[] = [];
let t = 0;
let raf: number;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const numCols = Math.ceil(canvas.width / FONT_SIZE);
while (cols.length < numCols) {
cols.push({
y: Math.random() * canvas.height,
speed: 1.0 + Math.random() * 3.2,
len: 8 + Math.floor(Math.random() * 22),
chars: [],
opacity: 0.28 + Math.random() * 0.50,
});
}
if (cols.length > numCols) cols.length = numCols;
};
resize();
window.addEventListener('resize', resize);
function getBandColor(x: number, now: number): { hue: number; sat: number; alpha: number } {
const xf = x / (canvas?.width ?? 1);
let best = BANDS[0];
let bestDist = Infinity;
for (const b of BANDS) {
const bx = b.x + Math.sin(now * b.speed + b.phase) * 0.12;
const dist = Math.abs(xf - bx);
if (dist < bestDist) { bestDist = dist; best = b; }
}
const bx = best.x + Math.sin(now * best.speed + best.phase) * 0.12;
const alpha = Math.max(0, 1 - Math.abs(xf - bx) / 0.22);
return { hue: best.hue, sat: best.sat, alpha };
}
const draw = () => {
t++;
raf = requestAnimationFrame(draw);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = `${FONT_SIZE}px 'JetBrains Mono', monospace`;
cols.forEach((col, i) => {
const x = i * FONT_SIZE;
const band = getBandColor(x, t);
for (let j = 0; j < col.len; j++) {
const cy = col.y - j * FONT_SIZE;
if (cy < -FONT_SIZE || cy > canvas.height + FONT_SIZE) continue;
if (!col.chars[j] || (t % 8 === 0 && Math.random() < 0.05)) {
col.chars[j] = Math.random() < 0.5 ? '1' : '0';
}
const ch = col.chars[j];
const trailAlpha = (1 - j / col.len) * col.opacity;
const finalAlpha = trailAlpha * (band.alpha * 0.7 + 0.15);
if (j === 0) {
ctx.fillStyle = `hsla(${band.hue},${band.sat}%,94%,${Math.min(1, finalAlpha * 2.2)})`;
} else if (ch === '1') {
ctx.fillStyle = `hsla(${band.hue},${band.sat}%,65%,${finalAlpha})`;
} else {
ctx.fillStyle = `hsla(${(band.hue + 30) % 360},${Math.round(band.sat * 0.6)}%,45%,${finalAlpha * 0.55})`;
}
ctx.fillText(ch, x, cy);
}
col.y += col.speed;
if (col.y - col.len * FONT_SIZE > canvas.height) {
col.y = -FONT_SIZE * 2;
col.speed = 1.0 + Math.random() * 3.2;
col.len = 8 + Math.floor(Math.random() * 22);
col.chars = [];
col.opacity = 0.28 + Math.random() * 0.50;
}
});
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
opacity: 1,
pointerEvents: 'none',
zIndex: 0,
}}
/>
);
}
```
### 使い方
```tsx
// layout.tsx または page.tsx に追加するだけ
import { BinaryAurora } from '@/components/BinaryAurora';
export default function Layout({ children }) {
return (
<>
<BinaryAurora />
{children}
</>
);
}
```
---
## 4. カスタマイズチートシート
| 変えたいもの | 場所 | 変更内容 |
|-------------|------|---------|
| 雨の色 | `BANDS[*].hue` | オレンジ系は 18〜42、シアンは 185、パープルは 265 |
| 雨の速さ | `speed: 1.0 + Math.random() * 3.2` | 数値を大きくすると速い |
| 雨の長さ | `len: 8 + Math.floor(Math.random() * 22)` | 数値を大きくすると長いトレイル |
| 背景色 | `--bg` CSS 変数 | `#0C1221` 標準。より暗くしたければ `#080E1A` など |
| 上部グロー | `body::after``--accent` | アクセントカラーに追従する |
| キャンバス全体の明るさ | `canvas opacity` | `0.5〜1.0` の範囲で調整 |

1
posimai-guard Submodule

@ -0,0 +1 @@
Subproject commit 26cacc8cec858ba8d71124ed93440f8601979d6e

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Posimai Guard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2079
posimai-guard-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"name": "posimai-guard-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.3.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

View File

@ -0,0 +1,28 @@
[package]
name = "posimai-guard-app"
version = "0.1.0"
description = "Posimai Guard — AI Code Security Scanner"
authors = ["posimai"]
edition = "2021"
[lib]
name = "posimai_guard_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,104 @@
/// ファイル一覧を取得(除外ディレクトリをスキップ)
#[tauri::command]
fn read_dir_recursive(
path: String,
exclude_dirs: Vec<String>,
max_files: usize,
) -> Result<Vec<FileEntry>, String> {
let mut results = Vec::new();
collect_files(
std::path::Path::new(&path),
&path,
&exclude_dirs,
&mut results,
max_files,
);
Ok(results)
}
#[derive(serde::Serialize)]
struct FileEntry {
path: String, // ルートからの相対パス
abs_path: String, // 絶対パス(ファイル読み込み用)
size: u64,
}
fn collect_files(
dir: &std::path::Path,
root: &str,
exclude_dirs: &[String],
results: &mut Vec<FileEntry>,
max_files: usize,
) {
if results.len() >= max_files {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if results.len() >= max_files {
break;
}
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if exclude_dirs.contains(&name) {
continue;
}
collect_files(&path, root, exclude_dirs, results, max_files);
} else if path.is_file() {
let abs = path.to_string_lossy().to_string();
let rel = abs.strip_prefix(root).unwrap_or(&abs).trim_start_matches(['/', '\\']).to_string();
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
results.push(FileEntry { path: rel, abs_path: abs, size });
}
}
}
/// ファイル内容を読み込む
#[tauri::command]
fn read_file(abs_path: String) -> Result<String, String> {
std::fs::read_to_string(&abs_path).map_err(|e| e.to_string())
}
/// 修正済み内容をファイルに書き戻す
#[tauri::command]
fn write_file(abs_path: String, content: String) -> Result<(), String> {
std::fs::write(&abs_path, content).map_err(|e| e.to_string())
}
/// git status / diff / commit を実行
#[tauri::command]
fn git_command(cwd: String, args: Vec<String>) -> Result<String, String> {
let output = std::process::Command::new("git")
.current_dir(&cwd)
.args(&args)
.output()
.map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
Ok(stdout)
} else {
Err(if stderr.is_empty() { stdout } else { stderr })
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
read_dir_recursive,
read_file,
write_file,
git_command,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
posimai_guard_app_lib::run()
}

View File

@ -0,0 +1,45 @@
{
"productName": "Posimai Guard",
"version": "0.1.0",
"identifier": "com.posimai.guard",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "Posimai Guard",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"shell": {
"open": true
}
}
}

View File

@ -0,0 +1,383 @@
import { useState, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
// ── Types ────────────────────────────────────────────────────────────────────
interface FileEntry {
path: string;
abs_path: string;
size: number;
}
interface ScanIssue {
severity: 'danger' | 'warning' | 'info';
title: string;
description: string;
file: string;
line: number | null;
fix: string | null;
}
type AppState =
| { status: 'idle' }
| { status: 'scanning'; progress: string }
| { status: 'results'; issues: ScanIssue[]; rootDir: string; files: FileEntry[] };
const EXCLUDE_DIRS = ['node_modules', '.next', '.git', 'dist', 'build', 'out', '.turbo', 'coverage', '__pycache__'];
const SCAN_EXTS = new Set(['.ts','.tsx','.js','.jsx','.py','.go','.rb','.php','.java','.cs','.env','.yaml','.yml','.json','.toml','.tf']);
const MAX_FILES = 80;
const MAX_CHARS = 6000;
// ── Main component ────────────────────────────────────────────────────────────
export default function App() {
const [state, setState] = useState<AppState>({ status: 'idle' });
const [geminiKey, setGeminiKey] = useState(() => localStorage.getItem('guard-gemini-key') ?? '');
const [showKeyInput, setShowKeyInput] = useState(false);
const handleSelectFolder = useCallback(async () => {
const dir = await open({ directory: true, multiple: false, title: 'スキャンするフォルダを選択' });
if (!dir || typeof dir !== 'string') return;
await runScan(dir);
}, [geminiKey]);
const runScan = async (rootDir: string) => {
if (!geminiKey.trim()) {
setShowKeyInput(true);
return;
}
setState({ status: 'scanning', progress: 'ファイルを収集中...' });
// 1. ファイル一覧取得Rust コマンド)
const allFiles: FileEntry[] = await invoke('read_dir_recursive', {
path: rootDir,
excludeDirs: EXCLUDE_DIRS,
maxFiles: MAX_FILES * 3,
});
// 2. スキャン対象フィルタ
const targets = allFiles
.filter(f => SCAN_EXTS.has(f.path.substring(f.path.lastIndexOf('.'))))
.sort((a, b) => {
const priority = (p: string) => {
if (p.includes('.env')) return 0;
if (p.endsWith('.ts') || p.endsWith('.tsx')) return 1;
return 2;
};
return priority(a.path) - priority(b.path);
})
.slice(0, MAX_FILES);
setState({ status: 'scanning', progress: `スキャン中... (${targets.length} ファイル)` });
// 3. ファイル内容を読み込み、チャンク化
const chunks: string[] = [];
let chunk = '';
for (const f of targets) {
try {
const content: string = await invoke('read_file', { absPath: f.abs_path });
const trimmed = content.slice(0, MAX_CHARS);
const entry = `\n\n### FILE: ${f.path}\n\`\`\`\n${trimmed}\n\`\`\``;
if (chunk.length + entry.length > 28000 && chunk.length > 0) {
chunks.push(chunk);
chunk = entry;
} else {
chunk += entry;
}
} catch { /* skip unreadable */ }
}
if (chunk) chunks.push(chunk);
// 4. Gemini API でスキャン
const allIssues: ScanIssue[] = [];
for (let i = 0; i < chunks.length; i++) {
setState({ status: 'scanning', progress: `AI スキャン中... (${i + 1}/${chunks.length})` });
try {
const issues = await callGemini(geminiKey, chunks[i]);
allIssues.push(...issues);
} catch (e) {
console.error('Gemini error', e);
}
}
setState({ status: 'results', issues: allIssues, rootDir, files: targets });
};
const handleApplyFix = async (issue: ScanIssue, rootDir: string) => {
if (!issue.fix) return;
const confirmed = window.confirm(
`以下の修正を適用しますか?\n\nファイル: ${issue.file}\n修正内容: ${issue.fix.slice(0, 200)}`
);
if (!confirmed) return;
try {
const absPath = `${rootDir}/${issue.file}`.replace(/\\/g, '/');
const content: string = await invoke('read_file', { absPath });
// 簡易パッチ: fix の内容をコメントとしてファイル先頭に追記(実際の差分適用は後続で)
const patched = `// [Guard fix] ${issue.title}\n// ${issue.fix}\n` + content;
await invoke('write_file', { absPath, content: patched });
alert(`適用しました: ${issue.file}`);
} catch (e) {
alert(`エラー: ${String(e)}`);
}
};
const handleGitDiff = async (rootDir: string) => {
try {
const diff: string = await invoke('git_command', { cwd: rootDir, args: ['diff', '--stat'] });
alert(diff || '変更なし');
} catch (e) {
alert(`git error: ${String(e)}`);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Header */}
<header style={{
height: 52, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 16px', borderBottom: '1px solid var(--border)',
background: 'rgba(12,18,33,0.9)', backdropFilter: 'blur(12px)',
position: 'sticky', top: 0, zIndex: 100,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--accent)' }} />
<span style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.02em' }}>posimai guard</span>
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--accent)',
background: 'var(--accent-dim)', padding: '1px 8px', borderRadius: 99,
}}>desktop</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{state.status === 'results' && (
<button
onClick={() => handleGitDiff(state.rootDir)}
style={btnStyle}
>
git diff
</button>
)}
<button onClick={() => setShowKeyInput(v => !v)} style={btnStyle}>
API
</button>
</div>
</header>
{/* API Key input (collapsible) */}
{showKeyInput && (
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--surface)', display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="password"
placeholder="Gemini API キー (AIza...)"
value={geminiKey}
onChange={e => {
setGeminiKey(e.target.value);
localStorage.setItem('guard-gemini-key', e.target.value);
}}
style={{ flex: 1, background: 'var(--surface2)', border: '1px solid var(--border)', borderRadius: 8, padding: '6px 12px', color: 'var(--text)', fontSize: 13, outline: 'none' }}
/>
<button onClick={() => setShowKeyInput(false)} style={{ ...btnStyle, color: 'var(--ok)' }}></button>
</div>
)}
{/* Main */}
<main style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{state.status === 'idle' && (
<IdleView onSelect={handleSelectFolder} />
)}
{state.status === 'scanning' && (
<ScanningView progress={state.progress} />
)}
{state.status === 'results' && (
<ResultsView
issues={state.issues}
rootDir={state.rootDir}
onApplyFix={handleApplyFix}
onRescan={() => runScan(state.rootDir)}
/>
)}
</main>
</div>
);
}
// ── Sub views ────────────────────────────────────────────────────────────────
function IdleView({ onSelect }: { onSelect: () => void }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20, padding: 40 }}>
<div style={{ fontSize: 40 }}>🛡</div>
<h1 style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.03em', textAlign: 'center', lineHeight: 1.3 }}>
AI生成コードのリスクを<br />
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', textAlign: 'center', maxWidth: 380, lineHeight: 1.7 }}>
Gemini APIにのみ送信されます
</p>
<button
onClick={onSelect}
style={{
padding: '12px 28px', borderRadius: 'var(--radius)', border: 'none',
background: 'var(--accent)', color: '#fff', fontSize: 14, fontWeight: 600,
cursor: 'pointer', letterSpacing: '-0.01em',
}}
>
</button>
</div>
);
}
function ScanningView({ progress }: { progress: string }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div style={{ width: 36, height: 36, border: '3px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<p style={{ fontSize: 13, color: 'var(--text2)' }}>{progress}</p>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
function ResultsView({
issues, rootDir, onApplyFix, onRescan,
}: {
issues: ScanIssue[];
rootDir: string;
onApplyFix: (issue: ScanIssue, rootDir: string) => void;
onRescan: () => void;
}) {
const danger = issues.filter(i => i.severity === 'danger').length;
const warning = issues.filter(i => i.severity === 'warning').length;
return (
<div style={{ maxWidth: 720, width: '100%', margin: '0 auto', padding: '28px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', gap: 8 }}>
{danger > 0 && <Chip count={danger} label="危険" color="var(--danger)" dim="var(--danger-dim)" />}
{warning > 0 && <Chip count={warning} label="注意" color="var(--warning)" dim="var(--warning-dim)" />}
{issues.length === 0 && <span style={{ fontSize: 13, color: 'var(--ok)' }}></span>}
</div>
<button onClick={onRescan} style={btnStyle}></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{issues.map((issue, idx) => (
<IssueCard key={idx} issue={issue} onApplyFix={() => onApplyFix(issue, rootDir)} />
))}
</div>
</div>
);
}
function IssueCard({ issue, onApplyFix }: { issue: ScanIssue; onApplyFix: () => void }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const color = issue.severity === 'danger' ? 'var(--danger)' : issue.severity === 'warning' ? 'var(--warning)' : 'var(--info)';
const dim = issue.severity === 'danger' ? 'var(--danger-dim)' : issue.severity === 'warning' ? 'var(--warning-dim)' : 'var(--info-dim)';
const label = issue.severity === 'danger' ? '危険' : issue.severity === 'warning' ? '注意' : '改善';
const copyPrompt = async () => {
const loc = issue.line ? `${issue.file}${issue.line}行目付近)` : issue.file;
const text = [
'以下のセキュリティ問題を修正してください。',
'',
`ファイル: ${loc}`,
`問題: ${issue.title}`,
`詳細: ${issue.description}`,
...(issue.fix ? [`修正案: ${issue.fix}`] : []),
'',
'該当箇所のコードを貼り付けて、上記の問題を安全に修正してください。',
].join('\n');
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
<div style={{ background: 'rgba(17,24,39,0.7)', border: `1px solid var(--border)`, borderLeft: `2px solid ${color}`, borderRadius: 'var(--radius)', overflow: 'hidden' }}>
<button
onClick={() => setExpanded(v => !v)}
style={{ width: '100%', padding: '12px 14px', display: 'flex', alignItems: 'flex-start', gap: 10, background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left' }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 2 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{issue.title}</span>
<span style={{ fontSize: 10, fontWeight: 600, color, background: dim, padding: '1px 7px', borderRadius: 99, letterSpacing: '0.06em' }}>{label}</span>
</div>
<span style={{ fontSize: 11, color: 'var(--text3)', fontFamily: "'JetBrains Mono', monospace" }}>
{issue.file}{issue.line ? `:${issue.line}` : ''}
</span>
</div>
<span style={{ color: 'var(--text3)', fontSize: 13 }}>{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.7 }}>{issue.description}</p>
{issue.fix && (
<pre style={{ background: 'var(--surface2)', border: '1px solid var(--border)', borderRadius: 8, padding: '10px 12px', fontSize: 12, color: 'var(--text)', overflowX: 'auto', lineHeight: 1.6, fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{issue.fix}
</pre>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={copyPrompt} style={btnStyle}>
{copied ? '✓ コピー済み' : 'AIプロンプトをコピー'}
</button>
{issue.fix && (
<button
onClick={onApplyFix}
style={{ ...btnStyle, color: 'var(--ok)', borderColor: 'rgba(50,215,75,0.3)' }}
>
</button>
)}
</div>
</div>
)}
</div>
);
}
function Chip({ count, label, color, dim }: { count: number; label: string; color: string; dim: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 99, background: dim }}>
<span style={{ fontSize: 15, fontWeight: 700, color }}>{count}</span>
<span style={{ fontSize: 12, fontWeight: 500, color }}>{label}</span>
</div>
);
}
// ── Styles ───────────────────────────────────────────────────────────────────
const btnStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 12px', borderRadius: 8,
border: '1px solid var(--border)', background: 'transparent',
color: 'var(--text2)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
};
// ── Gemini API call ──────────────────────────────────────────────────────────
async function callGemini(apiKey: string, codeBlock: string): Promise<ScanIssue[]> {
const SYSTEM = `あなたはセキュリティ専門家です。提供されたコードを解析し、セキュリティリスクを JSON 配列で返してください。
: { severity: "danger"|"warning"|"info", title: string, description: string, file: string, line: number|null, fix: string|null }
[] JSON `;
const resp = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_instruction: { parts: [{ text: SYSTEM }] },
contents: [{ role: 'user', parts: [{ text: `以下のコードを解析してください:\n${codeBlock}` }] }],
generationConfig: { temperature: 0.1, responseMimeType: 'application/json' },
}),
}
);
if (!resp.ok) throw new Error(`Gemini ${resp.status}`);
const data = await resp.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '[]';
try {
return JSON.parse(text);
} catch {
return [];
}
}

View File

@ -0,0 +1,53 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0C1221;
--surface: #111827;
--surface2: #1A2332;
--border: #1F2D40;
--text: #F3F4F6;
--text2: #9CA3AF;
--text3: #4B5563;
--accent: #F97316;
--accent-dim: rgba(249,115,22,0.10);
--accent-border: rgba(249,115,22,0.28);
--danger: #FF453A;
--danger-dim: rgba(255,69,58,0.10);
--warning: #F97316;
--warning-dim: rgba(249,115,22,0.10);
--info: #A78BFA;
--info-dim: rgba(167,139,250,0.10);
--ok: #32D74B;
--ok-dim: rgba(50,215,75,0.10);
--radius: 12px;
--radius-sm: 8px;
}
html, body, #root {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* grid lines */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.028) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
#root { position: relative; z-index: 1; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Tauri expects a fixed port during dev
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: ['es2021', 'chrome105', 'safari15'],
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
});

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"
}
}

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"]
}