@fringetools/cli 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -3
- package/dist/bin.js +1 -1
- package/dist/{chunk-ZTIMW7ON.js → chunk-BCK7LSHQ.js} +270 -74
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/skill/fringe/SKILL.md +1 -1
- package/skill/fringe-help/SKILL.md +2 -0
package/README.md
CHANGED
|
@@ -23,12 +23,13 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
|
|
|
23
23
|
|---|---|
|
|
24
24
|
| `fringe check [dir\|file]` | Scan a directory (default `.`) or judge one file (the project root is found by climbing to the nearest `package.json`). Prints findings and the health score. Exit `1` on any `error`. |
|
|
25
25
|
| `fringe scan <url> [--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. |
|
|
26
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`. |
|
|
27
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. |
|
|
28
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`). |
|
|
29
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`. |
|
|
30
31
|
| `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 36 rules. |
|
|
31
|
-
| `fringe explain pattern [
|
|
32
|
+
| `fringe explain pattern [name]` | Build-time best-practice checklist for a component type: `form`, `auth` (sign in and sign up), `checkout`, `table`, `modal`, `navigation`, `data-view`. 48 items; each is marked *verified* (a static rule, or `by scan` for a running page) or *judged*. |
|
|
32
33
|
| `fringe check --json` | Full report as JSON, schema `fringe/check.v1` (stack, files, findings, counts, score). |
|
|
33
34
|
| `fringe check --claude-hook` | **For Claude Code hooks.** Silent when clean; on errors prints `{"decision":"block","reason":…}` (exit 0) so the agent sees and fixes them. |
|
|
34
35
|
| `fringe check --hook` | One-line `fringe/hook.v1` JSON for CI / pre-commit. Exits `1` on errors. Claude Code does **not** feed this back — use `--claude-hook` for agents. Wins over `--json`. |
|
|
@@ -108,9 +109,54 @@ npx @fringetools/cli scan http://localhost:3000 --modes dark,reflow # a sub
|
|
|
108
109
|
|
|
109
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.
|
|
110
111
|
|
|
111
|
-
### Pages behind a login
|
|
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
|
|
112
159
|
|
|
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
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.
|
|
115
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.
|
|
116
162
|
|
package/dist/bin.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import { existsSync as existsSync5, readFileSync as readFileSync11, statSync } from "fs";
|
|
4
|
-
import { dirname as
|
|
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.1";
|
|
8
8
|
|
|
9
9
|
// src/check.ts
|
|
10
10
|
import { readFileSync as readFileSync5 } from "fs";
|
|
@@ -1641,31 +1641,61 @@ function tabWalkFindings(url, focusables, visits) {
|
|
|
1641
1641
|
return out;
|
|
1642
1642
|
}
|
|
1643
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
|
+
}
|
|
1644
1656
|
function parseStates(json) {
|
|
1645
|
-
const
|
|
1646
|
-
|
|
1647
|
-
};
|
|
1648
|
-
|
|
1649
|
-
if (!Array.isArray(states)) return bad('expected { "states": [ ... ] }');
|
|
1650
|
-
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) => {
|
|
1651
1661
|
if (typeof s?.name !== "string" || !s.name.trim()) bad(`states[${i}] needs a "name"`);
|
|
1652
|
-
|
|
1653
|
-
for (const [k, step] of s.steps.entries()) {
|
|
1654
|
-
const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
|
|
1655
|
-
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`);
|
|
1656
|
-
if (keys[0] === "fill" && typeof step.value !== "string") bad(`state "${s.name}" step ${k + 1}: fill needs a "value"`);
|
|
1657
|
-
}
|
|
1658
|
-
return { name: s.name.trim(), steps: s.steps };
|
|
1662
|
+
return { name: s.name.trim(), steps: checkSteps(s.steps, `state "${s.name}"`) };
|
|
1659
1663
|
});
|
|
1660
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])));
|
|
1661
1691
|
async function runStep(page, step, base) {
|
|
1662
1692
|
const t = { timeout: 1e4 };
|
|
1663
1693
|
if ("click" in step) await page.click(step.click, t);
|
|
1664
|
-
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);
|
|
1665
1695
|
else if ("press" in step) await page.keyboard.press(step.press);
|
|
1666
1696
|
else if ("hover" in step) await page.hover(step.hover, t);
|
|
1667
1697
|
else if ("waitFor" in step) await page.waitForSelector(step.waitFor, t);
|
|
1668
|
-
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 });
|
|
1669
1699
|
await page.waitForTimeout(150);
|
|
1670
1700
|
}
|
|
1671
1701
|
async function audit(page, axePath, url) {
|
|
@@ -1683,12 +1713,12 @@ async function audit(page, axePath, url) {
|
|
|
1683
1713
|
}
|
|
1684
1714
|
return [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
|
|
1685
1715
|
}
|
|
1686
|
-
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}]` } : {} });
|
|
1687
1717
|
var MODES = ["dark", "reduced", "reflow"];
|
|
1688
1718
|
function parseModes(list) {
|
|
1689
1719
|
const out = list.split(",").map((m) => m.trim()).filter(Boolean);
|
|
1690
|
-
const
|
|
1691
|
-
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(", ")}`);
|
|
1692
1722
|
return out;
|
|
1693
1723
|
}
|
|
1694
1724
|
function onlyNew(base, extra) {
|
|
@@ -1751,14 +1781,51 @@ function applyScanBaseline(cwd, findings) {
|
|
|
1751
1781
|
return { fresh, known: findings.length - fresh.length };
|
|
1752
1782
|
}
|
|
1753
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
|
+
}
|
|
1754
1821
|
async function scan(url, cwd = process.cwd(), opts = {}) {
|
|
1755
1822
|
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
|
-
};
|
|
1761
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})`);
|
|
1762
1829
|
const req = createRequire(join8(cwd, "noop.js"));
|
|
1763
1830
|
let playwright, axePath;
|
|
1764
1831
|
try {
|
|
@@ -1778,35 +1845,46 @@ async function scan(url, cwd = process.cwd(), opts = {}) {
|
|
|
1778
1845
|
}
|
|
1779
1846
|
}
|
|
1780
1847
|
try {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
await
|
|
1790
|
-
let step = 0;
|
|
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 } : {});
|
|
1791
1857
|
try {
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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();
|
|
1796
1876
|
}
|
|
1797
|
-
await shot(page, `state-${st.name}`);
|
|
1798
|
-
all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
|
|
1799
1877
|
}
|
|
1800
|
-
|
|
1878
|
+
const files = sessions.length * (1 + states.length + modes.length);
|
|
1801
1879
|
if (opts.updateBaseline) {
|
|
1802
1880
|
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
|
|
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 };
|
|
1804
1882
|
}
|
|
1805
1883
|
const { fresh, known } = applyScanBaseline(cwd, all);
|
|
1806
1884
|
const { findings, clusters } = rankScan(url, fresh);
|
|
1807
1885
|
const counts = { error: 0, review: 0, signal: 0 };
|
|
1808
1886
|
for (const f of findings) counts[f.severity]++;
|
|
1809
|
-
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 } : {} };
|
|
1810
1888
|
} finally {
|
|
1811
1889
|
await browser.close();
|
|
1812
1890
|
}
|
|
@@ -1830,19 +1908,71 @@ function mergeScan(base, scanned, weights) {
|
|
|
1830
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) } : {} };
|
|
1831
1909
|
}
|
|
1832
1910
|
|
|
1833
|
-
// src/
|
|
1834
|
-
import {
|
|
1911
|
+
// src/login.ts
|
|
1912
|
+
import { createRequire as createRequire2 } from "module";
|
|
1913
|
+
import { mkdirSync as mkdirSync4 } from "fs";
|
|
1835
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";
|
|
1836
1966
|
import { fileURLToPath } from "url";
|
|
1837
1967
|
var HOOK = "npx -y @fringetools/cli check --claude-hook";
|
|
1838
1968
|
var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
|
|
1839
1969
|
function hookCommand(root) {
|
|
1840
|
-
return existsSync3(
|
|
1970
|
+
return existsSync3(join10(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
|
|
1841
1971
|
}
|
|
1842
1972
|
function isWired(root, home) {
|
|
1843
1973
|
for (const dir of [root, home]) {
|
|
1844
1974
|
try {
|
|
1845
|
-
if (/claude-hook|fringe(tool)?@/.test(readFileSync9(
|
|
1975
|
+
if (/claude-hook|fringe(tool)?@/.test(readFileSync9(join10(dir, ".claude", "settings.json"), "utf8"))) return true;
|
|
1846
1976
|
} catch {
|
|
1847
1977
|
}
|
|
1848
1978
|
}
|
|
@@ -1850,7 +1980,7 @@ function isWired(root, home) {
|
|
|
1850
1980
|
}
|
|
1851
1981
|
function initProject(root) {
|
|
1852
1982
|
const done = [];
|
|
1853
|
-
const settingsPath =
|
|
1983
|
+
const settingsPath = join10(root, ".claude", "settings.json");
|
|
1854
1984
|
let settings = {};
|
|
1855
1985
|
if (existsSync3(settingsPath)) {
|
|
1856
1986
|
try {
|
|
@@ -1863,16 +1993,16 @@ function initProject(root) {
|
|
|
1863
1993
|
if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
|
|
1864
1994
|
else {
|
|
1865
1995
|
post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: hookCommand(root) }] });
|
|
1866
|
-
|
|
1996
|
+
mkdirSync5(dirname2(settingsPath), { recursive: true });
|
|
1867
1997
|
writeFileSync4(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1868
1998
|
done.push(`hook added to ${settingsPath}`);
|
|
1869
1999
|
}
|
|
1870
2000
|
for (const name of readdirSync3(SKILL_DIR)) {
|
|
1871
|
-
const skillPath =
|
|
2001
|
+
const skillPath = join10(root, ".claude", "skills", name, "SKILL.md");
|
|
1872
2002
|
if (existsSync3(skillPath)) done.push(`skill ${name} already present`);
|
|
1873
2003
|
else {
|
|
1874
|
-
|
|
1875
|
-
copyFileSync(
|
|
2004
|
+
mkdirSync5(dirname2(skillPath), { recursive: true });
|
|
2005
|
+
copyFileSync(join10(SKILL_DIR, name, "SKILL.md"), skillPath);
|
|
1876
2006
|
done.push(`skill ${name} installed`);
|
|
1877
2007
|
}
|
|
1878
2008
|
}
|
|
@@ -1905,7 +2035,7 @@ function initFor(root, agents) {
|
|
|
1905
2035
|
for (const a of agents) {
|
|
1906
2036
|
const spec = AGENT_FILES[a];
|
|
1907
2037
|
if (!spec) throw new Error(`unknown agent '${a}': use ${AGENTS.join(", ")} (or 'claude' for the hook + skills)`);
|
|
1908
|
-
const file2 =
|
|
2038
|
+
const file2 = join10(root, spec.path);
|
|
1909
2039
|
const text = spec.wrap ? spec.wrap(body) : body;
|
|
1910
2040
|
if (existsSync3(file2)) {
|
|
1911
2041
|
const cur = readFileSync9(file2, "utf8");
|
|
@@ -1918,7 +2048,7 @@ ${MARK2}
|
|
|
1918
2048
|
${spec.wrap ? body : text}`);
|
|
1919
2049
|
done.push(`rules appended to ${spec.path}`);
|
|
1920
2050
|
} else {
|
|
1921
|
-
|
|
2051
|
+
mkdirSync5(dirname2(file2), { recursive: true });
|
|
1922
2052
|
writeFileSync4(file2, text.replace("# Fringe: handle your edges", `${MARK2}
|
|
1923
2053
|
# Fringe: handle your edges`));
|
|
1924
2054
|
done.push(`${spec.path} created`);
|
|
@@ -1927,10 +2057,10 @@ ${spec.wrap ? body : text}`);
|
|
|
1927
2057
|
return done;
|
|
1928
2058
|
}
|
|
1929
2059
|
function initGit(root) {
|
|
1930
|
-
const dir =
|
|
1931
|
-
if (!existsSync3(
|
|
1932
|
-
const file2 =
|
|
1933
|
-
const local = existsSync3(
|
|
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"));
|
|
1934
2064
|
const cmd = local ? "npx --no-install fringe check" : "npx -y @fringetools/cli check";
|
|
1935
2065
|
const script = `#!/bin/sh
|
|
1936
2066
|
# fringe pre-commit: blocks the commit while error-level UX/accessibility problems remain
|
|
@@ -1946,7 +2076,7 @@ exit 0
|
|
|
1946
2076
|
if (readFileSync9(file2, "utf8").includes("# fringe pre-commit")) return ["pre-commit hook already present"];
|
|
1947
2077
|
throw new Error(`${file2} already exists and is not Fringe's: add 'npx -y @fringetools/cli check || exit 1' to it yourself`);
|
|
1948
2078
|
}
|
|
1949
|
-
|
|
2079
|
+
mkdirSync5(dir, { recursive: true });
|
|
1950
2080
|
writeFileSync4(file2, script, { mode: 493 });
|
|
1951
2081
|
return [`pre-commit hook installed at ${file2}`];
|
|
1952
2082
|
}
|
|
@@ -1960,7 +2090,7 @@ var PATTERNS = {
|
|
|
1960
2090
|
title: "Form",
|
|
1961
2091
|
items: [
|
|
1962
2092
|
{ text: "Every field has a visible <label> bound with htmlFor/id", rules: ["FR-203"] },
|
|
1963
|
-
{ text: "Right input type and autocomplete (email, tel, current-password, new-password, name)", rules: ["FR-216"] },
|
|
2093
|
+
{ text: "Right input type and autocomplete (email, tel, current-password, new-password, name) (Fringe checks email, phone and password fields)", rules: ["FR-216"] },
|
|
1964
2094
|
{ text: "Required fields are marked in text, not by color alone" },
|
|
1965
2095
|
{ text: "Errors appear next to the field in words, linked with aria-describedby and aria-invalid; focus moves to the first error on submit" },
|
|
1966
2096
|
{ text: "The submit button shows a pending state and cannot be double-submitted" },
|
|
@@ -1988,6 +2118,48 @@ var PATTERNS = {
|
|
|
1988
2118
|
{ text: "Tap targets are large enough", rules: ["FR-305"] }
|
|
1989
2119
|
]
|
|
1990
2120
|
},
|
|
2121
|
+
auth: {
|
|
2122
|
+
title: "Sign in and sign up",
|
|
2123
|
+
items: [
|
|
2124
|
+
{ text: "Every field has a visible <label> bound with htmlFor/id (no placeholder-only fields)", rules: ["FR-203"] },
|
|
2125
|
+
{ text: "Right input types and autocomplete: username or email, current-password (sign in), new-password (sign up), one-time-code for codes (Fringe checks email, phone and password fields)", rules: ["FR-216"] },
|
|
2126
|
+
{ text: "Social and single-sign-on buttons carry text naming the provider, not just a logo", rules: ["FR-202"] },
|
|
2127
|
+
{ text: '"Forgot password" is a real link with descriptive text', rules: ["FR-217"] },
|
|
2128
|
+
{ text: "Password rules are shown before typing, not only after a failure; the user can reveal the password with a named toggle" },
|
|
2129
|
+
{ text: 'A failed sign-in says "email or password is incorrect" (never which one), keeps the email, and moves focus to the message' },
|
|
2130
|
+
{ text: "The submit button shows a pending state and cannot be double-submitted; pasting a password is allowed" },
|
|
2131
|
+
{ text: "The page has a title and a language (checked on full HTML pages), and keyboard focus never gets stuck (checked by scan)", rules: ["FR-218", "FR-401"] },
|
|
2132
|
+
{ text: "The whole flow, including the reveal toggle and any code entry, works with the keyboard alone" }
|
|
2133
|
+
]
|
|
2134
|
+
},
|
|
2135
|
+
checkout: {
|
|
2136
|
+
title: "Checkout and payment",
|
|
2137
|
+
items: [
|
|
2138
|
+
{ text: "Every field has a visible label", rules: ["FR-203"] },
|
|
2139
|
+
{ text: "Address and card fields use the standard autocomplete tokens (name, street-address, postal-code, cc-number, cc-exp, cc-csc)" },
|
|
2140
|
+
{ text: "The full price, including shipping, tax and any recurring cost, is visible before the pay button", rules: ["FR-214"] },
|
|
2141
|
+
{ text: "No pre-checked marketing or consent boxes", rules: ["FR-211"] },
|
|
2142
|
+
{ text: "No fake countdowns or manufactured urgency", rules: ["FR-210"] },
|
|
2143
|
+
{ text: "Declining an upsell or add-on is a neutral choice with neutral wording", rules: ["FR-212"] },
|
|
2144
|
+
{ text: "A payment failure says what failed in plain words, keeps the cart and the entered details, and offers a retry", rules: ["FR-215"] },
|
|
2145
|
+
{ text: 'The pay button states the action and amount ("Pay $42.00"), shows a pending state, and cannot charge twice' },
|
|
2146
|
+
{ text: "Order-summary changes (quantity, coupon, shipping) are announced to screen readers (aria-live)" },
|
|
2147
|
+
{ text: "Progress through the steps is shown, and going back never loses what was entered" },
|
|
2148
|
+
{ text: "Tap targets are large enough on a phone", rules: ["FR-305"] }
|
|
2149
|
+
]
|
|
2150
|
+
},
|
|
2151
|
+
table: {
|
|
2152
|
+
title: "Data table",
|
|
2153
|
+
items: [
|
|
2154
|
+
{ text: "A real <table> with <th> headers (scope set) and a caption or accessible name" },
|
|
2155
|
+
{ text: "Sortable headers are buttons, and the sorted column carries aria-sort" },
|
|
2156
|
+
{ text: 'Row-action icon buttons have names ("Delete invoice 1042"), and each row checkbox is labelled', rules: ["FR-202", "FR-203"] },
|
|
2157
|
+
{ text: "Loading, empty (with a next step) and error (with a retry) states", rules: ["FR-208", "FR-215"] },
|
|
2158
|
+
{ text: "Filters and the search box are labelled", rules: ["FR-203"] },
|
|
2159
|
+
{ text: "A wide table scrolls inside its own container instead of making the whole page scroll sideways at 320px", rules: ["FR-404"] },
|
|
2160
|
+
{ text: "Updated results after a filter or sort are announced (aria-live), and keyboard focus is not lost" }
|
|
2161
|
+
]
|
|
2162
|
+
},
|
|
1991
2163
|
"data-view": {
|
|
1992
2164
|
title: "Data view (list, table, dashboard)",
|
|
1993
2165
|
items: [
|
|
@@ -2002,7 +2174,7 @@ function renderPatterns(name) {
|
|
|
2002
2174
|
if (!name) return Object.entries(PATTERNS).map(([k, p2]) => `${k.padEnd(11)} ${p2.title}`).join("\n");
|
|
2003
2175
|
const p = PATTERNS[name.toLowerCase()];
|
|
2004
2176
|
if (!p) return void 0;
|
|
2005
|
-
const mark = (i) => i.rules ? `[verified: ${i.rules.join(", ")}]` : "[judged]";
|
|
2177
|
+
const mark = (i) => i.rules ? `[verified${i.rules.every((r) => /^FR-40[0-5]$/.test(r)) ? " by scan" : ""}: ${i.rules.join(", ")}]` : "[judged]";
|
|
2006
2178
|
return [
|
|
2007
2179
|
`${p.title}: build it like this`,
|
|
2008
2180
|
...p.items.map((i) => `- ${i.text} ${mark(i)}`),
|
|
@@ -2079,12 +2251,12 @@ After each batch, verify: \`fringe check ${target}\``);
|
|
|
2079
2251
|
|
|
2080
2252
|
// src/rulereport.ts
|
|
2081
2253
|
import { existsSync as existsSync4, readFileSync as readFileSync10 } from "fs";
|
|
2082
|
-
import { join as
|
|
2254
|
+
import { join as join11 } from "path";
|
|
2083
2255
|
async function ruleReport(root) {
|
|
2084
2256
|
const cfg = loadConfig(root);
|
|
2085
2257
|
const result = await runCheck(root, cfg, false);
|
|
2086
2258
|
const baselinedBy = /* @__PURE__ */ new Map();
|
|
2087
|
-
const bl =
|
|
2259
|
+
const bl = join11(root, "fringe.baseline.json");
|
|
2088
2260
|
if (existsSync4(bl)) {
|
|
2089
2261
|
try {
|
|
2090
2262
|
for (const k of JSON.parse(readFileSync10(bl, "utf8"))) baselinedBy.set(k.split("|")[0], (baselinedBy.get(k.split("|")[0]) ?? 0) + 1);
|
|
@@ -2207,7 +2379,7 @@ function verifyToMarkdown(v) {
|
|
|
2207
2379
|
}
|
|
2208
2380
|
|
|
2209
2381
|
// src/index.ts
|
|
2210
|
-
import { mkdirSync as
|
|
2382
|
+
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
2211
2383
|
var log = (o) => o.log ?? ((s) => console.log(s));
|
|
2212
2384
|
var err = (o) => o.err ?? ((s) => process.stderr.write(s));
|
|
2213
2385
|
function readManifest(path2, warn) {
|
|
@@ -2238,13 +2410,13 @@ function readManifest(path2, warn) {
|
|
|
2238
2410
|
}
|
|
2239
2411
|
function resolveTarget(arg) {
|
|
2240
2412
|
if (!statSync(arg).isFile()) return { root: arg };
|
|
2241
|
-
let dir =
|
|
2242
|
-
for (let d = dir, i = 0; i < 12; i++, d =
|
|
2243
|
-
if (existsSync5(
|
|
2413
|
+
let dir = dirname3(arg);
|
|
2414
|
+
for (let d = dir, i = 0; i < 12; i++, d = dirname3(d)) {
|
|
2415
|
+
if (existsSync5(join12(d, "package.json")) || existsSync5(join12(d, "fringe.config.json"))) {
|
|
2244
2416
|
dir = d;
|
|
2245
2417
|
break;
|
|
2246
2418
|
}
|
|
2247
|
-
if (
|
|
2419
|
+
if (dirname3(d) === d) break;
|
|
2248
2420
|
}
|
|
2249
2421
|
return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
|
|
2250
2422
|
}
|
|
@@ -2254,9 +2426,10 @@ function loadScanOptions(cwd, rest) {
|
|
|
2254
2426
|
return i === -1 ? void 0 : rest[i + 1];
|
|
2255
2427
|
};
|
|
2256
2428
|
const si = rest.indexOf("--states");
|
|
2257
|
-
const file2 = si !== -1 ? resolve(cwd, rest[si + 1] ?? "") :
|
|
2429
|
+
const file2 = si !== -1 ? resolve(cwd, rest[si + 1] ?? "") : join12(cwd, "fringe.scan.json");
|
|
2258
2430
|
let states = [];
|
|
2259
|
-
let
|
|
2431
|
+
let sessions;
|
|
2432
|
+
const storageFlag = flag("--storage-state") ? resolve(cwd, flag("--storage-state")) : void 0;
|
|
2260
2433
|
if (si !== -1 || existsSync5(file2)) {
|
|
2261
2434
|
let raw;
|
|
2262
2435
|
try {
|
|
@@ -2265,10 +2438,11 @@ function loadScanOptions(cwd, rest) {
|
|
|
2265
2438
|
throw new Error(`${file2}: ${e.message}`);
|
|
2266
2439
|
}
|
|
2267
2440
|
states = parseStates(raw);
|
|
2268
|
-
|
|
2441
|
+
const parsed = parseSessions(raw, dirname3(file2));
|
|
2442
|
+
if (parsed.length) sessions = parsed;
|
|
2269
2443
|
}
|
|
2270
2444
|
const shots = flag("--screenshots");
|
|
2271
|
-
return { states, modes: loadModes(rest), ...
|
|
2445
|
+
return { states, modes: loadModes(rest), ...storageFlag ? { storageState: storageFlag } : sessions ? { sessions } : {}, ...shots ? { screenshots: resolve(cwd, shots) } : {}, ...rest.includes("--update-baseline") ? { updateBaseline: true } : {} };
|
|
2272
2446
|
}
|
|
2273
2447
|
function loadModes(rest) {
|
|
2274
2448
|
const i = rest.indexOf("--modes");
|
|
@@ -2278,6 +2452,7 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
|
|
|
2278
2452
|
var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
2279
2453
|
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
2280
2454
|
fringe check --update-baseline [path] accept today's findings; only new ones gate
|
|
2455
|
+
fringe login <url> [--out auth.json] log in once in a browser window and save the session for scans
|
|
2281
2456
|
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
2457
|
fringe rules [dir] [--json] per rule: open / baselined / silenced, to spot rules that don't suit this repo
|
|
2283
2458
|
fringe handoff [dir|file] [--scan url] a work order for another agent or CI (saved to .fringe/handoff.json)
|
|
@@ -2405,6 +2580,27 @@ async function runCli(argv, opts = {}) {
|
|
|
2405
2580
|
return result.counts.error > 0 ? 1 : 0;
|
|
2406
2581
|
} catch (e) {
|
|
2407
2582
|
err(opts)(`fringe: ${e.message}
|
|
2583
|
+
`);
|
|
2584
|
+
return 2;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
if (cmd === "login") {
|
|
2588
|
+
try {
|
|
2589
|
+
const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--out");
|
|
2590
|
+
if (!url) {
|
|
2591
|
+
err(opts)("fringe: login needs a URL, e.g. fringe login http://localhost:3000/login\n");
|
|
2592
|
+
return 2;
|
|
2593
|
+
}
|
|
2594
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2595
|
+
const oi = rest.indexOf("--out");
|
|
2596
|
+
const out = resolve(cwd, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/auth.json");
|
|
2597
|
+
await saveSession(url, cwd, out, { headless: rest.includes("--headless"), wait: opts.waitForUser });
|
|
2598
|
+
log(opts)(`fringe: session saved to ${out}
|
|
2599
|
+
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"}`);
|
|
2600
|
+
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.");
|
|
2601
|
+
return 0;
|
|
2602
|
+
} catch (e) {
|
|
2603
|
+
err(opts)(`fringe: ${e.message}
|
|
2408
2604
|
`);
|
|
2409
2605
|
return 2;
|
|
2410
2606
|
}
|
|
@@ -2431,7 +2627,7 @@ async function runCli(argv, opts = {}) {
|
|
|
2431
2627
|
const h = buildHandoff(g.result, { root: g.root, target: g.target, ...g.only ? { only: g.only } : {}, ...g.scan ? { scan: g.scan } : {} }, g.limit);
|
|
2432
2628
|
const oi = rest.indexOf("--out");
|
|
2433
2629
|
const outFile = resolve(g.root, oi !== -1 ? rest[oi + 1] ?? "" : ".fringe/handoff.json");
|
|
2434
|
-
|
|
2630
|
+
mkdirSync6(dirname3(outFile), { recursive: true });
|
|
2435
2631
|
writeFileSync5(outFile, JSON.stringify(h, null, 2) + "\n");
|
|
2436
2632
|
log(opts)(rest.includes("--json") ? JSON.stringify(h, null, 2) : h.prompt);
|
|
2437
2633
|
err(opts)(`fringe: hand-off saved to ${outFile}; the receiving agent proves it is done with: fringe verify${oi !== -1 ? ` --handoff ${outFile}` : ""}
|
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
package/skill/fringe/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0
|
|
|
9
9
|
|
|
10
10
|
Ponytail trims your code. Fringe handles your edges. Work the ladder in order.
|
|
11
11
|
|
|
12
|
-
0. **Building a form, modal, navigation or data view?** Run `fringe explain pattern <form|modal|navigation|data-view>` first: a short best-practice checklist, each item marked verified (Fringe checks it) or judged (you decide and say why).
|
|
12
|
+
0. **Building a form, sign-in, checkout, table, modal, navigation or data view?** Run `fringe explain pattern <form|auth|checkout|table|modal|navigation|data-view>` first: a short best-practice checklist, each item marked verified (Fringe checks it) or judged (you decide and say why).
|
|
13
13
|
1. **Flow (judged).** Before building, ask what the user can skip. Cut steps, screens or options nobody needs. Write each decision with its evidence to `.fringe/manifest.json`: `{"flows":[{"action":"cut"|"kept","what":"...","evidence":"..."}]}`. No evidence, no cut.
|
|
14
14
|
2. **Edges (verified, gates).** After every edit the hook runs `fringe check --claude-hook` and feeds errors back to you. Fix every listed finding. If there is no hook, run `fringe check --hook` yourself from the project root and do not finish while `block` is `true`.
|
|
15
15
|
On a repo with many existing findings, run `fringe plan` and work it batch by batch (shared components first, since one fix clears many uses), re-running `fringe check` after each batch. `fringe explain <rule>` gives the recipe and what to avoid.
|
|
@@ -27,9 +27,11 @@ 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 |
|
|
30
31
|
| `handoff` / `verify` | Work order for another agent or CI, and proof it was done (complete / partial / regressed) |
|
|
31
32
|
| `rules` | Per rule: open / baselined / silenced, to spot rules that do not suit this repo |
|
|
32
33
|
| `init --for <agents>` / `--git` | Rules file for Codex, Cursor, Copilot, Gemini…; commit gate for any agent |
|
|
34
|
+
| `explain pattern <name>` | Checklist for form, auth, checkout, table, modal, navigation, data-view |
|
|
33
35
|
| `explain [FR-203]` | Why a rule matters and how to fix it well |
|
|
34
36
|
| `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
|
|
35
37
|
| `init` | Add the hook and install the skills |
|