@doxbrix/doxloop 0.1.4 → 0.2.0

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.
Files changed (357) hide show
  1. package/CHANGELOG.md +1153 -0
  2. package/README.md +422 -226
  3. package/assets/doxbrix-preview.css +77 -15
  4. package/contracts/agent-events-v1.schema.json +1 -0
  5. package/contracts/coverage-v1.schema.json +59 -0
  6. package/contracts/drift-v1.schema.json +1 -0
  7. package/contracts/evaluation-v1.schema.json +62 -0
  8. package/contracts/quality-codes-v1.json +1 -0
  9. package/contracts/quality-report-v1.schema.json +1 -0
  10. package/contracts/validation-v1.schema.json +1 -0
  11. package/dist/agent-failure.d.ts +36 -0
  12. package/dist/agent-failure.js +98 -0
  13. package/dist/agent-isolation.d.ts +17 -0
  14. package/dist/agent-isolation.js +94 -0
  15. package/dist/agent-log.d.ts +197 -0
  16. package/dist/agent-log.js +1094 -0
  17. package/dist/agent-process.d.ts +63 -0
  18. package/dist/agent-process.js +128 -0
  19. package/dist/agent-reply.d.ts +47 -0
  20. package/dist/agent-reply.js +315 -0
  21. package/dist/agents.d.ts +40 -4
  22. package/dist/agents.js +115 -11
  23. package/dist/api-coverage.d.ts +11 -0
  24. package/dist/api-coverage.js +72 -0
  25. package/dist/application-probe.d.ts +28 -0
  26. package/dist/application-probe.js +93 -0
  27. package/dist/application-url.d.ts +14 -0
  28. package/dist/application-url.js +26 -0
  29. package/dist/args.js +15 -0
  30. package/dist/artifact-deploy.d.ts +9 -0
  31. package/dist/artifact-deploy.js +28 -8
  32. package/dist/assets.d.ts +72 -0
  33. package/dist/assets.js +382 -0
  34. package/dist/auth.d.ts +10 -0
  35. package/dist/auth.js +38 -9
  36. package/dist/author.d.ts +206 -4
  37. package/dist/author.js +1650 -52
  38. package/dist/authoring-batches.d.ts +190 -0
  39. package/dist/authoring-batches.js +489 -0
  40. package/dist/authoring-postpass.d.ts +79 -0
  41. package/dist/authoring-postpass.js +1289 -0
  42. package/dist/authoring-progress.d.ts +95 -0
  43. package/dist/authoring-progress.js +269 -0
  44. package/dist/autosync.d.ts +56 -0
  45. package/dist/autosync.js +442 -0
  46. package/dist/batch-artifacts.d.ts +81 -0
  47. package/dist/batch-artifacts.js +412 -0
  48. package/dist/batch-limits.d.ts +37 -0
  49. package/dist/batch-limits.js +80 -0
  50. package/dist/branding.d.ts +41 -0
  51. package/dist/branding.js +229 -0
  52. package/dist/bundle-upload.d.ts +26 -0
  53. package/dist/bundle-upload.js +99 -0
  54. package/dist/capture-auth.d.ts +95 -0
  55. package/dist/capture-auth.js +204 -0
  56. package/dist/capture.d.ts +48 -0
  57. package/dist/capture.js +20 -1
  58. package/dist/cli.js +839 -32
  59. package/dist/content-links.d.ts +10 -0
  60. package/dist/content-links.js +49 -0
  61. package/dist/contract-validation.d.ts +4 -0
  62. package/dist/contract-validation.js +25 -0
  63. package/dist/coverage-actions.d.ts +8 -0
  64. package/dist/coverage-actions.js +71 -0
  65. package/dist/coverage-resolutions.d.ts +16 -0
  66. package/dist/coverage-resolutions.js +58 -0
  67. package/dist/db.d.ts +37 -0
  68. package/dist/db.js +288 -0
  69. package/dist/demo.d.ts +12 -0
  70. package/dist/demo.js +122 -0
  71. package/dist/deploy-credentials.d.ts +7 -0
  72. package/dist/deploy-credentials.js +76 -0
  73. package/dist/deploy-targets/github-pages.d.ts +3 -0
  74. package/dist/deploy-targets/github-pages.js +100 -0
  75. package/dist/deploy-targets/index.d.ts +8 -0
  76. package/dist/deploy-targets/index.js +78 -0
  77. package/dist/deploy-targets/netlify.d.ts +3 -0
  78. package/dist/deploy-targets/netlify.js +49 -0
  79. package/dist/deploy-targets/types.d.ts +32 -0
  80. package/dist/deploy-targets/types.js +2 -0
  81. package/dist/deploy-targets/vercel.d.ts +3 -0
  82. package/dist/deploy-targets/vercel.js +95 -0
  83. package/dist/deploy.d.ts +9 -0
  84. package/dist/deploy.js +87 -13
  85. package/dist/deterministic-capture.d.ts +90 -0
  86. package/dist/deterministic-capture.js +435 -0
  87. package/dist/direct-edit.d.ts +40 -0
  88. package/dist/direct-edit.js +159 -0
  89. package/dist/docs-crawl.d.ts +86 -0
  90. package/dist/docs-crawl.js +536 -0
  91. package/dist/docs-site.d.ts +49 -0
  92. package/dist/docs-site.js +233 -0
  93. package/dist/doctor.js +8 -0
  94. package/dist/documentation-collections.d.ts +23 -0
  95. package/dist/documentation-collections.js +191 -0
  96. package/dist/documentation-plan.d.ts +253 -0
  97. package/dist/documentation-plan.js +2563 -0
  98. package/dist/doxbrix-build.d.ts +19 -0
  99. package/dist/doxbrix-build.js +167 -0
  100. package/dist/doxbrix-markdown.d.ts +9 -0
  101. package/dist/doxbrix-markdown.js +115 -14
  102. package/dist/drift.d.ts +10 -0
  103. package/dist/drift.js +164 -0
  104. package/dist/evaluation.d.ts +46 -0
  105. package/dist/evaluation.js +113 -0
  106. package/dist/evidence-pack.d.ts +47 -0
  107. package/dist/evidence-pack.js +358 -0
  108. package/dist/evidence.d.ts +25 -0
  109. package/dist/evidence.js +175 -0
  110. package/dist/fs.d.ts +8 -2
  111. package/dist/fs.js +41 -11
  112. package/dist/generator-api.d.ts +89 -0
  113. package/dist/generator-preflight.d.ts +27 -0
  114. package/dist/generator-preflight.js +105 -0
  115. package/dist/generator-runtime.d.ts +7 -0
  116. package/dist/generator-runtime.js +17 -1
  117. package/dist/generators.d.ts +24 -2
  118. package/dist/generators.js +54 -1
  119. package/dist/git-delivery.d.ts +17 -0
  120. package/dist/git-delivery.js +123 -0
  121. package/dist/globs.d.ts +16 -0
  122. package/dist/globs.js +65 -0
  123. package/dist/glossary.d.ts +26 -0
  124. package/dist/glossary.js +179 -0
  125. package/dist/history.d.ts +106 -0
  126. package/dist/history.js +600 -0
  127. package/dist/html-markdown.d.ts +46 -0
  128. package/dist/html-markdown.js +423 -0
  129. package/dist/interactive.js +16 -15
  130. package/dist/job-events.d.ts +74 -0
  131. package/dist/job-events.js +377 -0
  132. package/dist/keep-awake.d.ts +50 -0
  133. package/dist/keep-awake.js +123 -0
  134. package/dist/local-source-snapshot.d.ts +20 -0
  135. package/dist/local-source-snapshot.js +61 -0
  136. package/dist/mintlify-detect.d.ts +3 -0
  137. package/dist/mintlify-detect.js +18 -0
  138. package/dist/mintlify-import.d.ts +75 -0
  139. package/dist/mintlify-import.js +190 -0
  140. package/dist/navigation.d.ts +98 -0
  141. package/dist/navigation.js +310 -0
  142. package/dist/openapi.d.ts +60 -0
  143. package/dist/openapi.js +439 -0
  144. package/dist/page-editor-bridge.d.ts +3 -0
  145. package/dist/page-editor-bridge.js +109 -0
  146. package/dist/page-editor-preview.d.ts +10 -0
  147. package/dist/page-editor-preview.js +55 -0
  148. package/dist/page-extension.d.ts +9 -0
  149. package/dist/page-extension.js +15 -0
  150. package/dist/page-metadata.d.ts +28 -0
  151. package/dist/page-metadata.js +166 -0
  152. package/dist/page-operations.d.ts +34 -0
  153. package/dist/page-operations.js +215 -0
  154. package/dist/page-routes.d.ts +4 -0
  155. package/dist/page-routes.js +61 -0
  156. package/dist/pages.d.ts +20 -0
  157. package/dist/pages.js +184 -0
  158. package/dist/plan-generator.d.ts +3 -0
  159. package/dist/plan-generator.js +21 -0
  160. package/dist/plan-navigation.d.ts +11 -0
  161. package/dist/plan-navigation.js +30 -0
  162. package/dist/planning-captures.d.ts +20 -0
  163. package/dist/planning-captures.js +143 -0
  164. package/dist/planning-research.d.ts +135 -0
  165. package/dist/planning-research.js +472 -0
  166. package/dist/planning-triage.d.ts +23 -0
  167. package/dist/planning-triage.js +131 -0
  168. package/dist/preview.d.ts +24 -0
  169. package/dist/preview.js +280 -29
  170. package/dist/project-detect.d.ts +36 -0
  171. package/dist/project-detect.js +251 -0
  172. package/dist/project-import.d.ts +54 -0
  173. package/dist/project-import.js +157 -0
  174. package/dist/project-lock.d.ts +6 -0
  175. package/dist/project-lock.js +96 -0
  176. package/dist/project-registry.d.ts +25 -0
  177. package/dist/project-registry.js +79 -0
  178. package/dist/project.d.ts +35 -3
  179. package/dist/project.js +388 -38
  180. package/dist/prompts.d.ts +9 -0
  181. package/dist/prompts.js +33 -4
  182. package/dist/proposal-replay.d.ts +32 -0
  183. package/dist/proposal-replay.js +99 -0
  184. package/dist/quality-claims.d.ts +8 -0
  185. package/dist/quality-claims.js +168 -0
  186. package/dist/quality-config.d.ts +5 -0
  187. package/dist/quality-config.js +84 -0
  188. package/dist/quality-contract.d.ts +37 -0
  189. package/dist/quality-contract.js +46 -0
  190. package/dist/quality-examples.d.ts +4 -0
  191. package/dist/quality-examples.js +233 -0
  192. package/dist/quality-gates.d.ts +16 -0
  193. package/dist/quality-gates.js +192 -0
  194. package/dist/quality-links.d.ts +7 -0
  195. package/dist/quality-links.js +149 -0
  196. package/dist/quality-lint.d.ts +6 -0
  197. package/dist/quality-lint.js +124 -0
  198. package/dist/quality-rendered.d.ts +11 -0
  199. package/dist/quality-rendered.js +222 -0
  200. package/dist/quality-schema.d.ts +3 -0
  201. package/dist/quality-schema.js +71 -0
  202. package/dist/release-notes.d.ts +44 -0
  203. package/dist/release-notes.js +183 -0
  204. package/dist/remote-monitor.d.ts +16 -0
  205. package/dist/remote-monitor.js +74 -0
  206. package/dist/remote-source.d.ts +34 -0
  207. package/dist/remote-source.js +426 -0
  208. package/dist/review-diff.d.ts +82 -0
  209. package/dist/review-diff.js +400 -0
  210. package/dist/review-learning.d.ts +11 -0
  211. package/dist/review-learning.js +60 -0
  212. package/dist/review-render.d.ts +38 -0
  213. package/dist/review-render.js +224 -0
  214. package/dist/review-report.d.ts +9 -0
  215. package/dist/review-report.js +89 -0
  216. package/dist/review-ui.d.ts +14 -0
  217. package/dist/review-ui.js +1248 -0
  218. package/dist/schedule.d.ts +78 -0
  219. package/dist/schedule.js +480 -0
  220. package/dist/screen-capture-provider.d.ts +52 -0
  221. package/dist/screen-capture-provider.js +218 -0
  222. package/dist/screenshot-workflow.d.ts +167 -0
  223. package/dist/screenshot-workflow.js +1237 -0
  224. package/dist/settings.d.ts +1 -1
  225. package/dist/settings.js +95 -7
  226. package/dist/site-export.d.ts +18 -0
  227. package/dist/site-export.js +87 -0
  228. package/dist/source-connectors.d.ts +33 -0
  229. package/dist/source-connectors.js +268 -0
  230. package/dist/source-discovery.d.ts +132 -0
  231. package/dist/source-discovery.js +823 -0
  232. package/dist/source-intelligence.d.ts +9 -0
  233. package/dist/source-intelligence.js +306 -0
  234. package/dist/sync-review.d.ts +28 -0
  235. package/dist/sync-review.js +264 -0
  236. package/dist/sync-runs.d.ts +192 -0
  237. package/dist/sync-runs.js +2244 -0
  238. package/dist/sync.d.ts +35 -0
  239. package/dist/sync.js +298 -32
  240. package/dist/text-diff.d.ts +9 -0
  241. package/dist/text-diff.js +59 -0
  242. package/dist/types.d.ts +946 -1
  243. package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
  244. package/dist/ui/assets/index-BHBYU2aG.css +1 -0
  245. package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
  246. package/dist/ui/index.html +18 -0
  247. package/dist/ui-server.d.ts +83 -0
  248. package/dist/ui-server.js +3532 -0
  249. package/dist/usage-budget.d.ts +28 -0
  250. package/dist/usage-budget.js +90 -0
  251. package/dist/validation.d.ts +25 -1
  252. package/dist/validation.js +312 -26
  253. package/dist/workspace-tools.d.ts +54 -0
  254. package/dist/workspace-tools.js +123 -0
  255. package/docs/agent-compatibility.md +50 -28
  256. package/docs/ci-and-automation.md +105 -66
  257. package/docs/doxbrix-http-api.md +8 -1
  258. package/docs/existing-documentation.md +80 -0
  259. package/docs/generation-performance.md +108 -0
  260. package/docs/generator-authoring.md +68 -5
  261. package/docs/generator-selection.md +50 -13
  262. package/docs/mintlify-import.md +71 -0
  263. package/docs/openapi-security.md +25 -0
  264. package/docs/project-format.md +331 -36
  265. package/docs/release-quality.md +158 -0
  266. package/docs/releasing.md +72 -0
  267. package/docs/review-workflows.md +51 -0
  268. package/docs/security-model.md +102 -39
  269. package/docs/troubleshooting.md +226 -93
  270. package/package.json +46 -15
  271. package/scripts/test-auto-screenshot.mjs +172 -0
  272. package/skills/doxloop-authoring/SKILL.md +242 -361
  273. package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
  274. package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
  275. package/skills/doxloop-authoring/references/page-depth.md +169 -0
  276. package/skills/doxloop-authoring/references/project-format.md +106 -7
  277. package/skills/doxloop-authoring/references/quality.md +10 -0
  278. package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
  279. package/skills/doxloop-authoring/references/screenshots.md +149 -235
  280. package/skills/doxloop-authoring/references/workflows.md +76 -0
  281. package/skills/doxloop-doxbrix/SKILL.md +38 -22
  282. package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
  283. package/skills/doxloop-doxbrix/references/components.md +30 -3
  284. package/skills/doxloop-doxbrix/references/manifest.md +3 -2
  285. package/vendor/doxbrix-import/LICENSE +202 -0
  286. package/vendor/doxbrix-import/README.md +17 -0
  287. package/vendor/doxbrix-import/UPSTREAM.json +47 -0
  288. package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
  289. package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
  290. package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
  291. package/vendor/doxbrix-import/dist/docs/import.js +147 -0
  292. package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
  293. package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
  294. package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
  295. package/vendor/doxbrix-import/dist/docs/project.js +77 -0
  296. package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
  297. package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
  298. package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
  299. package/vendor/doxbrix-import/dist/importer.js +1567 -0
  300. package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
  301. package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
  302. package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
  303. package/vendor/doxbrix-import/dist/safe-path.js +47 -0
  304. package/dist/agents.d.ts.map +0 -1
  305. package/dist/agents.js.map +0 -1
  306. package/dist/args.d.ts.map +0 -1
  307. package/dist/args.js.map +0 -1
  308. package/dist/artifact-deploy.d.ts.map +0 -1
  309. package/dist/artifact-deploy.js.map +0 -1
  310. package/dist/auth.d.ts.map +0 -1
  311. package/dist/auth.js.map +0 -1
  312. package/dist/author.d.ts.map +0 -1
  313. package/dist/author.js.map +0 -1
  314. package/dist/capture.d.ts.map +0 -1
  315. package/dist/capture.js.map +0 -1
  316. package/dist/cli.d.ts.map +0 -1
  317. package/dist/cli.js.map +0 -1
  318. package/dist/deploy.d.ts.map +0 -1
  319. package/dist/deploy.js.map +0 -1
  320. package/dist/deployment-visibility.d.ts.map +0 -1
  321. package/dist/deployment-visibility.js.map +0 -1
  322. package/dist/doctor.d.ts.map +0 -1
  323. package/dist/doctor.js.map +0 -1
  324. package/dist/doxbrix-markdown.d.ts.map +0 -1
  325. package/dist/doxbrix-markdown.js.map +0 -1
  326. package/dist/errors.d.ts.map +0 -1
  327. package/dist/errors.js.map +0 -1
  328. package/dist/fs.d.ts.map +0 -1
  329. package/dist/fs.js.map +0 -1
  330. package/dist/generator-api.d.ts.map +0 -1
  331. package/dist/generator-api.js.map +0 -1
  332. package/dist/generator-manager.d.ts.map +0 -1
  333. package/dist/generator-manager.js.map +0 -1
  334. package/dist/generator-runtime.d.ts.map +0 -1
  335. package/dist/generator-runtime.js.map +0 -1
  336. package/dist/generators.d.ts.map +0 -1
  337. package/dist/generators.js.map +0 -1
  338. package/dist/interactive.d.ts.map +0 -1
  339. package/dist/interactive.js.map +0 -1
  340. package/dist/preview.d.ts.map +0 -1
  341. package/dist/preview.js.map +0 -1
  342. package/dist/progress.d.ts.map +0 -1
  343. package/dist/progress.js.map +0 -1
  344. package/dist/project.d.ts.map +0 -1
  345. package/dist/project.js.map +0 -1
  346. package/dist/prompts.d.ts.map +0 -1
  347. package/dist/prompts.js.map +0 -1
  348. package/dist/settings.d.ts.map +0 -1
  349. package/dist/settings.js.map +0 -1
  350. package/dist/sync.d.ts.map +0 -1
  351. package/dist/sync.js.map +0 -1
  352. package/dist/types.d.ts.map +0 -1
  353. package/dist/types.js.map +0 -1
  354. package/dist/validation.d.ts.map +0 -1
  355. package/dist/validation.js.map +0 -1
  356. package/dist/version.d.ts.map +0 -1
  357. package/dist/version.js.map +0 -1
