feat(skillpack): SKILL.md-subscription battery #22

Merged
steve merged 3 commits from feat/skillpack-battery into main 2026-07-05 01:28:57 +00:00
10 changed files with 150 additions and 24 deletions
Showing only changes of commit 9bb5d143f7 - Show all commits
+20 -6
View File
@@ -2,6 +2,7 @@ package skillpack
import (
"context"
"errors"
"fmt"
"sort"
"strings"
@@ -40,10 +41,15 @@ func Resolve(ctx context.Context, cache PackCache, subs []Subscription) ([]*Pack
// pack (name + description) plus how to load one. This is the whole prompt cost
// of a subscription — the bodies stay out until skill_use is called.
func Catalog(packs []*Pack) string {
if len(packs) == 0 {
sorted := make([]*Pack, 0, len(packs))
for _, p := range packs {
if p != nil && p.Manifest != nil {
sorted = append(sorted, p)
Outdated
Review

🟠 Catalog panics on nil pack element in slice

error-handling · flagged by 1 model

  • skillpack/activation.go:47,54Catalog(packs []*Pack) will panic if any element in the slice is nil. The function checks len(packs) == 0 but does not defend against []*Pack{nil}. A caller constructing a slice with a nil element (or a bug in Resolve that appends nil) would cause a runtime panic at sorted[i].Manifest.Name. Fix: Add a nil check in the loop (if p == nil { continue }) or document that nil elements are not permitted.

🪰 Gadfly · advisory

🟠 **Catalog panics on nil pack element in slice** _error-handling · flagged by 1 model_ - **`skillpack/activation.go:47,54`** — `Catalog(packs []*Pack)` will **panic** if any element in the slice is `nil`. The function checks `len(packs) == 0` but does not defend against `[]*Pack{nil}`. A caller constructing a slice with a nil element (or a bug in `Resolve` that appends nil) would cause a runtime panic at `sorted[i].Manifest.Name`. **Fix:** Add a nil check in the loop (`if p == nil { continue }`) or document that nil elements are not permitted. <sub>🪰 Gadfly · advisory</sub>
}
}
if len(sorted) == 0 {
return ""
}
sorted := append([]*Pack(nil), packs...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Manifest.Name < sorted[j].Manifest.Name })
Review

🟠 Prompt injection via unsanitized Description field injected directly into system prompt Catalog

security · flagged by 1 model

🪰 Gadfly · advisory

🟠 **Prompt injection via unsanitized Description field injected directly into system prompt Catalog** _security · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
var b strings.Builder
@@ -71,12 +77,14 @@ type skillUseArgs struct {
// where to read scripts/references with its file tools. Leave it empty when the
// host has no staging.
func Activate(packs []*Pack, stagedDir string) mdagent.Skill {
Review

🟠 Activate panics on nil pack element in slice

error-handling · flagged by 1 model

  • skillpack/activation.go:79Activate(packs []*Pack, stagedDir string) will panic if any pack in the input slice is nil. Same issue: byName[p.Manifest.Name] dereferences without checking p != nil. Fix: Skip nil packs in the loop or return an error.

🪰 Gadfly · advisory

🟠 **Activate panics on nil pack element in slice** _error-handling · flagged by 1 model_ - **`skillpack/activation.go:79`** — `Activate(packs []*Pack, stagedDir string)` will **panic** if any pack in the input slice is `nil`. Same issue: `byName[p.Manifest.Name]` dereferences without checking `p != nil`. **Fix:** Skip nil packs in the loop or return an error. <sub>🪰 Gadfly · advisory</sub>
if len(packs) == 0 {
return nil
}
byName := make(map[string]*Pack, len(packs))
for _, p := range packs {
byName[p.Manifest.Name] = p
if p != nil && p.Manifest != nil {
byName[p.Manifest.Name] = p
}
}
if len(byName) == 0 {
return nil
}
tool := llm.DefineTool("skill_use",
1
@@ -99,6 +107,9 @@ func Activate(packs []*Pack, stagedDir string) mdagent.Skill {
// renderPackBody is what skill_use returns: the pack's instructions plus a
// pointer to its bundled files (with the staged path when known).
func renderPackBody(p *Pack, stagedDir string) string {
if p == nil || p.Manifest == nil {
return "Error: invalid skill pack."
}
var b strings.Builder
fmt.Fprintf(&b, "# Skill: %s\n\n%s\n", p.Manifest.Name, p.Manifest.Body)
if len(p.Bundled) > 0 {
1
@@ -118,6 +129,9 @@ func renderPackBody(p *Pack, stagedDir string) string {
// mount them (read-only is the host's concern) into a sandbox the agent's file
// tools can read. Returns the pack's staged directory.
func Stage(p *Pack, baseDir string) (string, error) {
if p == nil || p.Manifest == nil {
return "", errors.New("skillpack: Stage requires a non-nil pack")
}
dir := baseDir + "/" + p.Manifest.Name
if err := p.Tree.WriteTo(dir); err != nil {
return "", err
+19
View File
@@ -86,6 +86,25 @@ func TestActivate_Empty(t *testing.T) {
}
}
func TestNilPackElementsAreSafe(t *testing.T) {
packs := []*Pack{nil, mustPack(t, "real", "b", nil), {Manifest: nil}}
// Neither Catalog nor Activate may panic on nil / malformed elements.
if got := Catalog(packs); !strings.Contains(got, "real") {
t.Errorf("catalog should include the valid pack and skip nils: %q", got)
}
sk := Activate(packs, "")
if sk == nil {
t.Fatal("a valid pack among nils should still activate")
}
if _, ok := sk.Tools().Get("skill_use"); !ok {
t.Error("skill_use missing")
}
// All-nil activates to nothing rather than panicking.
if Activate([]*Pack{nil, {Manifest: nil}}, "") != nil {
t.Error("only-nil packs should activate to nil")
}
}
func TestResolveFromCache(t *testing.T) {
ctx := context.Background()
cache := NewMemoryPackCache()
+18 -2
View File
1
@@ -112,6 +112,10 @@ func (m *Manifest) Validate() error {
// Leading blank lines/BOM are tolerated. A missing or unterminated block is an
Review

🟡 splitFrontmatter claims BOM tolerance but does not strip a leading BOM, so BOM-prefixed SKILL.md fails to parse

error-handling · flagged by 1 model

  • skillpack/manifest.go:112-131 — BOM is claimed tolerated but isn't. The doc comment on splitFrontmatter says "Leading blank lines/BOM are tolerated," but the scan loop (lines 126-134) only skips blank lines (len(bytes.TrimSpace(trimmed)) == 0) and then checks string(bytes.TrimSpace(trimmed)) != "---". bytes.TrimSpace does not strip a UTF-8 BOM (0xEF 0xBB 0xBF), so a SKILL.md saved with a BOM (\xEF\xBB\xBF---) fails with "must start with a '---' frontmatter block." There is no…

🪰 Gadfly · advisory

🟡 **splitFrontmatter claims BOM tolerance but does not strip a leading BOM, so BOM-prefixed SKILL.md fails to parse** _error-handling · flagged by 1 model_ - **`skillpack/manifest.go:112-131` — BOM is claimed tolerated but isn't.** The doc comment on `splitFrontmatter` says "Leading blank lines/BOM are tolerated," but the scan loop (lines 126-134) only skips blank lines (`len(bytes.TrimSpace(trimmed)) == 0`) and then checks `string(bytes.TrimSpace(trimmed)) != "---"`. `bytes.TrimSpace` does not strip a UTF-8 BOM (`0xEF 0xBB 0xBF`), so a SKILL.md saved with a BOM (`\xEF\xBB\xBF---`) fails with "must start with a '---' frontmatter block." There is no… <sub>🪰 Gadfly · advisory</sub>
// error — a SKILL.md without frontmatter has no name/description to catalog.
func splitFrontmatter(raw []byte) (front, body []byte, err error) {
// Strip a leading UTF-8 BOM: editors on some platforms prepend one, and
// bytes.TrimSpace (used below) does not remove it, so a BOM would otherwise
// make the first "---" fence unrecognizable.
raw = bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF})
s := bufio.NewScanner(bytes.NewReader(raw))
s.Buffer(make([]byte, 0, 64*1024), maxBodyBytes+64*1024)
1
@@ -180,13 +184,25 @@ func trimAll(in []string) []string {
return out
}
// isKebab reports whether s is strict lowercase kebab-case: [a-z0-9] segments
// joined by single hyphens, with no leading, trailing, or consecutive hyphens.
func isKebab(s string) bool {
if s == "" || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
prevHyphen := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
prevHyphen = false
case r == '-':
if prevHyphen {
return false
}
prevHyphen = true
default:
return false
}
}
return s != ""
return true
}
+14
View File
@@ -61,6 +61,9 @@ func TestParseManifest_Errors(t *testing.T) {
"missing desc": "---\nname: x\n---\nb\n",
"bad name uppercase": "---\nname: PdfProcessing\ndescription: d\n---\nb\n",
"bad name space": "---\nname: pdf processing\ndescription: d\n---\nb\n",
"bad name leading -": "---\nname: -pdf\ndescription: d\n---\nb\n",
"bad name trailing-": "---\nname: pdf-\ndescription: d\n---\nb\n",
"bad name double -": "---\nname: pdf--tools\ndescription: d\n---\nb\n",
"bad yaml": "---\nname: [unclosed\n---\nb\n",
}
for label, in := range cases {
@@ -80,3 +83,14 @@ func TestParseManifest_LeadingBlanksAndCRLF(t *testing.T) {
t.Errorf("got name=%q body=%q", m.Name, m.Body)
}
}
func TestParseManifest_BOM(t *testing.T) {
in := append([]byte{0xEF, 0xBB, 0xBF}, []byte("---\nname: bom-ok\ndescription: d\n---\nbody\n")...)
m, err := ParseManifest(in)
if err != nil {
t.Fatalf("BOM-prefixed SKILL.md should parse: %v", err)
}
if m.Name != "bom-ok" {
t.Errorf("name = %q", m.Name)
}
}
+8
View File
3
@@ -107,6 +107,14 @@ func readTree(fsys fs.FS) (Tree, error) {
if d.IsDir() {
return nil
}
// Skip symlinks. A pack must be self-contained; os.DirFS + ReadFile
// follows symlinks, so a malicious pack with `SKILL.md -> /etc/passwd`
// or `scripts/x -> ../../.ssh/id_rsa` would otherwise read host files
// into the tree. WalkDir yields a symlink-to-dir as a non-dir entry
// carrying ModeSymlink, so this one check covers file and dir symlinks.
if d.Type()&fs.ModeSymlink != 0 {
return nil
}
b, err := fs.ReadFile(fsys, p)
if err != nil {
return err
1
+25
View File
@@ -68,6 +68,31 @@ func TestTreeWriteTo(t *testing.T) {
}
}
func TestReadTree_SkipsSymlinks(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ManifestName), []byte(goodManifest), 0o644); err != nil {
t.Fatal(err)
}
// A malicious pack pointing at a host file must NOT be read into the tree.
secret := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(secret, []byte("TOPSECRET"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secret, filepath.Join(dir, "leak")); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
tree, err := readTree(os.DirFS(dir))
if err != nil {
t.Fatal(err)
}
if _, ok := tree["leak"]; ok {
t.Fatal("symlink was followed into the tree — arbitrary host file read")
}
if _, ok := tree[ManifestName]; !ok {
t.Fatal("real file should still be read")
}
}
func TestTreeWriteTo_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
evil := Tree{"../escape.txt": []byte("nope")}
+18 -2
View File
@@ -88,17 +88,33 @@ func (g GitSource) run(ctx context.Context, dir string, args ...string) ([]byte,
}
func (g GitSource) Fetch(ctx context.Context, ref string) (Tree, string, error) {
// Argument-injection guard: a URL or ref beginning with "-" would be parsed
// by git as an option (e.g. --upload-pack=…), not a value. Reject it rather
// than rely solely on the "--" separator, which checkout does not honor for
// a rev. Hosts should also allow-list sources, but this is defense-in-depth
// for a library.
if strings.HasPrefix(g.URL, "-") {
return nil, "", fmt.Errorf("skillpack: git url must not start with '-': %q", g.URL)
Outdated
Review

🔴 Git command injection via g.URL in git clone — no -- separator before user-controlled URL

performance, security · flagged by 2 models

  • GitSource.Fetch clones full repo history — no --depth 1 (skillpack/source.go:97). The clone command is git clone --quiet g.URL tmp with no --depth 1 and no --single-branch. Every Subscribe and every Check (which runs on a host ticker via CheckAll) clones the entire repository history when only the current tree state is needed. For a pack published from a repo with years of history, this downloads orders of magnitude more data than necessary and burns CPU on delta reso…

🪰 Gadfly · advisory

🔴 **Git command injection via g.URL in git clone — no -- separator before user-controlled URL** _performance, security · flagged by 2 models_ - **`GitSource.Fetch` clones full repo history — no `--depth 1`** (`skillpack/source.go:97`). The clone command is `git clone --quiet g.URL tmp` with no `--depth 1` and no `--single-branch`. Every `Subscribe` and every `Check` (which runs on a host ticker via `CheckAll`) clones the **entire repository history** when only the current tree state is needed. For a pack published from a repo with years of history, this downloads orders of magnitude more data than necessary and burns CPU on delta reso… <sub>🪰 Gadfly · advisory</sub>
}
if strings.HasPrefix(ref, "-") {
return nil, "", fmt.Errorf("skillpack: git ref must not start with '-': %q", ref)
}
Outdated
Review

🔴 Git command injection via ref in git checkout — no -- separator before user-controlled ref

security · flagged by 1 model

🪰 Gadfly · advisory

🔴 **Git command injection via ref in git checkout — no -- separator before user-controlled ref** _security · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
tmp, err := os.MkdirTemp("", "skillpack-git-*")
if err != nil {
return nil, "", err
}
defer os.RemoveAll(tmp)
if _, err := g.run(ctx, "", "clone", "--quiet", g.URL, tmp); err != nil {
// --filter=blob:none: a blobless partial clone gets the ref graph cheaply
// and fetches only the blobs the checkout needs — much less than the full
// history, while still supporting an arbitrary commit-ish ref. "--" ends
// option parsing before the URL.
if _, err := g.run(ctx, "", "clone", "--quiet", "--filter=blob:none", "--", g.URL, tmp); err != nil {
Outdated
Review

🟠 GitSource subpath validation does not account for symlinks within subpath

security · flagged by 1 model

  • skillpack/source.go:113-121 — Subpath validation occurs after path.Clean, but symlink in subpath target not checked (MEDIUM, verified) The GitSource.Fetch validates that the subpath doesn't escape via within(tmp, root), but this only checks the path string. If the subpath directory itself contains symlinks (e.g., a data -> /etc symlink inside the valid subpath), those are followed by readTree. Combined with the symlink issue above, this allows reading files outside the repo.…

🪰 Gadfly · advisory

🟠 **GitSource subpath validation does not account for symlinks within subpath** _security · flagged by 1 model_ - **`skillpack/source.go:113-121` — Subpath validation occurs after `path.Clean`, but symlink in subpath target not checked (MEDIUM, verified)** The `GitSource.Fetch` validates that the subpath doesn't escape via `within(tmp, root)`, but this only checks the *path string*. If the subpath directory itself contains symlinks (e.g., a `data -> /etc` symlink inside the valid subpath), those are followed by `readTree`. Combined with the symlink issue above, this allows reading files outside the repo.… <sub>🪰 Gadfly · advisory</sub>
return nil, "", err
}
if ref != "" {
if _, err := g.run(ctx, tmp, "checkout", "--quiet", ref); err != nil {
if _, err := g.run(ctx, tmp, "checkout", "--quiet", "--detach", ref); err != nil {
return nil, "", err
}
}
+3 -2
View File
@@ -50,9 +50,10 @@ func (s *Subscription) HasPending() bool {
}
// pinTo advances the active pin to a fetched pack and clears any pending state.
// Used by initial pin and by Apply.
// Used by initial pin and by Apply. It does NOT set Name: a subscription's name
// is its stable host handle, fixed at Subscribe time — letting an upstream pack
// rename move it would silently collide with another subscription on Apply.
func (s *Subscription) pinTo(p *Pack, sourceRef, by string, now time.Time) {
s.Name = p.Manifest.Name
s.Description = p.Manifest.Description
s.PinnedDigest = p.Digest
s.PinnedSourceRef = sourceRef
+20 -8
View File
@@ -2,6 +2,7 @@ package skillpack
import (
"context"
"errors"
"fmt"
"time"
@@ -14,7 +15,7 @@ import (
// — the only call that changes the bytes an agent runs is Apply, always with an
// actor recorded.
Review

🟠 Syncer field Store has type PackCache, colliding with Store interface name

maintainability · flagged by 4 models

  • skillpack/sync.go:16-18Syncer has a field named Store whose type is PackCache, not Store. In the same package there is a Store interface (for subscriptions), so every reader will assume y.Store is subscription metadata storage. The comment on the field even says "content store for pinned trees", but the name collision makes the code harder to read and easier to misuse. Rename the field to Cache to match its type.

🪰 Gadfly · advisory

🟠 **Syncer field Store has type PackCache, colliding with Store interface name** _maintainability · flagged by 4 models_ - `skillpack/sync.go:16-18` — `Syncer` has a field named `Store` whose type is `PackCache`, not `Store`. In the same package there is a `Store` interface (for subscriptions), so every reader will assume `y.Store` is subscription metadata storage. The comment on the field even says "content store for pinned trees", but the name collision makes the code harder to read and easier to misuse. Rename the field to `Cache` to match its type. <sub>🪰 Gadfly · advisory</sub>
type Syncer struct {
Store PackCache // content store for pinned trees
Cache PackCache // content store for pinned trees
Subs Store // subscription metadata store
// SourceFor builds the Source for a stored subscription. A host overrides
@@ -73,7 +74,7 @@ func (y *Syncer) fetchPack(ctx context.Context, src Source, ref string) (*Pack,
if err != nil {
return nil, "", err
}
Review

🟠 fetchPack unconditionally re-caches the pack tree on every Check/CheckAll tick, deep-copying identical bytes via MemoryPackCache.Put even when the digest is unchanged

performance · flagged by 1 model

  • skillpack/sync.go:76 (fetchPack) — every Check re-caches the pack tree even when nothing changed. fetchPack unconditionally calls y.Store.Put(ctx, pack.Digest, pack.Tree) at line 76. Check calls fetchPack on every tick (line 126), and the digest comparison that would skip work happens after the Put (line 130). MemoryPackCache.Put (skillpack/memory.go:92) then cloneTrees the entire tree — a fresh map plus a new byte slice per file — overwriting the identical entry. S…

🪰 Gadfly · advisory

🟠 **fetchPack unconditionally re-caches the pack tree on every Check/CheckAll tick, deep-copying identical bytes via MemoryPackCache.Put even when the digest is unchanged** _performance · flagged by 1 model_ - **`skillpack/sync.go:76` (`fetchPack`) — every `Check` re-caches the pack tree even when nothing changed.** `fetchPack` unconditionally calls `y.Store.Put(ctx, pack.Digest, pack.Tree)` at line 76. `Check` calls `fetchPack` on every tick (line 126), and the digest comparison that would skip work happens *after* the `Put` (line 130). `MemoryPackCache.Put` (`skillpack/memory.go:92`) then `cloneTree`s the entire tree — a fresh map plus a new byte slice per file — overwriting the identical entry. S… <sub>🪰 Gadfly · advisory</sub>
if err := y.Store.Put(ctx, pack.Digest, pack.Tree); err != nil {
if err := y.Cache.Put(ctx, pack.Digest, pack.Tree); err != nil {
return nil, "", err
}
return pack, sourceRef, nil
@@ -87,21 +88,32 @@ func (y *Syncer) Subscribe(ctx context.Context, src Source, trackRef, by string)
if err != nil {
return nil, err
}
Review

🔴 GetByName error treated as 'name available' — any non-ErrNotFound error silently allows duplicate subscriptions

correctness, error-handling · flagged by 2 models

  • skillpack/sync.go:90 — swallowed store error in the duplicate-name check. Subscribe tests for an existing subscription with if existing, err := y.Subs.GetByName(ctx, pack.Manifest.Name); err == nil { ... }. Only the err == nil branch is handled; any other error from GetByName (a transient DB failure, connection error, anything that isn't ErrNotFound) is silently dropped and execution falls through to create a new subscription. That can produce a duplicate subscription (the ve…

🪰 Gadfly · advisory

🔴 **GetByName error treated as 'name available' — any non-ErrNotFound error silently allows duplicate subscriptions** _correctness, error-handling · flagged by 2 models_ - **`skillpack/sync.go:90` — swallowed store error in the duplicate-name check.** `Subscribe` tests for an existing subscription with `if existing, err := y.Subs.GetByName(ctx, pack.Manifest.Name); err == nil { ... }`. Only the `err == nil` branch is handled; any *other* error from `GetByName` (a transient DB failure, connection error, anything that isn't `ErrNotFound`) is silently dropped and execution falls through to create a new subscription. That can produce a duplicate subscription (the ve… <sub>🪰 Gadfly · advisory</sub>
if existing, err := y.Subs.GetByName(ctx, pack.Manifest.Name); err == nil {
existing, err := y.Subs.GetByName(ctx, pack.Manifest.Name)
if err == nil {
return nil, fmt.Errorf("skillpack: already subscribed to %q (id %s)", pack.Manifest.Name, existing.ID)
}
Review

🟡 Subscribe has awkward URL assignment that overwrites src.String() for GitSource

maintainability · flagged by 2 models

  • skillpack/sync.go:94-105 — Awkward URL handling in Subscribe. The code assigns src.String() to sub.SourceURL, then immediately overwrites it for GitSource to extract raw URL and Subpath fields. This roundabout pattern obscures intent. Suggested fix: Build the subscription's source fields directly from the concrete source type before the type switch, or have each source type expose its components via an interface method. Verified: Read sync.go lines 85-111.

🪰 Gadfly · advisory

🟡 **Subscribe has awkward URL assignment that overwrites src.String() for GitSource** _maintainability · flagged by 2 models_ - **skillpack/sync.go:94-105** — Awkward URL handling in `Subscribe`. The code assigns `src.String()` to `sub.SourceURL`, then immediately overwrites it for `GitSource` to extract raw `URL` and `Subpath` fields. This roundabout pattern obscures intent. **Suggested fix:** Build the subscription's source fields directly from the concrete source type before the type switch, or have each source type expose its components via an interface method. **Verified:** Read sync.go lines 85-111. <sub>🪰 Gadfly · advisory</sub>
if !errors.Is(err, ErrNotFound) {
// A transient store error must NOT fall through to creating a row — that
// would produce a duplicate subscription the uniqueness check missed.
return nil, fmt.Errorf("skillpack: checking for existing subscription %q: %w", pack.Manifest.Name, err)
}
sub := &Subscription{
ID: y.newID(),
Review

🟠 *GitSource type assertion only matches value type; silently drops URL/subpath for GitSource

correctness, maintainability · flagged by 2 models

  • skillpack/sync.go:102-105Subscribe does src.(GitSource) to extract the raw URL and Subpath fields. Because all GitSource methods have value receivers, *GitSource also satisfies Source; if a caller passes &GitSource{...}, the type assertion fails silently. The subscription then stores src.String() (the display form, e.g. url//subpath) as SourceURL, and later DefaultSourceFor reconstructs the source with the combined string instead of the bare URL. Handle both `GitSo…

🪰 Gadfly · advisory

🟠 **GitSource type assertion only matches value type; silently drops URL/subpath for *GitSource** _correctness, maintainability · flagged by 2 models_ - `skillpack/sync.go:102-105` — `Subscribe` does `src.(GitSource)` to extract the raw `URL` and `Subpath` fields. Because all `GitSource` methods have value receivers, `*GitSource` also satisfies `Source`; if a caller passes `&GitSource{...}`, the type assertion fails silently. The subscription then stores `src.String()` (the display form, e.g. `url//subpath`) as `SourceURL`, and later `DefaultSourceFor` reconstructs the source with the combined string instead of the bare URL. Handle both `GitSo… <sub>🪰 Gadfly · advisory</sub>
Name: pack.Manifest.Name,
SourceKind: src.Kind(),
SourceURL: src.String(),
TrackRef: trackRef,
Enabled: true,
}
// Store the raw URL/subpath (String() may combine them for display).
if gs, ok := src.(GitSource); ok {
sub.SourceURL = gs.URL
sub.Subpath = gs.Subpath
// Store the raw URL + subpath separately (String() may combine them for
// display). GitSource methods have value receivers, so a caller may pass
// either GitSource or *GitSource — handle both.
switch gs := src.(type) {
case GitSource:
sub.SourceURL, sub.Subpath = gs.URL, gs.Subpath
case *GitSource:
sub.SourceURL, sub.Subpath = gs.URL, gs.Subpath
}
sub.pinTo(pack, sourceRef, by, y.now())
if err := y.Subs.Save(ctx, sub); err != nil {
2
@@ -175,7 +187,7 @@ func (y *Syncer) Apply(ctx context.Context, id, by string) (*Subscription, error
if !sub.HasPending() {
return nil, fmt.Errorf("skillpack: %q has no pending update to apply", sub.Name)
}
tree, err := y.Store.Get(ctx, sub.PendingDigest)
tree, err := y.Cache.Get(ctx, sub.PendingDigest)
if err != nil {
return nil, fmt.Errorf("skillpack: pending tree for %q missing from cache: %w", sub.Name, err)
}
+5 -4
View File
@@ -2,6 +2,7 @@ package skillpack
import (
"context"
"fmt"
"testing"
"time"
)
@@ -27,10 +28,10 @@ func packTree(name, body string) Tree {
func newTestSyncer(src *fakeSource) *Syncer {
n := 0
return &Syncer{
Store: NewMemoryPackCache(),
Cache: NewMemoryPackCache(),
Subs: NewMemory(),
Now: func() time.Time { return time.Unix(1000, 0) },
Review

🟡 NewID test closure increments a dead counter n but always returns constant "id-1"

maintainability · flagged by 2 models

  • skillpack/sync_test.go:33NewID closure has a dead counter. n is incremented but the return is the constant "id-1", so n is never read. It reads like it's meant to produce unique IDs and silently doesn't. Either drop n and return a constant, or generate distinct ids (fmt.Sprintf("id-%d", n)). Verified at sync_test.go:27-35.

🪰 Gadfly · advisory

🟡 **NewID test closure increments a dead counter n but always returns constant "id-1"** _maintainability · flagged by 2 models_ - **`skillpack/sync_test.go:33` — `NewID` closure has a dead counter.** `n` is incremented but the return is the constant `"id-1"`, so `n` is never read. It reads like it's meant to produce unique IDs and silently doesn't. Either drop `n` and return a constant, or generate distinct ids (`fmt.Sprintf("id-%d", n)`). Verified at `sync_test.go:27-35`. <sub>🪰 Gadfly · advisory</sub>
NewID: func() string { n++; return "id-1" },
NewID: func() string { n++; return fmt.Sprintf("id-%d", n) },
SourceFor: func(*Subscription) (Source, error) { return src, nil },
}
}
@@ -51,7 +52,7 @@ func TestSubscribeAndPin(t *testing.T) {
t.Fatal("fresh subscription should have no pending update")
}
// pinned tree is cached under its digest
if _, err := y.Store.Get(ctx, sub.PinnedDigest); err != nil {
if _, err := y.Cache.Get(ctx, sub.PinnedDigest); err != nil {
t.Fatalf("pinned tree not cached: %v", err)
}
}
@@ -93,7 +94,7 @@ func TestCheck_RecordsPendingButDoesNotMovePin(t *testing.T) {
t.Errorf("pending ref = %q", updated.PendingSourceRef)
}
// the pending tree is cached, ready for Apply
if _, err := y.Store.Get(ctx, updated.PendingDigest); err != nil {
if _, err := y.Cache.Get(ctx, updated.PendingDigest); err != nil {
t.Fatalf("pending tree not cached: %v", err)
}
}