@fringetools/cli 0.4.1 → 0.6.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 +81 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-7ZFEVMML.js → chunk-JJI65ERU.js} +574 -92
- package/dist/index.d.ts +2 -1
- 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 +5 -1
- package/skill/fringe-review/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,11 @@ 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 login <url> [--out file]` | **Log in once, by hand, in a real browser window** and save the session (default `.fringe/auth.json`) for `scan --storage-state`. Works with MFA and SSO. The file holds live cookies: keep it out of git. |
|
|
27
|
+
| `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`. |
|
|
28
|
+
| `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. |
|
|
29
|
+
| `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
30
|
| `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
31
|
| `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 36 rules. |
|
|
28
32
|
| `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 +36,8 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
|
|
|
32
36
|
| `fringe check --update-baseline` | Accept today's findings into `fringe.baseline.json` (commit it). Exit `0`. |
|
|
33
37
|
| `fringe check --record [--manifest <path>]` | Also append this run to `.fringe/ledger.jsonl`, optionally merging an agent flow manifest. |
|
|
34
38
|
| `fringe` (no arguments) | Checks the current folder, and if the post-edit check is not set up yet, says how to set it up. |
|
|
39
|
+
| `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. |
|
|
40
|
+
| `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
41
|
| `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
42
|
| `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
43
|
| `fringe --help` · `--version` | Usage / version. |
|
|
@@ -103,8 +109,82 @@ npx @fringetools/cli scan http://localhost:3000 --modes dark,reflow # a sub
|
|
|
103
109
|
|
|
104
110
|
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
111
|
|
|
112
|
+
### Pages behind a login
|
|
113
|
+
|
|
114
|
+
Most apps hide the interesting pages behind a sign-in, and a scan that gets bounced to the login screen audits the wrong page. Three ways to be logged in; pick the one that fits.
|
|
115
|
+
|
|
116
|
+
**1. Log in once by hand (works with MFA, SSO, captchas).**
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
npx @fringetools/cli login http://localhost:3000/login # a Chrome window opens
|
|
120
|
+
# sign in as you normally would, then press Enter in the terminal (close the window afterwards)
|
|
121
|
+
npx @fringetools/cli scan http://localhost:3000/dashboard --storage-state .fringe/auth.json
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The session (cookies and local storage) is saved to `.fringe/auth.json`. It is a live login: **keep it out of git** (the command warns if `.gitignore` does not list `.fringe/`) and expect it to expire.
|
|
125
|
+
|
|
126
|
+
**2. A scripted login (for CI and agents).** Describe the steps in `fringe.scan.json`; credentials come from environment variables, so none are stored in the file:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"login": {
|
|
131
|
+
"url": "/login",
|
|
132
|
+
"steps": [
|
|
133
|
+
{ "fill": "#email", "value": "${env:FRINGE_EMAIL}" },
|
|
134
|
+
{ "fill": "#password", "value": "${env:FRINGE_PASSWORD}" },
|
|
135
|
+
{ "click": "button:has-text(\"Sign in\")" },
|
|
136
|
+
{ "waitFor": "[data-test=dashboard]" }
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
"states": [ { "name": "settings", "steps": [ { "goto": "/settings" } ] } ]
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Use a **test account**, not a real user. A missing variable stops the scan before any browser opens, naming the variable and never its value; messages show the step with the `${env:...}` placeholder, not the secret. Prefer specific selectors: `text=Sign in` can match the page heading instead of the button.
|
|
144
|
+
|
|
145
|
+
**3. Several roles in one scan.** List sessions; each runs the whole scan (initial page, states, stress modes) and findings are labelled `[as admin]`, `[as member]`:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{ "sessions": [
|
|
149
|
+
{ "name": "member", "login": { "url": "/login", "steps": [ ... ] } },
|
|
150
|
+
{ "name": "admin", "storageState": "admin-auth.json" }
|
|
151
|
+
] }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**What you will see when something is off**
|
|
155
|
+
- `FR-405` "the scan was sent to /login, which looks like a login page": the session is missing or expired. Everything else in that run describes the login screen, not your app, so refresh the session first.
|
|
156
|
+
- `FR-402` "login failed at step N": a selector did not match or the page did not reach the expected state. The step is shown; fix it in `fringe.scan.json`.
|
|
157
|
+
|
|
158
|
+
### Screenshots and a baseline for scan findings
|
|
159
|
+
|
|
160
|
+
- **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.
|
|
161
|
+
- **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.
|
|
162
|
+
|
|
106
163
|
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
164
|
|
|
165
|
+
## Agent hand-off
|
|
166
|
+
|
|
167
|
+
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.
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
fringe handoff --scan http://localhost:3000 > prompt.md # the work order; also saved as .fringe/handoff.json
|
|
171
|
+
claude -p "$(cat prompt.md)" # or any agent that takes a prompt
|
|
172
|
+
fringe verify # exit 0 only when complete; --json for the orchestrator
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
## Using Fringe with other coding agents
|
|
178
|
+
|
|
179
|
+
| Layer | Works with | Notes |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| **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. |
|
|
182
|
+
| **Git pre-commit gate** (`init --git`) | Any agent that runs `git commit` | The hook output goes to the agent's terminal. Fails open. |
|
|
183
|
+
| **CLI, `plan`, `handoff`/`verify`, CI** | Everything | Plain commands, exit codes and JSON. |
|
|
184
|
+
| **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. |
|
|
185
|
+
|
|
186
|
+
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.
|
|
187
|
+
|
|
108
188
|
## Priority, clusters and fix plans
|
|
109
189
|
|
|
110
190
|
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
|
|
3
|
+
import { existsSync as existsSync5, readFileSync as readFileSync11, statSync } from "fs";
|
|
4
|
+
import { dirname as dirname3, join as join12, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var VERSION = "0.
|
|
7
|
+
var VERSION = "0.6.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+)$/;
|
|
@@ -1640,31 +1641,61 @@ function tabWalkFindings(url, focusables, visits) {
|
|
|
1640
1641
|
return out;
|
|
1641
1642
|
}
|
|
1642
1643
|
var STEP_KEYS = ["click", "fill", "press", "hover", "waitFor", "goto"];
|
|
1644
|
+
var bad = (m) => {
|
|
1645
|
+
throw new Error(`fringe.scan.json: ${m}`);
|
|
1646
|
+
};
|
|
1647
|
+
function checkSteps(steps, where2) {
|
|
1648
|
+
if (!Array.isArray(steps) || steps.length === 0) return bad(`${where2} needs a non-empty "steps" list`);
|
|
1649
|
+
for (const [k, step] of steps.entries()) {
|
|
1650
|
+
const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
|
|
1651
|
+
if (keys.length !== 1 || typeof step[keys[0]] !== "string") bad(`${where2} step ${k + 1} must be exactly one of ${STEP_KEYS.join(", ")} with a string value`);
|
|
1652
|
+
if (keys[0] === "fill" && typeof step.value !== "string") bad(`${where2} step ${k + 1}: fill needs a "value"`);
|
|
1653
|
+
}
|
|
1654
|
+
return steps;
|
|
1655
|
+
}
|
|
1643
1656
|
function parseStates(json) {
|
|
1644
|
-
const
|
|
1645
|
-
|
|
1646
|
-
};
|
|
1647
|
-
|
|
1648
|
-
if (!Array.isArray(states)) return bad('expected { "states": [ ... ] }');
|
|
1649
|
-
return states.map((s, i) => {
|
|
1657
|
+
const o = json;
|
|
1658
|
+
if (o?.states === void 0 && (o?.login !== void 0 || o?.sessions !== void 0 || o?.storageState !== void 0)) return [];
|
|
1659
|
+
if (!Array.isArray(o?.states)) return bad('expected { "states": [ ... ] }');
|
|
1660
|
+
return o.states.map((s, i) => {
|
|
1650
1661
|
if (typeof s?.name !== "string" || !s.name.trim()) bad(`states[${i}] needs a "name"`);
|
|
1651
|
-
|
|
1652
|
-
for (const [k, step] of s.steps.entries()) {
|
|
1653
|
-
const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
|
|
1654
|
-
if (keys.length !== 1 || typeof step[keys[0]] !== "string") bad(`state "${s.name}" step ${k + 1} must be exactly one of ${STEP_KEYS.join(", ")} with a string value`);
|
|
1655
|
-
if (keys[0] === "fill" && typeof step.value !== "string") bad(`state "${s.name}" step ${k + 1}: fill needs a "value"`);
|
|
1656
|
-
}
|
|
1657
|
-
return { name: s.name.trim(), steps: s.steps };
|
|
1662
|
+
return { name: s.name.trim(), steps: checkSteps(s.steps, `state "${s.name}"`) };
|
|
1658
1663
|
});
|
|
1659
1664
|
}
|
|
1665
|
+
function parseSessions(json, baseDir) {
|
|
1666
|
+
const o = json ?? {};
|
|
1667
|
+
const one = (x, where2, name) => {
|
|
1668
|
+
if (x?.storageState !== void 0 && typeof x.storageState !== "string") bad(`${where2}: storageState must be a file path`);
|
|
1669
|
+
if (x?.login !== void 0 && typeof x.login?.url !== "undefined" && typeof x.login.url !== "string") bad(`${where2}: login.url must be a string`);
|
|
1670
|
+
return { ...name ? { name } : {}, ...x?.storageState ? { storageState: join8(baseDir, x.storageState) } : {}, ...x?.login ? { login: { ...x.login.url ? { url: x.login.url } : {}, steps: checkSteps(x.login.steps, `${where2} login`) } } : {} };
|
|
1671
|
+
};
|
|
1672
|
+
const out = [];
|
|
1673
|
+
if (o.login !== void 0 || o.storageState !== void 0) out.push(one(o, "top level"));
|
|
1674
|
+
if (o.sessions !== void 0) {
|
|
1675
|
+
if (!Array.isArray(o.sessions) || o.sessions.length === 0) bad('"sessions" must be a non-empty list');
|
|
1676
|
+
for (const [i, s] of o.sessions.entries()) {
|
|
1677
|
+
if (typeof s?.name !== "string" || !s.name.trim()) bad(`sessions[${i}] needs a "name"`);
|
|
1678
|
+
if (!s.storageState && !s.login) bad(`session "${s.name}" needs a "storageState" or a "login"`);
|
|
1679
|
+
out.push(one(s, `session "${s.name}"`, s.name.trim()));
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return out;
|
|
1683
|
+
}
|
|
1684
|
+
var ENV_REF = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
1685
|
+
var expandEnv = (s) => s.replace(ENV_REF, (_m, n) => {
|
|
1686
|
+
const v = process.env[n];
|
|
1687
|
+
if (v === void 0) throw new Error(`environment variable ${n} is not set (named in fringe.scan.json)`);
|
|
1688
|
+
return v;
|
|
1689
|
+
});
|
|
1690
|
+
var envNames = (steps) => steps.flatMap((st) => ("fill" in st ? [st.value] : "goto" in st ? [st.goto] : []).flatMap((v) => [...v.matchAll(ENV_REF)].map((m) => m[1])));
|
|
1660
1691
|
async function runStep(page, step, base) {
|
|
1661
1692
|
const t = { timeout: 1e4 };
|
|
1662
1693
|
if ("click" in step) await page.click(step.click, t);
|
|
1663
|
-
else if ("fill" in step) await page.fill(step.fill, step.value, t);
|
|
1694
|
+
else if ("fill" in step) await page.fill(step.fill, expandEnv(step.value), t);
|
|
1664
1695
|
else if ("press" in step) await page.keyboard.press(step.press);
|
|
1665
1696
|
else if ("hover" in step) await page.hover(step.hover, t);
|
|
1666
1697
|
else if ("waitFor" in step) await page.waitForSelector(step.waitFor, t);
|
|
1667
|
-
else await page.goto(new URL(step.goto, base).href, { waitUntil: "load", timeout: 3e4 });
|
|
1698
|
+
else await page.goto(new URL(expandEnv(step.goto), base).href, { waitUntil: "load", timeout: 3e4 });
|
|
1668
1699
|
await page.waitForTimeout(150);
|
|
1669
1700
|
}
|
|
1670
1701
|
async function audit(page, axePath, url) {
|
|
@@ -1682,12 +1713,12 @@ async function audit(page, axePath, url) {
|
|
|
1682
1713
|
}
|
|
1683
1714
|
return [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
|
|
1684
1715
|
}
|
|
1685
|
-
var inState = (name, url, f) => ({ ...f, message: `[${name}] ${f.message}`, loc: { ...f.loc, file: `${url} [${name}]` }, ...f.cluster ? { cluster: `${f.cluster} [${name}]` } : {} });
|
|
1716
|
+
var inState = (name, url, f) => ({ ...f, message: `[${name}] ${f.message}`, loc: { ...f.loc, file: `${f.loc.file === url ? url : f.loc.file} [${name}]` }, ...f.cluster ? { cluster: `${f.cluster} [${name}]` } : {} });
|
|
1686
1717
|
var MODES = ["dark", "reduced", "reflow"];
|
|
1687
1718
|
function parseModes(list) {
|
|
1688
1719
|
const out = list.split(",").map((m) => m.trim()).filter(Boolean);
|
|
1689
|
-
const
|
|
1690
|
-
if (
|
|
1720
|
+
const bad2 = out.find((m) => !MODES.includes(m));
|
|
1721
|
+
if (bad2 || out.length === 0) throw new Error(`unknown scan mode '${bad2 ?? ""}': use ${MODES.join(", ")}`);
|
|
1691
1722
|
return out;
|
|
1692
1723
|
}
|
|
1693
1724
|
function onlyNew(base, extra) {
|
|
@@ -1695,36 +1726,106 @@ function onlyNew(base, extra) {
|
|
|
1695
1726
|
return extra.filter((f) => !seen.has(`${f.rule}|${f.message}`));
|
|
1696
1727
|
}
|
|
1697
1728
|
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
|
|
1729
|
+
async function stress(context, axePath, url, mode, base, shot) {
|
|
1730
|
+
const page = await context.newPage();
|
|
1700
1731
|
try {
|
|
1701
|
-
return await stressOn(page, axePath, url, mode, base);
|
|
1732
|
+
return await stressOn(page, axePath, url, mode, base, shot);
|
|
1702
1733
|
} finally {
|
|
1703
1734
|
await page.close();
|
|
1704
1735
|
}
|
|
1705
1736
|
}
|
|
1706
|
-
async function stressOn(page, axePath, url, mode, base) {
|
|
1737
|
+
async function stressOn(page, axePath, url, mode, base, shot) {
|
|
1707
1738
|
const at0 = { file: `${url} [${mode}]`, line: 1, col: 0 };
|
|
1708
1739
|
await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1709
1740
|
if (mode === "dark") {
|
|
1710
1741
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
1711
1742
|
await page.waitForTimeout(150);
|
|
1743
|
+
await shot(page, "mode-dark");
|
|
1712
1744
|
return onlyNew(base, await audit(page, axePath, url)).map((f) => inState("dark mode", url, f));
|
|
1713
1745
|
}
|
|
1714
1746
|
if (mode === "reduced") {
|
|
1715
1747
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
1716
1748
|
await page.waitForTimeout(300);
|
|
1749
|
+
await shot(page, "mode-reduced");
|
|
1717
1750
|
const loops = await page.evaluate(countRunningLoops);
|
|
1718
1751
|
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
1752
|
}
|
|
1720
1753
|
await page.setViewportSize({ width: 320, height: 256 });
|
|
1721
1754
|
await page.waitForTimeout(250);
|
|
1755
|
+
await shot(page, "mode-reflow");
|
|
1722
1756
|
const wide = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
1723
1757
|
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
1758
|
return [...out, ...onlyNew(base, await audit(page, axePath, url)).map((f) => inState("reflow", url, f))];
|
|
1725
1759
|
}
|
|
1726
|
-
|
|
1760
|
+
var baselinePath = (cwd) => join8(cwd, "fringe.scan.baseline.json");
|
|
1761
|
+
var scanKey = (f) => `${f.rule}|${f.loc.file}|${f.message.split(": ")[0]}`;
|
|
1762
|
+
function applyScanBaseline(cwd, findings) {
|
|
1763
|
+
if (!existsSync2(baselinePath(cwd))) return { fresh: findings, known: 0 };
|
|
1764
|
+
let keys;
|
|
1765
|
+
try {
|
|
1766
|
+
keys = JSON.parse(readFileSync8(baselinePath(cwd), "utf8"));
|
|
1767
|
+
if (!Array.isArray(keys)) throw 0;
|
|
1768
|
+
} catch {
|
|
1769
|
+
throw new Error("fringe.scan.baseline.json is not a JSON array: rerun with --update-baseline");
|
|
1770
|
+
}
|
|
1771
|
+
const left = /* @__PURE__ */ new Map();
|
|
1772
|
+
for (const k of keys) left.set(k, (left.get(k) ?? 0) + 1);
|
|
1773
|
+
const fresh = findings.filter((f) => {
|
|
1774
|
+
const k = scanKey(f), n = left.get(k) ?? 0;
|
|
1775
|
+
if (n > 0) {
|
|
1776
|
+
left.set(k, n - 1);
|
|
1777
|
+
return false;
|
|
1778
|
+
}
|
|
1779
|
+
return true;
|
|
1780
|
+
});
|
|
1781
|
+
return { fresh, known: findings.length - fresh.length };
|
|
1782
|
+
}
|
|
1783
|
+
var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "page";
|
|
1784
|
+
var LOGINISH = /log-?in|sign-?in|sso|auth/i;
|
|
1785
|
+
var firstLine = (e) => String(e.message).split("\n")[0].slice(0, 120);
|
|
1786
|
+
async function loginRedirect(page, requested, who) {
|
|
1787
|
+
let now, want;
|
|
1788
|
+
try {
|
|
1789
|
+
now = new URL(page.url());
|
|
1790
|
+
want = new URL(requested);
|
|
1791
|
+
} catch {
|
|
1792
|
+
return [];
|
|
1793
|
+
}
|
|
1794
|
+
if (now.pathname === want.pathname) return [];
|
|
1795
|
+
const hasPassword = await page.evaluate(() => !!document.querySelector("input[type=password]"));
|
|
1796
|
+
if (!LOGINISH.test(now.pathname) && !hasPassword) return [];
|
|
1797
|
+
return [{ rule: "FR-405", severity: "review", message: `the scan was sent to ${now.pathname}, which looks like a login page: ${who ? `the "${who}" session may have expired or the login failed` : "this page needs a login"}`, hint: 'save a session with `fringe login <url>` or add a "login" block to fringe.scan.json; findings for this page describe the login screen, not your app', loc: { file: requested, line: 1, col: 0 } }];
|
|
1798
|
+
}
|
|
1799
|
+
async function auditContext(context, axePath, url, opts, who, shot) {
|
|
1800
|
+
const { states = [], modes = [] } = opts;
|
|
1801
|
+
const page = await context.newPage();
|
|
1802
|
+
await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1803
|
+
await shot(page, "initial");
|
|
1804
|
+
const all = [...await loginRedirect(page, url, who), ...await audit(page, axePath, url)];
|
|
1805
|
+
const plain = [...all];
|
|
1806
|
+
for (const st of states) {
|
|
1807
|
+
await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1808
|
+
let step = 0;
|
|
1809
|
+
try {
|
|
1810
|
+
for (; step < st.steps.length; step++) await runStep(page, st.steps[step], url);
|
|
1811
|
+
} catch (e) {
|
|
1812
|
+
all.push({ rule: "FR-402", severity: "review", message: `[${st.name}] state could not be reached: step ${step + 1} ${JSON.stringify(st.steps[step])} failed (${firstLine(e)})`, 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 } });
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
await shot(page, `state-${st.name}`);
|
|
1816
|
+
all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
|
|
1817
|
+
}
|
|
1818
|
+
for (const m of modes) all.push(...await stress(context, axePath, url, m, plain, shot));
|
|
1819
|
+
return all;
|
|
1820
|
+
}
|
|
1821
|
+
async function scan(url, cwd = process.cwd(), opts = {}) {
|
|
1822
|
+
const { states = [], modes = [] } = opts;
|
|
1727
1823
|
if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
|
|
1824
|
+
const sessions = opts.sessions?.length ? opts.sessions : [opts.storageState ? { storageState: opts.storageState } : {}];
|
|
1825
|
+
for (const n of /* @__PURE__ */ new Set([...sessions.flatMap((s) => envNames(s.login?.steps ?? [])), ...states.flatMap((s) => envNames(s.steps))])) {
|
|
1826
|
+
if (process.env[n] === void 0) throw new Error(`environment variable ${n} is not set (named in fringe.scan.json)`);
|
|
1827
|
+
}
|
|
1828
|
+
for (const s of sessions) if (s.storageState && !existsSync2(s.storageState)) throw new Error(`storage state file not found: ${s.storageState} (create one with: fringe login ${url})`);
|
|
1728
1829
|
const req = createRequire(join8(cwd, "noop.js"));
|
|
1729
1830
|
let playwright, axePath;
|
|
1730
1831
|
try {
|
|
@@ -1744,26 +1845,46 @@ async function scan(url, cwd = process.cwd(), states = [], modes = []) {
|
|
|
1744
1845
|
}
|
|
1745
1846
|
}
|
|
1746
1847
|
try {
|
|
1747
|
-
const
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1848
|
+
const all = [];
|
|
1849
|
+
for (const s of sessions) {
|
|
1850
|
+
const who = s.name, label = who ? `as ${who}` : void 0;
|
|
1851
|
+
const shot = async (page, name) => {
|
|
1852
|
+
if (!opts.screenshots) return;
|
|
1853
|
+
mkdirSync3(opts.screenshots, { recursive: true });
|
|
1854
|
+
await page.screenshot({ path: join8(opts.screenshots, `${slug(who ? `${who}-${name}` : name)}.png`), fullPage: true });
|
|
1855
|
+
};
|
|
1856
|
+
const context = await browser.newContext(s.storageState ? { storageState: s.storageState } : {});
|
|
1754
1857
|
try {
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1858
|
+
let found;
|
|
1859
|
+
if (s.login) {
|
|
1860
|
+
const lp = await context.newPage();
|
|
1861
|
+
let step = 0;
|
|
1862
|
+
try {
|
|
1863
|
+
await lp.goto(new URL(s.login.url ?? url, url).href, { waitUntil: "load", timeout: 3e4 });
|
|
1864
|
+
for (; step < s.login.steps.length; step++) await runStep(lp, s.login.steps[step], url);
|
|
1865
|
+
found = await auditContext(context, axePath, url, opts, who, shot);
|
|
1866
|
+
} catch (e) {
|
|
1867
|
+
found = [{ rule: "FR-402", severity: "review", message: `login${who ? ` as ${who}` : ""} failed at step ${step + 1} ${JSON.stringify(s.login.steps[step] ?? {})} (${firstLine(e)})`, hint: "check the login steps and selectors in fringe.scan.json; nothing was scanned for this session", loc: { file: url, line: 1, col: 0 } }];
|
|
1868
|
+
} finally {
|
|
1869
|
+
await lp.close().catch(() => {
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
} else found = await auditContext(context, axePath, url, opts, who, shot);
|
|
1873
|
+
all.push(...label ? found.map((f) => inState(label, url, f)) : found);
|
|
1874
|
+
} finally {
|
|
1875
|
+
await context.close();
|
|
1759
1876
|
}
|
|
1760
|
-
all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
|
|
1761
1877
|
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1878
|
+
const files = sessions.length * (1 + states.length + modes.length);
|
|
1879
|
+
if (opts.updateBaseline) {
|
|
1880
|
+
writeFileSync3(baselinePath(cwd), JSON.stringify(all.map(scanKey).sort(), null, 2) + "\n");
|
|
1881
|
+
return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files, skipped: [], findings: [], clusters: [], counts: { error: 0, review: 0, signal: 0 }, score: 100, baselined: all.length };
|
|
1882
|
+
}
|
|
1883
|
+
const { fresh, known } = applyScanBaseline(cwd, all);
|
|
1884
|
+
const { findings, clusters } = rankScan(url, fresh);
|
|
1764
1885
|
const counts = { error: 0, review: 0, signal: 0 };
|
|
1765
1886
|
for (const f of findings) counts[f.severity]++;
|
|
1766
|
-
return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files
|
|
1887
|
+
return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1), ...known ? { baselined: known } : {} };
|
|
1767
1888
|
} finally {
|
|
1768
1889
|
await browser.close();
|
|
1769
1890
|
}
|
|
@@ -1784,22 +1905,74 @@ function rankScan(url, findings) {
|
|
|
1784
1905
|
}
|
|
1785
1906
|
function mergeScan(base, scanned, weights) {
|
|
1786
1907
|
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) };
|
|
1908
|
+
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
1909
|
}
|
|
1789
1910
|
|
|
1790
|
-
// src/
|
|
1791
|
-
import {
|
|
1911
|
+
// src/login.ts
|
|
1912
|
+
import { createRequire as createRequire2 } from "module";
|
|
1913
|
+
import { mkdirSync as mkdirSync4 } from "fs";
|
|
1792
1914
|
import { dirname, join as join9 } from "path";
|
|
1915
|
+
import { createInterface } from "readline";
|
|
1916
|
+
async function saveSession(url, cwd, out, o = {}) {
|
|
1917
|
+
if (!/^https?:\/\//i.test(url)) throw new Error(`fringe login takes an http(s):// URL, got '${url}'`);
|
|
1918
|
+
const req = createRequire2(join9(cwd, "noop.js"));
|
|
1919
|
+
let playwright;
|
|
1920
|
+
try {
|
|
1921
|
+
playwright = req("playwright-core");
|
|
1922
|
+
} catch {
|
|
1923
|
+
throw new Error(SCAN_HELP);
|
|
1924
|
+
}
|
|
1925
|
+
let browser;
|
|
1926
|
+
const headless = o.headless ?? false;
|
|
1927
|
+
try {
|
|
1928
|
+
browser = await playwright.chromium.launch({ channel: "chrome", headless });
|
|
1929
|
+
} catch {
|
|
1930
|
+
try {
|
|
1931
|
+
browser = await playwright.chromium.launch({ headless });
|
|
1932
|
+
} catch {
|
|
1933
|
+
throw new Error(SCAN_HELP);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
try {
|
|
1937
|
+
const context = await browser.newContext();
|
|
1938
|
+
const page = await context.newPage();
|
|
1939
|
+
await page.goto(url, { waitUntil: "load", timeout: 6e4 });
|
|
1940
|
+
if (o.wait) await o.wait();
|
|
1941
|
+
else {
|
|
1942
|
+
process.stderr.write("fringe: log in in the browser window, then come back here and press Enter. Close the window afterwards, not before.\n");
|
|
1943
|
+
await new Promise((ok, fail) => {
|
|
1944
|
+
const rl = createInterface({ input: process.stdin });
|
|
1945
|
+
rl.once("line", () => {
|
|
1946
|
+
rl.close();
|
|
1947
|
+
ok();
|
|
1948
|
+
});
|
|
1949
|
+
browser.once("disconnected", () => {
|
|
1950
|
+
rl.close();
|
|
1951
|
+
fail(new Error("the browser was closed before the session was saved: press Enter in this terminal first, then close the window"));
|
|
1952
|
+
});
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
mkdirSync4(dirname(out), { recursive: true });
|
|
1956
|
+
await context.storageState({ path: out });
|
|
1957
|
+
} finally {
|
|
1958
|
+
await browser.close().catch(() => {
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// src/init.ts
|
|
1964
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync9, readdirSync as readdirSync3, writeFileSync as writeFileSync4, copyFileSync } from "fs";
|
|
1965
|
+
import { dirname as dirname2, join as join10 } from "path";
|
|
1793
1966
|
import { fileURLToPath } from "url";
|
|
1794
1967
|
var HOOK = "npx -y @fringetools/cli check --claude-hook";
|
|
1795
1968
|
var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
|
|
1796
1969
|
function hookCommand(root) {
|
|
1797
|
-
return
|
|
1970
|
+
return existsSync3(join10(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
|
|
1798
1971
|
}
|
|
1799
1972
|
function isWired(root, home) {
|
|
1800
1973
|
for (const dir of [root, home]) {
|
|
1801
1974
|
try {
|
|
1802
|
-
if (/claude-hook|fringe(tool)?@/.test(
|
|
1975
|
+
if (/claude-hook|fringe(tool)?@/.test(readFileSync9(join10(dir, ".claude", "settings.json"), "utf8"))) return true;
|
|
1803
1976
|
} catch {
|
|
1804
1977
|
}
|
|
1805
1978
|
}
|
|
@@ -1807,11 +1980,11 @@ function isWired(root, home) {
|
|
|
1807
1980
|
}
|
|
1808
1981
|
function initProject(root) {
|
|
1809
1982
|
const done = [];
|
|
1810
|
-
const settingsPath =
|
|
1983
|
+
const settingsPath = join10(root, ".claude", "settings.json");
|
|
1811
1984
|
let settings = {};
|
|
1812
|
-
if (
|
|
1985
|
+
if (existsSync3(settingsPath)) {
|
|
1813
1986
|
try {
|
|
1814
|
-
settings = JSON.parse(
|
|
1987
|
+
settings = JSON.parse(readFileSync9(settingsPath, "utf8"));
|
|
1815
1988
|
} catch {
|
|
1816
1989
|
throw new Error(`${settingsPath} is not valid JSON \u2014 fix it and rerun`);
|
|
1817
1990
|
}
|
|
@@ -1820,21 +1993,93 @@ function initProject(root) {
|
|
|
1820
1993
|
if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
|
|
1821
1994
|
else {
|
|
1822
1995
|
post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: hookCommand(root) }] });
|
|
1823
|
-
|
|
1824
|
-
|
|
1996
|
+
mkdirSync5(dirname2(settingsPath), { recursive: true });
|
|
1997
|
+
writeFileSync4(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1825
1998
|
done.push(`hook added to ${settingsPath}`);
|
|
1826
1999
|
}
|
|
1827
2000
|
for (const name of readdirSync3(SKILL_DIR)) {
|
|
1828
|
-
const skillPath =
|
|
1829
|
-
if (
|
|
2001
|
+
const skillPath = join10(root, ".claude", "skills", name, "SKILL.md");
|
|
2002
|
+
if (existsSync3(skillPath)) done.push(`skill ${name} already present`);
|
|
1830
2003
|
else {
|
|
1831
|
-
|
|
1832
|
-
copyFileSync(
|
|
2004
|
+
mkdirSync5(dirname2(skillPath), { recursive: true });
|
|
2005
|
+
copyFileSync(join10(SKILL_DIR, name, "SKILL.md"), skillPath);
|
|
1833
2006
|
done.push(`skill ${name} installed`);
|
|
1834
2007
|
}
|
|
1835
2008
|
}
|
|
1836
2009
|
return done;
|
|
1837
2010
|
}
|
|
2011
|
+
var RULES_FILE = fileURLToPath(new URL("../rules/fringe.md", import.meta.url));
|
|
2012
|
+
var AGENT_FILES = {
|
|
2013
|
+
codex: { path: "AGENTS.md" },
|
|
2014
|
+
generic: { path: "AGENTS.md" },
|
|
2015
|
+
amp: { path: "AGENTS.md" },
|
|
2016
|
+
jules: { path: "AGENTS.md" },
|
|
2017
|
+
zed: { path: "AGENTS.md" },
|
|
2018
|
+
gemini: { path: "GEMINI.md" },
|
|
2019
|
+
cursor: { path: ".cursor/rules/fringe.mdc", wrap: (b) => `---
|
|
2020
|
+
description: Fringe: check and fix UX/accessibility problems after editing frontend files
|
|
2021
|
+
alwaysApply: true
|
|
2022
|
+
---
|
|
2023
|
+
|
|
2024
|
+
${b}` },
|
|
2025
|
+
copilot: { path: ".github/copilot-instructions.md" },
|
|
2026
|
+
windsurf: { path: ".windsurf/rules/fringe.md" },
|
|
2027
|
+
cline: { path: ".clinerules/fringe.md" },
|
|
2028
|
+
kiro: { path: ".kiro/steering/fringe.md" }
|
|
2029
|
+
};
|
|
2030
|
+
var AGENTS = Object.keys(AGENT_FILES);
|
|
2031
|
+
var MARK2 = "<!-- fringe:rules -->";
|
|
2032
|
+
function initFor(root, agents) {
|
|
2033
|
+
const body = readFileSync9(RULES_FILE, "utf8");
|
|
2034
|
+
const done = [];
|
|
2035
|
+
for (const a of agents) {
|
|
2036
|
+
const spec = AGENT_FILES[a];
|
|
2037
|
+
if (!spec) throw new Error(`unknown agent '${a}': use ${AGENTS.join(", ")} (or 'claude' for the hook + skills)`);
|
|
2038
|
+
const file2 = join10(root, spec.path);
|
|
2039
|
+
const text = spec.wrap ? spec.wrap(body) : body;
|
|
2040
|
+
if (existsSync3(file2)) {
|
|
2041
|
+
const cur = readFileSync9(file2, "utf8");
|
|
2042
|
+
if (cur.includes(MARK2) || cur.includes("# Fringe: handle your edges")) {
|
|
2043
|
+
done.push(`${spec.path} already has the Fringe rules`);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
writeFileSync4(file2, `${cur.replace(/\n*$/, "\n")}
|
|
2047
|
+
${MARK2}
|
|
2048
|
+
${spec.wrap ? body : text}`);
|
|
2049
|
+
done.push(`rules appended to ${spec.path}`);
|
|
2050
|
+
} else {
|
|
2051
|
+
mkdirSync5(dirname2(file2), { recursive: true });
|
|
2052
|
+
writeFileSync4(file2, text.replace("# Fringe: handle your edges", `${MARK2}
|
|
2053
|
+
# Fringe: handle your edges`));
|
|
2054
|
+
done.push(`${spec.path} created`);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
return done;
|
|
2058
|
+
}
|
|
2059
|
+
function initGit(root) {
|
|
2060
|
+
const dir = join10(root, ".git", "hooks");
|
|
2061
|
+
if (!existsSync3(join10(root, ".git"))) throw new Error("not a git repository (no .git here)");
|
|
2062
|
+
const file2 = join10(dir, "pre-commit");
|
|
2063
|
+
const local = existsSync3(join10(root, "node_modules", "@fringetools", "cli", "package.json"));
|
|
2064
|
+
const cmd = local ? "npx --no-install fringe check" : "npx -y @fringetools/cli check";
|
|
2065
|
+
const script = `#!/bin/sh
|
|
2066
|
+
# fringe pre-commit: blocks the commit while error-level UX/accessibility problems remain
|
|
2067
|
+
out=$(${cmd} 2>&1); code=$?
|
|
2068
|
+
printf '%s\\n' "$out"
|
|
2069
|
+
if [ "$code" -eq 1 ] && printf '%s' "$out" | grep -q '^\\[ERROR\\]'; then
|
|
2070
|
+
echo "fringe: fix the errors above (or record them with: fringe check --update-baseline) and commit again." >&2
|
|
2071
|
+
exit 1
|
|
2072
|
+
fi
|
|
2073
|
+
exit 0
|
|
2074
|
+
`;
|
|
2075
|
+
if (existsSync3(file2)) {
|
|
2076
|
+
if (readFileSync9(file2, "utf8").includes("# fringe pre-commit")) return ["pre-commit hook already present"];
|
|
2077
|
+
throw new Error(`${file2} already exists and is not Fringe's: add 'npx -y @fringetools/cli check || exit 1' to it yourself`);
|
|
2078
|
+
}
|
|
2079
|
+
mkdirSync5(dir, { recursive: true });
|
|
2080
|
+
writeFileSync4(file2, script, { mode: 493 });
|
|
2081
|
+
return [`pre-commit hook installed at ${file2}`];
|
|
2082
|
+
}
|
|
1838
2083
|
|
|
1839
2084
|
// src/index.ts
|
|
1840
2085
|
import { homedir } from "os";
|
|
@@ -1962,13 +2207,143 @@ After each batch, verify: \`fringe check ${target}\``);
|
|
|
1962
2207
|
return out.join("\n");
|
|
1963
2208
|
}
|
|
1964
2209
|
|
|
2210
|
+
// src/rulereport.ts
|
|
2211
|
+
import { existsSync as existsSync4, readFileSync as readFileSync10 } from "fs";
|
|
2212
|
+
import { join as join11 } from "path";
|
|
2213
|
+
async function ruleReport(root) {
|
|
2214
|
+
const cfg = loadConfig(root);
|
|
2215
|
+
const result = await runCheck(root, cfg, false);
|
|
2216
|
+
const baselinedBy = /* @__PURE__ */ new Map();
|
|
2217
|
+
const bl = join11(root, "fringe.baseline.json");
|
|
2218
|
+
if (existsSync4(bl)) {
|
|
2219
|
+
try {
|
|
2220
|
+
for (const k of JSON.parse(readFileSync10(bl, "utf8"))) baselinedBy.set(k.split("|")[0], (baselinedBy.get(k.split("|")[0]) ?? 0) + 1);
|
|
2221
|
+
} catch {
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const silencedBy = /* @__PURE__ */ new Map();
|
|
2225
|
+
for (const f of walk(root, cfg)) {
|
|
2226
|
+
let src = "";
|
|
2227
|
+
try {
|
|
2228
|
+
src = readFileSync10(f, "utf8");
|
|
2229
|
+
} catch {
|
|
2230
|
+
continue;
|
|
2231
|
+
}
|
|
2232
|
+
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);
|
|
2233
|
+
}
|
|
2234
|
+
const rawBy = /* @__PURE__ */ new Map();
|
|
2235
|
+
for (const f of result.findings) rawBy.set(f.rule, (rawBy.get(f.rule) ?? 0) + 1);
|
|
2236
|
+
const { records } = readLedger(root);
|
|
2237
|
+
const first = records[0]?.byRule, last = records[records.length - 1]?.byRule;
|
|
2238
|
+
return ALL_RULES.map((r) => {
|
|
2239
|
+
const baselined = Math.min(baselinedBy.get(r.id) ?? 0, rawBy.get(r.id) ?? 0), silenced = silencedBy.get(r.id) ?? 0;
|
|
2240
|
+
const open = (rawBy.get(r.id) ?? 0) - baselined;
|
|
2241
|
+
const trend = records.length >= 2 && first && last ? `${first[r.id] ?? 0} -> ${last[r.id] ?? 0}` : void 0;
|
|
2242
|
+
return { rule: r.id, severity: r.severity, open, baselined, silenced, overridden: baselined + silenced, ...trend ? { trend } : {} };
|
|
2243
|
+
}).filter((x) => x.open + x.baselined + x.silenced > 0).sort((a, b) => b.overridden - a.overridden || b.open - a.open);
|
|
2244
|
+
}
|
|
2245
|
+
function rulesToText(rows) {
|
|
2246
|
+
if (rows.length === 0) return "No findings, baselined entries or suppressions: nothing to report per rule.";
|
|
2247
|
+
const hasTrend = rows.some((r) => r.trend);
|
|
2248
|
+
const head = ["rule", "severity", "open", "baselined", "silenced", ...hasTrend ? ["ledger trend"] : []];
|
|
2249
|
+
const body = rows.map((r) => [r.rule, r.severity, String(r.open), String(r.baselined), String(r.silenced), ...hasTrend ? [r.trend ?? "-"] : []]);
|
|
2250
|
+
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
2251
|
+
const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
|
|
2252
|
+
const noisy = rows.filter((r) => r.overridden >= 5 && r.overridden / (r.open + r.overridden) >= 0.5);
|
|
2253
|
+
return [
|
|
2254
|
+
line(head),
|
|
2255
|
+
...body.map(line),
|
|
2256
|
+
...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.`] : []
|
|
2257
|
+
].join("\n");
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// src/handoff.ts
|
|
2261
|
+
import { relative as relative5 } from "path";
|
|
2262
|
+
var fp = (root, f) => `${f.rule}|${f.loc.file.startsWith("/") ? relative5(root, f.loc.file) : f.loc.file}|${f.message}`;
|
|
2263
|
+
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}` : ""}`;
|
|
2264
|
+
var RULES = [
|
|
2265
|
+
"Fix the problems in the order given. Re-run `fringe verify` after each batch; stop only when it says `status: complete`.",
|
|
2266
|
+
"Do not change the visual design or behavior; fix the underlying problem (labels, names, contrast, focus), not the symptom.",
|
|
2267
|
+
"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.",
|
|
2268
|
+
"Prefer fixing a shared component once over its many uses.",
|
|
2269
|
+
"If a finding needs a design or content decision you cannot make, leave it and say so in your final message instead of guessing."
|
|
2270
|
+
];
|
|
2271
|
+
function buildHandoff(result, meta, limit = 15) {
|
|
2272
|
+
const batches = buildPlan(result, limit);
|
|
2273
|
+
const verifyCmd = "fringe verify";
|
|
2274
|
+
const prompt = [
|
|
2275
|
+
"# Fix the UX problems Fringe found",
|
|
2276
|
+
"",
|
|
2277
|
+
`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).`,
|
|
2278
|
+
"Your goal: no errors left, and nothing new introduced.",
|
|
2279
|
+
"",
|
|
2280
|
+
"## Rules",
|
|
2281
|
+
...RULES.map((r) => `- ${r}`),
|
|
2282
|
+
"",
|
|
2283
|
+
"## Definition of done",
|
|
2284
|
+
"Commands: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place.",
|
|
2285
|
+
`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.`,
|
|
2286
|
+
"",
|
|
2287
|
+
toPlanMarkdown(result, meta.target, limit).replace(/^# Fringe fix plan\n\n/, "## The work\n\n")
|
|
2288
|
+
].join("\n");
|
|
2289
|
+
return {
|
|
2290
|
+
schema: "fringe/handoff.v1",
|
|
2291
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2292
|
+
root: meta.root,
|
|
2293
|
+
target: meta.target,
|
|
2294
|
+
...meta.only ? { only: meta.only } : {},
|
|
2295
|
+
...meta.scan ? { scan: meta.scan } : {},
|
|
2296
|
+
score: result.score,
|
|
2297
|
+
counts: result.counts,
|
|
2298
|
+
suppressed: result.suppressed ?? 0,
|
|
2299
|
+
fingerprints: result.findings.map((f) => fp(meta.root, f)),
|
|
2300
|
+
batches,
|
|
2301
|
+
prompt
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
function verifyHandoff(h, now) {
|
|
2305
|
+
const left = /* @__PURE__ */ new Map();
|
|
2306
|
+
for (const k of h.fingerprints) left.set(k, (left.get(k) ?? 0) + 1);
|
|
2307
|
+
const introduced = [];
|
|
2308
|
+
const remaining = [];
|
|
2309
|
+
for (const f of now.findings) {
|
|
2310
|
+
const k = fp(h.root, f), n = left.get(k) ?? 0;
|
|
2311
|
+
if (n > 0) {
|
|
2312
|
+
left.set(k, n - 1);
|
|
2313
|
+
remaining.push(f);
|
|
2314
|
+
} else introduced.push(f);
|
|
2315
|
+
}
|
|
2316
|
+
const errs = (fs) => fs.filter((f) => f.severity === "error");
|
|
2317
|
+
const row = (f) => ({ rule: f.rule, where: where(h.root, f), message: f.message });
|
|
2318
|
+
const remainingErrors = errs(remaining).length, introducedErrors = errs(introduced).length;
|
|
2319
|
+
return {
|
|
2320
|
+
schema: "fringe/verify.v1",
|
|
2321
|
+
status: introducedErrors > 0 ? "regressed" : remainingErrors === 0 ? "complete" : "partial",
|
|
2322
|
+
score: { before: h.score, after: now.score },
|
|
2323
|
+
errors: { before: h.counts.error, fixed: h.counts.error - remainingErrors, remaining: remainingErrors, introduced: introducedErrors },
|
|
2324
|
+
review: { remaining: remaining.filter((f) => f.severity === "review").length, introduced: introduced.filter((f) => f.severity === "review").length },
|
|
2325
|
+
introduced: introduced.filter((f) => f.severity !== "signal").map(row).slice(0, 20),
|
|
2326
|
+
remaining: errs(remaining).map(row).slice(0, 20),
|
|
2327
|
+
newSuppressions: Math.max(0, (now.suppressed ?? 0) - h.suppressed)
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
function verifyToMarkdown(v) {
|
|
2331
|
+
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`];
|
|
2332
|
+
if (v.review.introduced) out.push(`review items introduced: ${v.review.introduced}`);
|
|
2333
|
+
if (v.introduced.length) out.push("", "Introduced since the hand-off (fix these first):", ...v.introduced.map((x) => `- ${x.rule} ${x.where}: ${x.message}`));
|
|
2334
|
+
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`] : []);
|
|
2335
|
+
if (v.newSuppressions) out.push("", `Needs human review: ${v.newSuppressions} finding(s) were silenced with fringe-ignore since the hand-off.`);
|
|
2336
|
+
return out.join("\n");
|
|
2337
|
+
}
|
|
2338
|
+
|
|
1965
2339
|
// src/index.ts
|
|
2340
|
+
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
1966
2341
|
var log = (o) => o.log ?? ((s) => console.log(s));
|
|
1967
2342
|
var err = (o) => o.err ?? ((s) => process.stderr.write(s));
|
|
1968
2343
|
function readManifest(path2, warn) {
|
|
1969
2344
|
let raw;
|
|
1970
2345
|
try {
|
|
1971
|
-
raw =
|
|
2346
|
+
raw = readFileSync11(path2, "utf8");
|
|
1972
2347
|
} catch {
|
|
1973
2348
|
warn(`fringe: warning: manifest not found at ${path2} \u2014 continuing without
|
|
1974
2349
|
`);
|
|
@@ -1993,27 +2368,39 @@ function readManifest(path2, warn) {
|
|
|
1993
2368
|
}
|
|
1994
2369
|
function resolveTarget(arg) {
|
|
1995
2370
|
if (!statSync(arg).isFile()) return { root: arg };
|
|
1996
|
-
let dir =
|
|
1997
|
-
for (let d = dir, i = 0; i < 12; i++, d =
|
|
1998
|
-
if (
|
|
2371
|
+
let dir = dirname3(arg);
|
|
2372
|
+
for (let d = dir, i = 0; i < 12; i++, d = dirname3(d)) {
|
|
2373
|
+
if (existsSync5(join12(d, "package.json")) || existsSync5(join12(d, "fringe.config.json"))) {
|
|
1999
2374
|
dir = d;
|
|
2000
2375
|
break;
|
|
2001
2376
|
}
|
|
2002
|
-
if (
|
|
2377
|
+
if (dirname3(d) === d) break;
|
|
2003
2378
|
}
|
|
2004
2379
|
return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
|
|
2005
2380
|
}
|
|
2006
|
-
function
|
|
2007
|
-
const
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2381
|
+
function loadScanOptions(cwd, rest) {
|
|
2382
|
+
const flag = (n) => {
|
|
2383
|
+
const i = rest.indexOf(n);
|
|
2384
|
+
return i === -1 ? void 0 : rest[i + 1];
|
|
2385
|
+
};
|
|
2386
|
+
const si = rest.indexOf("--states");
|
|
2387
|
+
const file2 = si !== -1 ? resolve(cwd, rest[si + 1] ?? "") : join12(cwd, "fringe.scan.json");
|
|
2388
|
+
let states = [];
|
|
2389
|
+
let sessions;
|
|
2390
|
+
const storageFlag = flag("--storage-state") ? resolve(cwd, flag("--storage-state")) : void 0;
|
|
2391
|
+
if (si !== -1 || existsSync5(file2)) {
|
|
2392
|
+
let raw;
|
|
2393
|
+
try {
|
|
2394
|
+
raw = JSON.parse(readFileSync11(file2, "utf8"));
|
|
2395
|
+
} catch (e) {
|
|
2396
|
+
throw new Error(`${file2}: ${e.message}`);
|
|
2397
|
+
}
|
|
2398
|
+
states = parseStates(raw);
|
|
2399
|
+
const parsed = parseSessions(raw, dirname3(file2));
|
|
2400
|
+
if (parsed.length) sessions = parsed;
|
|
2015
2401
|
}
|
|
2016
|
-
|
|
2402
|
+
const shots = flag("--screenshots");
|
|
2403
|
+
return { states, modes: loadModes(rest), ...storageFlag ? { storageState: storageFlag } : sessions ? { sessions } : {}, ...shots ? { screenshots: resolve(cwd, shots) } : {}, ...rest.includes("--update-baseline") ? { updateBaseline: true } : {} };
|
|
2017
2404
|
}
|
|
2018
2405
|
function loadModes(rest) {
|
|
2019
2406
|
const i = rest.indexOf("--modes");
|
|
@@ -2023,11 +2410,17 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
|
|
|
2023
2410
|
var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
2024
2411
|
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
2025
2412
|
fringe check --update-baseline [path] accept today's findings; only new ones gate
|
|
2026
|
-
fringe
|
|
2413
|
+
fringe login <url> [--out auth.json] log in once in a browser window and save the session for scans
|
|
2414
|
+
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
|
|
2415
|
+
fringe rules [dir] [--json] per rule: open / baselined / silenced, to spot rules that don't suit this repo
|
|
2416
|
+
fringe handoff [dir|file] [--scan url] a work order for another agent or CI (saved to .fringe/handoff.json)
|
|
2417
|
+
fringe verify [--handoff file] did the receiving agent finish? exit 0 only when complete
|
|
2027
2418
|
fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
|
|
2028
2419
|
fringe explain pattern [form] best-practice checklist for a component type
|
|
2029
2420
|
fringe explain [FR-203] why a rule matters and how to fix it well
|
|
2030
2421
|
fringe init [path] [--global] add the Claude Code hook + skills (--global: every project)
|
|
2422
|
+
fringe init --for cursor,codex,... write the rules file other agents read (cursor, codex, gemini, copilot, windsurf, cline, kiro, generic)
|
|
2423
|
+
fringe init --git pre-commit gate for any agent that commits
|
|
2031
2424
|
fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
|
|
2032
2425
|
fringe --help | --version`;
|
|
2033
2426
|
var KNOWN_FLAGS = /* @__PURE__ */ new Set(["--json", "--hook", "--claude-hook", "--record", "--manifest", "--update-baseline"]);
|
|
@@ -2048,6 +2441,28 @@ function parseArgs(rest, warn) {
|
|
|
2048
2441
|
}
|
|
2049
2442
|
return paths;
|
|
2050
2443
|
}
|
|
2444
|
+
async function gather(opts, rest) {
|
|
2445
|
+
const si = rest.indexOf("--scan");
|
|
2446
|
+
const scanUrl = si !== -1 ? rest[si + 1] : void 0;
|
|
2447
|
+
if (si !== -1 && (!scanUrl || scanUrl.startsWith("--"))) throw new Error("--scan needs a URL, e.g. fringe plan --scan http://localhost:3000");
|
|
2448
|
+
const li = rest.indexOf("--limit");
|
|
2449
|
+
const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
|
|
2450
|
+
const valued = /* @__PURE__ */ new Set(["--limit", "--scan", "--states", "--modes", "--out", "--storage-state", "--screenshots"]);
|
|
2451
|
+
const flags = /* @__PURE__ */ new Set(["--json", "--stress"]);
|
|
2452
|
+
const paths = parseArgs(rest.filter((a, i) => !valued.has(a) && !valued.has(rest[i - 1]) && !flags.has(a)), (s) => process.stderr.write(s));
|
|
2453
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2454
|
+
const { root, only } = resolveTarget(resolve(cwd, paths[0] ?? "."));
|
|
2455
|
+
const cfg = loadConfig(root);
|
|
2456
|
+
let result = await runCheck(root, cfg, true, only);
|
|
2457
|
+
let scanMeta;
|
|
2458
|
+
if (scanUrl) {
|
|
2459
|
+
result = mergeScan(result, await scan(scanUrl, cwd, loadScanOptions(cwd, rest)), cfg.weights);
|
|
2460
|
+
const sf = rest.indexOf("--states");
|
|
2461
|
+
const so = loadScanOptions(cwd, rest);
|
|
2462
|
+
scanMeta = { url: scanUrl, ...sf !== -1 ? { statesFile: resolve(cwd, rest[sf + 1]) } : {}, modes: so.modes ?? [], ...so.storageState ? { storageState: so.storageState } : {} };
|
|
2463
|
+
}
|
|
2464
|
+
return { result, root, target: paths[0] ?? ".", only: only ? [...only][0] : void 0, limit, scan: scanMeta };
|
|
2465
|
+
}
|
|
2051
2466
|
async function runCli(argv, opts = {}) {
|
|
2052
2467
|
const [cmd, ...rest] = argv;
|
|
2053
2468
|
if (cmd === void 0) {
|
|
@@ -2105,13 +2520,20 @@ async function runCli(argv, opts = {}) {
|
|
|
2105
2520
|
}
|
|
2106
2521
|
if (cmd === "scan") {
|
|
2107
2522
|
try {
|
|
2108
|
-
const url = rest.find((a, i) => !a.startsWith("--") &&
|
|
2523
|
+
const url = rest.find((a, i) => !a.startsWith("--") && !["--states", "--modes", "--storage-state", "--screenshots"].includes(rest[i - 1]));
|
|
2109
2524
|
if (!url) {
|
|
2110
2525
|
err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
|
|
2111
2526
|
return 2;
|
|
2112
2527
|
}
|
|
2113
2528
|
const cwd = opts.cwd ?? process.cwd();
|
|
2114
|
-
const
|
|
2529
|
+
const so = loadScanOptions(cwd, rest);
|
|
2530
|
+
const result = await scan(url, cwd, so);
|
|
2531
|
+
if (so.updateBaseline) {
|
|
2532
|
+
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`);
|
|
2533
|
+
return 0;
|
|
2534
|
+
}
|
|
2535
|
+
if (so.screenshots) err(opts)(`fringe: screenshots saved in ${so.screenshots}
|
|
2536
|
+
`);
|
|
2115
2537
|
log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
|
|
2116
2538
|
return result.counts.error > 0 ? 1 : 0;
|
|
2117
2539
|
} catch (e) {
|
|
@@ -2120,29 +2542,80 @@ async function runCli(argv, opts = {}) {
|
|
|
2120
2542
|
return 2;
|
|
2121
2543
|
}
|
|
2122
2544
|
}
|
|
2123
|
-
if (cmd === "
|
|
2545
|
+
if (cmd === "login") {
|
|
2124
2546
|
try {
|
|
2125
|
-
const
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
err(opts)("fringe: --scan needs a URL, e.g. fringe plan --scan http://localhost:3000\n");
|
|
2547
|
+
const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--out");
|
|
2548
|
+
if (!url) {
|
|
2549
|
+
err(opts)("fringe: login needs a URL, e.g. fringe login http://localhost:3000/login\n");
|
|
2129
2550
|
return 2;
|
|
2130
2551
|
}
|
|
2131
|
-
const
|
|
2132
|
-
const
|
|
2133
|
-
const
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2552
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2553
|
+
const oi = rest.indexOf("--out");
|
|
2554
|
+
const out = resolve(cwd, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/auth.json");
|
|
2555
|
+
await saveSession(url, cwd, out, { headless: rest.includes("--headless"), wait: opts.waitForUser });
|
|
2556
|
+
log(opts)(`fringe: session saved to ${out}
|
|
2557
|
+
It holds your logged-in cookies: keep it out of git. Use it with: fringe scan <url> --storage-state ${oi !== -1 ? rest[oi + 1] : ".fringe/auth.json"}`);
|
|
2558
|
+
if (oi === -1 && existsSync5(join12(cwd, ".gitignore")) && !/\.fringe/.test(readFileSync11(join12(cwd, ".gitignore"), "utf8"))) log(opts)("fringe: your .gitignore does not list .fringe/: add it, or the session file could be committed.");
|
|
2559
|
+
return 0;
|
|
2560
|
+
} catch (e) {
|
|
2561
|
+
err(opts)(`fringe: ${e.message}
|
|
2562
|
+
`);
|
|
2563
|
+
return 2;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
if (cmd === "rules") {
|
|
2567
|
+
try {
|
|
2568
|
+
const root = resolve(opts.cwd ?? process.cwd(), rest.find((a) => !a.startsWith("--")) ?? ".");
|
|
2569
|
+
const rows = await ruleReport(root);
|
|
2570
|
+
log(opts)(rest.includes("--json") ? JSON.stringify({ schema: "fringe/rules.v1", rules: rows }, null, 2) : rulesToText(rows));
|
|
2571
|
+
return 0;
|
|
2572
|
+
} catch (e) {
|
|
2573
|
+
err(opts)(`fringe: ${e.message}
|
|
2574
|
+
`);
|
|
2575
|
+
return 2;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
if (cmd === "plan" || cmd === "handoff") {
|
|
2579
|
+
try {
|
|
2580
|
+
const g = await gather(opts, rest);
|
|
2581
|
+
if (cmd === "plan") {
|
|
2582
|
+
log(opts)(rest.includes("--json") ? toPlanJson(g.result, g.limit) : toPlanMarkdown(g.result, g.target, g.limit));
|
|
2583
|
+
return 0;
|
|
2141
2584
|
}
|
|
2142
|
-
|
|
2585
|
+
const h = buildHandoff(g.result, { root: g.root, target: g.target, ...g.only ? { only: g.only } : {}, ...g.scan ? { scan: g.scan } : {} }, g.limit);
|
|
2586
|
+
const oi = rest.indexOf("--out");
|
|
2587
|
+
const outFile = resolve(g.root, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/handoff.json");
|
|
2588
|
+
mkdirSync6(dirname3(outFile), { recursive: true });
|
|
2589
|
+
writeFileSync5(outFile, JSON.stringify(h, null, 2) + "\n");
|
|
2590
|
+
log(opts)(rest.includes("--json") ? JSON.stringify(h, null, 2) : h.prompt);
|
|
2591
|
+
err(opts)(`fringe: hand-off saved to ${outFile}; the receiving agent proves it is done with: fringe verify${oi !== -1 ? ` --handoff ${outFile}` : ""}
|
|
2592
|
+
`);
|
|
2143
2593
|
return 0;
|
|
2144
2594
|
} catch (e) {
|
|
2145
|
-
err(opts)(`fringe:
|
|
2595
|
+
err(opts)(`fringe: ${e.message}
|
|
2596
|
+
`);
|
|
2597
|
+
return 2;
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
if (cmd === "verify") {
|
|
2601
|
+
try {
|
|
2602
|
+
const hi = rest.indexOf("--handoff");
|
|
2603
|
+
const file2 = resolve(opts.cwd ?? process.cwd(), hi !== -1 ? rest[hi + 1] ?? "" : ".fringe/handoff.json");
|
|
2604
|
+
let h;
|
|
2605
|
+
try {
|
|
2606
|
+
h = JSON.parse(readFileSync11(file2, "utf8"));
|
|
2607
|
+
} catch {
|
|
2608
|
+
throw new Error(`no readable hand-off at ${file2} \u2014 run 'fringe handoff' first`);
|
|
2609
|
+
}
|
|
2610
|
+
if (h.schema !== "fringe/handoff.v1") throw new Error(`${file2} is not a fringe/handoff.v1 file`);
|
|
2611
|
+
const cfg = loadConfig(h.root);
|
|
2612
|
+
let result = await runCheck(h.root, cfg, true, h.only ? /* @__PURE__ */ new Set([h.only]) : void 0);
|
|
2613
|
+
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);
|
|
2614
|
+
const v = verifyHandoff(h, result);
|
|
2615
|
+
log(opts)(rest.includes("--json") ? JSON.stringify(v, null, 2) : verifyToMarkdown(v));
|
|
2616
|
+
return v.status === "complete" ? 0 : 1;
|
|
2617
|
+
} catch (e) {
|
|
2618
|
+
err(opts)(`fringe: ${e.message}
|
|
2146
2619
|
`);
|
|
2147
2620
|
return 2;
|
|
2148
2621
|
}
|
|
@@ -2170,8 +2643,17 @@ async function runCli(argv, opts = {}) {
|
|
|
2170
2643
|
if (cmd === "init") {
|
|
2171
2644
|
try {
|
|
2172
2645
|
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
|
-
|
|
2646
|
+
const target = global ? opts.home ?? homedir() : resolve(opts.cwd ?? process.cwd(), rest.find((a, i) => !a.startsWith("-") && rest[i - 1] !== "--for") ?? ".");
|
|
2647
|
+
const fi = rest.indexOf("--for");
|
|
2648
|
+
const agents = fi !== -1 ? (rest[fi + 1] ?? "").split(",").map((a) => a.trim().toLowerCase()).filter(Boolean) : [];
|
|
2649
|
+
if (fi !== -1 && agents.length === 0) throw new Error(`--for needs an agent: ${AGENTS.join(", ")}, claude`);
|
|
2650
|
+
const lines = [];
|
|
2651
|
+
const wantsClaude = fi === -1 && !rest.includes("--git") || agents.includes("claude");
|
|
2652
|
+
if (wantsClaude) lines.push(...initProject(target));
|
|
2653
|
+
const others = agents.filter((a) => a !== "claude");
|
|
2654
|
+
if (others.length) lines.push(...initFor(target, others));
|
|
2655
|
+
if (rest.includes("--git")) lines.push(...initGit(target));
|
|
2656
|
+
for (const line of lines) log(opts)(`fringe: ${line}`);
|
|
2175
2657
|
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
2658
|
return 0;
|
|
2177
2659
|
} catch (e) {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ interface CliOpts {
|
|
|
2
2
|
log?: (s: string) => void;
|
|
3
3
|
cwd?: string;
|
|
4
4
|
err?: (s: string) => void; /** Home directory for `init --global` (tests override it). */
|
|
5
|
-
home?: string;
|
|
5
|
+
home?: string; /** `fringe login` waits for this instead of Enter on the terminal (tests). */
|
|
6
|
+
waitForUser?: () => Promise<void>;
|
|
6
7
|
}
|
|
7
8
|
declare function runCli(argv: string[], opts?: CliOpts): Promise<number>;
|
|
8
9
|
|
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.6.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,10 @@ 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
|
+
| `login <url>` | Log in once in a browser window and save the session, so `scan` can reach pages behind a sign-in |
|
|
31
|
+
| `handoff` / `verify` | Work order for another agent or CI, and proof it was done (complete / partial / regressed) |
|
|
32
|
+
| `rules` | Per rule: open / baselined / silenced, to spot rules that do not suit this repo |
|
|
33
|
+
| `init --for <agents>` / `--git` | Rules file for Codex, Cursor, Copilot, Gemini…; commit gate for any agent |
|
|
30
34
|
| `explain [FR-203]` | Why a rule matters and how to fix it well |
|
|
31
35
|
| `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
|
|
32
36
|
| `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:
|