Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions tools/cli/src/Cli.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// Cli — accessibility-scan CLI. Re-decomposed from src/cli.ts.
//
// Decomposition:
// * Per-command IIFE callback in commander → standalone fn per command
// * try/catch around each action → effect-row composition;
// Process.exit on failure path
// * Inline chalk colour calls → Style effect (no chained API)
// * `as any` on result → typed shim Violation/PassDetail

module Cli;

use Externs;

pub type WcagLevel = A | AA | AAA
pub type Format = Json | Table | Markdown
pub type Impact = Critical | Serious | Moderate | Minor

pub type NodeShim = {}
pub type Violation = {
impact: Impact,
description: String,
help: String,
help_url: String,
nodes: List[NodeShim],
tags: List[String],
}

pub type ScanResult = {
score: Int,
violations: List[Violation],
passes: List[{}],
incomplete: List[{}],
}

pub fn parse_wcag_level(s: ref String) -> WcagLevel {
match s {
"A" => A
"AAA" => AAA
_ => AA
}
}

pub fn parse_format(s: ref String) -> Format {
match s {
"json" => Json
"markdown" => Markdown
_ => Table
}
}

pub fn get_grade(score: Int) -> String {
if score >= 90 { "A" }
else if score >= 80 { "B" }
else if score >= 70 { "C" }
else if score >= 60 { "D" }
else { "F" }
}

pub fn impact_colored(i: ref Impact) -{Externs.Style}-> String {
match i {
Critical => Externs.red_bold("critical")
Serious => Externs.red("serious")
Moderate => Externs.yellow("moderate")
Minor => Externs.blue("minor")
}
}

pub fn score_colored(score: Int) -{Externs.Style}-> String {
// TODO: int-to-string once stdlib lands; sketch only.
let s = "<score>";
if score >= 90 { Externs.green_bold(s) }
else if score >= 70 { Externs.yellow_bold(s) }
else { Externs.red_bold(s) }
}

// ── Commands ──────────────────────────────────────────────────────────────────
//
// Each command is a fn over the effects it actually uses. The effect rows
// make the impurity surface visible in a way the TS callbacks did not.

pub fn scan_action(
url: ref String,
level: ref String,
format: ref String,
output: Option[String],
screenshot: Bool
) -{Externs.IO + Externs.Spinner + Externs.Style + Externs.Fs +
Externs.TableBuilder + Externs.Process}-> () {
// TODO: implement when Node-target lands. Decomposition matches TS:
// 1. Spinner.make("Scanning…") + start
// 2. Scanner.scan(opts) — cross-pkg call once .affex resolves
// 3. spinner.succeed; print header bar + summary table
// 4. format-dispatch (table/markdown/json) for violation listing
// 5. Fs.write_json if output set
// 6. Process.exit(1) on violations, else 0
let _ = url; let _ = level; let _ = format; let _ = output; let _ = screenshot;
()
}

pub fn ci_action(
url: ref String,
level: ref String,
min_score: Int,
fail_on_violations: Bool
) -{Externs.IO + Externs.Spinner + Externs.Style + Externs.Process}-> () {
// TODO: implement. Decomposition matches TS ci command —
// scan, compare score against min_score, exit(1) on failures.
let _ = url; let _ = level; let _ = min_score; let _ = fail_on_violations;
()
}

pub fn batch_action(
file: ref String,
level: ref String,
output_dir: ref String
) -{Externs.IO + Externs.Spinner + Externs.Style + Externs.Fs + Externs.Path +
Externs.Process}-> () {
// TODO: implement. Decomposition matches TS batch command —
// read URL list from file, ensure_dir(output_dir), iterate scans,
// write each result to disk with sanitised filename, summarise counts.
let _ = file; let _ = level; let _ = output_dir;
()
}

// ── Program wiring ────────────────────────────────────────────────────────────

