use crate::config::PackageWorkflow;
pub struct CheckStep {
pub tool_key: &'static str,
pub imports: &'static [&'static str],
pub result_var: &'static str,
pub comment: &'static str,
pub body: &'static str,
}
fn targets(testez_selected: bool) -> &'static str {
if testez_selected { r#""src", "tests""# } else { r#""src""# }
}
const ANALYZE_BODY: &str = r#"fs.writeStringToFile(
"roblox.d.luau",
net.client.request("https://luau-lsp.pages.dev/globalTypes.None.d.luau", { method = "GET" }).body
)
local analyze = process.run({
"luau-lsp",
"analyze",
"--sourcemap=sourcemap.json",
-- Vendored dependencies are third-party code; their type errors are
-- not this project's to fix, and submodules in particular are pinned
-- checkouts we don't control.
"--ignore=**/submodules/**",
"--ignore=**/Packages/**",
"--ignore=**/ServerPackages/**",
"--base-luaurc=.luaurc",
"--definitions=roblox.d.luau",
"--flag:LuauSolverV2=true",
{targets},
}, { stdio = "inherit" })
fs.remove("roblox.d.luau")"#;
pub const CHECK_STEPS: &[CheckStep] = &[
CheckStep {
tool_key: "rojo",
imports: &["process"],
result_var: "",
comment: "Regenerate the sourcemap so the type checker resolves requires\n-- against the current file tree rather than a stale snapshot.",
body: r#"process.run({ "rojo", "sourcemap", "--output=sourcemap.json" }, { stdio = "inherit" })"#,
},
CheckStep {
tool_key: "luau-lsp-cli",
imports: &["fs", "net", "process"],
result_var: "analyze",
comment: "Type-check the project with Roblox's API types and the new solver.",
body: ANALYZE_BODY,
},
CheckStep {
tool_key: "selene",
imports: &["process"],
result_var: "selene",
comment: "Lint for suspicious constructs (selene.toml decides severity).",
body: r#"local selene = process.run({ "selene", {targets} }, { stdio = "inherit" })"#,
},
CheckStep {
tool_key: "stylua",
imports: &["process"],
result_var: "stylua",
comment: "Fail if anything isn't formatted, rather than reformatting it here -\n-- CI must not rewrite the tree it was asked to check.",
body: r#"local stylua = process.run({ "stylua", "--check", {targets} }, { stdio = "inherit" })"#,
},
];
fn import_path(name: &str) -> &'static str {
match name {
"fs" => "@std/fs",
"net" => "@lute/net",
"process" => "@lute/process",
other => panic!("unknown lute import `{other}` in CHECK_STEPS"),
}
}
pub fn render_check(selected_tools: &[String], testez_selected: bool) -> Option<String> {
let steps: Vec<&CheckStep> = CHECK_STEPS
.iter()
.filter(|s| selected_tools.iter().any(|t| t == s.tool_key))
.collect();
if steps.is_empty() {
return None;
}
let mut imports: Vec<&str> = Vec::new();
for step in &steps {
for name in step.imports {
if !imports.contains(name) {
imports.push(name);
}
}
}
imports.sort_unstable();
let mut out = String::from(
"--!strict\n\
-- Generated by `rproj new`. The project's quality gate.\n\
-- Run locally with `lute run check`; CI runs the same script.\n\n",
);
for name in &imports {
out.push_str(&format!("local {name} = require(\"{}\")\n", import_path(name)));
}
let targets = targets(testez_selected);
for step in &steps {
out.push_str(&format!("\n-- {}\n{}\n", step.comment, step.body.replace("{targets}", targets)));
}
let vars: Vec<&str> = steps.iter().map(|s| s.result_var).filter(|v| !v.is_empty()).collect();
if !vars.is_empty() {
let condition =
vars.iter().map(|v| format!("{v}.ok")).collect::<Vec<_>>().join(" and ");
out.push_str(&format!(
"\n-- Every check runs before exiting, so one run reports all problems\n\
-- rather than stopping at the first.\n\
if not ({condition}) then\n\tprocess.exit(1)\nend\n"
));
}
Some(out)
}
const WPT_FIXED_REV: &str = "daf5c97bf451e9fed47080769cfaa75d419eb768";
fn wally_ci_steps(has_server_packages: bool) -> String {
let dirs = if has_server_packages { "Packages ServerPackages" } else { "Packages" };
format!(
r#"
# wally-package-types 1.6.2 - the newest release, and what rokit.toml
# pins - emits Luau that doesn't parse for packages whose generics mix
# defaults and non-defaults. Fixed upstream but not yet released, so
# the fix has to be built from a pinned commit. Delete this step and
# the cache above it once a release past 1.6.2 exists, and call the
# rokit-installed binary below instead.
- name: Cache wally-package-types
id: cache-wpt
uses: actions/cache@v6
with:
path: ~/.cargo/bin/wally-package-types
key: wally-package-types-{WPT_FIXED_REV}
- name: Build wally-package-types
if: steps.cache-wpt.outputs.cache-hit != 'true'
run: |
cargo install --locked --git https://github.com/JohnnyMorganz/wally-package-types --rev {WPT_FIXED_REV}
# Packages/ is gitignored, so it has to be installed here. The
# retyping step matters as much as the install: wally rewrites every
# link file without type information, so skipping it would have the
# type checker see `any` for every package.
#
# wally-package-types is called by absolute path on purpose. rokit's
# shim directory is also on PATH, and it holds the broken 1.6.2 - a
# bare `wally-package-types` would silently resolve to whichever comes
# first.
- name: Install packages
run: |
wally install
rojo sourcemap default.project.json --output sourcemap.json
~/.cargo/bin/wally-package-types --sourcemap sourcemap.json {dirs}
"#
)
}
pub fn ci_workflow(workflow: PackageWorkflow, has_server_packages: bool) -> String {
let install = match workflow {
PackageWorkflow::Wally => wally_ci_steps(has_server_packages),
PackageWorkflow::GitSubmodules => String::new(),
};
format!(
r#"name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Packages vendored as git submodules are part of the build.
submodules: true
# Installs every tool pinned in rokit.toml (rojo, luau-lsp, selene,
# stylua, lute...) at the exact versions this project uses.
- uses: CompeyDev/setup-rokit@v0.2.1
{install}
# Writes the ~/.lute typedef aliases into .luaurc. Merges into the
# committed file rather than replacing it, so languageMode survives.
- name: Set up Lute
run: lute setup --with-luaurc
- name: Check code quality
run: lute run check
- name: Run tests
run: lute test
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
fn tools(keys: &[&str]) -> Vec<String> {
keys.iter().map(|k| (*k).to_string()).collect()
}
#[test]
fn only_emits_steps_for_selected_tools() {
let script = render_check(&tools(&["rojo", "selene"]), false).unwrap();
assert!(script.contains("\"rojo\""));
assert!(script.contains("\"selene\""));
assert!(!script.contains("\"stylua\""), "stylua not selected:\n{script}");
assert!(!script.contains("luau-lsp"), "luau-lsp not selected:\n{script}");
}
#[test]
fn imports_only_what_the_emitted_steps_use() {
let script = render_check(&tools(&["rojo", "stylua"]), false).unwrap();
assert!(script.contains(r#"local process = require("@lute/process")"#));
assert!(!script.contains("@std/fs"), "fs is unused here:\n{script}");
assert!(!script.contains("@lute/net"), "net is unused here:\n{script}");
let with_analyze = render_check(&tools(&["luau-lsp-cli"]), false).unwrap();
assert!(with_analyze.contains("@std/fs"));
assert!(with_analyze.contains("@lute/net"));
}
#[test]
fn aggregates_exactly_the_declared_result_vars() {
let script = render_check(&tools(&["rojo", "luau-lsp-cli", "selene", "stylua"]), false).unwrap();
for var in ["analyze", "selene", "stylua"] {
assert!(script.contains(&format!("local {var} = process.run")), "{var} not declared");
assert!(script.contains(&format!("{var}.ok")), "{var} not aggregated");
}
assert!(!script.contains("rojo.ok"));
assert!(script.contains("process.exit(1)"));
}
#[test]
fn testez_projects_check_their_tests_too() {
let tools = tools(&["rojo", "luau-lsp-cli", "selene", "stylua"]);
let with_tests = render_check(&tools, true).unwrap();
assert!(with_tests.contains(r#"{ "selene", "src", "tests" }"#), "{with_tests}");
assert!(with_tests.contains(r#"{ "stylua", "--check", "src", "tests" }"#), "{with_tests}");
assert!(with_tests.contains("\t\"src\", \"tests\",\n"), "analyze:\n{with_tests}");
let without = render_check(&tools, false).unwrap();
assert!(!without.contains("tests"), "{without}");
assert!(without.contains(r#"{ "selene", "src" }"#), "{without}");
for script in [&with_tests, &without] {
assert!(!script.contains("{targets}"), "unsubstituted placeholder:\n{script}");
}
}
#[test]
fn omits_exit_check_when_no_step_produces_a_result() {
let script = render_check(&tools(&["rojo"]), false).unwrap();
assert!(!script.contains("process.exit"), "nothing to gate on:\n{script}");
}
#[test]
fn renders_nothing_when_no_step_applies() {
assert!(render_check(&tools(&["wally", "tarmac"]), false).is_none());
assert!(render_check(&[], false).is_none());
}
#[test]
fn every_declared_import_resolves() {
for step in CHECK_STEPS {
for name in step.imports {
let path = import_path(name);
assert!(path.starts_with('@'), "{name} -> {path}");
}
}
}
#[test]
fn ci_workflow_runs_the_gate_and_tolerates_no_tests() {
for workflow in [PackageWorkflow::Wally, PackageWorkflow::GitSubmodules] {
let ci = ci_workflow(workflow, false);
assert!(ci.contains("lute run check"));
assert!(ci.contains("lute test"));
assert!(!ci.contains("lute run tests"));
assert!(ci.contains("submodules: true"));
}
}
#[test]
fn wally_ci_installs_packages_and_restores_their_types() {
let ci = ci_workflow(PackageWorkflow::Wally, false);
assert!(ci.contains("wally install"), "{ci}");
assert!(ci.contains("wally-package-types"), "{ci}");
assert!(
ci.find("wally install") < ci.find("lute run check"),
"packages must be installed before the gate runs:\n{ci}"
);
let submodules = ci_workflow(PackageWorkflow::GitSubmodules, false);
assert!(!submodules.contains("wally"), "{submodules}");
}
#[test]
fn wally_ci_builds_the_fixed_wally_package_types() {
let ci = ci_workflow(PackageWorkflow::Wally, false);
assert_eq!(WPT_FIXED_REV.len(), 40, "pin a full 40-char sha, not {WPT_FIXED_REV}");
assert!(WPT_FIXED_REV.chars().all(|c| c.is_ascii_hexdigit()));
assert!(ci.contains(&format!("--rev {WPT_FIXED_REV}")), "{ci}");
assert!(ci.contains("cargo install --locked"), "unpinned deps aren't reproducible:\n{ci}");
assert!(
ci.find("cargo install") < ci.find("--sourcemap sourcemap.json"),
"the build has to precede the invocation:\n{ci}"
);
assert!(
ci.contains("~/.cargo/bin/wally-package-types --sourcemap"),
"must call the built binary by path, not by name:\n{ci}"
);
}
#[test]
fn ci_retypes_server_packages_only_when_there_are_any() {
let with = ci_workflow(PackageWorkflow::Wally, true);
assert!(with.contains("sourcemap.json Packages ServerPackages"), "{with}");
let without = ci_workflow(PackageWorkflow::Wally, false);
assert!(without.contains("sourcemap.json Packages\n"), "{without}");
assert!(!without.contains("ServerPackages"), "{without}");
}
#[test]
fn vendored_paths_use_wallys_real_capitalisation() {
assert!(ANALYZE_BODY.contains("**/Packages/**"), "{ANALYZE_BODY}");
assert!(ci_workflow(PackageWorkflow::Wally, false).contains("sourcemap.json Packages"));
}
}