turva-llms-txt-validator 0.3.10 → 0.3.11

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
@@ -54,7 +54,7 @@ Use JSON for automation and strict mode when warnings should fail a CI step:
54
54
  npx --yes turva-llms-txt-validator example.com --json --strict
55
55
  ```
56
56
 
57
- Completed validation returns `{ target, summary, checks }`. Each check contains `{ id, status, label, detail }`. With `--json`, input and fetch errors return `{ "error": "...", "target": "..." }` and exit with code `2`. The target is the address given on the command line, with any user name, password and query value masked, and a network error adds `code`, such as `EAI_AGAIN`. An unknown option, a second domain and a missing domain are input errors too. `--help` exits with code `0`, and `--version` or `-V` prints the installed version and exits with code `0`.
57
+ Completed validation returns `{ target, summary, checks }`. Each check contains `{ id, status, label, detail }`. The `input-path` check, which reports a path given with the domain and then ignored, also carries `value`, that path cut to 120 characters. With `--json`, input and fetch errors return `{ "error": "...", "target": "..." }` and exit with code `2`. The target is the address given on the command line, with any user name, password and query value masked, and a network error adds `code`, such as `EAI_AGAIN`. An unknown option, a second domain and a missing domain are input errors too. `--help` exits with code `0`, and `--version` or `-V` prints the installed version and exits with code `0`.
58
58
 
59
59
  ## What it checks
60
60
 
package/bin/cli.mjs CHANGED
@@ -60,9 +60,12 @@ try {
60
60
  console.log(JSON.stringify(result, null, 2));
61
61
  } else {
62
62
  // Details reach this point without control characters (validateHost strips them), so a
63
- // published link cannot write an escape sequence to the terminal.
63
+ // published link cannot write an escape sequence to the terminal. A check's reflected
64
+ // value, when it has one, is printed ahead of the sentence, the same position the old
65
+ // concatenated string put it in, so text output reads the same while the JSON field stays
66
+ // separate (round 3 Tek-542, mirrored from worker.js decision 19, Tek-526).
64
67
  console.log(result.target);
65
- for (const c of result.checks) console.log(" " + mark[c.status] + " " + c.label + " (" + c.detail + ")");
68
+ for (const c of result.checks) console.log(" " + mark[c.status] + " " + c.label + " (" + (c.value !== undefined ? c.value + " " : "") + c.detail + ")");
66
69
  console.log("result: " + result.summary);
67
70
  }
68
71
  if (result.summary === "not valid") process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turva-llms-txt-validator",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
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",
package/src/index.mjs CHANGED
@@ -59,8 +59,16 @@ export function maskLocation(href, base) {
59
59
  const start = scheme ? scheme[0].length : 0;
60
60
  let from = start;
61
61
  while (from < s.length && (s[from] === "/" || s[from] === "\\")) from++;
62
- // Without a scheme and two slashes there is no authority, and an @ is part of a path.
63
- if (!scheme && from - start < 2) return s;
62
+ // Without a scheme and two slashes there is normally no authority, and an @ is part of a
63
+ // path ("path@2x.png" stays unmasked below). But when an @ still appears before the first
64
+ // /, the text ahead of it reads as userinfo even when the parser here does not recognise it
65
+ // as a scheme (an underscore, a leading digit or space, a stray control character), so that
66
+ // shape is masked too instead of let through (VN1, round 3 Tek-542).
67
+ if (!scheme && from - start < 2) {
68
+ const firstSlash = s.indexOf("/");
69
+ const firstAt = s.indexOf("@");
70
+ if (firstAt < 0 || firstSlash < 0 || firstAt > firstSlash) return s;
71
+ }
64
72
  // Nothing tells which @ of a refused target ends the user information, so everything up to the
65
73
  // last one is shown as ***. The mask is visible on purpose: three review rounds on 2026-09-22
66
74
  // measured every rule that guessed, and each one either returned a password that holds a slash
@@ -213,12 +221,23 @@ function isEscaped(s, i) {
213
221
  // span itself crosses a real line ending: a fenceless "`[a](b)\ncontinued`" used to be masked
214
222
  // only on its own line, so the second line escaped the mask and its bracket read as a real link
215
223
  // (outside review 2026-09-28). A span opens with a run of backticks and closes with the next run
216
- // of the same length anywhere in the text; a pair whose interior holds a blank line (two line
217
- // endings in a row) is not a code span in CommonMark, so it is left unmasked and its backticks
218
- // stay literal. An escaped first backtick is literal and the rest of its run still opens, while a
219
- // closing run is never escaped, because a backslash inside a code span is literal. Runs are
220
- // grouped by length and every group is read with one forward pointer, so the scan stays linear on
221
- // a document of unmatched runs.
224
+ // of the same length anywhere in the text; a pair that crosses a block boundary, a blank line
225
+ // (blank or made only of spaces and tabs), a heading line or a new list item, is not a code span
226
+ // in CommonMark, so it is left unmasked and its backticks stay literal (VREG2, round 3 Tek-542:
227
+ // backticks in two different list items used to enclose a real hyperlink between them). An
228
+ // escaped first backtick is literal and the rest of its run still opens, while a closing run is
229
+ // never escaped, because a backslash inside a code span is literal. Runs are grouped by length
230
+ // and every group is read with one forward pointer, so the scan stays linear on a document of
231
+ // unmatched runs.
232
+ function crossesBlockBoundary(text, start, end) {
233
+ const inner = text.slice(start, end);
234
+ if (/\n[ \t]*\n/.test(inner)) return true;
235
+ const lines = inner.split("\n");
236
+ for (let i = 1; i < lines.length; i++) {
237
+ if (/^ {0,3}(#{1,6}(?:[ \t]|$)|[-*+][ \t]|\d{1,9}[.)][ \t])/.test(lines[i])) return true;
238
+ }
239
+ return false;
240
+ }
222
241
  function codeSpanMask(text) {
223
242
  const mask = new Uint8Array(text.length);
224
243
  const runs = [];
@@ -244,7 +263,7 @@ function codeSpanMask(text) {
244
263
  const close = runs[list[p]];
245
264
  const start = r.escaped ? r.at + 1 : r.at;
246
265
  const end = close.at + close.len;
247
- if (text.slice(start, end).indexOf("\n\n") === -1) mask.fill(1, start, end);
266
+ if (!crossesBlockBoundary(text, start, end)) mask.fill(1, start, end);
248
267
  k = list[p];
249
268
  }
250
269
  return mask;
@@ -258,15 +277,31 @@ function normalizeLinkLabel(s) {
258
277
 
259
278
  // Link reference definitions, "[label]: target", read once per document so a reference link
260
279
  // such as "[text][label]" can resolve against one written anywhere else in the same file,
261
- // before or after its use, the way CommonMark allows (outside review 2026-09-28). A title
262
- // after the target is not read, because nothing here compares it, and only the first
263
- // definition of a given label counts, per spec. A destination wrapped in angle brackets has
264
- // them stripped.
280
+ // before or after its use, the way CommonMark allows (outside review 2026-09-28). A definition
281
+ // only opens where a new block can start: at the top of the document, right after a blank
282
+ // line, right after a heading or list marker line, or right after another definition line; a
283
+ // line that continues open paragraph text is read as that paragraph, not as a fresh
284
+ // definition, and a "definition" written inside an inline code span is the span's own literal
285
+ // text (VREG1, round 3 Tek-542). A title after the target counts only in quotes or
286
+ // parentheses on the same line; anything else trailing the target, such as a bare word, makes
287
+ // the whole line not a definition, matching CommonMark instead of reading everything up to the
288
+ // next space as the title's own value. Only the first definition of a given label counts, per
289
+ // spec. A destination wrapped in angle brackets has them stripped.
265
290
  function collectLinkDefinitions(text) {
266
291
  const defs = new Map();
267
- for (const line of text.split("\n")) {
268
- const m = /^ {0,3}\[([^\]]+)\]:[ \t]*(<[^<>\n]*>|\S+)/.exec(line);
292
+ const lines = text.split("\n");
293
+ const code = codeSpanMask(text);
294
+ const starts = [];
295
+ { let offset = 0; for (const l of lines) { starts.push(offset); offset += l.length + 1; } }
296
+ const DEF_START = /^ {0,3}\[[^\]]+\]:/;
297
+ const BLOCK_START = /^ {0,3}(#{1,6}(?:[ \t]|$)|[-*+][ \t]|\d{1,9}[.)][ \t])/;
298
+ for (let i = 0; i < lines.length; i++) {
299
+ const line = lines[i];
300
+ const prev = i > 0 ? lines[i - 1] : "";
301
+ if (prev.trim() !== "" && !DEF_START.test(prev) && !BLOCK_START.test(prev)) continue;
302
+ const m = /^ {0,3}\[([^\]]+)\]:[ \t]*(<[^<>\n]*>|\S+)([ \t]+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?[ \t]*$/.exec(line);
269
303
  if (!m) continue;
304
+ if (code[starts[i] + line.indexOf("[")]) continue;
270
305
  const label = normalizeLinkLabel(m[1]);
271
306
  if (!label || defs.has(label)) continue;
272
307
  let dest = m[2];
@@ -392,6 +427,19 @@ function collectLinks(text, defs) {
392
427
  continue;
393
428
  }
394
429
  }
430
+ } else {
431
+ // A shortcut reference, "[text]" alone with neither "(" nor "[" right after it, resolves
432
+ // against a definition using the link text itself as the label (VN2, round 3 Tek-542).
433
+ // Checked last, and only here, so it never overrides the full or collapsed reference
434
+ // forms above. Not tried when a ":" follows: that is the definition's own "[label]:"
435
+ // syntax, not a use of the label, and a definition line must not resolve against itself.
436
+ const label = text[j + 1] === ":" ? "" : normalizeLinkLabel(text.slice(i + 1, j));
437
+ const dest = label ? defs.get(label) : undefined;
438
+ if (dest !== undefined) {
439
+ out.push({ name: text.slice(i + 1, j), target: decodeAttributeValue(dest), at: i });
440
+ i = j;
441
+ continue;
442
+ }
395
443
  }
396
444
  i = j;
397
445
  }
@@ -571,23 +619,29 @@ export function validateLlmsTxt(f) {
571
619
  // reference link definition written anywhere in the prose resolves the same way for each
572
620
  // (outside review 2026-09-28, V2): "[text][label]" used to be recognized in the whole-document
573
621
  // links check but not in the per-line section scan, which only ever saw one list item line.
574
- const proseText = lines.filter((l, i) => !fenced[i]).join("\n");
622
+ // Fenced lines are blanked rather than removed (VREG1, round 3 Tek-542): deleting them and
623
+ // joining the survivors let a link's own target span straight across a fenced code block, as
624
+ // if the fence had never separated the two halves.
625
+ const proseText = lines.map((l, i) => (fenced[i] ? "" : l)).join("\n");
575
626
  const linkDefs = collectLinkDefinitions(proseText);
576
627
  const proseLinks = collectLinks(proseText, linkDefs);
577
628
  // Which original line each recognized link starts on, so the section scan below can ask "does
578
629
  // this line carry a link" against the same whole-document result the links check itself uses,
579
630
  // instead of re-finding links one isolated line at a time (outside review 2026-09-28, V2).
631
+ // proseLinks is already ordered by position, so the line cursor only moves forward across the
632
+ // whole list instead of restarting the scan from line zero for every link, which used to make
633
+ // a many-link document's mapping step quadratic (VREG3/VN3, round 3 Tek-542). Each proseText
634
+ // line now sits at the same index as its source line, because fenced lines are blanked, not
635
+ // removed, so the index read off the start-offset table names the original line directly.
580
636
  const linkedLineIdx = new Set();
581
637
  {
582
- const owners = [];
583
- for (let i = 0; i < lines.length; i++) if (!fenced[i]) owners.push(i);
584
638
  const starts = [];
585
639
  let offset = 0;
586
640
  for (const pl of proseText.split("\n")) { starts.push(offset); offset += pl.length + 1; }
641
+ let idx = 0;
587
642
  for (const link of proseLinks) {
588
- let idx = 0;
589
- for (let k = 0; k < starts.length; k++) { if (starts[k] <= link.at) idx = k; else break; }
590
- linkedLineIdx.add(owners[idx]);
643
+ while (idx + 1 < starts.length && starts[idx + 1] <= link.at) idx++;
644
+ linkedLineIdx.add(idx);
591
645
  }
592
646
  }
593
647
  const h2Count = lines.filter((l, i) => !fenced[i] && /^ {0,3}## /.test(l)).length;
@@ -1568,7 +1622,11 @@ export async function validateHost(input, opts = {}) {
1568
1622
  // A path in the typed address is not used, and the result says so first (mirrored from worker.js
1569
1623
  // v3.176.0). Information moves no summary and no exit code.
1570
1624
  const unusedPath = enteredPath(input);
1571
- if (unusedPath) checks.unshift({ id: "input-path", status: "info", label: "Path in the address you entered", detail: cut(unusedPath, 120) + " is not used, because the validator always reads /llms.txt at the root of the host" });
1625
+ // The typed path goes on its own field with the same 120 character cut it always had (VREG,
1626
+ // round 3 Tek-542, mirrored from worker.js decision 19, Tek-526), instead of being built into
1627
+ // the sentence, so a JSON caller reads the raw value from one field instead of parsing it
1628
+ // back out of a message meant for people.
1629
+ if (unusedPath) checks.unshift({ id: "input-path", status: "info", label: "Path in the address you entered", value: cut(unusedPath, 120), detail: "This path is not used, because the validator always reads /llms.txt at the root of the host." });
1572
1630
  // Text quoted from the fetched file leaves without bidirectional controls (round 19,
1573
1631
  // V6-U1, mirrored from worker.js v3.164.0): a site's own H1 or summary could otherwise
1574
1632
  // read in another order here than in the file itself.