turva-llms-txt-validator 0.3.0 → 0.3.1

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.mjs +67 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
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.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/index.mjs CHANGED
@@ -103,6 +103,68 @@ export async function fetchLlmsTxt(host, opts = {}) {
103
103
  }
104
104
  }
105
105
 
106
+ // Every markdown link in the file, scanned once from left to right instead of collected with
107
+ // matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g). That bound meant a target longer than 2048
108
+ // characters was not counted as a link at all, and dropping the bound from the pattern would
109
+ // make it quadratic on a file that repeats "[a](" (Erik 2026-08-29). The scan carries no bound
110
+ // and no backtracking: every character is read once and the furthest failed target scan is
111
+ // remembered. matchAll resumes after a whole match, and so does this.
112
+ function collectLinks(text) {
113
+ const out = [];
114
+ let failEnd = -1;
115
+ for (let i = 0; i < text.length; i++) {
116
+ if (text[i] !== "[") continue;
117
+ let j = i + 1;
118
+ while (j < text.length && text[j] !== "]" && text[j] !== "[") j++;
119
+ if (j >= text.length) break;
120
+ if (text[j] === "[") { i = j - 1; continue; }
121
+ if (text[j + 1] !== "(") { i = j; continue; }
122
+ const k = j + 2;
123
+ if (k <= failEnd) { i = j + 1; continue; }
124
+ let e = k;
125
+ while (e < text.length && text[e] !== ")" && !/\s/.test(text[e])) e++;
126
+ if (e > k && text[e] === ")") { out.push({ name: text.slice(i + 1, j), target: text.slice(k, e) }); i = e; continue; }
127
+ if (text[e] !== ")") failEnd = e;
128
+ i = j + 1;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ // A markdown list item that carries a link, scanned once from left to right instead of
134
+ // matched with /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/. That pattern is quadratic on a line
135
+ // such as "- " followed by "[a](" repeated, because every candidate rescans the target to the
136
+ // end of the line, and the line comes from the audited site (CodeQL js/polynomial-redos,
137
+ // 2026-08-29). Bounding the quantifier would trade the speed bug for a silent accuracy bug,
138
+ // so the scan is by index: every character is read once and the furthest failed target scan
139
+ // is remembered.
140
+ function listItemHasLink(l) {
141
+ const m = /^ {0,3}[-*+] /.exec(l);
142
+ if (!m) return false;
143
+ const isSep = (c) => c === "\r" || c === "\n" || c === "\u2028" || c === "\u2029";
144
+ let failEnd = -1;
145
+ for (let i = m[0].length; i < l.length; i++) {
146
+ // A "." in the old pattern never crosses a line terminator, and split(/\r?\n/) leaves
147
+ // a bare CR, U+2028 and U+2029 inside a line, so a link behind one was not a match then
148
+ // and is not one now.
149
+ if (isSep(l[i])) return false;
150
+ if (l[i] !== "[") continue;
151
+ let j = i + 1, sep = false;
152
+ while (j < l.length && l[j] !== "]" && l[j] !== "[") { if (isSep(l[j])) sep = true; j++; }
153
+ if (j >= l.length) return false;
154
+ if (l[j] === "[") { if (sep) return false; i = j - 1; continue; }
155
+ if (l[j + 1] !== "(") { if (sep) return false; i = j; continue; }
156
+ const k = j + 2;
157
+ if (k <= failEnd) { if (sep) return false; i = j + 1; continue; }
158
+ let e = k;
159
+ while (e < l.length && l[e] !== ")" && !/\s/.test(l[e])) e++;
160
+ if (e > k && l[e] === ")") return true;
161
+ if (sep) return false;
162
+ if (l[e] !== ")") failEnd = e;
163
+ i = j + 1;
164
+ }
165
+ return false;
166
+ }
167
+
106
168
  function redirectFailDetail(f) {
107
169
  if (f.reason === "off-host") return "redirects to " + f.location + ", a different host; llms.txt is host-scoped, so validate that host directly";
108
170
  if (f.reason === "unsafe-target") return "redirects to an unsupported target (" + f.location + "); only https redirects to the same site are followed";
@@ -160,7 +222,7 @@ export function validateLlmsTxt(f) {
160
222
  for (const l of lines) {
161
223
  if (/^## /.test(l)) { inSection = true; counted = false; continue; }
162
224
  if (/^# /.test(l)) { inSection = false; continue; }
163
- if (inSection && !counted && /^ {0,3}[-*+] .*\[[^\][]*\]\([^)\s]+\)/.test(l)) { sectionsWithList++; counted = true; }
225
+ if (inSection && !counted && listItemHasLink(l)) { sectionsWithList++; counted = true; }
164
226
  }
165
227
  }
166
228
  if (h2Count > 0 && sectionsWithList > 0) {
@@ -170,12 +232,12 @@ export function validateLlmsTxt(f) {
170
232
  } else {
171
233
  add("sections", "warn", "H2 sections group the content", "no H2 sections found; sections are the convention for grouping links");
172
234
  }
173
- const links = [...f.text.matchAll(/\[([^\][]*)\]\(([^)\s]{1,2048})\)/g)];
235
+ const links = collectLinks(f.text);
174
236
  // An entry an agent can use has a name and a target with a host. An empty name and a
175
237
  // bare "https://" both counted as valid absolute links until 2026-08-29.
176
- const named = links.filter((m) => m[1].trim() !== "");
238
+ const named = links.filter((m) => m.name.trim() !== "");
177
239
  const unnamed = links.length - named.length;
178
- const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m[2])).length;
240
+ const absolute = named.filter((m) => /^https?:\/\/[^/\s?#]+/.test(m.target)).length;
179
241
  if (links.length === 0) {
180
242
  add("links", "warn", "Markdown links an agent can follow", "no markdown links found");
181
243
  } else if (unnamed > 0) {
@@ -183,7 +245,7 @@ export function validateLlmsTxt(f) {
183
245
  } else if (absolute === named.length) {
184
246
  add("links", "pass", "Markdown links an agent can follow", named.length + " link" + (named.length === 1 ? "" : "s") + ", all absolute URLs");
185
247
  } else {
186
- const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m[2])).length;
248
+ const relativeCount = named.filter((m) => !/^[a-z][a-z0-9+.-]*:/i.test(m.target)).length;
187
249
  const hostless = named.length - absolute - relativeCount;
188
250
  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
251
  }