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 +8 -12
- package/dist/cli.js +111 -20
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +96 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/data/rules/craft/craft-near-duplicate-scale.yaml +0 -29
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
|
|
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).
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
##
|
|
45
|
+
## Docs
|
|
46
46
|
|
|
47
|
-
|
|
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
|
-
|
|
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.
|
|
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("[
|
|
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
|
-
|
|
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) =>
|
|
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: "
|
|
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: "
|
|
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://
|
|
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:
|
|
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:
|
|
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);
|