@fringetools/cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
22
22
  | Command | What it does |
23
23
  |---|---|
24
24
  | `fringe check [dir\|file]` | Scan a directory (default `.`) or judge one file (the project root is found by climbing to the nearest `package.json`). Prints findings and the health score. Exit `1` on any `error`. |
25
- | `fringe scan <url> [--json]` | **Runtime check of a live page** (needs `npm i -D playwright-core axe-core`; uses your installed Chrome). Runs axe-core for real contrast, computed names and ARIA relations (`FR-400`; critical/serious = `error`, the rest `review`), then walks the page with Tab and reports keyboard traps (`FR-401`, error) and controls Tab never reaches (review). Accepts `http(s)://` and `file://` URLs. Exit `1` on errors. |
25
+ | `fringe scan <url> [--states file] [--json]` | **Runtime check of a live page** (needs `npm i -D playwright-core axe-core`; uses your installed Chrome). Runs axe-core for real contrast, computed names and ARIA relations (`FR-400`; critical/serious = `error`, the rest `review`), then walks the page with Tab and reports keyboard traps (`FR-401`, error) and controls Tab never reaches (review). Accepts `http(s)://` and `file://` URLs. Exit `1` on errors. |
26
26
  | `fringe plan [dir\|file] [--scan url] [--json] [--limit n]` | **Fix order for a repo with many findings**: P0 (errors in shared components or critical paths) → P1 (other errors, mechanical first) → P2 review → P3 polish, each cluster with its fix recipe. Never gates (exit 0). `--scan <url>` adds the live page's runtime findings to the same plan (one cluster per axe rule, with element selectors). `--json` = `fringe/plan.v1`. |
