///|
/// A public symbol discovered in a generated MoonBit interface.
pub(all) struct ApiSymbol {
name : String
kind : String
package_path : String
} derive(Eq)
///|
/// The test evidence associated with one public API symbol.
pub(all) struct ApiCoverageEntry {
name : String
kind : String
package_path : String
tested : Bool
test_files : Array[String]
} derive(Eq)
///|
/// Aggregate public-API test coverage.
pub(all) struct ApiCoverageSummary {
total : Int
covered : Int
uncovered : Int
percentage : Int
} derive(Eq)
///|
/// Public API coverage for a scanned project.
pub(all) struct ApiCoverage {
entries : Array[ApiCoverageEntry]
summary : ApiCoverageSummary
} derive(Eq)
///|
/// Associate generated public declarations with test files in the same
/// package. This is intentionally evidence-based: a symbol is covered when a
/// package test mentions its name, making the result explainable and useful
/// for directing stronger behavioral tests.
pub fn analyze_api_coverage(
root : String,
report : QualityReport,
) -> ApiCoverage {
let symbols = collect_api_symbols(root, report)
let entries : Array[ApiCoverageEntry] = []
let mut covered = 0
for symbol in symbols {
let matching_tests : Array[String] = []
for test_file in report.test_files {
if test_file.package_path == symbol.package_path {
let content = fs_read(join_path(root, test_file.path))
if content.contains(symbol.name) {
matching_tests.push(test_file.path)
}
}
}
let tested = matching_tests.length() > 0
if tested {
covered += 1
}
entries.push({
name: symbol.name,
kind: symbol.kind,
package_path: symbol.package_path,
tested,
test_files: matching_tests,
})
}
let total = entries.length()
{
entries,
summary: {
total,
covered,
uncovered: total - covered,
percentage: if total == 0 {
100
} else {
covered * 100 / total
},
},
}
}
///|
/// Render API coverage as a concise CI report.
pub fn render_api_coverage(value : ApiCoverage) -> String {
let out = StringBuilder()
out.write_string("MoonSeal API Coverage\n")
out.write_string(
"summary: covered=" +
value.summary.covered.to_string() +
" total=" +
value.summary.total.to_string() +
" percentage=" +
value.summary.percentage.to_string() +
"%\n",
)
for entry in value.entries {
let status = if entry.tested { "tested" } else { "uncovered" }
out.write_string(
"- " +
status +
" " +
entry.package_path +
"::" +
entry.name +
" (" +
entry.kind +
")\n",
)
if entry.tested {
out.write_string(
" evidence: " + join_strings(entry.test_files, ", ") + "\n",
)
}
}
out.to_string()
}
///|
fn collect_api_symbols(
root : String,
report : QualityReport,
) -> Array[ApiSymbol] {
let symbols : Array[ApiSymbol] = []
let files = fs_list_files(root)
for file in files {
if file.has_suffix("pkg.generated.mbti") {
let package_path = package_path_of(file)
let content = fs_read(join_path(root, file))
for raw_line in content.replace_all(old="\r\n", new="\n").split("\n") {
let line = raw_line.trim().to_owned()
match api_declaration(line) {
Some((kind, name)) =>
if !contains_api_symbol(symbols, package_path, name) {
symbols.push({ name, kind, package_path })
}
None => ()
}
}
}
}
ignore(report)
symbols
}
///|
fn api_declaration(line : String) -> (String, String)? {
match declaration_after(line, "pub fn ", "function") {
Some(value) => Some(value)
None =>
match declaration_after(line, "pub(all) struct ", "struct") {
Some(value) => Some(value)
None =>
match declaration_after(line, "pub(all) enum ", "enum") {
Some(value) => Some(value)
None =>
match declaration_after(line, "pub trait ", "trait") {
Some(value) => Some(value)
None => declaration_after(line, "pub type ", "type")
}
}
}
}
}
///|
fn declaration_after(
line : String,
prefix : String,
kind : String,
) -> (String, String)? {
if !line.has_prefix(prefix) {
return None
}
let rest = line[prefix.length():].to_owned()
let end = first_declaration_end(rest)
let name = rest[:end].trim().to_owned()
if name.length() == 0 {
None
} else {
Some((kind, name))
}
}
///|
fn first_declaration_end(input : String) -> Int {
let mut end = input.length()
for index in 0.. Bool {
for item in symbols {
if item.package_path == package_path && item.name == name {
return true
}
}
false
}
///|
fn join_strings(items : Array[String], separator : String) -> String {
let out = StringBuilder()
for index in 0.. 0 {
out.write_string(separator)
}
out.write_string(items[index])
}
out.to_string()
}
///|
pub impl ToJson for ApiSymbol with fn to_json(self : ApiSymbol) -> Json {
Json::object({
"name": Json::string(self.name),
"kind": Json::string(self.kind),
"package_path": Json::string(self.package_path),
})
}
///|
pub impl ToJson for ApiCoverageEntry with fn to_json(self : ApiCoverageEntry) -> Json {
let files = Array::new()
for file in self.test_files {
files.push(Json::string(file))
}
Json::object({
"name": Json::string(self.name),
"kind": Json::string(self.kind),
"package_path": Json::string(self.package_path),
"tested": Json::boolean(self.tested),
"test_files": Json::array(files),
})
}
///|
pub impl ToJson for ApiCoverageSummary with fn to_json(
self : ApiCoverageSummary,
) -> Json {
Json::object({
"total": Json::number(self.total.to_double()),
"covered": Json::number(self.covered.to_double()),
"uncovered": Json::number(self.uncovered.to_double()),
"percentage": Json::number(self.percentage.to_double()),
})
}
///|
pub impl ToJson for ApiCoverage with fn to_json(self : ApiCoverage) -> Json {
let entries = Array::new()
for entry in self.entries {
entries.push(entry.to_json())
}
Json::object({
"entries": Json::array(entries),
"summary": self.summary.to_json(),
})
}