///|
/// Shared result of one offline release-quality analysis.
pub(all) struct AnalysisContext {
config : DoctorConfig
inventory : ProjectInventory
manifest : MoonManifest?
package_graph : PackageGraph
} derive(Debug, Eq)
///|
pub fn AnalysisContext::new(
config : DoctorConfig,
inventory : ProjectInventory,
manifest : MoonManifest?,
package_graph : PackageGraph,
) -> AnalysisContext {
{ config, inventory, manifest, package_graph }
}
///|
pub fn AnalysisContext::snapshot(self : AnalysisContext) -> ProjectSnapshot {
self.inventory.to_snapshot()
}
///|
fn rule_location(
context : AnalysisContext,
path : String,
needle : String,
) -> SourceLocation {
match context.inventory.find_file(path) {
Some(file) =>
match file.location_of(needle) {
Some(location) => location
None => SourceLocation::new(path, 1, 1)
}
None => SourceLocation::new(path, 1, 1)
}
}
///|
fn catalog_diagnostic(
context : AnalysisContext,
rule_id : String,
path : String,
needle : String,
detail : String,
) -> Diagnostic {
match find_rule_definition(rule_id) {
Some(rule) =>
Diagnostic::at(
context.config.severity_for(rule.id, rule.default_severity),
rule.id,
rule.title,
detail,
rule_location(context, path, needle),
[rule.remediation],
)
None => Diagnostic::new(Warn, rule_id, "Unknown Moon Doctor rule", detail)
}
}
///|
fn add_if_enabled(
diagnostics : Array[Diagnostic],
context : AnalysisContext,
rule_id : String,
path : String,
needle : String,
detail : String,
) -> Unit {
if !context.config.is_rule_disabled(rule_id) {
diagnostics.push(catalog_diagnostic(context, rule_id, path, needle, detail))
}
}
///|
fn analyze_package_structure(
context : AnalysisContext,
diagnostics : Array[Diagnostic],
) -> Unit {
for node in context.package_graph.packages {
if !node.has_source() {
add_if_enabled(
diagnostics,
context,
"package_missing_source",
if node.path.length() == 0 {
"moon.pkg"
} else {
node.path + "/moon.pkg"
},
"",
"Package " + node.import_path + " has no non-test .mbt source file.",
)
}
if !node.has_tests() {
add_if_enabled(
diagnostics,
context,
"package_missing_tests",
if node.path.length() == 0 {
"moon.pkg"
} else {
node.path + "/moon.pkg"
},
"",
"Package " + node.import_path + " has no package-local test file.",
)
}
}
for _, pair in context.package_graph.missing_local_imports {
let (source, target) = pair
add_if_enabled(
diagnostics,
context,
"package_missing_local_import",
"moon.pkg",
target,
"Package " +
source +
" imports local package " +
target +
" but no matching moon.pkg was discovered.",
)
}
for cycle in context.package_graph.cycles {
add_if_enabled(
diagnostics,
context,
"package_cycle",
"moon.pkg",
cycle[0],
"Local package cycle: " + cycle.join(" -> "),
)
}
}
///|
fn analyze_release_metadata(
context : AnalysisContext,
diagnostics : Array[Diagnostic],
) -> Unit {
if context.config.profile == Mooncakes || context.config.profile == Release {
match context.manifest {
Some(manifest) => {
if manifest.text("version") is None {
add_if_enabled(
diagnostics, context, "missing_version", "moon.mod", "version", "Mooncakes and release profiles require a version.",
)
}
if manifest.text("description") is None {
add_if_enabled(
diagnostics, context, "missing_description", "moon.mod", "description",
"Published packages need a concise description.",
)
}
if manifest.text_array("keywords").length() == 0 {
add_if_enabled(
diagnostics, context, "missing_keywords", "moon.mod", "keywords", "Published packages need searchable keywords.",
)
}
}
None => ()
}
}
if context.config.profile == Github || context.config.profile == Release {
if !context.inventory.has_file(".github/workflows/ci.yml") &&
!context.inventory.has_file(".github/workflows/ci.yaml") {
add_if_enabled(
diagnostics, context, "missing_ci", ".github/workflows", "", "GitHub/release profile expects a CI workflow.",
)
}
}
}
///|
fn normalize_existing_diagnostics(
diagnostics : Array[Diagnostic],
context : AnalysisContext,
) -> Array[Diagnostic] {
let normalized : Array[Diagnostic] = []
for diagnostic in diagnostics {
if context.config.is_rule_disabled(diagnostic.code) {
continue
}
match find_rule_definition(diagnostic.code) {
Some(rule) => {
let location = match diagnostic.location {
Some(value) => value
None => rule_location(context, "moon.mod", diagnostic.code)
}
normalized.push(
Diagnostic::at(
context.config.severity_for(rule.id, diagnostic.severity),
diagnostic.code,
diagnostic.message,
diagnostic.detail,
location,
if diagnostic.remediation.length() == 0 {
[rule.remediation]
} else {
diagnostic.remediation
},
),
)
}
None => normalized.push(diagnostic)
}
}
normalized
}
///|
/// Run the full recursive inventory, package graph, policy and legacy checks.
pub fn analyze_project(
root : String,
config : DoctorConfig,
) -> DoctorReport raise {
let inventory = scan_project(root, config)
let manifest = parse_root_manifest(inventory)
let package_graph = build_package_graph(inventory)
let context = AnalysisContext::new(config, inventory, manifest, package_graph)
let baseline = diagnose_with_config(context.snapshot(), config)
let diagnostics = baseline.diagnostics
analyze_package_structure(context, diagnostics)
analyze_release_metadata(context, diagnostics)
DoctorReport::with_context(
root,
normalize_existing_diagnostics(diagnostics, context),
config.profile.label(),
config.release_checklist(),
)
}