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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 3.1.0 (2026-06-09)

### Added
- **Best-page context on every cross-cutting factor.** `CrossCuttingIssue` and cross-cutting `PrioritizedFix` entries now carry `bestScore` and `bestPageUrl` — the single strongest page for that factor (homepage wins ties). A site-wide gap reads as "Structured Data is 100 on the homepage — propagate that template to the rest" instead of a bare "add schema". The `avgScore` is unchanged; it stays an honest whole-site coverage number.
- **Page-specific factor classification.** Cross-cutting issues carry a `status`: `sitewide`, `limited`, or `opportunity`. Factors that legitimately apply to only some page types (**FAQ Content**, **Definition Blocks** — exported as `PAGE_SPECIFIC_FACTOR_IDS` from `@ainyc/aeo-audit/scoring`) no longer float to the top of `prioritizedFixes` and read as "Critical: build an FAQ" when the site already has one. A page-specific factor present on at least one page (best score ≥ 30) is `limited` (a tune-up, demoted below genuine site-wide gaps, scoped to the page(s) that carry it with the tune-up recommendation from there); one absent everywhere is an `opportunity` (optional, no pages marked affected). Presence — not coverage breadth — is the gate. See [docs/scoring.md](docs/scoring.md#sitemap-aggregation-cross-cutting-issues-and-page-specific-factors).

### Changed
- **`schemaVersion` bumped to `2.1`** (additive: `CrossCuttingIssue` gained `pageSpecific`, `status`, `bestScore`, `bestPageUrl`; `PrioritizedFix` gained optional `status`, `bestScore`, `bestPageUrl`). Existing fields are unchanged; parsers pinned to `2.0` keep working.
- Sitemap text and markdown reports show the `status` label and best-page alongside each factor's average; the markdown Cross-Cutting table gains **Status** and **Best (page)** columns. Page-specific factors render their concise status line instead of an "add it to every page" per-page dump (the same false positives that demotion removes); their real, scoped fix appears in Prioritized Fixes. Site-wide factors are unchanged and still list every affected page in full.

## 3.0.0 (2026-06-08)

### Breaking
Expand Down
6 changes: 3 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ const report = await runSitemapAudit('https://example.com', {
factors: ['schema-validity', 'structured-data'], // Optional subset
})

console.log(report.schemaVersion) // '2.0', JSON shape version (see "Machine-readable output")
console.log(report.schemaVersion) // '2.1', JSON shape version (see "Machine-readable output")
console.log(report.aggregateScore) // 84
console.log(report.pagesAudited) // 22
console.log(report.criticalDefects) // Binary per-page defects (multiple/missing H1, missing title/meta), grouped by defect
console.log(report.crossCuttingIssues) // Per-factor rollup with affectedUrls for every recommendation
console.log(report.prioritizedFixes) // Ranked PrioritizedFix[]: critical defects first, then cross-cutting by impact
```

Each entry in `crossCuttingIssues[].topIssues` carries a `recommendation` plus the exact `affectedUrls` so you can attribute each problem to specific pages, e.g. "FAQPage duplicate" pointing at every blog post that has it.
Each entry in `crossCuttingIssues[].topIssues` carries a `recommendation` plus the exact `affectedUrls` so you can attribute each problem to specific pages, e.g. "FAQPage duplicate" pointing at every blog post that has it. Every issue also carries `bestScore` / `bestPageUrl` (the strongest page for that factor, to propagate from) and a `status` — `sitewide`, `limited`, or `opportunity` — that classifies page-specific factors (FAQ, definitions) so an isolated-but-present FAQ reads as a `limited` tune-up rather than a site-wide gap. See [Sitemap aggregation](scoring.md#sitemap-aggregation-cross-cutting-issues-and-page-specific-factors).

`criticalDefects` surfaces **binary structural defects by impact, not prevalence**. The cross-cutting rollup ranks by how many pages a factor affects, so an unambiguous one-line-fix defect on a single important page (a homepage split across four `<h1>`s, or a `/contact-us` page with none) would otherwise be averaged into a passing factor score and excluded from `prioritizedFixes`. Each group names the offending pages (homepage and high sitemap-`priority` pages first), and the critical-severity ones lead `prioritizedFixes`.

Expand All @@ -49,7 +49,7 @@ Each entry in `crossCuttingIssues[].topIssues` carries a `recommendation` plus t
`--format json` and these return values are the contract for programmatic use. The report is built to be acted on, not just rendered:

- **`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`.
- **`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`. Cross-cutting fixes also carry `avgScore`, `bestScore` / `bestPageUrl`, and a `status` (`sitewide` | `limited` | `opportunity`); `limited`/`opportunity` page-specific factors are demoted below genuine site-wide gaps, and a `limited` fix is scoped to the page(s) that carry the factor with the tune-up recommendation from there.
- **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)
Expand Down
16 changes: 16 additions & 0 deletions docs/scoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,19 @@ The audit emits a **0–100 score** for every factor and a weighted **overall sc
| 0–39 | Weak — major work needed |

The CLI exits `0` for an overall score ≥ 70 and `1` below; see [Exit codes](cli.md#exit-codes).

## Sitemap aggregation: cross-cutting issues and page-specific factors

A sitemap (or static) run averages each factor across every audited page into `crossCuttingIssues[]`, and the worst of those plus the per-page critical defects become the ranked `prioritizedFixes[]`. Two refinements keep that rollup honest:

**Best-page context on every factor.** Each cross-cutting issue carries `bestScore` and `bestPageUrl` — the single highest-scoring page for that factor (homepage wins ties). A site-wide gap then reads as *"Structured Data is 100 on the homepage — propagate that template to the 393 other pages"* rather than a bare *"add schema"*. The average (`avgScore`) is unchanged: it's an honest coverage number and is never recomputed over a subset.

**Page-specific factors aren't site-wide failures.** Some factors legitimately apply to only certain page types — a product or portfolio page has no business carrying an **FAQ** or a glossary **Definition Block**, so a 0 there is correct, not a gap. Averaged across a whole site, these score near 0 and "affect" almost every page, which would otherwise float them to the top of the fix list and read as *"Critical: build an FAQ"* even when the site already has a good one on `/faq`. Each cross-cutting issue therefore carries a `status`:

| `status` | Meaning | Ranking |
|----------|---------|---------|
| `sitewide` | Expected on every page (schema, E-E-A-T, freshness, citations…); a low average is a real coverage gap. | By prevalence, as before. |
| `limited` | A page-specific factor **present on at least one page** (best score ≥ 30) but isolated. A tune-up/extend, not build-from-scratch. | Demoted below all `sitewide` issues. |
| `opportunity` | A page-specific factor **not yet present on any audited page**. Adding it is discretionary. | Demoted, with no pages marked "affected". |

For a `limited` factor the fix is scoped to the page(s) that actually carry it and the recommendation is the tune-up from there (*"add question-style headings to `/faq`"*) — never the "add it everywhere" recommendation aggregated from pages that correctly lack it. Presence (best ≥ 30), **not** coverage breadth, is the gate: thin coverage is the expected state for these factors and never downgrades a `limited` to a worse label. The page-specific set is currently **FAQ Content** and **Definition Blocks** (`PAGE_SPECIFIC_FACTOR_IDS`, exported from `@ainyc/aeo-audit/scoring`).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ainyc/aeo-audit",
"version": "3.0.0",
"version": "3.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",
Expand Down
6 changes: 3 additions & 3 deletions skills/aeo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,15 @@ Pages are audited with bounded concurrency (5 in flight) to avoid hammering the
Returns:
- Per-page scores
- **Critical defects** — binary, one-line-fix structural defects (an `<h1>` count other than one, a missing `<title>`, a missing meta description) surfaced **regardless of how few pages they affect**, with the offending pages named (homepage and high sitemap-`priority` pages first). These would otherwise be averaged into a passing factor score; the JSON field is `criticalDefects` and critical-severity ones are also promoted to the top of `prioritizedFixes`. Shown even with `--top-issues`.
- Cross-cutting issues (factors failing across multiple pages)
- Cross-cutting issues (factors failing across multiple pages), each with the best-scoring page (`bestScore`/`bestPageUrl`) and a `status`: `sitewide` (a real coverage gap) vs. `limited`/`opportunity` for page-specific factors (FAQ, definitions) that legitimately apply to only some page types
- Aggregate score
- Prioritized fixes (critical defects first, then ranked by site-wide impact)
- Prioritized fixes (critical defects first, then site-wide gaps; page-specific `limited`/`opportunity` factors demoted below them, scoped to the page(s) that carry them)

#### Machine-readable output (for agents)

Use `--format json` for the full report, or **`--format agent`** for just the decision: `{ schemaVersion, tool, mode, url, score, 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.
- `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`. Cross-cutting fixes also carry `avgScore`, `bestScore`/`bestPageUrl`, and a `status` (`sitewide` | `limited` | `opportunity`) — treat `limited`/`opportunity` as page-specific tune-ups, not site-wide failures. It's the pre-computed to-do list — no need to re-rank factor scores yourself.
- 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
Expand Down
2 changes: 1 addition & 1 deletion src/agent-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const PASS_THRESHOLD = 70
export function agentSummaryFromAudit(report: AuditReport): AgentSummary {
const criticalDefects = buildCriticalDefects([report])
const crossCutting = buildCrossCuttingIssues([report])
const issues = buildPrioritizedFixes(crossCutting, 1, criticalDefects)
const issues = buildPrioritizedFixes(crossCutting, 1, criticalDefects, [report])

return {
schemaVersion: report.schemaVersion,
Expand Down
35 changes: 28 additions & 7 deletions src/formatters/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,34 @@ export function formatSitemapMarkdown(report: SitemapAuditReport, topIssuesOnly
}

if (report.crossCuttingIssues.length > 0) {
const shortUrl = (u: string): string => (u.length > 48 ? u.slice(0, 45) + '...' : u)
const statusLabel: Record<string, string> = { sitewide: 'Site-wide', limited: 'Limited', opportunity: 'Opportunity' }

lines.push(`## Cross-Cutting Issues`)
lines.push(``)
lines.push(`| Factor | Avg Score | Affected Pages |`)
lines.push(`|--------|-----------|----------------|`)
lines.push(`| Factor | Status | Avg | Best (page) | Affected Pages |`)
lines.push(`|--------|--------|-----|-------------|----------------|`)

for (const issue of report.crossCuttingIssues) {
const pct = Math.round((issue.affectedPages / issue.totalPages) * 100)
lines.push(`| ${issue.factorName} | ${issue.avgScore} | ${issue.affectedPages}/${issue.totalPages} (${pct}%) |`)
// Page-specific factors carry a structurally-low average across pages that
// correctly lack them, so "affected" is reported as isolated/none rather than
// a misleading near-100% gap.
const affected = issue.pageSpecific
? issue.status === 'limited'
? 'isolated'
: 'none'
: `${issue.affectedPages}/${issue.totalPages} (${Math.round((issue.affectedPages / issue.totalPages) * 100)}%)`
lines.push(
`| ${issue.factorName} | ${statusLabel[issue.status]} | ${issue.avgScore} | ${issue.bestScore} (${shortUrl(issue.bestPageUrl)}) | ${affected} |`,
)
}

lines.push(``)

const factorsWithIssues = report.crossCuttingIssues.filter((i) => i.topIssues.length > 0)
// The per-page breakdown is for site-wide factors only. A page-specific factor's
// "Add FAQ to this page" rows are the same false-positive noise that demotion
// removes; its real, scoped fix is in Prioritized Fixes below.
const factorsWithIssues = report.crossCuttingIssues.filter((i) => !i.pageSpecific && i.topIssues.length > 0)
if (factorsWithIssues.length > 0) {
lines.push(`### Per-Issue Breakdown`)
lines.push(``)
Expand All @@ -157,8 +172,14 @@ export function formatSitemapMarkdown(report: SitemapAuditReport, topIssuesOnly
for (let i = 0; i < report.prioritizedFixes.length; i++) {
const fix = report.prioritizedFixes[i]
const tag = fix.severity ? `**[${fix.severity}]** ` : ''
const avg = fix.avgScore !== undefined ? ` (avg ${fix.avgScore}/100)` : ''
lines.push(`${i + 1}. ${tag}**${fix.title}**${avg} _(${fix.prevalencePct}% of pages)_ — ${fix.recommendation}`)
const statusTag = fix.status === 'limited' ? `**[limited]** ` : fix.status === 'opportunity' ? `**[opportunity]** ` : ''
// Skip best for `opportunity` — the factor is absent everywhere, so "best 0/100 on /" is noise.
const showBest = fix.bestScore !== undefined && fix.status !== 'opportunity'
const avg =
fix.avgScore !== undefined
? ` (avg ${fix.avgScore}/100${showBest ? `, best ${fix.bestScore}/100 on ${fix.bestPageUrl}` : ''})`
: ''
lines.push(`${i + 1}. ${tag}${statusTag}**${fix.title}**${avg} _(${fix.prevalencePct}% of pages)_ — ${fix.recommendation}`)
// Spell out every affected page — agents and humans both need the full set.
for (const url of fix.affectedPages) {
const home = isHomepageUrl(url) ? ' **(homepage)**' : ''
Expand Down
31 changes: 29 additions & 2 deletions src/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,25 @@ export function formatSitemapText(report: SitemapAuditReport, topIssuesOnly = fa
lines.push(`${'─'.repeat(70)}`)

for (const issue of report.crossCuttingIssues) {
const best = `${DIM}best ${issue.bestScore}/100 on ${issue.bestPageUrl}${RESET}`

// Page-specific factors (FAQ, definitions): an isolated presence is expected,
// not a site-wide gap, so show the status + best page and skip the per-page
// "add it everywhere" dump (those pages have no business carrying it). The
// actionable, scoped tune-up lives in Prioritized Fixes below.
if (issue.status === 'limited') {
lines.push(` ${YELLOW}${issue.factorName.padEnd(30)}${RESET} ${YELLOW}[limited]${RESET} ${DIM}avg ${issue.avgScore}/100${RESET} · ${best}`)
lines.push(` ${DIM}present but isolated — a tune-up where it exists, not a site-wide gap${RESET}`)
continue
}
if (issue.status === 'opportunity') {
lines.push(` ${DIM}${issue.factorName.padEnd(30)} [opportunity] avg ${issue.avgScore}/100 · not present on any audited page${RESET}`)
continue
}

const pct = Math.round((issue.affectedPages / issue.totalPages) * 100)
const igc = scoreColor(issue.avgScore)
lines.push(` ${igc}${issue.factorName.padEnd(32)}${RESET} ${DIM}avg ${issue.avgScore}/100, affects ${pct}% of pages${RESET}`)
lines.push(` ${igc}${issue.factorName.padEnd(32)}${RESET} ${DIM}avg ${issue.avgScore}/100${RESET} · ${best} ${DIM}(affects ${pct}% of pages)${RESET}`)

for (const detail of issue.topIssues) {
lines.push(` ${DIM}• ${detail.recommendation}${RESET} ${DIM}(${detail.affectedUrls.length}/${issue.totalPages} pages)${RESET}`)
Expand All @@ -164,8 +180,19 @@ export function formatSitemapText(report: SitemapAuditReport, topIssuesOnly = fa
for (let i = 0; i < report.prioritizedFixes.length; i++) {
const fix = report.prioritizedFixes[i]
const tag = fix.severity ? `[${fix.severity === 'critical' ? RED : YELLOW}${fix.severity}${RESET}] ` : ''
const statusTag =
fix.status === 'limited'
? `${YELLOW}[limited]${RESET} `
: fix.status === 'opportunity'
? `${DIM}[opportunity]${RESET} `
: ''
const avg = fix.avgScore !== undefined ? `${DIM} avg ${fix.avgScore}/100${RESET}` : ''
lines.push(` ${CYAN}${i + 1}.${RESET} ${tag}${BOLD}${fix.title}${RESET}${avg} ${DIM}(${fix.prevalencePct}% of pages)${RESET}`)
// Skip best for `opportunity` — the factor is absent everywhere, so "best 0/100 on /" is noise.
const best =
fix.bestScore !== undefined && fix.status !== 'opportunity'
? `${DIM} · best ${fix.bestScore}/100 on ${fix.bestPageUrl}${RESET}`
: ''
lines.push(` ${CYAN}${i + 1}.${RESET} ${tag}${statusTag}${BOLD}${fix.title}${RESET}${avg}${best} ${DIM}(${fix.prevalencePct}% of pages)${RESET}`)
lines.push(` ${DIM}→ ${fix.recommendation}${RESET}`)
// Spell out every affected page — agents and humans both need the full set.
for (const url of fix.affectedPages) {
Expand Down
2 changes: 1 addition & 1 deletion src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
* Lives in its own module (not `index.ts`) so report builders can read it without
* importing the audit entry points — which test suites routinely mock.
*/
export const SCHEMA_VERSION = '2.0'
export const SCHEMA_VERSION = '2.1'
Loading
Loading