@fringetools/cli 0.4.1 → 0.5.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,10 @@ 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> [--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. |
25
+ | `fringe scan <url> [--states file] [--stress] [--storage-state auth.json] [--screenshots dir] [--update-baseline] [--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 rules [dir] [--json]` | **Per-rule health for this repo**: findings still open, accepted into the baseline, and silenced with `fringe-ignore`, plus the ledger trend when `--record` has been used. A rule that is mostly overridden here is flagged: it may not suit the repo, so retune, disable or report it. `fringe/rules.v1`. |
27
+ | `fringe handoff [dir\|file] [--scan url]` | **Work order for another agent or CI.** Prints a self-contained prompt (rules, definition of done, the ordered plan) and saves `.fringe/handoff.json` (`fringe/handoff.v1`). `--json` prints the file; `--out` chooses where. |
28
+ | `fringe verify [--handoff file] [--json]` | **Proof it was done.** Re-checks everything the hand-off covered and reports `complete` / `partial` / `regressed`: errors fixed, remaining, introduced, and how many findings were silenced with `fringe-ignore` since. Exit `0` only when `complete` (`fringe/verify.v1`). |
26
29
  | `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
30
  | `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 36 rules. |
28
31
  | `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*. |
@@ -32,6 +35,8 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
32
35
  | `fringe check --update-baseline` | Accept today's findings into `fringe.baseline.json` (commit it). Exit `0`. |
33
36
  | `fringe check --record [--manifest <path>]` | Also append this run to `.fringe/ledger.jsonl`, optionally merging an agent flow manifest. |
34
37
  | `fringe` (no arguments) | Checks the current folder, and if the post-edit check is not set up yet, says how to set it up. |
38
+ | `fringe init --for <agents>` | Writes the Fringe rules where other agents read them: `codex`, `generic` (AGENTS.md), `gemini` (GEMINI.md), `cursor` (.cursor/rules), `copilot`, `windsurf`, `cline`, `kiro`. Appends to an existing file, never overwrites. Comma-separate several. |
39
+ | `fringe init --git` | Installs a git pre-commit gate that blocks a commit while error-level problems remain, for any agent that commits. It fails open: if the checker cannot run (no network, no node), the commit goes through. Never replaces someone else's hook. |
35
40
  | `fringe init [path] [--global]` | Add the Claude Code hook and install the six skills. `--global` writes to `~/.claude` so every project is covered. Uses the project's own installed copy of Fringe when there is one (faster), otherwise `npx`. Safe to re-run; refuses to touch an invalid `settings.json`. |
36
41
  | `fringe open [path] [--port <n>]` | Serve the ledger as a plain HTML page on `127.0.0.1:4173` (loopback only). Ctrl+C to stop. |
37
42
  | `fringe --help` · `--version` | Usage / version. |
@@ -103,8 +108,37 @@ npx @fringetools/cli scan http://localhost:3000 --modes dark,reflow # a sub
103
108
 
104
109
  Each mode runs on a fresh page so one mode's emulation never leaks into the next, and only findings the plain page did not already have are kept. Findings are labelled `[dark mode]`, `[reduced motion]`, `[reflow]`. Modes apply to the initial page, not to named states.
105
110
 
111
+ ### Pages behind a login, screenshots, and a baseline for scan findings
112
+
113
+ - **Login:** save a browser session once, then point the scan at it: `npx playwright-core codegen --save-storage=auth.json http://localhost:3000` (log in by hand in the window that opens, then close it). Use it with `--storage-state auth.json`, or put `"storageState": "auth.json"` at the top of `fringe.scan.json` (relative to that file). Keep the file out of git: it contains session cookies.
114
+ - **Screenshots:** `--screenshots shots/` saves a full-page PNG of the initial page, each named state and each stress mode, so a reviewer can see what was audited.
115
+ - **Baseline:** `scan --update-baseline` writes `fringe.scan.baseline.json` (commit it); later scans hide those known findings and report `N baselined`. Matching is by rule, page/state and element, not axe's wording, so a contrast ratio that shifts slightly stays known.
116
+
106
117
  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).
107
118
 
119
+ ## Agent hand-off
120
+
121
+ For agent-to-agent or CI-to-agent work with no human in the middle: one agent (or a CI job) finds problems, another fixes them, and a check proves it.
122
+
123
+ ```sh
124
+ fringe handoff --scan http://localhost:3000 > prompt.md # the work order; also saved as .fringe/handoff.json
125
+ claude -p "$(cat prompt.md)" # or any agent that takes a prompt
126
+ fringe verify # exit 0 only when complete; --json for the orchestrator
127
+ ```
128
+
129
+ The prompt carries the rules (fix the cause, never silence a rule to get green, list any `fringe-ignore` you add, stop and report anything that needs a human decision), the definition of done, and the ordered plan with fix recipes. `verify` compares with the hand-off's fingerprints, so it can tell an error that was *fixed* from one that was *introduced*, and it reports new suppressions for a human to review. Tested end to end: a fresh agent given only the prompt fixed all 122 errors of a messy app, `verify` returned `complete` with 0 introduced and 0 silenced.
130
+
131
+ ## Using Fringe with other coding agents
132
+
133
+ | Layer | Works with | Notes |
134
+ |---|---|---|
135
+ | **Rules file** (`init --for …`) | Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline, Kiro, Amp, Jules, Zed, anything reading `AGENTS.md` | The agent is told to run `fringe check` after edits and fix errors. It depends on the model following instructions. |
136
+ | **Git pre-commit gate** (`init --git`) | Any agent that runs `git commit` | The hook output goes to the agent's terminal. Fails open. |
137
+ | **CLI, `plan`, `handoff`/`verify`, CI** | Everything | Plain commands, exit codes and JSON. |
138
+ | **Live post-edit hook** | Claude Code | Verified. Other hosts have their own hook contracts, which must each be tested against the real tool before we claim support. |
139
+
140
+ Verified so far: Claude Code (hook and skills); Codex CLI (read `AGENTS.md` and tried to run the check; its sandbox crashed `npx` with a keychain error, so there use `npm i -D @fringetools/cli`). Gemini CLI could not be tested here (no output headlessly), so `GEMINI.md` is written but unverified.
141
+
108
142
  ## Priority, clusters and fix plans
109
143
 
110
144
  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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-7ZFEVMML.js";
4
+ } from "./chunk-ZTIMW7ON.js";
5
5
 
6
6
  // src/bin.ts
7
7
  process.exitCode = await runCli(process.argv.slice(2));
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
2
  import { randomUUID } from "crypto";
3
- import { existsSync as existsSync3, readFileSync as readFileSync9, statSync } from "fs";
4
- import { dirname as dirname2, join as join10, resolve } from "path";
3
+ import { existsSync as existsSync5, readFileSync as readFileSync11, statSync } from "fs";
4
+ import { dirname as dirname2, join as join11, resolve } from "path";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = "0.4.1";
7
+ var VERSION = "0.5.0";
8
8
 
9
9
  // src/check.ts
10
10
  import { readFileSync as readFileSync5 } from "fs";
@@ -1601,6 +1601,7 @@ function receipts(root, findings) {
1601
1601
 
1602
1602
  // src/scan.ts
1603
1603
  import { createRequire } from "module";
1604
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
1604
1605
  import { join as join8 } from "path";
1605
1606
  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
1607
  var WCAG_TAG = /^wcag(\d)(\d)(\d+)$/;
@@ -1695,35 +1696,68 @@ function onlyNew(base, extra) {
1695
1696
  return extra.filter((f) => !seen.has(`${f.rule}|${f.message}`));
1696
1697
  }
1697
1698
  var countRunningLoops = `[...document.getAnimations()].filter((a) => a.playState === 'running' && a.effect && a.effect.getComputedTiming().iterations === Infinity).map((a) => { const t = a.effect.target; return t ? t.tagName.toLowerCase() + (t.id ? '#' + t.id : '') + (typeof t.className === 'string' && t.className ? '.' + t.className.trim().split(/\\s+/)[0] : '') : 'unknown'; })`;
1698
- async function stress(browser, axePath, url, mode, base) {
1699
- const page = await browser.newPage();
1699
+ async function stress(context, axePath, url, mode, base, shot) {
1700
+ const page = await context.newPage();
1700
1701
  try {
1701
- return await stressOn(page, axePath, url, mode, base);
1702
+ return await stressOn(page, axePath, url, mode, base, shot);
1702
1703
  } finally {
1703
1704
  await page.close();
1704
1705
  }
1705
1706
  }
1706
- async function stressOn(page, axePath, url, mode, base) {
1707
+ async function stressOn(page, axePath, url, mode, base, shot) {
1707
1708
  const at0 = { file: `${url} [${mode}]`, line: 1, col: 0 };
1708
1709
  await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1709
1710
  if (mode === "dark") {
1710
1711
  await page.emulateMedia({ colorScheme: "dark" });
1711
1712
  await page.waitForTimeout(150);
1713
+ await shot(page, "mode-dark");
1712
1714
  return onlyNew(base, await audit(page, axePath, url)).map((f) => inState("dark mode", url, f));
1713
1715
  }
1714
1716
  if (mode === "reduced") {
1715
1717
  await page.emulateMedia({ reducedMotion: "reduce" });
1716
1718
  await page.waitForTimeout(300);
1719
+ await shot(page, "mode-reduced");
1717
1720
  const loops = await page.evaluate(countRunningLoops);
1718
1721
  return loops.length === 0 ? [] : [{ rule: "FR-403", severity: "review", message: `[reduced motion] ${loops.length} animation(s) keep looping when the user asks for reduced motion: ${loops.slice(0, 3).join(", ")}`, hint: "honour prefers-reduced-motion: stop or replace infinite animations under @media (prefers-reduced-motion: reduce)", loc: at0 }];
1719
1722
  }
1720
1723
  await page.setViewportSize({ width: 320, height: 256 });
1721
1724
  await page.waitForTimeout(250);
1725
+ await shot(page, "mode-reflow");
1722
1726
  const wide = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
1723
1727
  const out = wide > 1 ? [{ rule: "FR-404", severity: "review", message: `[reflow] content is ${wide}px wider than a 320px screen, so it needs horizontal scrolling`, hint: "let content reflow to one column at 320px (WCAG 1.4.10); wide tables and code blocks may scroll inside their own container", loc: at0, wcag: "1.4.10" }] : [];
1724
1728
  return [...out, ...onlyNew(base, await audit(page, axePath, url)).map((f) => inState("reflow", url, f))];
1725
1729
  }
1726
- async function scan(url, cwd = process.cwd(), states = [], modes = []) {
1730
+ var baselinePath = (cwd) => join8(cwd, "fringe.scan.baseline.json");
1731
+ var scanKey = (f) => `${f.rule}|${f.loc.file}|${f.message.split(": ")[0]}`;
1732
+ function applyScanBaseline(cwd, findings) {
1733
+ if (!existsSync2(baselinePath(cwd))) return { fresh: findings, known: 0 };
1734
+ let keys;
1735
+ try {
1736
+ keys = JSON.parse(readFileSync8(baselinePath(cwd), "utf8"));
1737
+ if (!Array.isArray(keys)) throw 0;
1738
+ } catch {
1739
+ throw new Error("fringe.scan.baseline.json is not a JSON array: rerun with --update-baseline");
1740
+ }
1741
+ const left = /* @__PURE__ */ new Map();
1742
+ for (const k of keys) left.set(k, (left.get(k) ?? 0) + 1);
1743
+ const fresh = findings.filter((f) => {
1744
+ const k = scanKey(f), n = left.get(k) ?? 0;
1745
+ if (n > 0) {
1746
+ left.set(k, n - 1);
1747
+ return false;
1748
+ }
1749
+ return true;
1750
+ });
1751
+ return { fresh, known: findings.length - fresh.length };
1752
+ }
1753
+ var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "page";
1754
+ async function scan(url, cwd = process.cwd(), opts = {}) {
1755
+ const { states = [], modes = [] } = opts;
1756
+ const shot = async (page, name) => {
1757
+ if (!opts.screenshots) return;
1758
+ mkdirSync3(opts.screenshots, { recursive: true });
1759
+ await page.screenshot({ path: join8(opts.screenshots, `${slug(name)}.png`), fullPage: true });
1760
+ };
1727
1761
  if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
1728
1762
  const req = createRequire(join8(cwd, "noop.js"));
1729
1763
  let playwright, axePath;
@@ -1744,8 +1778,11 @@ async function scan(url, cwd = process.cwd(), states = [], modes = []) {
1744
1778
  }
1745
1779
  }
1746
1780
  try {
1747
- const page = await browser.newPage();
1781
+ if (opts.storageState && !existsSync2(opts.storageState)) throw new Error(`storage state file not found: ${opts.storageState} (create one with: npx playwright-core codegen --save-storage=auth.json ${url})`);
1782
+ const context = await browser.newContext(opts.storageState ? { storageState: opts.storageState } : {});
1783
+ const page = await context.newPage();
1748
1784
  await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1785
+ await shot(page, "initial");
1749
1786
  const all = await audit(page, axePath, url);
1750
1787
  const plain = [...all];
1751
1788
  for (const st of states) {
@@ -1757,13 +1794,19 @@ async function scan(url, cwd = process.cwd(), states = [], modes = []) {
1757
1794
  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 } });
1758
1795
  continue;
1759
1796
  }
1797
+ await shot(page, `state-${st.name}`);
1760
1798
  all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
1761
1799
  }
1762
- for (const m of modes) all.push(...await stress(browser, axePath, url, m, plain));
1763
- const { findings, clusters } = rankScan(url, all);
1800
+ for (const m of modes) all.push(...await stress(context, axePath, url, m, plain, shot));
1801
+ if (opts.updateBaseline) {
1802
+ writeFileSync3(baselinePath(cwd), JSON.stringify(all.map(scanKey).sort(), null, 2) + "\n");
1803
+ return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1 + states.length + modes.length, skipped: [], findings: [], clusters: [], counts: { error: 0, review: 0, signal: 0 }, score: 100, baselined: all.length };
1804
+ }
1805
+ const { fresh, known } = applyScanBaseline(cwd, all);
1806
+ const { findings, clusters } = rankScan(url, fresh);
1764
1807
  const counts = { error: 0, review: 0, signal: 0 };
1765
1808
  for (const f of findings) counts[f.severity]++;
1766
- return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1 + states.length + modes.length, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1) };
1809
+ return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1 + states.length + modes.length, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1), ...known ? { baselined: known } : {} };
1767
1810
  } finally {
1768
1811
  await browser.close();
1769
1812
  }
