@fringetools/cli 0.4.0 → 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*. |
@@ -31,7 +34,10 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
31
34
  | `fringe check --hook` | One-line `fringe/hook.v1` JSON for CI / pre-commit. Exits `1` on errors. Claude Code does **not** feed this back — use `--claude-hook` for agents. Wins over `--json`. |
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
- | `fringe init [path]` | Add the Claude Code hook and install the five skills. Safe to re-run; refuses to touch an invalid `settings.json`. |
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. |
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`. |
35
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. |
36
42
  | `fringe --help` · `--version` | Usage / version. |
37
43
 
@@ -87,8 +93,52 @@ Most real problems live in states the first load never shows: an open menu, a mo
87
93
 
88
94
  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
95
 
96
+ ### Stress modes: how the page behaves under real-world conditions
97
+
98
+ ```sh
99
+ npx @fringetools/cli scan http://localhost:3000 --stress # all three
100
+ npx @fringetools/cli scan http://localhost:3000 --modes dark,reflow # a subset
101
+ ```
102
+
103
+ | Mode | What it does | Reports |
104
+ |---|---|---|
105
+ | `dark` | Re-audits with the browser asking for a dark color scheme | contrast and other axe findings that appear only in dark mode |
106
+ | `reduced` | Asks for reduced motion, then looks for animations still looping | `FR-403` (review) |
107
+ | `reflow` | Shrinks to 320px (a 400% zoom of a 1280px window) | `FR-404` (review, WCAG 1.4.10) if the page needs horizontal scrolling, plus new axe findings |
108
+
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.
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
+
90
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).
91
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
+
92
142
  ## Priority, clusters and fix plans
93
143
 
94
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.
@@ -152,7 +202,7 @@ The hook also keeps `.fringe/last.json` (the previous run's errors) and tells th
152
202
 
153
203
  This was verified end to end with real agent runs: the agent receives the findings and fixes them (see [docs/architecture.md](../../docs/architecture.md)). Why `--claude-hook` and not `--hook`: Claude Code only passes a hook's output to the agent as `decision`/`reason` JSON on exit 0 (or stderr on exit 2); the CI contract `--hook` exits 1 and is ignored.
154
204
 
155
- **Skills** — `fringe init` installs five into `.claude/skills/` (sources in [skill/](skill/), one folder each); the Claude Code plugin ships the same folders, namespaced `/fringe:…`: `fringe` (below), `fringe-review` ("review this page": ranked, plain-English findings with fixes), `fringe-audit` (whole-repo plan: top rules and files, a fix order, offers `--update-baseline`), `fringe-debt` (lists `fringe-ignore` suppressions and the baseline) and `fringe-help` (quick reference). Invoke as `/fringe-review` etc. Reviews run `fringe check`, so Claude Code will ask you to approve that command once (or allow `Bash(fringe:*)` / `Bash(npx:*)`).
205
+ **Skills** — `fringe init` installs six into `.claude/skills/` (sources in [skill/](skill/), one folder each); the Claude Code plugin ships the same folders, namespaced `/fringetool:…`: `fringe` (below), `fringe-review` ("review this page": ranked, plain-English findings with fixes), `fringe-audit` (whole-repo plan: top rules and files, a fix order, offers `--update-baseline`), `fringe-debt` (lists `fringe-ignore` suppressions and the baseline), `fringe-setup` (guided onboarding: environment check, hook, baseline offer, critical paths) and `fringe-help` (quick reference). Invoke as `/fringe-review` etc. Reviews run `fringe check`, so Claude Code will ask you to approve that command once (or allow `Bash(fringe:*)` / `Bash(npx:*)`).
156
206
 
157
207
  **The main skill** (`.claude/skills/fringe/SKILL.md`) teaches the agent the ladder: *Flow* (cut what users don't need, with evidence in `.fringe/manifest.json`) → *Edges* (fix every hook finding, never silence a rule to get green) → *Polish* (tokens, scales, no AI look), then record the run. It is plain Markdown, so any agent platform that reads `AGENTS.md`/skill files can use it as-is.
158
208
 
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-JSWDIPHX.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.0";
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+)$/;
@@ -1683,7 +1684,80 @@ async function audit(page, axePath, url) {
1683
1684
  return [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
1684
1685
  }
1685
1686
  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 = []) {
1687
+ var MODES = ["dark", "reduced", "reflow"];
1688
+ function parseModes(list) {
1689
+ const out = list.split(",").map((m) => m.trim()).filter(Boolean);
1690
+ const bad = out.find((m) => !MODES.includes(m));
1691
+ if (bad || out.length === 0) throw new Error(`unknown scan mode '${bad ?? ""}': use ${MODES.join(", ")}`);
1692
+ return out;
1693
+ }
1694
+ function onlyNew(base, extra) {
1695
+ const seen = new Set(base.map((f) => `${f.rule}|${f.message}`));
1696
+ return extra.filter((f) => !seen.has(`${f.rule}|${f.message}`));
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'; })`;
1699
+ async function stress(context, axePath, url, mode, base, shot) {
1700
+ const page = await context.newPage();
1701
+ try {
1702
+ return await stressOn(page, axePath, url, mode, base, shot);
1703
+ } finally {
1704
+ await page.close();
1705
+ }
1706
+ }
1707
+ async function stressOn(page, axePath, url, mode, base, shot) {
1708
+ const at0 = { file: `${url} [${mode}]`, line: 1, col: 0 };
1709
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1710
+ if (mode === "dark") {
1711
+ await page.emulateMedia({ colorScheme: "dark" });
1712
+ await page.waitForTimeout(150);
1713
+ await shot(page, "mode-dark");
1714
+ return onlyNew(base, await audit(page, axePath, url)).map((f) => inState("dark mode", url, f));
1715
+ }
1716
+ if (mode === "reduced") {
1717
+ await page.emulateMedia({ reducedMotion: "reduce" });
1718
+ await page.waitForTimeout(300);
1719
+ await shot(page, "mode-reduced");
1720
+ const loops = await page.evaluate(countRunningLoops);
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 }];
1722
+ }
1723
+ await page.setViewportSize({ width: 320, height: 256 });
1724
+ await page.waitForTimeout(250);
1725
+ await shot(page, "mode-reflow");
1726
+ const wide = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
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" }] : [];
1728
+ return [...out, ...onlyNew(base, await audit(page, axePath, url)).map((f) => inState("reflow", url, f))];
1729
+ }
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
+ };
1687
1761
  if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
