@fringetools/cli 0.3.0 → 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 CHANGED
@@ -23,7 +23,7 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
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
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`. |
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,7 @@ 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. 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).
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
76
 
77
77
  ## Priority, clusters and fix plans
78
78
 
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-5RPXF7S7.js";
4
+ } from "./chunk-P5N5ETBT.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.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();
@@ -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
- 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
- }));
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) {
@@ -1668,14 +1674,32 @@ async function scan(url, cwd = process.cwd()) {
1668
1674
  if (now === null && visits.length > 0) break;
1669
1675
  visits.push(now);
1670
1676
  }
1671
- const findings = [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
1677
+ const { findings, clusters } = rankScan(url, [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)]);
1672
1678
  const counts = { error: 0, review: 0, signal: 0 };
1673
1679
  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) };
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) };
1675
1681
  } finally {
1676
1682
  await browser.close();
1677
1683
  }
1678
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
+ }
1679
1703
 
1680
1704
  // src/init.ts
1681
1705
  import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
@@ -1779,10 +1803,14 @@ var TITLES = {
1779
1803
  P3: ["Polish", "advice only; never blocks"]
1780
1804
  };
1781
1805
  function buildPlan(r, limit = 15) {
1782
- const lines = /* @__PURE__ */ new Map();
1783
- for (const f of r.findings) if (f.cluster) lines.set(f.cluster, [...lines.get(f.cluster) ?? [], f.loc.line]);
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
+ }
1784
1812
  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 }));
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 }));
1786
1814
  const mechFirst = (a, b) => (a.fixability === b.fixability ? 0 : a.fixability === "mechanical" ? -1 : 1) || b.priority - a.priority;
1787
1815
  const pick = (id) => items.filter((c) => {
1788
1816
  const s = sev.get(c.id);
@@ -1816,8 +1844,10 @@ _${b.why}_`);
1816
1844
  const tags = [c.rootCause === "shared component" ? `shared component, imported by ${c.importedBy} files` : "", c.critical ? "critical path" : "", `${c.fixability} fix`].filter(Boolean);
1817
1845
  out.push(`
1818
1846
  ### ${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.recipe && !shown.has(c.rule)) {
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)) {
1821
1851
  shown.add(c.rule);
1822
1852
  out.push(`Fix: ${c.recipe}${c.avoid ? `
1823
1853
  Avoid: ${c.avoid}` : ""}`);
@@ -1877,7 +1907,7 @@ var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
1877
1907
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
1878
1908
  fringe check --update-baseline [path] accept today's findings; only new ones gate
1879
1909
  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
1910
+ fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
1881
1911
  fringe explain pattern [form] best-practice checklist for a component type
1882
1912
  fringe explain [FR-203] why a rule matters and how to fix it well
1883
1913
  fringe init [path] add the Claude Code hook + skill
@@ -1969,12 +1999,20 @@ async function runCli(argv, opts = {}) {
1969
1999
  }
1970
2000
  if (cmd === "plan") {
1971
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
+ }
1972
2008
  const li = rest.indexOf("--limit");
1973
2009
  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));
2010
+ const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json" && a !== "--scan" && rest[i - 1] !== "--scan"), err(opts));
1975
2011
  const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
1976
2012
  const { root, only } = resolveTarget(arg);
1977
- const result = await runCheck(root, loadConfig(root), true, only);
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);
1978
2016
  log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
1979
2017
  return 0;
1980
2018
  } catch (e) {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-5RPXF7S7.js";
3
+ } from "./chunk-P5N5ETBT.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.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
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]` | Ordered fix batches (shared components first) with recipes |
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 |