miller/pkg/terminals/help/entry_json_test.go
John Kerl 6a357d5df9
Add JSON index and mlr which capability router (#2098) (#2107)
* Add JSON index and mlr which capability router (#2098)

PR 2 of the AI-friendly roadmap (plans/plan-2098-llm.md):

- `mlr help --as-json --index` emits a lightweight [{kind,name,summary}]
  index across all 651 catalog items (verbs, functions, flags, keywords),
  sorted by kind then name. Agents use this as a cheap first call to pick
  a verb before fetching its full entry.

- `mlr which "<query>"` is a new terminal that tokenizes a natural-language
  query, scores every catalog item (name match +20/token, body match +5/token),
  and returns ranked JSON [{kind,name,score,summary}]. Exit code 0 means a
  confident match (at least one token hit the item name); exit code 2 means
  low confidence. Agents branch on the exit code rather than parsing prose.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Move mlr which into pkg/terminals/help, deduplicate firstLine

pkg/terminals/which/ was a misplaced package: it imported the same four
catalog registries as pkg/terminals/help/ and duplicated the firstLine
helper. Moving the logic into help/entry_which.go fixes both issues:

- WhichMain and all which helpers now live alongside the other --as-json
  catalog machinery in pkg/terminals/help/
- indexFirstLine (entry_json.go) and firstLine (which/entry.go) collapse
  into a single firstLine shared by both files
- pkg/terminals/terminals.go calls help.WhichMain directly; the
  pkg/terminals/which/ package is deleted

No behavior change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Fix five Go-purity issues found in code review

1. Exit-code false positive: whichScore now returns (int, bool) where the
   bool records whether any token hit the item name. WhichMain uses
   results[0].nameHit for exit-code 0, so 4 body-only token hits (4×5=20)
   no longer incorrectly signal a confident match.

2. Flag Summary inconsistency: whichSearch was setting Summary: fl.Help
   directly for flags while using firstLine(...) for functions and keywords.
   Changed to firstLine(fl.Help) so all four kinds behave consistently.

3+5. kindOrder/whichKindRank duplication: the verb<function<flag<keyword
   ordering was encoded twice — as a local map[string]int in buildIndex and
   as a switch in whichKindRank. Replaced both with a single package-level
   kindRank() function. The map lookup also silently returned 0 (= verb rank)
   for unknown kinds; the switch correctly returns 4 (sorts last).

4. extractIndexFlag/extractAsJSONFlag duplication: both had identical loop
   bodies differing only in the sentinel string. Introduced a generic
   extractFlag(args, flag) helper; both are now one-liners.

Also promoted whichStopwords to a package-level var so whichTokenize does
not allocate a new map on every call.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-28 17:40:50 -04:00

193 lines
5.4 KiB
Go

package help
import (
"encoding/json"
"testing"
)
// TestFullCatalogIsValidJSON guards the `mlr help --as-json` contract: the
// assembled catalog must round-trip through encoding/json and carry a non-empty
// entry in each of the four sections.
func TestFullCatalogIsValidJSON(t *testing.T) {
catalog := buildFullCatalog()
bytes, err := json.Marshal(catalog)
if err != nil {
t.Fatalf("catalog failed to marshal: %v", err)
}
var roundTrip CatalogForJSON
if err := json.Unmarshal(bytes, &roundTrip); err != nil {
t.Fatalf("catalog failed to unmarshal: %v", err)
}
if catalog.MlrVersion == "" {
t.Error("catalog mlr_version is empty")
}
if catalog.CatalogSchemaVersion == 0 {
t.Error("catalog catalog_schema_version is zero")
}
if len(catalog.Verbs) == 0 {
t.Error("catalog has no verbs")
}
if len(catalog.Functions) == 0 {
t.Error("catalog has no functions")
}
if len(catalog.Flags) == 0 {
t.Error("catalog has no flags")
}
if len(catalog.Keywords) == 0 {
t.Error("catalog has no keywords")
}
}
// TestCatalogEntriesArePopulated checks that the structured fields agents rely
// on are actually filled in, not just present.
func TestCatalogEntriesArePopulated(t *testing.T) {
catalog := buildFullCatalog()
for _, verb := range catalog.Verbs {
if verb.Name == "" {
t.Error("found a verb with an empty name")
}
if verb.UsageText == "" {
t.Errorf("verb %q has empty usage_text", verb.Name)
}
}
for _, function := range catalog.Functions {
if function.Name == "" {
t.Error("found a function with an empty name")
}
if function.Class == "" {
t.Errorf("function %q has empty class", function.Name)
}
if function.Arity == "" {
t.Errorf("function %q has empty arity", function.Name)
}
if function.Help == "" {
t.Errorf("function %q has empty help", function.Name)
}
}
for _, flag := range catalog.Flags {
if flag.Name == "" {
t.Error("found a flag with an empty name")
}
if flag.Section == "" {
t.Errorf("flag %q has empty section", flag.Name)
}
}
for _, keyword := range catalog.Keywords {
if keyword.Name == "" {
t.Error("found a keyword with an empty name")
}
if keyword.Help == "" {
t.Errorf("keyword %q has empty help", keyword.Name)
}
}
}
// TestIndexIsComplete verifies that `mlr help --as-json --index` covers every
// catalog item (verb, function, flag, keyword) with non-empty name and summary.
func TestIndexIsComplete(t *testing.T) {
full := buildFullCatalog()
index := buildIndex()
// Build sets from the full catalog for comparison.
verbSet := make(map[string]bool, len(full.Verbs))
for _, v := range full.Verbs {
verbSet[v.Name] = true
}
funcSet := make(map[string]bool, len(full.Functions))
for _, f := range full.Functions {
funcSet[f.Name] = true
}
flagSet := make(map[string]bool, len(full.Flags))
for _, fl := range full.Flags {
flagSet[fl.Name] = true
}
kwSet := make(map[string]bool, len(full.Keywords))
for _, kw := range full.Keywords {
kwSet[kw.Name] = true
}
for _, entry := range index {
if entry.Name == "" {
t.Errorf("index entry has empty name (kind=%s)", entry.Kind)
}
if entry.Summary == "" {
t.Errorf("index entry %q (kind=%s) has empty summary", entry.Name, entry.Kind)
}
switch entry.Kind {
case "verb":
delete(verbSet, entry.Name)
case "function":
delete(funcSet, entry.Name)
case "flag":
delete(flagSet, entry.Name)
case "keyword":
delete(kwSet, entry.Name)
default:
t.Errorf("index entry %q has unexpected kind %q", entry.Name, entry.Kind)
}
}
for name := range verbSet {
t.Errorf("verb %q present in full catalog but missing from index", name)
}
for name := range funcSet {
t.Errorf("function %q present in full catalog but missing from index", name)
}
for name := range flagSet {
t.Errorf("flag %q present in full catalog but missing from index", name)
}
for name := range kwSet {
t.Errorf("keyword %q present in full catalog but missing from index", name)
}
}
// TestIndexExtractFlag verifies that --index is extracted correctly.
func TestIndexExtractFlag(t *testing.T) {
got, rest := extractIndexFlag([]string{"--index"})
if !got {
t.Error("expected --index to be found")
}
if len(rest) != 0 {
t.Errorf("unexpected rest: %v", rest)
}
got, rest = extractIndexFlag([]string{"verb", "--index", "cat"})
if !got {
t.Error("expected --index to be found in middle position")
}
if len(rest) != 2 || rest[0] != "verb" || rest[1] != "cat" {
t.Errorf("unexpected rest: %v", rest)
}
got, _ = extractIndexFlag([]string{"verb", "cat"})
if got {
t.Error("expected --index not to be found")
}
}
// TestPerTopicLookups exercises the single-entry lookups used by
// `mlr help <topic> <name> --json`, including the not-found path.
func TestPerTopicLookups(t *testing.T) {
if got := collectVerbs([]string{"cat"}); len(got) != 1 || got[0].Name != "cat" {
t.Errorf("verb lookup for cat: got %+v", got)
}
if got := collectVerbs([]string{"no-such-verb"}); len(got) != 0 {
t.Errorf("verb lookup for bogus name should be empty, got %+v", got)
}
if got := collectFunctions([]string{"splitax"}); len(got) != 1 || got[0].Name != "splitax" {
t.Errorf("function lookup for splitax: got %+v", got)
}
if got := collectKeywords([]string{"ENV"}); len(got) != 1 || got[0].Name != "ENV" {
t.Errorf("keyword lookup for ENV: got %+v", got)
}
if got := collectFlags([]string{"--ifs"}); len(got) != 1 || got[0].Name != "--ifs" {
t.Errorf("flag lookup for --ifs: got %+v", got)
}
}