refactor to get db omports out of utils, but still have failing tests, may need updating
Dependencies
- [2]
AVQ66WO4tools/ refactor - [3]
JAT3DXOLcyclo over 15 - [4]
QVIGQOQZmore work on utils/ with glm - [5]
LQLC7S3Atrying gemini: Inconsistent Standards in @utils/ refactoring - [6]
LBWQJEDHminor refactor and more tests for utils/ - [7]
VYNOHQJWtidied up CLAUDE.md - [8]
RUVJ3V4Ncyclo to 14 now - [9]
KZKLAINJrun out of space on nest, cleaned out - [10]
KLUEQ6X5cyclo 21+ - [11]
WKQ7LFTPrefactor of utils/ - [12]
2P27XV3Dfixed cyclo over 30
Change contents
- file addition: placeholders.go[3.1]
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, ", ")} - edit in utils/mapping.go at line 9
"skraak/db" - replacement in utils/mapping.go at line 190
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` - replacement in utils/mapping.go at line 235
WHERE s.label = ? AND ct.label IN (` + db.Placeholders(len(ctLabels)) + `) AND ct.active = true`WHERE s.label = ? AND ct.label IN (` + Placeholders(len(ctLabels)) + `) AND ct.active = true` - edit in utils/file_import.go at line 142
Exec(query string, args ...any) (sql.Result, error) - file addition: deps_test.go[3.1]
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} - edit in utils/cluster_import.go at line 10
"skraak/db" - replacement in utils/cluster_import.go at line 42
// 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. - replacement in utils/cluster_import.go at line 60
// 5. Batch insert in single transaction:// 5. Batch insert using the provided transaction: - edit in utils/cluster_import.go at line 65
// - All-or-nothing commit - edit in utils/cluster_import.go at line 66
//// Transaction behavior: ALL files succeed or ALL rollback// This preserves cluster integrity (cluster = complete recording session) - replacement in utils/cluster_import.go at line 67
database *sql.DB,database DB,tx *sql.Tx, - replacement in utils/cluster_import.go at line 112
// Batch insert into database// Batch insert into database using the provided transaction - replacement in utils/cluster_import.go at line 114
database,tx, - replacement in utils/cluster_import.go at line 150
func GetLocationData(database *sql.DB, locationID string) (*LocationData, error) {func GetLocationData(database DB, locationID string) (*LocationData, error) { - replacement in utils/cluster_import.go at line 165
func EnsureClusterPath(database *sql.DB, clusterID, folderPath string) error {func EnsureClusterPath(database DB, clusterID, folderPath string) error { - replacement in utils/cluster_import.go at line 339
tx *db.LoggedTx,tx *sql.Tx, - replacement in utils/cluster_import.go at line 341
fileStmt, datasetStmt, mothStmt *db.LoggedStmt,fileStmt, datasetStmt, mothStmt *sql.Stmt, - replacement in utils/cluster_import.go at line 395
fileStmt *db.LoggedStmtdatasetStmt *db.LoggedStmtmothStmt *db.LoggedStmtfileStmt *sql.StmtdatasetStmt *sql.StmtmothStmt *sql.Stmt - replacement in utils/cluster_import.go at line 401
func prepareClusterStmts(ctx context.Context, tx *db.LoggedTx) (*clusterStmts, error) {func prepareClusterStmts(ctx context.Context, tx *sql.Tx) (*clusterStmts, error) { - replacement in utils/cluster_import.go at line 444
// insertClusterFiles inserts all file data into database in a single transaction// insertClusterFiles inserts all file data into database using the provided transaction.// The caller is responsible for committing or rolling back the transaction. - replacement in utils/cluster_import.go at line 447
database *sql.DB,tx *sql.Tx, - edit in utils/cluster_import.go at line 452
tx, err := db.BeginLoggedTx(ctx, database, "import_audio_files")if err != nil {return 0, 0, nil, fmt.Errorf("failed to begin transaction: %w", err)}defer tx.Rollback() - edit in utils/cluster_import.go at line 474[3.186466]→[3.186466:186470](∅→∅),[3.186470]→[3.10239:10276](∅→∅),[3.10276]→[3.186529:186601](∅→∅),[3.186529]→[3.186529:186601](∅→∅)
}if err := tx.Commit(); err != nil {return 0, 0, errors, fmt.Errorf("transaction commit failed: %w", err) - edit in utils/audiomoth_parser_test.go at line 4
"skraak/db" - replacement in utils/audiomoth_parser_test.go at line 78
if result.Gain != db.GainMedium {t.Errorf("Gain incorrect: got %s, want %s", result.Gain, db.GainMedium)if result.Gain != GainMedium {t.Errorf("Gain incorrect: got %s, want %s", result.Gain, GainMedium) - replacement in utils/audiomoth_parser_test.go at line 118
if result.Gain != db.GainHigh {t.Errorf("Gain incorrect: got %s, want %s", result.Gain, db.GainHigh)if result.Gain != GainHigh {t.Errorf("Gain incorrect: got %s, want %s", result.Gain, GainHigh) - replacement in utils/audiomoth_parser_test.go at line 132
expected db.GainLevelexpected GainLevel - replacement in utils/audiomoth_parser_test.go at line 134
{"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}, - replacement in utils/audiomoth_parser_test.go at line 180
expected db.GainLevelexpected GainLevel - replacement in utils/audiomoth_parser_test.go at line 183
{"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}, - replacement in utils/audiomoth_parser_test.go at line 316
if result.Gain != db.GainMedium {t.Errorf("Gain should be normalized: got %s, want %s", result.Gain, db.GainMedium)if result.Gain != GainMedium {t.Errorf("Gain should be normalized: got %s, want %s", result.Gain, GainMedium) - edit in utils/audiomoth_parser.go at line 9
)// GainLevel represents the gain_level enum for AudioMoth recordingstype GainLevel string - replacement in utils/audiomoth_parser.go at line 14
"skraak/db"// AudioMoth gain level enum constantsconst (GainLow GainLevel = "low"GainLowMedium GainLevel = "low-medium"GainMedium GainLevel = "medium"GainMediumHigh GainLevel = "medium-high"GainHigh GainLevel = "high" - replacement in utils/audiomoth_parser.go at line 27
Gain db.GainLevelGain GainLevel - replacement in utils/audiomoth_parser.go at line 214
func parseGainLevel(gainStr string) (db.GainLevel, error) {func parseGainLevel(gainStr string) (GainLevel, error) { - replacement in utils/audiomoth_parser.go at line 219
return db.GainLow, nilreturn GainLow, nil - replacement in utils/audiomoth_parser.go at line 221
return db.GainLowMedium, nilreturn GainLowMedium, nil - replacement in utils/audiomoth_parser.go at line 223
return db.GainMedium, nilreturn GainMedium, nil - replacement in utils/audiomoth_parser.go at line 225
return db.GainMediumHigh, nilreturn GainMediumHigh, nil - replacement in utils/audiomoth_parser.go at line 227
return db.GainHigh, nilreturn GainHigh, nil - replacement in tools/import_files.go at line 74
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{ - edit in tools/import_files.go at line 87
tx.Rollback() - edit in tools/import_files.go at line 89
}if err := tx.Commit(); err != nil {return output, fmt.Errorf("transaction commit failed: %w", err) - replacement in tools/bulk_file_import.go at line 466
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{ - edit in tools/bulk_file_import.go at line 481
tx.Rollback() - edit in tools/bulk_file_import.go at line 485
if err := tx.Commit(); err != nil {return nil, fmt.Errorf("transaction commit failed: %w", err)} - replacement in db/utils.go at line 3
import "strings"import ("skraak/utils") - replacement in db/utils.go at line 7
// Placeholders generates SQL placeholder string for IN clauses// Placeholders generates SQL placeholder string for IN clauses.// Delegates to utils.Placeholders. - replacement in db/utils.go at line 10
if n == 0 {return ""}ph := make([]string, n)for i := range ph {ph[i] = "?"}return strings.Join(ph, ", ")return utils.Placeholders(n) - edit in db/types.go at line 6
"skraak/utils" - replacement in db/types.go at line 188
// GainLevel represents the gain_level enum for AudioMoth recordingstype GainLevel string// GainLevel is re-exported from utils for backward compatibility.type GainLevel = utils.GainLevel - replacement in db/types.go at line 191
// AudioMoth gain level enum constants// Gain level constants re-exported from utils. - replacement in db/types.go at line 193
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 - edit in db/tx_logger.go at line 133
// UnderlyingTx returns the underlying *sql.Tx for use by packages that// should not import db (e.g. utils). Prefer using LoggedTx methods directly// when event logging is desired.func (l *LoggedTx) UnderlyingTx() *sql.Tx {return l.tx} - replacement in CLAUDE.md at line 13
**Simple rule:** If called by `cmd/`, it goes in `tools/`. If called by `tools/`, it goes in `utils/`.**Dependency rule:** packages may only import packages below them in this list: - replacement in CLAUDE.md at line 15
- **`utils/`** - Reusable helpers (no MCP types, no `*Input`/`*Output` structs)- **`cmd/*.go`** - CLI commands (parse flags, call tools, print JSON) - replacement in CLAUDE.md at line 17
- **`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/`. - replacement in CLAUDE.md at line 33
utils/*.go → Reusable helpersdb/ → Database connection + typestui/ → Interactive classify UIdb/ → Database connection + types + transactionsutils/*.go → Reusable helpers (leaf package, no db import) - edit in CHANGELOG.md at line 4
## [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. - edit in CHANGELOG.md at line 14
- **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).