ksk-design-system 1.38.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NATIVE_RECIPES.md +141 -0
- package/README.md +42 -3
- package/bin/init.js +10 -0
- package/bin/lint.js +266 -0
- package/contracts/components.json +292 -18
- package/contracts/rules.json +100 -1
- package/dist/index.js +4802 -3582
- package/dist/native/ui.js +3450 -2229
- package/dist/types/components/patterns/admin/data-table.d.ts +73 -9
- package/dist/types/components/patterns/bottom-sheet-frame.d.ts +8 -0
- package/dist/types/components/patterns/celebration.d.ts +6 -1
- package/dist/types/components/patterns/commerce/bottom-tab-bar.d.ts +34 -2
- package/dist/types/components/patterns/compact-file-picker.d.ts +27 -0
- package/dist/types/components/patterns/detail-sheet-scaffold.d.ts +17 -0
- package/dist/types/components/patterns/empty-state.d.ts +7 -1
- package/dist/types/components/patterns/keyboard-aware-sheet-footer.d.ts +15 -0
- package/dist/types/components/patterns/mobile-app-header.d.ts +13 -0
- package/dist/types/components/patterns/mobile-app-shell.d.ts +18 -0
- package/dist/types/components/patterns/mobile-floating-action-button.d.ts +17 -0
- package/dist/types/components/patterns/quick-action-grid.d.ts +22 -0
- package/dist/types/components/patterns/settings-section.d.ts +22 -0
- package/dist/types/components/ui/auto-grow-textarea.d.ts +6 -2
- package/dist/types/components/ui/status-action-badge.d.ts +15 -0
- package/dist/types/components/ui/toast.d.ts +4 -0
- package/dist/types/index.d.ts +24 -4
- package/dist/types/lib/use-visual-viewport-keyboard-inset.d.ts +8 -0
- package/dist/types/native/components/AutoGrowTextarea.d.ts +3 -1
- package/dist/types/native/components/BottomSheetFrame.d.ts +13 -0
- package/dist/types/native/components/BottomTabBar.d.ts +71 -1
- package/dist/types/native/components/Button.d.ts +10 -2
- package/dist/types/native/components/Celebration.d.ts +25 -0
- package/dist/types/native/components/CompactFilePicker.d.ts +27 -0
- package/dist/types/native/components/DetailSheetScaffold.d.ts +24 -0
- package/dist/types/native/components/GlassView.d.ts +33 -8
- package/dist/types/native/components/KeyboardAwareSheetFooter.d.ts +10 -0
- package/dist/types/native/components/MobileAppHeader.d.ts +13 -0
- package/dist/types/native/components/MobileAppShell.d.ts +15 -0
- package/dist/types/native/components/MobileFloatingActionButton.d.ts +15 -0
- package/dist/types/native/components/QuickActionGrid.d.ts +24 -0
- package/dist/types/native/components/SettingsSection.d.ts +25 -0
- package/dist/types/native/components/StatusActionBadge.d.ts +15 -0
- package/dist/types/native/components/index.d.ts +14 -3
- package/package.json +8 -3
- package/src/components/COMPONENT_LOOKUP.md +38 -6
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# KSK Design System Native Recipes
|
|
2
|
+
|
|
3
|
+
Native / Expo consumer は、新規 UI を作る前に `ksk-design-system/native/ui` の既存コンポーネントを確認してください。ローカル `ds/` に独自 wrapper を増やす前に、このファイルの recipe を使います。
|
|
4
|
+
|
|
5
|
+
## Expo Router / React Navigation bottom tabs
|
|
6
|
+
|
|
7
|
+
Expo Router の `<Tabs>` では `createExpoRouterTabBar` を渡します。アイコンは各 screen の `tabBarIcon` をそのまま使えるため、consumer 側で floating tab bar を組み直す必要はありません。
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { Tabs } from "expo-router"
|
|
11
|
+
import { createExpoRouterTabBar } from "ksk-design-system/native/ui"
|
|
12
|
+
|
|
13
|
+
const tabBar = createExpoRouterTabBar({
|
|
14
|
+
glass: true,
|
|
15
|
+
floating: true,
|
|
16
|
+
keyboardBehavior: "hide",
|
|
17
|
+
hiddenRouteNames: ["modal"],
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export default function Layout() {
|
|
21
|
+
return (
|
|
22
|
+
<Tabs tabBar={tabBar}>
|
|
23
|
+
<Tabs.Screen name="index" options={{ title: "ホーム" }} />
|
|
24
|
+
<Tabs.Screen name="settings" options={{ title: "設定" }} />
|
|
25
|
+
<Tabs.Screen name="modal" options={{ href: null }} />
|
|
26
|
+
</Tabs>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
個別画面で tab bar を隠す場合は `options={{ tabBarStyle: { display: "none" } }}` を使います。画面をルーティング対象から隠す場合は Expo Router の `href: null` を使います。
|
|
32
|
+
|
|
33
|
+
React Navigation でも同じ factory を `tabBar` に渡せます。
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
<Tab.Navigator tabBar={createExpoRouterTabBar({ glass: true, floating: true })}>
|
|
37
|
+
{/* screens */}
|
|
38
|
+
</Tab.Navigator>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## iOS 26 Liquid Glass
|
|
42
|
+
|
|
43
|
+
`GlassView` は `expo-glass-effect` が使える iOS 26 環境では native Liquid Glass を使い、未導入または非対応環境では `expo-blur`、最後に tokenized surface へフォールバックします。
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx expo install expo-glass-effect expo-blur
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { GlassView } from "ksk-design-system/native/ui"
|
|
51
|
+
|
|
52
|
+
<GlassView
|
|
53
|
+
nativeGlass
|
|
54
|
+
fallback="blur"
|
|
55
|
+
glassEffectStyle="regular"
|
|
56
|
+
tint="system"
|
|
57
|
+
intensity="regular"
|
|
58
|
+
borderRadius={28}
|
|
59
|
+
interactive
|
|
60
|
+
>
|
|
61
|
+
{/* tab bar, composer, floating action surface */}
|
|
62
|
+
</GlassView>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`nativeGlass` を有効にしても Android / web / 非対応 iOS では自動で fallback します。feature check が必要な場合は `isNativeLiquidGlassAvailable()` を使います。
|
|
66
|
+
|
|
67
|
+
## Button loading and icon slots
|
|
68
|
+
|
|
69
|
+
Native `Button` は `loading`、`loadingLabel`、`leadingIcon`、`trailingIcon` を持ちます。loading 中は disabled 扱いになり、`ActivityIndicator` は `Text` に包まれません。
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
<Button
|
|
73
|
+
variant="primary"
|
|
74
|
+
loading={saving}
|
|
75
|
+
loadingLabel="保存中"
|
|
76
|
+
leadingIcon={<SaveIcon />}
|
|
77
|
+
onPress={save}
|
|
78
|
+
>
|
|
79
|
+
保存する
|
|
80
|
+
</Button>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Mobile app shell
|
|
84
|
+
|
|
85
|
+
`MobileAppShell` は header / main / bottom nav / global FAB / desktop sidebar の基本 geometry を DS 側に寄せる recipe です。consumer 側で main padding や fixed nav の重なりを毎回計算しません。
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
import {
|
|
89
|
+
BottomTabBar,
|
|
90
|
+
MobileAppShell,
|
|
91
|
+
MobileFloatingActionButton,
|
|
92
|
+
} from "ksk-design-system/native/ui"
|
|
93
|
+
|
|
94
|
+
<MobileAppShell
|
|
95
|
+
header={<AppHeader title="Belle" />}
|
|
96
|
+
bottomNav={<BottomTabBar items={items} glass />}
|
|
97
|
+
fab={<MobileFloatingActionButton label="追加" onPress={openCreate} />}
|
|
98
|
+
>
|
|
99
|
+
<HomeScreen />
|
|
100
|
+
</MobileAppShell>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Web/PWA consumer は `ksk-design-system` の `MobileAppShell` と `BottomTabBar variant="pill"` / `MobileFloatingActionButton` を組み合わせます。`bottomNavMode="fixed"` では shell が fixed wrapper と safe-area padding を持ちます。既に fixed な nav を渡す場合は `bottomNavMode="external"` を指定します。
|
|
104
|
+
|
|
105
|
+
## Settings screens
|
|
106
|
+
|
|
107
|
+
設定画面は `SettingsSection` と `SettingsListRow` を使います。`Card + SectionHeader + ListItem` のローカル wrapper を consumer 側で複製しないでください。
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
<SettingsSection title="通知" variant="card">
|
|
111
|
+
<SettingsListRow title="プッシュ通知" rightSlot={<Switch value={enabled} />} />
|
|
112
|
+
<SettingsListRow title="通知時間" description="毎日 9:00" rightSlot={<Badge>ON</Badge>} />
|
|
113
|
+
</SettingsSection>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Attachments
|
|
117
|
+
|
|
118
|
+
Web では `CompactFilePicker` / `ImageAttachmentPicker` が hidden file input、trigger、preview、remove affordance を持ちます。Native では DocumentPicker / ImagePicker の起動だけ consumer が渡し、trigger と preview は DS が持ちます。
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
<ImageAttachmentPicker
|
|
122
|
+
multiple
|
|
123
|
+
images={images}
|
|
124
|
+
onFilesChange={setFiles}
|
|
125
|
+
onRemove={removeImage}
|
|
126
|
+
/>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Bottom sheet frames
|
|
130
|
+
|
|
131
|
+
`BottomSheetFrame` は `SheetContent` の外枠 preset です。中身は `DetailSheetScaffold` と `KeyboardAwareSheetFooter` をそのまま組み合わせます。
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
135
|
+
<BottomSheetFrame preset="mobile-form">
|
|
136
|
+
<DetailSheetScaffold header={<DetailSheetHeader title="編集" />} footer={<KeyboardAwareSheetFooter />}>
|
|
137
|
+
{/* fields */}
|
|
138
|
+
</DetailSheetScaffold>
|
|
139
|
+
</BottomSheetFrame>
|
|
140
|
+
</Sheet>
|
|
141
|
+
```
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**🔗 ライブ Storybook → https://ksk-design-system.vercel.app**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
125 コンポーネントを実際に操作・確認できます。
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
- **アクセシブル** — shadcn/ui(Radix UI ベース)+ `@storybook/addon-a11y` で a11y を担保
|
|
15
15
|
- **Tailwind CSS v4 ネイティブ** — `@theme` ベースのトークン設計
|
|
16
16
|
- **型安全** — React 19 + TypeScript、CVA によるバリアント管理
|
|
17
|
-
- **
|
|
17
|
+
- **125 + 96 コンポーネント** — Web 125(UI 57 / EC 11 / 管理 8 / シェル 3 / パターン 46)+ React Native 96
|
|
18
18
|
- **iOS 26 Liquid Glass 対応** — RN 側 `GlassView` + `Button variant="glass"`、Web 側 `.glass` CSS マテリアル
|
|
19
19
|
|
|
20
20
|
## 🎨 テーマ
|
|
@@ -54,6 +54,22 @@ import { Button, Card, Input, FormField } from "ksk-design-system"
|
|
|
54
54
|
|
|
55
55
|
新規クライアント案件では、テーマファイルで `--Primitive-Brand-500` などブランドカラーの 10 行を定義するだけで、全コンポーネントがそのブランドカラーで動作します。
|
|
56
56
|
|
|
57
|
+
### Consumer lint
|
|
58
|
+
|
|
59
|
+
consumer 側のローカル grep script が古くならないよう、DS 本体から `contracts/rules.json` を読む lint CLI を同梱しています。
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx ksk-ds lint src
|
|
63
|
+
npx ksk-ds lint src --format json
|
|
64
|
+
npx ksk-ds lint --changed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
出力は `file:line rule severity fix` を含みます。どうしても DS で表現できない domain-specific UI は、理由付きの escape コメントを置きます。
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
// ksk-ds-allow-custom-ui: medical chart requires bespoke interaction
|
|
71
|
+
```
|
|
72
|
+
|
|
57
73
|
### Media overlay utilities
|
|
58
74
|
|
|
59
75
|
動画・写真の上に文字や操作を置く場合は、`--Text-on-Media` と `.text-on-media` / `.text-on-media-secondary`、上下の `.media-scrim-top` / `.media-scrim-bottom` を使います。TikTok / Reels 型の操作群は `MediaActionCluster` が glass ボタン、ラベル、safe-area anchor、idle auto-hide をまとめて扱います。
|
|
@@ -92,6 +108,27 @@ import { Screen, PhotoHero, Button } from "ksk-design-system"
|
|
|
92
108
|
</Screen>
|
|
93
109
|
```
|
|
94
110
|
|
|
111
|
+
### Liquid Glass bottom navigation
|
|
112
|
+
|
|
113
|
+
Web のグローバルナビで iOS 26 風の Liquid Glass を使う場合は、`BottomTabBar variant="pill"` を使います。実アプリの中央 CTA は `centerAction`、ラベル付き構成は `showLabels`、暗い写真・動画・gradient 上では `tone="inverse"` を指定します。
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { BottomTabBar } from "ksk-design-system"
|
|
117
|
+
|
|
118
|
+
<BottomTabBar
|
|
119
|
+
variant="pill"
|
|
120
|
+
items={[
|
|
121
|
+
{ label: "トーク", icon: <TalkIcon />, href: "/talk", isActive: true },
|
|
122
|
+
{ label: "ギャラリー", icon: <GalleryIcon />, href: "/gallery" },
|
|
123
|
+
]}
|
|
124
|
+
centerAction={{ label: "作成", icon: <PlusIcon />, href: "/create" }}
|
|
125
|
+
tone="inverse"
|
|
126
|
+
maxWidth={430}
|
|
127
|
+
/>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`pillPosition` は実アプリでは既定の `fixed`、Storybook や mobile shell 内のデモでは `absolute` を使います。safe-area は内部で `env(safe-area-inset-bottom)` を見ます。入力フォーム画面では keyboard 表示時に被らないよう、画面側で nav を隠すか bottom action に切り替えてください。
|
|
131
|
+
|
|
95
132
|
### React Native / Expo
|
|
96
133
|
|
|
97
134
|
`ksk-design-system/native/ui` から直接 RN 用コンポーネント(91 個)を import できます。iOS 26 の **Liquid Glass** にも対応:
|
|
@@ -102,11 +139,13 @@ import { ThemeProvider, Button, Card, GlassView } from "ksk-design-system/native
|
|
|
102
139
|
|
|
103
140
|
```bash
|
|
104
141
|
# Liquid Glass を本物の UIVisualEffectView で出したい場合
|
|
105
|
-
npx expo install expo-blur
|
|
142
|
+
npx expo install expo-glass-effect expo-blur
|
|
106
143
|
```
|
|
107
144
|
|
|
108
145
|
Web は backdrop-filter で擬似、Android は半透明 surface でフォールバックします。
|
|
109
146
|
|
|
147
|
+
Expo Router / React Navigation の tab bar、native `GlassView`、Button loading、settings/attachment/mobile shell recipes は `NATIVE_RECIPES.md` を参照してください。
|
|
148
|
+
|
|
110
149
|
## 🧪 試してみる(1コマンドお試し)
|
|
111
150
|
|
|
112
151
|
```bash
|
package/bin/init.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// npx ksk-design-system init # AGENTS.md + CLAUDE.md を設置
|
|
11
11
|
// npx ksk-design-system init --force # 既存ファイルを上書き
|
|
12
12
|
// npx ksk-design-system demo [dir] # DS リポを clone + setup(お試し用)
|
|
13
|
+
// npx ksk-ds lint src # contracts/rules.json に基づき consumer UI を検査
|
|
13
14
|
// npx ksk-design-system postinstall # npm postinstall から呼ばれる silent モード
|
|
14
15
|
|
|
15
16
|
import { copyFileSync, existsSync } from "node:fs"
|
|
@@ -32,10 +33,19 @@ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
|
32
33
|
npx ksk-design-system init --force 既存ファイルを上書き
|
|
33
34
|
npx ksk-design-system demo [dir] DS リポを clone + npm install(お試し)
|
|
34
35
|
dir 省略時は ./ksk-ds-demo
|
|
36
|
+
npx ksk-ds lint src DS-first ルール違反を検査
|
|
37
|
+
npx ksk-ds lint src --format json CI 向け JSON 出力
|
|
38
|
+
npx ksk-ds lint --changed Git 差分のみ検査
|
|
35
39
|
`)
|
|
36
40
|
process.exit(0)
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
if (cmd === "lint") {
|
|
44
|
+
const { runLintCli } = await import("./lint.js")
|
|
45
|
+
const status = await runLintCli(args.slice(1), { cwd: process.cwd(), pkgRoot })
|
|
46
|
+
process.exit(status)
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
if (cmd === "demo") {
|
|
40
50
|
runDemo(args.slice(1))
|
|
41
51
|
process.exit(0)
|
package/bin/lint.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
2
|
+
import { extname, join, relative, resolve } from "node:path"
|
|
3
|
+
import { spawnSync } from "node:child_process"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx"])
|
|
6
|
+
const DEFAULT_IGNORES = [
|
|
7
|
+
".git",
|
|
8
|
+
".next",
|
|
9
|
+
"build",
|
|
10
|
+
"coverage",
|
|
11
|
+
"dist",
|
|
12
|
+
"node_modules",
|
|
13
|
+
"storybook-static",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export async function runLintCli(argv, { cwd = process.cwd(), pkgRoot = resolve(".") } = {}) {
|
|
17
|
+
const options = parseArgs(argv)
|
|
18
|
+
const rulesPath = resolve(pkgRoot, "contracts/rules.json")
|
|
19
|
+
if (!existsSync(rulesPath)) {
|
|
20
|
+
console.error(`contracts/rules.json が見つかりません: ${rulesPath}`)
|
|
21
|
+
return 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rules = loadRules(rulesPath)
|
|
25
|
+
const files = options.changed
|
|
26
|
+
? getChangedFiles(cwd, options)
|
|
27
|
+
: collectTargetFiles(cwd, options.targets, options)
|
|
28
|
+
const findings = []
|
|
29
|
+
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
findings.push(...lintFile(file, cwd, rules, options))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const summary = summarize(findings)
|
|
35
|
+
if (options.format === "json") {
|
|
36
|
+
console.log(JSON.stringify({ results: findings, summary }, null, 2))
|
|
37
|
+
} else {
|
|
38
|
+
printText(findings, summary)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return summary.errors > 0 ? 1 : 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseArgs(argv) {
|
|
45
|
+
const targets = []
|
|
46
|
+
const excludes = []
|
|
47
|
+
let format = "text"
|
|
48
|
+
let changed = false
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const arg = argv[i]
|
|
52
|
+
if (arg === "--format") {
|
|
53
|
+
format = argv[++i] ?? "text"
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith("--format=")) {
|
|
57
|
+
format = arg.slice("--format=".length)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (arg === "--changed") {
|
|
61
|
+
changed = true
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (arg === "--exclude") {
|
|
65
|
+
excludes.push(argv[++i])
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (arg.startsWith("--exclude=")) {
|
|
69
|
+
excludes.push(arg.slice("--exclude=".length))
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (arg === "--help" || arg === "-h") {
|
|
73
|
+
console.log(`ksk-ds lint
|
|
74
|
+
|
|
75
|
+
使い方:
|
|
76
|
+
ksk-ds lint [path ...]
|
|
77
|
+
ksk-ds lint src --changed
|
|
78
|
+
ksk-ds lint src --format json
|
|
79
|
+
|
|
80
|
+
オプション:
|
|
81
|
+
--changed Git の変更ファイルのみ検査
|
|
82
|
+
--format json JSON 出力
|
|
83
|
+
--exclude TEXT パスに TEXT を含むファイルを除外
|
|
84
|
+
|
|
85
|
+
例外:
|
|
86
|
+
// ksk-ds-allow-custom-ui: domain-specific reason
|
|
87
|
+
`)
|
|
88
|
+
process.exit(0)
|
|
89
|
+
}
|
|
90
|
+
if (!arg.startsWith("-")) targets.push(arg)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
changed,
|
|
95
|
+
excludes: excludes.filter(Boolean),
|
|
96
|
+
format: format === "json" ? "json" : "text",
|
|
97
|
+
targets: targets.length > 0 ? targets : ["."],
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadRules(rulesPath) {
|
|
102
|
+
const contract = JSON.parse(readFileSync(rulesPath, "utf8"))
|
|
103
|
+
const prohibited = Array.isArray(contract)
|
|
104
|
+
? contract
|
|
105
|
+
: Array.isArray(contract.prohibited)
|
|
106
|
+
? contract.prohibited
|
|
107
|
+
: []
|
|
108
|
+
const aiPatterns = Array.isArray(contract?.aiPatterns?.patterns)
|
|
109
|
+
? contract.aiPatterns.patterns.map((rule) => ({ ...rule, category: "ai-pattern", severity: "warn" }))
|
|
110
|
+
: []
|
|
111
|
+
|
|
112
|
+
return [...prohibited, ...aiPatterns].filter((rule) => typeof rule.pattern === "string" && rule.pattern.length > 0)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function collectTargetFiles(cwd, targets, options) {
|
|
116
|
+
const files = []
|
|
117
|
+
for (const target of targets) {
|
|
118
|
+
const abs = resolve(cwd, target)
|
|
119
|
+
if (!existsSync(abs)) continue
|
|
120
|
+
collect(abs, cwd, options, files)
|
|
121
|
+
}
|
|
122
|
+
return files
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function collect(abs, cwd, options, out) {
|
|
126
|
+
const rel = normalize(relative(cwd, abs))
|
|
127
|
+
if (shouldIgnorePath(rel, options)) return
|
|
128
|
+
const stat = statSync(abs)
|
|
129
|
+
if (stat.isDirectory()) {
|
|
130
|
+
for (const entry of readdirSync(abs)) {
|
|
131
|
+
collect(join(abs, entry), cwd, options, out)
|
|
132
|
+
}
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (stat.isFile() && DEFAULT_EXTENSIONS.has(extname(abs))) out.push(abs)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getChangedFiles(cwd, options) {
|
|
139
|
+
const names = new Set()
|
|
140
|
+
for (const args of [
|
|
141
|
+
["diff", "--name-only", "--diff-filter=ACMR", "origin/main...HEAD"],
|
|
142
|
+
["diff", "--name-only", "--diff-filter=ACMR"],
|
|
143
|
+
["diff", "--name-only", "--diff-filter=ACMR", "--cached"],
|
|
144
|
+
]) {
|
|
145
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" })
|
|
146
|
+
if (result.status !== 0) continue
|
|
147
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
148
|
+
if (line.trim()) names.add(line.trim())
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...names]
|
|
152
|
+
.map((name) => resolve(cwd, name))
|
|
153
|
+
.filter((abs) => existsSync(abs))
|
|
154
|
+
.filter((abs) => !shouldIgnorePath(normalize(relative(cwd, abs)), options))
|
|
155
|
+
.filter((abs) => DEFAULT_EXTENSIONS.has(extname(abs)))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function lintFile(file, cwd, rules) {
|
|
159
|
+
const rel = normalize(relative(cwd, file))
|
|
160
|
+
const source = readFileSync(file, "utf8")
|
|
161
|
+
const escape = findEscape(source, rel)
|
|
162
|
+
if (escape.valid) return []
|
|
163
|
+
|
|
164
|
+
const findings = []
|
|
165
|
+
if (escape.invalid) findings.push(escape.invalid)
|
|
166
|
+
const lines = source.split(/\r?\n/)
|
|
167
|
+
|
|
168
|
+
for (const rule of rules) {
|
|
169
|
+
let regex
|
|
170
|
+
try {
|
|
171
|
+
regex = new RegExp(rule.pattern)
|
|
172
|
+
} catch {
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (rule.pattern.includes("[\\s\\S]")) {
|
|
176
|
+
const match = source.match(regex)
|
|
177
|
+
if (match && match.index != null) {
|
|
178
|
+
const lineNumber = lineForIndex(source, match.index)
|
|
179
|
+
const line = lines[lineNumber - 1] ?? ""
|
|
180
|
+
if (!matchesRuleExclude(rule, rel, line)) {
|
|
181
|
+
findings.push(toFinding(rule, rel, lineNumber))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
for (let index = 0; index < lines.length; index++) {
|
|
187
|
+
const line = lines[index]
|
|
188
|
+
if (matchesRuleExclude(rule, rel, line)) continue
|
|
189
|
+
if (!regex.test(line)) continue
|
|
190
|
+
findings.push(toFinding(rule, rel, index + 1))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return findings
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function toFinding(rule, file, line) {
|
|
198
|
+
return {
|
|
199
|
+
file,
|
|
200
|
+
line,
|
|
201
|
+
ruleId: rule.id ?? "UNKNOWN",
|
|
202
|
+
severity: rule.severity === "error" ? "error" : "warn",
|
|
203
|
+
category: rule.category ?? "pattern",
|
|
204
|
+
message: rule.message ?? rule.name ?? "DS rule violation",
|
|
205
|
+
fix: rule.fix ?? "",
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function findEscape(source, file) {
|
|
210
|
+
const match = source.match(/ksk-ds-allow-custom-ui(?::\s*(.+))?/)
|
|
211
|
+
if (!match) return { valid: false }
|
|
212
|
+
const reason = match[1]?.trim()
|
|
213
|
+
if (reason) return { valid: true }
|
|
214
|
+
return {
|
|
215
|
+
valid: false,
|
|
216
|
+
invalid: {
|
|
217
|
+
file,
|
|
218
|
+
line: lineForIndex(source, match.index ?? 0),
|
|
219
|
+
ruleId: "ESCAPE001",
|
|
220
|
+
severity: "error",
|
|
221
|
+
category: "escape",
|
|
222
|
+
message: "ksk-ds-allow-custom-ui には理由が必要です",
|
|
223
|
+
fix: "例: // ksk-ds-allow-custom-ui: domain-specific reason",
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function lineForIndex(source, index) {
|
|
229
|
+
return source.slice(0, index).split(/\r?\n/).length
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function matchesRuleExclude(rule, file, line) {
|
|
233
|
+
const excludes = Array.isArray(rule.excludes) ? rule.excludes : []
|
|
234
|
+
return excludes.some((exclude) => file.includes(exclude) || line.includes(exclude))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function shouldIgnorePath(relPath, options) {
|
|
238
|
+
if (!relPath || relPath === ".") return false
|
|
239
|
+
const parts = relPath.split("/")
|
|
240
|
+
if (parts.some((part) => DEFAULT_IGNORES.includes(part))) return true
|
|
241
|
+
return options.excludes.some((exclude) => relPath.includes(exclude))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function summarize(findings) {
|
|
245
|
+
return {
|
|
246
|
+
files: new Set(findings.map((finding) => finding.file)).size,
|
|
247
|
+
errors: findings.filter((finding) => finding.severity === "error").length,
|
|
248
|
+
warnings: findings.filter((finding) => finding.severity !== "error").length,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function printText(findings, summary) {
|
|
253
|
+
if (findings.length === 0) {
|
|
254
|
+
console.log("ksk-ds lint: 違反は見つかりませんでした")
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
for (const finding of findings) {
|
|
258
|
+
console.log(`${finding.file}:${finding.line} ${finding.severity} ${finding.ruleId} ${finding.message}`)
|
|
259
|
+
if (finding.fix) console.log(` fix: ${finding.fix}`)
|
|
260
|
+
}
|
|
261
|
+
console.log(`\nksk-ds lint: ${summary.errors} error / ${summary.warnings} warn in ${summary.files} files`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalize(path) {
|
|
265
|
+
return path.replaceAll("\\", "/")
|
|
266
|
+
}
|