@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 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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-Z4UN7OMW.js";
4
+ } from "./chunk-TNZKMKKQ.js";
5
5
 
6
6
  // src/bin.ts
7
7
  process.exitCode = await runCli(process.argv.slice(2));
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
2
  import { randomUUID } from "crypto";
3
- import { existsSync as existsSync3, readFileSync as readFileSync8, statSync } from "fs";
4
- import { dirname as dirname2, join as join8, resolve } from "path";
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.1";
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(file, source) {
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(file, source) {
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(file, source) {
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 file = isAbsolute(rel) ? rel : join4(root, rel);
1059
+ const file2 = isAbsolute(rel) ? rel : join4(root, rel);
1060
1060
  let json;
1061
1061
  try {
1062
- json = JSON.parse(readFileSync3(file, "utf8"));
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 file of files) {
1389
+ for (const file2 of files) {
1390
1390
  let source = "";
1391
1391
  try {
1392
- source = readFileSync5(file, "utf8");
1392
+ source = readFileSync5(file2, "utf8");
1393
1393
  } catch {
1394
- skipped.push(file);
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(file)) ctx.elems = parseJsx(file, source);
1400
- else if (/\.html$/.test(file)) ctx.elems = parseHtml(file, source);
1401
- else if (/\.(css|scss)$/.test(file)) {
1402
- ctx.css = parseCss(file, source);
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(file);
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 errs = r.findings.filter((f) => f.severity === "error");
1459
- if (errs.length === 0) return "";
1460
- const shown = errs.slice(0, 8).map((f) => `- ${f.rule} ${relative4(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
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: `Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
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 mkdirSync2, readFileSync as readFileSync7, readdirSync as readdirSync3, writeFileSync as writeFileSync2, copyFileSync } from "fs";
1567
- import { dirname, join as join7 } from "path";
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 = join7(root, ".claude", "settings.json");
1609
+ const settingsPath = join8(root, ".claude", "settings.json");
1574
1610
  let settings = {};
1575
1611
  if (existsSync2(settingsPath)) {
1576
1612
  try {
1577
- settings = JSON.parse(readFileSync7(settingsPath, "utf8"));
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
- mkdirSync2(dirname(settingsPath), { recursive: true });
1587
- writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
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 = join7(root, ".claude", "skills", name, "SKILL.md");
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
- mkdirSync2(dirname(skillPath), { recursive: true });
1595
- copyFileSync(join7(SKILL_DIR, name, "SKILL.md"), skillPath);
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 = readFileSync8(path2, "utf8");
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(join8(d, "package.json")) || existsSync3(join8(d, "fringe.config.json"))) {
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
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-Z4UN7OMW.js";
3
+ } from "./chunk-TNZKMKKQ.js";
4
4
  export {
5
5
  runCli
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fringetools/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {