-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
318 lines (285 loc) · 9.28 KB
/
Copy pathmain.go
File metadata and controls
318 lines (285 loc) · 9.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
// skilldrift は導入済みClaude Code Skillsの上流変更を監視するCLI。
// lockfile(skilldrift.lock)に記録した出自と上流の現在状態を比較し、ドリフトを検知する。
package main
import (
"context"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/kpab/skilldrift/internal/lockfile"
"github.com/kpab/skilldrift/internal/report"
"github.com/kpab/skilldrift/internal/riskscan"
"github.com/kpab/skilldrift/internal/scan"
"github.com/kpab/skilldrift/internal/upstream"
)
var version = "dev"
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "init":
err = runInit(os.Args[2:])
case "check":
drifted, cerr := runCheck(os.Args[2:])
if cerr != nil {
fmt.Fprintln(os.Stderr, "skilldrift:", cerr)
os.Exit(2)
}
if drifted {
os.Exit(1)
}
return
case "version":
fmt.Println("skilldrift", version)
case "help", "-h", "--help":
usage()
default:
fmt.Fprintf(os.Stderr, "skilldrift: 不明なサブコマンド %q\n\n", os.Args[1])
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "skilldrift:", err)
os.Exit(2)
}
}
func usage() {
fmt.Fprint(os.Stderr, `skilldrift — Dependabot for Claude Code Skills
Usage:
skilldrift <command> [flags]
Commands:
init スキルの出自を skilldrift.lock に記録する
check lockfileと上流を比較しドリフトを検知する
version バージョンを表示する
checkの終了コード: 0=ドリフトなし / 1=ドリフト検知 / 2=エラー
`)
}
func runInit(args []string) error {
fset := flag.NewFlagSet("init", flag.ContinueOnError)
dir := fset.String("dir", ".", "走査するリポジトリルート")
if err := fset.Parse(args); err != nil {
return err
}
found, err := scan.Skills(*dir)
if err != nil {
return err
}
if len(found) == 0 {
return fmt.Errorf("init: %s 以下にスキル(SKILL.mdを含むディレクトリ)が見つからない", *dir)
}
lockPath := filepath.Join(*dir, lockfile.DefaultPath)
lf, err := lockfile.Load(lockPath)
switch {
case errors.Is(err, fs.ErrNotExist):
lf = lockfile.New()
case err != nil:
return err
}
ch := lf.Reconcile(found)
if err := lf.Save(lockPath); err != nil {
return err
}
fmt.Printf("%s を書き出した: スキル%d件(新規%d・削除%d)\n", lockPath, len(lf.Skills), len(ch.Added), len(ch.Removed))
for _, name := range ch.Removed {
fmt.Printf(" 削除: %s(ローカルに存在しない)\n", name)
}
var untracked []lockfile.Skill
for _, s := range lf.Skills {
if !s.Tracked() {
untracked = append(untracked, s)
}
}
if len(untracked) > 0 {
fmt.Printf("\n出自未記入のスキルが%d件ある。%s の source.repo(owner/name)と commit を記入すると監視対象になる:\n", len(untracked), lockPath)
for _, s := range untracked {
fmt.Printf(" - %s (%s)\n", s.Name, s.Path)
}
}
return nil
}
// runCheck はlockfileと上流を比較する。返り値はドリフト有無とエラー。
func runCheck(args []string) (bool, error) {
return runCheckWith(upstream.New(), args)
}
func runCheckWith(client *upstream.Client, args []string) (bool, error) {
fset := flag.NewFlagSet("check", flag.ContinueOnError)
dir := fset.String("dir", ".", "lockfileのあるリポジトリルート")
issue := fset.Bool("issue", false, "ドリフト検知時にGitHub Issueを作成/更新する(要 GITHUB_TOKEN)")
issueRepo := fset.String("issue-repo", os.Getenv("GITHUB_REPOSITORY"), "Issueを立てるリポジトリ(owner/name。既定は $GITHUB_REPOSITORY)")
scanRisk := fset.Bool("scan", true, "ドリフト検知時にSkillSpectorで新旧リスクを再評価する(skillspector未導入なら自動スキップ)")
if err := fset.Parse(args); err != nil {
return false, err
}
if *issue && *issueRepo == "" {
return false, fmt.Errorf("check: -issue には -issue-repo(または環境変数 GITHUB_REPOSITORY)が必要")
}
// リスク再評価を有効化するのは、要求(-scan)されていてskillspectorがPATHにある時だけ。
scanEnabled := *scanRisk && riskscan.Available()
scanRequestedButMissing := *scanRisk && !scanEnabled
lockPath := filepath.Join(*dir, lockfile.DefaultPath)
lf, err := lockfile.Load(lockPath)
if errors.Is(err, fs.ErrNotExist) {
return false, fmt.Errorf("check: %s が無い。先に skilldrift init を実行してください", lockPath)
}
if err != nil {
return false, err
}
ctx := context.Background()
resolved := map[string]string{} // "repo@ref" -> commit SHA(同一上流への問い合わせは1回)
var drifts []report.Drift
var untracked, failures int
for _, s := range lf.Skills {
if !s.Tracked() {
untracked++
continue
}
key := s.Source.Repo + "@" + s.Source.Ref
sha, ok := resolved[key]
if !ok {
sha, err = client.ResolveCommit(ctx, s.Source.Repo, s.Source.Ref)
if err != nil {
fmt.Printf("エラー: %s — %v\n", s.Name, err)
failures++
continue
}
resolved[key] = sha
}
if sha == s.Source.Commit {
fmt.Printf("変更なし: %s(commit一致 %s)\n", s.Name, shortSHA(sha))
continue
}
// commitが動いた(または未記入)。コンテンツハッシュで最終判定する
files, err := client.FetchDir(ctx, s.Source.Repo, sha, s.Source.Subdir)
if err != nil {
fmt.Printf("エラー: %s — %v\n", s.Name, err)
failures++
continue
}
current := make(map[string]string, len(files))
for p, b := range files {
current[p] = lockfile.HashBytes(b)
}
changes := lockfile.DiffFiles(s.Files, current)
if len(changes) == 0 {
fmt.Printf("変更なし: %s(commitは %s → %s に進んだが内容は同一)\n", s.Name, shortSHA(s.Source.Commit), shortSHA(sha))
continue
}
newHashes := make(map[string]string, len(changes))
for _, c := range changes {
if h, ok := current[c.Path]; ok {
newHashes[c.Path] = h
}
}
d := report.Drift{
Skill: s.Name,
Repo: s.Source.Repo,
Subdir: s.Source.Subdir,
OldCommit: s.Source.Commit,
NewCommit: sha,
Changes: changes,
NewHashes: newHashes,
}
if scanEnabled {
d.OldRisk, d.NewRisk = assessRisk(ctx, filepath.Join(*dir, s.Path), files)
}
drifts = append(drifts, d)
fmt.Printf("\nドリフト: %s(%s", s.Name, s.Source.Repo)
if s.Source.Subdir != "" {
fmt.Printf(" の %s", s.Source.Subdir)
}
fmt.Printf(")\n")
fmt.Printf(" commit: %s → %s\n", shortSHA(s.Source.Commit), shortSHA(sha))
for _, c := range changes {
fmt.Printf(" %s %s\n", changeLabel(c.Kind), c.Path)
}
if d.NewRisk != nil {
fmt.Printf(" リスク: %s → %s\n", riskLabel(d.OldRisk), riskLabel(d.NewRisk))
}
fmt.Println()
}
if scanRequestedButMissing && len(drifts) > 0 {
fmt.Printf("注意: skillspector が見つからないためリスク再評価はスキップした(導入すればIssueに新旧スコア比較が載る)\n")
}
if untracked > 0 {
fmt.Printf("出自未記入のためスキップ: %d件(%s の source.repo を記入すると監視対象になる)\n", untracked, lockPath)
}
if *issue && len(drifts) > 0 {
results, perr := report.Publish(ctx, client, *issueRepo, drifts)
for _, r := range results {
fmt.Printf("Issue %s#%d(%s): %s\n", *issueRepo, r.Number, r.Skill, publishLabel(r.Action))
}
if perr != nil {
return len(drifts) > 0, fmt.Errorf("check: Issueの報告に失敗した: %w", perr)
}
}
switch {
case failures > 0:
return len(drifts) > 0, fmt.Errorf("check: %d件のスキルで上流の取得に失敗した", failures)
case len(drifts) > 0:
fmt.Printf("ドリフトを%d件検知した。上流の変更内容を確認してください\n", len(drifts))
return true, nil
default:
fmt.Println("ドリフトなし")
return false, nil
}
}
// assessRisk は旧(手元のローカルディレクトリ)と新(上流の取得ファイル)を
// SkillSpectorでスキャンして比較用のリスク評価を返す。
// 片方のスキャンが失敗しても、もう片方は返す(そのため個別にnil可)。
func assessRisk(ctx context.Context, localDir string, newFiles map[string][]byte) (old, new *report.Risk) {
if r, err := riskscan.Scan(ctx, localDir); err != nil {
fmt.Printf(" 警告: 手元スキルのリスク評価に失敗した(%s): %v\n", localDir, err)
} else {
old = toReportRisk(r)
}
if r, err := riskscan.ScanFiles(ctx, newFiles); err != nil {
fmt.Printf(" 警告: 上流新版のリスク評価に失敗した: %v\n", err)
} else {
new = toReportRisk(r)
}
return old, new
}
func toReportRisk(r riskscan.Result) *report.Risk {
return &report.Risk{Score: r.Score, Severity: r.Severity, Recommendation: r.Recommendation}
}
func riskLabel(r *report.Risk) string {
if r == nil {
return "(取得失敗)"
}
return fmt.Sprintf("%d/%s/%s", r.Score, r.Severity, r.Recommendation)
}
func publishLabel(a report.Action) string {
switch a {
case report.ActionCreated:
return "新規作成した"
case report.ActionUpdated:
return "本文を更新した(上流がさらに進行)"
default:
return "既報のためスキップ"
}
}
func changeLabel(k lockfile.ChangeKind) string {
switch k {
case lockfile.ChangeAdded:
return "追加:"
case lockfile.ChangeRemoved:
return "削除:"
default:
return "変更:"
}
}
func shortSHA(sha string) string {
if sha == "" {
return "(未記入)"
}
if len(sha) > 12 {
return sha[:12]
}
return sha
}