taste-lint 0.2.0 → 0.3.0
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 +5 -3
- package/data/config.schema.json +7 -0
- package/data/rules/copywriting/copywriting-anthropomorphism.yaml +4 -3
- package/data/rules/copywriting/copywriting-antithesis-frame.yaml +21 -0
- package/data/rules/copywriting/copywriting-click-here-links.yaml +4 -3
- package/data/rules/copywriting/copywriting-conditions-first.yaml +2 -2
- package/data/rules/copywriting/copywriting-copula-avoidance.yaml +21 -0
- package/data/rules/copywriting/copywriting-error-descriptions.yaml +2 -2
- package/data/rules/copywriting/copywriting-filler-words.yaml +4 -3
- package/data/rules/copywriting/copywriting-latinisms.yaml +6 -4
- package/data/rules/copywriting/copywriting-nominalisations.yaml +4 -3
- package/data/rules/copywriting/copywriting-participle-tail.yaml +21 -0
- package/data/rules/copywriting/copywriting-passive-voice.yaml +4 -3
- package/data/rules/copywriting/copywriting-promotional-docs-voice.yaml +4 -3
- package/data/rules/copywriting/copywriting-requirements-language.yaml +2 -2
- package/data/rules/copywriting/copywriting-searchable-headings.yaml +2 -2
- package/data/rules/copywriting/copywriting-tee-up.yaml +21 -0
- package/data/rules/copywriting/copywriting-temporal-words.yaml +4 -3
- package/data/rules/craft/craft-atmosphere-gradient.yaml +28 -0
- package/data/rules/craft/craft-default-indigo.yaml +28 -0
- package/data/rules/motion/motion-will-change-misuse.yaml +28 -0
- package/data/rules/product/product-nested-modals.yaml +1 -1
- package/data/rules/product/product-select-for-few-options.yaml +1 -1
- package/dist/cli.js +186 -37
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +185 -35
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Command, CommanderError, Option } from "commander";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
5
6
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
6
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
8
|
import { parse } from "yaml";
|
|
@@ -9,12 +10,11 @@ import { fromMarkdown } from "mdast-util-from-markdown";
|
|
|
9
10
|
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
10
11
|
import { gfm } from "micromark-extension-gfm";
|
|
11
12
|
import { parseSync } from "oxc-parser";
|
|
12
|
-
import os from "node:os";
|
|
13
13
|
import { mdxFromMarkdown } from "mdast-util-mdx";
|
|
14
14
|
import { mdxjs } from "micromark-extension-mdxjs";
|
|
15
15
|
import { styleText } from "node:util";
|
|
16
16
|
//#region packages/cli/package.json
|
|
17
|
-
var version = "0.
|
|
17
|
+
var version = "0.3.0";
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/lib/stats.ts
|
|
20
20
|
const makePRNG = (seed) => {
|
|
@@ -862,7 +862,10 @@ const DEFAULT_EXCLUDE = [
|
|
|
862
862
|
"**/.next/**",
|
|
863
863
|
"**/.git/**",
|
|
864
864
|
"**/results/**",
|
|
865
|
-
"**/coverage/**"
|
|
865
|
+
"**/coverage/**",
|
|
866
|
+
"**/LICENSE",
|
|
867
|
+
"**/LICENSE.*",
|
|
868
|
+
"**/CHANGELOG.md"
|
|
866
869
|
];
|
|
867
870
|
const globToRegExp = (glob) => {
|
|
868
871
|
let out = "^";
|
|
@@ -1071,10 +1074,12 @@ function validateConfig(raw) {
|
|
|
1071
1074
|
"smartQuotesAtBuild",
|
|
1072
1075
|
"docTypes",
|
|
1073
1076
|
"components",
|
|
1074
|
-
"tailwind"
|
|
1077
|
+
"tailwind",
|
|
1078
|
+
"rules"
|
|
1075
1079
|
], "");
|
|
1076
1080
|
if (value.$schema !== void 0 && typeof value.$schema !== "string") invalid("$schema", "a string");
|
|
1077
1081
|
if (value.exclude !== void 0) strings(value.exclude, "exclude");
|
|
1082
|
+
if (value.rules !== void 0) strings(value.rules, "rules");
|
|
1078
1083
|
if (value.smartQuotesAtBuild !== void 0 && typeof value.smartQuotesAtBuild !== "boolean") invalid("smartQuotesAtBuild", "a boolean");
|
|
1079
1084
|
if (value.docTypes !== void 0) {
|
|
1080
1085
|
if (!Array.isArray(value.docTypes)) invalid("docTypes", "an array");
|
|
@@ -1099,6 +1104,22 @@ function validateConfig(raw) {
|
|
|
1099
1104
|
}
|
|
1100
1105
|
}
|
|
1101
1106
|
}
|
|
1107
|
+
const ruleDir = (root, raw) => {
|
|
1108
|
+
const expanded = raw.replace(/^~(?=$|\/)/u, os.homedir()).replaceAll(/\$\{?(\w+)\}?/gu, (_, name) => {
|
|
1109
|
+
const value = process.env[name];
|
|
1110
|
+
if (!value) throw new InputError("INVALID_CONFIG", `taste-lint.config.json: rules entry ${raw} names ${name}, which is not set.`, {
|
|
1111
|
+
field: "rules",
|
|
1112
|
+
variable: name
|
|
1113
|
+
});
|
|
1114
|
+
return value;
|
|
1115
|
+
});
|
|
1116
|
+
const dir = path.resolve(root, expanded);
|
|
1117
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new InputError("INVALID_CONFIG", `taste-lint.config.json: rules entry ${raw} is not a directory (${dir}).`, {
|
|
1118
|
+
field: "rules",
|
|
1119
|
+
path: dir
|
|
1120
|
+
});
|
|
1121
|
+
return dir;
|
|
1122
|
+
};
|
|
1102
1123
|
const loadConfig = (root, defaults = []) => {
|
|
1103
1124
|
const absRoot = path.resolve(root);
|
|
1104
1125
|
try {
|
|
@@ -1133,6 +1154,7 @@ const loadConfig = (root, defaults = []) => {
|
|
|
1133
1154
|
],
|
|
1134
1155
|
exclude: user.exclude ?? [],
|
|
1135
1156
|
root: absRoot,
|
|
1157
|
+
rules: (user.rules ?? []).map((raw) => ruleDir(absRoot, raw)),
|
|
1136
1158
|
smartQuotesAtBuild: typeof user.smartQuotesAtBuild === "boolean" ? user.smartQuotesAtBuild : detectSmartQuotesAtBuild(absRoot),
|
|
1137
1159
|
tailwind: { theme: user.tailwind?.theme ?? {} }
|
|
1138
1160
|
};
|
|
@@ -1890,11 +1912,54 @@ const smartQuotes = (prose) => prose.replaceAll(OPENING_DOUBLE, "$1“").replace
|
|
|
1890
1912
|
const ellipsis = (prose) => prose.replaceAll("...", "…");
|
|
1891
1913
|
const multiplicationSign = (prose) => prose.replaceAll(/(?<![\w.])([1-9]\d*)\s?x\s?(\d+)(?![\w.%])/gu, "$1×$2");
|
|
1892
1914
|
const nbspValueUnit = (prose) => prose.replaceAll(/(\d) (?=(?:px|pt|em|rem|ms|min|KB|MB|GB|TB|kg|km|cm|mm|fps|%)(?![\p{L}\d]))/gu, "$1\xA0");
|
|
1915
|
+
const stripUtm = (prose) => prose.replaceAll(/\?utm_source=chatgpt\.com(?![\w&=-])/gu, "").replaceAll(/([?&])utm_source=chatgpt\.com&/gu, "$1").replaceAll("&utm_source=chatgpt.com", "");
|
|
1916
|
+
const AU_SPELLING = {
|
|
1917
|
+
analyze: "analyse",
|
|
1918
|
+
analyzed: "analysed",
|
|
1919
|
+
apologize: "apologise",
|
|
1920
|
+
behavior: "behaviour",
|
|
1921
|
+
behaviors: "behaviours",
|
|
1922
|
+
canceled: "cancelled",
|
|
1923
|
+
canceling: "cancelling",
|
|
1924
|
+
catalog: "catalogue",
|
|
1925
|
+
color: "colour",
|
|
1926
|
+
colored: "coloured",
|
|
1927
|
+
colors: "colours",
|
|
1928
|
+
customize: "customise",
|
|
1929
|
+
customized: "customised",
|
|
1930
|
+
favorite: "favourite",
|
|
1931
|
+
favorites: "favourites",
|
|
1932
|
+
gray: "grey",
|
|
1933
|
+
honor: "honour",
|
|
1934
|
+
labeled: "labelled",
|
|
1935
|
+
labeling: "labelling",
|
|
1936
|
+
modeling: "modelling",
|
|
1937
|
+
neighbor: "neighbour",
|
|
1938
|
+
optimize: "optimise",
|
|
1939
|
+
optimized: "optimised",
|
|
1940
|
+
organization: "organisation",
|
|
1941
|
+
organizations: "organisations",
|
|
1942
|
+
organize: "organise",
|
|
1943
|
+
organized: "organised",
|
|
1944
|
+
prioritize: "prioritise",
|
|
1945
|
+
realize: "realise",
|
|
1946
|
+
realized: "realised",
|
|
1947
|
+
recognize: "recognise",
|
|
1948
|
+
traveling: "travelling"
|
|
1949
|
+
};
|
|
1950
|
+
const AU_SPELLING_PATTERN = String.raw`(?<!\S*/\S*)\b(?:${Object.keys(AU_SPELLING).join("|")})\b(?!\S*/)`;
|
|
1951
|
+
const auSpelling = (prose) => prose.replaceAll(new RegExp(AU_SPELLING_PATTERN, "giu"), (word) => {
|
|
1952
|
+
const au = AU_SPELLING[word.toLowerCase()];
|
|
1953
|
+
if (!au || word !== word.toLowerCase() && word !== `${word[0]}${word.slice(1).toLowerCase()}`) return word;
|
|
1954
|
+
return word[0] === word[0].toUpperCase() ? `${au[0].toUpperCase()}${au.slice(1)}` : au;
|
|
1955
|
+
});
|
|
1893
1956
|
const FIXES = {
|
|
1957
|
+
auSpelling,
|
|
1894
1958
|
ellipsis,
|
|
1895
1959
|
multiplicationSign,
|
|
1896
1960
|
nbspValueUnit,
|
|
1897
|
-
smartQuotes
|
|
1961
|
+
smartQuotes,
|
|
1962
|
+
stripUtm
|
|
1898
1963
|
};
|
|
1899
1964
|
//#endregion
|
|
1900
1965
|
//#region src/rules/validate-sections.ts
|
|
@@ -2550,7 +2615,7 @@ const codeRule = (spec, module) => {
|
|
|
2550
2615
|
//#endregion
|
|
2551
2616
|
//#region src/rules/code/classes.ts
|
|
2552
2617
|
const MODULE$1 = "src/rules/code/classes.ts";
|
|
2553
|
-
const ANIMATION = "skills/ui-animation/references";
|
|
2618
|
+
const ANIMATION$1 = "skills/ui-animation/references";
|
|
2554
2619
|
const bases = (unit) => (unit.classes ?? []).map(baseClass);
|
|
2555
2620
|
const animates = (list) => list.some((c) => c.startsWith("transition") && c !== "transition-none" || c.startsWith("duration-") || c.startsWith("animate-") && c !== "animate-none");
|
|
2556
2621
|
const durationMs = (cls) => {
|
|
@@ -2615,7 +2680,7 @@ const CLASS_RULES = [
|
|
|
2615
2680
|
id: "motion-ease-in-on-transition",
|
|
2616
2681
|
source: {
|
|
2617
2682
|
line: 27,
|
|
2618
|
-
path: `${ANIMATION}/debugging-symptoms.md`
|
|
2683
|
+
path: `${ANIMATION$1}/debugging-symptoms.md`
|
|
2619
2684
|
},
|
|
2620
2685
|
title: "Transition eases in, so it starts slow while the user is watching"
|
|
2621
2686
|
}),
|
|
@@ -2630,7 +2695,7 @@ const CLASS_RULES = [
|
|
|
2630
2695
|
id: "motion-linear-easing-on-transition",
|
|
2631
2696
|
source: {
|
|
2632
2697
|
line: 37,
|
|
2633
|
-
path: `${ANIMATION}/debugging-symptoms.md`
|
|
2698
|
+
path: `${ANIMATION$1}/debugging-symptoms.md`
|
|
2634
2699
|
},
|
|
2635
2700
|
title: "Linear easing on motion that is not a spinner or marquee"
|
|
2636
2701
|
}),
|
|
@@ -2646,7 +2711,7 @@ const CLASS_RULES = [
|
|
|
2646
2711
|
id: "motion-duration-over-300ms",
|
|
2647
2712
|
source: {
|
|
2648
2713
|
line: 29,
|
|
2649
|
-
path: `${ANIMATION}/debugging-symptoms.md`
|
|
2714
|
+
path: `${ANIMATION$1}/debugging-symptoms.md`
|
|
2650
2715
|
},
|
|
2651
2716
|
title: "Transition longer than 300ms on product UI"
|
|
2652
2717
|
}),
|
|
@@ -2657,7 +2722,7 @@ const CLASS_RULES = [
|
|
|
2657
2722
|
id: "motion-transition-all",
|
|
2658
2723
|
source: {
|
|
2659
2724
|
line: 58,
|
|
2660
|
-
path: `${ANIMATION}/review-format.md`
|
|
2725
|
+
path: `${ANIMATION$1}/review-format.md`
|
|
2661
2726
|
},
|
|
2662
2727
|
title: "transition-all animates every property, layout included"
|
|
2663
2728
|
}),
|
|
@@ -2672,7 +2737,7 @@ const CLASS_RULES = [
|
|
|
2672
2737
|
id: "motion-scale-from-zero",
|
|
2673
2738
|
source: {
|
|
2674
2739
|
line: 46,
|
|
2675
|
-
path: `${ANIMATION}/debugging-symptoms.md`
|
|
2740
|
+
path: `${ANIMATION$1}/debugging-symptoms.md`
|
|
2676
2741
|
},
|
|
2677
2742
|
title: "Entrance from scale zero"
|
|
2678
2743
|
}),
|
|
@@ -2688,7 +2753,7 @@ const CLASS_RULES = [
|
|
|
2688
2753
|
id: "motion-animate-without-reduced-motion",
|
|
2689
2754
|
source: {
|
|
2690
2755
|
line: 57,
|
|
2691
|
-
path: `${ANIMATION}/live-tuning.md`
|
|
2756
|
+
path: `${ANIMATION$1}/live-tuning.md`
|
|
2692
2757
|
},
|
|
2693
2758
|
status: "review-only",
|
|
2694
2759
|
title: "Animation has no local reduced-motion guard"
|
|
@@ -2871,7 +2936,7 @@ const document = (unit) => {
|
|
|
2871
2936
|
return unit.facts.document;
|
|
2872
2937
|
};
|
|
2873
2938
|
const nodes = (unit) => descendants(document(unit));
|
|
2874
|
-
const make$1 = (name, title, source, include, check, hint, authoring = false) => codeRule({
|
|
2939
|
+
const make$1 = (name, title, source, include, check, hint, authoring = false, status = "review-only") => codeRule({
|
|
2875
2940
|
categoryId: authoring ? "instruction-quality" : "reference-reading",
|
|
2876
2941
|
check,
|
|
2877
2942
|
hint,
|
|
@@ -2882,7 +2947,7 @@ const make$1 = (name, title, source, include, check, hint, authoring = false) =>
|
|
|
2882
2947
|
path: `skills/${source}`,
|
|
2883
2948
|
repo: "mblode/agent-skills"
|
|
2884
2949
|
},
|
|
2885
|
-
status
|
|
2950
|
+
status,
|
|
2886
2951
|
title,
|
|
2887
2952
|
unit: ["source"]
|
|
2888
2953
|
}, "src/rules/code/documents.ts");
|
|
@@ -2893,7 +2958,7 @@ const INSTRUCTIONS = [
|
|
|
2893
2958
|
"**/SKILL.md"
|
|
2894
2959
|
];
|
|
2895
2960
|
const DOCS = ["**/*.md"];
|
|
2896
|
-
const README_SOURCE = "
|
|
2961
|
+
const README_SOURCE = "ghostwriter/references/readme.md";
|
|
2897
2962
|
const DOCUMENT_RULES = [
|
|
2898
2963
|
make$1("readme-scaffold", "README still contains scaffold instructions", README_SOURCE, README, (u) => {
|
|
2899
2964
|
const n = nodes(u).find((candidate) => candidate.type === "paragraph" && /This is a \[?Next\.js.*bootstrapped with|This template provides a minimal setup to get React working in Vite/i.test(nodeText(candidate)));
|
|
@@ -2916,7 +2981,7 @@ const DOCUMENT_RULES = [
|
|
|
2916
2981
|
}
|
|
2917
2982
|
return none;
|
|
2918
2983
|
}, "Provide a complete first-run example; move intentionally partial examples to a tutorial."),
|
|
2919
|
-
make$1("document-empty-heading", "A section has no content", "
|
|
2984
|
+
make$1("document-empty-heading", "A section has no content", "ghostwriter/references/docs.md", DOCS, (u) => {
|
|
2920
2985
|
const children = document(u).children ?? [];
|
|
2921
2986
|
for (const [i, n] of children.entries()) {
|
|
2922
2987
|
if (n.type !== "heading") continue;
|
|
@@ -2925,7 +2990,7 @@ const DOCUMENT_RULES = [
|
|
|
2925
2990
|
}
|
|
2926
2991
|
return none;
|
|
2927
2992
|
}, "Add the section's content or remove its heading."),
|
|
2928
|
-
make$1("document-heading-order", "Heading skips a structural level", "
|
|
2993
|
+
make$1("document-heading-order", "Heading skips a structural level", "ghostwriter/references/docs.md", DOCS, (u) => {
|
|
2929
2994
|
let previous = 0;
|
|
2930
2995
|
for (const n of nodes(u).filter((candidate) => candidate.type === "heading")) {
|
|
2931
2996
|
const depth = n.depth ?? 1;
|
|
@@ -2934,7 +2999,11 @@ const DOCUMENT_RULES = [
|
|
|
2934
2999
|
}
|
|
2935
3000
|
return none;
|
|
2936
3001
|
}, "Nest heading levels in order so the document outline reflects its structure."),
|
|
2937
|
-
make$1("document-
|
|
3002
|
+
make$1("document-code-fence-language", "Code fence has no language tag", "ghostwriter/references/docs.md", DOCS, (u) => {
|
|
3003
|
+
const n = nodes(u).find((candidate) => candidate.type === "code" && !candidate.lang);
|
|
3004
|
+
return n ? at(n, "Fence without a language tag") : none;
|
|
3005
|
+
}, "Tag the fence: ```bash, ```ts, or ```text for output. The tag drives highlighting and tells the reader what to paste where."),
|
|
3006
|
+
make$1("document-broken-local-link", "Document links to a missing local file", "ghostwriter/references/docs.md", DOCS, (u) => {
|
|
2938
3007
|
const repo = u.facts.repository;
|
|
2939
3008
|
for (const n of nodes(u).filter((candidate) => [
|
|
2940
3009
|
"link",
|
|
@@ -2955,7 +3024,7 @@ const DOCUMENT_RULES = [
|
|
|
2955
3024
|
if (!repo.exists(resolved)) return at(n, `Missing local target: ${target}`);
|
|
2956
3025
|
}
|
|
2957
3026
|
return none;
|
|
2958
|
-
}, "Correct the link or provide the referenced file. Generated site routes need a route-aware check."),
|
|
3027
|
+
}, "Correct the link or provide the referenced file. Generated site routes need a route-aware check.", false, "active"),
|
|
2959
3028
|
make$1("instruction-missing-script", "Instruction invokes an undefined package script", "agents-md/references/quick-checklist.md", INSTRUCTIONS, (u) => {
|
|
2960
3029
|
const manifest = u.facts.repository.manifest(u.file);
|
|
2961
3030
|
if (!manifest) throw new UnresolvedError("No readable package manifest for this instruction file");
|
|
@@ -3024,6 +3093,7 @@ const IMAGE_RULES = [{
|
|
|
3024
3093
|
id: "interaction-a11y-image-alt-text",
|
|
3025
3094
|
required: ["alt"],
|
|
3026
3095
|
source: "a11y-image-alt-text",
|
|
3096
|
+
status: "active",
|
|
3027
3097
|
title: "Image is missing an alt attribute"
|
|
3028
3098
|
}, {
|
|
3029
3099
|
categoryId: "resilience",
|
|
@@ -3031,6 +3101,7 @@ const IMAGE_RULES = [{
|
|
|
3031
3101
|
id: "craft-image-dimensions-and-priority",
|
|
3032
3102
|
required: ["width", "height"],
|
|
3033
3103
|
source: "perf-image-dimensions-and-priority",
|
|
3104
|
+
status: "review-only",
|
|
3034
3105
|
title: "Image has no declared dimensions or style"
|
|
3035
3106
|
}].map((spec) => codeRule({
|
|
3036
3107
|
categoryId: spec.categoryId,
|
|
@@ -3056,11 +3127,62 @@ const IMAGE_RULES = [{
|
|
|
3056
3127
|
repo: "mblode/agent-skills",
|
|
3057
3128
|
ruleId: spec.source
|
|
3058
3129
|
},
|
|
3059
|
-
status:
|
|
3130
|
+
status: spec.status,
|
|
3060
3131
|
title: spec.title,
|
|
3061
3132
|
unit: ["source"]
|
|
3062
3133
|
}, "src/rules/code/images.ts"));
|
|
3063
3134
|
//#endregion
|
|
3135
|
+
//#region src/rules/code/motion-source.ts
|
|
3136
|
+
const ANIMATION = "skills/ui-animation/SKILL.md";
|
|
3137
|
+
const stripComments = (text) => text.replaceAll(/\/\*[\s\S]*?\*\/|(?<![:"'\w])\/\/[^\n]*/gu, (m) => m.replaceAll(/[^\n]/gu, " "));
|
|
3138
|
+
const first = (unit, re, evidence) => {
|
|
3139
|
+
const m = re.exec(stripComments(unit.text));
|
|
3140
|
+
return m ? {
|
|
3141
|
+
evidence,
|
|
3142
|
+
fired: true,
|
|
3143
|
+
offset: m.index
|
|
3144
|
+
} : none;
|
|
3145
|
+
};
|
|
3146
|
+
const MOTION_SOURCE_RULES = [codeRule({
|
|
3147
|
+
categoryId: "motion-restraint",
|
|
3148
|
+
check: (unit) => first(unit, /(?:^|[{;\s])transition(?:-property)?\s*:\s*["'`]?all\b/mu, "transition: all"),
|
|
3149
|
+
hint: "List the properties: transition: transform 200ms, opacity 200ms. `all` animates layout and silently adopts properties added later.",
|
|
3150
|
+
id: "motion-css-transition-all",
|
|
3151
|
+
scope: { include: [
|
|
3152
|
+
"**/*.css",
|
|
3153
|
+
"**/*.scss",
|
|
3154
|
+
"**/*.tsx",
|
|
3155
|
+
"**/*.jsx"
|
|
3156
|
+
] },
|
|
3157
|
+
source: {
|
|
3158
|
+
line: 67,
|
|
3159
|
+
path: ANIMATION,
|
|
3160
|
+
repo: "mblode/agent-skills"
|
|
3161
|
+
},
|
|
3162
|
+
title: "transition: all animates every property, layout included",
|
|
3163
|
+
unit: ["source"]
|
|
3164
|
+
}, "src/rules/code/motion-source.ts"), codeRule({
|
|
3165
|
+
categoryId: "motion-restraint",
|
|
3166
|
+
check: (unit) => first(unit, /\bfrom\s*["']framer-motion(?:\/[^"']*)?["']|\b(?:import|require)\s*\(\s*["']framer-motion["']/u, "imports framer-motion"),
|
|
3167
|
+
hint: "Install `motion` and import from \"motion/react\". A mixed codebase compiles but ships two copies of the library.",
|
|
3168
|
+
id: "motion-framer-motion-import",
|
|
3169
|
+
scope: { include: [
|
|
3170
|
+
"**/*.tsx",
|
|
3171
|
+
"**/*.jsx",
|
|
3172
|
+
"**/*.ts",
|
|
3173
|
+
"**/*.js",
|
|
3174
|
+
"**/*.mjs",
|
|
3175
|
+
"**/*.mts"
|
|
3176
|
+
] },
|
|
3177
|
+
source: {
|
|
3178
|
+
line: 153,
|
|
3179
|
+
path: ANIMATION,
|
|
3180
|
+
repo: "mblode/agent-skills"
|
|
3181
|
+
},
|
|
3182
|
+
title: "Imports framer-motion instead of motion/react",
|
|
3183
|
+
unit: ["source"]
|
|
3184
|
+
}, "src/rules/code/motion-source.ts")];
|
|
3185
|
+
//#endregion
|
|
3064
3186
|
//#region src/rules/code/skill-classes.ts
|
|
3065
3187
|
const make = (id, title, categoryId, source, check, hint) => codeRule({
|
|
3066
3188
|
categoryId,
|
|
@@ -3320,7 +3442,7 @@ const CODE_RULES = [
|
|
|
3320
3442
|
rule({
|
|
3321
3443
|
categoryId: "machine-prose",
|
|
3322
3444
|
check: (unit) => {
|
|
3323
|
-
const long = unit.text.split(/(?<=[.!?])\s+/u).map((s) => [s, countWords(s)]).filter(([, n]) => n > 25);
|
|
3445
|
+
const long = unit.text.split(/(?<=[.!?])\s+/u).map((s) => [s, countWords(s)]).filter(([s, n]) => n > 25 && (s.match(/\b(?:and|but)\b/giu)?.length ?? 0) >= 2);
|
|
3324
3446
|
if (long.length === 0) return none;
|
|
3325
3447
|
const [sentence, n] = long[0];
|
|
3326
3448
|
return hit(`${n} words: "${sentence.slice(0, 80)}${sentence.length > 80 ? "..." : ""}"`);
|
|
@@ -3342,18 +3464,19 @@ const CODE_RULES = [
|
|
|
3342
3464
|
notInCode: true
|
|
3343
3465
|
},
|
|
3344
3466
|
source: {
|
|
3345
|
-
line:
|
|
3346
|
-
path: "skills/
|
|
3467
|
+
line: 17,
|
|
3468
|
+
path: "skills/ghostwriter/references/docs.md",
|
|
3347
3469
|
repo: "mblode/agent-skills"
|
|
3348
3470
|
},
|
|
3349
|
-
title: "Sentence over 25 words",
|
|
3471
|
+
title: "Sentence over 25 words joins several clauses",
|
|
3350
3472
|
unit: ["paragraph", "jsx-text"]
|
|
3351
3473
|
})
|
|
3352
3474
|
],
|
|
3353
3475
|
...CLASS_RULES,
|
|
3354
3476
|
...DOCUMENT_RULES,
|
|
3355
3477
|
...IMAGE_RULES,
|
|
3356
|
-
...SKILL_CLASS_RULES
|
|
3478
|
+
...SKILL_CLASS_RULES,
|
|
3479
|
+
...MOTION_SOURCE_RULES
|
|
3357
3480
|
];
|
|
3358
3481
|
//#endregion
|
|
3359
3482
|
//#region src/rules/load.ts
|
|
@@ -3416,10 +3539,8 @@ const readTuning = (rulesDir) => {
|
|
|
3416
3539
|
}
|
|
3417
3540
|
return out;
|
|
3418
3541
|
};
|
|
3419
|
-
const
|
|
3420
|
-
const tuning = readTuning(rulesDir);
|
|
3542
|
+
const readYamlRules = (rulesDir) => {
|
|
3421
3543
|
const rules = [];
|
|
3422
|
-
const seen = /* @__PURE__ */ new Set();
|
|
3423
3544
|
const domains = fs.readdirSync(rulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name !== "schema").map((d) => d.name).toSorted();
|
|
3424
3545
|
for (const domain of domains) {
|
|
3425
3546
|
const dir = path.join(rulesDir, domain);
|
|
@@ -3427,24 +3548,49 @@ const loadRules = (rulesDir, options = {}) => {
|
|
|
3427
3548
|
if (!name.endsWith(".yaml")) continue;
|
|
3428
3549
|
const file = path.join(dir, name);
|
|
3429
3550
|
const expectedId = name.slice(0, -5);
|
|
3430
|
-
let raw;
|
|
3431
3551
|
try {
|
|
3432
|
-
raw
|
|
3552
|
+
let raw;
|
|
3553
|
+
try {
|
|
3554
|
+
raw = parse(fs.readFileSync(file, "utf-8"));
|
|
3555
|
+
} catch (error) {
|
|
3556
|
+
throw new Error(`Invalid rule ${file}: YAML did not parse (${error.message})`, { cause: error });
|
|
3557
|
+
}
|
|
3558
|
+
const rule = validateRule(raw, file, expectedId);
|
|
3559
|
+
if (rule.domain !== domain) throw new Error(`Invalid rule ${file}: domain ${rule.domain} does not match folder ${domain}`);
|
|
3560
|
+
rules.push(rule);
|
|
3433
3561
|
} catch (error) {
|
|
3434
|
-
|
|
3562
|
+
const invalid = new InputError("INVALID_RULE", error.message, { file });
|
|
3563
|
+
invalid.cause = error;
|
|
3564
|
+
throw invalid;
|
|
3435
3565
|
}
|
|
3436
|
-
const rule = validateRule(raw, file, expectedId);
|
|
3437
|
-
if (rule.domain !== domain) throw new Error(`Invalid rule ${file}: domain ${rule.domain} does not match folder ${domain}`);
|
|
3438
|
-
rules.push(rule);
|
|
3439
3566
|
}
|
|
3440
3567
|
}
|
|
3568
|
+
return rules;
|
|
3569
|
+
};
|
|
3570
|
+
const loadRules = (rulesDir, options = {}) => {
|
|
3571
|
+
const tuning = readTuning(rulesDir);
|
|
3572
|
+
const rules = [];
|
|
3573
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3574
|
+
rules.push(...readYamlRules(rulesDir));
|
|
3575
|
+
for (const dir of options.extraDirs ?? []) {
|
|
3576
|
+
const extra = readYamlRules(dir);
|
|
3577
|
+
if (extra.length === 0) throw new InputError("INVALID_CONFIG", `rules entry ${dir} holds no rules; put YAML rule files in a domain folder such as ${path.join(dir, "copywriting")}/`, {
|
|
3578
|
+
field: "rules",
|
|
3579
|
+
path: dir
|
|
3580
|
+
});
|
|
3581
|
+
rules.push(...extra);
|
|
3582
|
+
}
|
|
3441
3583
|
for (const code of CODE_RULES) rules.push({
|
|
3442
3584
|
...code,
|
|
3443
3585
|
thresholds: { ...code.thresholds }
|
|
3444
3586
|
});
|
|
3445
3587
|
for (const rule of rules) {
|
|
3446
|
-
|
|
3447
|
-
|
|
3588
|
+
const first = seen.get(rule.id);
|
|
3589
|
+
if (first !== void 0) throw new InputError("DUPLICATE_RULE", `Rule id ${rule.id} is defined in ${first} and ${rule.file}; rename one.`, {
|
|
3590
|
+
files: [first, rule.file],
|
|
3591
|
+
id: rule.id
|
|
3592
|
+
});
|
|
3593
|
+
seen.set(rule.id, rule.file);
|
|
3448
3594
|
const overlay = tuning[rule.id];
|
|
3449
3595
|
if (isCandidateRule(rule) && (rule.status === "active" || overlay?.status === "active")) throw new Error(`Candidate-only rule ${rule.id} cannot be active; add an evidence-based check before promotion.`);
|
|
3450
3596
|
if (overlay) {
|
|
@@ -5375,7 +5521,10 @@ const runLint = async (options = {}, ctx = {}) => {
|
|
|
5375
5521
|
const config = loadConfig(options.root ?? process.cwd());
|
|
5376
5522
|
const rulesDir = resolveRulesDir(options.rulesDir);
|
|
5377
5523
|
const profile = options.profile ? profileFor(options.profile) : void 0;
|
|
5378
|
-
const loaded = loadRules(rulesDir, {
|
|
5524
|
+
const loaded = loadRules(rulesDir, {
|
|
5525
|
+
extraDirs: config.rules,
|
|
5526
|
+
only: options.only
|
|
5527
|
+
});
|
|
5379
5528
|
const rules = profile && !options.only?.length ? profileRules(profile, loaded) : loaded;
|
|
5380
5529
|
if (rules.length === 0) throw new InputError("EMPTY_RULE_SELECTION", "No rules match the selected profile and ids.");
|
|
5381
5530
|
const model = options.model ?? "jev-latest";
|