Skip to content

Commit 54712a3

Browse files
committed
Implement 4 new checks, shared markdown helper, and cross-check integration tests
Implement auth-gate-detection, cache-header-hygiene, http-status-codes, and markdown-code-fence-validity checks, bringing the total from 10 to 14. Extract shared getMarkdownContent helper to deduplicate standalone-mode markdown fetching between page-size-markdown and markdown-code-fence-validity. Cache discoverAndSamplePages results on the context so all checks within a run test the same sampled pages, fixing inconsistent page counts when multiple checks independently sampled different subsets. Add cross-check integration tests (check-pipeline.test.ts) that run real checks through the runner and verify data flows correctly between them via pageCache, previousResults, and shared sampling. Update DEVELOPMENT.md with shared state documentation, helper usage guidance, and guidelines for when to update pipeline tests. Update README.md to reflect the 14 implemented checks.
1 parent 07d77a3 commit 54712a3

17 files changed

Lines changed: 2622 additions & 207 deletions

DEVELOPMENT.md

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,18 @@ src/
6969
registry.ts # Check registration and lookup
7070
index.ts # Side-effect imports that register all checks
7171
cli/ # CLI entry point and formatters
72-
helpers/ # Shared utilities (HTTP, markdown detection, etc.)
72+
helpers/ # Shared utilities
73+
get-page-urls.ts # Page discovery (llms.txt, sitemap) and sampling
74+
get-markdown-content.ts # Shared markdown fetching (cached or standalone)
75+
detect-markdown.ts # Heuristics for identifying markdown content
76+
to-md-urls.ts # Generate .md URL candidates from a page URL
77+
html-to-markdown.ts # HTML → markdown conversion
7378
runner.ts # Orchestrates check execution with dependency resolution
7479
types.ts # Shared type definitions
7580
http.ts # Rate-limited HTTP client
7681
test/
77-
unit/ # Unit tests (mocked HTTP via MSW)
78-
integration/ # Integration tests (spawns the CLI binary)
82+
unit/ # Unit tests (one check or helper at a time, mocked HTTP)
83+
integration/ # Integration tests (CLI binary + cross-check pipelines)
7984
fixtures/ # Shared test fixtures
8085
bin/
8186
afdocs.mjs # CLI binary entry point
@@ -126,6 +131,28 @@ const {
126131

127132
This handles the full discovery chain (llms.txt links, sitemap, baseUrl fallback) and Fisher-Yates shuffles down to `maxLinksToTest` when needed.
128133

134+
The result is **cached on `ctx._sampledPages`** so that all checks within a single run share the same sampled page list. This ensures consistent results: if markdown-url-support tests pages A, B, C, then content-negotiation, page-size-html, http-status-codes, and every other check that calls `discoverAndSamplePages` will test the same pages A, B, C. Do not bypass this caching by calling `getPageUrls` directly unless your check genuinely needs a different page set.
135+
136+
### Getting markdown content
137+
138+
Checks that analyze markdown content (page size, code fences, content parity, etc.) should use the shared `getMarkdownContent` helper from `src/helpers/get-markdown-content.ts`:
139+
140+
```ts
141+
import { getMarkdownContent } from '../../helpers/get-markdown-content.js';
142+
143+
const mdResult = await getMarkdownContent(ctx);
144+
// mdResult.mode === 'cached' when markdown-url-support or content-negotiation ran
145+
// mdResult.mode === 'standalone' when neither ran (fetches markdown independently)
146+
// mdResult.pages contains MarkdownPage[] with url, content, and source
147+
```
148+
149+
This helper handles two scenarios:
150+
151+
- **Cached mode**: When `markdown-url-support` or `content-negotiation` has run, reads from `ctx.pageCache`. Also checks whether the dependency passed (some pages had markdown) or failed (no markdown found).
152+
- **Standalone mode**: When neither dependency ran (e.g. user ran `--checks page-size-markdown` alone), discovers pages and fetches markdown independently.
153+
154+
In both modes, llms.txt content from `llms-txt-exists` results is included automatically. The `source` field on each page indicates its origin (`'md-url'`, `'content-negotiation'`, `'standalone-md-url'`, `'standalone-content-negotiation'`, or `'llms-txt'`).
155+
129156
### Check dependencies and standalone mode
130157

131158
Checks can declare dependencies via `dependsOn`. The runner resolves these so that, for example, `page-size-markdown` can read cached markdown from `markdown-url-support` and `content-negotiation`.
@@ -147,7 +174,21 @@ When a user runs a single check with `--checks`, its dependencies may not have e
147174

148175
3. **Ensure parity.** A standalone check should produce the same results as when it runs as part of the full suite. If standalone mode discovers pages differently (fewer URLs, different sources), users will see inconsistent results depending on which checks they run.
149176

150-
See `page-size-markdown.ts` for a concrete example: it reads from `ctx.pageCache` when dependencies ran, and falls back to `discoverAndSamplePages` with its own markdown fetching when they didn't.
177+
See `page-size-markdown.ts` for a concrete example: it uses `getMarkdownContent()`, which reads from `ctx.pageCache` when dependencies ran and falls back to independent fetching when they didn't.
178+
179+
### Shared state between checks
180+
181+
Checks communicate through three mechanisms on `CheckContext`:
182+
183+
| Mechanism | Written by | Read by | Purpose |
184+
| ----------------- | --------------------------------------------- | -------------------------------- | ---------------------------------------------- |
185+
| `previousResults` | Runner (after each check) | Any downstream check | Check status, details (e.g. `discoveredFiles`) |
186+
| `pageCache` | `markdown-url-support`, `content-negotiation` | `getMarkdownContent()` consumers | Cached markdown content keyed by page URL |
187+
| `_sampledPages` | `discoverAndSamplePages` (first call) | All subsequent callers | Ensures consistent page sampling across checks |
188+
189+
When a check reads from `previousResults`, it creates an implicit ordering dependency. If your check reads from another check's results, either declare it in `dependsOn` or handle the case where it hasn't run. For example, `cache-header-hygiene` reads llms.txt URLs from `llms-txt-exists` results but doesn't declare it as a dependency; it gracefully falls back to an empty list.
190+
191+
`content-negotiation` guards against overwriting `pageCache` entries that `markdown-url-support` already populated: `if (!ctx.pageCache.has(url))`. This ensures the `.md` URL version takes precedence when both mechanisms find markdown for the same page.
151192

152193
### Testing checks with dependencies
153194

@@ -191,7 +232,13 @@ server.use(
191232

192233
## Testing
193234

194-
Tests use [Vitest](https://vitest.dev/) with [MSW](https://mswjs.io/) (Mock Service Worker) for HTTP mocking. The typical pattern:
235+
Tests use [Vitest](https://vitest.dev/) with [MSW](https://mswjs.io/) (Mock Service Worker) for HTTP mocking. There are two levels of tests:
236+
237+
### Unit tests (`test/unit/`)
238+
239+
Each check gets its own test file at `test/unit/checks/<check-id>.test.ts`. These test a single check in isolation by manually constructing a `CheckContext` with the expected `previousResults` and `pageCache` state.
240+
241+
The typical pattern:
195242

196243
```ts
197244
import { setupServer } from 'msw/node';
@@ -230,6 +277,20 @@ Use unique hostnames per test (e.g. `http://my-check-pass.local/...`) to avoid M
230277

231278
Set `requestDelay: 0` in test contexts to avoid artificial delays.
232279

280+
### Cross-check integration tests (`test/integration/check-pipeline.test.ts`)
281+
282+
These tests run multiple real checks through the runner and verify that data flows correctly between them. Unlike unit tests (which manually set up context), pipeline tests exercise the actual check execution order, `pageCache` population, `previousResults` propagation, and shared sampling.
283+
284+
**When to update `check-pipeline.test.ts`:**
285+
286+
- **Adding a check that reads from `previousResults` or `pageCache`**: Add a test verifying it receives the expected data from its upstream checks, and that it handles the "upstream didn't run" case.
287+
- **Adding a check that writes to `pageCache`**: Add a test verifying downstream consumers see the cached data.
288+
- **Changing `dependsOn` declarations**: Add a test covering the new dependency chain (skip when dep fails, standalone when dep absent).
289+
- **Adding a check that calls `discoverAndSamplePages`**: Add it to the "shared sampling" test to verify it samples the same pages as other checks.
290+
- **Changing shared helpers** (`getMarkdownContent`, `discoverAndSamplePages`, etc.): Run the pipeline tests to verify cross-check behavior is preserved.
291+
292+
The pipeline tests use a `setupSite` helper to configure a mock docs site with llms.txt, HTML pages, .md URLs, and content-negotiation support, then run a subset of checks via `runChecks()` with `checkIds`.
293+
233294
## Known issues
234295

235296
### Node.js 25 localStorage warning

README.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Test your documentation site against the [Agent-Friendly Documentation Spec](htt
77

88
Agents don't use docs like humans. They hit truncation limits, get walls of CSS instead of content, can't follow cross-host redirects, and don't know about quality-of-life improvements like `llms.txt` or `.md` docs pages that would make life swell. Maybe this is because the industry has lacked guidance - until now.
99

10-
afdocs runs 21 checks across 8 categories to evaluate how well your docs serve agent consumers. 10 are fully implemented; the rest return `skip` until completed.
10+
afdocs runs 21 checks across 8 categories to evaluate how well your docs serve agent consumers. 14 are fully implemented; the rest return `skip` until completed.
1111

1212
> **Status: Early development (0.x)**
1313
> This project is under active development. Check IDs, CLI flags, and output formats may change between minor versions. Feel free to try it out, but don't build automation against specific output until 1.0.
@@ -36,8 +36,14 @@ Markdown Availability
3636
✗ content-negotiation: Server ignores Accept: text/markdown header (0/50 sampled pages return markdown)
3737
✗ markdown-url-support: No sampled pages support .md URLs (0/50 tested)
3838
39+
URL Stability
40+
✓ http-status-codes: All 50 sampled pages return proper error codes for bad URLs
41+
42+
Authentication
43+
✓ auth-gate-detection: All 50 sampled pages are publicly accessible
44+
3945
Summary
40-
5 passed, 2 failed, 14 skipped (21 total)
46+
9 passed, 3 failed, 9 skipped (21 total)
4147
```
4248

4349
## Install
@@ -138,7 +144,7 @@ describe('agent-friendliness', () => {
138144

139145
## Checks
140146

141-
21 checks across 8 categories. Checks marked with \* are stub implementations that return `skip`.
147+
21 checks across 8 categories. Checks marked with \* are not yet implemented and return `skip`.
142148

143149
### Category 1: llms.txt
144150

@@ -171,13 +177,13 @@ describe('agent-friendliness', () => {
171177
| --------------------------------- | -------------------------------------------------- |
172178
| `tabbed-content-serialization` \* | Whether tabbed content creates oversized output |
173179
| `section-header-quality` \* | Whether headers in tabbed sections include context |
174-
| `markdown-code-fence-validity` \* | Whether markdown has unclosed code fences |
180+
| `markdown-code-fence-validity` | Whether markdown has unclosed code fences |
175181

176182
### Category 5: URL Stability and Redirects
177183

178184
| Check | Description |
179185
| ---------------------- | ----------------------------------------------- |
180-
| `http-status-codes` \* | Whether error pages return correct status codes |
186+
| `http-status-codes` | Whether error pages return correct status codes |
181187
| `redirect-behavior` \* | Whether redirects are same-host HTTP redirects |
182188

183189
### Category 6: Agent Discoverability Directives
@@ -192,13 +198,13 @@ describe('agent-friendliness', () => {
192198
| ---------------------------- | ---------------------------------------------- |
193199
| `llms-txt-freshness` \* | Whether `llms.txt` reflects current site state |
194200
| `markdown-content-parity` \* | Whether markdown and HTML versions match |
195-
| `cache-header-hygiene` \* | Whether cache headers allow timely updates |
201+
| `cache-header-hygiene` | Whether cache headers allow timely updates |
196202

197203
### Category 8: Authentication and Access
198204

199205
| Check | Description |
200206
| ---------------------------- | -------------------------------------------------------------------- |
201-
| `auth-gate-detection` \* | Whether documentation pages require authentication to access content |
207+
| `auth-gate-detection` | Whether documentation pages require authentication to access content |
202208
| `auth-alternative-access` \* | Whether auth-gated sites provide alternative access paths for agents |
203209

204210
## Check dependencies

src/checks/authentication/auth-gate-detection.ts

Lines changed: 198 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,205 @@
11
import { registerCheck } from '../registry.js';
2+
import { discoverAndSamplePages } from '../../helpers/get-page-urls.js';
23
import type { CheckContext, CheckResult } from '../../types.js';
34

4-
async function check(_ctx: CheckContext): Promise<CheckResult> {
5+
type PageClassification = 'accessible' | 'auth-required' | 'soft-auth-gate' | 'auth-redirect';
6+
7+
interface AuthResult {
8+
url: string;
9+
classification: PageClassification;
10+
status: number | null;
11+
redirectUrl?: string;
12+
ssoDomain?: string;
13+
hint?: string;
14+
error?: string;
15+
}
16+
17+
const SSO_DOMAINS = [
18+
'okta.com',
19+
'auth0.com',
20+
'login.microsoftonline.com',
21+
'accounts.google.com',
22+
'login.salesforce.com',
23+
'sso.',
24+
'idp.',
25+
'auth.',
26+
'login.',
27+
];
28+
29+
function isSsoDomain(url: string): string | undefined {
30+
try {
31+
const hostname = new URL(url).hostname.toLowerCase();
32+
return SSO_DOMAINS.find(
33+
(domain) =>
34+
hostname === domain || hostname.endsWith('.' + domain) || hostname.startsWith(domain),
35+
);
36+
} catch {
37+
return undefined;
38+
}
39+
}
40+
41+
function detectLoginForm(body: string): string | undefined {
42+
const sample = body.slice(0, 50000).toLowerCase();
43+
44+
if (sample.includes('<input') && sample.includes('type="password"')) {
45+
return 'Contains password input field';
46+
}
47+
48+
// Check page title for login indicators
49+
const titleMatch = /<title[^>]*>(.*?)<\/title>/i.exec(sample);
50+
if (titleMatch) {
51+
const title = titleMatch[1].toLowerCase();
52+
if (/sign\s*in|log\s*in|authenticate/i.test(title)) {
53+
return `Page title suggests login: "${titleMatch[1].trim()}"`;
54+
}
55+
}
56+
57+
// Check for SSO form actions
58+
if (/<form[^>]*action\s*=\s*["'][^"']*(?:saml|oauth|openid|sso|auth)[^"']*["']/i.test(sample)) {
59+
return 'Contains SSO-related form action';
60+
}
61+
62+
return undefined;
63+
}
64+
65+
async function check(ctx: CheckContext): Promise<CheckResult> {
66+
const id = 'auth-gate-detection';
67+
const category = 'authentication';
68+
69+
const { urls: pageUrls, totalPages, sampled, warnings } = await discoverAndSamplePages(ctx);
70+
71+
const results: AuthResult[] = [];
72+
const concurrency = ctx.options.maxConcurrency;
73+
74+
for (let i = 0; i < pageUrls.length; i += concurrency) {
75+
const batch = pageUrls.slice(i, i + concurrency);
76+
const batchResults = await Promise.all(
77+
batch.map(async (url): Promise<AuthResult> => {
78+
try {
79+
const response = await ctx.http.fetch(url, { redirect: 'manual' });
80+
const status = response.status;
81+
82+
// Auth-required status codes
83+
if (status === 401 || status === 403) {
84+
return { url, classification: 'auth-required', status };
85+
}
86+
87+
// Redirect — check if it's to an SSO domain
88+
if (status >= 300 && status < 400) {
89+
const location = response.headers.get('location');
90+
if (location) {
91+
const resolvedLocation = location.startsWith('http')
92+
? location
93+
: new URL(location, url).toString();
94+
const ssoDomain = isSsoDomain(resolvedLocation);
95+
if (ssoDomain) {
96+
return {
97+
url,
98+
classification: 'auth-redirect',
99+
status,
100+
redirectUrl: resolvedLocation,
101+
ssoDomain,
102+
};
103+
}
104+
}
105+
// Non-SSO redirect — treat as accessible (normal redirect)
106+
return { url, classification: 'accessible', status };
107+
}
108+
109+
// 200 — check for soft auth gate (login form)
110+
if (status === 200) {
111+
let body: string;
112+
try {
113+
body = await response.text();
114+
} catch {
115+
return { url, classification: 'accessible', status };
116+
}
117+
118+
const loginHint = detectLoginForm(body);
119+
if (loginHint) {
120+
return { url, classification: 'soft-auth-gate', status, hint: loginHint };
121+
}
122+
123+
return { url, classification: 'accessible', status };
124+
}
125+
126+
// Other status codes — treat as accessible
127+
return { url, classification: 'accessible', status };
128+
} catch (err) {
129+
return {
130+
url,
131+
classification: 'accessible',
132+
status: null,
133+
error: err instanceof Error ? err.message : String(err),
134+
};
135+
}
136+
}),
137+
);
138+
results.push(...batchResults);
139+
}
140+
141+
const fetchErrors = results.filter((r) => r.error).length;
142+
const tested = results.filter((r) => !r.error);
143+
144+
if (tested.length === 0) {
145+
return {
146+
id,
147+
category,
148+
status: 'fail',
149+
message: `Could not fetch any pages to check authentication${fetchErrors > 0 ? `; ${fetchErrors} failed to fetch` : ''}`,
150+
details: {
151+
totalPages,
152+
testedPages: results.length,
153+
sampled,
154+
fetchErrors,
155+
pageResults: results,
156+
discoveryWarnings: warnings,
157+
},
158+
};
159+
}
160+
161+
const accessible = tested.filter((r) => r.classification === 'accessible');
162+
const authRequired = tested.filter((r) => r.classification === 'auth-required');
163+
const softAuthGate = tested.filter((r) => r.classification === 'soft-auth-gate');
164+
const authRedirect = tested.filter((r) => r.classification === 'auth-redirect');
165+
const gatedCount = authRequired.length + softAuthGate.length + authRedirect.length;
166+
167+
const ssoDomains = [...new Set(authRedirect.map((r) => r.ssoDomain).filter(Boolean) as string[])];
168+
169+
let status: 'pass' | 'warn' | 'fail';
170+
let message: string;
171+
const suffix = fetchErrors > 0 ? `; ${fetchErrors} failed to fetch` : '';
172+
const pageLabel = sampled ? 'sampled pages' : 'pages';
173+
174+
if (gatedCount === 0) {
175+
status = 'pass';
176+
message = `All ${accessible.length} ${pageLabel} are publicly accessible${suffix}`;
177+
} else if (accessible.length > 0 && gatedCount > 0) {
178+
status = 'warn';
179+
message = `${gatedCount} of ${tested.length} ${pageLabel} require authentication (${accessible.length} accessible)${suffix}`;
180+
} else {
181+
status = 'fail';
182+
message = `All ${tested.length} ${pageLabel} require authentication${suffix}`;
183+
}
184+
5185
return {
6-
id: 'auth-gate-detection',
7-
category: 'authentication',
8-
status: 'skip',
9-
message: 'Not yet implemented',
186+
id,
187+
category,
188+
status,
189+
message,
190+
details: {
191+
totalPages,
192+
testedPages: results.length,
193+
sampled,
194+
accessible: accessible.length,
195+
authRequired: authRequired.length,
196+
softAuthGate: softAuthGate.length,
197+
authRedirect: authRedirect.length,
198+
ssoDomains,
199+
fetchErrors,
200+
pageResults: results,
201+
discoveryWarnings: warnings,
202+
},
10203
};
11204
}
12205

0 commit comments

Comments
 (0)