@@ -1784,22 +1827,22 @@ function rankScan(url, findings) {
1784
1827
  }
1785
1828
  function mergeScan(base, scanned, weights) {
1786
1829
  const counts = { error: base.counts.error + scanned.counts.error, review: base.counts.review + scanned.counts.review, signal: base.counts.signal + scanned.counts.signal };
1787
- 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) };
1830
+ 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), ...(base.baselined ?? 0) + (scanned.baselined ?? 0) ? { baselined: (base.baselined ?? 0) + (scanned.baselined ?? 0) } : {} };
1788
1831
  }
1789
1832
 
1790
1833
  // src/init.ts
1791
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
1834
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync9, readdirSync as readdirSync3, writeFileSync as writeFileSync4, copyFileSync } from "fs";
1792
1835
  import { dirname, join as join9 } from "path";
1793
1836
  import { fileURLToPath } from "url";
1794
1837
  var HOOK = "npx -y @fringetools/cli check --claude-hook";
1795
1838
  var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
1796
1839
  function hookCommand(root) {
1797
- return existsSync2(join9(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
1840
+ return existsSync3(join9(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
1798
1841
  }
1799
1842
  function isWired(root, home) {
1800
1843
  for (const dir of [root, home]) {
1801
1844
  try {
1802
- if (/claude-hook|fringe(tool)?@/.test(readFileSync8(join9(dir, ".claude", "settings.json"), "utf8"))) return true;
1845
+ if (/claude-hook|fringe(tool)?@/.test(readFileSync9(join9(dir, ".claude", "settings.json"), "utf8"))) return true;
1803
1846
  } catch {
1804
1847
  }
1805
1848
  }
@@ -1809,9 +1852,9 @@ function initProject(root) {
1809
1852
  const done = [];
1810
1853
  const settingsPath = join9(root, ".claude", "settings.json");
1811
1854
  let settings = {};
1812
- if (existsSync2(settingsPath)) {
1855
+ if (existsSync3(settingsPath)) {
1813
1856
  try {
1814
- settings = JSON.parse(readFileSync8(settingsPath, "utf8"));
1857
+ settings = JSON.parse(readFileSync9(settingsPath, "utf8"));
1815
1858
  } catch {
1816
1859
  throw new Error(`${settingsPath} is not valid JSON \u2014 fix it and rerun`);
1817
1860
  }
@@ -1820,21 +1863,93 @@ function initProject(root) {
1820
1863
  if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
1821
1864
  else {
1822
1865
  post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: hookCommand(root) }] });
1823
- mkdirSync3(dirname(settingsPath), { recursive: true });
1824
- writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1866
+ mkdirSync4(dirname(settingsPath), { recursive: true });
1867
+ writeFileSync4(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1825
1868
  done.push(`hook added to ${settingsPath}`);
1826
1869
  }
1827
1870
  for (const name of readdirSync3(SKILL_DIR)) {
1828
1871
  const skillPath = join9(root, ".claude", "skills", name, "SKILL.md");
1829
- if (existsSync2(skillPath)) done.push(`skill ${name} already present`);
1872
+ if (existsSync3(skillPath)) done.push(`skill ${name} already present`);
1830
1873
  else {
1831
- mkdirSync3(dirname(skillPath), { recursive: true });
1874
+ mkdirSync4(dirname(skillPath), { recursive: true });
1832
1875
  copyFileSync(join9(SKILL_DIR, name, "SKILL.md"), skillPath);
1833
1876
  done.push(`skill ${name} installed`);
1834
1877
  }
1835
1878
  }
1836
1879
  return done;
1837
1880
  }
1881
+ var RULES_FILE = fileURLToPath(new URL("../rules/fringe.md", import.meta.url));
1882
+ var AGENT_FILES = {
1883
+ codex: { path: "AGENTS.md" },
1884
+ generic: { path: "AGENTS.md" },
1885
+ amp: { path: "AGENTS.md" },
1886
+ jules: { path: "AGENTS.md" },
1887
+ zed: { path: "AGENTS.md" },
1888
+ gemini: { path: "GEMINI.md" },
1889
+ cursor: { path: ".cursor/rules/fringe.mdc", wrap: (b) => `---
1890
+ description: Fringe: check and fix UX/accessibility problems after editing frontend files
1891
+ alwaysApply: true
1892
+ ---
1893
+
1894
+ ${b}` },
1895
+ copilot: { path: ".github/copilot-instructions.md" },
1896
+ windsurf: { path: ".windsurf/rules/fringe.md" },
1897
+ cline: { path: ".clinerules/fringe.md" },
1898
+ kiro: { path: ".kiro/steering/fringe.md" }
1899
+ };
1900
+ var AGENTS = Object.keys(AGENT_FILES);
1901
+ var MARK2 = "<!-- fringe:rules -->";
1902
+ function initFor(root, agents) {
1903
+ const body = readFileSync9(RULES_FILE, "utf8");
1904
+ const done = [];
1905
+ for (const a of agents) {
1906
+ const spec = AGENT_FILES[a];
1907
+ if (!spec) throw new Error(`unknown agent '${a}': use ${AGENTS.join(", ")} (or 'claude' for the hook + skills)`);
1908
+ const file2 = join9(root, spec.path);
1909
+ const text = spec.wrap ? spec.wrap(body) : body;
1910
+ if (existsSync3(file2)) {
1911
+ const cur = readFileSync9(file2, "utf8");
1912
+ if (cur.includes(MARK2) || cur.includes("# Fringe: handle your edges")) {
1913
+ done.push(`${spec.path} already has the Fringe rules`);
1914
+ continue;
1915
+ }
1916
+ writeFileSync4(file2, `${cur.replace(/\n*$/, "\n")}
1917
+ ${MARK2}
1918
+ ${spec.wrap ? body : text}`);
1919
+ done.push(`rules appended to ${spec.path}`);
1920
+ } else {
1921
+ mkdirSync4(dirname(file2), { recursive: true });
1922
+ writeFileSync4(file2, text.replace("# Fringe: handle your edges", `${MARK2}
1923
+ # Fringe: handle your edges`));
1924
+ done.push(`${spec.path} created`);
1925
+ }
1926
+ }
1927
+ return done;
1928
+ }
1929
+ function initGit(root) {
1930
+ const dir = join9(root, ".git", "hooks");
1931
+ if (!existsSync3(join9(root, ".git"))) throw new Error("not a git repository (no .git here)");
1932
+ const file2 = join9(dir, "pre-commit");
1933
+ const local = existsSync3(join9(root, "node_modules", "@fringetools", "cli", "package.json"));
1934
+ const cmd = local ? "npx --no-install fringe check" : "npx -y @fringetools/cli check";
1935
+ const script = `#!/bin/sh
1936
+ # fringe pre-commit: blocks the commit while error-level UX/accessibility problems remain
1937
+ out=$(${cmd} 2>&1); code=$?
1938
+ printf '%s\\n' "$out"
1939
+ if [ "$code" -eq 1 ] && printf '%s' "$out" | grep -q '^\\[ERROR\\]'; then
1940
+ echo "fringe: fix the errors above (or record them with: fringe check --update-baseline) and commit again." >&2
1941
+ exit 1
1942
+ fi
1943
+ exit 0
1944
+ `;
1945
+ if (existsSync3(file2)) {
1946
+ if (readFileSync9(file2, "utf8").includes("# fringe pre-commit")) return ["pre-commit hook already present"];
1947
+ throw new Error(`${file2} already exists and is not Fringe's: add 'npx -y @fringetools/cli check || exit 1' to it yourself`);
1948
+ }
1949
+ mkdirSync4(dir, { recursive: true });
1950
+ writeFileSync4(file2, script, { mode: 493 });
1951
+ return [`pre-commit hook installed at ${file2}`];
1952
+ }
1838
1953
 
1839
1954
  // src/index.ts
1840
1955
  import { homedir } from "os";
@@ -1962,13 +2077,143 @@ After each batch, verify: \`fringe check ${target}\``);
1962
2077
  return out.join("\n");
1963
2078
  }
1964
2079
 
2080
+ // src/rulereport.ts
2081
+ import { existsSync as existsSync4, readFileSync as readFileSync10 } from "fs";
2082
+ import { join as join10 } from "path";
2083
+ async function ruleReport(root) {
2084
+ const cfg = loadConfig(root);
2085
+ const result = await runCheck(root, cfg, false);
2086
+ const baselinedBy = /* @__PURE__ */ new Map();
2087
+ const bl = join10(root, "fringe.baseline.json");
2088
+ if (existsSync4(bl)) {
2089
+ try {
2090
+ for (const k of JSON.parse(readFileSync10(bl, "utf8"))) baselinedBy.set(k.split("|")[0], (baselinedBy.get(k.split("|")[0]) ?? 0) + 1);
2091
+ } catch {
2092
+ }
2093
+ }
2094
+ const silencedBy = /* @__PURE__ */ new Map();
2095
+ for (const f of walk(root, cfg)) {
2096
+ let src = "";
2097
+ try {
2098
+ src = readFileSync10(f, "utf8");
2099
+ } catch {
2100
+ continue;
2101
+ }
2102
+ for (const m of src.matchAll(/fringe-ignore\s+((?:FR-\d+[\s,]*)+)/g)) for (const id of m[1].match(/FR-\d+/g) ?? []) silencedBy.set(id, (silencedBy.get(id) ?? 0) + 1);
2103
+ }
2104
+ const rawBy = /* @__PURE__ */ new Map();
2105
+ for (const f of result.findings) rawBy.set(f.rule, (rawBy.get(f.rule) ?? 0) + 1);
2106
+ const { records } = readLedger(root);
2107
+ const first = records[0]?.byRule, last = records[records.length - 1]?.byRule;
2108
+ return ALL_RULES.map((r) => {
2109
+ const baselined = Math.min(baselinedBy.get(r.id) ?? 0, rawBy.get(r.id) ?? 0), silenced = silencedBy.get(r.id) ?? 0;
2110
+ const open = (rawBy.get(r.id) ?? 0) - baselined;
2111
+ const trend = records.length >= 2 && first && last ? `${first[r.id] ?? 0} -> ${last[r.id] ?? 0}` : void 0;
2112
+ return { rule: r.id, severity: r.severity, open, baselined, silenced, overridden: baselined + silenced, ...trend ? { trend } : {} };
2113
+ }).filter((x) => x.open + x.baselined + x.silenced > 0).sort((a, b) => b.overridden - a.overridden || b.open - a.open);
2114
+ }
2115
+ function rulesToText(rows) {
2116
+ if (rows.length === 0) return "No findings, baselined entries or suppressions: nothing to report per rule.";
2117
+ const hasTrend = rows.some((r) => r.trend);
2118
+ const head = ["rule", "severity", "open", "baselined", "silenced", ...hasTrend ? ["ledger trend"] : []];
2119
+ const body = rows.map((r) => [r.rule, r.severity, String(r.open), String(r.baselined), String(r.silenced), ...hasTrend ? [r.trend ?? "-"] : []]);
2120
+ const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
2121
+ const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
2122
+ const noisy = rows.filter((r) => r.overridden >= 5 && r.overridden / (r.open + r.overridden) >= 0.5);
2123
+ return [
2124
+ line(head),
2125
+ ...body.map(line),
2126
+ ...noisy.length ? ["", `Mostly overridden here (${noisy.map((r) => r.rule).join(", ")}): the rule may not suit this repo. Check a few silenced cases; if they are wrong findings, disable the rule in fringe.config.json or report a false positive.`] : []
2127
+ ].join("\n");
2128
+ }
2129
+
2130
+ // src/handoff.ts
2131
+ import { relative as relative5 } from "path";
2132
+ var fp = (root, f) => `${f.rule}|${f.loc.file.startsWith("/") ? relative5(root, f.loc.file) : f.loc.file}|${f.message}`;
2133
+ var where = (root, f) => `${f.loc.file.startsWith("/") ? relative5(root, f.loc.file) : f.loc.file}${f.loc.line > 1 || !f.target ? `:${f.loc.line}` : ""}${f.target ? ` ${f.target}` : ""}`;
2134
+ var RULES = [
2135
+ "Fix the problems in the order given. Re-run `fringe verify` after each batch; stop only when it says `status: complete`.",
2136
+ "Do not change the visual design or behavior; fix the underlying problem (labels, names, contrast, focus), not the symptom.",
2137
+ "Do not silence a rule to get green. A `fringe-ignore FR-xxx` comment is only for a genuine false positive; list every one you add, with the reason, in your final message. `verify` reports how many were added.",
2138
+ "Prefer fixing a shared component once over its many uses.",
2139
+ "If a finding needs a design or content decision you cannot make, leave it and say so in your final message instead of guessing."
2140
+ ];
2141
+ function buildHandoff(result, meta, limit = 15) {
2142
+ const batches = buildPlan(result, limit);
2143
+ const verifyCmd = "fringe verify";
2144
+ const prompt = [
2145
+ "# Fix the UX problems Fringe found",
2146
+ "",
2147
+ `You are taking over from another agent or a CI job. Fringe found ${result.counts.error} error(s), ${result.counts.review} review item(s) and ${result.counts.signal} polish item(s) in \`${meta.target}\` (health ${result.score}/100).`,
2148
+ "Your goal: no errors left, and nothing new introduced.",
2149
+ "",
2150
+ "## Rules",
2151
+ ...RULES.map((r) => `- ${r}`),
2152
+ "",
2153
+ "## Definition of done",
2154
+ "Commands: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place.",
2155
+ `Run \`${verifyCmd}\` (it re-checks everything this hand-off covers). You are done when it prints \`status: complete\` and exits 0. If it prints \`regressed\`, you broke something: fix what it lists as introduced first.`,
2156
+ "",
2157
+ toPlanMarkdown(result, meta.target, limit).replace(/^# Fringe fix plan\n\n/, "## The work\n\n")
2158
+ ].join("\n");
2159
+ return {
2160
+ schema: "fringe/handoff.v1",
2161
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2162
+ root: meta.root,
2163
+ target: meta.target,
2164
+ ...meta.only ? { only: meta.only } : {},
2165
+ ...meta.scan ? { scan: meta.scan } : {},
2166
+ score: result.score,
2167
+ counts: result.counts,
2168
+ suppressed: result.suppressed ?? 0,
2169
+ fingerprints: result.findings.map((f) => fp(meta.root, f)),
2170
+ batches,
2171
+ prompt
2172
+ };
2173
+ }
2174
+ function verifyHandoff(h, now) {
2175
+ const left = /* @__PURE__ */ new Map();
2176
+ for (const k of h.fingerprints) left.set(k, (left.get(k) ?? 0) + 1);
2177
+ const introduced = [];
2178
+ const remaining = [];
2179
+ for (const f of now.findings) {
2180
+ const k = fp(h.root, f), n = left.get(k) ?? 0;
2181
+ if (n > 0) {
2182
+ left.set(k, n - 1);
2183
+ remaining.push(f);
2184
+ } else introduced.push(f);
2185
+ }
2186
+ const errs = (fs) => fs.filter((f) => f.severity === "error");
2187
+ const row = (f) => ({ rule: f.rule, where: where(h.root, f), message: f.message });
2188
+ const remainingErrors = errs(remaining).length, introducedErrors = errs(introduced).length;
2189
+ return {
2190
+ schema: "fringe/verify.v1",
2191
+ status: introducedErrors > 0 ? "regressed" : remainingErrors === 0 ? "complete" : "partial",
2192
+ score: { before: h.score, after: now.score },
2193
+ errors: { before: h.counts.error, fixed: h.counts.error - remainingErrors, remaining: remainingErrors, introduced: introducedErrors },
2194
+ review: { remaining: remaining.filter((f) => f.severity === "review").length, introduced: introduced.filter((f) => f.severity === "review").length },
2195
+ introduced: introduced.filter((f) => f.severity !== "signal").map(row).slice(0, 20),
2196
+ remaining: errs(remaining).map(row).slice(0, 20),
2197
+ newSuppressions: Math.max(0, (now.suppressed ?? 0) - h.suppressed)
2198
+ };
2199
+ }
2200
+ function verifyToMarkdown(v) {
2201
+ const out = [`status: ${v.status}`, `health ${v.score.before} -> ${v.score.after}`, `errors: ${v.errors.fixed} of ${v.errors.before} fixed, ${v.errors.remaining} remaining, ${v.errors.introduced} introduced`];
2202
+ if (v.review.introduced) out.push(`review items introduced: ${v.review.introduced}`);
2203
+ if (v.introduced.length) out.push("", "Introduced since the hand-off (fix these first):", ...v.introduced.map((x) => `- ${x.rule} ${x.where}: ${x.message}`));
2204
+ if (v.remaining.length) out.push("", "Errors still to fix:", ...v.remaining.slice(0, 10).map((x) => `- ${x.rule} ${x.where}: ${x.message}`), ...v.remaining.length > 10 ? [`- \u2026and ${v.remaining.length - 10} more`] : []);
2205
+ if (v.newSuppressions) out.push("", `Needs human review: ${v.newSuppressions} finding(s) were silenced with fringe-ignore since the hand-off.`);
2206
+ return out.join("\n");
2207
+ }
2208
+
1965
2209
  // src/index.ts
2210
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
1966
2211
  var log = (o) => o.log ?? ((s) => console.log(s));
1967
2212
  var err = (o) => o.err ?? ((s) => process.stderr.write(s));
1968
2213
  function readManifest(path2, warn) {
1969
2214
  let raw;
1970
2215
  try {
1971
- raw = readFileSync9(path2, "utf8");
2216
+ raw = readFileSync11(path2, "utf8");
1972
2217
  } catch {
1973
2218
  warn(`fringe: warning: manifest not found at ${path2} \u2014 continuing without
1974
2219
  `);
@@ -1995,7 +2240,7 @@ function resolveTarget(arg) {
1995
2240
  if (!statSync(arg).isFile()) return { root: arg };
1996
2241
  let dir = dirname2(arg);
1997
2242
  for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
1998
- if (existsSync3(join10(d, "package.json")) || existsSync3(join10(d, "fringe.config.json"))) {
2243
+ if (existsSync5(join11(d, "package.json")) || existsSync5(join11(d, "fringe.config.json"))) {
1999
2244
  dir = d;
2000
2245
  break;
2001
2246
  }
@@ -2003,17 +2248,27 @@ function resolveTarget(arg) {
2003
2248
  }
2004
2249
  return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
2005
2250
  }
2006
- function loadStates(cwd, rest) {
2007
- const i = rest.indexOf("--states");
2008
- const file2 = i !== -1 ? resolve(cwd, rest[i + 1] ?? "") : join10(cwd, "fringe.scan.json");
2009
- if (i === -1 && !existsSync3(file2)) return [];
2010
- let raw;
2011
- try {
2012
- raw = JSON.parse(readFileSync9(file2, "utf8"));
2013
- } catch (e) {
2014
- throw new Error(`${file2}: ${e.message}`);
2251
+ function loadScanOptions(cwd, rest) {
2252
+ const flag = (n) => {
2253
+ const i = rest.indexOf(n);
2254
+ return i === -1 ? void 0 : rest[i + 1];
2255
+ };
2256
+ const si = rest.indexOf("--states");
2257
+ const file2 = si !== -1 ? resolve(cwd, rest[si + 1] ?? "") : join11(cwd, "fringe.scan.json");
2258
+ let states = [];
2259
+ let storageState = flag("--storage-state") ? resolve(cwd, flag("--storage-state")) : void 0;
2260
+ if (si !== -1 || existsSync5(file2)) {
2261
+ let raw;
2262
+ try {
2263
+ raw = JSON.parse(readFileSync11(file2, "utf8"));
2264
+ } catch (e) {
2265
+ throw new Error(`${file2}: ${e.message}`);
2266
+ }
2267
+ states = parseStates(raw);
2268
+ if (!storageState && typeof raw.storageState === "string") storageState = resolve(dirname2(file2), raw.storageState);
2015
2269
  }
2016
- return parseStates(raw);
2270
+ const shots = flag("--screenshots");
2271
+ return { states, modes: loadModes(rest), ...storageState ? { storageState } : {}, ...shots ? { screenshots: resolve(cwd, shots) } : {}, ...rest.includes("--update-baseline") ? { updateBaseline: true } : {} };
2017
2272
  }
2018
2273
  function loadModes(rest) {
2019
2274
  const i = rest.indexOf("--modes");
@@ -2023,11 +2278,16 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
2023
2278
  var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
2024
2279
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
2025
2280
  fringe check --update-baseline [path] accept today's findings; only new ones gate
2026
- fringe scan <url> [--states f] [--stress | --modes dark,reduced,reflow] [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
2281
+ fringe scan <url> [--states f] [--stress | --modes ...] [--storage-state auth.json] [--screenshots dir] [--update-baseline] [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
2282
+ fringe rules [dir] [--json] per rule: open / baselined / silenced, to spot rules that don't suit this repo
2283
+ fringe handoff [dir|file] [--scan url] a work order for another agent or CI (saved to .fringe/handoff.json)
2284
+ fringe verify [--handoff file] did the receiving agent finish? exit 0 only when complete
2027
2285
  fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
2028
2286
  fringe explain pattern [form] best-practice checklist for a component type
2029
2287
  fringe explain [FR-203] why a rule matters and how to fix it well
2030
2288
  fringe init [path] [--global] add the Claude Code hook + skills (--global: every project)
2289
+ fringe init --for cursor,codex,... write the rules file other agents read (cursor, codex, gemini, copilot, windsurf, cline, kiro, generic)
2290
+ fringe init --git pre-commit gate for any agent that commits
2031
2291
  fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
2032
2292
  fringe --help | --version`;
2033
2293
  var KNOWN_FLAGS = /* @__PURE__ */ new Set(["--json", "--hook", "--claude-hook", "--record", "--manifest", "--update-baseline"]);
@@ -2048,6 +2308,28 @@ function parseArgs(rest, warn) {
2048
2308
  }
2049
2309
  return paths;
2050
2310
  }
2311
+ async function gather(opts, rest) {
2312
+ const si = rest.indexOf("--scan");
2313
+ const scanUrl = si !== -1 ? rest[si + 1] : void 0;
2314
+ if (si !== -1 && (!scanUrl || scanUrl.startsWith("--"))) throw new Error("--scan needs a URL, e.g. fringe plan --scan http://localhost:3000");
2315
+ const li = rest.indexOf("--limit");
2316
+ const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
2317
+ const valued = /* @__PURE__ */ new Set(["--limit", "--scan", "--states", "--modes", "--out", "--storage-state", "--screenshots"]);
2318
+ const flags = /* @__PURE__ */ new Set(["--json", "--stress"]);
2319
+ const paths = parseArgs(rest.filter((a, i) => !valued.has(a) && !valued.has(rest[i - 1]) && !flags.has(a)), (s) => process.stderr.write(s));
2320
+ const cwd = opts.cwd ?? process.cwd();
2321
+ const { root, only } = resolveTarget(resolve(cwd, paths[0] ?? "."));
2322
+ const cfg = loadConfig(root);
2323
+ let result = await runCheck(root, cfg, true, only);
2324
+ let scanMeta;
2325
+ if (scanUrl) {
2326
+ result = mergeScan(result, await scan(scanUrl, cwd, loadScanOptions(cwd, rest)), cfg.weights);
2327
+ const sf = rest.indexOf("--states");
2328
+ const so = loadScanOptions(cwd, rest);
2329
+ scanMeta = { url: scanUrl, ...sf !== -1 ? { statesFile: resolve(cwd, rest[sf + 1]) } : {}, modes: so.modes ?? [], ...so.storageState ? { storageState: so.storageState } : {} };
2330
+ }
2331
+ return { result, root, target: paths[0] ?? ".", only: only ? [...only][0] : void 0, limit, scan: scanMeta };
2332
+ }
2051
2333
  async function runCli(argv, opts = {}) {
2052
2334
  const [cmd, ...rest] = argv;
2053
2335
  if (cmd === void 0) {
@@ -2105,13 +2387,20 @@ async function runCli(argv, opts = {}) {
2105
2387
  }
2106
2388
  if (cmd === "scan") {
2107
2389
  try {
2108
- const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--states" && rest[i - 1] !== "--modes");
2390
+ const url = rest.find((a, i) => !a.startsWith("--") && !["--states", "--modes", "--storage-state", "--screenshots"].includes(rest[i - 1]));
2109
2391
  if (!url) {
2110
2392
  err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
2111
2393
  return 2;
2112
2394
  }
2113
2395
  const cwd = opts.cwd ?? process.cwd();
2114
- const result = await scan(url, cwd, loadStates(cwd, rest), loadModes(rest));
2396
+ const so = loadScanOptions(cwd, rest);
2397
+ const result = await scan(url, cwd, so);
2398
+ if (so.updateBaseline) {
2399
+ log(opts)(`fringe: scan baseline written: ${result.baselined ?? 0} known findings in fringe.scan.baseline.json (commit it); only new ones show from now on`);
2400
+ return 0;
2401
+ }
2402
+ if (so.screenshots) err(opts)(`fringe: screenshots saved in ${so.screenshots}
2403
+ `);
2115
2404
  log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
2116
2405
  return result.counts.error > 0 ? 1 : 0;
2117
2406
  } catch (e) {
@@ -2120,29 +2409,59 @@ async function runCli(argv, opts = {}) {
2120
2409
  return 2;
2121
2410
  }
2122
2411
  }
2123
- if (cmd === "plan") {
2412
+ if (cmd === "rules") {
2124
2413
  try {
2125
- const si = rest.indexOf("--scan");
2126
- const scanUrl = si !== -1 ? rest[si + 1] : void 0;
2127
- if (si !== -1 && (!scanUrl || scanUrl.startsWith("--"))) {
2128
- err(opts)("fringe: --scan needs a URL, e.g. fringe plan --scan http://localhost:3000\n");
2129
- return 2;
2130
- }
2131
- const li = rest.indexOf("--limit");
2132
- const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
2133
- 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" && a !== "--stress" && a !== "--modes" && rest[i - 1] !== "--modes"), err(opts));
2134
- const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
2135
- const { root, only } = resolveTarget(arg);
2136
- const cfg = loadConfig(root);
2137
- let result = await runCheck(root, cfg, true, only);
2138
- if (scanUrl) {
2139
- const cwd = opts.cwd ?? process.cwd();
2140
- result = mergeScan(result, await scan(scanUrl, cwd, loadStates(cwd, rest), loadModes(rest)), cfg.weights);
2414
+ const root = resolve(opts.cwd ?? process.cwd(), rest.find((a) => !a.startsWith("--")) ?? ".");
2415
+ const rows = await ruleReport(root);
2416
+ log(opts)(rest.includes("--json") ? JSON.stringify({ schema: "fringe/rules.v1", rules: rows }, null, 2) : rulesToText(rows));
2417
+ return 0;
2418
+ } catch (e) {
2419
+ err(opts)(`fringe: ${e.message}
2420
+ `);
2421
+ return 2;
2422
+ }
2423
+ }
2424
+ if (cmd === "plan" || cmd === "handoff") {
2425
+ try {
2426
+ const g = await gather(opts, rest);
2427
+ if (cmd === "plan") {
2428
+ log(opts)(rest.includes("--json") ? toPlanJson(g.result, g.limit) : toPlanMarkdown(g.result, g.target, g.limit));
2429
+ return 0;
2141
2430
  }
2142
- log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
2431
+ const h = buildHandoff(g.result, { root: g.root, target: g.target, ...g.only ? { only: g.only } : {}, ...g.scan ? { scan: g.scan } : {} }, g.limit);
2432
+ const oi = rest.indexOf("--out");
2433
+ const outFile = resolve(g.root, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/handoff.json");
2434
+ mkdirSync5(dirname2(outFile), { recursive: true });
2435
+ writeFileSync5(outFile, JSON.stringify(h, null, 2) + "\n");
2436
+ log(opts)(rest.includes("--json") ? JSON.stringify(h, null, 2) : h.prompt);
2437
+ err(opts)(`fringe: hand-off saved to ${outFile}; the receiving agent proves it is done with: fringe verify${oi !== -1 ? ` --handoff ${outFile}` : ""}
2438
+ `);
2143
2439
  return 0;
2144
2440
  } catch (e) {
2145
- err(opts)(`fringe: internal error: ${e.message}
2441
+ err(opts)(`fringe: ${e.message}
2442
+ `);
2443
+ return 2;
2444
+ }
2445
+ }
2446
+ if (cmd === "verify") {
2447
+ try {
2448
+ const hi = rest.indexOf("--handoff");
2449
+ const file2 = resolve(opts.cwd ?? process.cwd(), hi !== -1 ? rest[hi + 1] ?? "" : ".fringe/handoff.json");
2450
+ let h;
2451
+ try {
2452
+ h = JSON.parse(readFileSync11(file2, "utf8"));
2453
+ } catch {
2454
+ throw new Error(`no readable hand-off at ${file2} \u2014 run 'fringe handoff' first`);
2455
+ }
2456
+ if (h.schema !== "fringe/handoff.v1") throw new Error(`${file2} is not a fringe/handoff.v1 file`);
2457
+ const cfg = loadConfig(h.root);
2458
+ let result = await runCheck(h.root, cfg, true, h.only ? /* @__PURE__ */ new Set([h.only]) : void 0);
2459
+ if (h.scan) result = mergeScan(result, await scan(h.scan.url, h.root, { ...loadScanOptions(h.root, h.scan.statesFile ? ["--states", h.scan.statesFile] : []), modes: h.scan.modes, ...h.scan.storageState ? { storageState: h.scan.storageState } : {} }), cfg.weights);
2460
+ const v = verifyHandoff(h, result);
2461
+ log(opts)(rest.includes("--json") ? JSON.stringify(v, null, 2) : verifyToMarkdown(v));
2462
+ return v.status === "complete" ? 0 : 1;
2463
+ } catch (e) {
2464
+ err(opts)(`fringe: ${e.message}
2146
2465
  `);
2147
2466
  return 2;
2148
2467
  }
@@ -2170,8 +2489,17 @@ async function runCli(argv, opts = {}) {
2170
2489
  if (cmd === "init") {
2171
2490
  try {
2172
2491
  const global = rest.includes("--global") || rest.includes("-g");
2173
- const target = global ? opts.home ?? homedir() : resolve(opts.cwd ?? process.cwd(), rest.find((a) => !a.startsWith("-")) ?? ".");
2174
- for (const line of initProject(target)) log(opts)(`fringe: ${line}`);
2492
+ const target = global ? opts.home ?? homedir() : resolve(opts.cwd ?? process.cwd(), rest.find((a, i) => !a.startsWith("-") && rest[i - 1] !== "--for") ?? ".");
2493
+ const fi = rest.indexOf("--for");
2494
+ const agents = fi !== -1 ? (rest[fi + 1] ?? "").split(",").map((a) => a.trim().toLowerCase()).filter(Boolean) : [];
2495
+ if (fi !== -1 && agents.length === 0) throw new Error(`--for needs an agent: ${AGENTS.join(", ")}, claude`);
2496
+ const lines = [];
2497
+ const wantsClaude = fi === -1 && !rest.includes("--git") || agents.includes("claude");
2498
+ if (wantsClaude) lines.push(...initProject(target));
2499
+ const others = agents.filter((a) => a !== "claude");
2500
+ if (others.length) lines.push(...initFor(target, others));
2501
+ if (rest.includes("--git")) lines.push(...initGit(target));
2502
+ for (const line of lines) log(opts)(`fringe: ${line}`);
2175
2503
  if (global) log(opts)("fringe: set up for your user, so every project is covered (run without --global to set up one project only)");
2176
2504
  return 0;
2177
2505
  } catch (e) {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-7ZFEVMML.js";
3
+ } from "./chunk-ZTIMW7ON.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.4.1",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  },
12
12
  "files": [
13
13
  "dist",
14
- "skill"
14
+ "skill",
15
+ "rules"
15
16
  ],
16
17
  "scripts": {
17
18
  "build": "tsup src/index.ts src/bin.ts --format esm --dts --outDir dist --clean",
@@ -0,0 +1,23 @@
1
+ # Fringe: handle your edges
2
+
3
+ For any coding agent that reads a rules file. Fringe is a static checker for frontends: it measures accessibility, interaction states, dark patterns and the generic "AI look", and exits non-zero on hard failures.
4
+
5
+ Run it from the project root: `npx -y @fringetools/cli check` (or `fringe check` if installed). It takes a directory, not a single file.
6
+
7
+ ## While you work
8
+
9
+ 1. **Flow.** Before building UI, ask what the user can skip. Cut steps, screens and options nobody needs, and say why with evidence (a metric, a usage count, a requirement). No evidence, no cut.
10
+ 2. **Edges.** After every edit to a frontend file, run `fringe check`. Fix every `[ERROR]` before you finish: missing `alt`, unnamed buttons, unlabeled inputs, skipped headings, removed focus rings, `onClick` on non-interactive elements, invalid `aria-*`. Do not finish with errors outstanding.
11
+ Many findings? `fringe plan` orders them (shared components first) with a fix recipe each; `fringe explain FR-203` shows why a rule matters and the preferred fix.
12
+ 3. **Polish.** Fix `[REVIEW]` items you agree with and cheap `[SIGNAL]` items; say which you left and why.
13
+
14
+ ## Rules
15
+
16
+ - Never silence a rule to get green. A `// fringe-ignore FR-123` comment is for a real false positive only; say so in your final message with the rule and the reason. Every run prints `N suppressed`.
17
+ - On a repo that already has problems, do not fix everything first: `fringe check --update-baseline` records today's findings so only new ones gate (commit `fringe.baseline.json`).
18
+ - If `npx` cannot run here (a sandbox with no network), ask the user to run `npm i -D @fringetools/cli` once, then use `npx --no-install fringe check`. If Fringe still cannot run, say so. Do not skip the check silently.
19
+ - Report the health score as measured; never estimate it.
20
+
21
+ Works alongside Ponytail: Ponytail keeps the code small and promises never to cut accessibility; Fringe is the check that verifies that promise.
22
+
23
+ Reference: https://github.com/skc-learn/fringe/blob/main/packages/cli/README.md
@@ -5,7 +5,7 @@ description: Use when writing or changing frontend code (JSX/TSX, HTML, CSS, Vue
5
5
 
6
6
  # Fringe
7
7
 
8
- Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.0` in its place (e.g. `npx -y @fringetools/cli@0.4.0 check --json .`).
8
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.1` in its place (e.g. `npx -y @fringetools/cli@0.4.1 check --json .`).
9
9
 
10
10
  Ponytail trims your code. Fringe handles your edges. Work the ladder in order.
11
11
 
@@ -5,7 +5,7 @@ description: Use when the user wants a whole-repo UX/accessibility audit, a plan
5
5
 
6
6
  # Fringe audit
7
7
 
8
- Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.0` in its place (e.g. `npx -y @fringetools/cli@0.4.0 check --json .`).
8
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.1` in its place (e.g. `npx -y @fringetools/cli@0.4.1 check --json .`).
9
9
 
10
10
  1. Run `fringe plan --json --limit 5` (or `fringe plan` for Markdown). It already orders the work: P0 errors in shared components or critical paths, P1 other errors with mechanical fixes first, P2 review, P3 polish, each cluster with its recipe. Use that order; do not re-derive it. Add `"critical": ["src/checkout/**"]` to `fringe.config.json` if the user names the pages that matter most. Do not print every finding.
11
11
  2. Summarize as a plan:
@@ -7,7 +7,7 @@ description: Use when the user asks what Fringe findings have been silenced or d
7
7
 
8
8
  Fringe has two ways to defer a finding: an inline `fringe-ignore` comment and the `fringe.baseline.json` file. This lists both so a deferral can't quietly become permanent.
9
9
 
10
- Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.0` in its place.
10
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.1` in its place.
11
11
 
12
12
  1. **Inline suppressions.** `git grep --no-index --exclude-standard -nE 'fringe-ignore +FR-[0-9]+'`. One row per hit: `<file>:<line>, <rule> — <why, if the comment or nearby code says>`. Skip hits that are only prose mentioning the convention.
13
13
  2. **Baseline.** If `fringe.baseline.json` exists, count entries per rule (each entry is `rule|file|message`) and list the files with the most. If it is missing, say there is no baseline.
@@ -18,7 +18,7 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
18
18
  | `/fringe-setup` | Guided onboarding: wire the hook, offer a baseline, set critical paths |
19
19
  | `/fringe-help` | This reference |
20
20
 
21
- **Commands** (`fringe`, or `npx -y @fringetools/cli@0.4.0`)
21
+ **Commands** (`fringe`, or `npx -y @fringetools/cli@0.4.1`)
22
22
 
23
23
  | Command | What it does |
24
24
  |---|---|
@@ -27,6 +27,9 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
27
27
  | `check --update-baseline` | Accept today's findings; only new ones gate |
28
28
  | `scan <url>` | Runtime check of a live page: real contrast, keyboard walk, and named states from `fringe.scan.json` (needs `playwright-core` + `axe-core`) |
29
29
  | `plan [dir\|file] [--scan url]` | Ordered fix batches (shared components first) with recipes; `--scan` adds the live page |
30
+ | `handoff` / `verify` | Work order for another agent or CI, and proof it was done (complete / partial / regressed) |
31
+ | `rules` | Per rule: open / baselined / silenced, to spot rules that do not suit this repo |
32
+ | `init --for <agents>` / `--git` | Rules file for Codex, Cursor, Copilot, Gemini…; commit gate for any agent |
30
33
  | `explain [FR-203]` | Why a rule matters and how to fix it well |
31
34
  | `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
32
35
  | `init` | Add the hook and install the skills |
@@ -5,7 +5,7 @@ description: Use when the user asks to review, check or critique a UI, page or c
5
5
 
6
6
  # Fringe review
7
7
 
8
- Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.0` in its place (e.g. `npx -y @fringetools/cli@0.4.0 check --json .`).
8
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.1` in its place (e.g. `npx -y @fringetools/cli@0.4.1 check --json .`).
9
9
 
10
10
  1. Run `fringe check --json <file or folder>` from the project root: the file or page the user named, else `.`. Each finding already carries `priority` and `fixability`; report highest priority first. For the fix text use `fringe explain <rule>` (why, recipe, what to avoid) rather than improvising.
11
11
  2. Report in plain English, no jargon left unexplained: