Skip to content

feat(cli): add experimental JSON graph export - #22

Open
hlubek wants to merge 2 commits into
mainfrom
feat/experimental-json-export
Open

hlubek wants to merge 2 commits into
mainfrom
feat/experimental-json-export

Conversation

@hlubek

@hlubek hlubek commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Adds sdd export, an experimental command that writes the graph as one JSON document, for prototyping UIs that need entries and references as data. It is built on the internal model and finders, not on pkg/application, and its shape is explicitly unstable; the structured read the application is to return (20260928-080143-d-cpt-kv4) stays the real target.

sdd export [--format json] [--dependencies none|referenced|all] [--hops N] [--out FILE]
  • Dependencies come from the repo's declared dependency closure (breadth-first over each repo's own .sdd/config.yaml). referenced (default) exports only the dependency entries the local graph cites, plus --hops upstream steps inside the dependency (default 1); all exports whole dependency graphs; none only the local graph. Each dependency records its selection {mode, hops, total_entries}.
  • Per entry: identity and attributes, effective topics, engine-derived status / status_by / closed_by / superseded_by, refs with kind and desc, heat (exp-14d) and in_degree, summary, body, attachments (Markdown and plain text inlined, cut under 16 KB), and landed_at: the committer time of the first-parent commit that brought the entry onto the branch, read in one git log pass.
  • Base entries are emitted once as a top-level embedded list; any repo's ref to such an ID resolves there unless the repo has its own entry with that ID.
  • --out writes atomically via temp file and rename; stdout otherwise.

Known limits, noted for the structured read: capture time carries no zone; ref kinds are normalized at parse time; heat, in-degree and closure stop at the repo boundary; the dependency walk mirrors serve's semantics because inDependencyClosure is private to pkg/application.

Tests: TestCLIExportJSON (a real git repo with a --no-ff merge for landed_at, status, refs, heat, attachments, WIP, revision) and TestCLIExportDependencySelection (local → dep → deep closure across none, --hops 0/1/2, all, --out, refused flag combinations). go vet ./... clean, full test suite passing.

🤖 Generated with Claude Code

RetriggerConfidence Score: 1/5

Fix the output permissions, first-commit failure, and missing base targets before merging.

Findings

  1. P1 Security Private exports become readable ▶
  2. P1 New graphs cannot export ▶
  3. P1 Dependency references lose base entries ▶
Fix with agent prompt
### Issue 1
internal/cliapp/export.go:144
If other local users can access the output directory, `--out` lets them read the exported entry bodies and text attachments. `os.Chmod` forces `0644`, ignoring a restrictive umask and replacing any existing `0600` file with a readable one. Keep the temporary file private, or preserve the destination's permissions.

**How this was verified:** The output writer applies `0644` before renaming a document that includes entry bodies and attachment contents.

### Issue 2
internal/finders/export.go:133-135
In an initialized Git repository with no commits, `HeadRevision` returns an empty revision without an error. But `exportRepo` still calls `FileArrivals`, whose `git log` fails because there is no commit. The command stops without exporting the uncommitted graph. Skip the history lookup when the revision is empty and leave `landed_at` unset.

### Issue 3
internal/finders/export.go:65-68
When the local repository overrides a base entry's ID, that original entry disappears from the top-level `embedded` list. A dependency can still reference the original because each graph loads its own base entries. But dependency exports skip those entries too, so the reference has no exported target. Build `embedded` from the complete base set, while keeping each repository's overrides in its own `entries` list.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds experimental sdd export JSON output with dependency selection, derived entry fields, attachment text, Git arrival times, and atomic file replacement.

  • Output files become readable by other local users.
  • Export fails before a Git repository's first commit.
  • Local base overrides remove targets needed by dependency references.

Acknowledged limits: hlubek explicitly described the shape as unstable, capture times as zone-free, ref kinds as normalized, derived scores and closure as repository-local, and the dependency walk as matching serve's semantics. These intentional limits are not findings.

Reviews (1) · Last reviewed commit: "feat(cli): select export dependencies fr..."

hlubek and others added 2 commits September 28, 2026 16:02
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Comment thread internal/cliapp/export.go
if err = tmp.Close(); err != nil {
return err
}
if err = os.Chmod(tmp.Name(), 0o644); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Private exports become readable

If other local users can access the output directory, --out lets them read the exported entry bodies and text attachments. os.Chmod forces 0644, ignoring a restrictive umask and replacing any existing 0600 file with a readable one. Keep the temporary file private, or preserve the destination's permissions.

How this was verified: The output writer applies 0644 before renaming a document that includes entry bodies and attachment contents.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/cliapp/export.go
Line: 144

Comment:
**Private exports become readable**

If other local users can access the output directory, `--out` lets them read the exported entry bodies and text attachments. `os.Chmod` forces `0644`, ignoring a restrictive umask and replacing any existing `0600` file with a readable one. Keep the temporary file private, or preserve the destination's permissions.

**How this was verified:** The output writer applies `0644` before renaming a document that includes entry bodies and attachment contents.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +133 to +135
if x.arrivals, err = h.FileArrivals(ctx, dir); err != nil {
return query.ExportRepo{}, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 New graphs cannot export

In an initialized Git repository with no commits, HeadRevision returns an empty revision without an error. But exportRepo still calls FileArrivals, whose git log fails because there is no commit. The command stops without exporting the uncommitted graph. Skip the history lookup when the revision is empty and leave landed_at unset.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/finders/export.go
Line: 133-135

Comment:
**New graphs cannot export**

In an initialized Git repository with no commits, `HeadRevision` returns an empty revision without an error. But `exportRepo` still calls `FileArrivals`, whose `git log` fails because there is no commit. The command stops without exporting the uncommitted graph. Skip the history lookup when the revision is empty and leave `landed_at` unset.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +65 to +68
for _, e := range gf.graph.Entries {
if e.Embedded {
result.Embedded = append(result.Embedded, x.entry(gf.graph, e, nil))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dependency references lose base entries

When the local repository overrides a base entry's ID, that original entry disappears from the top-level embedded list. A dependency can still reference the original because each graph loads its own base entries. But dependency exports skip those entries too, so the reference has no exported target. Build embedded from the complete base set, while keeping each repository's overrides in its own entries list.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/finders/export.go
Line: 65-68

Comment:
**Dependency references lose base entries**

When the local repository overrides a base entry's ID, that original entry disappears from the top-level `embedded` list. A dependency can still reference the original because each graph loads its own base entries. But dependency exports skip those entries too, so the reference has no exported target. Build `embedded` from the complete base set, while keeping each repository's overrides in its own `entries` list.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

// 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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is that needed?

// 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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for this model logic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also: is that the best place for this code? Why not a method on a type that owns the dependencies? I dislike passing these areound as arguments.

// 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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the []string type. We should have a proper custom type (based on string) for repository identifiers.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant