@fringetools/cli 0.3.0 → 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 +18 -3
- package/dist/bin.js +1 -1
- package/dist/{chunk-5RPXF7S7.js → chunk-JSWDIPHX.js} +132 -34
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/skill/fringe-help/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -22,8 +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 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] [--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`. |
|
|
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
|
+
| `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*. |
|
|
29
29
|
| `fringe check --json` | Full report as JSON, schema `fringe/check.v1` (stack, files, findings, counts, score). |
|
|
@@ -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.
|
|
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
|
@@ -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.
|
|
7
|
+
var VERSION = "0.4.0";
|
|
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();
|
|
@@ -1610,14 +1611,19 @@ function axeToFindings(url, violations) {
|
|
|
1610
1611
|
const tag2 = v.tags.map((t) => WCAG_TAG.exec(t)).find(Boolean);
|
|
1611
1612
|
const wcag = tag2 ? `${tag2[1]}.${tag2[2]}.${tag2[3]}` : void 0;
|
|
1612
1613
|
const severity = v.impact === "critical" || v.impact === "serious" ? "error" : "review";
|
|
1613
|
-
return v.nodes.map((n) =>
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
+
});
|
|
1621
1627
|
});
|
|
1622
1628
|
}
|
|
1623
1629
|
function tabWalkFindings(url, focusables, visits) {
|
|
@@ -1633,7 +1639,51 @@ function tabWalkFindings(url, focusables, visits) {
|
|
|
1633
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" });
|
|
1634
1640
|
return out;
|
|
1635
1641
|
}
|
|
1636
|
-
|
|
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 = []) {
|
|
1637
1687
|
if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
|
|
1638
1688
|
const req = createRequire(join8(cwd, "noop.js"));
|
|
1639
1689
|
let playwright, axePath;
|
|
@@ -1656,26 +1706,44 @@ async function scan(url, cwd = process.cwd()) {
|
|
|
1656
1706
|
try {
|
|
1657
1707
|
const page = await browser.newPage();
|
|
1658
1708
|
await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1659
|
-
await page
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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)));
|
|
1670
1720
|
}
|
|
1671
|
-
const findings
|
|
1721
|
+
const { findings, clusters } = rankScan(url, all);
|
|
1672
1722
|
const counts = { error: 0, review: 0, signal: 0 };
|
|
1673
1723
|
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) };
|
|
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) };
|
|
1675
1725
|
} finally {
|
|
1676
1726
|
await browser.close();
|
|
1677
1727
|
}
|
|
1678
1728
|
}
|
|
1729
|
+
function rankScan(url, findings) {
|
|
1730
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
1731
|
+
const ranked = findings.map((f) => {
|
|
1732
|
+
const priority = Math.round(TIER[f.severity] + wcagBonus(f.wcag));
|
|
1733
|
+
const id = f.cluster ?? `${f.rule}@${url}`;
|
|
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 };
|
|
1735
|
+
c.count++;
|
|
1736
|
+
c.priority = Math.max(c.priority, priority);
|
|
1737
|
+
clusters.set(id, c);
|
|
1738
|
+
return { ...f, priority, cluster: id, fixability: c.fixability };
|
|
1739
|
+
});
|
|
1740
|
+
for (const c of clusters.values()) c.priority += Math.round(Math.min(15, 3 * Math.log2(c.count)));
|
|
1741
|
+
return { findings: ranked, clusters: [...clusters.values()] };
|
|
1742
|
+
}
|
|
1743
|
+
function mergeScan(base, scanned, weights) {
|
|
1744
|
+
const counts = { error: base.counts.error + scanned.counts.error, review: base.counts.review + scanned.counts.review, signal: base.counts.signal + scanned.counts.signal };
|
|
1745
|
+
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) };
|
|
1746
|
+
}
|
|
1679
1747
|
|
|
1680
1748
|
// src/init.ts
|
|
1681
1749
|
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
|
|
@@ -1779,10 +1847,14 @@ var TITLES = {
|
|
|
1779
1847
|
P3: ["Polish", "advice only; never blocks"]
|
|
1780
1848
|
};
|
|
1781
1849
|
function buildPlan(r, limit = 15) {
|
|
1782
|
-
const lines = /* @__PURE__ */ new Map();
|
|
1783
|
-
for (const f of r.findings) if (f.cluster)
|
|
1850
|
+
const lines = /* @__PURE__ */ new Map(), targets = /* @__PURE__ */ new Map(), hints = /* @__PURE__ */ new Map();
|
|
1851
|
+
for (const f of r.findings) if (f.cluster) {
|
|
1852
|
+
if (f.target) targets.set(f.cluster, [...targets.get(f.cluster) ?? [], f.target]);
|
|
1853
|
+
else lines.set(f.cluster, [...lines.get(f.cluster) ?? [], f.loc.line]);
|
|
1854
|
+
if (!hints.has(f.cluster)) hints.set(f.cluster, f.hint);
|
|
1855
|
+
}
|
|
1784
1856
|
const sev = new Map(r.findings.map((f) => [f.cluster, f.severity]));
|
|
1785
|
-
const items = (r.clusters ?? []).map((c) => ({ ...c, lines: lines.get(c.id) ?? [], recipe: GUIDANCE[c.rule]?.recipe, avoid: GUIDANCE[c.rule]?.avoid }));
|
|
1857
|
+
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 }));
|
|
1786
1858
|
const mechFirst = (a, b) => (a.fixability === b.fixability ? 0 : a.fixability === "mechanical" ? -1 : 1) || b.priority - a.priority;
|
|
1787
1859
|
const pick = (id) => items.filter((c) => {
|
|
1788
1860
|
const s = sev.get(c.id);
|
|
@@ -1816,8 +1888,10 @@ _${b.why}_`);
|
|
|
1816
1888
|
const tags = [c.rootCause === "shared component" ? `shared component, imported by ${c.importedBy} files` : "", c.critical ? "critical path" : "", `${c.fixability} fix`].filter(Boolean);
|
|
1817
1889
|
out.push(`
|
|
1818
1890
|
### ${c.rule} \xB7 \xD7${c.count} \xB7 ${c.file} (${tags.join("; ")})`);
|
|
1819
|
-
out.push(`Lines: ${c.lines.slice(0, 8).join(", ")}${c.lines.length > 8 ? ", \u2026" : ""}`);
|
|
1820
|
-
if (c.
|
|
1891
|
+
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" : ""}`);
|
|
1892
|
+
if (c.rule.startsWith("FR-4")) {
|
|
1893
|
+
if (c.recipe) out.push(`Fix: ${c.recipe}`);
|
|
1894
|
+
} else if (c.recipe && !shown.has(c.rule)) {
|
|
1821
1895
|
shown.add(c.rule);
|
|
1822
1896
|
out.push(`Fix: ${c.recipe}${c.avoid ? `
|
|
1823
1897
|
Avoid: ${c.avoid}` : ""}`);
|
|
@@ -1872,12 +1946,24 @@ function resolveTarget(arg) {
|
|
|
1872
1946
|
}
|
|
1873
1947
|
return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
|
|
1874
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
|
+
}
|
|
1875
1961
|
var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\/?a|none|tbd|obvious|unnecessary|clutter|unused)\.?$/i.test(e.trim());
|
|
1876
1962
|
var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
1877
1963
|
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
1878
1964
|
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
|
|
1880
|
-
fringe plan [dir|file] [--json] [--limit n] ordered fix batches with recipes
|
|
1965
|
+
fringe scan <url> [--states f] [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
|
|
1966
|
+
fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
|
|
1881
1967
|
fringe explain pattern [form] best-practice checklist for a component type
|
|
1882
1968
|
fringe explain [FR-203] why a rule matters and how to fix it well
|
|
1883
1969
|
fringe init [path] add the Claude Code hook + skill
|
|
@@ -1953,12 +2039,13 @@ async function runCli(argv, opts = {}) {
|
|
|
1953
2039
|
}
|
|
1954
2040
|
if (cmd === "scan") {
|
|
1955
2041
|
try {
|
|
1956
|
-
const url = rest.find((a) => !a.startsWith("--"));
|
|
2042
|
+
const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--states");
|
|
1957
2043
|
if (!url) {
|
|
1958
2044
|
err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
|
|
1959
2045
|
return 2;
|
|
1960
2046
|
}
|
|
1961
|
-
const
|
|
2047
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2048
|
+
const result = await scan(url, cwd, loadStates(cwd, rest));
|
|
1962
2049
|
log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
|
|
1963
2050
|
return result.counts.error > 0 ? 1 : 0;
|
|
1964
2051
|
} catch (e) {
|
|
@@ -1969,12 +2056,23 @@ async function runCli(argv, opts = {}) {
|
|
|
1969
2056
|
}
|
|
1970
2057
|
if (cmd === "plan") {
|
|
1971
2058
|
try {
|
|
2059
|
+
const si = rest.indexOf("--scan");
|
|
2060
|
+
const scanUrl = si !== -1 ? rest[si + 1] : void 0;
|
|
2061
|
+
if (si !== -1 && (!scanUrl || scanUrl.startsWith("--"))) {
|
|
2062
|
+
err(opts)("fringe: --scan needs a URL, e.g. fringe plan --scan http://localhost:3000\n");
|
|
2063
|
+
return 2;
|
|
2064
|
+
}
|
|
1972
2065
|
const li = rest.indexOf("--limit");
|
|
1973
2066
|
const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
|
|
1974
|
-
const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json"), 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));
|
|
1975
2068
|
const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
|
|
1976
2069
|
const { root, only } = resolveTarget(arg);
|
|
1977
|
-
const
|
|
2070
|
+
const cfg = loadConfig(root);
|
|
2071
|
+
let result = await runCheck(root, cfg, true, only);
|
|
2072
|
+
if (scanUrl) {
|
|
2073
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2074
|
+
result = mergeScan(result, await scan(scanUrl, cwd, loadStates(cwd, rest)), cfg.weights);
|
|
2075
|
+
}
|
|
1978
2076
|
log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
|
|
1979
2077
|
return 0;
|
|
1980
2078
|
} catch (e) {
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -24,8 +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
|
-
| `scan <url>` | Runtime check of a live page: real contrast, keyboard walk (needs `playwright-core` + `axe-core`) |
|
|
28
|
-
| `plan [dir\|file]` | Ordered fix batches (shared components first) with recipes |
|
|
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
|
+
| `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` |
|
|
31
31
|
| `init` | Add the hook and install the skills |
|