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.
Files changed (30) hide show
  1. package/README.md +5 -3
  2. package/data/config.schema.json +7 -0
  3. package/data/rules/copywriting/copywriting-anthropomorphism.yaml +4 -3
  4. package/data/rules/copywriting/copywriting-antithesis-frame.yaml +21 -0
  5. package/data/rules/copywriting/copywriting-click-here-links.yaml +4 -3
  6. package/data/rules/copywriting/copywriting-conditions-first.yaml +2 -2
  7. package/data/rules/copywriting/copywriting-copula-avoidance.yaml +21 -0
  8. package/data/rules/copywriting/copywriting-error-descriptions.yaml +2 -2
  9. package/data/rules/copywriting/copywriting-filler-words.yaml +4 -3
  10. package/data/rules/copywriting/copywriting-latinisms.yaml +6 -4
  11. package/data/rules/copywriting/copywriting-nominalisations.yaml +4 -3
  12. package/data/rules/copywriting/copywriting-participle-tail.yaml +21 -0
  13. package/data/rules/copywriting/copywriting-passive-voice.yaml +4 -3
  14. package/data/rules/copywriting/copywriting-promotional-docs-voice.yaml +4 -3
  15. package/data/rules/copywriting/copywriting-requirements-language.yaml +2 -2
  16. package/data/rules/copywriting/copywriting-searchable-headings.yaml +2 -2
  17. package/data/rules/copywriting/copywriting-tee-up.yaml +21 -0
  18. package/data/rules/copywriting/copywriting-temporal-words.yaml +4 -3
  19. package/data/rules/craft/craft-atmosphere-gradient.yaml +28 -0
  20. package/data/rules/craft/craft-default-indigo.yaml +28 -0
  21. package/data/rules/motion/motion-will-change-misuse.yaml +28 -0
  22. package/data/rules/product/product-nested-modals.yaml +1 -1
  23. package/data/rules/product/product-select-for-few-options.yaml +1 -1
  24. package/dist/cli.js +186 -37
  25. package/dist/cli.js.map +1 -1
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +185 -35
  29. package/dist/index.js.map +1 -1
  30. 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.2.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: "review-only",
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 = "readme-creator/references/quality-checklist.md";
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", "docs-writing/rules/structure-no-filler-sections.md", DOCS, (u) => {
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", "docs-writing/rules/format-semantic-html.md", DOCS, (u) => {
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-broken-local-link", "Document links to a missing local file", "docs-writing/rules/review-verify-links.md", DOCS, (u) => {
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: "review-only",
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: 7,
3346
- path: "skills/docs-writing/rules/clarity-one-idea-per-sentence.md",
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 loadRules = (rulesDir, options = {}) => {
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 = parse(fs.readFileSync(file, "utf-8"));
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
- throw new Error(`Invalid rule ${file}: YAML did not parse (${error.message})`, { cause: error });
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
- if (seen.has(rule.id)) throw new Error(`Duplicate rule id ${rule.id}`);
3447
- seen.add(rule.id);
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, { only: options.only });
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";