fix(session): r2 — platform-split no-follow + recursive convention scan + doc fixes on PR #5
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:
parent
8ffd10c2ac
commit
92ecddaa69
9 changed files with 174 additions and 71 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
5
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
|
||||
)
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
53
parser.go
53
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
|
||||
}
|
||||
|
|
|
|||
20
parser_other.go
Normal file
20
parser_other.go
Normal 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
83
parser_unix.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue