@rankcli/agent-runtime 0.0.13 → 0.0.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/agent-runtime",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "RankCLI agent runtime - executes SEO audits and fixes with AI",
5
5
  "homepage": "https://rankcli.dev",
6
6
  "main": "dist/index.js",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@anthropic-ai/sdk": "^0.52.0",
30
+ "@supabase/supabase-js": "^2.98.0",
30
31
  "cheerio": "1.0.0-rc.12",
31
32
  "openai": "^6.16.0",
32
33
  "yaml": "^2.3.0"
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Agent Experience (AX) readiness
3
+ *
4
+ * Classic GEO asks "can an AI crawler read this page and cite it in an
5
+ * answer?" — that's what ai-readiness.ts covers. AX asks a different
6
+ * question: "can an AI AGENT (not just a chatbot) discover what this site
7
+ * can DO and act on it?" — a distinct, newer discipline (named tooling in
8
+ * this space: AgentGrade and similar agent-readiness scanners, 2026).
9
+ *
10
+ * This checks for the machine-readable discovery surface an agent looks
11
+ * for before it can act: llms-full.txt (the fuller, more-fetched sibling
12
+ * of llms.txt), a SKILL.md capability manifest, a discoverable MCP server,
13
+ * and an OpenAPI spec. None of these are required — most sites won't have
14
+ * any of them yet, this is genuinely emerging — so absence is reported as
15
+ * a low-severity opportunity, not a failure.
16
+ */
17
+
18
+ import { httpGet } from '../../utils/http.js';
19
+ import type { AuditIssue } from '../types.js';
20
+ import { ISSUE_DEFINITIONS } from '../types.js';
21
+
22
+ export interface AgentExperienceSignal {
23
+ path: string;
24
+ present: boolean;
25
+ description: string;
26
+ }
27
+
28
+ export interface AgentExperienceData {
29
+ signals: AgentExperienceSignal[];
30
+ score: number; // 0-100, percentage of signals present
31
+ }
32
+
33
+ const SIGNAL_PATHS: Array<{ path: string; description: string }> = [
34
+ { path: '/llms-full.txt', description: 'Full-site content dump for AI agents — the fuller sibling of llms.txt, measured as fetched roughly 2x more often' },
35
+ { path: '/skill.md', description: 'SKILL.md capability manifest (Anthropic Agent Skills convention)' },
36
+ { path: '/mcp', description: 'MCP (Model Context Protocol) server, discoverable at a conventional path' },
37
+ { path: '/.well-known/mcp.json', description: 'MCP server descriptor at the .well-known convention' },
38
+ { path: '/openapi.json', description: 'OpenAPI spec — lets an agent discover and call your API directly instead of scraping HTML' },
39
+ { path: '/.well-known/ai-plugin.json', description: 'AI plugin manifest (older but still-referenced convention for agent tool discovery)' },
40
+ ];
41
+
42
+ async function fetchBody(baseUrl: string, path: string): Promise<{ status: number; body: string } | null> {
43
+ try {
44
+ const url = new URL(path, baseUrl).href;
45
+ const response = await httpGet<string>(url, {
46
+ timeout: 8000,
47
+ validateStatus: () => true,
48
+ });
49
+ return { status: response.status, body: String(response.data ?? '') };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Many sites — anything client-side-routed (React/Vue/etc. SPAs in
57
+ * particular) — serve their app shell with HTTP 200 for EVERY unmatched
58
+ * path rather than a real 404. A naive "status 200 = found" check would
59
+ * report every single signal as present on such a site, which is worse
60
+ * than not checking at all — actively misleading rather than merely
61
+ * incomplete. Fetching one definitely-nonexistent path first and
62
+ * comparing every signal's response against it catches this: identical
63
+ * bytes back means "this is the fallback shell," not a real resource,
64
+ * regardless of what status code came back with it.
65
+ */
66
+ async function pathExists(baseUrl: string, path: string, fallbackBody: string | null): Promise<boolean> {
67
+ const result = await fetchBody(baseUrl, path);
68
+ if (!result) return false;
69
+ if (result.status === 404) return false;
70
+ if (result.status < 200 || result.status >= 400) return false;
71
+ if (fallbackBody != null && result.body === fallbackBody) return false;
72
+ return true;
73
+ }
74
+
75
+ export async function checkAgentExperience(
76
+ baseUrl: string
77
+ ): Promise<{ issues: AuditIssue[]; data: AgentExperienceData }> {
78
+ const issues: AuditIssue[] = [];
79
+
80
+ const probePath = `/__rankcli_ax_probe_${Math.random().toString(36).slice(2)}`;
81
+ const baseline = await fetchBody(baseUrl, probePath);
82
+ const fallbackBody = baseline && baseline.status >= 200 && baseline.status < 400 ? baseline.body : null;
83
+
84
+ const signals = await Promise.all(
85
+ SIGNAL_PATHS.map(async ({ path, description }) => ({
86
+ path,
87
+ description,
88
+ present: await pathExists(baseUrl, path, fallbackBody),
89
+ }))
90
+ );
91
+
92
+ const presentCount = signals.filter((s) => s.present).length;
93
+ const score = Math.round((presentCount / signals.length) * 100);
94
+
95
+ // Only worth flagging when a site has essentially no agent-facing
96
+ // discovery surface at all — this is a forward-looking opportunity
97
+ // check, not a compliance requirement, so it stays low-severity and
98
+ // only fires below a meaningful threshold.
99
+ if (presentCount === 0) {
100
+ issues.push({
101
+ ...ISSUE_DEFINITIONS.NO_AGENT_EXPERIENCE_SURFACE,
102
+ affectedUrls: [baseUrl],
103
+ details: { checkedPaths: SIGNAL_PATHS.map((s) => s.path) },
104
+ });
105
+ }
106
+
107
+ return { issues, data: { signals, score } };
108
+ }
@@ -5,6 +5,7 @@ import { httpGet } from '../../utils/http.js';
5
5
  import * as cheerio from 'cheerio';
6
6
  import type { AuditIssue } from '../types.js';
7
7
  import { ISSUE_DEFINITIONS } from '../types.js';
8
+ import { checkAgentExperience, type AgentExperienceData } from './agent-experience.js';
8
9
 
9
10
  // Known AI bot user agents
10
11
  const AI_BOTS = {
@@ -34,10 +35,18 @@ export interface AIBotBlockingResult {
34
35
  allBlocked: boolean;
35
36
  }
36
37
 
38
+ export interface CloudflareAICrawlerGateResult {
39
+ behindCloudflare: boolean;
40
+ hasExplicitAIRules: boolean;
41
+ ambiguous: boolean; // behind Cloudflare with no explicit AI crawler rules
42
+ }
43
+
37
44
  export interface AIReadinessData {
38
45
  llmsTxt: LlmsTxtResult;
39
46
  botBlocking: AIBotBlockingResult;
40
47
  jsRenderingRatio: number; // 0-100%
48
+ cloudflareAIGate: CloudflareAICrawlerGateResult;
49
+ agentExperience: AgentExperienceData;
41
50
  }
42
51
 
43
52
  /**
@@ -307,6 +316,54 @@ export function checkJSRenderingRatio(
307
316
  };
308
317
  }
309
318
 
319
+ /**
320
+ * Check whether a Cloudflare-fronted site has explicit AI crawler rules.
321
+ *
322
+ * Cloudflare blocks "mixed-use" AI crawlers by default on ad-hosting zones
323
+ * starting September 15, 2026, and is expanding Pay Per Crawl into a
324
+ * broader Pay Per Use gating model. A site with no explicit robots.txt
325
+ * stance on AI crawlers is now relying on whatever Cloudflare's
326
+ * account-level bot-management default happens to be — invisible from
327
+ * the codebase, and liable to change without a corresponding commit.
328
+ */
329
+ export async function checkCloudflareAICrawlerGate(
330
+ baseUrl: string,
331
+ botBlocking: AIBotBlockingResult
332
+ ): Promise<{ issues: AuditIssue[]; data: CloudflareAICrawlerGateResult }> {
333
+ const issues: AuditIssue[] = [];
334
+
335
+ let behindCloudflare = false;
336
+ try {
337
+ const response = await httpGet<string>(baseUrl, {
338
+ timeout: 10000,
339
+ validateStatus: () => true,
340
+ });
341
+ const server = response.headers['server'] || '';
342
+ behindCloudflare = server.toLowerCase().includes('cloudflare') || 'cf-ray' in response.headers;
343
+ } catch {
344
+ behindCloudflare = false;
345
+ }
346
+
347
+ // "Explicit" means robots.txt exists and names at least one AI crawler
348
+ // specifically, rather than relying purely on a bare wildcard rule or
349
+ // the absence of a file.
350
+ const hasExplicitAIRules = botBlocking.robotsExists && botBlocking.blockedBots.length > 0;
351
+
352
+ const ambiguous = behindCloudflare && !hasExplicitAIRules;
353
+
354
+ if (ambiguous) {
355
+ issues.push({
356
+ ...ISSUE_DEFINITIONS.CLOUDFLARE_AI_CRAWLER_GATE_AMBIGUOUS,
357
+ affectedUrls: [baseUrl],
358
+ });
359
+ }
360
+
361
+ return {
362
+ issues,
363
+ data: { behindCloudflare, hasExplicitAIRules, ambiguous },
364
+ };
365
+ }
366
+
310
367
  /**
311
368
  * Run all AI readiness checks
312
369
  */
@@ -328,12 +385,22 @@ export async function runAIReadinessChecks(
328
385
  const jsResult = checkJSRenderingRatio(html, baseUrl);
329
386
  allIssues.push(...jsResult.issues);
330
387
 
388
+ // Check Cloudflare AI-crawler-access gating posture
389
+ const cfGateResult = await checkCloudflareAICrawlerGate(baseUrl, botResult.data);
390
+ allIssues.push(...cfGateResult.issues);
391
+
392
+ // Check agent-facing discovery surface (llms-full.txt, SKILL.md, MCP, OpenAPI)
393
+ const agentExperienceResult = await checkAgentExperience(baseUrl);
394
+ allIssues.push(...agentExperienceResult.issues);
395
+
331
396
  return {
332
397
  issues: allIssues,
333
398
  data: {
334
399
  llmsTxt: llmsResult.data,
335
400
  botBlocking: botResult.data,
336
401
  jsRenderingRatio: jsResult.data.ratio,
402
+ cloudflareAIGate: cfGateResult.data,
403
+ agentExperience: agentExperienceResult.data,
337
404
  },
338
405
  };
339
406
  }
@@ -312,17 +312,17 @@ function generateRecommendations(
312
312
 
313
313
  if (framework === 'React') {
314
314
  recommendations.push(
315
- 'Quick fix: Add react-snap to pre-render pages at build time (npm install -D react-snap, add "postbuild": "react-snap" to scripts)'
315
+ 'Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed'
316
316
  );
317
317
  recommendations.push(
318
- 'Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks'
318
+ 'Alternative: Migrate to Next.js or Remix for built-in SSR/SSG support'
319
319
  );
320
320
  } else if (framework === 'Vue') {
321
321
  recommendations.push(
322
- 'Quick fix: Add prerender-spa-plugin to pre-render pages at build time'
322
+ 'Quick fix: Use Vike (vike.dev) for SSR/SSG - works with Vite, minimal config needed'
323
323
  );
324
324
  recommendations.push(
325
- 'Alternative: Use Vike (vite-plugin-ssr) for SSR/SSG without changing frameworks'
325
+ 'Alternative: Migrate to Nuxt for built-in SSR/SSG support'
326
326
  );
327
327
  } else if (framework === 'Angular Universal') {
328
328
  recommendations.push(
@@ -330,7 +330,7 @@ function generateRecommendations(
330
330
  );
331
331
  } else {
332
332
  recommendations.push(
333
- 'Quick fix: Use a pre-rendering tool like react-snap or prerender-spa-plugin'
333
+ 'Quick fix: Use Vike (vike.dev) for SSR/SSG with any Vite-based framework'
334
334
  );
335
335
  }
336
336
 
@@ -368,10 +368,10 @@ export function analyzeClientRendering(
368
368
  // Critical: Client-side only rendering
369
369
  if (analysis.renderingMethod === 'csr' && analysis.confidence !== 'low') {
370
370
  const howToFixByFramework = analysis.frameworkDetected === 'React'
371
- ? 'Add react-snap to pre-render pages: npm install -D react-snap, then add "postbuild": "react-snap" to package.json scripts. No code changes needed.'
371
+ ? 'Use Vike (vike.dev) to add SSR/SSG to your Vite React app: npm install vike vike-react, then follow the setup guide. Alternatively, migrate to Next.js or Remix.'
372
372
  : analysis.frameworkDetected === 'Vue'
373
- ? 'Add prerender-spa-plugin to pre-render pages at build time. Alternatively, use Vike for SSR/SSG.'
374
- : 'Pre-render your pages using react-snap, prerender-spa-plugin, or similar build-time tools.';
373
+ ? 'Use Vike (vike.dev) to add SSR/SSG to your Vite Vue app: npm install vike vike-vue, then follow the setup guide. Alternatively, migrate to Nuxt.'
374
+ : 'Use Vike (vike.dev) for SSR/SSG with Vite-based frameworks, or use a framework with built-in SSR support (Next.js, Nuxt, Remix, SvelteKit).';
375
375
 
376
376
  issues.push({
377
377
  code: 'CLIENT_SIDE_RENDERING',
@@ -431,8 +431,8 @@ export function analyzeClientRendering(
431
431
  'Single Page Applications without SSR have slower time-to-content for search crawlers.',
432
432
  howToFix:
433
433
  analysis.frameworkDetected === 'React'
434
- ? 'Quick fix: Add react-snap (npm install -D react-snap) to pre-render at build time. Alternative: Use Vike for SSR/SSG.'
435
- : 'Quick fix: Add prerender-spa-plugin to pre-render at build time. Alternative: Use Vike for SSR/SSG.',
434
+ ? 'Add SSR/SSG using Vike (vike.dev): npm install vike vike-react. Alternative: migrate to Next.js or Remix.'
435
+ : 'Add SSR/SSG using Vike (vike.dev): npm install vike vike-vue. Alternative: migrate to Nuxt.',
436
436
  affectedUrls: [url],
437
437
  details: {
438
438
  framework: analysis.frameworkDetected,
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { analyzeRAGChunkReadiness } from './rag-chunk-readiness.js';
3
+
4
+ const testUrl = 'https://example.com';
5
+
6
+ function words(n: number, prefix = 'word'): string {
7
+ return Array.from({ length: n }, (_, i) => `${prefix}${i}`).join(' ');
8
+ }
9
+
10
+ describe('analyzeRAGChunkReadiness', () => {
11
+ it('returns zero sections and score 0 for a page with no headings', () => {
12
+ const html = `<html><body><p>${words(200)}</p></body></html>`;
13
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
14
+ expect(data.totalSections).toBe(0);
15
+ expect(data.chunkReadinessScore).toBe(0);
16
+ });
17
+
18
+ it('classifies a well-sized section (150-450 words) as good', () => {
19
+ const html = `<html><body><h2>Topic</h2><p>${words(300)}</p></body></html>`;
20
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
21
+ expect(data.totalSections).toBe(1);
22
+ expect(data.sections[0].wordCount).toBe(300);
23
+ expect(data.sections[0].sizeQuality).toBe('good');
24
+ });
25
+
26
+ it('classifies a very short section as too-short', () => {
27
+ const html = `<html><body><h2>Topic</h2><p>${words(20)}</p></body></html>`;
28
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
29
+ expect(data.sections[0].sizeQuality).toBe('too-short');
30
+ });
31
+
32
+ it('classifies a very long section as too-long', () => {
33
+ const html = `<html><body><h2>Topic</h2><p>${words(800)}</p></body></html>`;
34
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
35
+ expect(data.sections[0].sizeQuality).toBe('too-long');
36
+ });
37
+
38
+ it('splits content into sections at each h1/h2/h3, regardless of nesting depth', () => {
39
+ // Deliberately nested in wrapper divs — a sibling-only DOM walk would
40
+ // fail to associate the paragraphs with their heading here; document
41
+ // order should still get this right.
42
+ const html = `
43
+ <html><body>
44
+ <div class="section-wrapper">
45
+ <div class="inner"><h2>First</h2></div>
46
+ <div class="content"><p>${words(200, 'a')}</p></div>
47
+ </div>
48
+ <div class="section-wrapper">
49
+ <div class="inner"><h2>Second</h2></div>
50
+ <div class="content"><p>${words(200, 'b')}</p></div>
51
+ </div>
52
+ </body></html>
53
+ `;
54
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
55
+ expect(data.totalSections).toBe(2);
56
+ expect(data.sections[0].heading).toBe('First');
57
+ expect(data.sections[0].wordCount).toBe(200);
58
+ expect(data.sections[1].heading).toBe('Second');
59
+ expect(data.sections[1].wordCount).toBe(200);
60
+ });
61
+
62
+ it('accumulates multiple paragraphs and list items under one heading', () => {
63
+ const html = `
64
+ <html><body>
65
+ <h2>Topic</h2>
66
+ <p>${words(100, 'a')}</p>
67
+ <p>${words(100, 'b')}</p>
68
+ <ul><li>${words(50, 'c')}</li><li>${words(50, 'd')}</li></ul>
69
+ </body></html>
70
+ `;
71
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
72
+ expect(data.totalSections).toBe(1);
73
+ expect(data.sections[0].wordCount).toBe(300);
74
+ });
75
+
76
+ it('content before the first heading is not attributed to any section', () => {
77
+ const html = `
78
+ <html><body>
79
+ <p>${words(500)}</p>
80
+ <h2>Topic</h2>
81
+ <p>${words(200)}</p>
82
+ </body></html>
83
+ `;
84
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
85
+ expect(data.totalSections).toBe(1);
86
+ expect(data.sections[0].wordCount).toBe(200);
87
+ });
88
+
89
+ it('detects a dangling-reference opener', () => {
90
+ const html = `<html><body><h2>Topic</h2><p>This approach reduces cost significantly. ${words(150)}</p></body></html>`;
91
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
92
+ expect(data.sections[0].startsWithDanglingReference).toBe(true);
93
+ });
94
+
95
+ it('does not flag a section that names its subject', () => {
96
+ const html = `<html><body><h2>Topic</h2><p>Caching reduces cost significantly. ${words(150)}</p></body></html>`;
97
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
98
+ expect(data.sections[0].startsWithDanglingReference).toBe(false);
99
+ });
100
+
101
+ it('raises RAG_CHUNK_SIZE_MISMATCH when most sections are poorly sized', () => {
102
+ const html = `
103
+ <html><body>
104
+ <h2>One</h2><p>${words(20)}</p>
105
+ <h2>Two</h2><p>${words(900)}</p>
106
+ <h2>Three</h2><p>${words(15)}</p>
107
+ </body></html>
108
+ `;
109
+ const { issues } = analyzeRAGChunkReadiness(html, testUrl);
110
+ expect(issues.some((i) => i.code === 'RAG_CHUNK_SIZE_MISMATCH')).toBe(true);
111
+ });
112
+
113
+ it('does not raise RAG_CHUNK_SIZE_MISMATCH when most sections are well-sized', () => {
114
+ const html = `
115
+ <html><body>
116
+ <h2>One</h2><p>${words(200)}</p>
117
+ <h2>Two</h2><p>${words(250)}</p>
118
+ <h2>Three</h2><p>${words(300)}</p>
119
+ </body></html>
120
+ `;
121
+ const { issues } = analyzeRAGChunkReadiness(html, testUrl);
122
+ expect(issues.some((i) => i.code === 'RAG_CHUNK_SIZE_MISMATCH')).toBe(false);
123
+ });
124
+
125
+ it('raises RAG_CHUNK_DANGLING_REFERENCES when over 30% of sections open with one', () => {
126
+ const html = `
127
+ <html><body>
128
+ <h2>One</h2><p>This reduces cost. ${words(150)}</p>
129
+ <h2>Two</h2><p>It also improves speed. ${words(150)}</p>
130
+ <h2>Three</h2><p>Caching is the mechanism. ${words(150)}</p>
131
+ </body></html>
132
+ `;
133
+ const { issues } = analyzeRAGChunkReadiness(html, testUrl);
134
+ expect(issues.some((i) => i.code === 'RAG_CHUNK_DANGLING_REFERENCES')).toBe(true);
135
+ });
136
+
137
+ it('does not raise issues for a page with too few sections to judge (under 3)', () => {
138
+ const html = `<html><body><h2>One</h2><p>${words(20)}</p><h2>Two</h2><p>${words(900)}</p></body></html>`;
139
+ const { issues } = analyzeRAGChunkReadiness(html, testUrl);
140
+ expect(issues.length).toBe(0);
141
+ });
142
+
143
+ it('computes a chunkReadinessScore of 100 when all sections are good and self-contained', () => {
144
+ const html = `
145
+ <html><body>
146
+ <h2>One</h2><p>Caching reduces latency. ${words(200)}</p>
147
+ <h2>Two</h2><p>Compression reduces bandwidth. ${words(200)}</p>
148
+ </body></html>
149
+ `;
150
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
151
+ expect(data.chunkReadinessScore).toBe(100);
152
+ });
153
+
154
+ it('estimates tokens at roughly 1.33 tokens per word', () => {
155
+ const html = `<html><body><h2>Topic</h2><p>${words(300)}</p></body></html>`;
156
+ const { data } = analyzeRAGChunkReadiness(html, testUrl);
157
+ expect(data.sections[0].estimatedTokens).toBe(399); // round(300 * 1.33)
158
+ });
159
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * RAG Chunk Readiness
3
+ *
4
+ * How AI answer engines actually retrieve content: RAG (Retrieval-Augmented
5
+ * Generation) systems chunk a page into pieces — the 2026 convergence
6
+ * benchmark is roughly 400-512 tokens per chunk, 10-25% overlap — then
7
+ * retrieve whichever chunk(s) best match a query, not the whole page. A page
8
+ * whose own heading-delimited sections are naturally chunk-sized, with clear
9
+ * headings and paragraphs that don't lean on the previous section's context
10
+ * to make sense, is less likely to get split mid-thought by a RAG chunker —
11
+ * preserving the semantic boundaries the author actually wrote, which is
12
+ * what the RAG-chunking literature credits with better retrieval recall.
13
+ *
14
+ * This is a genuinely different angle from ai-content-structure.ts (tables/
15
+ * lists/Q&A formatting): that's about FORMAT, this is about SECTION SIZE
16
+ * and SELF-CONTAINMENT. Novel enough that no dedicated site-auditing tool
17
+ * for it was found as of this writing — RAG-chunking guidance that exists
18
+ * targets people building RAG systems, not people writing web content.
19
+ */
20
+
21
+ import * as cheerio from 'cheerio';
22
+ import type { AuditIssue } from '../types.js';
23
+
24
+ export interface ChunkSection {
25
+ heading: string;
26
+ headingLevel: number;
27
+ wordCount: number;
28
+ estimatedTokens: number;
29
+ sizeQuality: 'too-short' | 'good' | 'too-long';
30
+ startsWithDanglingReference: boolean;
31
+ }
32
+
33
+ export interface RAGChunkReadinessData {
34
+ sections: ChunkSection[];
35
+ totalSections: number;
36
+ wellSizedSectionCount: number;
37
+ danglingReferenceCount: number;
38
+ chunkReadinessScore: number; // 0-100
39
+ }
40
+
41
+ // 400-512 tokens is the 2026 RAG-chunking convergence benchmark. English
42
+ // prose runs roughly 1.3 words per token (~0.75 tokens/word), so that's
43
+ // roughly 300-385 words — widened to 150-450 here since this measures how
44
+ // naturally an AUTHOR sectioned their content, not what a chunker itself
45
+ // targets; a section in this band is unlikely to need mid-thought splitting
46
+ // either way, which is the property that actually matters.
47
+ const MIN_GOOD_WORDS = 150;
48
+ const MAX_GOOD_WORDS = 450;
49
+ const TOKENS_PER_WORD = 1.33;
50
+
51
+ // A section opening on a bare pronoun/demonstrative reference ("This
52
+ // approach...", "It also...") reads fine in place but loses its antecedent
53
+ // the moment a RAG system retrieves it standalone — a real self-containment
54
+ // signal, not a grammar nitpick.
55
+ const DANGLING_OPENERS = /^(this|it|these|that|they|such|those|the former|the latter)\b/i;
56
+
57
+ function classifySize(wordCount: number): ChunkSection['sizeQuality'] {
58
+ if (wordCount < MIN_GOOD_WORDS) return 'too-short';
59
+ if (wordCount > MAX_GOOD_WORDS) return 'too-long';
60
+ return 'good';
61
+ }
62
+
63
+ /**
64
+ * Splits content into heading-delimited sections by walking all
65
+ * heading/content elements in DOCUMENT ORDER (a single multi-tag cheerio
66
+ * selector), rather than via sibling traversal — sibling-only walks break
67
+ * on the wrapper-div-heavy DOM nesting common in modern frameworks, where
68
+ * a heading and "its" paragraphs aren't actually direct siblings. Document
69
+ * order is robust to arbitrary nesting depth.
70
+ */
71
+ function extractSections($: cheerio.CheerioAPI): Array<{ heading: string; headingLevel: number; text: string }> {
72
+ const elements = $('h1, h2, h3, p, li, blockquote').toArray();
73
+ const sections: Array<{ heading: string; headingLevel: number; text: string }> = [];
74
+ let current: { heading: string; headingLevel: number; text: string } | null = null;
75
+
76
+ for (const el of elements) {
77
+ const tag = (el as { tagName?: string }).tagName?.toLowerCase();
78
+ if (tag === 'h1' || tag === 'h2' || tag === 'h3') {
79
+ if (current) sections.push(current);
80
+ current = { heading: $(el).text().trim(), headingLevel: parseInt(tag.slice(1), 10), text: '' };
81
+ } else if (current) {
82
+ current.text += ' ' + $(el).text();
83
+ }
84
+ // Content before the first heading has no section to attribute it to —
85
+ // intentionally skipped rather than inventing a synthetic "intro" chunk.
86
+ }
87
+ if (current) sections.push(current);
88
+ return sections;
89
+ }
90
+
91
+ export function analyzeRAGChunkReadiness(
92
+ html: string,
93
+ url: string
94
+ ): { issues: AuditIssue[]; data: RAGChunkReadinessData } {
95
+ const issues: AuditIssue[] = [];
96
+ const $ = cheerio.load(html);
97
+ $('nav, footer, aside, script, style, noscript, header').remove();
98
+
99
+ const rawSections = extractSections($);
100
+
101
+ const sections: ChunkSection[] = rawSections.map((s) => {
102
+ const words = s.text.trim().split(/\s+/).filter(Boolean);
103
+ const wordCount = words.length;
104
+ const estimatedTokens = Math.round(wordCount * TOKENS_PER_WORD);
105
+ const firstSentence = s.text.trim().split(/[.!?]/)[0] || '';
106
+ return {
107
+ heading: s.heading,
108
+ headingLevel: s.headingLevel,
109
+ wordCount,
110
+ estimatedTokens,
111
+ sizeQuality: classifySize(wordCount),
112
+ startsWithDanglingReference: DANGLING_OPENERS.test(firstSentence.trim()),
113
+ };
114
+ });
115
+
116
+ const totalSections = sections.length;
117
+ const wellSizedSectionCount = sections.filter((s) => s.sizeQuality === 'good').length;
118
+ const danglingReferenceCount = sections.filter((s) => s.startsWithDanglingReference).length;
119
+
120
+ const chunkReadinessScore =
121
+ totalSections === 0
122
+ ? 0
123
+ : Math.round(
124
+ ((wellSizedSectionCount / totalSections) * 0.7 +
125
+ ((totalSections - danglingReferenceCount) / totalSections) * 0.3) *
126
+ 100
127
+ );
128
+
129
+ if (totalSections >= 3 && wellSizedSectionCount / totalSections < 0.5) {
130
+ const tooLong = sections.filter((s) => s.sizeQuality === 'too-long').length;
131
+ const tooShort = sections.filter((s) => s.sizeQuality === 'too-short').length;
132
+ issues.push({
133
+ code: 'RAG_CHUNK_SIZE_MISMATCH',
134
+ severity: 'notice',
135
+ category: 'ai-readiness',
136
+ title: 'Most sections are poorly sized for AI retrieval chunking',
137
+ description: `${wellSizedSectionCount} of ${totalSections} sections fall in a well-sized range for RAG retrieval (roughly 150-450 words); ${tooLong} are too long and likely to get split mid-thought, ${tooShort} are too short and likely to get merged with unrelated neighbors.`,
138
+ impact: 'When an AI answer engine retrieves a chunk of this page in response to a query, oversized sections risk being cut off mid-point and undersized ones risk losing standalone context — both reduce the odds the retrieved chunk reads coherently in an AI-generated answer.',
139
+ howToFix: 'Break up long sections (450+ words) with an additional H2/H3 subheading roughly every 300-400 words. Merge very short sections (under 150 words) into a neighboring section or expand them with enough context to stand alone.',
140
+ affectedUrls: [url],
141
+ details: { wellSizedSectionCount, totalSections, tooLong, tooShort },
142
+ });
143
+ }
144
+
145
+ if (totalSections >= 3 && danglingReferenceCount / totalSections > 0.3) {
146
+ issues.push({
147
+ code: 'RAG_CHUNK_DANGLING_REFERENCES',
148
+ severity: 'notice',
149
+ category: 'ai-readiness',
150
+ title: 'Several sections open with a reference to prior context',
151
+ description: `${danglingReferenceCount} of ${totalSections} sections start with a pronoun or demonstrative ("This...", "It...", "These...") that depends on the previous section to make sense.`,
152
+ impact: 'A RAG system that retrieves one of these sections on its own — which is exactly how retrieval works, one chunk at a time — surfaces a sentence whose subject is undefined, reading as broken or confusing in an AI-generated answer.',
153
+ howToFix: 'Open each section by naming its actual subject instead of referring back to the previous one — e.g. "This approach reduces cost" becomes "Caching reduces cost."',
154
+ affectedUrls: [url],
155
+ details: { danglingReferenceCount, totalSections },
156
+ });
157
+ }
158
+
159
+ return {
160
+ issues,
161
+ data: { sections, totalSections, wellSizedSectionCount, danglingReferenceCount, chunkReadinessScore },
162
+ };
163
+ }
@@ -59,7 +59,15 @@ export async function analyzeSecurityHeaders(url: string): Promise<{ issues: Aud
59
59
  });
60
60
 
61
61
  const headers = response.headers;
62
- const isHttps = url.startsWith('https://');
62
+ // Use URL parsing for robust protocol detection (handles case variations like Https://)
63
+ let isHttps = false;
64
+ try {
65
+ const parsedUrl = new URL(url);
66
+ isHttps = parsedUrl.protocol === 'https:';
67
+ } catch {
68
+ // If URL parsing fails, fall back to startsWith check
69
+ isHttps = url.toLowerCase().startsWith('https://');
70
+ }
63
71
 
64
72
  // Extract security headers (case-insensitive)
65
73
  const getHeader = (name: string): string | null => {
@@ -151,10 +159,18 @@ export async function analyzeSecurityHeaders(url: string): Promise<{ issues: Aud
151
159
  },
152
160
  };
153
161
  } catch (error) {
162
+ // Use URL parsing for robust protocol detection in error case too
163
+ let isHttps = false;
164
+ try {
165
+ const parsedUrl = new URL(url);
166
+ isHttps = parsedUrl.protocol === 'https:';
167
+ } catch {
168
+ isHttps = url.toLowerCase().startsWith('https://');
169
+ }
154
170
  return {
155
171
  issues,
156
172
  data: {
157
- https: url.startsWith('https://'),
173
+ https: isHttps,
158
174
  headers: {
159
175
  hsts: null,
160
176
  csp: null,