pub fn build_program() -{Externs.Commander}-> own Program {
let p = Externs.make_program();
Externs.set_name(p, "accessibility-scan");
Externs.set_description(p, "Command-line tool for accessibility scanning");
Externs.set_version(p, "1.0.0");

// TODO: wire scan_action / ci_action / batch_action via Externs.cmd_action.
// Commander's options API maps to Externs.cmd_option_default; arguments
// to Externs.cmd_argument. Sketch matches the three commands defined
// in cli.ts (scan / ci / batch).
p
}

pub fn main() -{Externs.Commander}-> () {
let p = build_program();
Externs.parse(p)
}
96 changes: 96 additions & 0 deletions tools/cli/src/Externs.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// Externs — compatibility shim for @accessibility-everywhere/cli.
// TODO: migrate to .affex once that filesystem is specified.

module Externs;

// ── Opaque types from the TS dependency surface ───────────────────────────────

pub extern type Program; // commander Command
pub extern type Cmd;
pub extern type Spinner; // ora
pub extern type Table; // cli-table3

// ── Terminal IO (stdout/stderr) ───────────────────────────────────────────────
//
// Per Q3, terminal output is the *only* domain where the existing warmup
// `IO` effect applies. console.log/error from the TS map cleanly here.

pub effect IO {
fn println(s: ref String) -> ();
fn eprintln(s: ref String) -> ();
}

// ── Process control ───────────────────────────────────────────────────────────

pub effect Process {
fn exit(code: Int) -> Never;
}

// ── File system ───────────────────────────────────────────────────────────────

pub type WriteJsonOpts = { spaces: Int }

pub effect Fs {
fn read_file_utf8(path: ref String) -> String;
fn write_json[D](path: ref String, doc: D, opts: ref WriteJsonOpts) -> ();
fn ensure_dir(path: ref String) -> ();
}

// ── Path joining ──────────────────────────────────────────────────────────────

pub effect Path {
fn join(a: ref String, b: ref String) -> String;
}

// ── Commander surface ─────────────────────────────────────────────────────────

pub effect Commander {
fn make_program() -> own Program;
fn set_name(p: mut Program, name: ref String) -> ();
fn set_description(p: mut Program, desc: ref String) -> ();
fn set_version(p: mut Program, v: ref String) -> ();
fn command(p: mut Program, name: ref String) -> mut Cmd;
fn cmd_description(c: mut Cmd, desc: ref String) -> ();
fn cmd_argument(c: mut Cmd, spec: ref String, desc: ref String) -> ();
fn cmd_option(c: mut Cmd, spec: ref String, desc: ref String) -> ();
fn cmd_option_default(
c: mut Cmd, spec: ref String, desc: ref String, default: ref String
) -> ();
fn cmd_action[H](c: mut Cmd, handler: H) -> ();
fn parse(p: mut Program) -> ();
}

// ── Ora (spinners) ────────────────────────────────────────────────────────────

pub effect Spinner {
fn make(text: ref String) -> own Spinner;
fn start(s: mut Spinner) -> ();
fn succeed(s: mut Spinner, msg: ref String) -> ();
fn fail(s: mut Spinner, msg: ref String) -> ();
}

// ── Chalk (terminal colours) — pure string transformations ────────────────────

pub effect Style {
fn red(s: ref String) -> String;
fn green(s: ref String) -> String;
fn yellow(s: ref String) -> String;
fn blue(s: ref String) -> String;
fn gray(s: ref String) -> String;
fn bold(s: ref String) -> String;
fn red_bold(s: ref String) -> String;
fn green_bold(s: ref String) -> String;
fn yellow_bold(s: ref String) -> String;
fn blue_bold(s: ref String) -> String;
}

// ── cli-table3 ────────────────────────────────────────────────────────────────

pub type TableInit = { head: List[String], col_widths: List[Int] }

pub effect TableBuilder {
fn make(init: ref TableInit) -> own Table;
fn push(t: mut Table, row: ref List[String]) -> ();
fn render(t: ref Table) -> String;
}
8 changes: 8 additions & 0 deletions tools/cli/src/Index.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
module Index;

use Cli;

