From 2d18cbdd1cd07ffa1bf1e1f3cc4730df533d6c14 Mon Sep 17 00:00:00 2001 From: Jonathan Jewell <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 3 May 2026 19:48:34 +0100 Subject: [PATCH] =?UTF-8?q?feat(affinescript):=20step=20(a)=20=E2=80=94=20?= =?UTF-8?q?design=20lock-in=20for=20all=205=20Node-bound=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the migration plan d→c→a, this is step (a): write AffineScript .affine sources alongside the JS fallback (PR #14) for each Node-bound package, so the playbook re-decomposition is captured while context is fresh. Bodies remain TODO until the Node-target lands (affinescript#35); the type-level contract is the design lock-in. All files conform to the locked AffineScript conventions (see feedback_affinescript_conventions.md): - Q1 face = canonical (// comments, fn keyword, brace blocks, -{Effects}-> arrow) - Q2 ext = .affine - Q3 effects = domain-specific (DOM/Fs/Process/Net/Db/IO/Action per the table) - Q4 cross-pkg = per-package Externs.affine shim (interim, pending .affex) 14 new .affine files across the 5 packages: scanner/ Externs.affine Browser + Fs + Clock effects, opaque puppeteer/playwright types Scanner.affine ScanOptions/ScanResult records + make_scanner + scan Index.affine re-exports core/ Externs.affine Db + Process effects, opaque arangojs types Arangodb.affine Site/Scan/Violation/WcagCriterion/Organization records; Service owned record; 7 query fns matching arangodb.ts; tagged-template aql lifted to bind-vars per the playbook Index.affine re-exports github-action/ Externs.affine Action + Github effects, opaque Octokit/Context types Action.affine parse_wcag_level, generate_summary, post_pr_comment, run Index.affine re-export run cli/ Externs.affine IO/Process/Fs/Path/Commander/Spinner/Style/TableBuilder effects Cli.affine scan_action, ci_action, batch_action, build_program, main Index.affine re-export main monitoring-api/ Externs.affine Net + Joi + Dotenv + IO + Process + Clock effects; Express App/Router/Req/Res opaque Server.affine AppState (replaces module-level db/scanner singletons), respond_error helper, 13 route handlers (one per route in src/routes/*.ts), boot() Key playbook re-decompositions captured at the type level: * Module-level `db` / `scanner` singletons → owned AppState threaded as ref * try/catch around handlers → effect rows make impurity visible * `'A' | 'AA' | 'AAA'` string unions → AffineScript sum types * tagged-template aql → bind-vars Db.query * forwardRef class (in React.affine, prior PR) → standalone fn with own return Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/cli/src/Cli.affine | 144 ++++++++++ tools/cli/src/Externs.affine | 96 +++++++ tools/cli/src/Index.affine | 8 + tools/github-action/src/Action.affine | 139 +++++++++ tools/github-action/src/Externs.affine | 43 +++ tools/github-action/src/Index.affine | 8 + tools/monitoring-api/src/Externs.affine | 101 +++++++ tools/monitoring-api/src/Server.affine | 267 ++++++++++++++++++ tools/stale/packages/core/src/Arangodb.affine | 246 ++++++++++++++++ tools/stale/packages/core/src/Externs.affine | 69 +++++ tools/stale/packages/core/src/Index.affine | 63 +++++ .../stale/packages/scanner/src/Externs.affine | 38 +++ tools/stale/packages/scanner/src/Index.affine | 27 ++ .../stale/packages/scanner/src/Scanner.affine | 123 ++++++++ 14 files changed, 1372 insertions(+) create mode 100644 tools/cli/src/Cli.affine create mode 100644 tools/cli/src/Externs.affine create mode 100644 tools/cli/src/Index.affine create mode 100644 tools/github-action/src/Action.affine create mode 100644 tools/github-action/src/Externs.affine create mode 100644 tools/github-action/src/Index.affine create mode 100644 tools/monitoring-api/src/Externs.affine create mode 100644 tools/monitoring-api/src/Server.affine create mode 100644 tools/stale/packages/core/src/Arangodb.affine create mode 100644 tools/stale/packages/core/src/Externs.affine create mode 100644 tools/stale/packages/core/src/Index.affine create mode 100644 tools/stale/packages/scanner/src/Externs.affine create mode 100644 tools/stale/packages/scanner/src/Index.affine create mode 100644 tools/stale/packages/scanner/src/Scanner.affine diff --git a/tools/cli/src/Cli.affine b/tools/cli/src/Cli.affine new file mode 100644 index 0000000..712b165 --- /dev/null +++ b/tools/cli/src/Cli.affine @@ -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 = ""; + 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) +} diff --git a/tools/cli/src/Externs.affine b/tools/cli/src/Externs.affine new file mode 100644 index 0000000..c6d8da5 --- /dev/null +++ b/tools/cli/src/Externs.affine @@ -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; +} diff --git a/tools/cli/src/Index.affine b/tools/cli/src/Index.affine new file mode 100644 index 0000000..0c0071e --- /dev/null +++ b/tools/cli/src/Index.affine @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +module Index; + +use Cli; + +pub fn main() -{Externs.Commander}-> () { + Cli.main() +} diff --git a/tools/github-action/src/Action.affine b/tools/github-action/src/Action.affine new file mode 100644 index 0000000..f918a70 --- /dev/null +++ b/tools/github-action/src/Action.affine @@ -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; + () +} diff --git a/tools/github-action/src/Externs.affine b/tools/github-action/src/Externs.affine new file mode 100644 index 0000000..3062c6c --- /dev/null +++ b/tools/github-action/src/Externs.affine @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Externs — compatibility shim for @accessibility-everywhere/github-action. +// TODO: migrate to .affex once that filesystem is specified. + +module Externs; + +pub extern type Octokit; +pub extern type Context; +pub extern type PullRequest; + +pub type RepoRef = { owner: String, repo: String } + +pub type CreateCommentArgs = { + owner: String, + repo: String, + issue_number: Int, + body: String, +} + +// ── @actions/core surface ───────────────────────────────────────────────────── + +pub effect Action { + fn get_input(name: ref String) -> String; + fn get_input_required(name: ref String) -> String; + fn info(msg: ref String) -> (); + fn warning(msg: ref String) -> (); + fn set_failed(msg: ref String) -> (); + fn set_output_str(name: ref String, value: ref String) -> (); + fn set_output_int(name: ref String, value: Int) -> (); + fn summary_add_raw(markdown: ref String) -> (); + fn summary_write() -> (); +} + +// ── @actions/github surface ─────────────────────────────────────────────────── + +pub effect Github { + fn context() -> Context; + fn context_repo(ctx: ref Context) -> RepoRef; + fn context_pull_request(ctx: ref Context) -> Option[PullRequest]; + fn pull_request_number(pr: ref PullRequest) -> Int; + fn get_octokit(token: ref String) -> own Octokit; + fn create_comment(octo: mut Octokit, args: ref CreateCommentArgs) -> (); +} diff --git a/tools/github-action/src/Index.affine b/tools/github-action/src/Index.affine new file mode 100644 index 0000000..30dabc7 --- /dev/null +++ b/tools/github-action/src/Index.affine @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +module Index; + +use Action; + +pub fn run() -{Externs.Action + Externs.Github}-> () { + Action.run() +} diff --git a/tools/monitoring-api/src/Externs.affine b/tools/monitoring-api/src/Externs.affine new file mode 100644 index 0000000..56007cf --- /dev/null +++ b/tools/monitoring-api/src/Externs.affine @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Externs — compatibility shim for @accessibility-everywhere/monitoring-api. +// TODO: migrate to .affex once that filesystem is specified. + +module Externs; + +// ── Express opaque types ────────────────────────────────────────────────────── + +pub extern type App; +pub extern type Router; +pub extern type Req; +pub extern type Res; + +// ── Net effect — server lifecycle, routing, request/response surface ────────── +// +// One named effect `Net` covers everything Express does: it's the network / +// HTTP-server domain. Middleware and routing are first-order ops on App/Router; +// per-request state (req.params/body/query, res.json/status) is also Net. + +pub effect Net { + // App lifecycle + fn make_app() -> own App; + fn use_helmet(app: mut App) -> (); + fn use_cors(app: mut App) -> (); + fn use_compression(app: mut App) -> (); + fn use_json_parser(app: mut App, limit: ref String) -> (); + fn use_rate_limit(app: mut App, path: ref String, window_ms: Int, max: Int, msg: ref String) -> (); + fn listen(app: mut App, port: Int, on_ready: fn() -{Externs.IO}-> ()) -> (); + + // Top-level routes + fn get_app[H](app: mut App, path: ref String, handler: H) -> (); + fn use_router(app: mut App, path: ref String, router: own Router) -> (); + fn use_error_handler[H](app: mut App, handler: H) -> (); + fn use_not_found_handler[H](app: mut App, handler: H) -> (); + + // Router + fn make_router() -> own Router; + fn router_get[H](r: mut Router, path: ref String, h: H) -> (); + fn router_post[H](r: mut Router, path: ref String, h: H) -> (); + fn router_patch[H](r: mut Router, path: ref String, h: H) -> (); + + // Request accessors + fn req_body[B](req: ref Req) -> B; + fn req_param(req: ref Req, name: ref String) -> String; + fn req_query(req: ref Req, name: ref String) -> Option[String]; + fn req_protocol(req: ref Req) -> String; + fn req_header(req: ref Req, name: ref String) -> String; + + // Response builders + fn res_status(res: mut Res, code: Int) -> (); + fn res_json[B](res: mut Res, body: B) -> (); + fn res_send(res: mut Res, body: ref String) -> (); + fn res_header(res: mut Res, name: ref String, value: ref String) -> (); +} + +// ── Joi extern ──────────────────────────────────────────────────────────────── + +pub extern type Schema; + +pub type ValidationError = { + message: String, +} + +pub type ValidationResult[V] = { + error: Option[ValidationError], + value: V, +} + +pub effect Joi { + fn object_schema(fields: List[(String, Schema)]) -> own Schema; + fn string_schema() -> own Schema; + fn boolean_schema() -> own Schema; + fn s_uri(s: mut Schema) -> (); + fn s_required(s: mut Schema) -> (); + fn s_valid(s: mut Schema, options: ref List[String]) -> (); + fn s_default_str(s: mut Schema, default: ref String) -> (); + fn s_default_bool(s: mut Schema, default: Bool) -> (); + fn validate[I, V](schema: ref Schema, input: I) -> ValidationResult[V]; +} + +// ── Dotenv (env loading at boot) ────────────────────────────────────────────── + +pub effect Dotenv { + fn config() -> (); +} + +// ── Shared IO + Process effects (for console + process.exit + clock) ────────── + +pub effect IO { + fn println(s: ref String) -> (); + fn eprintln(s: ref String) -> (); +} + +pub effect Process { + fn env(name: ref String) -> Option[String]; + fn exit(code: Int) -> Never; +} + +pub effect Clock { + fn now_iso() -> String; +} diff --git a/tools/monitoring-api/src/Server.affine b/tools/monitoring-api/src/Server.affine new file mode 100644 index 0000000..7ac5feb --- /dev/null +++ b/tools/monitoring-api/src/Server.affine @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// +// Server — Express bootstrap + 6 mounted routes. +// +// Re-decomposed from src/server.ts + 6 src/routes/*.ts per the playbook. +// +// Decomposition highlights: +// * Top-level `db` and `scanner` module-level singletons → owned record +// constructed once in `boot()` and threaded through route handlers as a +// `ref AppState`. Removes the implicit-singleton service-locator pattern +// the playbook flags (Source-Pattern Index → "Module-level getter for a +// singleton runtime"). +// * `try/catch` around each handler → effect rows (Net + Db + Joi …) make +// each handler's surface visible; failures flow through Net.use_error_handler. +// * Joi schemas declared once at module top → unchanged shape; just typed. +// * `res.json({error: {…, status}})` → `respond_error(res, code, msg)` helper. + +module Server; + +use Externs; + +// ── Cross-package view of the core service ──────────────────────────────────── +// +// We re-declare the slice of @accessibility-everywhere/core's Service that +// we actually use (collections + the query fns). Once `.affex` cross-package +// types resolve, this becomes `use Core` and the local re-decl drops. +// +// For the design-lock-in pass these are *opaque* — the Db effect carries the +// methods we need; the Service record is just an owned tuple of collection +// handles. + +pub extern type Service; // re-declared opaque from core + +pub effect Db { + // Re-declared from core's Db effect. Once .affex cross-package types land, + // this disappears in favour of `use Core`. + fn get_site_by_url(svc: ref Service, url: ref String) -> Option[Site]; + fn get_recent_scans(svc: ref Service, key: ref String, limit: Int) -> List[Scan]; + fn get_violations_for_scan(svc: ref Service, key: ref String) -> List[Violation]; + fn get_top_sites(svc: ref Service, limit: Int) -> List[Site]; + fn get_common_violations(svc: ref Service, limit: Int) -> List[CriterionCount]; + fn get_organization_sites(svc: ref Service, key: ref String) -> List[Site]; + fn collection_save[D](svc: ref Service, coll_name: ref String, doc: D) -> SavedDoc; + fn collection_update[D](svc: ref Service, coll_name: ref String, key: ref String, doc: D) -> (); + fn collection_count(svc: ref Service, coll_name: ref String) -> Int; +} + +pub effect Scanner { + fn scan(opts: ref ScanOptions) -> ScanResult; +} + +// ── Locally re-declared core/scanner types (slim shims) ─────────────────────── + +pub type SavedDoc = { key: String } +pub type WcagLevel = A | AA | AAA +pub type Impact = Critical | Serious | Moderate | Minor +pub type Site = { key: String, url: String, domain: String, current_score: Int, last_scanned: String, scan_count: Int, previous_score: Option[Int] } +pub type Scan = { key: String, score: Int } +pub type Violation = { key: String } +pub type CriterionCount = { criterion: String, count: Int } + +pub type ScanOptions = { + url: String, + wcag_level: WcagLevel, + screenshot: Bool, +} + +pub type ScanResult = { + score: Int, + duration: Int, + timestamp: String, + violations: List[ViolationSlim], + passes: List[{}], + incomplete: List[{}], + metadata: { user_agent: String }, +} + +pub type ViolationSlim = { + impact: String, + description: String, + help_url: String, + wcag: List[String], + nodes: List[{ html: String, target: List[String] }], +} + +// ── App-wide state (replaces the module-level `db` / `scanner` singletons) ──── + +pub type AppState = { + db: Service, + scanner_handle: (), // placeholder — real Scanner is opaque from core +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +pub fn respond_error(res: mut Externs.Res, status: Int, msg: ref String) -{Externs.Net}-> () { + Externs.res_status(res, status); + Externs.res_json(res, { error: { message: msg, status: status } }) +} + +pub fn parse_wcag_level(s: ref String) -> WcagLevel { + match s { + "A" => A + "AAA" => AAA + _ => AA + } +} + +// ── Route handlers ──────────────────────────────────────────────────────────── +// +// Each handler is a standalone fn with its effect row visible. The TS routes +// silently performed Db reads/writes inside try/catch — here those effects +// are explicit at the signature. (Bodies remain TODO until the Node-target +// lands; the fn contract is the design lock-in.) + +pub fn handle_post_scan( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Externs.Joi + Db + Scanner + Externs.Clock}-> () { + // TODO: validate body against scanSchema; on error → respond_error(400); + // on ok → Scanner.scan; Db get_site_by_url + collection_save/update; + // Db.collection_save scan; iterate violations + nodes saving each; + // Net.res_json with the success envelope. + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_scan_by_id( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db}-> () { + // TODO: lookup scan via Db.collection_document; 404 if missing. + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_post_violation( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db + Externs.Clock}-> () { + // TODO: get-or-create site; persist violation with timestamp. + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_common_violations( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db}-> () { + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_violations_for_site( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db}-> () { + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_patch_violation_fixed( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db}-> () { + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_leaderboard( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db + Externs.Clock}-> () { + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_leaderboard_category( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db}-> () { + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_badge( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db}-> () { + // Renders SVG when ?format=svg, else JSON. SVG template stays as a + // multi-line string literal, same as TS. + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_stats( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db + Externs.Clock}-> () { + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_stats_for_site( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db}-> () { + let _ = state; let _ = req; let _ = res; + () +} + +pub fn handle_get_dashboard( + state: ref AppState, + req: ref Externs.Req, + res: mut Externs.Res +) -{Externs.Net + Db}-> () { + let _ = state; let _ = req; let _ = res; + () +} + +// ── Bootstrap ───────────────────────────────────────────────────────────────── + +pub fn boot( + port: Int +) -{Externs.Dotenv + Externs.Net + Externs.IO + Externs.Process + Db + Scanner + Externs.Clock}-> () { + Externs.config(); + + // TODO: construct AppState — Service from core's create_arango_db_service, + // scanner from scanner's make_scanner. Pending cross-pkg resolution. + + let app = Externs.make_app(); + Externs.use_helmet(app); + Externs.use_cors(app); + Externs.use_compression(app); + Externs.use_json_parser(app, "10mb"); + Externs.use_rate_limit( + app, "/v1/", 900000, 100, + "Too many requests from this IP, please try again later." + ); + + // Health + version routes. (Top-level handlers; non-router.) + // TODO: wire via Externs.get_app + handler closures over `state`. + + // Mount routers — sequence matches src/server.ts mounting order. + // TODO: build six routers (one per route group), wire each handler above + // to its path, then Externs.use_router(app, "/v1/scan", scan_router) + // … etc for /v1/violations, /v1/leaderboard, /v1/badge, /v1/stats, + // /v1/dashboard. + + // Error + 404 handlers. + // TODO: Externs.use_error_handler(app, …); Externs.use_not_found_handler(app, …) + + // initialize_database (await Db.initialize from core), then listen. + Externs.listen(app, port, fn() -{Externs.IO}-> () { + Externs.println("✓ Accessibility Everywhere API listening") + }) +} diff --git a/tools/stale/packages/core/src/Arangodb.affine b/tools/stale/packages/core/src/Arangodb.affine new file mode 100644 index 0000000..1b08d2b --- /dev/null +++ b/tools/stale/packages/core/src/Arangodb.affine @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Arangodb — full re-decomposition of the TS arangodb.ts (409 LOC). +// Tagged-template aql queries lifted into bind-vars form per the playbook. + +module Arangodb; + +use Externs; + +// ── Domain enums ────────────────────────────────────────────────────────────── + +pub type WcagLevel = A | AA | AAA +pub type Impact = Critical | Serious | Moderate | Minor +pub type Principle = Perceivable | Operable | Understandable | Robust +pub type SiteStatus = Active | Inactive | Failed +pub type OrgTier = Free | Pro | Enterprise + +// ── Domain records ──────────────────────────────────────────────────────────── + +pub type ArangoConfig = { + url: String, + database: String, + username: String, + password: String, +} + +pub type Site = { + key: String, + url: String, + domain: String, + first_scanned: String, // ISO-8601 + last_scanned: String, + scan_count: Int, + current_score: Int, + previous_score: Option[Int], + status: SiteStatus, +} + +pub type Scan = { + key: String, + site_key: String, + timestamp: String, + score: Int, + violations: Int, + passes: Int, + incomplete: Int, + url: String, + wcag_level: WcagLevel, + duration: Int, + user_agent: Option[String], +} + +pub type Violation = { + key: String, + scan_key: String, + site_key: String, + wcag_criterion: String, + wcag_level: WcagLevel, + impact: Impact, + description: String, + help_url: String, + selector: String, + html: String, + timestamp: String, + fixed: Bool, +} + +pub type WcagCriterion = { + key: String, + criterion: String, + level: WcagLevel, + principle: Principle, + guideline: String, + title: String, + description: String, + success_criteria: String, + techniques: List[String], + failures: List[String], +} + +pub type Organization = { + key: String, + name: String, + domain: String, + contact_email: Option[String], + tier: OrgTier, + created_at: String, + api_key: Option[String], +} + +pub type CriterionCount = { criterion: String, count: Int } +pub type TrendPoint = { timestamp: String, violations: Int, score: Int } + +// ── Service ────────────────────────────────────────────────────────────────── +// +// The TS `class ArangoDBService` is decomposed per the playbook: +// * private state (`db`, collection refs) → owned `Service` record +// * methods → standalone fns taking `ref Service` (or `mut Service` where +// they wire collections during initialise) +// * exception path → effect rows (Db) make failure modes explicit at +// callsites; tightening to `Result[T, DbErr]` is a follow-up + +pub type Service = own { + db: Externs.Database, + // Document collections + sites: Externs.Collection, + scans: Externs.Collection, + violations: Externs.Collection, + wcag_criteria: Externs.Collection, + organizations: Externs.Collection, + // Edge collections + site_scans: Externs.EdgeCollection, + scan_violations: Externs.EdgeCollection, + violation_criteria: Externs.EdgeCollection, + org_sites: Externs.EdgeCollection, +} + +// ── Construction ────────────────────────────────────────────────────────────── + +pub fn make_service(config: ref ArangoConfig) -{Externs.Db}-> own Service { + // TODO: open_database; collections wired in `initialize`. Sketch only — + // the literal `ArangoConfig → DatabaseInit` mapping plus collection + // handles is a one-shot constructor. + let _ = config; + // Placeholder — implementation deferred until Node-target lands. + // Body intentionally uses a single Externs.Db op to keep the effect row + // honest. + let _db = Externs.open_database({ + url: config.url, + database_name: config.database, + username: config.username, + password: config.password, + }); + // The remaining collection handles need to come from `initialize`'s mutable + // pass — sketched separately below; this constructor returns a partially + // initialised value in the full impl, or this fn is removed and `initialize` + // becomes the only entry point. To be decided when implementing. + // For now, mark unreachable until impl lands: + // panic!("Service construction stub — see initialize()") + unimplemented() +} + +extern fn unimplemented[T]() -> T; + +// ── Initialisation ──────────────────────────────────────────────────────────── + +pub fn initialize(svc: mut Service) -{Externs.Db}-> () { + // TODO: per the TS impl, + // 1. ensure database exists (list_databases + create_database) + // 2. create document collections (sites, scans, violations, wcag_criteria, + // organizations) if absent + // 3. create edge collections (site_scans, scan_violations, + // violation_criteria, org_sites) if absent + // 4. create_indexes(svc) — per the TS createIndexes() + // 5. initialize_wcag_criteria(svc) — seed the WCAG 2.1 AA criteria data + () +} + +// ── Query API (8 methods, one per TS method) ────────────────────────────────── + +pub fn get_site_by_url(svc: ref Service, url: ref String) -{Externs.Db}-> Option[Site] { + // AQL: FOR site IN sites FILTER site.url == @url LIMIT 1 RETURN site + let _ = svc; let _ = url; + None +} + +pub fn get_recent_scans_for_site( + svc: ref Service, + site_key: ref String, + limit: Int +) -{Externs.Db}-> List[Scan] { + // AQL: FOR scan IN scans FILTER scan.siteKey == @siteKey + // SORT scan.timestamp DESC LIMIT @limit RETURN scan + let _ = svc; let _ = site_key; let _ = limit; + [] +} + +pub fn get_violations_for_scan( + svc: ref Service, + scan_key: ref String +) -{Externs.Db}-> List[Violation] { + // AQL: FOR violation IN violations FILTER violation.scanKey == @scanKey + // SORT violation.impact DESC RETURN violation + let _ = svc; let _ = scan_key; + [] +} + +pub fn get_top_sites(svc: ref Service, limit: Int) -{Externs.Db}-> List[Site] { + // AQL: FOR site IN sites FILTER site.currentScore > 0 + // SORT site.currentScore DESC LIMIT @limit RETURN site + let _ = svc; let _ = limit; + [] +} + +pub fn get_common_violations( + svc: ref Service, + limit: Int +) -{Externs.Db}-> List[CriterionCount] { + // AQL: FOR violation IN violations COLLECT … WITH COUNT INTO count + // SORT count DESC LIMIT @limit RETURN { criterion, count } + let _ = svc; let _ = limit; + [] +} + +pub fn get_site_violation_trend( + svc: ref Service, + site_key: ref String, + days: Int +) -{Externs.Db}-> List[TrendPoint] { + // AQL: FOR scan IN scans FILTER scan.siteKey == @siteKey + // AND scan.timestamp >= @startDate SORT scan.timestamp ASC + // RETURN { timestamp, violations, score } + let _ = svc; let _ = site_key; let _ = days; + [] +} + +pub fn get_organization_sites( + svc: ref Service, + org_key: ref String +) -{Externs.Db}-> List[Site] { + // AQL: FOR org IN organizations FILTER org._key == @orgKey + // FOR v, e IN 1..1 OUTBOUND org org_sites RETURN v + let _ = svc; let _ = org_key; + [] +} + +// ── Factory with env-based defaults ─────────────────────────────────────────── + +pub fn create_service_from_env() -{Externs.Db + Externs.Process}-> own Service { + let url = Externs.env("ARANGO_URL" ) |> default_or("http://localhost:8529"); + let database = Externs.env("ARANGO_DATABASE" ) |> default_or("accessibility"); + let username = Externs.env("ARANGO_USERNAME" ) |> default_or("root"); + let password = Externs.env("ARANGO_PASSWORD" ) |> default_or("development"); + make_service({ + url: url, + database: database, + username: username, + password: password, + }) +} + +fn default_or(o: Option[String], fallback: String) -> String { + match o { + Some(s) => s + None => fallback + } +} diff --git a/tools/stale/packages/core/src/Externs.affine b/tools/stale/packages/core/src/Externs.affine new file mode 100644 index 0000000..97e9946 --- /dev/null +++ b/tools/stale/packages/core/src/Externs.affine @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Externs — compatibility shim for @accessibility-everywhere/core. +// TODO: migrate to .affex once that filesystem is specified. + +module Externs; + +// ── ArangoDB opaque types ───────────────────────────────────────────────────── + +pub extern type Database; +pub extern type Collection; +pub extern type EdgeCollection; +pub extern type Cursor; + +pub type CollectionInfo = { + name: String, +} + +pub type IndexSpec = { + index_type: String, // "persistent" | "hash" | … + fields: List[String], + unique: Bool, + sparse: Bool, +} + +pub type DatabaseInit = { + url: String, + database_name: String, + username: String, + password: String, +} + +// ── Db effect ───────────────────────────────────────────────────────────────── +// +// The single `Db` effect covers Database creation + collection lifecycle + +// AQL queries + per-collection mutations + cursor materialisation. + +pub effect Db { + // Database lifecycle + fn open_database(init: ref DatabaseInit) -> own Database; + fn database_name(db: ref Database) -> String; + fn list_databases(db: ref Database) -> List[String]; + fn create_database(db: mut Database, name: ref String) -> (); + fn list_collections(db: ref Database) -> List[CollectionInfo]; + fn create_collection(db: mut Database, name: ref String) -> own Collection; + fn create_edge_collection(db: mut Database, name: ref String) -> own EdgeCollection; + fn collection(db: ref Database, name: ref String) -> Collection; + + // AQL queries — bind-vars form (the playbook decomposition for the + // tagged-template `aql` form in the source TS) + fn query[B](db: ref Database, query: ref String, bind_vars: B) -> own Cursor; + + // Per-collection ops + fn ensure_index(coll: mut Collection, spec: ref IndexSpec) -> (); + fn save[D, S](coll: mut Collection, doc: D) -> S; + fn update[D](coll: mut Collection, key: ref String, doc: D) -> (); + fn document[D](coll: ref Collection, key: ref String) -> D; + fn by_example[E](coll: ref Collection, example: E) -> own Cursor; + fn count(coll: ref Collection) -> Int; + + // Cursor materialisation + fn cursor_all[T](cursor: own Cursor) -> List[T]; + fn cursor_count(cursor: ref Cursor) -> Option[Int]; +} + +// ── Process / env extern (for createArangoDBService defaults) ───────────────── + +pub effect Process { + fn env(name: ref String) -> Option[String]; +} diff --git a/tools/stale/packages/core/src/Index.affine b/tools/stale/packages/core/src/Index.affine new file mode 100644 index 0000000..226f6f3 --- /dev/null +++ b/tools/stale/packages/core/src/Index.affine @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Public surface for @accessibility-everywhere/core. + +module Index; + +use Arangodb; + +pub type WcagLevel = Arangodb.WcagLevel +pub type Impact = Arangodb.Impact +pub type SiteStatus = Arangodb.SiteStatus +pub type Site = Arangodb.Site +pub type Scan = Arangodb.Scan +pub type Violation = Arangodb.Violation +pub type Service = Arangodb.Service +pub type ArangoConfig = Arangodb.ArangoConfig +pub type CriterionCount = Arangodb.CriterionCount +pub type TrendPoint = Arangodb.TrendPoint + +pub fn create_arango_db_service() -{Externs.Db + Externs.Process}-> own Service { + Arangodb.create_service_from_env() +} + +pub fn initialize(svc: mut Service) -{Externs.Db}-> () { + Arangodb.initialize(svc) +} + +pub fn get_site_by_url(svc: ref Service, url: ref String) -{Externs.Db}-> Option[Site] { + Arangodb.get_site_by_url(svc, url) +} + +pub fn get_recent_scans_for_site( + svc: ref Service, site_key: ref String, limit: Int +) -{Externs.Db}-> List[Scan] { + Arangodb.get_recent_scans_for_site(svc, site_key, limit) +} + +pub fn get_violations_for_scan( + svc: ref Service, scan_key: ref String +) -{Externs.Db}-> List[Violation] { + Arangodb.get_violations_for_scan(svc, scan_key) +} + +pub fn get_top_sites(svc: ref Service, limit: Int) -{Externs.Db}-> List[Site] { + Arangodb.get_top_sites(svc, limit) +} + +pub fn get_common_violations( + svc: ref Service, limit: Int +) -{Externs.Db}-> List[CriterionCount] { + Arangodb.get_common_violations(svc, limit) +} + +pub fn get_site_violation_trend( + svc: ref Service, site_key: ref String, days: Int +) -{Externs.Db}-> List[TrendPoint] { + Arangodb.get_site_violation_trend(svc, site_key, days) +} + +pub fn get_organization_sites( + svc: ref Service, org_key: ref String +) -{Externs.Db}-> List[Site] { + Arangodb.get_organization_sites(svc, org_key) +} diff --git a/tools/stale/packages/scanner/src/Externs.affine b/tools/stale/packages/scanner/src/Externs.affine new file mode 100644 index 0000000..333af8a --- /dev/null +++ b/tools/stale/packages/scanner/src/Externs.affine @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Externs — compatibility shim for @accessibility-everywhere/scanner. +// TODO: migrate to .affex once that filesystem is specified. + +module Externs; + +// ── Headless-browser opaque types (puppeteer + playwright dual-engine) ──────── + +pub extern type Browser; +pub extern type Page; + +// ── File-system extern (single read of axe.min.js at scanner construction) ──── + +pub effect Fs { + fn read_file_sync(path: ref String) -> String; + fn require_resolve(spec: ref String) -> String; +} + +// ── Browser-automation effect (engine-agnostic) ─────────────────────────────── +// +// The two-engine TS impl chose puppeteer or playwright at runtime. In the +// AffineScript decomposition the effect surface is engine-neutral; engine +// selection lives behind a single `launch` op. + +pub effect Browser { + fn launch(args: ref List[String]) -> own Browser; + fn new_page(browser: mut Browser) -> own Page; + fn goto(page: mut Page, url: ref String) -> (); + fn evaluate_with_source[T](page: mut Page, script: ref String) -> T; + fn screenshot(page: mut Page) -> String; // base64-encoded PNG + fn close(browser: own Browser) -> (); +} + +// ── Time / clock (for scan duration measurement) ────────────────────────────── + +pub effect Clock { + fn now_ms() -> Int; +} diff --git a/tools/stale/packages/scanner/src/Index.affine b/tools/stale/packages/scanner/src/Index.affine new file mode 100644 index 0000000..32d62a7 --- /dev/null +++ b/tools/stale/packages/scanner/src/Index.affine @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Public surface for @accessibility-everywhere/scanner. + +module Index; + +use Scanner; + +pub type WcagLevel = Scanner.WcagLevel +pub type Engine = Scanner.Engine +pub type Impact = Scanner.Impact +pub type ScanOptions = Scanner.ScanOptions +pub type ScanResult = Scanner.ScanResult +pub type ViolationDetail = Scanner.ViolationDetail +pub type PassDetail = Scanner.PassDetail +pub type IncompleteDetail = Scanner.IncompleteDetail +pub type NodeDetail = Scanner.NodeDetail + +pub fn make_scanner() -{Externs.Fs}-> own Scanner.Scanner { + Scanner.make_scanner() +} + +pub fn scan( + scanner: ref Scanner.Scanner, + opts: ref Scanner.ScanOptions +) -{Externs.Browser + Externs.Clock}-> Scanner.ScanResult { + Scanner.scan(scanner, opts) +} diff --git a/tools/stale/packages/scanner/src/Scanner.affine b/tools/stale/packages/scanner/src/Scanner.affine new file mode 100644 index 0000000..3aaba32 --- /dev/null +++ b/tools/stale/packages/scanner/src/Scanner.affine @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Scanner — multi-engine accessibility audit kernel. +// Re-decomposed per the AffineScript migration playbook. + +module Scanner; + +use Externs; + +// ── Domain enums ────────────────────────────────────────────────────────────── + +pub type WcagLevel = A | AA | AAA +pub type Engine = Puppeteer | Playwright +pub type Impact = Critical | Serious | Moderate | Minor + +// ── Domain records ──────────────────────────────────────────────────────────── + +pub type ScanOptions = { + url: String, + wcag_level: WcagLevel, + screenshot: Bool, + engine: Engine, +} + +pub type NodeDetail = { + html: String, + target: List[String], + failure_summary: Option[String], +} + +pub type ViolationDetail = { + id: String, + impact: Impact, + description: String, + help: String, + help_url: String, + tags: List[String], + nodes: List[NodeDetail], +} + +pub type PassDetail = { + id: String, + description: String, + help: String, + tags: List[String], + nodes: List[NodeDetail], +} + +pub type IncompleteDetail = { + id: String, + description: String, + help: String, + nodes: List[NodeDetail], +} + +pub type InapplicableDetail = { + id: String, + description: String, + help: String, +} + +pub type ScanResult = { + url: String, + timestamp: String, + score: Int, + duration_ms: Int, + wcag_level: WcagLevel, + violations: List[ViolationDetail], + passes: List[PassDetail], + incomplete: List[IncompleteDetail], + inapplicable: List[InapplicableDetail], + screenshot: Option[String], +} + +// The scanner instance carries the loaded axe.min.js source for injection. +pub type Scanner = own { + axe_source: String, +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +pub fn make_scanner() -{Externs.Fs}-> own Scanner { + let path = Externs.require_resolve("axe-core/axe.min.js"); + let src = Externs.read_file_sync(path); + Scanner { axe_source: src } +} + +// SCORING KERNEL: penalty weights per impact: +// Critical = 10, Serious = 5, Moderate = 3, Minor = 1. +pub fn calculate_score(violations: ref List[ViolationDetail]) -> Int { + // TODO: implement weighted score aggregation when AffineScript Node-target + // lands and stdlib List/fold operations are settled. + 100 +} + +pub fn scan( + scanner: ref Scanner, + opts: ref ScanOptions +) -{Externs.Browser + Externs.Clock}-> ScanResult { + // TODO: implement once Node-target lands. The decomposition: + // 1. Externs.Clock.now_ms() to capture start + // 2. Externs.Browser.launch with sandbox-disabled args + // 3. new_page + goto(opts.url) + // 4. evaluate_with_source(page, scanner.axe_source ++ axe_run_template) + // 5. optional screenshot when opts.screenshot + // 6. close(browser) + // 7. calculate_score over violations; assemble ScanResult + // + // Engine dispatch (Puppeteer | Playwright) lives behind the single + // Externs.Browser effect — Externs implementation chooses the engine + // when bound at the call site. + ScanResult { + url: opts.url, + timestamp: "1970-01-01T00:00:00Z", + score: 0, + duration_ms: 0, + wcag_level: opts.wcag_level, + violations: [], + passes: [], + incomplete: [], + inapplicable: [], + screenshot: None, + } +}