test(conventions): enforce AX review rules
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
7c85a9c21d
commit
af4e1d6ae2
6 changed files with 285 additions and 5 deletions
|
|
@ -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
54
CODEX.md
Normal 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
213
conventions_test.go
Normal 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 != '_'
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue