@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 +35 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-7ZFEVMML.js → chunk-ZTIMW7ON.js} +386 -58
- package/dist/index.js +1 -1
- package/package.json +3 -2
- package/rules/fringe.md +23 -0
- package/skill/fringe/SKILL.md +1 -1
- package/skill/fringe-audit/SKILL.md +1 -1
- package/skill/fringe-debt/SKILL.md +1 -1
- package/skill/fringe-help/SKILL.md +4 -1
- package/skill/fringe-review/SKILL.md +1 -1
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,10 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { existsSync as
|
|
4
|
-
import { dirname as dirname2, join as
|
|
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.
|
|
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(
|
|
1699
|
-
const page = await
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1763
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 (
|
|
1855
|
+
if (existsSync3(settingsPath)) {
|
|
1813
1856
|
try {
|
|
1814
|
-
settings = JSON.parse(
|
|
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
|
-
|
|
1824
|
-
|
|
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 (
|
|
1872
|
+
if (existsSync3(skillPath)) done.push(`skill ${name} already present`);
|
|
1830
1873
|
else {
|
|
1831
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
2007
|
-
const
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
|
|
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
|
|
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("--") &&
|
|
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
|
|
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 === "
|
|
2412
|
+
if (cmd === "rules") {
|
|
2124
2413
|
try {
|
|
2125
|
-
const
|
|
2126
|
-
const
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
if (
|
|
2139
|
-
|
|
2140
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fringetools/cli",
|
|
3
|
-
"version": "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",
|
package/rules/fringe.md
ADDED
|
@@ -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
|
package/skill/fringe/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|