Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# ADR-43409: Post-Parse Diagnostic Improvement for Tool Config Shape Violations

**Date**: 2026-07-05
**Status**: Draft
**Deciders**: Unknown (generated from PR diff)

---

### Context

When a user writes a workflow frontmatter `tools.<name>` key as a scalar string (e.g. `github: "invalid-string"`) and also includes nested child keys beneath it (e.g. `toolsets: [default]`), the upstream YAML parser produces two quality problems:

1. **Wrong source location** — the error caret lands on the child key line rather than on the scalar value that is the actual mistake.
2. **Parser-internal phrasing** — the raw message ("value is not allowed in this context. map key-value is pre-defined") is meaningless to workflow authors and doesn't name the tool key involved.

The compiler also failed to classify this diagnostic as a syntax error, causing it to surface generic event/filter remediation hints instead of syntax remediation guidance. These combined issues made the error experience confusing and hard to act on.

### Decision

We will intercept and rewrite affected YAML diagnostics in a new post-parse adjustment layer (`improveFrontmatterDiagnostic` in `pkg/workflow/frontmatter_error.go`) rather than modifying the upstream YAML parser or catching the pattern pre-parse. This function detects the scalar-with-nested-key diagnostic pattern, re-anchors the reported position to the scalar value line, and replaces the message with a tool-aware phrase: `tools.<name> tool config must be an object, not a string (for example: toolsets: [default])`. The error classifier is also extended to recognise the translated message as a syntax-category error.

### Alternatives Considered

#### Alternative 1: Modify the Upstream YAML Parser

Patch the third-party YAML parser to emit the error at the parent scalar line with a user-friendly message at parse time.

Considered because it would fix the issue at the root cause. Not chosen because it requires forking or upstreaming a change to an external library, is high-risk for regressions across all YAML error paths, and is disproportionate effort for a single error pattern.

#### Alternative 2: Pre-Parse Structural Validation

Before invoking the YAML parser, scan the raw frontmatter text for the `key: "scalar"\n child:` pattern and produce a custom error without involving the YAML parser at all.

Considered because it avoids dependence on parser error message text (which can change). Not chosen because it duplicates partial YAML understanding in the pre-parse layer, cannot leverage the parser's existing line/column tracking, and adds a fragile regex-based YAML structural check outside the normal parse pipeline.

#### Alternative 3: Generic "Review Your Tools Config" Fallback Message

When the parser emits this error pattern, replace the message with a generic "check your tools configuration" hint without attempting to re-anchor the source position.

Considered as a low-effort improvement. Not chosen because it still leaves the caret on the wrong line, which is the more confusing of the two problems, and because the tool name is available from context and makes the message significantly more actionable.

### Consequences

#### Positive
- Workflow authors see the error caret on the line they actually need to fix (the scalar value line) rather than on an unrelated child key line.
- The message names the specific tool key involved (`tools.github`) and provides a concrete correct example, reducing iteration time to fix the mistake.
- The fix is isolated to the error-formatting layer and does not affect the parse, compile, or validation pipelines.
- The syntax-category classification ensures users receive the correct remediation hint ("Fix the YAML/frontmatter syntax first") instead of an unrelated event/filter hint.

#### Negative
- The detection heuristic relies on matching translated message text and regex-parsing source lines; if the upstream parser changes its raw message or the translation changes, the heuristic silently stops firing (falling back to the original, lower-quality diagnostic).
- The approach is specific to the `tools.<name>` ancestor context; other scalar-with-children patterns elsewhere in frontmatter are not improved.
- `frontmatter_error.go` now carries a third special-case adjustment path, increasing the maintenance surface of the error formatting layer.

#### Neutral
- A custom `renderSourceContextForPosition` helper is added to re-render the source context snippet when the position is re-anchored, keeping the VSCode-compatible output format consistent.
- The `parsePositiveInt` helper duplicates a simple int-parsing pattern rather than importing a shared utility, keeping the dependency surface minimal.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
4 changes: 4 additions & 0 deletions pkg/parser/yaml_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ var yamlErrorTranslations = []struct {
"mapping values are not allowed",
"unexpected ':' — check indentation or if this key belongs in a mapping block",
},
{
"value is not allowed in this context. map key-value is pre-defined",
"value cannot have child keys here — this key must be an object/mapping, not a scalar value",
},
{
"string was used where mapping is expected",
"expected a YAML mapping (key: value pairs) but got a plain string",
Expand Down
6 changes: 6 additions & 0 deletions pkg/parser/yaml_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ func TestTranslateYAMLMessage(t *testing.T) {
contains: "missing ':' after key",
excludes: "mapping value not found",
},
{
name: "scalar with nested key diagnostic",
input: "value is not allowed in this context. map key-value is pre-defined",
contains: "must be an object/mapping",
excludes: "map key-value is pre-defined",
},
{
name: "found character that cannot start any token",
input: "found character that cannot start any token",
Expand Down
19 changes: 19 additions & 0 deletions pkg/workflow/compiler_yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,25 @@ Invalid YAML with missing comma in array.`,
expectedMessagePart: "',' or ']' must be specified",
description: "missing comma in array should be detected",
},
{
name: "github_tool_scalar_with_nested_key",
content: `---
on: push
tools:
github: "invalid-string"
toolsets: [default]
engine: claude
strict: false
---

# Test Workflow

Invalid YAML with scalar github tool config that has nested keys.`,
expectedErrorLine: 4, // highlight the invalid github scalar value line, not the nested key line

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test YAML content is technically invalid — github: "invalid-string" with an indented child key is a YAML syntax error, not a valid frontmatter snippet — so the test asserts only the first compiler error path. There's no assertion that expectedMessagePart does not appear in any other error produced on the same input, which means a latent regression could slip through if the parser emits the old wording as a second error.

💡 Suggestion

Add an excludedMessagePart field to the test struct and assert that the raw YAML parser wording ("map key-value is pre-defined") is absent from all error messages, not just the main one. This is consistent with the pattern already used in TestTranslateYAMLMessage.

@copilot please address this.

expectedErrorColumn: 11,
expectedMessagePart: "tools.github tool config must be a mapping (object), not a scalar value",
description: "invalid github tool scalar should point to the scalar line with actionable message",
},
{
name: "mixed_tabs_and_spaces",
content: "---\non: push\npermissions:\n contents: read\n\tissues: write\nengine: claude\n---\n\n# Test Workflow\n\nInvalid YAML with mixed tabs and spaces.",
Expand Down
5 changes: 4 additions & 1 deletion pkg/workflow/error_recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,10 @@ func classifyErrorMessage(message string) PrioritizedError {
strings.Contains(lower, "failed to parse yaml frontmatter"),
strings.Contains(lower, "no frontmatter found"),
strings.Contains(lower, "mapping values are not allowed"),
strings.Contains(lower, "did not find expected key"):
strings.Contains(lower, "did not find expected key"),
strings.Contains(lower, "missing ':' after key"),
strings.Contains(lower, "unexpected ':'"),
strings.Contains(lower, "tool config must be a mapping"):
return PrioritizedError{
Message: message,
Severity: SeverityCritical,
Expand Down
33 changes: 33 additions & 0 deletions pkg/workflow/error_recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,36 @@ func TestBuildPrioritizedErrorReportFromMessages_UnknownPermissionScopeHintPrese
require.Len(t, report.DisplayedErrors, 1)
assert.Equal(t, "Remove or replace the unknown permission scope `Contents` in `permissions:` and re-run `gh aw compile`.", report.DisplayedErrors[0].Suggestion)
}

func TestBuildPrioritizedErrorReportFromMessages_YAMLSyntaxGetsSyntaxHint(t *testing.T) {
message := `/tmp/workflow.md:3:1: error: missing ':' after key
2 | on: push
3 | permissions
^`

report := BuildPrioritizedErrorReportFromMessages([]string{message}, true)

require.Len(t, report.DisplayedErrors, 1)
prioritized := report.DisplayedErrors[0]
assert.Equal(t, SeverityCritical, prioritized.Severity)
assert.Equal(t, "syntax", prioritized.Category)
assert.Equal(t, "Fix the YAML/frontmatter syntax first, then re-run `gh aw compile`.", prioritized.Suggestion)
assert.NotContains(t, prioritized.Suggestion, "event or filter")
}

func TestBuildPrioritizedErrorReportFromMessages_ToolConfigMappingGetsSyntaxHint(t *testing.T) {
message := `/tmp/workflow.md:4:11: error: tools.github tool config must be a mapping (object), not a scalar value (for example: toolsets: [default])
3 | tools:
> 4 | github: "invalid-string"
^
5 | toolsets: [default]`

report := BuildPrioritizedErrorReportFromMessages([]string{message}, true)

require.Len(t, report.DisplayedErrors, 1)
prioritized := report.DisplayedErrors[0]
assert.Equal(t, SeverityCritical, prioritized.Severity)
assert.Equal(t, "syntax", prioritized.Category)
assert.Equal(t, "Fix the YAML/frontmatter syntax first, then re-run `gh aw compile`.", prioritized.Suggestion)
assert.NotContains(t, prioritized.Suggestion, "event or filter")
}
146 changes: 146 additions & 0 deletions pkg/workflow/frontmatter_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package workflow
import (
"fmt"
"regexp"
"strconv"
"strings"

"github.com/github/gh-aw/pkg/logger"
Expand All @@ -21,6 +22,8 @@ const frontmatterParseErrPrefix = "failed to parse frontmatter:\n"
var (
lineColPattern = regexp.MustCompile(`\[(\d+):(\d+)\]\s*(.+)`)
sourceContextPattern = regexp.MustCompile(`\n(>?\s*\d+\s*\|)`)
yamlKeyLinePattern = regexp.MustCompile(`^(\s*)([A-Za-z0-9._-]+)\s*:\s*(.+?)\s*$`)
yamlAnyKeyLine = regexp.MustCompile(`^(\s*)([A-Za-z0-9._-]+)\s*:\s*(.*)$`)
)

// readSourceContextLines extracts source lines around a target line (±3 lines)
Expand Down Expand Up @@ -86,6 +89,8 @@ func (c *Compiler) createFrontmatterError(filePath, content string, err error, f
if matches := lineColPattern.FindStringSubmatch(errorStr); len(matches) >= 4 {
line := matches[1]
col := matches[2]
originalLine := line
originalCol := col
message := matches[3]
// Extract just the first line of the message (before newline)
if idx := strings.Index(message, "\n"); idx != -1 {
Expand All @@ -94,6 +99,7 @@ func (c *Compiler) createFrontmatterError(filePath, content string, err error, f
// Translate raw YAML parser messages to user-friendly plain English.
// Uses the shared translation table from pkg/parser to keep both code paths in sync.
message = parser.TranslateYAMLMessage(message)
line, col, message = improveFrontmatterDiagnostic(content, line, col, message)

// Format as: filename:line:column: error: message
// This is compatible with VSCode's problem matcher
Expand All @@ -104,6 +110,11 @@ func (c *Compiler) createFrontmatterError(filePath, content string, err error, f
if loc := sourceContextPattern.FindStringIndex(errorStr); loc != nil {
// Extract from the first source context line to the end
context := errorStr[loc[0]+1:] // +1 to skip the leading newline
if line != originalLine || col != originalCol {
if custom := renderSourceContextForPosition(content, parsePositiveInt(line), parsePositiveInt(col)); custom != "" {
context = custom
}
}
// Return VSCode-compatible format on first line, followed by source context only
frontmatterErrorLog.Print("Formatting error for VSCode compatibility")
return parser.NewFormattedParserError(fmt.Sprintf("%s\n%s", vscodeFormat, context))
Expand Down Expand Up @@ -136,3 +147,138 @@ func (c *Compiler) createFrontmatterError(filePath, content string, err error, f
fallbackFmt := fmt.Sprintf("%s:%d:1: error: %s", filePath, frontmatterLineOffset, err.Error())
return parser.NewFormattedParserError(fallbackFmt)
}

// improveFrontmatterDiagnostic adjusts known low-quality parser diagnostics to
// point at the true source line and provide user-facing wording.
func improveFrontmatterDiagnostic(content, line, col, message string) (string, string, string) {
lower := strings.ToLower(strings.TrimSpace(message))
// Only the translated phrase is checked here because parser.TranslateYAMLMessage
// runs before improveFrontmatterDiagnostic (see createFrontmatterError); the raw
// parser wording is never seen by the time this function is called.
isScalarWithNestedKey := strings.Contains(lower, "value cannot have child keys here")
if !isScalarWithNestedKey {
return line, col, message
}

lineNum, colNum := parsePositiveInt(line), parsePositiveInt(col)
if lineNum <= 1 || colNum <= 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lineNum <= 1 guard silently swallows errors on line 2: the parent of a child at line 2 would be line 1, which is valid content, but this guard returns the original diagnostic unchanged.

💡 Details
lineNum, colNum := parsePositiveInt(line), parsePositiveInt(col)
if lineNum <= 1 || colNum <= 0 {
    return line, col, message
}

The condition lineNum <= 1 bails out when lineNum == 1 (correct — no parent possible) but also when lineNum == 2, which has a valid parent at lines[0]. In practice the frontmatter starts with --- so lines[0] is never a YAML key, but this is accidental protection. The guard is overly strict and would hide a real scalar-at-line-2 error in a future format. The correct guard for "no parent possible" is lineNum < 2.

// Fix:
if lineNum < 2 || colNum <= 0 {
    return line, col, message
}

return line, col, message
}

lines := strings.Split(content, "\n")
if lineNum-1 >= len(lines) {
return line, col, message
}

childLine := lines[lineNum-1]
parentLineNum := lineNum - 1
if parentLineNum-1 < 0 || parentLineNum-1 >= len(lines) {
return line, col, message
}
parentLine := lines[parentLineNum-1]

parentMatch := yamlKeyLinePattern.FindStringSubmatch(parentLine)
if len(parentMatch) < 4 {
return line, col, message
}

parentIndent := len(parentMatch[1])
parentKey := parentMatch[2]
parentValue := strings.TrimSpace(parentMatch[3])
if parentValue == "" || isLikelyYAMLContainer(parentValue) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] isLikelyYAMLContainer only guards against block scalars (|, >) and empty collections ({}, []). A parent value like 123, true, false, or null would pass through and produce a misleading tools.<key> tool config must be an object, not a string error message even though the value is not a string.

💡 Suggestion

Rename to isLikelyNonScalar (or widen the guard) and add coverage for the integer/boolean cases:

{
    name: "integer parent value is not rewritten",
    content: "tools:\n  github: 42\n    toolsets: [default]",
    // expect line/col/message unchanged (42 is not a string)
}

Alternatively, keep the check narrow but ensure the generated message says "not a scalar" rather than "not a string" so it covers all scalar types.

@copilot please address this.

return line, col, message
}

childIndent := len(childLine) - len(strings.TrimLeft(childLine, " "))
if childIndent <= parentIndent {
return line, col, message
}

ancestorKey := nearestAncestorKey(lines, parentLineNum-1, parentIndent)
if ancestorKey != "tools" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] improveFrontmatterDiagnostic silently passes through whenever the ancestor key is not "tools", but there is no test verifying this guard. A scalar-with-children mistake under permissions: or engine: would produce the raw YAML error wording rather than a helpful message — and the function would return unchanged without any signal.

💡 Suggested test

Add a table-driven unit test for improveFrontmatterDiagnostic directly, covering the non-tools ancestor case:

{
    name: "non-tools parent is not rewritten",
    content: "permissions:\n  contents: \"invalid-string\"\n    extra: value",
    line: "3", col: "5",
    message: "value cannot have child keys here — ...",
    // expect line/col/message unchanged
}

This makes the guard explicit and prevents silent failures if the scope is widened later.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only tools parent key is handled — other keys silently fall back to generic message

The check if ancestorKey != "tools" hard-codes the only key for which the improved diagnostic fires. That is intentional for this PR, but the code in improveFrontmatterDiagnostic claims via its comment to improve any "scalar-with-nested-key" parser diagnostic.

A user who writes:

engine: "bad-string"\n  sub: value

will still get the raw translated message ("value cannot have child keys here") instead of a tool-aware message. Consider either:

  1. Broadening the check to any known scalar-enforced top-level key, or
  2. Adding a doc comment to improveFrontmatterDiagnostic explicitly stating it is tools-only at this stage.

This is a non-blocking suggestion; the current behaviour is at least no worse than before.

@copilot please address this.

return line, col, message
}

// Find the value's column offset by searching after the colon, not the full line,
// to correctly handle keys and values that share a substring (e.g. "foo: foo").
keyEnd := len(parentMatch[1]) + len(parentMatch[2])
valueCol := colNum - 1 // fallback: original column
if colonPos := strings.Index(parentLine[keyEnd:], ":"); colonPos >= 0 {
afterColon := parentLine[keyEnd+colonPos+1:]
trimmed := strings.TrimLeft(afterColon, " ")
valueCol = keyEnd + colonPos + 1 + (len(afterColon) - len(trimmed))
}

return strconv.Itoa(parentLineNum), strconv.Itoa(valueCol + 1),
fmt.Sprintf("tools.%s tool config must be a mapping (object), not a scalar value (for example: toolsets: [default])", parentKey)
}

func parsePositiveInt(v string) int {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] parsePositiveInt is defined twice: once here and once in pkg/workflow/compilerenv/manager_test.go (which even has a regression test TestParsePositiveIntEnvVar_OverflowRegression). Duplicating a parsing utility fragments test coverage and makes future fixes diverge.

💡 Suggestion

Extract parsePositiveInt to a shared internal package (e.g., pkg/internal/strutil) or to pkg/parser, and have both call sites import it. This is consistent with the existing pattern of sharing TranslateYAMLMessage through pkg/parser.

@copilot please address this.

n := 0
for _, ch := range v {
if ch < '0' || ch > '9' {
return 0
}
n = n*10 + int(ch-'0')
}
return n
}

func isLikelyYAMLContainer(value string) bool {
switch strings.TrimSpace(value) {
case "|", ">", "{}", "[]":
return true
}
return strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[")
}

func nearestAncestorKey(lines []string, startIdx, childIndent int) string {
for i := startIdx; i >= 0; i-- {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}

match := yamlAnyKeyLine.FindStringSubmatch(line)
if len(match) < 3 {
continue
}
if len(match[1]) < childIndent {
return match[2]
}
}
return ""
}

func renderSourceContextForPosition(content string, targetLine, targetCol int) string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] renderSourceContextForPosition has no direct unit test. The only coverage is through the integration path (TestCompileWorkflowWithInvalidYAML), which makes it hard to verify edge cases: targetCol past the line's length, a targetLine of exactly len(lines), or an empty content string.

💡 Why this matters

The caret rendering (strings.Repeat(" ", 7+targetCol-1)) will silently produce a misaligned caret if targetCol is larger than the actual line width. A dedicated unit test would catch this without needing a full compiler round-trip.

Consider extracting the test cases from the existing compiler integration test into a focused TestRenderSourceContextForPosition table test.

@copilot please address this.

if targetLine <= 0 {
return ""
}
lines := strings.Split(content, "\n")
if targetLine > len(lines) {
return ""
}
if targetCol <= 0 {
targetCol = 1
}

startLine := max(1, targetLine-2)
endLine := min(len(lines), targetLine+2)

var b strings.Builder
for lineNum := startLine; lineNum <= endLine; lineNum++ {
prefix := " "
if lineNum == targetLine {
prefix = ">"
}
fmt.Fprintf(&b, "%s %3d | %s\n", prefix, lineNum, lines[lineNum-1])
if lineNum == targetLine {
// The source-context prefix is: one prefix char ("%s") + one space + three-digit
// line number ("%3d") + " | " = 7 fixed chars plus the variable prefix char = 8
// total. Column N therefore needs 7+N spaces before the caret character.
fmt.Fprintf(&b, "%s^\n", strings.Repeat(" ", 7+targetCol))
}
}
return strings.TrimRight(b.String(), "\n")
}
Loading