Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,63 @@ Before committing: run `task lint-fix` then `task test`.

Pull requests should NOT include a test plan section.

## Benchmarks

Naming convention: `Benchmark{Component}_{Operation}_{Scale}` (e.g., `BenchmarkSlugSearchCache_Search_500k`)

All benchmarks must:
- Call `b.ReportAllocs()` for allocation tracking
- Use `b.Run()` subtests for scale tiers or variants
- Set up data before `b.ResetTimer()`
- Use `for b.Loop()` iteration pattern

```bash
task bench # Run all benchmarks
task bench-db # Run database benchmarks only
task bench-baseline # Generate baseline (commit the output)
task bench-compare # Compare current vs baseline via benchstat
```

Optimization targets and thresholds: [docs/optimization-targets.md](docs/optimization-targets.md)

## Background Agent Mode

When running as a background agent (scheduled, headless, or autonomous):

### Always Allowed
- Run `task test`, `task lint`, `task vulncheck`, `task nilcheck`, `task deadlock`
- Run `task bench` and `task bench-compare`
- Read any file, run `go vet`, analyze code
- Report findings as GitHub issues with `agent-finding` label
- Run `task fuzz` with default time limits

### Create PR with Evidence (Human Review Required)
- Performance optimizations — must include before/after benchstat output in PR description
- Refactoring that changes function signatures or public API
- Adding new dependencies
- Changes to security-sensitive files:
- `pkg/api/middleware/` (auth)
- `pkg/zapscript/utils.go` (command execution)
- `pkg/readers/shared/ndef/` (untrusted input parsing)
- `pkg/config/auth.go` (auth config)
- Database schema changes or migrations

### Never
- Modify tests to make failing code pass (fix the code, not the test)
- Remove or weaken linter rules
- Add `nolint` directives without justification
- Disable security checks (gosec, govulncheck)
- Change the `forbidigo` rules for sync.Mutex/RWMutex
- Modify CI workflow files
- Push directly to main
- Change benchmark baselines without human review

### Reporting Format
Title: `[agent:{type}] {summary}` — types: security, perf, quality
Body: evidence, affected files, proposed fix, risk assessment
Label: `agent-finding`
For perf findings: include benchstat comparison

## When Stuck

Don't guess — ask for help or gather more information first.
Expand Down
26 changes: 26 additions & 0 deletions Taskfile.dist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,32 @@ tasks:
cmds:
- go-mutesting {{.CLI_ARGS}}

# Benchmark workflow:
# 1. task bench-baseline (generates testdata/benchmarks/baseline.txt, commit this)
# 2. make changes
# 3. task bench-compare (compares current vs committed baseline via benchstat)
# Quick runs: task bench (count=3) or task bench-db (database only)
bench:
desc: Run all benchmarks
cmds:
- go test -bench=. -benchmem -count=3 -timeout=30m ./...

bench-db:
desc: Run database benchmarks only
cmds:
- go test -bench=. -benchmem -count=3 -timeout=20m ./pkg/database/...

bench-baseline:
desc: Generate benchmark baseline (commit the output)
cmds:
- go test -bench=. -benchmem -count=6 -timeout=45m ./... | tee testdata/benchmarks/baseline.txt

bench-compare:
desc: Compare current benchmarks against baseline (requires benchstat, install with go install golang.org/x/perf/cmd/benchstat@latest)
cmds:
- go test -bench=. -benchmem -count=6 -timeout=45m ./... > /tmp/zaparoo-bench-current.txt
- benchstat testdata/benchmarks/baseline.txt /tmp/zaparoo-bench-current.txt

get-logs:
desc: Download and decode logs from Zaparoo API
vars:
Expand Down
40 changes: 40 additions & 0 deletions docs/optimization-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Optimization Targets

Measurable performance targets for zaparoo-core. Background agents should use these as benchmarks for optimization work.

## Critical Path: Token Scan to Launch

Target: < 50ms from NFC scan to launcher invocation (software path, excluding launcher startup)

Components: NDEF parse -> mapping match -> ZapScript parse -> command dispatch -> title resolve -> launch

## Slug Search Cache - Search

Target at 500k entries: < 100ms per search query (single word)
Target at 1M entries: < 250ms per search query (single word)
Currently: linear scan with bytes.Contains(), O(n) per query

## Slug Search Cache - Build

Target at 500k entries: < 5s cache construction from DB
Target at 1M entries: < 15s cache construction from DB

## Slug Search Cache - Memory

Target at 500k entries: < 30MB heap
Target at 1M entries: < 60MB heap

## Fuzzy Matching

Target at 500k candidates: < 500ms per query
Currently: O(n*m) Jaro-Winkler, length pre-filter eliminates ~70-80%

## Media Indexing

Target: Index 500k files in < 60s
Components: fastwalk -> filename parsing -> slugification -> tag extraction -> DB batch insert

## Memory Budget (Total)

Idle with 500k collection: < 100MB total heap
During re-index of 500k: < 200MB peak heap
17 changes: 17 additions & 0 deletions docs/prompts/dependency-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Audit all direct dependencies for security, freshness, and maintenance status.

