@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.
- package/CHANGELOG.md +1153 -0
- package/README.md +422 -226
- package/assets/doxbrix-preview.css +77 -15
- package/contracts/agent-events-v1.schema.json +1 -0
- package/contracts/coverage-v1.schema.json +59 -0
- package/contracts/drift-v1.schema.json +1 -0
- package/contracts/evaluation-v1.schema.json +62 -0
- package/contracts/quality-codes-v1.json +1 -0
- package/contracts/quality-report-v1.schema.json +1 -0
- package/contracts/validation-v1.schema.json +1 -0
- package/dist/agent-failure.d.ts +36 -0
- package/dist/agent-failure.js +98 -0
- package/dist/agent-isolation.d.ts +17 -0
- package/dist/agent-isolation.js +94 -0
- package/dist/agent-log.d.ts +197 -0
- package/dist/agent-log.js +1094 -0
- package/dist/agent-process.d.ts +63 -0
- package/dist/agent-process.js +128 -0
- package/dist/agent-reply.d.ts +47 -0
- package/dist/agent-reply.js +315 -0
- package/dist/agents.d.ts +40 -4
- package/dist/agents.js +115 -11
- package/dist/api-coverage.d.ts +11 -0
- package/dist/api-coverage.js +72 -0
- package/dist/application-probe.d.ts +28 -0
- package/dist/application-probe.js +93 -0
- package/dist/application-url.d.ts +14 -0
- package/dist/application-url.js +26 -0
- package/dist/args.js +15 -0
- package/dist/artifact-deploy.d.ts +9 -0
- package/dist/artifact-deploy.js +28 -8
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +382 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +38 -9
- package/dist/author.d.ts +206 -4
- package/dist/author.js +1650 -52
- package/dist/authoring-batches.d.ts +190 -0
- package/dist/authoring-batches.js +489 -0
- package/dist/authoring-postpass.d.ts +79 -0
- package/dist/authoring-postpass.js +1289 -0
- package/dist/authoring-progress.d.ts +95 -0
- package/dist/authoring-progress.js +269 -0
- package/dist/autosync.d.ts +56 -0
- package/dist/autosync.js +442 -0
- package/dist/batch-artifacts.d.ts +81 -0
- package/dist/batch-artifacts.js +412 -0
- package/dist/batch-limits.d.ts +37 -0
- package/dist/batch-limits.js +80 -0
- package/dist/branding.d.ts +41 -0
- package/dist/branding.js +229 -0
- package/dist/bundle-upload.d.ts +26 -0
- package/dist/bundle-upload.js +99 -0
- package/dist/capture-auth.d.ts +95 -0
- package/dist/capture-auth.js +204 -0
- package/dist/capture.d.ts +48 -0
- package/dist/capture.js +20 -1
- package/dist/cli.js +839 -32
- package/dist/content-links.d.ts +10 -0
- package/dist/content-links.js +49 -0
- package/dist/contract-validation.d.ts +4 -0
- package/dist/contract-validation.js +25 -0
- package/dist/coverage-actions.d.ts +8 -0
- package/dist/coverage-actions.js +71 -0
- package/dist/coverage-resolutions.d.ts +16 -0
- package/dist/coverage-resolutions.js +58 -0
- package/dist/db.d.ts +37 -0
- package/dist/db.js +288 -0
- package/dist/demo.d.ts +12 -0
- package/dist/demo.js +122 -0
- package/dist/deploy-credentials.d.ts +7 -0
- package/dist/deploy-credentials.js +76 -0
- package/dist/deploy-targets/github-pages.d.ts +3 -0
- package/dist/deploy-targets/github-pages.js +100 -0
- package/dist/deploy-targets/index.d.ts +8 -0
- package/dist/deploy-targets/index.js +78 -0
- package/dist/deploy-targets/netlify.d.ts +3 -0
- package/dist/deploy-targets/netlify.js +49 -0
- package/dist/deploy-targets/types.d.ts +32 -0
- package/dist/deploy-targets/types.js +2 -0
- package/dist/deploy-targets/vercel.d.ts +3 -0
- package/dist/deploy-targets/vercel.js +95 -0
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +87 -13
- package/dist/deterministic-capture.d.ts +90 -0
- package/dist/deterministic-capture.js +435 -0
- package/dist/direct-edit.d.ts +40 -0
- package/dist/direct-edit.js +159 -0
- package/dist/docs-crawl.d.ts +86 -0
- package/dist/docs-crawl.js +536 -0
- package/dist/docs-site.d.ts +49 -0
- package/dist/docs-site.js +233 -0
- package/dist/doctor.js +8 -0
- package/dist/documentation-collections.d.ts +23 -0
- package/dist/documentation-collections.js +191 -0
- package/dist/documentation-plan.d.ts +253 -0
- package/dist/documentation-plan.js +2563 -0
- package/dist/doxbrix-build.d.ts +19 -0
- package/dist/doxbrix-build.js +167 -0
- package/dist/doxbrix-markdown.d.ts +9 -0
- package/dist/doxbrix-markdown.js +115 -14
- package/dist/drift.d.ts +10 -0
- package/dist/drift.js +164 -0
- package/dist/evaluation.d.ts +46 -0
- package/dist/evaluation.js +113 -0
- package/dist/evidence-pack.d.ts +47 -0
- package/dist/evidence-pack.js +358 -0
- package/dist/evidence.d.ts +25 -0
- package/dist/evidence.js +175 -0
- package/dist/fs.d.ts +8 -2
- package/dist/fs.js +41 -11
- package/dist/generator-api.d.ts +89 -0
- package/dist/generator-preflight.d.ts +27 -0
- package/dist/generator-preflight.js +105 -0
- package/dist/generator-runtime.d.ts +7 -0
- package/dist/generator-runtime.js +17 -1
- package/dist/generators.d.ts +24 -2
- package/dist/generators.js +54 -1
- package/dist/git-delivery.d.ts +17 -0
- package/dist/git-delivery.js +123 -0
- package/dist/globs.d.ts +16 -0
- package/dist/globs.js +65 -0
- package/dist/glossary.d.ts +26 -0
- package/dist/glossary.js +179 -0
- package/dist/history.d.ts +106 -0
- package/dist/history.js +600 -0
- package/dist/html-markdown.d.ts +46 -0
- package/dist/html-markdown.js +423 -0
- package/dist/interactive.js +16 -15
- package/dist/job-events.d.ts +74 -0
- package/dist/job-events.js +377 -0
- package/dist/keep-awake.d.ts +50 -0
- package/dist/keep-awake.js +123 -0
- package/dist/local-source-snapshot.d.ts +20 -0
- package/dist/local-source-snapshot.js +61 -0
- package/dist/mintlify-detect.d.ts +3 -0
- package/dist/mintlify-detect.js +18 -0
- package/dist/mintlify-import.d.ts +75 -0
- package/dist/mintlify-import.js +190 -0
- package/dist/navigation.d.ts +98 -0
- package/dist/navigation.js +310 -0
- package/dist/openapi.d.ts +60 -0
- package/dist/openapi.js +439 -0
- package/dist/page-editor-bridge.d.ts +3 -0
- package/dist/page-editor-bridge.js +109 -0
- package/dist/page-editor-preview.d.ts +10 -0
- package/dist/page-editor-preview.js +55 -0
- package/dist/page-extension.d.ts +9 -0
- package/dist/page-extension.js +15 -0
- package/dist/page-metadata.d.ts +28 -0
- package/dist/page-metadata.js +166 -0
- package/dist/page-operations.d.ts +34 -0
- package/dist/page-operations.js +215 -0
- package/dist/page-routes.d.ts +4 -0
- package/dist/page-routes.js +61 -0
- package/dist/pages.d.ts +20 -0
- package/dist/pages.js +184 -0
- package/dist/plan-generator.d.ts +3 -0
- package/dist/plan-generator.js +21 -0
- package/dist/plan-navigation.d.ts +11 -0
- package/dist/plan-navigation.js +30 -0
- package/dist/planning-captures.d.ts +20 -0
- package/dist/planning-captures.js +143 -0
- package/dist/planning-research.d.ts +135 -0
- package/dist/planning-research.js +472 -0
- package/dist/planning-triage.d.ts +23 -0
- package/dist/planning-triage.js +131 -0
- package/dist/preview.d.ts +24 -0
- package/dist/preview.js +280 -29
- package/dist/project-detect.d.ts +36 -0
- package/dist/project-detect.js +251 -0
- package/dist/project-import.d.ts +54 -0
- package/dist/project-import.js +157 -0
- package/dist/project-lock.d.ts +6 -0
- package/dist/project-lock.js +96 -0
- package/dist/project-registry.d.ts +25 -0
- package/dist/project-registry.js +79 -0
- package/dist/project.d.ts +35 -3
- package/dist/project.js +388 -38
- package/dist/prompts.d.ts +9 -0
- package/dist/prompts.js +33 -4
- package/dist/proposal-replay.d.ts +32 -0
- package/dist/proposal-replay.js +99 -0
- package/dist/quality-claims.d.ts +8 -0
- package/dist/quality-claims.js +168 -0
- package/dist/quality-config.d.ts +5 -0
- package/dist/quality-config.js +84 -0
- package/dist/quality-contract.d.ts +37 -0
- package/dist/quality-contract.js +46 -0
- package/dist/quality-examples.d.ts +4 -0
- package/dist/quality-examples.js +233 -0
- package/dist/quality-gates.d.ts +16 -0
- package/dist/quality-gates.js +192 -0
- package/dist/quality-links.d.ts +7 -0
- package/dist/quality-links.js +149 -0
- package/dist/quality-lint.d.ts +6 -0
- package/dist/quality-lint.js +124 -0
- package/dist/quality-rendered.d.ts +11 -0
- package/dist/quality-rendered.js +222 -0
- package/dist/quality-schema.d.ts +3 -0
- package/dist/quality-schema.js +71 -0
- package/dist/release-notes.d.ts +44 -0
- package/dist/release-notes.js +183 -0
- package/dist/remote-monitor.d.ts +16 -0
- package/dist/remote-monitor.js +74 -0
- package/dist/remote-source.d.ts +34 -0
- package/dist/remote-source.js +426 -0
- package/dist/review-diff.d.ts +82 -0
- package/dist/review-diff.js +400 -0
- package/dist/review-learning.d.ts +11 -0
- package/dist/review-learning.js +60 -0
- package/dist/review-render.d.ts +38 -0
- package/dist/review-render.js +224 -0
- package/dist/review-report.d.ts +9 -0
- package/dist/review-report.js +89 -0
- package/dist/review-ui.d.ts +14 -0
- package/dist/review-ui.js +1248 -0
- package/dist/schedule.d.ts +78 -0
- package/dist/schedule.js +480 -0
- package/dist/screen-capture-provider.d.ts +52 -0
- package/dist/screen-capture-provider.js +218 -0
- package/dist/screenshot-workflow.d.ts +167 -0
- package/dist/screenshot-workflow.js +1237 -0
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +95 -7
- package/dist/site-export.d.ts +18 -0
- package/dist/site-export.js +87 -0
- package/dist/source-connectors.d.ts +33 -0
- package/dist/source-connectors.js +268 -0
- package/dist/source-discovery.d.ts +132 -0
- package/dist/source-discovery.js +823 -0
- package/dist/source-intelligence.d.ts +9 -0
- package/dist/source-intelligence.js +306 -0
- package/dist/sync-review.d.ts +28 -0
- package/dist/sync-review.js +264 -0
- package/dist/sync-runs.d.ts +192 -0
- package/dist/sync-runs.js +2244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +298 -32
- package/dist/text-diff.d.ts +9 -0
- package/dist/text-diff.js +59 -0
- package/dist/types.d.ts +946 -1
- package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
- package/dist/ui/assets/index-BHBYU2aG.css +1 -0
- package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
- package/dist/ui/index.html +18 -0
- package/dist/ui-server.d.ts +83 -0
- package/dist/ui-server.js +3532 -0
- package/dist/usage-budget.d.ts +28 -0
- package/dist/usage-budget.js +90 -0
- package/dist/validation.d.ts +25 -1
- package/dist/validation.js +312 -26
- package/dist/workspace-tools.d.ts +54 -0
- package/dist/workspace-tools.js +123 -0
- package/docs/agent-compatibility.md +50 -28
- package/docs/ci-and-automation.md +105 -66
- package/docs/doxbrix-http-api.md +8 -1
- package/docs/existing-documentation.md +80 -0
- package/docs/generation-performance.md +108 -0
- package/docs/generator-authoring.md +68 -5
- package/docs/generator-selection.md +50 -13
- package/docs/mintlify-import.md +71 -0
- package/docs/openapi-security.md +25 -0
- package/docs/project-format.md +331 -36
- package/docs/release-quality.md +158 -0
- package/docs/releasing.md +72 -0
- package/docs/review-workflows.md +51 -0
- package/docs/security-model.md +102 -39
- package/docs/troubleshooting.md +226 -93
- package/package.json +46 -15
- package/scripts/test-auto-screenshot.mjs +172 -0
- package/skills/doxloop-authoring/SKILL.md +242 -361
- package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
- package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
- package/skills/doxloop-authoring/references/page-depth.md +169 -0
- package/skills/doxloop-authoring/references/project-format.md +106 -7
- package/skills/doxloop-authoring/references/quality.md +10 -0
- package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
- package/skills/doxloop-authoring/references/screenshots.md +149 -235
- package/skills/doxloop-authoring/references/workflows.md +76 -0
- package/skills/doxloop-doxbrix/SKILL.md +38 -22
- package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
- package/skills/doxloop-doxbrix/references/components.md +30 -3
- package/skills/doxloop-doxbrix/references/manifest.md +3 -2
- package/vendor/doxbrix-import/LICENSE +202 -0
- package/vendor/doxbrix-import/README.md +17 -0
- package/vendor/doxbrix-import/UPSTREAM.json +47 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
- package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
- package/vendor/doxbrix-import/dist/docs/import.js +147 -0
- package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
- package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
- package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
- package/vendor/doxbrix-import/dist/docs/project.js +77 -0
- package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
- package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
- package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
- package/vendor/doxbrix-import/dist/importer.js +1567 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
- package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
- package/vendor/doxbrix-import/dist/safe-path.js +47 -0
- package/dist/agents.d.ts.map +0 -1
- package/dist/agents.js.map +0 -1
- package/dist/args.d.ts.map +0 -1
- package/dist/args.js.map +0 -1
- package/dist/artifact-deploy.d.ts.map +0 -1
- package/dist/artifact-deploy.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/author.d.ts.map +0 -1
- package/dist/author.js.map +0 -1
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/deploy.d.ts.map +0 -1
- package/dist/deploy.js.map +0 -1
- package/dist/deployment-visibility.d.ts.map +0 -1
- package/dist/deployment-visibility.js.map +0 -1
- package/dist/doctor.d.ts.map +0 -1
- package/dist/doctor.js.map +0 -1
- package/dist/doxbrix-markdown.d.ts.map +0 -1
- package/dist/doxbrix-markdown.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/fs.d.ts.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/generator-api.d.ts.map +0 -1
- package/dist/generator-api.js.map +0 -1
- package/dist/generator-manager.d.ts.map +0 -1
- package/dist/generator-manager.js.map +0 -1
- package/dist/generator-runtime.d.ts.map +0 -1
- package/dist/generator-runtime.js.map +0 -1
- package/dist/generators.d.ts.map +0 -1
- package/dist/generators.js.map +0 -1
- package/dist/interactive.d.ts.map +0 -1
- package/dist/interactive.js.map +0 -1
- package/dist/preview.d.ts.map +0 -1
- package/dist/preview.js.map +0 -1
- package/dist/progress.d.ts.map +0 -1
- package/dist/progress.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/settings.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- 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
|
package/dist/validation.d.ts
CHANGED
|
@@ -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
|
package/dist/validation.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
55
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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,
|
|
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,
|