mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
* refine the plan
* Fix all staticcheck lint findings (uncapped)
golangci-lint's default max-same-issues=3 was hiding most of the backlog:
the true pre-fix count was 69 staticcheck findings, not 34. This fixes all
of them, driving staticcheck to zero:
- ST1023/QF1011 (37): omit explicit types inferred from the RHS
- S1009/S1031 (15): drop redundant nil checks before len()/range
- SA9003 (9): remove comment-only empty branches, keeping the comments
- QF1007 (3): merge conditional assignment into declaration
- QF1006 (3): lift break conditions into loop conditions
- QF1001 (3): apply De Morgan's law / name the negated predicate
Also updates plans/lintfixes.md with the cap discovery and the corrected
errcheck picture (1202 uncapped, ~949 of them fmt.Fprint*).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drive errcheck to zero: config for bulk categories, propagate real errors
Adds .golangci.yml with errcheck exclude-functions for fmt.Fprint* (usage
printers), (*bufio.Writer).Write/WriteString (sticky errors, surfaced at the
now-checked final Flush), and (*strings.Builder).WriteString; pins
max-issues-per-linter/max-same-issues to 0 so CI reports true counts.
Real error paths now propagate instead of being dropped:
- Finalize{Reader,Writer}Options in join/put/filter/split/tee and the
repl/script entry points: 'mlr join -i badformat' now errors instead of
silently using wrong separators
- final output-stream Flush in pkg/stream: write failure no longer exits 0
- DSL emit/print/dump redirect writes, matching their sibling branches
- CSV writer WriteCSVRecordMaybeColorized, close-time Flush in file output
handlers, ENV[...] Setenv, REPL record-write and redirect-close errors
- termcvt write-side Close before rename (had "TODO: check return status")
The rest are deliberate ignores, marked with _ = and a comment where the
reason isn't obvious: unset-of-missing-path no-ops, read-side closes,
mid-stream FlushOnEveryRecord, init-time strftime registrations, in-memory
usage-capture pipes, and regtest-harness env/temp-file teardown.
golangci-lint now reports 0 issues on ./cmd/mlr ./pkg/... with all caps off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
180 lines
5.8 KiB
Go
180 lines
5.8 KiB
Go
// mlr which -- intent-to-capability router for AI agents and interactive users.
|
|
//
|
|
// Usage:
|
|
//
|
|
// mlr which "natural language query"
|
|
//
|
|
// Searches verb names and summaries (and other catalog items) for query-word
|
|
// matches, emits a ranked JSON array, and exits with:
|
|
//
|
|
// 0 — at least one result whose name contains a query token (confident match)
|
|
// 1 — usage / argument error
|
|
// 2 — no result scored a name-level match (low confidence)
|
|
//
|
|
// The exit-code contract lets an agent branch on status rather than parsing
|
|
// the prose, while the result array is still useful even on exit code 2.
|
|
//
|
|
// Lives here (alongside the other --as-json help machinery) rather than in its
|
|
// own package because it imports the same four catalog registries and shares
|
|
// the firstLine and kindRank helpers with entry_json.go.
|
|
|
|
package help
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/johnkerl/miller/v6/pkg/cli"
|
|
"github.com/johnkerl/miller/v6/pkg/dsl/cst"
|
|
"github.com/johnkerl/miller/v6/pkg/transformers"
|
|
)
|
|
|
|
// WhichResultEntry is one ranked match returned by `mlr which`.
|
|
// nameHit is unexported (not in JSON) and records whether any query token
|
|
// matched the item's name; it drives the exit-code decision in WhichMain.
|
|
type WhichResultEntry struct {
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Score int `json:"score"`
|
|
Summary string `json:"summary"`
|
|
nameHit bool
|
|
}
|
|
|
|
// WhichMain is the entrypoint called by the terminals dispatcher for `mlr which`.
|
|
func WhichMain(args []string) int {
|
|
args = args[1:] // strip "which"
|
|
|
|
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
|
fmt.Fprintf(os.Stderr, "Usage: mlr which \"natural language query\"\n")
|
|
fmt.Fprintf(os.Stderr, "Searches verb names, summaries, and other catalog items for query-word matches.\n")
|
|
fmt.Fprintf(os.Stderr, "Emits a JSON array of {kind, name, score, summary} sorted by descending score.\n")
|
|
fmt.Fprintf(os.Stderr, "Exit codes: 0=confident match (name hit), 2=no confident match.\n")
|
|
return 1
|
|
}
|
|
|
|
query := strings.Join(args, " ")
|
|
tokens := whichTokenize(query)
|
|
if len(tokens) == 0 {
|
|
fmt.Fprintf(os.Stderr, "mlr which: empty query\n")
|
|
return 1
|
|
}
|
|
|
|
results := whichSearch(tokens)
|
|
|
|
b, err := json.MarshalIndent(results, "", " ")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "mlr which: could not render JSON: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Println(string(b))
|
|
|
|
if len(results) > 0 && results[0].nameHit {
|
|
return 0
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// whichNameMatchScore is the per-token weight for a name match.
|
|
const whichNameMatchScore = 20
|
|
|
|
// whichSearch scores every catalog item against tokens and returns matches in
|
|
// descending score order. Items with score 0 are omitted.
|
|
func whichSearch(tokens []string) []WhichResultEntry {
|
|
results := make([]WhichResultEntry, 0)
|
|
|
|
for _, v := range transformers.GetVerbInfosForJSON() {
|
|
if s, hit := whichScore(tokens, v.Name, v.Summary+" "+v.UsageText); s > 0 {
|
|
results = append(results, WhichResultEntry{
|
|
Kind: "verb", Name: v.Name, Score: s, Summary: v.Summary, nameHit: hit,
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, f := range cst.BuiltinFunctionManagerInstance.GetFunctionInfosForJSON() {
|
|
if s, hit := whichScore(tokens, f.Name, f.Help); s > 0 {
|
|
results = append(results, WhichResultEntry{
|
|
Kind: "function", Name: f.Name, Score: s, Summary: firstLine(f.Help), nameHit: hit,
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, fl := range cli.FLAG_TABLE.GetFlagInfosForJSON() {
|
|
nameText := fl.Name + " " + strings.Join(fl.AltNames, " ")
|
|
if s, hit := whichScore(tokens, nameText, fl.Help); s > 0 {
|
|
results = append(results, WhichResultEntry{
|
|
Kind: "flag", Name: fl.Name, Score: s, Summary: firstLine(fl.Help), nameHit: hit,
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, kw := range cst.GetKeywordInfosForJSON() {
|
|
if s, hit := whichScore(tokens, kw.Name, kw.Help); s > 0 {
|
|
results = append(results, WhichResultEntry{
|
|
Kind: "keyword", Name: kw.Name, Score: s, Summary: firstLine(kw.Help), nameHit: hit,
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(results, func(i, j int) bool {
|
|
if results[i].Score != results[j].Score {
|
|
return results[i].Score > results[j].Score
|
|
}
|
|
// Tiebreak: verbs first (most useful for agents), then alphabetical name.
|
|
ki, kj := kindRank(results[i].Kind), kindRank(results[j].Kind)
|
|
if ki != kj {
|
|
return ki < kj
|
|
}
|
|
return results[i].Name < results[j].Name
|
|
})
|
|
|
|
return results
|
|
}
|
|
|
|
// whichScore sums per-token weights: whichNameMatchScore per token found in
|
|
// name, 5 per token found in body. Matching is case-insensitive substring.
|
|
// Returns the total score and whether any token hit the name.
|
|
func whichScore(tokens []string, name, body string) (score int, nameHit bool) {
|
|
lname := strings.ToLower(name)
|
|
lbody := strings.ToLower(body)
|
|
for _, tok := range tokens {
|
|
if strings.Contains(lname, tok) {
|
|
score += whichNameMatchScore
|
|
nameHit = true
|
|
} else if strings.Contains(lbody, tok) {
|
|
score += 5
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// whichStopwords is the set of words that carry no discriminating signal
|
|
// against Miller's catalog and are dropped during query tokenization.
|
|
var whichStopwords = map[string]bool{
|
|
"a": true, "an": true, "the": true, "to": true, "of": true,
|
|
"in": true, "on": true, "at": true, "by": true, "for": true,
|
|
"and": true, "or": true, "is": true, "it": true, "do": true,
|
|
"with": true, "from": true, "into": true, "how": true,
|
|
"get": true, "use": true, "two": true, "my": true,
|
|
}
|
|
|
|
// whichTokenize lowercases and splits a query into non-trivial words, dropping
|
|
// single-character tokens and stopwords.
|
|
func whichTokenize(query string) []string {
|
|
words := strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
|
|
isWordRune := ('a' <= r && r <= 'z') || ('0' <= r && r <= '9') || r == '-' || r == '_'
|
|
return !isWordRune
|
|
})
|
|
var tokens []string
|
|
seen := map[string]bool{}
|
|
for _, w := range words {
|
|
if len(w) <= 1 || whichStopwords[w] || seen[w] {
|
|
continue
|
|
}
|
|
seen[w] = true
|
|
tokens = append(tokens, w)
|
|
}
|
|
return tokens
|
|
}
|