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 +30 -111
- package/data/{rules → rule-drafts}/craft/craft-affordance-mismatch.yaml +2 -1
- package/data/{rules → rule-drafts}/craft/craft-virtualize-large-lists.yaml +2 -1
- package/data/rules/typography/typography-title-case-heading.yaml +6 -4
- package/dist/cli.js +163 -10
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +148 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,141 +1,60 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Taste Lint
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Catch AI slop before you ship.**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Scan your project with local checks and [Jev by TypeSafe AI](https://docs.typesafe.ai/introduction).
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
14
|
+
</div>
|
|
12
15
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
35
|
+
## What it checks
|
|
88
36
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
43
|
+
## Useful options
|
|
96
44
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
| `--dry-run`
|
|
100
|
-
| `--mechanical-only` |
|
|
101
|
-
| `--
|
|
102
|
-
| `--
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
25
|
+
status: draft
|
|
@@ -14,13 +14,13 @@ scope:
|
|
|
14
14
|
- "**/README.md"
|
|
15
15
|
unit: [heading, jsx-text, attr-string]
|
|
16
16
|
mechanical:
|
|
17
|
-
#
|
|
18
|
-
regex: "
|
|
19
|
-
flags:
|
|
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.
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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.
|
|
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: "
|
|
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
|
-
|
|
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 (/\.
|
|
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);
|