@@ -0,0 +1,28 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { AgentUsage } from './types.js';
3
+ export declare function isAccountLimit(reason: string | undefined): boolean;
4
+ export declare const budgetContext: AsyncLocalStorage<UsageBudget>;
5
+ export declare class UsageBudget {
6
+ readonly file: string;
7
+ readonly maxTokens: number;
8
+ readonly maxUsd: number;
9
+ private sessions;
10
+ private stops;
11
+ private saving;
12
+ private lastSave;
13
+ stoppedReason: string | undefined;
14
+ constructor(file: string, maxTokens: number, maxUsd: number);
15
+ static open(root: string, planId: string, maxUsd?: number): Promise<UsageBudget>;
16
+ get totals(): {
17
+ tokens: number;
18
+ cost: number;
19
+ };
20
+ get remainingUsd(): number | undefined;
21
+ assertAvailable(): void;
22
+ register(stop: () => void): string;
23
+ update(id: string, usage: AgentUsage | undefined, reason?: string): void;
24
+ stop(reason: string): void;
25
+ finish(id: string, usage: AgentUsage | undefined, reason?: string): Promise<void>;
26
+ flush(): Promise<void>;
27
+ }
28
+ //# sourceMappingURL=usage-budget.d.ts.map
@@ -0,0 +1,90 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ export function isAccountLimit(reason) {
6
+ return /hit your (?:session|usage|weekly|daily) limit|usage limit|quota.{0,30}(?:exceeded|exhausted)|insufficient_quota|credit balance|rate.limit|budget exhausted/i.test(reason ?? '');
7
+ }
8
+ export const budgetContext = new AsyncLocalStorage();
9
+ export class UsageBudget {
10
+ file;
11
+ maxTokens;
12
+ maxUsd;
13
+ sessions = {};
14
+ stops = new Map();
15
+ saving = Promise.resolve();
16
+ lastSave = 0;
17
+ stoppedReason;
18
+ constructor(file, maxTokens, maxUsd) {
19
+ this.file = file;
20
+ this.maxTokens = maxTokens;
21
+ this.maxUsd = maxUsd;
22
+ }
23
+ static async open(root, planId, maxUsd) {
24
+ // No limit unless one is set explicitly: a run should never stop on its own because of a built-in cap.
25
+ const positive = (raw, fallback) => Number.isFinite(Number(raw)) && Number(raw) > 0 ? Number(raw) : fallback;
26
+ if (!/^[\w-]+$/.test(planId))
27
+ throw new Error('Invalid budget plan ID');
28
+ const budget = new UsageBudget(join(root, '.doxloop', 'plans', planId, 'usage-budget.json'), positive(process.env.DOXLOOP_MAX_TOKENS, Number.POSITIVE_INFINITY), positive(process.env.DOXLOOP_MAX_USD, maxUsd ?? Number.POSITIVE_INFINITY));
29
+ try {
30
+ const saved = JSON.parse(await readFile(budget.file, 'utf8'));
31
+ if (!saved.sessions || typeof saved.sessions !== 'object' || Array.isArray(saved.sessions) || Object.values(saved.sessions).some((value) => !value || !Number.isFinite(value.tokens) || value.tokens < 0 || !Number.isFinite(value.cost) || value.cost < 0))
32
+ throw new Error('Invalid saved usage ledger');
33
+ budget.sessions = saved.sessions;
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'ENOENT')
37
+ throw new Error(`Cannot read the saved usage budget: ${String(error)}. Preserve the ledger and repair it before resuming.`);
38
+ }
39
+ return budget;
40
+ }
41
+ get totals() {
42
+ return Object.values(this.sessions).reduce((a, b) => ({ tokens: a.tokens + b.tokens, cost: a.cost + b.cost }), { tokens: 0, cost: 0 });
43
+ }
44
+ get remainingUsd() { return Number.isFinite(this.maxUsd) ? Math.max(0, this.maxUsd - this.totals.cost) : undefined; }
45
+ assertAvailable() {
46
+ const totals = this.totals;
47
+ if (!this.stoppedReason && (totals.tokens >= this.maxTokens || totals.cost >= this.maxUsd))
48
+ this.stop(`Documentation budget exhausted (${totals.tokens} tokens, $${totals.cost.toFixed(2)}). Completed work is saved. Raise or clear DOXLOOP_MAX_TOKENS / DOXLOOP_MAX_USD or the project spend cap to continue this plan.`);
49
+ if (this.stoppedReason)
50
+ throw new Error(this.stoppedReason);
51
+ }
52
+ register(stop) { this.assertAvailable(); const id = randomUUID(); this.stops.set(id, stop); return id; }
53
+ update(id, usage, reason) {
54
+ if (usage)
55
+ this.sessions[id] = { tokens: usage.totalTokens, cost: usage.costUsd ?? 0 };
56
+ if (isAccountLimit(reason))
57
+ this.stop(`Agent account limit reached: ${reason}. Completed work is saved; resume after the allowance resets.`);
58
+ try {
59
+ this.assertAvailable();
60
+ }
61
+ catch { /* Stop callbacks checkpoint active sessions. */ }
62
+ if (Date.now() - this.lastSave > 2000) {
63
+ this.lastSave = Date.now();
64
+ void this.flush().catch(() => { });
65
+ }
66
+ }
67
+ stop(reason) {
68
+ if (this.stoppedReason)
69
+ return;
70
+ this.stoppedReason = reason;
71
+ for (const stop of this.stops.values())
72
+ stop();
73
+ }
74
+ async finish(id, usage, reason) {
75
+ this.stops.delete(id);
76
+ this.update(id, usage, reason);
77
+ await this.flush();
78
+ }
79
+ async flush() {
80
+ const value = JSON.stringify({ schemaVersion: 1, sessions: this.sessions, totals: this.totals });
81
+ this.saving = this.saving.catch(() => { }).then(async () => {
82
+ await mkdir(dirname(this.file), { recursive: true });
83
+ const temp = `${this.file}.${randomUUID()}.tmp`;
84
+ await writeFile(temp, value, { mode: 0o600 });
85
+ await rename(temp, this.file);
86
+ });
87
+ return this.saving;
88
+ }
89
+ }
90
+ //# sourceMappingURL=usage-budget.js.map
@@ -1,4 +1,28 @@
1
- import type { ValidationResult } from './types.js';
1
+ import type { DoxbrixNavNode, ValidationIssue, ValidationResult } from './types.js';
2
2
  export declare function validateProject(root: string): Promise<ValidationResult>;
