///|
/// Parse a non-negative count. `None` when the text is not one.
///
/// The flag is REFUSED rather than silently taken as zero: a scan that printed
/// nothing because the flag was mistyped would look exactly like a clean file,
/// which is the failure mode the cap itself exists to stop.
fn parse_count(s : String) -> Int32? {
// Seven digits is the ceiling: the cap is a PRINTING limit, and a number
// that large is a typo rather than a request.
if s.length() == 0 || s.length() > 7 {
return None
}
let mut n = 0
for ch in s {
if !ch.is_ascii_digit() {
return None
}
n = n * 10 + (ch.to_int() - '0'.to_int())
}
Some(n)
}
///|
/// Split a comma- or space-separated list of family ids.
///
/// 0507. Both separators are accepted because both are natural to type, and a
/// list that is split wrongly would silently select the wrong families.
fn split_ids(s : String) -> Array[String] {
let out : Array[String] = []
for part in s.split(",") {
let p = part.to_owned()
for piece in p.split(" ") {
let t = piece.trim().to_owned()
if t != "" {
out.push(t)
}
}
}
out
}
///|
async fn main {
let args = @env.args()
// NOTE: this cannot exit non-zero. MoonBit exposes no process-exit entry point
// in the packages available here (no `sys` package; moonbitlang/async has only
// `Output::exit_code`, which reads a finished process). So a target that
// cannot be located prints ERROR and still exits 0 — a caller must read the
// output, not the exit code. Recorded as a known limitation.
//
// Usage:
// moonx riantr/moonbit_static_analysis@latest [--exclude ]...
// [--extern-iface-dir ]...
// [--no-auto-fetch-deps]
// [--show-notices]
// [--findings-shown-per-file N]
// registry coordinate (author/module[@version]) or a path
// --exclude a directory name to skip, repeatable
// --extern-iface-dir a directory to scan for `.mbti` files whose declared
// types/values are treated as valid in every .mbt file
// under analysis. Repeatable. The natural target is
// `.repos/` after `moon fetch` of the dependencies you
// want indexed. The target's own tree is always indexed.
// --no-auto-fetch-deps by default the analyzer reads the target's
// `moon.mod` and `moon fetch`es each direct import.
// Pass this to skip the auto-fetch.
// --mmd ALSO write the scan as a mermaid `stateDiagram-v2`
// to ``, and print `MMD` on the
// last line. Opt-in on purpose: stdout stays the
// authoritative text report, and a diagram may be a
// deliberate subset (see `mermaid_states`' file cap),
// so it is never the only output.
// --mmd-auto Same, but pick the name and write it to the
// working directory -- and the name is the SAME
// one `scan_artifact_name` builds:
// `___.mmd`, with
// `unknown` for anything it could not determine.
// Never inside the analysed tree: this
// module only ever READS the target's files.
// --per-function Enter the BEHAVIORAL lens at each FUNCTION rather
// than at the module. By default a function body is
// executed only if something calls it from a
// top-level statement, so on a library module the
// behavioral lens never executes a body at all. This
// flag analyzes every function as if it were the
// program's entry point, with its parameters bound
// to fresh abstract values of their declared types.
// --show-notices Print EVERY notice per line, not only the ones
// that can hide a finding. By default a notice is
// printed only when it sits inside a function's
// extent -- that is, when a conclusion may have
// been withheld because of it -- and the rest are
// counted by shape in the `NOTICES` table. This flag
// restores the whole per-line list, which is what
// the tool printed before 0506 and what a reader
// wants when they are looking for a specific
// construct. It changes no count: the SUMMARY's
// numbers are the same either way.
// --findings-shown-per-file N
// How many findings are PRINTED per file (default 40).
// This is a PRINTING limit: the SUMMARY counts come
// from every finding, so raising it reveals findings
// that were already counted and changes no summary
// number. It exists because the default cap hid the
// defects on one file -- the first forty findings were
// all notices and the thirty-eight defects behind them
// were never printed.
// --families MSA-007,MSA-002
// Report ONLY these families, by their STABLE id.
// The ids are listed in the README and are the
// `MSA-NNN` codes that appear in every rendered
// finding and in the JSON record. An id this version
// does not know is a WARNING and the scan still
// runs -- a family added in a later release must not
// make a profile unusable. An id that is BOTH
// included and excluded is an ERROR and the scan
// does not run at all: the two rules contradict each
// other, and which one won would be an accident of
// the order of two `if`s.
// --exclude-families MSA-007
// Report everything EXCEPT these families. The
// same id vocabulary, the same rules.
// --honor-nosec
// Honour `// nosec` comments in the analysed
// source: `// nosec` silences every finding on that
// line, and `// nosec MSA-007` silences that one
// family on that line. OFF by default, and the
// default is a decision rather than an omission: a
// tool that stops reporting a finding because of a
// comment in the analysed source has an output the
// reader cannot trust without reading that source
// first. A `nosec` that suppresses NOTHING is
// reported as a finding of its own -- a stale
// suppression is a fact about the source, and the
// source is what this tool analyses.
let mut target = ""
let excludes : Array[String] = []
let extern_iface_dirs : Array[String] = []
let mut auto_fetch_deps = true
// "" = off; "@" = on with an auto-named file in the working directory;
// anything else = on, writing exactly here.
let mut mmd_out = ""
let mut per_function = false
let mut show_notices = false
let mut findings_shown_per_file = @sa.default_findings_shown_per_file
// 0507. The family PROFILE: which families to report, and whether the
// `nosec` comments in the analysed source are honoured. Both are off by
// default, and the profile is validated BEFORE the scan runs.
let family_include : Array[String] = []
let family_exclude : Array[String] = []
let mut honor_nosec = false
let mut i = 1
while i < args.length() {
let a = args[i]
if a == "--exclude" {
if i + 1 >= args.length() {
println("ERROR\t--exclude needs a directory name")
return
}
i += 1
excludes.push(args[i])
} else if a == "--extern-iface-dir" {
if i + 1 >= args.length() {
println("ERROR\t--extern-iface-dir needs a directory name")
return
}
i += 1
extern_iface_dirs.push(args[i])
} else if a == "--findings-shown-per-file" {
if i + 1 >= args.length() {
println("ERROR\t--findings-shown-per-file needs a count")
return
}
i += 1
// A count that is not a number is refused rather than silently taken as
// zero: a scan that printed nothing because the flag was mistyped would
// look exactly like a clean file.
match parse_count(args[i]) {
Some(v) => findings_shown_per_file = v
None => {
println(
"ERROR\t--findings-shown-per-file needs a count, got '\{args[i]}'",
)
return
}
}
} else if a == "--families" {
if i + 1 >= args.length() {
println("ERROR\t--families needs a list of family ids")
return
}
i += 1
for id in split_ids(args[i]) {
family_include.push(id)
}
} else if a == "--exclude-families" {
if i + 1 >= args.length() {
println("ERROR\t--exclude-families needs a list of family ids")
return
}
i += 1
for id in split_ids(args[i]) {
family_exclude.push(id)
}
} else if a == "--honor-nosec" {
honor_nosec = true
} else if a == "--mmd" {
// The path is REQUIRED here, and that is a decision rather than an
// omission. An optional value would be ambiguous: in
// ` --mmd ` the bare word after `--mmd` could equally be
// the path (this reading) or a second, unexpected target (the other).
// There is no way to tell them apart, and guessing wrong fails with
// "unexpected extra argument" — so the value is mandatory and
// `--mmd-auto` is the separate spelling for "pick the name for me".
if i + 1 >= args.length() {
println(
"ERROR\t--mmd needs a path, or use --mmd-auto for the default name",
)
return
}
i += 1
mmd_out = args[i]
} else if a == "--mmd-auto" {
mmd_out = "@"
} else if a == "--per-function" {
per_function = true
} else if a == "--show-notices" {
show_notices = true
} else if a == "--no-auto-fetch-deps" {
auto_fetch_deps = false
} else if target == "" {
target = a
} else {
println("ERROR\tunexpected extra argument: \{a}")
return
}
i += 1
}
if target == "" {
target = "."
}
// 0507. THE PROFILE IS VALIDATED BEFORE THE SCAN RUNS, and the two rules
// are different on purpose:
//
// * an id this version does not know is a WARNING, and the scan still
// runs. A family added in a later release must not make an older
// analyzer's profile unusable, and a typo must be loud rather than
// fatal.
// * an id that is BOTH included and excluded is an ERROR, and the scan
// does not run at all. The two rules contradict each other, so there is
// no reading of the profile that satisfies both, and which one won
// would be decided by the order of two `if`s inside the scanner.
let (profile_warnings, profile_error) = @sa.validate_profile(
family_include, family_exclude,
)
for w in profile_warnings {
println("WARNING\t\{w}")
}
if profile_error != "" {
println("ERROR\t\{profile_error}")
return
}
@sa.scan(
target,
excludes,
extern_iface_dirs,
auto_fetch_deps,
mmd_out,
per_function,
findings_shown_per_file,
show_notices,
@sa.Profile::{
include_ids: family_include,
exclude_ids: family_exclude,
honor_nosec,
},
)
}