@farming-labs/docs 0.2.62 → 0.2.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-CQTH7NFu.mjs +624 -0
- package/dist/agent-DKKptIgy.mjs +4365 -0
- package/dist/agent-evals-B7MIxuEX.mjs +2144 -0
- package/dist/agent-export-CBgWgPvH.mjs +910 -0
- package/dist/agent-scope-C_U--OZ7.mjs +283 -0
- package/dist/agent-skills-bundle.d.mts +13 -0
- package/dist/agent-skills-bundle.mjs +12 -0
- package/dist/agent-skills-server-CPja6Syt.d.mts +14 -0
- package/dist/agent-skills-server-DraIb6FV.mjs +415 -0
- package/dist/agent-skills-vite.d.mts +31 -0
- package/dist/agent-skills-vite.mjs +70 -0
- package/dist/agents-XWZBub6f.mjs +221 -0
- package/dist/analytics-Bx44lg6d.mjs +177 -0
- package/dist/cli/index.d.mts +15 -0
- package/dist/cli/index.mjs +452 -0
- package/dist/client/react.d.mts +45 -0
- package/dist/client/react.mjs +223 -0
- package/dist/cloud-analytics-CSyFE6SS.mjs +132 -0
- package/dist/cloud-ask-ai-sbpjOR2K.mjs +382 -0
- package/dist/cloud-ask-ai-zpwkdwnF.d.mts +23 -0
- package/dist/cloud-pdNC-tyj.mjs +1615 -0
- package/dist/code-blocks-DnNVNK2M.mjs +871 -0
- package/dist/codeblocks-CFuurVIH.mjs +250 -0
- package/dist/config-Wcdj-D0a.mjs +369 -0
- package/dist/dev-Cmy6DtdF.mjs +1333 -0
- package/dist/docs-cloud-server.d.mts +70 -0
- package/dist/docs-cloud-server.mjs +310 -0
- package/dist/doctor-DtGYZ41i.mjs +2036 -0
- package/dist/downgrade-w7e6Se0L.mjs +184 -0
- package/dist/errors-DbOhkE1h.mjs +20 -0
- package/dist/golden-evaluations-Dj-9Eo3v.mjs +1785 -0
- package/dist/i18n-CCaFUnAN.mjs +40 -0
- package/dist/index.d.mts +1150 -0
- package/dist/index.mjs +10 -0
- package/dist/init-CQY0Woe3.mjs +1264 -0
- package/dist/mcp-B9dcsivk.mjs +156 -0
- package/dist/mcp.d.mts +298 -0
- package/dist/mcp.mjs +4430 -0
- package/dist/metadata-DWExHQnx.mjs +237 -0
- package/dist/package-version-n5AFur8a.mjs +128 -0
- package/dist/reading-time-CYZ5VvKU.mjs +742 -0
- package/dist/review-CLoHTywU.mjs +673 -0
- package/dist/robots-BIpC4j4P.mjs +201 -0
- package/dist/robots-CUTahhoY.mjs +179 -0
- package/dist/search-B6V6qtiI.mjs +1826 -0
- package/dist/search-CaSyi6H6.d.mts +279 -0
- package/dist/search-DSjCeOk7.mjs +104 -0
- package/dist/server.d.mts +343 -0
- package/dist/server.mjs +14 -0
- package/dist/sitemap-Cykpe3Tz.mjs +249 -0
- package/dist/sitemap-server-C_6Wes83.mjs +1137 -0
- package/dist/standards-discovery-C4HUqMd2.d.mts +227 -0
- package/dist/standards-discovery-jkykaXq1.mjs +519 -0
- package/dist/templates-Bq_P7ctv.mjs +2465 -0
- package/dist/types-lMBIdZg0.d.mts +3315 -0
- package/dist/upgrade-oz-GChgt.mjs +56 -0
- package/dist/utils-DpiIioYb.mjs +225 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2036 @@
|
|
|
1
|
+
import { i as scanDocsPageTargets, n as compactAgentDocs, r as inspectAgentCompactionState } from "./agent-CQTH7NFu.mjs";
|
|
2
|
+
import { l as httpLinkHeaderHasTargetRelation } from "./reading-time-CYZ5VvKU.mjs";
|
|
3
|
+
import { Dt as DEFAULT_SITEMAP_MD_ROUTE, Ft as resolveDocsSitemapConfig, Ht as PAGE_AGENT_CONTRACT_FIELDS, N as buildDocsMcpEndpointCandidates, Ot as DEFAULT_SITEMAP_MD_WELL_KNOWN_ROUTE, S as DEFAULT_SKILL_MD_WELL_KNOWN_ROUTE, T as DOCS_CONFIG_MAP_TOP_LEVEL_KEYS, c as DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE, dn as normalizeDocsMcpAuthorizationServerUrls, en as DEFAULT_MCP_PUBLIC_ROUTE, h as DEFAULT_LLMS_FULL_TXT_ROUTE, i as DEFAULT_AGENT_FEEDBACK_ROUTE, in as getDocsMcpProtectedResourceMetadataRoutes, j as buildDocsConfigMap, k as buildDocsAgentDiscoverySpec, kt as DEFAULT_SITEMAP_XML_ROUTE, l as DEFAULT_AGENT_SPEC_WELL_KNOWN_ROUTE, n as DEFAULT_AGENTS_MD_WELL_KNOWN_ROUTE, nn as DEFAULT_MCP_WELL_KNOWN_ROUTE, sn as isDocsMcpOAuthScopeToken, t as DEFAULT_AGENTS_MD_ROUTE, v as DEFAULT_LLMS_TXT_ROUTE, x as DEFAULT_SKILL_MD_ROUTE } from "./agent-DKKptIgy.mjs";
|
|
4
|
+
import { c as DEFAULT_AGENT_SKILLS_INDEX_ROUTE, g as DEFAULT_API_CATALOG_ROUTE, h as DEFAULT_API_CATALOG_FORMAT, k as resolveDocsDiscoveryApiRoute, l as DEFAULT_AGENT_SKILLS_ROUTE_PATTERN, n as API_CATALOG_MEDIA_TYPE, p as DEFAULT_AGENT_SKILL_FORMAT, r as API_CATALOG_PROFILE_URI, s as DEFAULT_AGENT_SKILLS_INDEX_FORMAT, t as AGENT_SKILLS_DISCOVERY_SCHEMA_URI, u as DEFAULT_AGENT_SKILLS_ROUTE_PREFIX } from "./standards-discovery-jkykaXq1.mjs";
|
|
5
|
+
import { d as resolveAskAISearchRequestConfig } from "./search-B6V6qtiI.mjs";
|
|
6
|
+
import { a as analyzeDocsRobotsTxt, n as DEFAULT_ROBOTS_TXT_ROUTE, u as resolveDocsRobotsConfig } from "./robots-BIpC4j4P.mjs";
|
|
7
|
+
import { a as resolveDocsMetadataBaseUrl } from "./metadata-DWExHQnx.mjs";
|
|
8
|
+
import "./sitemap-server-C_6Wes83.mjs";
|
|
9
|
+
import { t as runDocsGoldenTasks } from "./agent-evals-B7MIxuEX.mjs";
|
|
10
|
+
import { createFilesystemDocsMcpSource, getDocsConfigSchema, resolveDocsMcpConfig } from "./mcp.mjs";
|
|
11
|
+
import "./code-blocks-DnNVNK2M.mjs";
|
|
12
|
+
import "./agent-skills-server-DraIb6FV.mjs";
|
|
13
|
+
import "./server.mjs";
|
|
14
|
+
import { _ as resolveDocsContentDir, d as readNavTitle, g as resolveDocsConfigPath, h as readTopLevelStringProperty, l as readBooleanProperty, r as extractTopLevelConfigObject, s as loadDocsConfigModuleResultWithProjectEnv, t as extractNestedObjectLiteral } from "./config-Wcdj-D0a.mjs";
|
|
15
|
+
import { t as detectFramework } from "./utils-DpiIioYb.mjs";
|
|
16
|
+
import { i as createAgentUsefulnessPagesFromMcp, n as analyzeAgentSurfaceDrift, r as analyzeAgentUsefulness, t as resolveGoldenEvaluationInput } from "./golden-evaluations-Dj-9Eo3v.mjs";
|
|
17
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import pc from "picocolors";
|
|
22
|
+
|
|
23
|
+
//#region src/cli/doctor.ts
|
|
24
|
+
const NEXT_CONFIG_PATTERN = /^next\.config\.(?:[cm]?js|[cm]?ts)$/;
|
|
25
|
+
const ASTRO_CONFIG_PATTERN = /^astro\.config\.(?:[cm]?js|[cm]?ts)$/;
|
|
26
|
+
const CODE_FILE_PATTERN = /\.(?:[cm]?js|[cm]?ts|jsx|tsx)$/;
|
|
27
|
+
const IGNORED_DIRS = new Set([
|
|
28
|
+
".git",
|
|
29
|
+
".next",
|
|
30
|
+
".nuxt",
|
|
31
|
+
".output",
|
|
32
|
+
".svelte-kit",
|
|
33
|
+
".turbo",
|
|
34
|
+
"build",
|
|
35
|
+
"coverage",
|
|
36
|
+
"dist",
|
|
37
|
+
"node_modules",
|
|
38
|
+
"out"
|
|
39
|
+
]);
|
|
40
|
+
function parseInlineFlag(arg) {
|
|
41
|
+
const [rawKey, value] = arg.slice(2).split("=", 2);
|
|
42
|
+
return {
|
|
43
|
+
key: rawKey.trim(),
|
|
44
|
+
value
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function parseDoctorOnlyMode(value) {
|
|
48
|
+
if (value === "agent") return "agent";
|
|
49
|
+
if (value === "site") return "human";
|
|
50
|
+
throw new Error("Invalid value for --only. Expected agent or site.");
|
|
51
|
+
}
|
|
52
|
+
function parseDoctorFailOn(value) {
|
|
53
|
+
if (value === "warn" || value === "fail") return value;
|
|
54
|
+
throw new Error("Invalid value for --fail-on. Expected warn or fail.");
|
|
55
|
+
}
|
|
56
|
+
function parseDoctorArgs(argv) {
|
|
57
|
+
const parsed = {};
|
|
58
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
59
|
+
const arg = argv[index];
|
|
60
|
+
if (arg === "--help" || arg === "-h") {
|
|
61
|
+
parsed.help = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (arg === "--agent" || arg === "agent") {
|
|
65
|
+
parsed.mode = "agent";
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (arg === "--json") {
|
|
69
|
+
parsed.json = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg === "--strict") {
|
|
73
|
+
parsed.strict = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (arg === "--fix") {
|
|
77
|
+
parsed.fix = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg === "--dry-run") {
|
|
81
|
+
parsed.dryRun = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (arg.startsWith("--fail-on=")) {
|
|
85
|
+
const value = parseInlineFlag(arg).value;
|
|
86
|
+
if (!value) throw new Error("Missing value for --fail-on.");
|
|
87
|
+
parsed.failOn = parseDoctorFailOn(value);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (arg === "--fail-on") {
|
|
91
|
+
const value = argv[index + 1];
|
|
92
|
+
if (!value || value.startsWith("--")) throw new Error("Missing value for --fail-on.");
|
|
93
|
+
parsed.failOn = parseDoctorFailOn(value);
|
|
94
|
+
index += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (arg.startsWith("--only=")) {
|
|
98
|
+
const value = parseInlineFlag(arg).value;
|
|
99
|
+
if (!value) throw new Error("Missing value for --only.");
|
|
100
|
+
parsed.mode = parseDoctorOnlyMode(value);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (arg === "--only") {
|
|
104
|
+
const value = argv[index + 1];
|
|
105
|
+
if (!value || value.startsWith("--")) throw new Error("Missing value for --only.");
|
|
106
|
+
parsed.mode = parseDoctorOnlyMode(value);
|
|
107
|
+
index += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (arg === "--human" || arg === "human" || arg === "--site" || arg === "site") {
|
|
111
|
+
parsed.mode = "human";
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (arg.startsWith("--config=")) {
|
|
115
|
+
const value = parseInlineFlag(arg).value;
|
|
116
|
+
if (!value) throw new Error("Missing value for --config.");
|
|
117
|
+
parsed.configPath = value;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (arg.startsWith("--url=")) {
|
|
121
|
+
const value = parseInlineFlag(arg).value;
|
|
122
|
+
if (!value) throw new Error("Missing value for --url.");
|
|
123
|
+
parsed.url = value;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (arg === "--url") {
|
|
127
|
+
const value = argv[index + 1];
|
|
128
|
+
if (!value || value.startsWith("--")) throw new Error("Missing value for --url.");
|
|
129
|
+
parsed.url = value;
|
|
130
|
+
index += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (arg === "--config") {
|
|
134
|
+
const value = argv[index + 1];
|
|
135
|
+
if (!value || value.startsWith("--")) throw new Error("Missing value for --config.");
|
|
136
|
+
parsed.configPath = value;
|
|
137
|
+
index += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`Unknown doctor flag or subcommand: ${arg}.`);
|
|
141
|
+
}
|
|
142
|
+
if (!parsed.help && !parsed.mode) parsed.mode = "agent";
|
|
143
|
+
return parsed;
|
|
144
|
+
}
|
|
145
|
+
function printDoctorHelp() {
|
|
146
|
+
console.log(`
|
|
147
|
+
${pc.bold("@farming-labs/docs doctor")}
|
|
148
|
+
|
|
149
|
+
${pc.dim("Usage:")}
|
|
150
|
+
pnpm exec docs doctor
|
|
151
|
+
pnpm exec docs doctor --agent
|
|
152
|
+
pnpm exec docs doctor --site
|
|
153
|
+
pnpm exec docs doctor --agent --json
|
|
154
|
+
pnpm exec docs doctor --agent --strict
|
|
155
|
+
pnpm exec docs doctor --agent --fix
|
|
156
|
+
pnpm exec docs doctor --agent --fix --dry-run
|
|
157
|
+
pnpm exec docs doctor --agent --fail-on fail
|
|
158
|
+
pnpm exec docs doctor --only agent
|
|
159
|
+
pnpm exec docs doctor --only site
|
|
160
|
+
pnpm exec docs doctor agent
|
|
161
|
+
pnpm exec docs doctor site
|
|
162
|
+
|
|
163
|
+
${pc.dim("Options:")}
|
|
164
|
+
${pc.cyan("--agent")} Score agent-readiness for the current docs app (default)
|
|
165
|
+
${pc.cyan("--site")} Score reader-facing docs quality for the current docs app
|
|
166
|
+
${pc.cyan("--human")} Alias for ${pc.cyan("--site")}
|
|
167
|
+
${pc.cyan("--only <mode>")} Run only one doctor suite: ${pc.cyan("agent")} or ${pc.cyan("site")}
|
|
168
|
+
${pc.cyan("--json")} Print the report as JSON for CI, scripts, and other agents
|
|
169
|
+
${pc.cyan("--strict")} Exit with failure when any check warns or fails
|
|
170
|
+
${pc.cyan("--fix")} Refresh stale generated agent.md files and token-budget missing outputs
|
|
171
|
+
${pc.cyan("--dry-run")} With ${pc.cyan("--fix")}, report the compaction command without writing files
|
|
172
|
+
${pc.cyan("--fail-on <level>")} Exit with failure on ${pc.cyan("warn")} or only on ${pc.cyan("fail")}
|
|
173
|
+
${pc.cyan("--url <url>")} Probe hosted agent surfaces, e.g. ${pc.dim("https://docs.example.com")}
|
|
174
|
+
${pc.cyan("--config <path>")} Use a custom docs config path instead of ${pc.dim("docs.config.ts[x]")}
|
|
175
|
+
${pc.cyan("-h, --help")} Show this help message
|
|
176
|
+
`);
|
|
177
|
+
}
|
|
178
|
+
function escapeRegExp(value) {
|
|
179
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
180
|
+
}
|
|
181
|
+
function splitTopLevelProperties(content) {
|
|
182
|
+
const properties = [];
|
|
183
|
+
let start = 0;
|
|
184
|
+
let stringQuote = null;
|
|
185
|
+
let escaped = false;
|
|
186
|
+
let braceDepth = 0;
|
|
187
|
+
let bracketDepth = 0;
|
|
188
|
+
let parenDepth = 0;
|
|
189
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
190
|
+
const char = content[index];
|
|
191
|
+
if (stringQuote) {
|
|
192
|
+
if (escaped) {
|
|
193
|
+
escaped = false;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (char === "\\") {
|
|
197
|
+
escaped = true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (char === stringQuote) stringQuote = null;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (char === "\"" || char === "'" || char === "`") {
|
|
204
|
+
stringQuote = char;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (char === "{") {
|
|
208
|
+
braceDepth += 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (char === "}") {
|
|
212
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (char === "[") {
|
|
216
|
+
bracketDepth += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (char === "]") {
|
|
220
|
+
bracketDepth = Math.max(0, bracketDepth - 1);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (char === "(") {
|
|
224
|
+
parenDepth += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (char === ")") {
|
|
228
|
+
parenDepth = Math.max(0, parenDepth - 1);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (char === "," && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
|
|
232
|
+
properties.push(content.slice(start, index));
|
|
233
|
+
start = index + 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const trailing = content.slice(start);
|
|
237
|
+
if (trailing.trim().length > 0) properties.push(trailing);
|
|
238
|
+
return properties;
|
|
239
|
+
}
|
|
240
|
+
function readTopLevelBooleanProperty(content, key) {
|
|
241
|
+
const rootObject = extractTopLevelConfigObject(content) ?? content;
|
|
242
|
+
const propertyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*(true|false)(?:\\s|$)`);
|
|
243
|
+
for (const property of splitTopLevelProperties(rootObject)) {
|
|
244
|
+
const match = property.trim().match(propertyPattern);
|
|
245
|
+
if (match) return match[1] === "true";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function readObjectBooleanProperty(content, key) {
|
|
249
|
+
const propertyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*(true|false)(?:\\s|$)`);
|
|
250
|
+
for (const property of splitTopLevelProperties(content)) {
|
|
251
|
+
const match = property.trim().match(propertyPattern);
|
|
252
|
+
if (match) return match[1] === "true";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function resolveFeatureEnabled(config, content, key) {
|
|
256
|
+
const current = config?.[key];
|
|
257
|
+
if (typeof current === "boolean") return current;
|
|
258
|
+
if (current && typeof current === "object") return current.enabled ?? true;
|
|
259
|
+
const topLevelBoolean = readTopLevelBooleanProperty(content, key);
|
|
260
|
+
if (typeof topLevelBoolean === "boolean") return topLevelBoolean;
|
|
261
|
+
const block = extractNestedObjectLiteral(content, [key]);
|
|
262
|
+
if (!block) return true;
|
|
263
|
+
return readBooleanProperty(block, "enabled") ?? true;
|
|
264
|
+
}
|
|
265
|
+
function readSitemapConfigFromStatic(content) {
|
|
266
|
+
const topLevelBoolean = readTopLevelBooleanProperty(content, "sitemap");
|
|
267
|
+
if (typeof topLevelBoolean === "boolean") return topLevelBoolean;
|
|
268
|
+
const block = extractNestedObjectLiteral(content, ["sitemap"]);
|
|
269
|
+
if (!block) return void 0;
|
|
270
|
+
const config = {};
|
|
271
|
+
const enabled = readObjectBooleanProperty(block, "enabled");
|
|
272
|
+
const routePrefix = block.match(/\broutePrefix\s*:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
273
|
+
const baseUrl = block.match(/\bbaseUrl\s*:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
274
|
+
const manifestPath = block.match(/\bmanifestPath\s*:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
275
|
+
if (typeof enabled === "boolean") config.enabled = enabled;
|
|
276
|
+
if (routePrefix) config.routePrefix = routePrefix;
|
|
277
|
+
if (baseUrl) config.baseUrl = baseUrl;
|
|
278
|
+
if (manifestPath) config.manifestPath = manifestPath;
|
|
279
|
+
return config;
|
|
280
|
+
}
|
|
281
|
+
function readRobotsConfigFromStatic(content) {
|
|
282
|
+
const topLevelBoolean = readTopLevelBooleanProperty(content, "robots");
|
|
283
|
+
if (typeof topLevelBoolean === "boolean") return topLevelBoolean;
|
|
284
|
+
const block = extractNestedObjectLiteral(content, ["robots"]);
|
|
285
|
+
if (!block) return void 0;
|
|
286
|
+
const config = {};
|
|
287
|
+
const enabled = readObjectBooleanProperty(block, "enabled");
|
|
288
|
+
const pathValue = block.match(/\bpath\s*:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
289
|
+
const baseUrl = block.match(/\bbaseUrl\s*:\s*["'`]([^"'`]+)["'`]/)?.[1];
|
|
290
|
+
const aiString = block.match(/\bai\s*:\s*["'`](allow|disallow)["'`]/)?.[1];
|
|
291
|
+
const aiBoolean = readObjectBooleanProperty(block, "ai");
|
|
292
|
+
if (typeof enabled === "boolean") config.enabled = enabled;
|
|
293
|
+
if (pathValue) config.path = pathValue;
|
|
294
|
+
if (baseUrl) config.baseUrl = baseUrl;
|
|
295
|
+
if (aiString) config.ai = aiString;
|
|
296
|
+
else if (typeof aiBoolean === "boolean") config.ai = aiBoolean;
|
|
297
|
+
return config;
|
|
298
|
+
}
|
|
299
|
+
function resolvePublicDir(rootDir, framework) {
|
|
300
|
+
if (framework === "sveltekit") return path.join(rootDir, "static");
|
|
301
|
+
return path.join(rootDir, "public");
|
|
302
|
+
}
|
|
303
|
+
function resolveRobotsFilePath(rootDir, framework, robots) {
|
|
304
|
+
if (robots?.path) return path.isAbsolute(robots.path) ? robots.path : path.resolve(rootDir, robots.path);
|
|
305
|
+
return path.join(resolvePublicDir(rootDir, framework), "robots.txt");
|
|
306
|
+
}
|
|
307
|
+
function resolveStaticExport(config, content) {
|
|
308
|
+
if (typeof config?.staticExport === "boolean") return config.staticExport;
|
|
309
|
+
return readTopLevelBooleanProperty(content, "staticExport") ?? false;
|
|
310
|
+
}
|
|
311
|
+
function resolveAgentFeedbackEnabled(config, content) {
|
|
312
|
+
const feedback = config?.feedback;
|
|
313
|
+
if (feedback === false) return false;
|
|
314
|
+
if (feedback === true) return true;
|
|
315
|
+
if (feedback && typeof feedback === "object") {
|
|
316
|
+
const agent = feedback.agent;
|
|
317
|
+
if (typeof agent === "boolean") return agent;
|
|
318
|
+
if (agent && typeof agent === "object") return agent.enabled ?? true;
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
const topLevelBoolean = readTopLevelBooleanProperty(content, "feedback");
|
|
322
|
+
if (typeof topLevelBoolean === "boolean") return topLevelBoolean;
|
|
323
|
+
const feedbackBlock = extractNestedObjectLiteral(content, ["feedback"]);
|
|
324
|
+
if (!feedbackBlock) return true;
|
|
325
|
+
const nestedAgentBlock = extractNestedObjectLiteral(content, ["feedback", "agent"]);
|
|
326
|
+
if (nestedAgentBlock) return readBooleanProperty(nestedAgentBlock, "enabled") ?? true;
|
|
327
|
+
return readBooleanProperty(feedbackBlock, "agent") ?? true;
|
|
328
|
+
}
|
|
329
|
+
function resolveHumanFeedbackEnabled(config, content) {
|
|
330
|
+
const feedback = config?.feedback;
|
|
331
|
+
if (typeof feedback === "boolean") return feedback;
|
|
332
|
+
if (feedback && typeof feedback === "object") return feedback.enabled ?? true;
|
|
333
|
+
const topLevelBoolean = readTopLevelBooleanProperty(content, "feedback");
|
|
334
|
+
if (typeof topLevelBoolean === "boolean") return topLevelBoolean;
|
|
335
|
+
const feedbackBlock = extractNestedObjectLiteral(content, ["feedback"]);
|
|
336
|
+
if (!feedbackBlock) return false;
|
|
337
|
+
return readBooleanProperty(feedbackBlock, "enabled") ?? true;
|
|
338
|
+
}
|
|
339
|
+
function resolveLastUpdatedEnabled(config, content) {
|
|
340
|
+
const current = config?.lastUpdated;
|
|
341
|
+
if (typeof current === "boolean") return current;
|
|
342
|
+
if (current && typeof current === "object") return current.enabled ?? true;
|
|
343
|
+
const topLevelBoolean = readTopLevelBooleanProperty(content, "lastUpdated");
|
|
344
|
+
if (typeof topLevelBoolean === "boolean") return topLevelBoolean;
|
|
345
|
+
const block = extractNestedObjectLiteral(content, ["lastUpdated"]);
|
|
346
|
+
if (!block) return true;
|
|
347
|
+
return readBooleanProperty(block, "enabled") ?? true;
|
|
348
|
+
}
|
|
349
|
+
function hasGithubIntegration(config, content) {
|
|
350
|
+
if (typeof config?.github === "string") return config.github.trim().length > 0;
|
|
351
|
+
if (config?.github && typeof config.github === "object") return typeof config.github.url === "string" && config.github.url.trim().length > 0;
|
|
352
|
+
const topLevelString = readTopLevelStringProperty(content, "github");
|
|
353
|
+
if (typeof topLevelString === "string" && topLevelString.trim().length > 0) return true;
|
|
354
|
+
const githubBlock = extractNestedObjectLiteral(content, ["github"]);
|
|
355
|
+
if (!githubBlock) return false;
|
|
356
|
+
const urlMatch = githubBlock.match(/\burl\s*:\s*["'`]([^"'`]+)["'`]/);
|
|
357
|
+
return typeof urlMatch?.[1] === "string" && urlMatch[1].trim().length > 0;
|
|
358
|
+
}
|
|
359
|
+
function hasReadingTimeSurface(config, content) {
|
|
360
|
+
const current = config?.readingTime;
|
|
361
|
+
if (current === true) return true;
|
|
362
|
+
if (current && typeof current === "object") return current.enabled !== false;
|
|
363
|
+
const topLevelBoolean = readTopLevelBooleanProperty(content, "readingTime");
|
|
364
|
+
if (typeof topLevelBoolean === "boolean") return topLevelBoolean;
|
|
365
|
+
const block = extractNestedObjectLiteral(content, ["readingTime"]);
|
|
366
|
+
if (!block) return false;
|
|
367
|
+
return readBooleanProperty(block, "enabled") ?? true;
|
|
368
|
+
}
|
|
369
|
+
function hasAgentCompactDefaults(config, content) {
|
|
370
|
+
if (config?.agent?.compact) return true;
|
|
371
|
+
return extractNestedObjectLiteral(content, ["agent", "compact"]) !== void 0;
|
|
372
|
+
}
|
|
373
|
+
function listProjectFiles(rootDir) {
|
|
374
|
+
const files = [];
|
|
375
|
+
const visit = (dir) => {
|
|
376
|
+
if (!existsSync(dir)) return;
|
|
377
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
378
|
+
const fullPath = path.join(dir, entry);
|
|
379
|
+
const stat = lstatSync(fullPath);
|
|
380
|
+
if (stat.isSymbolicLink()) continue;
|
|
381
|
+
if (stat.isDirectory()) {
|
|
382
|
+
if (IGNORED_DIRS.has(entry)) continue;
|
|
383
|
+
visit(fullPath);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
files.push(path.relative(rootDir, fullPath).replace(/\\/g, "/"));
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
visit(rootDir);
|
|
390
|
+
return files;
|
|
391
|
+
}
|
|
392
|
+
function buildFileReader(rootDir) {
|
|
393
|
+
const cache = /* @__PURE__ */ new Map();
|
|
394
|
+
return (relativePath) => {
|
|
395
|
+
const cached = cache.get(relativePath);
|
|
396
|
+
if (cached !== void 0) return cached;
|
|
397
|
+
const content = readFileSync(path.join(rootDir, relativePath), "utf-8");
|
|
398
|
+
cache.set(relativePath, content);
|
|
399
|
+
return content;
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function formatPathList(paths) {
|
|
403
|
+
if (paths.length === 0) return "";
|
|
404
|
+
if (paths.length === 1) return paths[0];
|
|
405
|
+
return `${paths[0]} (+${paths.length - 1} more)`;
|
|
406
|
+
}
|
|
407
|
+
function findCodeFiles(files, predicate) {
|
|
408
|
+
return files.filter((relativePath) => CODE_FILE_PATTERN.test(relativePath) && predicate(relativePath));
|
|
409
|
+
}
|
|
410
|
+
function detectFrameworkFromFiles(files) {
|
|
411
|
+
if (files.some((file) => NEXT_CONFIG_PATTERN.test(path.basename(file)))) return "nextjs";
|
|
412
|
+
if (files.some((file) => file.startsWith("src/routes/") && file.includes("api.docs"))) return "tanstack-start";
|
|
413
|
+
if (files.some((file) => file === "src/hooks.server.js" || file === "src/hooks.server.ts")) return "sveltekit";
|
|
414
|
+
if (files.some((file) => ASTRO_CONFIG_PATTERN.test(path.basename(file)))) return "astro";
|
|
415
|
+
if (files.some((file) => file.startsWith("server/middleware/"))) return "nuxt";
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
function detectRouteSurface(rootDir, framework, staticExport, files) {
|
|
419
|
+
const read = buildFileReader(rootDir);
|
|
420
|
+
if (framework === "nextjs") {
|
|
421
|
+
const withDocsConfigs = files.filter((file) => NEXT_CONFIG_PATTERN.test(path.basename(file))).filter((file) => read(file).includes("withDocs("));
|
|
422
|
+
const apiRoutes = findCodeFiles(files, (file) => /(?:^|\/)route\.(?:[cm]?js|[cm]?ts|jsx|tsx)$/.test(file) && read(file).includes("createDocsAPI("));
|
|
423
|
+
if (staticExport) return {
|
|
424
|
+
apiMounted: false,
|
|
425
|
+
apiDetail: "Next static export disables /api/docs and the shared agent endpoints.",
|
|
426
|
+
publicMounted: false,
|
|
427
|
+
publicDetail: "Public .md, llms.txt, sitemap, AGENTS.md, skill.md, and agent discovery routes depend on /api/docs."
|
|
428
|
+
};
|
|
429
|
+
return {
|
|
430
|
+
apiMounted: apiRoutes.length > 0,
|
|
431
|
+
apiDetail: apiRoutes.length > 0 ? `Found docs API route at ${formatPathList(apiRoutes)}.` : "Could not find a Next docs API route that uses createDocsAPI().",
|
|
432
|
+
publicMounted: withDocsConfigs.length > 0,
|
|
433
|
+
publicDetail: withDocsConfigs.length > 0 ? `Found withDocs() in ${formatPathList(withDocsConfigs)}.` : "Could not find withDocs() in next.config.*, so public docs rewrites are not verified."
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (framework === "tanstack-start") {
|
|
437
|
+
const apiRoutes = findCodeFiles(files, (file) => file.startsWith("src/routes/") && read(file).includes("docsServer.GET") && /createFileRoute\((["'])\/api/.test(read(file)));
|
|
438
|
+
const publicHandlers = findCodeFiles(files, (file) => file.startsWith("src/routes/") && read(file).includes("isDocsPublicGetRequest(") && read(file).includes("isDocsMcpRequest("));
|
|
439
|
+
return {
|
|
440
|
+
apiMounted: apiRoutes.length > 0,
|
|
441
|
+
apiDetail: apiRoutes.length > 0 ? `Found TanStack docs API route at ${formatPathList(apiRoutes)}.` : "Could not find a TanStack route that forwards /api/docs into docsServer.GET.",
|
|
442
|
+
publicMounted: publicHandlers.length > 0,
|
|
443
|
+
publicDetail: publicHandlers.length > 0 ? `Found public docs forwarder at ${formatPathList(publicHandlers)}.` : "Could not find a TanStack public docs forwarder using isDocsPublicGetRequest()."
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (framework === "sveltekit") {
|
|
447
|
+
const apiRoutes = findCodeFiles(files, (file) => file.startsWith("src/routes/") && path.basename(file).startsWith("+server.") && read(file).includes("docs.server"));
|
|
448
|
+
const publicHandlers = findCodeFiles(files, (file) => /^src\/hooks\.server\.(?:[cm]?js|[cm]?ts)$/.test(file) && read(file).includes("isDocsPublicGetRequest("));
|
|
449
|
+
return {
|
|
450
|
+
apiMounted: apiRoutes.length > 0,
|
|
451
|
+
apiDetail: apiRoutes.length > 0 ? `Found SvelteKit docs API route at ${formatPathList(apiRoutes)}.` : "Could not find a SvelteKit +server route that re-exports docs.server.",
|
|
452
|
+
publicMounted: publicHandlers.length > 0,
|
|
453
|
+
publicDetail: publicHandlers.length > 0 ? `Found SvelteKit public docs hook at ${formatPathList(publicHandlers)}.` : "Could not find hooks.server with isDocsPublicGetRequest()."
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (framework === "astro") {
|
|
457
|
+
const apiRoutes = findCodeFiles(files, (file) => file.startsWith("src/pages/") && read(file).includes("docsGET") && read(file).includes("docsPOST"));
|
|
458
|
+
const publicHandlers = findCodeFiles(files, (file) => /^src\/middleware\.(?:[cm]?js|[cm]?ts)$/.test(file) && read(file).includes("isDocsPublicGetRequest("));
|
|
459
|
+
return {
|
|
460
|
+
apiMounted: apiRoutes.length > 0,
|
|
461
|
+
apiDetail: apiRoutes.length > 0 ? `Found Astro docs API route at ${formatPathList(apiRoutes)}.` : "Could not find an Astro docs API route that forwards to docs.server.",
|
|
462
|
+
publicMounted: publicHandlers.length > 0,
|
|
463
|
+
publicDetail: publicHandlers.length > 0 ? `Found Astro middleware forwarder at ${formatPathList(publicHandlers)}.` : "Could not find Astro middleware using isDocsPublicGetRequest()."
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (framework === "nuxt") {
|
|
467
|
+
const apiRoutes = findCodeFiles(files, (file) => file.startsWith("server/api/") && read(file).includes("defineDocsHandler("));
|
|
468
|
+
const publicHandlers = findCodeFiles(files, (file) => file.startsWith("server/middleware/") && read(file).includes("defineDocsPublicHandler("));
|
|
469
|
+
return {
|
|
470
|
+
apiMounted: apiRoutes.length > 0,
|
|
471
|
+
apiDetail: apiRoutes.length > 0 ? `Found Nuxt docs API handler at ${formatPathList(apiRoutes)}.` : "Could not find a Nuxt docs API handler using defineDocsHandler().",
|
|
472
|
+
publicMounted: publicHandlers.length > 0,
|
|
473
|
+
publicDetail: publicHandlers.length > 0 ? `Found Nuxt public docs middleware at ${formatPathList(publicHandlers)}.` : "Could not find Nuxt middleware using defineDocsPublicHandler()."
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
apiMounted: false,
|
|
478
|
+
apiDetail: "Could not detect a supported framework, so API route inspection was skipped.",
|
|
479
|
+
publicMounted: false,
|
|
480
|
+
publicDetail: "Could not detect a supported framework, so public route inspection was skipped."
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function coverageScore(explicitCoverage) {
|
|
484
|
+
if (explicitCoverage >= 80) return {
|
|
485
|
+
status: "pass",
|
|
486
|
+
score: 10
|
|
487
|
+
};
|
|
488
|
+
if (explicitCoverage >= 50) return {
|
|
489
|
+
status: "pass",
|
|
490
|
+
score: 8
|
|
491
|
+
};
|
|
492
|
+
if (explicitCoverage >= 20) return {
|
|
493
|
+
status: "warn",
|
|
494
|
+
score: 5
|
|
495
|
+
};
|
|
496
|
+
if (explicitCoverage > 0) return {
|
|
497
|
+
status: "warn",
|
|
498
|
+
score: 3
|
|
499
|
+
};
|
|
500
|
+
return {
|
|
501
|
+
status: "warn",
|
|
502
|
+
score: 0
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
function metadataScore(descriptionCoverage, relatedCoverage) {
|
|
506
|
+
if (descriptionCoverage >= 90 && relatedCoverage >= 20) return {
|
|
507
|
+
status: "pass",
|
|
508
|
+
score: 5
|
|
509
|
+
};
|
|
510
|
+
if (descriptionCoverage >= 75) return {
|
|
511
|
+
status: "pass",
|
|
512
|
+
score: 4
|
|
513
|
+
};
|
|
514
|
+
if (descriptionCoverage >= 50) return {
|
|
515
|
+
status: "warn",
|
|
516
|
+
score: 2
|
|
517
|
+
};
|
|
518
|
+
if (descriptionCoverage > 0) return {
|
|
519
|
+
status: "warn",
|
|
520
|
+
score: 1
|
|
521
|
+
};
|
|
522
|
+
return {
|
|
523
|
+
status: "warn",
|
|
524
|
+
score: 0
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function descriptionScore(descriptionCoverage) {
|
|
528
|
+
if (descriptionCoverage >= 90) return {
|
|
529
|
+
status: "pass",
|
|
530
|
+
score: 15
|
|
531
|
+
};
|
|
532
|
+
if (descriptionCoverage >= 75) return {
|
|
533
|
+
status: "pass",
|
|
534
|
+
score: 12
|
|
535
|
+
};
|
|
536
|
+
if (descriptionCoverage >= 50) return {
|
|
537
|
+
status: "warn",
|
|
538
|
+
score: 8
|
|
539
|
+
};
|
|
540
|
+
if (descriptionCoverage > 0) return {
|
|
541
|
+
status: "warn",
|
|
542
|
+
score: 4
|
|
543
|
+
};
|
|
544
|
+
return {
|
|
545
|
+
status: "warn",
|
|
546
|
+
score: 0
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function structureScore(structureCoverage) {
|
|
550
|
+
if (structureCoverage >= 90) return {
|
|
551
|
+
status: "pass",
|
|
552
|
+
score: 15
|
|
553
|
+
};
|
|
554
|
+
if (structureCoverage >= 75) return {
|
|
555
|
+
status: "pass",
|
|
556
|
+
score: 12
|
|
557
|
+
};
|
|
558
|
+
if (structureCoverage >= 50) return {
|
|
559
|
+
status: "warn",
|
|
560
|
+
score: 8
|
|
561
|
+
};
|
|
562
|
+
if (structureCoverage > 0) return {
|
|
563
|
+
status: "warn",
|
|
564
|
+
score: 4
|
|
565
|
+
};
|
|
566
|
+
return {
|
|
567
|
+
status: "warn",
|
|
568
|
+
score: 0
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function navigationScore(navigationCoverage) {
|
|
572
|
+
if (navigationCoverage >= 100) return {
|
|
573
|
+
status: "pass",
|
|
574
|
+
score: 15
|
|
575
|
+
};
|
|
576
|
+
if (navigationCoverage >= 80) return {
|
|
577
|
+
status: "pass",
|
|
578
|
+
score: 12
|
|
579
|
+
};
|
|
580
|
+
if (navigationCoverage >= 50) return {
|
|
581
|
+
status: "warn",
|
|
582
|
+
score: 8
|
|
583
|
+
};
|
|
584
|
+
if (navigationCoverage > 0) return {
|
|
585
|
+
status: "warn",
|
|
586
|
+
score: 4
|
|
587
|
+
};
|
|
588
|
+
return {
|
|
589
|
+
status: "fail",
|
|
590
|
+
score: 0
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const AGENT_OPTIMIZATION_BLOCKING_CHECKS = new Set([
|
|
594
|
+
"surface-drift",
|
|
595
|
+
"agent-context-quality",
|
|
596
|
+
"agent-task-completeness",
|
|
597
|
+
"agent-applicability",
|
|
598
|
+
"command-health",
|
|
599
|
+
"related-coverage",
|
|
600
|
+
"golden-tasks"
|
|
601
|
+
]);
|
|
602
|
+
function gradeForAgentScore(score, checks = []) {
|
|
603
|
+
const hasBlockingFailure = checks.some((check) => check.status === "fail" && AGENT_OPTIMIZATION_BLOCKING_CHECKS.has(check.id));
|
|
604
|
+
if (score >= 90 && !hasBlockingFailure) return "Agent-optimized";
|
|
605
|
+
if (score >= 75) return "Agent-ready";
|
|
606
|
+
if (score >= 60) return "Promising";
|
|
607
|
+
return "Needs work";
|
|
608
|
+
}
|
|
609
|
+
function gradeForHumanScore(score) {
|
|
610
|
+
if (score >= 90) return "Human-optimized";
|
|
611
|
+
if (score >= 75) return "Reader-ready";
|
|
612
|
+
if (score >= 60) return "Promising";
|
|
613
|
+
return "Needs work";
|
|
614
|
+
}
|
|
615
|
+
function percentageScore(score, maxScore) {
|
|
616
|
+
if (maxScore <= 0) return 0;
|
|
617
|
+
return Math.round(score / maxScore * 100);
|
|
618
|
+
}
|
|
619
|
+
function normalizedDoctorScore(score, maxScore) {
|
|
620
|
+
return {
|
|
621
|
+
score: percentageScore(score, maxScore),
|
|
622
|
+
maxScore: 100
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function formatStatus(status) {
|
|
626
|
+
if (status === "pass") return pc.green("PASS");
|
|
627
|
+
if (status === "warn") return pc.yellow("WARN");
|
|
628
|
+
return pc.red("FAIL");
|
|
629
|
+
}
|
|
630
|
+
function buildCoverage(pages) {
|
|
631
|
+
const totalPages = pages.length;
|
|
632
|
+
const pagesWithAgentFiles = pages.filter((page) => page.agentRawContent !== void 0).length;
|
|
633
|
+
const pagesWithAgentBlocks = pages.filter((page) => page.agentFallbackRawContent !== void 0).length;
|
|
634
|
+
const explicitPages = pages.filter((page) => page.agentRawContent !== void 0 || page.agentFallbackRawContent !== void 0).length;
|
|
635
|
+
return {
|
|
636
|
+
totalPages,
|
|
637
|
+
pagesWithAgentFiles,
|
|
638
|
+
pagesWithAgentBlocks,
|
|
639
|
+
explicitPages,
|
|
640
|
+
explicitCoverage: totalPages === 0 ? 0 : Math.round(explicitPages / Math.max(totalPages, 1) * 100),
|
|
641
|
+
compaction: {
|
|
642
|
+
freshGeneratedPages: 0,
|
|
643
|
+
staleGeneratedPages: 0,
|
|
644
|
+
modifiedGeneratedPages: 0,
|
|
645
|
+
unknownGeneratedPages: 0,
|
|
646
|
+
tokenBudgetMissingPages: 0,
|
|
647
|
+
otherMissingPages: 0
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function scoreUsefulnessCoverage(completed, total, maxScore) {
|
|
652
|
+
if (total === 0) return {
|
|
653
|
+
status: "warn",
|
|
654
|
+
score: 0,
|
|
655
|
+
coverage: 0
|
|
656
|
+
};
|
|
657
|
+
const coverage = Math.round(completed / total * 100);
|
|
658
|
+
const score = Math.round(coverage / 100 * maxScore);
|
|
659
|
+
return {
|
|
660
|
+
status: coverage >= 80 ? "pass" : coverage > 0 ? "warn" : "fail",
|
|
661
|
+
score,
|
|
662
|
+
coverage
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
function buildCompactionCoverage(rootDir, contentDir, entry, pages, defaults) {
|
|
666
|
+
const targets = scanDocsPageTargets(rootDir, contentDir, entry);
|
|
667
|
+
const targetsBySlug = new Map(targets.map((target) => [target.slug, target]));
|
|
668
|
+
const coverage = {
|
|
669
|
+
freshGeneratedPages: 0,
|
|
670
|
+
staleGeneratedPages: 0,
|
|
671
|
+
modifiedGeneratedPages: 0,
|
|
672
|
+
unknownGeneratedPages: 0,
|
|
673
|
+
tokenBudgetMissingPages: 0,
|
|
674
|
+
otherMissingPages: 0
|
|
675
|
+
};
|
|
676
|
+
for (const page of pages) {
|
|
677
|
+
const target = targetsBySlug.get(page.slug);
|
|
678
|
+
if (!target) continue;
|
|
679
|
+
const state = inspectAgentCompactionState(page, target, defaults);
|
|
680
|
+
switch (state.status) {
|
|
681
|
+
case "fresh":
|
|
682
|
+
coverage.freshGeneratedPages += 1;
|
|
683
|
+
break;
|
|
684
|
+
case "stale":
|
|
685
|
+
coverage.staleGeneratedPages += 1;
|
|
686
|
+
break;
|
|
687
|
+
case "modified":
|
|
688
|
+
case "stale-modified":
|
|
689
|
+
coverage.modifiedGeneratedPages += 1;
|
|
690
|
+
break;
|
|
691
|
+
case "unknown":
|
|
692
|
+
coverage.unknownGeneratedPages += 1;
|
|
693
|
+
break;
|
|
694
|
+
case "missing":
|
|
695
|
+
if (state.tokenBudget !== void 0) coverage.tokenBudgetMissingPages += 1;
|
|
696
|
+
else coverage.otherMissingPages += 1;
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return coverage;
|
|
701
|
+
}
|
|
702
|
+
function compactionFreshnessScore(coverage, compactConfigured) {
|
|
703
|
+
if (coverage.staleGeneratedPages > 0 || coverage.modifiedGeneratedPages > 0 || coverage.tokenBudgetMissingPages > 0) {
|
|
704
|
+
const recommendations = [];
|
|
705
|
+
if (coverage.staleGeneratedPages > 0) recommendations.push("Run docs agent compact --stale to refresh stale generated agent.md files.");
|
|
706
|
+
if (coverage.modifiedGeneratedPages > 0) recommendations.push("Review modified generated agent.md files before overwriting them; --stale skips manual edits on purpose.");
|
|
707
|
+
if (coverage.tokenBudgetMissingPages > 0) recommendations.push("Run docs agent compact --stale --include-missing to create generated agent.md files for pages that opted into agent.tokenBudget.");
|
|
708
|
+
return {
|
|
709
|
+
status: "warn",
|
|
710
|
+
score: compactConfigured ? 2 : 0,
|
|
711
|
+
recommendation: recommendations.join(" ")
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
if (coverage.unknownGeneratedPages > 0) return {
|
|
715
|
+
status: "pass",
|
|
716
|
+
score: compactConfigured ? 5 : 3
|
|
717
|
+
};
|
|
718
|
+
if (coverage.freshGeneratedPages > 0) return {
|
|
719
|
+
status: "pass",
|
|
720
|
+
score: compactConfigured ? 5 : 4
|
|
721
|
+
};
|
|
722
|
+
if (compactConfigured) return {
|
|
723
|
+
status: "pass",
|
|
724
|
+
score: 5
|
|
725
|
+
};
|
|
726
|
+
return {
|
|
727
|
+
status: "warn",
|
|
728
|
+
score: 0,
|
|
729
|
+
recommendation: "Add agent.compact defaults if you want docs agent compact and stale detection to run without repeating model and compression settings."
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function buildMetadataCoverage(pages) {
|
|
733
|
+
const totalPages = pages.length;
|
|
734
|
+
const describedPages = pages.filter((page) => typeof page.description === "string" && page.description.trim().length > 0).length;
|
|
735
|
+
const relatedPages = pages.filter((page) => Array.isArray(page.related) && page.related.length > 0).length;
|
|
736
|
+
return {
|
|
737
|
+
describedPages,
|
|
738
|
+
relatedPages,
|
|
739
|
+
descriptionCoverage: totalPages === 0 ? 0 : Math.round(describedPages / totalPages * 100),
|
|
740
|
+
relatedCoverage: totalPages === 0 ? 0 : Math.round(relatedPages / totalPages * 100)
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function countNavigationPages(node) {
|
|
744
|
+
const urls = /* @__PURE__ */ new Set();
|
|
745
|
+
const visit = (current) => {
|
|
746
|
+
if (current.type === "page" && typeof current.url === "string") urls.add(current.url);
|
|
747
|
+
if (current.index && typeof current.index === "object") {
|
|
748
|
+
const indexNode = current.index;
|
|
749
|
+
if (typeof indexNode.url === "string") urls.add(indexNode.url);
|
|
750
|
+
}
|
|
751
|
+
const children = Array.isArray(current.children) ? current.children : [];
|
|
752
|
+
for (const child of children) {
|
|
753
|
+
if (!child || typeof child !== "object") continue;
|
|
754
|
+
visit(child);
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
visit(node);
|
|
758
|
+
return urls.size;
|
|
759
|
+
}
|
|
760
|
+
function estimateWordCount(content) {
|
|
761
|
+
return content.match(/\b[\p{L}\p{N}][\p{L}\p{N}'’-]*\b/gu)?.length ?? 0;
|
|
762
|
+
}
|
|
763
|
+
function hasSectionHeadings(content) {
|
|
764
|
+
return /^###{0,1}\s+/m.test(content);
|
|
765
|
+
}
|
|
766
|
+
function buildHumanCoverage(pages, navigationPages) {
|
|
767
|
+
const totalPages = pages.length;
|
|
768
|
+
const describedPages = pages.filter((page) => typeof page.description === "string" && page.description.trim().length > 0).length;
|
|
769
|
+
const longPages = pages.filter((page) => estimateWordCount(page.rawContent ?? "") >= 120).length;
|
|
770
|
+
const structuredLongPages = pages.filter((page) => {
|
|
771
|
+
const content = page.rawContent ?? "";
|
|
772
|
+
return estimateWordCount(content) >= 120 && hasSectionHeadings(content);
|
|
773
|
+
}).length;
|
|
774
|
+
return {
|
|
775
|
+
totalPages,
|
|
776
|
+
describedPages,
|
|
777
|
+
descriptionCoverage: totalPages === 0 ? 0 : Math.round(describedPages / totalPages * 100),
|
|
778
|
+
longPages,
|
|
779
|
+
structuredLongPages,
|
|
780
|
+
structureCoverage: longPages === 0 ? 100 : Math.round(structuredLongPages / longPages * 100),
|
|
781
|
+
navigationPages
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function normalizeDoctorBaseUrl(value) {
|
|
785
|
+
const url = new URL(value);
|
|
786
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http or https.");
|
|
787
|
+
url.hash = "";
|
|
788
|
+
url.search = "";
|
|
789
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
790
|
+
return url.toString().replace(/\/+$/, "");
|
|
791
|
+
}
|
|
792
|
+
function joinDoctorUrl(baseUrl, route) {
|
|
793
|
+
const base = new URL(baseUrl);
|
|
794
|
+
const basePath = base.pathname.replace(/\/+$/, "");
|
|
795
|
+
const routePath = route.startsWith("/") ? route : `/${route}`;
|
|
796
|
+
return new URL(`${basePath}${routePath}`, base.origin).toString();
|
|
797
|
+
}
|
|
798
|
+
function toMarkdownRoute(pageUrl) {
|
|
799
|
+
if (!pageUrl) return void 0;
|
|
800
|
+
const normalized = pageUrl === "/" ? "/index" : pageUrl.replace(/\/+$/, "");
|
|
801
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
802
|
+
}
|
|
803
|
+
async function fetchWithTimeout(url, init = {}, timeoutMs = 8e3) {
|
|
804
|
+
const controller = new AbortController();
|
|
805
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
806
|
+
try {
|
|
807
|
+
return await fetch(url, {
|
|
808
|
+
...init,
|
|
809
|
+
signal: controller.signal
|
|
810
|
+
});
|
|
811
|
+
} finally {
|
|
812
|
+
clearTimeout(timeout);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
async function probeTextRoute(baseUrl, route) {
|
|
816
|
+
const url = joinDoctorUrl(baseUrl, route);
|
|
817
|
+
try {
|
|
818
|
+
const response = await fetchWithTimeout(url, { headers: { Accept: "text/plain, text/markdown, */*" } });
|
|
819
|
+
const body = await response.text().catch(() => "");
|
|
820
|
+
if (!response.ok) return {
|
|
821
|
+
ok: false,
|
|
822
|
+
status: response.status,
|
|
823
|
+
detail: `${route} returned HTTP ${response.status}.`
|
|
824
|
+
};
|
|
825
|
+
if (body.trim().length === 0) return {
|
|
826
|
+
ok: false,
|
|
827
|
+
status: response.status,
|
|
828
|
+
detail: `${route} returned an empty body.`
|
|
829
|
+
};
|
|
830
|
+
return {
|
|
831
|
+
ok: true,
|
|
832
|
+
status: response.status,
|
|
833
|
+
detail: `${route} returned HTTP ${response.status} with ${body.length} characters.`,
|
|
834
|
+
body,
|
|
835
|
+
linkHeader: response.headers.get("link") ?? void 0
|
|
836
|
+
};
|
|
837
|
+
} catch (error) {
|
|
838
|
+
return {
|
|
839
|
+
ok: false,
|
|
840
|
+
detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function decodeHtmlEntity(value) {
|
|
845
|
+
const named = {
|
|
846
|
+
amp: "&",
|
|
847
|
+
apos: "'",
|
|
848
|
+
gt: ">",
|
|
849
|
+
lt: "<",
|
|
850
|
+
quot: "\""
|
|
851
|
+
};
|
|
852
|
+
return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, raw) => {
|
|
853
|
+
const lower = raw.toLowerCase();
|
|
854
|
+
if (lower.startsWith("#x")) {
|
|
855
|
+
const codePoint = Number.parseInt(lower.slice(2), 16);
|
|
856
|
+
return Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 1114111 ? String.fromCodePoint(codePoint) : entity;
|
|
857
|
+
}
|
|
858
|
+
if (lower.startsWith("#")) {
|
|
859
|
+
const codePoint = Number.parseInt(lower.slice(1), 10);
|
|
860
|
+
return Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 1114111 ? String.fromCodePoint(codePoint) : entity;
|
|
861
|
+
}
|
|
862
|
+
return named[lower] ?? entity;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
function htmlAttribute(tag, name) {
|
|
866
|
+
for (const match of tag.matchAll(/([^\s"'<>/=]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g)) {
|
|
867
|
+
if (match[1]?.toLowerCase() !== name.toLowerCase()) continue;
|
|
868
|
+
return decodeHtmlEntity(match[2] ?? match[3] ?? match[4] ?? "");
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function hasJsonLdScript(html) {
|
|
872
|
+
return /<script\b(?=[^>]*\btype\s*=\s*["']application\/ld\+json["'])[^>]*>/i.test(html);
|
|
873
|
+
}
|
|
874
|
+
function markdownAlternateHref(html) {
|
|
875
|
+
for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
|
|
876
|
+
const tag = match[0];
|
|
877
|
+
const rel = htmlAttribute(tag, "rel") ?? "";
|
|
878
|
+
const type = htmlAttribute(tag, "type") ?? "";
|
|
879
|
+
const href = htmlAttribute(tag, "href");
|
|
880
|
+
const relTokens = rel.toLowerCase().split(/\s+/).filter(Boolean);
|
|
881
|
+
if (href && relTokens.includes("alternate") && /^text\/markdown(?:\s*;|$)/i.test(type.trim())) return href;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function resolveMarkdownAlternateUrl(href, pageUrl) {
|
|
885
|
+
if (!href) return void 0;
|
|
886
|
+
try {
|
|
887
|
+
const url = new URL(href, pageUrl);
|
|
888
|
+
const page = new URL(pageUrl);
|
|
889
|
+
return url.origin === page.origin && url.pathname.endsWith(".md") ? url : void 0;
|
|
890
|
+
} catch {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function canonicalLinkFromHeader(header) {
|
|
895
|
+
if (!header) return void 0;
|
|
896
|
+
for (const match of header.matchAll(/<([^>]+)>\s*((?:;\s*[^,]+)*)/g)) {
|
|
897
|
+
const rel = (match[2] ?? "").match(/(?:^|;)\s*rel\s*=\s*(?:"([^"]*)"|([^;\s,]+))/i);
|
|
898
|
+
if ((rel?.[1] ?? rel?.[2] ?? "").toLowerCase().split(/\s+/).includes("canonical")) return match[1];
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function normalizeCanonicalUrl(value, baseUrl) {
|
|
902
|
+
try {
|
|
903
|
+
const url = new URL(value, baseUrl);
|
|
904
|
+
url.hash = "";
|
|
905
|
+
url.search = "";
|
|
906
|
+
return url.toString().replace(/\/+$/, "");
|
|
907
|
+
} catch {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function hasCanonicalLinkHeader(header, pageUrl, responseUrl) {
|
|
912
|
+
const canonical = canonicalLinkFromHeader(header);
|
|
913
|
+
if (!canonical) return false;
|
|
914
|
+
return normalizeCanonicalUrl(canonical, responseUrl) === normalizeCanonicalUrl(pageUrl, pageUrl);
|
|
915
|
+
}
|
|
916
|
+
async function probeRobotsRoute(baseUrl, route = DEFAULT_ROBOTS_TXT_ROUTE) {
|
|
917
|
+
const url = joinDoctorUrl(baseUrl, route);
|
|
918
|
+
try {
|
|
919
|
+
const response = await fetchWithTimeout(url, { headers: { Accept: "text/plain, */*" } });
|
|
920
|
+
const body = await response.text().catch(() => "");
|
|
921
|
+
if (!response.ok) return {
|
|
922
|
+
ok: false,
|
|
923
|
+
status: response.status,
|
|
924
|
+
detail: `${route} returned HTTP ${response.status}.`
|
|
925
|
+
};
|
|
926
|
+
if (body.trim().length === 0) return {
|
|
927
|
+
ok: false,
|
|
928
|
+
status: response.status,
|
|
929
|
+
detail: `${route} returned an empty body.`
|
|
930
|
+
};
|
|
931
|
+
return {
|
|
932
|
+
ok: true,
|
|
933
|
+
status: response.status,
|
|
934
|
+
body,
|
|
935
|
+
detail: `${route} returned HTTP ${response.status} with ${body.length} characters.`
|
|
936
|
+
};
|
|
937
|
+
} catch (error) {
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async function probeJsonRoute(baseUrl, route) {
|
|
945
|
+
const url = joinDoctorUrl(baseUrl, route);
|
|
946
|
+
try {
|
|
947
|
+
const response = await fetchWithTimeout(url, { headers: { Accept: "application/json" } });
|
|
948
|
+
const text = await response.text().catch(() => "");
|
|
949
|
+
if (!response.ok) return {
|
|
950
|
+
ok: false,
|
|
951
|
+
status: response.status,
|
|
952
|
+
detail: `${route} returned HTTP ${response.status}.`
|
|
953
|
+
};
|
|
954
|
+
try {
|
|
955
|
+
const body = JSON.parse(text);
|
|
956
|
+
return {
|
|
957
|
+
ok: true,
|
|
958
|
+
status: response.status,
|
|
959
|
+
detail: `${route} returned valid JSON.`,
|
|
960
|
+
body
|
|
961
|
+
};
|
|
962
|
+
} catch {
|
|
963
|
+
return {
|
|
964
|
+
ok: false,
|
|
965
|
+
status: response.status,
|
|
966
|
+
detail: `${route} did not return valid JSON.`
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
} catch (error) {
|
|
970
|
+
return {
|
|
971
|
+
ok: false,
|
|
972
|
+
detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
async function parseMcpResponse(response) {
|
|
977
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
978
|
+
const body = await response.text();
|
|
979
|
+
if (contentType.includes("application/json")) return JSON.parse(body);
|
|
980
|
+
const data = body.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).filter(Boolean).at(-1);
|
|
981
|
+
if (!data) throw new Error(`Expected MCP JSON-RPC payload, got ${body.slice(0, 120) || "empty body"}.`);
|
|
982
|
+
return JSON.parse(data);
|
|
983
|
+
}
|
|
984
|
+
async function postMcpJson(baseUrl, route, body, sessionId) {
|
|
985
|
+
return fetchWithTimeout(joinDoctorUrl(baseUrl, route), {
|
|
986
|
+
method: "POST",
|
|
987
|
+
headers: {
|
|
988
|
+
"Content-Type": "application/json",
|
|
989
|
+
Accept: "application/json, text/event-stream",
|
|
990
|
+
"mcp-protocol-version": LATEST_PROTOCOL_VERSION,
|
|
991
|
+
...sessionId ? { "mcp-session-id": sessionId } : {}
|
|
992
|
+
},
|
|
993
|
+
body: JSON.stringify(body)
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
async function probeMcpRoute(baseUrl, route, expectedTools) {
|
|
997
|
+
try {
|
|
998
|
+
const initializeResponse = await postMcpJson(baseUrl, route, {
|
|
999
|
+
jsonrpc: "2.0",
|
|
1000
|
+
id: "doctor-initialize",
|
|
1001
|
+
method: "initialize",
|
|
1002
|
+
params: {
|
|
1003
|
+
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
1004
|
+
capabilities: {},
|
|
1005
|
+
clientInfo: {
|
|
1006
|
+
name: "@farming-labs/docs doctor",
|
|
1007
|
+
version: "0.0.0"
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
if (initializeResponse.status === 401) return await probeProtectedMcpDiscovery(baseUrl, route, initializeResponse);
|
|
1012
|
+
const initializePayload = await parseMcpResponse(initializeResponse);
|
|
1013
|
+
if (!initializeResponse.ok || initializePayload.error) return {
|
|
1014
|
+
ok: false,
|
|
1015
|
+
detail: `${route} initialize returned HTTP ${initializeResponse.status}: ${String(initializePayload.error?.message ?? "unknown MCP error")}.`
|
|
1016
|
+
};
|
|
1017
|
+
const sessionId = initializeResponse.headers.get("mcp-session-id") ?? void 0;
|
|
1018
|
+
if (sessionId) await postMcpJson(baseUrl, route, {
|
|
1019
|
+
jsonrpc: "2.0",
|
|
1020
|
+
method: "notifications/initialized",
|
|
1021
|
+
params: {}
|
|
1022
|
+
}, sessionId).catch(() => void 0);
|
|
1023
|
+
const toolsResponse = await postMcpJson(baseUrl, route, {
|
|
1024
|
+
jsonrpc: "2.0",
|
|
1025
|
+
id: "doctor-tools-list",
|
|
1026
|
+
method: "tools/list",
|
|
1027
|
+
params: {}
|
|
1028
|
+
}, sessionId);
|
|
1029
|
+
const toolsPayload = await parseMcpResponse(toolsResponse);
|
|
1030
|
+
if (sessionId) await fetchWithTimeout(joinDoctorUrl(baseUrl, route), {
|
|
1031
|
+
method: "DELETE",
|
|
1032
|
+
headers: {
|
|
1033
|
+
"mcp-protocol-version": LATEST_PROTOCOL_VERSION,
|
|
1034
|
+
"mcp-session-id": sessionId
|
|
1035
|
+
}
|
|
1036
|
+
}).catch(() => void 0);
|
|
1037
|
+
if (!toolsResponse.ok || toolsPayload.error) return {
|
|
1038
|
+
ok: false,
|
|
1039
|
+
detail: `${route} tools/list returned HTTP ${toolsResponse.status}: ${String(toolsPayload.error?.message ?? "unknown MCP error")}.`
|
|
1040
|
+
};
|
|
1041
|
+
const tools = toolsPayload.result?.tools;
|
|
1042
|
+
const toolNames = Array.isArray(tools) ? tools.map((tool) => tool.name).filter((name) => typeof name === "string") : [];
|
|
1043
|
+
const missingTools = expectedTools.filter((tool) => !toolNames.includes(tool));
|
|
1044
|
+
if (missingTools.length > 0) return {
|
|
1045
|
+
ok: false,
|
|
1046
|
+
detail: `${route} connected but is missing tools: ${missingTools.join(", ")}.`
|
|
1047
|
+
};
|
|
1048
|
+
return {
|
|
1049
|
+
ok: true,
|
|
1050
|
+
detail: `${route} initialized ${sessionId ? "with a session" : "statelessly"} and exposed ${toolNames.length} MCP tool${toolNames.length === 1 ? "" : "s"}.`
|
|
1051
|
+
};
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
return {
|
|
1054
|
+
ok: false,
|
|
1055
|
+
detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
async function probeProtectedMcpDiscovery(baseUrl, route, response) {
|
|
1060
|
+
const bearerChallenge = findBearerChallenge(response.headers.get("www-authenticate") ?? "");
|
|
1061
|
+
const rawMetadataUrl = bearerChallenge ? readHttpAuthQuotedParameter(bearerChallenge, "resource_metadata") : void 0;
|
|
1062
|
+
if (!bearerChallenge || !rawMetadataUrl) return {
|
|
1063
|
+
ok: false,
|
|
1064
|
+
detail: `${route} requires authentication but did not return a Bearer resource_metadata challenge.`
|
|
1065
|
+
};
|
|
1066
|
+
const resourceUrl = new URL(joinDoctorUrl(baseUrl, route));
|
|
1067
|
+
let metadataUrl;
|
|
1068
|
+
try {
|
|
1069
|
+
metadataUrl = new URL(rawMetadataUrl);
|
|
1070
|
+
} catch {
|
|
1071
|
+
return {
|
|
1072
|
+
ok: false,
|
|
1073
|
+
detail: `${route} returned an invalid resource_metadata URL.`
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
if (metadataUrl.origin !== resourceUrl.origin) return {
|
|
1077
|
+
ok: false,
|
|
1078
|
+
detail: `${route} returned cross-origin protected-resource metadata; the hosted doctor will not fetch it.`
|
|
1079
|
+
};
|
|
1080
|
+
let metadataResponse;
|
|
1081
|
+
try {
|
|
1082
|
+
metadataResponse = await fetchWithTimeout(metadataUrl.href, {
|
|
1083
|
+
headers: { Accept: "application/json" },
|
|
1084
|
+
redirect: "manual"
|
|
1085
|
+
});
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
return {
|
|
1088
|
+
ok: false,
|
|
1089
|
+
detail: `${route} protected-resource metadata failed: ${error instanceof Error ? error.message : String(error)}.`
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
if (metadataResponse.status !== 200) return {
|
|
1093
|
+
ok: false,
|
|
1094
|
+
detail: `${route} protected-resource metadata returned HTTP ${metadataResponse.status}.`
|
|
1095
|
+
};
|
|
1096
|
+
if (responseMediaType(metadataResponse.headers.get("content-type")) !== "application/json") return {
|
|
1097
|
+
ok: false,
|
|
1098
|
+
detail: `${route} protected-resource metadata did not return application/json.`
|
|
1099
|
+
};
|
|
1100
|
+
let metadata;
|
|
1101
|
+
try {
|
|
1102
|
+
const metadataRecord = asRecord(await metadataResponse.json());
|
|
1103
|
+
if (!metadataRecord) return {
|
|
1104
|
+
ok: false,
|
|
1105
|
+
detail: `${route} protected-resource metadata must be a JSON object.`
|
|
1106
|
+
};
|
|
1107
|
+
metadata = metadataRecord;
|
|
1108
|
+
} catch {
|
|
1109
|
+
return {
|
|
1110
|
+
ok: false,
|
|
1111
|
+
detail: `${route} protected-resource metadata is not valid JSON.`
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
if (metadata.resource !== resourceUrl.href) return {
|
|
1115
|
+
ok: false,
|
|
1116
|
+
detail: `${route} protected-resource metadata identifies ${String(metadata.resource ?? "no resource")} instead of ${resourceUrl.href}.`
|
|
1117
|
+
};
|
|
1118
|
+
const authorizationServers = metadata.authorization_servers;
|
|
1119
|
+
if (!Array.isArray(authorizationServers) || authorizationServers.length === 0 || normalizeDocsMcpAuthorizationServerUrls(authorizationServers.filter((issuer) => typeof issuer === "string")).length !== authorizationServers.length) return {
|
|
1120
|
+
ok: false,
|
|
1121
|
+
detail: `${route} protected-resource metadata is missing valid authorization_servers.`
|
|
1122
|
+
};
|
|
1123
|
+
const scopesSupported = metadata.scopes_supported;
|
|
1124
|
+
if (scopesSupported !== void 0 && (!Array.isArray(scopesSupported) || scopesSupported.some((scope) => !isDocsMcpOAuthScopeToken(scope)))) return {
|
|
1125
|
+
ok: false,
|
|
1126
|
+
detail: `${route} protected-resource metadata has invalid scopes_supported.`
|
|
1127
|
+
};
|
|
1128
|
+
const challengeScope = readHttpAuthQuotedParameter(bearerChallenge, "scope");
|
|
1129
|
+
if (challengeScope !== void 0 && challengeScope.split(" ").some((scope) => !isDocsMcpOAuthScopeToken(scope))) return {
|
|
1130
|
+
ok: false,
|
|
1131
|
+
detail: `${route} returned an invalid Bearer scope challenge.`
|
|
1132
|
+
};
|
|
1133
|
+
return {
|
|
1134
|
+
ok: true,
|
|
1135
|
+
detail: `${route} is protected and exposes valid RFC 9728 metadata at ${metadataUrl.pathname}.`
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
function findBearerChallenge(header) {
|
|
1139
|
+
let first = 0;
|
|
1140
|
+
while (first < header.length && /\s/u.test(header[first] ?? "")) first += 1;
|
|
1141
|
+
const starts = [first];
|
|
1142
|
+
let quoted = false;
|
|
1143
|
+
let escaped = false;
|
|
1144
|
+
for (let index = 0; index < header.length; index += 1) {
|
|
1145
|
+
const character = header[index];
|
|
1146
|
+
if (escaped) {
|
|
1147
|
+
escaped = false;
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
if (quoted && character === "\\") {
|
|
1151
|
+
escaped = true;
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
if (character === "\"") {
|
|
1155
|
+
quoted = !quoted;
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
if (quoted || character !== ",") continue;
|
|
1159
|
+
let candidate = index + 1;
|
|
1160
|
+
while (candidate < header.length && /\s/u.test(header[candidate] ?? "")) candidate += 1;
|
|
1161
|
+
const tokenStart = candidate;
|
|
1162
|
+
while (candidate < header.length && /[!#$%&'*+\-.^_`|~0-9A-Za-z]/u.test(header[candidate] ?? "")) candidate += 1;
|
|
1163
|
+
if (candidate === tokenStart) continue;
|
|
1164
|
+
let afterToken = candidate;
|
|
1165
|
+
while (afterToken < header.length && /\s/u.test(header[afterToken] ?? "")) afterToken += 1;
|
|
1166
|
+
if (header[afterToken] !== "=") starts.push(tokenStart);
|
|
1167
|
+
}
|
|
1168
|
+
for (let index = 0; index < starts.length; index += 1) {
|
|
1169
|
+
const start = starts[index] ?? 0;
|
|
1170
|
+
if ((/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+/u.exec(header.slice(start))?.[0])?.toLowerCase() !== "bearer") continue;
|
|
1171
|
+
const end = starts[index + 1] ?? header.length;
|
|
1172
|
+
return header.slice(start, end).replace(/,\s*$/u, "").trim();
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
function readHttpAuthQuotedParameter(header, name) {
|
|
1176
|
+
return (new RegExp(`(?:^|[,\\s])${name}\\s*=\\s*"((?:\\\\.|[^"\\\\])*)"`, "i").exec(header)?.[1])?.replace(/\\(.)/g, "$1");
|
|
1177
|
+
}
|
|
1178
|
+
async function probeMcpRouteCandidates(baseUrl, routes, expectedTools) {
|
|
1179
|
+
const candidates = buildDocsMcpEndpointCandidates(baseUrl, routes);
|
|
1180
|
+
const probes = await Promise.all(candidates.map(async (candidate) => {
|
|
1181
|
+
const probe = await probeMcpRoute(candidate.baseUrl, candidate.route, expectedTools);
|
|
1182
|
+
return {
|
|
1183
|
+
...probe,
|
|
1184
|
+
detail: `${candidate.label}: ${probe.detail}`
|
|
1185
|
+
};
|
|
1186
|
+
}));
|
|
1187
|
+
return {
|
|
1188
|
+
labels: candidates.map((candidate) => candidate.label),
|
|
1189
|
+
probes
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
function asRecord(value) {
|
|
1193
|
+
return value && typeof value === "object" ? value : void 0;
|
|
1194
|
+
}
|
|
1195
|
+
function isNonEmptyString(value) {
|
|
1196
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1197
|
+
}
|
|
1198
|
+
function isBooleanRecord(value) {
|
|
1199
|
+
const record = asRecord(value);
|
|
1200
|
+
return Boolean(record && Object.keys(record).length > 0 && Object.values(record).every((item) => typeof item === "boolean"));
|
|
1201
|
+
}
|
|
1202
|
+
function isHostedAgentDiscoveryManifest(value) {
|
|
1203
|
+
const root = asRecord(value);
|
|
1204
|
+
const site = asRecord(root?.site);
|
|
1205
|
+
const capabilities = asRecord(root?.capabilities);
|
|
1206
|
+
const api = asRecord(root?.api);
|
|
1207
|
+
const apiCatalog = asRecord(root?.apiCatalog);
|
|
1208
|
+
const config = asRecord(root?.config);
|
|
1209
|
+
const search = asRecord(root?.search);
|
|
1210
|
+
const skillsDiscovery = asRecord(asRecord(root?.skills)?.discovery);
|
|
1211
|
+
const mcp = asRecord(root?.mcp);
|
|
1212
|
+
const agentContractFields = asRecord(asRecord(root?.agentContract)?.fields);
|
|
1213
|
+
const apiRoute = isNonEmptyString(api?.docs) ? resolveDocsDiscoveryApiRoute(api.docs) : void 0;
|
|
1214
|
+
return Boolean(root && isNonEmptyString(root.version) && isNonEmptyString(root.name) && isNonEmptyString(root.baseUrl) && isNonEmptyString(site?.entry) && typeof capabilities?.search === "boolean" && typeof capabilities?.mcp === "boolean" && capabilities?.apiCatalog === true && capabilities?.agentSkillsDiscovery === true && apiRoute && api?.docs === apiRoute && api?.config === `${apiRoute}?format=config` && api?.apiCatalog === DEFAULT_API_CATALOG_ROUTE && api?.apiCatalogQuery === `${apiRoute}?format=${DEFAULT_API_CATALOG_FORMAT}` && api?.agentSkillsIndex === DEFAULT_AGENT_SKILLS_INDEX_ROUTE && apiCatalog?.enabled === true && apiCatalog?.route === DEFAULT_API_CATALOG_ROUTE && apiCatalog?.api === `${apiRoute}?format=${DEFAULT_API_CATALOG_FORMAT}` && apiCatalog?.mediaType === API_CATALOG_MEDIA_TYPE && apiCatalog?.profile === API_CATALOG_PROFILE_URI && skillsDiscovery?.schema === AGENT_SKILLS_DISCOVERY_SCHEMA_URI && skillsDiscovery?.index === DEFAULT_AGENT_SKILLS_INDEX_ROUTE && skillsDiscovery?.artifact === DEFAULT_AGENT_SKILLS_ROUTE_PATTERN && skillsDiscovery?.apiIndex === `${apiRoute}?format=${DEFAULT_AGENT_SKILLS_INDEX_FORMAT}` && skillsDiscovery?.apiArtifact === `${apiRoute}?format=${DEFAULT_AGENT_SKILL_FORMAT}&name={name}` && skillsDiscovery?.digest === "sha256" && config?.endpoint === `${apiRoute}?format=config` && typeof search?.enabled === "boolean" && isNonEmptyString(search?.endpoint) && typeof mcp?.enabled === "boolean" && isNonEmptyString(mcp?.endpoint) && isBooleanRecord(mcp?.tools) && agentContractFields && Object.keys(agentContractFields).length > 0);
|
|
1215
|
+
}
|
|
1216
|
+
function responseMediaType(contentType) {
|
|
1217
|
+
return contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
1218
|
+
}
|
|
1219
|
+
function contentTypeParameter(contentType, name) {
|
|
1220
|
+
if (!contentType) return void 0;
|
|
1221
|
+
const parameterPattern = new RegExp(`(?:^|;)\\s*${name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*=\\s*(?:"([^"]*)"|([^;\\s]+))`, "i");
|
|
1222
|
+
const match = contentType.match(parameterPattern);
|
|
1223
|
+
return match?.[1] ?? match?.[2];
|
|
1224
|
+
}
|
|
1225
|
+
function isApiCatalogLinkset(value) {
|
|
1226
|
+
const root = asRecord(value);
|
|
1227
|
+
if (!Array.isArray(root?.linkset) || root.linkset.length === 0) return false;
|
|
1228
|
+
let apiLinks = 0;
|
|
1229
|
+
for (const rawContext of root.linkset) {
|
|
1230
|
+
const context = asRecord(rawContext);
|
|
1231
|
+
if (!isNonEmptyString(context?.anchor)) return false;
|
|
1232
|
+
if (!Array.isArray(context.item)) continue;
|
|
1233
|
+
for (const rawItem of context.item) {
|
|
1234
|
+
if (!isNonEmptyString(asRecord(rawItem)?.href)) return false;
|
|
1235
|
+
apiLinks += 1;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return apiLinks > 0;
|
|
1239
|
+
}
|
|
1240
|
+
async function probeApiCatalogRoute(baseUrl) {
|
|
1241
|
+
const route = DEFAULT_API_CATALOG_ROUTE;
|
|
1242
|
+
const url = joinDoctorUrl(baseUrl, route);
|
|
1243
|
+
try {
|
|
1244
|
+
const [getResponse, headResponse] = await Promise.all([fetchWithTimeout(url, { headers: { Accept: API_CATALOG_MEDIA_TYPE } }), fetchWithTimeout(url, {
|
|
1245
|
+
method: "HEAD",
|
|
1246
|
+
headers: { Accept: API_CATALOG_MEDIA_TYPE }
|
|
1247
|
+
})]);
|
|
1248
|
+
const text = await getResponse.text().catch(() => "");
|
|
1249
|
+
let body;
|
|
1250
|
+
try {
|
|
1251
|
+
body = JSON.parse(text);
|
|
1252
|
+
} catch {
|
|
1253
|
+
body = void 0;
|
|
1254
|
+
}
|
|
1255
|
+
const failures = [];
|
|
1256
|
+
if (!getResponse.ok) failures.push(`GET returned HTTP ${getResponse.status}`);
|
|
1257
|
+
if (!headResponse.ok) failures.push(`HEAD returned HTTP ${headResponse.status}`);
|
|
1258
|
+
for (const [method, response] of [["GET", getResponse], ["HEAD", headResponse]]) {
|
|
1259
|
+
const contentType = response.headers.get("content-type");
|
|
1260
|
+
if (responseMediaType(contentType) !== API_CATALOG_MEDIA_TYPE) failures.push(`${method} Content-Type is ${JSON.stringify(contentType ?? "<missing>")}`);
|
|
1261
|
+
else if (contentTypeParameter(contentType, "profile") !== API_CATALOG_PROFILE_URI) failures.push(`${method} Content-Type is missing the RFC 9727 profile`);
|
|
1262
|
+
if (!httpLinkHeaderHasTargetRelation(response.headers.get("link"), DEFAULT_API_CATALOG_ROUTE, "api-catalog", url)) failures.push(`${method} is missing an ${DEFAULT_API_CATALOG_ROUTE} rel="api-catalog" Link value`);
|
|
1263
|
+
}
|
|
1264
|
+
if (!isApiCatalogLinkset(body)) failures.push("GET did not return a JSON Linkset with at least one API item");
|
|
1265
|
+
return failures.length === 0 ? {
|
|
1266
|
+
ok: true,
|
|
1267
|
+
detail: `${route} passed RFC 9727 GET and HEAD checks with a profiled JSON Linkset and api-catalog Link headers.`
|
|
1268
|
+
} : {
|
|
1269
|
+
ok: false,
|
|
1270
|
+
detail: `${route} failed standards validation: ${failures.join("; ")}.`
|
|
1271
|
+
};
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
return {
|
|
1274
|
+
ok: false,
|
|
1275
|
+
detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
function isValidAgentSkillEntry(value) {
|
|
1280
|
+
const entry = asRecord(value);
|
|
1281
|
+
return Boolean(entry && typeof entry.name === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(entry.name) && entry.name.length <= 64 && (entry.type === "skill-md" || entry.type === "archive") && isNonEmptyString(entry.description) && entry.description.length <= 1024 && isNonEmptyString(entry.url) && typeof entry.digest === "string" && /^sha256:[0-9a-f]{64}$/u.test(entry.digest));
|
|
1282
|
+
}
|
|
1283
|
+
async function probeAgentSkillsDiscovery(baseUrl) {
|
|
1284
|
+
const route = DEFAULT_AGENT_SKILLS_INDEX_ROUTE;
|
|
1285
|
+
const indexUrl = joinDoctorUrl(baseUrl, route);
|
|
1286
|
+
try {
|
|
1287
|
+
const [indexResponse, indexHeadResponse] = await Promise.all([fetchWithTimeout(indexUrl, { headers: { Accept: "application/json" } }), fetchWithTimeout(indexUrl, {
|
|
1288
|
+
method: "HEAD",
|
|
1289
|
+
headers: { Accept: "application/json" }
|
|
1290
|
+
})]);
|
|
1291
|
+
const text = await indexResponse.text().catch(() => "");
|
|
1292
|
+
const failures = [];
|
|
1293
|
+
let body;
|
|
1294
|
+
try {
|
|
1295
|
+
body = JSON.parse(text);
|
|
1296
|
+
} catch {
|
|
1297
|
+
body = void 0;
|
|
1298
|
+
}
|
|
1299
|
+
if (!indexResponse.ok) failures.push(`index returned HTTP ${indexResponse.status}`);
|
|
1300
|
+
if (!indexHeadResponse.ok) failures.push(`index HEAD returned HTTP ${indexHeadResponse.status}`);
|
|
1301
|
+
if (responseMediaType(indexResponse.headers.get("content-type")) !== "application/json") failures.push("index Content-Type is not application/json");
|
|
1302
|
+
if (responseMediaType(indexHeadResponse.headers.get("content-type")) !== "application/json") failures.push("index HEAD Content-Type is not application/json");
|
|
1303
|
+
const root = asRecord(body);
|
|
1304
|
+
if (root?.$schema !== AGENT_SKILLS_DISCOVERY_SCHEMA_URI) failures.push("index $schema is not the Agent Skills discovery 0.2.0 schema");
|
|
1305
|
+
const skills = Array.isArray(root?.skills) ? root.skills : [];
|
|
1306
|
+
if (skills.length === 0) failures.push("index does not publish any skills");
|
|
1307
|
+
if (skills.length > 100) failures.push("index publishes more than 100 skills");
|
|
1308
|
+
const validSkills = skills.filter(isValidAgentSkillEntry);
|
|
1309
|
+
if (validSkills.length !== skills.length) failures.push("one or more index entries have invalid name, type, description, URL, or digest fields");
|
|
1310
|
+
if (new Set(validSkills.map((skill) => skill.name)).size !== validSkills.length) failures.push("index contains duplicate skill names");
|
|
1311
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
1312
|
+
const artifactDetails = [];
|
|
1313
|
+
if (skills.length <= 100) for (const skill of validSkills) {
|
|
1314
|
+
let artifactUrl;
|
|
1315
|
+
try {
|
|
1316
|
+
artifactUrl = new URL(skill.url, indexUrl);
|
|
1317
|
+
} catch {
|
|
1318
|
+
failures.push(`${skill.name} has an invalid artifact URL`);
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
const expectedPath = skill.type === "archive" ? `${DEFAULT_AGENT_SKILLS_ROUTE_PREFIX}/${skill.name}.tar.gz` : DEFAULT_AGENT_SKILLS_ROUTE_PATTERN.replace("{name}", skill.name);
|
|
1322
|
+
const expectedMediaType = skill.type === "archive" ? "application/gzip" : "text/markdown";
|
|
1323
|
+
if (artifactUrl.origin !== baseOrigin) {
|
|
1324
|
+
failures.push(`${skill.name} artifact is not same-origin`);
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
if (!artifactUrl.pathname.endsWith(expectedPath)) {
|
|
1328
|
+
failures.push(`${skill.name} artifact URL does not match ${expectedPath}`);
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
const [artifactResponse, artifactHeadResponse] = await Promise.all([fetchWithTimeout(artifactUrl.toString(), { headers: { Accept: expectedMediaType } }), fetchWithTimeout(artifactUrl.toString(), {
|
|
1333
|
+
method: "HEAD",
|
|
1334
|
+
headers: { Accept: expectedMediaType }
|
|
1335
|
+
})]);
|
|
1336
|
+
const content = Buffer.from(await artifactResponse.arrayBuffer());
|
|
1337
|
+
const computedDigest = `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
1338
|
+
if (!artifactResponse.ok) failures.push(`${skill.name} artifact returned HTTP ${artifactResponse.status}`);
|
|
1339
|
+
else if (!artifactHeadResponse.ok) failures.push(`${skill.name} artifact HEAD returned HTTP ${artifactHeadResponse.status}`);
|
|
1340
|
+
else if (responseMediaType(artifactResponse.headers.get("content-type")) !== expectedMediaType) failures.push(`${skill.name} artifact Content-Type is not ${expectedMediaType}`);
|
|
1341
|
+
else if (responseMediaType(artifactHeadResponse.headers.get("content-type")) !== expectedMediaType) failures.push(`${skill.name} artifact HEAD Content-Type is not ${expectedMediaType}`);
|
|
1342
|
+
else if (content.length === 0) failures.push(`${skill.name} artifact is empty`);
|
|
1343
|
+
else if (computedDigest !== skill.digest) failures.push(`${skill.name} artifact digest does not match the index`);
|
|
1344
|
+
else artifactDetails.push(`${skill.name} digest verified`);
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
failures.push(`${skill.name} artifact failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return failures.length === 0 ? {
|
|
1350
|
+
ok: true,
|
|
1351
|
+
detail: `${route} returned a valid Agent Skills index; ${artifactDetails.join(", ")}.`
|
|
1352
|
+
} : {
|
|
1353
|
+
ok: false,
|
|
1354
|
+
detail: `${route} failed standards validation: ${failures.join("; ")}.`
|
|
1355
|
+
};
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
return {
|
|
1358
|
+
ok: false,
|
|
1359
|
+
detail: `${route} failed: ${error instanceof Error ? error.message : String(error)}.`
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
function readDiscoveryRoute(value) {
|
|
1364
|
+
return typeof value === "string" && value.startsWith("/") ? value : void 0;
|
|
1365
|
+
}
|
|
1366
|
+
function hostedSitemapRoutes(discoveryBody) {
|
|
1367
|
+
const sitemap = asRecord(asRecord(discoveryBody)?.sitemap);
|
|
1368
|
+
if (sitemap?.enabled === false) return {
|
|
1369
|
+
enabled: false,
|
|
1370
|
+
routes: []
|
|
1371
|
+
};
|
|
1372
|
+
const xml = asRecord(sitemap?.xml);
|
|
1373
|
+
const markdown = asRecord(sitemap?.markdown);
|
|
1374
|
+
const routes = [
|
|
1375
|
+
xml?.enabled === false ? void 0 : readDiscoveryRoute(xml?.route) ?? DEFAULT_SITEMAP_XML_ROUTE,
|
|
1376
|
+
markdown?.enabled === false ? void 0 : readDiscoveryRoute(markdown?.route) ?? DEFAULT_SITEMAP_MD_ROUTE,
|
|
1377
|
+
markdown?.enabled === false ? void 0 : readDiscoveryRoute(markdown?.docsRoute),
|
|
1378
|
+
markdown?.enabled === false ? void 0 : readDiscoveryRoute(markdown?.wellKnownRoute) ?? DEFAULT_SITEMAP_MD_WELL_KNOWN_ROUTE
|
|
1379
|
+
].filter((route) => typeof route === "string");
|
|
1380
|
+
return {
|
|
1381
|
+
enabled: true,
|
|
1382
|
+
routes: Array.from(new Set(routes))
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
function hostedRobotsRoute(discoveryBody) {
|
|
1386
|
+
const robots = asRecord(asRecord(discoveryBody)?.robots);
|
|
1387
|
+
return {
|
|
1388
|
+
enabled: robots?.enabled === false ? false : true,
|
|
1389
|
+
route: readDiscoveryRoute(robots?.route) ?? DEFAULT_ROBOTS_TXT_ROUTE
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
function hostedMcpRoutes(discoveryBody) {
|
|
1393
|
+
const mcp = asRecord(asRecord(discoveryBody)?.mcp);
|
|
1394
|
+
const publicEndpoints = mcp?.publicEndpoints ?? mcp?.endpoints;
|
|
1395
|
+
const declaredRoutes = Array.isArray(publicEndpoints) ? publicEndpoints.filter((value) => typeof value === "string" && value.startsWith("/")) : [];
|
|
1396
|
+
if (declaredRoutes.length > 0) return Array.from(new Set(declaredRoutes));
|
|
1397
|
+
return Array.from(new Set([readDiscoveryRoute(mcp?.publicEndpoint) ?? DEFAULT_MCP_PUBLIC_ROUTE, readDiscoveryRoute(mcp?.wellKnownEndpoint) ?? DEFAULT_MCP_WELL_KNOWN_ROUTE]));
|
|
1398
|
+
}
|
|
1399
|
+
const MCP_DISCOVERY_TOOL_NAMES = [
|
|
1400
|
+
["listDocs", "list_docs"],
|
|
1401
|
+
["listPages", "list_pages"],
|
|
1402
|
+
["listTasks", "list_tasks"],
|
|
1403
|
+
["readTask", "read_task"],
|
|
1404
|
+
["getNavigation", "get_navigation"],
|
|
1405
|
+
["searchDocs", "search_docs"],
|
|
1406
|
+
["readPage", "read_page"],
|
|
1407
|
+
["getCodeExamples", "get_code_examples"],
|
|
1408
|
+
["getConfigSchema", "get_config_schema"],
|
|
1409
|
+
["getContext", "get_context"]
|
|
1410
|
+
];
|
|
1411
|
+
function hostedMcpExpectedTools(discoveryBody) {
|
|
1412
|
+
const tools = asRecord(asRecord(asRecord(discoveryBody)?.mcp)?.tools);
|
|
1413
|
+
return MCP_DISCOVERY_TOOL_NAMES.filter(([flag]) => tools?.[flag] !== false).map(([, name]) => name);
|
|
1414
|
+
}
|
|
1415
|
+
function hostedCapability(discoveryBody, key) {
|
|
1416
|
+
const root = asRecord(discoveryBody);
|
|
1417
|
+
const capability = asRecord(root?.capabilities)?.[key];
|
|
1418
|
+
if (typeof capability === "boolean") return capability;
|
|
1419
|
+
const enabled = asRecord(root?.[key])?.enabled;
|
|
1420
|
+
return typeof enabled === "boolean" ? enabled : void 0;
|
|
1421
|
+
}
|
|
1422
|
+
function hostedRootDocsRoute(discoveryBody) {
|
|
1423
|
+
const site = asRecord(asRecord(discoveryBody)?.site);
|
|
1424
|
+
return `/${(typeof site?.entry === "string" && site.entry.trim() ? site.entry.trim() : "docs").replace(/^\/+|\/+$/g, "") || "docs"}`;
|
|
1425
|
+
}
|
|
1426
|
+
function hostedPageUrl(baseUrl, pageRoute) {
|
|
1427
|
+
try {
|
|
1428
|
+
const base = new URL(baseUrl);
|
|
1429
|
+
const parsed = new URL(pageRoute, base.origin);
|
|
1430
|
+
if (parsed.origin !== base.origin) return void 0;
|
|
1431
|
+
parsed.hash = "";
|
|
1432
|
+
parsed.search = "";
|
|
1433
|
+
const basePath = base.pathname.replace(/\/+$/, "");
|
|
1434
|
+
const pagePath = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
1435
|
+
const pathname = !basePath || pagePath === basePath || pagePath.startsWith(`${basePath}/`) ? pagePath : `${basePath}${pagePath}`;
|
|
1436
|
+
return new URL(pathname || "/", base.origin).toString().replace(/\/+$/, "");
|
|
1437
|
+
} catch {
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
function sampleHostedPageUrls(baseUrl, discoveryBody, pages, limit = 10) {
|
|
1442
|
+
const pageRoutes = pages.map((page) => page.url).filter((route) => route.startsWith("/") && !route.endsWith(".md"));
|
|
1443
|
+
const fallback = hostedRootDocsRoute(discoveryBody);
|
|
1444
|
+
const unique = Array.from(new Set(pageRoutes.length > 0 ? pageRoutes : [fallback])).sort();
|
|
1445
|
+
return (unique.length <= limit ? unique : Array.from({ length: limit }, (_, index) => unique[Math.floor(index * (unique.length / limit))])).map((route) => hostedPageUrl(baseUrl, route)).filter((url) => typeof url === "string");
|
|
1446
|
+
}
|
|
1447
|
+
async function probeHostedHtmlPage(url) {
|
|
1448
|
+
try {
|
|
1449
|
+
const response = await fetchWithTimeout(url, { headers: { Accept: "text/html, */*" } });
|
|
1450
|
+
const body = await response.text().catch(() => "");
|
|
1451
|
+
const pathname = new URL(url).pathname;
|
|
1452
|
+
if (!response.ok) return {
|
|
1453
|
+
ok: false,
|
|
1454
|
+
detail: `${pathname} returned HTTP ${response.status}.`,
|
|
1455
|
+
hasJsonLd: false,
|
|
1456
|
+
hasMarkdownAlternate: false
|
|
1457
|
+
};
|
|
1458
|
+
const alternateUrl = resolveMarkdownAlternateUrl(markdownAlternateHref(body), url);
|
|
1459
|
+
return {
|
|
1460
|
+
ok: true,
|
|
1461
|
+
detail: `${pathname} returned HTML with ${body.length} characters.`,
|
|
1462
|
+
hasJsonLd: hasJsonLdScript(body),
|
|
1463
|
+
hasMarkdownAlternate: Boolean(alternateUrl)
|
|
1464
|
+
};
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
return {
|
|
1467
|
+
ok: false,
|
|
1468
|
+
detail: `${url} failed: ${error instanceof Error ? error.message : String(error)}.`,
|
|
1469
|
+
hasJsonLd: false,
|
|
1470
|
+
hasMarkdownAlternate: false
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
function hostedSurfaceScore(probes, predicate) {
|
|
1475
|
+
const total = probes.length;
|
|
1476
|
+
const passed = probes.filter((probe) => probe.ok && predicate(probe)).length;
|
|
1477
|
+
if (total === 0) return {
|
|
1478
|
+
status: "warn",
|
|
1479
|
+
score: 0,
|
|
1480
|
+
passed: 0,
|
|
1481
|
+
total
|
|
1482
|
+
};
|
|
1483
|
+
if (passed === total) return {
|
|
1484
|
+
status: "pass",
|
|
1485
|
+
score: 5,
|
|
1486
|
+
passed,
|
|
1487
|
+
total
|
|
1488
|
+
};
|
|
1489
|
+
if (passed > 0) return {
|
|
1490
|
+
status: "warn",
|
|
1491
|
+
score: Math.round(passed / total * 5),
|
|
1492
|
+
passed,
|
|
1493
|
+
total
|
|
1494
|
+
};
|
|
1495
|
+
return {
|
|
1496
|
+
status: "fail",
|
|
1497
|
+
score: 0,
|
|
1498
|
+
passed,
|
|
1499
|
+
total
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
async function buildHostedAgentChecks(url, pages) {
|
|
1503
|
+
let baseUrl;
|
|
1504
|
+
try {
|
|
1505
|
+
baseUrl = normalizeDoctorBaseUrl(url);
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
return { checks: [makeCheck("hosted-url", "Hosted URL", "fail", 0, 5, `Could not parse --url "${url}": ${error instanceof Error ? error.message : String(error)}`, "Pass a full hosted URL such as https://docs.example.com.")] };
|
|
1508
|
+
}
|
|
1509
|
+
const checks = [];
|
|
1510
|
+
const discovery = await probeJsonRoute(baseUrl, DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE);
|
|
1511
|
+
const discoveryManifestValid = discovery.ok && isHostedAgentDiscoveryManifest(discovery.body);
|
|
1512
|
+
checks.push(makeCheck("hosted-agent-discovery", "Hosted agent discovery", discoveryManifestValid ? "pass" : "fail", discoveryManifestValid ? 5 : 0, 5, `${baseUrl}: ${discovery.ok && !discoveryManifestValid ? `${discovery.detail} The payload is not a complete agent discovery manifest.` : discovery.detail}`, discoveryManifestValid ? void 0 : `Make sure ${DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE} returns the complete agent discovery manifest from the shared docs API.`));
|
|
1513
|
+
const [apiCatalog, agentSkills] = await Promise.all([probeApiCatalogRoute(baseUrl), probeAgentSkillsDiscovery(baseUrl)]);
|
|
1514
|
+
checks.push(makeCheck("hosted-api-catalog", "Hosted RFC 9727 API catalog", apiCatalog.ok ? "pass" : "fail", apiCatalog.ok ? 5 : 0, 5, apiCatalog.detail, apiCatalog.ok ? void 0 : `Publish ${DEFAULT_API_CATALOG_ROUTE} with GET and HEAD support, the profiled ${API_CATALOG_MEDIA_TYPE} content type, a rel="api-catalog" Link header, and API links.`));
|
|
1515
|
+
checks.push(makeCheck("hosted-agent-skills", "Hosted Agent Skills discovery", agentSkills.ok ? "pass" : "fail", agentSkills.ok ? 5 : 0, 5, agentSkills.detail, agentSkills.ok ? void 0 : `Publish ${DEFAULT_AGENT_SKILLS_INDEX_ROUTE} and make every indexed SKILL.md artifact match its declared SHA-256 digest.`));
|
|
1516
|
+
const llms = await Promise.all([probeTextRoute(baseUrl, DEFAULT_LLMS_TXT_ROUTE), probeTextRoute(baseUrl, DEFAULT_LLMS_FULL_TXT_ROUTE)]);
|
|
1517
|
+
const llmsPassed = llms.filter((result) => result.ok).length;
|
|
1518
|
+
checks.push(makeCheck("hosted-llms", "Hosted llms.txt", llmsPassed === llms.length ? "pass" : llmsPassed > 0 ? "warn" : "fail", llmsPassed === llms.length ? 5 : llmsPassed > 0 ? 3 : 0, 5, llms.map((result) => result.detail).join(" "), llmsPassed === llms.length ? void 0 : "Verify deployed /llms.txt and /llms-full.txt routes return non-empty text."));
|
|
1519
|
+
const sitemapRoutes = hostedSitemapRoutes(discovery.body);
|
|
1520
|
+
if (sitemapRoutes.enabled && sitemapRoutes.routes.length > 0) {
|
|
1521
|
+
const sitemap = await Promise.all(sitemapRoutes.routes.map((route) => probeTextRoute(baseUrl, route)));
|
|
1522
|
+
const sitemapPassed = sitemap.filter((result) => result.ok).length;
|
|
1523
|
+
checks.push(makeCheck("hosted-sitemap", "Hosted sitemap", sitemapPassed === sitemap.length ? "pass" : sitemapPassed > 0 ? "warn" : "fail", sitemapPassed === sitemap.length ? 5 : sitemapPassed > 0 ? 3 : 0, 5, sitemap.map((result) => result.detail).join(" "), sitemapPassed === sitemap.length ? void 0 : `Verify deployed sitemap routes return non-empty text: ${sitemapRoutes.routes.join(", ")}.`));
|
|
1524
|
+
} else if (sitemapRoutes.enabled) checks.push(makeCheck("hosted-sitemap", "Hosted sitemap", "warn", 0, 5, "The hosted discovery spec reports sitemap support but did not expose sitemap routes.", "Check sitemap.xml and sitemap.markdown config so at least one sitemap route is enabled."));
|
|
1525
|
+
else checks.push(makeCheck("hosted-sitemap", "Hosted sitemap", "warn", 0, 5, "The hosted discovery spec reports sitemap routes as disabled.", "Enable sitemap in docs.config when agents and crawlers should discover canonical URLs and freshness metadata."));
|
|
1526
|
+
const robotsRoute = hostedRobotsRoute(discovery.body);
|
|
1527
|
+
if (robotsRoute.enabled) {
|
|
1528
|
+
const robots = await probeRobotsRoute(baseUrl, robotsRoute.route);
|
|
1529
|
+
const robotsAnalysis = robots.body ? analyzeDocsRobotsTxt(robots.body) : void 0;
|
|
1530
|
+
const robotsBlocked = robotsAnalysis?.blocksAgentRoutes || robotsAnalysis?.blocksAiAgents;
|
|
1531
|
+
const robotsComplete = robotsAnalysis?.hasAgentRoutes && robotsAnalysis?.hasAiPolicy;
|
|
1532
|
+
checks.push(makeCheck("hosted-robots", "Hosted robots.txt", robots.ok && !robotsBlocked && robotsComplete ? "pass" : robots.ok && !robotsBlocked ? "warn" : "fail", robots.ok && !robotsBlocked && robotsComplete ? 5 : robots.ok && !robotsBlocked ? 3 : 0, 5, robots.ok ? robotsBlocked ? `${robotsRoute.route} is reachable but blocks ${robotsAnalysis?.blocksAiAgents ? "common AI crawlers" : "agent-readable docs routes"}.` : robotsComplete ? `${robots.detail} It advertises agent-readable routes and common AI crawler policy.` : `${robots.detail} It is missing ${robotsAnalysis?.missingRoutes.length ? `agent routes (${robotsAnalysis.missingRoutes.join(", ")})` : "common AI crawler policy"}.` : robots.detail, robots.ok && !robotsBlocked && robotsComplete ? void 0 : "Publish an agent-friendly robots.txt with `docs robots generate`, or append the generated block to the existing file."));
|
|
1533
|
+
} else checks.push(makeCheck("hosted-robots", "Hosted robots.txt", "warn", 0, 5, "The hosted discovery spec reports robots.txt as disabled.", "Enable robots and publish an agent-friendly robots.txt with `docs robots generate`."));
|
|
1534
|
+
const skill = await Promise.all([probeTextRoute(baseUrl, DEFAULT_SKILL_MD_ROUTE), probeTextRoute(baseUrl, DEFAULT_SKILL_MD_WELL_KNOWN_ROUTE)]);
|
|
1535
|
+
const skillPassed = skill.filter((result) => result.ok).length;
|
|
1536
|
+
checks.push(makeCheck("hosted-skill", "Hosted skill.md", skillPassed === skill.length ? "pass" : skillPassed > 0 ? "warn" : "fail", skillPassed === skill.length ? 5 : skillPassed > 0 ? 3 : 0, 5, skill.map((result) => result.detail).join(" "), skillPassed === skill.length ? void 0 : "Verify deployed /skill.md and /.well-known/skill.md routes return non-empty markdown."));
|
|
1537
|
+
const agents = await Promise.all([probeTextRoute(baseUrl, DEFAULT_AGENTS_MD_ROUTE), probeTextRoute(baseUrl, DEFAULT_AGENTS_MD_WELL_KNOWN_ROUTE)]);
|
|
1538
|
+
const agentsPassed = agents.filter((result) => result.ok).length;
|
|
1539
|
+
checks.push(makeCheck("hosted-agents", "Hosted AGENTS.md", agentsPassed === agents.length ? "pass" : agentsPassed > 0 ? "warn" : "fail", agentsPassed === agents.length ? 5 : agentsPassed > 0 ? 3 : 0, 5, agents.map((result) => result.detail).join(" "), agentsPassed === agents.length ? void 0 : "Verify deployed /AGENTS.md and /.well-known/AGENTS.md routes return non-empty markdown."));
|
|
1540
|
+
const markdownRoute = toMarkdownRoute(pages[0]?.url);
|
|
1541
|
+
if (markdownRoute) {
|
|
1542
|
+
const markdownPageUrl = pages[0]?.url ? joinDoctorUrl(baseUrl, pages[0].url) : void 0;
|
|
1543
|
+
const markdownResponseUrl = joinDoctorUrl(baseUrl, markdownRoute);
|
|
1544
|
+
const markdown = await probeTextRoute(baseUrl, markdownRoute);
|
|
1545
|
+
checks.push(makeCheck("hosted-markdown", "Hosted markdown route", markdown.ok ? "pass" : "fail", markdown.ok ? 5 : 0, 5, markdown.detail, markdown.ok ? void 0 : `Verify deployed markdown routes are forwarded, starting with ${markdownRoute}.`));
|
|
1546
|
+
const hasCanonicalHeader = markdown.ok && markdownPageUrl ? hasCanonicalLinkHeader(markdown.linkHeader, markdownPageUrl, markdownResponseUrl) : false;
|
|
1547
|
+
checks.push(makeCheck("hosted-markdown-canonical", "Hosted markdown canonical header", hasCanonicalHeader ? "pass" : "warn", hasCanonicalHeader ? 1 : 0, 1, markdown.ok ? hasCanonicalHeader ? `${markdownRoute} includes a canonical Link header pointing to ${pages[0]?.url}.` : `${markdownRoute} is reachable but is missing a canonical Link response header.` : markdown.detail, hasCanonicalHeader ? void 0 : "Return `Link: <canonical-page-url>; rel=\"canonical\"` on successful markdown page responses so agents can cite the normal docs URL."));
|
|
1548
|
+
} else {
|
|
1549
|
+
checks.push(makeCheck("hosted-markdown", "Hosted markdown route", "warn", 0, 5, "No local docs page was available to choose a sample .md route.", "Add docs pages so the hosted doctor can probe a representative .md route."));
|
|
1550
|
+
checks.push(makeCheck("hosted-markdown-canonical", "Hosted markdown canonical header", "warn", 0, 1, "No local docs page was available to choose a sample .md route.", "Add docs pages so the hosted doctor can probe a markdown canonical Link header."));
|
|
1551
|
+
}
|
|
1552
|
+
const htmlPageUrls = sampleHostedPageUrls(baseUrl, discovery.body, pages);
|
|
1553
|
+
const htmlPageProbes = await Promise.all(htmlPageUrls.map((pageUrl) => probeHostedHtmlPage(pageUrl)));
|
|
1554
|
+
const structuredDataScore = hostedSurfaceScore(htmlPageProbes, (probe) => probe.hasJsonLd);
|
|
1555
|
+
const structuredDataEnabled = hostedCapability(discovery.body, "structuredData");
|
|
1556
|
+
checks.push(makeCheck("hosted-structured-data", "Hosted structured data", structuredDataEnabled === false ? "warn" : structuredDataScore.status, structuredDataEnabled === false ? 0 : structuredDataScore.score, 5, structuredDataEnabled === false ? "The hosted discovery spec reports structured data as disabled." : structuredDataScore.total > 0 ? `${structuredDataScore.passed}/${structuredDataScore.total} sampled hosted docs pages include application/ld+json structured data.` : "No hosted docs pages were available to verify application/ld+json structured data.", structuredDataEnabled === false || structuredDataScore.status === "pass" ? void 0 : "Keep JSON-LD enabled on every docs page so agents can read canonical title, description, URL, breadcrumbs, and freshness hints."));
|
|
1557
|
+
const markdownAlternateScore = hostedSurfaceScore(htmlPageProbes, (probe) => probe.hasMarkdownAlternate);
|
|
1558
|
+
const markdownRoutesEnabled = hostedCapability(discovery.body, "markdownRoutes");
|
|
1559
|
+
checks.push(makeCheck("hosted-markdown-alternate", "Hosted markdown alternate links", markdownRoutesEnabled === false ? "warn" : markdownAlternateScore.status, markdownRoutesEnabled === false ? 0 : markdownAlternateScore.score, 5, markdownRoutesEnabled === false ? "The hosted discovery spec reports markdown routes as disabled." : markdownAlternateScore.total > 0 ? `${markdownAlternateScore.passed}/${markdownAlternateScore.total} sampled hosted docs pages include <link rel="alternate" type="text/markdown"> pointing to .md routes.` : "No hosted docs pages were available to verify markdown alternate links.", markdownRoutesEnabled === false || markdownAlternateScore.status === "pass" ? void 0 : "Add a text/markdown alternate link in each docs page head, usually through `alternates.types['text/markdown']`, so agents can discover the page markdown URL from HTML."));
|
|
1560
|
+
if (hostedCapability(discovery.body, "mcp") === false) checks.push(makeCheck("hosted-mcp", "Hosted MCP handshake", "warn", 0, 10, "The hosted discovery spec reports MCP as disabled.", "Enable MCP when agents should use structured list/search/read tools."));
|
|
1561
|
+
else {
|
|
1562
|
+
const mcp = await probeMcpRouteCandidates(baseUrl, hostedMcpRoutes(discovery.body), hostedMcpExpectedTools(discovery.body));
|
|
1563
|
+
const mcpPassed = mcp.probes.filter((result) => result.ok).length;
|
|
1564
|
+
const mcpDetailProbes = mcpPassed > 0 ? mcp.probes.filter((result) => result.ok) : mcp.probes;
|
|
1565
|
+
checks.push(makeCheck("hosted-mcp", "Hosted MCP handshake", mcpPassed > 0 ? "pass" : "fail", mcpPassed > 0 ? 10 : 0, 10, mcpDetailProbes.map((result) => result.detail).join(" "), mcpPassed > 0 ? void 0 : `Verify one of ${mcp.labels.join(" or ")} supports Streamable HTTP initialize and tools/list.`));
|
|
1566
|
+
}
|
|
1567
|
+
return {
|
|
1568
|
+
baseUrl,
|
|
1569
|
+
checks
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
function makeCheck(id, title, status, score, maxScore, detail, recommendation) {
|
|
1573
|
+
return {
|
|
1574
|
+
id,
|
|
1575
|
+
title,
|
|
1576
|
+
status,
|
|
1577
|
+
score,
|
|
1578
|
+
maxScore,
|
|
1579
|
+
detail,
|
|
1580
|
+
recommendation
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
async function inspectAgentReadiness(options = {}) {
|
|
1584
|
+
const rootDir = process.cwd();
|
|
1585
|
+
const files = listProjectFiles(rootDir);
|
|
1586
|
+
const framework = detectFramework(rootDir) ?? detectFrameworkFromFiles(files) ?? "unknown";
|
|
1587
|
+
const configCheckMax = 10;
|
|
1588
|
+
let configPath;
|
|
1589
|
+
try {
|
|
1590
|
+
configPath = resolveDocsConfigPath(rootDir, options.configPath);
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
const checks = [makeCheck("config", "Docs config", "fail", 0, configCheckMax, error instanceof Error ? error.message : String(error), "Add docs.config.ts[x] or pass --config so the doctor can inspect the docs app.")];
|
|
1593
|
+
return {
|
|
1594
|
+
mode: "agent",
|
|
1595
|
+
framework,
|
|
1596
|
+
score: 0,
|
|
1597
|
+
maxScore: 100,
|
|
1598
|
+
grade: gradeForAgentScore(0),
|
|
1599
|
+
checks,
|
|
1600
|
+
coverage: {
|
|
1601
|
+
totalPages: 0,
|
|
1602
|
+
pagesWithAgentFiles: 0,
|
|
1603
|
+
pagesWithAgentBlocks: 0,
|
|
1604
|
+
explicitPages: 0,
|
|
1605
|
+
explicitCoverage: 0,
|
|
1606
|
+
compaction: {
|
|
1607
|
+
freshGeneratedPages: 0,
|
|
1608
|
+
staleGeneratedPages: 0,
|
|
1609
|
+
modifiedGeneratedPages: 0,
|
|
1610
|
+
unknownGeneratedPages: 0,
|
|
1611
|
+
tokenBudgetMissingPages: 0,
|
|
1612
|
+
otherMissingPages: 0
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
recommendations: checks.map((check) => check.recommendation).filter(Boolean)
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
const configContent = readFileSync(configPath, "utf-8");
|
|
1619
|
+
const configLoad = await loadDocsConfigModuleResultWithProjectEnv(rootDir, options.configPath);
|
|
1620
|
+
const config = configLoad.status === "evaluated" ? configLoad.config : void 0;
|
|
1621
|
+
const entry = config?.entry ?? readTopLevelStringProperty(configContent, "entry") ?? "docs";
|
|
1622
|
+
const contentDir = config?.contentDir ?? resolveDocsContentDir(rootDir, configContent, entry);
|
|
1623
|
+
const ordering = config?.ordering === "alphabetical" || config?.ordering === "numeric" || Array.isArray(config?.ordering) ? config.ordering : void 0;
|
|
1624
|
+
const siteTitle = typeof config?.nav?.title === "string" ? config.nav.title : readNavTitle(configContent) ?? "Documentation";
|
|
1625
|
+
const staticExport = resolveStaticExport(config, configContent);
|
|
1626
|
+
const llmsEnabled = resolveFeatureEnabled(config, configContent, "llmsTxt");
|
|
1627
|
+
const searchEnabled = resolveFeatureEnabled(config, configContent, "search");
|
|
1628
|
+
const mcpEnabled = resolveFeatureEnabled(config, configContent, "mcp");
|
|
1629
|
+
const agentFeedbackEnabled = resolveAgentFeedbackEnabled(config, configContent);
|
|
1630
|
+
const compactConfigured = hasAgentCompactDefaults(config, configContent);
|
|
1631
|
+
const skillFileExists = existsSync(path.join(rootDir, "skill.md"));
|
|
1632
|
+
const agentsFileExists = existsSync(path.join(rootDir, "AGENTS.md")) || existsSync(path.join(rootDir, "AGENT.md"));
|
|
1633
|
+
const source = createFilesystemDocsMcpSource({
|
|
1634
|
+
rootDir,
|
|
1635
|
+
entry,
|
|
1636
|
+
contentDir,
|
|
1637
|
+
siteTitle,
|
|
1638
|
+
ordering
|
|
1639
|
+
});
|
|
1640
|
+
const pages = await Promise.resolve(source.getPages());
|
|
1641
|
+
const coverage = buildCoverage(pages);
|
|
1642
|
+
const usefulness = analyzeAgentUsefulness({
|
|
1643
|
+
rootDir,
|
|
1644
|
+
pages: createAgentUsefulnessPagesFromMcp(rootDir, pages),
|
|
1645
|
+
projectFramework: framework === "unknown" ? void 0 : framework
|
|
1646
|
+
});
|
|
1647
|
+
const evaluationInput = config?.agent?.evaluations;
|
|
1648
|
+
const evaluation = resolveGoldenEvaluationInput(evaluationInput);
|
|
1649
|
+
const evaluationBaseUrl = config ? resolveDocsMetadataBaseUrl(config) : void 0;
|
|
1650
|
+
const evaluationMcp = resolveDocsMcpConfig(config?.mcp, { defaultName: siteTitle });
|
|
1651
|
+
const askAISearch = resolveAskAISearchRequestConfig({
|
|
1652
|
+
search: config?.search,
|
|
1653
|
+
useMcp: config?.ai?.useMcp,
|
|
1654
|
+
mcpEndpoint: evaluationMcp.route,
|
|
1655
|
+
mcpEnabled: evaluationMcp.enabled,
|
|
1656
|
+
mcpSearchEnabled: evaluationMcp.tools.searchDocs,
|
|
1657
|
+
requestUrl: evaluationBaseUrl
|
|
1658
|
+
});
|
|
1659
|
+
const evaluations = await runDocsGoldenTasks(pages, evaluation.tasks, {
|
|
1660
|
+
...evaluation.options,
|
|
1661
|
+
search: config?.search,
|
|
1662
|
+
askAISearch,
|
|
1663
|
+
siteTitle,
|
|
1664
|
+
baseUrl: evaluationBaseUrl,
|
|
1665
|
+
rootDir,
|
|
1666
|
+
codeBlocksValidate: config?.codeBlocks?.validate
|
|
1667
|
+
});
|
|
1668
|
+
const compactionCoverage = buildCompactionCoverage(rootDir, contentDir, entry, pages, config?.agent?.compact ?? {});
|
|
1669
|
+
coverage.compaction = compactionCoverage;
|
|
1670
|
+
const metadataCoverage = buildMetadataCoverage(pages);
|
|
1671
|
+
const metadataResult = metadataScore(metadataCoverage.descriptionCoverage, metadataCoverage.relatedCoverage);
|
|
1672
|
+
const compactionResult = compactionFreshnessScore(compactionCoverage, compactConfigured);
|
|
1673
|
+
const contextQualityResult = scoreUsefulnessCoverage(usefulness.metrics.agentBlocks.useful, usefulness.metrics.agentBlocks.total, 15);
|
|
1674
|
+
const taskCompletenessResult = scoreUsefulnessCoverage(usefulness.metrics.taskCompleteness.completePages, usefulness.metrics.actionablePages, 15);
|
|
1675
|
+
const applicabilityIssueCount = usefulness.metrics.applicability.conflictingPages + usefulness.metrics.applicability.ambiguousPages + usefulness.metrics.applicability.mismatchedPages;
|
|
1676
|
+
const applicabilityResult = scoreUsefulnessCoverage(Math.max(0, usefulness.metrics.actionablePages - applicabilityIssueCount), usefulness.metrics.actionablePages, 10);
|
|
1677
|
+
const commandHealthResult = scoreUsefulnessCoverage(usefulness.metrics.commands.healthy, usefulness.metrics.commands.total, 10);
|
|
1678
|
+
const relatedCoverageResult = scoreUsefulnessCoverage(usefulness.metrics.related.coveredActionablePages, usefulness.metrics.actionablePages, 5);
|
|
1679
|
+
const routeSurface = detectRouteSurface(rootDir, framework, staticExport, files);
|
|
1680
|
+
const mcpConfig = resolveDocsMcpConfig(config?.mcp ?? void 0, { defaultName: siteTitle });
|
|
1681
|
+
const sitemapConfig = resolveDocsSitemapConfig(config?.sitemap ?? readSitemapConfigFromStatic(configContent) ?? true);
|
|
1682
|
+
const robotsInput = config?.robots ?? readRobotsConfigFromStatic(configContent) ?? true;
|
|
1683
|
+
const robotsConfig = robotsInput === false ? resolveDocsRobotsConfig(false) : resolveDocsRobotsConfig(robotsInput, { baseUrl: (typeof robotsInput === "object" ? robotsInput.baseUrl : void 0) ?? sitemapConfig.baseUrl });
|
|
1684
|
+
const robotsPath = resolveRobotsFilePath(rootDir, framework, typeof robotsInput === "object" ? robotsInput : void 0);
|
|
1685
|
+
const feedbackRoute = DEFAULT_AGENT_FEEDBACK_ROUTE;
|
|
1686
|
+
const feedbackSchemaRoute = `${feedbackRoute}/schema`;
|
|
1687
|
+
const apiRoute = resolveDocsDiscoveryApiRoute(config?.cloud?.apiRoute);
|
|
1688
|
+
const discovery = buildDocsAgentDiscoverySpec({
|
|
1689
|
+
origin: "http://localhost",
|
|
1690
|
+
entry,
|
|
1691
|
+
apiRoute,
|
|
1692
|
+
search: searchEnabled,
|
|
1693
|
+
mcp: mcpConfig,
|
|
1694
|
+
feedback: {
|
|
1695
|
+
enabled: agentFeedbackEnabled,
|
|
1696
|
+
route: feedbackRoute,
|
|
1697
|
+
schemaRoute: feedbackSchemaRoute
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
const configuredAgentReviewPaths = configLoad.status === "evaluated" ? Object.values(buildDocsConfigMap(configLoad.config).pointers).map((pointer) => pointer.path).filter((optionPath) => optionPath === "agent" || optionPath.startsWith("agent.") || optionPath === "mcp" || optionPath.startsWith("mcp.") || optionPath === "review" || optionPath.startsWith("review.")) : [];
|
|
1701
|
+
const expectedMcpProtectedResource = mcpConfig.enabled && mcpConfig.security?.authenticate && mcpConfig.security.protectedResource ? {
|
|
1702
|
+
metadataEndpoints: [...getDocsMcpProtectedResourceMetadataRoutes(mcpConfig.route)],
|
|
1703
|
+
authorizationServers: mcpConfig.security.protectedResource.authorizationServers,
|
|
1704
|
+
scopesSupported: mcpConfig.security.protectedResource.scopesSupported,
|
|
1705
|
+
requiredScopes: mcpConfig.security.protectedResource.requiredScopes
|
|
1706
|
+
} : null;
|
|
1707
|
+
const surfaceDrift = analyzeAgentSurfaceDrift({
|
|
1708
|
+
configOptionPaths: [...new Set([...DOCS_CONFIG_MAP_TOP_LEVEL_KEYS, ...configuredAgentReviewPaths])],
|
|
1709
|
+
schemaOptions: getDocsConfigSchema().options,
|
|
1710
|
+
agentContractFields: PAGE_AGENT_CONTRACT_FIELDS,
|
|
1711
|
+
discovery,
|
|
1712
|
+
expected: {
|
|
1713
|
+
entry,
|
|
1714
|
+
search: {
|
|
1715
|
+
enabled: searchEnabled,
|
|
1716
|
+
endpoint: `${apiRoute}?query={query}`
|
|
1717
|
+
},
|
|
1718
|
+
mcp: {
|
|
1719
|
+
enabled: mcpConfig.enabled,
|
|
1720
|
+
endpoint: mcpConfig.route,
|
|
1721
|
+
tools: mcpConfig.tools,
|
|
1722
|
+
protectedResource: expectedMcpProtectedResource
|
|
1723
|
+
},
|
|
1724
|
+
routes: {
|
|
1725
|
+
"api.docs": apiRoute,
|
|
1726
|
+
"api.config": `${apiRoute}?format=config`,
|
|
1727
|
+
"api.apiCatalog": DEFAULT_API_CATALOG_ROUTE,
|
|
1728
|
+
"api.apiCatalogQuery": `${apiRoute}?format=${DEFAULT_API_CATALOG_FORMAT}`,
|
|
1729
|
+
"api.agentSkillsIndex": DEFAULT_AGENT_SKILLS_INDEX_ROUTE,
|
|
1730
|
+
"apiCatalog.route": DEFAULT_API_CATALOG_ROUTE,
|
|
1731
|
+
"apiCatalog.api": `${apiRoute}?format=${DEFAULT_API_CATALOG_FORMAT}`,
|
|
1732
|
+
"config.endpoint": `${apiRoute}?format=config`,
|
|
1733
|
+
"skills.discovery.index": DEFAULT_AGENT_SKILLS_INDEX_ROUTE,
|
|
1734
|
+
"skills.discovery.artifact": DEFAULT_AGENT_SKILLS_ROUTE_PATTERN,
|
|
1735
|
+
"skills.discovery.apiIndex": `${apiRoute}?format=${DEFAULT_AGENT_SKILLS_INDEX_FORMAT}`,
|
|
1736
|
+
"skills.discovery.apiArtifact": `${apiRoute}?format=${DEFAULT_AGENT_SKILL_FORMAT}&name={name}`
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
const checks = [];
|
|
1741
|
+
checks.push(makeCheck("config", "Docs config", configLoad.status === "evaluated" ? "pass" : "warn", configLoad.status === "evaluated" ? 10 : 2, configCheckMax, configLoad.status === "evaluated" ? `Resolved ${path.relative(rootDir, configLoad.path).replace(/\\/g, "/")} and evaluated the config module.` : `Resolved ${path.relative(rootDir, configPath).replace(/\\/g, "/")} using static parsing fallback.`, configLoad.status === "evaluated" ? void 0 : "Fix docs.config module evaluation before relying on resolved diagnostic scores."));
|
|
1742
|
+
const canScoreSurfaceDrift = configLoad.status === "evaluated";
|
|
1743
|
+
checks.push(makeCheck("surface-drift", "Discovery/config/schema consistency", !canScoreSurfaceDrift ? "warn" : surfaceDrift.length === 0 ? "pass" : "fail", !canScoreSurfaceDrift ? 0 : surfaceDrift.length === 0 ? 10 : Math.max(0, 10 - surfaceDrift.length * 2), 10, !canScoreSurfaceDrift ? "Not scored because docs.config could not be evaluated; static parsing cannot prove discovery/config/schema consistency." : surfaceDrift.length === 0 ? "Resolved config, discovery metadata, MCP tool flags, config schema, and agent contract fields agree." : surfaceDrift.slice(0, 3).map((issue) => issue.message).join(" "), canScoreSurfaceDrift && surfaceDrift.length === 0 ? void 0 : canScoreSurfaceDrift ? "Align docs config, agent discovery (including API catalog and Agent Skills routes), MCP tools, config schema, and the canonical page agent contract before publishing." : "Fix docs.config module evaluation so doctor can compare resolved config with discovery and schema surfaces."));
|
|
1744
|
+
const dynamicStaticFallback = configLoad.status === "static-fallback" && /(?:\.\.\.|process\.env|import\.meta\.env|\[[^\]]+\]\s*:|:\s*[A-Za-z_$][\w$]*\s*[,}])/u.test(configContent);
|
|
1745
|
+
checks.push(configLoad.status === "evaluated" ? makeCheck("config-confidence", "Config loading confidence", "pass", 5, 5, "High confidence: docs.config was evaluated with project-local environment values.") : makeCheck("config-confidence", "Config loading confidence", "warn", dynamicStaticFallback ? 1 : 2, 5, `${dynamicStaticFallback ? "Low" : "Partial"} confidence: only static config parsing succeeded. ${configLoad.error}`, "Fix docs.config module evaluation so doctor and review can inspect dynamic values, golden tasks, and resolved feature settings."));
|
|
1746
|
+
const contentDirAbs = path.resolve(rootDir, contentDir);
|
|
1747
|
+
checks.push(coverage.totalPages > 0 ? makeCheck("content", "Docs content", "pass", 10, 10, `Found ${coverage.totalPages} docs page${coverage.totalPages === 1 ? "" : "s"} in ${path.relative(rootDir, contentDirAbs).replace(/\\/g, "/")}.`) : makeCheck("content", "Docs content", "fail", 0, 10, `No folder-based docs pages were found in ${path.relative(rootDir, contentDirAbs).replace(/\\/g, "/")}.`, "Add index/page MDX files under the configured contentDir so the machine-readable surfaces have pages to serve."));
|
|
1748
|
+
checks.push(makeCheck("api-route", "Docs API route", routeSurface.apiMounted ? "pass" : "fail", routeSurface.apiMounted ? 10 : 0, 10, routeSurface.apiDetail, routeSurface.apiMounted ? void 0 : "Wire the framework docs API route so /api/docs can serve markdown, llms.txt, sitemap, AGENTS.md, skill.md, and discovery responses."));
|
|
1749
|
+
checks.push(makeCheck("public-routes", "Public agent routes", routeSurface.publicMounted ? "pass" : "fail", routeSurface.publicMounted ? 10 : 0, 10, routeSurface.publicDetail, routeSurface.publicMounted ? void 0 : "Add the framework public forwarder so /.well-known/api-catalog, /.well-known/agent-skills/*, the other /.well-known/* resources, /llms.txt, /sitemap.xml, /sitemap.md, /docs/sitemap.md, /AGENTS.md, /skill.md, /mcp, and .md routes resolve from the shared docs API."));
|
|
1750
|
+
checks.push(makeCheck("agent-discovery", "Agent discovery spec", routeSurface.apiMounted && routeSurface.publicMounted ? "pass" : "fail", routeSurface.apiMounted && routeSurface.publicMounted ? 5 : 0, 5, routeSurface.apiMounted && routeSurface.publicMounted ? `Expected discovery endpoints are available through ${DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE}, ${DEFAULT_AGENT_SPEC_WELL_KNOWN_ROUTE}, ${DEFAULT_API_CATALOG_ROUTE}, ${DEFAULT_AGENT_SKILLS_INDEX_ROUTE}, and /api/docs?agent=spec.` : "Could not verify the shared agent discovery spec endpoints because docs API/public route wiring is incomplete.", routeSurface.apiMounted && routeSurface.publicMounted ? void 0 : "Make sure both the docs API handler and public docs forwarder expose the custom agent manifest, RFC 9727 API catalog, and Agent Skills discovery routes."));
|
|
1751
|
+
checks.push(llmsEnabled ? makeCheck("llms", "llms.txt discovery", "pass", 5, 5, `Enabled via ${DEFAULT_LLMS_TXT_ROUTE} and ${DEFAULT_LLMS_FULL_TXT_ROUTE}.`) : makeCheck("llms", "llms.txt discovery", "warn", 0, 5, `${DEFAULT_LLMS_TXT_ROUTE} and ${DEFAULT_LLMS_FULL_TXT_ROUTE} are disabled in docs config.`, "Enable llmsTxt so agents and GEO crawlers can discover the docs index and full context surfaces."));
|
|
1752
|
+
checks.push(sitemapConfig.enabled ? makeCheck("sitemap", "Sitemap discovery", "pass", 5, 5, `Enabled via ${[
|
|
1753
|
+
sitemapConfig.xml.route,
|
|
1754
|
+
sitemapConfig.markdown.route,
|
|
1755
|
+
sitemapConfig.markdown.docsRoute,
|
|
1756
|
+
sitemapConfig.markdown.wellKnownRoute
|
|
1757
|
+
].filter(Boolean).join(", ")}.`) : makeCheck("sitemap", "Sitemap discovery", "warn", 0, 5, "Generated sitemap routes are disabled in docs config.", "Enable sitemap so crawlers and agents can discover canonical docs URLs, semantic sections, and lastmod freshness metadata."));
|
|
1758
|
+
const relativeRobotsPath = path.relative(rootDir, robotsPath).replace(/\\/g, "/");
|
|
1759
|
+
if (!robotsConfig.enabled) checks.push(makeCheck("robots", "Robots agent policy", "warn", 0, 5, "Robots generation is disabled in docs config.", "Enable robots and run `docs robots generate` so crawlers can discover agent-readable docs routes."));
|
|
1760
|
+
else if (!existsSync(robotsPath)) if (routeSurface.apiMounted && routeSurface.publicMounted && !staticExport) checks.push(makeCheck("robots", "Robots agent policy", "pass", 5, 5, "Runtime /robots.txt is served by the shared docs handler."));
|
|
1761
|
+
else checks.push(makeCheck("robots", "Robots agent policy", "warn", 0, 5, `No robots.txt found at ${relativeRobotsPath}.`, `Run docs robots generate --path ${relativeRobotsPath} to publish an agent-friendly crawl policy.`));
|
|
1762
|
+
else {
|
|
1763
|
+
const analysis = analyzeDocsRobotsTxt(readFileSync(robotsPath, "utf-8"), {
|
|
1764
|
+
entry,
|
|
1765
|
+
sitemap: sitemapConfig,
|
|
1766
|
+
baseUrl: robotsConfig.baseUrl,
|
|
1767
|
+
robots: robotsConfig
|
|
1768
|
+
});
|
|
1769
|
+
const blocked = analysis.blocksAgentRoutes || analysis.blocksAiAgents;
|
|
1770
|
+
const complete = analysis.hasAgentRoutes && analysis.hasAiPolicy;
|
|
1771
|
+
checks.push(makeCheck("robots", "Robots agent policy", blocked ? "fail" : complete ? "pass" : "warn", blocked ? 0 : complete ? 5 : 3, 5, blocked ? `${relativeRobotsPath} blocks ${analysis.blocksAiAgents ? "common AI crawlers" : "agent-readable docs routes"}.` : complete ? `${relativeRobotsPath} advertises agent-readable routes and common AI crawler policy.` : `${relativeRobotsPath} exists, but is missing ${analysis.missingRoutes.length > 0 ? `agent routes (${analysis.missingRoutes.join(", ")})` : "common AI crawler policy"}.`, blocked || !complete ? `Run docs robots generate --append --path ${relativeRobotsPath} to add the generated agent policy without replacing the existing file.` : void 0));
|
|
1772
|
+
}
|
|
1773
|
+
checks.push(skillFileExists ? makeCheck("skill", "Skill document", "pass", 5, 5, `Found root skill.md for ${DEFAULT_SKILL_MD_ROUTE} and ${DEFAULT_SKILL_MD_WELL_KNOWN_ROUTE}.`) : makeCheck("skill", "Skill document", "pass", 5, 5, `No root skill.md found; the framework will serve the generated fallback at ${DEFAULT_SKILL_MD_ROUTE}.`));
|
|
1774
|
+
checks.push(agentsFileExists ? makeCheck("agents", "Agent instructions", "pass", 5, 5, `Found root AGENTS.md/AGENT.md for ${DEFAULT_AGENTS_MD_ROUTE} and ${DEFAULT_AGENTS_MD_WELL_KNOWN_ROUTE}.`) : makeCheck("agents", "Agent instructions", "pass", 5, 5, `No root AGENTS.md found; the framework will serve the generated fallback at ${DEFAULT_AGENTS_MD_ROUTE}.`));
|
|
1775
|
+
checks.push(mcpEnabled ? makeCheck("mcp", "MCP access", "pass", 10, 10, `Enabled with public aliases ${DEFAULT_MCP_PUBLIC_ROUTE} and ${DEFAULT_MCP_WELL_KNOWN_ROUTE} (canonical route ${mcpConfig.route}).`) : makeCheck("mcp", "MCP access", "warn", 0, 10, "MCP is disabled in docs config.", "Enable mcp so agents can use list/search/read tools directly instead of only scraping markdown routes."));
|
|
1776
|
+
checks.push(searchEnabled ? makeCheck("search", "Search surface", "pass", 5, 5, "Search is enabled for the shared docs API and agent flows.") : makeCheck("search", "Search surface", "warn", 0, 5, "Search is disabled in docs config.", "Enable search so agents can narrow retrieval before reading whole markdown pages."));
|
|
1777
|
+
checks.push(agentFeedbackEnabled ? makeCheck("feedback", "Agent feedback", "pass", 5, 5, `Structured agent feedback is enabled at ${feedbackRoute} with schema ${feedbackSchemaRoute}.`) : makeCheck("feedback", "Agent feedback", "warn", 0, 5, "Structured agent feedback is not enabled.", "Enable feedback.agent if you want agents to discover and post feedback through the shared docs API."));
|
|
1778
|
+
checks.push(makeCheck("metadata", "Page metadata", metadataResult.status, metadataResult.score, 5, coverage.totalPages > 0 ? `${metadataCoverage.describedPages}/${coverage.totalPages} pages include descriptions and ${metadataCoverage.relatedPages}/${coverage.totalPages} pages include related links (${metadataCoverage.descriptionCoverage}% described, ${metadataCoverage.relatedCoverage}% related).` : "No docs pages were available to score page metadata.", metadataCoverage.descriptionCoverage >= 75 ? void 0 : "Add page descriptions and related links to more docs pages so agent markdown output carries better context and navigation hints."));
|
|
1779
|
+
const coverageResult = coverageScore(coverage.explicitCoverage);
|
|
1780
|
+
checks.push(makeCheck("coverage", "Audience-tailored page optimization", coverageResult.status, coverageResult.score, 10, coverage.totalPages > 0 ? `${coverage.explicitPages}/${coverage.totalPages} pages define audience-tailored context (${coverage.pagesWithAgentFiles} page${coverage.pagesWithAgentFiles === 1 ? "" : "s"} with agent.md, ${coverage.pagesWithAgentBlocks} page${coverage.pagesWithAgentBlocks === 1 ? "" : "s"} with embedded audience projections, ${coverage.explicitCoverage}% of pages).` : "No docs pages were available to score audience-tailored page optimization.", coverage.explicitCoverage >= 50 ? void 0 : "Add agent.md files or audience primitives such as <Agent>, <Human>, or <Audience only=\"agent\"> to more pages, or run docs agent compact to create page-level machine docs."));
|
|
1781
|
+
checks.push(makeCheck("agent-context-quality", "Agent context usefulness", contextQualityResult.status, contextQualityResult.score, 15, usefulness.metrics.agentBlocks.total > 0 ? `${usefulness.metrics.agentBlocks.useful}/${usefulness.metrics.agentBlocks.total} agent-only blocks are specific and non-repetitive (${usefulness.metrics.agentBlocks.duplicate} duplicate, ${usefulness.metrics.agentBlocks.boilerplate} boilerplate, ${usefulness.metrics.agentBlocks.generic} generic).` : "No embedded agent-only blocks were present; page contracts and sibling agent.md files remain available as alternatives.", usefulness.metrics.agentBlocks.total > 0 && usefulness.metrics.agentBlocks.useful === usefulness.metrics.agentBlocks.total ? void 0 : "Replace repeated agent-only boilerplate with page-specific constraints, commands, files, expected results, and recovery guidance."));
|
|
1782
|
+
checks.push(makeCheck("agent-task-completeness", "Agent task completeness", taskCompletenessResult.status, taskCompletenessResult.score, 15, `${usefulness.metrics.taskCompleteness.completePages}/${usefulness.metrics.actionablePages} actionable pages include prerequisites, an expected result, and recovery guidance (${usefulness.metrics.taskCompleteness.coverage}% complete).`, usefulness.metrics.actionablePages > 0 && usefulness.metrics.taskCompleteness.coverage >= 80 ? void 0 : "Add prerequisites, observable outcomes or verification expectations, and rollback or resolved failure modes to actionable pages."));
|
|
1783
|
+
checks.push(makeCheck("agent-applicability", "Framework and version applicability", applicabilityResult.status, applicabilityResult.score, 10, `${usefulness.metrics.applicability.conflictingPages} conflicting, ${usefulness.metrics.applicability.ambiguousPages} ambiguous, and ${usefulness.metrics.applicability.mismatchedPages} project-mismatched actionable pages.`, usefulness.metrics.actionablePages > 0 && applicabilityIssueCount === 0 ? void 0 : "Declare consistent framework/version metadata in page frontmatter and agent.appliesTo so retrieval can select the right variant."));
|
|
1784
|
+
checks.push(makeCheck("command-health", "Documented command health", commandHealthResult.status, commandHealthResult.score, 10, `${usefulness.metrics.commands.healthy}/${usefulness.metrics.commands.total} statically inspected commands are healthy; ${usefulness.metrics.commands.unhealthy} are unhealthy and ${usefulness.metrics.commands.unverified} could not be verified safely.`, usefulness.metrics.commands.total > 0 && usefulness.metrics.commands.unhealthy === 0 && usefulness.metrics.commands.unverified === 0 ? void 0 : "Update broken or stale commands and make workspace selectors resolvable so scripts, working directories, package managers, and docs CLI subcommands can be verified."));
|
|
1785
|
+
checks.push(makeCheck("related-coverage", "Related-page task coverage", relatedCoverageResult.status, relatedCoverageResult.score, 5, `${usefulness.metrics.related.coveredActionablePages}/${usefulness.metrics.actionablePages} actionable pages link to a valid related docs route; ${usefulness.metrics.related.brokenLinks} related links are broken.`, usefulness.metrics.actionablePages > 0 && usefulness.metrics.related.coverage >= 80 && usefulness.metrics.related.brokenLinks === 0 ? void 0 : "Add and validate related routes on actionable pages so agents can expand context without guessing."));
|
|
1786
|
+
checks.push(makeCheck("compact", "Agent compaction freshness", compactionResult.status, compactionResult.score, 5, `${compactionCoverage.freshGeneratedPages} fresh, ${compactionCoverage.staleGeneratedPages} stale, ${compactionCoverage.modifiedGeneratedPages} modified, ${compactionCoverage.unknownGeneratedPages} unknown, ${compactionCoverage.tokenBudgetMissingPages} token-budget missing, and ${compactionCoverage.otherMissingPages} other missing page${compactionCoverage.otherMissingPages === 1 ? "" : "s"} across compactable docs pages.` + (compactConfigured ? " agent.compact defaults are configured." : " No agent.compact defaults were found in docs config."), compactionResult.recommendation));
|
|
1787
|
+
const averageMetric = (values) => values.length === 0 ? 0 : Math.round(values.reduce((total, value) => total + value, 0) / values.length * 100) / 100;
|
|
1788
|
+
const proportionalEvaluationScore = evaluations.score === null ? 0 : Math.round(evaluations.score / 100 * 15);
|
|
1789
|
+
const evaluationScore = evaluations.status === "failed" ? Math.min(14, proportionalEvaluationScore) : proportionalEvaluationScore;
|
|
1790
|
+
checks.push(makeCheck("golden-tasks", "Golden agent tasks", evaluations.status === "passed" ? "pass" : evaluations.status === "failed" ? "fail" : "warn", evaluationScore, 15, evaluations.status === "unmeasured" ? "No golden agent tasks are configured; retrieval usefulness is unmeasured." : `${evaluations.passedTaskCount}/${evaluations.taskCount} golden tasks passed with ${evaluations.score}/100 average score, ${averageMetric(evaluations.tasks.map((task) => task.retrieval.recallAtK))} retrieval recall, ${averageMetric(evaluations.tasks.map((task) => task.citations.recall))} citation recall, and ${evaluations.tasks.reduce((total, task) => total + task.usage.usedUtf8Bytes, 0)} UTF-8 context bytes used.`, evaluations.status === "passed" ? void 0 : evaluations.status === "unmeasured" ? "Configure agent.evaluations.tasks so doctor and review can measure retrieval, citations, framework/version selection, executable examples, and token usage." : "Inspect the failed golden task metrics and fix retrieval ranking, citations, applicability metadata, examples, or context budgets."));
|
|
1791
|
+
const hosted = options.url ? await buildHostedAgentChecks(options.url, pages) : void 0;
|
|
1792
|
+
if (hosted) checks.push(...hosted.checks);
|
|
1793
|
+
const { score, maxScore } = normalizedDoctorScore(checks.reduce((total, check) => total + check.score, 0), checks.reduce((total, check) => total + check.maxScore, 0));
|
|
1794
|
+
return {
|
|
1795
|
+
mode: "agent",
|
|
1796
|
+
framework,
|
|
1797
|
+
configPath: path.relative(rootDir, configPath).replace(/\\/g, "/"),
|
|
1798
|
+
entry,
|
|
1799
|
+
contentDir,
|
|
1800
|
+
url: hosted?.baseUrl,
|
|
1801
|
+
score,
|
|
1802
|
+
maxScore,
|
|
1803
|
+
grade: gradeForAgentScore(score, checks),
|
|
1804
|
+
checks,
|
|
1805
|
+
coverage,
|
|
1806
|
+
usefulness: usefulness.metrics,
|
|
1807
|
+
evaluations,
|
|
1808
|
+
recommendations: checks.map((check) => check.recommendation).filter((recommendation) => Boolean(recommendation)).slice(0, 3)
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
async function inspectHumanReadiness(options = {}) {
|
|
1812
|
+
const rootDir = process.cwd();
|
|
1813
|
+
const files = listProjectFiles(rootDir);
|
|
1814
|
+
const framework = detectFramework(rootDir) ?? detectFrameworkFromFiles(files) ?? "unknown";
|
|
1815
|
+
const configCheckMax = 10;
|
|
1816
|
+
let configPath;
|
|
1817
|
+
try {
|
|
1818
|
+
configPath = resolveDocsConfigPath(rootDir, options.configPath);
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
const checks = [makeCheck("config", "Docs config", "fail", 0, configCheckMax, error instanceof Error ? error.message : String(error), "Add docs.config.ts[x] or pass --config so the doctor can inspect the docs app.")];
|
|
1821
|
+
return {
|
|
1822
|
+
mode: "human",
|
|
1823
|
+
framework,
|
|
1824
|
+
score: 0,
|
|
1825
|
+
maxScore: 100,
|
|
1826
|
+
grade: gradeForHumanScore(0),
|
|
1827
|
+
checks,
|
|
1828
|
+
coverage: {
|
|
1829
|
+
totalPages: 0,
|
|
1830
|
+
describedPages: 0,
|
|
1831
|
+
descriptionCoverage: 0,
|
|
1832
|
+
longPages: 0,
|
|
1833
|
+
structuredLongPages: 0,
|
|
1834
|
+
structureCoverage: 0,
|
|
1835
|
+
navigationPages: 0
|
|
1836
|
+
},
|
|
1837
|
+
recommendations: checks.map((check) => check.recommendation).filter(Boolean)
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
const configContent = readFileSync(configPath, "utf-8");
|
|
1841
|
+
const configLoad = await loadDocsConfigModuleResultWithProjectEnv(rootDir, options.configPath);
|
|
1842
|
+
const config = configLoad.status === "evaluated" ? configLoad.config : void 0;
|
|
1843
|
+
const entry = config?.entry ?? readTopLevelStringProperty(configContent, "entry") ?? "docs";
|
|
1844
|
+
const contentDir = config?.contentDir ?? resolveDocsContentDir(rootDir, configContent, entry);
|
|
1845
|
+
const ordering = config?.ordering === "alphabetical" || config?.ordering === "numeric" || Array.isArray(config?.ordering) ? config.ordering : void 0;
|
|
1846
|
+
const siteTitle = typeof config?.nav?.title === "string" ? config.nav.title : readNavTitle(configContent) ?? "Documentation";
|
|
1847
|
+
const searchEnabled = resolveFeatureEnabled(config, configContent, "search");
|
|
1848
|
+
const humanFeedbackEnabled = resolveHumanFeedbackEnabled(config, configContent);
|
|
1849
|
+
const lastUpdatedEnabled = resolveLastUpdatedEnabled(config, configContent);
|
|
1850
|
+
const githubEnabled = hasGithubIntegration(config, configContent);
|
|
1851
|
+
const readingTimeEnabled = hasReadingTimeSurface(config, configContent);
|
|
1852
|
+
const source = createFilesystemDocsMcpSource({
|
|
1853
|
+
rootDir,
|
|
1854
|
+
entry,
|
|
1855
|
+
contentDir,
|
|
1856
|
+
siteTitle,
|
|
1857
|
+
ordering
|
|
1858
|
+
});
|
|
1859
|
+
const coverage = buildHumanCoverage(await Promise.resolve(source.getPages()), countNavigationPages(await Promise.resolve(source.getNavigation())));
|
|
1860
|
+
const descriptionResult = descriptionScore(coverage.descriptionCoverage);
|
|
1861
|
+
const structureResult = structureScore(coverage.structureCoverage);
|
|
1862
|
+
const navigationCoverage = coverage.totalPages === 0 ? 0 : Math.min(100, Math.round(coverage.navigationPages / coverage.totalPages * 100));
|
|
1863
|
+
const navigationResult = navigationScore(navigationCoverage);
|
|
1864
|
+
const checks = [];
|
|
1865
|
+
checks.push(makeCheck("config", "Docs config", configLoad.status === "evaluated" ? "pass" : "warn", configLoad.status === "evaluated" ? 10 : 2, 10, configLoad.status === "evaluated" ? `Resolved ${path.relative(rootDir, configLoad.path).replace(/\\/g, "/")} and evaluated the config module.` : `Resolved ${path.relative(rootDir, configPath).replace(/\\/g, "/")} using static parsing fallback. ${configLoad.error}`, configLoad.status === "evaluated" ? void 0 : "Fix docs.config module evaluation so the site score uses resolved dynamic configuration."));
|
|
1866
|
+
const contentDirAbs = path.resolve(rootDir, contentDir);
|
|
1867
|
+
checks.push(coverage.totalPages > 0 ? makeCheck("content", "Docs content", "pass", 15, 15, `Found ${coverage.totalPages} docs page${coverage.totalPages === 1 ? "" : "s"} in ${path.relative(rootDir, contentDirAbs).replace(/\\/g, "/")}.`) : makeCheck("content", "Docs content", "fail", 0, 15, `No folder-based docs pages were found in ${path.relative(rootDir, contentDirAbs).replace(/\\/g, "/")}.`, "Add index/page MDX files under the configured contentDir so the human docs site has pages to render."));
|
|
1868
|
+
checks.push(makeCheck("navigation", "Navigation coverage", navigationResult.status, navigationResult.score, 15, coverage.totalPages > 0 ? `The generated docs navigation exposes ${coverage.navigationPages}/${coverage.totalPages} page entries (${navigationCoverage}% coverage).` : "No docs pages were available to score navigation coverage.", navigationCoverage >= 100 ? void 0 : "Make sure every important docs page is reachable from the generated navigation tree and not stranded outside the main docs flow."));
|
|
1869
|
+
checks.push(makeCheck("descriptions", "Page descriptions", descriptionResult.status, descriptionResult.score, 15, coverage.totalPages > 0 ? `${coverage.describedPages}/${coverage.totalPages} pages include a description (${coverage.descriptionCoverage}% coverage).` : "No docs pages were available to score descriptions.", coverage.descriptionCoverage >= 75 ? void 0 : "Add frontmatter descriptions to more pages so readers get better search snippets, summaries, and page introductions."));
|
|
1870
|
+
checks.push(makeCheck("structure", "Page structure", structureResult.status, structureResult.score, 15, coverage.longPages > 0 ? `${coverage.structuredLongPages}/${coverage.longPages} longer pages include section headings (${coverage.structureCoverage}% coverage).` : "No longer docs pages required section-heading checks.", coverage.structureCoverage >= 75 ? void 0 : "Break longer pages into clearer sections with H2/H3 headings so readers can scan and navigate without hitting a wall of text."));
|
|
1871
|
+
checks.push(searchEnabled ? makeCheck("search", "Search surface", "pass", 10, 10, "Search is enabled for the docs site.") : makeCheck("search", "Search surface", "warn", 0, 10, "Search is disabled in docs config.", "Enable search so readers can jump directly to the right page instead of relying only on sidebar browsing."));
|
|
1872
|
+
const trustScore = (githubEnabled ? 5 : 0) + (lastUpdatedEnabled ? 5 : 0);
|
|
1873
|
+
checks.push(makeCheck("trust", "Trust signals", trustScore === 10 ? "pass" : "warn", trustScore, 10, githubEnabled && lastUpdatedEnabled ? "Edit links and last-updated metadata are configured." : githubEnabled ? "Edit links are configured, but last-updated metadata is not enabled." : lastUpdatedEnabled ? "Last-updated metadata is enabled, but edit links are not configured." : "Edit links and last-updated metadata are not configured.", trustScore === 10 ? void 0 : "Configure GitHub edit links and/or lastUpdated so readers can trust freshness and find the source of truth faster."));
|
|
1874
|
+
checks.push(humanFeedbackEnabled ? makeCheck("feedback", "Reader feedback", "pass", 5, 5, "Built-in page feedback is enabled for the docs site.") : makeCheck("feedback", "Reader feedback", "warn", 0, 5, "Built-in page feedback is not enabled.", "Enable feedback if you want readers to leave quick page-level quality signals without opening an issue."));
|
|
1875
|
+
checks.push(readingTimeEnabled ? makeCheck("reading-time", "Reading-time cues", "pass", 5, 5, "Reading time is configured for the docs site.") : makeCheck("reading-time", "Reading-time cues", "warn", 0, 5, "Reading time is not enabled.", "Enable readingTime if you want readers to get a quick effort estimate before they dive into longer pages."));
|
|
1876
|
+
const { score, maxScore } = normalizedDoctorScore(checks.reduce((total, check) => total + check.score, 0), checks.reduce((total, check) => total + check.maxScore, 0));
|
|
1877
|
+
return {
|
|
1878
|
+
mode: "human",
|
|
1879
|
+
framework,
|
|
1880
|
+
configPath: path.relative(rootDir, configPath).replace(/\\/g, "/"),
|
|
1881
|
+
entry,
|
|
1882
|
+
contentDir,
|
|
1883
|
+
score,
|
|
1884
|
+
maxScore,
|
|
1885
|
+
grade: gradeForHumanScore(score),
|
|
1886
|
+
checks,
|
|
1887
|
+
coverage,
|
|
1888
|
+
recommendations: checks.map((check) => check.recommendation).filter((recommendation) => Boolean(recommendation)).slice(0, 3)
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
function printAgentDoctorReport(report) {
|
|
1892
|
+
console.log(`${pc.bold("@farming-labs/docs doctor")} ${pc.dim("—")} ${pc.bold("agent")}`);
|
|
1893
|
+
console.log();
|
|
1894
|
+
console.log(`${pc.bold("Score:")} ${pc.cyan(`${report.score}%`)} ${pc.dim(`(${report.grade})`)}`);
|
|
1895
|
+
console.log(`${pc.bold("Framework:")} ${report.framework} ${pc.dim("•")} ${pc.bold("Entry:")} ${report.entry ?? "docs"} ${pc.dim("•")} ${pc.bold("Content:")} ${report.contentDir ?? "-"}`);
|
|
1896
|
+
if (report.url) console.log(`${pc.bold("Hosted URL:")} ${report.url}`);
|
|
1897
|
+
console.log(`${pc.bold("Audience-tailored pages:")} ${report.coverage.explicitPages}/${report.coverage.totalPages} pages ${pc.dim(`(${report.coverage.explicitCoverage}%)`)}`);
|
|
1898
|
+
if (report.usefulness) console.log(`${pc.bold("Useful agent-only blocks:")} ${report.usefulness.agentBlocks.useful}/${report.usefulness.agentBlocks.total} ${pc.dim(`• ${report.usefulness.taskCompleteness.completePages}/${report.usefulness.actionablePages} actionable pages task-complete`)}`);
|
|
1899
|
+
if (report.evaluations) console.log(`${pc.bold("Golden tasks:")} ${report.evaluations.status === "unmeasured" ? "unmeasured" : `${report.evaluations.passedTaskCount}/${report.evaluations.taskCount} passed (${report.evaluations.score}/100)`}`);
|
|
1900
|
+
console.log(`${pc.bold("Generated agent.md freshness:")} ${report.coverage.compaction.freshGeneratedPages} fresh ${pc.dim("•")} ${report.coverage.compaction.staleGeneratedPages} stale ${pc.dim("•")} ${report.coverage.compaction.modifiedGeneratedPages} modified ${pc.dim("•")} ${report.coverage.compaction.tokenBudgetMissingPages} token-budget missing`);
|
|
1901
|
+
if (report.fixes && report.fixes.length > 0) console.log(`${pc.bold("Fixes:")} ${report.fixes.map((fix) => `${fix.status === "applied" ? "applied" : "skipped"} ${fix.title}`).join(pc.dim(" • "))}`);
|
|
1902
|
+
console.log();
|
|
1903
|
+
for (const check of report.checks) {
|
|
1904
|
+
console.log(`${formatStatus(check.status)} ${check.title} ${pc.dim(`(${check.score}/${check.maxScore})`)}`);
|
|
1905
|
+
console.log(` ${check.detail}`);
|
|
1906
|
+
}
|
|
1907
|
+
if (report.recommendations.length > 0) {
|
|
1908
|
+
console.log();
|
|
1909
|
+
console.log(pc.bold("Next steps"));
|
|
1910
|
+
for (const recommendation of report.recommendations) console.log(`- ${recommendation}`);
|
|
1911
|
+
}
|
|
1912
|
+
console.log();
|
|
1913
|
+
console.log(pc.dim(`Expected public surfaces: ${DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE}, ${DEFAULT_AGENT_SPEC_WELL_KNOWN_ROUTE}, ${DEFAULT_LLMS_TXT_ROUTE}, ${DEFAULT_LLMS_FULL_TXT_ROUTE}, ${DEFAULT_AGENTS_MD_ROUTE}, ${DEFAULT_SKILL_MD_ROUTE}, ${DEFAULT_MCP_PUBLIC_ROUTE}`));
|
|
1914
|
+
}
|
|
1915
|
+
function printHumanDoctorReport(report) {
|
|
1916
|
+
console.log(`${pc.bold("@farming-labs/docs doctor")} ${pc.dim("—")} ${pc.bold("site")}`);
|
|
1917
|
+
console.log();
|
|
1918
|
+
console.log(`${pc.bold("Score:")} ${pc.cyan(`${report.score}%`)} ${pc.dim(`(${report.grade})`)}`);
|
|
1919
|
+
console.log(`${pc.bold("Framework:")} ${report.framework} ${pc.dim("•")} ${pc.bold("Entry:")} ${report.entry ?? "docs"} ${pc.dim("•")} ${pc.bold("Content:")} ${report.contentDir ?? "-"}`);
|
|
1920
|
+
console.log(`${pc.bold("Described pages:")} ${report.coverage.describedPages}/${report.coverage.totalPages} pages ${pc.dim(`(${report.coverage.descriptionCoverage}%)`)}`);
|
|
1921
|
+
console.log();
|
|
1922
|
+
for (const check of report.checks) {
|
|
1923
|
+
console.log(`${formatStatus(check.status)} ${check.title} ${pc.dim(`(${check.score}/${check.maxScore})`)}`);
|
|
1924
|
+
console.log(` ${check.detail}`);
|
|
1925
|
+
}
|
|
1926
|
+
if (report.recommendations.length > 0) {
|
|
1927
|
+
console.log();
|
|
1928
|
+
console.log(pc.bold("Next steps"));
|
|
1929
|
+
for (const recommendation of report.recommendations) console.log(`- ${recommendation}`);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
function serializeDoctorJsonReport(report) {
|
|
1933
|
+
if (report.mode === "human") return {
|
|
1934
|
+
...report,
|
|
1935
|
+
mode: "site"
|
|
1936
|
+
};
|
|
1937
|
+
return report;
|
|
1938
|
+
}
|
|
1939
|
+
function printDoctorJsonReport(report) {
|
|
1940
|
+
console.log(JSON.stringify(serializeDoctorJsonReport(report), null, 2));
|
|
1941
|
+
}
|
|
1942
|
+
function hasNonPassingDoctorCheck(report) {
|
|
1943
|
+
return report.checks.some((check) => check.status !== "pass");
|
|
1944
|
+
}
|
|
1945
|
+
function hasFailingDoctorCheck(report) {
|
|
1946
|
+
return report.checks.some((check) => check.status === "fail");
|
|
1947
|
+
}
|
|
1948
|
+
function applyDoctorExitCode(report, options) {
|
|
1949
|
+
const failOn = options.failOn ?? (options.strict ? "warn" : void 0);
|
|
1950
|
+
if (!failOn) return;
|
|
1951
|
+
if (failOn === "warn" ? hasNonPassingDoctorCheck(report) : hasFailingDoctorCheck(report)) process.exitCode = 1;
|
|
1952
|
+
}
|
|
1953
|
+
async function runAgentDoctorFixes(report, options) {
|
|
1954
|
+
const fixes = [];
|
|
1955
|
+
const compaction = report.coverage.compaction;
|
|
1956
|
+
if (!(compaction.staleGeneratedPages > 0 || compaction.tokenBudgetMissingPages > 0)) {
|
|
1957
|
+
if (compaction.modifiedGeneratedPages > 0 || compaction.unknownGeneratedPages > 0) fixes.push({
|
|
1958
|
+
id: "agent-compact",
|
|
1959
|
+
title: "agent compact",
|
|
1960
|
+
status: "skipped",
|
|
1961
|
+
detail: "Only modified or unknown generated agent.md files need attention; doctor --fix leaves those for manual review."
|
|
1962
|
+
});
|
|
1963
|
+
return fixes;
|
|
1964
|
+
}
|
|
1965
|
+
const runCompaction = () => compactAgentDocs({
|
|
1966
|
+
configPath: options.configPath,
|
|
1967
|
+
stale: true,
|
|
1968
|
+
includeMissing: compaction.tokenBudgetMissingPages > 0
|
|
1969
|
+
});
|
|
1970
|
+
const command = `docs agent compact --stale${compaction.tokenBudgetMissingPages > 0 ? " --include-missing" : ""}`;
|
|
1971
|
+
if (options.dryRun) {
|
|
1972
|
+
const missingOutputDetail = compaction.tokenBudgetMissingPages > 0 ? " and create token-budget missing outputs" : "";
|
|
1973
|
+
fixes.push({
|
|
1974
|
+
id: "agent-compact",
|
|
1975
|
+
title: "agent compact",
|
|
1976
|
+
status: "skipped",
|
|
1977
|
+
detail: `Dry run: would run ${command} to refresh stale generated agent.md files${missingOutputDetail}.`
|
|
1978
|
+
});
|
|
1979
|
+
return fixes;
|
|
1980
|
+
}
|
|
1981
|
+
if (options.json) {
|
|
1982
|
+
const originalLog = console.log;
|
|
1983
|
+
console.log = () => void 0;
|
|
1984
|
+
try {
|
|
1985
|
+
await runCompaction();
|
|
1986
|
+
} finally {
|
|
1987
|
+
console.log = originalLog;
|
|
1988
|
+
}
|
|
1989
|
+
} else {
|
|
1990
|
+
console.log();
|
|
1991
|
+
console.log(pc.bold("Applying doctor --fix"));
|
|
1992
|
+
await runCompaction();
|
|
1993
|
+
}
|
|
1994
|
+
fixes.push({
|
|
1995
|
+
id: "agent-compact",
|
|
1996
|
+
title: "agent compact",
|
|
1997
|
+
status: "applied",
|
|
1998
|
+
detail: compaction.tokenBudgetMissingPages > 0 ? "Ran docs agent compact --stale --include-missing to refresh stale generated agent.md files and create token-budget missing outputs." : "Ran docs agent compact --stale to refresh stale generated agent.md files."
|
|
1999
|
+
});
|
|
2000
|
+
return fixes;
|
|
2001
|
+
}
|
|
2002
|
+
async function runDoctor(options = {}) {
|
|
2003
|
+
if (options.mode === "human") {
|
|
2004
|
+
if (options.fix) throw new Error("doctor --fix is currently only supported with --agent.");
|
|
2005
|
+
const report = await inspectHumanReadiness(options);
|
|
2006
|
+
applyDoctorExitCode(report, options);
|
|
2007
|
+
if (options.json) {
|
|
2008
|
+
printDoctorJsonReport(report);
|
|
2009
|
+
return report;
|
|
2010
|
+
}
|
|
2011
|
+
printHumanDoctorReport(report);
|
|
2012
|
+
return report;
|
|
2013
|
+
}
|
|
2014
|
+
let report = await inspectAgentReadiness(options);
|
|
2015
|
+
let fixes;
|
|
2016
|
+
if (options.fix) {
|
|
2017
|
+
fixes = await runAgentDoctorFixes(report, options);
|
|
2018
|
+
report = fixes.some((fix) => fix.status === "applied") ? {
|
|
2019
|
+
...await inspectAgentReadiness(options),
|
|
2020
|
+
fixes
|
|
2021
|
+
} : {
|
|
2022
|
+
...report,
|
|
2023
|
+
fixes
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
applyDoctorExitCode(report, options);
|
|
2027
|
+
if (options.json) {
|
|
2028
|
+
printDoctorJsonReport(report);
|
|
2029
|
+
return report;
|
|
2030
|
+
}
|
|
2031
|
+
printAgentDoctorReport(report);
|
|
2032
|
+
return report;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
//#endregion
|
|
2036
|
+
export { parseDoctorArgs, printDoctorHelp, runDoctor };
|