From cdfa4caf4fa831e54e54b95495e18cc189b5954f Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 28 Sep 2026 16:02:17 +0200 Subject: [PATCH 1/4] feat(cli): add experimental JSON graph export `sdd export --format json` writes the local graph plus selected connected repos with engine-derived status, heat, effective topics and first-parent git arrival times, for prototyping a hosted graph viewer. Not a stable read contract: built on the internal model, not pkg/application. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cliapp/app.go | 2 + internal/cliapp/export.go | 67 +++++++++++ internal/cliapp/export_test.go | 157 ++++++++++++++++++++++++ internal/finders/export.go | 160 +++++++++++++++++++++++++ internal/finders/finder.go | 14 +++ internal/git/history.go | 48 ++++++++ internal/model/arrivals.go | 34 ++++++ internal/presenters/export.go | 210 +++++++++++++++++++++++++++++++++ internal/query/export.go | 66 +++++++++++ 9 files changed, 758 insertions(+) create mode 100644 internal/cliapp/export.go create mode 100644 internal/cliapp/export_test.go create mode 100644 internal/finders/export.go create mode 100644 internal/git/history.go create mode 100644 internal/model/arrivals.go create mode 100644 internal/presenters/export.go create mode 100644 internal/query/export.go diff --git a/internal/cliapp/app.go b/internal/cliapp/app.go index 29285b08..541d28ae 100644 --- a/internal/cliapp/app.go +++ b/internal/cliapp/app.go @@ -183,6 +183,7 @@ func newReadFinder() (*finders.Finder, error) { PreflightRunner: readOnlyRunner, Config: cfg, Repos: reg, + GitHistory: git.CLI{}, }), nil } @@ -383,6 +384,7 @@ func New(options Options) *cli.Command { configCmd(), showCmd(), viewCmd(), + exportCmd(), newCmd(), rewriteCmd(), wipCmd(), diff --git a/internal/cliapp/export.go b/internal/cliapp/export.go new file mode 100644 index 00000000..f22fe6fa --- /dev/null +++ b/internal/cliapp/export.go @@ -0,0 +1,67 @@ +package cliapp + +import ( + "context" + "fmt" + "time" + + "github.com/networkteam/sdd/internal/presenters" + "github.com/networkteam/sdd/internal/query" + "github.com/urfave/cli/v3" +) + +func exportCmd() *cli.Command { + return &cli.Command{ + Name: "export", + Usage: "EXPERIMENTAL: export the whole graph with derived status, heat and git arrival times as one JSON document (unstable shape, for UI prototyping)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "format", + Value: "json", + Usage: "Output format: json (the only one)", + }, + &cli.StringSliceFlag{ + Name: "repo", + Usage: "Also export a connected repo's graph by repo-id (repeatable, additive to the local graph)", + }, + &cli.BoolFlag{ + Name: "all-repos", + Usage: "Also export every connected repo", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + if format := cmd.String("format"); format != "json" { + return fmt.Errorf("--format: unsupported value %q (only json)", format) + } + reg, _, err := defaultRepos() + if err != nil { + return err + } + repoIDs, err := reg.SelectRepoIDs(cmd.StringSlice("repo"), cmd.Bool("all-repos")) + if err != nil { + return err + } + if err := freshenRepoCaches(ctx, cmd.ErrWriter, repoIDs); err != nil { + return err + } + + dir, err := resolveGraphDir(cmd) + if err != nil { + return err + } + f, err := newReadFinder() + if err != nil { + return err + } + g, err := f.CurrentGraph(dir) + if err != nil { + return err + } + result, err := f.OnGraph(g).Export(ctx, query.ExportQuery{RepoIDs: repoIDs, Now: time.Now()}) + if err != nil { + return err + } + return presenters.RenderExportJSON(cmd.Writer, result) + }, + } +} diff --git a/internal/cliapp/export_test.go b/internal/cliapp/export_test.go new file mode 100644 index 00000000..337ac3ba --- /dev/null +++ b/internal/cliapp/export_test.go @@ -0,0 +1,157 @@ +package cliapp_test + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + "testing" +) + +type exportDoc struct { + Experimental bool `json:"experimental"` + Repos []struct { + RepoID string `json:"repo_id"` + Local bool `json:"local"` + Revision string `json:"revision"` + Entries []struct { + ID string `json:"id"` + ShortID string `json:"short_id"` + Time string `json:"time"` + LandedAt string `json:"landed_at"` + Status string `json:"status"` + StatusBy string `json:"status_by"` + ClosedBy []string `json:"closed_by"` + Closes []string `json:"closes"` + Summary string `json:"summary"` + Heat float64 `json:"heat"` + InDegree int `json:"in_degree"` + Embedded bool `json:"embedded"` + Refs []struct { + ID, Kind, Desc string + } `json:"refs"` + Attachments []struct { + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Content string `json:"content"` + Truncated bool `json:"truncated"` + } `json:"attachments"` + } `json:"entries"` + WIP []struct { + Entry string `json:"entry"` + Participant string `json:"participant"` + } `json:"wip"` + } `json:"repos"` +} + +// TestCLIExportJSON pins the export's derived attributes against a repo whose +// closing decision arrived on main through a merge commit: its landed_at is the +// merge's committer time, not its capture or branch-commit time. +func TestCLIExportJSON(t *testing.T) { + f := newAppFixture(t) + const ( + closerID = "20260914-030000-d-tac-mrg" + pendingID = "20260914-040000-s-tac-new" + mergedAt = "2026-09-15T10:00:00+02:00" + ) + git := func(date string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false"}, args...)...) + cmd.Dir = f.root + cmd.Env = os.Environ() + if date != "" { + cmd.Env = append(cmd.Env, "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) + } + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + git("", "init", "-q", "-b", "main") + git("", "config", "user.email", "test@example.com") + git("", "config", "user.name", "Test") + git("2026-09-14T02:00:10+02:00", "add", "decisions") + git("2026-09-14T02:00:10+02:00", "commit", "-q", "-m", "capture signal") + git("", "checkout", "-q", "-b", "feature") + f.write(t, "decisions/2026/09/14-030000-d-tac-mrg.md", "---\ntype: decision\nkind: directive\nintent: pending\nlayer: tactical\nconfidence: high\nparticipants: [Local Author]\nrefs:\n - id: "+appEntryID+"\n kind: addresses\n desc: answers the gap\ncloses: ["+appEntryID+"]\n---\n\nThe branch decision.\n") + f.write(t, "decisions/2026/09/14-030000-d-tac-mrg/notes.md", strings.Repeat("A note line.\n", 2000)) + git("2026-09-14T03:00:30+02:00", "add", "decisions") + git("2026-09-14T03:00:30+02:00", "commit", "-q", "-m", "capture decision") + git("", "checkout", "-q", "main") + git(mergedAt, "merge", "-q", "--no-ff", "-m", "merge feature", "feature") + f.write(t, "decisions/2026/09/14-040000-s-tac-new.md", "---\ntype: signal\nkind: gap\nlayer: tactical\nconfidence: low\nparticipants: [Local Author]\n---\n\nNot committed yet.\n") + f.write(t, "decisions/wip/20260914-050000-local-author.md", "---\nentry: "+closerID+"\nparticipant: Local Author\n---\n\nFollowing up.\n") + head := git("", "rev-parse", "HEAD") + + result := f.run(t, "export", "--format", "json") + if result.err != nil { + t.Fatalf("export: %v\n%s", result.err, result.stderr) + } + var doc exportDoc + if err := json.Unmarshal([]byte(result.stdout), &doc); err != nil { + t.Fatalf("export emitted invalid JSON: %v\n%s", err, result.stdout) + } + if !doc.Experimental || len(doc.Repos) != 1 { + t.Fatalf("experimental = %v, repos = %d; want true, 1", doc.Experimental, len(doc.Repos)) + } + repo := doc.Repos[0] + if repo.RepoID != "example.test/cli" || !repo.Local || repo.Revision != head { + t.Errorf("repo = %s local=%v revision=%s; want example.test/cli local=true revision=%s", repo.RepoID, repo.Local, repo.Revision, head) + } + if len(repo.WIP) != 1 || repo.WIP[0].Entry != closerID || repo.WIP[0].Participant != "Local Author" { + t.Errorf("wip = %+v, want one marker on %s", repo.WIP, closerID) + } + + byID := map[string]int{} + embedded := 0 + for i, e := range repo.Entries { + byID[e.ID] = i + if e.Embedded { + embedded++ + } + } + if embedded == 0 { + t.Error("export omitted the embedded base entries") + } + + signal := repo.Entries[byID[appEntryID]] + if signal.ShortID != "s-tac-cli" || signal.Time != "2026-09-14T02:00:00" || signal.Summary != appSummary { + t.Errorf("signal identity = %s %s %q", signal.ShortID, signal.Time, signal.Summary) + } + if signal.Status != "closed-by" || signal.StatusBy != closerID || len(signal.ClosedBy) != 1 || signal.ClosedBy[0] != closerID { + t.Errorf("signal status = %s by %s closed_by %v; want closed-by %s", signal.Status, signal.StatusBy, signal.ClosedBy, closerID) + } + if signal.InDegree != 1 || signal.Heat <= 0 { + t.Errorf("signal in_degree = %d heat = %v; want 1 and positive heat", signal.InDegree, signal.Heat) + } + if signal.LandedAt != "2026-09-14T02:00:10+02:00" { + t.Errorf("signal landed_at = %q, want its own commit time", signal.LandedAt) + } + + closer := repo.Entries[byID[closerID]] + if closer.LandedAt != mergedAt { + t.Errorf("merged decision landed_at = %q, want merge commit time %s", closer.LandedAt, mergedAt) + } + if closer.Status != "active" || len(closer.Closes) != 1 || len(closer.Refs) != 1 || + closer.Refs[0].ID != appEntryID || closer.Refs[0].Kind != "addresses" || closer.Refs[0].Desc != "answers the gap" { + t.Errorf("decision = status %s closes %v refs %+v", closer.Status, closer.Closes, closer.Refs) + } + if len(closer.Attachments) != 1 { + t.Fatalf("attachments = %+v, want notes.md", closer.Attachments) + } + a := closer.Attachments[0] + if a.Name != "notes.md" || a.Size != 26000 || !strings.HasPrefix(a.ContentType, "text/markdown") || + !a.Truncated || len(a.Content) == 0 || len(a.Content) > 16*1024 { + t.Errorf("attachment = %s size %d type %s truncated %v content %d bytes", a.Name, a.Size, a.ContentType, a.Truncated, len(a.Content)) + } + + if pending := repo.Entries[byID[pendingID]]; pending.LandedAt != "" { + t.Errorf("uncommitted entry landed_at = %q, want none", pending.LandedAt) + } + + if bad := f.run(t, "export", "--format", "text"); bad.err == nil || bad.stdout != "" { + t.Errorf("export --format text = %v with output %q, want an error and no result", bad.err, bad.stdout) + } +} diff --git a/internal/finders/export.go b/internal/finders/export.go new file mode 100644 index 00000000..42245bd3 --- /dev/null +++ b/internal/finders/export.go @@ -0,0 +1,160 @@ +package finders + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/networkteam/sdd/internal/model" + "github.com/networkteam/sdd/internal/query" + "github.com/networkteam/sdd/internal/truncate" +) + +// GitHistory is the git surface the graph export reads provenance from. +type GitHistory interface { + // InWorkTree reports whether dir lies inside a git work tree. + InWorkTree(ctx context.Context, dir string) bool + // HeadRevision returns the commit hash HEAD points at, "" before the + // first commit. + HeadRevision(ctx context.Context, dir string) (string, error) + // FileArrivals maps each file under dir (slash-separated, relative to + // dir) to the committer time of the first-parent commit that added it. + FileArrivals(ctx context.Context, dir string) (map[string]time.Time, error) +} + +// attachmentSniffBytes is how much of a non-text attachment is read to +// detect its content type. +const attachmentSniffBytes = 512 + +// attachmentTypes pins the content type of the attachment formats graphs +// commonly carry, so the export does not depend on the host's MIME tables. +var attachmentTypes = map[string]string{ + ".md": "text/markdown; charset=utf-8", + ".markdown": "text/markdown; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".log": "text/plain; charset=utf-8", + ".json": "application/json", +} + +// Export assembles the held graph and the selected connected repos into one +// document carrying each entry with the attributes the engine derives for it +// in its owning graph (see query.ExportQuery). +func (gf *GraphFinder) Export(ctx context.Context, q query.ExportQuery) (*query.ExportResult, error) { + if gf.graph == nil { + return nil, fmt.Errorf("export: graph is required") + } + repoID, err := gf.finder.localRepoID() + if err != nil { + return nil, err + } + decay, err := model.DecayByName(model.DefaultDecayName) + if err != nil { + return nil, err + } + + local, err := gf.exportRepo(ctx, repoID, true, decay, q.Now) + if err != nil { + return nil, err + } + result := &query.ExportResult{GeneratedAt: q.Now, Repos: []query.ExportRepo{local}} + for _, id := range q.RepoIDs { + member, err := gf.graph.MemberGraph(id) + if err != nil { + return nil, fmt.Errorf("loading graph for %s: %w", id, err) + } + if member == nil { + result.Repos = append(result.Repos, query.ExportRepo{RepoID: id, Unavailable: true}) + continue + } + repo, err := gf.finder.OnGraph(member).exportRepo(ctx, id, false, decay, q.Now) + if err != nil { + return nil, err + } + result.Repos = append(result.Repos, repo) + } + return result, nil +} + +// exportRepo exports the held graph. Embedded base entries are identical in +// every graph, so only the local repo carries them. +func (gf *GraphFinder) exportRepo(ctx context.Context, repoID string, local bool, decay model.DecayFunc, now time.Time) (query.ExportRepo, error) { + g := gf.graph + wip, err := gf.WIPMarkers() + if err != nil { + return query.ExportRepo{}, err + } + repo := query.ExportRepo{RepoID: repoID, Local: local, WIP: wip, LoadIssues: g.LoadIssues} + + var arrivals map[string]time.Time + if h, dir := gf.finder.gitHistory, g.GraphDir(); h != nil && dir != "" && h.InWorkTree(ctx, dir) { + if repo.Revision, err = h.HeadRevision(ctx, dir); err != nil { + return query.ExportRepo{}, err + } + if arrivals, err = h.FileArrivals(ctx, dir); err != nil { + return query.ExportRepo{}, err + } + } + + for _, e := range g.Entries { + if e.Embedded && !local { + continue + } + entry := query.ExportEntry{ + Entry: e, + Status: g.DerivedStatus(e), + ClosedBy: g.ClosedBy[e.ID], + SupersededBy: g.SupersededBy[e.ID], + Topics: g.EffectiveTopics(e), + Heat: model.HeatScore(g, e, decay, now), + InDegree: int(model.InDegreeScore(g, e)), + } + if key, ok := g.DisplayID(e.ID); ok && key != e.ID { + entry.FullID = key + } + if rel, err := model.IDToRelPath(e.ID); err == nil { + entry.LandedAt = arrivals[filepath.ToSlash(rel)] + } + if entry.Attachments, err = gf.exportAttachments(e); err != nil { + return query.ExportRepo{}, err + } + repo.Entries = append(repo.Entries, entry) + } + return repo, nil +} + +// exportAttachments describes an entry's attachments through the shared +// accessor, inlining Markdown and plain-text content up to the export limit. +func (gf *GraphFinder) exportAttachments(e *model.Entry) ([]query.ExportAttachment, error) { + var out []query.ExportAttachment + for _, rel := range e.Attachments { + name := filepath.Base(rel) + head, err := gf.ReadAttachment(query.ReadAttachmentQuery{EntryID: e.ID, Name: name, MaxBytes: attachmentSniffBytes}) + if err != nil { + return nil, err + } + a := query.ExportAttachment{Name: name, Size: head.TotalBytes, ContentType: attachmentContentType(name, head.Content)} + if strings.HasPrefix(a.ContentType, "text/markdown") || strings.HasPrefix(a.ContentType, "text/plain") { + // Read past the limit by one rune so the cut can tell a file that + // ends exactly at the limit from a longer one. + page, err := gf.ReadAttachment(query.ReadAttachmentQuery{EntryID: e.ID, Name: name, MaxBytes: query.ExportAttachmentTextLimit + utf8.UTFMax}) + if err != nil { + return nil, err + } + text := truncate.Bytes(page.Content, query.ExportAttachmentTextLimit, "") + a.Content, a.Truncated = text.Text, page.More || !text.Cut.Clean() + } + out = append(out, a) + } + return out, nil +} + +func attachmentContentType(name, head string) string { + if t, ok := attachmentTypes[strings.ToLower(filepath.Ext(name))]; ok { + return t + } + return http.DetectContentType([]byte(head)) +} diff --git a/internal/finders/finder.go b/internal/finders/finder.go index 7c9d7ec5..5c62cc7d 100644 --- a/internal/finders/finder.go +++ b/internal/finders/finder.go @@ -26,6 +26,7 @@ type Finder struct { writingGuideRunner llm.Runner cfg *model.PerRepoConfig gitSyncer GitSyncer + gitHistory GitHistory repos *repos.Registry procedureRegistry *engine.Registry } @@ -38,6 +39,9 @@ type Options struct { WritingGuideRunner llm.Runner Config *model.PerRepoConfig GitSyncer GitSyncer + // GitHistory supplies revision and arrival provenance for the graph + // export. Nil exports without it. + GitHistory GitHistory // Repos is the pure read surface over the connected repos — the only // cross-repo capability a finder holds (no clone, no pull). Nil means no // connected-repos support: cross-repo refs stay unresolved. @@ -55,6 +59,7 @@ func New(opts Options) *Finder { writingGuideRunner: opts.WritingGuideRunner, cfg: opts.Config, gitSyncer: opts.GitSyncer, + gitHistory: opts.GitHistory, repos: opts.Repos, procedureRegistry: opts.ProcedureRegistry, } @@ -87,3 +92,12 @@ func (f *Finder) declaredDependencies() ([]string, error) { } return f.cfg.Dependencies, nil } + +// localRepoID returns the repo's canonical identity from config; "" means the +// repo declares none (local-only). +func (f *Finder) localRepoID() (string, error) { + if f.cfg == nil { + return "", fmt.Errorf("repo ID unavailable: no per-repo config") + } + return f.cfg.RepoID, nil +} diff --git a/internal/git/history.go b/internal/git/history.go new file mode 100644 index 00000000..eff5f2eb --- /dev/null +++ b/internal/git/history.go @@ -0,0 +1,48 @@ +package git + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/networkteam/sdd/internal/model" +) + +// The finders.GitHistory surface: read-only provenance for the graph export. + +// InWorkTree reports whether dir lies inside a git work tree. +func (CLI) InWorkTree(ctx context.Context, dir string) bool { + out, err := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--is-inside-work-tree").Output() + return err == nil && strings.TrimSpace(string(out)) == "true" +} + +// HeadRevision returns the commit hash HEAD points at in dir's repository, or +// "" when HEAD has no commit yet. +func (CLI) HeadRevision(ctx context.Context, dir string) (string, error) { + out, err := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--verify", "--quiet", "HEAD").Output() + if err != nil { + if errExitCode(err) == 1 && len(out) == 0 { + return "", nil + } + return "", fmt.Errorf("git rev-parse HEAD: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// FileArrivals maps each file under dir that HEAD's first-parent history added +// (path relative to dir) to the committer time of the first-parent commit that +// brought it — for a file that arrived through a merge, the merge commit. One +// log pass; renames count as additions so a rewritten entry arrives when its +// new path did. +func (CLI) FileArrivals(ctx context.Context, dir string) (map[string]time.Time, error) { + out, err := exec.CommandContext(ctx, "git", "-C", dir, "log", + "--first-parent", "--diff-merges=first-parent", "--diff-filter=A", "--no-renames", + "--name-only", "--relative", "--no-color", "--format=%x00%cI", "--", ".", + ).Output() + if err != nil { + return nil, fmt.Errorf("git log in %s: %w", dir, err) + } + return model.ParseFileArrivals(string(out)) +} diff --git a/internal/model/arrivals.go b/internal/model/arrivals.go new file mode 100644 index 00000000..07659a65 --- /dev/null +++ b/internal/model/arrivals.go @@ -0,0 +1,34 @@ +package model + +import ( + "fmt" + "strings" + "time" +) + +// ParseFileArrivals parses `git log --first-parent --diff-filter=A +// --name-only --format=%x00%cI` output into each path's arrival time: the +// committer time of the first-parent commit that added it. Log order is +// newest first, so a path added more than once keeps its latest addition — +// the one that brought the current file. +func ParseFileArrivals(gitLogOutput string) (map[string]time.Time, error) { + arrivals := make(map[string]time.Time) + var current time.Time + for line := range strings.SplitSeq(gitLogOutput, "\n") { + if stamp, ok := strings.CutPrefix(line, "\x00"); ok { + t, err := time.Parse(time.RFC3339, stamp) + if err != nil { + return nil, fmt.Errorf("parsing commit time %q: %w", stamp, err) + } + current = t + continue + } + if line == "" || current.IsZero() { + continue + } + if _, seen := arrivals[line]; !seen { + arrivals[line] = current + } + } + return arrivals, nil +} diff --git a/internal/presenters/export.go b/internal/presenters/export.go new file mode 100644 index 00000000..6d0bdcc1 --- /dev/null +++ b/internal/presenters/export.go @@ -0,0 +1,210 @@ +package presenters + +import ( + "encoding/json" + "io" + "time" + + "github.com/networkteam/sdd/internal/model" + "github.com/networkteam/sdd/internal/query" +) + +// wallClockLayout renders the timestamp an entry or WIP ID carries: the +// capturer's local wall clock, which records no zone, so none is claimed. +const wallClockLayout = "2006-01-02T15:04:05" + +type exportJSON struct { + Experimental bool `json:"experimental"` + GeneratedAt string `json:"generated_at"` + Repos []exportRepoJSON `json:"repos"` +} + +type exportRepoJSON struct { + RepoID string `json:"repo_id"` + Local bool `json:"local"` + Unavailable bool `json:"unavailable,omitempty"` + Revision string `json:"revision,omitempty"` + Entries []exportEntryJSON `json:"entries"` + WIP []exportWIPJSON `json:"wip"` + LoadIssues []loadIssueJSON `json:"load_issues,omitempty"` +} + +type exportEntryJSON struct { + ID string `json:"id"` + FullID string `json:"full_id,omitempty"` + ShortID string `json:"short_id,omitempty"` + Type model.EntryType `json:"type"` + Kind model.Kind `json:"kind,omitempty"` + Layer model.Layer `json:"layer"` + Confidence string `json:"confidence,omitempty"` + Intent model.Intent `json:"intent,omitempty"` + Participants []string `json:"participants,omitempty"` + Topics []string `json:"topics,omitempty"` + Time string `json:"time"` + LandedAt string `json:"landed_at,omitempty"` + Status model.StatusKind `json:"status,omitempty"` + StatusBy string `json:"status_by,omitempty"` + ClosedBy []string `json:"closed_by,omitempty"` + SupersededBy []string `json:"superseded_by,omitempty"` + Closes []string `json:"closes,omitempty"` + Supersedes []string `json:"supersedes,omitempty"` + Refs []refJSON `json:"refs,omitempty"` + Heat float64 `json:"heat"` + InDegree int `json:"in_degree"` + Summary string `json:"summary,omitempty"` + Body string `json:"body,omitempty"` + Canonical string `json:"canonical,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Class model.ProcedureClass `json:"class,omitempty"` + Actor string `json:"actor,omitempty"` + Override string `json:"override,omitempty"` + Index *factIndexJSON `json:"index,omitempty"` + AnnotationTopics []annotationTopicJSON `json:"annotation_topics,omitempty"` + FocusActors []string `json:"focus_actors,omitempty"` + FocusWhen *focusWhenJSON `json:"focus_when,omitempty"` + Involvement []involvementJSON `json:"involvement,omitempty"` + Preflight string `json:"preflight,omitempty"` + Embedded bool `json:"embedded,omitempty"` + Warnings []warningJSON `json:"warnings,omitempty"` + Attachments []exportAttachmentJSON `json:"attachments,omitempty"` +} + +type refJSON struct { + ID string `json:"id"` + Kind model.RefKind `json:"kind"` + Desc string `json:"desc,omitempty"` +} + +type factIndexJSON struct { + Title string `json:"title"` + Topic string `json:"topic,omitempty"` +} + +type annotationTopicJSON struct { + Label string `json:"label"` + Members []string `json:"members,omitempty"` +} + +// involvementJSON carries the effective actors and scope, focus-level +// defaults already applied. +type involvementJSON struct { + Target string `json:"target"` + Actors []string `json:"actors"` + When *focusWhenJSON `json:"when,omitempty"` +} + +type focusWhenJSON struct { + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` +} + +type warningJSON struct { + Field string `json:"field"` + Value string `json:"value,omitempty"` + Message string `json:"message"` +} + +type exportAttachmentJSON struct { + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Content string `json:"content,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +type exportWIPJSON struct { + ID string `json:"id"` + Entry string `json:"entry"` + Participant string `json:"participant"` + Exclusive bool `json:"exclusive,omitempty"` + Branch string `json:"branch,omitempty"` + Description string `json:"description,omitempty"` + Time string `json:"time"` +} + +type loadIssueJSON struct { + Ref string `json:"ref"` + Message string `json:"message"` +} + +// RenderExportJSON writes the experimental whole-graph export document. +func RenderExportJSON(w io.Writer, r *query.ExportResult) error { + out := exportJSON{Experimental: true, GeneratedAt: r.GeneratedAt.Format(time.RFC3339), Repos: []exportRepoJSON{}} + for _, repo := range r.Repos { + rj := exportRepoJSON{ + RepoID: repo.RepoID, Local: repo.Local, Unavailable: repo.Unavailable, Revision: repo.Revision, + Entries: []exportEntryJSON{}, WIP: []exportWIPJSON{}, + } + for _, e := range repo.Entries { + rj.Entries = append(rj.Entries, exportEntryJSONFrom(e)) + } + for _, m := range repo.WIP { + rj.WIP = append(rj.WIP, exportWIPJSON{ + ID: m.ID, Entry: m.Entry, Participant: m.Participant, Exclusive: m.Exclusive, + Branch: m.Branch, Description: m.Content, Time: m.Time.Format(wallClockLayout), + }) + } + for _, issue := range repo.LoadIssues { + rj.LoadIssues = append(rj.LoadIssues, loadIssueJSON(issue)) + } + out.Repos = append(out.Repos, rj) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +func exportEntryJSONFrom(x query.ExportEntry) exportEntryJSON { + e := x.Entry + ej := exportEntryJSON{ + ID: e.ID, FullID: x.FullID, Type: e.Type, Kind: e.Kind, Layer: e.Layer, + Confidence: e.Confidence, Intent: e.Intent, Participants: e.Participants, + Time: e.Time.Format(wallClockLayout), Status: x.Status.Kind, StatusBy: x.Status.By, + ClosedBy: x.ClosedBy, SupersededBy: x.SupersededBy, Closes: e.Closes, Supersedes: e.Supersedes, + Heat: x.Heat, InDegree: x.InDegree, Summary: e.Summary, Body: e.Content, + Canonical: e.Canonical, Aliases: e.Aliases, Class: e.Class, Actor: e.Actor, Override: e.Override, + FocusActors: e.FocusActors, FocusWhen: focusWhenJSONFrom(e.FocusWhen), Preflight: e.Preflight, Embedded: e.Embedded, + } + if p, err := model.ParseID(e.ID); err == nil { + ej.ShortID = p.TypeCode + "-" + p.LayerCode + "-" + p.Suffix + } + if !x.LandedAt.IsZero() { + ej.LandedAt = x.LandedAt.Format(time.RFC3339) + } + for _, t := range x.Topics { + ej.Topics = append(ej.Topics, t.String()) + } + for _, ref := range e.Refs { + ej.Refs = append(ej.Refs, refJSON(ref)) + } + if e.Index != nil { + ej.Index = &factIndexJSON{Title: e.Index.Title} + if len(e.Index.Topic.Components) > 0 { + ej.Index.Topic = e.Index.Topic.String() + } + } + for _, t := range e.AnnotationTopics { + ej.AnnotationTopics = append(ej.AnnotationTopics, annotationTopicJSON(t)) + } + for _, inv := range e.Involvement { + actors := e.ResolveActors(inv) + if actors == nil { + actors = []string{} + } + ej.Involvement = append(ej.Involvement, involvementJSON{Target: inv.Target, Actors: actors, When: focusWhenJSONFrom(e.ResolveWhen(inv))}) + } + for _, w := range e.Warnings { + ej.Warnings = append(ej.Warnings, warningJSON(w)) + } + for _, a := range x.Attachments { + ej.Attachments = append(ej.Attachments, exportAttachmentJSON(a)) + } + return ej +} + +func focusWhenJSONFrom(w *model.FocusWhen) *focusWhenJSON { + if w == nil { + return nil + } + return &focusWhenJSON{From: w.From, To: w.To} +} diff --git a/internal/query/export.go b/internal/query/export.go new file mode 100644 index 00000000..bfefe351 --- /dev/null +++ b/internal/query/export.go @@ -0,0 +1,66 @@ +package query + +import ( + "time" + + "github.com/networkteam/sdd/internal/model" +) + +// ExportQuery captures intent to export the whole graph as one structured +// document. EXPERIMENTAL: built on the internal model for UI prototyping; the +// shape is not a stable read contract. +type ExportQuery struct { + // RepoIDs selects connected repos exported after the local graph. + RepoIDs []string + // Now is the reference time heat is computed against. + Now time.Time +} + +// ExportAttachmentTextLimit caps the inlined content of a text attachment. +const ExportAttachmentTextLimit = 16 * 1024 + +// ExportResult is the structured output of an ExportQuery: the local graph +// first, then each selected connected repo. +type ExportResult struct { + GeneratedAt time.Time + Repos []ExportRepo +} + +// ExportRepo is one exported graph. Unavailable marks a selected repo with no +// loadable cache. Revision is empty when the graph is not inside a git work +// tree. +type ExportRepo struct { + RepoID string + Local bool + Unavailable bool + Revision string + Entries []ExportEntry + WIP []*model.WIPMarker + LoadIssues []model.LoadIssue +} + +// ExportEntry pairs an entry with the attributes the engine derives for it in +// its owning graph. FullID is set for entries of a connected repo. LandedAt is +// zero when git history does not carry the entry file. +type ExportEntry struct { + Entry *model.Entry + FullID string + Status model.Status + ClosedBy []string + SupersededBy []string + Topics []model.TopicPath + Heat float64 + InDegree int + LandedAt time.Time + Attachments []ExportAttachment +} + +// ExportAttachment describes one attachment file; Content is set only for +// Markdown and plain-text attachments, cut to ExportAttachmentTextLimit. +type ExportAttachment struct { + Name string + Size int64 + ContentType string + Content string + Truncated bool +} From 2ccdd2343622fb53488411446d87fb864dca53c7 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 28 Sep 2026 16:46:57 +0200 Subject: [PATCH 2/4] feat(cli): select export dependencies from the declared closure The experimental export now follows the repo's declared dependencies transitively instead of the machine's connected repos: --dependencies none|referenced|all, with --hops bounding the upstream expansion from cited entries. Base entries are emitted once at the top level, and --out writes the document atomically. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cliapp/export.go | 107 +++++++++-- internal/cliapp/export_test.go | 264 +++++++++++++++++++++----- internal/finders/export.go | 120 ++++++++---- internal/model/crossrepo_selection.go | 99 ++++++++++ internal/presenters/export.go | 27 ++- internal/query/export.go | 60 +++++- 6 files changed, 573 insertions(+), 104 deletions(-) create mode 100644 internal/model/crossrepo_selection.go diff --git a/internal/cliapp/export.go b/internal/cliapp/export.go index f22fe6fa..2ef01dbe 100644 --- a/internal/cliapp/export.go +++ b/internal/cliapp/export.go @@ -2,47 +2,70 @@ package cliapp import ( "context" + "errors" "fmt" + "io" + "os" + "path/filepath" "time" + "github.com/networkteam/sdd/internal/model" "github.com/networkteam/sdd/internal/presenters" "github.com/networkteam/sdd/internal/query" + sddapp "github.com/networkteam/sdd/pkg/application" "github.com/urfave/cli/v3" ) func exportCmd() *cli.Command { return &cli.Command{ Name: "export", - Usage: "EXPERIMENTAL: export the whole graph with derived status, heat and git arrival times as one JSON document (unstable shape, for UI prototyping)", + Usage: "EXPERIMENTAL: export the graph and its declared dependencies with derived status, heat and git arrival times as one JSON document (unstable shape)", Flags: []cli.Flag{ &cli.StringFlag{ Name: "format", Value: "json", Usage: "Output format: json (the only one)", }, - &cli.StringSliceFlag{ - Name: "repo", - Usage: "Also export a connected repo's graph by repo-id (repeatable, additive to the local graph)", + &cli.StringFlag{ + Name: "dependencies", + Value: string(query.ExportDependenciesReferenced), + Usage: "What the declared dependency closure contributes: none, referenced (entries the local graph cites, plus --hops upstream steps) or all (whole graphs)", + }, + &cli.IntFlag{ + Name: "hops", + Value: 1, + Usage: "Upstream steps (refs, closes, supersedes) followed from cited dependency entries with --dependencies referenced; 0 = only the cited ones", }, - &cli.BoolFlag{ - Name: "all-repos", - Usage: "Also export every connected repo", + &cli.StringFlag{ + Name: "out", + Usage: "Write the document to this file (replaced atomically) instead of stdout", }, }, Action: func(ctx context.Context, cmd *cli.Command) error { if format := cmd.String("format"); format != "json" { return fmt.Errorf("--format: unsupported value %q (only json)", format) } - reg, _, err := defaultRepos() + mode, err := query.ParseExportDependencies(cmd.String("dependencies")) if err != nil { - return err + return fmt.Errorf("--dependencies: %w", err) + } + hops := int(cmd.Int("hops")) + if hops < 0 { + return fmt.Errorf("--hops: must not be negative, got %d", hops) } - repoIDs, err := reg.SelectRepoIDs(cmd.StringSlice("repo"), cmd.Bool("all-repos")) + if cmd.IsSet("hops") && mode != query.ExportDependenciesReferenced { + return fmt.Errorf("--hops applies only to --dependencies referenced") + } + + cfg, err := loadConfig() if err != nil { return err } - if err := freshenRepoCaches(ctx, cmd.ErrWriter, repoIDs); err != nil { - return err + var dependencyIDs []string + if mode != query.ExportDependenciesNone && cfg != nil { + if dependencyIDs, err = dependencyClosure(ctx, cmd.ErrWriter, cfg); err != nil { + return err + } } dir, err := resolveGraphDir(cmd) @@ -57,11 +80,69 @@ func exportCmd() *cli.Command { if err != nil { return err } - result, err := f.OnGraph(g).Export(ctx, query.ExportQuery{RepoIDs: repoIDs, Now: time.Now()}) + result, err := f.OnGraph(g).Export(ctx, query.ExportQuery{ + Dependencies: mode, DependencyIDs: dependencyIDs, Hops: hops, Now: time.Now(), + }) if err != nil { return err } + if out := cmd.String("out"); out != "" { + return writeFileAtomic(out, func(w io.Writer) error { return presenters.RenderExportJSON(w, result) }) + } return presenters.RenderExportJSON(cmd.Writer, result) }, } } + +// dependencyClosure resolves the repo's declared dependencies transitively: +// each reached repo's cache is freshened, then its own declarations are read +// from its committed config with the reader `sdd serve` composes dependency +// projects from. +func dependencyClosure(ctx context.Context, errWriter io.Writer, cfg *model.PerRepoConfig) ([]string, error) { + reg, _, err := defaultRepos() + if err != nil { + return nil, err + } + return model.DependencyClosure(cfg.RepoID, cfg.Dependencies, func(repoID string) ([]string, error) { + if err := freshenRepoCaches(ctx, errWriter, []string{repoID}); err != nil { + return nil, err + } + cacheDir, err := reg.CacheDir(repoID) + if err != nil { + return nil, err + } + dependencyCfg, err := sddapp.ReadProjectConfigFS(os.DirFS(cacheDir)) + if errors.Is(err, sddapp.ErrNotAnSDDProject) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading config of dependency %s: %w", repoID, err) + } + return dependencyCfg.Dependencies, nil + }) +} + +// writeFileAtomic renders into a temp file beside path and renames it over +// path, so a reader never sees a partial document. +func writeFileAtomic(path string, render func(io.Writer) error) (err error) { + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") + if err != nil { + return err + } + defer func() { + if err != nil { + _ = os.Remove(tmp.Name()) + } + }() + if err = render(tmp); err != nil { + _ = tmp.Close() + return err + } + if err = tmp.Close(); err != nil { + return err + } + if err = os.Chmod(tmp.Name(), 0o644); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/internal/cliapp/export_test.go b/internal/cliapp/export_test.go index 337ac3ba..c7be5684 100644 --- a/internal/cliapp/export_test.go +++ b/internal/cliapp/export_test.go @@ -4,47 +4,72 @@ import ( "encoding/json" "os" "os/exec" + "path/filepath" "strings" "testing" ) +type exportEntry struct { + ID string `json:"id"` + FullID string `json:"full_id"` + ShortID string `json:"short_id"` + Time string `json:"time"` + LandedAt string `json:"landed_at"` + Status string `json:"status"` + StatusBy string `json:"status_by"` + ClosedBy []string `json:"closed_by"` + Closes []string `json:"closes"` + Summary string `json:"summary"` + Heat float64 `json:"heat"` + InDegree int `json:"in_degree"` + Refs []struct { + ID, Kind, Desc string + } `json:"refs"` + Attachments []struct { + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Content string `json:"content"` + Truncated bool `json:"truncated"` + } `json:"attachments"` +} + type exportDoc struct { - Experimental bool `json:"experimental"` + Experimental bool `json:"experimental"` + Embedded []exportEntry `json:"embedded"` Repos []struct { - RepoID string `json:"repo_id"` - Local bool `json:"local"` - Revision string `json:"revision"` - Entries []struct { - ID string `json:"id"` - ShortID string `json:"short_id"` - Time string `json:"time"` - LandedAt string `json:"landed_at"` - Status string `json:"status"` - StatusBy string `json:"status_by"` - ClosedBy []string `json:"closed_by"` - Closes []string `json:"closes"` - Summary string `json:"summary"` - Heat float64 `json:"heat"` - InDegree int `json:"in_degree"` - Embedded bool `json:"embedded"` - Refs []struct { - ID, Kind, Desc string - } `json:"refs"` - Attachments []struct { - Name string `json:"name"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Content string `json:"content"` - Truncated bool `json:"truncated"` - } `json:"attachments"` - } `json:"entries"` - WIP []struct { + RepoID string `json:"repo_id"` + Local bool `json:"local"` + Revision string `json:"revision"` + Selection *struct { + Mode string `json:"mode"` + Hops *int `json:"hops"` + TotalEntries int `json:"total_entries"` + } `json:"selection"` + Entries []exportEntry `json:"entries"` + WIP []struct { Entry string `json:"entry"` Participant string `json:"participant"` } `json:"wip"` } `json:"repos"` } +// runGit runs git in dir, dating any commit at date when set. +func runGit(t *testing.T, dir, date string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false"}, args...)...) + cmd.Dir = dir + cmd.Env = os.Environ() + if date != "" { + cmd.Env = append(cmd.Env, "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) + } + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + // TestCLIExportJSON pins the export's derived attributes against a repo whose // closing decision arrived on main through a merge commit: its landed_at is the // merge's committer time, not its capture or branch-commit time. @@ -57,17 +82,7 @@ func TestCLIExportJSON(t *testing.T) { ) git := func(date string, args ...string) string { t.Helper() - cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false"}, args...)...) - cmd.Dir = f.root - cmd.Env = os.Environ() - if date != "" { - cmd.Env = append(cmd.Env, "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) - } - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } - return strings.TrimSpace(string(out)) + return runGit(t, f.root, date, args...) } git("", "init", "-q", "-b", "main") git("", "config", "user.email", "test@example.com") @@ -104,16 +119,17 @@ func TestCLIExportJSON(t *testing.T) { t.Errorf("wip = %+v, want one marker on %s", repo.WIP, closerID) } + if len(doc.Embedded) == 0 { + t.Error("export omitted the embedded base entries") + } byID := map[string]int{} - embedded := 0 for i, e := range repo.Entries { byID[e.ID] = i - if e.Embedded { - embedded++ - } } - if embedded == 0 { - t.Error("export omitted the embedded base entries") + for _, e := range doc.Embedded { + if _, ok := byID[e.ID]; ok { + t.Errorf("embedded entry %s also listed under the local repo", e.ID) + } } signal := repo.Entries[byID[appEntryID]] @@ -155,3 +171,159 @@ func TestCLIExportJSON(t *testing.T) { t.Errorf("export --format text = %v with output %q, want an error and no result", bad.err, bad.stdout) } } + +// newDependencyRepo commits a graph declaring repoID (and deps) in a fresh +// repository and connects it, so the export's cache freshening clones it. +func newDependencyRepo(t *testing.T, f *appFixture, repoID string, deps []string, entries map[string]string) { + t.Helper() + dir := t.TempDir() + cfg := "repo_id: " + repoID + "\n" + if len(deps) > 0 { + cfg += "dependencies: [" + strings.Join(deps, ", ") + "]\n" + } + files := map[string]string{".sdd/config.yaml": cfg} + for rel, content := range entries { + files[".sdd/graph/"+rel] = content + } + for rel, content := range files { + path := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + runGit(t, dir, "", "init", "-q", "-b", "main") + runGit(t, dir, "", "config", "user.email", "test@example.com") + runGit(t, dir, "", "config", "user.name", "Test") + runGit(t, dir, "", "add", ".") + runGit(t, dir, "", "commit", "-q", "-m", "graph") + + config, err := os.ReadFile(filepath.Join(f.root, "config/sdd/config.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(config), "repos:") { + config = append(config, "repos:\n"...) + } + config = append(config, " - repo_id: "+repoID+"\n clone_url: "+dir+"\n"...) + f.write(t, "config/sdd/config.yaml", string(config)) +} + +func testEntry(frontmatter string) string { + return "---\n" + frontmatter + "layer: tactical\nconfidence: medium\nparticipants: [Local Author]\n---\n\nBody.\n" +} + +// TestCLIExportDependencySelection walks the declared closure local → dep → +// deep: referenced mode starts from what the local graph cites and follows +// refs and supersedes upstream, crossing into deep only within the hop budget. +func TestCLIExportDependencySelection(t *testing.T) { + f := newAppFixture(t) + const ( + cited = "20260901-100000-d-tac-aaa" + grounding = "20260901-090000-s-tac-bbb" + superseded = "20260901-080000-s-tac-ccc" + uncited = "20260901-070000-s-tac-ddd" + deepEntry = "20260801-100000-s-tac-xxx" + baseEntry = "20260703-094500-d-prc-cap" + ) + newDependencyRepo(t, f, "example.test/deep", nil, map[string]string{ + "2026/08/01-100000-s-tac-xxx.md": testEntry("type: signal\nkind: fact\n"), + }) + newDependencyRepo(t, f, "example.test/dep", []string{"example.test/deep"}, map[string]string{ + "2026/09/01-100000-d-tac-aaa.md": testEntry("type: decision\nkind: directive\nintent: pending\nrefs:\n - {id: " + grounding + ", kind: grounded-in}\n - {id: " + baseEntry + ", kind: related}\nsupersedes: [" + superseded + "]\n"), + "2026/09/01-090000-s-tac-bbb.md": testEntry("type: signal\nkind: gap\nrefs:\n - {id: 'example.test/deep:" + deepEntry + "', kind: grounded-in}\n"), + "2026/09/01-080000-s-tac-ccc.md": testEntry("type: signal\nkind: gap\n"), + "2026/09/01-070000-s-tac-ddd.md": testEntry("type: signal\nkind: gap\n"), + }) + f.write(t, ".sdd/config.yaml", "repo_id: example.test/cli\ngraph_dir: decisions\ndefault_branch: main\ndependencies: [example.test/dep]\n") + f.write(t, "decisions/2026/09/14-030000-d-tac-loc.md", testEntry("type: decision\nkind: directive\nintent: pending\nrefs:\n - {id: 'example.test/dep:"+cited+"', kind: grounded-in}\n")) + + export := func(args ...string) exportDoc { + t.Helper() + result := f.run(t, append([]string{"export"}, args...)...) + if result.err != nil { + t.Fatalf("export %v: %v\n%s", args, result.err, result.stderr) + } + var doc exportDoc + if err := json.Unmarshal([]byte(result.stdout), &doc); err != nil { + t.Fatalf("export %v emitted invalid JSON: %v", args, err) + } + return doc + } + type repoWant struct { + repoID string + entries []string + } + for _, tt := range []struct { + name string + args []string + mode string + hops int + repos []repoWant + }{ + {name: "none", args: []string{"--dependencies", "none"}}, + {name: "referenced cited only", args: []string{"--hops", "0"}, mode: "referenced", hops: 0, repos: []repoWant{ + {"example.test/dep", []string{cited}}, {"example.test/deep", nil}, + }}, + {name: "referenced default one hop", mode: "referenced", hops: 1, repos: []repoWant{ + {"example.test/dep", []string{superseded, grounding, cited}}, {"example.test/deep", nil}, + }}, + {name: "referenced crosses into deep", args: []string{"--hops", "2"}, mode: "referenced", hops: 2, repos: []repoWant{ + {"example.test/dep", []string{superseded, grounding, cited}}, {"example.test/deep", []string{deepEntry}}, + }}, + {name: "all", args: []string{"--dependencies", "all"}, mode: "all", repos: []repoWant{ + {"example.test/dep", []string{uncited, superseded, grounding, cited}}, {"example.test/deep", []string{deepEntry}}, + }}, + } { + t.Run(tt.name, func(t *testing.T) { + doc := export(tt.args...) + if len(doc.Repos) != 1+len(tt.repos) || doc.Repos[0].RepoID != "example.test/cli" || doc.Repos[0].Selection != nil { + t.Fatalf("repos = %d, first %s selection %v; want local plus %d", len(doc.Repos), doc.Repos[0].RepoID, doc.Repos[0].Selection, len(tt.repos)) + } + for i, want := range tt.repos { + repo := doc.Repos[i+1] + var got []string + for _, e := range repo.Entries { + got = append(got, e.ID) + if e.FullID != want.repoID+":"+e.ID { + t.Errorf("%s entry full_id = %q", want.repoID, e.FullID) + } + } + if repo.RepoID != want.repoID || strings.Join(got, ",") != strings.Join(want.entries, ",") { + t.Errorf("repo %d = %s %v, want %s %v", i+1, repo.RepoID, got, want.repoID, want.entries) + } + sel := repo.Selection + wantTotal := map[string]int{"example.test/dep": 4, "example.test/deep": 1}[want.repoID] + if sel == nil || sel.Mode != tt.mode || sel.TotalEntries != wantTotal || (sel.Hops == nil) != (tt.mode != "referenced") || + (sel.Hops != nil && *sel.Hops != tt.hops) { + t.Errorf("%s selection = %+v, want mode %s hops %d total %d", want.repoID, sel, tt.mode, tt.hops, wantTotal) + } + } + found := false + for _, e := range doc.Embedded { + found = found || e.ID == baseEntry + } + if !found { + t.Errorf("base entry %s cited from a dependency is missing from the embedded list", baseEntry) + } + }) + } + + out := filepath.Join(f.root, "export.json") + if result := f.run(t, "export", "--out", out); result.err != nil || result.stdout != "" { + t.Fatalf("export --out = %v with stdout %q", result.err, result.stdout) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + var doc exportDoc + if err := json.Unmarshal(data, &doc); err != nil || len(doc.Repos) != 3 { + t.Errorf("--out file holds %d repos (%v), want 3", len(doc.Repos), err) + } + if bad := f.run(t, "export", "--dependencies", "all", "--hops", "2"); bad.err == nil { + t.Error("--hops with --dependencies all was accepted") + } +} diff --git a/internal/finders/export.go b/internal/finders/export.go index 42245bd3..93cc210e 100644 --- a/internal/finders/export.go +++ b/internal/finders/export.go @@ -40,9 +40,9 @@ var attachmentTypes = map[string]string{ ".json": "application/json", } -// Export assembles the held graph and the selected connected repos into one -// document carrying each entry with the attributes the engine derives for it -// in its owning graph (see query.ExportQuery). +// Export assembles the held graph and its dependency repos into one document +// carrying each entry with the attributes the engine derives for it in its +// owning graph (see query.ExportQuery). func (gf *GraphFinder) Export(ctx context.Context, q query.ExportQuery) (*query.ExportResult, error) { if gf.graph == nil { return nil, fmt.Errorf("export: graph is required") @@ -51,17 +51,33 @@ func (gf *GraphFinder) Export(ctx context.Context, q query.ExportQuery) (*query. if err != nil { return nil, err } - decay, err := model.DecayByName(model.DefaultDecayName) - if err != nil { + x := exporter{now: q.Now} + if x.decay, err = model.DecayByName(model.DefaultDecayName); err != nil { return nil, err } - local, err := gf.exportRepo(ctx, repoID, true, decay, q.Now) + local, err := gf.exportRepo(ctx, x, repoID, nil) if err != nil { return nil, err } + local.Local = true result := &query.ExportResult{GeneratedAt: q.Now, Repos: []query.ExportRepo{local}} - for _, id := range q.RepoIDs { + for _, e := range gf.graph.Entries { + if e.Embedded { + result.Embedded = append(result.Embedded, x.entry(gf.graph, e, nil)) + } + } + if q.Dependencies == query.ExportDependenciesNone { + return result, nil + } + + var cited map[string]map[string]bool + if q.Dependencies == query.ExportDependenciesReferenced { + if cited, err = model.CitedAcross(gf.graph, q.DependencyIDs, q.Hops); err != nil { + return nil, err + } + } + for _, id := range q.DependencyIDs { member, err := gf.graph.MemberGraph(id) if err != nil { return nil, fmt.Errorf("loading graph for %s: %w", id, err) @@ -70,62 +86,100 @@ func (gf *GraphFinder) Export(ctx context.Context, q query.ExportQuery) (*query. result.Repos = append(result.Repos, query.ExportRepo{RepoID: id, Unavailable: true}) continue } - repo, err := gf.finder.OnGraph(member).exportRepo(ctx, id, false, decay, q.Now) + var selected map[string]bool + if cited != nil { + selected = cited[id] + if selected == nil { + selected = map[string]bool{} + } + } + repo, err := gf.finder.OnGraph(member).exportRepo(ctx, x, id, selected) if err != nil { return nil, err } + repo.Selection = &query.ExportSelection{Mode: q.Dependencies, Hops: q.Hops, TotalEntries: ownEntries(member)} result.Repos = append(result.Repos, repo) } return result, nil } -// exportRepo exports the held graph. Embedded base entries are identical in -// every graph, so only the local repo carries them. -func (gf *GraphFinder) exportRepo(ctx context.Context, repoID string, local bool, decay model.DecayFunc, now time.Time) (query.ExportRepo, error) { +// exporter holds what every exported entry is derived against. +type exporter struct { + decay model.DecayFunc + now time.Time + arrivals map[string]time.Time +} + +// exportRepo exports the held graph's own entries (embedded base entries are +// exported once, outside any repo), restricted to selected when non-nil. +func (gf *GraphFinder) exportRepo(ctx context.Context, x exporter, repoID string, selected map[string]bool) (query.ExportRepo, error) { g := gf.graph - wip, err := gf.WIPMarkers() + keep := func(id string) bool { return selected == nil || selected[id] } + markers, err := gf.WIPMarkers() if err != nil { return query.ExportRepo{}, err } - repo := query.ExportRepo{RepoID: repoID, Local: local, WIP: wip, LoadIssues: g.LoadIssues} + repo := query.ExportRepo{RepoID: repoID, LoadIssues: g.LoadIssues} + for _, m := range markers { + if keep(m.Entry) { + repo.WIP = append(repo.WIP, m) + } + } - var arrivals map[string]time.Time if h, dir := gf.finder.gitHistory, g.GraphDir(); h != nil && dir != "" && h.InWorkTree(ctx, dir) { if repo.Revision, err = h.HeadRevision(ctx, dir); err != nil { return query.ExportRepo{}, err } - if arrivals, err = h.FileArrivals(ctx, dir); err != nil { + if x.arrivals, err = h.FileArrivals(ctx, dir); err != nil { return query.ExportRepo{}, err } } for _, e := range g.Entries { - if e.Embedded && !local { + if e.Embedded || !keep(e.ID) { continue } - entry := query.ExportEntry{ - Entry: e, - Status: g.DerivedStatus(e), - ClosedBy: g.ClosedBy[e.ID], - SupersededBy: g.SupersededBy[e.ID], - Topics: g.EffectiveTopics(e), - Heat: model.HeatScore(g, e, decay, now), - InDegree: int(model.InDegreeScore(g, e)), - } - if key, ok := g.DisplayID(e.ID); ok && key != e.ID { - entry.FullID = key - } - if rel, err := model.IDToRelPath(e.ID); err == nil { - entry.LandedAt = arrivals[filepath.ToSlash(rel)] - } - if entry.Attachments, err = gf.exportAttachments(e); err != nil { + attachments, err := gf.exportAttachments(e) + if err != nil { return query.ExportRepo{}, err } - repo.Entries = append(repo.Entries, entry) + repo.Entries = append(repo.Entries, x.entry(g, e, attachments)) } return repo, nil } +// entry derives one entry's exported attributes in its owning graph g. +func (x exporter) entry(g *model.Graph, e *model.Entry, attachments []query.ExportAttachment) query.ExportEntry { + entry := query.ExportEntry{ + Entry: e, + Status: g.DerivedStatus(e), + ClosedBy: g.ClosedBy[e.ID], + SupersededBy: g.SupersededBy[e.ID], + Topics: g.EffectiveTopics(e), + Heat: model.HeatScore(g, e, x.decay, x.now), + InDegree: int(model.InDegreeScore(g, e)), + Attachments: attachments, + } + if key, ok := g.DisplayID(e.ID); ok && key != e.ID { + entry.FullID = key + } + if rel, err := model.IDToRelPath(e.ID); err == nil { + entry.LandedAt = x.arrivals[filepath.ToSlash(rel)] + } + return entry +} + +// ownEntries counts a graph's entries apart from the embedded base entries. +func ownEntries(g *model.Graph) int { + n := 0 + for _, e := range g.Entries { + if !e.Embedded { + n++ + } + } + return n +} + // exportAttachments describes an entry's attachments through the shared // accessor, inlining Markdown and plain-text content up to the export limit. func (gf *GraphFinder) exportAttachments(e *model.Entry) ([]query.ExportAttachment, error) { diff --git a/internal/model/crossrepo_selection.go b/internal/model/crossrepo_selection.go new file mode 100644 index 00000000..5a64231c --- /dev/null +++ b/internal/model/crossrepo_selection.go @@ -0,0 +1,99 @@ +package model + +// DependencyClosure walks declared dependencies breadth-first from direct and +// returns every reached repo ID once, in first-reached order, never root +// itself. declared returns a reached repo's own declarations; a repo it cannot +// resolve returns none, so it stays listed but nothing behind it is reached. +func DependencyClosure(root string, direct []string, declared func(repoID string) ([]string, error)) ([]string, error) { + seen := map[string]bool{root: true} + var closure []string + queue := append([]string(nil), direct...) + for len(queue) > 0 { + repoID := queue[0] + queue = queue[1:] + if seen[repoID] { + continue + } + seen[repoID] = true + closure = append(closure, repoID) + next, err := declared(repoID) + if err != nil { + return nil, err + } + queue = append(queue, next...) + } + return closure, nil +} + +// CitedAcross selects the entries of other repos that the local graph cites: +// the targets of its cross-repo refs, closes and supersedes, then up to hops +// further upstream steps along the same fields from each selected entry — a +// bare ID within that entry's repo, a cross-repo ID into the repo it names. +// Only repos in scope are entered, and embedded entries are never selected +// (no repo owns them). The result maps repo ID to the selected entry IDs. +func CitedAcross(local *Graph, scope []string, hops int) (map[string]map[string]bool, error) { + inScope := make(map[string]bool, len(scope)) + for _, repoID := range scope { + inScope[repoID] = true + } + type step struct { + repoID string + entry *Entry + depth int + } + selected := map[string]map[string]bool{} + var queue []step + add := func(repoID, id string, depth int) error { + if !inScope[repoID] || selected[repoID][id] { + return nil + } + member, err := local.MemberGraph(repoID) + if err != nil || member == nil { + return err + } + e, ok := member.ByID[id] + if !ok || e.Embedded { + return nil + } + if selected[repoID] == nil { + selected[repoID] = map[string]bool{} + } + selected[repoID][id] = true + queue = append(queue, step{repoID: repoID, entry: e, depth: depth}) + return nil + } + + for _, e := range local.Entries { + for _, id := range upstreamIDs(e) { + if repoID, entryID, ok := SplitCrossRepoID(id); ok { + if err := add(repoID, entryID, 0); err != nil { + return nil, err + } + } + } + } + for len(queue) > 0 { + s := queue[0] + queue = queue[1:] + if s.depth >= hops { + continue + } + for _, id := range upstreamIDs(s.entry) { + repoID, entryID := s.repoID, id + if r, eid, ok := SplitCrossRepoID(id); ok { + repoID, entryID = r, eid + } + if err := add(repoID, entryID, s.depth+1); err != nil { + return nil, err + } + } + } + return selected, nil +} + +// upstreamIDs lists the IDs an entry points at: refs, closes, supersedes. +func upstreamIDs(e *Entry) []string { + ids := RefIDs(e.Refs) + ids = append(ids, e.Closes...) + return append(ids, e.Supersedes...) +} diff --git a/internal/presenters/export.go b/internal/presenters/export.go index 6d0bdcc1..26e36d29 100644 --- a/internal/presenters/export.go +++ b/internal/presenters/export.go @@ -17,6 +17,9 @@ type exportJSON struct { Experimental bool `json:"experimental"` GeneratedAt string `json:"generated_at"` Repos []exportRepoJSON `json:"repos"` + // Embedded lists the base entries once; an ID from any repo that names + // one resolves here (query.ExportResult). + Embedded []exportEntryJSON `json:"embedded"` } type exportRepoJSON struct { @@ -24,6 +27,7 @@ type exportRepoJSON struct { Local bool `json:"local"` Unavailable bool `json:"unavailable,omitempty"` Revision string `json:"revision,omitempty"` + Selection *selectionJSON `json:"selection,omitempty"` Entries []exportEntryJSON `json:"entries"` WIP []exportWIPJSON `json:"wip"` LoadIssues []loadIssueJSON `json:"load_issues,omitempty"` @@ -64,11 +68,16 @@ type exportEntryJSON struct { FocusWhen *focusWhenJSON `json:"focus_when,omitempty"` Involvement []involvementJSON `json:"involvement,omitempty"` Preflight string `json:"preflight,omitempty"` - Embedded bool `json:"embedded,omitempty"` Warnings []warningJSON `json:"warnings,omitempty"` Attachments []exportAttachmentJSON `json:"attachments,omitempty"` } +type selectionJSON struct { + Mode query.ExportDependencies `json:"mode"` + Hops *int `json:"hops,omitempty"` + TotalEntries int `json:"total_entries"` +} + type refJSON struct { ID string `json:"id"` Kind model.RefKind `json:"kind"` @@ -129,12 +138,21 @@ type loadIssueJSON struct { // RenderExportJSON writes the experimental whole-graph export document. func RenderExportJSON(w io.Writer, r *query.ExportResult) error { - out := exportJSON{Experimental: true, GeneratedAt: r.GeneratedAt.Format(time.RFC3339), Repos: []exportRepoJSON{}} + out := exportJSON{ + Experimental: true, GeneratedAt: r.GeneratedAt.Format(time.RFC3339), + Repos: []exportRepoJSON{}, Embedded: []exportEntryJSON{}, + } for _, repo := range r.Repos { rj := exportRepoJSON{ RepoID: repo.RepoID, Local: repo.Local, Unavailable: repo.Unavailable, Revision: repo.Revision, Entries: []exportEntryJSON{}, WIP: []exportWIPJSON{}, } + if sel := repo.Selection; sel != nil { + rj.Selection = &selectionJSON{Mode: sel.Mode, TotalEntries: sel.TotalEntries} + if sel.Mode == query.ExportDependenciesReferenced { + rj.Selection.Hops = &sel.Hops + } + } for _, e := range repo.Entries { rj.Entries = append(rj.Entries, exportEntryJSONFrom(e)) } @@ -149,6 +167,9 @@ func RenderExportJSON(w io.Writer, r *query.ExportResult) error { } out.Repos = append(out.Repos, rj) } + for _, e := range r.Embedded { + out.Embedded = append(out.Embedded, exportEntryJSONFrom(e)) + } enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(out) @@ -163,7 +184,7 @@ func exportEntryJSONFrom(x query.ExportEntry) exportEntryJSON { ClosedBy: x.ClosedBy, SupersededBy: x.SupersededBy, Closes: e.Closes, Supersedes: e.Supersedes, Heat: x.Heat, InDegree: x.InDegree, Summary: e.Summary, Body: e.Content, Canonical: e.Canonical, Aliases: e.Aliases, Class: e.Class, Actor: e.Actor, Override: e.Override, - FocusActors: e.FocusActors, FocusWhen: focusWhenJSONFrom(e.FocusWhen), Preflight: e.Preflight, Embedded: e.Embedded, + FocusActors: e.FocusActors, FocusWhen: focusWhenJSONFrom(e.FocusWhen), Preflight: e.Preflight, } if p, err := model.ParseID(e.ID); err == nil { ej.ShortID = p.TypeCode + "-" + p.LayerCode + "-" + p.Suffix diff --git a/internal/query/export.go b/internal/query/export.go index bfefe351..9073afdb 100644 --- a/internal/query/export.go +++ b/internal/query/export.go @@ -1,17 +1,46 @@ package query import ( + "fmt" "time" "github.com/networkteam/sdd/internal/model" ) +// ExportDependencies selects what each dependency repo contributes to an +// export. +type ExportDependencies string + +const ( + // ExportDependenciesNone exports the local graph only. + ExportDependenciesNone ExportDependencies = "none" + // ExportDependenciesReferenced exports the dependency entries the local + // graph cites, expanded upstream by ExportQuery.Hops (model.CitedAcross). + ExportDependenciesReferenced ExportDependencies = "referenced" + // ExportDependenciesAll exports whole dependency graphs. + ExportDependenciesAll ExportDependencies = "all" +) + +// ParseExportDependencies resolves a dependency selection mode by name. +func ParseExportDependencies(s string) (ExportDependencies, error) { + switch mode := ExportDependencies(s); mode { + case ExportDependenciesNone, ExportDependenciesReferenced, ExportDependenciesAll: + return mode, nil + } + return "", fmt.Errorf("unknown dependency selection %q (known: none, referenced, all)", s) +} + // ExportQuery captures intent to export the whole graph as one structured -// document. EXPERIMENTAL: built on the internal model for UI prototyping; the -// shape is not a stable read contract. +// document. EXPERIMENTAL: built on the internal model; the shape is not a +// stable read contract. type ExportQuery struct { - // RepoIDs selects connected repos exported after the local graph. - RepoIDs []string + // Dependencies selects what each dependency repo contributes. + Dependencies ExportDependencies + // DependencyIDs is the local repo's declared dependency closure, in walk + // order (model.DependencyClosure). + DependencyIDs []string + // Hops bounds the upstream expansion of ExportDependenciesReferenced. + Hops int // Now is the reference time heat is computed against. Now time.Time } @@ -20,28 +49,41 @@ type ExportQuery struct { const ExportAttachmentTextLimit = 16 * 1024 // ExportResult is the structured output of an ExportQuery: the local graph -// first, then each selected connected repo. +// first, then each dependency repo. Embedded carries the base entries +// compiled into the binary once, outside every repo: they are identical in +// each graph, so any repo's ID for one resolves there. Their derived +// attributes are the local graph's. type ExportResult struct { GeneratedAt time.Time Repos []ExportRepo + Embedded []ExportEntry } -// ExportRepo is one exported graph. Unavailable marks a selected repo with no +// ExportRepo is one exported graph. Unavailable marks a dependency with no // loadable cache. Revision is empty when the graph is not inside a git work -// tree. +// tree. Selection is set for dependency repos. type ExportRepo struct { RepoID string Local bool Unavailable bool Revision string + Selection *ExportSelection Entries []ExportEntry WIP []*model.WIPMarker LoadIssues []model.LoadIssue } +// ExportSelection records how much of a dependency graph was exported: +// TotalEntries counts the repo's own entries, exported or not. +type ExportSelection struct { + Mode ExportDependencies + Hops int + TotalEntries int +} + // ExportEntry pairs an entry with the attributes the engine derives for it in -// its owning graph. FullID is set for entries of a connected repo. LandedAt is -// zero when git history does not carry the entry file. +// its owning graph. FullID is set for entries of a dependency repo. LandedAt +// is zero when git history does not carry the entry file. type ExportEntry struct { Entry *model.Entry FullID string From c07c75b038d615986c9899bc78133b50f0451dd0 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 5 Oct 2026 15:58:40 +0200 Subject: [PATCH 3/4] fix(cli): address the review findings on the experimental export Drops landed_at, which no viewer design carried forward reads; this also removes the git log pass that failed before a repository's first commit. Writes --out with the temp file's owner-only mode instead of forcing 0644. Collects embedded base entries from every exported graph, so a base entry the local graph overrides stays listed for a dependency citing the original. Typed repo IDs and the shared closure walk follow in a separate pull request: 20261005-154635-d-tac-1a7, 20261005-155120-d-tac-5vj. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cliapp/export.go | 9 ++- internal/cliapp/export_test.go | 117 +++++++++++++++++---------------- internal/finders/export.go | 35 +++++----- internal/git/history.go | 19 ------ internal/model/arrivals.go | 34 ---------- internal/presenters/export.go | 4 -- internal/query/export.go | 11 ++-- 7 files changed, 88 insertions(+), 141 deletions(-) delete mode 100644 internal/model/arrivals.go diff --git a/internal/cliapp/export.go b/internal/cliapp/export.go index 2ef01dbe..3c31b496 100644 --- a/internal/cliapp/export.go +++ b/internal/cliapp/export.go @@ -19,7 +19,7 @@ import ( func exportCmd() *cli.Command { return &cli.Command{ Name: "export", - Usage: "EXPERIMENTAL: export the graph and its declared dependencies with derived status, heat and git arrival times as one JSON document (unstable shape)", + Usage: "EXPERIMENTAL: export the graph and its declared dependencies with derived status and heat as one JSON document (unstable shape)", Flags: []cli.Flag{ &cli.StringFlag{ Name: "format", @@ -123,7 +123,9 @@ func dependencyClosure(ctx context.Context, errWriter io.Writer, cfg *model.PerR } // writeFileAtomic renders into a temp file beside path and renames it over -// path, so a reader never sees a partial document. +// path, so a reader never sees a partial document. The file keeps the temp +// file's owner-only mode: the document carries entry bodies and attachment +// text. func writeFileAtomic(path string, render func(io.Writer) error) (err error) { tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") if err != nil { @@ -141,8 +143,5 @@ func writeFileAtomic(path string, render func(io.Writer) error) (err error) { if err = tmp.Close(); err != nil { return err } - if err = os.Chmod(tmp.Name(), 0o644); err != nil { - return err - } return os.Rename(tmp.Name(), path) } diff --git a/internal/cliapp/export_test.go b/internal/cliapp/export_test.go index c7be5684..a9beca75 100644 --- a/internal/cliapp/export_test.go +++ b/internal/cliapp/export_test.go @@ -14,12 +14,12 @@ type exportEntry struct { FullID string `json:"full_id"` ShortID string `json:"short_id"` Time string `json:"time"` - LandedAt string `json:"landed_at"` Status string `json:"status"` StatusBy string `json:"status_by"` ClosedBy []string `json:"closed_by"` Closes []string `json:"closes"` Summary string `json:"summary"` + Body string `json:"body"` Heat float64 `json:"heat"` InDegree int `json:"in_degree"` Refs []struct { @@ -54,15 +54,11 @@ type exportDoc struct { } `json:"repos"` } -// runGit runs git in dir, dating any commit at date when set. -func runGit(t *testing.T, dir, date string, args ...string) string { +// runGit runs git in dir. +func runGit(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", append([]string{"-c", "commit.gpgsign=false"}, args...)...) cmd.Dir = dir - cmd.Env = os.Environ() - if date != "" { - cmd.Env = append(cmd.Env, "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) - } out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) @@ -70,47 +66,47 @@ func runGit(t *testing.T, dir, date string, args ...string) string { return strings.TrimSpace(string(out)) } -// TestCLIExportJSON pins the export's derived attributes against a repo whose -// closing decision arrived on main through a merge commit: its landed_at is the -// merge's committer time, not its capture or branch-commit time. +// TestCLIExportJSON pins the export's derived attributes against a git repo: +// identity, status, refs, heat, attachments, WIP, and the HEAD revision, which +// stays empty before the first commit. func TestCLIExportJSON(t *testing.T) { f := newAppFixture(t) - const ( - closerID = "20260914-030000-d-tac-mrg" - pendingID = "20260914-040000-s-tac-new" - mergedAt = "2026-09-15T10:00:00+02:00" - ) - git := func(date string, args ...string) string { + const closerID = "20260914-030000-d-tac-dec" + git := func(args ...string) string { t.Helper() - return runGit(t, f.root, date, args...) + return runGit(t, f.root, args...) } - git("", "init", "-q", "-b", "main") - git("", "config", "user.email", "test@example.com") - git("", "config", "user.name", "Test") - git("2026-09-14T02:00:10+02:00", "add", "decisions") - git("2026-09-14T02:00:10+02:00", "commit", "-q", "-m", "capture signal") - git("", "checkout", "-q", "-b", "feature") - f.write(t, "decisions/2026/09/14-030000-d-tac-mrg.md", "---\ntype: decision\nkind: directive\nintent: pending\nlayer: tactical\nconfidence: high\nparticipants: [Local Author]\nrefs:\n - id: "+appEntryID+"\n kind: addresses\n desc: answers the gap\ncloses: ["+appEntryID+"]\n---\n\nThe branch decision.\n") - f.write(t, "decisions/2026/09/14-030000-d-tac-mrg/notes.md", strings.Repeat("A note line.\n", 2000)) - git("2026-09-14T03:00:30+02:00", "add", "decisions") - git("2026-09-14T03:00:30+02:00", "commit", "-q", "-m", "capture decision") - git("", "checkout", "-q", "main") - git(mergedAt, "merge", "-q", "--no-ff", "-m", "merge feature", "feature") - f.write(t, "decisions/2026/09/14-040000-s-tac-new.md", "---\ntype: signal\nkind: gap\nlayer: tactical\nconfidence: low\nparticipants: [Local Author]\n---\n\nNot committed yet.\n") + git("init", "-q", "-b", "main") + git("config", "user.email", "test@example.com") + git("config", "user.name", "Test") + f.write(t, "decisions/2026/09/14-030000-d-tac-dec.md", "---\ntype: decision\nkind: directive\nintent: pending\nlayer: tactical\nconfidence: high\nparticipants: [Local Author]\nrefs:\n - id: "+appEntryID+"\n kind: addresses\n desc: answers the gap\ncloses: ["+appEntryID+"]\n---\n\nThe decision.\n") + f.write(t, "decisions/2026/09/14-030000-d-tac-dec/notes.md", strings.Repeat("A note line.\n", 2000)) f.write(t, "decisions/wip/20260914-050000-local-author.md", "---\nentry: "+closerID+"\nparticipant: Local Author\n---\n\nFollowing up.\n") - head := git("", "rev-parse", "HEAD") - result := f.run(t, "export", "--format", "json") - if result.err != nil { - t.Fatalf("export: %v\n%s", result.err, result.stderr) - } - var doc exportDoc - if err := json.Unmarshal([]byte(result.stdout), &doc); err != nil { - t.Fatalf("export emitted invalid JSON: %v\n%s", err, result.stdout) + export := func() exportDoc { + t.Helper() + result := f.run(t, "export", "--format", "json") + if result.err != nil { + t.Fatalf("export: %v\n%s", result.err, result.stderr) + } + var doc exportDoc + if err := json.Unmarshal([]byte(result.stdout), &doc); err != nil { + t.Fatalf("export emitted invalid JSON: %v\n%s", err, result.stdout) + } + if !doc.Experimental || len(doc.Repos) != 1 { + t.Fatalf("experimental = %v, repos = %d; want true, 1", doc.Experimental, len(doc.Repos)) + } + return doc } - if !doc.Experimental || len(doc.Repos) != 1 { - t.Fatalf("experimental = %v, repos = %d; want true, 1", doc.Experimental, len(doc.Repos)) + + if rev := export().Repos[0].Revision; rev != "" { + t.Errorf("revision before the first commit = %q, want none", rev) } + git("add", "decisions") + git("commit", "-q", "-m", "capture") + head := git("rev-parse", "HEAD") + + doc := export() repo := doc.Repos[0] if repo.RepoID != "example.test/cli" || !repo.Local || repo.Revision != head { t.Errorf("repo = %s local=%v revision=%s; want example.test/cli local=true revision=%s", repo.RepoID, repo.Local, repo.Revision, head) @@ -142,14 +138,8 @@ func TestCLIExportJSON(t *testing.T) { if signal.InDegree != 1 || signal.Heat <= 0 { t.Errorf("signal in_degree = %d heat = %v; want 1 and positive heat", signal.InDegree, signal.Heat) } - if signal.LandedAt != "2026-09-14T02:00:10+02:00" { - t.Errorf("signal landed_at = %q, want its own commit time", signal.LandedAt) - } closer := repo.Entries[byID[closerID]] - if closer.LandedAt != mergedAt { - t.Errorf("merged decision landed_at = %q, want merge commit time %s", closer.LandedAt, mergedAt) - } if closer.Status != "active" || len(closer.Closes) != 1 || len(closer.Refs) != 1 || closer.Refs[0].ID != appEntryID || closer.Refs[0].Kind != "addresses" || closer.Refs[0].Desc != "answers the gap" { t.Errorf("decision = status %s closes %v refs %+v", closer.Status, closer.Closes, closer.Refs) @@ -163,10 +153,6 @@ func TestCLIExportJSON(t *testing.T) { t.Errorf("attachment = %s size %d type %s truncated %v content %d bytes", a.Name, a.Size, a.ContentType, a.Truncated, len(a.Content)) } - if pending := repo.Entries[byID[pendingID]]; pending.LandedAt != "" { - t.Errorf("uncommitted entry landed_at = %q, want none", pending.LandedAt) - } - if bad := f.run(t, "export", "--format", "text"); bad.err == nil || bad.stdout != "" { t.Errorf("export --format text = %v with output %q, want an error and no result", bad.err, bad.stdout) } @@ -194,11 +180,11 @@ func newDependencyRepo(t *testing.T, f *appFixture, repoID string, deps []string t.Fatal(err) } } - runGit(t, dir, "", "init", "-q", "-b", "main") - runGit(t, dir, "", "config", "user.email", "test@example.com") - runGit(t, dir, "", "config", "user.name", "Test") - runGit(t, dir, "", "add", ".") - runGit(t, dir, "", "commit", "-q", "-m", "graph") + runGit(t, dir, "init", "-q", "-b", "main") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-q", "-m", "graph") config, err := os.ReadFile(filepath.Join(f.root, "config/sdd/config.yaml")) if err != nil { @@ -218,6 +204,8 @@ func testEntry(frontmatter string) string { // TestCLIExportDependencySelection walks the declared closure local → dep → // deep: referenced mode starts from what the local graph cites and follows // refs and supersedes upstream, crossing into deep only within the hop budget. +// The local graph overrides a base entry the dependency still cites, so the +// original must stay listed among the embedded entries. func TestCLIExportDependencySelection(t *testing.T) { f := newAppFixture(t) const ( @@ -227,17 +215,19 @@ func TestCLIExportDependencySelection(t *testing.T) { uncited = "20260901-070000-s-tac-ddd" deepEntry = "20260801-100000-s-tac-xxx" baseEntry = "20260703-094500-d-prc-cap" + overridden = "20260812-180000-s-prc-typ" ) newDependencyRepo(t, f, "example.test/deep", nil, map[string]string{ "2026/08/01-100000-s-tac-xxx.md": testEntry("type: signal\nkind: fact\n"), }) newDependencyRepo(t, f, "example.test/dep", []string{"example.test/deep"}, map[string]string{ - "2026/09/01-100000-d-tac-aaa.md": testEntry("type: decision\nkind: directive\nintent: pending\nrefs:\n - {id: " + grounding + ", kind: grounded-in}\n - {id: " + baseEntry + ", kind: related}\nsupersedes: [" + superseded + "]\n"), + "2026/09/01-100000-d-tac-aaa.md": testEntry("type: decision\nkind: directive\nintent: pending\nrefs:\n - {id: " + grounding + ", kind: grounded-in}\n - {id: " + baseEntry + ", kind: related}\n - {id: " + overridden + ", kind: related}\nsupersedes: [" + superseded + "]\n"), "2026/09/01-090000-s-tac-bbb.md": testEntry("type: signal\nkind: gap\nrefs:\n - {id: 'example.test/deep:" + deepEntry + "', kind: grounded-in}\n"), "2026/09/01-080000-s-tac-ccc.md": testEntry("type: signal\nkind: gap\n"), "2026/09/01-070000-s-tac-ddd.md": testEntry("type: signal\nkind: gap\n"), }) f.write(t, ".sdd/config.yaml", "repo_id: example.test/cli\ngraph_dir: decisions\ndefault_branch: main\ndependencies: [example.test/dep]\n") + f.write(t, "decisions/2026/08/12-180000-s-prc-typ.md", "---\ntype: signal\nkind: fact\nlayer: process\nconfidence: high\nparticipants: [Local Author]\n---\n\nLocal override.\n") f.write(t, "decisions/2026/09/14-030000-d-tac-loc.md", testEntry("type: decision\nkind: directive\nintent: pending\nrefs:\n - {id: 'example.test/dep:"+cited+"', kind: grounded-in}\n")) export := func(args ...string) exportDoc { @@ -302,19 +292,32 @@ func TestCLIExportDependencySelection(t *testing.T) { } } found := false - for _, e := range doc.Embedded { + var original *exportEntry + for i, e := range doc.Embedded { found = found || e.ID == baseEntry + if e.ID == overridden { + original = &doc.Embedded[i] + } } if !found { t.Errorf("base entry %s cited from a dependency is missing from the embedded list", baseEntry) } + if tt.mode != "" && (original == nil || original.Body == "" || original.Body == "Local override.") { + t.Errorf("base entry %s overridden locally but cited from a dependency: embedded = %+v, want the original", overridden, original) + } }) } out := filepath.Join(f.root, "export.json") + if err := os.WriteFile(out, []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } if result := f.run(t, "export", "--out", out); result.err != nil || result.stdout != "" { t.Fatalf("export --out = %v with stdout %q", result.err, result.stdout) } + if info, err := os.Stat(out); err != nil || info.Mode().Perm()&0o077 != 0 { + t.Errorf("--out file mode = %v (%v), want owner-only", info.Mode().Perm(), err) + } data, err := os.ReadFile(out) if err != nil { t.Fatal(err) diff --git a/internal/finders/export.go b/internal/finders/export.go index 93cc210e..52074532 100644 --- a/internal/finders/export.go +++ b/internal/finders/export.go @@ -21,9 +21,6 @@ type GitHistory interface { // HeadRevision returns the commit hash HEAD points at, "" before the // first commit. HeadRevision(ctx context.Context, dir string) (string, error) - // FileArrivals maps each file under dir (slash-separated, relative to - // dir) to the committer time of the first-parent commit that added it. - FileArrivals(ctx context.Context, dir string) (map[string]time.Time, error) } // attachmentSniffBytes is how much of a non-text attachment is read to @@ -62,11 +59,8 @@ func (gf *GraphFinder) Export(ctx context.Context, q query.ExportQuery) (*query. } local.Local = true result := &query.ExportResult{GeneratedAt: q.Now, Repos: []query.ExportRepo{local}} - for _, e := range gf.graph.Entries { - if e.Embedded { - result.Embedded = append(result.Embedded, x.entry(gf.graph, e, nil)) - } - } + embedded := map[string]bool{} + x.addEmbedded(result, embedded, gf.graph) if q.Dependencies == query.ExportDependenciesNone { return result, nil } @@ -99,15 +93,28 @@ func (gf *GraphFinder) Export(ctx context.Context, q query.ExportQuery) (*query. } repo.Selection = &query.ExportSelection{Mode: q.Dependencies, Hops: q.Hops, TotalEntries: ownEntries(member)} result.Repos = append(result.Repos, repo) + x.addEmbedded(result, embedded, member) } return result, nil } // exporter holds what every exported entry is derived against. type exporter struct { - decay model.DecayFunc - now time.Time - arrivals map[string]time.Time + decay model.DecayFunc + now time.Time +} + +// addEmbedded appends the base entries g carries that are not listed yet, +// derived in g. Collecting them from every exported graph keeps a base entry +// the local graph overrides: a dependency that does not override it still +// references the original. +func (x exporter) addEmbedded(result *query.ExportResult, listed map[string]bool, g *model.Graph) { + for _, e := range g.Entries { + if e.Embedded && !listed[e.ID] { + listed[e.ID] = true + result.Embedded = append(result.Embedded, x.entry(g, e, nil)) + } + } } // exportRepo exports the held graph's own entries (embedded base entries are @@ -130,9 +137,6 @@ func (gf *GraphFinder) exportRepo(ctx context.Context, x exporter, repoID string if repo.Revision, err = h.HeadRevision(ctx, dir); err != nil { return query.ExportRepo{}, err } - if x.arrivals, err = h.FileArrivals(ctx, dir); err != nil { - return query.ExportRepo{}, err - } } for _, e := range g.Entries { @@ -163,9 +167,6 @@ func (x exporter) entry(g *model.Graph, e *model.Entry, attachments []query.Expo if key, ok := g.DisplayID(e.ID); ok && key != e.ID { entry.FullID = key } - if rel, err := model.IDToRelPath(e.ID); err == nil { - entry.LandedAt = x.arrivals[filepath.ToSlash(rel)] - } return entry } diff --git a/internal/git/history.go b/internal/git/history.go index eff5f2eb..cd105c9c 100644 --- a/internal/git/history.go +++ b/internal/git/history.go @@ -5,9 +5,6 @@ import ( "fmt" "os/exec" "strings" - "time" - - "github.com/networkteam/sdd/internal/model" ) // The finders.GitHistory surface: read-only provenance for the graph export. @@ -30,19 +27,3 @@ func (CLI) HeadRevision(ctx context.Context, dir string) (string, error) { } return strings.TrimSpace(string(out)), nil } - -// FileArrivals maps each file under dir that HEAD's first-parent history added -// (path relative to dir) to the committer time of the first-parent commit that -// brought it — for a file that arrived through a merge, the merge commit. One -// log pass; renames count as additions so a rewritten entry arrives when its -// new path did. -func (CLI) FileArrivals(ctx context.Context, dir string) (map[string]time.Time, error) { - out, err := exec.CommandContext(ctx, "git", "-C", dir, "log", - "--first-parent", "--diff-merges=first-parent", "--diff-filter=A", "--no-renames", - "--name-only", "--relative", "--no-color", "--format=%x00%cI", "--", ".", - ).Output() - if err != nil { - return nil, fmt.Errorf("git log in %s: %w", dir, err) - } - return model.ParseFileArrivals(string(out)) -} diff --git a/internal/model/arrivals.go b/internal/model/arrivals.go deleted file mode 100644 index 07659a65..00000000 --- a/internal/model/arrivals.go +++ /dev/null @@ -1,34 +0,0 @@ -package model - -import ( - "fmt" - "strings" - "time" -) - -// ParseFileArrivals parses `git log --first-parent --diff-filter=A -// --name-only --format=%x00%cI` output into each path's arrival time: the -// committer time of the first-parent commit that added it. Log order is -// newest first, so a path added more than once keeps its latest addition — -// the one that brought the current file. -func ParseFileArrivals(gitLogOutput string) (map[string]time.Time, error) { - arrivals := make(map[string]time.Time) - var current time.Time - for line := range strings.SplitSeq(gitLogOutput, "\n") { - if stamp, ok := strings.CutPrefix(line, "\x00"); ok { - t, err := time.Parse(time.RFC3339, stamp) - if err != nil { - return nil, fmt.Errorf("parsing commit time %q: %w", stamp, err) - } - current = t - continue - } - if line == "" || current.IsZero() { - continue - } - if _, seen := arrivals[line]; !seen { - arrivals[line] = current - } - } - return arrivals, nil -} diff --git a/internal/presenters/export.go b/internal/presenters/export.go index 26e36d29..3d56b401 100644 --- a/internal/presenters/export.go +++ b/internal/presenters/export.go @@ -45,7 +45,6 @@ type exportEntryJSON struct { Participants []string `json:"participants,omitempty"` Topics []string `json:"topics,omitempty"` Time string `json:"time"` - LandedAt string `json:"landed_at,omitempty"` Status model.StatusKind `json:"status,omitempty"` StatusBy string `json:"status_by,omitempty"` ClosedBy []string `json:"closed_by,omitempty"` @@ -189,9 +188,6 @@ func exportEntryJSONFrom(x query.ExportEntry) exportEntryJSON { if p, err := model.ParseID(e.ID); err == nil { ej.ShortID = p.TypeCode + "-" + p.LayerCode + "-" + p.Suffix } - if !x.LandedAt.IsZero() { - ej.LandedAt = x.LandedAt.Format(time.RFC3339) - } for _, t := range x.Topics { ej.Topics = append(ej.Topics, t.String()) } diff --git a/internal/query/export.go b/internal/query/export.go index 9073afdb..8dcaaafb 100644 --- a/internal/query/export.go +++ b/internal/query/export.go @@ -51,8 +51,11 @@ const ExportAttachmentTextLimit = 16 * 1024 // ExportResult is the structured output of an ExportQuery: the local graph // first, then each dependency repo. Embedded carries the base entries // compiled into the binary once, outside every repo: they are identical in -// each graph, so any repo's ID for one resolves there. Their derived -// attributes are the local graph's. +// each graph, so any repo's ID for one resolves there unless that repo has +// its own entry with the ID. They are collected from every exported graph, +// so a base entry the local graph overrides stays listed for a dependency +// that references the original; each is derived in the first exported graph +// carrying it, the local graph first. type ExportResult struct { GeneratedAt time.Time Repos []ExportRepo @@ -82,8 +85,7 @@ type ExportSelection struct { } // ExportEntry pairs an entry with the attributes the engine derives for it in -// its owning graph. FullID is set for entries of a dependency repo. LandedAt -// is zero when git history does not carry the entry file. +// its owning graph. FullID is set for entries of a dependency repo. type ExportEntry struct { Entry *model.Entry FullID string @@ -93,7 +95,6 @@ type ExportEntry struct { Topics []model.TopicPath Heat float64 InDegree int - LandedAt time.Time Attachments []ExportAttachment } From 256446aac2f16789b0ad2123c328443340fc1cc1 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Mon, 5 Oct 2026 15:58:40 +0200 Subject: [PATCH 4/4] test(model): cover the dependency closure and cited-entry selection Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/model/crossrepo_selection_test.go | 139 +++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 internal/model/crossrepo_selection_test.go diff --git a/internal/model/crossrepo_selection_test.go b/internal/model/crossrepo_selection_test.go new file mode 100644 index 00000000..f6d5c824 --- /dev/null +++ b/internal/model/crossrepo_selection_test.go @@ -0,0 +1,139 @@ +package model + +import ( + "errors" + "maps" + "slices" + "strings" + "testing" +) + +func TestDependencyClosure(t *testing.T) { + declarations := map[string][]string{ + "a": {"c", "root"}, + "b": {"c", "d", "x"}, + "c": {"a"}, + "d": nil, + "e": {"f"}, + } + declared := func(repoID string) ([]string, error) { + if repoID == "broken" { + return nil, errors.New("unreadable config") + } + // "x" stands for a repo that cannot be resolved: it declares nothing. + return declarations[repoID], nil + } + + for _, tt := range []struct { + name string + direct []string + want []string + }{ + {name: "breadth-first, each once, never root", direct: []string{"a", "b"}, want: []string{"a", "b", "c", "d", "x"}}, + {name: "duplicate direct dependencies", direct: []string{"d", "d"}, want: []string{"d"}}, + {name: "no dependencies", direct: nil, want: nil}, + {name: "transitive chain", direct: []string{"e"}, want: []string{"e", "f"}}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := DependencyClosure("root", tt.direct, declared) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, tt.want) { + t.Errorf("closure = %v, want %v", got, tt.want) + } + }) + } + + if _, err := DependencyClosure("root", []string{"d", "broken"}, declared); err == nil { + t.Error("a declaration read error was swallowed") + } +} + +func TestCitedAcross(t *testing.T) { + const ( + dep = "example.com/team/dep" + deep = "example.com/team/deep" + other = "example.com/team/other" + gone = "example.com/team/gone" + bad = "example.com/team/bad" + ) + base := entry("20260301-090000-s-prc-bas", withEmbedded()) + local := NewGraph([]*Entry{ + entry("20260410-100000-d-tac-loc", withRefs( + dep+":20260401-100000-d-cpt-aaa", + dep+":20260301-090000-s-prc-bas", // a base entry: no repo owns it + other+":20260401-100000-s-cpt-oth", // outside the scope + gone+":20260401-100000-s-cpt-gon", // in scope, not resolvable + "20260409-100000-s-tac-bar", // bare: a local entry + )), + entry("20260409-100000-s-tac-bar"), + base, + }) + depGraph := NewGraph([]*Entry{ + entry("20260401-100000-d-cpt-aaa", + withRefs("20260331-100000-s-cpt-bbb", deep+":20260201-100000-s-cpt-xxx"), + withSupersedes("20260330-100000-d-cpt-ccc")), + entry("20260331-100000-s-cpt-bbb", withCloses("20260329-100000-s-cpt-ddd")), + entry("20260330-100000-d-cpt-ccc"), + entry("20260329-100000-s-cpt-ddd"), + entry("20260328-100000-s-cpt-unc"), + base, + }) + deepGraph := NewGraph([]*Entry{entry("20260201-100000-s-cpt-xxx")}) + NewMultiGraph(local, []string{dep}, func(repoID string) (*Graph, error) { + switch repoID { + case dep: + return depGraph, nil + case deep: + return deepGraph, nil + case other: + return NewGraph([]*Entry{entry("20260401-100000-s-cpt-oth")}), nil + } + return nil, nil + }) + scope := []string{dep, deep, gone} + + flatten := func(selected map[string]map[string]bool) string { + var out []string + for _, repoID := range slices.Sorted(maps.Keys(selected)) { + for _, id := range slices.Sorted(maps.Keys(selected[repoID])) { + out = append(out, repoID+":"+id) + } + } + return strings.Join(out, " ") + } + for _, tt := range []struct { + name string + hops int + want []string + }{ + {name: "cited only", hops: 0, want: []string{dep + ":20260401-100000-d-cpt-aaa"}}, + {name: "one hop follows refs, supersedes and crossings", hops: 1, want: []string{ + deep + ":20260201-100000-s-cpt-xxx", + dep + ":20260330-100000-d-cpt-ccc", dep + ":20260331-100000-s-cpt-bbb", dep + ":20260401-100000-d-cpt-aaa", + }}, + {name: "two hops follow closes", hops: 2, want: []string{ + deep + ":20260201-100000-s-cpt-xxx", + dep + ":20260329-100000-s-cpt-ddd", dep + ":20260330-100000-d-cpt-ccc", dep + ":20260331-100000-s-cpt-bbb", dep + ":20260401-100000-d-cpt-aaa", + }}, + } { + t.Run(tt.name, func(t *testing.T) { + selected, err := CitedAcross(local, scope, tt.hops) + if err != nil { + t.Fatal(err) + } + if got := flatten(selected); got != strings.Join(tt.want, " ") { + t.Errorf("selected = %s\nwant %s", got, strings.Join(tt.want, " ")) + } + }) + } + + t.Run("a member that fails to load is an error", func(t *testing.T) { + broken := NewGraph([]*Entry{entry("20260410-100000-d-tac-brk", withRefs(bad+":20260401-100000-s-cpt-bad"))}) + NewMultiGraph(broken, []string{bad}, func(string) (*Graph, error) { return nil, errors.New("broken cache") }) + if _, err := CitedAcross(broken, []string{bad}, 1); err == nil { + t.Error("the load error was swallowed") + } + }) +}