test(conventions): enforce AX review rules

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-26 11:14:35 +00:00
parent 7c85a9c21d
commit af4e1d6ae2
6 changed files with 285 additions and 5 deletions

View file

@ -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 <virgil@lethean.io>`
The conventions test suite enforces banned imports, exported usage comments, and test naming via `go test ./...`.

54
CODEX.md Normal file
View file

@ -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 <virgil@lethean.io>`
The conventions test suite enforces banned imports, exported usage comments, and test naming via `go test ./...`.

213
conventions_test.go Normal file
View file

@ -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 != '_'
}

View file

@ -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

View file

@ -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 ---

View file

@ -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"))