taste-lint 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,141 +1,60 @@
1
1
  <div align="center">
2
2
 
3
- # [taste-lint](https://github.com/mblode/taste-lint)
3
+ # Taste Lint
4
4
 
5
- **Taste rules from [Agent Skills](https://github.com/mblode/agent-skills) and [Taste Training](https://blode.co/taste-training), run as a linter**
5
+ **Catch AI slop before you ship.**
6
6
 
7
- Point it at Markdown, MDX, TSX or a rendered page and get findings with a severity and a probability.
7
+ Scan your project with local checks and [Jev by TypeSafe AI](https://docs.typesafe.ai/introduction).
8
8
 
9
- </div>
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/taste-lint"><img alt="npm version" src="https://img.shields.io/npm/v/taste-lint?style=flat&colorA=000000&colorB=000000" /></a>
11
+ <a href="https://github.com/mblode/taste-lint/blob/main/LICENSE.md"><img alt="MIT license" src="https://img.shields.io/npm/l/taste-lint?style=flat&colorA=000000&colorB=000000" /></a>
12
+ </p>
10
13
 
11
- ## Quickstart
14
+ </div>
12
15
 
13
- Requires Node 24.11 or later. Bring your own [Vercel AI Gateway API key](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys): open **AI Gateway → API Keys → Create Key** in your Vercel dashboard.
16
+ ## Install
14
17
 
15
18
  ```bash
16
19
  npm install -g taste-lint
17
- export AI_GATEWAY_API_KEY="your-vercel-ai-gateway-key"
18
-
19
- # Run from your project directory
20
- taste-lint scan .
21
- ```
22
-
23
- No taste-lint account or configuration file is required. Model usage is billed to your Vercel AI Gateway account. Taste-lint reads the key from your environment and sends selected text and rule context directly to the gateway.
24
-
25
- Preview scope and estimated cost with `taste-lint scan . --dry-run`. Try local checks without a key using `taste-lint scan . --mechanical-only`. The default scan focuses on product interfaces; use `--profile writing` for documentation or `--profile instructions` for agent instructions.
26
-
27
- Semantic rules remain advisory until calibrated against independent holdout evidence. Mechanical checks can fail a run immediately.
28
-
29
- ## Where the rules come from
30
-
31
- [Agent Skills](https://github.com/mblode/agent-skills) provides reusable instructions for coding agents. [Taste Training](https://blode.co/taste-training) teaches people to spot and fix problems in typography, copy, interaction, and motion. Taste-lint turns applicable guidance into repeatable checks, with source references attached to individual rules.
32
-
33
- ## Example findings
34
-
35
- ```text
36
- PASS: 161 active rules in data/rules
37
-
38
- notes.md
39
- [MINOR] typography-straight-quotes (p=1.00) notes.md:3:1
40
- Straight quotes in rendered copy
41
- 2 matches: "'", "'"
42
- Fix: Replace with the matching curly mark. Opening after whitespace or at the start, closing otherwise; an apostrophe is always the right single quote.
43
- [MINOR?] copywriting-claim-without-evidence (p=0.95) notes.md:3:1
44
- Quality claimed, nothing the reader could check
45
- 3 matches: "fast", "powerful", "seamless"; p=0.95
46
- Fix: Replace the adjective with the mechanism, the number or the standard it stands for. If none exists, cut the sentence.
47
-
48
- Units: 3 | Rules: 161 | Act: 1 | Review: 4 | Unknown: 0
49
- Jev: 2 requests, 0 cached answers, 7238 input tokens, $0.0003
50
- FAIL - 1 finding in the act band
51
20
  ```
52
21
 
53
- ## Two answers per finding
54
-
55
- - **Severity:** how bad the finding is if real, `major` or `minor`. Set by the rule, never by the model.
56
- - **Band:** how sure the tool is. `act` fails the run (mechanical hits, or Jev at or above the rule's act threshold), `review` prints a note with a `?`, below that is silent.
57
- - **Cost:** eligible questions about one unit are batched into requests. Preview estimated cost with `--dry-run`; runs report usage and reuse cached answers. Current rates are listed in the [Vercel AI Gateway model catalog](https://vercel.com/ai-gateway/models).
58
-
59
- ## Rule packs
22
+ Requires Node 24.11 or later.
60
23
 
61
- - **Typography:** straight quotes, dashes, ellipses, primes and units from `typography-audit`, plus size, weight, tracking and line-height checks over resolved Tailwind classes or computed styles.
62
- - **Copywriting:** claims without evidence, vague errors, friction CTAs, hedges and register shifts from `docs-writing` and the ui-design copy guideline, and Every's published AI-tell checker: 19 of its 21 questions, one rule each, with phrase candidates from the MIT `cw-ai-check` skill where it has them. Not ported: `uniform_cadence` (sentence-length arithmetic) and `formatting_overuse` (needs headings and bullets a paragraph never sees). The two authorship verdicts are excluded on purpose: taste-lint reports defects, not authorship.
63
- - **Interaction and craft:** the static checks of `ui-design/rules` as whole-file patterns (focus traps, error and empty states, target size, i18n, lazy loading) plus the shadcn/lint class hygiene rules (raw palette colours, arbitrary values, interpolated class strings).
64
- - **Motion and product:** the `ui-animation` flag-on-sight table (ease-in, linear easing, transitions over 300ms, `transition-all`, entrances from scale zero, no reduced-motion variant) and the two deterministic `product-design` rules.
65
-
66
- Every rule names the file and line of the skill or lesson it came from, and `taste-lint rules list` prints tier, status and category per rule.
24
+ ## Quickstart
67
25
 
68
- ## Rendered mode
26
+ Create a [Vercel AI Gateway key](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys). From your project directory, set your key and run:
69
27
 
70
28
  ```bash
71
- taste-lint lint --url https://example.com/pricing --selector main
72
- ```
73
-
74
- Runs [style-capture](https://www.npmjs.com/package/style-capture) in headless Chromium and lints computed styles: real pixel sizes, line heights, weights and letter-spacing, so the typography rules judge what the reader sees rather than what the class list implies. `--capture file.json` lints a saved capture.
75
-
76
- ## API
77
-
78
- ```typescript
79
- import { runLint } from "taste-lint";
80
-
81
- const result = await runLint({ root: process.cwd(), targets: ["content"] });
82
- console.log(result.scorecard.byDomain, result.usage.costUsd);
29
+ export AI_GATEWAY_API_KEY="your-vercel-ai-gateway-key"
30
+ taste-lint scan .
83
31
  ```
84
32
 
85
- `runLint` takes the same options as the `lint` command and returns findings, unknowns, the scorecard and usage. `taste-lint schema` prints every command, flag and default as JSON, and `--output json` turns an error into a `{ error, code, message }` envelope on stdout.
33
+ No account or config for taste-lint. AI checks send selected text and rule context to Vercel AI Gateway, billed to your account. Answers are cached for repeat runs.
86
34
 
87
- ## Agent skill
35
+ ## What it checks
88
36
 
89
- ```bash
90
- npx skills add mblode/taste-lint
91
- ```
37
+ - **Product interfaces:** copy, typography, interaction, and motion in JSX, TSX, and CSS.
38
+ - **Writing:** Markdown, MDX, and READMEs with `--profile writing`.
39
+ - **Agent instructions:** AGENTS.md and skills with `--profile instructions`.
92
40
 
93
- Installs the `taste-lint` skill for Claude Code, Codex, Cursor and OpenCode: how to read a finding, the dry-run-first workflow, and the gotchas. Source: [skills/taste-lint/SKILL.md](skills/taste-lint/SKILL.md).
41
+ Rules draw on [Agent Skills](https://github.com/mblode/agent-skills) and [Taste Training](https://blode.co/taste-training). Local checks handle measurable rules. Jev judges meaning and returns probabilities. Uncalibrated AI rules stay advisory; active findings can fail a run.
94
42
 
95
- ## Options
43
+ ## Useful options
96
44
 
97
- | Flag | Default | Description |
98
- | --- | --- | --- |
99
- | `--dry-run` | | Plan and print units, requests and estimated cost without calling Jev |
100
- | `--mechanical-only` | | Skip every Jev-backed rule; no key needed |
101
- | `--only <ids>` | | Comma-separated rule ids |
102
- | `--exclude <globs>` | | Comma-separated globs to skip, added to `taste-lint.config.json` |
103
- | `--fail-on <severity>` | `minor` | Lowest severity that fails the run |
104
- | `--fix` | | Apply deterministic fixes (curly quotes, ellipsis, multiplication sign, unit spaces) to act-band findings |
105
- | `--output <format>` | `tty` | `tty`, `json` or `sarif` |
106
- | `--url <url>` | | Lint a rendered page through style-capture |
45
+ | Option | What it does |
46
+ | ------------------- | ---------------------------------------------------- |
47
+ | `--dry-run` | Preview scope and estimated cost without model calls |
48
+ | `--mechanical-only` | Run local checks without an API key |
49
+ | `--output json` | Save findings for scripts and agents |
50
+ | `--output sarif` | Export findings for code review tools |
107
51
 
108
- `taste-lint eval` scores every rule against its labelled corpus (precision, recall, Wilson intervals, a calibration table) and `taste-lint tune` picks act thresholds from the dev split, promoting a rule only when the fixed threshold also clears the precision lower bound on an independent holdout. Both label classes, enough evaluated items, complete scoring, and source/text separation are required. `taste-lint eval coverage` reports class balance and split leakage without API calls; add `--output json` to coverage or evaluation for structured results.
52
+ Run `taste-lint scan --help` for all options. See the [scan guide](https://github.com/mblode/taste-lint/blob/main/docs/SCANS.md), [usage reference](https://github.com/mblode/taste-lint/blob/main/docs/USAGE.md), and [changelog](https://github.com/mblode/taste-lint/blob/main/CHANGELOG.md) for more.
109
53
 
110
54
  ## License
111
55
 
112
56
  MIT
113
57
 
114
- ## Reading a repository run
115
-
116
- The default text report summarizes scope, top rules and up to 30 examples. Use `--verbose` for the full list; every completed or incomplete run also saves a complete JSON report and prints its path. JSON v1 keeps the original grouped `findings`; `ruleFindings` preserves every rule's evidence and `coverage` counts eligible checks. `summary.failing` respects `--fail-on`, and determines the exit code together with run completeness.
117
-
118
- ```bash
119
- taste-lint lint --root ../my-site apps/web --dry-run
120
- taste-lint lint --root ../my-site apps/web --progress --output json > audit.json
121
- ```
122
-
123
- Progress goes to stderr. A fully cached run needs no API key. An incomplete report includes a retry command that reuses successful answers. Cost is reported from known usage, excluding any unreported provider billing for failures.
124
-
125
- Configuration is optional at `taste-lint.config.json` in the scan root. Its editor schema ships at `node_modules/taste-lint/data/config.schema.json`. Unknown fields and invalid types fail before evaluation. Select the scope explicitly: documentation and agent instructions remain included when you request a whole repository.
126
-
127
- ```json
128
- {
129
- "$schema": "./node_modules/taste-lint/data/config.schema.json",
130
- "exclude": ["docs/archive/**"],
131
- "docTypes": [
132
- { "glob": "apps/web/content/writing/**/*.mdx", "type": "explanation" }
133
- ]
134
- }
135
- ```
136
-
137
- See [TypeSafe contracts](docs/TYPESAFE.md) and the [review-rule evaluation](docs/evaluations/blode-co-review.md) for the implementation evidence and current calibration limits.
138
-
139
- See [skill packs](docs/SKILL-PACKS.md) for repository checks, architecture policy, personal-writing context, and source discovery.
58
+ ---
140
59
 
141
- Use `taste-lint scan . --profile product --dry-run` to preview a focused scan. The [scan workflow](docs/SCANS.md) covers profiles, baselines, review decisions, changed-code SARIF, calibration samples, graph-tool reports, and remediation exports.
60
+ Crafted by [<img src="https://blode.co/avatar-circle.png" width="20" align="top" alt="" />](https://blode.co) [Matthew Blode](https://blode.co)
@@ -1,3 +1,4 @@
1
+ # Disabled after real-project review: this pattern lacks evidence of a defect.
1
2
  # Ported from mblode/agent-skills skills/ui-design/rules/slop-affordance-mismatch.md by scripts/port-rules.ts.
2
3
  # Keys listed in handWritten are preserved on re-run; everything else is regenerated.
3
4
  id: craft-affordance-mismatch
@@ -20,4 +21,4 @@ mechanical:
20
21
  severity: minor
21
22
  fix:
22
23
  hint: A card lifts on hover, the cursor turns into a hand, the border brightens, and clicking does nothing. The user has already committed to the click by the time they learn it was a decoration; they click again, assume the page is broken or slow, and then trust the next hover state less. This is worse t
23
- status: review-only
24
+ status: draft
@@ -1,3 +1,4 @@
1
+ # Disabled after real-project review: this pattern lacks evidence of a defect.
1
2
  # Ported from mblode/agent-skills skills/ui-design/rules/perf-virtualize-large-lists.md by scripts/port-rules.ts.
2
3
  # Keys listed in handWritten are preserved on re-run; everything else is regenerated.
3
4
  id: craft-virtualize-large-lists
@@ -21,4 +22,4 @@ mechanical:
21
22
  severity: minor
22
23
  fix:
23
24
  hint: Large lists (roughly >50 visible items) should use virtualization/windowing. Rendering the whole dataset stalls scrolling and grows memory with the list.
24
- status: review-only
25
+ status: draft
@@ -14,13 +14,13 @@ scope:
14
14
  - "**/README.md"
15
15
  unit: [heading, jsx-text, attr-string]
16
16
  mechanical:
17
- # Three or more consecutive capitalised words. Proper nouns also match, so Jev decides.
18
- regex: "\\b[A-Z][a-z]+(?: (?:[A-Z][a-z]+|of|the|and|for|to|a|in|on|with)){2,}\\b"
19
- flags: gu
17
+ # Whole-label candidate: embedded capitalised names in a sentence are not Title Case.
18
+ regex: "^(?:[A-Z][a-z]+|of|the|and|for|to|a|in|on|with)(?: (?:[A-Z][a-z]+|of|the|and|for|to|a|in|on|with)){2,}[?!:.]?$"
19
+ flags: u
20
20
  question:
21
21
  context: [role]
22
22
  instructions: >-
23
- The TEXT is a heading, button label, tab or field label. Return true when most of its words start with a capital letter because it is set in Title Case, not because those words are names of people, products, places or organisations. Return false when the capitals are proper nouns or acronyms, when the TEXT is a single word, or when it is an ordinary sentence with one capital at the start.
23
+ Evaluate capitalisation of ordinary words only. Ignore the initial word and all words belonging to a product, person, place or organisation name. An imperative followed by a product name is sentence case, even when every word begins with a capital. If the supplied text does not establish that a capitalised phrase is ordinary words rather than a name, return false. The TEXT is a heading, button label, tab or field label. Return true when most of its words start with a capital letter because it is set in Title Case, not because those words are names of people, products, places or organisations. Return false when the capitals are proper nouns or acronyms, when the TEXT is a single word, or when it is an ordinary sentence with one capital at the start.
24
24
  criteria:
25
25
  true:
26
26
  what: Ordinary words capitalised as a heading style.
@@ -34,6 +34,8 @@ question:
34
34
  - "Connect Google Drive and Microsoft Teams"
35
35
  - "Manage your team settings"
36
36
  - "New York office hours"
37
+ - "Open Cedar Notes"
38
+ - "Ask River Desk"
37
39
  thresholds: { review: 0.4, act: 0.75 }
38
40
  severity: minor
39
41
  fix:
package/dist/cli.js CHANGED
@@ -10,12 +10,13 @@ import { fromMarkdown } from "mdast-util-from-markdown";
10
10
  import { gfmFromMarkdown } from "mdast-util-gfm";
11
11
  import { gfm } from "micromark-extension-gfm";
12
12
  import { parseSync } from "oxc-parser";
13
+ import { parse as parse$1 } from "parse5";
13
14
  import { mdxFromMarkdown } from "mdast-util-mdx";
14
15
  import { mdxjs } from "micromark-extension-mdxjs";
15
16
  import { styleText } from "node:util";
16
17
  import os from "node:os";
17
18
  //#region package.json
18
- var version = "0.0.1";
19
+ var version = "0.0.3";
19
20
  //#endregion
20
21
  //#region src/lib/stats.ts
21
22
  const makePRNG = (seed) => {
@@ -115,6 +116,11 @@ const calibrationTable = (pairs, buckets = 10) => Array.from({ length: buckets }
115
116
  //#endregion
116
117
  //#region src/rules/taxonomy.ts
117
118
  const CATEGORIES = [
119
+ {
120
+ domain: "seo",
121
+ id: "search-discovery",
122
+ label: "Search discovery"
123
+ },
118
124
  {
119
125
  domain: "architecture",
120
126
  id: "repository-contracts",
@@ -2470,7 +2476,7 @@ const codeRule = (spec, module) => {
2470
2476
  };
2471
2477
  //#endregion
2472
2478
  //#region src/rules/code/architecture.ts
2473
- const make$3 = (name, title, check, hint) => codeRule({
2479
+ const make$4 = (name, title, check, hint) => codeRule({
2474
2480
  categoryId: "repository-contracts",
2475
2481
  check,
2476
2482
  hint,
@@ -2486,7 +2492,7 @@ const make$3 = (name, title, check, hint) => codeRule({
2486
2492
  unit: ["source"]
2487
2493
  }, "src/rules/code/architecture.ts");
2488
2494
  const ARCHITECTURE_RULES = [
2489
- make$3("declared-import-boundary", "Import violates a declared module boundary", (u) => {
2495
+ make$4("declared-import-boundary", "Import violates a declared module boundary", (u) => {
2490
2496
  const boundaries = u.facts?.repository.policy?.boundaries?.filter((b) => matchesAny(u.file, [b.from]));
2491
2497
  if (!boundaries?.length) return none;
2492
2498
  if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
@@ -2501,7 +2507,7 @@ const ARCHITECTURE_RULES = [
2501
2507
  }
2502
2508
  return none;
2503
2509
  }, "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."),
2504
- make$3("deprecated-import", "Import uses a declared deprecated API", (u) => {
2510
+ make$4("deprecated-import", "Import uses a declared deprecated API", (u) => {
2505
2511
  const deprecated = u.facts?.repository.policy?.deprecatedImports;
2506
2512
  if (!deprecated || Object.keys(deprecated).length === 0) return none;
2507
2513
  if (!u.facts?.imports) throw new UnresolvedError(u.facts?.parseError ?? "Import facts unavailable");
@@ -2515,7 +2521,7 @@ const ARCHITECTURE_RULES = [
2515
2521
  }
2516
2522
  return none;
2517
2523
  }, "Use the replacement named in the repository's deprecation contract."),
2518
- make$3("generated-regeneration-hint", "Declared generated file has no regeneration guidance", (u) => {
2524
+ make$4("generated-regeneration-hint", "Declared generated file has no regeneration guidance", (u) => {
2519
2525
  const generated = u.facts?.repository.policy?.generated;
2520
2526
  if (!generated?.length || !matchesAny(u.file, generated)) return none;
2521
2527
  const banner = u.text.split("\n").slice(0, 12).join("\n");
@@ -2638,15 +2644,16 @@ const CLASS_RULES = [
2638
2644
  const classes = unit.classes ?? [];
2639
2645
  const animated = classes.map(baseClass).filter((c) => c.startsWith("animate-") && c !== "animate-none");
2640
2646
  if (animated.length === 0) return none;
2641
- return classes.some((c) => c.startsWith("motion-reduce:") || c.startsWith("motion-safe:")) ? none : hit(animated.join(" "));
2647
+ return classes.includes("motion-reduce:animate-none") || classes.filter((c) => baseClass(c).startsWith("animate-") && baseClass(c) !== "animate-none").every((c) => c.split(":").includes("motion-safe")) ? none : hit(animated.join(" "));
2642
2648
  },
2643
- hint: "Gate the animation with motion-safe:animate-* or pair it with motion-reduce:animate-none so users who asked for less motion get less.",
2649
+ hint: "Check shared CSS and component policy first. If no reduced-motion handling exists, use motion-safe:animate-* or motion-reduce:animate-none.",
2644
2650
  id: "motion-animate-without-reduced-motion",
2645
2651
  source: {
2646
2652
  line: 57,
2647
2653
  path: `${ANIMATION}/live-tuning.md`
2648
2654
  },
2649
- title: "Animation with no reduced-motion variant"
2655
+ status: "review-only",
2656
+ title: "Animation has no local reduced-motion guard"
2650
2657
  }),
2651
2658
  shadcn({
2652
2659
  categoryId: "colour-system",
@@ -2680,6 +2687,30 @@ const CLASS_RULES = [
2680
2687
  })
2681
2688
  ];
2682
2689
  //#endregion
2690
+ //#region src/analysis/html.ts
2691
+ const text = (node) => "value" in node ? node.value : "childNodes" in node ? node.childNodes.map(text).join("") : "";
2692
+ const htmlElements = (source) => {
2693
+ const output = [];
2694
+ const visit = (node, inHead = false) => {
2695
+ const head = inHead || "tagName" in node && node.tagName === "head";
2696
+ if ("tagName" in node && node.namespaceURI === "http://www.w3.org/1999/xhtml" && node.sourceCodeLocation && [
2697
+ "title",
2698
+ "meta",
2699
+ "link",
2700
+ "script"
2701
+ ].includes(node.tagName)) output.push({
2702
+ attrs: Object.fromEntries(node.attrs.map((a) => [a.name, a.value])),
2703
+ inHead: head,
2704
+ offset: node.sourceCodeLocation.startOffset,
2705
+ tag: node.tagName,
2706
+ text: text(node)
2707
+ });
2708
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child, head);
2709
+ };
2710
+ visit(parse$1(source, { sourceCodeLocationInfo: true }));
2711
+ return output;
2712
+ };
2713
+ //#endregion
2683
2714
  //#region src/analysis/repository.ts
2684
2715
  const descendants = (node) => [node, ...(node.children ?? []).flatMap(descendants)];
2685
2716
  const nodeText = (node) => node.value ?? (node.children ?? []).map(nodeText).join("");
@@ -2763,7 +2794,8 @@ const importsFrom = (value, output) => {
2763
2794
  };
2764
2795
  const sourceFacts = (file, text, repository) => {
2765
2796
  const facts = { repository };
2766
- if (/\.mdx?$/.test(file)) facts.document = fromMarkdown(text.replace(/^---\r?\n[\s\S]*?\r?\n---(?=\r?\n|$)/, (m) => m.replaceAll(/[^\r\n]/g, " ")), {
2797
+ if (/\.html?$/.test(file)) facts.html = htmlElements(text);
2798
+ 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, " ")), {
2767
2799
  extensions: [gfm()],
2768
2800
  mdastExtensions: [gfmFromMarkdown()]
2769
2801
  });
@@ -2780,6 +2812,94 @@ const sourceFacts = (file, text, repository) => {
2780
2812
  return facts;
2781
2813
  };
2782
2814
  //#endregion
2815
+ //#region src/rules/code/discovery.ts
2816
+ const at$2 = (evidence, offset = 0) => ({
2817
+ evidence,
2818
+ fired: true,
2819
+ offset
2820
+ });
2821
+ const html = (u) => {
2822
+ 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");
2823
+ return u.facts.html;
2824
+ };
2825
+ const make$3 = (id, title, include, source, check, hint) => codeRule({
2826
+ categoryId: id.startsWith("seo-") ? "search-discovery" : "instruction-quality",
2827
+ check,
2828
+ hint,
2829
+ id,
2830
+ scope: {
2831
+ exclude: [
2832
+ "**/supabase/templates/**",
2833
+ "**/emails/**",
2834
+ "**/email/**"
2835
+ ],
2836
+ include
2837
+ },
2838
+ source: {
2839
+ line: 1,
2840
+ path: `skills/${source}`,
2841
+ repo: "mblode/agent-skills"
2842
+ },
2843
+ status: "review-only",
2844
+ title,
2845
+ unit: ["source"]
2846
+ }, "src/rules/code/discovery.ts");
2847
+ const HTML = ["**/*.html", "**/*.htm"];
2848
+ const SEO = "seo/references/audit.md";
2849
+ const LLM = "agent-ready/references/docs-afdocs.md";
2850
+ const canonical = (u) => html(u).filter((n) => n.inHead && n.tag === "link" && n.attrs.rel?.toLowerCase().split(/\s+/).includes("canonical"));
2851
+ const DISCOVERY_RULES = [
2852
+ make$3("seo-document-title", "HTML document needs one nonempty title", HTML, SEO, (u) => {
2853
+ const titles = html(u).filter((n) => n.inHead && n.tag === "title");
2854
+ 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;
2855
+ }, "Provide one meaningful title in the HTML head. SVG titles do not name the page."),
2856
+ make$3("seo-empty-description", "Declared meta description is empty", HTML, SEO, (u) => {
2857
+ const empty = html(u).find((n) => n.inHead && n.tag === "meta" && n.attrs.name?.toLowerCase() === "description" && !n.attrs.content?.trim());
2858
+ return empty ? at$2("Empty meta description", empty.offset) : none;
2859
+ }, "Write a page-specific description, or omit a deliberately unspecified description."),
2860
+ make$3("seo-conflicting-canonicals", "HTML declares conflicting canonical destinations", HTML, SEO, (u) => {
2861
+ const links = canonical(u);
2862
+ return new Set(links.map((n) => n.attrs.href?.trim()).filter(Boolean)).size > 1 ? at$2("Multiple distinct canonical destinations", links[1].offset) : none;
2863
+ }, "Declare a single intended canonical destination; verify the deployed URL and HTTP headers."),
2864
+ make$3("seo-empty-canonical", "Canonical link has no destination", HTML, SEO, (u) => {
2865
+ const empty = canonical(u).find((n) => !n.attrs.href?.trim());
2866
+ return empty ? at$2("Canonical link has an empty or missing href", empty.offset) : none;
2867
+ }, "Set the intended canonical URL or remove an unintended canonical declaration."),
2868
+ make$3("seo-jsonld-syntax", "JSON-LD script is not valid JSON", HTML, SEO, (u) => {
2869
+ for (const node of html(u).filter((n) => n.tag === "script" && n.attrs.type?.trim().toLowerCase() === "application/ld+json")) try {
2870
+ JSON.parse(node.text);
2871
+ } catch {
2872
+ return at$2("JSON-LD cannot be parsed as JSON", node.offset);
2873
+ }
2874
+ return none;
2875
+ }, "Fix the JSON syntax. Valid JSON alone does not establish schema validity or rich-result eligibility."),
2876
+ make$3("seo-robots-sitemap-url", "Robots sitemap directive needs an absolute HTTP URL", ["**/robots.txt"], "seo/references/indexing-policy.md", (u) => {
2877
+ for (const match of u.text.matchAll(/^\s*sitemap\s*:\s*([^\r\n]*)/gim)) {
2878
+ const value = match[1].split("#")[0].trim();
2879
+ try {
2880
+ const url = new URL(value);
2881
+ if (["http:", "https:"].includes(url.protocol) && url.hostname && !/\s/.test(value)) continue;
2882
+ } catch {}
2883
+ return at$2("Sitemap directive is not an absolute HTTP(S) URL", match.index);
2884
+ }
2885
+ return none;
2886
+ }, "Use the deployed sitemap's absolute URL. Fetch it separately to verify status and content."),
2887
+ make$3("authoring-llms-title", "Agent index must start with an H1", ["**/llms.txt"], LLM, (u) => {
2888
+ const document = u.facts?.document;
2889
+ if (!document) throw new UnresolvedError("Markdown structure unavailable");
2890
+ const first = document.children?.[0];
2891
+ return first?.type === "heading" && first.depth === 1 ? none : at$2("llms.txt does not begin with an H1");
2892
+ }, "Start llms.txt with the site's name as an H1, then describe and link its real documentation."),
2893
+ make$3("authoring-llms-links", "Agent index has no documentation links", ["**/llms.txt"], LLM, (u) => {
2894
+ if (!u.facts?.document) throw new UnresolvedError("Markdown structure unavailable");
2895
+ 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");
2896
+ }, "Link the existing documentation pages that an agent should read."),
2897
+ make$3("authoring-llms-size", "Agent index exceeds the recommended character budget", ["**/llms.txt"], LLM, (u) => {
2898
+ const count = [...u.text].length;
2899
+ return count > 5e4 ? at$2(`llms.txt contains ${count} characters; recommended maximum is 50000`) : none;
2900
+ }, "Keep the index small and link separate documents or nested indexes.")
2901
+ ];
2902
+ //#endregion
2783
2903
  //#region src/rules/code/documents.ts
2784
2904
  const at$1 = (node, evidence) => ({
2785
2905
  evidence,
@@ -3088,6 +3208,14 @@ const make = (id, title, categoryId, source, check, hint) => codeRule({
3088
3208
  unit: ["class-list"]
3089
3209
  }, "src/rules/code/skill-classes.ts");
3090
3210
  const SKILL_CLASS_RULES = [
3211
+ make("motion-reduced-motion-animation", "Continuous animation explicitly targets reduced-motion users", "motion-restraint", "ui-animation/references/live-tuning.md", (u) => {
3212
+ const found = u.classes?.find((c) => c.split(":").includes("motion-reduce") && [
3213
+ "animate-spin",
3214
+ "animate-ping",
3215
+ "animate-bounce"
3216
+ ].includes(baseClass(c)));
3217
+ return found ? hit(found) : none;
3218
+ }, "Check the rendered reduced-motion state. Prefer a static indicator or motion-safe animation for continuous motion."),
3091
3219
  make("typography-justify-without-hyphens", "Justified text explicitly disables hyphenation", "reading-comfort", "typography-audit/rules/layout-justified-text.md", (u) => {
3092
3220
  const list = u.classes ?? [];
3093
3221
  return list.includes("text-justify") && list.includes("hyphens-none") ? hit("text-justify with hyphens-none") : none;
@@ -3103,7 +3231,7 @@ const SKILL_CLASS_RULES = [
3103
3231
  return distorted ? hit(distorted) : none;
3104
3232
  }, "Use a condensed/extended font or supported width axis; inspect intentional animation transforms separately."),
3105
3233
  make("motion-layout-property-transition", "Transition explicitly animates layout properties", "motion-restraint", "ui-animation/references/performance-deep-dive.md", (u) => {
3106
- const cls = u.classes?.find((c) => /^transition-\[(?:[^\]]*,)?(?:width|height|top|left|right|bottom|margin|padding)(?:,|\])/.test(c));
3234
+ const cls = u.classes?.find((c) => /^transition-\[(?:[^\]]*,)?(?:width|height|top|left|right|bottom|margin|padding)(?:,|\])/.test(baseClass(c)));
3107
3235
  return cls ? hit(cls) : none;
3108
3236
  }, "Prefer transform/opacity for frequent motion; measure deliberate container resizing before changing it.")
3109
3237
  ];
@@ -3336,6 +3464,7 @@ const CODE_RULES = [
3336
3464
  ],
3337
3465
  ...CLASS_RULES,
3338
3466
  ...DOCUMENT_RULES,
3467
+ ...DISCOVERY_RULES,
3339
3468
  ...REPOSITORY_RULES,
3340
3469
  ...SKILL_CLASS_RULES,
3341
3470
  ...ARCHITECTURE_RULES
@@ -4387,6 +4516,11 @@ const extractSource = (config, file, source, repository) => {
4387
4516
  return [...units, whole];
4388
4517
  };
4389
4518
  const SUPPORTED_GLOBS = [
4519
+ "**/*.html",
4520
+ "**/*.htm",
4521
+ "**/llms.txt",
4522
+ "**/llms-full.txt",
4523
+ "**/robots.txt",
4390
4524
  "**/*.md",
4391
4525
  "**/*.mdx",
4392
4526
  "**/*.tsx",
@@ -5412,6 +5546,7 @@ const PROFILE_NAMES = [
5412
5546
  "writing",
5413
5547
  "instructions",
5414
5548
  "architecture",
5549
+ "discovery",
5415
5550
  "all"
5416
5551
  ];
5417
5552
  const profileFor = (name) => {
@@ -5423,6 +5558,23 @@ const profileFor = (name) => {
5423
5558
  name
5424
5559
  };
5425
5560
  switch (name) {
5561
+ case "discovery": return {
5562
+ ...common,
5563
+ exclude: [
5564
+ ...artifacts,
5565
+ "**/supabase/templates/**",
5566
+ "**/emails/**",
5567
+ "**/email/**"
5568
+ ],
5569
+ include: [
5570
+ "**/*.html",
5571
+ "**/*.htm",
5572
+ "**/robots.txt",
5573
+ "**/llms.txt",
5574
+ "**/llms-full.txt"
5575
+ ],
5576
+ objective: "Validate static search and agent discovery artifacts"
5577
+ };
5426
5578
  case "product": return {
5427
5579
  ...common,
5428
5580
  exclude: [
@@ -5475,6 +5627,7 @@ const profileFor = (name) => {
5475
5627
  };
5476
5628
  const profileRules = (profile, rules) => rules.filter((rule) => {
5477
5629
  if (profile.name === "all") return true;
5630
+ if (profile.name === "discovery") return rule.domain === "seo" || rule.id.startsWith("authoring-llms-");
5478
5631
  if (profile.name === "architecture") return ["architecture", "dx"].includes(rule.domain);
5479
5632
  if (profile.name === "instructions") return rule.domain === "authoring" || rule.id.startsWith("copywriting-document-");
5480
5633
  if (profile.name === "writing") return ["copywriting", "typography"].includes(rule.domain);