This report was generated by AI and independently verified before publication.
Summary
Sonar export strips trailing lone-CR from final line; native end-of-file column becomes unresolvable and aborts the whole report (exit 1, no output)
Classification
Bug
Version and provenance
Unchanged source commit f6ad02a57c887e54ff22e8484ce1eb69c1f92c70; hoonarqube 0.10.4. Native binary SHA-256: c2fc5396a2af0f3ac5401b5a10f0a4461e8df054ca641e96cfea75f2a7b14a87. Built with Rust 1.96.0 using cargo build --locked --offline -p hoonarqube-cli.
Reproduction
Save the following as repro.py. Set HOO_NATIVE to the binary built from the source commit above. The script creates disposable inputs and does not modify the checkout.
#!/usr/bin/env python3
"""Hoonarqube CLI review probe: Sonar export aborts on trailing lone-CR file.
Contract (crates/hoonarqube-cli/src/main.rs, SonarSource):
Line starts are computed with `line_terminator_width(bytes, offset, style)`,
where a lone b'\r' is a terminator only under SourceLineStyle::Ecmascript.
For a Generic-style path (.cs) the trailing lone CR therefore remains part of
the final line's content, exactly as the native analyzer modeled it when it
produced the issue ranges. `SonarSource::line` must return that content so
`sonar_utf16_column` can convert every native column the analyzer emitted,
and `analyze --format sonar` must emit the Generic Issue Import document for
any file the analyzer itself accepted (native text/JSON run is complete).
Trigger: a 25-byte C# file with classic-Mac (lone CR) line endings whose final
byte is CR. Native analysis is complete (5 findings, exit 0, S113 anchored at
the end-of-file column). Sonar export drops the final CR from line 1's
content, cannot resolve the native end column, and aborts the ENTIRE report:
exit 1, 0 bytes on stdout, stderr:
"cannot render Sonar output: Sonar source line 1 has no Unicode scalar column 25"
Control: byte-identical content with LF endings. Sonar export emits the
document and exits 0.
Usage: python3 repro.py trigger|control (exit 1 = contract violated)
"""
import json
import os
import subprocess
import sys
import tempfile
BODY_LINES = ["class A {", " int x = 1;", "}"]
TIMEOUT = 120
def build_source(mode: str) -> bytes:
terminator = b"\r" if mode == "trigger" else b"\n"
return terminator.join(line.encode() for line in BODY_LINES) + terminator
def run(binary: str, args, cwd: str):
return subprocess.run(
[binary, *args],
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=TIMEOUT,
)
def check(mode: str) -> int:
binary = os.environ["HOO_NATIVE"]
source = build_source(mode)
name = "lone_cr.cs" if mode == "trigger" else "clean_lf.cs"
failures = []
diagnostics = []
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, name)
with open(path, "wb") as handle:
handle.write(source)
diagnostics.append(
f"[{mode}] file={name} bytes={len(source)} "
f"terminator={'CR' if mode == 'trigger' else 'LF'} "
f"content={source!r}"
)
# Step 1: the native analyzer must accept this file and complete.
native = run(binary, ["analyze", "--format", "json", "--", name], tmp)
diagnostics.append(
f"[{mode}] analyze --format json: exit={native.returncode} "
f"stdout_bytes={len(native.stdout)}"
)
if native.returncode != 0:
failures.append("native JSON analysis unexpectedly failed")
else:
report = json.loads(native.stdout)
files = report.get("files", [])
issues = [i for f in files for i in f.get("issues", [])]
diagnostics.append(
f"[{mode}] native findings={len(issues)} "
f"rules={sorted({i['rule_key'] for i in issues})}"
)
if not issues:
failures.append("native analysis produced no findings to export")
s113 = [i for i in issues if i["rule_key"].endswith(":S113")]
if mode == "trigger" and not s113:
failures.append("expected native S113 end-of-file finding is absent")
if mode == "trigger" and s113:
diagnostics.append(
f"[{mode}] native S113 range={s113[0]['range']} "
"(end column is the native end-of-file position)"
)
# Step 2: Sonar Generic Issue Import export of the same file.
sonar = run(binary, ["analyze", "--format", "sonar", "--", name], tmp)
diagnostics.append(
f"[{mode}] analyze --format sonar: exit={sonar.returncode} "
f"stdout_bytes={len(sonar.stdout)} stderr={sonar.stderr.decode(errors='replace').strip()!r}"
)
if sonar.returncode != 0:
failures.append(
f"sonar export exited {sonar.returncode} for a file the "
f"analyzer itself completed (expected 0 with a document)"
)
elif not sonar.stdout.strip():
failures.append("sonar export exited 0 but emitted no document")
else:
document = json.loads(sonar.stdout)
exported = document.get("issues", [])
diagnostics.append(
f"[{mode}] sonar document issues={len(exported)} "
f"rules={len(document.get('rules', []))}"
)
if not exported:
failures.append("sonar document contains no issues")
bad = [
i
for i in exported
if "textRange" in i.get("primaryLocation", {})
and not {"startLine", "startColumn", "endLine", "endColumn"}
<= set(i["primaryLocation"]["textRange"])
]
if bad:
failures.append(f"sonar document has malformed textRanges: {bad[:2]}")
for line in diagnostics:
print(line)
if failures:
for failure in failures:
print(f"FAIL [{mode}]: {failure}")
return 1
print(f"PASS [{mode}]: sonar export emitted a valid document (exit 0)")
return 0
def main() -> int:
if len(sys.argv) != 2 or sys.argv[1] not in {"trigger", "control"}:
print("usage: repro.py trigger|control", file=sys.stderr)
return 2
return check(sys.argv[1])
if __name__ == "__main__":
sys.exit(main())
Run python3 repro.py trigger (observed exit 1) and python3 repro.py control (observed exit 0).
Expected and actual behavior
Expected: For any file the analyzer itself completes, analyze --format sonar emits the Generic Issue Import document with a textRange for every native finding. SonarSource::new splits lines with line_terminator_width (crates/hoonarqube-cli/src/main.rs:1938), where a lone CR terminates lines only under SourceLineStyle::Ecmascript (assessment.rs:1390); for a Generic-style path (.cs) the trailing lone CR is therefore part of the final line's content, exactly as the native analyzer modeled it, and sonar_utf16_column must be able to convert every emitted column including the end-of-file column. README.md:741-742: a complete scan with findings exits 0; only report/serialization failures exit 1.
Actual: line_content_end strips one trailing lone CR unconditionally (assessment.rs:1424-1425 has no SourceLineStyle guard, unlike the U+2028/U+2029 branch at 1426-1433). SonarSource::line (main.rs:1967) returns the shortened final line, sonar_utf16_column (main.rs:2193-2197) cannot resolve the native end column, and the Err propagates via sonar_import_issue (main.rs:2101/2108/2115) to render_sonar_output (main.rs:3045-3048): cannot render Sonar output: Sonar source line 1 has no Unicode scalar column 25, exit 1, 0 bytes on stdout. Executed with the trusted native binary on class A {\r int x = 1;\r}\r (25 bytes, 0 LF): native text/JSON analysis completes with 5 findings (csharpsquid:S113 at 1:24-25) and exit 0, while the Sonar export aborts the entire document instead of emitting it.
Evidence and scope
Current contract: crates/hoonarqube-ir/src/assessment.rs.
pub fn line_content_end(
source: &[u8],
start: usize,
next: usize,
line_style: SourceLineStyle,
) -> usize {
let mut end = next;
if end > start && source[end - 1] == b'\n' {
end -= 1;
if end > start && source[end - 1] == b'\r' {
end -= 1;
}
} else if end > start && source[end - 1] == b'\r' {
end -= 1;
} else if end >= start + 3
&& matches!(line_style, SourceLineStyle::Ecmascript)
&& source
.get(end - 3..end)
.is_some_and(|suffix| suffix == b"\xe2\x80\xa8" || suffix == b"\xe2\x80\xa9")
{
end -= 3;
}
end
}
Trigger output:
[trigger] file=lone_cr.cs bytes=25 terminator=CR content=b'class A {\r int x = 1;\r}\r'
[trigger] analyze --format json: exit=0 stdout_bytes=38186
[trigger] native findings=5 rules=['csharpsquid:S113', 'csharpsquid:S1144', 'csharpsquid:S2933', 'csharpsquid:S3903', 'csharpsquid:S4487']
[trigger] native S113 range={'start': {'line': 1, 'column': 24}, 'end': {'line': 1, 'column': 25}} (end column is the native end-of-file position)
[trigger] analyze --format sonar: exit=1 stdout_bytes=0 stderr='cannot render Sonar output: Sonar source line 1 has no Unicode scalar column 25'
FAIL [trigger]: sonar export exited 1 for a file the analyzer itself completed (expected 0 with a document)
Adjacent clean control output:
[control] file=clean_lf.cs bytes=25 terminator=LF content=b'class A {\n int x = 1;\n}\n'
[control] analyze --format json: exit=0 stdout_bytes=38019
[control] native findings=4 rules=['csharpsquid:S1144', 'csharpsquid:S2933', 'csharpsquid:S3903', 'csharpsquid:S4487']
[control] analyze --format sonar: exit=0 stdout_bytes=38654 stderr=''
[control] sonar document issues=4 rules=4
PASS [control]: sonar export emitted a valid document (exit 0)
The exact probe was replayed against unchanged source in a fresh environment. One independent review checked semantics, the reproduction, scope and possible duplicates. A reference difference alone is not proof of a native defect.
Acceptance criteria
discovery-probes/repro.py trigger exits 1 with the assertion failure (sonar export exit=1, stdout_bytes=0, stderr message printed); repro.py control exits 0 with a valid Sonar document (issues=4, rules=4). Trigger uses lone-CR terminators, control uses byte-identical LF content; nothing else differs. JSON, text, SARIF and GitLab outputs of the same file are unaffected; the defect is specific to the Sonar export boundary re-reading source through line_content_end.
Summary
Sonar export strips trailing lone-CR from final line; native end-of-file column becomes unresolvable and aborts the whole report (exit 1, no output)
Classification
Bug
Version and provenance
Unchanged source commit
f6ad02a57c887e54ff22e8484ce1eb69c1f92c70;hoonarqube 0.10.4. Native binary SHA-256:c2fc5396a2af0f3ac5401b5a10f0a4461e8df054ca641e96cfea75f2a7b14a87. Built with Rust 1.96.0 usingcargo build --locked --offline -p hoonarqube-cli.Reproduction
Save the following as
repro.py. SetHOO_NATIVEto the binary built from the source commit above. The script creates disposable inputs and does not modify the checkout.Run
python3 repro.py trigger(observed exit 1) andpython3 repro.py control(observed exit 0).Expected and actual behavior
Expected: For any file the analyzer itself completes,
analyze --format sonaremits the Generic Issue Import document with a textRange for every native finding. SonarSource::new splits lines with line_terminator_width (crates/hoonarqube-cli/src/main.rs:1938), where a lone CR terminates lines only under SourceLineStyle::Ecmascript (assessment.rs:1390); for a Generic-style path (.cs) the trailing lone CR is therefore part of the final line's content, exactly as the native analyzer modeled it, and sonar_utf16_column must be able to convert every emitted column including the end-of-file column. README.md:741-742: a complete scan with findings exits 0; only report/serialization failures exit 1.Actual: line_content_end strips one trailing lone CR unconditionally (assessment.rs:1424-1425 has no SourceLineStyle guard, unlike the U+2028/U+2029 branch at 1426-1433). SonarSource::line (main.rs:1967) returns the shortened final line, sonar_utf16_column (main.rs:2193-2197) cannot resolve the native end column, and the Err propagates via sonar_import_issue (main.rs:2101/2108/2115) to render_sonar_output (main.rs:3045-3048):
cannot render Sonar output: Sonar source line 1 has no Unicode scalar column 25, exit 1, 0 bytes on stdout. Executed with the trusted native binary onclass A {\r int x = 1;\r}\r(25 bytes, 0 LF): native text/JSON analysis completes with 5 findings (csharpsquid:S113 at 1:24-25) and exit 0, while the Sonar export aborts the entire document instead of emitting it.Evidence and scope
Current contract: crates/hoonarqube-ir/src/assessment.rs.
Trigger output:
Adjacent clean control output:
The exact probe was replayed against unchanged source in a fresh environment. One independent review checked semantics, the reproduction, scope and possible duplicates. A reference difference alone is not proof of a native defect.
Acceptance criteria
discovery-probes/repro.py trigger exits 1 with the assertion failure (sonar export exit=1, stdout_bytes=0, stderr message printed); repro.py control exits 0 with a valid Sonar document (issues=4, rules=4). Trigger uses lone-CR terminators, control uses byte-identical LF content; nothing else differs. JSON, text, SARIF and GitLab outputs of the same file are unaffected; the defect is specific to the Sonar export boundary re-reading source through line_content_end.