@bridge-ds/cli 0.3.0 → 0.3.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.
Files changed (2) hide show
  1. package/dist/index.js +624 -130
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { existsSync as existsSync5 } from "node:fs";
5
5
  import { readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
6
- import { join as join5, relative as relative4, resolve as resolve3 } from "node:path";
6
+ import { isAbsolute, join as join5, relative as relative4, resolve as resolve3 } from "node:path";
7
7
 
8
8
  // ../core/src/audit/types.ts
9
9
  var AUDIT_SCHEMA_VERSION = 1;
@@ -602,6 +602,10 @@ var EQUIVALENTS = [
602
602
  [/^Badge$/, ["Badge"]],
603
603
  [/^(Chip|Tag|ChipField)$/, ["Tag", "Badge"]],
604
604
  [/^(Alert|AlertTitle|Callout)$/, ["Alert"]],
605
+ [/^(Banner|AnnouncementBar|TopBanner)$/, ["Banner", "Alert"]],
606
+ [/^(CodeBlock|Code|CodeSnippet|Snippet|Prism|SyntaxHighlighter|CopyBlock)$/, ["CodeBlock"]],
607
+ [/^(RadioCard|RadioCards|RadioTile|ChoiceCard|ChoiceCardGroup)$/, ["ChoiceCard", "RadioGroup"]],
608
+ [/^(Checklist|ChecklistItem|StepList)$/, ["Checklist"]],
605
609
  [/^(Snackbar|Toast|Toaster|Notification)$/, ["Toast"]],
606
610
  [/^(CircularProgress|Spinner|Loading|LoadingIndicator)$/, ["Spinner", "Progress"]],
607
611
  [/^(LinearProgress|Progress)$/, ["Progress"]],
@@ -1147,6 +1151,26 @@ var THEME_FNS = /^(createTheme|extendTheme|createMuiTheme|responsiveFontSizes|ex
1147
1151
  var CSS_TAGS = /^(styled(\.\w+|\(.*\)(\.attrs\(.*\))?)|css|keyframes|createGlobalStyle|injectGlobal|tw\.\w+)$/s;
1148
1152
  var MUI_SPACING_ATTR = /^([mp][trblxy]?|(margin|padding)(Top|Bottom|Left|Right|X|Y)?|gap|rowGap|columnGap|spacing)$/;
1149
1153
  var THEME_REF_RE = /\btheme\.(palette|spacing|shape|typography|shadows|zIndex|transitions|vars)\b/g;
1154
+ var DOM_RENDERER_RE = /^([a-z][A-Za-z0-9]*?)(Html|HTML|HtmlAttributes|Attributes|ClassName)$/;
1155
+ function domRendererComponent(fn, components) {
1156
+ const m = DOM_RENDERER_RE.exec(fn);
1157
+ if (!m) return void 0;
1158
+ const pascal2 = m[1][0].toUpperCase() + m[1].slice(1);
1159
+ if (components.has(pascal2)) return pascal2;
1160
+ return [...components].filter((c) => pascal2.startsWith(c)).sort((a, b) => b.length - a.length)[0];
1161
+ }
1162
+ var LOOKALIKE_CLASSES = { pill: "Badge", badge: "Badge", chip: "Tag", tooltip: "Tooltip", tip: "Tooltip", "has-tip": "Tooltip" };
1163
+ var LOOKALIKE_ATTRS = { "data-tip": "Tooltip" };
1164
+ var HTML_CLASS_RE = /\bclass(?:Name)?\s*=\s*["']([^"'$]*)/g;
1165
+ var HTML_ATTR_RE = /\s(data-tip)\s*=/g;
1166
+ function lookalikesInClassList(list2, components) {
1167
+ const out = [];
1168
+ for (const token of list2.split(/\s+/)) {
1169
+ const component = LOOKALIKE_CLASSES[token];
1170
+ if (component && components.has(component) && !out.some((o) => o.component === component)) out.push({ token, component });
1171
+ }
1172
+ return out;
1173
+ }
1150
1174
  function kindFor(path) {
1151
1175
  return path.endsWith(".tsx") ? ts.ScriptKind.TSX : path.endsWith(".jsx") ? ts.ScriptKind.JSX : path.endsWith(".ts") || path.endsWith(".mts") || path.endsWith(".cts") ? ts.ScriptKind.TS : ts.ScriptKind.JSX;
1152
1176
  }
@@ -1166,6 +1190,78 @@ function contextForDeclaration(name, typeText) {
1166
1190
  if (/^(styles?|sx|classes)$|Styles?$|Sx$/.test(name)) return /sx$/i.test(name) ? "sx" : "styled";
1167
1191
  return null;
1168
1192
  }
1193
+ function importsOf(path, text) {
1194
+ const sf = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, false, kindFor(path));
1195
+ const imports = /* @__PURE__ */ new Map();
1196
+ for (const node of sf.statements) {
1197
+ if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) continue;
1198
+ const mod = node.moduleSpecifier.text;
1199
+ const c = node.importClause;
1200
+ if (c?.name) imports.set(c.name.text, { module: mod, imported: "default" });
1201
+ if (c?.namedBindings && ts.isNamespaceImport(c.namedBindings)) imports.set(c.namedBindings.name.text, { module: mod, imported: "*" });
1202
+ if (c?.namedBindings && ts.isNamedImports(c.namedBindings)) for (const el of c.namedBindings.elements) imports.set(el.name.text, { module: mod, imported: (el.propertyName ?? el.name).text });
1203
+ }
1204
+ return imports;
1205
+ }
1206
+ function classifyTag(t, imports, ds) {
1207
+ const { name } = t;
1208
+ const at = { line: t.line, column: t.column };
1209
+ if (/^[a-z][\w-]*$/.test(name)) {
1210
+ if (!ds.nativeElements.includes(name)) return {};
1211
+ if (name === "input" && t.type === "hidden") return {};
1212
+ if (name === "a" && !t.hasHref) return {};
1213
+ if (t.inRenderProp) return {};
1214
+ const suggestion = nativeEquivalent(name, t.type, ds.components);
1215
+ return { component: { ...at, name, kind: "native", library: "native", ...suggestion ? { suggestion } : {} } };
1216
+ }
1217
+ const root = name.split(".")[0];
1218
+ const imp = imports.get(root);
1219
+ const ns = imp?.imported === "*";
1220
+ const shown = imp && !ns && imp.imported !== "default" ? [imp.imported, ...name.split(".").slice(1)].join(".") : name;
1221
+ const leaf = ns && name.includes(".") ? name.split(".").slice(1).join(".") : shown;
1222
+ if (imp) {
1223
+ const kind = classifyModule(imp.module, ds.packages, ds.foreignLibraries);
1224
+ if (isNonVisual(leaf.split(".").pop())) return {};
1225
+ if (kind === "ds") return { ds: leaf };
1226
+ if (kind === "charts") return { component: { ...at, name: shown, kind: "foreign", library: libraryLabel(imp.module, kind) } };
1227
+ if (kind === "other" && isRouterLink(imp.module, imp.imported === "default" ? "default" : leaf)) {
1228
+ if (t.inRenderProp) return {};
1229
+ const suggestion = ds.components.has("Link") ? "Link" : void 0;
1230
+ return { component: { ...at, name: shown, kind: "foreign", library: libraryLabel(imp.module, kind), ...suggestion ? { suggestion } : {} } };
1231
+ }
1232
+ if (kind === "foreign" || kind === "shadcn") {
1233
+ const suggestion = (ns ? dsEquivalent(root, ds.components) : void 0) ?? dsEquivalent(leaf, ds.components);
1234
+ return { component: { ...at, name: shown, kind: "foreign", library: libraryLabel(imp.module, kind), ...suggestion ? { suggestion } : {} } };
1235
+ }
1236
+ if (kind === "local" && ds.components.has(leaf.split(".")[0])) return { component: { ...at, name: leaf, kind: "recreated", library: "local", suggestion: leaf.split(".")[0] } };
1237
+ return {};
1238
+ }
1239
+ if (ds.components.has(root)) return { localCandidate: { ...at, name, kind: "recreated", library: "local", suggestion: root } };
1240
+ return {};
1241
+ }
1242
+ function tailwindFindings(hits, offset, pos, ds) {
1243
+ const findings = [];
1244
+ let themeRefs = 0;
1245
+ for (const h of hits) {
1246
+ if (h.kind === "scale") {
1247
+ themeRefs++;
1248
+ continue;
1249
+ }
1250
+ if (h.kind === "token") continue;
1251
+ let cat = h.category;
1252
+ const v = h.value;
1253
+ const isColor = Boolean(parseCssColor(v));
1254
+ if (cat === "color-or-size") cat = isColor ? "color" : "font-size";
1255
+ else if (cat === "color-or-width") cat = isColor ? "color" : "border-width";
1256
+ else if (cat === "color-or-shadow") cat = isColor ? "color" : "shadow";
1257
+ else if (cat === "font") cat = /^\d{3}$/.test(v) ? "font-weight" : "font-family";
1258
+ if (cat === "color" && !isColor) continue;
1259
+ const suggestion = ds.suggest(cat, v);
1260
+ const cap = h.kind === "palette" && suggestion && suggestion.confidence > 0.6 ? { ...suggestion, confidence: 0.6, level: "medium" } : suggestion;
1261
+ findings.push({ ...pos(offset + h.index), category: cat, value: h.cls, source: "tailwind", ...cap ? { suggestion: cap } : {} });
1262
+ }
1263
+ return { findings, themeRefs };
1264
+ }
1169
1265
  function scanCode(path, text, ds) {
1170
1266
  const sf = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kindFor(path));
1171
1267
  const out = { findings: [], tokenUsages: 0, themeRefs: 0, ds: {}, components: [], definitions: [] };
@@ -1180,24 +1276,9 @@ function scanCode(path, text, ds) {
1180
1276
  out.findings.push({ ...pos(offset), category: h.category, ...property ? { property } : {}, value: h.text, source, ...suggestion ? { suggestion } : {} });
1181
1277
  };
1182
1278
  const addTailwind = (hits, offset) => {
1183
- for (const h of hits) {
1184
- if (h.kind === "scale") {
1185
- out.themeRefs++;
1186
- continue;
1187
- }
1188
- if (h.kind === "token") continue;
1189
- let cat = h.category;
1190
- const v = h.value;
1191
- const isColor = Boolean(parseCssColor(v));
1192
- if (cat === "color-or-size") cat = isColor ? "color" : "font-size";
1193
- else if (cat === "color-or-width") cat = isColor ? "color" : "border-width";
1194
- else if (cat === "color-or-shadow") cat = isColor ? "color" : "shadow";
1195
- else if (cat === "font") cat = /^\d{3}$/.test(v) ? "font-weight" : "font-family";
1196
- if (cat === "color" && !isColor) continue;
1197
- const suggestion = ds.suggest(cat, v);
1198
- const cap = h.kind === "palette" && suggestion && suggestion.confidence > 0.6 ? { ...suggestion, confidence: 0.6, level: "medium" } : suggestion;
1199
- out.findings.push({ ...pos(offset + h.index), category: cat, value: h.cls, source: "tailwind", ...cap ? { suggestion: cap } : {} });
1200
- }
1279
+ const r = tailwindFindings(hits, offset, pos, ds);
1280
+ out.themeRefs += r.themeRefs;
1281
+ out.findings.push(...r.findings);
1201
1282
  };
1202
1283
  const styleValue = (key, node, ctx) => {
1203
1284
  const source = ctx === "sx" ? "sx" : ctx === "style" ? "style" : ctx === "theme" ? "theme" : "styled";
@@ -1258,13 +1339,18 @@ function scanCode(path, text, ds) {
1258
1339
  }
1259
1340
  for (const a of scan.applies) addTailwind(a.hits, start + a.start);
1260
1341
  };
1342
+ const lookalike = (list2, offset) => {
1343
+ for (const l of lookalikesInClassList(list2, ds.components)) out.components.push({ ...pos(offset), name: `.${l.token}`, kind: "recreated", library: "local", suggestion: l.component });
1344
+ };
1261
1345
  const classLiteral = (node) => {
1262
1346
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
1263
1347
  handled.add(node);
1348
+ lookalike(node.text, node.getStart(sf) + 1);
1264
1349
  addTailwind(scanClassString(node.text), node.getStart(sf) + 1);
1265
1350
  } else if (ts.isTemplateExpression(node)) {
1266
1351
  for (const p of [node.head, ...node.templateSpans.map((s) => s.literal)]) {
1267
1352
  handled.add(p);
1353
+ lookalike(p.text, p.getStart(sf) + 1);
1268
1354
  addTailwind(scanClassString(p.text), p.getStart(sf) + 1);
1269
1355
  }
1270
1356
  node.templateSpans.forEach((s) => visit(s.expression, null, true));
@@ -1273,49 +1359,13 @@ function scanCode(path, text, ds) {
1273
1359
  const jsxName = (n) => n.getText(sf);
1274
1360
  const element = (node) => {
1275
1361
  const name = jsxName(node.tagName);
1276
- const at = pos(node.getStart(sf));
1277
- if (/^[a-z][\w-]*$/.test(name)) {
1278
- if (!ds.nativeElements.includes(name)) return;
1279
- const typeAttr = node.attributes.properties.find((p) => ts.isJsxAttribute(p) && p.name.getText(sf) === "type");
1280
- const type = typeAttr?.initializer && ts.isStringLiteral(typeAttr.initializer) ? typeAttr.initializer.text : void 0;
1281
- if (name === "input" && type === "hidden") return;
1282
- if (name === "a" && !node.attributes.properties.some((p) => ts.isJsxAttribute(p) && p.name.getText(sf) === "href")) return;
1283
- if (isRenderElement(node)) return;
1284
- const suggestion = nativeEquivalent(name, type, ds.components);
1285
- out.components.push({ ...at, name, kind: "native", library: "native", ...suggestion ? { suggestion } : {} });
1286
- return;
1287
- }
1288
- const root = name.split(".")[0];
1289
- const imp = imports.get(root);
1290
- const ns = imp?.imported === "*";
1291
- const shown = imp && !ns && imp.imported !== "default" ? [imp.imported, ...name.split(".").slice(1)].join(".") : name;
1292
- const leaf = ns && name.includes(".") ? name.split(".").slice(1).join(".") : shown;
1293
- if (imp) {
1294
- const kind = classifyModule(imp.module, ds.packages, ds.foreignLibraries);
1295
- if (isNonVisual(leaf.split(".").pop())) return;
1296
- if (kind === "ds") {
1297
- out.ds[leaf] = (out.ds[leaf] ?? 0) + 1;
1298
- return;
1299
- }
1300
- if (kind === "charts") {
1301
- out.components.push({ ...at, name: shown, kind: "foreign", library: libraryLabel(imp.module, kind) });
1302
- return;
1303
- }
1304
- if (kind === "other" && isRouterLink(imp.module, imp.imported === "default" ? "default" : leaf)) {
1305
- if (isRenderElement(node)) return;
1306
- const suggestion = ds.components.has("Link") ? "Link" : void 0;
1307
- out.components.push({ ...at, name: shown, kind: "foreign", library: libraryLabel(imp.module, kind), ...suggestion ? { suggestion } : {} });
1308
- return;
1309
- }
1310
- if (kind === "foreign" || kind === "shadcn") {
1311
- const suggestion = (ns ? dsEquivalent(root, ds.components) : void 0) ?? dsEquivalent(leaf, ds.components);
1312
- out.components.push({ ...at, name: shown, kind: "foreign", library: libraryLabel(imp.module, kind), ...suggestion ? { suggestion } : {} });
1313
- return;
1314
- }
1315
- if (kind === "local" && ds.components.has(leaf.split(".")[0])) out.components.push({ ...at, name: leaf, kind: "recreated", library: "local", suggestion: leaf.split(".")[0] });
1316
- return;
1317
- }
1318
- if (ds.components.has(root)) pendingLocal.push({ ...at, name, kind: "recreated", library: "local", suggestion: root });
1362
+ const typeAttr = node.attributes.properties.find((p) => ts.isJsxAttribute(p) && p.name.getText(sf) === "type");
1363
+ const type = typeAttr?.initializer && ts.isStringLiteral(typeAttr.initializer) ? typeAttr.initializer.text : void 0;
1364
+ const hasHref = node.attributes.properties.some((p) => ts.isJsxAttribute(p) && p.name.getText(sf) === "href");
1365
+ const r = classifyTag({ name, ...pos(node.getStart(sf)), ...type !== void 0 ? { type } : {}, hasHref, inRenderProp: isRenderElement(node) }, imports, ds);
1366
+ if (r.ds) out.ds[r.ds] = (out.ds[r.ds] ?? 0) + 1;
1367
+ if (r.component) out.components.push(r.component);
1368
+ if (r.localCandidate) pendingLocal.push(r.localCandidate);
1319
1369
  };
1320
1370
  const pendingLocal = [];
1321
1371
  function visit(node, ctx, classCtx) {
@@ -1375,6 +1425,17 @@ function scanCode(path, text, ds) {
1375
1425
  cssTemplate(node.template);
1376
1426
  return;
1377
1427
  }
1428
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
1429
+ const imp = imports.get(node.expression.text);
1430
+ if (imp && imp.imported !== "*" && classifyModule(imp.module, ds.packages, ds.foreignLibraries) === "ds") {
1431
+ const comp = domRendererComponent(imp.imported === "default" ? node.expression.text : imp.imported, ds.components);
1432
+ if (comp) out.ds[comp] = (out.ds[comp] ?? 0) + 1;
1433
+ }
1434
+ }
1435
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left) && node.left.name.text === "className") {
1436
+ visit(node.left, ctx, classCtx);
1437
+ return classLiteral(node.right);
1438
+ }
1378
1439
  if (ts.isCallExpression(node)) {
1379
1440
  const callee = node.expression.getText(sf);
1380
1441
  if (CLASS_FNS.test(callee)) {
@@ -1419,6 +1480,14 @@ function scanCode(path, text, ds) {
1419
1480
  const literal = (node) => {
1420
1481
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) {
1421
1482
  const t = node.text;
1483
+ if (!handled.has(node) && (t.includes("<") || ts.isTemplateMiddle(node) || ts.isTemplateTail(node))) {
1484
+ const base = node.getStart(sf) + 1;
1485
+ for (const m of t.matchAll(HTML_CLASS_RE)) for (const l of lookalikesInClassList(m[1], ds.components)) out.components.push({ ...pos(base + m.index), name: `.${l.token}`, kind: "recreated", library: "local", suggestion: l.component });
1486
+ for (const m of t.matchAll(HTML_ATTR_RE)) {
1487
+ const component = LOOKALIKE_ATTRS[m[1]];
1488
+ if (component && ds.components.has(component)) out.components.push({ ...pos(base + m.index + 1), name: `[${m[1]}]`, kind: "recreated", library: "local", suggestion: component });
1489
+ }
1490
+ }
1422
1491
  for (const m of t.matchAll(/var\(\s*(--[\w-]+)/g)) {
1423
1492
  if (ds.isDsVar(m[1])) out.tokenUsages++;
1424
1493
  else out.themeRefs++;
@@ -1438,25 +1507,85 @@ function scanCode(path, text, ds) {
1438
1507
  return out;
1439
1508
  }
1440
1509
 
1441
- // ../core/src/audit/report.ts
1442
- var AUDIT_EXTENSIONS = [".css", ".scss", ".sass", ".less", ".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".mts", ".cts"];
1443
- var AUDIT_IGNORE_DIRS = ["node_modules", "dist", "build", "out", "coverage", ".git", ".next", ".nuxt", ".astro", ".turbo", ".cache", ".vercel", "storybook-static", "vendor", "public"];
1444
- var IGNORE_FILE = /\.d\.[cm]?ts$|\.(test|spec|stories|story)\.[cm]?[jt]sx?$|\.min\.(css|js)$|(^|\/)(vite|vitest|jest|webpack|rollup|eslint|postcss|tailwind|babel|next|astro|prettier|stylelint|tsup)\.config\.[cm]?[jt]s$/;
1445
- function isAuditable(path) {
1446
- if (!AUDIT_EXTENSIONS.some((e) => path.endsWith(e))) return false;
1447
- if (IGNORE_FILE.test(path)) return false;
1448
- return !path.split("/").some((seg) => AUDIT_IGNORE_DIRS.includes(seg));
1510
+ // ../core/src/audit/ignore.ts
1511
+ var IGNORE_DIRECTIVE = "bridge-audit-ignore-next-line";
1512
+ function ignoreDirectives(text) {
1513
+ const at = lineCol(text);
1514
+ const out = [];
1515
+ for (const m of text.matchAll(new RegExp(`${IGNORE_DIRECTIVE}\\b`, "g"))) {
1516
+ const start = m.index;
1517
+ const opener = /(<!--|\/\*|\/\/)(\s*)$/.exec(text.slice(Math.max(0, start - 200), start));
1518
+ const open = opener?.[1];
1519
+ if (!open || open === "//" && opener[2].includes("\n")) continue;
1520
+ const from = start + m[0].length;
1521
+ let end;
1522
+ if (open === "//") {
1523
+ end = text.indexOf("\n", from);
1524
+ if (end < 0) end = text.length;
1525
+ } else {
1526
+ end = text.indexOf(open === "<!--" ? "-->" : "*/", from);
1527
+ if (end < 0) continue;
1528
+ }
1529
+ const reason = text.slice(from, end).replace(/^\s*:?/, "").replace(/\s+/g, " ").trim();
1530
+ out.push({ line: at(start).line, target: at(end).line + 1, reason });
1531
+ }
1532
+ return out;
1449
1533
  }
1450
- var langOf = (path) => {
1451
- const ext = path.slice(path.lastIndexOf(".") + 1);
1452
- if (ext === "css") return "css";
1453
- if (ext === "scss" || ext === "sass") return "scss";
1454
- if (ext === "less") return "less";
1455
- if (ext === "tsx") return "tsx";
1456
- if (ext === "jsx") return "jsx";
1457
- if (/^[cm]?ts$/.test(ext)) return "ts";
1458
- return "js";
1459
- };
1534
+ var item = (x, reason, via) => "category" in x ? { line: x.line, column: x.column, kind: "value", what: x.value, reason, via } : { line: x.line, column: x.column, kind: "component", what: x.name, reason, via };
1535
+ function applyIgnoreComments(text, findings, components) {
1536
+ const errors = [];
1537
+ const reasons = /* @__PURE__ */ new Map();
1538
+ for (const d of ignoreDirectives(text)) {
1539
+ if (!d.reason) {
1540
+ errors.push({ line: d.line, message: `${IGNORE_DIRECTIVE} needs a reason after a colon (${IGNORE_DIRECTIVE}: why this one stays); nothing was ignored` });
1541
+ continue;
1542
+ }
1543
+ if (!findings.some((f) => f.line === d.target) && !components.some((c) => c.line === d.target)) {
1544
+ errors.push({ line: d.line, message: `${IGNORE_DIRECTIVE} ignores nothing: line ${d.target} has no finding; remove the comment` });
1545
+ continue;
1546
+ }
1547
+ reasons.set(d.target, d.reason);
1548
+ }
1549
+ const ignored = [];
1550
+ const keep = (xs) => xs.filter((x) => {
1551
+ const r = reasons.get(x.line);
1552
+ if (r === void 0) return true;
1553
+ ignored.push(item(x, r, "comment"));
1554
+ return false;
1555
+ });
1556
+ return { findings: keep(findings), components: keep(components), ignored, errors };
1557
+ }
1558
+ function applyAllowlist(entries, findings, components) {
1559
+ const used = /* @__PURE__ */ new Set();
1560
+ const ignored = [];
1561
+ const keep = (xs) => xs.filter((x) => {
1562
+ const what = "category" in x ? x.value : x.name;
1563
+ const fits = (e) => e.line === x.line && (e.what === void 0 || e.what === what);
1564
+ let i = entries.findIndex((e, k) => fits(e) && !used.has(k));
1565
+ if (i < 0) i = entries.findIndex(fits);
1566
+ if (i < 0) return true;
1567
+ used.add(i);
1568
+ ignored.push(item(x, entries[i].reason, "allowlist"));
1569
+ return false;
1570
+ });
1571
+ return { findings: keep(findings), components: keep(components), ignored, used };
1572
+ }
1573
+ function parseAllowlist(json) {
1574
+ const fail = (msg) => {
1575
+ throw new Error(`allowlist: ${msg}`);
1576
+ };
1577
+ if (!json || typeof json !== "object" || !Array.isArray(json.entries)) fail('expected { "entries": [...] }');
1578
+ return json.entries.map((e, i) => {
1579
+ const x = e;
1580
+ if (typeof x.file !== "string" || !x.file) fail(`entry ${i} needs "file"`);
1581
+ if (typeof x.line !== "number" || !Number.isInteger(x.line) || x.line < 1) fail(`entry ${i} (${x.file}) needs a "line" number`);
1582
+ if (typeof x.reason !== "string" || !x.reason.trim()) fail(`entry ${i} (${x.file}:${x.line}) needs a "reason"`);
1583
+ if (x.what !== void 0 && typeof x.what !== "string") fail(`entry ${i} (${x.file}:${x.line}): "what" must be a string`);
1584
+ return { file: x.file, line: x.line, ...x.what !== void 0 ? { what: x.what } : {}, reason: x.reason.trim() };
1585
+ });
1586
+ }
1587
+
1588
+ // ../core/src/audit/style.ts
1460
1589
  function isSafeFix(h, s) {
1461
1590
  if (!s || s.level !== "exact") return false;
1462
1591
  switch (h.category) {
@@ -1481,49 +1610,342 @@ function isSafeFix(h, s) {
1481
1610
  return false;
1482
1611
  }
1483
1612
  }
1484
- function auditFile(path, text, ds) {
1485
- const lang = langOf(path);
1486
- if (lang === "css" || lang === "scss" || lang === "less") {
1487
- const at = lineCol(text);
1488
- const scan = scanCssText(text, lang);
1489
- const findings = [];
1490
- let tokenUsages = 0;
1491
- let themeRefs = 0;
1492
- for (const d of scan.decls) {
1493
- for (const v of d.scan.vars) ds.isDsVar(v) ? tokenUsages++ : themeRefs++;
1494
- if (d.scan.themeRef) themeRefs++;
1495
- else if (lang !== "css" && !d.property.startsWith("$")) themeRefs += (d.value.match(/\$[\w-]+/g) ?? []).length;
1496
- const property = d.property.toLowerCase();
1497
- for (const h of d.scan.hits) {
1498
- const suggestion = ds.suggest(h.category, h.text, property);
1499
- const p = at(d.valueStart + h.index);
1500
- findings.push({ ...p, category: h.category, property, value: h.text, source: "css", ...suggestion ? { suggestion } : {}, ...isSafeFix(h, suggestion) ? { autofix: true } : {} });
1613
+ function auditCssText(text, lang, ds, opts) {
1614
+ const base = opts.offset ?? 0;
1615
+ const source = opts.source ?? "css";
1616
+ const at = (o) => opts.pos(base + o);
1617
+ const scan = scanCssText(text, lang);
1618
+ const findings = [];
1619
+ let tokenUsages = 0;
1620
+ let themeRefs = 0;
1621
+ for (const d of scan.decls) {
1622
+ for (const v of d.scan.vars) ds.isDsVar(v) ? tokenUsages++ : themeRefs++;
1623
+ if (d.scan.themeRef) themeRefs++;
1624
+ else if (lang !== "css" && !d.property.startsWith("$")) themeRefs += (d.value.match(/\$[\w-]+/g) ?? []).length;
1625
+ const property = d.property.toLowerCase();
1626
+ for (const h of d.scan.hits) {
1627
+ const suggestion = ds.suggest(h.category, h.text, property);
1628
+ const autofix = source === "css" && isSafeFix(h, suggestion);
1629
+ findings.push({ ...at(d.valueStart + h.index), category: h.category, property, value: h.text, source, ...suggestion ? { suggestion } : {}, ...autofix ? { autofix: true } : {} });
1630
+ }
1631
+ }
1632
+ for (const a of scan.applies) {
1633
+ for (const h of a.hits) {
1634
+ if (h.kind === "scale") themeRefs++;
1635
+ else if (h.kind !== "token" && h.value) {
1636
+ const cat = parseCssColor(h.value) ? "color" : h.category === "color-or-size" ? "font-size" : h.category === "color-or-width" ? "border-width" : h.category === "color-or-shadow" ? "shadow" : h.category === "font" ? "font-family" : h.category;
1637
+ const suggestion = ds.suggest(cat, h.value);
1638
+ findings.push({ ...at(a.start + h.index), category: cat, value: h.cls, source: "tailwind", ...suggestion ? { suggestion } : {} });
1639
+ } else if (h.kind === "token" && h.value) {
1640
+ const v = /var\(\s*(--[\w-]+)/.exec(h.value)?.[1];
1641
+ if (v && ds.isDsVar(v)) tokenUsages++;
1642
+ else themeRefs++;
1501
1643
  }
1502
1644
  }
1503
- for (const a of scan.applies) {
1504
- for (const h of a.hits) {
1505
- if (h.kind === "scale") themeRefs++;
1506
- else if (h.kind !== "token" && h.value) {
1507
- const cat = parseCssColor(h.value) ? "color" : h.category === "color-or-size" ? "font-size" : h.category === "color-or-width" ? "border-width" : h.category === "color-or-shadow" ? "shadow" : h.category === "font" ? "font-family" : h.category;
1508
- const suggestion = ds.suggest(cat, h.value);
1509
- findings.push({ ...at(a.start + h.index), category: cat, value: h.cls, source: "tailwind", ...suggestion ? { suggestion } : {} });
1510
- } else if (h.kind === "token" && h.value) {
1511
- const v = /var\(\s*(--[\w-]+)/.exec(h.value)?.[1];
1512
- if (v && ds.isDsVar(v)) tokenUsages++;
1513
- else themeRefs++;
1514
- }
1645
+ }
1646
+ findings.sort((x, y) => x.line - y.line || x.column - y.column);
1647
+ return { findings, tokenUsages, themeRefs };
1648
+ }
1649
+
1650
+ // ../core/src/audit/markup.ts
1651
+ var MARKUP_EXTENSIONS = [".astro", ".vue", ".svelte"];
1652
+ function mask2(text, ranges, keepInside) {
1653
+ const inside = new Uint8Array(text.length);
1654
+ for (const r of ranges) inside.fill(1, r.start, r.end);
1655
+ let out = "";
1656
+ for (let i = 0; i < text.length; i++) {
1657
+ const c = text[i];
1658
+ out += c === "\n" || Boolean(inside[i]) === keepInside ? c : " ";
1659
+ }
1660
+ return out;
1661
+ }
1662
+ var attrOf = (attrs, name) => new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs)?.slice(1).find((v) => v !== void 0);
1663
+ var isCodeScript = (attrs) => {
1664
+ const type = attrOf(attrs, "type")?.toLowerCase();
1665
+ return !type || ["module", "text/javascript", "application/javascript", "text/typescript", "ts", "text/babel"].includes(type);
1666
+ };
1667
+ function markupBlocks(text, lang) {
1668
+ const nonTemplate = [];
1669
+ let frontmatter;
1670
+ let from = 0;
1671
+ if (lang === "astro") {
1672
+ const open = /^\s*---[^\S\n]*\n/.exec(text);
1673
+ if (open) {
1674
+ const close = /\n---[^\S\n]*(\n|$)/g;
1675
+ close.lastIndex = open[0].length - 1;
1676
+ const m = close.exec(text);
1677
+ if (m) {
1678
+ frontmatter = { start: open[0].length, end: m.index + 1 };
1679
+ from = m.index + m[0].length;
1680
+ nonTemplate.push({ start: 0, end: from });
1515
1681
  }
1516
1682
  }
1517
- findings.sort((x, y) => x.line - y.line || x.column - y.column);
1518
- return { path, lang, tokenUsages, themeRefs, hardcoded: findings.length, dsComponents: 0, otherComponents: 0, findings, components: [], definitions: [], dsByName: {} };
1519
1683
  }
1520
- const c = scanCode(path, text, ds);
1684
+ const scripts = [];
1685
+ const styles = [];
1686
+ const re = /<(script|style)\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/\1\s*>/gi;
1687
+ re.lastIndex = from;
1688
+ for (let m; m = re.exec(text); ) {
1689
+ const contentStart = m.index + m[0].indexOf(">", m[1].length + 1 + m[2].length) + 1;
1690
+ const block = { start: contentStart, end: contentStart + m[3].length, attrs: m[2] };
1691
+ (m[1].toLowerCase() === "script" ? scripts : styles).push(block);
1692
+ nonTemplate.push({ start: m.index, end: m.index + m[0].length });
1693
+ }
1694
+ for (const m of text.slice(from).matchAll(/<!--[\s\S]*?-->/g)) nonTemplate.push({ start: from + m.index, end: from + m.index + m[0].length });
1695
+ return { ...frontmatter ? { frontmatter } : {}, scripts, styles, nonTemplate };
1696
+ }
1697
+ function closeBrace(s, i) {
1698
+ let depth = 0;
1699
+ for (let k = i; k < s.length; k++) {
1700
+ const c = s[k];
1701
+ if (c === "{") depth++;
1702
+ else if (c === "}") {
1703
+ if (--depth === 0) return k + 1;
1704
+ } else if (c === '"' || c === "'") {
1705
+ const e = s.indexOf(c, k + 1);
1706
+ if (e < 0) return s.length;
1707
+ k = e;
1708
+ } else if (c === "`") {
1709
+ for (k++; k < s.length && s[k] !== "`"; k++) {
1710
+ if (s[k] === "\\") k++;
1711
+ else if (s[k] === "$" && s[k + 1] === "{") k = closeBrace(s, k + 1) - 1;
1712
+ }
1713
+ }
1714
+ }
1715
+ return s.length;
1716
+ }
1717
+ function readTag(s, i, lang) {
1718
+ const attrs = [];
1719
+ let k = i;
1720
+ while (k < s.length) {
1721
+ while (k < s.length && /\s/.test(s[k])) k++;
1722
+ const c = s[k];
1723
+ if (c === void 0) break;
1724
+ if (c === ">") return { attrs, end: k + 1, selfClosing: false };
1725
+ if (c === "/" && s[k + 1] === ">") return { attrs, end: k + 2, selfClosing: true };
1726
+ if (c === "{") {
1727
+ const e = closeBrace(s, k);
1728
+ const inner = s.slice(k + 1, e - 1).trim();
1729
+ attrs.push(inner.startsWith("...") ? { name: "", kind: "spread", value: inner, valueStart: k + 1 } : { name: inner, kind: "expr", value: inner, valueStart: k + 1 });
1730
+ k = e;
1731
+ continue;
1732
+ }
1733
+ const nm = /^[^\s=>/"'{]+/.exec(s.slice(k, k + 200));
1734
+ if (!nm) {
1735
+ k++;
1736
+ continue;
1737
+ }
1738
+ const name = nm[0];
1739
+ k += name.length;
1740
+ let j = k;
1741
+ while (j < s.length && /\s/.test(s[j])) j++;
1742
+ if (s[j] !== "=") {
1743
+ attrs.push({ name, kind: "bare", value: "", valueStart: k });
1744
+ continue;
1745
+ }
1746
+ j++;
1747
+ while (j < s.length && /\s/.test(s[j])) j++;
1748
+ const q = s[j];
1749
+ const bound = lang === "vue" && /^(:|v-bind:|v-on:|@|v-)/.test(name);
1750
+ if (q === '"' || q === "'") {
1751
+ const e = s.indexOf(q, j + 1);
1752
+ const end = e < 0 ? s.length : e;
1753
+ attrs.push({ name, kind: bound ? "expr" : "static", value: s.slice(j + 1, end), valueStart: j + 1 });
1754
+ k = end + 1;
1755
+ } else if (q === "{") {
1756
+ const e = closeBrace(s, j);
1757
+ attrs.push({ name, kind: "expr", value: s.slice(j + 1, e - 1), valueStart: j + 1 });
1758
+ k = e;
1759
+ } else {
1760
+ const m = /^[^\s>]+/.exec(s.slice(j));
1761
+ const v = m?.[0].replace(/\/$/, "") ?? "";
1762
+ attrs.push({ name, kind: "static", value: v, valueStart: j });
1763
+ k = j + v.length;
1764
+ }
1765
+ }
1766
+ return { attrs, end: s.length, selfClosing: false };
1767
+ }
1768
+ var attrName = (n) => n.replace(/^(v-bind:|:|bind:)/, "");
1769
+ var pascal = (kebab) => kebab.replace(/(^|-)([a-z0-9])/g, (_m, _d, c) => c.toUpperCase());
1770
+ var NO_COLOR_ATTR_TAGS = /* @__PURE__ */ new Set(["meta", "link"]);
1771
+ function scanMarkup(path, text, lang, ds) {
1772
+ const out = { findings: [], tokenUsages: 0, themeRefs: 0, ds: {}, components: [], definitions: [] };
1773
+ const pos = lineCol(text);
1774
+ const blocks = markupBlocks(text, lang);
1775
+ const imports = /* @__PURE__ */ new Map();
1776
+ const merge = (c) => {
1777
+ out.findings.push(...c.findings);
1778
+ out.tokenUsages += c.tokenUsages;
1779
+ out.themeRefs += c.themeRefs;
1780
+ for (const [n, k] of Object.entries(c.ds)) out.ds[n] = (out.ds[n] ?? 0) + k;
1781
+ out.components.push(...c.components);
1782
+ out.definitions.push(...c.definitions);
1783
+ };
1784
+ const codeRanges = [...blocks.frontmatter ? [{ ...blocks.frontmatter, attrs: "" }] : [], ...blocks.scripts.filter((b) => isCodeScript(b.attrs))];
1785
+ for (const r of codeRanges) {
1786
+ const jsx = /^(tsx|jsx)$/i.test(attrOf(r.attrs, "lang") ?? "");
1787
+ const code = mask2(text, [r], true);
1788
+ const file = `${path}.${jsx ? "tsx" : "ts"}`;
1789
+ merge(scanCode(file, code, ds));
1790
+ for (const [k, v] of importsOf(file, code)) imports.set(k, v);
1791
+ }
1792
+ for (const b of blocks.styles) {
1793
+ const l = (attrOf(b.attrs, "lang") ?? "css").toLowerCase();
1794
+ const r = auditCssText(text.slice(b.start, b.end), l === "scss" || l === "sass" ? "scss" : l === "less" ? "less" : "css", ds, { pos, offset: b.start });
1795
+ out.findings.push(...r.findings);
1796
+ out.tokenUsages += r.tokenUsages;
1797
+ out.themeRefs += r.themeRefs;
1798
+ }
1799
+ const scanExpr = (prefix, expr, offset, suffix, keepLookalikes = false) => {
1800
+ const { line, column } = pos(offset);
1801
+ const src = line >= 2 ? `${"\n".repeat(line - 2)}${prefix}
1802
+ ${" ".repeat(column - 1)}${expr}${suffix}` : `${prefix}${expr}${suffix}`;
1803
+ const c = scanCode(`${path}.tsx`, src, ds);
1804
+ merge({ ...c, components: keepLookalikes ? c.components.filter((x) => x.kind === "recreated" && x.name.startsWith(".")) : [], definitions: [], ds: {} });
1805
+ };
1806
+ const styleAttr = (a) => {
1807
+ if (a.kind === "static") {
1808
+ const css = lang === "svelte" ? a.value.replace(/\{[^}]*\}/g, (m) => " ".repeat(m.length)) : a.value;
1809
+ const r = auditCssText(css, "css", ds, { pos, offset: a.valueStart, source: "style" });
1810
+ out.findings.push(...r.findings);
1811
+ out.tokenUsages += r.tokenUsages;
1812
+ out.themeRefs += r.themeRefs;
1813
+ return;
1814
+ }
1815
+ const str = /^(\s*)([`'"])([\s\S]*)\2\s*$/.exec(a.value);
1816
+ if (str) {
1817
+ const body = str[3].replace(/\$\{[^}]*\}/g, (m) => " ".repeat(m.length));
1818
+ const r = auditCssText(body, "css", ds, { pos, offset: a.valueStart + str[1].length + 1, source: "style" });
1819
+ out.findings.push(...r.findings);
1820
+ out.tokenUsages += r.tokenUsages;
1821
+ out.themeRefs += r.themeRefs;
1822
+ return;
1823
+ }
1824
+ scanExpr("const style: CSSProperties =", a.value, a.valueStart, ";");
1825
+ };
1826
+ const tpl = mask2(text, blocks.nonTemplate, false);
1827
+ const pending = [];
1828
+ let noscript = 0;
1829
+ const TAG = /<(\/?)([A-Za-z][\w.:-]*)/g;
1830
+ for (let m; m = TAG.exec(tpl); ) {
1831
+ const raw = m[2];
1832
+ if (m[1]) {
1833
+ if (raw.toLowerCase() === "noscript") noscript = Math.max(0, noscript - 1);
1834
+ continue;
1835
+ }
1836
+ const tag = readTag(tpl, m.index + m[0].length, lang);
1837
+ TAG.lastIndex = tag.end;
1838
+ const lower = raw.toLowerCase();
1839
+ if (lower === "noscript" && !tag.selfClosing) {
1840
+ noscript++;
1841
+ continue;
1842
+ }
1843
+ const attrs = tag.attrs;
1844
+ const get = (n) => attrs.find((a) => attrName(a.name) === n);
1845
+ for (const a of attrs) {
1846
+ const n = attrName(a.name);
1847
+ if (n === "style") styleAttr(a);
1848
+ else if (lang === "svelte" && a.name.startsWith("style:") && a.kind === "static") {
1849
+ const prop = a.name.slice(6).split("|")[0];
1850
+ const r2 = auditCssText(`${prop}:${a.value}`, "css", ds, { pos, offset: a.valueStart - prop.length - 1, source: "style" });
1851
+ out.findings.push(...r2.findings);
1852
+ out.tokenUsages += r2.tokenUsages;
1853
+ out.themeRefs += r2.themeRefs;
1854
+ } else if (/^(class|className|class:list)$/.test(n)) {
1855
+ if (a.kind === "static") {
1856
+ for (const l of lookalikesInClassList(a.value, ds.components)) out.components.push({ ...pos(a.valueStart), name: `.${l.token}`, kind: "recreated", library: "local", suggestion: l.component });
1857
+ const r2 = tailwindFindings(scanClassString(a.value), a.valueStart, pos, ds);
1858
+ out.findings.push(...r2.findings);
1859
+ out.themeRefs += r2.themeRefs;
1860
+ } else if (a.kind === "expr") scanExpr("cn(", a.value, a.valueStart, ")", true);
1861
+ } else if (LOOKALIKE_ATTRS[n] && ds.components.has(LOOKALIKE_ATTRS[n])) {
1862
+ out.components.push({ ...pos(a.valueStart - a.name.length - 1), name: `[${n}]`, kind: "recreated", library: "local", suggestion: LOOKALIKE_ATTRS[n] });
1863
+ } else if (a.kind === "static" && !NO_COLOR_ATTR_TAGS.has(lower) && isColorLiteral(a.value)) {
1864
+ const suggestion = ds.suggest("color", a.value.trim(), n);
1865
+ out.findings.push({ ...pos(a.valueStart), category: "color", property: n, value: a.value.trim(), source: "jsx-attr", ...suggestion ? { suggestion } : {} });
1866
+ }
1867
+ }
1868
+ let name = raw;
1869
+ if (raw.includes(":")) continue;
1870
+ if (raw.includes("-")) {
1871
+ if (!imports.has(pascal(raw))) continue;
1872
+ name = pascal(raw);
1873
+ }
1874
+ const isNative = /^[a-z]/.test(name) && !name.includes(".");
1875
+ if (isNative && noscript > 0) continue;
1876
+ const type = get("type");
1877
+ const r = classifyTag({ name, ...pos(m.index), ...type?.kind === "static" ? { type: type.value } : {}, hasHref: Boolean(get("href")) }, imports, ds);
1878
+ if (r.ds) out.ds[r.ds] = (out.ds[r.ds] ?? 0) + 1;
1879
+ if (r.component) out.components.push(r.component);
1880
+ if (r.localCandidate) pending.push(r.localCandidate);
1881
+ }
1882
+ const base = /([^/]+)\.(astro|vue|svelte)$/.exec(path)?.[1];
1883
+ if (base && /^[A-Z][A-Za-z0-9]*$/.test(base)) out.definitions.push({ name: base, line: 1 });
1884
+ const defined = new Set(out.definitions.map((d) => d.name));
1885
+ out.components.push(...pending.filter((f) => defined.has(f.suggestion)));
1886
+ out.findings.sort((a, b) => a.line - b.line || a.column - b.column);
1887
+ out.components.sort((a, b) => a.line - b.line || a.column - b.column);
1888
+ return out;
1889
+ }
1890
+
1891
+ // ../core/src/audit/report.ts
1892
+ var AUDIT_EXTENSIONS = [".css", ".scss", ".sass", ".less", ".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".mts", ".cts", ...MARKUP_EXTENSIONS];
1893
+ var AUDIT_IGNORE_DIRS = ["node_modules", "dist", "build", "out", "coverage", ".git", ".next", ".nuxt", ".astro", ".turbo", ".cache", ".vercel", "storybook-static", "vendor", "public"];
1894
+ var IGNORE_FILE = /\.d\.[cm]?ts$|\.(test|spec|stories|story)\.[cm]?[jt]sx?$|\.min\.(css|js)$|(^|\/)(vite|vitest|jest|webpack|rollup|eslint|postcss|tailwind|babel|next|astro|prettier|stylelint|tsup)\.config\.[cm]?[jt]s$/;
1895
+ function isAuditable(path) {
1896
+ if (!AUDIT_EXTENSIONS.some((e) => path.endsWith(e))) return false;
1897
+ if (IGNORE_FILE.test(path)) return false;
1898
+ return !path.split("/").some((seg) => AUDIT_IGNORE_DIRS.includes(seg));
1899
+ }
1900
+ var langOf = (path) => {
1901
+ const ext = path.slice(path.lastIndexOf(".") + 1);
1902
+ if (ext === "css") return "css";
1903
+ if (ext === "scss" || ext === "sass") return "scss";
1904
+ if (ext === "less") return "less";
1905
+ if (ext === "tsx") return "tsx";
1906
+ if (ext === "jsx") return "jsx";
1907
+ if (/^[cm]?ts$/.test(ext)) return "ts";
1908
+ if (ext === "astro" || ext === "vue" || ext === "svelte") return ext;
1909
+ return "js";
1910
+ };
1911
+ function auditFile(path, text, ds) {
1912
+ const lang = langOf(path);
1913
+ if (lang === "css" || lang === "scss" || lang === "less") {
1914
+ const r = auditCssText(text, lang, ds, { pos: lineCol(text) });
1915
+ return withIgnores(text, { path, lang, tokenUsages: r.tokenUsages, themeRefs: r.themeRefs, hardcoded: 0, dsComponents: 0, otherComponents: 0, findings: r.findings, components: [], definitions: [], dsByName: {} });
1916
+ }
1917
+ const c = lang === "astro" || lang === "vue" || lang === "svelte" ? scanMarkup(path, text, lang, ds) : scanCode(path, text, ds);
1521
1918
  const dsCount = Object.values(c.ds).reduce((a, b) => a + b, 0);
1522
- return { path, lang, tokenUsages: c.tokenUsages, themeRefs: c.themeRefs, hardcoded: c.findings.length, dsComponents: dsCount, otherComponents: c.components.length, findings: c.findings, components: c.components, definitions: c.definitions, dsByName: c.ds };
1919
+ return withIgnores(text, { path, lang, tokenUsages: c.tokenUsages, themeRefs: c.themeRefs, hardcoded: 0, dsComponents: dsCount, otherComponents: 0, findings: c.findings, components: c.components, definitions: c.definitions, dsByName: c.ds });
1920
+ }
1921
+ function withIgnores(text, f) {
1922
+ const r = applyIgnoreComments(text, f.findings, f.components);
1923
+ return {
1924
+ ...f,
1925
+ findings: r.findings,
1926
+ components: r.components,
1927
+ hardcoded: r.findings.length,
1928
+ otherComponents: r.components.length,
1929
+ ...r.ignored.length ? { ignored: r.ignored } : {},
1930
+ ...r.errors.length ? { ignoreErrors: r.errors } : {}
1931
+ };
1523
1932
  }
1524
1933
  var pct = (num2, den) => den > 0 ? Math.round(num2 / den * 1e3) / 10 : null;
1525
1934
  function buildAuditReport(input, ds) {
1526
- const reports = input.files.map((f) => auditFile(f.path, f.text, ds));
1935
+ const allow = input.allowlist ?? [];
1936
+ const usedAllow = /* @__PURE__ */ new Set();
1937
+ const reports = input.files.map((f) => {
1938
+ const r = auditFile(f.path, f.text, ds);
1939
+ const idx = allow.flatMap((e, i) => e.file === f.path ? [i] : []);
1940
+ if (!idx.length) return r;
1941
+ const a = applyAllowlist(idx.map((i) => allow[i]), r.findings, r.components);
1942
+ a.used.forEach((k) => usedAllow.add(idx[k]));
1943
+ if (!a.ignored.length) return r;
1944
+ const ignored = [...r.ignored ?? [], ...a.ignored].sort((x, y) => x.line - y.line || x.column - y.column);
1945
+ return { ...r, findings: a.findings, components: a.components, hardcoded: a.findings.length, otherComponents: a.components.length, ignored };
1946
+ });
1947
+ const ignoredItems = reports.flatMap((r) => (r.ignored ?? []).map((x) => ({ file: r.path, ...x })));
1948
+ const staleAllowlist = allow.filter((_e, i) => !usedAllow.has(i));
1527
1949
  const files = reports.map(({ definitions: _d, dsByName: _n, ...r }) => r);
1528
1950
  const byCategory = {};
1529
1951
  const fixes = /* @__PURE__ */ new Map();
@@ -1583,6 +2005,15 @@ function buildAuditReport(input, ds) {
1583
2005
  topFiles,
1584
2006
  topFixes,
1585
2007
  files: files.sort((a, b) => a.path.localeCompare(b.path)),
2008
+ ...ignoredItems.length || staleAllowlist.length ? {
2009
+ ignored: {
2010
+ total: ignoredItems.length,
2011
+ comment: ignoredItems.filter((x) => x.via === "comment").length,
2012
+ allowlist: ignoredItems.filter((x) => x.via === "allowlist").length,
2013
+ items: ignoredItems,
2014
+ staleAllowlist
2015
+ }
2016
+ } : {},
1586
2017
  history: []
1587
2018
  };
1588
2019
  }
@@ -1686,6 +2117,20 @@ function auditMarkdown(r, opts = {}) {
1686
2117
  for (const f of fixes) lines.push(`| \`${esc(f.value)}\` (${f.category}) | \`var(${f.suggestion.cssVar})\` ${esc(f.suggestion.value)}${f.suggestion.mode ? ` [${f.suggestion.mode}]` : ""} | ${f.suggestion.level} | ${f.count} |`);
1687
2118
  lines.push("");
1688
2119
  }
2120
+ const ig = r.ignored;
2121
+ if (ig && (ig.total || ig.staleAllowlist.length)) {
2122
+ lines.push("### Ignored on purpose", "", `${ig.total} item(s) left out of the counts: ${ig.comment} by \`bridge-audit-ignore-next-line\` comments, ${ig.allowlist} by the allowlist.`, "");
2123
+ const shown = ig.items.slice(0, opts.ignored ?? 50);
2124
+ if (shown.length) {
2125
+ lines.push("| Where | What | Reason |", "| --- | --- | --- |");
2126
+ for (const x of shown) lines.push(`| \`${esc(x.file)}:${x.line}\` | \`${esc(x.what)}\` | ${esc(x.reason)} (${x.via}) |`);
2127
+ if (ig.items.length > shown.length) lines.push(`| ... | ${ig.items.length - shown.length} more | |`);
2128
+ lines.push("");
2129
+ }
2130
+ if (ig.staleAllowlist.length) {
2131
+ lines.push(`Allowlist entries that match nothing any more (remove them): ${ig.staleAllowlist.map((e) => `\`${esc(e.file)}:${e.line}${e.what ? ` ${esc(e.what)}` : ""}\``).join(", ")}.`, "");
2132
+ }
2133
+ }
1689
2134
  if (b) {
1690
2135
  lines.push(`### Progress since ${b.label ?? b.generatedAt}`, "");
1691
2136
  lines.push(`- ${b.improved.length} files improved, ${b.regressed.length} regressed, ${b.newFiles.length} new, ${b.removedFiles.length} removed.`);
@@ -2425,13 +2870,16 @@ function collectAuditFiles(target, exclude = []) {
2425
2870
  }
2426
2871
  return { root: abs, files, skipped };
2427
2872
  }
2873
+ function loadAllowlist(path) {
2874
+ return parseAllowlist(JSON.parse(readFileSync(path, "utf8")));
2875
+ }
2428
2876
  function runAudit(target, opts = {}) {
2429
2877
  const dsRoot = opts.ds ?? findDesignSystemRoot(process.cwd());
2430
2878
  if (!dsRoot) throw new Error("No design system found: run from a repo with bridge.config.json or pass --ds <dir>.");
2431
2879
  const ds = loadDesignSystem(dsRoot);
2432
2880
  const { files, skipped } = collectAuditFiles(target, [...ds.config.audit?.exclude ?? [], ...opts.exclude ?? []]);
2433
2881
  const report = buildAuditReport(
2434
- { files, skipped, target: opts.displayPath ?? (toPosix(relative(process.cwd(), resolve(target))) || "."), ...opts.label ? { label: opts.label } : {}, ...opts.version ? { version: opts.version } : {} },
2882
+ { files, skipped, target: opts.displayPath ?? (toPosix(relative(process.cwd(), resolve(target))) || "."), ...opts.label ? { label: opts.label } : {}, ...opts.version ? { version: opts.version } : {}, ...opts.allowlist ? { allowlist: opts.allowlist } : {} },
2435
2883
  ds.index
2436
2884
  );
2437
2885
  return opts.baseline ? compareWithBaseline(report, opts.baseline) : report;
@@ -2600,6 +3048,12 @@ function classifyAuth(status, detail, opts = {}) {
2600
3048
  }
2601
3049
  return {};
2602
3050
  }
3051
+ function rateLimitMessage(x) {
3052
+ const when = x.resetsAt ? ` It resets at ${x.resetsAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}.` : "";
3053
+ if (x.withToken) return `GitHub's hourly request limit for this token is used up.${when} Try again after that.`;
3054
+ const hint = x.tokenHint ?? "add a GitHub token";
3055
+ return `GitHub's hourly limit for requests without a token (60 an hour) is used up.${when} To keep going now, ${hint}: a token gets 5,000 requests an hour.`;
3056
+ }
2603
3057
  var DEFAULT_RETRY = { attempts: 3, baseDelayMs: 600, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
2604
3058
  var isTransient = (status, detail) => status === 0 || status >= 500 || status === 429 || status === 403 && /secondary rate limit/i.test(detail);
2605
3059
  var API = "https://api.github.com";
@@ -2703,12 +3157,8 @@ var GitHubClient = class _GitHubClient {
2703
3157
  const remaining = res.headers?.get("x-ratelimit-remaining");
2704
3158
  const reset = res.headers?.get("x-ratelimit-reset");
2705
3159
  if ((res.status === 403 || res.status === 429) && remaining === "0") {
2706
- const when = reset ? new Date(Number(reset) * 1e3) : void 0;
2707
- throw new GitHubError(
2708
- 429,
2709
- `GitHub's hourly request limit for this token is used up.${when ? ` It resets at ${when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}.` : ""} Try again after that.`,
2710
- detail
2711
- );
3160
+ const resetsAt = reset ? new Date(Number(reset) * 1e3) : void 0;
3161
+ throw new GitHubError(429, rateLimitMessage({ withToken: Boolean(this.token), resetsAt, tokenHint: this.options.tokenHint }), detail);
2712
3162
  }
2713
3163
  const info = classifyAuth(res.status, detail, {
2714
3164
  path,
@@ -2865,6 +3315,25 @@ var GitHubClient = class _GitHubClient {
2865
3315
  throw new GitHubError(422, `GitHub refused to mark #${n} ready for review${detail ? `: ${detail}` : "."} Use "Ready for review" on GitHub instead.`, detail);
2866
3316
  }
2867
3317
  }
3318
+ /**
3319
+ * Turns an open pull request back into a draft: the GraphQL mutation convertPullRequestToDraft (REST cannot set
3320
+ * `draft` either), same token. No-op when it is already a draft.
3321
+ */
3322
+ async convertToDraft(n) {
3323
+ this.assertToken("Turning a pull request back into a draft");
3324
+ const pr = await this.request("GET", `${this.repoPath}/pulls/${n}`);
3325
+ if (pr.draft === true) return;
3326
+ if (pr.state && pr.state !== "open") throw new GitHubError(422, `Pull request #${n} is ${pr.state}: only an open one can go back to draft.`);
3327
+ if (!pr.node_id) throw new GitHubError(422, `Pull request #${n} has no node id: convert it to draft on GitHub.`);
3328
+ const r = await this.request("POST", "/graphql", {
3329
+ query: "mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { number isDraft } } }",
3330
+ variables: { id: pr.node_id }
3331
+ });
3332
+ if (r?.errors?.length) {
3333
+ const detail = r.errors.map((e) => e.message).filter(Boolean).join(" ");
3334
+ throw new GitHubError(422, `GitHub refused to turn #${n} back into a draft${detail ? `: ${detail}` : "."} Use "Convert to draft" on GitHub instead.`, detail);
3335
+ }
3336
+ }
2868
3337
  async createPullRequest(input) {
2869
3338
  this.assertWritableBranch(input.head, input.base);
2870
3339
  const r = await this.request(
@@ -4747,6 +5216,7 @@ async function context2(root, io) {
4747
5216
  const token = io.env.BRIDGE_GITHUB_TOKEN || io.env.GITHUB_TOKEN || io.ghToken();
4748
5217
  return BridgeContext3.create({ root, ...token ? { token } : {}, ...io.fetch ? { fetch: io.fetch } : {} });
4749
5218
  }
5219
+ var exploreSource = (env) => ({ source: "cli", ...env.CLAUDECODE ? { agent: "claude" } : {} });
4750
5220
  var date = (iso) => iso ? iso.slice(0, 10) : "";
4751
5221
  async function runExplore(a, root, io = defaultIO()) {
4752
5222
  const sub = a._[1];
@@ -4762,7 +5232,7 @@ async function runExplore(a, root, io = defaultIO()) {
4762
5232
  if (!name || !purpose) throw new RequestUsageError('Usage: bridge explore start <name> --purpose "What you want to learn"');
4763
5233
  const useCase = flag2(a, "use-case");
4764
5234
  const ctx = await context2(root, io);
4765
- const r = await explorations.startExploration(ctx, { name, purpose, ...useCase ? { useCase } : {}, ...a.flags.experimental ? { experimental: a.flags.experimental } : {}, dryRun: has2(a, "dry-run") });
5235
+ const r = await explorations.startExploration(ctx, { name, purpose, ...exploreSource(io.env), ...useCase ? { useCase } : {}, ...a.flags.experimental ? { experimental: a.flags.experimental } : {}, dryRun: has2(a, "dry-run") });
4766
5236
  if (json) return io.print(JSON.stringify(r, null, 2)), 0;
4767
5237
  if (r.status === "dry-run") {
4768
5238
  io.print(`Dry run, nothing written.
@@ -4865,6 +5335,7 @@ async function rolesReport(opts) {
4865
5335
 
4866
5336
  // src/index.ts
4867
5337
  import { brand, BridgeContext as BridgeContext4, CONFIG_FILE as CONFIG_FILE2, core, detectConfig, GitRefSource, history as hist, LocalSource, ops, optionsFromArgv, serveStdio, toConfigJson, VERSION } from "@bridge-ds/mcp";
5338
+ var MCP_PACKAGE = "@bridge-ds/mcp";
4868
5339
  var HELP = `bridge ${VERSION}: design system as code, for you and your Claude
4869
5340
 
4870
5341
  Usage:
@@ -4899,9 +5370,15 @@ Usage:
4899
5370
  undo a merged token change against HEAD: conflicts shown,
4900
5371
  new local bridge/* branch (never pushes, never main)
4901
5372
  bridge audit <path> [--json [out.json]] [--md [out.md]] [--fail-under 80] [--baseline prev.json]
4902
- [--label "..."] [--ds dir] [--no-regress]
5373
+ [--label "..."] [--ds dir] [--no-regress] [--allowlist known.json]
4903
5374
  design system adoption in a product folder: token and component
4904
5375
  adoption, hardcoded values with the closest token, top files.
5376
+ Reads CSS, SCSS, Less, TS/JS, TSX/JSX, and Astro, Vue and Svelte
5377
+ (template markup, frontmatter and <script>, <style> blocks).
5378
+ Leave one exception out with a comment on the line above:
5379
+ <!-- bridge-audit-ignore-next-line: reason --> (or // or /* */);
5380
+ --allowlist lists known file:line exceptions, each with a reason.
5381
+ Ignored items stay listed in the report.
4905
5382
  Exit 0 ok, 1 under --fail-under or regressed, 2 setup error
4906
5383
  bridge audit <path> --fix [--write] safe autofix: CSS values equal to a token become var(--token).
4907
5384
  Dry run unless --write
@@ -4970,18 +5447,19 @@ async function init(a, root) {
4970
5447
  }
4971
5448
  const serverJs = fileURLToPath(new URL("./index.js", import.meta.resolve("@bridge-ds/mcp")));
4972
5449
  const relServer = relative4(root, serverJs);
4973
- const inRepo = !relServer.startsWith("..");
5450
+ const inRepo = !relServer.startsWith("..") && !isAbsolute(relServer);
5451
+ const server = (dir) => inRepo ? { command: "node", args: [relServer, "--root", dir] } : { command: "npx", args: ["-y", MCP_PACKAGE, "--root", dir] };
4974
5452
  const mcpPath = join5(root, ".mcp.json");
4975
5453
  const existing = existsSync5(mcpPath) ? JSON.parse(await readFile5(mcpPath, "utf8")) : {};
4976
- existing.mcpServers = { ...existing.mcpServers ?? {}, bridge: { command: "node", args: [inRepo ? relServer : serverJs, "--root", "."] } };
5454
+ existing.mcpServers = { ...existing.mcpServers ?? {}, bridge: server(".") };
4977
5455
  await writeFile4(mcpPath, `${JSON.stringify(existing, null, 2)}
4978
5456
  `);
4979
5457
  print(`Wrote .mcp.json: Claude Code picks up the "bridge" server next time it starts in ${root}.`);
5458
+ const desktop = inRepo ? { command: "node", args: [serverJs, "--root", root] } : server(root);
4980
5459
  print("\nClaude Desktop: add this to claude_desktop_config.json (Settings > Developer > Edit config):");
4981
- print(JSON.stringify({ mcpServers: { bridge: { command: "node", args: [serverJs, "--root", root] } } }, null, 2));
5460
+ print(JSON.stringify({ mcpServers: { bridge: desktop } }, null, 2));
4982
5461
  print("\nOr with the Claude Code CLI:");
4983
- print(` claude mcp add bridge -- node ${serverJs} --root ${root}`);
4984
- print("(Once @bridge-ds/mcp is published: claude mcp add bridge -- npx -y @bridge-ds/mcp)");
5462
+ print(` claude mcp add bridge -- ${desktop.command} ${desktop.args.join(" ")}`);
4985
5463
  }
4986
5464
  async function check(a, root) {
4987
5465
  const ctx = await BridgeContext4.create({ root });
@@ -5291,7 +5769,15 @@ async function audit(a, root) {
5291
5769
  baseline = JSON.parse(await readFile5(baselinePath, "utf8"));
5292
5770
  }
5293
5771
  const label = flag3(a, "label");
5294
- const report = runAudit(target, { ds: dsRoot, version: VERSION, ...label ? { label } : {}, ...baseline ? { baseline } : {} });
5772
+ const allowlistPath = flag3(a, "allowlist");
5773
+ if (allowlistPath && !existsSync5(allowlistPath)) throw new UsageError(`Allowlist not found: ${allowlistPath}`);
5774
+ let allowlist;
5775
+ try {
5776
+ allowlist = allowlistPath ? loadAllowlist(allowlistPath) : void 0;
5777
+ } catch (e) {
5778
+ throw new UsageError(`${allowlistPath}: ${e.message}`);
5779
+ }
5780
+ const report = runAudit(target, { ds: dsRoot, version: VERSION, ...label ? { label } : {}, ...baseline ? { baseline } : {}, ...allowlist ? { allowlist } : {} });
5295
5781
  const md = auditMarkdown(report);
5296
5782
  const jsonOut = flag3(a, "json");
5297
5783
  const mdOut = flag3(a, "md");
@@ -5322,9 +5808,17 @@ async function audit(a, root) {
5322
5808
  print(" top fixes:");
5323
5809
  for (const f of fixes) print(` ${f.value} -> var(${f.suggestion.cssVar}) ${f.suggestion.level}, ${f.count}x`);
5324
5810
  }
5811
+ const ig = report.ignored;
5812
+ if (ig?.total) print(` ignored on purpose ${ig.total} (${ig.comment} by comment, ${ig.allowlist} by the allowlist); listed in --json and --md`);
5813
+ if (ig?.staleAllowlist.length) {
5814
+ print(` allowlist entries that match nothing any more (remove them):`);
5815
+ for (const e of ig.staleAllowlist) print(` ${e.file}:${e.line}${e.what ? ` ${e.what}` : ""}`);
5816
+ }
5325
5817
  if (jsonOut) print(`Wrote ${jsonOut}`);
5326
5818
  if (mdOut) print(`Wrote ${mdOut}`);
5327
5819
  }
5820
+ for (const f of report.files) for (const e of f.ignoreErrors ?? []) process.stderr.write(`${report.target.path}/${f.path}:${e.line}: ${e.message}
5821
+ `);
5328
5822
  let code = 0;
5329
5823
  const failUnder = flag3(a, "fail-under");
5330
5824
  if (failUnder !== void 0) {
@@ -5387,7 +5881,7 @@ async function main() {
5387
5881
  if (cmd === "explore") return runExplore(a, root);
5388
5882
  if (!cmd || has3(a, "help") || cmd === "help") {
5389
5883
  print(HELP);
5390
- return cmd ? 0 : 2;
5884
+ return cmd || has3(a, "help") ? 0 : 2;
5391
5885
  }
5392
5886
  if (cmd === "--version" || cmd === "version") return print(VERSION), 0;
5393
5887
  switch (cmd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge-ds/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "bridge CLI: set up Bridge in a design system repo, check tokens in CI, search and diff tokens, propose changes, start the MCP server.",
6
6
  "license": "MIT",
@@ -43,8 +43,8 @@
43
43
  "build:package": "node ../../scripts/build-packages.mjs cli"
44
44
  },
45
45
  "dependencies": {
46
- "@bridge-ds/mcp": "^0.3.0",
47
- "@bridge-ds/visual": "^0.2.1",
46
+ "@bridge-ds/mcp": "^0.3.2",
47
+ "@bridge-ds/visual": "^0.2.3",
48
48
  "typescript": "5.9.3"
49
49
  },
50
50
  "devDependencies": {