Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 162 additions & 2 deletions scripts/gh-json-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,169 @@ func guidance(source []byte) ([]string, error) {
return d.parts, nil
}

type markdownObserver struct {
position, delimiter, fenceLength, blockEnd int
fence byte
}

// markdownContentStart skips quote/list markers only when their container syntax is complete.
func markdownContentStart(source string, start, end int) (int, bool) {
for start < end {
markerEnd := start
switch source[start] {
case '>':
markerEnd++
case '-', '+', '*':
markerEnd++
if markerEnd == end || !strings.ContainsRune(" \t", rune(source[markerEnd])) {
return start, false
}
default:
for markerEnd < end && markerEnd-start < 9 && source[markerEnd] >= '0' && source[markerEnd] <= '9' {
markerEnd++
}
if markerEnd == start || markerEnd+1 >= end ||
(source[markerEnd] != '.' && source[markerEnd] != ')') ||
!strings.ContainsRune(" \t", rune(source[markerEnd+1])) {
return start, false
}
markerEnd++
}
start = markerEnd
if start < end && source[start] == ' ' {
start++ // Consume the container's optional/required separator only.
}
indent := start
for start < end && start-indent < 4 && source[start] == ' ' {
start++
}
if start-indent == 4 || start < end && source[start] == '\t' {
return start, true
}
}
return start, false
}

// delimiterAt advances once through source and observes the inline span before a field flag.
// Matching runs close spans; escaped prose and fenced/indented blocks cannot open them.
func (m *markdownObserver) delimiterAt(source string, stop int) int {
delimiter, fence, fenceLength := m.delimiter, m.fence, m.fenceLength
i := m.position
for i < stop {
if m.blockEnd > 0 && i >= m.blockEnd {
delimiter, m.blockEnd = 0, 0
}
if i == 0 || source[i-1] == '\n' {
lineEnd := strings.IndexByte(source[i:], '\n')
if lineEnd < 0 {
lineEnd = len(source)
} else {
lineEnd += i
}
start := i
for start < lineEnd && start-i < 4 && source[start] == ' ' {
start++
}
indented := start-i == 4 || start < lineEnd && source[start] == '\t'
if !indented {
var containerIndented bool
start, containerIndented = markdownContentStart(source, start, lineEnd)
if containerIndented {
indented, delimiter = true, 0
}
}
content := strings.Trim(source[start:lineEnd], " \t\r")
if content == "" || strings.Trim(source[i:lineEnd], " \t\r") == "%" {
delimiter = 0 // Paragraph and retained-value boundaries cannot supply an opener.
}
if fence == 0 && !indented && content != "" {
if strings.HasPrefix(source[start:], "<!--") {
delimiter = 0 // Raw HTML comments cannot open Markdown code spans.
closing := strings.Index(source[start+4:], "-->")
if closing < 0 {
i = len(source)
} else {
i = start + 4 + closing + 3
}
continue
}
heading := 0
for heading < len(content) && content[heading] == '#' {
heading++
}
if heading > 0 && heading <= 6 && (heading == len(content) || strings.ContainsRune(" \t", rune(content[heading]))) {
delimiter = 0
m.blockEnd = lineEnd + 1 // A heading's inline content ends on this line.
}
if strings.Trim(content, "=") == "" || strings.Trim(content, "- \t") == "" ||
strings.Trim(content, "* \t") == "" && strings.Count(content, "*") >= 3 ||
strings.Trim(content, "_ \t") == "" && strings.Count(content, "_") >= 3 {
delimiter = 0 // Setext headings and thematic breaks end the prior paragraph.
i = lineEnd + 1
continue
}
}
if !indented && start < lineEnd && (source[start] == '`' || source[start] == '~') {
end := start
for end < lineEnd && source[end] == source[start] {
end++
}
run := end - start
if fence == 0 && run >= 3 &&
(source[start] == '~' || !strings.Contains(source[end:lineEnd], "`")) {
fence, fenceLength = source[start], run
delimiter = 0
} else if fence == source[start] && run >= fenceLength &&
strings.Trim(source[end:lineEnd], " \t\r") == "" {
fence = 0
i = lineEnd + 1
continue
}
}
if fence != 0 || delimiter == 0 && indented {
i = lineEnd + 1 // Block contents are shell text, not inline-span openers.
continue
}
}
if delimiter == 0 && strings.HasPrefix(source[i:], "<!--") {
closing := strings.Index(source[i+4:], "-->")
if closing < 0 {
i = len(source)
} else {
i += 4 + closing + 3
}
continue
}
if delimiter == 0 && source[i] == '\\' && i+1 < stop {
i += 2 // An escaped prose backtick cannot open a code span.
continue
}
if source[i] != '`' {
i++
continue
}
end := i
for end < stop && source[end] == '`' {
end++
}
if delimiter == 0 {
delimiter = end - i
} else if delimiter == end-i {
delimiter = 0
}
i = end
}
m.position, m.delimiter, m.fence, m.fenceLength = i, delimiter, fence, fenceLength
return delimiter
}

