mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
* 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>
193 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|