///|
/// A project file captured by the caller.
pub(all) struct FileSnapshot {
path : String
content : String
} derive(Eq, @debug.Debug)
///|
/// Audit outcome for one requirement.
pub(all) enum Status {
Pass
Warn
Fail
} derive(Eq, @debug.Debug)
///|
/// Built-in checks map directly to MoonBit hackathon submission needs.
pub(all) enum CheckId {
Manifest
Readme
UsageExample
Tests
ContinuousIntegration
BuildCommands
ProjectMetadata
License
Traceability
MaintenanceDocs
Originality
} derive(Eq, @debug.Debug)
///|
/// One audit finding with short evidence.
pub(all) struct Finding {
id : CheckId
status : Status
message : String
evidence : String
} derive(Eq, @debug.Debug)
///|
/// Parsed project facts used by the audit report.
pub(all) struct ProjectProfile {
package_name : String?
version : String?
license : String?
repository : String?
moonbit_files : Int
test_files : Int
ci_files : Int
readme_chars : Int
originality_signals : Int
} derive(Eq, @debug.Debug)
///|
/// Full audit report. `score` is 0..100, where warns count as partial credit.
pub(all) struct Report {
score : Int
profile : ProjectProfile
findings : Array[Finding]
} derive(Eq, @debug.Debug)
///|
/// Audit a MoonBit project represented as file snapshots.
pub fn audit_project(files : Array[FileSnapshot]) -> Report {
let profile = build_profile(files)
let findings = []
findings.push(check_manifest(files, profile))
findings.push(check_readme(files, profile))
findings.push(check_usage_example(files))
findings.push(check_tests(profile))
findings.push(check_ci(files, profile))
findings.push(check_build_commands(files))
findings.push(check_project_metadata(files, profile))
findings.push(check_license(files, profile))
findings.push(check_traceability(files))
findings.push(check_maintenance_docs(files))
findings.push(check_originality(files, profile))
{ score: score_findings(findings), profile, findings }
}
///|
/// Render a compact human-readable report.
pub fn render_report(report : Report) -> String {
let lines = [
"MoonBit Submit Guard Report",
"score: " + report.score.to_string() + "/100",
"package: " + report.profile.package_name.unwrap_or(""),
"",
]
for finding in report.findings {
lines.push(
"- [" +
status_name(finding.status) +
"] " +
check_name(finding.id) +
": " +
finding.message +
evidence_suffix(finding.evidence),
)
}
lines.join("\n")
}
///|
/// Return commands that should pass before submitting the project.
pub fn submission_commands() -> Array[String] {
["moon check", "moon build", "moon test", "moon run cmd/main"]
}
///|
/// Count findings with the selected status.
pub fn count_status(report : Report, status : Status) -> Int {
let mut count = 0
for finding in report.findings {
if finding.status == status {
count = count + 1
}
}
count
}
///|
pub fn status_name(status : Status) -> String {
match status {
Pass => "pass"
Warn => "warn"
Fail => "fail"
}
}
///|
pub fn check_name(id : CheckId) -> String {
match id {
Manifest => "moon.mod"
Readme => "README"
UsageExample => "runnable example"
Tests => "tests"
ContinuousIntegration => "CI"
BuildCommands => "build commands"
ProjectMetadata => "project metadata"
License => "license"
Traceability => "traceability"
MaintenanceDocs => "maintenance docs"
Originality => "originality"
}
}
///|
/// Extract a quoted string value from moon.mod, for example `name = "owner/pkg"`.
pub fn moon_mod_string(content : String, key : String) -> String? {
let prefix = key + " = "
for line_view in content.split("\n") {
let line = trim(line_view.to_owned())
if line.has_prefix(prefix) {
return first_quoted_value(line)
}
}
None
}
///|
fn build_profile(files : Array[FileSnapshot]) -> ProjectProfile {
let moon_mod = find_file(files, "moon.mod")
let readme = readme_content(files)
{
package_name: moon_mod.bind(content => moon_mod_string(content, "name")),
version: moon_mod.bind(content => moon_mod_string(content, "version")),
license: moon_mod.bind(content => moon_mod_string(content, "license")),
repository: moon_mod.bind(content => moon_mod_string(content, "repository")),
moonbit_files: count_files(files, ".mbt"),
test_files: count_test_files(files),
ci_files: count_ci_files(files),
readme_chars: readme.unwrap_or("").length(),
originality_signals: originality_signals(files),
}
}
///|
fn check_manifest(
files : Array[FileSnapshot],
profile : ProjectProfile,
) -> Finding {
guard find_file(files, "moon.mod") is Some(_) else {
return finding(Manifest, Fail, "missing moon.mod", "moon.mod")
}
guard profile.package_name is Some(name) && name.contains("/") else {
return finding(Manifest, Fail, "package name must use owner/name", "name")
}
guard profile.version is Some(version) && version != "" else {
return finding(Manifest, Warn, "version is not set", "version")
}
finding(Manifest, Pass, "manifest is present and named", "moon.mod")
}
///|
fn check_readme(
files : Array[FileSnapshot],
profile : ProjectProfile,
) -> Finding {
match readme_content(files) {
None => finding(Readme, Fail, "missing README.md", "README.md")
Some(readme) => {
let lower = readme.to_lower()
let has_usage = contains_any(lower, [
"usage", "quick", "install", "example",
])
let has_boundary = contains_any(lower, [
"boundary", "scope", "unsupported",
])
if profile.readme_chars >= 1200 && has_usage && has_boundary {
finding(
Readme,
Pass,
"README explains purpose, usage, and scope",
"README.md",
)
} else if profile.readme_chars >= 600 && has_usage {
finding(
Readme,
Warn,
"README is useful but scope could be clearer",
"README.md",
)
} else {
finding(Readme, Fail, "README is too thin for review", "README.md")
}
}
}
}
///|
fn check_usage_example(files : Array[FileSnapshot]) -> Finding {
let main = find_file(files, "cmd/main/main.mbt")
match main {
Some(content) =>
if content.contains("fn main") && !content.contains("Hello") {
finding(
UsageExample,
Pass,
"cmd/main has a non-template example",
"cmd/main",
)
} else {
finding(
UsageExample,
Warn,
"example still looks like a template",
"cmd/main",
)
}
None =>
finding(
UsageExample,
Fail,
"missing runnable cmd/main example",
"cmd/main",
)
}
}
///|
fn check_tests(profile : ProjectProfile) -> Finding {
if profile.test_files >= 2 {
finding(
Tests,
Pass,
"blackbox and whitebox tests are present",
profile.test_files.to_string() + " test files",
)
} else if profile.test_files == 1 {
finding(Tests, Warn, "only one test file found", "1 test file")
} else {
finding(Tests, Fail, "no MoonBit tests found", "*_test.mbt")
}
}
///|
fn check_ci(files : Array[FileSnapshot], profile : ProjectProfile) -> Finding {
if profile.ci_files == 0 {
return finding(
ContinuousIntegration,
Fail,
"missing GitHub Actions workflow",
".github/workflows",
)
}
let ci = concat_matching(files, ".github/workflows/")
if contains_all(ci, ["moon check", "moon build", "moon test"]) {
finding(
ContinuousIntegration,
Pass,
"CI validates check, build, and tests",
".github/workflows",
)
} else {
finding(
ContinuousIntegration,
Warn,
"CI exists but does not cover the full MoonBit loop",
".github/workflows",
)
}
}
///|
fn check_build_commands(files : Array[FileSnapshot]) -> Finding {
let docs = all_text(files).to_lower()
if contains_all(docs, [
"moon check", "moon build", "moon test", "moon run cmd/main",
]) {
finding(
BuildCommands,
Pass,
"submission validation commands are documented",
"README/docs",
)
} else {
finding(
BuildCommands,
Warn,
"document all local validation commands",
"moon check/build/test/run",
)
}
}
///|
fn check_project_metadata(
files : Array[FileSnapshot],
profile : ProjectProfile,
) -> Finding {
guard find_file(files, "moon.mod") is Some(content) else {
return finding(ProjectMetadata, Fail, "missing moon.mod", "moon.mod")
}
let has_description = moon_mod_string(content, "description").unwrap_or("") !=
""
let has_keywords = content.contains("keywords") && content.contains("[")
let has_repository = profile.repository.unwrap_or("") != ""
if has_description && has_keywords && has_repository {
finding(
ProjectMetadata,
Pass,
"description, keywords, and repository are set",
"moon.mod",
)
} else {
finding(
ProjectMetadata,
Warn,
"project metadata needs description, keywords, and repository",
"moon.mod",
)
}
}
///|
fn check_license(
files : Array[FileSnapshot],
profile : ProjectProfile,
) -> Finding {
let has_license_file = file_exists(files, "LICENSE")
let manifest_license = profile.license.unwrap_or("")
if has_license_file && manifest_license != "" {
finding(
License,
Pass,
"license file and manifest license are present",
manifest_license,
)
} else {
finding(
License,
Fail,
"license file or manifest license is missing",
"LICENSE",
)
}
}
///|
fn check_traceability(files : Array[FileSnapshot]) -> Finding {
let has_changelog = file_exists(files, "CHANGELOG.md")
let has_issue_template = any_path_contains(files, ".github/ISSUE_TEMPLATE/")
let has_pr_template = file_exists(files, ".github/PULL_REQUEST_TEMPLATE.md")
if has_changelog && has_issue_template && has_pr_template {
finding(
Traceability,
Pass,
"changelog plus issue and PR templates are present",
".github",
)
} else if has_changelog {
finding(
Traceability,
Warn,
"add issue and PR templates for review traceability",
"CHANGELOG.md",
)
} else {
finding(
Traceability,
Fail,
"missing changelog or tracking templates",
"CHANGELOG.md",
)
}
}
///|
fn check_maintenance_docs(files : Array[FileSnapshot]) -> Finding {
let has_design = file_exists(files, "docs/DESIGN.md")
let has_api = file_exists(files, "docs/API.md")
if has_design && has_api {
finding(
MaintenanceDocs,
Pass,
"API and design notes are documented",
"docs/",
)
} else if has_design || has_api {
finding(MaintenanceDocs, Warn, "add both API and design notes", "docs/")
} else {
finding(MaintenanceDocs, Fail, "missing maintenance documentation", "docs/")
}
}
///|
fn check_originality(
files : Array[FileSnapshot],
profile : ProjectProfile,
) -> Finding {
let text = all_text(files).to_lower()
if contains_any(text, ["put public apis", "hello", "todo app", "calculator"]) {
return finding(
Originality,
Fail,
"template or common demo text found",
"template marker",
)
}
if profile.originality_signals >= 5 {
finding(
Originality,
Pass,
"project has enough non-template originality signals",
profile.originality_signals.to_string() + " signals",
)
} else if profile.originality_signals >= 3 {
finding(
Originality,
Warn,
"project is not template-like but needs stronger originality proof",
profile.originality_signals.to_string() + " signals",
)
} else {
finding(
Originality,
Fail,
"project lacks originality evidence",
profile.originality_signals.to_string() + " signals",
)
}
}
///|
fn originality_signals(files : Array[FileSnapshot]) -> Int {
let text = all_text(files).to_lower()
let mut signals = 0
if file_exists(files, "docs/DESIGN.md") {
signals = signals + 1
}
if file_exists(files, "PROJECT_APPLICATION.md") {
signals = signals + 1
}
if text.contains("boundary") || text.contains("scope") {
signals = signals + 1
}
if text.contains("github") ||
text.contains("submission") ||
text.contains("hackathon") {
signals = signals + 1
}
if text.contains("traceability") || text.contains("changelog") {
signals = signals + 1
}
if text.contains("originality") ||
text.contains("unique") ||
text.contains("collision") {
signals = signals + 1
}
signals
}
///|
fn score_findings(findings : Array[Finding]) -> Int {
let mut points = 0
let total = findings.length() * 2
guard total > 0 else { return 0 }
for item in findings {
match item.status {
Pass => points = points + 2
Warn => points = points + 1
Fail => ()
}
}
points * 100 / total
}
///|
fn finding(
id : CheckId,
status : Status,
message : String,
evidence : String,
) -> Finding {
{ id, status, message, evidence }
}
///|
fn find_file(files : Array[FileSnapshot], path : String) -> String? {
let wanted = normalize_path(path)
for file in files {
if normalize_path(file.path) == wanted {
return Some(file.content)
}
}
None
}
///|
fn file_exists(files : Array[FileSnapshot], path : String) -> Bool {
find_file(files, path) is Some(_)
}
///|
fn readme_content(files : Array[FileSnapshot]) -> String? {
match find_file(files, "README.md") {
Some(content) => Some(content)
None => match find_file(files, "README.mbt.md") {
Some(content) => Some(content)
None => find_file(files, "README.txt")
}
}
}
///|
fn count_files(files : Array[FileSnapshot], suffix : String) -> Int {
let mut count = 0
for file in files {
if normalize_path(file.path).has_suffix(suffix) {
count = count + 1
}
}
count
}
///|
fn count_test_files(files : Array[FileSnapshot]) -> Int {
let mut count = 0
for file in files {
let path = normalize_path(file.path)
if path.has_suffix("_test.mbt") || path.has_suffix("_wbtest.mbt") {
count = count + 1
}
}
count
}
///|
fn count_ci_files(files : Array[FileSnapshot]) -> Int {
let mut count = 0
for file in files {
let path = normalize_path(file.path)
if path.has_prefix(".github/workflows/") && path.has_suffix(".yml") {
count = count + 1
}
}
count
}
///|
fn any_path_contains(files : Array[FileSnapshot], needle : String) -> Bool {
let target = normalize_path(needle)
for file in files {
if normalize_path(file.path).contains(target) {
return true
}
}
false
}
///|
fn concat_matching(files : Array[FileSnapshot], path_prefix : String) -> String {
let target = normalize_path(path_prefix)
let parts = []
for file in files {
if normalize_path(file.path).has_prefix(target) {
parts.push(file.content)
}
}
parts.join("\n")
}
///|
fn all_text(files : Array[FileSnapshot]) -> String {
let parts = []
for file in files {
parts.push(file.path)
parts.push(file.content)
}
parts.join("\n").to_lower()
}
///|
fn first_quoted_value(line : String) -> String? {
match line.find("\"") {
None => None
Some(start) => {
let rest = line[start + 1:]
match rest.to_owned().find("\"") {
None => None
Some(end) => Some(rest[:end].to_owned())
}
}
}
}
///|
fn contains_all(text : String, needles : Array[String]) -> Bool {
let lower = text.to_lower()
for needle in needles {
if !lower.contains(needle.to_lower()) {
return false
}
}
true
}
///|
fn contains_any(text : String, needles : Array[String]) -> Bool {
let lower = text.to_lower()
for needle in needles {
if lower.contains(needle.to_lower()) {
return true
}
}
false
}
///|
fn evidence_suffix(evidence : String) -> String {
if evidence == "" {
""
} else {
" (" + evidence + ")"
}
}
///|
fn normalize_path(path : String) -> String {
path.replace_all(old="\\", new="/").trim(chars="/").to_owned().to_lower()
}
///|
fn trim(value : String) -> String {
value.trim(chars=" \t\r").to_owned()
}