Skip to content

Commit 6bfd2bf

Browse files
arberxclaude
andauthored
feat(cli): add --require-meta flag to hard-fail on missing meta description (1.11.0) (#37) (#38)
Promotes a missing <meta name="description"> from an info-level technical-seo finding to a hard exit-1 when --require-meta is passed, so CI can gate on it even when the page otherwise scores >= 70. Works in both single-URL and sitemap modes; sitemap mode lists the offending URLs on stderr. Default behavior is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d62de03 commit 6bfd2bf

7 files changed

Lines changed: 173 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.11.0 (2026-05-28)
4+
5+
### Added
6+
- **`--require-meta` flag (#37).** New CI gate: when passed, the CLI exits `1` if any audited page lacks `<meta name="description">`, regardless of the overall (or aggregate) score-based exit rule. Previously a missing meta description surfaced as a `missing` finding under `technical-seo` but did not fail the run on otherwise-healthy sites, so the issue could silently pass CI. Works in both single-URL and sitemap modes; in sitemap mode the failure lists the offending URLs (truncated to the first three) on stderr.
7+
38
## 1.10.0 (2026-05-23)
49

510
### Added

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ npx @ainyc/aeo-audit https://example.com --lighthouse
9292

9393
# Provide a PageSpeed Insights API key to lift anonymous rate limits
9494
PAGESPEED_API_KEY=xxx npx @ainyc/aeo-audit https://example.com --lighthouse --format json
95+
96+
# Force exit 1 when meta description is missing (CI gate)
97+
npx @ainyc/aeo-audit https://example.com --require-meta
98+
npx @ainyc/aeo-audit https://example.com --sitemap --require-meta
9599
```
96100

97101
### Platform Detection Mode
@@ -183,9 +187,10 @@ When fetching `/llms.txt`, `/llms-full.txt`, `/robots.txt`, and `/sitemap.xml` t
183187
| `--urls <src>` | In `--detect-platform` mode, run on multiple URLs. `<src>` is a file path (one URL per line), a comma-separated list, or `-` for stdin |
184188
| `--concurrency <n>` | In `--detect-platform` batch mode, max in-flight fetches (default 5) |
185189
| `--min-confidence <lvl>` | In platform-detect mode, only report matches at or above this level: `low` (default), `medium`, `high` |
190+
| `--require-meta` | Exit `1` if any audited page is missing `<meta name="description">`, regardless of the overall score. Works in both single-URL and sitemap modes. |
186191
| `-h`, `--help` | Show the help message |
187192

188-
Exit code `0` for score >= 70, `1` for < 70 (CI-friendly). In sitemap mode the exit code is based on the aggregate score.
193+
Exit code `0` for score >= 70, `1` for < 70 (CI-friendly). In sitemap mode the exit code is based on the aggregate score. When `--require-meta` is passed, exit is forced to `1` if any audited page lacks `<meta name="description">`, regardless of the score-based rule.
189194

190195
## Programmatic Usage
191196

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ainyc/aeo-audit",
3-
"version": "1.10.0",
3+
"version": "1.11.0",
44
"description": "The most comprehensive open-source Answer Engine Optimization (AEO) audit tool. Scores websites across 16 ranking factors that determine AI citation.",
55
"type": "module",
66
"main": "./dist/index.js",

skills/aeo/SKILL.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ If no mode is provided, default to `audit`.
5353
- `audit https://example.com --sitemap --limit 10`
5454
- `audit https://example.com --sitemap --top-issues`
5555
- `audit https://example.com --lighthouse`
56+
- `audit https://example.com --require-meta`
57+
- `audit https://example.com --sitemap --require-meta`
5658
- `fix https://example.com`
5759
- `schema https://example.com`
5860
- `llms https://example.com`
@@ -83,6 +85,10 @@ Use for broad requests such as "audit this site" or "why am I not being cited?"
8385
- Top fixes
8486
- Metadata such as fetch time and auxiliary file availability
8587

88+
#### `--require-meta` (CI gate)
89+
90+
Pass `--require-meta` (single or sitemap mode) to force exit `1` whenever any audited page is missing `<meta name="description">`, regardless of the otherwise score-based exit rule. Useful in CI pipelines that need to block deploys on a missing meta description even on otherwise-healthy sites.
91+
8692
### Sitemap Mode
8793

8894
Use `--sitemap` to audit all pages discovered from the site's sitemap:
@@ -98,6 +104,7 @@ Flags:
98104
- `--sitemap [url]` — auto-discover the sitemap (tries `/sitemap.xml`, then `/sitemap-index.xml`, then `Sitemap:` directives in `/robots.txt`) or provide an explicit URL
99105
- `--limit <n>` — cap pages audited (default 200, sorted by sitemap priority)
100106
- `--top-issues` — skip per-page output, show only cross-cutting patterns
107+
- `--require-meta` — force exit `1` if any audited page is missing `<meta name="description">`, regardless of overall score (useful as a CI gate)
101108

102109
Pages are audited with bounded concurrency (5 in flight) to avoid hammering the target origin.
103110

src/cli.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
BatchPlatformDetectionReport,
2626
PlatformConfidence,
2727
PlatformDetectionReport,
28+
ScoredFactor,
2829
SitemapAuditOptions,
2930
SitemapAuditReport,
3031
} from './types.js'
@@ -71,6 +72,7 @@ interface ParsedArgs {
7172
minConfidence: PlatformConfidence | null
7273
urls: string | null
7374
concurrency: number | null
75+
requireMeta: boolean
7476
}
7577

7678
function isFormatterName(value: string): value is FormatterName {
@@ -95,6 +97,7 @@ function parseArgs(argv: string[]): ParsedArgs {
9597
minConfidence: null,
9698
urls: null,
9799
concurrency: null,
100+
requireMeta: false,
98101
}
99102

100103
for (let i = 0; i < args.length; i += 1) {
@@ -142,6 +145,8 @@ function parseArgs(argv: string[]): ParsedArgs {
142145
result.concurrency = num
143146
}
144147
i += 1
148+
} else if (args[i] === '--require-meta') {
149+
result.requireMeta = true
145150
} else if (args[i] === '--help' || args[i] === '-h') {
146151
result.help = true
147152
} else if (!args[i].startsWith('-')) {
@@ -152,6 +157,15 @@ function parseArgs(argv: string[]): ParsedArgs {
152157
return result
153158
}
154159

160+
export function hasMissingMetaDescription(factors: ScoredFactor[] | undefined): boolean {
161+
if (!factors) return false
162+
const tech = factors.find((f) => f.id === 'technical-seo')
163+
if (!tech) return false
164+
return tech.findings.some(
165+
(f) => f.type === 'missing' && f.message.startsWith('No meta description found'),
166+
)
167+
}
168+
155169
export function parseUrlList(text: string): string[] {
156170
const urls: string[] = []
157171
for (const rawLine of text.split(/\r?\n/)) {
@@ -212,6 +226,8 @@ Options:
212226
--concurrency <n> In --detect-platform batch mode, max in-flight fetches (default 5).
213227
--min-confidence <lvl> In platform-detect mode, only report platforms at or above this
214228
confidence level: low (default), medium, high.
229+
--require-meta Exit 1 if any audited page is missing <meta name="description">,
230+
regardless of overall score. Works in both single-URL and sitemap modes.
215231
-h, --help Show this help message
216232
217233
Examples:
@@ -227,6 +243,8 @@ Examples:
227243
aeo-audit https://example.com --sitemap https://example.com/sitemap.xml
228244
aeo-audit https://example.com --sitemap --limit 10
229245
aeo-audit https://example.com --sitemap --top-issues
246+
aeo-audit https://example.com --require-meta
247+
aeo-audit https://example.com --sitemap --require-meta
230248
aeo-audit https://example.com --detect-platform
231249
aeo-audit https://example.com --detect-platform --format json
232250
aeo-audit https://example.com --detect-platform --min-confidence medium
@@ -237,6 +255,8 @@ Examples:
237255
Exit code: 0 when score >= 70, 1 otherwise. In sitemap mode, the aggregate score is used.
238256
In --detect-platform mode, exit code is 0 if any platform is detected, 1 otherwise.
239257
In --detect-platform batch mode, exit code is 0 if at least one URL succeeded, 1 otherwise.
258+
With --require-meta, exit is 1 if any audited page is missing <meta name="description">,
259+
regardless of the score-based rule above.
240260
`)
241261
}
242262

@@ -329,6 +349,22 @@ export async function main(argv: string[] = process.argv): Promise<number> {
329349
const report = await runSitemapAudit(args.url, options)
330350
const sitemapFormatter = SITEMAP_FORMATTERS[args.format]
331351
console.log(sitemapFormatter(report, args.topIssues))
352+
353+
if (args.requireMeta) {
354+
const missingPages = report.pages.filter(
355+
(p) => p.status === 'success' && hasMissingMetaDescription(p.factors),
356+
)
357+
if (missingPages.length > 0) {
358+
console.error(
359+
`Error: --require-meta failed. ${missingPages.length} page(s) missing <meta name="description">: ${missingPages
360+
.slice(0, 3)
361+
.map((p) => p.url)
362+
.join(', ')}${missingPages.length > 3 ? ` (+${missingPages.length - 3} more)` : ''}`,
363+
)
364+
return 1
365+
}
366+
}
367+
332368
return report.aggregateScore >= 70 ? 0 : 1
333369
}
334370

@@ -341,6 +377,12 @@ export async function main(argv: string[] = process.argv): Promise<number> {
341377
})
342378

343379
console.log(formatter(report))
380+
381+
if (args.requireMeta && hasMissingMetaDescription(report.factors)) {
382+
console.error(`Error: --require-meta failed. Page is missing <meta name="description">: ${report.finalUrl}`)
383+
return 1
384+
}
385+
344386
return report.overallScore >= 70 ? 0 : 1
345387
} catch (error) {
346388
if (isAeoAuditError(error)) {

test/cli-require-meta.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { hasMissingMetaDescription } from '../src/cli.js'
4+
import type { ScoredFactor } from '../src/types.js'
5+
6+
function technicalSeoFactor(
7+
findings: ScoredFactor['findings'],
8+
): ScoredFactor {
9+
return {
10+
id: 'technical-seo',
11+
name: 'Technical SEO',
12+
weight: 5,
13+
score: 50,
14+
grade: 'D',
15+
status: 'partial',
16+
findings,
17+
recommendations: [],
18+
}
19+
}
20+
21+
describe('hasMissingMetaDescription', () => {
22+
it('returns false when factors are undefined', () => {
23+
expect(hasMissingMetaDescription(undefined)).toBe(false)
24+
})
25+
26+
it('returns false when technical-seo factor is absent', () => {
27+
expect(
28+
hasMissingMetaDescription([
29+
{
30+
id: 'structured-data',
31+
name: 'Structured Data',
32+
weight: 10,
33+
score: 80,
34+
grade: 'B',
35+
status: 'pass',
36+
findings: [],
37+
recommendations: [],
38+
},
39+
]),
40+
).toBe(false)
41+
})
42+
43+
it('returns true when technical-seo has a missing-meta-description finding', () => {
44+
expect(
45+
hasMissingMetaDescription([
46+
technicalSeoFactor([{ type: 'missing', message: 'No meta description found.' }]),
47+
]),
48+
).toBe(true)
49+
})
50+
51+
it('returns false when meta description is present (any length)', () => {
52+
expect(
53+
hasMissingMetaDescription([
54+
technicalSeoFactor([
55+
{ type: 'found', message: 'Meta description present (152 chars).' },
56+
]),
57+
]),
58+
).toBe(false)
59+
})
60+
61+
it('returns false when meta description is merely short (info-level finding)', () => {
62+
expect(
63+
hasMissingMetaDescription([
64+
technicalSeoFactor([
65+
{
66+
type: 'info',
67+
message: 'Meta description is too short (90 chars; target 150–160): "..."',
68+
},
69+
]),
70+
]),
71+
).toBe(false)
72+
})
73+
74+
it('returns false when finding type is missing but unrelated message', () => {
75+
expect(
76+
hasMissingMetaDescription([
77+
technicalSeoFactor([
78+
{ type: 'missing', message: 'No canonical tag found.' },
79+
]),
80+
]),
81+
).toBe(false)
82+
})
83+
})

test/e2e/cli.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,35 @@ function captureConsole(t: TestContext): { stdout: string[]; stderr: string[] }
125125
return { stdout, stderr }
126126
}
127127

128+
test('--require-meta forces exit 1 when the fixture has no <meta name="description">', async (t) => {
129+
installMockClock(t)
130+
installMockFetch(t)
131+
const { stdout, stderr } = captureConsole(t)
132+
const { main } = await import(new URL('../../dist/cli.js', import.meta.url).href)
133+
134+
const exitCode = await main([
135+
'node',
136+
'aeo-audit',
137+
FIXTURE_URL,
138+
'--format',
139+
'json',
140+
'--require-meta',
141+
])
142+
143+
assert.equal(exitCode, 1, 'expected exit 1 because fixture HTML has no meta description')
144+
assert.equal(stdout.length, 1, 'report still printed before failure')
145+
assert.ok(
146+
stderr.some((line) => line.includes('--require-meta failed')),
147+
'stderr should mention --require-meta failure',
148+
)
149+
150+
const report = JSON.parse(stdout[0]) as AuditReport
151+
assert.ok(
152+
report.overallScore >= 70,
153+
'sanity check: fixture would otherwise pass the score-based exit rule',
154+
)
155+
})
156+
128157
test('compiled CLI returns the expected JSON report for the fixture site', async (t) => {
129158
installMockClock(t)
130159
installMockFetch(t)

0 commit comments

Comments
 (0)