1688
1762
  const req = createRequire(join8(cwd, "noop.js"));
1689
1763
  let playwright, axePath;
@@ -1704,9 +1778,13 @@ async function scan(url, cwd = process.cwd(), states = []) {
1704
1778
  }
1705
1779
  }
1706
1780
  try {
1707
- 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();
1708
1784
  await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1785
+ await shot(page, "initial");
1709
1786
  const all = await audit(page, axePath, url);
1787
+ const plain = [...all];
1710
1788
  for (const st of states) {
1711
1789
  await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1712
1790
  let step = 0;
@@ -1716,12 +1794,19 @@ async function scan(url, cwd = process.cwd(), states = []) {
1716
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 } });
1717
1795
  continue;
1718
1796
  }
1797
+ await shot(page, `state-${st.name}`);
1719
1798
  all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
1720
1799
  }
1721
- 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);
1722
1807
  const counts = { error: 0, review: 0, signal: 0 };
1723
1808
  for (const f of findings) counts[f.severity]++;
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) };
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 } : {} };
1725
1810
  } finally {
1726
1811
  await browser.close();
1727
1812
  }
@@ -1742,22 +1827,34 @@ function rankScan(url, findings) {
1742
1827
  }
1743
1828
  function mergeScan(base, scanned, weights) {
1744
1829
  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) };
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) } : {} };
1746
1831
  }
