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.
- package/README.md +5 -3
- package/data/config.schema.json +7 -0
- package/data/corpus/copywriting.jsonl +4 -4
- package/data/rule-drafts/interaction/interaction-layout-shift.yaml +3 -3
- package/data/rules/copywriting/copywriting-anthropomorphism.yaml +4 -3
- package/data/rules/copywriting/copywriting-antithesis-contrast.yaml +43 -0
- package/data/rules/copywriting/copywriting-antithesis-frame.yaml +23 -0
- package/data/rules/copywriting/copywriting-artificial-balance.yaml +4 -1
- package/data/rules/copywriting/copywriting-boilerplate-offer.yaml +1 -1
- package/data/rules/copywriting/copywriting-canned-phrasing.yaml +1 -1
- package/data/rules/copywriting/copywriting-claim-without-evidence.yaml +10 -1
- package/data/rules/copywriting/copywriting-click-here-links.yaml +4 -3
- package/data/rules/copywriting/copywriting-conditions-first.yaml +2 -2
- package/data/rules/copywriting/copywriting-copula-avoidance.yaml +21 -0
- package/data/rules/copywriting/copywriting-dead-link.yaml +28 -0
- package/data/rules/copywriting/copywriting-em-dash-in-ui.yaml +23 -0
- package/data/rules/copywriting/copywriting-error-descriptions.yaml +2 -2
- package/data/rules/copywriting/copywriting-excessive-hedging.yaml +1 -1
- package/data/rules/copywriting/copywriting-external-arrow-internal-link.yaml +26 -0
- package/data/rules/copywriting/copywriting-fabricated-specificity.yaml +6 -3
- package/data/rules/copywriting/copywriting-filler-words.yaml +4 -3
- package/data/rules/copywriting/copywriting-generic-framing.yaml +12 -6
- package/data/rules/copywriting/copywriting-generic-language.yaml +12 -4
- package/data/rules/copywriting/copywriting-generic-validation.yaml +1 -1
- package/data/rules/copywriting/copywriting-latinisms.yaml +6 -4
- package/data/rules/copywriting/copywriting-machine-prose.yaml +4 -1
- package/data/rules/copywriting/copywriting-mechanism-over-outcome.yaml +4 -1
- package/data/rules/copywriting/copywriting-meta-commentary.yaml +1 -1
- package/data/rules/copywriting/copywriting-missing-reasoning-link.yaml +4 -1
- package/data/rules/copywriting/copywriting-nominalisations.yaml +4 -3
- package/data/rules/copywriting/copywriting-over-explanation.yaml +4 -1
- package/data/rules/copywriting/copywriting-participle-tail.yaml +21 -0
- package/data/rules/copywriting/copywriting-passive-voice.yaml +4 -3
- package/data/rules/copywriting/copywriting-promotional-docs-voice.yaml +4 -3
- package/data/rules/copywriting/copywriting-prompt-restatement.yaml +5 -2
- package/data/rules/copywriting/copywriting-punctuation-tics.yaml +4 -1
- package/data/rules/copywriting/copywriting-reader-first.yaml +4 -1
- package/data/rules/copywriting/copywriting-repetitive-transitions.yaml +1 -1
- package/data/rules/copywriting/copywriting-requirements-language.yaml +2 -2
- package/data/rules/copywriting/copywriting-searchable-headings.yaml +2 -2
- package/data/rules/copywriting/copywriting-semantic-links.yaml +2 -2
- package/data/rules/copywriting/copywriting-slogan-headings.yaml +27 -0
- package/data/rules/copywriting/copywriting-structural-symmetry.yaml +4 -1
- package/data/rules/copywriting/copywriting-tee-up.yaml +23 -0
- package/data/rules/copywriting/copywriting-temporal-words.yaml +4 -3
- package/data/rules/copywriting/copywriting-unexplained-action.yaml +5 -2
- package/data/rules/copywriting/copywriting-unnatural-polish.yaml +8 -5
- package/data/rules/copywriting/copywriting-unverifiable-proof.yaml +1 -1
- package/data/rules/copywriting/copywriting-vague-error.yaml +2 -0
- package/data/rules/craft/craft-atmosphere-gradient.yaml +28 -0
- package/data/rules/craft/craft-decoration-no-role.yaml +2 -1
- package/data/rules/craft/craft-decorative-ordinals.yaml +26 -0
- package/data/rules/craft/craft-default-indigo.yaml +28 -0
- package/data/rules/craft/craft-faux-product-chrome.yaml +4 -1
- package/data/rules/motion/motion-will-change-misuse.yaml +28 -0
- package/data/rules/product/product-nested-modals.yaml +1 -1
- package/data/rules/product/product-select-for-few-options.yaml +1 -1
- package/data/rules/schema/rule.schema.json +7 -1
- package/dist/cli.js +748 -131
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +38 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +459 -51
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- 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
|
|
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.
|
|
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
|
|
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
|
|
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 = "
|
|
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", "
|
|
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", "
|
|
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-
|
|
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:
|
|
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:
|
|
3346
|
-
path: "skills/
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3447
|
-
|
|
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 (
|
|
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/
|
|
3676
|
-
const
|
|
3677
|
-
const
|
|
3678
|
-
const
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
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
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
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
|
|
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
|
|
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}
|
|
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.
|
|
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$
|
|
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$
|
|
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$
|
|
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, {
|
|
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);
|