// normalizeShellFields joins only adjacent literal fragments of advertised field words.
// Markdown delimiters end a word; expansions and unresolved quoting never establish a clean scan.
func normalizeShellFields(source string) (string, error) {
letter := func(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == ',' }
space := func(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
var output strings.Builder
markdown := &markdownObserver{}
position := 0
for position < len(source) {
relative := strings.Index(source[position:], "--json")
Expand Down Expand Up @@ -228,8 +385,11 @@ func normalizeShellFields(source string) (string, error) {
strings.Contains(separator, "\n") && strings.Trim(separator, " \t\r\n") == "" {
break // A following Markdown fence is not part of a field word.
}
line := strings.LastIndexByte(source[:flag], '\n') + 1
if strings.Count(source[line:flag], "`")%2 == 0 {
closing := end
for closing < len(source) && source[closing] == '`' {
closing++
}
if delimiter := markdown.delimiterAt(source, flag); delimiter == 0 || closing-end != delimiter {
return "", fmt.Errorf("JSON field word contains unresolved command substitution")
}
break // Close the Markdown span that opened before this command.
Expand Down
52 changes: 52 additions & 0 deletions scripts/gh-json-go/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,55 @@ func TestKnownArgumentsAreDecodedOnce(t *testing.T) {
t.Fatalf("known argv was redundantly decoded: %v", d.err)
}
}

// TestManyMarkdownSpans preserves literal guidance across many successive code spans.
func TestManyMarkdownSpans(t *testing.T) {
source := strings.Repeat("Use ``gh pr view --json state,mergedAt``. ", 10000)
text, err := normalizeShellFields(source)
if err != nil || text != source {
t.Fatalf("repeated literal guidance changed: %v", err)
}
}

// TestDecodedGuidanceBoundary prevents one retained value from supplying another's opener.
func TestDecodedGuidanceBoundary(t *testing.T) {
_, err := normalizeShellFields("An unmatched ` belongs to another decoded value.\n%\ngh pr view --json state,mer`printf ged`\n%\n")
if err == nil {
t.Fatal("a separate decoded value supplied a misleading Markdown opener")
}
}

// TestHeadingGuidanceBoundaries refuses delimiter inheritance across separate Markdown blocks.
func TestHeadingGuidanceBoundaries(t *testing.T) {
for _, source := range []string{
"# Heading with a literal `\ngh pr view --json state,mer`printf ged`",
"Prior paragraph with a literal `\n# gh pr view --json state,mer`printf ged`",
"Heading with a literal `\n=======================\ngh pr view --json state,mer`printf ged`",
"Paragraph with a literal `\n---\ngh pr view --json state,mer`printf ged`",
"Paragraph with a literal `\n***\ngh pr view --json state,mer`printf ged`",
"> echo `literal\n> gh pr view --json state,mer`printf ged`",
"<!-- Literal ` marker -->\ngh pr view --json state,mer`printf ged`",
} {
if _, err := normalizeShellFields(source); err == nil {
t.Errorf("a separate Markdown block supplied a misleading opener: %q", source)
}
}
}

// BenchmarkMarkdownSpans measures normalization at two input sizes while preserving all text.
func BenchmarkMarkdownSpans(b *testing.B) {
for _, count := range []int{1000, 10000} {
// Each size checks the full normalized output during every timed iteration.
b.Run(strconv.Itoa(count), func(b *testing.B) {
source := strings.Repeat("Use ``gh pr view --json state,mergedAt``. ", count)
b.SetBytes(int64(len(source)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
text, err := normalizeShellFields(source)
if err != nil || text != source {
b.Fatalf("literal guidance changed: %v", err)
}
}
})
}
}
93 changes: 93 additions & 0 deletions scripts/guard-gh-json-fields.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,99 @@ dir="$(fixture good-closing-fence-after-empty-list)"
printf '%s\n' '```bash' 'gh status --json' '```' > "${dir}/plugins/p/agents/case.md"
expect 0 'a closing Markdown fence after --json with no field list' "${dir}"

# Markdown spans close with a matching run, including after an earlier completed span.
dir="$(fixture good-double-backtick-span)"
printf '%s\n' 'Use ``gh pr view --json state,mergedAt``.' > "${dir}/plugins/p/agents/case.md"
expect 0 'a literal field list inside a double-backtick span' "${dir}"

dir="$(fixture good-triple-backtick-span)"
printf '%s\n' 'Use ```gh pr view --json state,mergedAt```.' > "${dir}/plugins/p/agents/case.md"
expect 0 'a literal field list inside a triple-backtick span' "${dir}"

dir="$(fixture good-earlier-backtick-span)"
printf '%s\n' 'A ``code`` span before ``gh pr view --json state,mergedAt``.' > "${dir}/plugins/p/agents/case.md"
expect 0 'a completed earlier span does not change the command delimiter' "${dir}"

dir="$(fixture bad-double-backtick-span)"
printf '%s\n' 'Use ``gh pr view --json state,merged``.' > "${dir}/plugins/p/agents/case.md"
expect 1 'a matching Markdown delimiter does not hide an invalid field' "${dir}"

dir="$(fixture unknown-backtick-inside-span)"
printf '%s\n' 'Use ``gh pr view --json state,mer`printf ged` ``.' > "${dir}/plugins/p/agents/case.md"
expect 2 'an unresolved single-backtick expression inside a double span is UNKNOWN' "${dir}"

dir="$(fixture unknown-mismatched-backtick-span)"
printf '%s\n' 'Use ``gh pr view --json state,mergedAt`.' > "${dir}/plugins/p/agents/case.md"
expect 2 'a mismatched closing Markdown delimiter is UNKNOWN' "${dir}"

dir="$(fixture good-multiline-backtick-span)"
printf '%s\n' 'Use ``gh pr view' '--json state,mergedAt``.' > "${dir}/plugins/p/agents/case.md"
expect 0 'an inline Markdown span can cross a newline' "${dir}"

dir="$(fixture unknown-escaped-backtick-prefix)"
printf '%s\n' 'Note \` is a literal marker. Run gh pr view --json state,mer`printf ged`' > "${dir}/plugins/p/agents/case.md"
expect 2 'an escaped prose backtick cannot disguise unresolved command substitution' "${dir}"

dir="$(fixture good-escaped-backtick-before-span)"
printf '%s\n' 'Note \` is literal. Use ``gh pr view --json state,mergedAt``.' > "${dir}/plugins/p/agents/case.md"
expect 0 'an escaped prose backtick does not change a later valid code span' "${dir}"

dir="$(fixture unknown-fenced-matching-substitution)"
printf '%s\n' '```bash' 'gh pr view --json state,mer```printf ged```' '```' > "${dir}/plugins/p/agents/case.md"
expect 2 'a block fence cannot disguise a same-length shell expression' "${dir}"

dir="$(fixture unknown-tilde-fenced-substitution)"
printf '%s\n' '~~~bash' 'gh pr view --json state,mer`printf ged`' '~~~' > "${dir}/plugins/p/agents/case.md"
expect 2 'a tilde-fenced unresolved shell expression is UNKNOWN' "${dir}"

dir="$(fixture unknown-indented-substitution)"
printf '%s\n' ' echo `gh pr view --json state,mer`printf ged`' > "${dir}/plugins/p/agents/case.md"
expect 2 'an indented code block cannot supply a Markdown opener' "${dir}"

dir="$(fixture unknown-tab-indented-substitution)"
printf '\t%s\n' 'echo `gh pr view --json state,mer`printf ged`' > "${dir}/plugins/p/agents/case.md"
expect 2 'a tab-indented code block cannot supply a Markdown opener' "${dir}"

dir="$(fixture good-span-after-fences)"
printf '%s\n' '```bash' 'echo `literal`' '````' 'Use ``gh pr view' '--json state,mergedAt``.' > "${dir}/plugins/p/agents/case.md"
expect 0 'a completed fenced block does not change a later multiline span' "${dir}"

dir="$(fixture unknown-opener-in-earlier-paragraph)"
printf '%s\n' 'An unmatched ` belongs to this paragraph.' '' 'gh pr view --json state,mer`printf ged`' > "${dir}/plugins/p/agents/case.md"
expect 2 'a prior paragraph cannot supply a code-span opener' "${dir}"

dir="$(fixture unknown-opener-before-code-block)"
printf '%s\n' 'An unmatched ` precedes a code block.' '```bash' 'gh pr view --json state,mer`printf ged`' '```' > "${dir}/plugins/p/agents/case.md"
expect 2 'an interrupting fenced block cannot inherit an inline opener' "${dir}"

dir="$(fixture unknown-quoted-fenced-substitution)"
printf '%s\n' '> ```bash' '> gh pr view --json state,mer```printf ged```' '> ```' > "${dir}/plugins/p/agents/case.md"
expect 2 'a blockquote fence cannot disguise unresolved command substitution' "${dir}"

dir="$(fixture unknown-list-fenced-substitution)"
printf '%s\n' '- ```bash' ' gh pr view --json state,mer```printf ged```' ' ```' > "${dir}/plugins/p/agents/case.md"
expect 2 'a list fence cannot disguise unresolved command substitution' "${dir}"

dir="$(fixture good-span-after-container-fences)"
printf '%s\n' '> ```bash' '> echo `literal`' '> ```' '- ```bash' ' echo `literal`' ' ```' 'Use ``gh pr view --json state,mergedAt``.' > "${dir}/plugins/p/agents/case.md"
expect 0 'completed blockquote and list fences preserve later literal guidance' "${dir}"

dir="$(fixture unknown-heading-opener)"
printf '%s\n' '# Heading with a literal `' 'gh pr view --json state,mer`printf ged`' > "${dir}/plugins/p/agents/case.md"
expect 2 'a heading cannot supply an opener for the next paragraph' "${dir}"

dir="$(fixture good-heading-command)"
printf '%s\n' '# Use ``gh pr view --json state,mergedAt``.' > "${dir}/plugins/p/agents/case.md"
expect 0 'a heading can contain its own complete literal command span' "${dir}"

dir="$(fixture unknown-quoted-indented-opener)"
printf '%s\n' '> echo `literal' '> gh pr view --json state,mer`printf ged`' > "${dir}/plugins/p/agents/case.md"
expect 2 'container-relative code indentation cannot supply an inline opener' "${dir}"

dir="$(fixture unknown-html-comment-opener)"
printf '%s\n' '<!-- Literal ` marker -->' 'gh pr view --json state,mer`printf ged`' > "${dir}/plugins/p/agents/case.md"
expect 2 'an HTML comment cannot supply an inline opener' "${dir}"

# A flag wrapped in shell quotes is still the flag: `gh pr view 42 '--json' state,merged`.
dir="$(fixture bad-quoted-flag)"
printf '%s\n' "gh pr view 42 '--json' state,merged" > "${dir}/plugins/p/agents/case.md"
Expand Down
Loading