pub fn main() -{Externs.Commander}-> () {
Cli.main()
}
139 changes: 139 additions & 0 deletions tools/github-action/src/Action.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// Action — GitHub Action entry point. Re-decomposed from src/index.ts.
//
// Decomposition:
// * top-level `run()` async fn → fn with Action + Github + Scanner.scan effects
// * try/catch around the whole run → effect-row composition; failures
// flow through Action.set_failed
// * `as 'A' | 'AA' | 'AAA'` cast on user input → parse_wcag_level total fn
// * inline summary HTML/markdown → generate_summary pure fn

module Action;

use Externs;

// We re-declare the slice of Scanner.ScanResult we actually consume. When
// cross-package types resolve via .affex, this becomes `use Scanner` and the
// local re-decl drops.
pub type WcagLevel = A | AA | AAA
pub type Impact = Critical | Serious | Moderate | Minor

pub type NodeShim = { target: List[String] }
pub type Violation = {
impact: Impact,
description: String,
help: String,
help_url: String,
nodes: List[NodeShim],
}
pub type ScanResult = {
score: Int,
violations: List[Violation],
passes: List[{}],
incomplete: List[{}],
}

pub fn parse_wcag_level(s: ref String) -> WcagLevel {
match s {
"A" => A
"AAA" => AAA
_ => AA
}
}

pub fn wcag_label(w: ref WcagLevel) -> String {
match w {
A => "A"
AA => "AA"
AAA => "AAA"
}
}

pub fn impact_label(i: ref Impact) -> String {
match i {
Critical => "critical"
Serious => "serious"
Moderate => "moderate"
Minor => "minor"
}
}

pub fn impact_emoji(i: ref Impact) -> String {
match i {
Critical => "🔴"
Serious => "🟠"
Moderate => "🟡"
Minor => "🔵"
}
}

pub fn get_grade(score: Int) -> String {
if score >= 90 { "A" }
else if score >= 80 { "B" }
else if score >= 70 { "C" }
else if score >= 60 { "D" }
else { "F" }
}

pub fn grade_emoji(grade: ref String) -> String {
match grade {
"A" => "🟢"
"B" => "🟡"
"C" => "🟠"
"D" | "F" => "🔴"
_ => "⚪"
}
}

pub fn generate_summary(
result: ref ScanResult,
url: ref String,
wcag: ref WcagLevel
) -> String {
// TODO: implement once stdlib String.join + List.map land. The TS builds
// a markdown report with header / summary table / violations
// (top-10) / footer link.
let _ = result; let _ = url; let _ = wcag;
""
}

pub fn post_pr_comment(
token: ref String,
result: ref ScanResult,
url: ref String,
wcag: ref WcagLevel
) -{Externs.Github}-> () {
let ctx = Externs.context();
match Externs.context_pull_request(ctx) {
None => ()
Some(pr) => {
let octo = Externs.get_octokit(token);
let repo = Externs.context_repo(ctx);
let body = generate_summary(result, url, wcag);
Externs.create_comment(octo, {
owner: repo.owner,
repo: repo.repo,
issue_number: Externs.pull_request_number(pr),
body: body,
})
}
}
}

pub fn run() -{Externs.Action + Externs.Github}-> () {
let url = Externs.get_input_required("url");
let wcag_input = Externs.get_input("wcag-level");
let wcag = parse_wcag_level(wcag_input);
let fail_on_violations = Externs.get_input("fail-on-violations") == "true";
let comment_pr = Externs.get_input("comment-pr") == "true";
let github_token = Externs.get_input("github-token");

Externs.info("Scanning " ++ url ++ " for WCAG " ++ wcag_label(wcag) ++ " compliance...");

// TODO: invoke Scanner.scan once cross-package types resolve; consume its
// ScanResult, set outputs, post comment if requested, set_failed on
// violations / below-min-score. Decomposition matches the TS run()
// body line-for-line; deferred until Node-target lands.
let _ = fail_on_violations; let _ = comment_pr; let _ = github_token;
()
}
Loading
Loading