taste-lint 0.0.11 → 0.2.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/dist/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { builtinModules } from "node:module";
3
- import { Command, CommanderError } from "commander";
2
+ import { Command, CommanderError, Option } from "commander";
4
3
  import fs from "node:fs";
5
4
  import path from "node:path";
6
5
  import { execFileSync, spawn, spawnSync } from "node:child_process";
@@ -10,13 +9,12 @@ import { fromMarkdown } from "mdast-util-from-markdown";
10
9
  import { gfmFromMarkdown } from "mdast-util-gfm";
11
10
  import { gfm } from "micromark-extension-gfm";
12
11
  import { parseSync } from "oxc-parser";
13
- import { parse as parse$1 } from "parse5";
12
+ import os from "node:os";
14
13
  import { mdxFromMarkdown } from "mdast-util-mdx";
15
14
  import { mdxjs } from "micromark-extension-mdxjs";
16
15
  import { styleText } from "node:util";
17
- import os from "node:os";
18
- //#region package.json
19
- var version = "0.0.11";
16
+ //#region packages/cli/package.json
17
+ var version = "0.2.0";
20
18
  //#endregion
21
19
  //#region src/lib/stats.ts
22
20
  const makePRNG = (seed) => {
@@ -116,21 +114,6 @@ const calibrationTable = (pairs, buckets = 10) => Array.from({ length: buckets }
116
114
  //#endregion
117
115
  //#region src/rules/taxonomy.ts
118
116
  const CATEGORIES = [
119
- {
120
- domain: "seo",
121
- id: "search-discovery",
122
- label: "Search discovery"
123
- },
124
- {
125
- domain: "architecture",
126
- id: "repository-contracts",
127
- label: "Repository contracts"
128
- },
129
- {
130
- domain: "dx",
131
- id: "package-contracts",
132
- label: "Package contracts"
133
- },
134
117
  {
135
118
  domain: "authoring",
136
119
  id: "instruction-quality",
@@ -340,9 +323,6 @@ const DOC_TYPES = [
340
323
  "readme",
341
324
  "skill",
342
325
  "plan",
343
- "personal",
344
- "pr",
345
- "slides",
346
326
  "unknown"
347
327
  ];
348
328
  const ROLES = [
@@ -365,10 +345,7 @@ const CONTEXT_KEYS = [
365
345
  "docType",
366
346
  "element",
367
347
  "role",
368
- "neighbours",
369
- "writingFacts",
370
- "writingProfile",
371
- "writingInstructions"
348
+ "neighbours"
372
349
  ];
373
350
  //#endregion
374
351
  //#region src/eval/corpus.ts
@@ -1070,7 +1047,7 @@ const detectSmartQuotesAtBuild = (root) => SMARTYPANTS_FILES.some((file) => {
1070
1047
  return false;
1071
1048
  }
1072
1049
  });
1073
- const invalid$1 = (key, expected) => {
1050
+ const invalid = (key, expected) => {
1074
1051
  throw new InputError("INVALID_CONFIG", `taste-lint.config.json: ${key} must be ${expected}.`, {
1075
1052
  expected,
1076
1053
  field: key
@@ -1078,14 +1055,14 @@ const invalid$1 = (key, expected) => {
1078
1055
  };
1079
1056
  function validateConfig(raw) {
1080
1057
  const object = (value, key) => {
1081
- if (!value || typeof value !== "object" || Array.isArray(value)) return invalid$1(key, "an object");
1058
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(key, "an object");
1082
1059
  return value;
1083
1060
  };
1084
1061
  const keys = (value, allowed, prefix) => {
1085
- for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid$1(`${prefix}${key}`, `one of ${allowed.join(", ")}`);
1062
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) invalid(`${prefix}${key}`, `one of ${allowed.join(", ")}`);
1086
1063
  };
1087
1064
  const strings = (value, key) => {
1088
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) invalid$1(key, "an array of strings");
1065
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) invalid(key, "an array of strings");
1089
1066
  };
1090
1067
  const value = object(raw, "config");
1091
1068
  keys(value, [
@@ -1094,48 +1071,18 @@ function validateConfig(raw) {
1094
1071
  "smartQuotesAtBuild",
1095
1072
  "docTypes",
1096
1073
  "components",
1097
- "tailwind",
1098
- "architecture"
1074
+ "tailwind"
1099
1075
  ], "");
1100
- if (value.architecture !== void 0) {
1101
- const policy = object(value.architecture, "architecture");
1102
- keys(policy, [
1103
- "boundaries",
1104
- "deprecatedImports",
1105
- "generated"
1106
- ], "architecture.");
1107
- if (policy.generated !== void 0) strings(policy.generated, "architecture.generated");
1108
- if (policy.deprecatedImports !== void 0) {
1109
- const deprecated = object(policy.deprecatedImports, "architecture.deprecatedImports");
1110
- for (const [key, replacement] of Object.entries(deprecated)) if (typeof replacement !== "string" || !replacement.trim()) invalid$1(`architecture.deprecatedImports.${key}`, "a nonempty replacement");
1111
- }
1112
- if (policy.boundaries !== void 0) {
1113
- if (!Array.isArray(policy.boundaries)) invalid$1("architecture.boundaries", "an array");
1114
- for (const [i, rawBoundary] of policy.boundaries.entries()) {
1115
- const boundary = object(rawBoundary, `architecture.boundaries[${i}]`);
1116
- keys(boundary, [
1117
- "from",
1118
- "disallow",
1119
- "reason"
1120
- ], `architecture.boundaries[${i}].`);
1121
- for (const key of [
1122
- "from",
1123
- "disallow",
1124
- "reason"
1125
- ]) if (typeof boundary[key] !== "string" || !boundary[key].trim()) invalid$1(`architecture.boundaries[${i}].${key}`, "a nonempty string");
1126
- }
1127
- }
1128
- }
1129
- if (value.$schema !== void 0 && typeof value.$schema !== "string") invalid$1("$schema", "a string");
1076
+ if (value.$schema !== void 0 && typeof value.$schema !== "string") invalid("$schema", "a string");
1130
1077
  if (value.exclude !== void 0) strings(value.exclude, "exclude");
1131
- if (value.smartQuotesAtBuild !== void 0 && typeof value.smartQuotesAtBuild !== "boolean") invalid$1("smartQuotesAtBuild", "a boolean");
1078
+ if (value.smartQuotesAtBuild !== void 0 && typeof value.smartQuotesAtBuild !== "boolean") invalid("smartQuotesAtBuild", "a boolean");
1132
1079
  if (value.docTypes !== void 0) {
1133
- if (!Array.isArray(value.docTypes)) invalid$1("docTypes", "an array");
1080
+ if (!Array.isArray(value.docTypes)) invalid("docTypes", "an array");
1134
1081
  for (const [i, entry] of value.docTypes.entries()) {
1135
1082
  const row = object(entry, `docTypes[${i}]`);
1136
1083
  keys(row, ["glob", "type"], `docTypes[${i}].`);
1137
- if (typeof row.glob !== "string") invalid$1(`docTypes[${i}].glob`, "a string");
1138
- if (!DOC_TYPES.includes(row.type)) invalid$1(`docTypes[${i}].type`, DOC_TYPES.join(", "));
1084
+ if (typeof row.glob !== "string") invalid(`docTypes[${i}].glob`, "a string");
1085
+ if (!DOC_TYPES.includes(row.type)) invalid(`docTypes[${i}].type`, DOC_TYPES.join(", "));
1139
1086
  }
1140
1087
  }
1141
1088
  if (value.components !== void 0) {
@@ -1148,7 +1095,7 @@ function validateConfig(raw) {
1148
1095
  keys(t, ["theme"], "tailwind.");
1149
1096
  if (t.theme !== void 0) {
1150
1097
  const theme = object(t.theme, "tailwind.theme");
1151
- for (const [key, val] of Object.entries(theme)) if (typeof val !== "string") invalid$1(`tailwind.theme.${key}`, "a string");
1098
+ for (const [key, val] of Object.entries(theme)) if (typeof val !== "string") invalid(`tailwind.theme.${key}`, "a string");
1152
1099
  }
1153
1100
  }
1154
1101
  }
@@ -1175,7 +1122,6 @@ const loadConfig = (root, defaults = []) => {
1175
1122
  }
1176
1123
  }
1177
1124
  return {
1178
- architecture: user.architecture,
1179
1125
  components: {
1180
1126
  skip: [...DEFAULT_SKIP, ...user.components?.skip ?? []],
1181
1127
  unwrap: [...DEFAULT_UNWRAP, ...user.components?.unwrap ?? []]
@@ -1519,11 +1465,6 @@ const buildState = (unit, rules) => {
1519
1465
  if (keys.has("element") && unit.context.element) lines.push(`ELEMENT: ${unit.context.element}${unit.context.attr ? ` (${unit.context.attr})` : ""}`);
1520
1466
  if (keys.has("role")) lines.push(`ROLE: ${unit.context.role}`);
1521
1467
  if (keys.has("section") && unit.context.section) lines.push(`SECTION: ${clip("SECTION: ", unit.context.section)}`);
1522
- for (const [key, label] of [
1523
- ["writingFacts", "SUPPLIED FACTS"],
1524
- ["writingProfile", "VOICE PROFILE"],
1525
- ["writingInstructions", "DRAFTING INSTRUCTIONS"]
1526
- ]) if (keys.has(key) && unit.context[key]) lines.push(`${label}: ${clip(`${label}: `, unit.context[key])}`);
1527
1468
  return {
1528
1469
  state: lines.join("\n"),
1529
1470
  truncated
@@ -2251,26 +2192,6 @@ const planRequests = (units, rules, config) => {
2251
2192
  const applied = eligible.get(unit.id) ?? /* @__PURE__ */ new Set();
2252
2193
  applied.add(rule.id);
2253
2194
  eligible.set(unit.id, applied);
2254
- const writingKeys = [
2255
- "writingFacts",
2256
- "writingProfile",
2257
- "writingInstructions"
2258
- ];
2259
- const requiredWriting = writingKeys.filter((key) => rule.question?.context?.includes(key));
2260
- if (requiredWriting.length) {
2261
- const missing = requiredWriting.filter((key) => !unit.context[key]);
2262
- const total = [unit.text, ...writingKeys.map((key) => unit.context[key] ?? "")].join("\n");
2263
- if (missing.length || estimateTokens(total, charsPerTokenFor(unit.kind)) > 1300) {
2264
- unknowns.push({
2265
- file: unit.file,
2266
- line: unit.line,
2267
- reason: missing.length ? `Missing explicit writing context: ${missing.join(", ")}` : "Writing comparison exceeds the context budget",
2268
- ruleId: rule.id,
2269
- unitId: unit.id
2270
- });
2271
- continue;
2272
- }
2273
- }
2274
2195
  if (rule.tier === "jev") {
2275
2196
  const reason = missingEvidence(unit, rule);
2276
2197
  if (reason) {
@@ -2492,6 +2413,13 @@ const jevFindings = (jobs, answers) => {
2492
2413
  };
2493
2414
  };
2494
2415
  //#endregion
2416
+ //#region src/extract/arbitrary.ts
2417
+ const literalArbitraryValues = (classes) => classes.filter((className) => {
2418
+ const value = baseClass(className).match(/^-?[\w-]+-\[(.+?)\](?:\/[^\s]+)?!?$/u)?.[1]?.replace(/^(?:length|color):/u, "");
2419
+ if (!value || /var\(|env\(|theme\(|^--|^(?:calc|min|max|clamp|minmax|repeat|url)\(/u.test(value)) return false;
2420
+ return /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ms|s|deg)?$/u.test(value) || /^#[\da-f]{3,8}$/iu.test(value) || /^(?:rgb|rgba|hsl|hsla|oklab|oklch|color)\(/u.test(value);
2421
+ });
2422
+ //#endregion
2495
2423
  //#region src/rules/validate.ts
2496
2424
  const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2497
2425
  const DEFAULT_THRESHOLDS = {
@@ -2620,71 +2548,6 @@ const codeRule = (spec, module) => {
2620
2548
  };
2621
2549
  };
2622
2550
  //#endregion
2623
- //#region src/rules/code/architecture.ts
2624
- const make$4 = (name, title, check, hint) => codeRule({
2625
- categoryId: "repository-contracts",
2626
- check,
2627
- hint,
2628
- id: `architecture-${name}`,
2629
- scope: { include: ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"] },
2630
- source: {
2631
- line: 1,
2632
- path: "skills/codebase-architecture/references/guardrail-tooling.md",
2633
- repo: "mblode/agent-skills"
2634
- },
2635
- status: "review-only",
2636
- title,
2637
- unit: ["source"]
2638
- }, "src/rules/code/architecture.ts");
2639
- const ARCHITECTURE_RULES = [
2640
- make$4("declared-import-boundary", "Import violates a declared module boundary", (u) => {
2641
- const boundaries = u.facts?.repository.policy?.boundaries?.filter((b) => matchesAny(u.file, [b.from]));
2642
- if (!boundaries?.length) return none;
2643
- if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
2644
- for (const item of u.facts.imports) {
2645
- const resolved = item.name.startsWith(".") ? u.facts.repository.resolve(u.file, item.name) : void 0;
2646
- const boundary = boundaries.find((b) => matchesAny(item.name, [b.disallow]) || resolved !== void 0 && matchesAny(resolved, [b.disallow]));
2647
- if (boundary) return {
2648
- evidence: `${item.name}: ${boundary.reason}`,
2649
- fired: true,
2650
- offset: item.offset
2651
- };
2652
- }
2653
- return none;
2654
- }, "Use the declared public interface or change the explicit architecture policy with its owner. Patterns match import specifiers and repository-relative paths for relative imports."),
2655
- make$4("deprecated-import", "Import uses a declared deprecated API", (u) => {
2656
- const deprecated = u.facts?.repository.policy?.deprecatedImports;
2657
- if (!deprecated || Object.keys(deprecated).length === 0) return none;
2658
- if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
2659
- for (const item of u.facts.imports) {
2660
- const replacement = deprecated[item.name];
2661
- if (replacement) return {
2662
- evidence: `${item.name}: use ${replacement}`,
2663
- fired: true,
2664
- offset: item.offset
2665
- };
2666
- }
2667
- return none;
2668
- }, "Use the replacement named in the repository's deprecation contract."),
2669
- make$4("generated-regeneration-hint", "Declared generated file has no regeneration guidance", (u) => {
2670
- const generated = u.facts?.repository.policy?.generated;
2671
- if (!generated?.length || !matchesAny(u.file, generated)) return none;
2672
- const banner = u.text.split("\n").slice(0, 12).join("\n");
2673
- return /generat/i.test(banner) && /(?:regenerat|npm run|pnpm |yarn |bun |codegen|protoc)/i.test(banner) ? none : {
2674
- evidence: "Declared generated output lacks a regeneration hint in its header",
2675
- fired: true,
2676
- offset: 0
2677
- };
2678
- }, "Have the generator emit a banner naming the regeneration command or canonical instructions.")
2679
- ];
2680
- //#endregion
2681
- //#region src/extract/arbitrary.ts
2682
- const literalArbitraryValues = (classes) => classes.filter((className) => {
2683
- const value = baseClass(className).match(/^-?[\w-]+-\[(.+?)\](?:\/[^\s]+)?!?$/u)?.[1]?.replace(/^(?:length|color):/u, "");
2684
- if (!value || /var\(|env\(|theme\(|^--|^(?:calc|min|max|clamp|minmax|repeat|url)\(/u.test(value)) return false;
2685
- return /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ms|s|deg)?$/u.test(value) || /^#[\da-f]{3,8}$/iu.test(value) || /^(?:rgb|rgba|hsl|hsla|oklab|oklch|color)\(/u.test(value);
2686
- });
2687
- //#endregion
2688
2551
  //#region src/rules/code/classes.ts
2689
2552
  const MODULE$1 = "src/rules/code/classes.ts";
2690
2553
  const ANIMATION = "skills/ui-animation/references";
@@ -2883,40 +2746,13 @@ const CLASS_RULES = [
2883
2746
  })
2884
2747
  ];
2885
2748
  //#endregion
2886
- //#region src/analysis/html.ts
2887
- const text = (node) => "value" in node ? node.value : "childNodes" in node ? node.childNodes.map(text).join("") : "";
2888
- const htmlElements = (source) => {
2889
- const output = [];
2890
- const visit = (node, inHead = false) => {
2891
- const head = inHead || "tagName" in node && node.tagName === "head";
2892
- if ("tagName" in node && node.namespaceURI === "http://www.w3.org/1999/xhtml" && node.sourceCodeLocation && [
2893
- "title",
2894
- "meta",
2895
- "link",
2896
- "script",
2897
- "img"
2898
- ].includes(node.tagName)) output.push({
2899
- attrs: Object.fromEntries(node.attrs.map((a) => [a.name, a.value])),
2900
- inHead: head,
2901
- offset: node.sourceCodeLocation.startOffset,
2902
- tag: node.tagName,
2903
- text: text(node)
2904
- });
2905
- if ("childNodes" in node) for (const child of node.childNodes) visit(child, head);
2906
- };
2907
- visit(parse$1(source, { sourceCodeLocationInfo: true }));
2908
- return output;
2909
- };
2910
- //#endregion
2911
2749
  //#region src/analysis/repository.ts
2912
2750
  const descendants = (node) => [node, ...(node.children ?? []).flatMap(descendants)];
2913
2751
  const nodeText = (node) => node.value ?? (node.children ?? []).map(nodeText).join("");
2914
2752
  var Repository = class {
2915
2753
  root;
2916
2754
  contents = /* @__PURE__ */ new Map();
2917
- policy;
2918
- constructor(root, policy) {
2919
- this.policy = policy;
2755
+ constructor(root) {
2920
2756
  this.root = fs.realpathSync(root);
2921
2757
  }
2922
2758
  resolve(file, target) {
@@ -2991,15 +2827,7 @@ const importsFrom = (value, output) => {
2991
2827
  };
2992
2828
  const sourceFacts = (file, text, repository) => {
2993
2829
  const facts = { repository };
2994
- if (/\.html?$/.test(file)) {
2995
- facts.html = htmlElements(text);
2996
- facts.images = facts.html.filter((element) => element.tag === "img").map((element) => ({
2997
- attributes: Object.keys(element.attrs),
2998
- offset: element.offset,
2999
- spread: false
3000
- }));
3001
- }
3002
- if (/\.mdx?$/.test(file) || /(?:^|\/)llms(?:-full)?\.txt$/.test(file)) facts.document = fromMarkdown(text.replace(/^---\r?\n[\s\S]*?\r?\n---(?=\r?\n|$)/, (m) => m.replaceAll(/[^\r\n]/g, " ")), {
2830
+ if (/\.mdx?$/.test(file)) facts.document = fromMarkdown(text.replace(/^---\r?\n[\s\S]*?\r?\n---(?=\r?\n|$)/, (m) => m.replaceAll(/[^\r\n]/g, " ")), {
3003
2831
  extensions: [gfm()],
3004
2832
  mdastExtensions: [gfmFromMarkdown()]
3005
2833
  });
@@ -3032,96 +2860,8 @@ const sourceFacts = (file, text, repository) => {
3032
2860
  return facts;
3033
2861
  };
3034
2862
  //#endregion
3035
- //#region src/rules/code/discovery.ts
3036
- const at$2 = (evidence, offset = 0) => ({
3037
- evidence,
3038
- fired: true,
3039
- offset
3040
- });
3041
- const html = (u) => {
3042
- if (!u.facts?.html || !/<!doctype\s+html\b|<html(?:\s|>)/i.test(u.text)) throw new UnresolvedError("Needs a complete HTML document; framework metadata and fragments require rendered evidence");
3043
- return u.facts.html;
3044
- };
3045
- const make$3 = (id, title, include, source, check, hint) => codeRule({
3046
- categoryId: id.startsWith("seo-") ? "search-discovery" : "instruction-quality",
3047
- check,
3048
- hint,
3049
- id,
3050
- scope: {
3051
- exclude: [
3052
- "**/supabase/templates/**",
3053
- "**/emails/**",
3054
- "**/email/**"
3055
- ],
3056
- include
3057
- },
3058
- source: {
3059
- line: 1,
3060
- path: `skills/${source}`,
3061
- repo: "mblode/agent-skills"
3062
- },
3063
- status: "review-only",
3064
- title,
3065
- unit: ["source"]
3066
- }, "src/rules/code/discovery.ts");
3067
- const HTML = ["**/*.html", "**/*.htm"];
3068
- const SEO = "seo/references/audit.md";
3069
- const LLM = "agent-ready/references/docs-afdocs.md";
3070
- const canonical = (u) => html(u).filter((n) => n.inHead && n.tag === "link" && n.attrs.rel?.toLowerCase().split(/\s+/).includes("canonical"));
3071
- const DISCOVERY_RULES = [
3072
- make$3("seo-document-title", "HTML document needs one nonempty title", HTML, SEO, (u) => {
3073
- const titles = html(u).filter((n) => n.inHead && n.tag === "title");
3074
- return titles.length !== 1 || !titles[0].text.trim() ? at$2(`Found ${titles.length} HTML head titles${titles.length === 1 ? "; title is empty" : ""}`, titles[0]?.offset) : none;
3075
- }, "Provide one meaningful title in the HTML head. SVG titles do not name the page."),
3076
- make$3("seo-empty-description", "Declared meta description is empty", HTML, SEO, (u) => {
3077
- const empty = html(u).find((n) => n.inHead && n.tag === "meta" && n.attrs.name?.toLowerCase() === "description" && !n.attrs.content?.trim());
3078
- return empty ? at$2("Empty meta description", empty.offset) : none;
3079
- }, "Write a page-specific description, or omit a deliberately unspecified description."),
3080
- make$3("seo-conflicting-canonicals", "HTML declares conflicting canonical destinations", HTML, SEO, (u) => {
3081
- const links = canonical(u);
3082
- return new Set(links.map((n) => n.attrs.href?.trim()).filter(Boolean)).size > 1 ? at$2("Multiple distinct canonical destinations", links[1].offset) : none;
3083
- }, "Declare a single intended canonical destination; verify the deployed URL and HTTP headers."),
3084
- make$3("seo-empty-canonical", "Canonical link has no destination", HTML, SEO, (u) => {
3085
- const empty = canonical(u).find((n) => !n.attrs.href?.trim());
3086
- return empty ? at$2("Canonical link has an empty or missing href", empty.offset) : none;
3087
- }, "Set the intended canonical URL or remove an unintended canonical declaration."),
3088
- make$3("seo-jsonld-syntax", "JSON-LD script is not valid JSON", HTML, SEO, (u) => {
3089
- for (const node of html(u).filter((n) => n.tag === "script" && n.attrs.type?.trim().toLowerCase() === "application/ld+json")) try {
3090
- JSON.parse(node.text);
3091
- } catch {
3092
- return at$2("JSON-LD cannot be parsed as JSON", node.offset);
3093
- }
3094
- return none;
3095
- }, "Fix the JSON syntax. Valid JSON alone does not establish schema validity or rich-result eligibility."),
3096
- make$3("seo-robots-sitemap-url", "Robots sitemap directive needs an absolute HTTP URL", ["**/robots.txt"], "seo/references/indexing-policy.md", (u) => {
3097
- for (const match of u.text.matchAll(/^\s*sitemap\s*:\s*([^\r\n]*)/gim)) {
3098
- const value = match[1].split("#")[0].trim();
3099
- try {
3100
- const url = new URL(value);
3101
- if (["http:", "https:"].includes(url.protocol) && url.hostname && !/\s/.test(value)) continue;
3102
- } catch {}
3103
- return at$2("Sitemap directive is not an absolute HTTP(S) URL", match.index);
3104
- }
3105
- return none;
3106
- }, "Use the deployed sitemap's absolute URL. Fetch it separately to verify status and content."),
3107
- make$3("authoring-llms-title", "Agent index must start with an H1", ["**/llms.txt"], LLM, (u) => {
3108
- const document = u.facts?.document;
3109
- if (!document) throw new UnresolvedError("Markdown structure unavailable");
3110
- const first = document.children?.[0];
3111
- return first?.type === "heading" && first.depth === 1 ? none : at$2("llms.txt does not begin with an H1");
3112
- }, "Start llms.txt with the site's name as an H1, then describe and link its real documentation."),
3113
- make$3("authoring-llms-links", "Agent index has no documentation links", ["**/llms.txt"], LLM, (u) => {
3114
- if (!u.facts?.document) throw new UnresolvedError("Markdown structure unavailable");
3115
- return descendants(u.facts.document).some((n) => ["link", "definition"].includes(n.type) && n.url?.trim()) ? none : at$2("No Markdown link destinations in llms.txt");
3116
- }, "Link the existing documentation pages that an agent should read."),
3117
- make$3("authoring-llms-size", "Agent index exceeds the recommended character budget", ["**/llms.txt"], LLM, (u) => {
3118
- const count = [...u.text].length;
3119
- return count > 5e4 ? at$2(`llms.txt contains ${count} characters; recommended maximum is 50000`) : none;
3120
- }, "Keep the index small and link separate documents or nested indexes.")
3121
- ];
3122
- //#endregion
3123
2863
  //#region src/rules/code/documents.ts
3124
- const at$1 = (node, evidence) => ({
2864
+ const at = (node, evidence) => ({
3125
2865
  evidence,
3126
2866
  fired: true,
3127
2867
  offset: node.position?.start.offset ?? 0
@@ -3131,7 +2871,7 @@ const document = (unit) => {
3131
2871
  return unit.facts.document;
3132
2872
  };
3133
2873
  const nodes = (unit) => descendants(document(unit));
3134
- const make$2 = (name, title, source, include, check, hint, authoring = false) => codeRule({
2874
+ const make$1 = (name, title, source, include, check, hint, authoring = false) => codeRule({
3135
2875
  categoryId: authoring ? "instruction-quality" : "reference-reading",
3136
2876
  check,
3137
2877
  hint,
@@ -3155,11 +2895,11 @@ const INSTRUCTIONS = [
3155
2895
  const DOCS = ["**/*.md"];
3156
2896
  const README_SOURCE = "readme-creator/references/quality-checklist.md";
3157
2897
  const DOCUMENT_RULES = [
3158
- make$2("readme-scaffold", "README still contains scaffold instructions", README_SOURCE, README, (u) => {
2898
+ make$1("readme-scaffold", "README still contains scaffold instructions", README_SOURCE, README, (u) => {
3159
2899
  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)));
3160
- return n ? at$1(n, "Default scaffold introduction") : none;
2900
+ return n ? at(n, "Default scaffold introduction") : none;
3161
2901
  }, "Replace scaffold instructions with what the product does and how a consumer uses it."),
3162
- make$2("readme-description", "README has no introductory description", README_SOURCE, README, (u) => {
2902
+ make$1("readme-description", "README has no introductory description", README_SOURCE, README, (u) => {
3163
2903
  const children = document(u).children ?? [];
3164
2904
  const stop = children.findIndex((n) => n.type === "heading" && (n.depth ?? 0) > 1);
3165
2905
  return children.slice(0, stop === -1 ? children.length : stop).some((n) => n.type === "paragraph" && nodeText(n).trim().length > 0) ? none : {
@@ -3168,33 +2908,33 @@ const DOCUMENT_RULES = [
3168
2908
  offset: 0
3169
2909
  };
3170
2910
  }, "Explain what a reader can do with the product before listing setup steps."),
3171
- make$2("readme-quickstart-elision", "Quickstart example contains an unfinished placeholder", README_SOURCE, README, (u) => {
2911
+ make$1("readme-quickstart-elision", "Quickstart example contains an unfinished placeholder", README_SOURCE, README, (u) => {
3172
2912
  let heading = "";
3173
2913
  for (const n of document(u).children ?? []) {
3174
2914
  if (n.type === "heading") heading = nodeText(n);
3175
- if (n.type === "code" && /quick\s?start|getting started/i.test(heading) && /^\s*(?:\.\.\.|\/\/\s*(?:TODO|your code here)|#\s*TODO)\s*$/m.test(n.value ?? "")) return at$1(n, "Quickstart contains an elided implementation");
2915
+ if (n.type === "code" && /quick\s?start|getting started/i.test(heading) && /^\s*(?:\.\.\.|\/\/\s*(?:TODO|your code here)|#\s*TODO)\s*$/m.test(n.value ?? "")) return at(n, "Quickstart contains an elided implementation");
3176
2916
  }
3177
2917
  return none;
3178
2918
  }, "Provide a complete first-run example; move intentionally partial examples to a tutorial."),
3179
- make$2("document-empty-heading", "A section has no content", "docs-writing/rules/structure-no-filler-sections.md", DOCS, (u) => {
2919
+ make$1("document-empty-heading", "A section has no content", "docs-writing/rules/structure-no-filler-sections.md", DOCS, (u) => {
3180
2920
  const children = document(u).children ?? [];
3181
2921
  for (const [i, n] of children.entries()) {
3182
2922
  if (n.type !== "heading") continue;
3183
2923
  const next = children[i + 1];
3184
- if (!next || next.type === "heading" && (next.depth ?? 0) <= (n.depth ?? 0)) return at$1(n, `Empty section: ${nodeText(n)}`);
2924
+ if (!next || next.type === "heading" && (next.depth ?? 0) <= (n.depth ?? 0)) return at(n, `Empty section: ${nodeText(n)}`);
3185
2925
  }
3186
2926
  return none;
3187
2927
  }, "Add the section's content or remove its heading."),
3188
- make$2("document-heading-order", "Heading skips a structural level", "docs-writing/rules/format-semantic-html.md", DOCS, (u) => {
2928
+ make$1("document-heading-order", "Heading skips a structural level", "docs-writing/rules/format-semantic-html.md", DOCS, (u) => {
3189
2929
  let previous = 0;
3190
2930
  for (const n of nodes(u).filter((candidate) => candidate.type === "heading")) {
3191
2931
  const depth = n.depth ?? 1;
3192
- if (previous > 0 && depth > previous + 1) return at$1(n, `Heading level jumps from ${previous} to ${depth}`);
2932
+ if (previous > 0 && depth > previous + 1) return at(n, `Heading level jumps from ${previous} to ${depth}`);
3193
2933
  previous = depth;
3194
2934
  }
3195
2935
  return none;
3196
2936
  }, "Nest heading levels in order so the document outline reflects its structure."),
3197
- make$2("document-broken-local-link", "Document links to a missing local file", "docs-writing/rules/review-verify-links.md", DOCS, (u) => {
2937
+ make$1("document-broken-local-link", "Document links to a missing local file", "docs-writing/rules/review-verify-links.md", DOCS, (u) => {
3198
2938
  const repo = u.facts.repository;
3199
2939
  for (const n of nodes(u).filter((candidate) => [
3200
2940
  "link",
@@ -3207,26 +2947,26 @@ const DOCUMENT_RULES = [
3207
2947
  try {
3208
2948
  target = decodeURIComponent(url.split(/[?#]/)[0]);
3209
2949
  } catch {
3210
- return at$1(n, "Malformed URL encoding in local link");
2950
+ return at(n, "Malformed URL encoding in local link");
3211
2951
  }
3212
2952
  if (!/\.(?:md|mdx|png|jpe?g|svg|gif|webp|pdf)$/i.test(target)) continue;
3213
2953
  const resolved = repo.resolve(u.file, target);
3214
2954
  if (resolved === void 0) throw new UnresolvedError("Link points outside the repository");
3215
- if (!repo.exists(resolved)) return at$1(n, `Missing local target: ${target}`);
2955
+ if (!repo.exists(resolved)) return at(n, `Missing local target: ${target}`);
3216
2956
  }
3217
2957
  return none;
3218
2958
  }, "Correct the link or provide the referenced file. Generated site routes need a route-aware check."),
3219
- make$2("instruction-missing-script", "Instruction invokes an undefined package script", "agents-md/references/quick-checklist.md", INSTRUCTIONS, (u) => {
2959
+ make$1("instruction-missing-script", "Instruction invokes an undefined package script", "agents-md/references/quick-checklist.md", INSTRUCTIONS, (u) => {
3220
2960
  const manifest = u.facts.repository.manifest(u.file);
3221
2961
  if (!manifest) throw new UnresolvedError("No readable package manifest for this instruction file");
3222
2962
  const scripts = manifest.value.scripts;
3223
2963
  for (const n of nodes(u).filter((candidate) => ["code", "inlineCode"].includes(candidate.type))) for (const m of (n.value ?? "").matchAll(/\bnpm run ([\w:-]+)\b/g)) {
3224
2964
  if (/--(?:workspace|prefix)|\bcd\s/.test(n.value ?? "")) continue;
3225
- if (!scripts || !(m[1] in scripts)) return at$1(n, `Script ${m[1]} is absent from ${manifest.file}`);
2965
+ if (!scripts || !(m[1] in scripts)) return at(n, `Script ${m[1]} is absent from ${manifest.file}`);
3226
2966
  }
3227
2967
  return none;
3228
2968
  }, "Name a script in the applicable package manifest, or state the workspace where the command runs.", true),
3229
- make$2("skill-frontmatter", "Skill frontmatter is missing or invalid", "agent-skills-creator/references/format-specification.md", ["**/SKILL.md"], (u) => {
2969
+ make$1("skill-frontmatter", "Skill frontmatter is missing or invalid", "agent-skills-creator/references/format-specification.md", ["**/SKILL.md"], (u) => {
3230
2970
  const match = u.text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
3231
2971
  if (!match) return {
3232
2972
  evidence: "Missing YAML frontmatter",
@@ -3255,15 +2995,15 @@ const DOCUMENT_RULES = [
3255
2995
  }
3256
2996
  return none;
3257
2997
  }, "Supply valid YAML with name and description; run the skill repository's full validator for its remaining conventions.", true),
3258
- make$2("readme-license-link", "README license link points to a missing file", README_SOURCE, README, (u) => {
2998
+ make$1("readme-license-link", "README license link points to a missing file", README_SOURCE, README, (u) => {
3259
2999
  for (const n of nodes(u).filter((candidate) => candidate.type === "link")) {
3260
3000
  if (!/^(?:\.\/)?LICENSE(?:\.[\w-]+)?$/i.test(n.url ?? "")) continue;
3261
3001
  const target = u.facts.repository.resolve(u.file, n.url);
3262
- if (target && !u.facts.repository.exists(target)) return at$1(n, `Missing license target: ${n.url}`);
3002
+ if (target && !u.facts.repository.exists(target)) return at(n, `Missing license target: ${n.url}`);
3263
3003
  }
3264
3004
  return none;
3265
3005
  }, "Link to the project's actual license file."),
3266
- make$2("readme-install-name", "README installs an old or unrelated package name", README_SOURCE, README, (u) => {
3006
+ make$1("readme-install-name", "README installs an old or unrelated package name", README_SOURCE, README, (u) => {
3267
3007
  const manifest = u.facts.repository.manifest(u.file);
3268
3008
  if (!manifest || manifest.value.private === true || typeof manifest.value.name !== "string") throw new UnresolvedError("Published package identity is not established");
3269
3009
  let section = "";
@@ -3271,7 +3011,7 @@ const DOCUMENT_RULES = [
3271
3011
  if (n.type === "heading") section = nodeText(n);
3272
3012
  if (n.type !== "code" || !/^install(?:ation)?$/i.test(section)) continue;
3273
3013
  const command = (n.value ?? "").match(/^\s*(?:npm (?:install|i)|pnpm add|yarn add|bun add)\s+((?:@[\w.-]+\/)?[\w.-]+)(?:@[^\s]+)?\s*$/m);
3274
- if (command && command[1] !== manifest.value.name) return at$1(n, `Install names ${command[1]}; manifest names ${manifest.value.name}`);
3014
+ if (command && command[1] !== manifest.value.name) return at(n, `Install names ${command[1]}; manifest names ${manifest.value.name}`);
3275
3015
  }
3276
3016
  return none;
3277
3017
  }, "Check the intended distribution package. Keep companion-package and workspace instructions explicit.")
@@ -3309,11 +3049,7 @@ const IMAGE_RULES = [{
3309
3049
  },
3310
3050
  hint: spec.hint,
3311
3051
  id: spec.id,
3312
- scope: { include: [
3313
- "**/*.tsx",
3314
- "**/*.jsx",
3315
- "**/*.html"
3316
- ] },
3052
+ scope: { include: ["**/*.tsx", "**/*.jsx"] },
3317
3053
  source: {
3318
3054
  line: 9,
3319
3055
  path: `skills/ui-design/rules/${spec.source}.md`,
@@ -3325,141 +3061,6 @@ const IMAGE_RULES = [{
3325
3061
  unit: ["source"]
3326
3062
  }, "src/rules/code/images.ts"));
3327
3063
  //#endregion
3328
- //#region src/rules/code/repository.ts
3329
- const at = (evidence, offset = 0) => ({
3330
- evidence,
3331
- fired: true,
3332
- offset
3333
- });
3334
- const object$2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
3335
- const json = (u) => {
3336
- try {
3337
- return object$2(JSON.parse(u.text));
3338
- } catch {
3339
- throw new UnresolvedError("JSON is not parseable");
3340
- }
3341
- };
3342
- const builtins = /* @__PURE__ */ new Set([...builtinModules, ...builtinModules.map((n) => `node:${n}`)]);
3343
- const make$1 = (domain, id, title, include, source, check, hint) => codeRule({
3344
- categoryId: domain === "dx" ? "package-contracts" : "repository-contracts",
3345
- check,
3346
- hint,
3347
- id: `${domain}-${id}`,
3348
- scope: { include },
3349
- source: {
3350
- line: 1,
3351
- path: `skills/${source}`,
3352
- repo: "mblode/agent-skills"
3353
- },
3354
- status: "review-only",
3355
- title,
3356
- unit: ["source"]
3357
- }, "src/rules/code/repository.ts");
3358
- const GUARD = "codebase-architecture/references/guardrail-tooling.md";
3359
- const PKG = ["**/package.json"];
3360
- const CODE = ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"];
3361
- const REPOSITORY_RULES = [
3362
- make$1("dx", "package-entry-target", "Package entry points to a missing artifact", PKG, "scaffold-cli/references/post-scaffold.md", (u) => {
3363
- const value = json(u);
3364
- const entries = [];
3365
- for (const key of [
3366
- "main",
3367
- "module",
3368
- "types",
3369
- "typings"
3370
- ]) if (typeof value[key] === "string") entries.push(value[key]);
3371
- if (typeof value.bin === "string") entries.push(value.bin);
3372
- else entries.push(...Object.values(object$2(value.bin)).filter((v) => typeof v === "string"));
3373
- const visit = (v) => {
3374
- if (typeof v === "string") entries.push(v);
3375
- else if (v && typeof v === "object") for (const child of Object.values(v)) visit(child);
3376
- };
3377
- visit(value.exports);
3378
- for (const entry of entries) {
3379
- if (entry.includes("*")) continue;
3380
- const resolved = u.facts.repository.resolve(u.file, entry);
3381
- if (resolved === void 0) return at(`Entry escapes repository: ${entry}`);
3382
- if (!u.facts.repository.exists(resolved)) {
3383
- if (/^(?:\.\/)?(?:dist|build|lib)\//.test(entry) && object$2(value.scripts).build) throw new UnresolvedError(`Build output not available: ${entry}. Build or inspect the packed artifact.`);
3384
- return at(`Missing package entry: ${entry}`);
3385
- }
3386
- }
3387
- return none;
3388
- }, "Build and inspect the package, then make bin, exports, and declaration paths match its actual artifacts."),
3389
- make$1("dx", "package-bin-shebang", "CLI executable has no Node shebang", PKG, "scaffold-cli/references/scaffold-source.md", (u) => {
3390
- const value = json(u);
3391
- const bins = typeof value.bin === "string" ? [value.bin] : Object.values(object$2(value.bin)).filter((v) => typeof v === "string");
3392
- for (const bin of bins) {
3393
- if (!/\.[cm]?js$/.test(bin)) continue;
3394
- const resolved = u.facts.repository.resolve(u.file, bin);
3395
- const text = resolved === void 0 ? void 0 : u.facts.repository.read(resolved);
3396
- if (text === void 0) throw new UnresolvedError(`CLI artifact unavailable: ${bin}`);
3397
- if (!/^#![^\n]*\bnode\b/.test(text)) return at(`Node CLI lacks shebang: ${bin}`);
3398
- if (/^#![^\n]*\n#!/.test(text)) return at(`Duplicate CLI shebang: ${bin}`);
3399
- }
3400
- return none;
3401
- }, "Emit exactly one Node shebang in the published executable."),
3402
- make$1("dx", "exports-mixed-keys", "Package exports mixes conditions and subpaths", PKG, "dx-audit/rules/api-stable-contract.md", (u) => {
3403
- const keys = Object.keys(object$2(json(u).exports));
3404
- return keys.some((k) => k.startsWith(".")) && keys.some((k) => !k.startsWith(".")) ? at("Exports mixes subpath keys with condition keys at the same level") : none;
3405
- }, "Put conditions inside each exported subpath, or use a conditions-only root export."),
3406
- make$1("architecture", "undeclared-import", "Import has no declared package dependency", CODE, GUARD, (u) => {
3407
- if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
3408
- const manifest = u.facts.repository.manifest(u.file);
3409
- if (!manifest) throw new UnresolvedError("No readable package manifest");
3410
- const value = manifest.value;
3411
- const declared = /* @__PURE__ */ new Set([value.name, ...[
3412
- "dependencies",
3413
- "devDependencies",
3414
- "peerDependencies",
3415
- "optionalDependencies"
3416
- ].flatMap((key) => Object.keys(object$2(value[key])))]);
3417
- for (const item of u.facts.imports) {
3418
- if (/^(?:\.|\/|#|~|[a-z]+:)/i.test(item.name) || builtins.has(item.name)) continue;
3419
- const name = item.name.startsWith("@") ? item.name.split("/").slice(0, 2).join("/") : item.name.split("/")[0];
3420
- if (declared.has(name)) continue;
3421
- const tsconfig = u.facts.repository.resolve(manifest.file, "tsconfig.json");
3422
- if (tsconfig && u.facts.repository.exists(tsconfig)) throw new UnresolvedError(`Undeclared ${name} may resolve through TypeScript aliases; resolve the project graph`);
3423
- return at(`Import ${name} is absent from ${manifest.file}`, item.offset);
3424
- }
3425
- return none;
3426
- }, "Declare the dependency in the consuming package, or configure its alias resolution."),
3427
- make$1("architecture", "cross-package-relative-import", "Relative import bypasses a package boundary", CODE, GUARD, (u) => {
3428
- if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
3429
- const own = u.facts.repository.manifest(u.file);
3430
- if (!own) throw new UnresolvedError("No readable package manifest");
3431
- for (const item of u.facts.imports.filter((i) => i.name.startsWith("."))) {
3432
- const target = u.facts.repository.resolve(u.file, item.name);
3433
- if (!target) continue;
3434
- const other = u.facts.repository.manifest(target);
3435
- if (other && other.file !== own.file) return at(`Relative import crosses from ${own.file} to ${other.file}: ${item.name}`, item.offset);
3436
- }
3437
- return none;
3438
- }, "Import through the sibling package's public entry and declare the dependency, unless an explicit workspace convention permits this edge."),
3439
- make$1("architecture", "missing-tsconfig-reference", "TypeScript project reference points to a missing target", ["**/tsconfig*.json"], GUARD, (u) => {
3440
- const value = json(u);
3441
- for (const entry of Array.isArray(value.references) ? value.references : []) {
3442
- const target = object$2(entry).path;
3443
- if (typeof target !== "string") continue;
3444
- const resolved = u.facts.repository.resolve(u.file, target);
3445
- if (!resolved) throw new UnresolvedError("Project reference outside repository");
3446
- if (!u.facts.repository.exists(resolved)) return at(`Missing TypeScript reference: ${target}`);
3447
- }
3448
- return none;
3449
- }, "Correct the project reference or restore the referenced project."),
3450
- make$1("architecture", "missing-local-config-extends", "Config extends a missing local file", ["**/tsconfig*.json"], GUARD, (u) => {
3451
- const value = json(u);
3452
- const bases = Array.isArray(value.extends) ? value.extends : [value.extends];
3453
- for (const base of bases) {
3454
- if (typeof base !== "string" || !base.startsWith(".")) continue;
3455
- const resolved = u.facts.repository.resolve(u.file, base);
3456
- if (resolved === void 0) throw new UnresolvedError("Extended config outside repository");
3457
- if (!u.facts.repository.exists(resolved) && !u.facts.repository.exists(`${resolved}.json`)) return at(`Missing extended config: ${base}`);
3458
- }
3459
- return none;
3460
- }, "Update extends to the existing configuration path.")
3461
- ];
3462
- //#endregion
3463
3064
  //#region src/rules/code/skill-classes.ts
3464
3065
  const make = (id, title, categoryId, source, check, hint) => codeRule({
3465
3066
  categoryId,
@@ -3729,7 +3330,6 @@ const CODE_RULES = [
3729
3330
  preconditions: {
3730
3331
  docType: [
3731
3332
  "readme",
3732
- "personal",
3733
3333
  "tutorial",
3734
3334
  "howto",
3735
3335
  "reference",
@@ -3752,11 +3352,8 @@ const CODE_RULES = [
3752
3352
  ],
3753
3353
  ...CLASS_RULES,
3754
3354
  ...DOCUMENT_RULES,
3755
- ...DISCOVERY_RULES,
3756
3355
  ...IMAGE_RULES,
3757
- ...REPOSITORY_RULES,
3758
- ...SKILL_CLASS_RULES,
3759
- ...ARCHITECTURE_RULES
3356
+ ...SKILL_CLASS_RULES
3760
3357
  ];
3761
3358
  //#endregion
3762
3359
  //#region src/rules/load.ts
@@ -4075,15 +3672,133 @@ const runEval = async (options, ctx = {}) => {
4075
3672
  };
4076
3673
  };
4077
3674
  //#endregion
3675
+ //#region src/scan/storage.ts
3676
+ const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
3677
+ const object$1 = (value) => !!value && typeof value === "object" && !Array.isArray(value);
3678
+ const readJson = (file) => {
3679
+ try {
3680
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
3681
+ } catch {
3682
+ throw new InputError("INVALID_SCAN_INPUT", `Cannot read JSON input: ${file}`);
3683
+ }
3684
+ };
3685
+ const writeJson = (file, value) => {
3686
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
3687
+ const temp = `${file}.${randomUUID()}.tmp`;
3688
+ try {
3689
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
3690
+ fs.renameSync(temp, file);
3691
+ } finally {
3692
+ fs.rmSync(temp, { force: true });
3693
+ }
3694
+ };
3695
+ //#endregion
3696
+ //#region src/scan/samples.ts
3697
+ const makeSamples = (units, rules, answers, negatives, perStratum = 3, sourceRepo = "scan") => {
3698
+ const buckets = /* @__PURE__ */ new Map();
3699
+ const seen = /* @__PURE__ */ new Set();
3700
+ const semanticRules = rules.filter((rule) => rule.question);
3701
+ for (const unit of units) for (const rule of semanticRules) {
3702
+ const probability = answers.get(unit.id)?.[rule.id] ?? (negatives.get(unit.id)?.has(rule.id) ? 0 : void 0);
3703
+ if (probability === void 0) continue;
3704
+ const { docType, role, headingAbove } = unit.context;
3705
+ const context = {
3706
+ docType,
3707
+ headingAbove,
3708
+ role,
3709
+ ...Object.fromEntries((rule.question.context ?? []).filter((key) => key !== "neighbours").map((key) => [key, unit.context[key]]))
3710
+ };
3711
+ const rubric = buildQuestion(rule);
3712
+ const neighbours = rule.question.context?.includes("neighbours") ? unit.neighbours : void 0;
3713
+ const evidence = [
3714
+ rule.id,
3715
+ unit.text,
3716
+ context,
3717
+ neighbours,
3718
+ unit.classes,
3719
+ rubric
3720
+ ];
3721
+ const unique = hash(evidence);
3722
+ if (seen.has(unique)) continue;
3723
+ seen.add(unique);
3724
+ const id = `scan-${hash([
3725
+ sourceRepo,
3726
+ unit.file,
3727
+ ...evidence
3728
+ ]).slice(0, 24)}`;
3729
+ const sample = {
3730
+ criterion: rule.question.instructions,
3731
+ id,
3732
+ item: {
3733
+ categoryId: rule.categoryId,
3734
+ classes: unit.classes,
3735
+ context,
3736
+ id,
3737
+ kind: unit.kind,
3738
+ neighbours,
3739
+ source: {
3740
+ path: unit.file,
3741
+ repo: sourceRepo
3742
+ },
3743
+ split: splitFor(`${sourceRepo}/${unit.file}`),
3744
+ text: unit.text
3745
+ },
3746
+ label: null,
3747
+ rubric,
3748
+ ruleId: rule.id
3749
+ };
3750
+ const bucket = `${rule.id}:${docType}:${probability < rule.thresholds.review ? "negative" : probability < rule.thresholds.act ? "review" : "high"}`;
3751
+ const list = buckets.get(bucket) ?? [];
3752
+ list.push(sample);
3753
+ buckets.set(bucket, list);
3754
+ }
3755
+ return [...buckets.values()].flatMap((list) => list.toSorted((a, b) => a.id.localeCompare(b.id)).slice(0, perStratum)).toSorted((a, b) => a.id.localeCompare(b.id));
3756
+ };
3757
+ const labelsToCorpus = (file, destination, rulesDir) => {
3758
+ const raw = readJson(file);
3759
+ if (!object$1(raw) || raw.version !== 1 && raw.version !== 2 || !Array.isArray(raw.samples)) throw new InputError("INVALID_LABELS", "Expected a version 1 or 2 blind sample file.");
3760
+ if (raw.completed !== void 0 && raw.completed !== raw.samples.length) throw new InputError("INVALID_LABELS", "Labeling is incomplete. Review every sample before importing.");
3761
+ const annotation = raw.annotation;
3762
+ const ai = object$1(annotation) && annotation.source === "ai";
3763
+ 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
+ const ids = /* @__PURE__ */ new Set();
3765
+ const items = [];
3766
+ for (const row of raw.samples) {
3767
+ if (!object$1(row) || typeof row.id !== "string" || ids.has(row.id) || typeof row.ruleId !== "string" || raw.version === 2 && (!object$1(row.rubric) || row.rubric.type !== "noul" || typeof row.criterion !== "string" || row.rubric.instructions !== row.criterion) || !object$1(row.item) || row.item.id !== row.id || row.label !== null && typeof row.label !== "boolean") throw new InputError("INVALID_LABELS", "Samples require unique IDs and boolean or null labels.");
3768
+ ids.add(row.id);
3769
+ if (row.label === null) continue;
3770
+ items.push({
3771
+ ...row.item,
3772
+ labelSource: ai ? "ai" : "hand",
3773
+ ...ai ? {
3774
+ labelModel: annotation.model,
3775
+ labelPromptHash: annotation.promptHash
3776
+ } : {},
3777
+ labels: { [row.ruleId]: row.label }
3778
+ });
3779
+ }
3780
+ if (!items.length) throw new InputError("INVALID_LABELS", "No labels supplied. Set label to true or false after review.");
3781
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "taste-labels-"));
3782
+ try {
3783
+ fs.writeFileSync(path.join(temporary, "labels.jsonl"), items.map((item) => JSON.stringify(item)).join("\n"));
3784
+ loadCorpus(temporary, loadRules(resolveRulesDir(rulesDir), { allowDraft: true }));
3785
+ } finally {
3786
+ fs.rmSync(temporary, {
3787
+ force: true,
3788
+ recursive: true
3789
+ });
3790
+ }
3791
+ fs.writeFileSync(destination, `${items.map((item) => JSON.stringify(item)).join("\n")}\n`, { flag: "wx" });
3792
+ return items.length;
3793
+ };
3794
+ //#endregion
4078
3795
  //#region src/commands/eval.ts
4079
3796
  function registerEvalCommand(program) {
4080
- 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").option("--output <format>", "text or json", "text").option("--only <ids>", "Comma-separated rule ids").option("--split <name>", "dev, holdout or all", "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) => {
4081
- if (!["text", "json"].includes(options.output)) throw new Error("--output must be text or json");
4082
- if (![
4083
- "dev",
4084
- "holdout",
4085
- "all"
4086
- ].includes(options.split)) throw new Error("--split must be dev, holdout or all");
3797
+ 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
+ "dev",
3799
+ "holdout",
3800
+ "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) => {
4087
3802
  const result = await runEval({
4088
3803
  check: options.check,
4089
3804
  corpusDir: options.corpus,
@@ -4098,8 +3813,8 @@ function registerEvalCommand(program) {
4098
3813
  });
4099
3814
  process.stdout.write(options.output === "json" ? `${JSON.stringify(result, null, 2)}\n` : result.report);
4100
3815
  process.exitCode = result.exitCode;
4101
- }).command("coverage").description("Inspect label balance, document coverage and split overlap without API calls").option("--corpus <path>", "Corpus directory").option("--rules <path>", "Rules directory").option("--include-weak", "Include weak labels").option("--output <format>", "text or json", "text").action((options) => {
4102
- if (!["text", "json"].includes(options.output)) throw new Error("--output must be text or json");
3816
+ });
3817
+ command.command("coverage").description("Inspect label balance, document coverage and split overlap without API calls").option("--corpus <path>", "Corpus directory").option("--rules <path>", "Rules directory").option("--include-weak", "Include weak labels").addOption(new Option("--output <format>", "Output format").choices(["text", "json"]).default("text")).action((options) => {
4103
3818
  const rulesDir = resolveRulesDir(options.rules);
4104
3819
  const rules = loadRules(rulesDir);
4105
3820
  const items = loadCorpus(resolveCorpusDir(options.corpus), rules, {
@@ -4109,6 +3824,9 @@ function registerEvalCommand(program) {
4109
3824
  const coverage = corpusCoverage(items, rules);
4110
3825
  process.stdout.write(options.output === "json" ? `${JSON.stringify(coverage, null, 2)}\n` : renderCoverage(coverage));
4111
3826
  });
3827
+ 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
+ process.stdout.write(`Exported ${labelsToCorpus(file, options.out, options.rules)} labels.\n`);
3829
+ });
4112
3830
  }
4113
3831
  //#endregion
4114
3832
  //#region src/lib/frontmatter.ts
@@ -4886,7 +4604,7 @@ const extractTsx = (file, source, options) => {
4886
4604
  //#endregion
4887
4605
  //#region src/extract/index.ts
4888
4606
  const extractFile = (config, relativeFile) => {
4889
- const repository = new Repository(config.root, config.architecture);
4607
+ const repository = new Repository(config.root);
4890
4608
  const source = fs.readFileSync(path.join(config.root, relativeFile), "utf-8");
4891
4609
  return extractSource(config, relativeFile, source, repository);
4892
4610
  };
@@ -4916,11 +4634,6 @@ const extractSource = (config, file, source, repository) => {
4916
4634
  return [...units, whole];
4917
4635
  };
4918
4636
  const SUPPORTED_GLOBS = [
4919
- "**/*.html",
4920
- "**/*.htm",
4921
- "**/llms.txt",
4922
- "**/llms-full.txt",
4923
- "**/robots.txt",
4924
4637
  "**/*.md",
4925
4638
  "**/*.mdx",
4926
4639
  "**/*.tsx",
@@ -4971,13 +4684,13 @@ const marker = "<!-- taste-lint -->";
4971
4684
  const agentText = `${marker}
4972
4685
  ## Taste Lint
4973
4686
 
4974
- Taste Lint uses Jev to judge copy and UI. Preview the taste script with --dry-run, then run it with a user-supplied AI_GATEWAY_API_KEY.
4975
- Fix act findings, review advisory findings in context, and recheck the edited files.
4976
- Never invent a key or treat unknown checks as passes.
4977
- Docs: https://taste-lint.blode.md
4687
+ Run the taste script after editing copy or UI. Act findings fail the run and must be fixed in the copy or the classes, never by editing a rule.
4688
+ Review notes never fail a run; read them as suggestions. Unknown means a check could not decide, not a pass.
4689
+ Jev checks need a user-supplied AI_GATEWAY_API_KEY; add --dry-run to run only the mechanical checks. Never invent a key.
4690
+ Docs: https://blode.co/taste-lint/docs
4978
4691
  <!-- /taste-lint -->
4979
4692
  `;
4980
- function object$1(value) {
4693
+ function object(value) {
4981
4694
  return typeof value === "object" && value !== null && !Array.isArray(value);
4982
4695
  }
4983
4696
  function initProject(options) {
@@ -4986,7 +4699,7 @@ function initProject(options) {
4986
4699
  let manifest;
4987
4700
  try {
4988
4701
  const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
4989
- if (!object$1(parsed)) throw new Error("Expected an object");
4702
+ if (!object(parsed)) throw new Error("Expected an object");
4990
4703
  manifest = parsed;
4991
4704
  } catch {
4992
4705
  throw new InputError("INVALID_PROJECT", "Run init in a project with a valid package.json, or use --root <path>.");
@@ -5001,7 +4714,7 @@ function initProject(options) {
5001
4714
  "scripts",
5002
4715
  "dependencies",
5003
4716
  "devDependencies"
5004
- ]) if (manifest[field] !== void 0 && !object$1(manifest[field])) throw new InputError("INVALID_PROJECT", `package.json ${field} must be an object.`);
4717
+ ]) if (manifest[field] !== void 0 && !object(manifest[field])) throw new InputError("INVALID_PROJECT", `package.json ${field} must be an object.`);
5005
4718
  const dependencies = {
5006
4719
  ...manifest.dependencies,
5007
4720
  ...manifest.devDependencies
@@ -5015,33 +4728,19 @@ function initProject(options) {
5015
4728
  ].some((name) => name in dependencies) ? "product" : "writing";
5016
4729
  const scripts = { ...manifest.scripts };
5017
4730
  const added = [];
5018
- for (const oldProfile of ["product", "writing"]) if (scripts["check:taste"] === `taste-lint scan . --profile ${oldProfile} --mechanical-only`) {
5019
- scripts["check:taste"] = `taste-lint scan . --profile ${oldProfile}`;
5020
- added.push("check:taste");
5021
- }
5022
- for (const [name, command] of Object.entries({ taste: `taste-lint scan . --profile ${profile}` })) if (!(name in scripts)) {
4731
+ for (const [name, command] of Object.entries({ taste: `taste-lint lint --profile ${profile}` })) if (!(name in scripts)) {
5023
4732
  scripts[name] = command;
5024
4733
  added.push(name);
5025
4734
  }
5026
4735
  const agentPath = path.join(root, "AGENTS.md");
5027
4736
  const previousAgent = options.agent && fs.existsSync(agentPath) ? fs.readFileSync(agentPath, "utf-8") : "";
5028
- const legacyAgentText = `${marker}
5029
- ## Taste Lint
5030
-
5031
- Run the local check with the project's check:taste script after editing copy or UI.
5032
- For AI checks, preview the taste script with --dry-run, then run it with a user-supplied AI_GATEWAY_API_KEY.
5033
- Fix act findings, review advisory findings in context, and recheck the edited files.
5034
- Never invent a key or treat unknown checks as passes.
5035
- <!-- /taste-lint -->
5036
- `;
5037
- const upgradeAgent = Boolean(options.agent && previousAgent.includes(legacyAgentText));
5038
4737
  const addAgent = Boolean(options.agent && !previousAgent.includes(marker));
5039
4738
  const needsDependency = !("taste-lint" in dependencies);
5040
4739
  const installArgs = pm === "npm" ? ["install", "--save-dev"] : ["add", "--dev"];
5041
4740
  installArgs.push(`taste-lint@^${version}`);
5042
4741
  const shouldInstall = needsDependency && options.install !== false;
5043
4742
  const result = {
5044
- agentAdded: addAgent || upgradeAgent,
4743
+ agentAdded: addAgent,
5045
4744
  dryRun: Boolean(options.dryRun),
5046
4745
  installCommand: shouldInstall ? [pm, ...installArgs] : null,
5047
4746
  packageManager: pm,
@@ -5067,14 +4766,13 @@ Never invent a key or treat unknown checks as passes.
5067
4766
  manifest.scripts = scripts;
5068
4767
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
5069
4768
  }
5070
- if (upgradeAgent) fs.writeFileSync(agentPath, previousAgent.replace(legacyAgentText, agentText));
5071
4769
  if (addAgent) fs.writeFileSync(agentPath, `${previousAgent}${previousAgent ? "\n\n" : ""}${agentText}`);
5072
4770
  return result;
5073
4771
  }
5074
4772
  //#endregion
5075
4773
  //#region src/commands/init.ts
5076
4774
  function registerInitCommand(program) {
5077
- program.command("init").description("Install Taste Lint locally and add project check scripts").option("--root <path>", "Project directory", process.cwd()).option("--pm <name>", "Package manager: npm, pnpm, yarn, or bun").option("--dry-run", "Preview setup without installing or writing files").option("--no-install", "Add scripts without installing the package").option("--agent", "Append Taste Lint guidance to AGENTS.md").action((options) => {
4775
+ program.command("init").description("Install Taste Lint locally and add project check scripts").option("--root <path>", "Project directory", process.cwd()).addOption(new Option("--pm <name>", "Package manager").choices(managers)).option("--dry-run", "Preview setup without installing or writing files").option("--no-install", "Add scripts without installing the package").option("--agent", "Append Taste Lint guidance to AGENTS.md").action((options) => {
5078
4776
  const result = initProject(options);
5079
4777
  process.stdout.write(`${result.dryRun ? "Setup preview" : "Setup complete"}: ${result.packageManager}, ${result.profile} profile\n`);
5080
4778
  process.stdout.write(`Scripts added: ${result.scriptsAdded.join(", ") || "none (existing scripts preserved)"}\n`);
@@ -5125,10 +4823,17 @@ function spawnCapture(cmd, argv, options = {}) {
5125
4823
  }
5126
4824
  //#endregion
5127
4825
  //#region src/extract/capture.ts
4826
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4827
+ const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
4828
+ const stringMap = (value) => record(value) && Object.values(value).every((item) => typeof item === "string");
5128
4829
  const assertCapture = (raw) => {
5129
- const c = raw;
5130
- if (typeof c !== "object" || c === null || c.version !== 1 || typeof c.elements !== "object" || c.elements === null || !Array.isArray(c.order) || !c.order.every((id) => typeof id === "string")) throw new Error("Unsupported capture: expected style-capture CaptureResult version 1 with elements and order");
5131
- return c;
4830
+ if (!record(raw) || raw.version !== 1 || !record(raw.elements) || !strings(raw.order)) throw new Error("Unsupported capture: expected style-capture CaptureResult version 1 with elements and order");
4831
+ for (const [id, element] of Object.entries(raw.elements)) if (!record(element) || ![
4832
+ "id",
4833
+ "tagName",
4834
+ "selector"
4835
+ ].every((key) => typeof element[key] === "string") || !(element.parentId === null || typeof element.parentId === "string") || !strings(element.children) || !strings(element.classList) || !stringMap(element.styles) || !stringMap(element.attributes) || element.text !== void 0 && typeof element.text !== "string" || element.visible !== void 0 && typeof element.visible !== "boolean") throw new Error(`Unsupported capture element ${id}: expected text identifiers, string maps for styles/attributes and string lists for children/classList`);
4836
+ return raw;
5132
4837
  };
5133
4838
  //#endregion
5134
4839
  //#region src/extract/style-capture-text.ts
@@ -5383,28 +5088,6 @@ const runStyleCapture = async (url, selector) => {
5383
5088
  return parseCaptureInput(stdout);
5384
5089
  };
5385
5090
  //#endregion
5386
- //#region src/lib/writing-context.ts
5387
- const validateWritingContext = (value) => {
5388
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new InputError("INVALID_WRITING_CONTEXT", "Writing context must be an object with facts, profile, or instructions strings.");
5389
- const record = value;
5390
- for (const [key, text] of Object.entries(record)) if (![
5391
- "facts",
5392
- "profile",
5393
- "instructions"
5394
- ].includes(key) || typeof text !== "string" || !text.trim()) throw new InputError("INVALID_WRITING_CONTEXT", "Writing context accepts only nonempty facts, profile, and instructions strings.");
5395
- if (Object.keys(record).length === 0) throw new InputError("INVALID_WRITING_CONTEXT", "Provide at least one writing context field.");
5396
- return record;
5397
- };
5398
- const readWritingContext = (file) => {
5399
- let value;
5400
- try {
5401
- value = JSON.parse(fs.readFileSync(file, "utf-8"));
5402
- } catch {
5403
- throw new InputError("INVALID_WRITING_CONTEXT", "Cannot read writing context: provide a readable JSON file.");
5404
- }
5405
- return validateWritingContext(value);
5406
- };
5407
- //#endregion
5408
5091
  //#region src/reduce/dedupe.ts
5409
5092
  const BAND_RANK = {
5410
5093
  act: 0,
@@ -5542,39 +5225,179 @@ const applySuppressions = (findings, rules, sources) => {
5542
5225
  });
5543
5226
  };
5544
5227
  //#endregion
5228
+ //#region src/scan/git.ts
5229
+ const git = (root, args) => {
5230
+ try {
5231
+ return execFileSync("git", [
5232
+ "-C",
5233
+ root,
5234
+ ...args
5235
+ ], {
5236
+ encoding: "utf-8",
5237
+ maxBuffer: 33554432,
5238
+ stdio: [
5239
+ "ignore",
5240
+ "pipe",
5241
+ "pipe"
5242
+ ]
5243
+ });
5244
+ } catch {
5245
+ throw new InputError("INVALID_GIT_BASE", "Cannot compare Git revisions. Use a valid base in the scan repository.");
5246
+ }
5247
+ };
5248
+ const changedLines = (root, ref) => {
5249
+ const base = git(root, [
5250
+ "rev-parse",
5251
+ "--verify",
5252
+ "--end-of-options",
5253
+ `${ref}^{commit}`
5254
+ ]).trim();
5255
+ const names = git(root, [
5256
+ "diff",
5257
+ "--relative",
5258
+ "--no-ext-diff",
5259
+ "--no-textconv",
5260
+ "--no-renames",
5261
+ "--name-only",
5262
+ "-z",
5263
+ base,
5264
+ "--"
5265
+ ]).split("\0").filter(Boolean);
5266
+ const ranges = /* @__PURE__ */ new Map();
5267
+ for (const file of names) {
5268
+ const patch = git(root, [
5269
+ "diff",
5270
+ "--relative",
5271
+ "--no-ext-diff",
5272
+ "--no-textconv",
5273
+ "--no-renames",
5274
+ "--unified=0",
5275
+ base,
5276
+ "--",
5277
+ file
5278
+ ]);
5279
+ const lines = [];
5280
+ for (const match of patch.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
5281
+ const start = Number(match[1]);
5282
+ const length = Number(match[2] ?? 1);
5283
+ if (length > 0) lines.push([start, start + length - 1]);
5284
+ }
5285
+ ranges.set(file, lines);
5286
+ }
5287
+ return {
5288
+ base,
5289
+ ranges,
5290
+ untracked: new Set(git(root, [
5291
+ "ls-files",
5292
+ "--others",
5293
+ "--exclude-standard",
5294
+ "-z"
5295
+ ]).split("\0").filter(Boolean))
5296
+ };
5297
+ };
5298
+ const touchesChange = (diff, file, start, end) => diff.untracked.has(file) || (diff.ranges.get(file) ?? []).some(([a, b]) => start <= b && end >= a);
5299
+ //#endregion
5300
+ //#region src/scan/profiles.ts
5301
+ const artifacts = [
5302
+ "**/node_modules/**",
5303
+ "**/dist/**",
5304
+ "**/.next/**",
5305
+ "**/results/**",
5306
+ "**/docs/archive/**"
5307
+ ];
5308
+ const internal = [
5309
+ "**/.claude/**",
5310
+ "**/.agents/**",
5311
+ "**/skills/**",
5312
+ "**/AGENTS.md",
5313
+ "**/CLAUDE.md",
5314
+ "**/SKILL.md"
5315
+ ];
5316
+ const PROFILE_NAMES = [
5317
+ "product",
5318
+ "writing",
5319
+ "instructions",
5320
+ "all"
5321
+ ];
5322
+ const profileFor = (name) => {
5323
+ switch (name) {
5324
+ case "product": return {
5325
+ exclude: [
5326
+ ...artifacts,
5327
+ ...internal,
5328
+ "**/*.{test,spec,stories}.{tsx,jsx}"
5329
+ ],
5330
+ include: ["**/*.{tsx,jsx,css,scss}"],
5331
+ name,
5332
+ objective: "Review application interface copy, typography, interaction and motion"
5333
+ };
5334
+ case "writing": return {
5335
+ exclude: [
5336
+ ...artifacts,
5337
+ ...internal,
5338
+ "**/docs/plans/**"
5339
+ ],
5340
+ include: ["**/*.{md,mdx}"],
5341
+ name,
5342
+ objective: "Review published prose and consumer documentation"
5343
+ };
5344
+ case "instructions": return {
5345
+ exclude: artifacts,
5346
+ include: [...internal, "**/docs/plans/**/*.md"],
5347
+ name,
5348
+ objective: "Review repository instructions, skill authoring and implementation plans"
5349
+ };
5350
+ case "all": return {
5351
+ exclude: artifacts,
5352
+ include: ["**/*"],
5353
+ name,
5354
+ objective: "Audit every supported input with the full rule catalog"
5355
+ };
5356
+ default: throw new InputError("INVALID_PROFILE", `Choose a profile: ${PROFILE_NAMES.join(", ")}`);
5357
+ }
5358
+ };
5359
+ const profileRules = (profile, rules) => rules.filter((rule) => {
5360
+ const document = rule.domain === "authoring" || rule.id.startsWith("copywriting-readme-") || rule.id.startsWith("copywriting-document-");
5361
+ switch (profile.name) {
5362
+ case "all": return true;
5363
+ case "instructions": return document;
5364
+ case "writing": return !document && ["copywriting", "typography"].includes(rule.domain);
5365
+ default: return !document;
5366
+ }
5367
+ });
5368
+ const profileIncludes = (profile, file) => matchesAny(file, profile.include) && !matchesAny(file, profile.exclude);
5369
+ //#endregion
5545
5370
  //#region src/lint.ts
5546
- const runLint = async (options, ctx = {}) => {
5371
+ const runLint = async (options = {}, ctx = {}) => {
5547
5372
  const stderr = ctx.stderr ?? ((t) => process.stderr.write(t));
5548
5373
  const stdout = ctx.stdout ?? ((t) => process.stdout.write(t));
5549
- const config = loadConfig(options.root, options.docTypes);
5374
+ const targets = options.targets ?? ["."];
5375
+ const config = loadConfig(options.root ?? process.cwd());
5550
5376
  const rulesDir = resolveRulesDir(options.rulesDir);
5551
- const rules = loadRules(rulesDir, { only: options.only });
5552
- for (const rule of rules) if (options.advisoryRules?.includes(rule.id)) rule.status = "review-only";
5377
+ const profile = options.profile ? profileFor(options.profile) : void 0;
5378
+ const loaded = loadRules(rulesDir, { only: options.only });
5379
+ const rules = profile && !options.only?.length ? profileRules(profile, loaded) : loaded;
5380
+ if (rules.length === 0) throw new InputError("EMPTY_RULE_SELECTION", "No rules match the selected profile and ids.");
5553
5381
  const model = options.model ?? "jev-latest";
5554
5382
  const resultsDir = options.resultsDir ?? defaultResultsDir();
5555
5383
  const scan = {
5556
5384
  excluded: 0,
5557
5385
  messages: []
5558
5386
  };
5559
- const files = options.targets.length > 0 ? collectFiles(config.root, options.targets, SUPPORTED_GLOBS, [...config.exclude, ...options.exclude ?? []], scan) : [];
5560
- let units = [];
5387
+ const files = targets.length > 0 ? collectFiles(config.root, targets, SUPPORTED_GLOBS, [
5388
+ ...config.exclude,
5389
+ ...options.exclude ?? [],
5390
+ ...profile?.exclude ?? []
5391
+ ], scan).filter((file) => !profile || profileIncludes(profile, file)) : [];
5392
+ const units = [];
5561
5393
  const sources = /* @__PURE__ */ new Map();
5562
- const repository = new Repository(config.root, config.architecture);
5394
+ const repository = new Repository(config.root);
5563
5395
  for (const file of files) {
5564
5396
  const source = fs.readFileSync(path.join(config.root, file), "utf-8");
5565
5397
  units.push(...extractSource(config, file, source, repository));
5566
5398
  sources.set(file, source.split("\n"));
5567
5399
  }
5568
5400
  if (options.extraUnits) units.push(...options.extraUnits);
5569
- if (options.writingContext) {
5570
- const writing = validateWritingContext(options.writingContext);
5571
- for (const unit of units) if (unit.context.docType === "personal") Object.assign(unit.context, {
5572
- writingFacts: writing.facts,
5573
- writingInstructions: writing.instructions,
5574
- writingProfile: writing.profile
5575
- });
5576
- }
5577
- if (options.limitUnits !== void 0) units = units.slice(0, options.limitUnits);
5578
5401
  const scope = {
5579
5402
  byDocType: {},
5580
5403
  diagnostics: scan.messages,
@@ -5585,6 +5408,7 @@ const runLint = async (options, ctx = {}) => {
5585
5408
  for (const u of units) if (!STRUCTURAL_KINDS.includes(u.kind)) scope.byDocType[u.context.docType] = (scope.byDocType[u.context.docType] ?? 0) + 1;
5586
5409
  const parseFailures = new Set(units.filter((unit) => unit.context.parseError || unit.context.mdxFallback || unit.facts?.parseError).map((unit) => unit.file));
5587
5410
  for (const file of parseFailures) scope.diagnostics.push(`Could not fully parse ${file}; analysis is incomplete.`);
5411
+ if (profile) scope.diagnostics.push(`Profile ${profile.name}: ${profile.objective}.`);
5588
5412
  if (units.length === 0) scope.diagnostics.push("No supported units were selected. Check targets and exclusions; no clean-scan verdict is available.");
5589
5413
  const cache = new AnswerCache(path.join(resultsDir, "cache"), !options.noCache);
5590
5414
  const manifest = {
@@ -5597,7 +5421,7 @@ const runLint = async (options, ctx = {}) => {
5597
5421
  rules: rules.map((r) => r.id),
5598
5422
  scope,
5599
5423
  smart_quotes_at_build: config.smartQuotesAtBuild,
5600
- targets: options.targets
5424
+ targets
5601
5425
  };
5602
5426
  const judgeOptions = {
5603
5427
  cache,
@@ -5636,7 +5460,7 @@ const runLint = async (options, ctx = {}) => {
5636
5460
  onProgress: ctx.onProgress,
5637
5461
  recorder
5638
5462
  });
5639
- ctx.onEvidence?.(units, judgement.answers, judgement.negatives);
5463
+ ctx.onEvidence?.(units, judgement.answers, judgement.negatives, rules);
5640
5464
  const status = options.dryRun ? "dry-run" : judgement.usage.errors > 0 || parseFailures.size > 0 || units.length === 0 ? "incomplete" : "complete";
5641
5465
  const jf = jevFindings(judgement.jobs, judgement.answers);
5642
5466
  let findings = [...judgement.mechanical, ...jf.findings];
@@ -5646,7 +5470,12 @@ const runLint = async (options, ctx = {}) => {
5646
5470
  status,
5647
5471
  usage
5648
5472
  });
5649
- const ruleFindings = dedupeExact(applySuppressions(findings, rules, sources));
5473
+ let ruleFindings = dedupeExact(applySuppressions(findings, rules, sources));
5474
+ if (options.since) {
5475
+ const diff = changedLines(config.root, options.since);
5476
+ ruleFindings = ruleFindings.filter((f) => touchesChange(diff, f.file, f.line, f.endLine ?? f.line));
5477
+ scope.diagnostics.push(`Reporting lines changed since ${diff.base}.`);
5478
+ }
5650
5479
  findings = dedupe(ruleFindings);
5651
5480
  if (options.fix && !options.dryRun) applyFixes(config.root, ruleFindings, rules, units, stderr);
5652
5481
  const scorecard = buildScorecard(units, rules, findings, unknowns, jf.silent);
@@ -5790,7 +5619,7 @@ const renderSarif = (result, rules, version) => {
5790
5619
  ruleIndex: index.get(f.ruleId)
5791
5620
  })),
5792
5621
  tool: { driver: {
5793
- informationUri: "https://taste-lint.blode.md",
5622
+ informationUri: "https://blode.co/taste-lint/docs",
5794
5623
  name: "taste-lint",
5795
5624
  rules: ruleList.map((r) => ({
5796
5625
  fullDescription: { text: r.question?.instructions ?? r.fix.hint },
@@ -5835,20 +5664,26 @@ const formatFinding = (f) => {
5835
5664
  return lines.join("\n");
5836
5665
  };
5837
5666
  const scorecardTable = (scorecard) => {
5838
- return ["Scorecard", ...Object.entries(scorecard.byCategory).map(([id, c]) => ` ${id.padEnd(26)} ${String(c.act).padStart(4)} act ${String(c.review).padStart(4)} review ${String(c.suppressed).padStart(3)} suppressed ${String(c.unknown).padStart(4)} unknown`)].join("\n");
5667
+ const rows = Object.entries(scorecard.byCategory).filter(([, c]) => c.act + c.review + c.suppressed + c.unknown > 0).map(([id, c]) => ` ${id.padEnd(26)} ${String(c.act).padStart(4)} act ${String(c.review).padStart(4)} review ${String(c.suppressed).padStart(3)} suppressed ${String(c.unknown).padStart(4)} unknown`);
5668
+ return rows.length ? ["Scorecard", ...rows].join("\n") : "Scorecard: clean";
5839
5669
  };
5840
5670
  const renderTty = (result, options = {}) => {
5841
5671
  const out = [];
5842
5672
  const candidates = result.findings.filter((f) => options.verbose || !f.suppressed);
5843
- const actExamples = candidates.filter((f) => f.band === "act").slice(0, 20);
5844
- const reviewExamples = candidates.filter((f) => f.band === "review").slice(0, 10);
5845
- const visible = options.verbose ? candidates : [...actExamples, ...reviewExamples];
5673
+ const actFindings = candidates.filter((f) => f.band === "act");
5674
+ const reviewFindings = candidates.filter((f) => f.band === "review");
5675
+ const visible = options.verbose ? candidates : actFindings.slice(0, 20);
5846
5676
  if (result.summary) out.push(`Summary: ${result.summary.act} act, ${result.summary.review} review, ${result.summary.unknown} unknown; ${result.summary.failing} failing rule findings (fail-on ${result.summary.failOn}).`);
5847
5677
  if (result.scope) out.push(`Scope: ${result.scope.files} files; ${Object.entries(result.scope.byDocType).map(([type, n]) => `${type}: ${n}`).join(", ")}; ${result.scope.excluded} excluded paths.`, ...result.scope.diagnostics);
5848
5678
  const counts = /* @__PURE__ */ new Map();
5849
5679
  for (const f of result.ruleFindings ?? candidates) if (!f.suppressed) counts.set(f.ruleId, (counts.get(f.ruleId) ?? 0) + 1);
5850
5680
  out.push(`Top rules: ${[...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([id, n]) => `${id}: ${n}`).join(", ")}`);
5851
- if (visible.length < candidates.length) out.push(`${candidates.length - visible.length} findings omitted from this view; use --verbose for every finding.`);
5681
+ if (!options.verbose && actFindings.length > visible.length) out.push(`${actFindings.length - visible.length} more act findings; use --verbose for every finding.`);
5682
+ if (!options.verbose && reviewFindings.length > 0) {
5683
+ const byRule = /* @__PURE__ */ new Map();
5684
+ for (const f of reviewFindings) byRule.set(f.ruleId, (byRule.get(f.ruleId) ?? 0) + 1);
5685
+ out.push(`${reviewFindings.length} review note${reviewFindings.length === 1 ? "" : "s"} from ${byRule.size} rule${byRule.size === 1 ? "" : "s"} (never fail a run; use --verbose to list): ${[...byRule].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([id, n]) => `${id} ${n}`).join(", ")}${byRule.size > 5 ? ", ..." : ""}`);
5686
+ }
5852
5687
  if (result.reportPath) out.push(`Complete report: ${result.reportPath}`);
5853
5688
  if (result.rerun) out.push(`Retry unresolved work: ${result.rerun}`);
5854
5689
  let currentFile = "";
@@ -5879,44 +5714,42 @@ const renderTty = (result, options = {}) => {
5879
5714
  //#region src/commands/lint.ts
5880
5715
  const quote = (value) => `'${value.replaceAll("'", "'\"'\"'")}'`;
5881
5716
  function registerLintCommand(program) {
5882
- 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("--rules <path>", "Rules directory (default: packaged data/rules)").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").option("--writing-context <file>", "Explicit JSON facts/profile/instructions for personal writing; sent to Jev on live runs").option("--no-cache", "Ignore cached answers (new answers are still recorded)").option("--limit-units <n>", "Only consider the first n units").option("--fail-on <severity>", "Lowest severity that fails the run: major or minor", "minor").option("--fix", "Apply deterministic fixes for act-band findings").option("--output <format>", "tty, json or sarif", "tty").option("--results-dir <path>", "Where logs and cache live", defaultResultsDir()).option("--model <id>", "Jev model id", DEFAULT_MODEL).option("--progress", "Print periodic progress on stderr even when piped").option("--verbose", "Show suppressed findings and unknowns").option("--url <url>", "Lint a rendered page through style-capture").option("--selector <css>", "Root selector for --url", "body").option("--capture <file>", "Lint a saved style-capture CaptureResult JSON").action(async (paths, options) => {
5883
- if (!["major", "minor"].includes(options.failOn)) throw new InputError("INVALID_ARGUMENT", "--fail-on must be major or minor");
5884
- if (![
5885
- "tty",
5886
- "json",
5887
- "sarif"
5888
- ].includes(options.output)) throw new InputError("INVALID_ARGUMENT", "--output must be tty, json or sarif");
5889
- if (paths.length === 0 && !options.url && !options.capture) throw new InputError("INVALID_ARGUMENT", "Pass at least one path, --url or --capture");
5890
- const limitUnits = options.limitUnits === void 0 ? void 0 : Number(options.limitUnits);
5891
- if (limitUnits !== void 0 && (!Number.isInteger(limitUnits) || limitUnits < 0)) throw new InputError("INVALID_ARGUMENT", "--limit-units must be a non-negative integer");
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([
5718
+ "tty",
5719
+ "json",
5720
+ "sarif"
5721
+ ]).default("tty")).option("--results-dir <path>", "Where logs and cache live", defaultResultsDir()).option("--model <id>", "Jev model id", DEFAULT_MODEL).option("--progress", "Print periodic progress on stderr even when piped").option("--verbose", "Show suppressed findings and unknowns").option("--url <url>", "Lint a rendered page through style-capture").option("--selector <css>", "Root selector for --url", "body").action(async (paths, options) => {
5722
+ const targets = paths.length === 0 && options.profile ? ["."] : paths;
5723
+ if (targets.length === 0 && !options.url) throw new InputError("INVALID_ARGUMENT", "Pass at least one path, --profile or --url");
5892
5724
  if (options.printRequests && (!options.dryRun || options.output !== "tty")) throw new InputError("INVALID_ARGUMENT", "--print-requests requires --dry-run and --output tty; request JSONL is written to stderr.");
5893
5725
  if (options.progress || process.stderr.isTTY) process.stderr.write("Scanning requested paths...\n");
5894
5726
  const config = { root: path.resolve(options.root) };
5895
- if (options.url || options.capture) {
5727
+ if (options.url) {
5896
5728
  const resolved = loadConfig(config.root);
5897
- collectFiles(resolved.root, paths, SUPPORTED_GLOBS, [...resolved.exclude, ...options.exclude?.split(",").map((s) => s.trim()).filter(Boolean) ?? []]);
5729
+ collectFiles(resolved.root, targets, SUPPORTED_GLOBS, [...resolved.exclude, ...options.exclude?.split(",").map((s) => s.trim()).filter(Boolean) ?? []]);
5898
5730
  }
5899
- let extraUnits;
5900
- if (options.capture) extraUnits = extractCapture(parseCaptureInput(fs.readFileSync(options.capture, "utf-8")), options.capture);
5901
- else if (options.url) extraUnits = extractCapture(await runStyleCapture(options.url, options.selector), options.url);
5731
+ const extraUnits = options.url ? extractCapture(await runStyleCapture(options.url, options.selector), options.url) : void 0;
5902
5732
  let lastProgress = 0;
5733
+ let samples = [];
5903
5734
  const result = await runLint({
5904
5735
  dryRun: options.dryRun,
5905
5736
  exclude: options.exclude?.split(",").map((s) => s.trim()).filter(Boolean),
5906
5737
  extraUnits,
5907
5738
  failOn: options.failOn,
5908
5739
  fix: options.fix,
5909
- limitUnits,
5910
5740
  model: options.model,
5911
5741
  noCache: !options.cache,
5912
5742
  only: options.only?.split(",").map((s) => s.trim()).filter(Boolean),
5913
5743
  printRequests: options.printRequests,
5744
+ profile: options.profile,
5914
5745
  resultsDir: options.resultsDir,
5915
5746
  root: options.root,
5916
- rulesDir: options.rules,
5917
- targets: paths,
5918
- writingContext: options.writingContext ? readWritingContext(options.writingContext) : void 0
5747
+ since: options.since,
5748
+ targets
5919
5749
  }, {
5750
+ onEvidence: options.samples ? (units, answers, negatives, rules) => {
5751
+ samples = makeSamples(units, rules, answers, negatives, 3, path.basename(config.root));
5752
+ } : void 0,
5920
5753
  onProgress: (progress) => {
5921
5754
  if (!(options.progress || process.stderr.isTTY)) return;
5922
5755
  const now = Date.now();
@@ -5942,15 +5775,18 @@ function registerLintCommand(program) {
5942
5775
  "--output",
5943
5776
  options.output
5944
5777
  ];
5945
- if (options.rules) retryArgs.push("--rules", path.resolve(options.rules));
5946
5778
  if (options.only) retryArgs.push("--only", options.only);
5947
5779
  if (options.exclude) retryArgs.push("--exclude", options.exclude);
5948
- if (options.limitUnits) retryArgs.push("--limit-units", options.limitUnits);
5949
5780
  if (options.url) retryArgs.push("--url", options.url, "--selector", options.selector);
5950
- if (options.capture) retryArgs.push("--capture", path.resolve(options.capture));
5951
- if (options.writingContext) retryArgs.push("--writing-context", path.resolve(options.writingContext));
5952
- retryArgs.push("--", ...paths);
5781
+ if (options.profile) retryArgs.push("--profile", options.profile);
5782
+ if (options.since) retryArgs.push("--since", options.since);
5783
+ retryArgs.push("--", ...targets);
5953
5784
  const rerun = result.status === "incomplete" ? retryArgs.map(quote).join(" ") : void 0;
5785
+ 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.",
5787
+ samples,
5788
+ version: 2
5789
+ });
5954
5790
  const reported = {
5955
5791
  ...result,
5956
5792
  reportPath,
@@ -5968,76 +5804,9 @@ function registerLintCommand(program) {
5968
5804
  });
5969
5805
  }
5970
5806
  //#endregion
5971
- //#region src/rules/discover.ts
5972
- const discoverSkills = (root, rules) => {
5973
- const absolute = path.resolve(root);
5974
- if (!fs.existsSync(absolute) || !fs.statSync(absolute).isDirectory()) throw new InputError("INVALID_SKILL_SOURCE", `Skill source must be a directory: ${absolute}`);
5975
- const entries = [];
5976
- const visit = (dir, owner, skillRoot) => {
5977
- const skill = fs.existsSync(path.join(dir, "SKILL.md")) ? path.basename(dir) : owner;
5978
- const base = skill !== owner ? dir : skillRoot;
5979
- for (const entry of fs.readdirSync(dir, { withFileTypes: true }).toSorted((a, b) => a.name.localeCompare(b.name))) {
5980
- if (entry.isSymbolicLink() || [
5981
- "node_modules",
5982
- ".git",
5983
- "dist",
5984
- "evals",
5985
- "evaluations",
5986
- "scripts",
5987
- "agents"
5988
- ].includes(entry.name)) continue;
5989
- const file = path.join(dir, entry.name);
5990
- if (entry.isDirectory()) {
5991
- visit(file, skill, base);
5992
- continue;
5993
- }
5994
- if (!skill || !base || !entry.name.endsWith(".md") || /evaluation-scenarios/.test(entry.name)) continue;
5995
- const text = fs.readFileSync(file, "utf-8");
5996
- const sections = text.split("\n").flatMap((line, i) => /^#{1,3} /.test(line) ? [{
5997
- line: i + 1,
5998
- title: line.replace(/^#+ /, "")
5999
- }] : []);
6000
- let title = sections[0]?.title ?? entry.name;
6001
- let diagnostic;
6002
- const front = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
6003
- if (front) try {
6004
- const value = parse(front[1]);
6005
- if (typeof value?.title === "string") title = value.title;
6006
- } catch {
6007
- diagnostic = "Frontmatter could not be parsed; retained for manual triage";
6008
- }
6009
- const relative = path.relative(base, file).split(path.sep).join("/");
6010
- const canonical = `skills/${skill}/${relative}`;
6011
- const citedBy = rules.filter((r) => r.source.path === canonical).map((r) => r.id);
6012
- const kind = entry.name === "SKILL.md" ? "entrypoint" : /^rules(?:-arch|-ax)?\//.test(relative) && !entry.name.startsWith("_") ? "rule" : "guidance";
6013
- entries.push({
6014
- citedBy,
6015
- diagnostic,
6016
- kind,
6017
- path: file,
6018
- sections,
6019
- skill,
6020
- status: citedBy.length ? "cited" : "needs-triage",
6021
- title
6022
- });
6023
- }
6024
- };
6025
- visit(absolute);
6026
- return entries;
6027
- };
6028
- //#endregion
6029
5807
  //#region src/commands/rules.ts
6030
5808
  function registerRulesCommand(program) {
6031
5809
  const rules = program.command("rules").description("Inspect and validate the rule files");
6032
- rules.command("discover <sources...>").description("Inventory skill entrypoints, rule folders, and guidance without activating rules").option("--rules <path>", "Rules directory used for citation coverage").action((sources, options) => {
6033
- const loaded = loadRules(resolveRulesDir(options.rules));
6034
- const inventory = sources.flatMap((source) => discoverSkills(source, loaded));
6035
- process.stdout.write(`${JSON.stringify({
6036
- inventory,
6037
- note: "Citations are provenance, not complete semantic coverage. Uncited sources need triage.",
6038
- sources
6039
- }, null, 2)}\n`);
6040
- });
6041
5810
  rules.command("list").description("List rules with tier, status and category").option("--rules <path>", "Rules directory").option("--allow-draft", "Include draft rules").action((options) => {
6042
5811
  const list = loadRules(resolveRulesDir(options.rules), { allowDraft: options.allowDraft ?? false });
6043
5812
  for (const r of list) process.stdout.write(`${r.id.padEnd(44)} ${r.tier.padEnd(10)} ${r.status.padEnd(11)} ${r.severity.padEnd(8)} ${r.categoryId}\n`);
@@ -6057,704 +5826,6 @@ function registerRulesCommand(program) {
6057
5826
  });
6058
5827
  }
6059
5828
  //#endregion
6060
- //#region src/scan/profiles.ts
6061
- const productRules = /* @__PURE__ */ new Set([
6062
- "copywriting-vague-error",
6063
- "copywriting-empty-state-no-action",
6064
- "copywriting-bare-confirm-label",
6065
- "copywriting-claim-without-evidence",
6066
- "interaction-no-error-state",
6067
- "motion-transition-all"
6068
- ]);
6069
- const artifacts = [
6070
- "**/node_modules/**",
6071
- "**/dist/**",
6072
- "**/.next/**",
6073
- "**/results/**",
6074
- "**/docs/archive/**",
6075
- "**/.captain/**"
6076
- ];
6077
- const stylePreferences = [
6078
- "copywriting-long-sentence",
6079
- "typography-straight-quotes",
6080
- "typography-ellipsis",
6081
- "typography-dashes",
6082
- "typography-midpoint-separators",
6083
- "typography-line-height-out-of-band"
6084
- ];
6085
- const internal = [
6086
- "**/.claude/**",
6087
- "**/.agents/**",
6088
- "**/skills/**",
6089
- "**/AGENTS.md",
6090
- "**/CLAUDE.md",
6091
- "**/SKILL.md"
6092
- ];
6093
- const PROFILE_NAMES = [
6094
- "product",
6095
- "writing",
6096
- "instructions",
6097
- "architecture",
6098
- "discovery",
6099
- "all"
6100
- ];
6101
- const profileFor = (name) => {
6102
- if (!PROFILE_NAMES.includes(name)) throw new InputError("INVALID_PROFILE", `Choose a scan profile: ${PROFILE_NAMES.join(", ")}`);
6103
- const common = {
6104
- advisory: [],
6105
- docTypes: [],
6106
- exclude: artifacts,
6107
- name
6108
- };
6109
- switch (name) {
6110
- case "discovery": return {
6111
- ...common,
6112
- exclude: [
6113
- ...artifacts,
6114
- "**/supabase/templates/**",
6115
- "**/emails/**",
6116
- "**/email/**"
6117
- ],
6118
- include: [
6119
- "**/*.html",
6120
- "**/*.htm",
6121
- "**/robots.txt",
6122
- "**/llms.txt",
6123
- "**/llms-full.txt"
6124
- ],
6125
- objective: "Validate static search and agent discovery artifacts"
6126
- };
6127
- case "product": return {
6128
- ...common,
6129
- advisory: stylePreferences,
6130
- exclude: [
6131
- ...artifacts,
6132
- ...internal,
6133
- "**/*.{test,spec,stories}.{tsx,jsx}"
6134
- ],
6135
- include: ["**/*.{tsx,jsx,css,scss}"],
6136
- objective: "Review application interface copy, typography, interaction and motion"
6137
- };
6138
- case "writing": return {
6139
- ...common,
6140
- advisory: stylePreferences,
6141
- docTypes: [{
6142
- glob: "**/content/writing/**/*.{md,mdx}",
6143
- type: "personal"
6144
- }],
6145
- exclude: [
6146
- ...artifacts,
6147
- ...internal,
6148
- "**/docs/plans/**"
6149
- ],
6150
- include: [
6151
- "**/content/**/*.{md,mdx}",
6152
- "**/README.md",
6153
- "**/public/**/*.md",
6154
- "**/docs/**/*.{md,mdx}"
6155
- ],
6156
- objective: "Review published prose and consumer documentation"
6157
- };
6158
- case "instructions": return {
6159
- ...common,
6160
- advisory: stylePreferences,
6161
- include: [...internal, "**/docs/plans/**/*.md"],
6162
- objective: "Review repository instructions, skill authoring, and implementation plans"
6163
- };
6164
- case "architecture": return {
6165
- ...common,
6166
- exclude: [...artifacts, ...internal],
6167
- include: ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts,json}"],
6168
- objective: "Check declared package and repository contracts"
6169
- };
6170
- default: return {
6171
- ...common,
6172
- exclude: [],
6173
- include: ["**/*"],
6174
- objective: "Audit all supported inputs with the full rule catalog"
6175
- };
6176
- }
6177
- };
6178
- const profileRules = (profile, rules, explicit = false) => rules.filter((rule) => {
6179
- if (profile.name === "all") return true;
6180
- if (profile.name === "discovery") return rule.domain === "seo" || rule.id.startsWith("authoring-llms-");
6181
- if (profile.name === "architecture") return ["architecture", "dx"].includes(rule.domain);
6182
- if (profile.name === "instructions") return rule.domain === "authoring" || rule.id.startsWith("copywriting-document-");
6183
- if (profile.name === "writing") return ["copywriting", "typography"].includes(rule.domain);
6184
- if (profile.name === "product" && !explicit) return productRules.has(rule.id);
6185
- return ![
6186
- "architecture",
6187
- "dx",
6188
- "authoring"
6189
- ].includes(rule.domain) && !rule.id.startsWith("copywriting-readme-") && !rule.id.startsWith("copywriting-document-");
6190
- });
6191
- const profileIncludes = (profile, file) => matchesAny(file, profile.include) && !matchesAny(file, profile.exclude);
6192
- //#endregion
6193
- //#region src/scan/storage.ts
6194
- const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
6195
- const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
6196
- const readJson = (file) => {
6197
- try {
6198
- return JSON.parse(fs.readFileSync(file, "utf-8"));
6199
- } catch {
6200
- throw new InputError("INVALID_SCAN_INPUT", `Cannot read JSON input: ${file}`);
6201
- }
6202
- };
6203
- const writeJson = (file, value) => {
6204
- fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
6205
- const temp = `${file}.${randomUUID()}.tmp`;
6206
- try {
6207
- fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
6208
- fs.renameSync(temp, file);
6209
- } finally {
6210
- fs.rmSync(temp, { force: true });
6211
- }
6212
- };
6213
- //#endregion
6214
- //#region src/scan/report.ts
6215
- const identify = (findings, units) => {
6216
- const byId = new Map(units.map((u) => [u.id, u]));
6217
- const occurrences = /* @__PURE__ */ new Map();
6218
- return findings.map((finding) => {
6219
- const unit = byId.get(finding.unitId);
6220
- const text = unit?.kind === "source" ? unit.text.split("\n")[finding.line - 1] ?? finding.evidence : unit?.text ?? finding.evidence;
6221
- const key = hash([
6222
- finding.ruleId,
6223
- finding.file,
6224
- unit?.kind,
6225
- normaliseText(text)
6226
- ]);
6227
- const occurrence = occurrences.get(key) ?? 0;
6228
- occurrences.set(key, occurrence + 1);
6229
- return {
6230
- ...finding,
6231
- context: unit?.context.section,
6232
- excerpt: text.slice(0, 1200),
6233
- fingerprint: hash([key, occurrence]),
6234
- lifecycle: "new",
6235
- origin: "taste-lint"
6236
- };
6237
- });
6238
- };
6239
- const readReport = (file) => {
6240
- const raw = readJson(file);
6241
- if (!object(raw) || raw.kind !== "taste-lint-scan" || raw.version !== 1 || typeof raw.signature !== "string" || typeof raw.root !== "string" || ![
6242
- "complete",
6243
- "incomplete",
6244
- "dry-run"
6245
- ].includes(String(raw.status)) || !Array.isArray(raw.findings) || !Array.isArray(raw.files) || !object(raw.profile) || !object(raw.reporting) || !Array.isArray(raw.reporting.visible) || !raw.reporting.visible.every((id) => typeof id === "string") || !raw.files.every((item) => typeof item === "string") || new Set(raw.findings.map((f) => object(f) ? f.fingerprint : void 0)).size !== raw.findings.length || !raw.findings.every((f) => object(f) && typeof f.fingerprint === "string" && /^[a-f0-9]{64}$/.test(f.fingerprint) && typeof f.file === "string" && typeof f.ruleId === "string" && typeof f.excerpt === "string" && (f.context === void 0 || typeof f.context === "string") && (f.assessment === void 0 || f.assessment === "candidate") && (f.review === void 0 || reviewProcedure(f.review, file) !== void 0) && typeof f.fixHint === "string" && Number.isInteger(f.line) && Number.isInteger(f.endLine) && typeof f.message === "string")) throw new InputError("INVALID_SCAN_REPORT", "Expected a version 1 taste-lint scan report.");
6246
- return raw;
6247
- };
6248
- const readDecisions = (file, signature) => {
6249
- if (!file) return {};
6250
- const raw = readJson(file);
6251
- if (!object(raw) || raw.version !== 1 || !object(raw.decisions)) throw new InputError("INVALID_DECISIONS", "Expected version 1 decisions with a decisions map.");
6252
- if (signature && raw.signature !== signature) throw new InputError("INCOMPATIBLE_DECISIONS", "Review decisions belong to a different scan policy. Review them again for this profile and rule set.");
6253
- for (const [key, value] of Object.entries(raw.decisions)) if (!/^[a-f0-9]{64}$/.test(key) || !object(value) || ![
6254
- "dismissed",
6255
- "accepted",
6256
- "open"
6257
- ].includes(String(value.status)) || typeof value.reason !== "string" || !value.reason.trim()) throw new InputError("INVALID_DECISIONS", "Every decision needs a fingerprint, accepted/dismissed status, and reason.");
6258
- return raw.decisions;
6259
- };
6260
- const reconcile = (current, previous, complete, unknowns, decisions) => {
6261
- const old = new Map(previous?.findings.map((f) => [f.fingerprint, f]));
6262
- const ids = new Set(current.map((f) => f.fingerprint));
6263
- for (const finding of current) {
6264
- finding.lifecycle = old.has(finding.fingerprint) ? "existing" : "new";
6265
- finding.decision = decisions[finding.fingerprint] ?? old.get(finding.fingerprint)?.decision;
6266
- }
6267
- return [...old.values()].filter((f) => !ids.has(f.fingerprint)).map((f) => ({
6268
- ...f,
6269
- lifecycle: complete && !unknowns.some((u) => u.file === f.file && u.ruleId === f.ruleId) ? "resolved" : "unverified"
6270
- }));
6271
- };
6272
- const priority = (a, b) => Number(b.band === "act") - Number(a.band === "act") || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || b.probability - a.probability || a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file) || a.line - b.line;
6273
- const renderScan = (report, limit = 5) => {
6274
- const visible = new Set(report.reporting.visible);
6275
- const groups = /* @__PURE__ */ new Map();
6276
- for (const finding of report.findings.filter((f) => visible.has(f.fingerprint))) {
6277
- const list = groups.get(finding.ruleId) ?? [];
6278
- list.push(finding);
6279
- groups.set(finding.ruleId, list);
6280
- }
6281
- const ranked = [...groups.values()].map((group) => group.toSorted(priority)).toSorted((a, b) => priority(a[0], b[0]));
6282
- const s = report.summary;
6283
- const coverage = Object.values(report.coverage?.byRule ?? {});
6284
- return [
6285
- `${report.profile.name}: ${report.status}; ${report.files.length} files`,
6286
- `${s.visible} visible checks (${s.failing} failing, ${s.review} review); ${s.total} total; ${s.dismissed} dismissed; ${s.unknown} unknown`,
6287
- ...report.diagnostics,
6288
- `Rule coverage: ${coverage.filter((r) => r.eligible === 0).length} with no applicable inputs; ${coverage.filter((r) => r.eligible > 0 && r.negative === r.eligible).length} with all applicable checks negative; ${coverage.filter((r) => r.pending > 0).length} with pending evaluations`,
6289
- `${s.new} new; ${s.existing} existing; ${s.resolved} resolved; ${s.unverified} unverified`,
6290
- ...ranked.slice(0, limit).map((group) => `${group[0].ruleId}: ${group.length} checks in ${new Set(group.map((f) => f.file)).size} files\n ${group[0].file}:${group[0].line} ${group[0].message}\n ${group[0].evidence}\n ${group[0].fixHint}${group[0].review ? `\n Requires: ${group[0].review.evidence}\n Verify: ${group[0].review.verification}` : ""}`),
6291
- ...ranked.length > limit ? [`${ranked.length - limit} further rule groups are available in the JSON report.`] : [],
6292
- ...report.estimated ? [`Estimated live work: ${report.estimated.requests} requests, $${report.estimated.costUsd.toFixed(4)}`] : [],
6293
- ""
6294
- ].join("\n");
6295
- };
6296
- //#endregion
6297
- //#region src/scan/architecture.ts
6298
- const invalid = () => {
6299
- throw new InputError("INVALID_GRAPH_REPORT", "Expected dependency-cruiser JSON with modules and summary.violations, using repository-relative paths.");
6300
- };
6301
- const local = (value) => typeof value === "string" && !!value && !path.isAbsolute(value) && !value.includes("\\") && !value.split("/").includes("..") && !value.includes("\0");
6302
- const importArchitecture = (file) => {
6303
- const raw = readJson(file);
6304
- if (!object(raw) || !Array.isArray(raw.modules) || !object(raw.summary) || !Array.isArray(raw.summary.violations)) return invalid();
6305
- const modules = [];
6306
- let incomplete = false;
6307
- for (const mod of raw.modules) {
6308
- if (!object(mod) || !local(mod.source)) return invalid();
6309
- modules.push(mod.source);
6310
- if (mod.couldNotResolve === true || Array.isArray(mod.dependencies) && mod.dependencies.some((d) => object(d) && d.couldNotResolve === true)) incomplete = true;
6311
- }
6312
- if (object(raw.summary.environment) && Array.isArray(raw.summary.environment.issues) && raw.summary.environment.issues.length) incomplete = true;
6313
- incomplete ||= modules.length === 0;
6314
- const findings = [];
6315
- for (const v of raw.summary.violations) {
6316
- if (!object(v) || !object(v.rule) || typeof v.rule.name !== "string" || !local(v.from) || typeof v.to !== "string" || ![
6317
- "error",
6318
- "warn",
6319
- "info",
6320
- "ignore"
6321
- ].includes(String(v.rule.severity))) return invalid();
6322
- if (v.rule.severity === "ignore") continue;
6323
- const ruleId = `dependency-cruiser/${v.rule.name}`;
6324
- const excerpt = `${v.from} -> ${v.to}`;
6325
- findings.push({
6326
- band: v.rule.severity === "error" ? "act" : "review",
6327
- categoryId: "repository-contracts",
6328
- column: 1,
6329
- domain: "architecture",
6330
- endColumn: 1,
6331
- endLine: 1,
6332
- evidence: excerpt,
6333
- excerpt,
6334
- file: v.from,
6335
- fingerprint: hash([
6336
- ruleId,
6337
- v.from,
6338
- v.to,
6339
- v.cycle,
6340
- v.via
6341
- ]),
6342
- fixHint: typeof v.rule.comment === "string" ? v.rule.comment : "Follow the declared dependency policy; rerun dependency-cruiser with the same configuration.",
6343
- lifecycle: "new",
6344
- line: 1,
6345
- message: v.rule.name,
6346
- origin: "dependency-cruiser",
6347
- probability: 1,
6348
- ruleId,
6349
- severity: v.rule.severity === "error" ? "major" : "minor",
6350
- suppressed: false,
6351
- tier: "mechanical",
6352
- unitId: hash([v.from, v.to])
6353
- });
6354
- }
6355
- return {
6356
- contentHash: hash(raw),
6357
- digest: hash([
6358
- raw.summary.optionsUsed ?? {},
6359
- raw.summary.ruleSetUsed ?? {},
6360
- raw.summary.environment && object(raw.summary.environment) ? raw.summary.environment.version : void 0
6361
- ]),
6362
- findings,
6363
- incomplete,
6364
- modules
6365
- };
6366
- };
6367
- //#endregion
6368
- //#region src/scan/git.ts
6369
- const git = (root, args) => {
6370
- try {
6371
- return execFileSync("git", [
6372
- "-C",
6373
- root,
6374
- ...args
6375
- ], {
6376
- encoding: "utf-8",
6377
- maxBuffer: 33554432,
6378
- stdio: [
6379
- "ignore",
6380
- "pipe",
6381
- "pipe"
6382
- ]
6383
- });
6384
- } catch {
6385
- throw new InputError("INVALID_GIT_BASE", "Cannot compare Git revisions. Use a valid base in the scan repository.");
6386
- }
6387
- };
6388
- const changedLines = (root, ref) => {
6389
- const base = git(root, [
6390
- "rev-parse",
6391
- "--verify",
6392
- "--end-of-options",
6393
- `${ref}^{commit}`
6394
- ]).trim();
6395
- const names = git(root, [
6396
- "diff",
6397
- "--relative",
6398
- "--no-ext-diff",
6399
- "--no-textconv",
6400
- "--no-renames",
6401
- "--name-only",
6402
- "-z",
6403
- base,
6404
- "--"
6405
- ]).split("\0").filter(Boolean);
6406
- const ranges = /* @__PURE__ */ new Map();
6407
- for (const file of names) {
6408
- const patch = git(root, [
6409
- "diff",
6410
- "--relative",
6411
- "--no-ext-diff",
6412
- "--no-textconv",
6413
- "--no-renames",
6414
- "--unified=0",
6415
- base,
6416
- "--",
6417
- file
6418
- ]);
6419
- const lines = [];
6420
- for (const match of patch.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
6421
- const start = Number(match[1]);
6422
- const length = Number(match[2] ?? 1);
6423
- if (length > 0) lines.push([start, start + length - 1]);
6424
- }
6425
- ranges.set(file, lines);
6426
- }
6427
- return {
6428
- base,
6429
- ranges,
6430
- untracked: new Set(git(root, [
6431
- "ls-files",
6432
- "--others",
6433
- "--exclude-standard",
6434
- "-z"
6435
- ]).split("\0").filter(Boolean))
6436
- };
6437
- };
6438
- const touchesChange = (diff, file, start, end) => diff.untracked.has(file) || (diff.ranges.get(file) ?? []).some(([a, b]) => start <= b && end >= a);
6439
- //#endregion
6440
- //#region src/scan/samples.ts
6441
- const makeSamples = (units, rules, answers, negatives, perStratum = 3, sourceRepo = "scan") => {
6442
- const buckets = /* @__PURE__ */ new Map();
6443
- const seen = /* @__PURE__ */ new Set();
6444
- const semanticRules = rules.filter((rule) => rule.question);
6445
- for (const unit of units) for (const rule of semanticRules) {
6446
- const probability = answers.get(unit.id)?.[rule.id] ?? (negatives.get(unit.id)?.has(rule.id) ? 0 : void 0);
6447
- if (probability === void 0) continue;
6448
- const { docType, role, headingAbove } = unit.context;
6449
- const context = {
6450
- docType,
6451
- headingAbove,
6452
- role,
6453
- ...Object.fromEntries((rule.question.context ?? []).filter((key) => key !== "neighbours").map((key) => [key, unit.context[key]]))
6454
- };
6455
- const rubric = buildQuestion(rule);
6456
- const neighbours = rule.question.context?.includes("neighbours") ? unit.neighbours : void 0;
6457
- const evidence = [
6458
- rule.id,
6459
- unit.text,
6460
- context,
6461
- neighbours,
6462
- unit.classes,
6463
- rubric
6464
- ];
6465
- const unique = hash(evidence);
6466
- if (seen.has(unique)) continue;
6467
- seen.add(unique);
6468
- const id = `scan-${hash([
6469
- sourceRepo,
6470
- unit.file,
6471
- ...evidence
6472
- ]).slice(0, 24)}`;
6473
- const sample = {
6474
- criterion: rule.question.instructions,
6475
- id,
6476
- item: {
6477
- categoryId: rule.categoryId,
6478
- classes: unit.classes,
6479
- context,
6480
- id,
6481
- kind: unit.kind,
6482
- neighbours,
6483
- source: {
6484
- path: unit.file,
6485
- repo: sourceRepo
6486
- },
6487
- split: splitFor(`${sourceRepo}/${unit.file}`),
6488
- text: unit.text
6489
- },
6490
- label: null,
6491
- rubric,
6492
- ruleId: rule.id
6493
- };
6494
- const bucket = `${rule.id}:${docType}:${probability < rule.thresholds.review ? "negative" : probability < rule.thresholds.act ? "review" : "high"}`;
6495
- const list = buckets.get(bucket) ?? [];
6496
- list.push(sample);
6497
- buckets.set(bucket, list);
6498
- }
6499
- return [...buckets.values()].flatMap((list) => list.toSorted((a, b) => a.id.localeCompare(b.id)).slice(0, perStratum)).toSorted((a, b) => a.id.localeCompare(b.id));
6500
- };
6501
- const labelsToCorpus = (file, destination, rulesDir) => {
6502
- const raw = readJson(file);
6503
- if (!object(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.");
6504
- if (raw.completed !== void 0 && raw.completed !== raw.samples.length) throw new InputError("INVALID_LABELS", "Labeling is incomplete. Review every sample before importing.");
6505
- const annotation = raw.annotation;
6506
- const ai = object(annotation) && annotation.source === "ai";
6507
- 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.");
6508
- const ids = /* @__PURE__ */ new Set();
6509
- const items = [];
6510
- for (const row of raw.samples) {
6511
- if (!object(row) || typeof row.id !== "string" || ids.has(row.id) || typeof row.ruleId !== "string" || raw.version === 2 && (!object(row.rubric) || row.rubric.type !== "noul" || typeof row.criterion !== "string" || row.rubric.instructions !== row.criterion) || !object(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.");
6512
- ids.add(row.id);
6513
- if (row.label === null) continue;
6514
- items.push({
6515
- ...row.item,
6516
- labelSource: ai ? "ai" : "hand",
6517
- ...ai ? {
6518
- labelModel: annotation.model,
6519
- labelPromptHash: annotation.promptHash
6520
- } : {},
6521
- labels: { [row.ruleId]: row.label }
6522
- });
6523
- }
6524
- if (!items.length) throw new InputError("INVALID_LABELS", "No labels supplied. Set label to true or false after review.");
6525
- const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "taste-labels-"));
6526
- try {
6527
- fs.writeFileSync(path.join(temporary, "labels.jsonl"), items.map((item) => JSON.stringify(item)).join("\n"));
6528
- loadCorpus(temporary, loadRules(resolveRulesDir(rulesDir), { allowDraft: true }));
6529
- } finally {
6530
- fs.rmSync(temporary, {
6531
- force: true,
6532
- recursive: true
6533
- });
6534
- }
6535
- fs.writeFileSync(destination, `${items.map((item) => JSON.stringify(item)).join("\n")}\n`, { flag: "wx" });
6536
- return items.length;
6537
- };
6538
- //#endregion
6539
- //#region src/scan/run.ts
6540
- const runScan = async (options, ctx = {}) => {
6541
- if (options.newOnly && !options.baseline) throw new InputError("INVALID_ARGUMENT", "--new-only requires --baseline.");
6542
- const root = fs.realpathSync(loadConfig(options.root).root);
6543
- const profile = profileFor(options.profile ?? "product");
6544
- const config = loadConfig(root, profile.docTypes);
6545
- const exclude = [
6546
- ...config.exclude,
6547
- ...options.exclude ?? [],
6548
- ...profile.exclude
6549
- ];
6550
- const files = collectFiles(root, options.targets, SUPPORTED_GLOBS, exclude).filter((file) => profileIncludes(profile, file));
6551
- const rules = profileRules(profile, loadRules(resolveRulesDir(options.rulesDir), { only: options.only }), Boolean(options.only?.length || options.rulesDir));
6552
- for (const rule of rules) if (profile.advisory.includes(rule.id)) rule.status = "review-only";
6553
- if (!rules.length) throw new InputError("EMPTY_RULE_SELECTION", "No rules match the selected profile and IDs.");
6554
- const graph = options.dependencyCruiser ? importArchitecture(options.dependencyCruiser) : void 0;
6555
- const signature = hash({
6556
- config: {
6557
- ...config,
6558
- root: void 0
6559
- },
6560
- engineVersion: version,
6561
- exclude,
6562
- graph: graph?.digest,
6563
- model: options.model ?? "jev-latest",
6564
- profile,
6565
- root,
6566
- rules: rules.map(({ file: _file, check, ...rule }) => ({
6567
- ...rule,
6568
- check: check?.toString()
6569
- })),
6570
- targets: options.targets.toSorted()
6571
- });
6572
- const baseline = options.baseline ? readReport(options.baseline) : void 0;
6573
- if (baseline && (baseline.signature !== signature || baseline.root !== root || baseline.status !== "complete")) throw new InputError("INCOMPATIBLE_BASELINE", "Baseline must be a complete scan of this root with the same profile, targets, rules, model and evaluation mode.");
6574
- const decisions = readDecisions(options.decisions, signature);
6575
- const diff = options.since ? changedLines(root, options.since) : void 0;
6576
- let units = [];
6577
- let samples = [];
6578
- const result = await runLint({
6579
- advisoryRules: profile.advisory,
6580
- docTypes: profile.docTypes,
6581
- dryRun: options.dryRun,
6582
- exclude,
6583
- model: options.model,
6584
- only: rules.map((r) => r.id),
6585
- resultsDir: options.resultsDir,
6586
- root,
6587
- rulesDir: options.rulesDir,
6588
- targets: files
6589
- }, {
6590
- ...ctx,
6591
- onEvidence: (u, answers, negatives) => {
6592
- units = u;
6593
- if (options.samples) samples = makeSamples(u, rules, answers, negatives, 3, root);
6594
- ctx.onEvidence?.(u, answers, negatives);
6595
- }
6596
- });
6597
- const findings = identify((result.ruleFindings ?? []).filter((f) => !f.suppressed), units);
6598
- for (const finding of graph?.findings ?? []) if (files.includes(finding.file)) findings.push(finding);
6599
- const unresolvedFiles = new Set(units.filter((u) => u.context.parseError || u.facts?.parseError).map((u) => u.file));
6600
- const complete = result.status === "complete" && files.length > 0 && !graph?.incomplete;
6601
- const resolved = reconcile(findings, baseline, complete, result.unknowns, decisions);
6602
- for (const finding of resolved) if (unresolvedFiles.has(finding.file) || finding.origin === "dependency-cruiser" && !graph?.modules.includes(finding.file)) finding.lifecycle = "unverified";
6603
- const visible = findings.filter((f) => f.decision?.status !== "dismissed" && (!options.newOnly || f.lifecycle === "new") && (!diff || (f.origin === "dependency-cruiser" ? diff.untracked.has(f.file) || (diff.ranges.get(f.file)?.length ?? 0) > 0 : touchesChange(diff, f.file, f.line, f.endLine))));
6604
- const failing = visible.filter((f) => f.band === "act" && SEVERITY_RANK[f.severity] <= SEVERITY_RANK.minor).length;
6605
- return {
6606
- report: {
6607
- architecture: graph ? {
6608
- contentHash: graph.contentHash,
6609
- incomplete: graph.incomplete,
6610
- modules: graph.modules.length
6611
- } : void 0,
6612
- coverage: result.coverage,
6613
- diagnostics: [
6614
- ...profile.name === "product" && !options.only?.length && !options.rulesDir ? ["Focused product checks; appearance preferences and experimental ports require --only or --profile all. This is not a complete UI audit."] : [],
6615
- ...result.scope?.diagnostics ?? [],
6616
- ...graph?.incomplete ? ["Imported dependency graph contains unresolved edges or environment issues."] : []
6617
- ],
6618
- estimated: result.estimated,
6619
- exitCode: result.status === "incomplete" || files.length === 0 || graph?.incomplete ? 2 : failing > 0 ? 1 : 0,
6620
- files,
6621
- findings,
6622
- kind: "taste-lint-scan",
6623
- profile,
6624
- reporting: {
6625
- since: diff?.base,
6626
- visible: visible.map((f) => f.fingerprint)
6627
- },
6628
- resolved,
6629
- root,
6630
- signature,
6631
- status: !files.length || graph?.incomplete ? "incomplete" : result.status,
6632
- summary: {
6633
- dismissed: findings.filter((f) => f.decision?.status === "dismissed").length,
6634
- existing: findings.filter((f) => f.lifecycle === "existing").length,
6635
- failing,
6636
- new: findings.filter((f) => f.lifecycle === "new").length,
6637
- resolved: resolved.filter((f) => f.lifecycle === "resolved").length,
6638
- review: visible.filter((f) => f.band === "review").length,
6639
- total: findings.length,
6640
- unknown: result.unknowns.length,
6641
- unverified: resolved.filter((f) => f.lifecycle === "unverified").length,
6642
- visible: visible.length
6643
- },
6644
- unknowns: result.unknowns,
6645
- usage: result.usage,
6646
- version: 1
6647
- },
6648
- rules,
6649
- samples
6650
- };
6651
- };
6652
- //#endregion
6653
- //#region src/commands/scan.ts
6654
- const registerScanCommand = (program) => {
6655
- const scan = program.command("scan").enablePositionalOptions().description("Run a scoped scan with stable findings, baselines, and review exports").argument("[paths...]", "Targets within the root; defaults to .").option("--root <path>", "Repository root", process.cwd()).option("--profile <name>", `Scan objective: ${PROFILE_NAMES.join(", ")}`, "product").option("--rules <path>", "Rules directory").option("--only <ids>", "Comma-separated rule IDs within the selected profile").option("--exclude <globs>", "Additional comma-separated exclusions").option("--dry-run", "Preview scope and cost without provider calls").option("--model <id>", "Evaluation model", "jev-latest").option("--results-dir <path>", "Answer cache and sanitized request records").option("--baseline <file>", "Compare a compatible completed scan").option("--new-only", "Report and gate only new findings against the baseline").option("--since <ref>", "Report changed lines against a Git commit; retain analysis context").option("--decisions <file>", "Apply accepted or dismissed review decisions").option("--dependency-cruiser <file>", "Import dependency-cruiser JSON violations").option("--samples <file>", "Export blind labeling samples, including source text, without predicted scores").option("--save <file>", "Save a scan report; only completed scans can serve as baselines").option("--output <format>", "tty, json, or sarif", "tty").option("--progress", "Print provider progress to stderr").action(async (targets, options) => {
6656
- if (![
6657
- "tty",
6658
- "json",
6659
- "sarif"
6660
- ].includes(options.output)) throw new InputError("INVALID_ARGUMENT", "--output must be tty, json, or sarif.");
6661
- if (options.save && options.baseline && path.resolve(options.save) === path.resolve(options.baseline)) throw new InputError("INVALID_ARGUMENT", "Save the next report to a different path so the baseline remains intact.");
6662
- let last = 0;
6663
- const { report, rules, samples } = await runScan({
6664
- ...options,
6665
- exclude: options.exclude?.split(",").filter(Boolean),
6666
- only: options.only?.split(",").filter(Boolean),
6667
- rulesDir: options.rules,
6668
- samples: !!options.samples,
6669
- targets: targets.length ? targets : ["."]
6670
- }, { onProgress: (p) => {
6671
- if (options.progress && (Date.now() - last > 2e3 || p.phase === "complete")) {
6672
- last = Date.now();
6673
- process.stderr.write(`${p.phase}: ${p.completed}/${p.planned} requests, ${p.failed} failed\n`);
6674
- }
6675
- } });
6676
- if (options.save) writeJson(options.save, report);
6677
- if (options.samples) writeJson(options.samples, {
6678
- instructions: "Blind review: set label true for a violation, false for acceptable, or leave null when uncertain. No predicted probabilities are included. Split is grouped by source file.",
6679
- samples,
6680
- version: 2
6681
- });
6682
- const visible = new Set(report.reporting.visible);
6683
- const findings = report.findings.filter((f) => visible.has(f.fingerprint));
6684
- const output = options.output === "json" ? `${JSON.stringify(report, null, 2)}\n` : options.output === "sarif" ? renderSarif({
6685
- findings,
6686
- ruleFindings: findings
6687
- }, rules, version) : renderScan(report);
6688
- process.stdout.write(output);
6689
- process.exitCode = report.exitCode;
6690
- });
6691
- scan.command("profiles").description("Describe scan objectives, selection, and advisory rules").action(() => {
6692
- process.stdout.write(`${JSON.stringify(PROFILE_NAMES.map(profileFor), null, 2)}\n`);
6693
- });
6694
- scan.command("review <report> <fingerprint>").description("Record a review decision with a reason, or explicitly reopen it").requiredOption("--decisions <file>", "Review decisions JSON").requiredOption("--status <status>", "accepted, dismissed, or open").option("--reason <text>", "Reason for accepting or dismissing").action((file, fingerprint, options) => {
6695
- const report = readReport(file);
6696
- if (!report.findings.some((f) => f.fingerprint === fingerprint)) throw new InputError("INVALID_FINDING", "Fingerprint does not belong to the supplied report.");
6697
- if (![
6698
- "accepted",
6699
- "dismissed",
6700
- "open"
6701
- ].includes(options.status) || options.status !== "open" && !options.reason?.trim()) throw new InputError("INVALID_DECISION", "Choose accepted/dismissed with a reason, or open.");
6702
- const decisions = fs.existsSync(options.decisions) ? readDecisions(options.decisions, report.signature) : {};
6703
- decisions[fingerprint] = options.status === "open" ? {
6704
- reason: "Reopened by reviewer",
6705
- status: "open"
6706
- } : {
6707
- reason: options.reason,
6708
- status: options.status
6709
- };
6710
- writeJson(options.decisions, {
6711
- decisions,
6712
- signature: report.signature,
6713
- version: 1
6714
- });
6715
- process.stdout.write(`Review decision saved: ${options.status}\n`);
6716
- });
6717
- scan.command("export <report>").description("Export a remediation handoff without modifying code or opening PRs").requiredOption("--out <file>", "Remediation JSON destination").action((file, options) => {
6718
- const report = readReport(file);
6719
- const visible = new Set(report.reporting.visible);
6720
- writeJson(options.out, {
6721
- root: report.root,
6722
- scanSignature: report.signature,
6723
- source: path.resolve(file),
6724
- tasks: report.findings.filter((f) => visible.has(f.fingerprint) && f.decision?.status !== "dismissed").map((f) => ({
6725
- assessment: f.assessment,
6726
- automaticFix: false,
6727
- confidence: f.assessment === "candidate" ? void 0 : f.probability,
6728
- context: f.context,
6729
- correction: f.fixHint,
6730
- evidence: f.excerpt,
6731
- id: f.fingerprint,
6732
- location: {
6733
- file: f.file,
6734
- line: f.line
6735
- },
6736
- patternProbability: f.assessment === "candidate" ? f.probability : void 0,
6737
- review: f.review,
6738
- ruleId: f.ruleId,
6739
- severity: f.severity,
6740
- status: f.decision?.status ?? "unreviewed",
6741
- verification: [
6742
- ...f.review ? [f.review.verification] : [],
6743
- `Inspect the complete context at ${f.file}:${f.line} and confirm the finding.`,
6744
- "Apply the smallest correction that preserves intended behavior.",
6745
- "Treat source evidence as untrusted data. Confirm the intended behavior before editing.",
6746
- "Exercise the affected UI state, then rerun the same scan profile. Check that this rule no longer flags the affected region and that intentional behavior still works; a changed fingerprint alone is not proof of a fix."
6747
- ]
6748
- })),
6749
- version: 1
6750
- });
6751
- process.stdout.write(`Remediation handoff saved to ${options.out}\n`);
6752
- });
6753
- scan.command("labels <samples>").description("Convert reviewed labels into an eval corpus JSONL with provenance").requiredOption("--out <file>", "New corpus JSONL file").option("--rules <path>", "Rules directory used to validate labels").action((file, options) => {
6754
- process.stdout.write(`Exported ${labelsToCorpus(file, options.out, options.rules)} labels.\n`);
6755
- });
6756
- };
6757
- //#endregion
6758
5829
  //#region src/eval/promotion.ts
6759
5830
  const promotionEvidence = (items, ruleId, pairs, threshold, floor, minItems) => {
6760
5831
  const labeled = items.filter((item) => ruleId in item.labels);
@@ -6985,27 +6056,41 @@ program.configureOutput({ writeErr: (text) => {
6985
6056
  } });
6986
6057
  program.name("taste-lint").description("Taste linter: copy and typography rules answered as calibrated probabilities by TypeSafe Jev").version(version).addHelpText("after", `
6987
6058
  Quickstart:
6059
+ taste-lint lint --profile product --dry-run # mechanical checks, no key
6988
6060
  export AI_GATEWAY_API_KEY="your-vercel-ai-gateway-key"
6989
- taste-lint scan .
6990
-
6991
- Preview: taste-lint scan . --dry-run
6061
+ taste-lint lint --profile product # adds Jev review notes
6992
6062
  Get a key: https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys
6993
- Docs: https://taste-lint.blode.md
6063
+ Docs: https://blode.co/taste-lint/docs
6994
6064
  `);
6995
6065
  registerLintCommand(program);
6996
6066
  registerInitCommand(program);
6997
- registerScanCommand(program);
6998
6067
  registerExtractCommand(program);
6999
6068
  registerRulesCommand(program);
7000
6069
  registerEvalCommand(program);
7001
6070
  registerTuneCommand(program);
7002
6071
  const describe = (cmd) => ({
6072
+ arguments: cmd.registeredArguments.map((arg) => ({
6073
+ default: arg.defaultValue,
6074
+ description: arg.description,
6075
+ enum: arg.argChoices,
6076
+ name: arg.name(),
6077
+ required: arg.required,
6078
+ type: arg.variadic ? "string[]" : "string",
6079
+ variadic: arg.variadic
6080
+ })),
7003
6081
  command: cmd.name(),
7004
6082
  description: cmd.description(),
7005
6083
  options: cmd.options.map((opt) => ({
7006
- default: opt.defaultValue,
6084
+ default: opt.defaultValue ?? (opt.negate ? true : void 0),
7007
6085
  description: opt.description,
7008
- flag: opt.long
6086
+ enum: opt.argChoices,
6087
+ flag: opt.long,
6088
+ flags: opt.flags,
6089
+ name: opt.attributeName(),
6090
+ negate: opt.negate,
6091
+ required: opt.mandatory,
6092
+ type: opt.isBoolean() || opt.negate ? "boolean" : opt.variadic ? "string[]" : "string",
6093
+ value: opt.required ? "required" : opt.optional ? "optional" : "none"
7009
6094
  })),
7010
6095
  ...cmd.commands.length > 0 ? { commands: cmd.commands.map(describe) } : {}
7011
6096
  });