27
27
  | `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 36 rules. |
28
28
  | `fringe explain pattern [form]` | Build-time best-practice checklist for a component type (`form`, `modal`, `navigation`, `data-view`); each item is marked *verified* (a rule checks it) or *judged*. |
@@ -72,7 +72,22 @@ npx @fringetools/cli scan http://localhost:3000
72
72
 
73
73
  What it reports: every axe-core WCAG 2.0 to 2.2 A/AA violation, one finding per element (`FR-400`, WCAG criterion attached), and a Tab walk (`FR-401`): focus that keeps landing on one element is a *keyboard trap*; interactive elements Tab never reaches are listed. Radio buttons (reached by arrow keys) and hidden controls are excluded.
74
74
 
75
- Example: on a real project page the static check reported no findings and `scan` reported 17 color-contrast errors. Limits, plainly: it scans one URL in its initial state (no login, no opening of menus or modals yet); it needs the page running; findings have no source line (they carry a CSS selector); automated checks only find part of the real problems. `fringe plan --scan <url>` merges it with the static findings into one fix order. It does not feed the baseline or `fringe-ignore` yet, and is not part of the edit hook (it needs a running app and takes seconds).
75
+ Example: on a real project page the static check reported no findings and `scan` reported 17 color-contrast errors. ### States: audit what only exists after a click
76
+
77
+ Most real problems live in states the first load never shows: an open menu, a modal, a form after a failed submit. Describe them in `fringe.scan.json` (found in the current directory, or pass `--states <file>`):
78
+
79
+ ```json
80
+ {
81
+ "states": [
82
+ { "name": "filters open", "steps": [ { "click": "#open-filters" } ] },
83
+ { "name": "bad login", "steps": [ { "fill": "#email", "value": "nobody@example.com" }, { "fill": "#password", "value": "wrong" }, { "click": "text=Sign in" }, { "waitFor": "[role=alert]" } ] }
84
+ ]
85
+ }
86
+ ```
87
+
88
+ For each state Fringe reloads the page, runs the steps, then audits the result (axe-core and the Tab walk). Steps are exactly one of `click`, `fill` (with `value`), `press`, `hover`, `waitFor`, `goto`; selectors are [Playwright selectors](https://playwright.dev/docs/other-locators) (CSS, `text=...`, `role=...`). Findings are labelled `[filters open]` and cluster separately from the same rule on the initial page. A step that fails (selector not found) is reported as `FR-402` ("state could not be reached") and nothing in that state is audited. Use throw-away test data in `fill` values and do not commit real credentials. `plan --scan` uses the same file.
89
+
90
+ Limits, plainly: states are independent (each starts from a fresh load of the URL, so there are no multi-page flows that depend on earlier state beyond `goto`); it needs the page running; findings have no source line (they carry a CSS selector); automated checks only find part of the real problems. `fringe plan --scan <url>` merges it with the static findings into one fix order. It does not feed the baseline or `fringe-ignore` yet, and is not part of the edit hook (it needs a running app and takes seconds).
76
91
 
77
92
  ## Priority, clusters and fix plans
78
93
 
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-P5N5ETBT.js";
4
+ } from "./chunk-JSWDIPHX.js";
5
5
 
6
6
  // src/bin.ts
7
7
  process.exitCode = await runCli(process.argv.slice(2));
@@ -4,7 +4,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync9, statSync } fr
4
4
  import { dirname as dirname2, join as join10, resolve } from "path";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = "0.3.1";
7
+ var VERSION = "0.4.0";
8
8
 
9
9
  // src/check.ts
10
10
  import { readFileSync as readFileSync5 } from "fs";
@@ -1639,7 +1639,51 @@ function tabWalkFindings(url, focusables, visits) {
1639
1639
  out.push({ rule: "FR-401", severity: "review", message: `${missed.length} interactive element(s) were not reached with Tab: ${missed.slice(0, 5).join(", ")}${missed.length > 5 ? ", \u2026" : ""}`, hint: "make sure each control is a real button/link/input or has tabIndex={0}; hidden-until-hover controls also land here", loc: at(url), wcag: "2.1.1" });
1640
1640
  return out;
1641
1641
  }
1642
- async function scan(url, cwd = process.cwd()) {
1642
+ var STEP_KEYS = ["click", "fill", "press", "hover", "waitFor", "goto"];
1643
+ function parseStates(json) {
1644
+ const bad = (m) => {
1645
+ throw new Error(`fringe.scan.json: ${m}`);
1646
+ };
1647
+ const states = json?.states;
1648
+ if (!Array.isArray(states)) return bad('expected { "states": [ ... ] }');
1649
+ return states.map((s, i) => {
1650
+ if (typeof s?.name !== "string" || !s.name.trim()) bad(`states[${i}] needs a "name"`);
1651
+ if (!Array.isArray(s.steps) || s.steps.length === 0) bad(`state "${s.name}" needs a non-empty "steps" list`);
1652
+ for (const [k, step] of s.steps.entries()) {
1653
+ const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
1654
+ if (keys.length !== 1 || typeof step[keys[0]] !== "string") bad(`state "${s.name}" step ${k + 1} must be exactly one of ${STEP_KEYS.join(", ")} with a string value`);
1655
+ if (keys[0] === "fill" && typeof step.value !== "string") bad(`state "${s.name}" step ${k + 1}: fill needs a "value"`);
1656
+ }
1657
+ return { name: s.name.trim(), steps: s.steps };
1658
+ });
1659
+ }
1660
+ async function runStep(page, step, base) {
1661
+ const t = { timeout: 1e4 };
1662
+ if ("click" in step) await page.click(step.click, t);
1663
+ else if ("fill" in step) await page.fill(step.fill, step.value, t);
1664
+ else if ("press" in step) await page.keyboard.press(step.press);
1665
+ else if ("hover" in step) await page.hover(step.hover, t);
1666
+ else if ("waitFor" in step) await page.waitForSelector(step.waitFor, t);
1667
+ else await page.goto(new URL(step.goto, base).href, { waitUntil: "load", timeout: 3e4 });
1668
+ await page.waitForTimeout(150);
1669
+ }
1670
+ async function audit(page, axePath, url) {
1671
+ if (!await page.evaluate(() => !!globalThis.axe)) await page.addScriptTag({ path: axePath });
1672
+ const axeRes = await page.evaluate(() => globalThis.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }));
1673
+ const describe = `(el) => { const i = [...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].indexOf(el); return i < 0 ? null : el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + '[' + i + ']'; }`;
1674
+ const focusables = await page.evaluate(`[...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].filter((el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && !el.disabled; }).map((el, _i, all) => (${describe})(el))`);
1675
+ const visits = [];
1676
+ for (let i = 0; i < focusables.length + 6; i++) {
1677
+ await page.keyboard.press("Tab");
1678
+ await page.waitForTimeout(40);
1679
+ const now = await page.evaluate(`(${describe})(document.activeElement)`);
1680
+ if (now === null && visits.length > 0) break;
1681
+ visits.push(now);
1682
+ }
1683
+ return [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
1684
+ }
1685
+ var inState = (name, url, f) => ({ ...f, message: `[${name}] ${f.message}`, loc: { ...f.loc, file: `${url} [${name}]` }, ...f.cluster ? { cluster: `${f.cluster} [${name}]` } : {} });
1686
+ async function scan(url, cwd = process.cwd(), states = []) {
1643
1687
  if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
1644
1688
  const req = createRequire(join8(cwd, "noop.js"));
1645
1689
  let playwright, axePath;
@@ -1662,22 +1706,22 @@ async function scan(url, cwd = process.cwd()) {
1662
1706
  try {
1663
1707
  const page = await browser.newPage();
1664
1708
  await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1665
- await page.addScriptTag({ path: axePath });
1666
- const axeRes = await page.evaluate(() => globalThis.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }));
1667
- const describe = `(el) => { const i = [...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].indexOf(el); return i < 0 ? null : el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + '[' + i + ']'; }`;
1668
- const focusables = await page.evaluate(`[...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].filter((el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && !el.disabled; }).map((el, _i, all) => (${describe})(el))`);
1669
- const visits = [];
1670
- for (let i = 0; i < focusables.length + 6; i++) {
1671
- await page.keyboard.press("Tab");
1672
- await page.waitForTimeout(40);
1673
- const now = await page.evaluate(`(${describe})(document.activeElement)`);
1674
- if (now === null && visits.length > 0) break;
1675
- visits.push(now);
1709
+ const all = await audit(page, axePath, url);
1710
+ for (const st of states) {
1711
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1712
+ let step = 0;
1713
+ try {
1714
+ for (; step < st.steps.length; step++) await runStep(page, st.steps[step], url);
1715
+ } catch (e) {
1716
+ all.push({ rule: "FR-402", severity: "review", message: `[${st.name}] state could not be reached: step ${step + 1} ${JSON.stringify(st.steps[step])} failed (${String(e.message).split("\n")[0].slice(0, 120)})`, hint: "fix the step in fringe.scan.json or the page; nothing in this state was audited", loc: { file: `${url} [${st.name}]`, line: 1, col: 0 } });
1717
+ continue;
1718
+ }
1719
+ all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
1676
1720
  }
1677
- const { findings, clusters } = rankScan(url, [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)]);
1721
+ const { findings, clusters } = rankScan(url, all);
1678
1722
  const counts = { error: 0, review: 0, signal: 0 };
1679
1723
  for (const f of findings) counts[f.severity]++;
1680
- return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1) };
1724
+ return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1 + states.length, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1) };
1681
1725
  } finally {
1682
1726
  await browser.close();
1683
1727
  }
@@ -1687,7 +1731,7 @@ function rankScan(url, findings) {
1687
1731
  const ranked = findings.map((f) => {
1688
1732
  const priority = Math.round(TIER[f.severity] + wcagBonus(f.wcag));
1689
1733
  const id = f.cluster ?? `${f.rule}@${url}`;
1690
- const c = clusters.get(id) ?? { id, rule: f.rule, file: url, count: 0, priority: 0, fixability: "judgment", rootCause: "local", importedBy: 0, critical: false };
1734
+ const c = clusters.get(id) ?? { id, rule: f.rule, file: f.loc.file, count: 0, priority: 0, fixability: "judgment", rootCause: "local", importedBy: 0, critical: false };
1691
1735
  c.count++;
1692
1736
  c.priority = Math.max(c.priority, priority);
1693
1737
  clusters.set(id, c);
@@ -1902,11 +1946,23 @@ function resolveTarget(arg) {
1902
1946
  }
1903
1947
  return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
1904
1948
  }
1949
+ function loadStates(cwd, rest) {
1950
+ const i = rest.indexOf("--states");
1951
+ const file2 = i !== -1 ? resolve(cwd, rest[i + 1] ?? "") : join10(cwd, "fringe.scan.json");
1952
+ if (i === -1 && !existsSync3(file2)) return [];
1953
+ let raw;
1954
+ try {
1955
+ raw = JSON.parse(readFileSync9(file2, "utf8"));
1956
+ } catch (e) {
1957
+ throw new Error(`${file2}: ${e.message}`);
1958
+ }
1959
+ return parseStates(raw);
1960
+ }
1905
1961
  var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\/?a|none|tbd|obvious|unnecessary|clutter|unused)\.?$/i.test(e.trim());
1906
1962
  var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
1907
1963
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
1908
1964
  fringe check --update-baseline [path] accept today's findings; only new ones gate
1909
- fringe scan <url> [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
1965
+ fringe scan <url> [--states f] [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
1910
1966
  fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
1911
1967
  fringe explain pattern [form] best-practice checklist for a component type
1912
1968
  fringe explain [FR-203] why a rule matters and how to fix it well
@@ -1983,12 +2039,13 @@ async function runCli(argv, opts = {}) {
1983
2039
  }
1984
2040
  if (cmd === "scan") {
1985
2041
  try {
1986
- const url = rest.find((a) => !a.startsWith("--"));
2042
+ const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--states");
1987
2043
  if (!url) {
1988
2044
  err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
1989
2045
  return 2;
1990
2046
  }
1991
- const result = await scan(url, opts.cwd ?? process.cwd());
2047
+ const cwd = opts.cwd ?? process.cwd();
2048
+ const result = await scan(url, cwd, loadStates(cwd, rest));
1992
2049
  log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
1993
2050
  return result.counts.error > 0 ? 1 : 0;
1994
2051
  } catch (e) {
@@ -2007,12 +2064,15 @@ async function runCli(argv, opts = {}) {
2007
2064
  }
2008
2065
  const li = rest.indexOf("--limit");
2009
2066
  const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
2010
- const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json" && a !== "--scan" && rest[i - 1] !== "--scan"), err(opts));
2067
+ const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json" && a !== "--scan" && rest[i - 1] !== "--scan" && a !== "--states" && rest[i - 1] !== "--states"), err(opts));
2011
2068
  const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
2012
2069
  const { root, only } = resolveTarget(arg);
2013
2070
  const cfg = loadConfig(root);
2014
2071
  let result = await runCheck(root, cfg, true, only);
2015
- if (scanUrl) result = mergeScan(result, await scan(scanUrl, opts.cwd ?? process.cwd()), cfg.weights);
2072
+ if (scanUrl) {
2073
+ const cwd = opts.cwd ?? process.cwd();
2074
+ result = mergeScan(result, await scan(scanUrl, cwd, loadStates(cwd, rest)), cfg.weights);
2075
+ }
2016
2076
  log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
2017
2077
  return 0;
2018
2078
  } catch (e) {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-P5N5ETBT.js";
3
+ } from "./chunk-JSWDIPHX.js";
4
4
  export {
5
5
  runCli
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fringetools/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
24
24
  | `check [dir]` | Scan and print findings and the health score; exit 1 on any error |
25
25
  | `check --json` / `--hook` / `--claude-hook` | Machine output; CI gate; agent feedback form |
26
26
  | `check --update-baseline` | Accept today's findings; only new ones gate |
27
- | `scan <url>` | Runtime check of a live page: real contrast, keyboard walk (needs `playwright-core` + `axe-core`) |
27
+ | `scan <url>` | Runtime check of a live page: real contrast, keyboard walk, and named states from `fringe.scan.json` (needs `playwright-core` + `axe-core`) |
28
28
  | `plan [dir\|file] [--scan url]` | Ordered fix batches (shared components first) with recipes; `--scan` adds the live page |
29
29
  | `explain [FR-203]` | Why a rule matters and how to fix it well |
30
30
  | `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |