Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/audits/ai-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { AuditResult } from '../types.js';
import type { GatherResult } from '../gatherers/base-gatherer.js';
import type { HttpGatherResult } from '../gatherers/http-gatherer.js';
import { BaseAudit, type AuditMeta } from './base-audit.js';

/**
* Checks whether the site exposes an AI plugin manifest at
* `/.well-known/ai-plugin.json`.
*
* The ai-plugin.json manifest is the OpenAI ChatGPT plugin standard
* and enables AI platforms to discover and integrate with the site.
*/
export class AiPluginAudit extends BaseAudit {
meta: AuditMeta = {
id: 'ai-plugin',
title: 'Site provides an ai-plugin.json manifest',
failureTitle: 'Site does not provide an ai-plugin.json manifest',
description:
'An ai-plugin.json manifest at /.well-known/ai-plugin.json enables AI platforms ' +
'(such as ChatGPT plugins) to discover and interact with the site.',
requiredGatherers: ['http'],
scoreDisplayMode: 'binary',
};

async audit(artifacts: Record<string, GatherResult>): Promise<AuditResult> {
const http = artifacts['http'] as HttpGatherResult;
const aiPlugin = http.aiPlugin;

if (!aiPlugin.found) {
return this.fail({
type: 'text',
summary: 'No /.well-known/ai-plugin.json file was found at the target URL.',
});
}

const content = (aiPlugin.content ?? '').trim();

if (content.length === 0) {
return this.fail({
type: 'text',
summary: 'An ai-plugin.json file was found but it is empty.',
});
}

// Validate that it is parseable JSON
try {
const manifest = JSON.parse(content) as Record<string, unknown>;

// Basic structural validation: check for expected fields
const hasRequiredFields =
typeof manifest.schema_version === 'string' &&
typeof manifest.name_for_human === 'string' &&
typeof manifest.name_for_model === 'string';

if (!hasRequiredFields) {
return this.partial(0.5, {
type: 'text',
summary:
'An ai-plugin.json file was found with valid JSON, but it is missing ' +
'expected fields (schema_version, name_for_human, name_for_model).',
});
}

return this.pass({
type: 'text',
summary: `Found a valid ai-plugin.json manifest for "${String(manifest.name_for_human)}".`,
});
} catch {
return this.fail({
type: 'text',
summary: 'An ai-plugin.json file was found but it contains invalid JSON.',
});
}
}
}
68 changes: 68 additions & 0 deletions src/audits/content-negotiation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { AuditResult } from '../types.js';
import type { GatherResult } from '../gatherers/base-gatherer.js';
import type { HttpGatherResult } from '../gatherers/http-gatherer.js';
import { BaseAudit, type AuditMeta } from './base-audit.js';

/**
* Checks whether the site returns proper content-type headers and
* supports JSON-based content negotiation.
*
* Proper content-type headers and accept-header support enable AI agents
* to negotiate the most suitable response format.
*/
export class ContentNegotiationAudit extends BaseAudit {
meta: AuditMeta = {
id: 'content-negotiation',
title: 'Site supports proper content negotiation',
failureTitle: 'Site does not support proper content negotiation',
description:
'Proper content-type headers and support for JSON content negotiation ' +
'enable AI agents to request and receive data in the most suitable format.',
requiredGatherers: ['http'],
scoreDisplayMode: 'binary',
};

async audit(artifacts: Record<string, GatherResult>): Promise<AuditResult> {
const http = artifacts['http'] as HttpGatherResult;
const headers = http.headers;

const contentType = headers['content-type'] ?? '';

// Check that a content-type header is present at all
if (!contentType) {
return this.fail({
type: 'text',
summary:
'The site does not return a content-type header. ' +
'AI agents need content-type headers to correctly parse responses.',
});
}

// Check for JSON support indicators
const hasJsonSupport =
contentType.includes('application/json') ||
headers['accept']?.includes('application/json') ||
// Vary: Accept header indicates the server performs content negotiation
(headers['vary'] ?? '').toLowerCase().includes('accept');

if (hasJsonSupport) {
return this.pass({
type: 'table',
summary: 'The site returns proper content-type headers and supports JSON.',
items: [
{ header: 'content-type', value: contentType },
...(headers['vary'] ? [{ header: 'vary', value: headers['vary'] }] : []),
],
});
}

// The site has a content-type header but no JSON support signals
return this.fail({
type: 'table',
summary:
'The site returns a content-type header but does not indicate JSON support. ' +
'Consider supporting application/json for AI agent consumption.',
items: [{ header: 'content-type', value: contentType }],
});
}
}
43 changes: 43 additions & 0 deletions src/audits/error-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { AuditResult } from '../types.js';
import type { GatherResult } from '../gatherers/base-gatherer.js';
import type { ApiGatherResult } from '../gatherers/api-gatherer.js';
import { BaseAudit, type AuditMeta } from './base-audit.js';

/**
* Checks if the API uses structured error codes.
* Structured error responses (with code, message, and error fields) help AI agents
* programmatically handle failures and implement retry/recovery logic.
*/
export class ErrorCodesAudit extends BaseAudit {
meta: AuditMeta = {
id: 'error-codes',
title: 'API uses structured error codes',
failureTitle: 'API lacks structured error codes',
description:
'Structured error codes (e.g., {"error": "...", "code": "...", "message": "..."}) ' +
'allow AI agents to programmatically interpret failures and take corrective action ' +
'rather than attempting to parse free-text error messages.',
requiredGatherers: ['api'],
scoreDisplayMode: 'binary',
};

async audit(artifacts: Record<string, GatherResult>): Promise<AuditResult> {
const api = artifacts['api'] as ApiGatherResult;

if (api.errorCodeStructured) {
return this.pass({
type: 'text',
summary:
'Structured error codes detected in API specification. ' +
'Error responses include machine-readable code and message fields.',
});
}

return this.fail({
type: 'text',
summary:
'No structured error codes found. Define error response schemas with ' +
'"error", "code", and "message" fields in your API specification.',
});
}
}
31 changes: 31 additions & 0 deletions src/audits/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,33 @@
export { BaseAudit } from './base-audit.js';
export type { AuditMeta } from './base-audit.js';

// Discovery
export { LlmsTxtAudit } from './llms-txt.js';
export { OpenapiSpecAudit } from './openapi-spec.js';
export { RobotsAiAudit } from './robots-ai.js';
export { AiPluginAudit } from './ai-plugin.js';
export { SchemaOrgAudit } from './schema-org.js';

// API Quality
export { OpenapiValidAudit } from './openapi-valid.js';
export { ResponseFormatAudit } from './response-format.js';
export { ResponseExamplesAudit } from './response-examples.js';
export { ContentNegotiationAudit } from './content-negotiation.js';

// Structured Data
export { JsonLdAudit } from './json-ld.js';
export { MetaTagsAudit } from './meta-tags.js';
export { SemanticHtmlAudit } from './semantic-html.js';

// Auth & Onboarding
export { SelfServiceAuthAudit } from './self-service-auth.js';
export { NoCaptchaAudit } from './no-captcha.js';

// Error Handling
export { ErrorCodesAudit } from './error-codes.js';
export { RateLimitHeadersAudit } from './rate-limit-headers.js';
export { RetryAfterAudit } from './retry-after.js';

// Documentation
export { MachineReadableDocsAudit } from './machine-readable-docs.js';
export { SdkAvailableAudit } from './sdk-available.js';
56 changes: 56 additions & 0 deletions src/audits/json-ld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { AuditResult, AuditDetails } from '../types.js';
import type { GatherResult } from '../gatherers/base-gatherer.js';
import type { HtmlGatherResult } from '../gatherers/html-gatherer.js';
import { BaseAudit, type AuditMeta } from './base-audit.js';

/**
* Checks if the page contains valid JSON-LD structured data.
* JSON-LD helps AI agents understand page content and entity relationships.
*/
export class JsonLdAudit extends BaseAudit {
meta: AuditMeta = {
id: 'json-ld',
title: 'Page has JSON-LD structured data',
failureTitle: 'Page is missing JSON-LD structured data',
description:
'JSON-LD provides machine-readable structured data that helps AI agents ' +
'understand page content, entities, and relationships without parsing HTML.',
requiredGatherers: ['html'],
scoreDisplayMode: 'binary',
};

async audit(artifacts: Record<string, GatherResult>): Promise<AuditResult> {
const html = artifacts['html'] as HtmlGatherResult;
const jsonLdBlocks = html.jsonLd;

if (jsonLdBlocks.length > 0) {
const types = jsonLdBlocks
.filter(
(block): block is Record<string, unknown> =>
typeof block === 'object' && block !== null
)
.map((block) => block['@type'] as string | undefined)
.filter(Boolean);

const details: AuditDetails = {
type: 'table',
items: [
{
blockCount: jsonLdBlocks.length,
types: types.join(', ') || 'unknown',
},
],
summary: `Found ${jsonLdBlocks.length} JSON-LD block(s)`,
};

return this.pass(details);
}

return this.fail({
type: 'text',
summary:
'No JSON-LD structured data found. Add <script type="application/ld+json"> ' +
'blocks to help AI agents parse your content.',
});
}
}
49 changes: 49 additions & 0 deletions src/audits/llms-txt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { AuditResult } from '../types.js';
import type { GatherResult } from '../gatherers/base-gatherer.js';
import type { HttpGatherResult } from '../gatherers/http-gatherer.js';
import { BaseAudit, type AuditMeta } from './base-audit.js';

/**
* Checks whether the site exposes a `/llms.txt` file with meaningful content.
*
* llms.txt is a convention that helps LLM-based agents understand a site's
* purpose, API surface, and intended use, improving discoverability.
*/
export class LlmsTxtAudit extends BaseAudit {
meta: AuditMeta = {
id: 'llms-txt',
title: 'Site provides an llms.txt file',
failureTitle: 'Site does not provide an llms.txt file',
description:
'An llms.txt file helps AI agents understand the site purpose and available resources. ' +
'Providing one improves discoverability by LLM-based tools.',
requiredGatherers: ['http'],
scoreDisplayMode: 'binary',
};

async audit(artifacts: Record<string, GatherResult>): Promise<AuditResult> {
const http = artifacts['http'] as HttpGatherResult;
const llmsTxt = http.llmsTxt;

if (!llmsTxt.found) {
return this.fail({
type: 'text',
summary: 'No /llms.txt file was found at the target URL.',
});
}

const content = (llmsTxt.content ?? '').trim();

if (content.length === 0) {
return this.partial(0.5, {
type: 'text',
summary: 'An /llms.txt file was found but it is empty.',
});
}

return this.pass({
type: 'text',
summary: `Found /llms.txt with ${content.length} characters of content.`,
});
}
}
44 changes: 44 additions & 0 deletions src/audits/machine-readable-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { AuditResult } from '../types.js';
import type { GatherResult } from '../gatherers/base-gatherer.js';
import type { ApiGatherResult } from '../gatherers/api-gatherer.js';
import { BaseAudit, type AuditMeta } from './base-audit.js';

/**
* Checks if the site provides machine-readable documentation.
* Machine-readable docs (OpenAPI, llms.txt, ai-plugin.json) let AI agents
* discover and use APIs without parsing human-readable pages.
*/
export class MachineReadableDocsAudit extends BaseAudit {
meta: AuditMeta = {
id: 'machine-readable-docs',
title: 'Site provides machine-readable documentation',
failureTitle: 'Site lacks machine-readable documentation',
description:
'Machine-readable documentation (OpenAPI/Swagger spec, llms.txt, or ai-plugin.json) ' +
'enables AI agents to automatically discover API endpoints, understand request/response ' +
'schemas, and integrate without manual interpretation of human-written docs.',
requiredGatherers: ['api'],
scoreDisplayMode: 'binary',
};

async audit(artifacts: Record<string, GatherResult>): Promise<AuditResult> {
const api = artifacts['api'] as ApiGatherResult;

if (api.hasMachineReadableDocs) {
return this.pass({
type: 'text',
summary:
'Machine-readable documentation found (OpenAPI spec, llms.txt, ' +
'and/or ai-plugin.json). AI agents can auto-discover API capabilities.',
});
}

return this.fail({
type: 'text',
summary:
'No machine-readable documentation found. Provide at least one of: ' +
'OpenAPI spec at /openapi.json, llms.txt at /llms.txt, or ' +
'ai-plugin.json at /.well-known/ai-plugin.json.',
});
}
}
Loading
Loading