1. List all direct dependencies from go.mod
2. For each dependency, check:
- Latest available version vs current version
- Last commit date on the main repository
- Any known CVEs not yet in the Go vulnerability database
- Whether the module has been retracted or deprecated
3. Flag dependencies with:
- No commits in 2+ years (potentially abandoned)
- Major version behind latest
- Known security advisories
4. Run `task vulncheck` and report any findings
5. Check `go mod tidy` for drift: `go mod tidy && git diff --exit-code go.mod go.sum`
6. Review indirect dependencies for any that should be pinned directly

Report findings as a table: dependency, current version, latest version, last activity, status (ok/warn/critical).
19 changes: 19 additions & 0 deletions docs/prompts/optimization-scan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Run benchmarks and identify optimization opportunities in the database and search layer.

1. Run `task bench` and capture full output
2. Run `task bench-compare` to compare against stored baseline
3. Identify any regressions >5% in ns/op, B/op, or allocs/op
4. Profile allocation hotspots: focus on functions with highest allocs/op
5. Check for optimization opportunities in priority order:
- SlugSearchCache: bytes.Contains() linear scan, system filter short-circuiting
- Fuzzy matching: Jaro-Winkler per-call cost, length pre-filter effectiveness
- Slug generation: regex allocations, Unicode normalization
- Indexing pipeline: tag extraction regex, batch transaction overhead
6. For any proposed optimization:
- Make targeted changes (one optimization per commit)
- Re-run benchmarks with `go test -bench=BenchmarkAffected -benchmem -count=6`
- Only commit if >10% improvement with no regressions elsewhere
- Include before/after benchstat output in commit message
7. Report findings summary with affected files and measured impact

Reference: docs/optimization-targets.md for target thresholds
26 changes: 26 additions & 0 deletions docs/prompts/security-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Perform a security audit of zaparoo-core focusing on areas static analysis misses.

1. **Execute command allowlist audit**: Trace all paths to `exec.CommandContext` in `pkg/zapscript/utils.go`. Verify `IsExecuteAllowed()` gate is always hit. Confirm no bypass paths exist.

2. **Auth middleware coverage**: Trace the chi router setup in `pkg/api/server.go`. Verify no API endpoints skip authentication. Check WebSocket auth handler covers upgrade path.

3. **WebSocket message size limits**: Verify melody config in `pkg/api/server.go` prevents memory exhaustion from oversized messages. Check MaxMessageSize and WriteBufferSize settings.

4. **NDEF parser boundary analysis**: Run extended fuzzing on the 5 fuzz functions in `pkg/readers/shared/ndef/parser_fuzz_test.go`:
```
go test -run "^$" -fuzz=FuzzParseToText -fuzztime=5m ./pkg/readers/shared/ndef/
go test -run "^$" -fuzz=FuzzValidateNDEFMessage -fuzztime=5m ./pkg/readers/shared/ndef/
go test -run "^$" -fuzz=FuzzExtractTLVPayload -fuzztime=5m ./pkg/readers/shared/ndef/
go test -run "^$" -fuzz=FuzzParseTextPayload -fuzztime=5m ./pkg/readers/shared/ndef/
go test -run "^$" -fuzz=FuzzParseURIPayload -fuzztime=5m ./pkg/readers/shared/ndef/
```

5. **nolint:gosec directive audit**: Find all `nolint:gosec` directives and verify each is still justified. Flag any that suppress warnings about user-controlled input.

6. **Mapping regex safety**: Verify `pkg/database/userdb/mappings.go` only uses Go's `regexp` package (linear-time, safe from ReDoS). Confirm no switch to a different regex engine.

7. **Config path handling**: Check TOML config file path handling in `pkg/config/` can't be used to escape expected directories via path traversal.

8. Run `task vulncheck` for known CVEs.

Report each finding with: severity, affected file(s), evidence, and recommended fix.
22 changes: 22 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1118,3 +1118,25 @@ func TestSave_OmitsGamepadEnabledWhenNil(t *testing.T) {
assert.NotContains(t, content, "gamepad_enabled", "gamepad_enabled should not be in config when nil")
assert.NotContains(t, content, "[input]", "input section should not be in config when all fields nil")
}

func BenchmarkConfig_Read(b *testing.B) {
b.ReportAllocs()
cfg := &Instance{vals: BaseDefaults}
b.ResetTimer()
for b.Loop() {
_ = cfg.IsExecuteAllowed("some-command")
}
}

func BenchmarkConfig_Load(b *testing.B) {
b.ReportAllocs()
tempDir := b.TempDir()
cfg, err := NewConfig(tempDir, BaseDefaults)
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for b.Loop() {
_ = cfg.Load()
}
}
124 changes: 124 additions & 0 deletions pkg/database/matcher/fuzzy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@
package matcher

import (
"fmt"
"math/rand"
"strings"
"testing"

"github.com/ZaparooProject/zaparoo-core/v2/pkg/database/slugs"
"github.com/hbollon/go-edlib"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -482,3 +487,122 @@ func TestFindFuzzyMatches_MultipleSimilarCandidates(t *testing.T) {
t.Logf("Found %d matches", len(matches))
})
}

// buildSyntheticCandidates generates n deterministic game-title-like slugs
// using a fixed seed for reproducible benchmarks.
func buildSyntheticCandidates(n int) []string {
words := []string{
"super", "mario", "zelda", "sonic", "metroid", "castlevania",
"mega", "man", "final", "fantasy", "dragon", "quest", "street",
"fighter", "mortal", "kombat", "donkey", "kong", "kirby", "star",
"fox", "fire", "emblem", "pokemon", "contra", "ninja", "gaiden",
}

//nolint:gosec // Deterministic seed for reproducible benchmarks
rng := rand.New(rand.NewSource(42))
candidates := make([]string, n)

for i := range n {
// Build slug from 2-4 random words
wordCount := 2 + rng.Intn(3)
parts := make([]string, wordCount)
for w := range wordCount {
parts[w] = words[rng.Intn(len(words))]
}
// Append unique suffix to avoid duplicates
candidates[i] = fmt.Sprintf("%s-%d", strings.Join(parts, "-"), i)
}

return candidates
}

func BenchmarkFindFuzzyMatches_500k(b *testing.B) {
b.ReportAllocs()
candidates := buildSyntheticCandidates(500_000)
query := "supermariobros"
b.ResetTimer()
for b.Loop() {
FindFuzzyMatches(query, candidates, 2, 0.85)
}
}

func BenchmarkFindFuzzyMatches_1M(b *testing.B) {
b.ReportAllocs()
candidates := buildSyntheticCandidates(1_000_000)
query := "supermariobros"
b.ResetTimer()
for b.Loop() {
FindFuzzyMatches(query, candidates, 2, 0.85)
}
}

func BenchmarkFindFuzzyMatches_LengthPreFilter(b *testing.B) {
candidates := buildSyntheticCandidates(500_000)
query := "supermariobros"

b.Run("maxDistance=3", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
FindFuzzyMatches(query, candidates, 3, 0.85)
}
})

b.Run("maxDistance=10", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
FindFuzzyMatches(query, candidates, 10, 0.85)
}
})
}

func BenchmarkFindFuzzyMatches_NoMatch_500k(b *testing.B) {
b.ReportAllocs()
candidates := buildSyntheticCandidates(500_000)
query := "zzzzzzzzzznotarealname"
b.ResetTimer()
for b.Loop() {
FindFuzzyMatches(query, candidates, 2, 0.85)
}
}

func BenchmarkJaroWinkler_Similarity(b *testing.B) {
b.ReportAllocs()
s1 := "supermariobros"
s2 := "supermraiobrso"
b.ResetTimer()
for b.Loop() {
edlib.JaroWinklerSimilarity(s1, s2)
}
}

func BenchmarkGenerateTokenSignature_500k(b *testing.B) {
b.ReportAllocs()

words := []string{
"Super", "Mario", "Zelda", "Sonic", "Metroid", "Castlevania",
"Mega", "Man", "Final", "Fantasy", "Dragon", "Quest", "Street",
"Fighter", "Mortal", "Kombat", "Donkey", "Kong", "Kirby", "Star",
"Fox", "Fire", "Emblem", "Pokemon", "Contra", "Ninja", "Gaiden",
}

//nolint:gosec // Deterministic seed for reproducible benchmarks
rng := rand.New(rand.NewSource(42))
titles := make([]string, 500_000)
for i := range 500_000 {
wordCount := 2 + rng.Intn(3)
parts := make([]string, wordCount)
for w := range wordCount {
parts[w] = words[rng.Intn(len(words))]
}
titles[i] = strings.Join(parts, " ")
}

b.ResetTimer()
for b.Loop() {
for _, title := range titles {
GenerateTokenSignature(slugs.MediaTypeGame, title)
}
}
}
23 changes: 23 additions & 0 deletions pkg/database/mediadb/slug_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,29 @@ import (
"github.com/stretchr/testify/assert"
)

func BenchmarkGenerateSlugWithMetadata(b *testing.B) {
cases := []struct {
name string
input string
}{
{"Latin_simple", "Super Mario Bros"},
{"Latin_complex", "The Legend of Zelda: Ocarina of Time (USA) [!]"},
{"CJK_Japanese", "ゼルダの伝説 時のオカリナ"}, //nolint:gosmopolitan // CJK benchmark
{"CJK_Chinese", "最终幻想七 重制版"}, //nolint:gosmopolitan // CJK benchmark
{"With_secondary", "The Legend of Zelda: Ocarina of Time"},
{"Long_ROM", "Shin Megami Tensei - Digital Devil Saga 2 - Avatar Tuner (Japan) (Rev 1) (Disc 1 of 2)"},
}

for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
GenerateSlugWithMetadata(slugs.MediaTypeGame, tc.input)
}
})
}
}

func TestSlugMetadata(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading