diff --git a/CLAUDE.md b/CLAUDE.md index e69efe1..3d39ce2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,12 @@ Coverage target: maintain ≥90.9%. - UK English throughout (colour, licence, initialise) - Explicit types on all function signatures and struct fields +- Exported declarations must have Go doc comments beginning with the identifier name - `go test ./...` and `go vet ./...` must pass before commit - SPDX header on all source files: `// SPDX-Licence-Identifier: EUPL-1.2` - Error handling: all errors must use `coreerr.E(op, msg, err)` from `dappco.re/go/core/log`, never `fmt.Errorf` or `errors.New` +- Banned imports in non-test Go files: `errors`, `github.com/pkg/errors`, and legacy `forge.lthn.ai/...` paths - Conventional commits: `type(scope): description` - Co-Author trailer: `Co-Authored-By: Virgil ` + +The conventions test suite enforces banned imports, exported usage comments, and test naming via `go test ./...`. diff --git a/CODEX.md b/CODEX.md new file mode 100644 index 0000000..b5ecf8e --- /dev/null +++ b/CODEX.md @@ -0,0 +1,54 @@ +# CODEX.md + +This file provides guidance to Codex when working in this repository. + +Claude Code JSONL transcript parser, analytics engine, and HTML/video renderer. Module: `dappco.re/go/core/session` + +## Commands + +```bash +go test ./... # Run all tests +go test -v -run TestFunctionName_Context # Run single test +go test -race ./... # Race detector +go test -bench=. -benchmem ./... # Benchmarks +go vet ./... # Vet +golangci-lint run ./... # Lint (optional, config in .golangci.yml) +``` + +## Architecture + +Single-package library (`package session`) with five source files forming a pipeline: + +1. **parser.go** — Core JSONL parser. Reads Claude Code session files line-by-line (8 MiB scanner buffer), correlates `tool_use`/`tool_result` pairs via a `pendingTools` map keyed by tool ID, and produces `Session` with `[]Event`. Also handles session listing, fetching, and pruning. +2. **analytics.go** — Pure computation over `[]Event`. `Analyse()` returns `SessionAnalytics` (per-tool counts, error rates, latency stats, token estimates). No I/O. +3. **html.go** — `RenderHTML()` generates a self-contained HTML file (inline CSS/JS, dark theme, collapsible panels, client-side search). All user content is `html.EscapeString`-escaped. +4. **video.go** — `RenderMP4()` generates a VHS `.tape` script and shells out to `vhs`. Requires `vhs` on PATH. +5. **search.go** — `Search()`/`SearchSeq()` does cross-session case-insensitive substring search over tool event inputs and outputs. + +Both slice-returning and `iter.Seq` variants exist for `ListSessions`, `Search`, and `Session.EventsSeq`. + +### Adding a new tool type + +Touch all layers: add input struct in `parser.go` → case in `extractToolInput` → label in `html.go` `RenderHTML` → tape entry in `video.go` `generateTape` → tests in `parser_test.go`. + +## Testing + +Tests are white-box (`package session`). Test helpers in `parser_test.go` build synthetic JSONL in-memory — no fixture files. Use `writeJSONL(t, dir, name, lines...)` and the entry builders (`toolUseEntry`, `toolResultEntry`, `userTextEntry`, `assistantTextEntry`). + +Naming convention: `TestFunctionName_Context_Good/Bad/Ugly` (happy path / expected errors / extreme edge cases). + +Coverage target: maintain ≥90.9%. + +## Coding Standards + +- UK English throughout (colour, licence, initialise) +- Explicit types on all function signatures and struct fields +- Exported declarations must have Go doc comments beginning with the identifier name +- `go test ./...` and `go vet ./...` must pass before commit +- SPDX header on all source files: `// SPDX-Licence-Identifier: EUPL-1.2` +- Error handling: all errors must use `coreerr.E(op, msg, err)` from `dappco.re/go/core/log`, never `fmt.Errorf` or `errors.New` +- Banned imports in non-test Go files: `errors`, `github.com/pkg/errors`, and legacy `forge.lthn.ai/...` paths +- Conventional commits: `type(scope): description` +- Co-Author trailer: `Co-Authored-By: Virgil ` + +The conventions test suite enforces banned imports, exported usage comments, and test naming via `go test ./...`. diff --git a/conventions_test.go b/conventions_test.go new file mode 100644 index 0000000..9b8bdb3 --- /dev/null +++ b/conventions_test.go @@ -0,0 +1,213 @@ +// SPDX-Licence-Identifier: EUPL-1.2 +package session + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" +) + +var testNamePattern = regexp.MustCompile(`^Test[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+_(Good|Bad|Ugly)$`) + +func TestConventions_BannedImports_Good(t *testing.T) { + files := parsePackageFiles(t) + + banned := map[string]string{ + "errors": "use coreerr.E(op, msg, err) for package errors", + "github.com/pkg/errors": "use coreerr.E(op, msg, err) for package errors", + } + + for _, file := range files { + if strings.HasSuffix(file.path, "_test.go") { + continue + } + + for _, spec := range file.ast.Imports { + path := strings.Trim(spec.Path.Value, `"`) + if strings.HasPrefix(path, "forge.lthn.ai/") { + t.Errorf("%s imports %q; use dappco.re/go/core/... paths instead", file.path, path) + continue + } + if reason, ok := banned[path]; ok { + t.Errorf("%s imports %q; %s", file.path, path, reason) + } + } + } +} + +func TestConventions_TestNaming_Good(t *testing.T) { + files := parsePackageFiles(t) + + for _, file := range files { + if !strings.HasSuffix(file.path, "_test.go") { + continue + } + + for _, decl := range file.ast.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil { + continue + } + if !strings.HasPrefix(fn.Name.Name, "Test") || fn.Name.Name == "TestMain" { + continue + } + if !isTestingTFunc(fn) { + continue + } + if !testNamePattern.MatchString(fn.Name.Name) { + t.Errorf("%s contains %s; expected TestFunctionName_Context_Good/Bad/Ugly", file.path, fn.Name.Name) + } + } + } +} + +func TestConventions_UsageComments_Good(t *testing.T) { + files := parsePackageFiles(t) + + for _, file := range files { + if strings.HasSuffix(file.path, "_test.go") { + continue + } + + for _, decl := range file.ast.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + if d.Recv != nil || !d.Name.IsExported() { + continue + } + if !hasDocPrefix(commentText(d.Doc), d.Name.Name) { + t.Errorf("%s: exported function %s needs a usage comment starting with %s", file.path, d.Name.Name, d.Name.Name) + } + case *ast.GenDecl: + for i, spec := range d.Specs { + switch s := spec.(type) { + case *ast.TypeSpec: + if !s.Name.IsExported() { + continue + } + if !hasDocPrefix(commentText(typeDocGroup(d, s, i)), s.Name.Name) { + t.Errorf("%s: exported type %s needs a usage comment starting with %s", file.path, s.Name.Name, s.Name.Name) + } + case *ast.ValueSpec: + doc := valueDocGroup(d, s, i) + for _, name := range s.Names { + if !name.IsExported() { + continue + } + if !hasDocPrefix(commentText(doc), name.Name) { + t.Errorf("%s: exported declaration %s needs a usage comment starting with %s", file.path, name.Name, name.Name) + } + } + } + } + } + } + } +} + +type parsedFile struct { + path string + ast *ast.File +} + +func parsePackageFiles(t *testing.T) []parsedFile { + t.Helper() + + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", nil, parser.ParseComments) + if err != nil { + t.Fatalf("parse package: %v", err) + } + + pkg, ok := pkgs["session"] + if !ok { + t.Fatal("package session not found") + } + + paths := filePaths(pkg.Files) + slices.Sort(paths) + files := make([]parsedFile, 0, len(paths)) + for _, path := range paths { + files = append(files, parsedFile{ + path: filepath.Base(path), + ast: pkg.Files[path], + }) + } + return files +} + +func filePaths(files map[string]*ast.File) []string { + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + return paths +} + +func isTestingTFunc(fn *ast.FuncDecl) bool { + if fn.Type == nil || fn.Type.Params == nil || len(fn.Type.Params.List) != 1 { + return false + } + + param := fn.Type.Params.List[0] + star, ok := param.Type.(*ast.StarExpr) + if !ok { + return false + } + + sel, ok := star.X.(*ast.SelectorExpr) + if !ok { + return false + } + + pkg, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + + return pkg.Name == "testing" && sel.Sel.Name == "T" +} + +func typeDocGroup(decl *ast.GenDecl, spec *ast.TypeSpec, index int) *ast.CommentGroup { + if spec.Doc != nil { + return spec.Doc + } + if len(decl.Specs) == 1 && index == 0 { + return decl.Doc + } + return nil +} + +func valueDocGroup(decl *ast.GenDecl, spec *ast.ValueSpec, index int) *ast.CommentGroup { + if spec.Doc != nil { + return spec.Doc + } + if len(decl.Specs) == 1 && index == 0 { + return decl.Doc + } + return nil +} + +func commentText(group *ast.CommentGroup) string { + if group == nil { + return "" + } + return strings.TrimSpace(group.Text()) +} + +func hasDocPrefix(text, name string) bool { + if text == "" || !strings.HasPrefix(text, name) { + return false + } + if len(text) == len(name) { + return true + } + + next := text[len(name)] + return (next < 'A' || next > 'Z') && (next < 'a' || next > 'z') && (next < '0' || next > '9') && next != '_' +} diff --git a/docs/development.md b/docs/development.md index f9cfc6f..747b694 100644 --- a/docs/development.md +++ b/docs/development.md @@ -138,6 +138,17 @@ Both `go vet ./...` and `golangci-lint run ./...` must be clean before committin - Use explicit types on struct fields and function signatures. - Avoid `interface{}` in public APIs; use typed parameters where possible. - Handle all errors explicitly; do not use blank `_` for error returns in non-test code. +- Exported declarations must have Go doc comments beginning with the identifier name. + +### Imports and Error Handling + +- Do not import `errors` or `github.com/pkg/errors` in non-test Go files; use `coreerr.E(op, msg, err)` from `dappco.re/go/core/log`. +- Do not reintroduce legacy `forge.lthn.ai/...` module paths; use `dappco.re/go/core/...` imports. + +### Test Naming + +Test functions should follow `TestFunctionName_Context_Good/Bad/Ugly`. +The conventions test suite checks test naming, banned imports, and exported usage comments during `go test ./...`. ### File Headers diff --git a/parser_test.go b/parser_test.go index 2b3b06d..e8e6412 100644 --- a/parser_test.go +++ b/parser_test.go @@ -807,13 +807,13 @@ func TestTruncate_Empty_Good(t *testing.T) { // --- helper function tests --- -func TestShortID_Good(t *testing.T) { +func TestShortID_TruncatesAndPreservesLength_Good(t *testing.T) { assert.Equal(t, "abcdefgh", shortID("abcdefghijklmnop")) assert.Equal(t, "short", shortID("short")) assert.Equal(t, "12345678", shortID("12345678")) } -func TestFormatDuration_Good(t *testing.T) { +func TestFormatDuration_CommonDurations_Good(t *testing.T) { assert.Equal(t, "500ms", formatDuration(500*time.Millisecond)) assert.Equal(t, "1.5s", formatDuration(1500*time.Millisecond)) assert.Equal(t, "2m30s", formatDuration(2*time.Minute+30*time.Second)) @@ -1453,5 +1453,3 @@ func TestListSessions_TruncatedFile_Good(t *testing.T) { } // --- PruneSessions tests --- - - diff --git a/video_test.go b/video_test.go index 7a195ce..9be524c 100644 --- a/video_test.go +++ b/video_test.go @@ -174,7 +174,7 @@ func TestGenerateTape_BashEmptyCommand_Bad(t *testing.T) { assert.NotContains(t, tape, `"$ "`) } -func TestExtractCommand_Good(t *testing.T) { +func TestExtractCommand_StripsDescriptionSuffix_Good(t *testing.T) { assert.Equal(t, "ls -la", extractCommand("ls -la # list files")) assert.Equal(t, "go test ./...", extractCommand("go test ./...")) assert.Equal(t, "echo hello", extractCommand("echo hello"))