Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cmd/agentic/agentic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ hadron task run <task-urn>|<loc> -m <memory> [--arg k=v]... [--app <ref> [--as-s
hadron chat read [--since <seq>] [--node <urn> | -m <memory> --messages-loc <prefix>] | post (--body <text|-> | --body-file <path>) [--node <urn>] [--reply-to <loc>] [--handle <h>] [--identity <i>] [--role <r>]
hadron search <query> [-m <memory>]... [--mode hybrid|keyword|vector|regex] [--prefix <loc>] [--type <type>] [--object-type <t>] [--tag <t>]... [--where <json>] [--sort-property <json>] [--limit N] [--offset N] [-l|--long] [--json]
hadron replace text <old> <new> --field <f> (--node <urn> | -m <memory>) [--prefix <loc>] [--regex] [-i] [--dry-run] [--yes] [--max-nodes N]
hadron edge list <node-urn> | add | update <edge-id> | rm <edge-id>
hadron edge list <node-urn> | <loc> -m <memory> | <node-id> [--direction incoming|outgoing] [--name <substr>] [--to <ref>] [--from <ref>] | add | update <edge-id> | rm <edge-id>
hadron spec list [-m <memory>] | get <citation>|--prefix <prefix> | describe | use [<memory>] | register [--check] | find <query> [--match-exactly] | grep <pattern> [--regex] [-i] [--field content|abstract] [--prefix <loc>] | replace <pattern> <replacement> [--regex] [--word-boundary=false] [--field content|abstract] [--dry-run] [--yes] [--max-specs N] | new ... | edit <citation> | extract <citation> --to-feature <fff> | link <from> <to> | lint [<citation>] | check-tools [--prefix <loc>] | supersede <citation> | import spec-kit|code
hadron coding review lint -m <memory> [--root <loc>] [--toolchain <t>|-] [--strict] [--suggest] [--fix [--yes]] [--json] | preflight lint -m <memory> [--root <loc>] [--strict] [--json]
hadron app list --org <org> | install (--org <id> | --owner-me) --agent <ref> --name <n> [--type <t>] [--urn <slug>] [--description <d>] | uninstall <id> | use <urn>
Expand Down
112 changes: 111 additions & 1 deletion internal/cmd/edge/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package edge

import (
"io"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -39,16 +40,119 @@ func edgeListRow(id, dir string, name *string, loc string, isRunnable *bool, pri
}
}

// edgeFilter narrows a node's edges. The zero value matches everything, so an
// unfiltered `edge list` is unchanged.
//
// To and From are directional by construction: an edge TO x is one this node
// points at (outgoing), an edge FROM x is one pointing at this node (incoming).
// Each matches the far endpoint by loc OR id, since both are printed in --json
// and either is what a caller has to hand.
type edgeFilter struct {
Direction string // "" | incoming | outgoing
Name string // case-insensitive substring of the edge label
To string // far endpoint loc or id, outgoing only
From string // far endpoint loc or id, incoming only
}

func (fl edgeFilter) match(e edgeListDTO) bool {
fl.Name, fl.To, fl.From = strings.TrimSpace(fl.Name), strings.TrimSpace(fl.To), strings.TrimSpace(fl.From)
if fl.Direction != "" && e.Direction != fl.Direction {
return false
}
if fl.Name != "" && !strings.Contains(strings.ToLower(e.Name), strings.ToLower(fl.Name)) {
return false
}
if fl.To != "" && (e.Direction != "outgoing" || !endpointIs(e, fl.To)) {
return false
}
if fl.From != "" && (e.Direction != "incoming" || !endpointIs(e, fl.From)) {
Comment on lines +65 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject explicitly empty endpoint filters

When a shell variable expands to empty, such as --to "$TARGET" with TARGET unset, Cobra records an explicitly supplied empty value but these checks treat it as if the flag were absent. The command consequently returns every edge—including redacted endpoints—instead of rejecting the invalid reference or returning no matches; --from "" behaves the same way. Check whether each flag was supplied and reject an empty value before filtering.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 4c2bef4. I reproduced it before changing anything:

$ TARGET=""
$ hadron edge ls preflight -m hadronmemory.com::dev --to "$TARGET" --json | jq length
20
$ hadron edge ls preflight -m hadronmemory.com::dev --json | jq length
20        # identical — the filter silently vanished

Returning everything for a filter the caller meant to narrow with is the worst shape this could take, and it's silent. validate now takes the set of flags cobra saw as Changed, so an explicitly-supplied empty (or whitespace-only) value on any of --direction/--name/--to/--from is a usage error:

hadron: --to was given an empty value — omit the flag to not filter on it (a shell variable that expanded to nothing?)

I passed a provided map[string]bool rather than the pflag FlagSet so the rule stays unit-testable without pulling cobra into the pure layer. Covered at both levels: TestEmptyFilterValueIsRejected for the rule, TestEdgeListRejectsEmptyFilterValue end-to-end for all four flags.

While there I also trimmed filter values on apply, so --to " findings:x " from shell quoting still matches — TestFilterValuesAreTrimmed.

return false
}
return true
}

// endpointIs reports whether the far endpoint is ref, by loc or by id. A
// redacted endpoint (#781) carries neither, so it never matches — correct: the
// caller asked for a specific node and this edge can't be shown to be it.
func endpointIs(e edgeListDTO, ref string) bool {
return (e.OtherLoc != "" && e.OtherLoc == ref) || (e.OtherID != "" && e.OtherID == ref)
}

// filterEdges applies fl, always returning a non-nil slice so --json renders
// [] rather than null.
func filterEdges(edges []edgeListDTO, fl edgeFilter) []edgeListDTO {
out := []edgeListDTO{}
for _, e := range edges {
if fl.match(e) {
out = append(out, e)
}
}
return out
}
Comment on lines +83 to +91

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'd rather not, and I think the numbers support leaving it.

filterEdges runs once per command invocation, over one node's edges. The largest routers in these memories are 75 edges (mmdata preflight) and 55 (mmdata review); the dev router is 20. One slice of a few dozen small structs, once, is not a cost worth designing around — and edge list is a human/agent-facing command whose runtime is dominated by the GraphQL round trip that precedes it by orders of magnitude.

The short-circuit would also change the function's contract in a way that isn't free: returning the caller's slice on the zero-value filter makes filterEdges sometimes-aliasing and sometimes-copying. That's fine at today's single call site, which discards the input, but it's the kind of subtlety that bites whoever reuses it later — and the alternative (copy anyway) buys nothing.

So the trade reads as: a guaranteed-copy contract that's trivial to reason about, versus saving one allocation on a path that just did a network read. Happy to add it if you'd still prefer — it's three lines — but I don't think it earns the ambiguity.


// validate rejects filters that can only ever match nothing, or that would
// silently widen the result instead of narrowing it.
//
// provided names the flags the user actually passed, which an empty value
// alone can't tell us: `--to "$TARGET"` with TARGET unset reaches us as an
// explicitly-supplied "". Treating that as "no filter" returned EVERY edge —
// the opposite of what the caller asked for, and silent. It is a usage error.
func (fl edgeFilter) validate(provided map[string]bool) error {
for flag, val := range map[string]string{
"direction": fl.Direction, "name": fl.Name, "to": fl.To, "from": fl.From,
} {
if provided[flag] && strings.TrimSpace(val) == "" {
return exitcode.Newf(exitcode.Usage,
"--%s was given an empty value — omit the flag to not filter on it (a shell variable that expanded to nothing?)", flag)
}
}
switch fl.Direction {
case "", "incoming", "outgoing":
default:
return exitcode.Newf(exitcode.Usage, "--direction %q must be \"incoming\" or \"outgoing\"", fl.Direction)
}
if fl.To != "" && fl.From != "" {
return exitcode.Newf(exitcode.Usage, "--to and --from are mutually exclusive — an edge has one far endpoint, and the two name opposite directions")
}
if fl.To != "" && fl.Direction == "incoming" {
return exitcode.Newf(exitcode.Usage, "--to selects outgoing edges, so it cannot be combined with --direction incoming")
}
if fl.From != "" && fl.Direction == "outgoing" {
return exitcode.Newf(exitcode.Usage, "--from selects incoming edges, so it cannot be combined with --direction outgoing")
}
return nil
}

func newCmdLs(f *cmdutil.Factory) *cobra.Command {
var memory string
var fl edgeFilter
cmd := &cobra.Command{
Use: "list <node-urn> | <loc> -m <memory>",
Aliases: []string{"ls"},
Short: "List a node's edges (both directions)",
Long: `List a node's edges, outgoing and incoming.

Without filters every edge is listed. --direction, --name, --to and --from
narrow that client-side; the row shape is unchanged, so a filtered --json
run is a subset of an unfiltered one.

--to and --from are directional: --to names a node this one points AT
(outgoing), --from a node pointing at THIS one (incoming). Either takes the
far endpoint's loc or its id.`,
Example: ` hadron edge list hadronmemory.com::dev::start-here
hadron edge list start-here -m hadronmemory.com::dev`,
hadron edge list start-here -m hadronmemory.com::dev
hadron edge list review -m micromentor.org::mmdata --direction incoming
hadron edge list preflight -m hadronmemory.com::dev --name routes-to
hadron edge list preflight -m hadronmemory.com::dev --to findings:prisma-upsert-not-race-safe`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
provided := map[string]bool{}
for _, n := range []string{"direction", "name", "to", "from"} {
provided[n] = cmd.Flags().Changed(n)
}
if err := fl.validate(provided); err != nil {
return err
}
client, err := f.GraphQLClient()
if err != nil {
return err
Expand Down Expand Up @@ -84,6 +188,8 @@ func newCmdLs(f *cmdutil.Factory) *cobra.Command {
edges = append(edges, edgeListRow(e.Id, "incoming", e.Name, e.Loc, e.IsRunnable, e.Priority, sid, sloc))
}

edges = filterEdges(edges, fl)

return output.Write(f.IOStreams, f.JSON, edges, func(w io.Writer) error {
t := output.NewTable(w, "DIR", "REL", "NODE", "EDGE-ID")
for _, e := range edges {
Expand All @@ -102,5 +208,9 @@ func newCmdLs(f *cmdutil.Factory) *cobra.Command {
},
}
cmd.Flags().StringVarP(&memory, "memory", "m", "", "memory (org::memory) to resolve a bare <loc> against")
cmd.Flags().StringVar(&fl.Direction, "direction", "", `only "incoming" or only "outgoing" edges`)
cmd.Flags().StringVar(&fl.Name, "name", "", "only edges whose label contains this (case-insensitive)")
cmd.Flags().StringVar(&fl.To, "to", "", "only outgoing edges whose target is this loc or id")
cmd.Flags().StringVar(&fl.From, "from", "", "only incoming edges whose source is this loc or id")
return cmd
}
186 changes: 186 additions & 0 deletions internal/cmd/edge/ls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package edge

import (
"strings"
"testing"

"github.com/hadron-memory/hadron-cli/internal/exitcode"
)

// A node's edges as `edge list` builds them: two out, two in, plus one whose
// far endpoint the server redacted (#781).
func sampleEdges() []edgeListDTO {
return []edgeListDTO{
{ID: "e1", Direction: "outgoing", Name: "routes-to", OtherID: "n1", OtherLoc: "findings:race"},
{ID: "e2", Direction: "outgoing", Name: "to diagnose a slow query", OtherID: "n2", OtherLoc: "findings:slow"},
{ID: "e3", Direction: "incoming", Name: "Applies when a resolver changes", OtherID: "n3", OtherLoc: "review:thin-resolver"},
{ID: "e4", Direction: "incoming", Name: "child-of", OtherID: "n4", OtherLoc: "review:posthog"},
{ID: "e5", Direction: "outgoing", Name: "routes-to"}, // redacted endpoint: no loc, no id
}
}

func ids(es []edgeListDTO) []string {
out := make([]string, 0, len(es))
for _, e := range es {
out = append(out, e.ID)
}
return out
}

func eq(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

func TestFilterEdges(t *testing.T) {
cases := []struct {
name string
fl edgeFilter
want []string
}{
{"zero value matches everything", edgeFilter{}, []string{"e1", "e2", "e3", "e4", "e5"}},
{"direction outgoing", edgeFilter{Direction: "outgoing"}, []string{"e1", "e2", "e5"}},
{"direction incoming", edgeFilter{Direction: "incoming"}, []string{"e3", "e4"}},
{"name substring", edgeFilter{Name: "routes-to"}, []string{"e1", "e5"}},
{"name is case-insensitive", edgeFilter{Name: "APPLIES WHEN"}, []string{"e3"}},
{"name is a substring, not a prefix", edgeFilter{Name: "resolver"}, []string{"e3"}},
{"to, by loc", edgeFilter{To: "findings:slow"}, []string{"e2"}},
{"to, by id", edgeFilter{To: "n2"}, []string{"e2"}},
{"from, by loc", edgeFilter{From: "review:posthog"}, []string{"e4"}},
{"from, by id", edgeFilter{From: "n3"}, []string{"e3"}},
// --to is outgoing-only: an incoming edge from that node must not match.
{"to does not match an incoming endpoint", edgeFilter{To: "review:posthog"}, []string{}},
{"from does not match an outgoing endpoint", edgeFilter{From: "findings:slow"}, []string{}},
{"combined", edgeFilter{Direction: "outgoing", Name: "routes-to"}, []string{"e1", "e5"}},
{"no match", edgeFilter{Name: "nothing-like-this"}, []string{}},
}
for _, tc := range cases {
got := ids(filterEdges(sampleEdges(), tc.fl))
if !eq(got, tc.want) {
t.Errorf("%s:\n got %v\n want %v", tc.name, got, tc.want)
}
}
}

// A redacted endpoint carries neither loc nor id, so it must never satisfy a
// request for a specific node — matching on "" would make every such edge look
// like the one asked for.
func TestRedactedEndpointNeverMatches(t *testing.T) {
redacted := edgeListDTO{ID: "e5", Direction: "outgoing", Name: "routes-to"}
if endpointIs(redacted, "") {
t.Error(`an empty ref must not match a redacted endpoint`)
}
if got := filterEdges([]edgeListDTO{redacted}, edgeFilter{To: "anything"}); len(got) != 0 {
t.Errorf("redacted endpoint matched --to: %v", ids(got))
}
// It still shows up unfiltered, and under a name filter.
if got := filterEdges([]edgeListDTO{redacted}, edgeFilter{Name: "routes-to"}); len(got) != 1 {
t.Error("a redacted endpoint should still be listable by label")
}
}

// filterEdges must return a non-nil slice so --json renders [] rather than null.
func TestFilterEdgesNeverReturnsNil(t *testing.T) {
if got := filterEdges(nil, edgeFilter{}); got == nil {
t.Error("nil input produced a nil slice")
}
if got := filterEdges(sampleEdges(), edgeFilter{Name: "no-match"}); got == nil {
t.Error("an empty result produced a nil slice")
}
}

// Combinations that can only ever match nothing are rejected up front, rather
// than returning an empty list that reads like "no such edges".
func TestEdgeFilterValidate(t *testing.T) {
ok := []edgeFilter{
{},
{Direction: "incoming"},
{Direction: "outgoing"},
{To: "x"},
{From: "x"},
{Direction: "outgoing", To: "x"},
{Direction: "incoming", From: "x"},
{Name: "x", Direction: "incoming"},
}
for _, fl := range ok {
if err := fl.validate(nil); err != nil {
t.Errorf("%+v should be valid, got %v", fl, err)
}
}

bad := []struct {
fl edgeFilter
want string
}{
{edgeFilter{Direction: "both"}, "--direction"},
{edgeFilter{Direction: "Incoming"}, "--direction"}, // case-sensitive by design
{edgeFilter{To: "x", From: "y"}, "mutually exclusive"},
{edgeFilter{To: "x", Direction: "incoming"}, "--to selects outgoing"},
{edgeFilter{From: "x", Direction: "outgoing"}, "--from selects incoming"},
}
for _, tc := range bad {
err := tc.fl.validate(nil)
if err == nil {
t.Errorf("%+v should be rejected", tc.fl)
continue
}
if got := exitcode.FromError(err); got != exitcode.Usage {
t.Errorf("%+v should be a usage error, got %d", tc.fl, got)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%+v: error should mention %q, got %q", tc.fl, tc.want, err.Error())
}
}
}

// An explicitly-supplied empty value is a usage error, not "no filter".
// `--to "$TARGET"` with TARGET unset reaches us as "", and treating that as
// absent returned EVERY edge — the opposite of what the caller asked for.
func TestEmptyFilterValueIsRejected(t *testing.T) {
for _, flag := range []string{"direction", "name", "to", "from"} {
fl := edgeFilter{}
provided := map[string]bool{flag: true}
err := fl.validate(provided)
if err == nil {
t.Errorf("--%s \"\" should be rejected", flag)
continue
}
if got := exitcode.FromError(err); got != exitcode.Usage {
t.Errorf("--%s \"\": expected a usage error, got %d", flag, got)
}
if !strings.Contains(err.Error(), "--"+flag) {
t.Errorf("--%s: the error should name the flag, got %q", flag, err.Error())
}
}

// Whitespace-only counts as empty.
if err := (edgeFilter{To: " "}).validate(map[string]bool{"to": true}); err == nil {
t.Error(`--to " " should be rejected`)
}
// A flag NOT passed with an empty value is simply no filter.
if err := (edgeFilter{}).validate(map[string]bool{}); err != nil {
t.Errorf("an unset filter should be valid, got %v", err)
}
// A real value passed is fine.
if err := (edgeFilter{To: "findings:x"}).validate(map[string]bool{"to": true}); err != nil {
t.Errorf("a real --to should be valid, got %v", err)
}
}

// Filter values are trimmed on apply, so a ref padded by shell quoting matches.
func TestFilterValuesAreTrimmed(t *testing.T) {
got := ids(filterEdges(sampleEdges(), edgeFilter{To: " findings:slow "}))
if len(got) != 1 || got[0] != "e2" {
t.Errorf("a padded --to should still match: %v", got)
}
if got := ids(filterEdges(sampleEdges(), edgeFilter{Name: " routes-to "})); len(got) != 2 {
t.Errorf("a padded --name should still match: %v", got)
}
}
Loading