@fringetools/cli 0.2.2 → 0.3.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 +14 -0
- package/dist/bin.js +1 -1
- package/dist/{chunk-TNZKMKKQ.js → chunk-5RPXF7S7.js} +103 -7
- package/dist/index.js +1 -1
- package/package.json +15 -1
- package/skill/fringe-help/SKILL.md +1 -0
package/README.md
CHANGED
|
@@ -22,6 +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
26
|
| `fringe plan [dir\|file] [--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). `--json` = `fringe/plan.v1`. |
|
|
26
27
|
| `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 36 rules. |
|
|
27
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*. |
|
|
@@ -60,6 +61,19 @@ When a baseline or inline suppressions are in play, the last line adds `· N bas
|
|
|
60
61
|
|
|
61
62
|
Each finding costs its weight (default error 10, review 3, signal 1). The total is scaled to a 10-file baseline (`× 10 / max(files, 10)`), so repo size doesn't flatten the score. Score = `100 − penalty` up to a penalty of 75; beyond that it decays smoothly (`25·75/penalty`) and never pins at 0. Repos under 10 files score exactly `100 − total`. Weights live in `fringe.config.json`.
|
|
62
63
|
|
|
64
|
+
## Runtime scan (`fringe scan`)
|
|
65
|
+
|
|
66
|
+
Static checks read source; `scan` looks at the page as a browser renders it, so it can see real colors (a CSS variable, a theme, text over an image), the names a browser actually computes, and how the keyboard behaves.
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm i -D playwright-core axe-core # once, in the project being scanned (not installed by default)
|
|
70
|
+
npx @fringetools/cli scan http://localhost:3000
|
|
71
|
+
```
|
|
72
|
+
|
|
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
|
+
|
|
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. It does not feed `plan`, baseline or suppression yet, and is not part of the edit hook (it needs a running app and takes seconds).
|
|
76
|
+
|
|
63
77
|
## Priority, clusters and fix plans
|
|
64
78
|
|
|
65
79
|
Every finding in `--json` carries `priority`, `cluster` and `fixability`, and the result has a `clusters` list. A cluster is one rule in one file: usually one root cause and one fix.
|
package/dist/bin.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import { existsSync as existsSync3, readFileSync as readFileSync9, statSync } from "fs";
|
|
4
|
-
import { dirname as dirname2, join as
|
|
4
|
+
import { dirname as dirname2, join as join10, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var VERSION = "0.
|
|
7
|
+
var VERSION = "0.3.0";
|
|
8
8
|
|
|
9
9
|
// src/check.ts
|
|
10
10
|
import { readFileSync as readFileSync5 } from "fs";
|
|
@@ -1598,15 +1598,94 @@ function receipts(root, findings) {
|
|
|
1598
1598
|
return { introduced, fixed: [...left.values()].reduce((a, b) => a + b, 0) };
|
|
1599
1599
|
}
|
|
1600
1600
|
|
|
1601
|
+
// src/scan.ts
|
|
1602
|
+
import { createRequire } from "module";
|
|
1603
|
+
import { join as join8 } from "path";
|
|
1604
|
+
var SCAN_HELP = "fringe scan needs two optional packages in your project: npm i -D playwright-core axe-core (it uses your installed Google Chrome; otherwise run npx playwright-core install chromium)";
|
|
1605
|
+
var WCAG_TAG = /^wcag(\d)(\d)(\d+)$/;
|
|
1606
|
+
var FOCUSABLE = 'a[href], button, input:not([type=hidden]):not([type=radio]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
|
1607
|
+
var at = (url) => ({ file: url, line: 1, col: 0 });
|
|
1608
|
+
function axeToFindings(url, violations) {
|
|
1609
|
+
return violations.flatMap((v) => {
|
|
1610
|
+
const tag2 = v.tags.map((t) => WCAG_TAG.exec(t)).find(Boolean);
|
|
1611
|
+
const wcag = tag2 ? `${tag2[1]}.${tag2[2]}.${tag2[3]}` : void 0;
|
|
1612
|
+
const severity = v.impact === "critical" || v.impact === "serious" ? "error" : "review";
|
|
1613
|
+
return v.nodes.map((n) => ({
|
|
1614
|
+
rule: "FR-400",
|
|
1615
|
+
severity,
|
|
1616
|
+
message: `${v.id} at ${n.target.join(" ")}: ${v.help}`,
|
|
1617
|
+
hint: `${(n.failureSummary ?? "").split("\n").slice(1, 2).join("").trim() || v.help} (${v.helpUrl})`,
|
|
1618
|
+
loc: at(url),
|
|
1619
|
+
...wcag ? { wcag } : {}
|
|
1620
|
+
}));
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
function tabWalkFindings(url, focusables, visits) {
|
|
1624
|
+
const out = [];
|
|
1625
|
+
let run = 1;
|
|
1626
|
+
for (let i = 1; i < visits.length; i++) {
|
|
1627
|
+
run = visits[i] !== null && visits[i] === visits[i - 1] ? run + 1 : 1;
|
|
1628
|
+
if (run === 4) out.push({ rule: "FR-401", severity: "error", message: `keyboard trap: Tab keeps landing on ${visits[i]}`, hint: "let focus move on (Tab or Escape must leave), and trap it only inside an open modal dialog", loc: at(url), wcag: "2.1.2" });
|
|
1629
|
+
}
|
|
1630
|
+
const reached = new Set(visits.filter(Boolean));
|
|
1631
|
+
const missed = focusables.filter((f) => !reached.has(f));
|
|
1632
|
+
if (missed.length > 0)
|
|
1633
|
+
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" });
|
|
1634
|
+
return out;
|
|
1635
|
+
}
|
|
1636
|
+
async function scan(url, cwd = process.cwd()) {
|
|
1637
|
+
if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
|
|
1638
|
+
const req = createRequire(join8(cwd, "noop.js"));
|
|
1639
|
+
let playwright, axePath;
|
|
1640
|
+
try {
|
|
1641
|
+
playwright = req("playwright-core");
|
|
1642
|
+
axePath = req.resolve("axe-core/axe.min.js");
|
|
1643
|
+
} catch {
|
|
1644
|
+
throw new Error(SCAN_HELP);
|
|
1645
|
+
}
|
|
1646
|
+
let browser;
|
|
1647
|
+
try {
|
|
1648
|
+
browser = await playwright.chromium.launch({ channel: "chrome" });
|
|
1649
|
+
} catch {
|
|
1650
|
+
try {
|
|
1651
|
+
browser = await playwright.chromium.launch();
|
|
1652
|
+
} catch {
|
|
1653
|
+
throw new Error(SCAN_HELP);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
try {
|
|
1657
|
+
const page = await browser.newPage();
|
|
1658
|
+
await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1659
|
+
await page.addScriptTag({ path: axePath });
|
|
1660
|
+
const axeRes = await page.evaluate(() => globalThis.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }));
|
|
1661
|
+
const describe = `(el) => { const i = [...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].indexOf(el); return i < 0 ? null : el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + '[' + i + ']'; }`;
|
|
1662
|
+
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))`);
|
|
1663
|
+
const visits = [];
|
|
1664
|
+
for (let i = 0; i < focusables.length + 6; i++) {
|
|
1665
|
+
await page.keyboard.press("Tab");
|
|
1666
|
+
await page.waitForTimeout(40);
|
|
1667
|
+
const now = await page.evaluate(`(${describe})(document.activeElement)`);
|
|
1668
|
+
if (now === null && visits.length > 0) break;
|
|
1669
|
+
visits.push(now);
|
|
1670
|
+
}
|
|
1671
|
+
const findings = [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
|
|
1672
|
+
const counts = { error: 0, review: 0, signal: 0 };
|
|
1673
|
+
for (const f of findings) counts[f.severity]++;
|
|
1674
|
+
return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1, skipped: [], findings, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1) };
|
|
1675
|
+
} finally {
|
|
1676
|
+
await browser.close();
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1601
1680
|
// src/init.ts
|
|
1602
1681
|
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
|
|
1603
|
-
import { dirname, join as
|
|
1682
|
+
import { dirname, join as join9 } from "path";
|
|
1604
1683
|
import { fileURLToPath } from "url";
|
|
1605
1684
|
var HOOK = "npx -y @fringetools/cli check --claude-hook";
|
|
1606
1685
|
var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
|
|
1607
1686
|
function initProject(root) {
|
|
1608
1687
|
const done = [];
|
|
1609
|
-
const settingsPath =
|
|
1688
|
+
const settingsPath = join9(root, ".claude", "settings.json");
|
|
1610
1689
|
let settings = {};
|
|
1611
1690
|
if (existsSync2(settingsPath)) {
|
|
1612
1691
|
try {
|
|
@@ -1624,11 +1703,11 @@ function initProject(root) {
|
|
|
1624
1703
|
done.push(`hook added to ${settingsPath}`);
|
|
1625
1704
|
}
|
|
1626
1705
|
for (const name of readdirSync3(SKILL_DIR)) {
|
|
1627
|
-
const skillPath =
|
|
1706
|
+
const skillPath = join9(root, ".claude", "skills", name, "SKILL.md");
|
|
1628
1707
|
if (existsSync2(skillPath)) done.push(`skill ${name} already present`);
|
|
1629
1708
|
else {
|
|
1630
1709
|
mkdirSync3(dirname(skillPath), { recursive: true });
|
|
1631
|
-
copyFileSync(
|
|
1710
|
+
copyFileSync(join9(SKILL_DIR, name, "SKILL.md"), skillPath);
|
|
1632
1711
|
done.push(`skill ${name} installed`);
|
|
1633
1712
|
}
|
|
1634
1713
|
}
|
|
@@ -1785,7 +1864,7 @@ function resolveTarget(arg) {
|
|
|
1785
1864
|
if (!statSync(arg).isFile()) return { root: arg };
|
|
1786
1865
|
let dir = dirname2(arg);
|
|
1787
1866
|
for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
|
|
1788
|
-
if (existsSync3(
|
|
1867
|
+
if (existsSync3(join10(d, "package.json")) || existsSync3(join10(d, "fringe.config.json"))) {
|
|
1789
1868
|
dir = d;
|
|
1790
1869
|
break;
|
|
1791
1870
|
}
|
|
@@ -1797,6 +1876,7 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
|
|
|
1797
1876
|
var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
1798
1877
|
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
1799
1878
|
fringe check --update-baseline [path] accept today's findings; only new ones gate
|
|
1879
|
+
fringe scan <url> [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
|
|
1800
1880
|
fringe plan [dir|file] [--json] [--limit n] ordered fix batches with recipes
|
|
1801
1881
|
fringe explain pattern [form] best-practice checklist for a component type
|
|
1802
1882
|
fringe explain [FR-203] why a rule matters and how to fix it well
|
|
@@ -1867,6 +1947,22 @@ async function runCli(argv, opts = {}) {
|
|
|
1867
1947
|
return result.counts.error > 0 ? 1 : 0;
|
|
1868
1948
|
} catch (e) {
|
|
1869
1949
|
err(opts)(`fringe: internal error: ${e.message}
|
|
1950
|
+
`);
|
|
1951
|
+
return 2;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
if (cmd === "scan") {
|
|
1955
|
+
try {
|
|
1956
|
+
const url = rest.find((a) => !a.startsWith("--"));
|
|
1957
|
+
if (!url) {
|
|
1958
|
+
err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
|
|
1959
|
+
return 2;
|
|
1960
|
+
}
|
|
1961
|
+
const result = await scan(url, opts.cwd ?? process.cwd());
|
|
1962
|
+
log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
|
|
1963
|
+
return result.counts.error > 0 ? 1 : 0;
|
|
1964
|
+
} catch (e) {
|
|
1965
|
+
err(opts)(`fringe: ${e.message}
|
|
1870
1966
|
`);
|
|
1871
1967
|
return 2;
|
|
1872
1968
|
}
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fringetools/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.0.0",
|
|
23
|
+
"axe-core": "^4.14.0",
|
|
24
|
+
"playwright-core": "^1.64.1",
|
|
23
25
|
"tsup": "^8.0.0",
|
|
24
26
|
"typescript": "^5.5.0",
|
|
25
27
|
"vitest": "^2.0.0"
|
|
@@ -49,5 +51,17 @@
|
|
|
49
51
|
"homepage": "https://github.com/skc-learn/fringe#readme",
|
|
50
52
|
"bugs": {
|
|
51
53
|
"url": "https://github.com/skc-learn/fringe/issues"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"playwright-core": ">=1.40",
|
|
57
|
+
"axe-core": ">=4.8"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"playwright-core": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"axe-core": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
52
66
|
}
|
|
53
67
|
}
|
|
@@ -24,6 +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
28
|
| `plan [dir\|file]` | Ordered fix batches (shared components first) with recipes |
|
|
28
29
|
| `explain [FR-203]` | Why a rule matters and how to fix it well |
|
|
29
30
|
| `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
|