diff --git a/scripts/gh-json-go/main.go b/scripts/gh-json-go/main.go index 5a2a62a9..a671f0d6 100644 --- a/scripts/gh-json-go/main.go +++ b/scripts/gh-json-go/main.go @@ -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:], "") + 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:], "") + 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") @@ -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. diff --git a/scripts/gh-json-go/main_test.go b/scripts/gh-json-go/main_test.go index 07f6d563..5c8a313b 100644 --- a/scripts/gh-json-go/main_test.go +++ b/scripts/gh-json-go/main_test.go @@ -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`", + "\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) + } + } + }) + } +} diff --git a/scripts/guard-gh-json-fields.test.sh b/scripts/guard-gh-json-fields.test.sh index 2f26534a..94609560 100755 --- a/scripts/guard-gh-json-fields.test.sh +++ b/scripts/guard-gh-json-fields.test.sh @@ -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' '' '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"