taste-lint 0.2.0 → 0.4.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 (66) hide show
  1. package/README.md +5 -3
  2. package/data/config.schema.json +7 -0
  3. package/data/corpus/copywriting.jsonl +4 -4
  4. package/data/rule-drafts/interaction/interaction-layout-shift.yaml +3 -3
  5. package/data/rules/copywriting/copywriting-anthropomorphism.yaml +4 -3
  6. package/data/rules/copywriting/copywriting-antithesis-contrast.yaml +43 -0
  7. package/data/rules/copywriting/copywriting-antithesis-frame.yaml +23 -0
  8. package/data/rules/copywriting/copywriting-artificial-balance.yaml +4 -1
  9. package/data/rules/copywriting/copywriting-boilerplate-offer.yaml +1 -1
  10. package/data/rules/copywriting/copywriting-canned-phrasing.yaml +1 -1
  11. package/data/rules/copywriting/copywriting-claim-without-evidence.yaml +10 -1
  12. package/data/rules/copywriting/copywriting-click-here-links.yaml +4 -3
  13. package/data/rules/copywriting/copywriting-conditions-first.yaml +2 -2
  14. package/data/rules/copywriting/copywriting-copula-avoidance.yaml +21 -0
  15. package/data/rules/copywriting/copywriting-dead-link.yaml +28 -0
  16. package/data/rules/copywriting/copywriting-em-dash-in-ui.yaml +23 -0
  17. package/data/rules/copywriting/copywriting-error-descriptions.yaml +2 -2
  18. package/data/rules/copywriting/copywriting-excessive-hedging.yaml +1 -1
  19. package/data/rules/copywriting/copywriting-external-arrow-internal-link.yaml +26 -0
  20. package/data/rules/copywriting/copywriting-fabricated-specificity.yaml +6 -3
  21. package/data/rules/copywriting/copywriting-filler-words.yaml +4 -3
  22. package/data/rules/copywriting/copywriting-generic-framing.yaml +12 -6
  23. package/data/rules/copywriting/copywriting-generic-language.yaml +12 -4
  24. package/data/rules/copywriting/copywriting-generic-validation.yaml +1 -1
  25. package/data/rules/copywriting/copywriting-latinisms.yaml +6 -4
  26. package/data/rules/copywriting/copywriting-machine-prose.yaml +4 -1
  27. package/data/rules/copywriting/copywriting-mechanism-over-outcome.yaml +4 -1
  28. package/data/rules/copywriting/copywriting-meta-commentary.yaml +1 -1
  29. package/data/rules/copywriting/copywriting-missing-reasoning-link.yaml +4 -1
  30. package/data/rules/copywriting/copywriting-nominalisations.yaml +4 -3
  31. package/data/rules/copywriting/copywriting-over-explanation.yaml +4 -1
  32. package/data/rules/copywriting/copywriting-participle-tail.yaml +21 -0
  33. package/data/rules/copywriting/copywriting-passive-voice.yaml +4 -3
  34. package/data/rules/copywriting/copywriting-promotional-docs-voice.yaml +4 -3
  35. package/data/rules/copywriting/copywriting-prompt-restatement.yaml +5 -2
  36. package/data/rules/copywriting/copywriting-punctuation-tics.yaml +4 -1
  37. package/data/rules/copywriting/copywriting-reader-first.yaml +4 -1
  38. package/data/rules/copywriting/copywriting-repetitive-transitions.yaml +1 -1
  39. package/data/rules/copywriting/copywriting-requirements-language.yaml +2 -2
  40. package/data/rules/copywriting/copywriting-searchable-headings.yaml +2 -2
  41. package/data/rules/copywriting/copywriting-semantic-links.yaml +2 -2
  42. package/data/rules/copywriting/copywriting-slogan-headings.yaml +27 -0
  43. package/data/rules/copywriting/copywriting-structural-symmetry.yaml +4 -1
  44. package/data/rules/copywriting/copywriting-tee-up.yaml +23 -0
  45. package/data/rules/copywriting/copywriting-temporal-words.yaml +4 -3
  46. package/data/rules/copywriting/copywriting-unexplained-action.yaml +5 -2
  47. package/data/rules/copywriting/copywriting-unnatural-polish.yaml +8 -5
  48. package/data/rules/copywriting/copywriting-unverifiable-proof.yaml +1 -1
  49. package/data/rules/copywriting/copywriting-vague-error.yaml +2 -0
  50. package/data/rules/craft/craft-atmosphere-gradient.yaml +28 -0
  51. package/data/rules/craft/craft-decoration-no-role.yaml +2 -1
  52. package/data/rules/craft/craft-decorative-ordinals.yaml +26 -0
  53. package/data/rules/craft/craft-default-indigo.yaml +28 -0
  54. package/data/rules/craft/craft-faux-product-chrome.yaml +4 -1
  55. package/data/rules/motion/motion-will-change-misuse.yaml +28 -0
  56. package/data/rules/product/product-nested-modals.yaml +1 -1
  57. package/data/rules/product/product-select-for-few-options.yaml +1 -1
  58. package/data/rules/schema/rule.schema.json +7 -1
  59. package/dist/cli.js +748 -131
  60. package/dist/cli.js.map +1 -1
  61. package/dist/index.d.ts +38 -18
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +459 -51
  64. package/dist/index.js.map +1 -1
  65. package/package.json +2 -2
  66. package/data/rules/copywriting/copywriting-idea-repetition.yaml +0 -34
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { fromMarkdown } from "mdast-util-from-markdown";
4
4
  import { gfmFromMarkdown } from "mdast-util-gfm";
5
5
  import { gfm } from "micromark-extension-gfm";
6
6
  import { parseSync } from "oxc-parser";
7
+ import os from "node:os";
7
8
  import { execFileSync, spawnSync } from "node:child_process";
8
9
  import { mdxFromMarkdown } from "mdast-util-mdx";
9
10
  import { mdxjs } from "micromark-extension-mdxjs";
@@ -182,7 +183,9 @@ const CONTEXT_KEYS = [
182
183
  "docType",
183
184
  "element",
184
185
  "role",
185
- "neighbours"
186
+ "neighbours",
187
+ "earlier",
188
+ "brief"
186
189
  ];
187
190
  //#endregion
188
191
  //#region src/lib/errors.ts
@@ -204,7 +207,10 @@ const DEFAULT_EXCLUDE = [
204
207
  "**/.next/**",
205
208
  "**/.git/**",
206
209
  "**/results/**",
207
- "**/coverage/**"
210
+ "**/coverage/**",
211
+ "**/LICENSE",
212
+ "**/LICENSE.*",
213
+ "**/CHANGELOG.md"
208
214
  ];
209
215
  const globToRegExp = (glob) => {
210
216
  let out = "^";
@@ -395,17 +401,17 @@ const invalid = (key, expected) => {
395
401
  field: key
396
402
  });
397
403
  };
404
+ const object = (value, key) => {
405
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(key, "an object");
406
+ return value;
407
+ };
408
+ const keys = (value, allowed, prefix) => {
409
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid(`${prefix}${key}`, `one of ${allowed.join(", ")}`);
410
+ };
411
+ const strings = (value, key) => {
412
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) invalid(key, "an array of strings");
413
+ };
398
414
  function validateConfig(raw) {
399
- const object = (value, key) => {
400
- if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(key, "an object");
401
- return value;
402
- };
403
- const keys = (value, allowed, prefix) => {
404
- for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid(`${prefix}${key}`, `one of ${allowed.join(", ")}`);
405
- };
406
- const strings = (value, key) => {
407
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) invalid(key, "an array of strings");
408
- };
409
415
  const value = object(raw, "config");
410
416
  keys(value, [
411
417
  "$schema",
@@ -413,10 +419,12 @@ function validateConfig(raw) {
413
419
  "smartQuotesAtBuild",
414
420
  "docTypes",
415
421
  "components",
416
- "tailwind"
422
+ "tailwind",
423
+ "rules"
417
424
  ], "");
418
425
  if (value.$schema !== void 0 && typeof value.$schema !== "string") invalid("$schema", "a string");
419
426
  if (value.exclude !== void 0) strings(value.exclude, "exclude");
427
+ if (value.rules !== void 0) strings(value.rules, "rules");
420
428
  if (value.smartQuotesAtBuild !== void 0 && typeof value.smartQuotesAtBuild !== "boolean") invalid("smartQuotesAtBuild", "a boolean");
421
429
  if (value.docTypes !== void 0) {
422
430
  if (!Array.isArray(value.docTypes)) invalid("docTypes", "an array");
@@ -441,6 +449,22 @@ function validateConfig(raw) {
441
449
  }
442
450
  }
443
451
  }
