-
Notifications
You must be signed in to change notification settings - Fork 0
feat(findings): render RCA readably and surface detector verdicts in findings get #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dark-sorceror
wants to merge
5
commits into
main
Choose a base branch
from
feat/findings-get-render
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
adede04
feat(render): add width-aware text wrapping with minimal markdown sty…
dark-sorceror 687c846
feat(findings): wrap RCA output and surface detector verdicts in find…
dark-sorceror 5e83333
feat(findings): end findings get with trace link and next-step hints
dark-sorceror 0d4a931
fix(render): preserve punctuation adjacency around bold spans when wr…
dark-sorceror 052478e
fix(render): keep indented continuations inside their list items
dark-sorceror File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| /** | ||
| * Width-aware text wrapping with minimal markdown treatment, for rendering | ||
| * free-form text (e.g. an RCA result) that may contain light markdown from an | ||
| * LLM. Pure string transform — no I/O, no width detection — so callers pass in | ||
| * an explicit width (typically `process.stdout.columns ?? 80`) and a `bold` | ||
| * styler (typically `createStyler(sink).bold`), keeping this testable without a | ||
| * real TTY. | ||
| * | ||
| * Handled, deliberately minimally: | ||
| * - `# `..`###### ` headings: the `#` marker is stripped and the heading text | ||
| * is styled bold (or left plain when `bold` is a no-op). | ||
| * - `**bold**` spans: the `**` markers are stripped and the enclosed text is | ||
| * styled bold. | ||
| * - `` `code` `` spans: the backticks are stripped (left plain). | ||
| * - `-`/`*`/`1.`-style list items: wrapped with a hanging indent so | ||
| * continuation lines align under the item's text rather than the marker; | ||
| * indented source lines directly under a bullet are folded into that item. | ||
| * - Plain paragraphs (consecutive non-blank, non-list, non-heading lines): | ||
| * joined and re-wrapped to `width`; blank lines are preserved as paragraph | ||
| * separators. | ||
| * | ||
| * Never emits a literal `#`/`**`/backtick marker for the constructs above. | ||
| */ | ||
|
|
||
| /** A styling function, e.g. `Styler["bold"]`; identity for plain-text output. */ | ||
| export type StyleFn = (text: string) => string; | ||
|
|
||
| const HEADING_RE = /^(#{1,6})\s+(.*)$/; | ||
| const LIST_ITEM_RE = /^(\s*)([-*]|\d+\.)\s+(.*)$/; | ||
| const BOLD_SPAN_RE = /\*\*(.+?)\*\*/g; | ||
| const CODE_SPAN_RE = /`([^`]+)`/g; | ||
|
|
||
| /** A run of same-emphasis characters within a {@link Word}. */ | ||
| interface Piece { | ||
| plain: string; | ||
| bold: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * One wrappable unit: a maximal run of non-whitespace characters. A word can | ||
| * span emphasis boundaries (e.g. `(**timeout**),` is one word of three pieces: | ||
| * `(`, bold `timeout`, `),`), so punctuation glued to a bold span in the source | ||
| * stays glued in the output. `length` is the visible (unstyled) width. | ||
| */ | ||
| interface Word { | ||
| pieces: Piece[]; | ||
| length: number; | ||
| } | ||
|
|
||
| /** Strips inline code backticks (left plain) then splits `**bold**` spans out. */ | ||
| function tokenizeInline(text: string): Array<{ text: string; bold: boolean }> { | ||
| const noCode = text.replace(CODE_SPAN_RE, "$1"); | ||
| const tokens: Array<{ text: string; bold: boolean }> = []; | ||
| let last = 0; | ||
| for (const match of noCode.matchAll(BOLD_SPAN_RE)) { | ||
| const index = match.index ?? 0; | ||
| if (index > last) { | ||
| tokens.push({ text: noCode.slice(last, index), bold: false }); | ||
| } | ||
| tokens.push({ text: match[1] ?? "", bold: true }); | ||
| last = index + match[0].length; | ||
| } | ||
| if (last < noCode.length) { | ||
| tokens.push({ text: noCode.slice(last), bold: false }); | ||
| } | ||
| return tokens; | ||
| } | ||
|
|
||
| /** | ||
| * Splits inline-tokenized text into words. Whitespace in the SOURCE is the only | ||
| * word boundary: an emphasis edge with no whitespace around it (e.g. | ||
| * `**Note**:` or `pre**bold**post`) continues the current word, so no space is | ||
| * invented next to punctuation when the pieces are rejoined. | ||
| */ | ||
| function toWords(text: string): Word[] { | ||
| const words: Word[] = []; | ||
| let pieces: Piece[] = []; | ||
|
|
||
| const flushWord = (): void => { | ||
| if (pieces.length > 0) { | ||
| words.push({ pieces, length: pieces.reduce((n, p) => n + p.plain.length, 0) }); | ||
| pieces = []; | ||
| } | ||
| }; | ||
|
|
||
| for (const token of tokenizeInline(text)) { | ||
| // Split into alternating whitespace / non-whitespace runs (both kept): | ||
| // whitespace ends the current word; a non-whitespace run extends it. | ||
| for (const run of token.text.split(/(\s+)/)) { | ||
| if (run === "") { | ||
| continue; | ||
| } | ||
| if (/^\s/.test(run)) { | ||
| flushWord(); | ||
| } else { | ||
| pieces.push({ plain: run, bold: token.bold }); | ||
| } | ||
| } | ||
| } | ||
| flushWord(); | ||
| return words; | ||
| } | ||
|
|
||
| /** Renders a word's pieces, applying `style` to the bold ones. */ | ||
| function renderWord(word: Word, style: StyleFn): string { | ||
| return word.pieces.map((p) => (p.bold ? style(p.plain) : p.plain)).join(""); | ||
| } | ||
|
|
||
| /** A copy of `word` with every piece marked bold (for headings). */ | ||
| function boldWord(word: Word): Word { | ||
| return { pieces: word.pieces.map((p) => ({ ...p, bold: true })), length: word.length }; | ||
| } | ||
|
|
||
| /** | ||
| * Greedily fills lines up to `width` visible columns (ANSI escapes added by | ||
| * `style` don't count toward width, since wrapping decisions use `plain` | ||
| * lengths). `firstPrefix` prefixes the first output line (e.g. a list marker); | ||
| * `contPrefix` prefixes every following line (e.g. matching spaces), so | ||
| * continuation lines align under the first line's text. | ||
| */ | ||
| function fillLines( | ||
| words: Word[], | ||
| width: number, | ||
| style: StyleFn, | ||
| firstPrefix: string, | ||
| contPrefix: string, | ||
| ): string[] { | ||
| const safeWidth = Math.max(1, width); | ||
| const lines: string[] = []; | ||
| let current: Word[] = []; | ||
| let currentLen = firstPrefix.length; | ||
|
|
||
| const flush = (): void => { | ||
| const prefix = lines.length === 0 ? firstPrefix : contPrefix; | ||
| lines.push(prefix + current.map((w) => renderWord(w, style)).join(" ")); | ||
| current = []; | ||
| currentLen = contPrefix.length; | ||
| }; | ||
|
|
||
| for (const word of words) { | ||
| const sep = current.length === 0 ? 0 : 1; | ||
| if (current.length > 0 && currentLen + sep + word.length > safeWidth) { | ||
| flush(); | ||
| } | ||
| current.push(word); | ||
| currentLen += (current.length === 1 ? 0 : 1) + word.length; | ||
| } | ||
| if (current.length > 0 || lines.length === 0) { | ||
| flush(); | ||
| } | ||
| return lines; | ||
| } | ||
|
|
||
| /** | ||
| * Wraps `text` to `width` columns, applying minimal markdown styling (see | ||
| * module doc). `bold` is applied to headings and `**bold**` spans; pass the | ||
| * identity function for a plain-text (no-ANSI) fallback. | ||
| */ | ||
| /** An in-progress list item: its marker prefixes plus accumulated text lines. */ | ||
| interface OpenListItem { | ||
| firstPrefix: string; | ||
| contPrefix: string; | ||
| text: string[]; | ||
| } | ||
|
|
||
| export function wrapMarkdown(text: string, width: number, bold: StyleFn = (s) => s): string { | ||
| const out: string[] = []; | ||
| let paragraph: string[] = []; | ||
| let listItem: OpenListItem | null = null; | ||
|
|
||
| const flushParagraph = (): void => { | ||
| if (paragraph.length === 0) { | ||
| return; | ||
| } | ||
| const joined = paragraph.join(" ").trim(); | ||
| out.push(...fillLines(toWords(joined), width, bold, "", "")); | ||
| paragraph = []; | ||
| }; | ||
|
|
||
| const flushListItem = (): void => { | ||
| if (listItem === null) { | ||
| return; | ||
| } | ||
| const joined = listItem.text.join(" ").trim(); | ||
| out.push(...fillLines(toWords(joined), width, bold, listItem.firstPrefix, listItem.contPrefix)); | ||
| listItem = null; | ||
| }; | ||
|
|
||
| // At most one of paragraph / listItem is open at a time; flush both at any | ||
| // block boundary (blank line, heading, new bullet). | ||
| const flushBlocks = (): void => { | ||
| flushParagraph(); | ||
| flushListItem(); | ||
| }; | ||
|
|
||
| for (const rawLine of text.split("\n")) { | ||
| const line = rawLine.trimEnd(); | ||
| if (line.trim() === "") { | ||
| flushBlocks(); | ||
| out.push(""); | ||
| continue; | ||
| } | ||
|
|
||
| const heading = HEADING_RE.exec(line); | ||
| if (heading) { | ||
| flushBlocks(); | ||
| const words = toWords((heading[2] ?? "").trim()).map(boldWord); | ||
| out.push(...fillLines(words, width, bold, "", "")); | ||
| continue; | ||
| } | ||
|
|
||
| const marker = LIST_ITEM_RE.exec(line); | ||
| if (marker) { | ||
| flushBlocks(); | ||
| const firstPrefix = `${marker[1] ?? ""}${marker[2] ?? ""} `; | ||
| listItem = { | ||
| firstPrefix, | ||
| contPrefix: " ".repeat(firstPrefix.length), | ||
| text: [marker[3] ?? ""], | ||
| }; | ||
| continue; | ||
| } | ||
|
|
||
| // An indented line directly under an open bullet continues that item, so | ||
| // it keeps the item's hanging indent instead of falling out flush-left. | ||
| if (listItem !== null && /^\s/.test(line)) { | ||
| listItem.text.push(line.trim()); | ||
| continue; | ||
| } | ||
|
|
||
| flushListItem(); | ||
| paragraph.push(line.trim()); | ||
| } | ||
| flushBlocks(); | ||
|
|
||
| return out.join("\n"); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.