@fringetools/cli 0.1.3 → 0.2.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 +23 -3
- package/dist/bin.js +1 -1
- package/dist/{chunk-MOTRTP2I.js → chunk-ZK2GPUZF.js} +336 -10
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/skill/fringe/SKILL.md +1 -0
- package/skill/fringe-audit/SKILL.md +1 -1
- package/skill/fringe-help/SKILL.md +2 -0
- package/skill/fringe-review/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
|
|
|
21
21
|
|
|
22
22
|
| Command | What it does |
|
|
23
23
|
|---|---|
|
|
24
|
-
| `fringe check [
|
|
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 plan [dir\|file] [--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). `--json` = `fringe/plan.v1`. |
|
|
26
|
+
| `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 29 rules. |
|
|
25
27
|
| `fringe check --json` | Full report as JSON, schema `fringe/check.v1` (stack, files, findings, counts, score). |
|
|
26
28
|
| `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. |
|
|
27
29
|
| `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`. |
|
|
@@ -31,7 +33,7 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
|
|
|
31
33
|
| `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. |
|
|
32
34
|
| `fringe --help` · `--version` | Usage / version. |
|
|
33
35
|
|
|
34
|
-
Notes: the first path is the check root (config + scan); extra paths are accepted and ignored so agent-invoked runs never crash.
|
|
36
|
+
Notes: the first path is the check root (config + scan); extra paths are accepted and ignored so agent-invoked runs never crash. Unknown flags warn on stderr and are ignored.
|
|
35
37
|
|
|
36
38
|
Default (human) output:
|
|
37
39
|
|
|
@@ -57,6 +59,23 @@ When a baseline or inline suppressions are in play, the last line adds `· N bas
|
|
|
57
59
|
|
|
58
60
|
Each finding costs its weight (default error 10, review 3, signal 1). The total is scaled to a 10-file baseline (`× 10 / max(files, 10)`), so repo size doesn't flatten the score. Score = `100 − penalty` up to a penalty of 75; beyond that it decays smoothly (`25·75/penalty`) and never pins at 0. Repos under 10 files score exactly `100 − total`. Weights live in `fringe.config.json`.
|
|
59
61
|
|
|
62
|
+
## Priority, clusters and fix plans
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
`priority = tier + WCAG level + critical path + blast radius`
|
|
67
|
+
|
|
68
|
+
| Part | Value |
|
|
69
|
+
|---|---|
|
|
70
|
+
| Tier | error 1000 · review 400 · signal 100 (so any error outranks any review, whatever else is true) |
|
|
71
|
+
| WCAG level | A +30 · AA +15 |
|
|
72
|
+
| Critical path | +40 when the file matches `critical` in `fringe.config.json` (e.g. `"critical": ["src/checkout/**", "src/auth/**"]`) |
|
|
73
|
+
| Blast radius | up to +20, growing with the number of files that import this one |
|
|
74
|
+
|
|
75
|
+
A cluster is a **shared component** when 3 or more files import its file; fix those first, since one change clears every use. Repeats lift a cluster slightly (never across a tier). Blast radius matches importers by file base name, a deliberate shortcut.
|
|
76
|
+
|
|
77
|
+
`fringe plan` turns this into the work order; `fringe explain FR-203` gives the recipe for one rule. The `--claude-hook` message includes each shown rule's recipe once, so the agent gets "how to fix it well", not just "what is wrong".
|
|
78
|
+
|
|
60
79
|
## Baseline
|
|
61
80
|
|
|
62
81
|
Adopting Fringe on a repo that already has problems? Don't fix 300 findings first.
|
|
@@ -170,12 +189,13 @@ Optional, at the check root. Everything defaults sensibly; all keys are optional
|
|
|
170
189
|
|
|
171
190
|
```json
|
|
172
191
|
{
|
|
173
|
-
"ignore": ["node_modules/**", "dist/**", "build/**", "out/**", "coverage/**", ".fringe/**", "*.min.*", "examples/**"],
|
|
192
|
+
"ignore": ["node_modules/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", ".fringe/**", "*.min.*", "examples/**"],
|
|
174
193
|
"disable": ["FR-214"],
|
|
175
194
|
"weights": { "error": 10, "review": 3, "signal": 1 }
|
|
176
195
|
}
|
|
177
196
|
```
|
|
178
197
|
|
|
198
|
+
- `critical` — globs for the files that matter most (checkout, auth, sign-up). Their findings rank higher in `plan` and `--json`.
|
|
179
199
|
- `ignore` — glob list matched against paths relative to the root. **Use the `dir/**` form to skip a directory** — a bare-dir glob like `"dist"` matches only the directory entry itself and does **not** prune the files inside it. (`node_modules` and dot-directories are always skipped.)
|
|
180
200
|
- `disable` — rule IDs to turn off.
|
|
181
201
|
- `weights` — per-finding penalties into the health score (see [Health score](#health-score)).
|
package/dist/bin.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { readFileSync as readFileSync8 } from "fs";
|
|
4
|
-
import { resolve } from "path";
|
|
3
|
+
import { existsSync as existsSync3, readFileSync as readFileSync8, statSync } from "fs";
|
|
4
|
+
import { dirname as dirname2, join as join8, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var VERSION = "0.
|
|
7
|
+
var VERSION = "0.2.0";
|
|
8
8
|
|
|
9
9
|
// src/check.ts
|
|
10
10
|
import { readFileSync as readFileSync5 } from "fs";
|
|
@@ -13,7 +13,8 @@ import { readFileSync as readFileSync5 } from "fs";
|
|
|
13
13
|
import { readFileSync } from "fs";
|
|
14
14
|
import { join } from "path";
|
|
15
15
|
var DEFAULT_CONFIG = {
|
|
16
|
-
|
|
16
|
+
// Both forms: `dist/**` matches only at the root, `**/dist/**` matches nested build output (e.g. frontend/dist).
|
|
17
|
+
ignore: ["node_modules/**", "dist/**", "build/**", "out/**", "coverage/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", ".fringe/**", "*.min.*"],
|
|
17
18
|
disable: [],
|
|
18
19
|
weights: { error: 10, review: 3, signal: 1 }
|
|
19
20
|
};
|
|
@@ -28,6 +29,10 @@ function loadConfig(root) {
|
|
|
28
29
|
weights: { ...DEFAULT_CONFIG.weights, ...user.weights ?? {} }
|
|
29
30
|
};
|
|
30
31
|
validateWeights(cfg.weights);
|
|
32
|
+
if (user.critical !== void 0) {
|
|
33
|
+
if (!Array.isArray(user.critical) || user.critical.some((g) => typeof g !== "string")) throw new Error("fringe.config.json: critical must be an array of glob strings");
|
|
34
|
+
cfg.critical = user.critical;
|
|
35
|
+
}
|
|
31
36
|
if (user.tokenSource !== void 0) {
|
|
32
37
|
cfg.tokenSource = validateTokenSource(user.tokenSource);
|
|
33
38
|
}
|
|
@@ -1013,8 +1018,220 @@ function suppressedIds(lines, line) {
|
|
|
1013
1018
|
return ids;
|
|
1014
1019
|
}
|
|
1015
1020
|
|
|
1021
|
+
// src/prioritize.ts
|
|
1022
|
+
import { basename, extname, relative as relative3 } from "path";
|
|
1023
|
+
|
|
1024
|
+
// src/guidance.ts
|
|
1025
|
+
var GUIDANCE = {
|
|
1026
|
+
"FR-201": {
|
|
1027
|
+
why: "A screen reader announces an image with no alt as its file name, or skips it.",
|
|
1028
|
+
recipe: 'Informative image: describe its purpose, not its look (alt="Order #4521 receipt"). Decorative image: alt="".',
|
|
1029
|
+
avoid: 'alt="image", alt="photo", or the file name.',
|
|
1030
|
+
example: { before: '<img src="/hero.png" />', after: '<img src="/hero.png" alt="Team reviewing a dashboard" />' },
|
|
1031
|
+
fixability: "judgment"
|
|
1032
|
+
},
|
|
1033
|
+
"FR-202": {
|
|
1034
|
+
why: 'A button or link with no text is announced as just "button" or "link".',
|
|
1035
|
+
recipe: 'Put visible text inside it. For an icon-only control, add aria-label naming the action and aria-hidden="true" on the icon.',
|
|
1036
|
+
avoid: 'title alone (not reliably announced) or an aria-label that repeats the icon name ("cross").',
|
|
1037
|
+
example: { before: "<button><CloseIcon /></button>", after: '<button aria-label="Close dialog"><CloseIcon aria-hidden="true" /></button>' },
|
|
1038
|
+
fixability: "mechanical"
|
|
1039
|
+
},
|
|
1040
|
+
"FR-203": {
|
|
1041
|
+
why: "A form field with no label is announced without a name, and the placeholder vanishes as soon as the user types.",
|
|
1042
|
+
recipe: "Add a visible <label htmlFor> bound to the input id. Use aria-label only where a visible label is genuinely wrong for the design (e.g. a search box beside a magnifier button).",
|
|
1043
|
+
avoid: "Placeholder as the only label; aria-label when a visible label would fit.",
|
|
1044
|
+
example: { before: '<input id="email" placeholder="Email" />', after: '<label htmlFor="email">Email</label>\n<input id="email" />' },
|
|
1045
|
+
fixability: "mechanical"
|
|
1046
|
+
},
|
|
1047
|
+
"FR-204": {
|
|
1048
|
+
why: "Screen reader users navigate by headings; skipped levels or several h1s break the outline.",
|
|
1049
|
+
recipe: "Use one h1 per page, then h2, h3 in order. Choose the level by structure and style it with CSS.",
|
|
1050
|
+
avoid: "Picking h3 because it looks smaller.",
|
|
1051
|
+
fixability: "mechanical"
|
|
1052
|
+
},
|
|
1053
|
+
"FR-205": {
|
|
1054
|
+
why: "Keyboard users cannot see where focus is when the outline is removed.",
|
|
1055
|
+
recipe: "Keep a visible focus style. Replace outline-none with focus-visible:outline-2 focus-visible:outline-offset-2 (or a :focus-visible rule with a visible outline).",
|
|
1056
|
+
avoid: "outline: none with no replacement; styling :focus only for mouse users.",
|
|
1057
|
+
fixability: "mechanical"
|
|
1058
|
+
},
|
|
1059
|
+
"FR-206": {
|
|
1060
|
+
why: "A clickable div is not reachable by keyboard and has no role for assistive technology.",
|
|
1061
|
+
recipe: 'Use a <button> (or <a href> for navigation). Only if that is impossible, add role="button", tabIndex={0} and Enter/Space key handlers.',
|
|
1062
|
+
avoid: 'role="button" without keyboard handlers.',
|
|
1063
|
+
example: { before: "<div onClick={open}>Open</div>", after: '<button type="button" onClick={open}>Open</button>' },
|
|
1064
|
+
fixability: "mechanical"
|
|
1065
|
+
},
|
|
1066
|
+
"FR-207": {
|
|
1067
|
+
why: "A positive tabIndex pulls the element out of the natural order and makes focus jump unpredictably.",
|
|
1068
|
+
recipe: "Use tabIndex={0} (join the natural order) or -1 (focusable by script only), and fix the order by moving the element in the DOM.",
|
|
1069
|
+
avoid: "tabIndex={1}, {2}... to force an order.",
|
|
1070
|
+
fixability: "mechanical"
|
|
1071
|
+
},
|
|
1072
|
+
"FR-208": {
|
|
1073
|
+
why: "Data that loads, comes back empty or fails leaves the user staring at nothing.",
|
|
1074
|
+
recipe: "Render three states next to the data: loading (skeleton or spinner with a text label), empty (what to do next), error (what failed and a retry).",
|
|
1075
|
+
avoid: "A spinner with no failure path.",
|
|
1076
|
+
fixability: "judgment"
|
|
1077
|
+
},
|
|
1078
|
+
"FR-209": {
|
|
1079
|
+
why: "A misspelled aria-* attribute is ignored, so the intended name or state never reaches the user.",
|
|
1080
|
+
recipe: "Correct the attribute name against the ARIA spec (aria-labelledby, aria-describedby, aria-expanded...).",
|
|
1081
|
+
example: { before: '<input aria-labeledby="h" />', after: '<input aria-labelledby="h" />' },
|
|
1082
|
+
fixability: "mechanical"
|
|
1083
|
+
},
|
|
1084
|
+
"FR-210": {
|
|
1085
|
+
why: "A fake countdown pressures users into rushed decisions.",
|
|
1086
|
+
recipe: "State the real deadline as a date, or remove the urgency wording.",
|
|
1087
|
+
fixability: "judgment"
|
|
1088
|
+
},
|
|
1089
|
+
"FR-211": {
|
|
1090
|
+
why: "A pre-checked consent box opts people in without a decision.",
|
|
1091
|
+
recipe: "Leave consent boxes unchecked and make the choice explicit.",
|
|
1092
|
+
fixability: "mechanical"
|
|
1093
|
+
},
|
|
1094
|
+
"FR-212": {
|
|
1095
|
+
why: "Guilt-trip wording on the decline option manipulates the choice.",
|
|
1096
|
+
recipe: 'Make both options neutral ("No thanks").',
|
|
1097
|
+
fixability: "judgment"
|
|
1098
|
+
},
|
|
1099
|
+
"FR-213": {
|
|
1100
|
+
why: "Infinite scroll with no way to stop traps keyboard users and buries the footer.",
|
|
1101
|
+
recipe: 'Offer a "Load more" button or pagination, and keep the footer reachable.',
|
|
1102
|
+
fixability: "judgment"
|
|
1103
|
+
},
|
|
1104
|
+
"FR-214": {
|
|
1105
|
+
why: '"Free", then a price later, erodes trust.',
|
|
1106
|
+
recipe: "Show the full price up front, including recurring cost.",
|
|
1107
|
+
fixability: "judgment"
|
|
1108
|
+
},
|
|
1109
|
+
"FR-215": {
|
|
1110
|
+
why: '"Something went wrong" tells the user nothing about what to do next.',
|
|
1111
|
+
recipe: "Say what failed in plain words and give a next step: retry, a link, or a way to contact support.",
|
|
1112
|
+
fixability: "judgment"
|
|
1113
|
+
},
|
|
1114
|
+
"FR-301": {
|
|
1115
|
+
why: "Hardcoded colors drift from the design system and make theming painful.",
|
|
1116
|
+
recipe: "Use the project tokens (CSS variables or palette utilities) instead of literal hex values.",
|
|
1117
|
+
fixability: "mechanical"
|
|
1118
|
+
},
|
|
1119
|
+
"FR-302": {
|
|
1120
|
+
why: "Off-scale font sizes break the type rhythm.",
|
|
1121
|
+
recipe: "Use the type scale (text-sm, text-base, ...) or add the size to the theme.",
|
|
1122
|
+
fixability: "mechanical"
|
|
1123
|
+
},
|
|
1124
|
+
"FR-303": {
|
|
1125
|
+
why: "Off-scale spacing makes layouts inconsistent.",
|
|
1126
|
+
recipe: "Use the spacing scale (p-4, gap-2, ...) or extend the theme.",
|
|
1127
|
+
fixability: "mechanical"
|
|
1128
|
+
},
|
|
1129
|
+
"FR-304": {
|
|
1130
|
+
why: "Low contrast text is hard to read for many people, and unreadable in sunlight.",
|
|
1131
|
+
recipe: "Raise the contrast to at least 4.5:1 for body text (3:1 for large text) by darkening the text or lightening the background.",
|
|
1132
|
+
fixability: "judgment"
|
|
1133
|
+
},
|
|
1134
|
+
"FR-305": {
|
|
1135
|
+
why: "Tiny hit areas are hard to tap, especially for people with motor impairments.",
|
|
1136
|
+
recipe: "Give interactive elements at least 24x24px (44x44px on touch) with padding, not just font size.",
|
|
1137
|
+
fixability: "mechanical"
|
|
1138
|
+
},
|
|
1139
|
+
"FR-361": {
|
|
1140
|
+
why: "Italic serif display headlines are a recognisable AI-generated look.",
|
|
1141
|
+
recipe: "Choose type from the project design system for headlines.",
|
|
1142
|
+
fixability: "judgment"
|
|
1143
|
+
},
|
|
1144
|
+
"FR-362": {
|
|
1145
|
+
why: "The uppercase eyebrow chip above a headline is a recognisable AI-generated look.",
|
|
1146
|
+
recipe: "Drop the chip or fold the content into the heading or subheading.",
|
|
1147
|
+
fixability: "judgment"
|
|
1148
|
+
},
|
|
1149
|
+
"FR-363": {
|
|
1150
|
+
why: "Cards nested in cards add visual noise and hide hierarchy.",
|
|
1151
|
+
recipe: "Flatten: use spacing and one border level.",
|
|
1152
|
+
fixability: "judgment"
|
|
1153
|
+
},
|
|
1154
|
+
"FR-364": {
|
|
1155
|
+
why: "A stack of icon tiles is a recognisable AI-generated layout.",
|
|
1156
|
+
recipe: "Replace with content that carries information, or vary the structure.",
|
|
1157
|
+
fixability: "judgment"
|
|
1158
|
+
},
|
|
1159
|
+
"FR-365": {
|
|
1160
|
+
why: "A thin border with a wide shadow signals elevation inconsistently.",
|
|
1161
|
+
recipe: "Pick one: a border for flat surfaces or a soft shadow for raised ones.",
|
|
1162
|
+
fixability: "judgment"
|
|
1163
|
+
},
|
|
1164
|
+
"FR-366": {
|
|
1165
|
+
why: "Many status chips make a page read as noise.",
|
|
1166
|
+
recipe: "Keep chips that carry state the user acts on; merge or drop the rest.",
|
|
1167
|
+
fixability: "judgment"
|
|
1168
|
+
},
|
|
1169
|
+
"FR-367": {
|
|
1170
|
+
why: '"01 Discover" style labels are a recognisable AI-generated look.',
|
|
1171
|
+
recipe: "Use plain section headings.",
|
|
1172
|
+
fixability: "judgment"
|
|
1173
|
+
},
|
|
1174
|
+
"FR-368": {
|
|
1175
|
+
why: "The violet/indigo gradient is a recognisable AI-generated look.",
|
|
1176
|
+
recipe: "Use the project brand colors.",
|
|
1177
|
+
fixability: "judgment"
|
|
1178
|
+
},
|
|
1179
|
+
"FR-369": {
|
|
1180
|
+
why: "Emoji in headings read as decoration and can be announced noisily.",
|
|
1181
|
+
recipe: "Remove the emoji, or put a real icon beside the heading with aria-hidden.",
|
|
1182
|
+
fixability: "judgment"
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
function renderExplain(id) {
|
|
1186
|
+
if (!id) return ALL_RULES.map((r) => `${r.id} ${r.severity.padEnd(6)} ${GUIDANCE[r.id].why}`).join("\n");
|
|
1187
|
+
const rule = ALL_RULES.find((r) => r.id === id.toUpperCase());
|
|
1188
|
+
if (!rule) return void 0;
|
|
1189
|
+
const g = GUIDANCE[rule.id];
|
|
1190
|
+
const out = [`${rule.id} \xB7 ${rule.severity} \xB7 ${g.fixability} fix`, `Why: ${g.why}`, `Fix: ${g.recipe}`];
|
|
1191
|
+
if (g.avoid) out.push(`Avoid: ${g.avoid}`);
|
|
1192
|
+
if (g.example) out.push("Before: " + g.example.before.replace(/\n/g, "\n "), "After: " + g.example.after.replace(/\n/g, "\n "));
|
|
1193
|
+
return out.join("\n");
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// src/prioritize.ts
|
|
1197
|
+
var TIER = { error: 1e3, review: 400, signal: 100 };
|
|
1198
|
+
var WCAG_A = /* @__PURE__ */ new Set(["1.1.1", "1.3.1", "2.1.1", "2.4.3", "3.3.2", "4.1.2"]);
|
|
1199
|
+
var WCAG_AA = /* @__PURE__ */ new Set(["1.4.3", "2.4.7", "2.5.8"]);
|
|
1200
|
+
var CRITICAL_BONUS = 40;
|
|
1201
|
+
var SHARED_FROM = 3;
|
|
1202
|
+
var IMPORT = /(?:from\s+|import\s*\(\s*|require\(\s*)['"]([^'"]+)['"]/g;
|
|
1203
|
+
function importerCounts(sources) {
|
|
1204
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1205
|
+
for (const src of sources.values()) {
|
|
1206
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1207
|
+
for (const m of src.matchAll(IMPORT)) seen.add(basename(m[1], extname(m[1])));
|
|
1208
|
+
for (const b of seen) counts.set(b, (counts.get(b) ?? 0) + 1);
|
|
1209
|
+
}
|
|
1210
|
+
return counts;
|
|
1211
|
+
}
|
|
1212
|
+
function prioritize(findings, sources, root, critical) {
|
|
1213
|
+
const crit = critical.map(globToRegex);
|
|
1214
|
+
const importers = importerCounts(sources);
|
|
1215
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
1216
|
+
const out = findings.map((f) => {
|
|
1217
|
+
const rel = relative3(root, f.loc.file);
|
|
1218
|
+
const blast = Math.min(20, 4 * Math.log2(1 + (importers.get(basename(rel, extname(rel))) ?? 0)));
|
|
1219
|
+
const level = f.wcag && WCAG_A.has(f.wcag) ? 30 : f.wcag && WCAG_AA.has(f.wcag) ? 15 : 0;
|
|
1220
|
+
const priority = Math.round(TIER[f.severity] + level + (crit.some((r) => r.test(rel)) ? CRITICAL_BONUS : 0) + blast);
|
|
1221
|
+
const id = `${f.rule}@${rel}`;
|
|
1222
|
+
const by = importers.get(basename(rel, extname(rel))) ?? 0;
|
|
1223
|
+
const c = clusters.get(id) ?? { id, rule: f.rule, file: rel, count: 0, priority: 0, fixability: GUIDANCE[f.rule]?.fixability ?? "judgment", rootCause: by >= SHARED_FROM ? "shared component" : "local", importedBy: by, critical: crit.some((r) => r.test(rel)) };
|
|
1224
|
+
c.count++;
|
|
1225
|
+
c.priority = Math.max(c.priority, priority);
|
|
1226
|
+
clusters.set(id, c);
|
|
1227
|
+
return { ...f, priority, cluster: id, fixability: c.fixability };
|
|
1228
|
+
});
|
|
1229
|
+
for (const c of clusters.values()) c.priority += Math.round(Math.min(15, 3 * Math.log2(c.count)));
|
|
1230
|
+
return { findings: out, clusters: [...clusters.values()].sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id)) };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1016
1233
|
// src/check.ts
|
|
1017
|
-
async function runCheck(root, cfg = loadConfig(root), useBaseline = true) {
|
|
1234
|
+
async function runCheck(root, cfg = loadConfig(root), useBaseline = true, only) {
|
|
1018
1235
|
const stack = detectStack(root);
|
|
1019
1236
|
const files = walk(root, cfg);
|
|
1020
1237
|
const rules = activeRules(cfg);
|
|
@@ -1050,6 +1267,7 @@ async function runCheck(root, cfg = loadConfig(root), useBaseline = true) {
|
|
|
1050
1267
|
const tokenColors = gatherTokenColors(root, cfg, cssSources);
|
|
1051
1268
|
for (const ctx of contexts) ctx.tokenColors = tokenColors;
|
|
1052
1269
|
for (const ctx of contexts) {
|
|
1270
|
+
if (only && !only.has(ctx.file)) continue;
|
|
1053
1271
|
for (const rule of rules) findings.push(...rule.check(ctx));
|
|
1054
1272
|
}
|
|
1055
1273
|
const lines = new Map(contexts.map((c) => [c.file, c.source.split("\n")]));
|
|
@@ -1058,7 +1276,8 @@ async function runCheck(root, cfg = loadConfig(root), useBaseline = true) {
|
|
|
1058
1276
|
const { fresh, known } = useBaseline ? applyBaseline(root, live) : { fresh: live, known: 0 };
|
|
1059
1277
|
const counts = { error: 0, review: 0, signal: 0 };
|
|
1060
1278
|
for (const f of fresh) counts[f.severity]++;
|
|
1061
|
-
|
|
1279
|
+
const ranked = prioritize(fresh, new Map(contexts.map((c) => [c.file, c.source])), root, cfg.critical ?? []);
|
|
1280
|
+
return { stack, files: only ? only.size : files.length, skipped, findings: ranked.findings, clusters: ranked.clusters, counts, score: score(counts, cfg.weights, only ? only.size : files.length), ...known ? { baselined: known } : {}, ...suppressed ? { suppressed } : {} };
|
|
1062
1281
|
}
|
|
1063
1282
|
|
|
1064
1283
|
// src/report/json.ts
|
|
@@ -1067,7 +1286,7 @@ function toJson(r) {
|
|
|
1067
1286
|
}
|
|
1068
1287
|
|
|
1069
1288
|
// src/report/hook.ts
|
|
1070
|
-
import { relative as
|
|
1289
|
+
import { relative as relative4 } from "path";
|
|
1071
1290
|
var RANK = { error: 0, review: 1, signal: 2 };
|
|
1072
1291
|
function topFinding(r) {
|
|
1073
1292
|
return [...r.findings].sort((a, b) => {
|
|
@@ -1091,13 +1310,20 @@ function toHook(r) {
|
|
|
1091
1310
|
function toClaudeHook(r, root) {
|
|
1092
1311
|
const errs = r.findings.filter((f) => f.severity === "error");
|
|
1093
1312
|
if (errs.length === 0) return "";
|
|
1094
|
-
const shown = errs.slice(0, 8).map((f) => `- ${f.rule} ${
|
|
1313
|
+
const shown = errs.slice(0, 8).map((f) => `- ${f.rule} ${relative4(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
|
|
1314
|
+
const shownRules = [...new Set(errs.slice(0, 8).map((f) => f.rule))];
|
|
1315
|
+
const recipes = shownRules.map((id) => {
|
|
1316
|
+
const g = GUIDANCE[id];
|
|
1317
|
+
return g ? `- ${id}: ${g.recipe}${g.avoid ? ` Avoid: ${g.avoid}` : ""}` : "";
|
|
1318
|
+
}).filter(Boolean);
|
|
1095
1319
|
const more = errs.length > shown.length ? `
|
|
1096
1320
|
\u2026and ${errs.length - shown.length} more (run \`fringe check\`).` : "";
|
|
1097
1321
|
return JSON.stringify({
|
|
1098
1322
|
decision: "block",
|
|
1099
1323
|
reason: `Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
|
|
1100
1324
|
${shown.join("\n")}${more}
|
|
1325
|
+
How to fix them well:
|
|
1326
|
+
${recipes.join("\n")}
|
|
1101
1327
|
Use a fringe-ignore comment only for a genuine false positive, and say so.`
|
|
1102
1328
|
});
|
|
1103
1329
|
}
|
|
@@ -1226,6 +1452,66 @@ function initProject(root) {
|
|
|
1226
1452
|
return done;
|
|
1227
1453
|
}
|
|
1228
1454
|
|
|
1455
|
+
// src/report/plan.ts
|
|
1456
|
+
var TITLES = {
|
|
1457
|
+
P0: ["Fix first", "errors in shared components or critical paths: one fix clears many uses"],
|
|
1458
|
+
P1: ["Then the other errors", "blocking problems; mechanical fixes first"],
|
|
1459
|
+
P2: ["Review", "heuristics that need your judgment"],
|
|
1460
|
+
P3: ["Polish", "advice only; never blocks"]
|
|
1461
|
+
};
|
|
1462
|
+
function buildPlan(r, limit = 15) {
|
|
1463
|
+
const lines = /* @__PURE__ */ new Map();
|
|
1464
|
+
for (const f of r.findings) if (f.cluster) lines.set(f.cluster, [...lines.get(f.cluster) ?? [], f.loc.line]);
|
|
1465
|
+
const sev = new Map(r.findings.map((f) => [f.cluster, f.severity]));
|
|
1466
|
+
const items = (r.clusters ?? []).map((c) => ({ ...c, lines: lines.get(c.id) ?? [], recipe: GUIDANCE[c.rule]?.recipe, avoid: GUIDANCE[c.rule]?.avoid }));
|
|
1467
|
+
const mechFirst = (a, b) => (a.fixability === b.fixability ? 0 : a.fixability === "mechanical" ? -1 : 1) || b.priority - a.priority;
|
|
1468
|
+
const pick = (id) => items.filter((c) => {
|
|
1469
|
+
const s = sev.get(c.id);
|
|
1470
|
+
if (id === "P0") return s === "error" && (c.rootCause === "shared component" || c.critical);
|
|
1471
|
+
if (id === "P1") return s === "error" && !(c.rootCause === "shared component" || c.critical);
|
|
1472
|
+
return s === (id === "P2" ? "review" : "signal");
|
|
1473
|
+
}).sort(id === "P1" ? mechFirst : (a, b) => b.priority - a.priority);
|
|
1474
|
+
return ["P0", "P1", "P2", "P3"].map((id) => {
|
|
1475
|
+
const all = pick(id);
|
|
1476
|
+
return { id, title: TITLES[id][0], why: TITLES[id][1], items: all.slice(0, limit), more: Math.max(0, all.length - limit) };
|
|
1477
|
+
}).filter((b) => b.items.length > 0);
|
|
1478
|
+
}
|
|
1479
|
+
function toPlanJson(r, limit) {
|
|
1480
|
+
return JSON.stringify({ schema: "fringe/plan.v1", score: r.score, counts: r.counts, batches: buildPlan(r, limit) }, null, 2);
|
|
1481
|
+
}
|
|
1482
|
+
function toPlanMarkdown(r, target, limit) {
|
|
1483
|
+
const batches = buildPlan(r, limit);
|
|
1484
|
+
const head = `# Fringe fix plan
|
|
1485
|
+
|
|
1486
|
+
health ${r.score}/100 \xB7 ${r.counts.error} errors \xB7 ${r.counts.review} review \xB7 ${r.counts.signal} signals`;
|
|
1487
|
+
if (batches.length === 0) return `${head}
|
|
1488
|
+
|
|
1489
|
+
Nothing to fix.`;
|
|
1490
|
+
const out = [head];
|
|
1491
|
+
for (const b of batches) {
|
|
1492
|
+
out.push(`
|
|
1493
|
+
## ${b.id} \xB7 ${b.title}
|
|
1494
|
+
_${b.why}_`);
|
|
1495
|
+
const shown = /* @__PURE__ */ new Set();
|
|
1496
|
+
for (const c of b.items) {
|
|
1497
|
+
const tags = [c.rootCause === "shared component" ? `shared component, imported by ${c.importedBy} files` : "", c.critical ? "critical path" : "", `${c.fixability} fix`].filter(Boolean);
|
|
1498
|
+
out.push(`
|
|
1499
|
+
### ${c.rule} \xB7 \xD7${c.count} \xB7 ${c.file} (${tags.join("; ")})`);
|
|
1500
|
+
out.push(`Lines: ${c.lines.slice(0, 8).join(", ")}${c.lines.length > 8 ? ", \u2026" : ""}`);
|
|
1501
|
+
if (c.recipe && !shown.has(c.rule)) {
|
|
1502
|
+
shown.add(c.rule);
|
|
1503
|
+
out.push(`Fix: ${c.recipe}${c.avoid ? `
|
|
1504
|
+
Avoid: ${c.avoid}` : ""}`);
|
|
1505
|
+
} else if (c.recipe) out.push(`Fix: as for ${c.rule} above (\`fringe explain ${c.rule}\`).`);
|
|
1506
|
+
}
|
|
1507
|
+
if (b.more) out.push(`
|
|
1508
|
+
\u2026and ${b.more} more in this batch (raise --limit).`);
|
|
1509
|
+
}
|
|
1510
|
+
out.push(`
|
|
1511
|
+
After each batch, verify: \`fringe check ${target}\``);
|
|
1512
|
+
return out.join("\n");
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1229
1515
|
// src/index.ts
|
|
1230
1516
|
var log = (o) => o.log ?? ((s) => console.log(s));
|
|
1231
1517
|
var err = (o) => o.err ?? ((s) => process.stderr.write(s));
|
|
@@ -1255,10 +1541,24 @@ function readManifest(path2, warn) {
|
|
|
1255
1541
|
return void 0;
|
|
1256
1542
|
}
|
|
1257
1543
|
}
|
|
1544
|
+
function resolveTarget(arg) {
|
|
1545
|
+
if (!statSync(arg).isFile()) return { root: arg };
|
|
1546
|
+
let dir = dirname2(arg);
|
|
1547
|
+
for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
|
|
1548
|
+
if (existsSync3(join8(d, "package.json")) || existsSync3(join8(d, "fringe.config.json"))) {
|
|
1549
|
+
dir = d;
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
if (dirname2(d) === d) break;
|
|
1553
|
+
}
|
|
1554
|
+
return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
|
|
1555
|
+
}
|
|
1258
1556
|
var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\/?a|none|tbd|obvious|unnecessary|clutter|unused)\.?$/i.test(e.trim());
|
|
1259
1557
|
var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
1260
1558
|
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
1261
1559
|
fringe check --update-baseline [path] accept today's findings; only new ones gate
|
|
1560
|
+
fringe plan [dir|file] [--json] [--limit n] ordered fix batches with recipes
|
|
1561
|
+
fringe explain [FR-203] why a rule matters and how to fix it well
|
|
1262
1562
|
fringe init [path] add the Claude Code hook + skill
|
|
1263
1563
|
fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
|
|
1264
1564
|
fringe --help | --version`;
|
|
@@ -1290,7 +1590,7 @@ async function runCli(argv, opts = {}) {
|
|
|
1290
1590
|
try {
|
|
1291
1591
|
const paths = parseArgs(rest, err(opts));
|
|
1292
1592
|
const cwd = opts.cwd ?? process.cwd();
|
|
1293
|
-
const root = paths.length > 0 ? resolve(cwd, paths[0]) : cwd;
|
|
1593
|
+
const { root, only } = resolveTarget(paths.length > 0 ? resolve(cwd, paths[0]) : cwd);
|
|
1294
1594
|
const cfg = loadConfig(root);
|
|
1295
1595
|
if (rest.includes("--update-baseline")) {
|
|
1296
1596
|
const all = await runCheck(root, cfg, false);
|
|
@@ -1298,7 +1598,7 @@ async function runCli(argv, opts = {}) {
|
|
|
1298
1598
|
log(opts)(`fringe: baseline written \u2014 ${all.findings.length} known findings in fringe.baseline.json (commit it); only new ones gate from now on`);
|
|
1299
1599
|
return 0;
|
|
1300
1600
|
}
|
|
1301
|
-
const result = await runCheck(root, cfg);
|
|
1601
|
+
const result = await runCheck(root, cfg, true, only);
|
|
1302
1602
|
if (rest.includes("--claude-hook")) {
|
|
1303
1603
|
const out = toClaudeHook(result, root);
|
|
1304
1604
|
if (out) log(opts)(out);
|
|
@@ -1330,6 +1630,32 @@ async function runCli(argv, opts = {}) {
|
|
|
1330
1630
|
return 2;
|
|
1331
1631
|
}
|
|
1332
1632
|
}
|
|
1633
|
+
if (cmd === "plan") {
|
|
1634
|
+
try {
|
|
1635
|
+
const li = rest.indexOf("--limit");
|
|
1636
|
+
const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
|
|
1637
|
+
const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json"), err(opts));
|
|
1638
|
+
const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
|
|
1639
|
+
const { root, only } = resolveTarget(arg);
|
|
1640
|
+
const result = await runCheck(root, loadConfig(root), true, only);
|
|
1641
|
+
log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
|
|
1642
|
+
return 0;
|
|
1643
|
+
} catch (e) {
|
|
1644
|
+
err(opts)(`fringe: internal error: ${e.message}
|
|
1645
|
+
`);
|
|
1646
|
+
return 2;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
if (cmd === "explain") {
|
|
1650
|
+
const out = renderExplain(rest[0]);
|
|
1651
|
+
if (out === void 0) {
|
|
1652
|
+
err(opts)(`fringe: unknown rule '${rest[0]}' \u2014 run 'fringe explain' to list them
|
|
1653
|
+
`);
|
|
1654
|
+
return 2;
|
|
1655
|
+
}
|
|
1656
|
+
log(opts)(out);
|
|
1657
|
+
return 0;
|
|
1658
|
+
}
|
|
1333
1659
|
if (cmd === "init") {
|
|
1334
1660
|
try {
|
|
1335
1661
|
for (const line of initProject(resolve(opts.cwd ?? process.cwd(), rest[0] ?? "."))) log(opts)(`fringe: ${line}`);
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
package/skill/fringe/SKILL.md
CHANGED
|
@@ -11,6 +11,7 @@ Ponytail trims your code. Fringe handles your edges. Work the ladder in order.
|
|
|
11
11
|
|
|
12
12
|
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.
|
|
13
13
|
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`.
|
|
14
|
+
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.
|
|
14
15
|
3. **Polish (verified, never gates).** Run `fringe check` once before finishing. Fix `review` items you agree with. Fix `signal` items that are cheap. Say which you left and why.
|
|
15
16
|
|
|
16
17
|
When done: `fringe check --record --manifest .fringe/manifest.json`. Report the health score as measured; never estimate it yourself.
|
|
@@ -7,7 +7,7 @@ description: Use when the user wants a whole-repo UX/accessibility audit, a plan
|
|
|
7
7
|
|
|
8
8
|
Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place (e.g. `npx -y @fringetools/cli check --json .`).
|
|
9
9
|
|
|
10
|
-
1. Run `fringe
|
|
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:
|
|
12
12
|
- Totals by severity and the health score, plus files scanned and skipped.
|
|
13
13
|
- **Top rules** by count, and **top files** by error count (the 5 worst of each).
|
|
@@ -24,6 +24,8 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
|
|
|
24
24
|
| `check [dir]` | Scan and print findings and the health score; exit 1 on any error |
|
|
25
25
|
| `check --json` / `--hook` / `--claude-hook` | Machine output; CI gate; agent feedback form |
|
|
26
26
|
| `check --update-baseline` | Accept today's findings; only new ones gate |
|
|
27
|
+
| `plan [dir\|file]` | Ordered fix batches (shared components first) with recipes |
|
|
28
|
+
| `explain [FR-203]` | Why a rule matters and how to fix it well |
|
|
27
29
|
| `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
|
|
28
30
|
| `init` | Add the hook and install the skills |
|
|
29
31
|
| `open` | View the ledger at 127.0.0.1:4173 |
|
|
@@ -7,7 +7,7 @@ description: Use when the user asks to review, check or critique a UI, page or c
|
|
|
7
7
|
|
|
8
8
|
Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place (e.g. `npx -y @fringetools/cli check --json .`).
|
|
9
9
|
|
|
10
|
-
1. Run `fringe check --json
|
|
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:
|
|
12
12
|
- **Must fix** — `error` findings. Group by rule. For each: where (`file:line`), what is wrong in one sentence, the exact fix, and who is hurt (for example "a screen reader says only 'button'"). Cite the `wcag` criterion when present.
|
|
13
13
|
- **Worth a look** — `review` findings. These are heuristics: say they need your judgment, and give your call on each.
|