@fringetools/cli 0.2.2 → 0.3.1
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 +15 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-TNZKMKKQ.js → chunk-P5N5ETBT.js} +149 -15
- package/dist/index.js +1 -1
- package/package.json +15 -1
- package/skill/fringe-help/SKILL.md +2 -1
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ 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
|
|
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. |
|
|
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`. |
|
|
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*. |
|
|
28
29
|
| `fringe check --json` | Full report as JSON, schema `fringe/check.v1` (stack, files, findings, counts, score). |
|
|
@@ -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. `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
|
+
|
|
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.1";
|
|
8
8
|
|
|
9
9
|
// src/check.ts
|
|
10
10
|
import { readFileSync as readFileSync5 } from "fs";
|
|
@@ -1346,6 +1346,7 @@ var WCAG_A = /* @__PURE__ */ new Set(["1.1.1", "1.3.1", "2.1.1", "2.4.3", "3.3.2
|
|
|
1346
1346
|
var WCAG_AA = /* @__PURE__ */ new Set(["1.4.3", "2.4.7", "2.5.8"]);
|
|
1347
1347
|
var CRITICAL_BONUS = 40;
|
|
1348
1348
|
var SHARED_FROM = 3;
|
|
1349
|
+
var wcagBonus = (wcag) => wcag && WCAG_A.has(wcag) ? 30 : wcag && WCAG_AA.has(wcag) ? 15 : 0;
|
|
1349
1350
|
var IMPORT = /(?:from\s+|import\s*\(\s*|require\(\s*)['"]([^'"]+)['"]/g;
|
|
1350
1351
|
function importerCounts(sources) {
|
|
1351
1352
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -1598,15 +1599,117 @@ function receipts(root, findings) {
|
|
|
1598
1599
|
return { introduced, fixed: [...left.values()].reduce((a, b) => a + b, 0) };
|
|
1599
1600
|
}
|
|
1600
1601
|
|
|
1602
|
+
// src/scan.ts
|
|
1603
|
+
import { createRequire } from "module";
|
|
1604
|
+
import { join as join8 } from "path";
|
|
1605
|
+
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)";
|
|
1606
|
+
var WCAG_TAG = /^wcag(\d)(\d)(\d+)$/;
|
|
1607
|
+
var FOCUSABLE = 'a[href], button, input:not([type=hidden]):not([type=radio]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
|
1608
|
+
var at = (url) => ({ file: url, line: 1, col: 0 });
|
|
1609
|
+
function axeToFindings(url, violations) {
|
|
1610
|
+
return violations.flatMap((v) => {
|
|
1611
|
+
const tag2 = v.tags.map((t) => WCAG_TAG.exec(t)).find(Boolean);
|
|
1612
|
+
const wcag = tag2 ? `${tag2[1]}.${tag2[2]}.${tag2[3]}` : void 0;
|
|
1613
|
+
const severity = v.impact === "critical" || v.impact === "serious" ? "error" : "review";
|
|
1614
|
+
return v.nodes.map((n) => {
|
|
1615
|
+
const detail = (n.failureSummary ?? "").split("\n").slice(1, 2).join("").trim() || v.help;
|
|
1616
|
+
return {
|
|
1617
|
+
rule: "FR-400",
|
|
1618
|
+
severity,
|
|
1619
|
+
target: n.target.join(" "),
|
|
1620
|
+
cluster: `${v.id}@${url}`,
|
|
1621
|
+
message: `${v.id} at ${n.target.join(" ")}: ${detail}`,
|
|
1622
|
+
hint: `${v.help} (${v.helpUrl})`,
|
|
1623
|
+
loc: at(url),
|
|
1624
|
+
...wcag ? { wcag } : {}
|
|
1625
|
+
};
|
|
1626
|
+
});
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
function tabWalkFindings(url, focusables, visits) {
|
|
1630
|
+
const out = [];
|
|
1631
|
+
let run = 1;
|
|
1632
|
+
for (let i = 1; i < visits.length; i++) {
|
|
1633
|
+
run = visits[i] !== null && visits[i] === visits[i - 1] ? run + 1 : 1;
|
|
1634
|
+
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" });
|
|
1635
|
+
}
|
|
1636
|
+
const reached = new Set(visits.filter(Boolean));
|
|
1637
|
+
const missed = focusables.filter((f) => !reached.has(f));
|
|
1638
|
+
if (missed.length > 0)
|
|
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
|
+
return out;
|
|
1641
|
+
}
|
|
1642
|
+
async function scan(url, cwd = process.cwd()) {
|
|
1643
|
+
if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
|
|
1644
|
+
const req = createRequire(join8(cwd, "noop.js"));
|
|
1645
|
+
let playwright, axePath;
|
|
1646
|
+
try {
|
|
1647
|
+
playwright = req("playwright-core");
|
|
1648
|
+
axePath = req.resolve("axe-core/axe.min.js");
|
|
1649
|
+
} catch {
|
|
1650
|
+
throw new Error(SCAN_HELP);
|
|
1651
|
+
}
|
|
1652
|
+
let browser;
|
|
1653
|
+
try {
|
|
1654
|
+
browser = await playwright.chromium.launch({ channel: "chrome" });
|
|
1655
|
+
} catch {
|
|
1656
|
+
try {
|
|
1657
|
+
browser = await playwright.chromium.launch();
|
|
1658
|
+
} catch {
|
|
1659
|
+
throw new Error(SCAN_HELP);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
try {
|
|
1663
|
+
const page = await browser.newPage();
|
|
1664
|
+
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);
|
|
1676
|
+
}
|
|
1677
|
+
const { findings, clusters } = rankScan(url, [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)]);
|
|
1678
|
+
const counts = { error: 0, review: 0, signal: 0 };
|
|
1679
|
+
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) };
|
|
1681
|
+
} finally {
|
|
1682
|
+
await browser.close();
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function rankScan(url, findings) {
|
|
1686
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
1687
|
+
const ranked = findings.map((f) => {
|
|
1688
|
+
const priority = Math.round(TIER[f.severity] + wcagBonus(f.wcag));
|
|
1689
|
+
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 };
|
|
1691
|
+
c.count++;
|
|
1692
|
+
c.priority = Math.max(c.priority, priority);
|
|
1693
|
+
clusters.set(id, c);
|
|
1694
|
+
return { ...f, priority, cluster: id, fixability: c.fixability };
|
|
1695
|
+
});
|
|
1696
|
+
for (const c of clusters.values()) c.priority += Math.round(Math.min(15, 3 * Math.log2(c.count)));
|
|
1697
|
+
return { findings: ranked, clusters: [...clusters.values()] };
|
|
1698
|
+
}
|
|
1699
|
+
function mergeScan(base, scanned, weights) {
|
|
1700
|
+
const counts = { error: base.counts.error + scanned.counts.error, review: base.counts.review + scanned.counts.review, signal: base.counts.signal + scanned.counts.signal };
|
|
1701
|
+
return { ...base, findings: [...base.findings, ...scanned.findings], clusters: [...base.clusters ?? [], ...scanned.clusters ?? []].sort((a, b) => b.priority - a.priority), counts, score: score(counts, weights, base.files) };
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1601
1704
|
// src/init.ts
|
|
1602
1705
|
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
|
|
1603
|
-
import { dirname, join as
|
|
1706
|
+
import { dirname, join as join9 } from "path";
|
|
1604
1707
|
import { fileURLToPath } from "url";
|
|
1605
1708
|
var HOOK = "npx -y @fringetools/cli check --claude-hook";
|
|
1606
1709
|
var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
|
|
1607
1710
|
function initProject(root) {
|
|
1608
1711
|
const done = [];
|
|
1609
|
-
const settingsPath =
|
|
1712
|
+
const settingsPath = join9(root, ".claude", "settings.json");
|
|
1610
1713
|
let settings = {};
|
|
1611
1714
|
if (existsSync2(settingsPath)) {
|
|
1612
1715
|
try {
|
|
@@ -1624,11 +1727,11 @@ function initProject(root) {
|
|
|
1624
1727
|
done.push(`hook added to ${settingsPath}`);
|
|
1625
1728
|
}
|
|
1626
1729
|
for (const name of readdirSync3(SKILL_DIR)) {
|
|
1627
|
-
const skillPath =
|
|
1730
|
+
const skillPath = join9(root, ".claude", "skills", name, "SKILL.md");
|
|
1628
1731
|
if (existsSync2(skillPath)) done.push(`skill ${name} already present`);
|
|
1629
1732
|
else {
|
|
1630
1733
|
mkdirSync3(dirname(skillPath), { recursive: true });
|
|
1631
|
-
copyFileSync(
|
|
1734
|
+
copyFileSync(join9(SKILL_DIR, name, "SKILL.md"), skillPath);
|
|
1632
1735
|
done.push(`skill ${name} installed`);
|
|
1633
1736
|
}
|
|
1634
1737
|
}
|
|
@@ -1700,10 +1803,14 @@ var TITLES = {
|
|
|
1700
1803
|
P3: ["Polish", "advice only; never blocks"]
|
|
1701
1804
|
};
|
|
1702
1805
|
function buildPlan(r, limit = 15) {
|
|
1703
|
-
const lines = /* @__PURE__ */ new Map();
|
|
1704
|
-
for (const f of r.findings) if (f.cluster)
|
|
1806
|
+
const lines = /* @__PURE__ */ new Map(), targets = /* @__PURE__ */ new Map(), hints = /* @__PURE__ */ new Map();
|
|
1807
|
+
for (const f of r.findings) if (f.cluster) {
|
|
1808
|
+
if (f.target) targets.set(f.cluster, [...targets.get(f.cluster) ?? [], f.target]);
|
|
1809
|
+
else lines.set(f.cluster, [...lines.get(f.cluster) ?? [], f.loc.line]);
|
|
1810
|
+
if (!hints.has(f.cluster)) hints.set(f.cluster, f.hint);
|
|
1811
|
+
}
|
|
1705
1812
|
const sev = new Map(r.findings.map((f) => [f.cluster, f.severity]));
|
|
1706
|
-
const items = (r.clusters ?? []).map((c) => ({ ...c, lines: lines.get(c.id) ?? [], recipe: GUIDANCE[c.rule]?.recipe, avoid: GUIDANCE[c.rule]?.avoid }));
|
|
1813
|
+
const items = (r.clusters ?? []).map((c) => ({ ...c, lines: lines.get(c.id) ?? [], targets: targets.get(c.id) ?? [], recipe: GUIDANCE[c.rule]?.recipe ?? hints.get(c.id), avoid: GUIDANCE[c.rule]?.avoid }));
|
|
1707
1814
|
const mechFirst = (a, b) => (a.fixability === b.fixability ? 0 : a.fixability === "mechanical" ? -1 : 1) || b.priority - a.priority;
|
|
1708
1815
|
const pick = (id) => items.filter((c) => {
|
|
1709
1816
|
const s = sev.get(c.id);
|
|
@@ -1737,8 +1844,10 @@ _${b.why}_`);
|
|
|
1737
1844
|
const tags = [c.rootCause === "shared component" ? `shared component, imported by ${c.importedBy} files` : "", c.critical ? "critical path" : "", `${c.fixability} fix`].filter(Boolean);
|
|
1738
1845
|
out.push(`
|
|
1739
1846
|
### ${c.rule} \xB7 \xD7${c.count} \xB7 ${c.file} (${tags.join("; ")})`);
|
|
1740
|
-
out.push(`Lines: ${c.lines.slice(0, 8).join(", ")}${c.lines.length > 8 ? ", \u2026" : ""}`);
|
|
1741
|
-
if (c.
|
|
1847
|
+
out.push(c.targets.length ? `Elements: ${c.targets.slice(0, 4).join(" | ")}${c.targets.length > 4 ? " | \u2026" : ""}` : `Lines: ${c.lines.slice(0, 8).join(", ")}${c.lines.length > 8 ? ", \u2026" : ""}`);
|
|
1848
|
+
if (c.rule.startsWith("FR-4")) {
|
|
1849
|
+
if (c.recipe) out.push(`Fix: ${c.recipe}`);
|
|
1850
|
+
} else if (c.recipe && !shown.has(c.rule)) {
|
|
1742
1851
|
shown.add(c.rule);
|
|
1743
1852
|
out.push(`Fix: ${c.recipe}${c.avoid ? `
|
|
1744
1853
|
Avoid: ${c.avoid}` : ""}`);
|
|
@@ -1785,7 +1894,7 @@ function resolveTarget(arg) {
|
|
|
1785
1894
|
if (!statSync(arg).isFile()) return { root: arg };
|
|
1786
1895
|
let dir = dirname2(arg);
|
|
1787
1896
|
for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
|
|
1788
|
-
if (existsSync3(
|
|
1897
|
+
if (existsSync3(join10(d, "package.json")) || existsSync3(join10(d, "fringe.config.json"))) {
|
|
1789
1898
|
dir = d;
|
|
1790
1899
|
break;
|
|
1791
1900
|
}
|
|
@@ -1797,7 +1906,8 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
|
|
|
1797
1906
|
var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
1798
1907
|
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
1799
1908
|
fringe check --update-baseline [path] accept today's findings; only new ones gate
|
|
1800
|
-
fringe
|
|
1909
|
+
fringe scan <url> [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
|
|
1910
|
+
fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
|
|
1801
1911
|
fringe explain pattern [form] best-practice checklist for a component type
|
|
1802
1912
|
fringe explain [FR-203] why a rule matters and how to fix it well
|
|
1803
1913
|
fringe init [path] add the Claude Code hook + skill
|
|
@@ -1867,18 +1977,42 @@ async function runCli(argv, opts = {}) {
|
|
|
1867
1977
|
return result.counts.error > 0 ? 1 : 0;
|
|
1868
1978
|
} catch (e) {
|
|
1869
1979
|
err(opts)(`fringe: internal error: ${e.message}
|
|
1980
|
+
`);
|
|
1981
|
+
return 2;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
if (cmd === "scan") {
|
|
1985
|
+
try {
|
|
1986
|
+
const url = rest.find((a) => !a.startsWith("--"));
|
|
1987
|
+
if (!url) {
|
|
1988
|
+
err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
|
|
1989
|
+
return 2;
|
|
1990
|
+
}
|
|
1991
|
+
const result = await scan(url, opts.cwd ?? process.cwd());
|
|
1992
|
+
log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
|
|
1993
|
+
return result.counts.error > 0 ? 1 : 0;
|
|
1994
|
+
} catch (e) {
|
|
1995
|
+
err(opts)(`fringe: ${e.message}
|
|
1870
1996
|
`);
|
|
1871
1997
|
return 2;
|
|
1872
1998
|
}
|
|
1873
1999
|
}
|
|
1874
2000
|
if (cmd === "plan") {
|
|
1875
2001
|
try {
|
|
2002
|
+
const si = rest.indexOf("--scan");
|
|
2003
|
+
const scanUrl = si !== -1 ? rest[si + 1] : void 0;
|
|
2004
|
+
if (si !== -1 && (!scanUrl || scanUrl.startsWith("--"))) {
|
|
2005
|
+
err(opts)("fringe: --scan needs a URL, e.g. fringe plan --scan http://localhost:3000\n");
|
|
2006
|
+
return 2;
|
|
2007
|
+
}
|
|
1876
2008
|
const li = rest.indexOf("--limit");
|
|
1877
2009
|
const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
|
|
1878
|
-
const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json"), err(opts));
|
|
2010
|
+
const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json" && a !== "--scan" && rest[i - 1] !== "--scan"), err(opts));
|
|
1879
2011
|
const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
|
|
1880
2012
|
const { root, only } = resolveTarget(arg);
|
|
1881
|
-
const
|
|
2013
|
+
const cfg = loadConfig(root);
|
|
2014
|
+
let result = await runCheck(root, cfg, true, only);
|
|
2015
|
+
if (scanUrl) result = mergeScan(result, await scan(scanUrl, opts.cwd ?? process.cwd()), cfg.weights);
|
|
1882
2016
|
log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
|
|
1883
2017
|
return 0;
|
|
1884
2018
|
} catch (e) {
|
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.1",
|
|
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,7 +24,8 @@ 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
|
-
| `
|
|
27
|
+
| `scan <url>` | Runtime check of a live page: real contrast, keyboard walk (needs `playwright-core` + `axe-core`) |
|
|
28
|
+
| `plan [dir\|file] [--scan url]` | Ordered fix batches (shared components first) with recipes; `--scan` adds the live page |
|
|
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` |
|
|
30
31
|
| `init` | Add the hook and install the skills |
|