diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c90051..a26b22e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.1.0 (2026-06-03) + +### Added +- **Stable finding codes.** Every `AuditFinding` now carries a `code` namespaced as `.[.]` (e.g. `technical-seo.h1.multiple`, `schema-validity.singleton.duplicate`), so agents and integrations key on a stable machine identifier instead of regex-matching the human `message` (which can change between releases). 212 codes across all 19 analyzers; the full registry is in [docs/finding-codes.md](docs/finding-codes.md). Codes follow a documented convention and are unique across the tool (enforced by a test). `AuditFinding.code` is required, so the compiler guarantees no finding ships without one. +- `hasMissingMetaDescription` (the `--require-meta` gate) now keys on `technical-seo.meta-description.missing` rather than a message prefix — the first consumer migrated to codes. + +### Changed +- **`schemaVersion` bumped to `1.1`** (additive: findings gained the `code` field). Report shapes are otherwise unchanged. + ## 2.0.0 (2026-06-03) ### Breaking diff --git a/README.md b/README.md index c622757..e2ccc43 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ - Audit **built HTML offline** in CI: a `next export` / `dist` / `out` directory, no network. [Static output](docs/cli.md#static-output-mode) - Detect the **platform / CMS / framework**: WordPress, Webflow, Shopify, Next.js, Vercel. [Platform detection](docs/cli.md#platform-detection) - Opt in to **Lighthouse, geographic, and agent-skill** factors. [Optional factors](docs/scoring.md#optional-factors) -- `text`, `json`, and `markdown` output with **CI-friendly exit codes**. [CLI reference](docs/cli.md) +- `text`, `json`, `markdown`, and `agent` output with **CI-friendly exit codes**. [CLI reference](docs/cli.md) +- **Agent-native output**: a versioned `schemaVersion`, a slim `--format agent` decision, ranked structured fixes, and stable [finding codes](docs/finding-codes.md) so integrations key on codes, not prose. [API](docs/api.md#machine-readable-output-for-ai-agents) - Use as a **library** ([API](docs/api.md)) or from Claude Code via the **`/aeo` skill** ([skill](docs/skill.md)). Website: [canonry.ai](https://canonry.ai) diff --git a/docs/api.md b/docs/api.md index 1a20e0b..54e5979 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,7 +33,7 @@ const report = await runSitemapAudit('https://example.com', { factors: ['schema-validity', 'structured-data'], // Optional subset }) -console.log(report.schemaVersion) // '1.0', JSON shape version (see "Machine-readable output") +console.log(report.schemaVersion) // '1.1', JSON shape version (see "Machine-readable output") console.log(report.aggregateGrade) // 'B+' console.log(report.pagesAudited) // 22 console.log(report.criticalDefects) // Binary per-page defects (multiple/missing H1, missing title/meta), grouped by defect @@ -51,7 +51,7 @@ Each entry in `crossCuttingIssues[].topIssues` carries a `recommendation` plus t - **`schemaVersion`** (on `AuditReport` and `SitemapAuditReport`, exported as `SCHEMA_VERSION`) versions the JSON shape independently of the npm version. Pin to it and treat a major bump as breaking; treat its absence as a pre-2.0 report. - **`prioritizedFixes: PrioritizedFix[]`** is the ranked, pre-computed to-do list, so an agent need not average factor scores and re-rank. Each fix carries a stable `id` (a defect id like `"multiple-h1"` or a factor id like `"technical-seo"`), `kind`, an optional `severity`, the complete `affectedPages` array (never truncated), `affectsHomepage`, `prevalencePct`, and a human `summary`. -- **Stable identifiers** on the decision surface (`criticalDefects[].id`, `prioritizedFixes[].id` / `kind`) let integrations key on codes, not on matching message strings. +- **Stable identifiers** everywhere: the decision surface (`criticalDefects[].id`, `prioritizedFixes[].id` / `kind`) and every individual factor finding (`factors[].findings[].code`, e.g. `technical-seo.h1.multiple`) carry stable codes, so integrations key on codes, not on matching message strings. The full code registry is in [finding-codes.md](finding-codes.md). ## Static output (offline, from disk) diff --git a/docs/finding-codes.md b/docs/finding-codes.md new file mode 100644 index 0000000..0eec9a9 --- /dev/null +++ b/docs/finding-codes.md @@ -0,0 +1,278 @@ +# Finding codes + +Every `AuditFinding` carries a stable `code` so integrations can key on a machine identifier instead of matching the human `message` string (which may change between releases). + +## Convention + +`.[.]` — lowercase kebab-case, dot-separated. `` names the sub-check (e.g. `h1`, `meta-description`); `` distinguishes the outcomes of one check (e.g. `missing`, `multiple`, `single`). All branches of one check share the `` segment. Codes are stable across releases and unique across the tool. + +## Registry + +### Structured Data (JSON-LD) + +- `structured-data.json-ld.found` +- `structured-data.json-ld.missing` +- `structured-data.schema.found` +- `structured-data.schema.missing` +- `structured-data.schema-depth.strong` +- `structured-data.schema-depth.moderate` +- `structured-data.schema-depth.low` + +### Content Depth + +- `content-depth.word-count.strong` +- `content-depth.word-count.moderate` +- `content-depth.word-count.low` +- `content-depth.h1.single` +- `content-depth.h1.multiple` +- `content-depth.h1.missing` +- `content-depth.headings.strong` +- `content-depth.headings.moderate` +- `content-depth.headings.low` +- `content-depth.paragraphs.strong` +- `content-depth.paragraphs.moderate` +- `content-depth.paragraphs.low` +- `content-depth.lists.present` +- `content-depth.lists.none` + +### AI-Readable Content + +- `ai-readable-content.content-negotiation.found` +- `ai-readable-content.aux-resource.missing` +- `ai-readable-content.aux-resource.timeout` +- `ai-readable-content.aux-resource.unreachable` +- `ai-readable-content.aux-resource.not-html` +- `ai-readable-content.aux-resource.found` +- `ai-readable-content.llms-txt.strong` +- `ai-readable-content.llms-txt.short` +- `ai-readable-content.llms-full-txt.strong` +- `ai-readable-content.llms-full-txt.short` +- `ai-readable-content.robots-txt.found` +- `ai-readable-content.robots-txt.unreachable` +- `ai-readable-content.robots-txt.missing` +- `ai-readable-content.sitemap.found` +- `ai-readable-content.sitemap.unreachable` +- `ai-readable-content.sitemap.missing` +- `ai-readable-content.llms-txt-link.found` +- `ai-readable-content.llms-txt-link.missing` +- `ai-readable-content.markdown-endpoint.found` +- `ai-readable-content.markdown-endpoint.missing` + +### E-E-A-T Signals + +- `eeat-signals.author.credentialed` +- `eeat-signals.author.no-credentials` +- `eeat-signals.author.missing` +- `eeat-signals.author-meta.found` +- `eeat-signals.author-meta.missing` +- `eeat-signals.review.found` +- `eeat-signals.review.missing` +- `eeat-signals.trust-links.strong` +- `eeat-signals.trust-links.partial` +- `eeat-signals.trust-links.missing` +- `eeat-signals.organization.with-people` +- `eeat-signals.organization.no-people` +- `eeat-signals.organization.missing` + +### FAQ Content + +- `faq-content.faqpage.present` +- `faq-content.faqpage.missing` +- `faq-content.details.multiple` +- `faq-content.details.single` +- `faq-content.details.none` +- `faq-content.headings.multiple` +- `faq-content.headings.low` +- `faq-content.headings.missing` +- `faq-content.qa-pairs.multiple` +- `faq-content.qa-pairs.low` +- `faq-content.qa-pairs.none` + +### Citations & Authority Signals + +- `citations.external-links.strong` +- `citations.external-links.moderate` +- `citations.external-links.low` +- `citations.authoritative-domains.found` +- `citations.authoritative-domains.none` +- `citations.sameas.strong` +- `citations.sameas.moderate` +- `citations.sameas.missing` +- `citations.anchor-text.strong` +- `citations.anchor-text.moderate` +- `citations.anchor-text.low` + +### Schema Completeness + +- `schema-completeness.schema.none` +- `schema-completeness.local-business.strong` +- `schema-completeness.local-business.partial` +- `schema-completeness.local-business.low` +- `schema-completeness.faqpage.strong` +- `schema-completeness.faqpage.partial` +- `schema-completeness.faqpage.low` +- `schema-completeness.howto.strong` +- `schema-completeness.howto.partial` +- `schema-completeness.organization.strong` +- `schema-completeness.organization.partial` +- `schema-completeness.organization.low` +- `schema-completeness.schema-depth.moderate` +- `schema-completeness.schema-depth.low` + +### Schema Validity + +- `schema-validity.json-ld.none` +- `schema-validity.block.empty` +- `schema-validity.block.invalid` +- `schema-validity.singleton.duplicate` +- `schema-validity.block.valid` + +### Entity Consistency + +- `entity-consistency.name.missing` +- `entity-consistency.name.single` +- `entity-consistency.name.moderate` +- `entity-consistency.name.multiple` +- `entity-consistency.title.ok` +- `entity-consistency.title.long` +- `entity-consistency.canonical.present` +- `entity-consistency.canonical.missing` +- `entity-consistency.contact.ok` +- `entity-consistency.contact.partial` +- `entity-consistency.contact.missing` + +### Content Freshness + +- `content-freshness.date-modified.recent` +- `content-freshness.date-modified.moderate` +- `content-freshness.date-modified.stale` +- `content-freshness.date-modified.missing` +- `content-freshness.last-modified.recent` +- `content-freshness.last-modified.older` +- `content-freshness.last-modified.missing` +- `content-freshness.sitemap.recent` +- `content-freshness.sitemap.stale` +- `content-freshness.sitemap.no-match` +- `content-freshness.sitemap.timeout` +- `content-freshness.sitemap.unreachable` +- `content-freshness.sitemap.missing` +- `content-freshness.copyright.recent` +- `content-freshness.copyright.older` +- `content-freshness.copyright.missing` + +### Content Extractability + +- `content-extractability.content-ratio.strong` +- `content-extractability.content-ratio.moderate` +- `content-extractability.content-ratio.low` +- `content-extractability.citable-blocks.strong` +- `content-extractability.citable-blocks.moderate` +- `content-extractability.citable-blocks.missing` +- `content-extractability.paywall.found` +- `content-extractability.paywall.none` +- `content-extractability.ad-density.high` +- `content-extractability.ad-density.low` +- `content-extractability.ad-density.none` +- `content-extractability.direct-answer.strong` +- `content-extractability.direct-answer.moderate` +- `content-extractability.direct-answer.none` + +### Definition Blocks + +- `definition-blocks.headings.multiple` +- `definition-blocks.headings.single` +- `definition-blocks.headings.missing` +- `definition-blocks.lists.found` +- `definition-blocks.lists.none` +- `definition-blocks.schema.found` +- `definition-blocks.schema.missing` +- `definition-blocks.dl.found` +- `definition-blocks.dl.none` + +### AI Crawler Access + +- `ai-crawler-access.robots-txt.missing` +- `ai-crawler-access.robots-txt.unreachable` +- `ai-crawler-access.crawler.allowed` +- `ai-crawler-access.crawler.blocked` +- `ai-crawler-access.sitemap.found` +- `ai-crawler-access.content-signal.found` + +### Named Entities + +- `named-entities.brand-name.strong` +- `named-entities.brand-name.low` +- `named-entities.brand-name.missing` +- `named-entities.entity-name.missing` +- `named-entities.knows-about.present` +- `named-entities.knows-about.missing` +- `named-entities.proper-noun-density.strong` +- `named-entities.proper-noun-density.moderate` +- `named-entities.proper-noun-density.low` + +### Technical SEO + +- `technical-seo.h1.single` +- `technical-seo.h1.missing` +- `technical-seo.h1.multiple` +- `technical-seo.alt-text.none` +- `technical-seo.alt-text.ok` +- `technical-seo.alt-text.missing` +- `technical-seo.alt-text.empty` +- `technical-seo.meta-description.missing` +- `technical-seo.meta-description.short` +- `technical-seo.meta-description.long` +- `technical-seo.meta-description.present` +- `technical-seo.canonical.missing` +- `technical-seo.canonical.present` + +### Snippet Eligibility + +- `snippet-eligibility.directives.none` +- `snippet-eligibility.noindex.present` +- `snippet-eligibility.nosnippet.present` +- `snippet-eligibility.max-snippet.zero` +- `snippet-eligibility.max-snippet.low` +- `snippet-eligibility.noarchive.present` +- `snippet-eligibility.noimageindex.present` +- `snippet-eligibility.directives.not-restrictive` + +### Geographic Signals (optional) + +- `geographic-signals.localbusiness-schema.found` +- `geographic-signals.localbusiness-schema.missing` +- `geographic-signals.geo-coordinates.found` +- `geographic-signals.geo-coordinates.missing` +- `geographic-signals.postal-address.found` +- `geographic-signals.postal-address.missing` +- `geographic-signals.area-served.found` +- `geographic-signals.area-served.missing` +- `geographic-signals.geo-meta.found` +- `geographic-signals.geo-meta.missing` +- `geographic-signals.visible-location.found` +- `geographic-signals.visible-location.missing` + +### Agent Skill Exposure (optional) + +- `agent-skill-exposure.schema-action.well-formed` +- `agent-skill-exposure.schema-action.partial` +- `agent-skill-exposure.schema-action.missing` +- `agent-skill-exposure.mcp-discovery.found` +- `agent-skill-exposure.mcp-discovery.missing` +- `agent-skill-exposure.a2a-agent-card.found` +- `agent-skill-exposure.a2a-agent-card.missing` +- `agent-skill-exposure.openapi.found` +- `agent-skill-exposure.openapi.missing` +- `agent-skill-exposure.microdata.found` +- `agent-skill-exposure.microdata.missing` +- `agent-skill-exposure.forms.none` +- `agent-skill-exposure.forms.strong` +- `agent-skill-exposure.forms.partial` +- `agent-skill-exposure.forms.weak` + +### Lighthouse (optional) + +- `lighthouse.psi.unreachable` +- `lighthouse.category.missing` +- `lighthouse.category.score` +- `lighthouse.category.none` diff --git a/package.json b/package.json index 768ff50..cc3c13a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ainyc/aeo-audit", - "version": "2.0.0", + "version": "2.1.0", "description": "The most comprehensive open-source Answer Engine Optimization (AEO) audit tool. Scores websites across 16 ranking factors that determine AI citation.", "type": "module", "main": "./dist/index.js", diff --git a/skills/aeo/SKILL.md b/skills/aeo/SKILL.md index 2f39ee8..3b43817 100644 --- a/skills/aeo/SKILL.md +++ b/skills/aeo/SKILL.md @@ -128,7 +128,7 @@ Returns: Use `--format json` for the full report, or **`--format agent`** for just the decision: `{ schemaVersion, tool, mode, url, score, grade, pass, criticalDefectCount, issues }`, where `issues` is the ranked `prioritizedFixes` and the per-factor/per-page detail is omitted. Prefer `--format agent` when you only need to decide and act. Key fields for acting on the result without parsing prose: - `schemaVersion` (on every audit report) versions the JSON shape independently of the package version — pin to it and treat a major bump as breaking; absence means a pre-2.0 report. - `prioritizedFixes` is a ranked array of objects, each with a stable `id`, `kind`, optional `severity`, the complete `affectedPages` list (never truncated), `affectsHomepage`, `prevalencePct`, and a human `summary`. It's the pre-computed to-do list — no need to re-rank factor scores yourself. -- Stable identifiers (`criticalDefects[].id`, `prioritizedFixes[].id`) let integrations key on codes rather than message strings. +- Stable identifiers everywhere — `criticalDefects[].id`, `prioritizedFixes[].id`, and every factor finding's `code` (e.g. `technical-seo.h1.multiple`) — let integrations key on codes rather than message strings. #### Auxiliary File Diagnostics diff --git a/src/analyzers/agent-skill-exposure.ts b/src/analyzers/agent-skill-exposure.ts index 5eeafac..38ec5cf 100644 --- a/src/analyzers/agent-skill-exposure.ts +++ b/src/analyzers/agent-skill-exposure.ts @@ -124,15 +124,15 @@ export function analyzeAgentSkillExposure(context: AuditContext): AnalysisResult if (wellFormed.length > 0) { score += 35 const types = [...new Set(wellFormed.map((a) => a.type))].slice(0, 3).join(', ') - findings.push({ type: 'found', message: `Schema.org Action markup declared with target and inputs: ${types}.` }) + findings.push({ type: 'found', code: 'agent-skill-exposure.schema-action.well-formed', message: `Schema.org Action markup declared with target and inputs: ${types}.` }) } else { score += 18 const types = [...new Set(actions.map((a) => a.type))].slice(0, 3).join(', ') - findings.push({ type: 'info', message: `Schema.org Action types present (${types}) but missing target/urlTemplate or query-input/object shape.` }) + findings.push({ type: 'info', code: 'agent-skill-exposure.schema-action.partial', message: `Schema.org Action types present (${types}) but missing target/urlTemplate or query-input/object shape.` }) recommendations.push('Add target (with urlTemplate) and query-input/object to Action schema so agents know how to invoke it.') } } else { - findings.push({ type: 'missing', message: 'No Schema.org Action markup detected (PotentialAction / SearchAction / OrderAction / etc.).' }) + findings.push({ type: 'missing', code: 'agent-skill-exposure.schema-action.missing', message: 'No Schema.org Action markup detected (PotentialAction / SearchAction / OrderAction / etc.).' }) recommendations.push('Declare interactive affordances with Schema.org Action markup (e.g. SearchAction with urlTemplate and query-input) so agents can invoke them as tools.') } @@ -149,9 +149,9 @@ export function analyzeAgentSkillExposure(context: AuditContext): AnalysisResult : mcpMeta.length ? `` : 'Link header' - findings.push({ type: 'found', message: `Agent protocol discovery present (${src}).` }) + findings.push({ type: 'found', code: 'agent-skill-exposure.mcp-discovery.found', message: `Agent protocol discovery present (${src}).` }) } else { - findings.push({ type: 'missing', message: 'No MCP / WebMCP / ai-plugin discovery link or header.' }) + findings.push({ type: 'missing', code: 'agent-skill-exposure.mcp-discovery.missing', message: 'No MCP / WebMCP / ai-plugin discovery link or header.' }) recommendations.push('Expose an MCP server card via or a Link header so agents can discover your tools.') } @@ -166,9 +166,9 @@ export function analyzeAgentSkillExposure(context: AuditContext): AnalysisResult const agentCardHeader = /rel="?(agent-card|a2a)"?/i.test(linkHeader) if (agentCardLink.length || agentCardMeta.length || agentCardHeader) { score += 12 - findings.push({ type: 'found', message: 'A2A agent card discovery present — agents can fetch an agent card to negotiate capabilities.' }) + findings.push({ type: 'found', code: 'agent-skill-exposure.a2a-agent-card.found', message: 'A2A agent card discovery present — agents can fetch an agent card to negotiate capabilities.' }) } else { - findings.push({ type: 'info', message: 'No A2A agent card discovery (no link/meta/Link header pointing to an agent card).' }) + findings.push({ type: 'info', code: 'agent-skill-exposure.a2a-agent-card.missing', message: 'No A2A agent card discovery (no link/meta/Link header pointing to an agent card).' }) recommendations.push( `Publish an A2A agent card and advertise it via or a Link header. ${specCitation('a2a-agent-cards')}`, ) @@ -180,9 +180,9 @@ export function analyzeAgentSkillExposure(context: AuditContext): AnalysisResult ).first() if (openapiLink.length) { score += 10 - findings.push({ type: 'found', message: `Service description link found (type="${openapiLink.attr('type') || 'unspecified'}").` }) + findings.push({ type: 'found', code: 'agent-skill-exposure.openapi.found', message: `Service description link found (type="${openapiLink.attr('type') || 'unspecified'}").` }) } else { - findings.push({ type: 'info', message: 'No OpenAPI / service-description link found.' }) + findings.push({ type: 'info', code: 'agent-skill-exposure.openapi.missing', message: 'No OpenAPI / service-description link found.' }) recommendations.push('Link to an OpenAPI document via so agents can see the underlying endpoint shape.') } @@ -191,9 +191,9 @@ export function analyzeAgentSkillExposure(context: AuditContext): AnalysisResult const itemtypeCount = $('[itemtype]').length if (itempropCount >= 3 || itemtypeCount >= 1) { score += 10 - findings.push({ type: 'found', message: `Microdata present (${itempropCount} itemprop, ${itemtypeCount} itemtype) — helps agents map semantic meaning.` }) + findings.push({ type: 'found', code: 'agent-skill-exposure.microdata.found', message: `Microdata present (${itempropCount} itemprop, ${itemtypeCount} itemtype) — helps agents map semantic meaning.` }) } else { - findings.push({ type: 'info', message: 'Little or no microdata (itemprop / itemtype) found on the page.' }) + findings.push({ type: 'info', code: 'agent-skill-exposure.microdata.missing', message: 'Little or no microdata (itemprop / itemtype) found on the page.' }) } // ── Form structural fallback (up to 25) ───────────────────────────────── @@ -207,7 +207,7 @@ export function analyzeAgentSkillExposure(context: AuditContext): AnalysisResult }) if (candidateForms.length === 0) { - findings.push({ type: 'info', message: 'No interactive forms detected on this page.' }) + findings.push({ type: 'info', code: 'agent-skill-exposure.forms.none', message: 'No interactive forms detected on this page.' }) } else { const perFormScores: number[] = [] candidateForms.each((_, el) => { @@ -218,12 +218,12 @@ export function analyzeAgentSkillExposure(context: AuditContext): AnalysisResult score += formContribution if (avg >= 80) { - findings.push({ type: 'found', message: `${candidateForms.length} form(s) with strong agent-usable structure (labels, autocomplete, semantic types).` }) + findings.push({ type: 'found', code: 'agent-skill-exposure.forms.strong', message: `${candidateForms.length} form(s) with strong agent-usable structure (labels, autocomplete, semantic types).` }) } else if (avg >= 40) { - findings.push({ type: 'info', message: `${candidateForms.length} form(s) partially agent-usable. Average structure score ${Math.round(avg)}/100.` }) + findings.push({ type: 'info', code: 'agent-skill-exposure.forms.partial', message: `${candidateForms.length} form(s) partially agent-usable. Average structure score ${Math.round(avg)}/100.` }) recommendations.push('Strengthen forms with aria-label /