diff --git a/README.md b/README.md index 894f903..609d11c 100644 --- a/README.md +++ b/README.md @@ -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)) diff --git a/conventions_test.go b/conventions_test.go index d8ed9f4..5a6894e 100644 --- a/conventions_test.go +++ b/conventions_test.go @@ -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) } } diff --git a/go.mod b/go.mod index fb10cc3..7899fe6 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index c8d1dca..06fae0a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/kb/Home.md b/kb/Home.md index 931ca99..e91cbbc 100644 --- a/kb/Home.md +++ b/kb/Home.md @@ -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)) diff --git a/parser.go b/parser.go index cac2948..9c1bbbe 100644 --- a/parser.go +++ b/parser.go @@ -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 -} diff --git a/parser_other.go b/parser_other.go new file mode 100644 index 0000000..79673d4 --- /dev/null +++ b/parser_other.go @@ -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 +} diff --git a/parser_unix.go b/parser_unix.go new file mode 100644 index 0000000..f7b9aa1 --- /dev/null +++ b/parser_unix.go @@ -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 +} diff --git a/tests/cli/session/main.go b/tests/cli/session/main.go index 2e410b1..a942f3d 100644 --- a/tests/cli/session/main.go +++ b/tests/cli/session/main.go @@ -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.