fix(session): r2 — platform-split no-follow + recursive convention scan + doc fixes on PR #5
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Round 2 follow-up to 8ffd10c.

Code:
- parser_unix.go (new): Unix O_NOFOLLOW implementation
- parser_other.go (new): non-Unix fallback
- parser.go: removed syscall import; syscall failures wrapped via
  coreerr.E
- tests/cli/session/main.go: smoke driver uses core path/fs/string
  helpers (was using direct os + filepath + strings)

Tests:
- conventions_test.go: recursive Go file collection + nested-file test
  case (was non-recursive, missing nested files)

Doc:
- README.md: quick-start compile fix (fmt import + discard unused
  parse stats)
- kb/Home.md: ParseTranscript signature aligned to current API
  (captures and uses stats)

Verification: gofmt clean, golangci-lint v2 0 issues, GOWORK=off
go vet + go test -count=1 ./... pass with explicit cache paths.
AX-6 clean: no testify references; smoke driver uses core helpers.

Closes residual findings on https://github.com/dAppCore/go-session/pull/5

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Snider 2026-04-27 18:48:40 +01:00
parent 8ffd10c2ac
commit 92ecddaa69
9 changed files with 174 additions and 71 deletions

View file

@ -13,9 +13,13 @@ Claude Code JSONL transcript parser, analytics engine, and HTML timeline rendere
## Quick Start
```go
import "dappco.re/go/session"
import (
"fmt"
sess, stats, err := session.ParseTranscript("/path/to/session.jsonl")
"dappco.re/go/session"
)
sess, _, err := session.ParseTranscript("/path/to/session.jsonl")
analytics := session.Analyse(sess)
fmt.Println(session.FormatAnalytics(analytics))

View file

@ -174,7 +174,7 @@ type parsedFile struct {
func parseGoFiles(t *testing.T, dir string) []parsedFile {
t.Helper()
paths := core.PathGlob(path.Join(dir, "*.go"))
paths := collectGoPaths(dir)
if len(paths) == 0 {
t.Fatalf("no Go files found in %s", dir)
}
@ -191,7 +191,7 @@ func parseGoFiles(t *testing.T, dir string) []parsedFile {
testingImportNames, hasTestingDotImport := testingImports(fileAST)
files = append(files, parsedFile{
path: path.Base(filePath),
path: relativeGoPath(dir, filePath),
ast: fileAST,
testingImportNames: testingImportNames,
hasTestingDotImport: hasTestingDotImport,
@ -200,22 +200,52 @@ func parseGoFiles(t *testing.T, dir string) []parsedFile {
return files
}
// collectGoPaths supports the session test suite.
func collectGoPaths(dir string) []string {
var paths []string
for _, entryPath := range core.PathGlob(path.Join(dir, "*")) {
if hostFS.IsDir(entryPath) {
paths = append(paths, collectGoPaths(entryPath)...)
continue
}
if core.HasSuffix(entryPath, ".go") {
paths = append(paths, entryPath)
}
}
return paths
}
// relativeGoPath supports the session test suite.
func relativeGoPath(root, filePath string) string {
prefix := core.TrimSuffix(root, "/")
if prefix == "." || prefix == "" {
return filePath
}
prefix += "/"
if core.HasPrefix(filePath, prefix) {
return filePath[len(prefix):]
}
return path.Base(filePath)
}
// TestConventions_ParseGoFilesMultiplePackages_Good verifies the behaviour covered by this test case.
func TestConventions_ParseGoFilesMultiplePackages_Good(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, path.Join(dir, "session.go"), "package session\n")
writeTestFile(t, path.Join(dir, "session_external_test.go"), "package session_test\n")
writeTestFile(t, path.Join(dir, "nested", "worker.go"), "package nested\n")
writeTestFile(t, path.Join(dir, "README.md"), "# ignored\n")
files := parseGoFiles(t, dir)
if len(files) != 2 {
t.Fatalf("expected 2 Go files, got %d", len(files))
if len(files) != 3 {
t.Fatalf("expected 3 Go files, got %d", len(files))
}
names := []string{files[0].path, files[1].path}
names = append(names, files[2].path)
slices.Sort(names)
if names[0] != "session.go" || names[1] != "session_external_test.go" {
if names[0] != "nested/worker.go" || names[1] != "session.go" || names[2] != "session_external_test.go" {
t.Fatalf("unexpected files: %v", names)
}
}

5
go.mod
View file

@ -2,4 +2,7 @@ module dappco.re/go/session
go 1.26.0
require dappco.re/go/core v0.8.0-alpha.1
require (
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/log v0.1.2
)

2
go.sum
View file

@ -1,5 +1,7 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/log v0.1.2 h1:pQSZxKD8VycdvjNJmatXbPSq2OxcP2xHbF20zgFIiZI=
dappco.re/go/core/log v0.1.2/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=

View file

@ -50,10 +50,11 @@ import (
func main() {
// Parse a single transcript
sess, err := session.ParseTranscript("~/.claude/projects/abc123.jsonl")
sess, stats, err := session.ParseTranscript("~/.claude/projects/abc123.jsonl")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Skipped lines: %d\n", stats.SkippedLines)
fmt.Printf("Session %s: %d events over %s\n",
sess.ID, len(sess.Events), sess.EndTime.Sub(sess.StartTime))

View file

@ -2,12 +2,11 @@
package session
import (
"io" // Note: intrinsic — Reader, ReadCloser, and EOF contracts for transcript streams and hostFS handles; no core equivalent
"io/fs" // Note: intrinsic — fs.FileInfo metadata returned from hostFS.Stat; no core equivalent
"iter" // Note: intrinsic — public lazy sequence API for sessions and events; no core equivalent
"slices" // Note: intrinsic — iterator collection, sorted keys, and session ordering; no core equivalent
"syscall" // Note: intrinsic — O_NOFOLLOW descriptor opens and Errno checks for transcript safety; no core equivalent
"time" // Note: intrinsic — RFC3339 transcript timestamps and session age calculations; no core equivalent
"io" // Note: intrinsic — Reader, ReadCloser, and EOF contracts for transcript streams and hostFS handles; no core equivalent
"io/fs" // Note: intrinsic — fs.FileInfo metadata returned from hostFS.Stat; no core equivalent
"iter" // Note: intrinsic — public lazy sequence API for sessions and events; no core equivalent
"slices" // Note: intrinsic — iterator collection, sorted keys, and session ordering; no core equivalent
"time" // Note: intrinsic — RFC3339 transcript timestamps and session age calculations; no core equivalent
core "dappco.re/go/core"
)
@ -276,7 +275,7 @@ func FetchSession(projectsDir, id string) (*Session, *ParseStats, error) {
filePath := transcriptPath(projectsDir, id+".jsonl")
f, err := openTranscriptNoFollow(filePath)
if err != nil {
if err == syscall.ENOENT {
if isTranscriptMissing(err) {
return nil, nil, core.E("FetchSession", "open transcript", err)
}
return nil, nil, core.E("FetchSession", "invalid session path", nil)
@ -675,43 +674,3 @@ func transcriptPath(projectsDir, name string) string {
}
return core.CleanPath(core.JoinPath(projectsDir, name), "/")
}
type noFollowFile struct {
fd int
}
// Read reads bytes from a descriptor opened without following symlinks.
func (f *noFollowFile) Read(p []byte) (int, error) {
n, err := syscall.Read(f.fd, p)
if err != nil {
return n, err
}
if n == 0 {
return 0, io.EOF
}
return n, nil
}
// Close closes a descriptor opened without following symlinks.
func (f *noFollowFile) Close() error {
return syscall.Close(f.fd)
}
// openTranscriptNoFollow opens a regular transcript file without following symlinks.
func openTranscriptNoFollow(filePath string) (io.ReadCloser, error) {
fd, err := syscall.Open(filePath, syscall.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
return nil, err
}
var st syscall.Stat_t
if err := syscall.Fstat(fd, &st); err != nil {
_ = syscall.Close(fd)
return nil, err
}
if st.Mode&syscall.S_IFMT != syscall.S_IFREG {
_ = syscall.Close(fd)
return nil, core.E("openTranscriptNoFollow", "not a regular file", nil)
}
return &noFollowFile{fd: fd}, nil
}

20
parser_other.go Normal file
View file

@ -0,0 +1,20 @@
//go:build !unix
// SPDX-Licence-Identifier: EUPL-1.2
package session
import (
"io" // Note: intrinsic — keeps the platform stub signature aligned with the Unix io.ReadCloser implementation; no core equivalent
coreerr "dappco.re/go/core/log"
)
// openTranscriptNoFollow reports that secure no-follow opens are unavailable on this platform.
func openTranscriptNoFollow(filePath string) (io.ReadCloser, error) {
return nil, coreerr.E("openTranscriptNoFollow", "secure no-follow transcript opens are unsupported on this platform: "+filePath, nil)
}
// isTranscriptMissing reports whether err wraps a missing transcript path error.
func isTranscriptMissing(error) bool {
return false
}

83
parser_unix.go Normal file
View file

@ -0,0 +1,83 @@
//go:build unix
// SPDX-Licence-Identifier: EUPL-1.2
package session
import (
"io" // Note: intrinsic — io.ReadCloser contract and EOF signalling for descriptor-backed transcript reads; no core equivalent
"syscall" // Note: intrinsic — O_NOFOLLOW descriptor opens and fstat checks are platform syscalls; no core equivalent
coreerr "dappco.re/go/core/log"
)
type noFollowFile struct {
fd int
}
// Read reads bytes from a descriptor opened without following symlinks.
func (f *noFollowFile) Read(p []byte) (int, error) {
n, err := syscall.Read(f.fd, p)
if err != nil {
return n, coreerr.E("noFollowFile.Read", "read transcript descriptor", err)
}
if n == 0 {
return 0, io.EOF
}
return n, nil
}
// Close closes a descriptor opened without following symlinks.
func (f *noFollowFile) Close() error {
if err := syscall.Close(f.fd); err != nil {
return coreerr.E("noFollowFile.Close", "close transcript descriptor", err)
}
return nil
}
// openTranscriptNoFollow opens a regular transcript file without following symlinks.
func openTranscriptNoFollow(filePath string) (io.ReadCloser, error) {
const op = "openTranscriptNoFollow"
fd, err := syscall.Open(filePath, syscall.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
return nil, coreerr.E(op, "open transcript without following symlinks", err)
}
var st syscall.Stat_t
if err := syscall.Fstat(fd, &st); err != nil {
if closeErr := closeNoFollowFD(fd); closeErr != nil {
return nil, closeErr
}
return nil, coreerr.E(op, "stat transcript descriptor", err)
}
if st.Mode&syscall.S_IFMT != syscall.S_IFREG {
if closeErr := closeNoFollowFD(fd); closeErr != nil {
return nil, closeErr
}
return nil, coreerr.E(op, "not a regular file", nil)
}
return &noFollowFile{fd: fd}, nil
}
// closeNoFollowFD closes a raw descriptor after a failed secure-open check.
func closeNoFollowFD(fd int) error {
if err := syscall.Close(fd); err != nil {
return coreerr.E("openTranscriptNoFollow", "close rejected transcript descriptor", err)
}
return nil
}
// isTranscriptMissing reports whether err wraps a missing transcript path error.
func isTranscriptMissing(err error) bool {
for err != nil {
if err == syscall.ENOENT {
return true
}
unwrapper, ok := err.(interface{ Unwrap() error })
if !ok {
return false
}
err = unwrapper.Unwrap()
}
return false
}

View file

@ -2,11 +2,9 @@
package main
import (
"os"
"path/filepath"
"strings"
"time"
core "dappco.re/go/core"
session "dappco.re/go/session"
)
@ -18,14 +16,16 @@ const transcript = `{"type":"user","timestamp":"2026-02-20T10:00:00Z","sessionId
// main runs the CLI session smoke test.
func main() {
dir, err := os.MkdirTemp("", "go-session-ax10-")
requireNoError(err, "create temporary directory")
fs := (&core.Fs{}).NewUnrestricted()
dir := fs.TempDir("go-session-ax10-")
require(dir != "", "create temporary directory")
defer func() {
_ = os.RemoveAll(dir)
_ = fs.DeleteAll(dir)
}()
transcriptPath := filepath.Join(dir, "ax10-session.jsonl")
requireNoError(os.WriteFile(transcriptPath, []byte(transcript), 0o600), "write transcript")
transcriptPath := core.Path(dir, "ax10-session.jsonl")
writeResult := fs.WriteMode(transcriptPath, transcript, 0o600)
require(writeResult.OK, "write transcript")
sess, stats, err := session.ParseTranscript(transcriptPath)
requireNoError(err, "parse transcript")
@ -50,7 +50,7 @@ func main() {
require(analytics.ToolCounts["Bash"] == 1, "expected analytics Bash count")
expectedSuccessRate := successfulToolRate(sess)
require(analytics.SuccessRate == expectedSuccessRate, "expected analytics success rate")
require(strings.Contains(session.FormatAnalytics(analytics), "Bash"), "expected formatted analytics to include Bash")
require(core.Contains(session.FormatAnalytics(analytics), "Bash"), "expected formatted analytics to include Bash")
results, err := session.Search(dir, "ax10")
requireNoError(err, "search sessions")
@ -66,13 +66,14 @@ func main() {
requireNoError(err, "fetch session")
require(fetched.ID == sess.ID, "expected fetched session to match parsed session")
htmlPath := filepath.Join(dir, "timeline.html")
htmlPath := core.Path(dir, "timeline.html")
requireNoError(session.RenderHTML(sess, htmlPath), "render HTML")
htmlBytes, err := os.ReadFile(htmlPath)
requireNoError(err, "read rendered HTML")
html := string(htmlBytes)
require(strings.Contains(html, "Session ax10"), "expected rendered HTML session title")
require(strings.Contains(html, "echo ax10"), "expected rendered HTML tool input")
readResult := fs.Read(htmlPath)
require(readResult.OK, "read rendered HTML")
html, ok := readResult.Value.(string)
require(ok, "read rendered HTML as string")
require(core.Contains(html, "Session ax10"), "expected rendered HTML session title")
require(core.Contains(html, "echo ax10"), "expected rendered HTML tool input")
}
// successfulToolRate calculates the same tool-call success ratio as session.Analyse.