diff --git a/README.md b/README.md index b6fc533..21e1f68 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,13 @@ address) and `mesh_runtimes` (which runtimes answered discovery: per runtime its answer took, its size in bytes, how many modules and tools it announced, whether it was shortened to fit the bus, when it was last heard — and who was expected and not heard). +`mesh_search` also finds a tool by the shell command it replaces (novox/hq ADR 0245): each seat verb and +module tool says what it replaces in the controller's records (`replaces`, read from the controller's +`tools` and `modules` answers), so `journalctl -u x`, `systemctl status x` or `docker ps` puts the verb +for it first, and says the command it matched as `instead_of`. A word also matches through a short table +of the words agents use for the mesh's (`logs` finds the journal). Best first: a replaced command, then +words in a name or a replaced command, then words in a description; seats before modules on a tie. + Discovery asks the bus (ADR 0197): every runtime answers the NATS services protocol's `$SRV.PING` and `$SRV.INFO` with what it serves at that moment, and the console reads where the controller's records place each module. The console waits at least 750 ms, and up to 5 s for every runtime that answered diff --git a/merge-check.sh b/merge-check.sh index cca6af2..d04b8a5 100644 --- a/merge-check.sh +++ b/merge-check.sh @@ -30,8 +30,9 @@ bundleless="" if ! command -v node >/dev/null 2>&1 || [ ! -d node_modules/@novox/mesh-sdk ]; then echo "NOT TESTED HERE: internal/runtime and internal/console launch bundles that need node and @novox/mesh-sdk" packages=$(printf '%s\n' "$packages" | grep -v -e '/internal/runtime$' -e '/internal/console$') - # What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287). - bundleless='^TestASeatAndAModuleOfOneNameAreEachReached$' + # What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287), + # and how a search ranks (novox/hq ADR 0245). + bundleless='^(TestASeatAndAModuleOfOneNameAreEachReached|TestSearch.*|TestASearchByCommandSaysWhatItReplaces)$' fi if command -v gcc >/dev/null 2>&1; then CGO_ENABLED=1 go test -race -count=1 $packages diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index f8236aa..f216ddc 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -67,9 +67,10 @@ func discovery() []map[string]any { {"name": verbMachine, "inputSchema": obj(map[string]any{"node": str("the machine, as mesh_overview names it")}, "node"), "description": "One machine: the seats it holds with their verbs, and the modules assigned to it with their tools — " + "each with the address to describe or call it by. " + grammar}, - {"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases`")}, "query"), + {"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases` — or the shell command you would have run, e.g. `journalctl -u x`, `docker ps`")}, "query"), "description": "Find tools anywhere in the mesh by words: every match's address and a line of what it does, across the " + - "mesh's seats, the machines' seats and every module on every machine. " + grammar}, + "mesh's seats, the machines' seats and every module on every machine, best first. A shell command finds the tool " + + "that replaces it — search before reaching for ssh, journalctl, systemctl or docker. " + grammar}, {"name": verbDescribe, "inputSchema": obj(map[string]any{"address": str("the tool's address")}, "address"), "description": "What one tool does and the arguments it takes, as a JSON schema. The machine is in the address, " + "never an argument. " + grammar}, @@ -130,6 +131,9 @@ type index struct { // what it serves in time, or it answered before and not now. An address it might answer is never // called missing while it is here (2026-10-05). Unheard []unheard + // Replaces is what each verb and tool replaces, from the controller's records (novox/hq ADR 0245): + // `seat:.` and `.`. + Replaces map[string][]string } // unheard is one runtime discovery did not hear in full, and why. @@ -221,6 +225,8 @@ type recordedModule struct { Module string `json:"module"` On []string `json:"on"` Tools bool `json:"tools"` + // Replaces is what each of its own tools replaces (novox/hq ADR 0245). + Replaces map[string][]string `json:"replaces,omitempty"` } // recordedMachine is a machine as the controller's records hold it (`node list --json`). @@ -234,9 +240,18 @@ type recordedMachine struct { func indexOn(conn *bus.Conn) (*index, error) { var wg sync.WaitGroup var nodesOut, modulesOut string - wg.Add(2) + var toolsAnswer json.RawMessage + wg.Add(3) go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }() go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }() + // The seats' verbs as the records define them, for what each replaces (novox/hq ADR 0245): answered + // in-process from the records, never a command. + go func() { + defer wg.Done() + if got, err := conn.Ask("seat:mesh-controller.tools", map[string]any{}, ""); err == nil { + toolsAnswer = got.Result + } + }() d, err := announce.Gather(conn) wg.Wait() if err != nil { @@ -258,7 +273,9 @@ func indexOn(conn *bus.Conn) (*index, error) { // What should have answered: every assignment of a module that declares tools. Silence is named; // a module with no tools is never a name here. var recorded []recordedModule - if json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil { + recordedOK := json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil + x.Replaces = replacesOf(toolsAnswer, recorded) + if recordedOK { for _, m := range recorded { if !m.Tools { continue @@ -803,70 +820,10 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any { case verbSearch: query := strings.ToLower(str("query")) if query == "" { - return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`") - } - words := strings.Fields(query) - type hit struct { - Address string `json:"address"` - Does string `json:"does"` - Also string `json:"also,omitempty"` - } - var hits []hit - matches := func(parts ...string) bool { - hay := strings.ToLower(strings.Join(parts, " ")) - for _, w := range words { - if !strings.Contains(hay, w) { - return false - } - } - return true - } - for _, st := range x.Seats { - for _, v := range st.Verbs { - if !matches(st.Seat, v.Name, v.Description) { - continue - } - if st.Scope == "node" { - on := nodesOf(st.Holders) - first := "" - also := "" - if len(on) > 0 { - first = on[0] - if len(on) > 1 { - also = "also on " + strings.Join(on[1:], ", ") - } - } - hits = append(hits, hit{first + "/" + st.Seat + "." + v.Name, firstLine(v.Description), also}) - } else { - hits = append(hits, hit{st.Seat + "." + v.Name, firstLine(v.Description), ""}) - } - } - } - names := make([]string, 0, len(x.Modules)) - for n := range x.Modules { - names = append(names, n) - } - sort.Strings(names) - for _, n := range names { - m := x.Modules[n] - for _, t := range m.Tools { - if !matches(m.Module, t.Name, t.Description) { - continue - } - switch { - case m.Interchangeable: - hits = append(hits, hit{m.Module + "." + t.Name, firstLine(t.Description), "any instance; on " + orNobody(m.On)}) - case len(m.On) == 0: - hits = append(hits, hit{"/" + m.Module + "." + t.Name, firstLine(t.Description), "the mesh places it on no machine"}) - default: - also := "" - if len(m.On) > 1 { - also = "also on " + strings.Join(m.On[1:], ", ") - } - hits = append(hits, hit{m.On[0] + "/" + m.Module + "." + t.Name, firstLine(t.Description), also}) - } - } + return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`, or the command you would " + + "have run, e.g. `journalctl` or `docker ps`") } + hits := x.search(query) out := map[string]any{"matches": hits} if len(hits) > searchCap { out["matches"] = hits[:searchCap] @@ -874,7 +831,8 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any { } if len(hits) == 0 { out["matches"] = []hit{} - out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse" + out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse. " + + "If no tool does it, one is created in the module that owns it, on its seat — never worked around (novox/hq ADR 0245)" } if len(x.Unheard) > 0 { out["incomplete"] = "discovery did not hear in full from " + sayUnheard(x.Unheard) + @@ -895,8 +853,15 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any { if description == "" { description = t.Name } - return answerText(map[string]any{"address": t.Address, "description": description, - "arguments": schemaAsPassed(t)}) + described := map[string]any{"address": t.Address, "description": description, "arguments": schemaAsPassed(t)} + key := t.Tool.Module + "." + t.Tool.Name + if t.Seat { + key = "seat:" + key + } + if r := x.Replaces[key]; len(r) > 0 { + described["instead_of"] = r + } + return answerText(described) case verbCall: t, err := resolve(x, str("address")) diff --git a/node-tools/internal/console/search.go b/node-tools/internal/console/search.go new file mode 100644 index 0000000..eaeaf1d --- /dev/null +++ b/node-tools/internal/console/search.go @@ -0,0 +1,272 @@ +package console + +// Finding a tool by the words an agent already has (novox/hq ADR 0245). +// +// An agent that wants a unit's log reaches for `journalctl`, and one that wants a container's for `docker +// logs`; it searches with those words, or with `logs`. Matched against names and descriptions alone, none +// of them found the service manager's `journal` — whose description says "journal", never "logs" or +// "journalctl" — and the agent went round the mesh over ssh. So each verb and tool says what it replaces, +// and search matches three ways, best first: +// +// 1. **The query is a command a tool replaces** — `journalctl -u x`, `systemctl status x`, `docker ps`: +// every word of the replaced command, in order, among the query's words. The most specific command wins. +// 2. **Every word is in the tool's name, its replaced commands or its description**, a word also matching +// through a short table of the words agents use for the mesh's (logs ⇄ journal, ps ⇄ list). A word in +// the name or a replaced command weighs more than one in the description, and a name the query says +// whole more than one it says in part. +// 3. Nothing else: a search that matches nothing says so. +// +// Ties go to a seat — the place tools belong — then to the shorter name, then by address. + +import ( + "encoding/json" + "path" + "sort" + "strings" + "unicode" +) + +// synonyms are the words an agent uses for the mesh's own, each matching the other's (a word, not part +// of one: `log` must not match `login`). Kept short: a command is said by the tool that replaces it. +var synonyms = map[string][]string{ + "logs": {"log", "journal"}, + "log": {"logs", "journal"}, + "journal": {"logs", "log"}, + "ps": {"list", "running"}, + "ls": {"list"}, + "list": {"ps", "ls"}, + "restart": {"restarts"}, + "service": {"unit", "units"}, + "services": {"unit", "units"}, + "unit": {"service"}, + "container": {"containers", "docker"}, + "firewall": {"filter", "nftables"}, + "hosts": {"hostname"}, +} + +// hit is one match as mesh_search answers it. +type hit struct { + Address string `json:"address"` + Does string `json:"does"` + // InsteadOf is the command the query matched, when it matched one the tool replaces. + InsteadOf string `json:"instead_of,omitempty"` + Also string `json:"also,omitempty"` + score int +} + +// words are a text's words, lower-cased, split at anything that is not a letter, a digit, `-` or `_` +// within a word — so `node-service-manager.journal` is its three names' parts and the whole. +func words(s string) []string { + var out []string + for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '/') + }) { + out = append(out, w) + for _, part := range strings.FieldsFunc(w, func(r rune) bool { return r == '-' || r == '_' || r == '/' }) { + if part != w { + out = append(out, part) + } + } + } + return out +} + +// commandWords are a command line's words as matched against a replaced command: lower-cased, a +// program named by path reduced to its name, and what only runs it (`sudo`) left out. +func commandWords(line string) []string { + var out []string + for i, w := range strings.Fields(strings.ToLower(line)) { + if i == 0 || strings.HasPrefix(w, "/") { + w = path.Base(w) + } + if w == "sudo" { + continue + } + out = append(out, w) + } + return out +} + +// inOrder says whether every one of want is among have, in the same order. +func inOrder(want, have []string) bool { + i := 0 + for _, h := range have { + if i < len(want) && h == want[i] { + i++ + } + } + return len(want) > 0 && i == len(want) +} + +// replacedBy is the replaced command a query names, the most specific of those it matches, or "". +func replacedBy(query string, replaces []string) string { + q := commandWords(query) + best, bestLen := "", 0 + for _, r := range replaces { + w := commandWords(r) + if inOrder(w, q) && len(w) > bestLen { + best, bestLen = r, len(w) + } + } + return best +} + +// scoreOf is how well a tool matches the query: 0 for not at all. +func scoreOf(query string, qwords []string, name, description string, replaces []string) (int, string) { + if cmd := replacedBy(query, replaces); cmd != "" { + return 100 + 10*len(commandWords(cmd)), cmd + } + strong := strings.ToLower(name + " " + strings.Join(replaces, " ")) + weak := strings.ToLower(description) + strongWords := map[string]bool{} + for _, w := range words(strong) { + strongWords[w] = true + } + weakWords := map[string]bool{} + for _, w := range words(weak) { + weakWords[w] = true + } + score := 10 + for _, w := range qwords { + switch { + case strings.Contains(strong, w): + score += 5 + case strings.Contains(weak, w): + score++ + default: + found := 0 + for _, s := range synonyms[w] { + if strongWords[s] { + found = 5 + break + } + if weakWords[s] { + found = 1 + } + } + if found == 0 { + return 0, "" + } + score += found + } + } + // A name the query says whole — `docker logs` for docker_logs, not docker_secrets_in_logs — first. + own := name[strings.LastIndex(name, ".")+1:] + whole := true + for _, part := range strings.FieldsFunc(strings.ToLower(own), func(r rune) bool { return r == '_' || r == '-' }) { + in := false + for _, w := range qwords { + in = in || w == part + } + whole = whole && in + } + if whole { + score += 2 + } + return score, "" +} + +// search is mesh_search's answer: every match, best first. +func (x *index) search(query string) []hit { + query = strings.ToLower(strings.TrimSpace(query)) + qwords := strings.Fields(query) + var hits []hit + for _, st := range x.Seats { + for _, v := range st.Verbs { + replaces := x.Replaces["seat:"+st.Seat+"."+v.Name] + score, cmd := scoreOf(query, qwords, st.Seat+"."+v.Name, v.Description, replaces) + if score == 0 { + continue + } + h := hit{Does: firstLine(v.Description), InsteadOf: cmd, score: score + 1} // a seat first, on a tie + if st.Scope == "node" { + on := nodesOf(st.Holders) + first := "" + if len(on) > 0 { + first = on[0] + if len(on) > 1 { + h.Also = "also on " + strings.Join(on[1:], ", ") + } + } + h.Address = first + "/" + st.Seat + "." + v.Name + } else { + h.Address = st.Seat + "." + v.Name + } + hits = append(hits, h) + } + } + names := make([]string, 0, len(x.Modules)) + for n := range x.Modules { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + m := x.Modules[n] + for _, t := range m.Tools { + score, cmd := scoreOf(query, qwords, m.Module+"."+t.Name, t.Description, x.Replaces[m.Module+"."+t.Name]) + if score == 0 { + continue + } + h := hit{Does: firstLine(t.Description), InsteadOf: cmd, score: score} + switch { + case m.Interchangeable: + h.Address, h.Also = m.Module+"."+t.Name, "any instance; on "+orNobody(m.On) + case len(m.On) == 0: + h.Address, h.Also = "/"+m.Module+"."+t.Name, "the mesh places it on no machine" + default: + h.Address = m.On[0] + "/" + m.Module + "." + t.Name + if len(m.On) > 1 { + h.Also = "also on " + strings.Join(m.On[1:], ", ") + } + } + hits = append(hits, h) + } + } + // Best first; on a tie the shorter name — the plainer tool — then the address, so an answer never + // depends on the order discovery heard things in. + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].score != hits[j].score { + return hits[i].score > hits[j].score + } + a, b := hits[i].Address[strings.LastIndex(hits[i].Address, ".")+1:], hits[j].Address[strings.LastIndex(hits[j].Address, ".")+1:] + if len(a) != len(b) { + return len(a) < len(b) + } + return hits[i].Address < hits[j].Address + }) + return hits +} + +// replacesOf is what every verb and tool replaces, from the controller's records (novox/hq ADR 0245): +// a seat's verbs from its `tools` answer, keyed `seat:.`, and a module's own tools from +// its `modules` answer, keyed `.`. A controller older than the field answers neither, +// and search matches names and descriptions as before. +func replacesOf(toolsAnswer json.RawMessage, modules []recordedModule) map[string][]string { + out := map[string][]string{} + var seats struct { + Seats []struct { + Seat string `json:"seat"` + Tools []struct { + Name string `json:"name"` + Replaces []string `json:"replaces"` + } `json:"tools"` + } `json:"seats"` + } + if json.Unmarshal(toolsAnswer, &seats) == nil { + for _, s := range seats.Seats { + for _, t := range s.Tools { + if len(t.Replaces) > 0 { + out["seat:"+s.Seat+"."+t.Name] = t.Replaces + } + } + } + } + for _, m := range modules { + for tool, r := range m.Replaces { + if len(r) > 0 { + out[m.Module+"."+tool] = r + } + } + } + return out +} diff --git a/node-tools/internal/console/search_test.go b/node-tools/internal/console/search_test.go new file mode 100644 index 0000000..41ba59c --- /dev/null +++ b/node-tools/internal/console/search_test.go @@ -0,0 +1,151 @@ +package console + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novox/mesh-tools/node-tools/internal/announce" +) + +// meshToSearch is a mesh as discovery and the controller's records describe it: the service manager's +// verbs on two machines, the controller's own, a docker module with its tools, and what each replaces +// as the records say it — the seats' from `tools`, the module's from `modules`. +func meshToSearch(t *testing.T) *index { + t.Helper() + var endpoints []announce.Endpoint + verbs := map[string]string{ + "units": "The units the service manager knows in a scope, each with its load, active and sub state.", + "status": "One unit as the service manager sees it now: its states, whether it starts at boot.", + "restart": "Restart one unit.", + "journal": "The last lines of one unit's journal (at most 2000).", + } + for _, node := range []string{"anchor", "laptop"} { + for verb, does := range verbs { + endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "systemd", Seat: "node-service-manager", + Tool: verb, Scope: "node", Node: node, Description: does, + Subject: "mesh.seat.node-service-manager.tool." + verb + "." + node}) + } + } + for verb, does := range map[string]string{ + "node": "What one machine reported it can do, what it is assigned, and why.", + "nodes": "Every machine the mesh knows, with whether it is converged or adopted.", + } { + endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller", + Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-controller.tool." + verb}) + } + for tool, does := range map[string]string{ + "docker_list": "Every container on this machine: name, image, state, ports.", + "docker_logs": "The last lines a container wrote.", + "docker_secrets_in_logs": "Whether a container's logs hold a secret.", + "docker_restart": "Restart one container.", + } { + endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindTool, Module: "docker", Tool: tool, Node: "anchor", + Description: does, Subject: "mesh.mod.docker.tool." + tool + ".anchor"}) + } + x, _, _ := indexOfEndpoints(endpoints) + tools := json.RawMessage(`{"seats":[{"seat":"node-service-manager","scope":"node","tools":[ + {"name":"units","replaces":["systemctl list-units","systemctl --failed"]}, + {"name":"status","replaces":["systemctl status","systemctl is-active"]}, + {"name":"restart","replaces":["systemctl restart"]}, + {"name":"journal","replaces":["journalctl"]}]}, + {"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","replaces":["hostnamectl","uptime"]},{"name":"nodes"}]}]}`) + x.Replaces = replacesOf(tools, []recordedModule{{Module: "docker", On: []string{"anchor"}, Tools: true, + Replaces: map[string][]string{"docker_list": {"docker ps"}, "docker_logs": {"docker logs"}, "docker_restart": {"docker restart"}}}}) + return x +} + +// **The words an agent has find the verb first** (novox/hq ADR 0245): the command it would have run, or +// the word it would have used, puts the mesh's tool for it at the top. +func TestSearchFindsTheToolThatReplacesACommandFirst(t *testing.T) { + x := meshToSearch(t) + for query, want := range map[string]string{ + "journalctl": "anchor/node-service-manager.journal", + "journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal", + "sudo journalctl -fu sshd": "anchor/node-service-manager.journal", + "/usr/bin/journalctl": "anchor/node-service-manager.journal", + "logs": "anchor/node-service-manager.journal", + "systemctl status": "anchor/node-service-manager.status", + "systemctl status nats.service": "anchor/node-service-manager.status", + "systemctl restart mesh-controller": "anchor/node-service-manager.restart", + "systemctl --failed": "anchor/node-service-manager.units", + "docker ps": "anchor/docker.docker_list", + "docker ps -a": "anchor/docker.docker_list", + "docker logs": "anchor/docker.docker_logs", + "docker logs --tail 100 nats": "anchor/docker.docker_logs", + "docker restart nats": "anchor/docker.docker_restart", + "container logs": "anchor/docker.docker_logs", + "uptime": "mesh-controller.node", + } { + hits := x.search(query) + if len(hits) == 0 { + t.Errorf("%q found nothing; want %s first", query, want) + continue + } + if hits[0].Address != want { + var got []string + for _, h := range hits { + got = append(got, h.Address) + } + t.Errorf("%q found %s first; want %s (all: %s)", query, hits[0].Address, want, strings.Join(got, ", ")) + } + } +} + +// A match on a replaced command says which command it replaced, and the most specific one wins. +func TestASearchByCommandSaysWhatItReplaces(t *testing.T) { + x := meshToSearch(t) + hits := x.search("systemctl status sshd") + if hits[0].InsteadOf != "systemctl status" { + t.Errorf("the match does not say what it replaces: %+v", hits[0]) + } + if hits[0].Also != "also on laptop" { + t.Errorf("a node seat's verb does not say its other machines: %+v", hits[0]) + } + // `logs` also finds the container's logs, after the journal — both are answers. + var docker bool + for _, h := range x.search("logs")[:3] { + docker = docker || h.Address == "anchor/docker.docker_logs" + } + if !docker { + t.Errorf("`logs` does not find docker_logs among the first: %+v", x.search("logs")) + } +} + +// What search did before stays: every word in a name or description, and nothing for a word nothing has. +// A synonym is a word, never part of one: `logs` matches through `log`, and finds no `login`. +func TestSearchStillMatchesEveryWord(t *testing.T) { + x := meshToSearch(t) + if hits := x.search("machine converged"); len(hits) != 1 || hits[0].Address != "mesh-controller.nodes" { + t.Errorf("words in a description: %+v", hits) + } + if hits := x.search("journalctl kubernetes"); len(hits) != 1 || hits[0].Address != "anchor/node-service-manager.journal" { + t.Errorf("a command with arguments nothing has is still that command: %+v", hits) + } + if hits := x.search("kubernetes"); len(hits) != 0 { + t.Errorf("a word nothing has matched: %+v", hits) + } + x.Modules["shell"] = &moduleInfo{Module: "shell", On: []string{"anchor"}, Tools: []Tool{{Module: "shell", Name: "shell_login", + Description: "The account's login shell."}}} + for _, h := range x.search("logs") { + if h.Address == "anchor/shell.shell_login" { + t.Errorf("`logs` found `login` through its synonym `log`: %+v", h) + } + } +} + +// A controller older than `replaces` answers without it: search matches names and descriptions as before. +func TestSearchWithoutReplacesMatchesAsBefore(t *testing.T) { + x := meshToSearch(t) + x.Replaces = replacesOf(json.RawMessage(`{"seats":[{"seat":"node-service-manager","tools":[{"name":"journal"}]}]}`), + []recordedModule{{Module: "docker"}}) + if len(x.Replaces) != 0 { + t.Fatalf("replaces from records that have none: %v", x.Replaces) + } + if hits := x.search("journal"); len(hits) == 0 || hits[0].Address != "anchor/node-service-manager.journal" { + t.Errorf("a verb's own name: %+v", hits) + } + if hits := x.search("docker logs"); len(hits) == 0 || hits[0].Address != "anchor/docker.docker_logs" { + t.Errorf("a tool's own name: %+v", hits) + } +}