1747
1832
 
1748
1833
  // src/init.ts
1749
- 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";
1750
1835
  import { dirname, join as join9 } from "path";
1751
1836
  import { fileURLToPath } from "url";
1752
1837
  var HOOK = "npx -y @fringetools/cli check --claude-hook";
1753
1838
  var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
1839
+ function hookCommand(root) {
1840
+ return existsSync3(join9(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
1841
+ }
1842
+ function isWired(root, home) {
1843
+ for (const dir of [root, home]) {
1844
+ try {
1845
+ if (/claude-hook|fringe(tool)?@/.test(readFileSync9(join9(dir, ".claude", "settings.json"), "utf8"))) return true;
1846
+ } catch {
1847
+ }
1848
+ }
1849
+ return false;
1850
+ }
1754
1851
  function initProject(root) {
1755
1852
  const done = [];
1756
1853
  const settingsPath = join9(root, ".claude", "settings.json");
1757
1854
  let settings = {};
1758
- if (existsSync2(settingsPath)) {
1855
+ if (existsSync3(settingsPath)) {
1759
1856
  try {
1760
- settings = JSON.parse(readFileSync8(settingsPath, "utf8"));
1857
+ settings = JSON.parse(readFileSync9(settingsPath, "utf8"));
1761
1858
  } catch {
1762
1859
  throw new Error(`${settingsPath} is not valid JSON \u2014 fix it and rerun`);
1763
1860
  }
@@ -1765,22 +1862,97 @@ function initProject(root) {
1765
1862
  const post = (settings.hooks ??= {}).PostToolUse ??= [];
1766
1863
  if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
1767
1864
  else {
1768
- post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: HOOK }] });
1769
- mkdirSync3(dirname(settingsPath), { recursive: true });
1770
- writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1865
+ post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: hookCommand(root) }] });
1866
+ mkdirSync4(dirname(settingsPath), { recursive: true });
1867
+ writeFileSync4(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1771
1868
  done.push(`hook added to ${settingsPath}`);
1772
1869
  }
1773
1870
  for (const name of readdirSync3(SKILL_DIR)) {
1774
1871
  const skillPath = join9(root, ".claude", "skills", name, "SKILL.md");
1775
- if (existsSync2(skillPath)) done.push(`skill ${name} already present`);
1872
+ if (existsSync3(skillPath)) done.push(`skill ${name} already present`);
1776
1873
  else {
1777
- mkdirSync3(dirname(skillPath), { recursive: true });
1874
+ mkdirSync4(dirname(skillPath), { recursive: true });
1778
1875
  copyFileSync(join9(SKILL_DIR, name, "SKILL.md"), skillPath);
1779
1876
  done.push(`skill ${name} installed`);
1780
1877
  }
1781
1878
  }
1782
1879
  return done;
1783
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
+ }
1953
+
1954
+ // src/index.ts
1955
+ import { homedir } from "os";
1784
1956
 
1785
1957
  // src/patterns.ts
1786
1958
  var PATTERNS = {
@@ -1905,13 +2077,143 @@ After each batch, verify: \`fringe check ${target}\``);
1905
2077
  return out.join("\n");
1906
2078
  }
1907
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
+
1908
2209
  // src/index.ts
2210
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
1909
2211
  var log = (o) => o.log ?? ((s) => console.log(s));
1910
2212
  var err = (o) => o.err ?? ((s) => process.stderr.write(s));
