turva-llms-txt-validator 0.3.0 → 0.3.2

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,6 +1,6 @@
1
1
  # turva-llms-txt-validator
2
2
 
3
- Validate a site's llms.txt structure from the command line, from Node or in CI. Eight structure checks, each reported as pass, warn or fail with a one-line detail, and two v2 discovery checks reported as information. No score on purpose: a structure check can say what is there and what is missing, and a number on top of ten checks would look like an agent-readiness score without measuring one.
3
+ Validate a site's llms.txt structure from the command line, from Node or in CI. Eight structure checks, each reported as pass, warn or fail with a one-line detail, and two v2 discovery checks that read pass or info and never move the summary. No score on purpose: a structure check can say what is there and what is missing, and a number on top of ten checks would look like an agent-readiness score without measuring one.
4
4
 
5
5
  This is the open-source form of the hosted validator at [turva.dev/llms-txt-validator](https://turva.dev/llms-txt-validator), which runs the same logic inside the open [turva.dev Cloudflare Worker](https://github.com/erekola/turva-worker). The hosted validator stays canonical: if the two ever disagree, the hosted one wins and this package gets the fix.
6
6
 
package/bin/cli.mjs CHANGED
@@ -31,6 +31,11 @@ try {
31
31
  if (result.summary === "valid with warnings" && flags.has("--strict")) process.exit(1);
32
32
  process.exit(0);
33
33
  } catch (err) {
34
- console.error("error: " + (err && err.message ? err.message : String(err)));
34
+ const message = err && err.message ? err.message : String(err);
35
+ // The hosted validator answers every failure as JSON when JSON was asked for, so a CI
36
+ // step that parses --json output gets {"error": ...} here too instead of an empty stdout
37
+ // and a plain-text stderr (round 16 S3-3). The exit code is unchanged.
38
+ if (flags.has("--json")) console.log(JSON.stringify({ error: message }, null, 2));
39
+ else console.error("error: " + message);
35
40
  process.exit(2);
36
41
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.3.0",
4
- "description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks as information, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
3
+ "version": "0.3.2",
4
+ "description": "Validate a site's llms.txt structure: eight checks as pass/warn/fail plus two llms.txt v2 discovery checks that read pass or info and never move the summary, JSON output. Same checks as the hosted validator at turva.dev/llms-txt-validator.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
7
7
  "exports": "./src/index.mjs",
package/src/index.mjs CHANGED
@@ -12,8 +12,20 @@ import { readFileSync } from "node:fs";
12
12
  export const VERSION = JSON.parse(
13
13
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
14
14
  ).version;
15
+ // The hosted validator identifies itself without a version, because its version is the
16
+ // site's and moves with every release; this package carries its own. The two strings
17
+ // differ on purpose and nothing compares them (round 16 S3-6).
15
18
  const UA = "turva-llms-txt-validator/" + VERSION + " (+https://turva.dev/llms-txt-validator)";
16
19
 
20
+ // Cut a string to at most n UTF-16 code units without leaving a lone high surrogate at the
21
+ // end. String.prototype.slice counts code units, so a cut that lands inside a surrogate
22
+ // pair leaves half of it, and that half serialises as bytes that are not valid UTF-8
23
+ // (round 16 S3-1 and S3-2, measured on the hosted validator). Mirrors worker.js cut().
24
+ export function cut(s, n) {
25
+ s = String(s).slice(0, n);
26
+ return /[\uD800-\uDBFF]$/.test(s) ? s.slice(0, -1) : s;
27
+ }
28
+
17
29
  export function normalizeHostInput(raw) {
18
30
  let s = String(raw || "").trim().toLowerCase();
19
31
  if (!s) return null;
@@ -58,13 +70,13 @@ export async function fetchLlmsTxt(host, opts = {}) {
58
70
  if (res.status >= 300 && res.status < 400) {
59
71
  const loc = res.headers.get("location") || "";
60
72
  if (!loc) return { redirect: true, reason: "no-location", status: res.status, location: "" };
61
- if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: loc.slice(0, 120) };
73
+ if (hop >= 4) return { redirect: true, reason: "too-many", status: res.status, location: cut(loc, 120) };
62
74
  let next;
63
- try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: loc.slice(0, 120) }; }
75
+ try { next = new URL(loc, url); } catch { return { redirect: true, reason: "bad-location", status: res.status, location: cut(loc, 120) }; }
64
76
  const safeTarget = next.protocol === "https:" && !next.port && !next.username && !next.password && isValidPublicHost(next.hostname);
65
77
  const twin = (next.hostname.startsWith("www.") ? next.hostname.slice(4) : next.hostname) === reqApex;
66
- if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: next.href.slice(0, 120) };
67
- if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: next.href.slice(0, 120) };
78
+ if (!safeTarget) return { redirect: true, reason: "unsafe-target", status: res.status, location: cut(next.href, 120) };
79
+ if (!twin) return { redirect: true, reason: "off-host", status: res.status, location: cut(next.href, 120) };
68
80
  if (!redirectedFrom) redirectedFrom = url;
69
81
  url = next.href;
70
82
  continue;
@@ -103,6 +115,68 @@ export async function fetchLlmsTxt(host, opts = {}) {
103
115
  }
104
116
  }
105
117
 
118
+ // Every markdown link in the file, scanned once from left to right instead of collected with
119
+ // matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g). That bound meant a target longer than 2048
120
+ // characters was not counted as a link at all, and dropping the bound from the pattern would
121
+ // make it quadratic on a file that repeats "[a](" (Erik 2026-08-29). The scan carries no bound
122
+ // and no backtracking: every character is read once and the furthest failed target scan is
123
+ // remembered. matchAll resumes after a whole match, and so does this.
124
+ function collectLinks(text) {
125
+ const out = [];
126
+ let failEnd = -1;
127
+ for (let i = 0; i < text.length; i++) {
128
+ if (text[i] !== "[") continue;
129
+ let j = i + 1;
130
+ while (j < text.length && text[j] !== "]" && text[j] !== "[") j++;
131
+ if (j >= text.length) break;
132
+ if (text[j] === "[") { i = j - 1; continue; }
133
+ if (text[j + 1] !== "(") { i = j; continue; }
134
+ const k = j + 2;
135
+ if (k <= failEnd) { i = j + 1; continue; }
136
+ let e = k;
137
+ while (e < text.length && text[e] !== ")" && !/\s/.test(text[e])) e++;
138
+ if (e > k && text[e] === ")") { out.push({ name: text.slice(i + 1, j), target: text.slice(k, e) }); i = e; continue; }
139
+ if (text[e] !== ")") failEnd = e;
140
+ i = j + 1;
141
+ }
142
+ return out;
143
+ }
144
+
145
+ // A markdown list item that carries a link, scanned once from left to right instead of
146
+ // matched with /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/. That pattern is quadratic on a line
147
+ // such as "- " followed by "[a](" repeated, because every candidate rescans the target to the
148
+ // end of the line, and the line comes from the audited site (CodeQL js/polynomial-redos,
149
+ // 2026-08-29). Bounding the quantifier would trade the speed bug for a silent accuracy bug,
150
+ // so the scan is by index: every character is read once and the furthest failed target scan
151
+ // is remembered.
152
+ function listItemHasLink(l) {
153
+ const m = /^ {0,3}[-*+] /.exec(l);
154
+ if (!m) return false;
155
+ const isSep = (c) => c === "\r" || c === "\n" || c === "\u2028" || c === "\u2029";
156
+ let failEnd = -1;
157
+ for (let i = m[0].length; i < l.length; i++) {
158
+ // A "." in the old pattern never crosses a line terminator, and split(/\r?\n/) leaves
159
+ // a bare CR, U+2028 and U+2029 inside a line, so a link behind one was not a match then
160
+ // and is not one now.
161
+ if (isSep(l[i])) return false;
162
+ if (l[i] !== "[") continue;
163
+ let j = i + 1, sep = false;
164
+ while (j < l.length && l[j] !== "]" && l[j] !== "[") { if (isSep(l[j])) sep = true; j++; }
165
+ if (j >= l.length) return false;
166
+ if (l[j] === "[") { if (sep) return false; i = j - 1; continue; }
167
+ if (l[j + 1] !== "(") { if (sep) return false; i = j; continue; }
168
+ const k = j + 2;
169
+ if (k <= failEnd) { if (sep) return false; i = j + 1; continue; }
170
+ let e = k;
171
+ while (e < l.length && l[e] !== ")" && !/\s/.test(l[e])) e++;
172
+ if (e > k && l[e] === ")") return true;
173
+ if (sep) return false;
174
+ if (l[e] !== ")") failEnd = e;
175
+ i = j + 1;
176
+ }
177
+ return false;
178
+ }
179
+
106
180
  function redirectFailDetail(f) {
107
181
  if (f.reason === "off-host") return "redirects to " + f.location + ", a different host; llms.txt is host-scoped, so validate that host directly";
108
182
  if (f.reason === "unsafe-target") return "redirects to an unsupported target (" + f.location + "); only https redirects to the same site are followed";
@@ -141,13 +215,13 @@ export function validateLlmsTxt(f) {
141
215
  // indented code block rather than a heading, and trimming erased that difference, so
142
216
  // " # Site" passed as the H1 until 2026-08-29. CommonMark allows three spaces.
143
217
  if (/^ {0,3}# \S/.test(firstRaw)) {
144
- add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(first.slice(0, 80)));
218
+ add("h1-title", "pass", "Starts with an H1 title", JSON.stringify(cut(first, 80)));
145
219
  } else {
146
220
  add("h1-title", "fail", "Starts with an H1 title", "the first non-empty line should be a markdown H1 (# Site name)");
147
221
  }
148
222
  const afterH1 = lines.slice(firstIdx + 1).find((l) => l.trim() !== "") || "";
149
223
  if (afterH1.trim().startsWith("> ")) {
150
- add("summary", "pass", "Blockquote summary after the title", JSON.stringify(afterH1.trim().slice(0, 80)));
224
+ add("summary", "pass", "Blockquote summary after the title", JSON.stringify(cut(afterH1.trim(), 80)));
151
225
  } else {
152
226
  add("summary", "warn", "Blockquote summary after the title", "recommended by the format (> one-line summary), not required");
153
227
  }
@@ -160,7 +234,7 @@ export function validateLlmsTxt(f) {
160
234
  for (const l of lines) {
161
235
  if (/^## /.test(l)) { inSection = true; counted = false; continue; }
162
236
  if (/^# /.test(l)) { inSection = false; continue; }
163
- if (inSection && !counted && /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/.test(l)) { sectionsWithList++; counted = true; }
237
+ if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
164
238
  }
165
239
  }
166
240
  if (h2Count > 0 && sectionsWithList > 0) {
@@ -170,12 +244,12 @@ export function validateLlmsTxt(f) {
170
244
  } else {
171
245
  add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
172
246
  }
173
- const links = [...f.text.matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g)];
247
+ const links = collectLinks(f.text);
174
248
  // An entry an agent can use has a name and a target with a host. An empty name and a
175
249
  // bare "https://" both counted as valid absolute links until 2026-08-29.
176
- const named = links.filter((m) => m[1].trim() !== "");
250
+ const named = links.filter((m) => m.name.trim() !== "");
177
251
  const unnamed = links.length - named.length;
178
- const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m[2])).length;
252
+ const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m.target)).length;
179
253
  if (links.length === 0) {
180
254
  add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
181
255
  } else if (unnamed > 0) {
@@ -183,7 +257,7 @@ export function validateLlmsTxt(f) {
183
257
  } else if (absolute === named.length) {
184
258
  add("links", "pass", "Markdown links an agent can follow", named.length + " link" + (named.length === 1 ? "" : "s") + ", all absolute URLs");
185
259
  } else {
186
- const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m[2])).length;
260
+ const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m.target)).length;
187
261
  const hostless = named.length - absolute - relativeCount;
188
262
  add("links", "warn", "Markdown links an agent can follow", named.length + " links, " + relativeCount + " relative" + (hostless > 0 ? " and " + hostless + " with a scheme but no host" : "") + "; absolute URLs travel better when the file is read out of context");
189
263
  }
@@ -473,9 +547,9 @@ export function validateV2Discovery(found, unreadReason) {
473
547
  return checks;
474
548
  }
475
549
  add("v2-describedby", found.describedby ? "pass" : "info", "Home page points to its llms.txt (v2)",
476
- found.describedby ? 'rel="describedby" to ' + found.describedby.slice(0, 120) : 'no rel="describedby" in the head or the Link header; v2 recommends it so an agent finds the file without guessing');
550
+ found.describedby ? 'rel="describedby" to ' + cut(found.describedby, 120) : 'no rel="describedby" in the head or the Link header; v2 recommends it so an agent finds the file without guessing');
477
551
  add("v2-markdown-alternate", found.markdown ? "pass" : "info", "Home page points to a markdown version (v2)",
478
- found.markdown ? 'rel="alternate" type="text/markdown" to ' + found.markdown.slice(0, 120) : 'no rel="alternate" type="text/markdown" in the head or the Link header; v2 recommends it so an agent finds the markdown form without guessing');
552
+ found.markdown ? 'rel="alternate" type="text/markdown" to ' + cut(found.markdown, 120) : 'no rel="alternate" type="text/markdown" in the head or the Link header; v2 recommends it so an agent finds the markdown form without guessing');
479
553
  return checks;
480
554
  }
481
555
 
@@ -488,7 +562,7 @@ export function summarizeChecks(checks) {
488
562
  export async function validateHost(input, opts = {}) {
489
563
  const host = normalizeHostInput(input);
490
564
  if (!host || !isValidPublicHost(host)) {
491
- throw new Error("not a public domain name: " + String(input).slice(0, 120));
565
+ throw new Error("not a public domain name: " + cut(input, 120));
492
566
  }
493
567
  // opts comes from the caller, and path and accept exist for this function's own
494
568
  // second read. Forwarding them to the first read would let a caller point the