taste-lint 0.0.6 → 0.0.8

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
@@ -19,20 +19,20 @@ Scan your project with [Jev by TypeSafe AI](https://docs.typesafe.ai/introductio
19
19
  npx taste-lint@latest init
20
20
  ```
21
21
 
22
- Requires Node 24.11 or later. Run from your project directory to install locally and add a scan script.
22
+ Requires Node 24.11 or later. Run it from your project directory. Init installs locally and adds a scan script.
23
23
 
24
24
  ## Quickstart
25
25
 
26
- Create a [Vercel AI Gateway key](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys). From your project directory, set your key and run:
26
+ Create a [Vercel AI Gateway key](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys). Then:
27
27
 
28
28
  ```bash
29
29
  export AI_GATEWAY_API_KEY="your-vercel-ai-gateway-key"
30
30
  npm run taste
31
31
  ```
32
32
 
33
- No account or config for taste-lint. AI checks send selected text and rule context to Vercel AI Gateway, billed to your account. Answers are cached for repeat runs.
33
+ No taste-lint account or config. AI checks send selected text and rule context to Vercel AI Gateway, billed to your account. Repeat runs reuse cached answers.
34
34
 
35
- Use your package manager in place of npm. Add `--agent` to init for agent instructions, or `--dry-run` to preview setup.
35
+ Use your package manager in place of npm. Pass `--agent` to init for agent instructions, or `--dry-run` to preview setup.
36
36
 
37
37
  ## What it checks
38
38
 
@@ -40,17 +40,13 @@ Use your package manager in place of npm. Add `--agent` to init for agent instru
40
40
  - **Writing:** Markdown, MDX, and READMEs with `--profile writing`.
41
41
  - **Agent instructions:** AGENTS.md and skills with `--profile instructions`.
42
42
 
43
- Rules draw on [Agent Skills](https://github.com/mblode/agent-skills) and [Taste Training](https://blode.co/taste-training). Local checks handle measurable rules. Jev judges meaning and returns probabilities. Uncalibrated AI rules stay advisory; active findings can fail a run.
43
+ Rules come from [Agent Skills](https://github.com/mblode/agent-skills) and [Taste Training](https://blode.co/taste-training). Local checks handle measurable rules. Jev judges meaning and returns probabilities. Uncalibrated AI rules stay advisory. Active findings can fail a run.
44
44
 
45
- ## Useful options
45
+ ## Docs
46
46
 
47
- | Option | What it does |
48
- | ---------------- | ---------------------------------------------------- |
49
- | `--dry-run` | Preview scope and estimated cost without model calls |
50
- | `--output json` | Save findings for scripts and agents |
51
- | `--output sarif` | Export findings for code review tools |
47
+ [taste-lint.blode.md](https://taste-lint.blode.md)
52
48
 
53
- Run `taste-lint scan --help` for all options. See the [scan guide](https://github.com/mblode/taste-lint/blob/main/docs/SCANS.md), [usage reference](https://github.com/mblode/taste-lint/blob/main/docs/USAGE.md), and [changelog](https://github.com/mblode/taste-lint/blob/main/CHANGELOG.md) for more.
49
+ `taste-lint scan --help` lists every option. `--dry-run` previews scope and cost. `--output json` and `--output sarif` are for scripts and code review.
54
50
 
55
51
  ## License
56
52
 
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { mdxjs } from "micromark-extension-mdxjs";
16
16
  import { styleText } from "node:util";
17
17
  import os from "node:os";
18
18
  //#region package.json
19
- var version = "0.0.6";
19
+ var version = "0.0.8";
20
20
  //#endregion
21
21
  //#region src/lib/stats.ts
22
22
  const makePRNG = (seed) => {
@@ -444,6 +444,7 @@ const loadCorpus = (corpusDir, rules, options = {}) => {
444
444
  if (r.context !== void 0) {
445
445
  if (!isRecord$1(r.context)) throw new Error(`Invalid corpus line ${where}: context must be an object`);
446
446
  const { docType, role } = r.context;
447
+ if (r.context.fontScale !== void 0 && (!isRecord$1(r.context.fontScale) || Object.values(r.context.fontScale).some((value) => typeof value !== "string"))) throw new Error(`Invalid corpus line ${where}: fontScale must map tokens to strings`);
447
448
  if (docType !== void 0 && !DOC_TYPES.includes(docType) || role !== void 0 && !ROLES.includes(role)) throw new Error(`Invalid corpus line ${where}: unknown context docType or role`);
448
449
  }
449
450
  if (r.neighbours !== void 0) {
@@ -661,7 +662,7 @@ const stripVariants = (cls) => {
661
662
  };
662
663
  };
663
664
  const isColour = (token) => {
664
- if (token.startsWith("[#") || token.startsWith("[rgb") || token.startsWith("[hsl") || token.startsWith("[oklch") || token.startsWith("[var(")) return true;
665
+ if (token.startsWith("[#") || token.startsWith("[rgb") || token.startsWith("[hsl") || token.startsWith("[oklch") || token.startsWith("[oklab") || token.startsWith("[color:") || token.startsWith("(color:")) return true;
665
666
  return COLOUR_NAMES.has(token.replace(/-\d{2,3}$/, ""));
666
667
  };
667
668
  const resolveTypography = (classes, config) => {
@@ -680,7 +681,7 @@ const resolveTypography = (classes, config) => {
680
681
  if (variants.some((v) => !["dark"].includes(v))) continue;
681
682
  if (base.startsWith("text-")) {
682
683
  const [sizeToken, leadingToken] = base.slice(5).split("/");
683
- if (isColour(sizeToken)) {
684
+ if (!Object.hasOwn(theme, sizeToken) && isColour(sizeToken)) {
684
685
  out.colourClasses.push(cls);
685
686
  continue;
686
687
  }
@@ -699,21 +700,25 @@ const resolveTypography = (classes, config) => {
699
700
  "clip"
700
701
  ].includes(sizeToken)) continue;
701
702
  const known = FONT_SIZES[sizeToken];
702
- if (known) {
703
+ const declared = Object.hasOwn(theme, sizeToken);
704
+ const arbitrary = sizeToken.startsWith("[") && sizeToken.endsWith("]");
705
+ if (declared || arbitrary) {
706
+ const raw = declared ? theme[sizeToken] : sizeToken.slice(1, -1).replace(/^length:/u, "");
707
+ const px = parseLength(raw);
708
+ out.fontSizePx = px ?? void 0;
709
+ if (lineHeightFromSize) {
710
+ out.lineHeightPx = void 0;
711
+ out.lineHeight = void 0;
712
+ lineHeightFromSize = false;
713
+ }
714
+ if (px === null) out.unresolved.push(cls);
715
+ } else if (known) {
703
716
  out.fontSizePx = known[0];
704
717
  if (!leadingToken && (lineHeightFromSize || out.lineHeightPx === void 0 && out.lineHeight === void 0)) {
705
718
  out.lineHeightPx = known[1];
706
719
  out.lineHeight = void 0;
707
720
  lineHeightFromSize = true;
708
721
  }
709
- } else if (sizeToken.startsWith("[") && sizeToken.endsWith("]")) {
710
- const px = parseLength(sizeToken.slice(1, -1));
711
- if (px === null) out.unresolved.push(cls);
712
- else out.fontSizePx = px;
713
- } else if (theme[sizeToken]) {
714
- const px = parseLength(theme[sizeToken]);
715
- if (px === null) out.unresolved.push(cls);
716
- else out.fontSizePx = px;
717
722
  } else out.unresolved.push(cls);
718
723
  if (leadingToken) applyLeading(leadingToken, cls);
719
724
  continue;
@@ -784,6 +789,51 @@ const resolveLeading = (out, token, cls) => {
784
789
  out.unresolved.push(cls);
785
790
  };
786
791
  const splitClasses = (value) => value.split(/\s+/u).filter(Boolean);
792
+ /** Only explicit pixel font tokens establish a scale for this comparison.
793
+ * Relative lengths need a rendered root size; other families need their own
794
+ * theme evidence. Neither is inferred from Tailwind's default typography.
795
+ */
796
+ const compareScale = (classes, theme = {}) => {
797
+ const matches = [];
798
+ const unresolved = [];
799
+ for (const className of classes) {
800
+ const { base, variants } = stripVariants(className);
801
+ const candidate = base.match(/^(text|p[xytrblse]?|m[xytrblse]?|gap(?:-[xy])?|space-[xy]|rounded(?:-[a-z]+)?|leading|w|h|size|inset|top|bottom|left|right)-\[(-?(?:\d+(?:\.\d+)?|\.\d+))px\]$/u);
802
+ if (!candidate || Math.abs(Number(candidate[2])) <= 2) continue;
803
+ if (candidate[1] !== "text") {
804
+ unresolved.push(`${className}: ${candidate[1]} scale family is unsupported`);
805
+ continue;
806
+ }
807
+ const entries = Object.entries(theme);
808
+ if (!entries.length) {
809
+ unresolved.push(`${className}: no explicitly declared font scale`);
810
+ continue;
811
+ }
812
+ const value = Number(candidate[2]);
813
+ const resolved = entries.flatMap(([name, raw]) => {
814
+ if (!/^[\w-]+$/u.test(name) || !/^(?:\d+(?:\.\d+)?|\.\d+)px$/u.test(raw)) return [];
815
+ const stepPx = Number(raw.slice(0, -2));
816
+ return Number.isFinite(stepPx) && stepPx > 0 ? [{
817
+ difference: Math.abs(value - stepPx),
818
+ name,
819
+ stepPx
820
+ }] : [];
821
+ });
822
+ const nearest = resolved.toSorted((a, b) => a.difference - b.difference || a.name.localeCompare(b.name))[0];
823
+ const tolerance = Number.EPSILON * Math.max(1, Math.abs(value), nearest?.stepPx ?? 0);
824
+ if (nearest && nearest.difference <= 1 + tolerance) matches.push({
825
+ className,
826
+ difference: Math.min(nearest.difference, 1),
827
+ stepPx: nearest.stepPx,
828
+ token: [...variants, `text-${nearest.name}`].join(":")
829
+ });
830
+ else if (resolved.length < entries.length) unresolved.push(`${className}: declared font scale contains unresolved values`);
831
+ }
832
+ return {
833
+ matches,
834
+ unresolved
835
+ };
836
+ };
787
837
  //#endregion
788
838
  //#region src/lib/errors.ts
789
839
  var InputError = class extends Error {
@@ -2543,7 +2593,7 @@ const ARCHITECTURE_RULES = [
2543
2593
  const MODULE$1 = "src/rules/code/classes.ts";
2544
2594
  const ANIMATION = "skills/ui-animation/references";
2545
2595
  const bases = (unit) => (unit.classes ?? []).map(baseClass);
2546
- const animates = (list) => list.some((c) => c.startsWith("transition") || c.startsWith("duration-") || c.startsWith("animate-") && c !== "animate-none");
2596
+ const animates = (list) => list.some((c) => c.startsWith("transition") && c !== "transition-none" || c.startsWith("duration-") || c.startsWith("animate-") && c !== "animate-none");
2547
2597
  const durationMs = (cls) => {
2548
2598
  const named = cls.match(/^duration-(\d+)$/u);
2549
2599
  if (named) return Number(named[1]);
@@ -2573,6 +2623,29 @@ const shadcn = (spec) => rule$1({
2573
2623
  unit: ["class-list"]
2574
2624
  });
2575
2625
  const CLASS_RULES = [
2626
+ rule$1({
2627
+ categoryId: "look-constraints",
2628
+ check: (unit) => {
2629
+ const compared = compareScale(unit.classes ?? [], unit.context.fontScale);
2630
+ if (!compared.matches.length) {
2631
+ if (compared.unresolved.length) throw new UnresolvedError(compared.unresolved.join("; "));
2632
+ return none;
2633
+ }
2634
+ if (unit.context.dynamic) throw new UnresolvedError("Dynamic classes may override the near-scale candidate");
2635
+ return hit(compared.matches.map(({ className, token, stepPx, difference }) => `${className} is ${difference}px from ${token} (${stepPx}px)`).join("; "));
2636
+ },
2637
+ hint: "Review the matching font token before changing this value. If the difference is intentional or recurring, name the existing size in the theme instead of changing its appearance. This comparison does not establish design intent.",
2638
+ id: "craft-near-duplicate-scale",
2639
+ source: {
2640
+ line: 10,
2641
+ path: "skills/ui-design/rules/slop-near-duplicate-scale.md",
2642
+ repo: "mblode/agent-skills",
2643
+ ruleId: "slop-near-duplicate-scale"
2644
+ },
2645
+ status: "review-only",
2646
+ title: "Arbitrary font size within 1px of a declared theme step",
2647
+ unit: ["class-list"]
2648
+ }),
2576
2649
  motion({
2577
2650
  categoryId: "easing-and-duration",
2578
2651
  check: (unit) => {
@@ -2606,7 +2679,7 @@ const CLASS_RULES = [
2606
2679
  categoryId: "easing-and-duration",
2607
2680
  check: (unit) => {
2608
2681
  const list = bases(unit);
2609
- if (!list.some((c) => c.startsWith("transition"))) return none;
2682
+ if (!list.some((c) => c.startsWith("transition") && c !== "transition-none")) return none;
2610
2683
  const slow = list.filter((c) => (durationMs(c) ?? 0) > 300);
2611
2684
  return slow.length > 0 ? hit(slow.join(" ")) : none;
2612
2685
  },
@@ -2675,13 +2748,18 @@ const CLASS_RULES = [
2675
2748
  shadcn({
2676
2749
  categoryId: "look-constraints",
2677
2750
  check: (unit) => {
2678
- const found = bases(unit).filter((c) => /-\[[^\]]+\]/u.test(c));
2751
+ const found = bases(unit).filter((c) => {
2752
+ const value = c.match(/^-?[\w-]+-\[(.+?)\](?:\/[^\s]+)?!?$/u)?.[1]?.replace(/^(?:length|color):/u, "");
2753
+ if (!value) return false;
2754
+ if (/var\(|env\(|theme\(|^--|^(?:calc|min|max|clamp|minmax|repeat|url)\(/u.test(value)) return false;
2755
+ return /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ms|s|deg)?$/u.test(value) || /^#[\da-f]{3,8}$/iu.test(value) || /^(?:rgb|rgba|hsl|hsla|oklab|oklch|color)\(/u.test(value);
2756
+ });
2679
2757
  return found.length > 0 ? hit(found.join(" ")) : none;
2680
2758
  },
2681
- hint: "Step the scale (p-3, text-sm, rounded-md) or register the value in the theme so the next component can reach it by name. p-[13px] is a nudge, not a decision.",
2759
+ hint: "Review whether an existing token serves this literal value. Keep deliberate layout, asset and optical constraints; register a recurring design value if useful. This syntax check does not establish a defect or a safe replacement.",
2682
2760
  id: "craft-arbitrary-value-class",
2683
2761
  ruleId: "shadcn/no-arbitrary-values",
2684
- title: "Arbitrary value in a utility class"
2762
+ title: "Literal arbitrary utility value to review"
2685
2763
  }),
2686
2764
  shadcn({
2687
2765
  categoryId: "look-constraints",
@@ -4475,6 +4553,7 @@ const extractTsx = (file, source, options) => {
4475
4553
  docType,
4476
4554
  dynamic: classInfo.dynamic || void 0,
4477
4555
  element: name,
4556
+ fontScale: config?.tailwind.theme,
4478
4557
  interpolated: classInfo.interpolated || void 0,
4479
4558
  role
4480
4559
  },
@@ -4673,6 +4752,7 @@ const agentText = `${marker}
4673
4752
  Taste Lint uses Jev to judge copy and UI. Preview the taste script with --dry-run, then run it with a user-supplied AI_GATEWAY_API_KEY.
4674
4753
  Fix act findings, review advisory findings in context, and recheck the edited files.
4675
4754
  Never invent a key or treat unknown checks as passes.
4755
+ Docs: https://taste-lint.blode.md
4676
4756
  <!-- /taste-lint -->
4677
4757
  `;
4678
4758
  function object$1(value) {
@@ -5486,7 +5566,7 @@ const renderSarif = (result, rules, version) => {
5486
5566
  ruleIndex: index.get(f.ruleId)
5487
5567
  })),
5488
5568
  tool: { driver: {
5489
- informationUri: "https://github.com/mblode/taste-lint",
5569
+ informationUri: "https://taste-lint.blode.md",
5490
5570
  name: "taste-lint",
5491
5571
  rules: ruleList.map((r) => ({
5492
5572
  fullDescription: { text: r.question?.instructions ?? r.fix.hint },
@@ -5760,6 +5840,14 @@ const artifacts = [
5760
5840
  "**/docs/archive/**",
5761
5841
  "**/.captain/**"
5762
5842
  ];
5843
+ const stylePreferences = [
5844
+ "copywriting-long-sentence",
5845
+ "typography-straight-quotes",
5846
+ "typography-ellipsis",
5847
+ "typography-dashes",
5848
+ "typography-midpoint-separators",
5849
+ "typography-line-height-out-of-band"
5850
+ ];
5763
5851
  const internal = [
5764
5852
  "**/.claude/**",
5765
5853
  "**/.agents/**",
@@ -5804,6 +5892,7 @@ const profileFor = (name) => {
5804
5892
  };
5805
5893
  case "product": return {
5806
5894
  ...common,
5895
+ advisory: stylePreferences,
5807
5896
  exclude: [
5808
5897
  ...artifacts,
5809
5898
  ...internal,
@@ -5814,7 +5903,7 @@ const profileFor = (name) => {
5814
5903
  };
5815
5904
  case "writing": return {
5816
5905
  ...common,
5817
- advisory: ["copywriting-long-sentence", "typography-straight-quotes"],
5906
+ advisory: stylePreferences,
5818
5907
  docTypes: [{
5819
5908
  glob: "**/content/writing/**/*.{md,mdx}",
5820
5909
  type: "personal"
@@ -5834,7 +5923,7 @@ const profileFor = (name) => {
5834
5923
  };
5835
5924
  case "instructions": return {
5836
5925
  ...common,
5837
- advisory: ["copywriting-long-sentence", "typography-straight-quotes"],
5926
+ advisory: stylePreferences,
5838
5927
  include: [...internal, "**/docs/plans/**/*.md"],
5839
5928
  objective: "Review repository instructions, skill authoring, and implementation plans"
5840
5929
  };
@@ -6231,6 +6320,7 @@ const runScan = async (options, ctx = {}) => {
6231
6320
  ...config,
6232
6321
  root: void 0
6233
6322
  },
6323
+ engineVersion: version,
6234
6324
  exclude,
6235
6325
  graph: graph?.digest,
6236
6326
  model: options.model ?? "jev-latest",
@@ -6652,6 +6742,7 @@ Quickstart:
6652
6742
 
6653
6743
  Preview: taste-lint scan . --dry-run
6654
6744
  Get a key: https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys
6745
+ Docs: https://taste-lint.blode.md
6655
6746
  `);
6656
6747
  registerLintCommand(program);
6657
6748
  registerInitCommand(program);