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