1911
2213
  function readManifest(path2, warn) {
1912
2214
  let raw;
1913
2215
  try {
1914
- raw = readFileSync9(path2, "utf8");
2216
+ raw = readFileSync11(path2, "utf8");
1915
2217
  } catch {
1916
2218
  warn(`fringe: warning: manifest not found at ${path2} \u2014 continuing without
1917
2219
  `);
@@ -1938,7 +2240,7 @@ function resolveTarget(arg) {
1938
2240
  if (!statSync(arg).isFile()) return { root: arg };
1939
2241
  let dir = dirname2(arg);
1940
2242
  for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
1941
- 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"))) {
1942
2244
  dir = d;
1943
2245
  break;
1944
2246
  }
@@ -1946,27 +2248,46 @@ function resolveTarget(arg) {
1946
2248
  }
1947
2249
  return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
1948
2250
  }
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}`);
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);
1958
2269
  }
1959
- 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 } : {} };
2272
+ }
2273
+ function loadModes(rest) {
2274
+ const i = rest.indexOf("--modes");
2275
+ return i !== -1 ? parseModes(rest[i + 1] ?? "") : rest.includes("--stress") ? [...MODES] : [];
1960
2276
  }
1961
2277
  var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\/?a|none|tbd|obvious|unnecessary|clutter|unused)\.?$/i.test(e.trim());
1962
2278
  var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
1963
2279
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
1964
2280
  fringe check --update-baseline [path] accept today's findings; only new ones gate
1965
- fringe scan <url> [--states f] [--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
1966
2285
  fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
1967
2286
  fringe explain pattern [form] best-practice checklist for a component type
1968
2287
  fringe explain [FR-203] why a rule matters and how to fix it well
1969
- fringe init [path] add the Claude Code hook + skill
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
1970
2291
  fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
1971
2292
  fringe --help | --version`;
1972
2293
  var KNOWN_FLAGS = /* @__PURE__ */ new Set(["--json", "--hook", "--claude-hook", "--record", "--manifest", "--update-baseline"]);
@@ -1987,8 +2308,35 @@ function parseArgs(rest, warn) {
1987
2308
  }
1988
2309
  return paths;
