JZRF7OBJNERB4NIB37RSAF3ZK2A4RBWSCFV5OCRXZYVGPSNOWKTAC AVQ66WO4R4KVXAVP4YPEF65CPHJJY55H7ZOVPZ2BHFMGEBTWRUQQC KZKLAINJJWZ64T5MUZT34LJVQIKBTKZ6EJGD7C7TTSSDGCHEDPMAC LQLC7S3ADBR4O2JYVUSQJD65U3HG4ADOQBGB4F7KQCXUMNKMNEKAC 2P27XV3DGJCRA4SNJENCJYZLPR2XWZMTY7CGYYSJOY4UMDVVO25AC RUVJ3V4N5V4Z3HSH2YYESKQF5G7RIHBFB5TLV2IPDWXSGJDRD54AC WKQ7LFTPDGWTPJKRWB6DH5PUCX2HF34UCGJDIPYC5PTDX4MCZJXAC KLUEQ6X5CXVBV3KLJKEHWQYHIU6AYPP2WT4PWKM2QZJ7SNACCJ6QC VYNOHQJWFL6ZEKFLASJAZZIHN4S3NJJECKANT7JNSXPJN3KC2DJAC package utilsimport "strings"// Placeholders generates SQL placeholder string for IN clauses (e.g. "?, ?, ?")func Placeholders(n int) string {if n == 0 {return ""}ph := make([]string, n)for i := range ph {ph[i] = "?"}return strings.Join(ph, ", ")}
query := `SELECT label FROM species WHERE label IN (` + db.Placeholders(len(speciesLabels)) + `) AND active = true`
query := `SELECT label FROM species WHERE label IN (` + Placeholders(len(speciesLabels)) + `) AND active = true`
package utilsimport ("go/parser""go/token""os/exec""strings""testing")// TestPackageDependencies enforces the project's package dependency rules// defined in CLAUDE.md. Packages may only import packages below them://// cmd → tools, tui, utils, db// tools → utils, db// tui → tools, utils// utils → (nothing — leaf package)// db → utils//// Note: This test lives in utils/ only because it needs to be in some// package. It validates rules for ALL project packages.func TestPackageDependencies(t *testing.T) {rules := map[string]map[string]bool{// pkg → set of allowed skraak-internal imports"skraak/cmd": {"skraak/tools": true, "skraak/tui": true, "skraak/utils": true, "skraak/db": true},"skraak/tools": {"skraak/utils": true, "skraak/db": true},"skraak/tui": {"skraak/tools": true, "skraak/utils": true},"skraak/utils": {}, // leaf package — no skraak imports allowed"skraak/db": {"skraak/utils": true},}for pkg, allowed := range rules {imports := getInternalImports(t, pkg)for _, imp := range imports {if !allowed[imp] {t.Errorf("%s imports %s — not allowed by dependency rules", pkg, imp)}}}}// TestNoDirectDBImportInUtils checks that no file in utils/ imports skraak/db.// This is a fast source-level check that doesn't require `go list`.func TestNoDirectDBImportInUtils(t *testing.T) {fset := token.NewFileSet()pkgs, err := parser.ParseDir(fset, ".", nil, parser.ImportsOnly)if err != nil {t.Fatalf("parse utils/: %v", err)}for _, pkg := range pkgs {for filename, file := range pkg.Files {for _, imp := range file.Imports {path := strings.Trim(imp.Path.Value, `"`)if path == "skraak/db" {t.Errorf("%s: forbidden import of skraak/db (utils is the leaf package)", filename)}}}}}// getInternalImports returns the skraak-internal imports for a package using `go list`.func getInternalImports(t *testing.T, pkg string) []string {t.Helper()out, err := exec.Command("go", "list", "-f", "{{range .Imports}}{{.}}\n{{end}}", pkg).Output()if err != nil {t.Fatalf("go list %s: %v", pkg, err)}var internal []stringfor line := range strings.SplitSeq(string(out), "\n") {line = strings.TrimSpace(line)if strings.HasPrefix(line, "skraak/") || line == "skraak" {internal = append(internal, line)}}return internal}
// ImportCluster imports all WAV files from a folder into a cluster
// ImportCluster imports all WAV files from a folder into a cluster.// The caller must provide an open transaction via tx; this function does NOT// commit or rollback — the caller owns the transaction lifecycle.
{"low", db.GainLow},{"low-medium", db.GainLowMedium},{"medium", db.GainMedium},{"medium-high", db.GainMediumHigh},{"high", db.GainHigh},
{"low", GainLow},{"low-medium", GainLowMedium},{"medium", GainMedium},{"medium-high", GainMediumHigh},{"high", GainHigh},
{"low", db.GainLow, false},{"LOW", db.GainLow, false},{" low ", db.GainLow, false},{"low-medium", db.GainLowMedium, false},{"medium", db.GainMedium, false},{"medium-high", db.GainMediumHigh, false},{"high", db.GainHigh, false},
{"low", GainLow, false},{"LOW", GainLow, false},{" low ", GainLow, false},{"low-medium", GainLowMedium, false},{"medium", GainMedium, false},{"medium-high", GainMediumHigh, false},{"high", GainHigh, false},
clusterOutput, err := utils.ImportCluster(database, utils.ClusterImportInput{
tx, err := db.BeginLoggedTx(ctx, database, "import_audio_files")if err != nil {return output, fmt.Errorf("failed to begin transaction: %w", err)}clusterOutput, err := utils.ImportCluster(database, tx.UnderlyingTx(), utils.ClusterImportInput{
clusterOutput, err := utils.ImportCluster(database, utils.ClusterImportInput{
ctx := context.Background()tx, err := db.BeginLoggedTx(ctx, database, "import_audio_files")if err != nil {return nil, fmt.Errorf("failed to begin transaction: %w", err)}clusterOutput, err := utils.ImportCluster(database, tx.UnderlyingTx(), utils.ClusterImportInput{
GainLow GainLevel = "low"GainLowMedium GainLevel = "low-medium"GainMedium GainLevel = "medium"GainMediumHigh GainLevel = "medium-high"GainHigh GainLevel = "high"
GainLow = utils.GainLowGainLowMedium = utils.GainLowMediumGainMedium = utils.GainMediumGainMediumHigh = utils.GainMediumHighGainHigh = utils.GainHigh
- **`cmd/*.go`** - CLI commands (parse flags, call tools, print JSON)
- **`tui/`** - TUI (interactive classify UI)- **`utils/`** - Reusable helpers (no MCP types, no `*Input`/`*Output` structs, no `db` import)- **`db/`** - Database connection, types, transactions (may import `utils/`)`utils/` is the leaf package — it must not import `cmd/`, `tools/`, `tui/`, or `db/`.
## [2026-05-07] Remove utils → db package dependencyThree changes to eliminate the `utils → db` import edge, restoring clean layering(`cmd → tools → utils` and `cmd → db`, with no upward dependency from utils):- **Moved `Placeholders()` from `db/` to `utils/`**: It's a pure string function(generates `?, ?, ?` for SQL IN clauses) with no database dependency. `db.Placeholders`now delegates to `utils.Placeholders` for backward compatibility.
- **Moved `GainLevel` type and constants from `db/` to `utils/`**: It's a string enumthat naturally belongs next to `AudioMothData` in the audiomoth parser. `db.GainLevel`is now a type alias (`type GainLevel = utils.GainLevel`) and the constants arere-exported for backward compatibility.- **Refactored `ImportCluster` to accept `*sql.Tx` instead of managing its owntransaction**: The caller (tools/) now opens the `LoggedTx`, passes the underlying`*sql.Tx` via `UnderlyingTx()`, and manages commit/rollback. This removes`db.BeginLoggedTx`, `db.LoggedTx`, and `db.LoggedStmt` from utils entirely.Added `UnderlyingTx()` method to `db.LoggedTx` to expose the raw `*sql.Tx`.- **Extended `DB` interface** with `Exec()`: `EnsureClusterPath` needed `Exec` whichwas missing from the original `DB` interface. Both `*sql.DB` and `*sql.Tx` satisfythe updated interface.Result: `utils/` no longer imports `skraak/db`. The dependency now flows correctly:`db → utils` (for Placeholders and GainLevel re-exports).