3
+ export interface DoxbrixNavigationEntry {
4
+ path: string;
5
+ section?: string;
6
+ }
7
+ /** Flatten Doxbrix navigation in reader order for validation and page discovery. */
8
+ export declare function readDoxbrixNavigation(spaces: Array<{
9
+ name: string;
10
+ version?: string;
11
+ nav: DoxbrixNavNode[];
12
+ }>): DoxbrixNavigationEntry[];
13
+ /** Theme checks shared by validation and the branding panel's pre-write check. */
14
+ export declare function validateDoxbrixTheme(value: unknown, contentRoot: string, configFile: string, issues: ValidationIssue[]): Promise<void>;
15
+ /**
16
+ * Whether the section around an <ApiEndpoint> (from the heading before it to
17
+ * the next heading or endpoint) carries a fenced request for it: a cURL
18
+ * command, an HTTPie call, or a raw HTTP request line naming its path.
19
+ */
20
+ export declare function hasRequestSample(masked: string, raw: string, start: number, end: number, path: string): boolean;
21
+ /** A Mermaid diagram in any of the syntaxes the supported generators render. */
22
+ export declare function hasDiagram(raw: string): boolean;
23
+ export declare function validateShellExamples(raw: string, file: string): ValidationIssue[];
24
+ export declare function validatePageDepth(body: string, file: string, planType?: string): ValidationIssue[];
25
+ /** Generated scaffolding the authoring agent is told to replace: a starter marker or its placeholder language. */
26
+ export declare function isStarterContent(content: string): boolean;
3
27
  export declare function formatValidation(result: ValidationResult): string;
4
28
  //# sourceMappingURL=validation.d.ts.map
@@ -1,22 +1,29 @@
1
+ import { documentationCollections, collectionForPath } from './documentation-collections.js';
2
+ import { contentLinks } from './content-links.js';
1
3
  import { access, readFile } from 'node:fs/promises';
2
4
  import { dirname, extname, join, relative, resolve } from 'node:path';
5
+ import { EVIDENCE_MAP_FILE, readEvidenceMap } from './evidence.js';
6
+ import { readSyncState } from './sync.js';
3
7
  import { pathExists, resolveContainedDirectory } from './fs.js';
4
8
  import { loadGeneratorAdapter } from './generators.js';
5
9
  import { loadPages, loadProject, loadSiteConfig, pageId, readPage, relativePath, siteConfigPath, } from './project.js';
6
10
  export async function validateProject(root) {
7
11
  const issues = [];
8
12
  const project = await loadProject(root);
9
- const contentRoot = await resolveContainedDirectory(root, project.contentDir, 'Validation content directory');
13
+ const contentRoot = await resolveContainedDirectory(root, project.contentDir, 'Validation content directory', { allowRoot: project.generator === 'doxbrix' });
14
+ const collections = await documentationCollections(root, project);
10
15
  const files = await loadPages(root, project);
11
16
  const pages = files.map((path) => pageId(contentRoot, path));
12
17
  const pageSet = new Set(pages);
13
18
  const adapter = project.generator === 'doxbrix'
14
19
  ? undefined
15
20
  : await loadGeneratorAdapter(root, project);
21
+ const planTypes = await plannedPageTypes(root, project);
16
22
  if (project.generator === 'doxbrix') {
17
23
  const site = await loadSiteConfig(root, project);
18
24
  const configFile = relativePath(root, await siteConfigPath(root, project));
19
- validateDoxbrixNavigation(site.spaces, pageSet, pages, configFile, issues);
25
+ const defaultVersion = (site.versions?.find((entry) => entry.default || entry.isDefault) ?? site.versions?.[0])?.version;
26
+ validateDoxbrixNavigation(site.spaces.map((space) => ({ ...space, ...(defaultVersion && !space.version ? { version: defaultVersion } : {}) })), pageSet, pages, configFile, issues);
20
27
  await validateDoxbrixTheme(site.theme, contentRoot, configFile, issues);
21
28
  }
22
29
  else {
@@ -24,8 +31,8 @@ export async function validateProject(root) {
24
31
  root,
25
32
  contentRoot,
26
33
  project,
27
- pages: files,
28
- pageIds: pages,
34
+ pages: files.filter((file) => file.startsWith(`${contentRoot}/`)),
35
+ pageIds: files.filter((file) => file.startsWith(`${contentRoot}/`)).map((file) => pageId(contentRoot, file)),
29
36
  })));
30
37
  }
31
38
  for (const path of files) {
@@ -51,16 +58,106 @@ export async function validateProject(root) {
51
58
  issues.push(...validateDoxbrixComponents(raw, file));
52
59
  }
53
60
  issues.push(...validateProfessionalContent(page.body, raw, file));
54
- if ((adapter?.project.contentFormat ?? 'markdown') === 'markdown') {
55
- issues.push(...(await validateLinks(path, contentRoot, raw, root, adapter)));
61
+ issues.push(...validateShellExamples(raw, file));
62
+ const planned = planTypes.get(file.replace(/\.[^./]+$/, ''));
63
+ issues.push(...validatePageDepth(page.body, file, planned?.type));
64
+ if (planned?.diagram === 'required' && !hasDiagram(raw)) {
65
+ issues.push(warning('missing-diagram', 'The approved plan requires a diagram on this page. Add a Mermaid block that shows the model or lifecycle it explains.', file));
56
66
  }
67
+ issues.push(...(await validateLinks(path, resolve(root, collectionForPath(collections, file)?.directory ?? project.contentDir), raw, root, adapter)));
57
68
  }
69
+ issues.push(...(await validateEvidenceMap(root, files.map((path) => relativePath(root, path)), project)));
58
70
  const errors = issues.filter((issue) => issue.severity === 'error').length;
59
71
  const warnings = issues.length - errors;
60
72
  return { issues, pages, errors, warnings };
61
73
  }
74
+ /**
75
+ * Coverage checks for `.doxloop/evidence-map.json`. These are warnings and run
76
+ * only once a project has a map, so projects that predate it are unaffected
77
+ * and a partially recorded map never blocks validation.
78
+ */
79
+ async function validateEvidenceMap(root, pageFiles, project) {
80
+ const map = await readEvidenceMap(root);
81
+ if (!map)
82
+ return [];
83
+ const issues = [];
84
+ const pages = new Set(pageFiles);
85
+ const sourceNames = new Set(project.sources.map((source) => source.name));
86
+ const pathCoverage = new Map();
87
+ const syncState = project.sync.maxVerificationAgeDays ? await readSyncState(root) : undefined;
88
+ for (const [page, evidence] of Object.entries(map.pages)) {
89
+ if (!pages.has(page)) {
90
+ issues.push(warning('evidence-map-orphan', `The evidence map records "${page}", which is not a documentation page. Remove the entry when a page is deleted or renamed.`, EVIDENCE_MAP_FILE));
91
+ continue;
92
+ }
93
+ for (const entry of evidence.sources) {
94
+ if (!sourceNames.has(entry.source)) {
95
+ issues.push(warning('evidence-map-unknown-source', `The evidence map binds "${page}" to source "${entry.source}", which is not configured.`, EVIDENCE_MAP_FILE));
96
+ }
97
+ for (const path of entry.paths ?? []) {
98
+ const key = `${entry.source}:${path}`;
99
+ const covered = pathCoverage.get(key) ?? new Set();
100
+ covered.add(page);
101
+ pathCoverage.set(key, covered);
102
+ }
103
+ if (project.sync.maxVerificationAgeDays) {
104
+ const record = syncState?.sources[entry.source];
105
+ const revision = evidence.verifiedAt?.[entry.source];
106
+ const verifiedOn = evidence.verifiedOn?.[entry.source] ?? (record && revision && [record.commit, record.contentFingerprint].includes(revision) ? record.recordedAt : undefined);
107
+ const ageDays = verifiedOn ? Math.floor((Date.now() - Date.parse(verifiedOn)) / 86_400_000) : Number.POSITIVE_INFINITY;
108
+ if (ageDays > project.sync.maxVerificationAgeDays) {
109
+ const severity = project.sync.maxVerificationAgeSeverity === 'fail' ? 'error' : 'warning';
110
+ issues.push({
111
+ severity,
112
+ code: 'evidence-verification-expired',
113
+ message: `Verification for source "${entry.source}" is ${Number.isFinite(ageDays) ? `${ageDays} days old` : 'not dated'}; the maximum is ${project.sync.maxVerificationAgeDays} days. Re-verify the page even when the source revision is unchanged.`,
114
+ file: page,
115
+ });
116
+ }
117
+ }
118
+ }
119
+ if (evidence.confidence === 'needs-human') {
120
+ issues.push(warning('evidence-unverified', 'A claim on this page could not be verified from configured evidence and needs human confirmation.', page));
121
+ }
122
+ }
123
+ if (pageFiles.length >= 4) {
124
+ for (const [key, covered] of pathCoverage) {
125
+ if (covered.size <= pageFiles.length / 2)
126
+ continue;
127
+ const separator = key.indexOf(':');
128
+ const source = key.slice(0, separator);
129
+ const path = key.slice(separator + 1);
130
+ issues.push(warning('evidence-map-broad-path', `Source path "${path}" from "${source}" is attached to ${covered.size} of ${pageFiles.length} pages. Verify that each page directly depends on it; broad evidence makes localized changes mark most documentation stale.`, EVIDENCE_MAP_FILE));
131
+ }
132
+ }
133
+ for (const page of pageFiles) {
134
+ if (!map.pages[page]) {
135
+ issues.push(warning('evidence-map-missing-page', 'This page has no evidence-map entry, so `doxloop check` cannot report when its sources change.', page));
136
+ }
137
+ }
138
+ return issues;
139
+ }
140
+ /** Flatten Doxbrix navigation in reader order for validation and page discovery. */
141
+ export function readDoxbrixNavigation(spaces) {
142
+ const entries = [];
143
+ const visit = (nodes, section) => {
144
+ for (const node of nodes) {
145
+ if (node.type === 'page' && typeof node.file === 'string' && node.file.trim()) {
146
+ entries.push({ path: node.file, ...(section ? { section } : {}) });
147
+ }
148
+ else if (node.type === 'group' && Array.isArray(node.items)) {
149
+ visit(node.items, node.label || section);
150
+ }
151
+ }
152
+ };
153
+ for (const space of spaces)
154
+ visit(space.nav, space.name);
155
+ return entries;
156
+ }
62
157
  function validateDoxbrixNavigation(spaces, pageSet, pages, configFile, issues) {
63
158
  const navigation = new Set();
159
+ const versions = new Map();
160
+ let versionNavigation = new Set();
64
161
  if (spaces.length === 0) {
65
162
  issues.push(error('navigation-spaces', 'Doxbrix docs.json needs at least one space.', configFile));
66
163
  }
@@ -76,10 +173,11 @@ function validateDoxbrixNavigation(spaces, pageSet, pages, configFile, issues) {
76
173
  issues.push(error('navigation-page', `${nodeLocation} needs a page file.`, configFile));
77
174
  continue;
78
175
  }
79
- if (navigation.has(node.file)) {
176
+ if (versionNavigation.has(node.file)) {
80
177
  issues.push(error('duplicate-navigation', `"${node.file}" appears more than once.`, configFile));
81
178
  }
82
179
  navigation.add(node.file);
180
+ versionNavigation.add(node.file);
83
181
  if (!pageSet.has(node.file)) {
84
182
  issues.push(error('missing-page', `Navigation references missing page "${node.file}".`, configFile));
85
183
  }
@@ -90,6 +188,11 @@ function validateDoxbrixNavigation(spaces, pageSet, pages, configFile, issues) {
90
188
  continue;
91
189
  }
92
190
  visit(node.items, `group "${node.label}"`);
191
+ // A group that holds one page is a page with an extra click in front
192
+ // of it; the planner is told to give every group at least two.
193
+ if (nodes.filter((sibling) => sibling && typeof sibling === 'object' && sibling.type === 'group').length > 1 && node.items.filter((item) => item && typeof item === 'object' && item.type === 'page').length === 1 && node.items.length === 1) {
194
+ issues.push(warning('single-page-group', `Navigation group "${node.label}" holds one page; merge it into a neighbouring group or give it the pages that belong with it.`, configFile));
195
+ }
93
196
  }
94
197
  else if (!['label', 'divider', 'link', 'api'].includes(node.type)) {
95
198
  issues.push(error('navigation-node', `${nodeLocation} has unsupported type "${node.type}".`, configFile));
@@ -101,15 +204,47 @@ function validateDoxbrixNavigation(spaces, pageSet, pages, configFile, issues) {
101
204
  issues.push(error('navigation-space', `Space ${index + 1} must have a name and nav array.`, configFile));
102
205
  continue;
103
206
  }
207
+ const version = space.version ?? '';
208
+ versionNavigation = versions.get(version) ?? new Set();
209
+ versions.set(version, versionNavigation);
210
+ const before = versionNavigation.size;
104
211
  visit(space.nav, `space "${space.name}"`);
212
+ // A top-level space is a destination in the site header. One that holds
213
+ // a handful of pages splits a reader journey for no gain; the planner is
214
+ // asked for at least five pages before it promotes a surface to a space.
215
+ if (spaces.length > 1 && versionNavigation.size - before < 5) {
216
+ issues.push(warning('thin-space', `Space "${space.name}" holds ${versionNavigation.size - before} page${versionNavigation.size - before === 1 ? '' : 's'}; fold it into another space as a group, or give it the pages that make it a real reader surface.`, configFile));
217
+ }
218
+ if (/^(?:documentation|docs|reference)$/i.test(space.name.trim()) && spaces.length > 1) {
219
+ issues.push(warning('generic-space-name', `Space "${space.name}" is a generic label; name spaces after the product's reader surfaces (for example "Guides", "API", "Self-hosting").`, configFile));
220
+ }
105
221
  }
106
222
  for (const id of pages) {
107
223
  if (!navigation.has(id)) {
108
224
  issues.push(error('unnavigated-page', `Page "${id}" is not in Doxbrix navigation.`));
109
225
  }
110
226
  }
227
+ // Doxbrix serves a page at its file name, so "guides/troubleshooting" and
228
+ // "embed/troubleshooting" share one address and the published site shows
229
+ // only one of them. Within each version, file names must be unique.
230
+ for (const ids of versions.values()) {
231
+ const byName = new Map();
232
+ for (const id of ids) {
233
+ // Each edition (editions/<version>/<locale>/…) is its own set of routes.
234
+ const edition = id.startsWith('editions/') ? id.split('/').slice(0, 3).join('/') : '';
235
+ const name = `${edition}:${id.split('/').at(-1) === 'index' ? id : id.split('/').at(-1)}`;
236
+ byName.set(name, [...(byName.get(name) ?? []), id]);
237
+ }
238
+ for (const [name, same] of byName) {
239
+ if (same.length < 2)
240
+ continue;
241
+ const fileName = name.slice(name.indexOf(':') + 1);
242
+ issues.push(error('duplicate-page-name', `Pages ${same.map((id) => `"${id}"`).join(' and ')} share the file name "${fileName}". Doxbrix serves pages by file name, so only one would be published; rename the others (for example "${same[1].replace(/[^/]+$/, `${same[1].split('/').at(-2) ?? 'more'}-${fileName}`)}").`, configFile));
243
+ }
244
+ }
111
245
  }