1989
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
+ }
1990
2333
  async function runCli(argv, opts = {}) {
1991
2334
  const [cmd, ...rest] = argv;
2335
+ if (cmd === void 0) {
2336
+ const code = await runCli(["check"], opts);
2337
+ if (!isWired(opts.cwd ?? process.cwd(), opts.home ?? homedir())) log(opts)("\nfringe: not checking after your agent's edits yet. Run `npx @fringetools/cli init` (this project) or `init --global` (every project).");
2338
+ return code;
2339
+ }
1992
2340
  if (cmd === "--version" || cmd === "-v") {
1993
2341
  log(opts)(VERSION);
1994
2342
  return 0;
@@ -2039,13 +2387,20 @@ async function runCli(argv, opts = {}) {
2039
2387
  }
2040
2388
  if (cmd === "scan") {
2041
2389
  try {
2042
- const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--states");
2390
+ const url = rest.find((a, i) => !a.startsWith("--") && !["--states", "--modes", "--storage-state", "--screenshots"].includes(rest[i - 1]));
2043
2391
  if (!url) {
2044
2392
  err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
2045
2393
  return 2;
2046
2394
  }
2047
2395
  const cwd = opts.cwd ?? process.cwd();
2048
- const result = await scan(url, cwd, loadStates(cwd, 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
+ `);
2049
2404
  log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
2050
2405
  return result.counts.error > 0 ? 1 : 0;
2051
2406
  } catch (e) {
@@ -2054,29 +2409,59 @@ async function runCli(argv, opts = {}) {
2054
2409
  return 2;
2055
2410
  }
2056
2411
  }
2057
- if (cmd === "plan") {
2412
+ if (cmd === "rules") {
2058
2413
  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
- }
2065
- const li = rest.indexOf("--limit");
2066
- const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
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));
2068
- const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
2069
- const { root, only } = resolveTarget(arg);
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);
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;
2075
2430
  }
2076
- 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
+ `);
2077
2439
  return 0;
2078
2440
  } catch (e) {
2079
- 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}
2080
2465
  `);
2081
2466
  return 2;
2082
2467
  }
@@ -2103,7 +2488,19 @@ async function runCli(argv, opts = {}) {
2103
2488
  }
2104
2489
  if (cmd === "init") {
2105
2490
  try {
2106
- for (const line of initProject(resolve(opts.cwd ?? process.cwd(), rest[0] ?? "."))) log(opts)(`fringe: ${line}`);
2491
+ const global = rest.includes("--global") || rest.includes("-g");
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}`);
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)");
2107
2504
  return 0;
2108
2505
  } catch (e) {
2109
2506
  err(opts)(`fringe: ${e.message}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  interface CliOpts {
2
2
  log?: (s: string) => void;
3
3
  cwd?: string;
4
- err?: (s: string) => void;
4
+ err?: (s: string) => void; /** Home directory for `init --global` (tests override it). */
5
+ home?: string;
5
6
  }
6
7
  declare function runCli(argv: string[], opts?: CliOpts): Promise<number>;
7
8
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-JSWDIPHX.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.0",
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` in its place (e.g. `npx -y @fringetools/cli 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` in its place (e.g. `npx -y @fringetools/cli 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` 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.
@@ -15,9 +15,10 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
15
15
  | `/fringe-review` | Review a page or component: ranked findings with plain-English fixes |
16
16
  | `/fringe-audit` | Audit the whole repo: top rules and files, a fix order, offer a baseline |
17
17
  | `/fringe-debt` | List silenced findings (`fringe-ignore`, baseline) |
18
+ | `/fringe-setup` | Guided onboarding: wire the hook, offer a baseline, set critical paths |
18
19
  | `/fringe-help` | This reference |
19
20
 
20
- **Commands** (`fringe`, or `npx -y @fringetools/cli`)
21
+ **Commands** (`fringe`, or `npx -y @fringetools/cli@0.4.1`)
21
22
 
22
23
  | Command | What it does |
23
24
  |---|---|
@@ -26,6 +27,9 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
26
27
  | `check --update-baseline` | Accept today's findings; only new ones gate |
27
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`) |
28
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 |
29
33
  | `explain [FR-203]` | Why a rule matters and how to fix it well |
30
34
  | `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
31
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` in its place (e.g. `npx -y @fringetools/cli 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:
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: fringe-setup
3
+ description: Use when the user wants to set up or get started with Fringe, or asks why it is not checking their edits ("set up fringe", "get started", "install fringe", /fringe-setup). Guided onboarding: checks the environment, wires the hook, offers a baseline, and records the critical pages.
4
+ ---
5
+
6
+ # Fringe setup
7
+
8
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place. Ask before each step that changes a file, and say what it will change.
9
+
10
+ 1. **Environment.** Run `node -v` (needs 18 or newer). If it is missing or older, stop and tell the user to install Node first.
11
+ 2. **Where things stand.** Run `fringe check .` and report the counts. If it says it is not checking after edits, the hook is not wired.
12
+ 3. **Wire the hook (skip if the Fringe plugin is already installed, or the check above reported it is set up).** Ask: this project only, or every project? Then run `fringe init` or `fringe init --global`. Both add the post-edit hook and the skills without touching other settings.
13
+ 4. **Existing problems.** If there are more than a handful of errors, explain that `fringe check --update-baseline` records today's findings so only new ones block, and offer it. Do not run it without a yes. Remind the user to commit `fringe.baseline.json`.
14
+ 5. **What matters most.** Ask which folders are most important to users (checkout, sign-up, login). Add them to `fringe.config.json` as `"critical": ["src/checkout/**", ...]`, merging into the file if it exists, never overwriting other keys.
15
+ 6. **Optional.** Mention `fringe scan <url>` (needs `npm i -D playwright-core axe-core` and Chrome) for real contrast and keyboard checks; install nothing unless asked.
16
+ 7. **Finish** with one short summary of what was changed and the commands to remember: `/fringe-review`, `/fringe-audit`, `fringe plan`, `fringe explain FR-203`.
17
+
18
+ If anything fails, report the exact message; do not retry blindly or edit `.claude/settings.json` by hand.