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/cli.js CHANGED
@@ -2,19 +2,20 @@
2
2
  import { Command, CommanderError, Option } from "commander";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import { execFileSync, spawn, spawnSync } from "node:child_process";
5
+ import readline from "node:readline";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
+ import os from "node:os";
8
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
7
9
  import { parse } from "yaml";
8
10
  import { fromMarkdown } from "mdast-util-from-markdown";
9
11
  import { gfmFromMarkdown } from "mdast-util-gfm";
10
12
  import { gfm } from "micromark-extension-gfm";
11
13
  import { parseSync } from "oxc-parser";
12
- import os from "node:os";
13
14
  import { mdxFromMarkdown } from "mdast-util-mdx";
14
15
  import { mdxjs } from "micromark-extension-mdxjs";
15
16
  import { styleText } from "node:util";
16
17
  //#region packages/cli/package.json
17
- var version = "0.2.0";
18
+ var version = "0.4.0";
18
19
  //#endregion
19
20
  //#region src/lib/stats.ts
20
21
  const makePRNG = (seed) => {
@@ -345,7 +346,9 @@ const CONTEXT_KEYS = [
345
346
  "docType",
346
347
  "element",
347
348
  "role",
348
- "neighbours"
349
+ "neighbours",
350
+ "earlier",
351
+ "brief"
349
352
  ];
350
353
  //#endregion
351
354
  //#region src/eval/corpus.ts
@@ -416,6 +419,7 @@ const loadCorpus = (corpusDir, rules, options = {}) => {
416
419
  ].includes(labelSource)) throw new Error(`Invalid corpus line ${where}: labelSource`);
417
420
  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`);
418
421
  if (!isRecord$1(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`);
422
+ if (r.note !== void 0 && typeof r.note !== "string") throw new TypeError(`Invalid corpus line ${where}: note must be text`);
419
423
  if (r.split !== void 0 && r.split !== "dev" && r.split !== "holdout") throw new Error(`Invalid corpus line ${where}: split must be dev or holdout`);
420
424
  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`);
421
425
  if (r.context !== void 0) {
@@ -442,6 +446,7 @@ const loadCorpus = (corpusDir, rules, options = {}) => {
442
446
  labelSource,
443
447
  labels,
444
448
  neighbours: r.neighbours,
449
+ note: r.note,
445
450
  source: r.source,
446
451
  split: r.split ?? splitFor(JSON.stringify([r.source.repo, r.source.path])),
447
452
  text: r.text
@@ -552,6 +557,218 @@ const pairedOutcomes = (items, rule, probabilities) => {
552
557
  return contrast;
553
558
  };
554
559
  //#endregion
560
+ //#region src/lib/errors.ts
561
+ var InputError = class extends Error {
562
+ code;
563
+ details;
564
+ constructor(code, message, details = {}) {
565
+ super(message);
566
+ this.code = code;
567
+ this.details = details;
568
+ this.name = "InputError";
569
+ }
570
+ };
571
+ //#endregion
572
+ //#region src/scan/storage.ts
573
+ const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
574
+ const object$2 = (value) => !!value && typeof value === "object" && !Array.isArray(value);
575
+ const readJson = (file) => {
576
+ try {
577
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
578
+ } catch {
579
+ throw new InputError("INVALID_SCAN_INPUT", `Cannot read JSON input: ${file}`);
580
+ }
581
+ };
582
+ const writeJson = (file, value) => {
583
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
584
+ const temp = `${file}.${randomUUID()}.tmp`;
585
+ try {
586
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
587
+ fs.renameSync(temp, file);
588
+ } finally {
589
+ fs.rmSync(temp, { force: true });
590
+ }
591
+ };
592
+ //#endregion
593
+ //#region src/eval/label.ts
594
+ const readSamples = (file) => {
595
+ const raw = readJson(file);
596
+ if (!object$2(raw) || raw.version !== 1 && raw.version !== 2 || !Array.isArray(raw.samples)) throw new InputError("INVALID_LABELS", `Expected a version 1 or 2 sample file from lint --samples: ${file}`);
597
+ return raw;
598
+ };
599
+ const show = (sample, index, total, first) => {
600
+ const { context, neighbours, text } = sample.item;
601
+ const lines = [
602
+ "",
603
+ `[${index}/${total}] ${sample.ruleId}${context?.docType ? ` · ${context.docType}` : ""}${context?.role ? ` · ${context.role}` : ""}`,
604
+ `Q: ${sample.criterion}`
605
+ ];
606
+ const criteria = sample.rubric?.criteria;
607
+ if (first && criteria) for (const [key, side] of [["yes", criteria.true], ["no", criteria.false]]) {
608
+ if (!side) continue;
609
+ lines.push(` ${key}: ${side.what ?? ""}`);
610
+ for (const example of side.examples ?? []) lines.push(` e.g. ${example}`);
611
+ }
612
+ if (context?.headingAbove) lines.push(`Under heading: ${context.headingAbove}`);
613
+ if (neighbours?.prev) lines.push(`Before: ${neighbours.prev.text}`);
614
+ lines.push("Text:", ` ${text.split("\n").join("\n ")}`);
615
+ if (neighbours?.next) lines.push(`After: ${neighbours.next.text}`);
616
+ return `${lines.join("\n")}\n`;
617
+ };
618
+ /** Walk unlabelled samples, saving after every answer so quitting loses nothing. */
619
+ const labelSession = async (file, options) => {
620
+ const data = readSamples(file);
621
+ if (data.annotation !== void 0) throw new InputError("INVALID_LABELS", "This file carries AI annotation. Hand-label a fresh lint --samples file.");
622
+ const now = options.now ?? Date.now;
623
+ const deadline = options.minutes === void 0 ? Infinity : now() + options.minutes * 6e4;
624
+ const pending = data.samples.filter((s) => s.label === null && !s.unsure);
625
+ const rl = readline.createInterface({
626
+ input: options.input,
627
+ terminal: false
628
+ });
629
+ const lines = rl[Symbol.asyncIterator]();
630
+ const ask = async (prompt) => {
631
+ options.output.write(prompt);
632
+ const next = await lines.next();
633
+ return next.done ? void 0 : String(next.value).trim();
634
+ };
635
+ const seenRules = /* @__PURE__ */ new Set();
636
+ let labelled = 0;
637
+ let unsure = 0;
638
+ let stopped = "done";
639
+ try {
640
+ for (const [index, sample] of pending.entries()) {
641
+ if (now() >= deadline) {
642
+ stopped = "timebox";
643
+ break;
644
+ }
645
+ options.output.write(show(sample, index + 1, pending.length, !seenRules.has(sample.ruleId)));
646
+ seenRules.add(sample.ruleId);
647
+ let answer;
648
+ do
649
+ answer = (await ask("[y] violation [n] fine [u] unsure [s] skip [q] quit > "))?.toLowerCase();
650
+ while (answer !== void 0 && ![
651
+ "y",
652
+ "n",
653
+ "u",
654
+ "s",
655
+ "q"
656
+ ].includes(answer));
657
+ if (answer === void 0 || answer === "q") {
658
+ stopped = "quit";
659
+ break;
660
+ }
661
+ if (answer === "s") continue;
662
+ const note = await ask(answer === "u" ? "What would you need to decide? > " : "Note (enter to skip) > ");
663
+ if (answer === "u") {
664
+ sample.unsure = true;
665
+ unsure += 1;
666
+ } else {
667
+ sample.label = answer === "y";
668
+ labelled += 1;
669
+ }
670
+ if (note) sample.note = note;
671
+ writeJson(file, data);
672
+ if (note === void 0) {
673
+ stopped = "quit";
674
+ break;
675
+ }
676
+ }
677
+ } finally {
678
+ rl.close();
679
+ }
680
+ return {
681
+ labelled,
682
+ remaining: data.samples.filter((s) => s.label === null && !s.unsure).length,
683
+ stopped,
684
+ unsure
685
+ };
686
+ };
687
+ /** Group hand labels by rule: unsure answers and notes are rubric gaps. */
688
+ const labelGaps = (files) => {
689
+ const byRule = /* @__PURE__ */ new Map();
690
+ for (const file of files) for (const sample of readSamples(file).samples) {
691
+ if (sample.label === null && !sample.unsure) continue;
692
+ const gaps = byRule.get(sample.ruleId) ?? {
693
+ fine: 0,
694
+ notes: [],
695
+ ruleId: sample.ruleId,
696
+ unsure: 0,
697
+ violations: 0
698
+ };
699
+ if (sample.unsure) gaps.unsure += 1;
700
+ else if (sample.label) gaps.violations += 1;
701
+ else gaps.fine += 1;
702
+ if (sample.note) gaps.notes.push({
703
+ label: sample.unsure ? "unsure" : Boolean(sample.label),
704
+ note: sample.note,
705
+ text: sample.item.text
706
+ });
707
+ byRule.set(sample.ruleId, gaps);
708
+ }
709
+ return [...byRule.values()].toSorted((a, b) => b.unsure - a.unsure || b.notes.length - a.notes.length || a.ruleId.localeCompare(b.ruleId));
710
+ };
711
+ const renderGaps = (gaps) => {
712
+ if (!gaps.length) return "No hand labels yet. Run taste-lint eval label <samples.json>.\n";
713
+ const out = [];
714
+ for (const g of gaps) {
715
+ out.push(`${g.ruleId}: ${g.violations} violation, ${g.fine} fine, ${g.unsure} unsure`);
716
+ if (g.unsure) out.push(" Unsure answers mean the rubric cannot settle these; sharpen question.criteria before tuning.");
717
+ for (const n of g.notes) {
718
+ const label = n.label === "unsure" ? "unsure" : n.label ? "yes" : "no";
719
+ out.push(` [${label}] ${n.note}`, ` ${n.text.replaceAll("\n", " ").slice(0, 100)}`);
720
+ }
721
+ }
722
+ return `${out.join("\n")}\n`;
723
+ };
724
+ /** How often a second labeller (usually an AI) matches your hand labels, per rule. */
725
+ const labelAgreement = (handFile, otherFile, floor = .8) => {
726
+ const hand = new Map(readSamples(handFile).samples.filter((s) => s.label !== null).map((s) => [s.id, s]));
727
+ const byRule = /* @__PURE__ */ new Map();
728
+ for (const other of readSamples(otherFile).samples) {
729
+ const mine = hand.get(other.id);
730
+ if (!mine || other.label === null || mine.ruleId !== other.ruleId) continue;
731
+ const tally = byRule.get(other.ruleId) ?? {
732
+ agree: 0,
733
+ n: 0
734
+ };
735
+ tally.n += 1;
736
+ tally.agree += mine.label === other.label ? 1 : 0;
737
+ byRule.set(other.ruleId, tally);
738
+ }
739
+ return [...byRule.entries()].map(([ruleId, { n, agree }]) => {
740
+ const ci = wilson(agree, n);
741
+ return {
742
+ agree,
743
+ ci,
744
+ n,
745
+ ruleId,
746
+ trusted: ci[0] >= floor
747
+ };
748
+ }).toSorted((a, b) => a.ruleId.localeCompare(b.ruleId));
749
+ };
750
+ const pct$1 = (x) => `${Math.round(x * 100)}%`;
751
+ const renderAgreement = (rows, floor) => {
752
+ if (!rows.length) return "No samples are labelled in both files.\n";
753
+ return `${rows.map((r) => `${r.ruleId}: ${r.agree}/${r.n} agree (${pct$1(r.agree / r.n)}, 95% CI ${pct$1(r.ci[0])}-${pct$1(r.ci[1])}) ${r.trusted ? "trusted" : `below ${pct$1(floor)}: label this rule by hand`}`).join("\n")}\n`;
754
+ };
755
+ const renderDisagreements = (evals, items, rules) => {
756
+ const byId = new Map(items.map((item) => [item.id, item]));
757
+ const out = [];
758
+ for (const e of evals) {
759
+ const rule = rules.find((r) => r.id === e.ruleId);
760
+ if (!rule) continue;
761
+ const wrong = e.pairs.filter((p) => p.probability >= rule.thresholds.review !== p.label);
762
+ if (!wrong.length) continue;
763
+ out.push(`${e.ruleId}: ${wrong.length} of ${e.pairs.length} disagree (${path.relative(process.cwd(), rule.file)})`);
764
+ for (const p of wrong) {
765
+ const item = byId.get(p.id);
766
+ 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}`] : []);
767
+ }
768
+ }
769
+ return out.length ? `\nDisagreements (review threshold):\n${out.join("\n")}\n` : "\nNo disagreements at the review threshold.\n";
770
+ };
771
+ //#endregion
555
772
  //#region src/extract/tailwind.ts
556
773
  const ROOT_PX = 16;
557
774
  const FONT_SIZES = {
@@ -843,18 +1060,6 @@ const compareScale = (classes, theme = {}) => {
843
1060
  };
844
1061
  };
845
1062
  //#endregion
846
- //#region src/lib/errors.ts
847
- var InputError = class extends Error {
848
- code;
849
- details;
850
- constructor(code, message, details = {}) {
851
- super(message);
852
- this.code = code;
853
- this.details = details;
854
- this.name = "InputError";
855
- }
856
- };
857
- //#endregion
858
1063
  //#region src/lib/glob.ts
859
1064
  const DEFAULT_EXCLUDE = [
860
1065
  "**/node_modules/**",
@@ -862,7 +1067,10 @@ const DEFAULT_EXCLUDE = [
862
1067
  "**/.next/**",
863
1068
  "**/.git/**",
864
1069
  "**/results/**",
865
- "**/coverage/**"
1070
+ "**/coverage/**",
1071
+ "**/LICENSE",
1072
+ "**/LICENSE.*",
1073
+ "**/CHANGELOG.md"
866
1074
  ];
867
1075
  const globToRegExp = (glob) => {
868
1076
  let out = "^";
@@ -1053,52 +1261,70 @@ const invalid = (key, expected) => {
1053
1261
  field: key
1054
1262
  });
1055
1263
  };
1264
+ const object$1 = (value, key) => {
1265
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(key, "an object");
1266
+ return value;
1267
+ };
1268
+ const keys = (value, allowed, prefix) => {
1269
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid(`${prefix}${key}`, `one of ${allowed.join(", ")}`);
1270
+ };
1271
+ const strings$1 = (value, key) => {
1272
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) invalid(key, "an array of strings");
1273
+ };
1056
1274
  function validateConfig(raw) {
1057
- const object = (value, key) => {
1058
- if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(key, "an object");
1059
- return value;
1060
- };
1061
- const keys = (value, allowed, prefix) => {
1062
- for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid(`${prefix}${key}`, `one of ${allowed.join(", ")}`);
1063
- };
1064
- const strings = (value, key) => {
1065
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) invalid(key, "an array of strings");
1066
- };
1067
- const value = object(raw, "config");
1275
+ const value = object$1(raw, "config");
1068
1276
  keys(value, [
1069
1277
  "$schema",
1070
1278
  "exclude",
1071
1279
  "smartQuotesAtBuild",
1072
1280
  "docTypes",
1073
1281
  "components",
1074
- "tailwind"
1282
+ "tailwind",
1283
+ "rules"
1075
1284
  ], "");
1076
1285
  if (value.$schema !== void 0 && typeof value.$schema !== "string") invalid("$schema", "a string");
1077
- if (value.exclude !== void 0) strings(value.exclude, "exclude");
1286
+ if (value.exclude !== void 0) strings$1(value.exclude, "exclude");
1287
+ if (value.rules !== void 0) strings$1(value.rules, "rules");
1078
1288
  if (value.smartQuotesAtBuild !== void 0 && typeof value.smartQuotesAtBuild !== "boolean") invalid("smartQuotesAtBuild", "a boolean");
1079
1289
  if (value.docTypes !== void 0) {
1080
1290
  if (!Array.isArray(value.docTypes)) invalid("docTypes", "an array");
1081
1291
  for (const [i, entry] of value.docTypes.entries()) {
1082
- const row = object(entry, `docTypes[${i}]`);
1292
+ const row = object$1(entry, `docTypes[${i}]`);
1083
1293
  keys(row, ["glob", "type"], `docTypes[${i}].`);
1084
1294
  if (typeof row.glob !== "string") invalid(`docTypes[${i}].glob`, "a string");
1085
1295
  if (!DOC_TYPES.includes(row.type)) invalid(`docTypes[${i}].type`, DOC_TYPES.join(", "));
1086
1296
  }
1087
1297
  }
1088
1298
  if (value.components !== void 0) {
1089
- const c = object(value.components, "components");
1299
+ const c = object$1(value.components, "components");
1090
1300
  keys(c, ["skip", "unwrap"], "components.");
1091
- for (const key of ["skip", "unwrap"]) if (c[key] !== void 0) strings(c[key], `components.${key}`);
1301
+ for (const key of ["skip", "unwrap"]) if (c[key] !== void 0) strings$1(c[key], `components.${key}`);
1092
1302
  }
1093
1303
  if (value.tailwind !== void 0) {
1094
- const t = object(value.tailwind, "tailwind");
1304
+ const t = object$1(value.tailwind, "tailwind");
1095
1305
  keys(t, ["theme"], "tailwind.");
1096
1306
  if (t.theme !== void 0) {
1097
- const theme = object(t.theme, "tailwind.theme");
1307
+ const theme = object$1(t.theme, "tailwind.theme");
1098
1308
  for (const [key, val] of Object.entries(theme)) if (typeof val !== "string") invalid(`tailwind.theme.${key}`, "a string");
1099
1309
  }
1100
1310
  }
1101
1311
  }
1312
+ const ruleDir = (root, raw) => {
1313
+ const expanded = raw.replace(/^~(?=$|\/)/u, os.homedir()).replaceAll(/\$\{?(\w+)\}?/gu, (_, name) => {
1314
+ const value = process.env[name];
1315
+ if (!value) throw new InputError("INVALID_CONFIG", `taste-lint.config.json: rules entry ${raw} names ${name}, which is not set.`, {
1316
+ field: "rules",
1317
+ variable: name
1318
+ });
1319
+ return value;
1320
+ });
1321
+ const dir = path.resolve(root, expanded);
1322
+ 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}).`, {
1323
+ field: "rules",
1324
+ path: dir
1325
+ });
1326
+ return dir;
1327
+ };
1102
1328
  const loadConfig = (root, defaults = []) => {
1103
1329
  const absRoot = path.resolve(root);
1104
1330
  try {
@@ -1133,6 +1359,7 @@ const loadConfig = (root, defaults = []) => {
1133
1359
  ],
1134
1360
  exclude: user.exclude ?? [],
1135
1361
  root: absRoot,
1362
+ rules: (user.rules ?? []).map((raw) => ruleDir(absRoot, raw)),
1136
1363
  smartQuotesAtBuild: typeof user.smartQuotesAtBuild === "boolean" ? user.smartQuotesAtBuild : detectSmartQuotesAtBuild(absRoot),
1137
1364
  tailwind: { theme: user.tailwind?.theme ?? {} }
1138
1365
  };
@@ -1464,6 +1691,8 @@ const buildState = (unit, rules) => {
1464
1691
  if (keys.has("docType")) lines.push(`DOC TYPE: ${unit.context.docType}`);
1465
1692
  if (keys.has("element") && unit.context.element) lines.push(`ELEMENT: ${unit.context.element}${unit.context.attr ? ` (${unit.context.attr})` : ""}`);
1466
1693
  if (keys.has("role")) lines.push(`ROLE: ${unit.context.role}`);
1694
+ if (keys.has("brief") && unit.context.brief) lines.push(`BRIEF: ${clip("BRIEF: ", unit.context.brief)}`);
1695
+ if (keys.has("earlier") && unit.context.earlier) lines.push(`EARLIER: ${clip("EARLIER: ", unit.context.earlier)}`);
1467
1696
  if (keys.has("section") && unit.context.section) lines.push(`SECTION: ${clip("SECTION: ", unit.context.section)}`);
1468
1697
  return {
1469
1698
  state: lines.join("\n"),
@@ -1890,11 +2119,54 @@ const smartQuotes = (prose) => prose.replaceAll(OPENING_DOUBLE, "$1“").replace
1890
2119
  const ellipsis = (prose) => prose.replaceAll("...", "…");
1891
2120
  const multiplicationSign = (prose) => prose.replaceAll(/(?<![\w.])([1-9]\d*)\s?x\s?(\d+)(?![\w.%])/gu, "$1×$2");
1892
2121
  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");
2122
+ const stripUtm = (prose) => prose.replaceAll(/\?utm_source=chatgpt\.com(?![\w&=-])/gu, "").replaceAll(/([?&])utm_source=chatgpt\.com&/gu, "$1").replaceAll("&utm_source=chatgpt.com", "");
2123
+ const AU_SPELLING = {
2124
+ analyze: "analyse",
2125
+ analyzed: "analysed",
2126
+ apologize: "apologise",
2127
+ behavior: "behaviour",
2128
+ behaviors: "behaviours",
2129
+ canceled: "cancelled",
2130
+ canceling: "cancelling",
2131
+ catalog: "catalogue",
2132
+ color: "colour",
2133
+ colored: "coloured",
2134
+ colors: "colours",
2135
+ customize: "customise",
2136
+ customized: "customised",
2137
+ favorite: "favourite",
2138
+ favorites: "favourites",
2139
+ gray: "grey",
2140
+ honor: "honour",
2141
+ labeled: "labelled",
2142
+ labeling: "labelling",
2143
+ modeling: "modelling",
2144
+ neighbor: "neighbour",
2145
+ optimize: "optimise",
2146
+ optimized: "optimised",
2147
+ organization: "organisation",
2148
+ organizations: "organisations",
2149
+ organize: "organise",
2150
+ organized: "organised",
2151
+ prioritize: "prioritise",
2152
+ realize: "realise",
2153
+ realized: "realised",
2154
+ recognize: "recognise",
2155
+ traveling: "travelling"
2156
+ };
2157
+ const AU_SPELLING_PATTERN = String.raw`(?<!\S*/\S*)\b(?:${Object.keys(AU_SPELLING).join("|")})\b(?!\S*/)`;
2158
+ const auSpelling = (prose) => prose.replaceAll(new RegExp(AU_SPELLING_PATTERN, "giu"), (word) => {
2159
+ const au = AU_SPELLING[word.toLowerCase()];
2160
+ if (!au || word !== word.toLowerCase() && word !== `${word[0]}${word.slice(1).toLowerCase()}`) return word;
2161
+ return word[0] === word[0].toUpperCase() ? `${au[0].toUpperCase()}${au.slice(1)}` : au;
2162
+ });
1893
2163
  const FIXES = {
2164
+ auSpelling,
1894
2165
  ellipsis,
1895
2166
  multiplicationSign,
1896
2167
  nbspValueUnit,
1897
- smartQuotes
2168
+ smartQuotes,
2169
+ stripUtm
1898
2170
  };
1899
2171
  //#endregion
1900
2172
  //#region src/rules/validate-sections.ts
@@ -2020,7 +2292,8 @@ const preconditions = (file, v) => {
2020
2292
  "notInCode",
2021
2293
  "docType",
2022
2294
  "role",
2023
- "smartQuotesAtBuild"
2295
+ "smartQuotesAtBuild",
2296
+ "minWords"
2024
2297
  ]);
2025
2298
  const out = {};
2026
2299
  if (raw.notInCode !== void 0) {
@@ -2033,6 +2306,10 @@ const preconditions = (file, v) => {
2033
2306
  if (raw.smartQuotesAtBuild !== false) fail(file, "preconditions.smartQuotesAtBuild may only be false");
2034
2307
  out.smartQuotesAtBuild = false;
2035
2308
  }
2309
+ if (raw.minWords !== void 0) {
2310
+ if (!Number.isInteger(raw.minWords) || raw.minWords < 1) fail(file, "preconditions.minWords must be a positive integer");
2311
+ out.minWords = raw.minWords;
2312
+ }
2036
2313
  return out;
2037
2314
  };
2038
2315
  const INCLUDE_BY_KIND = {
@@ -2128,6 +2405,7 @@ const missingEvidence = (unit, rule) => {
2128
2405
  };
2129
2406
  //#endregion
2130
2407
  //#region src/map/plan.ts
2408
+ const WORD$1 = /[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu;
2131
2409
  const inScope = (rule, unit) => unit.kind === "element" || matchesAny(unit.file, rule.scope.include) && !matchesAny(unit.file, rule.scope.exclude);
2132
2410
  const passesPreconditions = (rule, unit, config) => {
2133
2411
  const p = rule.preconditions;
@@ -2135,6 +2413,7 @@ const passesPreconditions = (rule, unit, config) => {
2135
2413
  if (p.notInCode && unit.inCode) return false;
2136
2414
  if (p.docType && !p.docType.includes(unit.context.docType)) return false;
2137
2415
  if (p.role && !p.role.includes(unit.context.role)) return false;
2416
+ if (p.minWords && (unit.text.match(WORD$1)?.length ?? 0) < p.minWords) return false;
2138
2417
  if (p.smartQuotesAtBuild === false && config.smartQuotesAtBuild && (unit.kind === "paragraph" || unit.kind === "heading") && /\.(md|mdx)$/.test(unit.file)) return false;
2139
2418
  return true;
2140
2419
  };
@@ -2550,7 +2829,7 @@ const codeRule = (spec, module) => {
2550
2829
  //#endregion
2551
2830
  //#region src/rules/code/classes.ts
2552
2831
  const MODULE$1 = "src/rules/code/classes.ts";
2553
- const ANIMATION = "skills/ui-animation/references";
2832
+ const ANIMATION$1 = "skills/ui-animation/references";
2554
2833
  const bases = (unit) => (unit.classes ?? []).map(baseClass);
2555
2834
  const animates = (list) => list.some((c) => c.startsWith("transition") && c !== "transition-none" || c.startsWith("duration-") || c.startsWith("animate-") && c !== "animate-none");
2556
2835
  const durationMs = (cls) => {
@@ -2615,7 +2894,7 @@ const CLASS_RULES = [
2615
2894
  id: "motion-ease-in-on-transition",
2616
2895
  source: {
2617
2896
  line: 27,
2618
- path: `${ANIMATION}/debugging-symptoms.md`
2897
+ path: `${ANIMATION$1}/debugging-symptoms.md`
2619
2898
  },
2620
2899
  title: "Transition eases in, so it starts slow while the user is watching"
2621
2900
  }),
@@ -2630,7 +2909,7 @@ const CLASS_RULES = [
2630
2909
  id: "motion-linear-easing-on-transition",
2631
2910
  source: {
2632
2911
  line: 37,
2633
- path: `${ANIMATION}/debugging-symptoms.md`
2912
+ path: `${ANIMATION$1}/debugging-symptoms.md`
2634
2913
  },
2635
2914
  title: "Linear easing on motion that is not a spinner or marquee"
2636
2915
  }),
@@ -2646,7 +2925,7 @@ const CLASS_RULES = [
2646
2925
  id: "motion-duration-over-300ms",
2647
2926
  source: {
2648
2927
  line: 29,
2649
- path: `${ANIMATION}/debugging-symptoms.md`
2928
+ path: `${ANIMATION$1}/debugging-symptoms.md`
2650
2929
  },
2651
2930
  title: "Transition longer than 300ms on product UI"
2652
2931
  }),
@@ -2657,7 +2936,7 @@ const CLASS_RULES = [
2657
2936
  id: "motion-transition-all",
2658
2937
  source: {
2659
2938
  line: 58,
2660
- path: `${ANIMATION}/review-format.md`
2939
+ path: `${ANIMATION$1}/review-format.md`
2661
2940
  },
2662
2941
  title: "transition-all animates every property, layout included"
2663
2942
  }),
@@ -2672,7 +2951,7 @@ const CLASS_RULES = [
2672
2951
  id: "motion-scale-from-zero",
2673
2952
  source: {
2674
2953
  line: 46,
2675
- path: `${ANIMATION}/debugging-symptoms.md`
2954
+ path: `${ANIMATION$1}/debugging-symptoms.md`
2676
2955
  },
2677
2956
  title: "Entrance from scale zero"
2678
2957
  }),
@@ -2688,7 +2967,7 @@ const CLASS_RULES = [
2688
2967
  id: "motion-animate-without-reduced-motion",
2689
2968
  source: {
2690
2969
  line: 57,
2691
- path: `${ANIMATION}/live-tuning.md`
2970
+ path: `${ANIMATION$1}/live-tuning.md`
2692
2971
  },
2693
2972
  status: "review-only",
2694
2973
  title: "Animation has no local reduced-motion guard"
@@ -2871,7 +3150,7 @@ const document = (unit) => {
2871
3150
  return unit.facts.document;
2872
3151
  };
2873
3152
  const nodes = (unit) => descendants(document(unit));
2874
- const make$1 = (name, title, source, include, check, hint, authoring = false) => codeRule({
3153
+ const make$1 = (name, title, source, include, check, hint, authoring = false, status = "review-only") => codeRule({
2875
3154
  categoryId: authoring ? "instruction-quality" : "reference-reading",
2876
3155
  check,
2877
3156
  hint,
@@ -2882,7 +3161,7 @@ const make$1 = (name, title, source, include, check, hint, authoring = false) =>
2882
3161
  path: `skills/${source}`,
2883
3162
  repo: "mblode/agent-skills"
2884
3163
  },
2885
- status: "review-only",
3164
+ status,
2886
3165
  title,
2887
3166
  unit: ["source"]
2888
3167
  }, "src/rules/code/documents.ts");
@@ -2893,7 +3172,7 @@ const INSTRUCTIONS = [
2893
3172
  "**/SKILL.md"
2894
3173
  ];
2895
3174
  const DOCS = ["**/*.md"];
2896
- const README_SOURCE = "readme-creator/references/quality-checklist.md";
3175
+ const README_SOURCE = "ghostwriter/references/readme.md";
2897
3176
  const DOCUMENT_RULES = [
2898
3177
  make$1("readme-scaffold", "README still contains scaffold instructions", README_SOURCE, README, (u) => {
2899
3178
  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 +3195,7 @@ const DOCUMENT_RULES = [
2916
3195
  }
2917
3196
  return none;
2918
3197
  }, "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) => {
3198
+ make$1("document-empty-heading", "A section has no content", "ghostwriter/references/docs.md", DOCS, (u) => {
2920
3199
  const children = document(u).children ?? [];
2921
3200
  for (const [i, n] of children.entries()) {
2922
3201
  if (n.type !== "heading") continue;
@@ -2925,7 +3204,7 @@ const DOCUMENT_RULES = [
2925
3204
  }
2926
3205
  return none;
2927
3206
  }, "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) => {
3207
+ make$1("document-heading-order", "Heading skips a structural level", "ghostwriter/references/docs.md", DOCS, (u) => {
2929
3208
  let previous = 0;
2930
3209
  for (const n of nodes(u).filter((candidate) => candidate.type === "heading")) {
2931
3210
  const depth = n.depth ?? 1;
@@ -2934,7 +3213,11 @@ const DOCUMENT_RULES = [
2934
3213
  }
2935
3214
  return none;
2936
3215
  }, "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) => {
3216
+ make$1("document-code-fence-language", "Code fence has no language tag", "ghostwriter/references/docs.md", DOCS, (u) => {
3217
+ const n = nodes(u).find((candidate) => candidate.type === "code" && !candidate.lang);
3218
+ return n ? at(n, "Fence without a language tag") : none;
3219
+ }, "Tag the fence: ```bash, ```ts, or ```text for output. The tag drives highlighting and tells the reader what to paste where."),
3220
+ make$1("document-broken-local-link", "Document links to a missing local file", "ghostwriter/references/docs.md", DOCS, (u) => {
2938
3221
  const repo = u.facts.repository;
2939
3222
  for (const n of nodes(u).filter((candidate) => [
2940
3223
  "link",
@@ -2955,7 +3238,7 @@ const DOCUMENT_RULES = [
2955
3238
  if (!repo.exists(resolved)) return at(n, `Missing local target: ${target}`);
2956
3239
  }
2957
3240
  return none;
2958
- }, "Correct the link or provide the referenced file. Generated site routes need a route-aware check."),
3241
+ }, "Correct the link or provide the referenced file. Generated site routes need a route-aware check.", false, "active"),
2959
3242
  make$1("instruction-missing-script", "Instruction invokes an undefined package script", "agents-md/references/quick-checklist.md", INSTRUCTIONS, (u) => {
2960
3243
  const manifest = u.facts.repository.manifest(u.file);
2961
3244
  if (!manifest) throw new UnresolvedError("No readable package manifest for this instruction file");
@@ -3024,6 +3307,7 @@ const IMAGE_RULES = [{
3024
3307
  id: "interaction-a11y-image-alt-text",
3025
3308
  required: ["alt"],
3026
3309
  source: "a11y-image-alt-text",
3310
+ status: "active",
3027
3311
  title: "Image is missing an alt attribute"
3028
3312
  }, {
3029
3313
  categoryId: "resilience",
@@ -3031,6 +3315,7 @@ const IMAGE_RULES = [{
3031
3315
  id: "craft-image-dimensions-and-priority",
3032
3316
  required: ["width", "height"],
3033
3317
  source: "perf-image-dimensions-and-priority",
3318
+ status: "review-only",
3034
3319
  title: "Image has no declared dimensions or style"
3035
3320
  }].map((spec) => codeRule({
3036
3321
  categoryId: spec.categoryId,
@@ -3056,11 +3341,166 @@ const IMAGE_RULES = [{
3056
3341
  repo: "mblode/agent-skills",
3057
3342
  ruleId: spec.source
3058
3343
  },
3059
- status: "review-only",
3344
+ status: spec.status,
3060
3345
  title: spec.title,
3061
3346
  unit: ["source"]
3062
3347
  }, "src/rules/code/images.ts"));
3063
3348
  //#endregion
3349
+ //#region src/rules/code/motion-source.ts
3350
+ const ANIMATION = "skills/ui-animation/SKILL.md";
3351
+ const stripComments = (text) => text.replaceAll(/\/\*[\s\S]*?\*\/|(?<![:"'\w])\/\/[^\n]*/gu, (m) => m.replaceAll(/[^\n]/gu, " "));
3352
+ const first = (unit, re, evidence) => {
3353
+ const m = re.exec(stripComments(unit.text));
3354
+ return m ? {
3355
+ evidence,
3356
+ fired: true,
3357
+ offset: m.index
3358
+ } : none;
3359
+ };
3360
+ const MOTION_SOURCE_RULES = [codeRule({
3361
+ categoryId: "motion-restraint",
3362
+ check: (unit) => first(unit, /(?:^|[{;\s])transition(?:-property)?\s*:\s*["'`]?all\b/mu, "transition: all"),
3363
+ hint: "List the properties: transition: transform 200ms, opacity 200ms. `all` animates layout and silently adopts properties added later.",
3364
+ id: "motion-css-transition-all",
3365
+ scope: { include: [
3366
+ "**/*.css",
3367
+ "**/*.scss",
3368
+ "**/*.tsx",
3369
+ "**/*.jsx"
3370
+ ] },
3371
+ source: {
3372
+ line: 67,
3373
+ path: ANIMATION,
3374
+ repo: "mblode/agent-skills"
3375
+ },
3376
+ title: "transition: all animates every property, layout included",
3377
+ unit: ["source"]
3378
+ }, "src/rules/code/motion-source.ts"), codeRule({
3379
+ categoryId: "motion-restraint",
3380
+ check: (unit) => first(unit, /\bfrom\s*["']framer-motion(?:\/[^"']*)?["']|\b(?:import|require)\s*\(\s*["']framer-motion["']/u, "imports framer-motion"),
3381
+ hint: "Install `motion` and import from \"motion/react\". A mixed codebase compiles but ships two copies of the library.",
3382
+ id: "motion-framer-motion-import",
3383
+ scope: { include: [
3384
+ "**/*.tsx",
3385
+ "**/*.jsx",
3386
+ "**/*.ts",
3387
+ "**/*.js",
3388
+ "**/*.mjs",
3389
+ "**/*.mts"
3390
+ ] },
3391
+ source: {
3392
+ line: 153,
3393
+ path: ANIMATION,
3394
+ repo: "mblode/agent-skills"
3395
+ },
3396
+ title: "Imports framer-motion instead of motion/react",
3397
+ unit: ["source"]
3398
+ }, "src/rules/code/motion-source.ts")];
3399
+ //#endregion
3400
+ //#region src/rules/code/prose.ts
3401
+ const PROSE_ROLES = [
3402
+ "body",
3403
+ "list-item",
3404
+ "cell",
3405
+ "caption",
3406
+ "literal-copy",
3407
+ "unknown"
3408
+ ];
3409
+ const echoesEarlier = (unit) => unit.context.earlier ? hit(`shares content words with ${unit.context.earlier.split("\n").length} earlier sentence(s)`) : none;
3410
+ const hasBrief = (unit) => unit.context.brief ? hit("checked against the brief") : none;
3411
+ const PROSE_RULES = [codeRule({
3412
+ categoryId: "machine-prose",
3413
+ check: echoesEarlier,
3414
+ hint: "Cut the restatement or replace it with the evidence it was standing in for.",
3415
+ id: "copywriting-idea-repetition",
3416
+ preconditions: {
3417
+ minWords: 5,
3418
+ notInCode: true,
3419
+ role: PROSE_ROLES
3420
+ },
3421
+ question: {
3422
+ context: ["docType", "earlier"],
3423
+ criteria: {
3424
+ false: {
3425
+ examples: [
3426
+ "EARLIER: Northwind keeps both versions side by side. TEXT: Open the conflict and pick the lines you want from each version.",
3427
+ "EARLIER: Relay unreachable. Check your connection and try again. TEXT: Disk full. Free up space on this laptop and Northwind will resume.",
3428
+ "EARLIER: Speed matters to users. TEXT: A 400ms dropdown loses a click one time in five in our logs."
3429
+ ],
3430
+ what: "TEXT adds a fact, example, step or consequence, or is a parallel entry for a different case."
3431
+ },
3432
+ true: {
3433
+ 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."],
3434
+ what: "TEXT says again what an EARLIER sentence already said, with nothing new."
3435
+ }
3436
+ },
3437
+ 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)."
3438
+ },
3439
+ source: {
3440
+ line: 1,
3441
+ path: "https://typesafe-parallel-judgment-lab.every-4573.chatgpt.site/downloads/typesafe-lab-source.zip (scripts/run-ai-checker.mjs)",
3442
+ repo: "every.to",
3443
+ ruleId: "idea_repetition"
3444
+ },
3445
+ thresholds: {
3446
+ act: .75,
3447
+ review: .4
3448
+ },
3449
+ title: "Repeats the same idea without adding new evidence",
3450
+ unit: ["paragraph", "jsx-text"]
3451
+ }, "src/rules/code/prose.ts"), codeRule({
3452
+ categoryId: "evidence-over-claims",
3453
+ check: hasBrief,
3454
+ hint: "Cut the setting, mode, policy or comparison, or add it to the brief if the product really does it.",
3455
+ id: "copywriting-invented-capability",
3456
+ preconditions: { notInCode: true },
3457
+ question: {
3458
+ context: [
3459
+ "docType",
3460
+ "element",
3461
+ "brief"
3462
+ ],
3463
+ criteria: {
3464
+ false: {
3465
+ examples: [
3466
+ "BRIEF: keeps both versions when edits conflict. TEXT: Keep both versions",
3467
+ "BRIEF: 14-day free trial, no card required. TEXT: Start your free trial",
3468
+ "BRIEF: a desktop sync app. TEXT: Email address",
3469
+ "BRIEF: 14-day free trial, no card required; $8 per seat per month. TEXT: 14 days. No card. $8 per seat after that.",
3470
+ "BRIEF: keeps both versions when edits conflict. TEXT: Nobody’s work gets thrown away, even when two people edit the same file offline."
3471
+ ],
3472
+ 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."
3473
+ },
3474
+ true: {
3475
+ examples: [
3476
+ "BRIEF: local network first, falls back to an encrypted relay. TEXT: Relay only",
3477
+ "BRIEF: 14-day free trial, no card required. TEXT: Cancel anytime during the trial",
3478
+ "BRIEF: keeps both versions side by side. TEXT: Most sync tools pick a winner and silently overwrite the other."
3479
+ ],
3480
+ what: "TEXT offers or asserts a capability, setting, mode, role, policy, integration or comparison the brief never states."
3481
+ }
3482
+ },
3483
+ 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."
3484
+ },
3485
+ source: {
3486
+ line: 10,
3487
+ path: "skills/ui-design/rules/slop-invented-behaviour.md",
3488
+ repo: "mblode/agent-skills",
3489
+ ruleId: "slop-invented-behaviour"
3490
+ },
3491
+ thresholds: {
3492
+ act: .75,
3493
+ review: .4
3494
+ },
3495
+ title: "States a capability the brief never gave",
3496
+ unit: [
3497
+ "paragraph",
3498
+ "heading",
3499
+ "jsx-text",
3500
+ "attr-string"
3501
+ ]
3502
+ }, "src/rules/code/prose.ts")];
3503
+ //#endregion
3064
3504
  //#region src/rules/code/skill-classes.ts
3065
3505
  const make = (id, title, categoryId, source, check, hint) => codeRule({
3066
3506
  categoryId,
@@ -3105,6 +3545,67 @@ const SKILL_CLASS_RULES = [
3105
3545
  }, "Prefer transform/opacity for frequent motion; measure deliberate container resizing before changing it.")
3106
3546
  ];
3107
3547
  //#endregion
3548
+ //#region src/rules/code/slop-source.ts
3549
+ 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;
3550
+ const FACT = /\b\d+[- ]days?\b|\bno (?:credit )?card\b|\bcancel any ?time\b|\$\d+(?:\.\d\d)?(?= ?(?:\/|per|a) ?(?:seat|user|member|month))/giu;
3551
+ const MAX_EYEBROWS = 2;
3552
+ const MAX_REPEATS = 3;
3553
+ const eyebrows = (unit) => {
3554
+ const found = [...unit.text.matchAll(EYEBROW)];
3555
+ if (found.length <= MAX_EYEBROWS) return none;
3556
+ return {
3557
+ ...hit(`${found.length} headings carry an eyebrow label`),
3558
+ offset: found[MAX_EYEBROWS]?.index
3559
+ };
3560
+ };
3561
+ const factPadding = (unit) => {
3562
+ const counts = /* @__PURE__ */ new Map();
3563
+ for (const m of unit.text.matchAll(FACT)) {
3564
+ const key = m[0].toLowerCase().replaceAll(/[- ]/gu, " ");
3565
+ const seen = counts.get(key) ?? {
3566
+ first: m.index,
3567
+ n: 0
3568
+ };
3569
+ counts.set(key, {
3570
+ ...seen,
3571
+ n: seen.n + 1
3572
+ });
3573
+ }
3574
+ const [top] = [...counts].toSorted((a, b) => b[1].n - a[1].n);
3575
+ if (!top || top[1].n <= MAX_REPEATS) return none;
3576
+ return {
3577
+ ...hit(`"${top[0]}" appears ${top[1].n} times`),
3578
+ offset: top[1].first
3579
+ };
3580
+ };
3581
+ const source = (ruleId) => ({
3582
+ line: 10,
3583
+ path: `skills/ui-design/rules/${ruleId}.md`,
3584
+ repo: "mblode/agent-skills",
3585
+ ruleId
3586
+ });
3587
+ const SLOP_SOURCE_RULES = [codeRule({
3588
+ categoryId: "visual-hierarchy",
3589
+ check: eyebrows,
3590
+ hint: "Keep an eyebrow on at most two sections, where it names something the heading cannot. Delete the rest.",
3591
+ id: "craft-eyebrow-overuse",
3592
+ scope: { include: ["**/*.tsx", "**/*.jsx"] },
3593
+ source: source("slop-eyebrow-overuse"),
3594
+ status: "review-only",
3595
+ title: "An eyebrow over nearly every heading",
3596
+ unit: ["source"]
3597
+ }, "src/rules/code/slop-source.ts"), codeRule({
3598
+ categoryId: "evidence-over-claims",
3599
+ check: factPadding,
3600
+ 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.",
3601
+ id: "copywriting-fact-padding",
3602
+ scope: { include: ["**/*.tsx", "**/*.jsx"] },
3603
+ source: source("slop-fact-padding"),
3604
+ status: "review-only",
3605
+ title: "The same few facts restated to fill the page",
3606
+ unit: ["source"]
3607
+ }, "src/rules/code/slop-source.ts")];
3608
+ //#endregion
3108
3609
  //#region src/rules/code/typography.ts
3109
3610
  const MODULE = "src/rules/code/typography.ts";
3110
3611
  const AUDIT = "skills/typography-audit/rules";
@@ -3320,7 +3821,7 @@ const CODE_RULES = [
3320
3821
  rule({
3321
3822
  categoryId: "machine-prose",
3322
3823
  check: (unit) => {
3323
- const long = unit.text.split(/(?<=[.!?])\s+/u).map((s) => [s, countWords(s)]).filter(([, n]) => n > 25);
3824
+ 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
3825
  if (long.length === 0) return none;
3325
3826
  const [sentence, n] = long[0];
3326
3827
  return hit(`${n} words: "${sentence.slice(0, 80)}${sentence.length > 80 ? "..." : ""}"`);
@@ -3342,18 +3843,21 @@ const CODE_RULES = [
3342
3843
  notInCode: true
3343
3844
  },
3344
3845
  source: {
3345
- line: 7,
3346
- path: "skills/docs-writing/rules/clarity-one-idea-per-sentence.md",
3846
+ line: 17,
3847
+ path: "skills/ghostwriter/references/docs.md",
3347
3848
  repo: "mblode/agent-skills"
3348
3849
  },
3349
- title: "Sentence over 25 words",
3850
+ title: "Sentence over 25 words joins several clauses",
3350
3851
  unit: ["paragraph", "jsx-text"]
3351
3852
  })
3352
3853
  ],
3353
3854
  ...CLASS_RULES,
3354
3855
  ...DOCUMENT_RULES,
3355
3856
  ...IMAGE_RULES,
3356
- ...SKILL_CLASS_RULES
3857
+ ...SKILL_CLASS_RULES,
3858
+ ...MOTION_SOURCE_RULES,
3859
+ ...SLOP_SOURCE_RULES,
3860
+ ...PROSE_RULES
3357
3861
  ];
3358
3862
  //#endregion
3359
3863
  //#region src/rules/load.ts
@@ -3416,10 +3920,8 @@ const readTuning = (rulesDir) => {
3416
3920
  }
3417
3921
  return out;
3418
3922
  };
3419
- const loadRules = (rulesDir, options = {}) => {
3420
- const tuning = readTuning(rulesDir);
3923
+ const readYamlRules = (rulesDir) => {
3421
3924
  const rules = [];
3422
- const seen = /* @__PURE__ */ new Set();
3423
3925
  const domains = fs.readdirSync(rulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name !== "schema").map((d) => d.name).toSorted();
3424
3926
  for (const domain of domains) {
3425
3927
  const dir = path.join(rulesDir, domain);
@@ -3427,24 +3929,49 @@ const loadRules = (rulesDir, options = {}) => {
3427
3929
  if (!name.endsWith(".yaml")) continue;
3428
3930
  const file = path.join(dir, name);
3429
3931
  const expectedId = name.slice(0, -5);
3430
- let raw;
3431
3932
  try {
3432
- raw = parse(fs.readFileSync(file, "utf-8"));
3933
+ let raw;
3934
+ try {
3935
+ raw = parse(fs.readFileSync(file, "utf-8"));
3936
+ } catch (error) {
3937
+ throw new Error(`Invalid rule ${file}: YAML did not parse (${error.message})`, { cause: error });
3938
+ }
3939
+ const rule = validateRule(raw, file, expectedId);
3940
+ if (rule.domain !== domain) throw new Error(`Invalid rule ${file}: domain ${rule.domain} does not match folder ${domain}`);
3941
+ rules.push(rule);
3433
3942
  } catch (error) {
3434
- throw new Error(`Invalid rule ${file}: YAML did not parse (${error.message})`, { cause: error });
3943
+ const invalid = new InputError("INVALID_RULE", error.message, { file });
3944
+ invalid.cause = error;
3945
+ throw invalid;
3435
3946
  }
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
3947
  }
3440
3948
  }
3949
+ return rules;
3950
+ };
3951
+ const loadRules = (rulesDir, options = {}) => {
3952
+ const tuning = readTuning(rulesDir);
3953
+ const rules = [];
3954
+ const seen = /* @__PURE__ */ new Map();
3955
+ rules.push(...readYamlRules(rulesDir));
3956
+ for (const dir of options.extraDirs ?? []) {
3957
+ const extra = readYamlRules(dir);
3958
+ 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")}/`, {
3959
+ field: "rules",
3960
+ path: dir
3961
+ });
3962
+ rules.push(...extra);
3963
+ }
3441
3964
  for (const code of CODE_RULES) rules.push({
3442
3965
  ...code,
3443
3966
  thresholds: { ...code.thresholds }
3444
3967
  });
3445
3968
  for (const rule of rules) {
3446
- if (seen.has(rule.id)) throw new Error(`Duplicate rule id ${rule.id}`);
3447
- seen.add(rule.id);
3969
+ const first = seen.get(rule.id);
3970
+ if (first !== void 0) throw new InputError("DUPLICATE_RULE", `Rule id ${rule.id} is defined in ${first} and ${rule.file}; rename one.`, {
3971
+ files: [first, rule.file],
3972
+ id: rule.id
3973
+ });
3974
+ seen.set(rule.id, rule.file);
3448
3975
  const overlay = tuning[rule.id];
3449
3976
  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
3977
  if (overlay) {
@@ -3630,7 +4157,11 @@ const runEval = async (options, ctx = {}) => {
3630
4157
  knownRuleIds
3631
4158
  });
3632
4159
  if (options.split && options.split !== "all") items = items.filter((i) => i.split === options.split);
3633
- if (!items.length) throw new Error("No corpus items in the selected split.");
4160
+ if (options.sources) {
4161
+ const sources = new Set(options.sources);
4162
+ items = items.filter((i) => sources.has(i.labelSource));
4163
+ }
4164
+ if (!items.length) throw new Error("No corpus items in the selected split and label sources.");
3634
4165
  const model = options.model ?? "jev-latest";
3635
4166
  const resultsDir = options.resultsDir ?? defaultResultsDir();
3636
4167
  const cache = new AnswerCache(path.join(resultsDir, "cache"), !options.noCache);
@@ -3647,6 +4178,7 @@ const runEval = async (options, ctx = {}) => {
3647
4178
  model,
3648
4179
  recorder
3649
4180
  });
4181
+ if (unknowns.some((u) => u.reason === "auth")) throw new ProviderError("auth", 401);
3650
4182
  const labelSources = {};
3651
4183
  for (const item of items) labelSources[item.labelSource] = (labelSources[item.labelSource] ?? 0) + 1;
3652
4184
  const evals = evaluateRules(items, rules, probabilities, unknowns, skipped);
@@ -3666,41 +4198,56 @@ const runEval = async (options, ctx = {}) => {
3666
4198
  exitCode,
3667
4199
  labelSources,
3668
4200
  referenceCoverage: corpusCoverage(items, rules),
3669
- 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),
4201
+ 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) : ""),
3670
4202
  rules: evals,
3671
4203
  usage
3672
4204
  };
3673
4205
  };
3674
4206
  //#endregion
3675
- //#region src/scan/storage.ts
3676
- const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
3677
- const object$1 = (value) => !!value && typeof value === "object" && !Array.isArray(value);
3678
- const readJson = (file) => {
3679
- try {
3680
- return JSON.parse(fs.readFileSync(file, "utf-8"));
3681
- } catch {
3682
- throw new InputError("INVALID_SCAN_INPUT", `Cannot read JSON input: ${file}`);
4207
+ //#region src/reduce/suppress.ts
4208
+ const PATTERN = /(?:taste-lint-ignore|ui-audit-ignore)\s*:\s*([a-z0-9-]+)/giu;
4209
+ const suppressionsFor = (sourceLines, line) => {
4210
+ const ids = /* @__PURE__ */ new Set();
4211
+ for (const n of [line - 2, line - 1]) {
4212
+ const text = sourceLines[n];
4213
+ if (!text) continue;
4214
+ for (const m of text.matchAll(PATTERN)) ids.add(m[1].toLowerCase());
3683
4215
  }
4216
+ return ids;
3684
4217
  };
3685
- const writeJson = (file, value) => {
3686
- fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
3687
- const temp = `${file}.${randomUUID()}.tmp`;
3688
- try {
3689
- fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
3690
- fs.renameSync(temp, file);
3691
- } finally {
3692
- fs.rmSync(temp, { force: true });
3693
- }
4218
+ const applySuppressions = (findings, rules, sources) => {
4219
+ const byId = new Map(rules.map((r) => [r.id, r]));
4220
+ return findings.map((f) => {
4221
+ const lines = sources.get(f.file);
4222
+ if (!lines) return f;
4223
+ const ids = suppressionsFor(lines, f.line);
4224
+ const portedId = byId.get(f.ruleId)?.source.ruleId?.toLowerCase();
4225
+ if (ids.has(f.ruleId) || portedId && ids.has(portedId) || ids.has("all")) return {
4226
+ ...f,
4227
+ suppressed: true
4228
+ };
4229
+ return f;
4230
+ });
3694
4231
  };
3695
4232
  //#endregion
3696
4233
  //#region src/scan/samples.ts
3697
- const makeSamples = (units, rules, answers, negatives, perStratum = 3, sourceRepo = "scan") => {
4234
+ const STRATA = [
4235
+ "ignored",
4236
+ "disagree",
4237
+ "review",
4238
+ "high",
4239
+ "negative"
4240
+ ];
4241
+ const makeSamples = (units, rules, answers, negatives, perStratum = 3, sourceRepo = "scan", sources = /* @__PURE__ */ new Map()) => {
3698
4242
  const buckets = /* @__PURE__ */ new Map();
3699
4243
  const seen = /* @__PURE__ */ new Set();
3700
4244
  const semanticRules = rules.filter((rule) => rule.question);
3701
4245
  for (const unit of units) for (const rule of semanticRules) {
3702
- const probability = answers.get(unit.id)?.[rule.id] ?? (negatives.get(unit.id)?.has(rule.id) ? 0 : void 0);
4246
+ const answered = answers.get(unit.id)?.[rule.id];
4247
+ const probability = answered ?? (negatives.get(unit.id)?.has(rule.id) ? 0 : void 0);
3703
4248
  if (probability === void 0) continue;
4249
+ const ignores = suppressionsFor(sources.get(unit.file) ?? [], unit.line);
4250
+ const stratum = ignores.has(rule.id) || ignores.has("all") || ignores.has(rule.source.ruleId?.toLowerCase() ?? rule.id) ? "ignored" : answered !== void 0 && rule.mechanical && answered < rule.thresholds.review ? "disagree" : probability < rule.thresholds.review ? "negative" : probability < rule.thresholds.act ? "review" : "high";
3704
4251
  const { docType, role, headingAbove } = unit.context;
3705
4252
  const context = {
3706
4253
  docType,
@@ -3747,28 +4294,29 @@ const makeSamples = (units, rules, answers, negatives, perStratum = 3, sourceRep
3747
4294
  rubric,
3748
4295
  ruleId: rule.id
3749
4296
  };
3750
- const bucket = `${rule.id}:${docType}:${probability < rule.thresholds.review ? "negative" : probability < rule.thresholds.act ? "review" : "high"}`;
4297
+ const bucket = `${STRATA.indexOf(stratum)}:${rule.id}:${docType}`;
3751
4298
  const list = buckets.get(bucket) ?? [];
3752
4299
  list.push(sample);
3753
4300
  buckets.set(bucket, list);
3754
4301
  }
3755
- return [...buckets.values()].flatMap((list) => list.toSorted((a, b) => a.id.localeCompare(b.id)).slice(0, perStratum)).toSorted((a, b) => a.id.localeCompare(b.id));
4302
+ return [...buckets.entries()].toSorted(([a], [b]) => a.localeCompare(b)).flatMap(([, list]) => list.toSorted((a, b) => a.id.localeCompare(b.id)).slice(0, perStratum));
3756
4303
  };
3757
4304
  const labelsToCorpus = (file, destination, rulesDir) => {
3758
4305
  const raw = readJson(file);
3759
- if (!object$1(raw) || raw.version !== 1 && raw.version !== 2 || !Array.isArray(raw.samples)) throw new InputError("INVALID_LABELS", "Expected a version 1 or 2 blind sample file.");
4306
+ if (!object$2(raw) || raw.version !== 1 && raw.version !== 2 || !Array.isArray(raw.samples)) throw new InputError("INVALID_LABELS", "Expected a version 1 or 2 blind sample file.");
3760
4307
  if (raw.completed !== void 0 && raw.completed !== raw.samples.length) throw new InputError("INVALID_LABELS", "Labeling is incomplete. Review every sample before importing.");
3761
4308
  const annotation = raw.annotation;
3762
- const ai = object$1(annotation) && annotation.source === "ai";
4309
+ const ai = object$2(annotation) && annotation.source === "ai";
3763
4310
  if (annotation !== void 0 && (!ai || typeof annotation.model !== "string" || !annotation.model.trim() || typeof annotation.promptHash !== "string" || !/^[a-f0-9]{64}$/.test(annotation.promptHash))) throw new InputError("INVALID_LABELS", "AI annotation requires a model and prompt hash.");
3764
4311
  const ids = /* @__PURE__ */ new Set();
3765
4312
  const items = [];
3766
4313
  for (const row of raw.samples) {
3767
- if (!object$1(row) || typeof row.id !== "string" || ids.has(row.id) || typeof row.ruleId !== "string" || raw.version === 2 && (!object$1(row.rubric) || row.rubric.type !== "noul" || typeof row.criterion !== "string" || row.rubric.instructions !== row.criterion) || !object$1(row.item) || row.item.id !== row.id || row.label !== null && typeof row.label !== "boolean") throw new InputError("INVALID_LABELS", "Samples require unique IDs and boolean or null labels.");
4314
+ if (!object$2(row) || typeof row.id !== "string" || ids.has(row.id) || typeof row.ruleId !== "string" || raw.version === 2 && (!object$2(row.rubric) || row.rubric.type !== "noul" || typeof row.criterion !== "string" || row.rubric.instructions !== row.criterion) || !object$2(row.item) || row.item.id !== row.id || row.label !== null && typeof row.label !== "boolean") throw new InputError("INVALID_LABELS", "Samples require unique IDs and boolean or null labels.");
3768
4315
  ids.add(row.id);
3769
4316
  if (row.label === null) continue;
3770
4317
  items.push({
3771
4318
  ...row.item,
4319
+ ...typeof row.note === "string" && row.note.trim() ? { note: row.note.trim() } : {},
3772
4320
  labelSource: ai ? "ai" : "hand",
3773
4321
  ...ai ? {
3774
4322
  labelModel: annotation.model,
@@ -3793,15 +4341,27 @@ const labelsToCorpus = (file, destination, rulesDir) => {
3793
4341
  };
3794
4342
  //#endregion
3795
4343
  //#region src/commands/eval.ts
4344
+ const LABEL_SOURCES = [
4345
+ "hand",
4346
+ "ai",
4347
+ "manifest",
4348
+ "manifest-weak",
4349
+ "sweep"
4350
+ ];
4351
+ const parseSources = (value) => value?.split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
4352
+ if (!LABEL_SOURCES.includes(s)) throw new Error(`--source must be one or more of ${LABEL_SOURCES.join(", ")}`);
4353
+ return s;
4354
+ });
3796
4355
  function registerEvalCommand(program) {
3797
4356
  const command = program.command("eval").enablePositionalOptions().description("Run every rule over its labelled corpus and report precision, recall and calibration").option("--corpus <path>", "Corpus directory (default: packaged data/corpus)").option("--rules <path>", "Rules directory").addOption(new Option("--output <format>", "Output format").choices(["text", "json"]).default("text")).option("--only <ids>", "Comma-separated rule ids").addOption(new Option("--split <name>", "Evaluation split").choices([
3798
4357
  "dev",
3799
4358
  "holdout",
3800
4359
  "all"
3801
- ]).default("all")).option("--include-weak", "Include manifest-weak labels").option("--check", "Fail on reference disagreement; incomplete evaluation exits 2").option("--dry-run", "Plan without calling Jev").option("--no-cache", "Ignore cached answers").option("--results-dir <path>", "Where logs and cache live").option("--model <id>", "Jev model id", DEFAULT_MODEL).action(async (options) => {
4360
+ ]).default("all")).option("--include-weak", "Include manifest-weak labels").option("--source <sources>", "Only these label sources, e.g. hand to measure agreement with you").option("--disagreements", "List items where the judge and the label disagree, with text and notes").option("--check", "Fail on reference disagreement; incomplete evaluation exits 2").option("--dry-run", "Plan without calling Jev").option("--no-cache", "Ignore cached answers").option("--results-dir <path>", "Where logs and cache live").option("--model <id>", "Jev model id", DEFAULT_MODEL).action(async (options) => {
3802
4361
  const result = await runEval({
3803
4362
  check: options.check,
3804
4363
  corpusDir: options.corpus,
4364
+ disagreements: options.disagreements,
3805
4365
  dryRun: options.dryRun,
3806
4366
  includeWeak: options.includeWeak,
3807
4367
  model: options.model,
@@ -3809,6 +4369,7 @@ function registerEvalCommand(program) {
3809
4369
  only: options.only?.split(",").map((s) => s.trim()).filter(Boolean),
3810
4370
  resultsDir: options.resultsDir,
3811
4371
  rulesDir: options.rules,
4372
+ sources: parseSources(options.source),
3812
4373
  split: options.split
3813
4374
  });
3814
4375
  process.stdout.write(options.output === "json" ? `${JSON.stringify(result, null, 2)}\n` : result.report);
@@ -3827,8 +4388,77 @@ function registerEvalCommand(program) {
3827
4388
  command.command("labels <samples>").description("Convert a labelled sample file from lint --samples into a corpus JSONL").requiredOption("--out <file>", "New corpus JSONL file").option("--rules <path>", "Rules directory used to validate labels").action((file, options) => {
3828
4389
  process.stdout.write(`Exported ${labelsToCorpus(file, options.out, options.rules)} labels.\n`);
3829
4390
  });
4391
+ command.command("label <samples>").description("Label a lint --samples file yourself, one sample at a time; saves after every answer").option("--minutes <n>", "Stop after this many minutes", "120").action(async (file, options) => {
4392
+ const minutes = Number(options.minutes);
4393
+ if (!(Number.isFinite(minutes) && minutes > 0)) throw new Error("--minutes must be a positive number");
4394
+ const result = await labelSession(file, {
4395
+ input: process.stdin,
4396
+ minutes,
4397
+ output: process.stdout
4398
+ });
4399
+ process.stdout.write(`\n${result.labelled} labelled, ${result.unsure} unsure, ${result.remaining} left${result.stopped === "timebox" ? " (timebox reached)" : ""}.\nNext: taste-lint eval gaps ${file}, then taste-lint eval labels ${file} --out data/corpus/<name>.jsonl\n`);
4400
+ });
4401
+ command.command("gaps <samples...>").description("Group hand labels by rule: unsure answers and notes show where the rubric needs sharpening").action((files) => {
4402
+ process.stdout.write(renderGaps(labelGaps(files)));
4403
+ });
4404
+ command.command("agreement <hand> <other>").description("Compare another labeller's sample file (usually AI) with your hand labels, per rule").option("--floor <p>", "Agreement lower bound needed to trust the other labeller", "0.8").action((hand, other, options) => {
4405
+ const floor = Number(options.floor);
4406
+ if (!(Number.isFinite(floor) && floor > 0 && floor < 1)) throw new Error("--floor must be a number between 0 and 1 (exclusive)");
4407
+ const rows = labelAgreement(hand, other, floor);
4408
+ process.stdout.write(renderAgreement(rows, floor));
4409
+ process.exitCode = rows.every((r) => r.trusted) ? 0 : 1;
4410
+ });
3830
4411
  }
3831
4412
  //#endregion
4413
+ //#region src/extract/earlier.ts
4414
+ const NOT_PROSE = /* @__PURE__ */ new Set([
4415
+ "heading",
4416
+ "button",
4417
+ "link",
4418
+ "label",
4419
+ "placeholder",
4420
+ "aria"
4421
+ ]);
4422
+ 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(" "));
4423
+ const SENTENCE = /[^.!?\n]+[.!?]*/gu;
4424
+ const WORD = /[\p{L}\p{N}$][\p{L}\p{N}$'’.-]*[\p{L}\p{N}]|[\p{L}\p{N}]/gu;
4425
+ const MIN_SHARED = 3;
4426
+ const MAX_EVIDENCE = 8;
4427
+ const tokens = (sentence) => {
4428
+ const out = /* @__PURE__ */ new Set();
4429
+ for (const [raw] of sentence.toLowerCase().matchAll(WORD)) {
4430
+ const word = raw.replaceAll("’", "'");
4431
+ if (/\d/u.test(word)) out.add(word);
4432
+ else if (word.length >= 4 && !STOP.has(word)) out.add(word.length > 4 ? word.replace(/(?:'s|s)$/u, "") : word);
4433
+ }
4434
+ return out;
4435
+ };
4436
+ const isProse = (unit) => (unit.kind === "paragraph" || unit.kind === "jsx-text") && !unit.inCode && !NOT_PROSE.has(unit.context.role);
4437
+ const attachEarlier = (units) => {
4438
+ const prose = units.filter(isProse).toSorted((a, b) => a.sourceStart - b.sourceStart);
4439
+ const split = prose.map((unit) => [...unit.text.matchAll(SENTENCE)].map(([s]) => s.trim()).filter(Boolean).map((text) => ({
4440
+ text,
4441
+ words: tokens(text)
4442
+ })));
4443
+ const spread = /* @__PURE__ */ new Map();
4444
+ for (const sentences of split) for (const word of new Set(sentences.flatMap((s) => [...s.words]))) spread.set(word, (spread.get(word) ?? 0) + 1);
4445
+ const subject = (word) => prose.length >= 6 && (spread.get(word) ?? 0) * 3 > prose.length;
4446
+ const seen = [];
4447
+ const index = /* @__PURE__ */ new Map();
4448
+ for (const [u, unit] of prose.entries()) {
4449
+ const matched = /* @__PURE__ */ new Map();
4450
+ for (const sentence of split[u] ?? []) {
4451
+ const words = [...sentence.words].filter((w) => !subject(w));
4452
+ const shared = /* @__PURE__ */ new Map();
4453
+ for (const word of words) for (const id of index.get(word) ?? []) shared.set(id, (shared.get(id) ?? 0) + 1);
4454
+ 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));
4455
+ const id = seen.push(sentence.text) - 1;
4456
+ for (const word of words) index.set(word, [...index.get(word) ?? [], id]);
4457
+ }
4458
+ 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");
4459
+ }
4460
+ };
4461
+ //#endregion
3832
4462
  //#region src/lib/frontmatter.ts
3833
4463
  const stripBom = (text) => text.codePointAt(0) === 65279 ? ` ${text.slice(1)}` : text;
3834
4464
  const scalar = (value) => {
@@ -4617,6 +5247,7 @@ const extractSource = (config, file, source, repository) => {
4617
5247
  config,
4618
5248
  docType
4619
5249
  }) : [];
5250
+ attachEarlier(units);
4620
5251
  const whole = makeUnit(file, new LineIndex(source), {
4621
5252
  context: {
4622
5253
  docType,
@@ -5199,32 +5830,6 @@ const buildScorecard = (units, rules, findings, unknowns, silent) => {
5199
5830
  };
5200
5831
  };
5201
5832
  //#endregion
5202
- //#region src/reduce/suppress.ts
5203
- const PATTERN = /(?:taste-lint-ignore|ui-audit-ignore)\s*:\s*([a-z0-9-]+)/giu;
5204
- const suppressionsFor = (sourceLines, line) => {
5205
- const ids = /* @__PURE__ */ new Set();
5206
- for (const n of [line - 2, line - 1]) {
5207
- const text = sourceLines[n];
5208
- if (!text) continue;
5209
- for (const m of text.matchAll(PATTERN)) ids.add(m[1].toLowerCase());
5210
- }
5211
- return ids;
5212
- };
5213
- const applySuppressions = (findings, rules, sources) => {
5214
- const byId = new Map(rules.map((r) => [r.id, r]));
5215
- return findings.map((f) => {
5216
- const lines = sources.get(f.file);
5217
- if (!lines) return f;
5218
- const ids = suppressionsFor(lines, f.line);
5219
- const portedId = byId.get(f.ruleId)?.source.ruleId?.toLowerCase();
5220
- if (ids.has(f.ruleId) || portedId && ids.has(portedId) || ids.has("all")) return {
5221
- ...f,
5222
- suppressed: true
5223
- };
5224
- return f;
5225
- });
5226
- };
5227
- //#endregion
5228
5833
  //#region src/scan/git.ts
5229
5834
  const git = (root, args) => {
5230
5835
  try {
@@ -5375,7 +5980,10 @@ const runLint = async (options = {}, ctx = {}) => {
5375
5980
  const config = loadConfig(options.root ?? process.cwd());
5376
5981
  const rulesDir = resolveRulesDir(options.rulesDir);
5377
5982
  const profile = options.profile ? profileFor(options.profile) : void 0;
5378
- const loaded = loadRules(rulesDir, { only: options.only });
5983
+ const loaded = loadRules(rulesDir, {
5984
+ extraDirs: config.rules,
5985
+ only: options.only
5986
+ });
5379
5987
  const rules = profile && !options.only?.length ? profileRules(profile, loaded) : loaded;
5380
5988
  if (rules.length === 0) throw new InputError("EMPTY_RULE_SELECTION", "No rules match the selected profile and ids.");
5381
5989
  const model = options.model ?? "jev-latest";
@@ -5398,6 +6006,10 @@ const runLint = async (options = {}, ctx = {}) => {
5398
6006
  sources.set(file, source.split("\n"));
5399
6007
  }
5400
6008
  if (options.extraUnits) units.push(...options.extraUnits);
6009
+ if (options.brief) {
6010
+ const brief = fs.readFileSync(path.resolve(options.brief), "utf-8").trim();
6011
+ for (const unit of units) unit.context.brief = brief;
6012
+ }
5401
6013
  const scope = {
5402
6014
  byDocType: {},
5403
6015
  diagnostics: scan.messages,
@@ -5460,7 +6072,7 @@ const runLint = async (options = {}, ctx = {}) => {
5460
6072
  onProgress: ctx.onProgress,
5461
6073
  recorder
5462
6074
  });
5463
- ctx.onEvidence?.(units, judgement.answers, judgement.negatives, rules);
6075
+ ctx.onEvidence?.(units, judgement.answers, judgement.negatives, rules, sources);
5464
6076
  const status = options.dryRun ? "dry-run" : judgement.usage.errors > 0 || parseFailures.size > 0 || units.length === 0 ? "incomplete" : "complete";
5465
6077
  const jf = jevFindings(judgement.jobs, judgement.answers);
5466
6078
  let findings = [...judgement.mechanical, ...jf.findings];
@@ -5714,7 +6326,7 @@ const renderTty = (result, options = {}) => {
5714
6326
  //#region src/commands/lint.ts
5715
6327
  const quote = (value) => `'${value.replaceAll("'", "'\"'\"'")}'`;
5716
6328
  function registerLintCommand(program) {
5717
- program.command("lint").description("Lint files (or a rendered page) against the taste rules").argument("[paths...]", "Files or directories to lint, relative to --root").option("--root <path>", "Project root for globs and config", process.cwd()).option("--only <ids>", "Comma-separated rule ids").option("--exclude <globs>", "Comma-separated globs to skip (added to taste-lint.config.json exclude)").option("--dry-run", "Plan and estimate cost without calling Jev").option("--print-requests", "With --dry-run and --output tty, print request JSONL to stderr").addOption(new Option("--profile <name>", "Scope: product (tsx, jsx, css), writing (md, mdx), instructions (AGENTS.md, skills, plans) or all").choices(PROFILE_NAMES)).option("--since <ref>", "Report only findings on lines changed since this Git revision").option("--samples <file>", "Write blind labelling samples for the Jev rules that ran, without their predicted scores").option("--no-cache", "Ignore cached answers (new answers are still recorded)").addOption(new Option("--fail-on <severity>", "Lowest severity that fails the run").choices(SEVERITIES).default("minor")).option("--fix", "Apply deterministic fixes for act-band findings").addOption(new Option("--output <format>", "Output format").choices([
6329
+ program.command("lint").description("Lint files (or a rendered page) against the taste rules").argument("[paths...]", "Files or directories to lint, relative to --root").option("--root <path>", "Project root for globs and config", process.cwd()).option("--only <ids>", "Comma-separated rule ids").option("--exclude <globs>", "Comma-separated globs to skip (added to taste-lint.config.json exclude)").option("--dry-run", "Plan and estimate cost without calling Jev").option("--print-requests", "With --dry-run and --output tty, print request JSONL to stderr").addOption(new Option("--profile <name>", "Scope: product (tsx, jsx, css), writing (md, mdx), instructions (AGENTS.md, skills, plans) or all").choices(PROFILE_NAMES)).option("--since <ref>", "Report only findings on lines changed since this Git revision").option("--brief <file>", "What the product is and does; copy that states anything else is flagged").option("--samples <file>", "Write blind labelling samples for the Jev rules that ran, without their predicted scores").option("--no-cache", "Ignore cached answers (new answers are still recorded)").addOption(new Option("--fail-on <severity>", "Lowest severity that fails the run").choices(SEVERITIES).default("minor")).option("--fix", "Apply deterministic fixes for act-band findings").addOption(new Option("--output <format>", "Output format").choices([
5718
6330
  "tty",
5719
6331
  "json",
5720
6332
  "sarif"
@@ -5732,6 +6344,7 @@ function registerLintCommand(program) {
5732
6344
  let lastProgress = 0;
5733
6345
  let samples = [];
5734
6346
  const result = await runLint({
6347
+ brief: options.brief,
5735
6348
  dryRun: options.dryRun,
5736
6349
  exclude: options.exclude?.split(",").map((s) => s.trim()).filter(Boolean),
5737
6350
  extraUnits,
@@ -5747,8 +6360,8 @@ function registerLintCommand(program) {
5747
6360
  since: options.since,
5748
6361
  targets
5749
6362
  }, {
5750
- onEvidence: options.samples ? (units, answers, negatives, rules) => {
5751
- samples = makeSamples(units, rules, answers, negatives, 3, path.basename(config.root));
6363
+ onEvidence: options.samples ? (units, answers, negatives, rules, sources) => {
6364
+ samples = makeSamples(units, rules, answers, negatives, 3, path.basename(config.root), sources);
5752
6365
  } : void 0,
5753
6366
  onProgress: (progress) => {
5754
6367
  if (!(options.progress || process.stderr.isTTY)) return;
@@ -5780,10 +6393,11 @@ function registerLintCommand(program) {
5780
6393
  if (options.url) retryArgs.push("--url", options.url, "--selector", options.selector);
5781
6394
  if (options.profile) retryArgs.push("--profile", options.profile);
5782
6395
  if (options.since) retryArgs.push("--since", options.since);
6396
+ if (options.brief) retryArgs.push("--brief", options.brief);
5783
6397
  retryArgs.push("--", ...targets);
5784
6398
  const rerun = result.status === "incomplete" ? retryArgs.map(quote).join(" ") : void 0;
5785
6399
  if (options.samples) writeJson(options.samples, {
5786
- instructions: "Blind review: set label true for a violation, false for acceptable, or leave null when uncertain. No predicted probabilities are included.",
6400
+ instructions: "Blind review: set label true for a violation, false for acceptable, or leave null when uncertain. No predicted probabilities are included. Likely failures come first; label them yourself with taste-lint eval label.",
5787
6401
  samples,
5788
6402
  version: 2
5789
6403
  });
@@ -5845,6 +6459,7 @@ const promotionEvidence = (items, ruleId, pairs, threshold, floor, minItems) =>
5845
6459
  };
5846
6460
  //#endregion
5847
6461
  //#region src/eval/tune.ts
6462
+ const fromSources = (items, sources) => sources ? items.filter((item) => sources.includes(item.labelSource)) : items;
5848
6463
  const THRESHOLDS = Array.from({ length: 10 }, (_, i) => Math.round((.5 + i * .05) * 100) / 100);
5849
6464
  const decideThreshold = (pairs, currentAct, floor, minItems, review = 0) => {
5850
6465
  const n = pairs.length;
@@ -5898,7 +6513,7 @@ const runTune = async (options, ctx = {}) => {
5898
6513
  only: options.only
5899
6514
  }).filter((r) => r.tier !== "mechanical");
5900
6515
  const knownRuleIds = new Set(loadRules(rulesDir, { allowDraft: true }).map((r) => r.id));
5901
- const allItems = loadCorpus(resolveCorpusDir(options.corpusDir), rules, { knownRuleIds });
6516
+ const allItems = fromSources(loadCorpus(resolveCorpusDir(options.corpusDir), rules, { knownRuleIds }), options.sources);
5902
6517
  const items = allItems.filter((item) => item.split === "dev");
5903
6518
  const model = options.model ?? "jev-latest";
5904
6519
  const resultsDir = options.resultsDir ?? defaultResultsDir();
@@ -5973,7 +6588,7 @@ const runTuneAb = async (options, ctx = {}) => {
5973
6588
  };
5974
6589
  const variant = validateRule(merged, options.variantFile, rule.id);
5975
6590
  const knownRuleIds = new Set(loadRules(rulesDir, { allowDraft: true }).map((r) => r.id));
5976
- const items = loadCorpus(resolveCorpusDir(options.corpusDir), [rule], { knownRuleIds }).filter((i) => i.split === "dev" && rule.id in i.labels);
6591
+ const items = fromSources(loadCorpus(resolveCorpusDir(options.corpusDir), [rule], { knownRuleIds }), options.sources).filter((i) => i.split === "dev" && rule.id in i.labels);
5977
6592
  const model = options.model ?? "jev-latest";
5978
6593
  const resultsDir = options.resultsDir ?? defaultResultsDir();
5979
6594
  const cache = new AnswerCache(path.join(resultsDir, "cache"));
@@ -6019,7 +6634,7 @@ const ruleToRaw = (rule) => {
6019
6634
  //#endregion
6020
6635
  //#region src/commands/tune.ts
6021
6636
  function registerTuneCommand(program) {
6022
- program.command("tune").description("Pick act thresholds per rule from the dev split; --write updates data/rules/tuning.json").option("--corpus <path>", "Corpus directory").option("--rules <path>", "Rules directory").option("--only <ids>", "Comma-separated rule ids").option("--floor <p>", "Required lower bound on act-band precision", "0.8").option("--min-items <n>", "Labelled dev items a rule needs before a decision", "10").option("--write", "Write tuning.json").option("--results-dir <path>", "Where logs and cache live").option("--model <id>", "Jev model id", DEFAULT_MODEL).action(async (options) => {
6637
+ program.command("tune").description("Pick act thresholds per rule from the dev split; --write updates data/rules/tuning.json").option("--corpus <path>", "Corpus directory").option("--rules <path>", "Rules directory").option("--only <ids>", "Comma-separated rule ids").option("--floor <p>", "Required lower bound on act-band precision", "0.8").option("--min-items <n>", "Labelled dev items a rule needs before a decision", "10").option("--write", "Write tuning.json").option("--source <sources>", "Only these label sources; tune on hand to fit the judge to you").option("--results-dir <path>", "Where logs and cache live").option("--model <id>", "Jev model id", DEFAULT_MODEL).action(async (options) => {
6023
6638
  const result = await runTune({
6024
6639
  corpusDir: options.corpus,
6025
6640
  floor: Number(options.floor),
@@ -6028,17 +6643,19 @@ function registerTuneCommand(program) {
6028
6643
  only: options.only?.split(",").map((s) => s.trim()).filter(Boolean),
6029
6644
  resultsDir: options.resultsDir,
6030
6645
  rulesDir: options.rules,
6646
+ sources: parseSources(options.source),
6031
6647
  write: options.write
6032
6648
  });
6033
6649
  process.stdout.write(result.report);
6034
6650
  process.exitCode = result.exitCode;
6035
- }).command("ab").description("Compare two question wordings for one rule over the dev split (McNemar)").requiredOption("--rule <id>", "Rule id").requiredOption("--variant <file>", "YAML file with a replacement question").option("--corpus <path>", "Corpus directory").option("--rules <path>", "Rules directory").option("--results-dir <path>", "Where logs and cache live").option("--model <id>", "Jev model id", DEFAULT_MODEL).action(async (options) => {
6651
+ }).command("ab").description("Compare two question wordings for one rule over the dev split (McNemar)").requiredOption("--rule <id>", "Rule id").requiredOption("--variant <file>", "YAML file with a replacement question").option("--corpus <path>", "Corpus directory").option("--rules <path>", "Rules directory").option("--source <sources>", "Only these label sources; tune on hand to fit the judge to you").option("--results-dir <path>", "Where logs and cache live").option("--model <id>", "Jev model id", DEFAULT_MODEL).action(async (options) => {
6036
6652
  const result = await runTuneAb({
6037
6653
  corpusDir: options.corpus,
6038
6654
  model: options.model,
6039
6655
  resultsDir: options.resultsDir,
6040
6656
  ruleId: options.rule,
6041
6657
  rulesDir: options.rules,
6658
+ sources: parseSources(options.source),
6042
6659
  variantFile: options.variant
6043
6660
  });
6044
6661
  process.stdout.write(result.report);