112
- async function validateDoxbrixTheme(value, contentRoot, configFile, issues) {
246
+ /** Theme checks shared by validation and the branding panel's pre-write check. */
247
+ export async function validateDoxbrixTheme(value, contentRoot, configFile, issues) {
113
248
  if (value === undefined)
114
249
  return;
115
250
  if (value === 'light' || value === 'dark' || value === 'system')
@@ -255,7 +390,7 @@ function validateDoxbrixComponents(content, file) {
255
390
  issues.push(error('component-tag', `Line ${opening.line}: <${opening.name}> does not have a matching closing tag.`, file));
256
391
  }
257
392
  if (issues.length === 0) {
258
- issues.push(...validateDoxbrixApiEndpoints(masked, file));
393
+ issues.push(...validateDoxbrixApiEndpoints(masked, file, content));
259
394
  }
260
395
  return issues;
261
396
  }
@@ -384,7 +519,13 @@ function maskDoxbrixCode(content) {
384
519
  });
385
520
  return masked.join('\n');
386
521
  }
387
- function validateDoxbrixApiEndpoints(content, file) {
522
+ /**
523
+ * `content` has its code spans and fences masked to spaces (same length, same
524
+ * offsets) so component tags inside examples are not parsed; `raw` is the
525
+ * unmasked page, consulted only where the example itself is the payload — a
526
+ * fenced SVG or RSS body inside <Response> is a body, not an empty one.
527
+ */
528
+ function validateDoxbrixApiEndpoints(content, file, raw = content) {
388
529
  const issues = [];
389
530
  for (const [index, match] of [
390
531
  ...content.matchAll(/<ApiEndpoint\b([^>]*)>([\s\S]*?)<\/ApiEndpoint>/g),
@@ -392,6 +533,7 @@ function validateDoxbrixApiEndpoints(content, file) {
392
533
  const label = `API endpoint ${index + 1}`;
393
534
  const attributes = componentAttributes(match[1] ?? '');
394
535
  const body = match[2] ?? '';
536
+ const bodyOffset = (match.index ?? 0) + match[0].length - body.length - '</ApiEndpoint>'.length;
395
537
  const method = stringAttribute(attributes.method).toUpperCase();
396
538
  const path = stringAttribute(attributes.path);
397
539
  const baseUrl = stringAttribute(attributes.baseUrl);
@@ -404,11 +546,17 @@ function validateDoxbrixApiEndpoints(content, file) {
404
546
  if (!/^https?:\/\/[^/\s]+/i.test(baseUrl)) {
405
547
  issues.push(error('api-endpoint-base-url', `${label} needs an absolute HTTP or HTTPS baseUrl so Doxbrix can generate the request example.`, file));
406
548
  }
407
- if (stringAttribute(attributes.summary) === '' ||
549
+ // Doxbrix titles the endpoint card with `summary` and shows `description`
550
+ // under it only when present (its own OpenAPI importer omits an empty
551
+ // one), so one of the two is the contract. Demanding both produced 98
552
+ // warnings on a real run whose endpoints all had a summary and whose
553
+ // prose already described each operation.
554
+ if (stringAttribute(attributes.summary) === '' &&
408
555
  stringAttribute(attributes.description) === '') {
409
- issues.push(warning('api-endpoint-description', `${label} should include both summary and description attributes.`, file));
556
+ issues.push(warning('api-endpoint-description', `${label} should include a summary attribute.`, file));
410
557
  }
411
558
  const pathParameters = new Set();
559
+ const withoutExample = [];
412
560
  const paramPattern = /<Param\b([^>]*)>([\s\S]*?)<\/Param>/g;
413
561
  for (const [paramIndex, paramMatch] of [
414
562
  ...body.matchAll(paramPattern),
@@ -426,12 +574,20 @@ function validateDoxbrixApiEndpoints(content, file) {
426
574
  issues.push(error('api-endpoint-path-param', `${label} path parameter "${name}" must set required.`, file));
427
575
  }
428
576
  }
429
- if (stringAttribute(param.example) === '') {
430
- issues.push(warning('api-endpoint-param-example', `${label} parameter "${name || paramIndex + 1}" should include a verified example for the generated request.`, file));
431
- }
577
+ if (stringAttribute(param.example) === '')
578
+ withoutExample.push(name || String(paramIndex + 1));
579
+ }
580
+ // One suggestion per endpoint, not per parameter, and none when the page
581
+ // already shows a request for this endpoint in a code sample: a real run
582
+ // spent two fix sessions on 204 per-parameter warnings for endpoints
583
+ // whose cURL examples were already complete.
584
+ if (withoutExample.length > 0 && !hasRequestSample(content, raw, match.index ?? 0, (match.index ?? 0) + match[0].length, path)) {
585
+ issues.push(warning('api-endpoint-param-example', `${label} parameter${withoutExample.length === 1 ? '' : 's'} ${withoutExample.map((name) => `"${name}"`).join(', ')} should include a verified example for the generated request.`, file));
432
586
  }
433
587
  for (const placeholder of path.matchAll(/\{([^}]+)}/g)) {
434
- const name = placeholder[1] ?? '';
588
+ // A gRPC-gateway template names its variable before an "=" pattern:
589
+ // "/api/v1/{name=memos/*}" is the path parameter "name".
590
+ const name = (placeholder[1] ?? '').split('=')[0].trim();
435
591
  if (!pathParameters.has(name)) {
436
592
  issues.push(error('api-endpoint-path-param', `${label} path placeholder "{${name}}" needs a matching required <Param in="path">.`, file));
437
593
  }
@@ -450,13 +606,40 @@ function validateDoxbrixApiEndpoints(content, file) {
450
606
  stringAttribute(response.description) === '') {
451
607
  issues.push(warning('api-endpoint-response-metadata', `${label} response ${status || responseIndex + 1} should include contentType and description.`, file));
452
608
  }
453
- if ((responseMatch[2] ?? '').trim() === '') {
609
+ const responseBody = responseMatch[2] ?? '';
610
+ const responseOffset = bodyOffset + (responseMatch.index ?? 0) + responseMatch[0].length - responseBody.length - '</Response>'.length;
611
+ const rawResponseBody = raw.slice(responseOffset, responseOffset + responseBody.length);
612
+ if (responseBody.trim() === '' && rawResponseBody.trim() === '') {
454
613
  issues.push(error('api-endpoint-response', `${label} response ${status || responseIndex + 1} needs an example body.`, file));
455
614
  }
456
615
  }
457
616
  }
458
617
  return issues;
459
618
  }
619
+ /**
620
+ * Whether the section around an <ApiEndpoint> (from the heading before it to
621
+ * the next heading or endpoint) carries a fenced request for it: a cURL
622
+ * command, an HTTPie call, or a raw HTTP request line naming its path.
623
+ */
624
+ export function hasRequestSample(masked, raw, start, end, path) {
625
+ // Section boundaries come from the masked page, where a `# comment` inside
626
+ // a shell fence is blank, not a heading; the fences come from the raw page.
627
+ const before = masked.slice(0, start);
628
+ const headings = [...before.matchAll(/^#{1,6}\s/gm)];
629
+ const sectionStart = headings.at(-1)?.index ?? 0;
630
+ const after = masked.slice(end);
631
+ const next = after.search(/^#{1,6}\s|<ApiEndpoint\b/m);
632
+ const sectionEnd = next === -1 ? raw.length : end + next;
633
+ const section = `${raw.slice(sectionStart, start)}\n${raw.slice(end, sectionEnd)}`;
634
+ const staticPath = path.split(/[{:]/)[0].replace(/\/+$/, '');
635
+ for (const fence of section.matchAll(/^\s*(`{3,}|~{3,})[^\n]*\n([\s\S]*?)^\s*\1\s*$/gm)) {
636
+ const code = fence[2] ?? '';
637
+ const request = /\bcurl\b|\bhttp(?:ie)?\s+(?:GET|POST|PUT|PATCH|DELETE)\b|\bfetch\(|^\s*(?:GET|POST|PUT|PATCH|DELETE)\s+\S/im.test(code);
638
+ if (request && (staticPath === '' || code.includes(staticPath)))
639
+ return true;
640
+ }
641
+ return false;
642
+ }
460
643
  function componentAttributes(source) {
461
644
  const attributes = {};
462
645
  const pattern = /([A-Za-z_][\w-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}))?/g;
@@ -475,11 +658,116 @@ function stringAttribute(value) {
475
658
  function trueAttribute(value) {
476
659
  return value === true || value === 'true';
477
660
  }
661
+ /**
662
+ * Page types from the plan staged in the workspace, keyed by extension-less
663
+ * page path. Direct authoring without a plan infers the type from the page.
664
+ */
665
+ async function plannedPageTypes(root, project) {
666
+ const types = new Map();
667
+ try {
668
+ const plan = JSON.parse(await readFile(join(root, '.doxloop', 'documentation-plan.json'), 'utf8'));
669
+ for (const page of plan.pages ?? []) {
670
+ if (typeof page.path !== 'string' || typeof page.type !== 'string')
671
+ continue;
672
+ const key = join(project.contentDir, page.path).replaceAll('\\', '/').replace(/^\.\//, '');
673
+ types.set(key, { type: page.type, ...(typeof page.diagram === 'string' ? { diagram: page.diagram } : {}) });
674
+ }
675
+ }
676
+ catch {
677
+ // No staged plan: infer page types from content instead.
678
+ }
679
+ return types;
680
+ }
681
+ /** A Mermaid diagram in any of the syntaxes the supported generators render. */
682
+ export function hasDiagram(raw) {
683
+ return /<Mermaid[\s>]|```mermaid\b|\.\. mermaid::|\{%\s*mermaid|<pre class="mermaid"|\{\{<\s*mermaid/i.test(raw);
684
+ }
685
+ const PROCEDURAL_TYPES = new Set(['how-to', 'tutorial', 'getting-started']);
686
+ /** Minimum prose words before a page reads as a stub rather than documentation. */
687
+ // A five-step UI guide with prerequisites, verification, and troubleshooting
688
+ // runs past 350 words; the earlier 250 let 300-word skeletons through.
689
+ const MINIMUM_WORDS = { reference: 200, concept: 300, procedure: 350, other: 200 };
690
+ const MINIMUM_STEPS = 3;
691
+ /**
692
+ * Depth gate. A page with a title, a sentence, and one screenshot passes every
693
+ * structural check yet reads as a placeholder; the agent's own quality pass
694
+ * cannot notice that from inside the page, so Doxloop measures it. Warnings,
695
+ * never errors: a genuinely small surface may legitimately produce a short
696
+ * page, and the authoring contract tells the agent to resolve each one.
697
+ */
698
+ /**
699
+ * Shell variables a script cannot assign. A generated quickstart opened with
700
+ * `UID="$(date +%s)"` to make a unique username; bash stops with "UID:
701
+ * readonly variable" and zsh tries to change the user ID, so the very first
702
+ * command a reader copies failed.
703
+ */
704
+ const READONLY_SHELL_VARIABLES = ['UID', 'EUID', 'PPID', 'GID', 'EGID', 'BASHPID', 'BASH_VERSINFO', 'SHELLOPTS', 'BASHOPTS'];
705
+ const SHELL_FENCE = /^(```|~~~)\s*(?:bash|sh|shell|zsh|console)\b[^\n]*\n([\s\S]*?)^\1\s*$/gm;
706
+ export function validateShellExamples(raw, file) {
707
+ const issues = [];
708
+ const assignment = new RegExp(`(?:^|[;&|]\\s*|\\b(?:export|local|declare|readonly)\\s+)(${READONLY_SHELL_VARIABLES.join('|')})=`, 'm');
709
+ for (const match of raw.matchAll(SHELL_FENCE)) {
710
+ const fenceLine = raw.slice(0, match.index).split('\n').length;
711
+ for (const [offset, line] of match[2].split('\n').entries()) {
712
+ const found = assignment.exec(line.replace(/^\s*\$\s+/, '').trimStart());
713
+ if (!found)
714
+ continue;
715
+ issues.push(error('shell-readonly-variable', `Line ${fenceLine + offset + 1}: the shell example assigns ${found[1]}, a read-only shell variable; the command fails when a reader runs it. Use another name, such as RUN_ID or SUFFIX.`, file));
716
+ break;
717
+ }
718
+ }
719
+ return issues;
720
+ }
721
+ export function validatePageDepth(body, file, planType) {
722
+ const issues = [];
723
+ // A generated glossary is a list of short definitions by design; the
724
+ // depth gate is for workflow and concept pages, not term lists.
725
+ if (body.includes('doxloop:glossary'))
726
+ return issues;
727
+ const withoutCode = stripCodeFences(body);
728
+ const prose = withoutCode
729
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
730
+ .replace(/<[^>]+>/g, ' ')
731
+ .replace(/^\s*\|?\s*-{3,}.*$/gm, ' ');
732
+ const words = prose.split(/\s+/).filter((token) => /[A-Za-z0-9]/.test(token)).length;
733
+ const stepCount = (withoutCode.match(/<Step\b/g) ?? []).length;
734
+ const orderedItems = (withoutCode.match(/^\s*\d+\.\s+\S/gm) ?? []).length;
735
+ const type = planType ?? inferPageType(file, withoutCode, stepCount, orderedItems);
736
+ const procedural = PROCEDURAL_TYPES.has(type) || stepCount > 0;
737
+ const minimum = type === 'reference' ? MINIMUM_WORDS.reference : procedural ? MINIMUM_WORDS.procedure : type === 'concept' ? MINIMUM_WORDS.concept : MINIMUM_WORDS.other;
738
+ if (words < minimum) {
739
+ issues.push(warning('thin-page', `The page has about ${words} words of prose; ${procedural ? 'a procedural page' : type === 'other' ? 'a page like this' : `a ${type} page`} normally needs at least ${minimum} to be complete. Add the reader outcome, prerequisites, every step with its observable result, verification, evidence-backed troubleshooting, and a next step — or merge this page into one that can be complete.`, file));
740
+ }
741
+ if (procedural && Math.max(stepCount, orderedItems) < MINIMUM_STEPS && words < 600) {
742
+ issues.push(warning('thin-procedure', `The procedure has ${Math.max(stepCount, orderedItems)} step${Math.max(stepCount, orderedItems) === 1 ? '' : 's'}; a guide normally needs at least ${MINIMUM_STEPS} ordered steps that each name the reader action, the exact control or value, and the visible result. Split combined actions into their own steps and finish the workflow through verification.`, file));
743
+ }
744
+ return issues;
745
+ }
746
+ function inferPageType(file, body, stepCount, orderedItems) {
747
+ const normalized = file.toLowerCase();
748
+ if (/(^|\/)(?:reference|api|cli|commands?|configuration)(\/|\.)/.test(normalized))
749
+ return 'reference';
750
+ if (/(^|\/)(?:concepts?|explanations?|architecture)(\/|\.)/.test(normalized))
751
+ return 'concept';
752
+ if (/(^|\/)(?:guides?|how-?to|tutorials?|getting-started|quickstart)(\/|\.)/.test(normalized))
753
+ return 'how-to';
754
+ if (stepCount > 0 || orderedItems >= 2)
755
+ return 'how-to';
756
+ if (/^index\.[a-z]+$/.test(normalized) || /(^|\/)index\.[a-z]+$/.test(normalized))
757
+ return 'other';
758
+ return body.includes('<Steps') ? 'how-to' : 'other';
759
+ }
760
+ /** Generated scaffolding the authoring agent is told to replace: a starter marker or its placeholder language. */
761
+ export function isStarterContent(content) {
762
+ const prose = stripCodeFences(content);
763
+ return (/(?:<!--|\{\/\*)\s*doxloop:starter-page\s*(?:-->|\*\/\})/i.test(prose) ||
764
+ /^\.\.\s+doxloop:starter-page\s*$/im.test(prose) ||
765
+ /\b(?:replace this starter|the authoring agent will replace this starter)\b/i.test(prose));
766
+ }
478
767
  function validateProfessionalContent(body, raw, file) {
479
768
  const issues = [];
480
769
  const prose = stripCodeFences(body);
481
- if (/(?:<!--|\{\/\*)\s*doxloop:starter-page\s*(?:-->|\*\/\})/i.test(prose) ||
482
- /\b(?:replace this starter|the authoring agent will replace this starter)\b/i.test(prose)) {
770
+ if (isStarterContent(prose)) {
483
771
  issues.push(error('starter-content', 'Replace generated starter content before validation or publishing.', file));
484
772
  }
485
773
  if (/^\s*(?:TODO|TBD|FIXME)(?:\s*:|\b)/im.test(prose)) {
@@ -545,17 +833,15 @@ function stripCodeFences(content) {
545
833
  }
546
834
  async function validateLinks(pagePath, contentRoot, content, projectRoot, adapter) {
547
835
  const issues = [];
548
- const pattern = /!?\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
549
- for (const match of content.matchAll(pattern)) {
550
- const href = match[1];
836
+ for (const href of contentLinks(content)) {
551
837
  if (href === undefined ||
552
- href.startsWith('#') ||
838
+ href.startsWith('#') || href.startsWith('//') ||
553
839
  /^[a-z][a-z0-9+.-]*:/i.test(href)) {
554
840
  continue;
555
841
  }
556
842
  let decoded;
557
843
  try {
558
- decoded = decodeURIComponent(href.split('#')[0] ?? '');
844
+ decoded = decodeURIComponent(href.split(/[?#]/)[0] ?? '');
559
845
  }
560
846
  catch {
561
847
  issues.push(error('invalid-link', `Local link is not valid URL syntax: ${href}`, relativePath(projectRoot, pagePath)));
@@ -567,8 +853,8 @@ async function validateLinks(pagePath, contentRoot, content, projectRoot, adapte
567
853
  ? resolve(contentRoot, `.${decoded}`)
568
854
  : resolve(dirname(pagePath), decoded);
569
855
  const candidates = extname(base) === ''
570
- ? [base, `${base}.md`, `${base}.mdx`, join(base, 'index.md'), join(base, 'index.mdx')]
571
- : [base];
856
+ ? [base, ...['md', 'mdx', 'rst', 'html', 'htm'].flatMap((extension) => [`${base}.${extension}`, join(base, `index.${extension}`)])]
857
+ : /\.html?$/.test(base) ? [base, base.replace(/\.html?$/, '.rst'), base.replace(/\.html?$/, '.md'), base.replace(/\.html?$/, '.mdx')] : [base];
572
858
  const generatorAsset = adapter?.resolveLocalAsset?.({
573
859
  root: projectRoot,
574
860
  contentRoot,