452
+ const ruleDir = (root, raw) => {
453
+ const expanded = raw.replace(/^~(?=$|\/)/u, os.homedir()).replaceAll(/\$\{?(\w+)\}?/gu, (_, name) => {
454
+ const value = process.env[name];
455
+ if (!value) throw new InputError("INVALID_CONFIG", `taste-lint.config.json: rules entry ${raw} names ${name}, which is not set.`, {
456
+ field: "rules",
457
+ variable: name
458
+ });
459
+ return value;
460
+ });
461
+ const dir = path.resolve(root, expanded);
462
+ 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}).`, {
463
+ field: "rules",
464
+ path: dir
465
+ });
466
+ return dir;
467
+ };
444
468
  const loadConfig = (root, defaults = []) => {
445
469
  const absRoot = path.resolve(root);
446
470
  try {
@@ -475,6 +499,7 @@ const loadConfig = (root, defaults = []) => {
475
499
  ],
476
500
  exclude: user.exclude ?? [],
477
501
  root: absRoot,
502
+ rules: (user.rules ?? []).map((raw) => ruleDir(absRoot, raw)),
478
503
  smartQuotesAtBuild: typeof user.smartQuotesAtBuild === "boolean" ? user.smartQuotesAtBuild : detectSmartQuotesAtBuild(absRoot),
479
504
  tailwind: { theme: user.tailwind?.theme ?? {} }
480
505
  };
@@ -486,6 +511,55 @@ const docTypeFor = (config, relativeFile) => {
486
511
  return "unknown";
487
512
  };
488
513
  //#endregion
514
+ //#region src/extract/earlier.ts
515
+ const NOT_PROSE = /* @__PURE__ */ new Set([
516
+ "heading",
517
+ "button",
518
+ "link",
519
+ "label",
520
+ "placeholder",
521
+ "aria"
522
+ ]);
523
+ const STOP = new Set("about above after again also always another because been before being below between both came come could does doing done down each even every from have having here into just like made make many more most much must never only other over same should since some such than that their them then there these they this those through very want were what when where which while will with would your yours you're we're it's that's there's isn't don't can't won't".split(" "));
524
+ const SENTENCE = /[^.!?\n]+[.!?]*/gu;
525
+ const WORD$1 = /[\p{L}\p{N}$][\p{L}\p{N}$'’.-]*[\p{L}\p{N}]|[\p{L}\p{N}]/gu;
526
+ const MIN_SHARED = 3;
527
+ const MAX_EVIDENCE = 8;
528
+ const tokens = (sentence) => {
529
+ const out = /* @__PURE__ */ new Set();
530
+ for (const [raw] of sentence.toLowerCase().matchAll(WORD$1)) {
531
+ const word = raw.replaceAll("’", "'");
532
+ if (/\d/u.test(word)) out.add(word);
533
+ else if (word.length >= 4 && !STOP.has(word)) out.add(word.length > 4 ? word.replace(/(?:'s|s)$/u, "") : word);
534
+ }
535
+ return out;
536
+ };
537
+ const isProse = (unit) => (unit.kind === "paragraph" || unit.kind === "jsx-text") && !unit.inCode && !NOT_PROSE.has(unit.context.role);
538
+ const attachEarlier = (units) => {
539
+ const prose = units.filter(isProse).toSorted((a, b) => a.sourceStart - b.sourceStart);
540
+ const split = prose.map((unit) => [...unit.text.matchAll(SENTENCE)].map(([s]) => s.trim()).filter(Boolean).map((text) => ({
541
+ text,
542
+ words: tokens(text)
543
+ })));
544
+ const spread = /* @__PURE__ */ new Map();
545
+ for (const sentences of split) for (const word of new Set(sentences.flatMap((s) => [...s.words]))) spread.set(word, (spread.get(word) ?? 0) + 1);
546
+ const subject = (word) => prose.length >= 6 && (spread.get(word) ?? 0) * 3 > prose.length;
547
+ const seen = [];
548
+ const index = /* @__PURE__ */ new Map();
549
+ for (const [u, unit] of prose.entries()) {
550
+ const matched = /* @__PURE__ */ new Map();
551
+ for (const sentence of split[u] ?? []) {
552
+ const words = [...sentence.words].filter((w) => !subject(w));
553
+ const shared = /* @__PURE__ */ new Map();
554
+ for (const word of words) for (const id of index.get(word) ?? []) shared.set(id, (shared.get(id) ?? 0) + 1);
555
+ for (const [id, n] of shared) if (n >= MIN_SHARED || n >= 2 && n * 2 >= words.length) matched.set(id, Math.max(matched.get(id) ?? 0, n));
556
+ const id = seen.push(sentence.text) - 1;
557
+ for (const word of words) index.set(word, [...index.get(word) ?? [], id]);
558
+ }
559
+ if (matched.size > 0) unit.context.earlier = [...matched].toSorted((a, b) => b[1] - a[1] || a[0] - b[0]).slice(0, MAX_EVIDENCE).toSorted((a, b) => a[0] - b[0]).map(([id]) => seen[id]).join("\n");
560
+ }
561
+ };
562
+ //#endregion
489
563
  //#region src/lib/frontmatter.ts
490
564
  const stripBom = (text) => text.codePointAt(0) === 65279 ? ` ${text.slice(1)}` : text;
491
565
  const scalar = (value) => {
@@ -1757,6 +1831,7 @@ const extractSource = (config, file, source, repository) => {
1757
1831
  config,
1758
1832
  docType
1759
1833
  }) : [];
1834
+ attachEarlier(units);
1760
1835
  const whole = makeUnit(file, new LineIndex(source), {
1761
1836
  context: {
1762
1837
  docType,
@@ -2110,6 +2185,8 @@ const buildState = (unit, rules) => {
2110
2185
  if (keys.has("docType")) lines.push(`DOC TYPE: ${unit.context.docType}`);
2111
2186
  if (keys.has("element") && unit.context.element) lines.push(`ELEMENT: ${unit.context.element}${unit.context.attr ? ` (${unit.context.attr})` : ""}`);
2112
2187
  if (keys.has("role")) lines.push(`ROLE: ${unit.context.role}`);
2188
+ if (keys.has("brief") && unit.context.brief) lines.push(`BRIEF: ${clip("BRIEF: ", unit.context.brief)}`);
2189
+ if (keys.has("earlier") && unit.context.earlier) lines.push(`EARLIER: ${clip("EARLIER: ", unit.context.earlier)}`);
2113
2190
  if (keys.has("section") && unit.context.section) lines.push(`SECTION: ${clip("SECTION: ", unit.context.section)}`);
2114
2191
  return {
2115
2192
  state: lines.join("\n"),
@@ -2351,11 +2428,54 @@ const smartQuotes = (prose) => prose.replaceAll(OPENING_DOUBLE, "$1“").replace
2351
2428
  const ellipsis = (prose) => prose.replaceAll("...", "…");
2352
2429
  const multiplicationSign = (prose) => prose.replaceAll(/(?<![\w.])([1-9]\d*)\s?x\s?(\d+)(?![\w.%])/gu, "$1×$2");
2353
2430
  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");
2431
+ const stripUtm = (prose) => prose.replaceAll(/\?utm_source=chatgpt\.com(?![\w&=-])/gu, "").replaceAll(/([?&])utm_source=chatgpt\.com&/gu, "$1").replaceAll("&utm_source=chatgpt.com", "");
2432
+ const AU_SPELLING = {
2433
+ analyze: "analyse",
2434
+ analyzed: "analysed",
2435
+ apologize: "apologise",
2436
+ behavior: "behaviour",
2437
+ behaviors: "behaviours",
2438
+ canceled: "cancelled",
2439
+ canceling: "cancelling",
2440
+ catalog: "catalogue",
2441
+ color: "colour",
2442
+ colored: "coloured",
2443
+ colors: "colours",
2444
+ customize: "customise",
2445
+ customized: "customised",
2446
+ favorite: "favourite",
2447
+ favorites: "favourites",
2448
+ gray: "grey",
2449
+ honor: "honour",
2450
+ labeled: "labelled",
2451
+ labeling: "labelling",
2452
+ modeling: "modelling",
2453
+ neighbor: "neighbour",
2454
+ optimize: "optimise",
2455
+ optimized: "optimised",
2456
+ organization: "organisation",
2457
+ organizations: "organisations",
2458
+ organize: "organise",
2459
+ organized: "organised",
2460
+ prioritize: "prioritise",
2461
+ realize: "realise",
2462
+ realized: "realised",
2463
+ recognize: "recognise",
2464
+ traveling: "travelling"
2465
+ };
2466
+ const AU_SPELLING_PATTERN = String.raw`(?<!\S*/\S*)\b(?:${Object.keys(AU_SPELLING).join("|")})\b(?!\S*/)`;
2467
+ const auSpelling = (prose) => prose.replaceAll(new RegExp(AU_SPELLING_PATTERN, "giu"), (word) => {
2468
+ const au = AU_SPELLING[word.toLowerCase()];
2469
+ if (!au || word !== word.toLowerCase() && word !== `${word[0]}${word.slice(1).toLowerCase()}`) return word;
2470
+ return word[0] === word[0].toUpperCase() ? `${au[0].toUpperCase()}${au.slice(1)}` : au;
2471
+ });
2354
2472
  const FIXES = {
2473
+ auSpelling,
2355
2474
  ellipsis,
2356
2475
  multiplicationSign,
2357
2476
  nbspValueUnit,
2358
- smartQuotes
2477
+ smartQuotes,
2478
+ stripUtm
2359
2479
  };
2360
2480
  //#endregion
2361
2481
  //#region src/rules/validate-sections.ts
@@ -2481,7 +2601,8 @@ const preconditions = (file, v) => {
2481
2601
  "notInCode",
2482
2602
  "docType",
2483
2603
  "role",
2484
- "smartQuotesAtBuild"
2604
+ "smartQuotesAtBuild",
2605
+ "minWords"
2485
2606
  ]);
2486
2607
  const out = {};
2487
2608
  if (raw.notInCode !== void 0) {
@@ -2494,6 +2615,10 @@ const preconditions = (file, v) => {
2494
2615
  if (raw.smartQuotesAtBuild !== false) fail(file, "preconditions.smartQuotesAtBuild may only be false");
2495
2616
  out.smartQuotesAtBuild = false;
2496
2617
  }
2618
+ if (raw.minWords !== void 0) {
2619
+ if (!Number.isInteger(raw.minWords) || raw.minWords < 1) fail(file, "preconditions.minWords must be a positive integer");
2620
+ out.minWords = raw.minWords;
2621
+ }
2497
2622
  return out;
2498
2623
  };
2499
2624
  const INCLUDE_BY_KIND = {
@@ -2589,6 +2714,7 @@ const missingEvidence = (unit, rule) => {
2589
2714
  };
2590
2715
  //#endregion
2591
2716
  //#region src/map/plan.ts
2717
+ const WORD = /[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu;
2592
2718
  const inScope = (rule, unit) => unit.kind === "element" || matchesAny(unit.file, rule.scope.include) && !matchesAny(unit.file, rule.scope.exclude);
2593
2719
  const passesPreconditions = (rule, unit, config) => {
2594
2720
  const p = rule.preconditions;
@@ -2596,6 +2722,7 @@ const passesPreconditions = (rule, unit, config) => {
2596
2722
  if (p.notInCode && unit.inCode) return false;
2597
2723
  if (p.docType && !p.docType.includes(unit.context.docType)) return false;
2598
2724
  if (p.role && !p.role.includes(unit.context.role)) return false;
2725
+ if (p.minWords && (unit.text.match(WORD)?.length ?? 0) < p.minWords) return false;
2599
2726
  if (p.smartQuotesAtBuild === false && config.smartQuotesAtBuild && (unit.kind === "paragraph" || unit.kind === "heading") && /\.(md|mdx)$/.test(unit.file)) return false;
2600
2727
  return true;
2601
2728
  };
@@ -3316,7 +3443,7 @@ const codeRule = (spec, module) => {
3316
3443
  //#endregion
3317
3444
  //#region src/rules/code/classes.ts
3318
3445
  const MODULE$1 = "src/rules/code/classes.ts";
3319
- const ANIMATION = "skills/ui-animation/references";
3446
+ const ANIMATION$1 = "skills/ui-animation/references";
3320
3447
  const bases = (unit) => (unit.classes ?? []).map(baseClass);
3321
3448
  const animates = (list) => list.some((c) => c.startsWith("transition") && c !== "transition-none" || c.startsWith("duration-") || c.startsWith("animate-") && c !== "animate-none");
3322
3449
  const durationMs = (cls) => {
@@ -3381,7 +3508,7 @@ const CLASS_RULES = [
3381
3508
  id: "motion-ease-in-on-transition",
3382
3509
  source: {
3383
3510
  line: 27,
3384
- path: `${ANIMATION}/debugging-symptoms.md`
3511
+ path: `${ANIMATION$1}/debugging-symptoms.md`
3385
3512
  },
3386
3513
  title: "Transition eases in, so it starts slow while the user is watching"
3387
3514
  }),
@@ -3396,7 +3523,7 @@ const CLASS_RULES = [
3396
3523
  id: "motion-linear-easing-on-transition",
3397
3524
  source: {
3398
3525
  line: 37,
3399
- path: `${ANIMATION}/debugging-symptoms.md`
3526
+ path: `${ANIMATION$1}/debugging-symptoms.md`
3400
3527
  },
3401
3528
  title: "Linear easing on motion that is not a spinner or marquee"
3402
3529
  }),
@@ -3412,7 +3539,7 @@ const CLASS_RULES = [
3412
3539
  id: "motion-duration-over-300ms",
3413
3540
  source: {
3414
3541
  line: 29,
3415
- path: `${ANIMATION}/debugging-symptoms.md`
3542
+ path: `${ANIMATION$1}/debugging-symptoms.md`
3416
3543
  },
3417
3544
  title: "Transition longer than 300ms on product UI"
3418
3545
  }),
@@ -3423,7 +3550,7 @@ const CLASS_RULES = [
3423
3550
  id: "motion-transition-all",
3424
3551
  source: {
3425
3552
  line: 58,
3426
- path: `${ANIMATION}/review-format.md`
3553
+ path: `${ANIMATION$1}/review-format.md`
3427
3554
  },
3428
3555
  title: "transition-all animates every property, layout included"
3429
3556
  }),
@@ -3438,7 +3565,7 @@ const CLASS_RULES = [
3438
3565
  id: "motion-scale-from-zero",
3439
3566
  source: {
3440
3567
  line: 46,
3441
- path: `${ANIMATION}/debugging-symptoms.md`
3568
+ path: `${ANIMATION$1}/debugging-symptoms.md`
3442
3569
  },
3443
3570
  title: "Entrance from scale zero"
3444
3571
  }),
@@ -3454,7 +3581,7 @@ const CLASS_RULES = [
3454
3581
  id: "motion-animate-without-reduced-motion",
3455
3582
  source: {
3456
3583
  line: 57,
3457
- path: `${ANIMATION}/live-tuning.md`
3584
+ path: `${ANIMATION$1}/live-tuning.md`
3458
3585
  },
3459
3586
  status: "review-only",
3460
3587
  title: "Animation has no local reduced-motion guard"
@@ -3523,7 +3650,7 @@ const document = (unit) => {
3523
3650
  return unit.facts.document;
3524
3651
  };
3525
3652
  const nodes = (unit) => descendants(document(unit));
3526
- const make$1 = (name, title, source, include, check, hint, authoring = false) => codeRule({
3653
+ const make$1 = (name, title, source, include, check, hint, authoring = false, status = "review-only") => codeRule({
3527
3654
  categoryId: authoring ? "instruction-quality" : "reference-reading",
3528
3655
  check,
3529
3656
  hint,
@@ -3534,7 +3661,7 @@ const make$1 = (name, title, source, include, check, hint, authoring = false) =>
3534
3661
  path: `skills/${source}`,
3535
3662
  repo: "mblode/agent-skills"
3536
3663
  },
3537
- status: "review-only",
3664
+ status,
3538
3665
  title,
3539
3666
  unit: ["source"]
3540
3667
  }, "src/rules/code/documents.ts");
@@ -3545,7 +3672,7 @@ const INSTRUCTIONS = [
3545
3672
  "**/SKILL.md"
3546
3673
  ];
3547
3674
  const DOCS = ["**/*.md"];
3548
- const README_SOURCE = "readme-creator/references/quality-checklist.md";
3675
+ const README_SOURCE = "ghostwriter/references/readme.md";
3549
3676
  const DOCUMENT_RULES = [
3550
3677
  make$1("readme-scaffold", "README still contains scaffold instructions", README_SOURCE, README, (u) => {
3551
3678
  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)));
@@ -3568,7 +3695,7 @@ const DOCUMENT_RULES = [
3568
3695
  }
3569
3696
  return none;
3570
3697
  }, "Provide a complete first-run example; move intentionally partial examples to a tutorial."),
3571
- make$1("document-empty-heading", "A section has no content", "docs-writing/rules/structure-no-filler-sections.md", DOCS, (u) => {
3698
+ make$1("document-empty-heading", "A section has no content", "ghostwriter/references/docs.md", DOCS, (u) => {
3572
3699
  const children = document(u).children ?? [];
3573
3700
  for (const [i, n] of children.entries()) {
3574
3701
  if (n.type !== "heading") continue;
@@ -3577,7 +3704,7 @@ const DOCUMENT_RULES = [
3577
3704
  }
3578
3705
  return none;
3579
3706
  }, "Add the section's content or remove its heading."),
3580
- make$1("document-heading-order", "Heading skips a structural level", "docs-writing/rules/format-semantic-html.md", DOCS, (u) => {
3707
+ make$1("document-heading-order", "Heading skips a structural level", "ghostwriter/references/docs.md", DOCS, (u) => {
3581
3708
  let previous = 0;
3582
3709
  for (const n of nodes(u).filter((candidate) => candidate.type === "heading")) {
3583
3710
  const depth = n.depth ?? 1;
@@ -3586,7 +3713,11 @@ const DOCUMENT_RULES = [
3586
3713
  }
3587
3714
  return none;
3588
3715
  }, "Nest heading levels in order so the document outline reflects its structure."),
3589
- make$1("document-broken-local-link", "Document links to a missing local file", "docs-writing/rules/review-verify-links.md", DOCS, (u) => {
3716
+ make$1("document-code-fence-language", "Code fence has no language tag", "ghostwriter/references/docs.md", DOCS, (u) => {
3717
+ const n = nodes(u).find((candidate) => candidate.type === "code" && !candidate.lang);
3718
+ return n ? at(n, "Fence without a language tag") : none;
3719
+ }, "Tag the fence: ```bash, ```ts, or ```text for output. The tag drives highlighting and tells the reader what to paste where."),
3720
+ make$1("document-broken-local-link", "Document links to a missing local file", "ghostwriter/references/docs.md", DOCS, (u) => {
3590
3721
  const repo = u.facts.repository;
3591
3722
  for (const n of nodes(u).filter((candidate) => [
3592
3723
  "link",
@@ -3607,7 +3738,7 @@ const DOCUMENT_RULES = [
3607
3738
  if (!repo.exists(resolved)) return at(n, `Missing local target: ${target}`);
3608
3739
  }
3609
3740
  return none;
3610
- }, "Correct the link or provide the referenced file. Generated site routes need a route-aware check."),
3741
+ }, "Correct the link or provide the referenced file. Generated site routes need a route-aware check.", false, "active"),
3611
3742
  make$1("instruction-missing-script", "Instruction invokes an undefined package script", "agents-md/references/quick-checklist.md", INSTRUCTIONS, (u) => {
3612
3743
  const manifest = u.facts.repository.manifest(u.file);
3613
3744
  if (!manifest) throw new UnresolvedError("No readable package manifest for this instruction file");
@@ -3676,6 +3807,7 @@ const IMAGE_RULES = [{
3676
3807
  id: "interaction-a11y-image-alt-text",
3677
3808
  required: ["alt"],
3678
3809
  source: "a11y-image-alt-text",
3810
+ status: "active",
3679
3811
  title: "Image is missing an alt attribute"
3680
3812
  }, {
3681
3813
  categoryId: "resilience",
@@ -3683,6 +3815,7 @@ const IMAGE_RULES = [{
3683
3815
  id: "craft-image-dimensions-and-priority",
3684
3816
  required: ["width", "height"],
3685
3817
  source: "perf-image-dimensions-and-priority",
3818
+ status: "review-only",
3686
3819
  title: "Image has no declared dimensions or style"
3687
3820
  }].map((spec) => codeRule({
3688
3821
  categoryId: spec.categoryId,
@@ -3708,11 +3841,166 @@ const IMAGE_RULES = [{
3708
3841
  repo: "mblode/agent-skills",
3709
3842
  ruleId: spec.source
3710
3843
  },
3711
- status: "review-only",
3844
+ status: spec.status,
3712
3845
  title: spec.title,
3713
3846
  unit: ["source"]
3714
3847
  }, "src/rules/code/images.ts"));
3715
3848
  //#endregion
3849
+ //#region src/rules/code/motion-source.ts
3850
+ const ANIMATION = "skills/ui-animation/SKILL.md";
3851
+ const stripComments = (text) => text.replaceAll(/\/\*[\s\S]*?\*\/|(?<![:"'\w])\/\/[^\n]*/gu, (m) => m.replaceAll(/[^\n]/gu, " "));
3852
+ const first = (unit, re, evidence) => {
3853
+ const m = re.exec(stripComments(unit.text));
3854
+ return m ? {
3855
+ evidence,
3856
+ fired: true,
3857
+ offset: m.index
3858
+ } : none;
3859
+ };
3860
+ const MOTION_SOURCE_RULES = [codeRule({
3861
+ categoryId: "motion-restraint",
3862
+ check: (unit) => first(unit, /(?:^|[{;\s])transition(?:-property)?\s*:\s*["'`]?all\b/mu, "transition: all"),
3863
+ hint: "List the properties: transition: transform 200ms, opacity 200ms. `all` animates layout and silently adopts properties added later.",
3864
+ id: "motion-css-transition-all",
3865
+ scope: { include: [
3866
+ "**/*.css",
3867
+ "**/*.scss",
3868
+ "**/*.tsx",
3869
+ "**/*.jsx"
3870
+ ] },
3871
+ source: {
3872
+ line: 67,
3873
+ path: ANIMATION,
3874
+ repo: "mblode/agent-skills"
3875
+ },
3876
+ title: "transition: all animates every property, layout included",
3877
+ unit: ["source"]
3878
+ }, "src/rules/code/motion-source.ts"), codeRule({
3879
+ categoryId: "motion-restraint",
3880
+ check: (unit) => first(unit, /\bfrom\s*["']framer-motion(?:\/[^"']*)?["']|\b(?:import|require)\s*\(\s*["']framer-motion["']/u, "imports framer-motion"),
3881
+ hint: "Install `motion` and import from \"motion/react\". A mixed codebase compiles but ships two copies of the library.",
3882
+ id: "motion-framer-motion-import",
3883
+ scope: { include: [
3884
+ "**/*.tsx",
3885
+ "**/*.jsx",
3886
+ "**/*.ts",
3887
+ "**/*.js",
3888
+ "**/*.mjs",
3889
+ "**/*.mts"
3890
+ ] },
3891
+ source: {
3892
+ line: 153,
3893
+ path: ANIMATION,
3894
+ repo: "mblode/agent-skills"
3895
+ },
3896
+ title: "Imports framer-motion instead of motion/react",
3897
+ unit: ["source"]
3898
+ }, "src/rules/code/motion-source.ts")];
3899
+ //#endregion
3900
+ //#region src/rules/code/prose.ts
3901
+ const PROSE_ROLES = [
3902
+ "body",
3903
+ "list-item",
3904
+ "cell",
3905
+ "caption",
3906
+ "literal-copy",
3907
+ "unknown"
3908
+ ];
3909
+ const echoesEarlier = (unit) => unit.context.earlier ? hit(`shares content words with ${unit.context.earlier.split("\n").length} earlier sentence(s)`) : none;
3910
+ const hasBrief = (unit) => unit.context.brief ? hit("checked against the brief") : none;
3911
+ const PROSE_RULES = [codeRule({
3912
+ categoryId: "machine-prose",
3913
+ check: echoesEarlier,
3914
+ hint: "Cut the restatement or replace it with the evidence it was standing in for.",
3915
+ id: "copywriting-idea-repetition",
3916
+ preconditions: {
3917
+ minWords: 5,
3918
+ notInCode: true,
3919
+ role: PROSE_ROLES
3920
+ },
3921
+ question: {
3922
+ context: ["docType", "earlier"],
3923
+ criteria: {
3924
+ false: {
3925
+ examples: [
3926
+ "EARLIER: Northwind keeps both versions side by side. TEXT: Open the conflict and pick the lines you want from each version.",
3927
+ "EARLIER: Relay unreachable. Check your connection and try again. TEXT: Disk full. Free up space on this laptop and Northwind will resume.",
3928
+ "EARLIER: Speed matters to users. TEXT: A 400ms dropdown loses a click one time in five in our logs."
3929
+ ],
3930
+ what: "TEXT adds a fact, example, step or consequence, or is a parallel entry for a different case."
3931
+ },
3932
+ true: {
3933
+ examples: ["EARLIER: When two people edit the same file, Northwind keeps both versions side by side. TEXT: Conflicting edits are never overwritten: you get both versions, side by side.", "EARLIER: It costs $8 per seat per month, with a 14-day free trial. TEXT: Just $8 per seat per month, and your first 14 days are free."],
3934
+ what: "TEXT says again what an EARLIER sentence already said, with nothing new."
3935
+ }
3936
+ },
3937
+ instructions: "EARLIER lists sentences said before TEXT in the same document that share words with it. Does TEXT restate one of them without adding a new fact, example, step or consequence? Return false for parallel entries that share a structure but cover different cases (one error message, FAQ answer or step each), for a deliberate one-sentence summary closing a long section, and for a fact repeated where the reader acts on it (the price on the plan's button)."
3938
+ },
3939
+ source: {
3940
+ line: 1,
3941
+ path: "https://typesafe-parallel-judgment-lab.every-4573.chatgpt.site/downloads/typesafe-lab-source.zip (scripts/run-ai-checker.mjs)",
3942
+ repo: "every.to",
3943
+ ruleId: "idea_repetition"
3944
+ },
3945
+ thresholds: {
3946
+ act: .75,
3947
+ review: .4
3948
+ },
3949
+ title: "Repeats the same idea without adding new evidence",
3950
+ unit: ["paragraph", "jsx-text"]
3951
+ }, "src/rules/code/prose.ts"), codeRule({
3952
+ categoryId: "evidence-over-claims",
3953
+ check: hasBrief,
3954
+ hint: "Cut the setting, mode, policy or comparison, or add it to the brief if the product really does it.",
3955
+ id: "copywriting-invented-capability",
3956
+ preconditions: { notInCode: true },
3957
+ question: {
3958
+ context: [
3959
+ "docType",
3960
+ "element",
3961
+ "brief"
3962
+ ],
3963
+ criteria: {
3964
+ false: {
3965
+ examples: [
3966
+ "BRIEF: keeps both versions when edits conflict. TEXT: Keep both versions",
3967
+ "BRIEF: 14-day free trial, no card required. TEXT: Start your free trial",
3968
+ "BRIEF: a desktop sync app. TEXT: Email address",
3969
+ "BRIEF: 14-day free trial, no card required; $8 per seat per month. TEXT: 14 days. No card. $8 per seat after that.",
3970
+ "BRIEF: keeps both versions when edits conflict. TEXT: Nobody’s work gets thrown away, even when two people edit the same file offline."
3971
+ ],
3972
+ what: "Every claim in TEXT rewords, summarises or directly follows from the brief, or TEXT is ordinary interface chrome or an instruction to the reader."
3973
+ },
3974
+ true: {
3975
+ examples: [
3976
+ "BRIEF: local network first, falls back to an encrypted relay. TEXT: Relay only",
3977
+ "BRIEF: 14-day free trial, no card required. TEXT: Cancel anytime during the trial",
3978
+ "BRIEF: keeps both versions side by side. TEXT: Most sync tools pick a winner and silently overwrite the other."
3979
+ ],
3980
+ what: "TEXT offers or asserts a capability, setting, mode, role, policy, integration or comparison the brief never states."
3981
+ }
3982
+ },
3983
+ instructions: "BRIEF is everything known about the product. Does TEXT offer or assert a capability, setting, mode, role, policy, integration, page, or claim about other products that BRIEF neither states nor directly implies? Return false for rewording, shortening or drawing a direct consequence from BRIEF, for ordinary interface chrome every app has (Save, Cancel, Email, Password, Settings, Sign in), and for instructions to the reader."
3984
+ },
3985
+ source: {
3986
+ line: 10,
3987
+ path: "skills/ui-design/rules/slop-invented-behaviour.md",
3988
+ repo: "mblode/agent-skills",
3989
+ ruleId: "slop-invented-behaviour"
3990
+ },
3991
+ thresholds: {
3992
+ act: .75,
3993
+ review: .4
3994
+ },
3995
+ title: "States a capability the brief never gave",
3996
+ unit: [
3997
+ "paragraph",
3998
+ "heading",
3999
+ "jsx-text",
4000
+ "attr-string"
4001
+ ]
4002
+ }, "src/rules/code/prose.ts")];
4003
+ //#endregion
3716
4004
  //#region src/rules/code/skill-classes.ts
3717
4005
  const make = (id, title, categoryId, source, check, hint) => codeRule({
3718
4006
  categoryId,
@@ -3757,6 +4045,67 @@ const SKILL_CLASS_RULES = [
3757
4045
  }, "Prefer transform/opacity for frequent motion; measure deliberate container resizing before changing it.")
3758
4046
  ];
3759
4047
  //#endregion
4048
+ //#region src/rules/code/slop-source.ts
4049
+ const EYEBROW = /<(?:p|span|div)\b[^>]*className="[^"]*\b(?:uppercase|text-xs|text-\[(?:9|10|11|12)px\])[^"]*"[^>]*>(?:\s*<span[^>]*\/>)?[^<{]{2,80}<\/(?:p|span|div)>\s*<h[1-3]\b/gu;
4050
+ const FACT = /\b\d+[- ]days?\b|\bno (?:credit )?card\b|\bcancel any ?time\b|\$\d+(?:\.\d\d)?(?= ?(?:\/|per|a) ?(?:seat|user|member|month))/giu;
4051
+ const MAX_EYEBROWS = 2;
4052
+ const MAX_REPEATS = 3;
4053
+ const eyebrows = (unit) => {
4054
+ const found = [...unit.text.matchAll(EYEBROW)];
4055
+ if (found.length <= MAX_EYEBROWS) return none;
4056
+ return {
4057
+ ...hit(`${found.length} headings carry an eyebrow label`),
4058
+ offset: found[MAX_EYEBROWS]?.index
4059
+ };
4060
+ };
4061
+ const factPadding = (unit) => {
4062
+ const counts = /* @__PURE__ */ new Map();
4063
+ for (const m of unit.text.matchAll(FACT)) {
4064
+ const key = m[0].toLowerCase().replaceAll(/[- ]/gu, " ");
4065
+ const seen = counts.get(key) ?? {
4066
+ first: m.index,
4067
+ n: 0
4068
+ };
4069
+ counts.set(key, {
4070
+ ...seen,
4071
+ n: seen.n + 1
4072
+ });
4073
+ }
4074
+ const [top] = [...counts].toSorted((a, b) => b[1].n - a[1].n);
4075
+ if (!top || top[1].n <= MAX_REPEATS) return none;
4076
+ return {
4077
+ ...hit(`"${top[0]}" appears ${top[1].n} times`),
4078
+ offset: top[1].first
4079
+ };
4080
+ };
4081
+ const source = (ruleId) => ({
4082
+ line: 10,
4083
+ path: `skills/ui-design/rules/${ruleId}.md`,
4084
+ repo: "mblode/agent-skills",
4085
+ ruleId
4086
+ });
4087
+ const SLOP_SOURCE_RULES = [codeRule({
4088
+ categoryId: "visual-hierarchy",
4089
+ check: eyebrows,
4090
+ hint: "Keep an eyebrow on at most two sections, where it names something the heading cannot. Delete the rest.",
4091
+ id: "craft-eyebrow-overuse",
4092
+ scope: { include: ["**/*.tsx", "**/*.jsx"] },
4093
+ source: source("slop-eyebrow-overuse"),
4094
+ status: "review-only",
4095
+ title: "An eyebrow over nearly every heading",
4096
+ unit: ["source"]
4097
+ }, "src/rules/code/slop-source.ts"), codeRule({
4098
+ categoryId: "evidence-over-claims",
4099
+ check: factPadding,
4100
+ hint: "Say each fact once, where the reader needs it: the price on the plan, the trial on the button. Cut the sections that exist only to repeat them.",
4101
+ id: "copywriting-fact-padding",
4102
+ scope: { include: ["**/*.tsx", "**/*.jsx"] },
4103
+ source: source("slop-fact-padding"),
4104
+ status: "review-only",
4105
+ title: "The same few facts restated to fill the page",
4106
+ unit: ["source"]
4107
+ }, "src/rules/code/slop-source.ts")];
4108
+ //#endregion
3760
4109
  //#region src/rules/code/typography.ts
3761
4110
  const MODULE = "src/rules/code/typography.ts";
3762
4111
  const AUDIT = "skills/typography-audit/rules";
@@ -3972,7 +4321,7 @@ const CODE_RULES = [
3972
4321
  rule({
3973
4322
  categoryId: "machine-prose",
3974
4323
  check: (unit) => {
3975
- const long = unit.text.split(/(?<=[.!?])\s+/u).map((s) => [s, countWords(s)]).filter(([, n]) => n > 25);
4324
+ 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);
3976
4325
  if (long.length === 0) return none;
3977
4326
  const [sentence, n] = long[0];
3978
4327
  return hit(`${n} words: "${sentence.slice(0, 80)}${sentence.length > 80 ? "..." : ""}"`);
@@ -3994,18 +4343,21 @@ const CODE_RULES = [
3994
4343
  notInCode: true
3995
4344
  },
3996
4345
  source: {
3997
- line: 7,
3998
- path: "skills/docs-writing/rules/clarity-one-idea-per-sentence.md",
4346
+ line: 17,
4347
+ path: "skills/ghostwriter/references/docs.md",
3999
4348
  repo: "mblode/agent-skills"
4000
4349
  },
4001
- title: "Sentence over 25 words",
4350
+ title: "Sentence over 25 words joins several clauses",
4002
4351
  unit: ["paragraph", "jsx-text"]
4003
4352
  })
4004
4353
  ],
4005
4354
  ...CLASS_RULES,
4006
4355
  ...DOCUMENT_RULES,
4007
4356
  ...IMAGE_RULES,
4008
- ...SKILL_CLASS_RULES
4357
+ ...SKILL_CLASS_RULES,
4358
+ ...MOTION_SOURCE_RULES,
4359
+ ...SLOP_SOURCE_RULES,
4360
+ ...PROSE_RULES
4009
4361
  ];
4010
4362
  //#endregion
4011
4363
  //#region src/rules/load.ts
@@ -4068,10 +4420,8 @@ const readTuning = (rulesDir) => {
4068
4420
  }
4069
4421
  return out;
4070
4422
  };
4071
- const loadRules = (rulesDir, options = {}) => {
4072
- const tuning = readTuning(rulesDir);
4423
+ const readYamlRules = (rulesDir) => {
4073
4424
  const rules = [];
4074
- const seen = /* @__PURE__ */ new Set();
4075
4425
  const domains = fs.readdirSync(rulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name !== "schema").map((d) => d.name).toSorted();
4076
4426
  for (const domain of domains) {
4077
4427
  const dir = path.join(rulesDir, domain);
@@ -4079,24 +4429,49 @@ const loadRules = (rulesDir, options = {}) => {
4079
4429
  if (!name.endsWith(".yaml")) continue;
4080
4430
  const file = path.join(dir, name);
4081
4431
  const expectedId = name.slice(0, -5);
4082
- let raw;
4083
4432
  try {
4084
- raw = parse(fs.readFileSync(file, "utf-8"));
4433
+ let raw;
4434
+ try {
4435
+ raw = parse(fs.readFileSync(file, "utf-8"));
4436
+ } catch (error) {
4437
+ throw new Error(`Invalid rule ${file}: YAML did not parse (${error.message})`, { cause: error });
4438
+ }
4439
+ const rule = validateRule(raw, file, expectedId);
4440
+ if (rule.domain !== domain) throw new Error(`Invalid rule ${file}: domain ${rule.domain} does not match folder ${domain}`);
4441
+ rules.push(rule);
4085
4442
  } catch (error) {
4086
- throw new Error(`Invalid rule ${file}: YAML did not parse (${error.message})`, { cause: error });
4443
+ const invalid = new InputError("INVALID_RULE", error.message, { file });
4444
+ invalid.cause = error;
4445
+ throw invalid;
4087
4446
  }
4088
- const rule = validateRule(raw, file, expectedId);
4089
- if (rule.domain !== domain) throw new Error(`Invalid rule ${file}: domain ${rule.domain} does not match folder ${domain}`);
4090
- rules.push(rule);
4091
4447
  }
4092
4448
  }
4449
+ return rules;
4450
+ };
4451
+ const loadRules = (rulesDir, options = {}) => {
4452
+ const tuning = readTuning(rulesDir);
4453
+ const rules = [];
4454
+ const seen = /* @__PURE__ */ new Map();
4455
+ rules.push(...readYamlRules(rulesDir));
4456
+ for (const dir of options.extraDirs ?? []) {
4457
+ const extra = readYamlRules(dir);
4458
+ 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")}/`, {
4459
+ field: "rules",
4460
+ path: dir
4461
+ });
4462
+ rules.push(...extra);
4463
+ }
4093
4464
  for (const code of CODE_RULES) rules.push({
4094
4465
  ...code,
4095
4466
  thresholds: { ...code.thresholds }
4096
4467
  });
4097
4468
  for (const rule of rules) {
4098
- if (seen.has(rule.id)) throw new Error(`Duplicate rule id ${rule.id}`);
4099
- seen.add(rule.id);
4469
+ const first = seen.get(rule.id);
4470
+ if (first !== void 0) throw new InputError("DUPLICATE_RULE", `Rule id ${rule.id} is defined in ${first} and ${rule.file}; rename one.`, {
4471
+ files: [first, rule.file],
4472
+ id: rule.id
4473
+ });
4474
+ seen.set(rule.id, rule.file);
4100
4475
  const overlay = tuning[rule.id];
4101
4476
  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.`);
4102
4477
  if (overlay) {
@@ -4272,7 +4647,10 @@ const runLint = async (options = {}, ctx = {}) => {
4272
4647
  const config = loadConfig(options.root ?? process.cwd());
4273
4648
  const rulesDir = resolveRulesDir(options.rulesDir);
4274
4649
  const profile = options.profile ? profileFor(options.profile) : void 0;
4275
- const loaded = loadRules(rulesDir, { only: options.only });
4650
+ const loaded = loadRules(rulesDir, {
4651
+ extraDirs: config.rules,
4652
+ only: options.only
4653
+ });
4276
4654
  const rules = profile && !options.only?.length ? profileRules(profile, loaded) : loaded;
4277
4655
  if (rules.length === 0) throw new InputError("EMPTY_RULE_SELECTION", "No rules match the selected profile and ids.");
4278
4656
  const model = options.model ?? "jev-latest";
@@ -4295,6 +4673,10 @@ const runLint = async (options = {}, ctx = {}) => {
4295
4673
  sources.set(file, source.split("\n"));
4296
4674
  }
4297
4675
  if (options.extraUnits) units.push(...options.extraUnits);
4676
+ if (options.brief) {
4677
+ const brief = fs.readFileSync(path.resolve(options.brief), "utf-8").trim();
4678
+ for (const unit of units) unit.context.brief = brief;
4679
+ }
4298
4680
  const scope = {
4299
4681
  byDocType: {},
4300
4682
  diagnostics: scan.messages,
@@ -4357,7 +4739,7 @@ const runLint = async (options = {}, ctx = {}) => {
4357
4739
  onProgress: ctx.onProgress,
4358
4740
  recorder
4359
4741
  });
4360
- ctx.onEvidence?.(units, judgement.answers, judgement.negatives, rules);
4742
+ ctx.onEvidence?.(units, judgement.answers, judgement.negatives, rules, sources);
4361
4743
  const status = options.dryRun ? "dry-run" : judgement.usage.errors > 0 || parseFailures.size > 0 || units.length === 0 ? "incomplete" : "complete";
4362
4744
  const jf = jevFindings(judgement.jobs, judgement.answers);
4363
4745
  let findings = [...judgement.mechanical, ...jf.findings];
@@ -4746,6 +5128,7 @@ const loadCorpus = (corpusDir, rules, options = {}) => {
4746
5128
  ].includes(labelSource)) throw new Error(`Invalid corpus line ${where}: labelSource`);
4747
5129
  if (labelSource === "ai" && (typeof r.labelModel !== "string" || !r.labelModel.trim() || typeof r.labelPromptHash !== "string" || !/^[a-f0-9]{64}$/.test(r.labelPromptHash))) throw new Error(`Invalid corpus line ${where}: AI labels require model and prompt hash`);
4748
5130
  if (!isRecord(r.source) || typeof r.source.repo !== "string" || typeof r.source.path !== "string" || r.source.id !== void 0 && (typeof r.source.id !== "string" || !r.source.id.trim())) throw new Error(`Invalid corpus line ${where}: source needs repo and path`);
5131
+ if (r.note !== void 0 && typeof r.note !== "string") throw new TypeError(`Invalid corpus line ${where}: note must be text`);
4749
5132
  if (r.split !== void 0 && r.split !== "dev" && r.split !== "holdout") throw new Error(`Invalid corpus line ${where}: split must be dev or holdout`);
4750
5133
  if (r.classes !== void 0 && (!Array.isArray(r.classes) || r.classes.some((c) => typeof c !== "string"))) throw new Error(`Invalid corpus line ${where}: classes must be strings`);
4751
5134
  if (r.context !== void 0) {
@@ -4772,6 +5155,7 @@ const loadCorpus = (corpusDir, rules, options = {}) => {
4772
5155
  labelSource,
4773
5156
  labels,
4774
5157
  neighbours: r.neighbours,
5158
+ note: r.note,
4775
5159
  source: r.source,
4776
5160
  split: r.split ?? splitFor(JSON.stringify([r.source.repo, r.source.path])),
4777
5161
  text: r.text
@@ -4875,6 +5259,24 @@ const pairedOutcomes = (items, rule, probabilities) => {
4875
5259
  return contrast;
4876
5260
  };
4877
5261
  //#endregion
5262
+ //#region src/eval/label.ts
5263
+ const renderDisagreements = (evals, items, rules) => {
5264
+ const byId = new Map(items.map((item) => [item.id, item]));
5265
+ const out = [];
5266
+ for (const e of evals) {
5267
+ const rule = rules.find((r) => r.id === e.ruleId);
5268
+ if (!rule) continue;
5269
+ const wrong = e.pairs.filter((p) => p.probability >= rule.thresholds.review !== p.label);
5270
+ if (!wrong.length) continue;
5271
+ out.push(`${e.ruleId}: ${wrong.length} of ${e.pairs.length} disagree (${path.relative(process.cwd(), rule.file)})`);
5272
+ for (const p of wrong) {
5273
+ const item = byId.get(p.id);
5274
+ out.push(` ${p.label ? "labelled violation, judge missed" : "labelled fine, judge flagged"} p=${p.probability.toFixed(2)} [${item?.labelSource ?? "?"}/${item?.split ?? "?"}] ${p.id}`, ` ${(item?.text ?? "").replaceAll("\n", " ").slice(0, 160)}`, ...item?.note ? [` note: ${item.note}`] : []);
5275
+ }
5276
+ }
5277
+ return out.length ? `\nDisagreements (review threshold):\n${out.join("\n")}\n` : "\nNo disagreements at the review threshold.\n";
5278
+ };
5279
+ //#endregion
4878
5280
  //#region src/eval/metrics.ts
4879
5281
  const corpusConfig = () => ({
4880
5282
  components: {
@@ -5020,7 +5422,11 @@ const runEval = async (options, ctx = {}) => {
5020
5422
  knownRuleIds
5021
5423
  });
5022
5424
  if (options.split && options.split !== "all") items = items.filter((i) => i.split === options.split);
5023
- if (!items.length) throw new Error("No corpus items in the selected split.");
5425
+ if (options.sources) {
5426
+ const sources = new Set(options.sources);
5427
+ items = items.filter((i) => sources.has(i.labelSource));
5428
+ }
5429
+ if (!items.length) throw new Error("No corpus items in the selected split and label sources.");
5024
5430
  const model = options.model ?? "jev-latest";
5025
5431
  const resultsDir = options.resultsDir ?? defaultResultsDir();
5026
5432
  const cache = new AnswerCache(path.join(resultsDir, "cache"), !options.noCache);
@@ -5037,6 +5443,7 @@ const runEval = async (options, ctx = {}) => {
5037
5443
  model,
5038
5444
  recorder
5039
5445
  });
5446
+ if (unknowns.some((u) => u.reason === "auth")) throw new ProviderError("auth", 401);
5040
5447
  const labelSources = {};
5041
5448
  for (const item of items) labelSources[item.labelSource] = (labelSources[item.labelSource] ?? 0) + 1;
5042
5449
  const evals = evaluateRules(items, rules, probabilities, unknowns, skipped);
@@ -5056,7 +5463,7 @@ const runEval = async (options, ctx = {}) => {
5056
5463
  exitCode,
5057
5464
  labelSources,
5058
5465
  referenceCoverage: corpusCoverage(items, rules),
5059
- report: (options.check ? `Regression check: ${exitCode === 0 ? "PASS" : exitCode === 1 ? "FAIL (reference disagreement)" : "INCOMPLETE (missing evaluations)"}. Uses the review threshold.\n` : "") + (labelSources.ai ? `AI-labeled reference: ${labelSources.ai} items. Metrics measure agreement with AI labels, not human judgments.\n` : "") + renderEval(evals, usage, Boolean(options.dryRun), pendingRequests),
5466
+ report: (options.check ? `Regression check: ${exitCode === 0 ? "PASS" : exitCode === 1 ? "FAIL (reference disagreement)" : "INCOMPLETE (missing evaluations)"}. Uses the review threshold.\n` : "") + (labelSources.ai ? `AI-labeled reference: ${labelSources.ai} items. Metrics measure agreement with AI labels, not human judgments.\n` : "") + renderEval(evals, usage, Boolean(options.dryRun), pendingRequests) + (options.disagreements && !options.dryRun ? renderDisagreements(evals, items, rules) : ""),
5060
5467
  rules: evals,
5061
5468
  usage
5062
5469
  };
@@ -5081,6 +5488,7 @@ const promotionEvidence = (items, ruleId, pairs, threshold, floor, minItems) =>
5081
5488
  };
5082
5489
  //#endregion
5083
5490
  //#region src/eval/tune.ts
5491
+ const fromSources = (items, sources) => sources ? items.filter((item) => sources.includes(item.labelSource)) : items;
5084
5492
  const THRESHOLDS = Array.from({ length: 10 }, (_, i) => Math.round((.5 + i * .05) * 100) / 100);
5085
5493
  const decideThreshold = (pairs, currentAct, floor, minItems, review = 0) => {
5086
5494
  const n = pairs.length;
@@ -5134,7 +5542,7 @@ const runTune = async (options, ctx = {}) => {
5134
5542
  only: options.only
5135
5543
  }).filter((r) => r.tier !== "mechanical");
5136
5544
  const knownRuleIds = new Set(loadRules(rulesDir, { allowDraft: true }).map((r) => r.id));
5137
- const allItems = loadCorpus(resolveCorpusDir(options.corpusDir), rules, { knownRuleIds });
5545
+ const allItems = fromSources(loadCorpus(resolveCorpusDir(options.corpusDir), rules, { knownRuleIds }), options.sources);
5138
5546
  const items = allItems.filter((item) => item.split === "dev");
5139
5547
  const model = options.model ?? "jev-latest";
5140
5548
  const resultsDir = options.resultsDir ?? defaultResultsDir();