fix(aux-tab): correct nested-YAML parser so unknown-task surface works on remote

Bug 1 — the previous parser collected every indented child under
`auxiliary:` as if it were a task name, including leaf fields
(provider, model, base_url, api_key, timeout). Result: bogus rows
on local where the parser happened to fire, plus pollution of
the unknown-tasks set with field names that subtractFrom-known
left orphaned.

Bug 2 — the flat-dot-path branch (`auxiliary.X.Y:`) was dead
code. config.yaml is always nested YAML; the dot-path form only
appears in interactive `hermes config get` output, never on
disk. Removing it.

User reported the unknown-tasks section showed on local but not
on remote. Most likely root cause: the buggy parser surfaced
junk on local (where their config has nested-form aux settings)
while the dead flat-path branch never fired on remote either,
so remote silently rendered nothing. With the parser fixed both
contexts now surface real unknown task names if any are
present.

Rewrite as a clean two-pass walker:
- First nested line inside the block locks taskIndent.
- Only collect at exactly taskIndent (skip leaf fields deeper).
- Tolerate CRLF line endings, blank lines, and YAML comments
  without resetting block state.
- Handles 2-space and 4-space indent equally.

Verified manually with four fixture shapes: 2-space, 4-space,
with-comments-and-blanks, no-aux-block. All correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-05 13:12:55 +02:00
parent bccaba0742
commit 1eb5c92f6a
@@ -61,65 +61,71 @@ struct AuxiliaryTab: View {
/// fall-through "Other tasks in config.yaml" section. /// fall-through "Other tasks in config.yaml" section.
private var unknownTasks: [String] { private var unknownTasks: [String] {
let known = Set(tasks.map(\.key)) let known = Set(tasks.map(\.key))
let found = Self.parseAuxTaskNames(from: viewModel.rawConfigYAML)
return found.subtracting(known).sorted()
}
/// Walk the raw config.yaml for the top-level `auxiliary:` block
/// and collect ONLY direct-child task names (not the leaf
/// fields underneath them like `provider`, `model`, `api_key`).
/// Static + `internal` so unit tests can drive it with fixture
/// strings without standing up a SettingsViewModel.
///
/// Handles both 2-space and 4-space indent styles. Tolerates
/// blank lines and comments. Stops collecting when indent
/// drops back to or below the `auxiliary:` line same shape
/// the YAML parser uses to decide block boundaries.
static func parseAuxTaskNames(from yaml: String) -> Set<String> {
var found: Set<String> = [] var found: Set<String> = []
// Scan top-level `auxiliary.<key>.<field>` keys.
for line in viewModel.rawConfigYAML.components(separatedBy: "\n") {
let stripped = line.drop(while: { $0 == " " || $0 == "\t" })
// YAML emits these as either nested under `auxiliary:`
// or as flat dot-paths depending on Hermes' dump style.
// We match either shape by looking for keys after
// `auxiliary.<key>` OR by walking nested indentation.
// Quickest robust approach: regex over the raw text.
if let m = stripped.range(of: #"^auxiliary\.([A-Za-z0-9_]+)\."#, options: .regularExpression) {
let frag = String(stripped[m])
let parts = frag.split(separator: ".")
if parts.count >= 2 {
found.insert(String(parts[1]))
}
}
}
// Nested form: scan for `<indent>auxiliary:` then collect
// direct child keys.
let lines = viewModel.rawConfigYAML.components(separatedBy: "\n")
var inAuxBlock = false var inAuxBlock = false
var auxIndent = -1 var auxIndent = -1
for line in lines { // Indent of the first task-name line we see inside the
// block. Established lazily so we work with both 2- and
// 4-space indentation. Once locked, only collect at this
// exact indent anything deeper is a leaf field.
var taskIndent = -1
for rawLine in yaml.components(separatedBy: "\n") {
// Strip line-trailing CRs (Windows / SSH artifacts).
let line = rawLine.hasSuffix("\r") ? String(rawLine.dropLast()) : rawLine
let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) let trimmed = line.drop(while: { $0 == " " || $0 == "\t" })
let indent = line.count - trimmed.count let indent = line.count - trimmed.count
// Skip blanks + comments without resetting state.
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
if !inAuxBlock {
if trimmed.hasPrefix("auxiliary:") { if trimmed.hasPrefix("auxiliary:") {
inAuxBlock = true inAuxBlock = true
auxIndent = indent auxIndent = indent
taskIndent = -1
}
continue continue
} }
guard inAuxBlock else { continue }
// Out of the aux block when indent drops back to or // Out of the aux block when indent drops back to or
// below auxIndent on a non-empty line. // below auxIndent on a non-comment / non-blank line.
if !trimmed.isEmpty && indent <= auxIndent { if indent <= auxIndent {
inAuxBlock = false inAuxBlock = false
taskIndent = -1
continue continue
} }
// Direct children of `auxiliary:` are at indent // First nested line inside the block: that indent
// > auxIndent and contain a `key:` (no further nesting // level is the task-name level for the rest of this
// dots before the colon). // block.
if indent > auxIndent, if taskIndent == -1 {
let colonIdx = trimmed.firstIndex(of: ":") { taskIndent = indent
let key = trimmed[..<colonIdx] }
// The first deeper key under `auxiliary:` is a task // Skip leaf fields they live at indent > taskIndent.
// name. We can't easily distinguish "task name" from guard indent == taskIndent else { continue }
// "leaf field" without tracking indent more carefully, // The line should look like `<key>:` or `<key>: <inline>`.
// but only task names sit at indent == auxIndent + 2 // Match `<identifier>:` at the start to filter out
// (or +4 with two-space indent). Add the simplest // things like flow-style maps `[a, b]:` that aren't
// heuristic: collect any token that's plausibly a // task definitions.
// task name. guard let colonIdx = trimmed.firstIndex(of: ":") else { continue }
let candidate = String(key).trimmingCharacters(in: .whitespaces) let key = trimmed[..<colonIdx].trimmingCharacters(in: .whitespaces)
if !candidate.isEmpty if !key.isEmpty,
&& candidate.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) { key.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) {
found.insert(candidate) found.insert(key)
} }
} }
} return found
let unknown = found.subtracting(known)
return unknown.sorted()
} }
var body: some View { var body: some View {