diff --git a/internal/engine/report.go b/internal/engine/report.go index 1ab3c83..a4f9949 100644 --- a/internal/engine/report.go +++ b/internal/engine/report.go @@ -357,20 +357,24 @@ func (rb *ReportBuilder) buildTimeline(reqID string, stories []state.Story) []Ti storyIDSet[s.ID] = s.Title } + // Previously this loop issued one List per (event-type × story-id) pair + // — 5 × len(stories) sequential queries. On JSONL stores that's 5N file + // reads; on SQLite it's 5N transactions. Issue ONE List per event type + // (no StoryID filter), then filter against storyIDSet in memory: 5 + // queries total regardless of story count. for _, evtType := range storyEventTypes { - for storyID, storyTitle := range storyIDSet { - evts, _ := rb.es.List(state.EventFilter{ - Type: evtType, - StoryID: storyID, - }) - for _, evt := range evts { - entries = append(entries, TimelineEntry{ - Timestamp: evt.Timestamp, - EventType: string(evt.Type), - StoryID: storyID, - Description: rb.describeStoryEvent(evt.Type, storyTitle), - }) + evts, _ := rb.es.List(state.EventFilter{Type: evtType}) + for _, evt := range evts { + storyTitle, owned := storyIDSet[evt.StoryID] + if !owned { + continue } + entries = append(entries, TimelineEntry{ + Timestamp: evt.Timestamp, + EventType: string(evt.Type), + StoryID: evt.StoryID, + Description: rb.describeStoryEvent(evt.Type, storyTitle), + }) } } diff --git a/internal/engine/report_test.go b/internal/engine/report_test.go index 6d23c30..878d476 100644 --- a/internal/engine/report_test.go +++ b/internal/engine/report_test.go @@ -145,6 +145,40 @@ func setupReportStores(t *testing.T) (state.EventStore, *state.SQLiteStore, func return es, ps, cleanup } +// TestReportBuilder_Build_TimelineFiltersByStoryOwnership guards the +// post-N+1 path: buildTimeline issues one List per event type (no StoryID +// filter) and screens via storyIDSet in memory. The regression to prevent +// is "events from stories belonging to OTHER requirements leak into the +// timeline." Seed a STORY_MERGED for a foreign story id, then assert it is +// NOT in the report. +func TestReportBuilder_Build_TimelineFiltersByStoryOwnership(t *testing.T) { + es, ps, cleanup := setupReportStores(t) + defer cleanup() + + // Foreign story id — does NOT appear in stories(req-001). + foreignMerged := state.NewEvent(state.EventStoryMerged, "merger", "FOREIGN-STORY-Z", nil) + if err := es.Append(foreignMerged); err != nil { + t.Fatalf("append foreign event: %v", err) + } + if err := ps.Project(foreignMerged); err != nil { + t.Fatalf("project foreign event: %v", err) + } + + cfg := config.DefaultConfig() + rb := engine.NewReportBuilder(es, ps, cfg) + + report, err := rb.Build("req-001") + if err != nil { + t.Fatalf("Build: %v", err) + } + + for _, entry := range report.Timeline { + if entry.StoryID == "FOREIGN-STORY-Z" { + t.Fatalf("foreign story %q leaked into timeline (in-memory filter regression)", entry.StoryID) + } + } +} + func TestReportBuilder_Build_BasicFields(t *testing.T) { es, ps, cleanup := setupReportStores(t) defer cleanup()