@fringetools/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +220 -2
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +7 -0
- package/dist/chunk-AOUZJDQL.js +1353 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/package.json +51 -4
- package/skill/SKILL.md +21 -0
|
@@ -0,0 +1,1353 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
|
|
6
|
+
// src/version.ts
|
|
7
|
+
var VERSION = "0.1.0";
|
|
8
|
+
|
|
9
|
+
// src/check.ts
|
|
10
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
11
|
+
|
|
12
|
+
// src/config.ts
|
|
13
|
+
import { readFileSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
var DEFAULT_CONFIG = {
|
|
16
|
+
ignore: ["node_modules/**", "dist/**", "build/**", "out/**", "coverage/**", ".fringe/**", "*.min.*"],
|
|
17
|
+
disable: [],
|
|
18
|
+
weights: { error: 10, review: 3, signal: 1 }
|
|
19
|
+
};
|
|
20
|
+
var TOKEN_SOURCE_KINDS = ["css-vars", "theme-factory", "design-tokens-json"];
|
|
21
|
+
function loadConfig(root) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = readFileSync(join(root, "fringe.config.json"), "utf8");
|
|
24
|
+
const user = JSON.parse(raw);
|
|
25
|
+
const cfg = {
|
|
26
|
+
ignore: Array.isArray(user.ignore) ? user.ignore : [...DEFAULT_CONFIG.ignore],
|
|
27
|
+
disable: Array.isArray(user.disable) ? user.disable : [...DEFAULT_CONFIG.disable],
|
|
28
|
+
weights: { ...DEFAULT_CONFIG.weights, ...user.weights ?? {} }
|
|
29
|
+
};
|
|
30
|
+
validateWeights(cfg.weights);
|
|
31
|
+
if (user.tokenSource !== void 0) {
|
|
32
|
+
cfg.tokenSource = validateTokenSource(user.tokenSource);
|
|
33
|
+
}
|
|
34
|
+
return cfg;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.code === "ENOENT") return structuredClone(DEFAULT_CONFIG);
|
|
37
|
+
if (e instanceof SyntaxError) throw new Error("fringe.config.json: invalid JSON");
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function validateTokenSource(ts) {
|
|
42
|
+
let problem = null;
|
|
43
|
+
if (!ts || typeof ts !== "object" || !TOKEN_SOURCE_KINDS.includes(ts.kind)) {
|
|
44
|
+
problem = `tokenSource.kind must be one of: ${TOKEN_SOURCE_KINDS.join(", ")}`;
|
|
45
|
+
} else if (ts.kind === "design-tokens-json" && (typeof ts.path !== "string" || ts.path.trim() === "")) {
|
|
46
|
+
problem = "tokenSource.path is required when kind is design-tokens-json";
|
|
47
|
+
}
|
|
48
|
+
if (problem) throw new Error("fringe.config.json: " + problem);
|
|
49
|
+
return ts;
|
|
50
|
+
}
|
|
51
|
+
function validateWeights(weights) {
|
|
52
|
+
try {
|
|
53
|
+
for (const k of ["error", "review", "signal"]) {
|
|
54
|
+
if (typeof weights[k] !== "number" || Number.isNaN(weights[k]))
|
|
55
|
+
throw new Error(`weights.${k} must be a number`);
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
throw new Error("fringe.config.json: " + (e instanceof Error ? e.message : String(e)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/detect.ts
|
|
63
|
+
import { readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
64
|
+
import { join as join2 } from "path";
|
|
65
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "coverage"]);
|
|
66
|
+
function findFile(dir, match, depth) {
|
|
67
|
+
if (depth > 6) return false;
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
if (e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
|
|
76
|
+
if (e.isDirectory()) {
|
|
77
|
+
if (findFile(join2(dir, e.name), match, depth + 1)) return true;
|
|
78
|
+
} else if (match(e.name)) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
function detectStack(root) {
|
|
83
|
+
let deps = {};
|
|
84
|
+
try {
|
|
85
|
+
const pkg = JSON.parse(readFileSync2(join2(root, "package.json"), "utf8"));
|
|
86
|
+
deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
const has = (...names) => names.some((n) => deps[n]);
|
|
90
|
+
return {
|
|
91
|
+
jsx: has("react", "react-dom", "preact", "solid-js") || findFile(root, (n) => n.endsWith(".jsx") || n.endsWith(".tsx"), 0),
|
|
92
|
+
tailwind: has("tailwindcss"),
|
|
93
|
+
vue: has("vue", "nuxt"),
|
|
94
|
+
svelte: has("svelte"),
|
|
95
|
+
html: findFile(root, (n) => n.endsWith(".html"), 0)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/walk.ts
|
|
100
|
+
import { readdirSync as readdirSync2 } from "fs";
|
|
101
|
+
import { join as join3, relative } from "path";
|
|
102
|
+
var EXTS = [".jsx", ".tsx", ".js", ".ts", ".html", ".css", ".vue", ".svelte", ".astro", ".scss"];
|
|
103
|
+
function globToRegex(g) {
|
|
104
|
+
const esc2 = g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\0/g, ".*");
|
|
105
|
+
return new RegExp("^" + esc2 + "$");
|
|
106
|
+
}
|
|
107
|
+
function walk(root, cfg) {
|
|
108
|
+
const ignores = cfg.ignore.map(globToRegex);
|
|
109
|
+
const out = [];
|
|
110
|
+
const rec = (dir, depth) => {
|
|
111
|
+
if (depth > 12) return;
|
|
112
|
+
for (const e of readdirSync2(dir, { withFileTypes: true })) {
|
|
113
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
114
|
+
const p = join3(dir, e.name);
|
|
115
|
+
const rel = relative(root, p);
|
|
116
|
+
if (e.isDirectory()) rec(p, depth + 1);
|
|
117
|
+
else if (EXTS.some((x) => e.name.endsWith(x)) && !ignores.some((r) => r.test(rel))) out.push(p);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
rec(root, 0);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/parse/jsx.ts
|
|
125
|
+
import { parse } from "@babel/parser";
|
|
126
|
+
function attrValue(v) {
|
|
127
|
+
if (v == null) return "true";
|
|
128
|
+
if (v.type === "StringLiteral") return v.value;
|
|
129
|
+
if (v.type === "JSXExpressionContainer" && v.expression?.type === "StringLiteral") return v.expression.value;
|
|
130
|
+
return "{\u2026}";
|
|
131
|
+
}
|
|
132
|
+
function collectText(node) {
|
|
133
|
+
let t = "";
|
|
134
|
+
for (const c of node.children ?? []) {
|
|
135
|
+
if (c.type === "JSXText") t += c.value;
|
|
136
|
+
else if (c.type === "JSXExpressionContainer" && c.expression?.type === "StringLiteral") t += c.expression.value;
|
|
137
|
+
else if (c.type === "JSXExpressionContainer" && c.expression?.type !== "JSXEmptyExpression") t += "{\u2026}";
|
|
138
|
+
else if (c.type === "JSXElement") t += collectText(c);
|
|
139
|
+
}
|
|
140
|
+
return t.trim();
|
|
141
|
+
}
|
|
142
|
+
function parseJsx(file, source) {
|
|
143
|
+
let ast;
|
|
144
|
+
try {
|
|
145
|
+
ast = parse(source, { sourceType: "unambiguous", plugins: ["jsx", "typescript"], errorRecovery: false });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
throw new Error(`parse-skip: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
const elems = [];
|
|
150
|
+
(function walkNode(node, parent) {
|
|
151
|
+
if (!node || typeof node.type !== "string") return;
|
|
152
|
+
let nextParent = parent;
|
|
153
|
+
if (node.type === "JSXElement") {
|
|
154
|
+
const nameNode = node.openingElement.name;
|
|
155
|
+
const tag2 = nameNode.type === "JSXIdentifier" ? nameNode.name : `${nameNode.object?.name ?? ""}.${nameNode.property?.name ?? ""}`;
|
|
156
|
+
const attrs = {};
|
|
157
|
+
for (const a of node.openingElement.attributes) {
|
|
158
|
+
if (a.type === "JSXAttribute") attrs[a.name.name] = attrValue(a.value);
|
|
159
|
+
}
|
|
160
|
+
const el = { tag: tag2, attrs, text: collectText(node), loc: { file, line: node.loc?.start.line ?? 0, col: node.loc?.start.column ?? 0 } };
|
|
161
|
+
if (parent) el.parentTag = parent.tag;
|
|
162
|
+
if (parent?.inLabel) el.inLabel = true;
|
|
163
|
+
if (parent?.attrs?.className !== void 0) el.parentClass = parent.attrs.className;
|
|
164
|
+
elems.push(el);
|
|
165
|
+
nextParent = { tag: tag2, attrs, inLabel: tag2 === "label" || parent?.inLabel };
|
|
166
|
+
}
|
|
167
|
+
for (const k of Object.keys(node)) {
|
|
168
|
+
const c = node[k];
|
|
169
|
+
if (Array.isArray(c)) c.forEach((x) => typeof x === "object" && x && typeof x.type === "string" && walkNode(x, nextParent));
|
|
170
|
+
else if (c && typeof c === "object" && typeof c.type === "string") walkNode(c, nextParent);
|
|
171
|
+
}
|
|
172
|
+
})(ast.program);
|
|
173
|
+
return elems;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/parse/html.ts
|
|
177
|
+
import { parse as parse2 } from "parse5";
|
|
178
|
+
var textOf = (n) => n.nodeName === "#text" ? n.value : (n.childNodes ?? []).map(textOf).join("");
|
|
179
|
+
function parseHtml(file, source) {
|
|
180
|
+
const doc = parse2(source, { sourceCodeLocationInfo: true });
|
|
181
|
+
const elems = [];
|
|
182
|
+
(function walkNode(node, parent) {
|
|
183
|
+
if (node?.tagName) {
|
|
184
|
+
const attrs = {};
|
|
185
|
+
for (const a of node.attrs ?? []) attrs[a.name] = a.value;
|
|
186
|
+
const text = textOf(node).trim();
|
|
187
|
+
const el = { tag: node.tagName, attrs, text, loc: { file, line: node.sourceCodeLocation?.startLine ?? 0, col: node.sourceCodeLocation?.startCol ?? 0 } };
|
|
188
|
+
if (parent) el.parentTag = parent.tag;
|
|
189
|
+
if (parent?.inLabel) el.inLabel = true;
|
|
190
|
+
if (parent?.attrs?.class !== void 0) el.parentClass = parent.attrs.class;
|
|
191
|
+
elems.push(el);
|
|
192
|
+
for (const c of node.childNodes ?? []) walkNode(c, { tag: node.tagName, attrs, inLabel: node.tagName === "label" || parent?.inLabel });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const c of node?.childNodes ?? []) walkNode(c, parent);
|
|
196
|
+
})(doc);
|
|
197
|
+
return elems;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/parse/css.ts
|
|
201
|
+
import postcss from "postcss";
|
|
202
|
+
function parseCss(file, source) {
|
|
203
|
+
const decls = [];
|
|
204
|
+
const rec = (node, selector) => {
|
|
205
|
+
for (const child of node.nodes ?? []) {
|
|
206
|
+
if (child.type === "decl") {
|
|
207
|
+
decls.push({ prop: child.prop, value: String(child.value), selector, loc: { file, line: child.source?.start?.line ?? 0, col: child.source?.start?.column ?? 0 } });
|
|
208
|
+
} else if (child.type === "rule" || child.type === "atrule") {
|
|
209
|
+
rec(child, child.type === "rule" ? child.selector : selector);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
try {
|
|
214
|
+
rec(postcss.parse(source), "");
|
|
215
|
+
} catch (e) {
|
|
216
|
+
throw new Error(`parse-skip: ${e.message}`);
|
|
217
|
+
}
|
|
218
|
+
return decls;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/rules/fr201.ts
|
|
222
|
+
var fr201 = {
|
|
223
|
+
id: "FR-201",
|
|
224
|
+
rung: 2,
|
|
225
|
+
severity: "error",
|
|
226
|
+
check: (ctx) => ctx.elems.filter((e) => e.tag === "img" && e.attrs.alt === void 0).map((e) => ({ rule: "FR-201", severity: "error", message: `<img> missing alt text`, hint: `add alt="description" (or alt="" if decorative)`, loc: e.loc, wcag: "1.1.1" }))
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// src/rules/fr202.ts
|
|
230
|
+
var NAME_ATTRS = ["aria-label", "aria-labelledby", "title"];
|
|
231
|
+
var fr202 = {
|
|
232
|
+
id: "FR-202",
|
|
233
|
+
rung: 2,
|
|
234
|
+
severity: "error",
|
|
235
|
+
check: (ctx) => ctx.elems.filter((e) => ["button", "a"].includes(e.tag) && e.attrs["aria-hidden"] !== "true").filter((e) => !e.text && !NAME_ATTRS.some((a) => e.attrs[a])).map((e) => ({ rule: "FR-202", severity: "error", message: `<${e.tag}> has no accessible name`, hint: `add text content or aria-label`, loc: e.loc, wcag: "4.1.2" }))
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// src/rules/fr203.ts
|
|
239
|
+
var fr203 = {
|
|
240
|
+
id: "FR-203",
|
|
241
|
+
rung: 2,
|
|
242
|
+
severity: "error",
|
|
243
|
+
check: (ctx) => ctx.elems.filter((e) => e.tag === "input" && e.attrs.type !== "hidden" && !e.inLabel && e.attrs.hidden === void 0).filter((e) => {
|
|
244
|
+
const labeled = e.attrs["aria-label"] || e.attrs["aria-labelledby"] || e.attrs.id && new RegExp(`(htmlFor|for)=["'\`{]${e.attrs.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'\`}]`).test(ctx.source);
|
|
245
|
+
return !labeled;
|
|
246
|
+
}).map((e) => ({ rule: "FR-203", severity: "error", message: `input not associated with a label`, hint: `add id + <label htmlFor>, or aria-label`, loc: e.loc, wcag: "3.3.2" }))
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// src/rules/fr204.ts
|
|
250
|
+
var fr204 = {
|
|
251
|
+
id: "FR-204",
|
|
252
|
+
rung: 2,
|
|
253
|
+
severity: "error",
|
|
254
|
+
check: (ctx) => {
|
|
255
|
+
const out = [];
|
|
256
|
+
let prev = 0;
|
|
257
|
+
let seenH1 = false;
|
|
258
|
+
for (const e of ctx.elems.filter((el) => /^h[1-6]$/.test(el.tag))) {
|
|
259
|
+
const lvl = Number(e.tag[1]);
|
|
260
|
+
if (lvl === 1 && seenH1) out.push({ rule: "FR-204", severity: "error", message: `multiple <h1> in file`, hint: `use one h1 per page`, loc: e.loc, wcag: "1.3.1" });
|
|
261
|
+
if (lvl === 1) seenH1 = true;
|
|
262
|
+
if (prev && lvl > prev + 1) out.push({ rule: "FR-204", severity: "error", message: `heading skips from h${prev} to h${lvl}`, hint: `don't skip heading levels for visual size`, loc: e.loc, wcag: "1.3.1" });
|
|
263
|
+
if (!prev || lvl <= prev + 1) prev = lvl;
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// src/rules/fr205.ts
|
|
270
|
+
var fr205 = {
|
|
271
|
+
id: "FR-205",
|
|
272
|
+
rung: 2,
|
|
273
|
+
severity: "error",
|
|
274
|
+
check: (ctx) => {
|
|
275
|
+
const out = [];
|
|
276
|
+
for (const e of ctx.elems) {
|
|
277
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
278
|
+
if (/\boutline-(none|0)\b/.test(cls) && !cls.includes("focus-visible:"))
|
|
279
|
+
out.push({ rule: "FR-205", severity: "error", message: `outline removed without focus-visible alternative`, hint: `add focus-visible:outline-* or a custom focus ring`, loc: e.loc, wcag: "2.4.7" });
|
|
280
|
+
}
|
|
281
|
+
for (const d of ctx.css) {
|
|
282
|
+
if (d.prop === "outline" && /^(none|0)/.test(d.value) && !d.selector.includes(":focus-visible") && !ctx.source.includes(":focus-visible"))
|
|
283
|
+
out.push({ rule: "FR-205", severity: "error", message: `outline: none without :focus-visible alternative`, hint: `add a :focus-visible rule with visible outline`, loc: d.loc, wcag: "2.4.7" });
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// src/rules/fr206.ts
|
|
290
|
+
var fr206 = {
|
|
291
|
+
id: "FR-206",
|
|
292
|
+
rung: 2,
|
|
293
|
+
severity: "error",
|
|
294
|
+
check: (ctx) => {
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const e of ctx.elems.filter((el) => ["div", "span"].includes(el.tag) && (el.attrs.onClick ?? el.attrs.onclick) !== void 0 && el.attrs.role === void 0 && (el.attrs.tabIndex ?? el.attrs.tabindex) === void 0)) {
|
|
297
|
+
out.push({ rule: "FR-206", severity: "error", message: `onClick on non-interactive <${e.tag}>`, hint: `use a <button>/<a> or add role and tabIndex`, loc: e.loc, wcag: "2.1.1" });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// src/rules/fr207.ts
|
|
304
|
+
var JSX_FORM = /tabIndex=\{\s*(\d+)\s*\}/g;
|
|
305
|
+
var HTML_FORM = /tabindex=["']([1-9]\d*)["']/gi;
|
|
306
|
+
var fr207 = {
|
|
307
|
+
id: "FR-207",
|
|
308
|
+
rung: 2,
|
|
309
|
+
severity: "error",
|
|
310
|
+
check: (ctx) => {
|
|
311
|
+
const out = [];
|
|
312
|
+
ctx.source.split("\n").forEach((line, i) => {
|
|
313
|
+
for (const m of line.matchAll(JSX_FORM)) {
|
|
314
|
+
if (Number(m[1]) > 0)
|
|
315
|
+
out.push({ rule: "FR-207", severity: "error", message: `tabIndex=${m[1]} > 0 disrupts natural tab order`, hint: `use tabIndex={0} and place in DOM order`, loc: { file: ctx.file, line: i + 1, col: m.index ?? 0 }, wcag: "2.4.3" });
|
|
316
|
+
}
|
|
317
|
+
for (const m of line.matchAll(HTML_FORM)) {
|
|
318
|
+
out.push({ rule: "FR-207", severity: "error", message: `tabIndex=${m[1]} > 0 disrupts natural tab order`, hint: `use tabIndex={0} and place in DOM order`, loc: { file: ctx.file, line: i + 1, col: m.index ?? 0 }, wcag: "2.4.3" });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// src/rules/fr208.ts
|
|
326
|
+
var FETCH = /useQuery|useSWR|useMutation|useFetcher|useInfiniteQuery|\bfetch\(|axios|createResource|loader/i;
|
|
327
|
+
var STATES = /isLoading|isPending|isError|isFetching|loading|empty|isEmpty|\berror\b|\bcatch\b|skeleton|spinner|fallback|onError|Suspense/i;
|
|
328
|
+
var fr208 = {
|
|
329
|
+
id: "FR-208",
|
|
330
|
+
rung: 2,
|
|
331
|
+
severity: "review",
|
|
332
|
+
check: (ctx) => {
|
|
333
|
+
if (!FETCH.test(ctx.source) || STATES.test(ctx.source)) return [];
|
|
334
|
+
return [{ rule: "FR-208", severity: "review", message: `data is fetched but no loading/empty/error handling found`, hint: `render loading, empty, and error states \u2014 heuristic, verify by hand`, loc: { file: ctx.file, line: 1, col: 0 } }];
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// src/rules/fr209.ts
|
|
339
|
+
var VALID_ARIA = /* @__PURE__ */ new Set(["aria-label", "aria-labelledby", "aria-describedby", "aria-hidden", "aria-expanded", "aria-controls", "aria-current", "aria-live", "aria-disabled", "aria-invalid", "aria-required", "aria-selected", "aria-checked", "aria-valuenow", "aria-orientation", "aria-placeholder", "aria-pressed", "aria-roledescription", "aria-atomic", "aria-relevant", "aria-dropeffect", "aria-grabbed", "aria-level", "aria-multiline", "aria-multiselectable", "aria-readonly", "aria-sort", "aria-valuemax", "aria-valuemin", "aria-valuetext", "aria-busy", "aria-colcount", "aria-rowcount", "aria-details", "aria-errormessage", "aria-flowto", "aria-owns", "aria-posinset", "aria-setsize", "aria-modal", "aria-activedescendant", "aria-haspopup", "aria-autocomplete", "aria-keyshortcuts", "aria-colspan", "aria-colindex", "aria-rowspan", "aria-rowindex", "aria-description", "aria-braillelabel", "aria-brailleroledescription"]);
|
|
340
|
+
var fr209 = {
|
|
341
|
+
id: "FR-209",
|
|
342
|
+
rung: 2,
|
|
343
|
+
severity: "error",
|
|
344
|
+
check: (ctx) => ctx.elems.flatMap((e) => Object.keys(e.attrs).filter((a) => a.startsWith("aria-") && !VALID_ARIA.has(a)).map((a) => ({ rule: "FR-209", severity: "error", message: `unknown aria attribute '${a}'`, hint: `check spelling against the ARIA spec`, loc: e.loc, wcag: "4.1.2" })))
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// src/rules/fr210.ts
|
|
348
|
+
var fr210 = {
|
|
349
|
+
id: "FR-210",
|
|
350
|
+
rung: 2,
|
|
351
|
+
severity: "review",
|
|
352
|
+
check: (ctx) => {
|
|
353
|
+
const out = [];
|
|
354
|
+
ctx.source.split("\n").forEach((line, i) => {
|
|
355
|
+
for (const m of line.matchAll(/(hurry|expires?|offer ends|ending soon)[^<>\n]{0,60}\d{1,2}:\d{2}/gi)) {
|
|
356
|
+
out.push({ rule: "FR-210", severity: "review", message: `urgency wording paired with a countdown ('${m[0]}') pressures rushed decisions`, hint: `avoid manufactured urgency \u2014 state the plain expiry instead of a ticking clock`, loc: { file: ctx.file, line: i + 1, col: m.index ?? 0 } });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// src/rules/fr211.ts
|
|
364
|
+
var CHECKED_INPUT = /<input[^>]*type=["']checkbox["'][^>]*defaultChecked[^>]*>/;
|
|
365
|
+
var CONSENT_ID = /id=["'][^"']*(?:consent|agree|terms|subscribe|opt[- ]?in)/i;
|
|
366
|
+
var CONSENT_ANY = /consent|agree|terms|subscribe|opt[- ]?in/i;
|
|
367
|
+
var fr211 = {
|
|
368
|
+
id: "FR-211",
|
|
369
|
+
rung: 2,
|
|
370
|
+
severity: "review",
|
|
371
|
+
check: (ctx) => {
|
|
372
|
+
const out = [];
|
|
373
|
+
ctx.source.split("\n").forEach((line, i) => {
|
|
374
|
+
const m = line.match(CHECKED_INPUT);
|
|
375
|
+
const hit = m ? CONSENT_ID.test(m[0]) || CONSENT_ANY.test(ctx.source) : /defaultChecked/.test(line) && CONSENT_ANY.test(ctx.source);
|
|
376
|
+
if (!hit) return;
|
|
377
|
+
const col = m?.index ?? Math.max(line.indexOf("defaultChecked"), 0);
|
|
378
|
+
out.push({ rule: "FR-211", severity: "review", message: `pre-checked checkbox near consent/agreement wording may trick users into opting in`, hint: `leave consent checkboxes unchecked by default`, loc: { file: ctx.file, line: i + 1, col } });
|
|
379
|
+
});
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// src/rules/fr212.ts
|
|
385
|
+
var fr212 = {
|
|
386
|
+
id: "FR-212",
|
|
387
|
+
rung: 2,
|
|
388
|
+
severity: "review",
|
|
389
|
+
check: (ctx) => {
|
|
390
|
+
const out = [];
|
|
391
|
+
ctx.source.split("\n").forEach((line, i) => {
|
|
392
|
+
for (const m of line.matchAll(/no thanks[^<>\n]{0,50}(hate|don'?t (want|like|need)|prefer paying|pay full|miss out)/gi)) {
|
|
393
|
+
out.push({ rule: "FR-212", severity: "review", message: `decline option shames the user ('${m[0]}')`, hint: `make declining a neutral choice \u2014 no guilt-tripping copy`, loc: { file: ctx.file, line: i + 1, col: m.index ?? 0 } });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// src/rules/fr213.ts
|
|
401
|
+
var TRIGGER = /(infinite[- ]?scroll|load[- ]?more.{0,40}scroll|IntersectionObserver)/i;
|
|
402
|
+
var OPT_OUT = /load more|loadMore|show more/i;
|
|
403
|
+
var fr213 = {
|
|
404
|
+
id: "FR-213",
|
|
405
|
+
rung: 2,
|
|
406
|
+
severity: "review",
|
|
407
|
+
check: (ctx) => {
|
|
408
|
+
if (OPT_OUT.test(ctx.source)) return [];
|
|
409
|
+
const out = [];
|
|
410
|
+
ctx.source.split("\n").forEach((line, i) => {
|
|
411
|
+
const m = line.match(TRIGGER);
|
|
412
|
+
if (m && out.length === 0)
|
|
413
|
+
out.push({ rule: "FR-213", severity: "review", message: `infinite scroll with no visible 'load more'/'show more' opt-out`, hint: `offer pagination or an explicit load-more control so users can stop`, loc: { file: ctx.file, line: i + 1, col: m.index ?? 0 } });
|
|
414
|
+
});
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// src/rules/fr214.ts
|
|
420
|
+
var fr214 = {
|
|
421
|
+
id: "FR-214",
|
|
422
|
+
rung: 2,
|
|
423
|
+
severity: "review",
|
|
424
|
+
check: (ctx) => {
|
|
425
|
+
const out = [];
|
|
426
|
+
ctx.source.split("\n").forEach((line, i) => {
|
|
427
|
+
for (const m of line.matchAll(/(free|\$0)[^<>\n]{0,30}(then|after|later)[^<>\n]{0,30}\$\d+/gi)) {
|
|
428
|
+
out.push({ rule: "FR-214", severity: "review", message: `price framed as free but a cost follows ('${m[0]}')`, hint: `show the full price up front \u2014 drip pricing erodes trust`, loc: { file: ctx.file, line: i + 1, col: m.index ?? 0 } });
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// src/rules/fr215.ts
|
|
436
|
+
var ERROR_MARKER = /(isError|\berror\b|catch)/;
|
|
437
|
+
var GENERIC_COPY = /something went wrong|an error occurred|oops|whoops/i;
|
|
438
|
+
var RECOVERY = /(retry|try again|reload|contact support|support team)/i;
|
|
439
|
+
var fr215 = {
|
|
440
|
+
id: "FR-215",
|
|
441
|
+
rung: 2,
|
|
442
|
+
severity: "review",
|
|
443
|
+
check: (ctx) => {
|
|
444
|
+
if (ERROR_MARKER.test(ctx.source) && GENERIC_COPY.test(ctx.source) && !RECOVERY.test(ctx.source)) {
|
|
445
|
+
return [{ rule: "FR-215", severity: "review", message: "generic error copy with no recovery action", hint: "tell users what failed and give them a next step (retry, link, contact)", loc: { file: ctx.file, line: 1, col: 0 } }];
|
|
446
|
+
}
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// src/color.ts
|
|
452
|
+
var clamp255 = (n) => Math.min(255, Math.max(0, Math.round(n)));
|
|
453
|
+
function splitArgs(argStr) {
|
|
454
|
+
const [colorPart] = argStr.split("/");
|
|
455
|
+
const values = colorPart.trim().split(/[\s,]+/).filter(Boolean);
|
|
456
|
+
if (values.length < 3 || values.length > 4) return null;
|
|
457
|
+
return { values: values.slice(0, 3) };
|
|
458
|
+
}
|
|
459
|
+
function numericChannel(v) {
|
|
460
|
+
const m = v.match(/^([+-]?\d*\.?\d+)(%)?$/);
|
|
461
|
+
if (!m) return null;
|
|
462
|
+
const n = parseFloat(m[1]);
|
|
463
|
+
return m[2] ? n / 100 * 255 : n;
|
|
464
|
+
}
|
|
465
|
+
function hslChannel(v, isHue) {
|
|
466
|
+
const m = v.match(/^([+-]?\d*\.?\d+)(deg|%)?$/);
|
|
467
|
+
if (!m) return null;
|
|
468
|
+
const n = parseFloat(m[1]);
|
|
469
|
+
return isHue ? n : n / 100;
|
|
470
|
+
}
|
|
471
|
+
function hslToRgb(h, s, l) {
|
|
472
|
+
const hue = (h % 360 + 360) % 360;
|
|
473
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
474
|
+
const x = c * (1 - Math.abs(hue / 60 % 2 - 1));
|
|
475
|
+
const m = l - c / 2;
|
|
476
|
+
const [r1, g1, b1] = hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x];
|
|
477
|
+
return { r: clamp255((r1 + m) * 255), g: clamp255((g1 + m) * 255), b: clamp255((b1 + m) * 255) };
|
|
478
|
+
}
|
|
479
|
+
function parseColor(input) {
|
|
480
|
+
const s = input.trim().toLowerCase();
|
|
481
|
+
if (!s) return null;
|
|
482
|
+
if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(s)) {
|
|
483
|
+
const hex = s.slice(1);
|
|
484
|
+
if (hex.length === 3) {
|
|
485
|
+
return {
|
|
486
|
+
r: parseInt(hex[0] + hex[0], 16),
|
|
487
|
+
g: parseInt(hex[1] + hex[1], 16),
|
|
488
|
+
b: parseInt(hex[2] + hex[2], 16)
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16) };
|
|
492
|
+
}
|
|
493
|
+
const rgb = s.match(/^rgba?\(([^)]*)\)$/);
|
|
494
|
+
if (rgb) {
|
|
495
|
+
const args = splitArgs(rgb[1]);
|
|
496
|
+
if (!args) return null;
|
|
497
|
+
const nums = args.values.map(numericChannel);
|
|
498
|
+
if (nums.some((n) => n === null)) return null;
|
|
499
|
+
const [r, g, b] = nums;
|
|
500
|
+
return { r: clamp255(r), g: clamp255(g), b: clamp255(b) };
|
|
501
|
+
}
|
|
502
|
+
const hsl = s.match(/^hsla?\(([^)]*)\)$/);
|
|
503
|
+
if (hsl) {
|
|
504
|
+
const args = splitArgs(hsl[1]);
|
|
505
|
+
if (!args) return null;
|
|
506
|
+
const hue = hslChannel(args.values[0], true);
|
|
507
|
+
const sat = hslChannel(args.values[1], false);
|
|
508
|
+
const lig = hslChannel(args.values[2], false);
|
|
509
|
+
if (hue === null || sat === null || lig === null) return null;
|
|
510
|
+
return hslToRgb(hue, Math.min(1, Math.max(0, sat)), Math.min(1, Math.max(0, lig)));
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
function relativeLuminance({ r, g, b }) {
|
|
515
|
+
const lin = (c) => {
|
|
516
|
+
const x = c / 255;
|
|
517
|
+
return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
|
518
|
+
};
|
|
519
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
520
|
+
}
|
|
521
|
+
function contrastRatio(a, b) {
|
|
522
|
+
const la = relativeLuminance(a);
|
|
523
|
+
const lb = relativeLuminance(b);
|
|
524
|
+
const [l1, l2] = la >= lb ? [la, lb] : [lb, la];
|
|
525
|
+
return (l1 + 0.05) / (l2 + 0.05);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/rules/fr301.ts
|
|
529
|
+
var ARBITRARY_TW = /\b(?:bg|text|border|from|to|via|ring|fill|stroke)-\[#[0-9a-fA-F]{3,8}\]/g;
|
|
530
|
+
var PALETTE_TW = /\b(?:bg|text|border|from|to|via|ring|fill|stroke)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/g;
|
|
531
|
+
var COLOR_PROP = /color|^background$|border/;
|
|
532
|
+
var count = (source, re) => (source.match(re) ?? []).length;
|
|
533
|
+
var fr301 = {
|
|
534
|
+
id: "FR-301",
|
|
535
|
+
rung: 3,
|
|
536
|
+
severity: "signal",
|
|
537
|
+
check: (ctx) => {
|
|
538
|
+
const hardcoded = count(ctx.source, ARBITRARY_TW) + ctx.css.filter((d) => !d.prop.startsWith("--") && COLOR_PROP.test(d.prop) && parseColor(d.value) !== null).length;
|
|
539
|
+
const tokenUses = count(ctx.source, /var\(--/g) + count(ctx.source, PALETTE_TW);
|
|
540
|
+
if (!(hardcoded > 3 && hardcoded > tokenUses)) return [];
|
|
541
|
+
return [{
|
|
542
|
+
rule: "FR-301",
|
|
543
|
+
severity: "signal",
|
|
544
|
+
message: `${hardcoded} hardcoded colors vs ${tokenUses} token uses in this file`,
|
|
545
|
+
hint: `use design tokens / Tailwind palette instead of raw hex (tokenSource: ${ctx.tokenColors.length} colors found)`,
|
|
546
|
+
loc: { file: ctx.file, line: 1, col: 0 }
|
|
547
|
+
}];
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// src/rules/fr302.ts
|
|
552
|
+
var ARBITRARY_SIZE = /\btext-\[(?!#|color:)[^\]]+\]/g;
|
|
553
|
+
var fr302 = {
|
|
554
|
+
id: "FR-302",
|
|
555
|
+
rung: 3,
|
|
556
|
+
severity: "signal",
|
|
557
|
+
check: (ctx) => {
|
|
558
|
+
const out = [];
|
|
559
|
+
for (const e of ctx.elems) {
|
|
560
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
561
|
+
for (const m of cls.matchAll(ARBITRARY_SIZE))
|
|
562
|
+
out.push({
|
|
563
|
+
rule: "FR-302",
|
|
564
|
+
severity: "signal",
|
|
565
|
+
message: `font size off the type scale: ${m[0]}`,
|
|
566
|
+
hint: `use the type scale (text-xs..text-6xl) or extend the theme`,
|
|
567
|
+
loc: e.loc
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
// src/rules/fr303.ts
|
|
575
|
+
var ARBITRARY_SPACE = /\b(?:p|m|px|py|pt|pb|pl|pr|mt|mb|ml|mr|mx|my|gap|w|h)-\[[^\]]+\]/g;
|
|
576
|
+
var fr303 = {
|
|
577
|
+
id: "FR-303",
|
|
578
|
+
rung: 3,
|
|
579
|
+
severity: "signal",
|
|
580
|
+
check: (ctx) => {
|
|
581
|
+
const out = [];
|
|
582
|
+
for (const e of ctx.elems) {
|
|
583
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
584
|
+
for (const m of cls.matchAll(ARBITRARY_SPACE))
|
|
585
|
+
out.push({
|
|
586
|
+
rule: "FR-303",
|
|
587
|
+
severity: "signal",
|
|
588
|
+
message: `spacing/dimension off the scale: ${m[0]}`,
|
|
589
|
+
hint: `use spacing scale utilities (1 unit = 0.25rem) or extend the theme`,
|
|
590
|
+
loc: e.loc
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// src/rules/fr304.ts
|
|
598
|
+
function translucent(raw) {
|
|
599
|
+
const s = raw.trim().toLowerCase();
|
|
600
|
+
if (/^#[0-9a-f]{8}$/.test(s)) return parseInt(s.slice(7, 9), 16) < 255;
|
|
601
|
+
const fn = s.match(/^(?:rgba?|hsla?)\(([^)]*)\)$/);
|
|
602
|
+
if (!fn) return false;
|
|
603
|
+
const [colorPart, slashAlpha] = fn[1].split("/");
|
|
604
|
+
const alpha = slashAlpha !== void 0 ? slashAlpha.trim() : colorPart.trim().split(/[\s,]+/).filter(Boolean)[3];
|
|
605
|
+
if (alpha === void 0) return false;
|
|
606
|
+
const m = alpha.match(/^([+-]?\d*\.?\d+)(%)?$/);
|
|
607
|
+
if (!m) return false;
|
|
608
|
+
const n = parseFloat(m[1]);
|
|
609
|
+
return (m[2] ? n / 100 : n) < 1;
|
|
610
|
+
}
|
|
611
|
+
var fr304 = {
|
|
612
|
+
id: "FR-304",
|
|
613
|
+
rung: 3,
|
|
614
|
+
severity: "signal",
|
|
615
|
+
check: (ctx) => {
|
|
616
|
+
const out = [];
|
|
617
|
+
const bySelector = /* @__PURE__ */ new Map();
|
|
618
|
+
for (const d of ctx.css) {
|
|
619
|
+
const group = bySelector.get(d.selector) ?? [];
|
|
620
|
+
group.push(d);
|
|
621
|
+
bySelector.set(d.selector, group);
|
|
622
|
+
}
|
|
623
|
+
for (const [selector, decls] of bySelector) {
|
|
624
|
+
if (!selector) continue;
|
|
625
|
+
const last = (prop) => {
|
|
626
|
+
const matches = decls.filter((d) => d.prop === prop);
|
|
627
|
+
return matches[matches.length - 1];
|
|
628
|
+
};
|
|
629
|
+
const colorDecl = last("color");
|
|
630
|
+
const bgDecl = last("background-color") ?? decls.filter((d) => d.prop === "background" && parseColor(d.value) !== null).pop();
|
|
631
|
+
if (!colorDecl || !bgDecl) continue;
|
|
632
|
+
if (translucent(colorDecl.value) || translucent(bgDecl.value)) continue;
|
|
633
|
+
const fg = parseColor(colorDecl.value);
|
|
634
|
+
const bg = parseColor(bgDecl.value);
|
|
635
|
+
if (!fg || !bg) continue;
|
|
636
|
+
const ratio = contrastRatio(fg, bg);
|
|
637
|
+
if (ratio < 4.5)
|
|
638
|
+
out.push({
|
|
639
|
+
rule: "FR-304",
|
|
640
|
+
severity: "signal",
|
|
641
|
+
message: `contrast ${colorDecl.value.trim()} on ${bgDecl.value.trim()} is ${ratio.toFixed(1)}:1 (< 4.5:1)`,
|
|
642
|
+
hint: `darken the text or lighten the background to reach AA 4.5:1`,
|
|
643
|
+
loc: colorDecl.loc,
|
|
644
|
+
wcag: "1.4.3"
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
// src/rules/fr305.ts
|
|
652
|
+
var TINY_TEXT = /\btext-xs\b/;
|
|
653
|
+
var ADEQUATE_PADDING = /(?:^|\s)(?:p|py)-([2-9]|10)\b/;
|
|
654
|
+
var fr305 = {
|
|
655
|
+
id: "FR-305",
|
|
656
|
+
rung: 3,
|
|
657
|
+
severity: "signal",
|
|
658
|
+
check: (ctx) => {
|
|
659
|
+
const out = [];
|
|
660
|
+
for (const e of ctx.elems) {
|
|
661
|
+
if (e.tag !== "button" && e.tag !== "a") continue;
|
|
662
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
663
|
+
if (!TINY_TEXT.test(cls) || ADEQUATE_PADDING.test(cls)) continue;
|
|
664
|
+
out.push({
|
|
665
|
+
rule: "FR-305",
|
|
666
|
+
severity: "signal",
|
|
667
|
+
message: `tiny hit area: text-xs interactive element without adequate padding`,
|
|
668
|
+
hint: `add py-2+ padding or a larger text size (WCAG 2.5.8 target size)`,
|
|
669
|
+
loc: e.loc,
|
|
670
|
+
wcag: "2.5.8"
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// src/rules/fr361.ts
|
|
678
|
+
var ITALIC = /\bitalic\b/;
|
|
679
|
+
var SERIF = /\b(?:font-)?serif\b/;
|
|
680
|
+
var DISPLAY_SIZE = /\btext-(?:3xl|4xl|5xl|6xl)\b/;
|
|
681
|
+
var fr361 = {
|
|
682
|
+
id: "FR-361",
|
|
683
|
+
rung: 3,
|
|
684
|
+
severity: "signal",
|
|
685
|
+
check: (ctx) => {
|
|
686
|
+
const out = [];
|
|
687
|
+
for (const e of ctx.elems) {
|
|
688
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
689
|
+
if (!ITALIC.test(cls) || !SERIF.test(cls) || !DISPLAY_SIZE.test(cls)) continue;
|
|
690
|
+
out.push({
|
|
691
|
+
rule: "FR-361",
|
|
692
|
+
severity: "signal",
|
|
693
|
+
message: `italic serif display type \u2014 the AI-tell headline`,
|
|
694
|
+
hint: `choose a display treatment that isn't italic serif`,
|
|
695
|
+
loc: e.loc
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// src/rules/fr362.ts
|
|
703
|
+
var UPPERCASE = /\buppercase\b/;
|
|
704
|
+
var TRACKING = /\btracking-(?:widest|wider)\b/;
|
|
705
|
+
var PILL = /\brounded-full\b/;
|
|
706
|
+
var MAX_TEXT_LENGTH = 40;
|
|
707
|
+
var fr362 = {
|
|
708
|
+
id: "FR-362",
|
|
709
|
+
rung: 3,
|
|
710
|
+
severity: "signal",
|
|
711
|
+
check: (ctx) => {
|
|
712
|
+
const out = [];
|
|
713
|
+
for (const e of ctx.elems) {
|
|
714
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
715
|
+
if (!UPPERCASE.test(cls) || !TRACKING.test(cls) || !PILL.test(cls)) continue;
|
|
716
|
+
if (e.text.trim().length > MAX_TEXT_LENGTH) continue;
|
|
717
|
+
out.push({
|
|
718
|
+
rule: "FR-362",
|
|
719
|
+
severity: "signal",
|
|
720
|
+
message: `hero eyebrow chip`,
|
|
721
|
+
hint: `eyebrow chips above headings are a template tell; integrate context into the heading`,
|
|
722
|
+
loc: e.loc
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// src/rules/fr363.ts
|
|
730
|
+
var CARD_ROUNDING = /rounded-(lg|xl|2xl)/;
|
|
731
|
+
var BORDER_TOKEN = /(?:^|\s)border/;
|
|
732
|
+
var SURFACE = /shadow-|bg-/;
|
|
733
|
+
var cardish = (cls) => CARD_ROUNDING.test(cls) && (BORDER_TOKEN.test(cls) || SURFACE.test(cls));
|
|
734
|
+
var fr363 = {
|
|
735
|
+
id: "FR-363",
|
|
736
|
+
rung: 3,
|
|
737
|
+
severity: "signal",
|
|
738
|
+
check: (ctx) => {
|
|
739
|
+
const out = [];
|
|
740
|
+
for (const e of ctx.elems) {
|
|
741
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
742
|
+
if (!cardish(cls) || !e.parentClass || !cardish(e.parentClass)) continue;
|
|
743
|
+
out.push({
|
|
744
|
+
rule: "FR-363",
|
|
745
|
+
severity: "signal",
|
|
746
|
+
message: `card inside a card`,
|
|
747
|
+
hint: `one containment level: let whitespace, not boxes, group content`,
|
|
748
|
+
loc: e.loc
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return out;
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// src/rules/fr364.ts
|
|
756
|
+
var TILE_ROUNDING = /rounded-(lg|xl|2xl)/;
|
|
757
|
+
var isTile = (cls, text) => TILE_ROUNDING.test(cls) && /bg-/.test(cls) && /flex/.test(cls) && text === "";
|
|
758
|
+
var fr364 = {
|
|
759
|
+
id: "FR-364",
|
|
760
|
+
rung: 3,
|
|
761
|
+
severity: "signal",
|
|
762
|
+
check: (ctx) => {
|
|
763
|
+
let n = 0;
|
|
764
|
+
for (const e of ctx.elems) {
|
|
765
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
766
|
+
if (isTile(cls, e.text)) n++;
|
|
767
|
+
}
|
|
768
|
+
if (n < 3) return [];
|
|
769
|
+
return [{
|
|
770
|
+
rule: "FR-364",
|
|
771
|
+
severity: "signal",
|
|
772
|
+
message: `icon-tile stack (${n} tiles)`,
|
|
773
|
+
hint: `rows of icon tiles with verb labels are a landing-page tell`,
|
|
774
|
+
loc: { file: ctx.file, line: 1, col: 0 }
|
|
775
|
+
}];
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
// src/rules/fr366.ts
|
|
780
|
+
var PILL2 = /rounded-full/;
|
|
781
|
+
var CHIP_SIZE = /(?:px-2|px-2\.5|text-xs)/;
|
|
782
|
+
var isChip = (cls, text) => PILL2.test(cls) && CHIP_SIZE.test(cls) && text !== "";
|
|
783
|
+
var fr366 = {
|
|
784
|
+
id: "FR-366",
|
|
785
|
+
rung: 3,
|
|
786
|
+
severity: "signal",
|
|
787
|
+
check: (ctx) => {
|
|
788
|
+
let n = 0;
|
|
789
|
+
for (const e of ctx.elems) {
|
|
790
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
791
|
+
if (isChip(cls, e.text)) n++;
|
|
792
|
+
}
|
|
793
|
+
if (n < 5) return [];
|
|
794
|
+
return [{
|
|
795
|
+
rule: "FR-366",
|
|
796
|
+
severity: "signal",
|
|
797
|
+
message: `status-chip soup (${n} chips)`,
|
|
798
|
+
hint: `chips everywhere dilute meaning; reserve them for state`,
|
|
799
|
+
loc: { file: ctx.file, line: 1, col: 0 }
|
|
800
|
+
}];
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// src/rules/fr365.ts
|
|
805
|
+
var THIN_BORDER = /(?:^|\s)border(?!-0\b)(?:-[1-9])?\b/;
|
|
806
|
+
var WIDE_SHADOW = /\bshadow-(?:xl|2xl)\b/;
|
|
807
|
+
var fr365 = {
|
|
808
|
+
id: "FR-365",
|
|
809
|
+
rung: 3,
|
|
810
|
+
severity: "signal",
|
|
811
|
+
check: (ctx) => {
|
|
812
|
+
const out = [];
|
|
813
|
+
for (const e of ctx.elems) {
|
|
814
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
815
|
+
if (!THIN_BORDER.test(cls) || !WIDE_SHADOW.test(cls)) continue;
|
|
816
|
+
out.push({
|
|
817
|
+
rule: "FR-365",
|
|
818
|
+
severity: "signal",
|
|
819
|
+
message: `thin border with wide shadow`,
|
|
820
|
+
hint: `pair elevation with weight: heavier border or tighter shadow`,
|
|
821
|
+
loc: e.loc
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
// src/rules/fr367.ts
|
|
829
|
+
var SMALL_TEXT = /\btext-(?:xs|sm)\b/;
|
|
830
|
+
var NUMBERED_LABEL = /^\d{1,2}\s+\S+/;
|
|
831
|
+
var fr367 = {
|
|
832
|
+
id: "FR-367",
|
|
833
|
+
rung: 3,
|
|
834
|
+
severity: "signal",
|
|
835
|
+
check: (ctx) => {
|
|
836
|
+
const out = [];
|
|
837
|
+
for (const e of ctx.elems) {
|
|
838
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
839
|
+
if (!SMALL_TEXT.test(cls) || !NUMBERED_LABEL.test(e.text)) continue;
|
|
840
|
+
out.push({
|
|
841
|
+
rule: "FR-367",
|
|
842
|
+
severity: "signal",
|
|
843
|
+
message: `numbered section label ("01 Discover" style)`,
|
|
844
|
+
hint: `let hierarchy come from typography, not index numbers`,
|
|
845
|
+
loc: e.loc
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return out;
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
// src/rules/fr368.ts
|
|
853
|
+
var GRADIENT_UTILITY = /\bbg-gradient-to-\w+/;
|
|
854
|
+
var PURPLE_STOP = /\bfrom-(?:violet|purple|indigo|fuchsia)-\d{3,4}\b/;
|
|
855
|
+
var PURPLE_HEX = /#(?:8b5cf6|a78bfa|7c3aed|818cf8|6366f1|9333ea|c084fc)/i;
|
|
856
|
+
var BACKGROUND_PROP = /^background(?:-image)?$/;
|
|
857
|
+
var fr368 = {
|
|
858
|
+
id: "FR-368",
|
|
859
|
+
rung: 3,
|
|
860
|
+
severity: "signal",
|
|
861
|
+
check: (ctx) => {
|
|
862
|
+
const out = [];
|
|
863
|
+
for (const e of ctx.elems) {
|
|
864
|
+
const cls = e.attrs.className ?? e.attrs.class ?? "";
|
|
865
|
+
if (!GRADIENT_UTILITY.test(cls) || !PURPLE_STOP.test(cls)) continue;
|
|
866
|
+
out.push({
|
|
867
|
+
rule: "FR-368",
|
|
868
|
+
severity: "signal",
|
|
869
|
+
message: `the purple AI gradient`,
|
|
870
|
+
hint: `pick a palette that isn't the LLM default`,
|
|
871
|
+
loc: e.loc
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
for (const d of ctx.css) {
|
|
875
|
+
if (!BACKGROUND_PROP.test(d.prop) || !/linear-gradient/i.test(d.value) || !PURPLE_HEX.test(d.value)) continue;
|
|
876
|
+
out.push({
|
|
877
|
+
rule: "FR-368",
|
|
878
|
+
severity: "signal",
|
|
879
|
+
message: `the purple AI gradient`,
|
|
880
|
+
hint: `pick a palette that isn't the LLM default`,
|
|
881
|
+
loc: d.loc
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
return out;
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
// src/rules/fr369.ts
|
|
889
|
+
var HEADING = /^h[1-6]$/;
|
|
890
|
+
var EMOJI = /\p{Extended_Pictographic}/u;
|
|
891
|
+
var fr369 = {
|
|
892
|
+
id: "FR-369",
|
|
893
|
+
rung: 3,
|
|
894
|
+
severity: "signal",
|
|
895
|
+
check: (ctx) => {
|
|
896
|
+
const out = [];
|
|
897
|
+
for (const e of ctx.elems) {
|
|
898
|
+
if (!HEADING.test(e.tag) || !EMOJI.test(e.text)) continue;
|
|
899
|
+
out.push({
|
|
900
|
+
rule: "FR-369",
|
|
901
|
+
severity: "signal",
|
|
902
|
+
message: `emoji in heading`,
|
|
903
|
+
hint: `emoji in headings read as template output; use typography instead`,
|
|
904
|
+
loc: e.loc
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
// src/rules/registry.ts
|
|
912
|
+
var ALL_RULES = [];
|
|
913
|
+
ALL_RULES.push(fr201, fr202, fr203, fr204, fr205, fr206, fr207, fr208, fr209, fr210, fr211, fr212, fr213, fr214, fr215, fr301, fr302, fr303, fr304, fr305, fr361, fr362, fr363, fr364, fr366, fr365, fr367, fr368, fr369);
|
|
914
|
+
function activeRules(cfg) {
|
|
915
|
+
return ALL_RULES.filter((r) => !cfg.disable.includes(r.id));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/score.ts
|
|
919
|
+
var BASELINE_FILES = 10;
|
|
920
|
+
var KNEE = 75;
|
|
921
|
+
function score(counts, weights, files = BASELINE_FILES) {
|
|
922
|
+
const raw = counts.error * weights.error + counts.review * weights.review + counts.signal * weights.signal;
|
|
923
|
+
const penalty = raw * BASELINE_FILES / Math.max(files, BASELINE_FILES);
|
|
924
|
+
return Math.round(penalty <= KNEE ? 100 - penalty : (100 - KNEE) * KNEE / penalty);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/tokens.ts
|
|
928
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
929
|
+
import { isAbsolute, join as join4 } from "path";
|
|
930
|
+
var CSS_VAR_RE = /--[\w-]+\s*:\s*([^;}]+)/g;
|
|
931
|
+
function gatherTokenColors(root, cfg, cssSources) {
|
|
932
|
+
const out = [];
|
|
933
|
+
const seen = /* @__PURE__ */ new Set();
|
|
934
|
+
const add = (raw) => {
|
|
935
|
+
const value = raw.trim();
|
|
936
|
+
if (!value || seen.has(value) || parseColor(value) === null) return;
|
|
937
|
+
seen.add(value);
|
|
938
|
+
out.push(value);
|
|
939
|
+
};
|
|
940
|
+
const kind = cfg.tokenSource?.kind;
|
|
941
|
+
if (kind === "design-tokens-json") {
|
|
942
|
+
const rel = cfg.tokenSource?.path ?? "";
|
|
943
|
+
const file = isAbsolute(rel) ? rel : join4(root, rel);
|
|
944
|
+
let json;
|
|
945
|
+
try {
|
|
946
|
+
json = JSON.parse(readFileSync3(file, "utf8"));
|
|
947
|
+
} catch {
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
950
|
+
collectJsonColors(json, add);
|
|
951
|
+
return out;
|
|
952
|
+
}
|
|
953
|
+
if (kind !== "css-vars" && kind !== "theme-factory") return out;
|
|
954
|
+
for (const { source } of cssSources) {
|
|
955
|
+
for (const m of source.matchAll(CSS_VAR_RE)) add(m[1]);
|
|
956
|
+
}
|
|
957
|
+
return out;
|
|
958
|
+
}
|
|
959
|
+
function collectJsonColors(node, add) {
|
|
960
|
+
if (node === null || typeof node !== "object") return;
|
|
961
|
+
if (Array.isArray(node)) {
|
|
962
|
+
for (const item of node) collectJsonColors(item, add);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
for (const [key, value] of Object.entries(node)) {
|
|
966
|
+
if (key === "$value" && typeof value === "string") add(value);
|
|
967
|
+
else collectJsonColors(value, add);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/baseline.ts
|
|
972
|
+
import { existsSync, readFileSync as readFileSync4, writeFileSync } from "fs";
|
|
973
|
+
import { join as join5, relative as relative2 } from "path";
|
|
974
|
+
var path = (root) => join5(root, "fringe.baseline.json");
|
|
975
|
+
var keyOf = (root, f) => `${f.rule}|${relative2(root, f.loc.file)}|${f.message}`;
|
|
976
|
+
function writeBaseline(root, findings) {
|
|
977
|
+
writeFileSync(path(root), JSON.stringify(findings.map((f) => keyOf(root, f)).sort(), null, 2) + "\n");
|
|
978
|
+
}
|
|
979
|
+
function applyBaseline(root, findings) {
|
|
980
|
+
if (!existsSync(path(root))) return { fresh: findings, known: 0 };
|
|
981
|
+
let keys;
|
|
982
|
+
try {
|
|
983
|
+
keys = JSON.parse(readFileSync4(path(root), "utf8"));
|
|
984
|
+
if (!Array.isArray(keys)) throw 0;
|
|
985
|
+
} catch {
|
|
986
|
+
throw new Error("fringe.baseline.json is not a JSON array \u2014 rerun with --update-baseline");
|
|
987
|
+
}
|
|
988
|
+
const left = /* @__PURE__ */ new Map();
|
|
989
|
+
for (const k of keys) left.set(k, (left.get(k) ?? 0) + 1);
|
|
990
|
+
const fresh = findings.filter((f) => {
|
|
991
|
+
const k = keyOf(root, f), n = left.get(k) ?? 0;
|
|
992
|
+
if (n > 0) {
|
|
993
|
+
left.set(k, n - 1);
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
return true;
|
|
997
|
+
});
|
|
998
|
+
return { fresh, known: findings.length - fresh.length };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// src/suppress.ts
|
|
1002
|
+
var MARK = /fringe-ignore\s+((?:FR-\d+[\s,]*)+)/;
|
|
1003
|
+
function suppressedIds(lines, line) {
|
|
1004
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1005
|
+
for (const l of [lines[line - 1], lines[line - 2]]) {
|
|
1006
|
+
const m = l && MARK.exec(l);
|
|
1007
|
+
if (m) for (const id of m[1].match(/FR-\d+/g) ?? []) ids.add(id);
|
|
1008
|
+
}
|
|
1009
|
+
return ids;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// src/check.ts
|
|
1013
|
+
async function runCheck(root, cfg = loadConfig(root), useBaseline = true) {
|
|
1014
|
+
const stack = detectStack(root);
|
|
1015
|
+
const files = walk(root, cfg);
|
|
1016
|
+
const rules = activeRules(cfg);
|
|
1017
|
+
const findings = [];
|
|
1018
|
+
const skipped = [];
|
|
1019
|
+
const cssSources = [];
|
|
1020
|
+
const contexts = [];
|
|
1021
|
+
for (const file of files) {
|
|
1022
|
+
let source = "";
|
|
1023
|
+
try {
|
|
1024
|
+
source = readFileSync5(file, "utf8");
|
|
1025
|
+
} catch {
|
|
1026
|
+
skipped.push(file);
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const ctx = { file, source, elems: [], css: [], tokenColors: [] };
|
|
1030
|
+
try {
|
|
1031
|
+
if (/\.(jsx|tsx|js|ts)$/.test(file)) ctx.elems = parseJsx(file, source);
|
|
1032
|
+
else if (/\.html$/.test(file)) ctx.elems = parseHtml(file, source);
|
|
1033
|
+
else if (/\.(css|scss)$/.test(file)) {
|
|
1034
|
+
ctx.css = parseCss(file, source);
|
|
1035
|
+
cssSources.push({ file, source });
|
|
1036
|
+
}
|
|
1037
|
+
} catch (e) {
|
|
1038
|
+
if (e.message.startsWith("parse-skip")) {
|
|
1039
|
+
skipped.push(file);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
throw e;
|
|
1043
|
+
}
|
|
1044
|
+
contexts.push(ctx);
|
|
1045
|
+
}
|
|
1046
|
+
const tokenColors = gatherTokenColors(root, cfg, cssSources);
|
|
1047
|
+
for (const ctx of contexts) ctx.tokenColors = tokenColors;
|
|
1048
|
+
for (const ctx of contexts) {
|
|
1049
|
+
for (const rule of rules) findings.push(...rule.check(ctx));
|
|
1050
|
+
}
|
|
1051
|
+
const lines = new Map(contexts.map((c) => [c.file, c.source.split("\n")]));
|
|
1052
|
+
const live = findings.filter((f) => !suppressedIds(lines.get(f.loc.file) ?? [], f.loc.line).has(f.rule));
|
|
1053
|
+
const suppressed = findings.length - live.length;
|
|
1054
|
+
const { fresh, known } = useBaseline ? applyBaseline(root, live) : { fresh: live, known: 0 };
|
|
1055
|
+
const counts = { error: 0, review: 0, signal: 0 };
|
|
1056
|
+
for (const f of fresh) counts[f.severity]++;
|
|
1057
|
+
return { stack, files: files.length, skipped, findings: fresh, counts, score: score(counts, cfg.weights, files.length), ...known ? { baselined: known } : {}, ...suppressed ? { suppressed } : {} };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// src/report/json.ts
|
|
1061
|
+
function toJson(r) {
|
|
1062
|
+
return JSON.stringify({ schema: "fringe/check.v1", ...r }, null, 2);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/report/hook.ts
|
|
1066
|
+
import { relative as relative3 } from "path";
|
|
1067
|
+
var RANK = { error: 0, review: 1, signal: 2 };
|
|
1068
|
+
function topFinding(r) {
|
|
1069
|
+
return [...r.findings].sort((a, b) => {
|
|
1070
|
+
if (RANK[a.severity] !== RANK[b.severity]) return RANK[a.severity] - RANK[b.severity];
|
|
1071
|
+
if (a.loc.file !== b.loc.file) return a.loc.file < b.loc.file ? -1 : 1;
|
|
1072
|
+
return a.loc.line - b.loc.line;
|
|
1073
|
+
})[0];
|
|
1074
|
+
}
|
|
1075
|
+
function toHook(r) {
|
|
1076
|
+
const top = topFinding(r);
|
|
1077
|
+
const reason = top ? `${top.rule}: ${top.message} (${top.loc.file}:${top.loc.line})` : "no findings";
|
|
1078
|
+
return JSON.stringify({
|
|
1079
|
+
schema: "fringe/hook.v1",
|
|
1080
|
+
errors: r.counts.error,
|
|
1081
|
+
review: r.counts.review,
|
|
1082
|
+
signals: r.counts.signal,
|
|
1083
|
+
block: r.counts.error > 0,
|
|
1084
|
+
reason
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
function toClaudeHook(r, root) {
|
|
1088
|
+
const errs = r.findings.filter((f) => f.severity === "error");
|
|
1089
|
+
if (errs.length === 0) return "";
|
|
1090
|
+
const shown = errs.slice(0, 8).map((f) => `- ${f.rule} ${relative3(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
|
|
1091
|
+
const more = errs.length > shown.length ? `
|
|
1092
|
+
\u2026and ${errs.length - shown.length} more (run \`fringe check\`).` : "";
|
|
1093
|
+
return JSON.stringify({
|
|
1094
|
+
decision: "block",
|
|
1095
|
+
reason: `Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
|
|
1096
|
+
${shown.join("\n")}${more}
|
|
1097
|
+
Use a fringe-ignore comment only for a genuine false positive, and say so.`
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/report/pretty.ts
|
|
1102
|
+
var RANK2 = { error: 0, review: 1, signal: 2 };
|
|
1103
|
+
function tag(s) {
|
|
1104
|
+
return `[${s.toUpperCase()}]`;
|
|
1105
|
+
}
|
|
1106
|
+
function stackList(stack) {
|
|
1107
|
+
const keys = Object.keys(stack);
|
|
1108
|
+
const on = keys.filter((k) => stack[k]);
|
|
1109
|
+
return on.length > 0 ? on.join(",") : "none";
|
|
1110
|
+
}
|
|
1111
|
+
function toPretty(r) {
|
|
1112
|
+
const lines = [
|
|
1113
|
+
`fringe \xB7 stack ${stackList(r.stack)} \xB7 ${r.files} files` + (r.skipped.length > 0 ? ` \xB7 ${r.skipped.length} skipped` : "")
|
|
1114
|
+
];
|
|
1115
|
+
const sorted = [...r.findings].sort((a, b) => {
|
|
1116
|
+
if (RANK2[a.severity] !== RANK2[b.severity]) return RANK2[a.severity] - RANK2[b.severity];
|
|
1117
|
+
if (a.loc.file !== b.loc.file) return a.loc.file < b.loc.file ? -1 : 1;
|
|
1118
|
+
return a.loc.line - b.loc.line;
|
|
1119
|
+
});
|
|
1120
|
+
for (const f of sorted) {
|
|
1121
|
+
lines.push(`${tag(f.severity)} ${f.rule} ${f.loc.file}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
|
|
1122
|
+
}
|
|
1123
|
+
lines.push(
|
|
1124
|
+
`health ${r.score}/100 \xB7 ${r.counts.error} errors \xB7 ${r.counts.review} review \xB7 ${r.counts.signal} signals` + (r.baselined ? ` \xB7 ${r.baselined} baselined` : "") + (r.suppressed ? ` \xB7 ${r.suppressed} suppressed` : "")
|
|
1125
|
+
);
|
|
1126
|
+
if (r.skipped.length > 0) {
|
|
1127
|
+
lines.push(
|
|
1128
|
+
`legend ${tag("error")} must fix \xB7 ${tag("review")} needs judgment \xB7 ${tag("signal")} info \xB7 skipped: ${r.skipped.join(", ")}`
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
return lines.join("\n");
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// src/ledger.ts
|
|
1135
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync6 } from "fs";
|
|
1136
|
+
import { join as join6 } from "path";
|
|
1137
|
+
function ledgerPath(root) {
|
|
1138
|
+
return join6(root, ".fringe", "ledger.jsonl");
|
|
1139
|
+
}
|
|
1140
|
+
function appendRecord(root, rec) {
|
|
1141
|
+
mkdirSync(join6(root, ".fringe"), { recursive: true });
|
|
1142
|
+
appendFileSync(ledgerPath(root), JSON.stringify(rec) + "\n");
|
|
1143
|
+
}
|
|
1144
|
+
function readLedger(root) {
|
|
1145
|
+
let raw;
|
|
1146
|
+
try {
|
|
1147
|
+
raw = readFileSync6(ledgerPath(root), "utf8");
|
|
1148
|
+
} catch {
|
|
1149
|
+
return { records: [], corrupt: 0 };
|
|
1150
|
+
}
|
|
1151
|
+
const records = [];
|
|
1152
|
+
let corrupt = 0;
|
|
1153
|
+
for (const line of raw.split("\n")) {
|
|
1154
|
+
if (line.trim() === "") continue;
|
|
1155
|
+
try {
|
|
1156
|
+
records.push(JSON.parse(line));
|
|
1157
|
+
} catch {
|
|
1158
|
+
corrupt++;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return { records, corrupt };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// src/board.ts
|
|
1165
|
+
import { createServer } from "http";
|
|
1166
|
+
var esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1167
|
+
function renderBoard(records, corrupt = 0) {
|
|
1168
|
+
const rows = [...records].reverse().map((r) => {
|
|
1169
|
+
const flows = (r.manifest?.flows ?? []).map((f) => `<li>${esc(f.action)}: ${esc(f.what)} <small>(${esc(f.evidence)}) \u2014 judged</small></li>`).join("");
|
|
1170
|
+
return `<tr><td>${esc(r.ts)}</td><td>${r.score}</td><td>${r.counts.error}</td><td>${r.counts.review}</td><td>${r.counts.signal}</td><td>${r.files}</td><td>${r.suppressed ?? 0}</td><td>${flows ? `<ul>${flows}</ul>` : ""}</td></tr>`;
|
|
1171
|
+
}).join("");
|
|
1172
|
+
const body = records.length === 0 ? "<p>No runs yet. Run <code>fringe check --record</code> and reload.</p>" : `<table><tr><th>When</th><th>Health</th><th>Errors</th><th>Review</th><th>Signals</th><th>Files</th><th>Suppressed</th><th>Flow cuts</th></tr>${rows}</table>`;
|
|
1173
|
+
const warn = corrupt ? `<p>${corrupt} unreadable ledger line(s) skipped.</p>` : "";
|
|
1174
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Fringe ledger</title><style>body{font:16px system-ui;margin:2rem;max-width:70rem}td,th{padding:.4rem .8rem;text-align:left;vertical-align:top}</style><h1>Fringe ledger</h1>${warn}${body}</html>`;
|
|
1175
|
+
}
|
|
1176
|
+
function startBoard(root, port) {
|
|
1177
|
+
const server = createServer((_req, res) => {
|
|
1178
|
+
const { records, corrupt } = readLedger(root);
|
|
1179
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
1180
|
+
res.end(renderBoard(records, corrupt));
|
|
1181
|
+
});
|
|
1182
|
+
return new Promise((ok, fail) => {
|
|
1183
|
+
server.once("error", fail);
|
|
1184
|
+
server.listen(port, "127.0.0.1", () => ok(server));
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/init.ts
|
|
1189
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2, copyFileSync } from "fs";
|
|
1190
|
+
import { dirname, join as join7 } from "path";
|
|
1191
|
+
import { fileURLToPath } from "url";
|
|
1192
|
+
var HOOK = "npx -y @fringetools/cli check --claude-hook";
|
|
1193
|
+
var SKILL = fileURLToPath(new URL("../skill/SKILL.md", import.meta.url));
|
|
1194
|
+
function initProject(root) {
|
|
1195
|
+
const done = [];
|
|
1196
|
+
const settingsPath = join7(root, ".claude", "settings.json");
|
|
1197
|
+
let settings = {};
|
|
1198
|
+
if (existsSync2(settingsPath)) {
|
|
1199
|
+
try {
|
|
1200
|
+
settings = JSON.parse(readFileSync7(settingsPath, "utf8"));
|
|
1201
|
+
} catch {
|
|
1202
|
+
throw new Error(`${settingsPath} is not valid JSON \u2014 fix it and rerun`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const post = (settings.hooks ??= {}).PostToolUse ??= [];
|
|
1206
|
+
if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
|
|
1207
|
+
else {
|
|
1208
|
+
post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: HOOK }] });
|
|
1209
|
+
mkdirSync2(dirname(settingsPath), { recursive: true });
|
|
1210
|
+
writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1211
|
+
done.push(`hook added to ${settingsPath}`);
|
|
1212
|
+
}
|
|
1213
|
+
const skillPath = join7(root, ".claude", "skills", "fringe", "SKILL.md");
|
|
1214
|
+
if (existsSync2(skillPath)) done.push("skill already present");
|
|
1215
|
+
else {
|
|
1216
|
+
mkdirSync2(dirname(skillPath), { recursive: true });
|
|
1217
|
+
copyFileSync(SKILL, skillPath);
|
|
1218
|
+
done.push(`skill installed at ${skillPath}`);
|
|
1219
|
+
}
|
|
1220
|
+
return done;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// src/index.ts
|
|
1224
|
+
var log = (o) => o.log ?? ((s) => console.log(s));
|
|
1225
|
+
var err = (o) => o.err ?? ((s) => process.stderr.write(s));
|
|
1226
|
+
function readManifest(path2, warn) {
|
|
1227
|
+
let raw;
|
|
1228
|
+
try {
|
|
1229
|
+
raw = readFileSync8(path2, "utf8");
|
|
1230
|
+
} catch {
|
|
1231
|
+
warn(`fringe: warning: manifest not found at ${path2} \u2014 continuing without
|
|
1232
|
+
`);
|
|
1233
|
+
return void 0;
|
|
1234
|
+
}
|
|
1235
|
+
try {
|
|
1236
|
+
const parsed = JSON.parse(raw);
|
|
1237
|
+
if (!parsed || !Array.isArray(parsed.flows)) throw new Error("missing flows");
|
|
1238
|
+
return parsed;
|
|
1239
|
+
} catch {
|
|
1240
|
+
warn(`fringe: warning: manifest at ${path2} is not valid '{ flows: [...] }' JSON \u2014 continuing without
|
|
1241
|
+
`);
|
|
1242
|
+
return void 0;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
1246
|
+
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
1247
|
+
fringe check --update-baseline [path] accept today's findings; only new ones gate
|
|
1248
|
+
fringe init [path] add the Claude Code hook + skill
|
|
1249
|
+
fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
|
|
1250
|
+
fringe --help | --version`;
|
|
1251
|
+
var KNOWN_FLAGS = /* @__PURE__ */ new Set(["--json", "--hook", "--claude-hook", "--record", "--manifest", "--update-baseline"]);
|
|
1252
|
+
function parseArgs(rest, warn) {
|
|
1253
|
+
const paths = [];
|
|
1254
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1255
|
+
const a = rest[i];
|
|
1256
|
+
if (a === "--manifest") {
|
|
1257
|
+
i++;
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
if (a.startsWith("--")) {
|
|
1261
|
+
if (!KNOWN_FLAGS.has(a)) warn(`fringe: warning: unknown flag '${a}' \u2014 ignored
|
|
1262
|
+
`);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
paths.push(a);
|
|
1266
|
+
}
|
|
1267
|
+
return paths;
|
|
1268
|
+
}
|
|
1269
|
+
async function runCli(argv, opts = {}) {
|
|
1270
|
+
const [cmd, ...rest] = argv;
|
|
1271
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
1272
|
+
log(opts)(VERSION);
|
|
1273
|
+
return 0;
|
|
1274
|
+
}
|
|
1275
|
+
if (cmd === "check") {
|
|
1276
|
+
try {
|
|
1277
|
+
const paths = parseArgs(rest, err(opts));
|
|
1278
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1279
|
+
const root = paths.length > 0 ? resolve(cwd, paths[0]) : cwd;
|
|
1280
|
+
const cfg = loadConfig(root);
|
|
1281
|
+
if (rest.includes("--update-baseline")) {
|
|
1282
|
+
const all = await runCheck(root, cfg, false);
|
|
1283
|
+
writeBaseline(root, all.findings);
|
|
1284
|
+
log(opts)(`fringe: baseline written \u2014 ${all.findings.length} known findings in fringe.baseline.json (commit it); only new ones gate from now on`);
|
|
1285
|
+
return 0;
|
|
1286
|
+
}
|
|
1287
|
+
const result = await runCheck(root, cfg);
|
|
1288
|
+
if (rest.includes("--claude-hook")) {
|
|
1289
|
+
const out = toClaudeHook(result, root);
|
|
1290
|
+
if (out) log(opts)(out);
|
|
1291
|
+
return 0;
|
|
1292
|
+
}
|
|
1293
|
+
if (rest.includes("--hook")) log(opts)(toHook(result));
|
|
1294
|
+
else if (rest.includes("--json")) log(opts)(toJson(result));
|
|
1295
|
+
else log(opts)(toPretty(result));
|
|
1296
|
+
if (rest.includes("--record")) {
|
|
1297
|
+
const rec = {
|
|
1298
|
+
id: randomUUID(),
|
|
1299
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1300
|
+
stack: result.stack,
|
|
1301
|
+
files: result.files,
|
|
1302
|
+
counts: result.counts,
|
|
1303
|
+
score: result.score,
|
|
1304
|
+
byRule: result.findings.reduce((m, f) => (m[f.rule] = (m[f.rule] ?? 0) + 1, m), {}),
|
|
1305
|
+
suppressed: result.suppressed,
|
|
1306
|
+
tokenSource: cfg.tokenSource?.kind
|
|
1307
|
+
};
|
|
1308
|
+
const mIdx = rest.indexOf("--manifest");
|
|
1309
|
+
if (mIdx !== -1 && rest[mIdx + 1] !== void 0) rec.manifest = readManifest(rest[mIdx + 1], err(opts));
|
|
1310
|
+
appendRecord(root, rec);
|
|
1311
|
+
}
|
|
1312
|
+
return result.counts.error > 0 ? 1 : 0;
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
err(opts)(`fringe: internal error: ${e.message}
|
|
1315
|
+
`);
|
|
1316
|
+
return 2;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (cmd === "init") {
|
|
1320
|
+
try {
|
|
1321
|
+
for (const line of initProject(resolve(opts.cwd ?? process.cwd(), rest[0] ?? "."))) log(opts)(`fringe: ${line}`);
|
|
1322
|
+
return 0;
|
|
1323
|
+
} catch (e) {
|
|
1324
|
+
err(opts)(`fringe: ${e.message}
|
|
1325
|
+
`);
|
|
1326
|
+
return 2;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (cmd === "open") {
|
|
1330
|
+
try {
|
|
1331
|
+
const port = Number(rest[rest.indexOf("--port") + 1]) || 4173;
|
|
1332
|
+
const path2 = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--port");
|
|
1333
|
+
await startBoard(resolve(opts.cwd ?? process.cwd(), path2 ?? "."), port);
|
|
1334
|
+
log(opts)(`fringe: ledger at http://127.0.0.1:${port} \u2014 Ctrl+C to stop`);
|
|
1335
|
+
return await new Promise(() => {
|
|
1336
|
+
});
|
|
1337
|
+
} catch (e) {
|
|
1338
|
+
err(opts)(`fringe: internal error: ${e.message}
|
|
1339
|
+
`);
|
|
1340
|
+
return 2;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
1344
|
+
log(opts)(HELP);
|
|
1345
|
+
return 0;
|
|
1346
|
+
}
|
|
1347
|
+
log(opts)(`fringe: unknown command '${cmd ?? ""}' \u2014 try 'fringe check --help'`);
|
|
1348
|
+
return 2;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
export {
|
|
1352
|
+
runCli
|
|
1353
|
+
};
|