@fringetools/cli 0.2.1 → 0.2.2
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 +2 -0
- package/dist/bin.js +1 -1
- package/dist/{chunk-Z4UN7OMW.js → chunk-TNZKMKKQ.js} +74 -38
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,6 +119,8 @@ After each edit the hook runs; with errors it returns, for example:
|
|
|
119
119
|
{"decision":"block","reason":"Fringe found 2 accessibility error(s). Fix them before finishing:\n- FR-203 src/Signup.tsx:9 — input not associated with a label (add id + <label htmlFor>, or aria-label)\n…"}
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
The hook also keeps `.fringe/last.json` (the previous run's errors) and tells the agent what its **last edit broke**: "Your last edit introduced 1 new error(s) and fixed 2; 3 failing in total", with the new ones listed first and marked `NEW`. If that file is missing or unreadable it simply skips the comparison. It still blocks on every remaining error; on a legacy repo use the baseline so old debt is not nagged about.
|
|
123
|
+
|
|
122
124
|
This was verified end to end with real agent runs: the agent receives the findings and fixes them (see [docs/architecture.md](../../docs/architecture.md)). Why `--claude-hook` and not `--hook`: Claude Code only passes a hook's output to the agent as `decision`/`reason` JSON on exit 0 (or stderr on exit 2); the CI contract `--hook` exits 1 and is ignored.
|
|
123
125
|
|
|
124
126
|
**Skills** — `fringe init` installs five into `.claude/skills/` (sources in [skill/](skill/), one folder each); the Claude Code plugin ships the same folders, namespaced `/fringe:…`: `fringe` (below), `fringe-review` ("review this page": ranked, plain-English findings with fixes), `fringe-audit` (whole-repo plan: top rules and files, a fix order, offers `--update-baseline`), `fringe-debt` (lists `fringe-ignore` suppressions and the baseline) and `fringe-help` (quick reference). Invoke as `/fringe-review` etc. Reviews run `fringe check`, so Claude Code will ask you to approve that command once (or allow `Bash(fringe:*)` / `Bash(npx:*)`).
|
package/dist/bin.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { existsSync as existsSync3, readFileSync as
|
|
4
|
-
import { dirname as dirname2, join as
|
|
3
|
+
import { existsSync as existsSync3, readFileSync as readFileSync9, statSync } from "fs";
|
|
4
|
+
import { dirname as dirname2, join as join9, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var VERSION = "0.2.
|
|
7
|
+
var VERSION = "0.2.2";
|
|
8
8
|
|
|
9
9
|
// src/check.ts
|
|
10
10
|
import { readFileSync as readFileSync5 } from "fs";
|
|
@@ -144,7 +144,7 @@ function collectText(node) {
|
|
|
144
144
|
}
|
|
145
145
|
return t.trim();
|
|
146
146
|
}
|
|
147
|
-
function parseJsx(
|
|
147
|
+
function parseJsx(file2, source) {
|
|
148
148
|
let ast;
|
|
149
149
|
try {
|
|
150
150
|
ast = parse(source, { sourceType: "unambiguous", plugins: ["jsx", "typescript"], errorRecovery: false });
|
|
@@ -162,7 +162,7 @@ function parseJsx(file, source) {
|
|
|
162
162
|
for (const a of node.openingElement.attributes) {
|
|
163
163
|
if (a.type === "JSXAttribute") attrs[a.name.name] = attrValue(a.value);
|
|
164
164
|
}
|
|
165
|
-
const el = { tag: tag2, attrs, text: collectText(node), loc: { file, line: node.loc?.start.line ?? 0, col: node.loc?.start.column ?? 0 } };
|
|
165
|
+
const el = { tag: tag2, attrs, text: collectText(node), loc: { file: file2, line: node.loc?.start.line ?? 0, col: node.loc?.start.column ?? 0 } };
|
|
166
166
|
if (parent) el.parentTag = parent.tag;
|
|
167
167
|
if (parent?.inLabel) el.inLabel = true;
|
|
168
168
|
if (parent?.attrs?.className !== void 0) el.parentClass = parent.attrs.className;
|
|
@@ -181,7 +181,7 @@ function parseJsx(file, source) {
|
|
|
181
181
|
// src/parse/html.ts
|
|
182
182
|
import { parse as parse2 } from "parse5";
|
|
183
183
|
var textOf = (n) => n.nodeName === "#text" ? n.value : (n.childNodes ?? []).map(textOf).join("");
|
|
184
|
-
function parseHtml(
|
|
184
|
+
function parseHtml(file2, source) {
|
|
185
185
|
const doc = parse2(source, { sourceCodeLocationInfo: true });
|
|
186
186
|
const elems = [];
|
|
187
187
|
(function walkNode(node, parent) {
|
|
@@ -189,7 +189,7 @@ function parseHtml(file, source) {
|
|
|
189
189
|
const attrs = {};
|
|
190
190
|
for (const a of node.attrs ?? []) attrs[a.name] = a.value;
|
|
191
191
|
const text = textOf(node).trim();
|
|
192
|
-
const el = { tag: node.tagName, attrs, text, loc: { file, line: node.sourceCodeLocation?.startLine ?? 0, col: node.sourceCodeLocation?.startCol ?? 0 } };
|
|
192
|
+
const el = { tag: node.tagName, attrs, text, loc: { file: file2, line: node.sourceCodeLocation?.startLine ?? 0, col: node.sourceCodeLocation?.startCol ?? 0 } };
|
|
193
193
|
if (parent) el.parentTag = parent.tag;
|
|
194
194
|
if (parent?.inLabel) el.inLabel = true;
|
|
195
195
|
if (parent?.attrs?.class !== void 0) el.parentClass = parent.attrs.class;
|
|
@@ -204,12 +204,12 @@ function parseHtml(file, source) {
|
|
|
204
204
|
|
|
205
205
|
// src/parse/css.ts
|
|
206
206
|
import postcss from "postcss";
|
|
207
|
-
function parseCss(
|
|
207
|
+
function parseCss(file2, source) {
|
|
208
208
|
const decls = [];
|
|
209
209
|
const rec = (node, selector) => {
|
|
210
210
|
for (const child of node.nodes ?? []) {
|
|
211
211
|
if (child.type === "decl") {
|
|
212
|
-
decls.push({ prop: child.prop, value: String(child.value), selector, loc: { file, line: child.source?.start?.line ?? 0, col: child.source?.start?.column ?? 0 } });
|
|
212
|
+
decls.push({ prop: child.prop, value: String(child.value), selector, loc: { file: file2, line: child.source?.start?.line ?? 0, col: child.source?.start?.column ?? 0 } });
|
|
213
213
|
} else if (child.type === "rule" || child.type === "atrule") {
|
|
214
214
|
rec(child, child.type === "rule" ? child.selector : selector);
|
|
215
215
|
}
|
|
@@ -1056,10 +1056,10 @@ function gatherTokenColors(root, cfg, cssSources) {
|
|
|
1056
1056
|
const kind = cfg.tokenSource?.kind;
|
|
1057
1057
|
if (kind === "design-tokens-json") {
|
|
1058
1058
|
const rel = cfg.tokenSource?.path ?? "";
|
|
1059
|
-
const
|
|
1059
|
+
const file2 = isAbsolute(rel) ? rel : join4(root, rel);
|
|
1060
1060
|
let json;
|
|
1061
1061
|
try {
|
|
1062
|
-
json = JSON.parse(readFileSync3(
|
|
1062
|
+
json = JSON.parse(readFileSync3(file2, "utf8"));
|
|
1063
1063
|
} catch {
|
|
1064
1064
|
return out;
|
|
1065
1065
|
}
|
|
@@ -1386,25 +1386,25 @@ async function runCheck(root, cfg = loadConfig(root), useBaseline = true, only)
|
|
|
1386
1386
|
const skipped = [];
|
|
1387
1387
|
const cssSources = [];
|
|
1388
1388
|
const contexts = [];
|
|
1389
|
-
for (const
|
|
1389
|
+
for (const file2 of files) {
|
|
1390
1390
|
let source = "";
|
|
1391
1391
|
try {
|
|
1392
|
-
source = readFileSync5(
|
|
1392
|
+
source = readFileSync5(file2, "utf8");
|
|
1393
1393
|
} catch {
|
|
1394
|
-
skipped.push(
|
|
1394
|
+
skipped.push(file2);
|
|
1395
1395
|
continue;
|
|
1396
1396
|
}
|
|
1397
|
-
const ctx = { file, source, elems: [], css: [], tokenColors: [] };
|
|
1397
|
+
const ctx = { file: file2, source, elems: [], css: [], tokenColors: [] };
|
|
1398
1398
|
try {
|
|
1399
|
-
if (/\.(jsx|tsx|js|ts)$/.test(
|
|
1400
|
-
else if (/\.html$/.test(
|
|
1401
|
-
else if (/\.(css|scss)$/.test(
|
|
1402
|
-
ctx.css = parseCss(
|
|
1403
|
-
cssSources.push({ file, source });
|
|
1399
|
+
if (/\.(jsx|tsx|js|ts)$/.test(file2)) ctx.elems = parseJsx(file2, source);
|
|
1400
|
+
else if (/\.html$/.test(file2)) ctx.elems = parseHtml(file2, source);
|
|
1401
|
+
else if (/\.(css|scss)$/.test(file2)) {
|
|
1402
|
+
ctx.css = parseCss(file2, source);
|
|
1403
|
+
cssSources.push({ file: file2, source });
|
|
1404
1404
|
}
|
|
1405
1405
|
} catch (e) {
|
|
1406
1406
|
if (e.message.startsWith("parse-skip")) {
|
|
1407
|
-
skipped.push(
|
|
1407
|
+
skipped.push(file2);
|
|
1408
1408
|
continue;
|
|
1409
1409
|
}
|
|
1410
1410
|
throw e;
|
|
@@ -1454,10 +1454,14 @@ function toHook(r) {
|
|
|
1454
1454
|
reason
|
|
1455
1455
|
});
|
|
1456
1456
|
}
|
|
1457
|
-
function toClaudeHook(r, root) {
|
|
1458
|
-
const
|
|
1459
|
-
if (
|
|
1460
|
-
const
|
|
1457
|
+
function toClaudeHook(r, root, history) {
|
|
1458
|
+
const all = r.findings.filter((f) => f.severity === "error");
|
|
1459
|
+
if (all.length === 0) return "";
|
|
1460
|
+
const fresh = new Set(history?.introduced);
|
|
1461
|
+
const errs = [...all.filter((f) => fresh.has(f)), ...all.filter((f) => !fresh.has(f))];
|
|
1462
|
+
const note = history && (history.introduced.length > 0 || history.fixed > 0) ? `Your last edit introduced ${history.introduced.length} new error(s)${history.fixed ? ` and fixed ${history.fixed}` : ""}; ${errs.length} failing in total. New ones are marked NEW.
|
|
1463
|
+
` : "";
|
|
1464
|
+
const shown = errs.slice(0, 8).map((f) => `- ${fresh.has(f) ? "NEW " : ""}${f.rule} ${relative4(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
|
|
1461
1465
|
const shownRules = [...new Set(errs.slice(0, 8).map((f) => f.rule))];
|
|
1462
1466
|
const recipes = shownRules.map((id) => {
|
|
1463
1467
|
const g = GUIDANCE[id];
|
|
@@ -1467,7 +1471,7 @@ function toClaudeHook(r, root) {
|
|
|
1467
1471
|
\u2026and ${errs.length - shown.length} more (run \`fringe check\`).` : "";
|
|
1468
1472
|
return JSON.stringify({
|
|
1469
1473
|
decision: "block",
|
|
1470
|
-
reason:
|
|
1474
|
+
reason: `${note}Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
|
|
1471
1475
|
${shown.join("\n")}${more}
|
|
1472
1476
|
How to fix them well:
|
|
1473
1477
|
${recipes.join("\n")}
|
|
@@ -1562,19 +1566,51 @@ function startBoard(root, port) {
|
|
|
1562
1566
|
});
|
|
1563
1567
|
}
|
|
1564
1568
|
|
|
1569
|
+
// src/receipts.ts
|
|
1570
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
|
|
1571
|
+
import { join as join7 } from "path";
|
|
1572
|
+
var file = (root) => join7(root, ".fringe", "last.json");
|
|
1573
|
+
function receipts(root, findings) {
|
|
1574
|
+
const errs = findings.filter((f) => f.severity === "error");
|
|
1575
|
+
const keys = errs.map((f) => keyOf(root, f));
|
|
1576
|
+
let prev;
|
|
1577
|
+
try {
|
|
1578
|
+
const p = JSON.parse(readFileSync7(file(root), "utf8"));
|
|
1579
|
+
if (Array.isArray(p)) prev = p;
|
|
1580
|
+
} catch {
|
|
1581
|
+
}
|
|
1582
|
+
try {
|
|
1583
|
+
mkdirSync2(join7(root, ".fringe"), { recursive: true });
|
|
1584
|
+
writeFileSync2(file(root), JSON.stringify(keys));
|
|
1585
|
+
} catch {
|
|
1586
|
+
}
|
|
1587
|
+
if (!prev) return void 0;
|
|
1588
|
+
const left = /* @__PURE__ */ new Map();
|
|
1589
|
+
for (const k of prev) left.set(k, (left.get(k) ?? 0) + 1);
|
|
1590
|
+
const introduced = errs.filter((f, i) => {
|
|
1591
|
+
const n = left.get(keys[i]) ?? 0;
|
|
1592
|
+
if (n > 0) {
|
|
1593
|
+
left.set(keys[i], n - 1);
|
|
1594
|
+
return false;
|
|
1595
|
+
}
|
|
1596
|
+
return true;
|
|
1597
|
+
});
|
|
1598
|
+
return { introduced, fixed: [...left.values()].reduce((a, b) => a + b, 0) };
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1565
1601
|
// src/init.ts
|
|
1566
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
1567
|
-
import { dirname, join as
|
|
1602
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
|
|
1603
|
+
import { dirname, join as join8 } from "path";
|
|
1568
1604
|
import { fileURLToPath } from "url";
|
|
1569
1605
|
var HOOK = "npx -y @fringetools/cli check --claude-hook";
|
|
1570
1606
|
var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
|
|
1571
1607
|
function initProject(root) {
|
|
1572
1608
|
const done = [];
|
|
1573
|
-
const settingsPath =
|
|
1609
|
+
const settingsPath = join8(root, ".claude", "settings.json");
|
|
1574
1610
|
let settings = {};
|
|
1575
1611
|
if (existsSync2(settingsPath)) {
|
|
1576
1612
|
try {
|
|
1577
|
-
settings = JSON.parse(
|
|
1613
|
+
settings = JSON.parse(readFileSync8(settingsPath, "utf8"));
|
|
1578
1614
|
} catch {
|
|
1579
1615
|
throw new Error(`${settingsPath} is not valid JSON \u2014 fix it and rerun`);
|
|
1580
1616
|
}
|
|
@@ -1583,16 +1619,16 @@ function initProject(root) {
|
|
|
1583
1619
|
if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
|
|
1584
1620
|
else {
|
|
1585
1621
|
post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: HOOK }] });
|
|
1586
|
-
|
|
1587
|
-
|
|
1622
|
+
mkdirSync3(dirname(settingsPath), { recursive: true });
|
|
1623
|
+
writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1588
1624
|
done.push(`hook added to ${settingsPath}`);
|
|
1589
1625
|
}
|
|
1590
1626
|
for (const name of readdirSync3(SKILL_DIR)) {
|
|
1591
|
-
const skillPath =
|
|
1627
|
+
const skillPath = join8(root, ".claude", "skills", name, "SKILL.md");
|
|
1592
1628
|
if (existsSync2(skillPath)) done.push(`skill ${name} already present`);
|
|
1593
1629
|
else {
|
|
1594
|
-
|
|
1595
|
-
copyFileSync(
|
|
1630
|
+
mkdirSync3(dirname(skillPath), { recursive: true });
|
|
1631
|
+
copyFileSync(join8(SKILL_DIR, name, "SKILL.md"), skillPath);
|
|
1596
1632
|
done.push(`skill ${name} installed`);
|
|
1597
1633
|
}
|
|
1598
1634
|
}
|
|
@@ -1722,7 +1758,7 @@ var err = (o) => o.err ?? ((s) => process.stderr.write(s));
|
|
|
1722
1758
|
function readManifest(path2, warn) {
|
|
1723
1759
|
let raw;
|
|
1724
1760
|
try {
|
|
1725
|
-
raw =
|
|
1761
|
+
raw = readFileSync9(path2, "utf8");
|
|
1726
1762
|
} catch {
|
|
1727
1763
|
warn(`fringe: warning: manifest not found at ${path2} \u2014 continuing without
|
|
1728
1764
|
`);
|
|
@@ -1749,7 +1785,7 @@ function resolveTarget(arg) {
|
|
|
1749
1785
|
if (!statSync(arg).isFile()) return { root: arg };
|
|
1750
1786
|
let dir = dirname2(arg);
|
|
1751
1787
|
for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
|
|
1752
|
-
if (existsSync3(
|
|
1788
|
+
if (existsSync3(join9(d, "package.json")) || existsSync3(join9(d, "fringe.config.json"))) {
|
|
1753
1789
|
dir = d;
|
|
1754
1790
|
break;
|
|
1755
1791
|
}
|
|
@@ -1805,7 +1841,7 @@ async function runCli(argv, opts = {}) {
|
|
|
1805
1841
|
}
|
|
1806
1842
|
const result = await runCheck(root, cfg, true, only);
|
|
1807
1843
|
if (rest.includes("--claude-hook")) {
|
|
1808
|
-
const out = toClaudeHook(result, root);
|
|
1844
|
+
const out = toClaudeHook(result, root, receipts(root, result.findings));
|
|
1809
1845
|
if (out) log(opts)(out);
|
|
1810
1846
|
return 0;
|
|
1811
1847
|
}
|
package/dist/index.js
CHANGED