fix(kernel): CRLF docstring parity — JS multiline ^ anchors after \r, regex crate's (?m)^ is \n-only (#1329)

On CRLF checkouts (every Windows autocrlf clone) the JS reference's
block-continuation strip /^\s*\*\s?/gm finds a line start after the \r and
its greedy \s* consumes the \n, leaving a bare \r in the docstring; the
kernel's (?m)^ pass matched after \n only and kept \r\n. Caught by the O2
Windows VM leg (6 kernel-tsjs-parity failures), reproduced on macOS by
CRLF-converting the fixtures.

js_multiline_strip now replicates the JS anchor set (\n, \r, U+2028, U+2029)
for all five line-marker passes; CRLF variants of every torture fixture are
pinned in kernel-tsjs-parity, derived in-memory so nothing can normalize
them away.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-17 01:28:21 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 9e18ac2125
commit 5e329adc28
3 changed files with 93 additions and 10 deletions
+67 -10
View File
@@ -52,14 +52,55 @@ fn cleaners() -> &'static Cleaners {
paren_star_close: Regex::new(r"\*\)$").unwrap(),
brace_open: Regex::new(r"^\{").unwrap(),
brace_close: Regex::new(r"\}$").unwrap(),
slashes: Regex::new(r"(?m)^//[/!]?\s?").unwrap(),
dashes: Regex::new(r"(?m)^--\s?").unwrap(),
hash: Regex::new(r"(?m)^#\s?").unwrap(),
percent: Regex::new(r"(?m)^%+\s?").unwrap(),
star_cont: Regex::new(r"(?m)^\s*\*\s?").unwrap(),
slashes: Regex::new(r"\A//[/!]?\s?").unwrap(),
dashes: Regex::new(r"\A--\s?").unwrap(),
hash: Regex::new(r"\A#\s?").unwrap(),
percent: Regex::new(r"\A%+\s?").unwrap(),
star_cont: Regex::new(r"\A\s*\*\s?").unwrap(),
})
}
/// JS multiline `^` anchors after \n, \r, U+2028, U+2029; the regex crate's
/// `(?m)^` anchors after `\n` only. On CRLF content the JS engine finds a line
/// start after the `\r`, so a greedy leading `\s*` (the block-continuation
/// rule) consumes the `\n` and leaves the bare `\r` in the docstring —
/// byte-parity on CRLF checkouts (every Windows autocrlf clone) depends on
/// reproducing exactly that.
fn is_js_line_terminator(ch: char) -> bool {
matches!(ch, '\n' | '\r' | '\u{2028}' | '\u{2029}')
}
/// JS-semantics `str.replace(/^<pat>/gm, "")`: try the \A-anchored `pat` at
/// position 0 and after every JS line terminator, left to right, resuming
/// after each match's end — a faithful /g replace. (Remaining known
/// divergence: JS `\s` includes U+FEFF, Rust's does not; an embedded BOM
/// inside a comment is accepted as unreachable.)
fn js_multiline_strip(s: &str, pat: &Regex) -> String {
let mut out = String::with_capacity(s.len());
let mut last = 0usize;
let mut pos = 0usize;
while pos <= s.len() {
let at_line_start = pos == 0
|| s[..pos].chars().next_back().is_some_and(is_js_line_terminator);
if at_line_start {
if let Some(m) = pat.find(&s[pos..]) {
if !m.is_empty() {
out.push_str(&s[last..pos]);
last = pos + m.end();
pos = last;
continue;
}
}
}
match s[pos..].chars().next() {
Some(c) => pos += c.len_utf8(),
None => break,
}
}
out.push_str(&s[last..]);
out
}
/// cleanCommentMarkers — strip comment syntax, keep the prose.
pub fn clean_comment_markers(comment: &str) -> String {
let c = cleaners();
@@ -77,11 +118,11 @@ pub fn clean_comment_markers(comment: &str) -> String {
s = c.brace_open.replace(&s, "").into_owned();
s = c.brace_close.replace(&s, "").into_owned();
}
s = c.slashes.replace_all(&s, "").into_owned();
s = c.dashes.replace_all(&s, "").into_owned();
s = c.hash.replace_all(&s, "").into_owned();
s = c.percent.replace_all(&s, "").into_owned();
s = c.star_cont.replace_all(&s, "").into_owned();
s = js_multiline_strip(&s, &c.slashes);
s = js_multiline_strip(&s, &c.dashes);
s = js_multiline_strip(&s, &c.hash);
s = js_multiline_strip(&s, &c.percent);
s = js_multiline_strip(&s, &c.star_cont);
s.trim().to_string()
}
@@ -137,4 +178,20 @@ mod tests {
"Adds things.\n@param a first"
);
}
/// CRLF parity with the JS reference: multiline `^` matches after `\r`,
/// so the block-continuation `\s*` eats the `\n` and the bare `\r`
/// survives in the cleaned docstring (pinned against the wasm extractor
/// on a CRLF checkout — the Windows autocrlf shape).
#[test]
fn crlf_matches_js_reference() {
assert_eq!(
clean_comment_markers("/**\r\n * Class docs.\r\n * Multi-line.\r\n */"),
"Class docs.\rMulti-line."
);
assert_eq!(
clean_comment_markers("// a\r\n// b"),
"a\r\nb"
);
}
}