From 5dfc323c1b533eb30373a112f2115a1d1b81224a Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Sat, 15 Aug 2026 10:01:54 +0200 Subject: [PATCH] fix(interactive): resolve --label-column case-insensitively before pre-fill defaultInOptions matches exactly, so a --label-column differing from the CSV header only in case failed the match and fell through to defaultLabelChoice -- the FIRST column when nothing is named "label". Enter then accepted that wrong column silently. Until #505 a supplied --label-column skipped the prompt entirely and its spelling was kept verbatim, so it never had to agree with the header. Now that the question is always asked, the mismatch became reachable. canonicalHeader resolves the supplied value to the header's own spelling before the guard. Resolving there rather than loosening defaultInOptions keeps the case-insensitivity where the options are user data -- defaultInOptions also guards --intent and --label-policy against fixed vocabularies, which should stay exact. A value matching nothing is returned unchanged, so a genuine typo is still caught rather than case-folded into a hit. Mutation-proved: reverting the canonicalHeader call reddens both new rows with "Income" -> "age" and "CHURNED" -> "age", the defect verbatim. Found by Bugbot on release-train promotion PR cli#511. Co-Authored-By: Claude Opus 5 --- internal/cli/interactive.go | 35 +++++++++++++++++++++++++++----- internal/cli/interactive_test.go | 12 ++++++++++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index bf3d05c..27d1a50 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -625,22 +625,47 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b // named "label" if present; otherwise — the header isn't readable yet — it falls // back to free text pre-filled with the supplied value so the flow never stalls. // -// supplied is guarded through defaultInOptions before it reaches survey.Select: -// a mistyped --label-column that is not one of the real headers would otherwise -// abort the prompt on a TTY, the same default-not-in-options crash guarded -// everywhere else in the guided flow. +// supplied is resolved to the header's own spelling (canonicalHeader) and then +// guarded through defaultInOptions before it reaches survey.Select: a mistyped +// --label-column that is not one of the real headers would otherwise abort the +// prompt on a TTY, the same default-not-in-options crash guarded everywhere +// else in the guided flow. func promptLabelColumn(pr prompter, category, root, question, supplied string) (string, error) { headers, err := push.PreviewLabelHeaders(category, root) if err == nil && len(headers) > 0 { ans, serr := pr.Select(question, "pick the label/target column from your CSV header", headers, - defaultInOptions(supplied, headers, defaultLabelChoice(headers))) + defaultInOptions(canonicalHeader(supplied, headers), headers, defaultLabelChoice(headers))) return strings.TrimSpace(ans), serr } ans, ierr := pr.Input(question, "the label/target column name", supplied, nil) return strings.TrimSpace(ans), ierr } +// canonicalHeader returns the header that matches want case-insensitively, so a +// --label-column differing from the CSV only in case pre-selects the real column +// instead of silently falling back to the header default. +// +// Why this is needed at all: defaultInOptions matches EXACTLY, and until #505 a +// supplied --label-column skipped the prompt entirely, so its spelling was kept +// verbatim and never had to agree with the header. Now that the question is +// always asked, an unresolved value falls through to defaultLabelChoice — the +// FIRST column when nothing is named "label" — and Enter accepts that wrong +// column. Resolving here (rather than loosening defaultInOptions, which also +// guards --intent and --label-policy against fixed vocabularies) keeps the +// case-insensitivity where the options are user data. +// +// Returns want unchanged when nothing matches, leaving defaultInOptions to apply +// the fallback: a genuine typo must still be caught, not case-folded into a hit. +func canonicalHeader(want string, headers []string) string { + for _, h := range headers { + if strings.EqualFold(h, want) { + return h + } + } + return want +} + // defaultLabelChoice pre-highlights a column literally named "label" // (case-insensitive) when one exists, else the first column — a sensible // starting point for the SELECT. diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index 56a5ff2..e96a259 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -700,7 +700,9 @@ func TestRunInteractive_SuppliedLabelColumnStillAsks(t *testing.T) { // // Mutation-proof: passing `supplied` straight to pr.Select (dropping the guard) // makes the "mistyped" row error under the strict fake; not threading the value -// at all makes the "valid-supplied" row return the header default instead. +// at all makes the "valid-supplied" row return the header default instead; +// dropping canonicalHeader (resolving case) reddens the case-mismatch rows, +// which resolve to "age" — the first column — exactly as the defect did. func TestPromptLabelColumn_SuppliedDefaultPrefillsAndGuards(t *testing.T) { dir := tabularDir(t) // header: age,income,churned (no column named "label") const cat = "tabular_classification" @@ -714,6 +716,14 @@ func TestPromptLabelColumn_SuppliedDefaultPrefillsAndGuards(t *testing.T) { {"valid-supplied-is-preselected", "income", "income"}, {"empty-supplied-uses-header-default", "", "age"}, // defaultLabelChoice → first header {"mistyped-supplied-falls-back", "incom", "age"}, // guarded, no crash + + // Case-only differences must bind the HEADER's spelling, not fall back. + // Before the canonicalHeader resolve these landed on "age": the exact + // match failed, and with no column named "label" defaultLabelChoice + // returns the first column — which Enter then silently accepts as the + // label. That is the whole defect, so these rows are the regression. + {"case-mismatch-supplied-resolves", "Income", "income"}, + {"case-mismatch-uppercase-resolves", "CHURNED", "churned"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {