@doxbrix/doxloop 0.1.5 → 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 +1145 -0
- package/README.md +422 -227
- 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 +24 -5
- package/dist/agents.js +60 -9
- 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 +3 -0
- 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 +25 -0
- 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 +49 -32
- 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,233 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { chmod, cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { pathExists } from './fs.js';
|
|
7
|
+
import { QUALITY_CODES } from './quality-contract.js';
|
|
8
|
+
import { loadOpenApiSource } from './openapi.js';
|
|
9
|
+
import { loadProject, sourceKind } from './project.js';
|
|
10
|
+
export const EXAMPLES_FILE = join('.doxloop', 'examples.json');
|
|
11
|
+
export async function verifyExamples(root, enabled) {
|
|
12
|
+
const path = join(root, EXAMPLES_FILE);
|
|
13
|
+
if (!(await pathExists(path)))
|
|
14
|
+
return [{ code: QUALITY_CODES.exampleSourceVerified, category: 'examples', status: 'skipped', message: 'No executable-example manifest is configured; examples remain source-verified.' }];
|
|
15
|
+
if (!enabled)
|
|
16
|
+
return [{ code: QUALITY_CODES.exampleSourceVerified, category: 'examples', status: 'skipped', message: `Executable examples are opt-in. Set examples.enabled in .doxloop/quality.json to run ${EXAMPLES_FILE}.` }];
|
|
17
|
+
const manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
18
|
+
if (!validManifest(manifest))
|
|
19
|
+
return [{ code: QUALITY_CODES.exampleFailed, category: 'examples', status: 'fail', message: `${EXAMPLES_FILE} has an unsupported format.` }];
|
|
20
|
+
const checks = [];
|
|
21
|
+
for (const example of manifest.examples) {
|
|
22
|
+
if (example.runtime === 'source-verified' || example.runtime === 'shell-source-verified') {
|
|
23
|
+
checks.push({ code: QUALITY_CODES.exampleSourceVerified, category: 'examples', status: 'skipped', message: `${example.id} is explicitly source-verified rather than executed.`, file: example.file });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
checks.push(example.runtime === 'python' ? await executePythonExample(root, example) : example.runtime === 'openapi-request' ? await verifyOpenApiRequest(root, example) : await executeNodeExample(root, example));
|
|
27
|
+
}
|
|
28
|
+
return checks.length > 0 ? checks : [{ code: QUALITY_CODES.exampleSourceVerified, category: 'examples', status: 'skipped', message: 'The executable-example manifest contains no examples.' }];
|
|
29
|
+
}
|
|
30
|
+
async function verifyOpenApiRequest(root, example) {
|
|
31
|
+
const source = contained(contained(root, example.workingDirectory), example.file);
|
|
32
|
+
if (!(await pathExists(source)))
|
|
33
|
+
return failure(example, 'The declared HTTP example file does not exist.');
|
|
34
|
+
const content = await readFile(source, 'utf8');
|
|
35
|
+
const request = /^\s*(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(\/\S*)/im.exec(content);
|
|
36
|
+
if (!request)
|
|
37
|
+
return failure(example, 'HTTP examples must contain a METHOD /path request line.');
|
|
38
|
+
const expectedStatus = /^\s*#\s*expect-status:\s*([1-5]\d\d)\s*$/im.exec(content)?.[1];
|
|
39
|
+
const operationId = `${request[1].toUpperCase()} ${request[2].split('?')[0]}`;
|
|
40
|
+
const project = await loadProject(root);
|
|
41
|
+
for (const binding of project.sources.filter((item) => sourceKind(item) === 'openapi')) {
|
|
42
|
+
try {
|
|
43
|
+
const loaded = await loadOpenApiSource(root, binding);
|
|
44
|
+
const operation = loaded.snapshot.operations[operationId];
|
|
45
|
+
if (!operation)
|
|
46
|
+
continue;
|
|
47
|
+
const pathItem = objectRecord(objectRecord(loaded.document.paths)[request[2].split('?')[0]]);
|
|
48
|
+
const rawOperation = objectRecord(pathItem[request[1].toLowerCase()]);
|
|
49
|
+
const responses = objectRecord(rawOperation.responses);
|
|
50
|
+
if (expectedStatus && !Object.hasOwn(responses, expectedStatus))
|
|
51
|
+
return failure(example, `${operationId} does not declare expected HTTP ${expectedStatus}.`);
|
|
52
|
+
return { code: QUALITY_CODES.examplePassed, category: 'examples', status: 'pass', message: `${example.id} matches ${operationId}${expectedStatus ? ` and HTTP ${expectedStatus}` : ''} in ${binding.name}.`, file: example.file };
|
|
53
|
+
}
|
|
54
|
+
catch { /* Continue to another configured OpenAPI source. */ }
|
|
55
|
+
}
|
|
56
|
+
return failure(example, `${operationId} was not found in a configured OpenAPI source.`);
|
|
57
|
+
}
|
|
58
|
+
function objectRecord(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
|
|
59
|
+
async function executePythonExample(root, example) {
|
|
60
|
+
const image = process.env.DOXLOOP_PYTHON_SANDBOX_IMAGE;
|
|
61
|
+
if (!image || !/^[a-zA-Z0-9][a-zA-Z0-9./:_-]*@sha256:[a-f0-9]{64}$/.test(image)) {
|
|
62
|
+
return { code: QUALITY_CODES.exampleSourceVerified, category: 'examples', status: 'skipped', file: example.file, message: `${example.id} was not executed. Python requires a locally installed, digest-pinned container image in DOXLOOP_PYTHON_SANDBOX_IMAGE; host Python is never used as a sandbox.` };
|
|
63
|
+
}
|
|
64
|
+
const workingRoot = contained(root, example.workingDirectory);
|
|
65
|
+
const source = contained(workingRoot, example.file);
|
|
66
|
+
if (!(await pathExists(source)))
|
|
67
|
+
return failure(example, 'The declared Python example file does not exist.');
|
|
68
|
+
const content = await readFile(source, 'utf8');
|
|
69
|
+
if (containsCredential(content))
|
|
70
|
+
return failure(example, 'Execution was refused because the example appears to contain a real credential.');
|
|
71
|
+
if (containsProductionTarget(content))
|
|
72
|
+
return failure(example, 'Execution was refused because the example names a non-example network destination.');
|
|
73
|
+
if (/\b(?:ctypes|os\.system|subprocess|pty|multiprocessing)\b/.test(content))
|
|
74
|
+
return failure(example, 'Python examples may not start subprocesses or load native process APIs.');
|
|
75
|
+
const parent = await mkdtemp(join(tmpdir(), 'doxloop-python-example-'));
|
|
76
|
+
const container = `doxloop-example-${randomUUID()}`;
|
|
77
|
+
try {
|
|
78
|
+
const sandbox = join(parent, 'workspace');
|
|
79
|
+
await mkdir(sandbox);
|
|
80
|
+
const script = join(sandbox, basename(source));
|
|
81
|
+
await cp(source, script);
|
|
82
|
+
for (const fixture of example.fixtures) {
|
|
83
|
+
const fixtureSource = contained(workingRoot, fixture);
|
|
84
|
+
if (!(await pathExists(fixtureSource)))
|
|
85
|
+
return failure(example, `Fixture does not exist: ${fixture}`);
|
|
86
|
+
const unsafe = await unsafeFixture(fixtureSource);
|
|
87
|
+
if (unsafe)
|
|
88
|
+
return failure(example, `Fixture ${fixture} ${unsafe}.`);
|
|
89
|
+
await cp(fixtureSource, join(sandbox, basename(fixtureSource)), { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
await readableFixtures(sandbox);
|
|
92
|
+
const result = await run('docker', [
|
|
93
|
+
'run', '--rm', '--pull=never', '--name', container,
|
|
94
|
+
'--network=none', '--read-only', '--cap-drop=ALL', '--security-opt=no-new-privileges',
|
|
95
|
+
'--pids-limit=32', '--memory=256m', '--cpus=1', '--user=65534:65534',
|
|
96
|
+
'--tmpfs', '/tmp:rw,noexec,nosuid,size=16m',
|
|
97
|
+
'--mount', `type=bind,src=${sandbox},dst=/workspace,readonly`,
|
|
98
|
+
'--workdir=/workspace', '--env=HOME=/tmp', '--entrypoint=python3', image,
|
|
99
|
+
'-I', `/workspace/${basename(source)}`,
|
|
100
|
+
], sandbox, 20_000);
|
|
101
|
+
const matches = result.code === example.expected.exitCode &&
|
|
102
|
+
(example.expected.stdoutIncludes === undefined || result.stdout.includes(example.expected.stdoutIncludes)) &&
|
|
103
|
+
(example.expected.stderrIncludes === undefined || result.stderr.includes(example.expected.stderrIncludes));
|
|
104
|
+
return matches
|
|
105
|
+
? { code: QUALITY_CODES.examplePassed, category: 'examples', status: 'pass', message: `${example.id} produced the declared result in a container with no network, a read-only filesystem, and only the declared example fixtures mounted.`, file: example.file }
|
|
106
|
+
: failure(example, `Expected exit ${example.expected.exitCode}; received ${result.code}.`, `stdout: ${clip(result.stdout)}\nstderr: ${clip(result.stderr)}`);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return failure(example, error instanceof Error ? error.message : String(error));
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await run('docker', ['rm', '-f', container], parent, 5_000).catch(() => undefined);
|
|
113
|
+
await rm(parent, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function readableFixtures(path) {
|
|
117
|
+
const info = await stat(path);
|
|
118
|
+
await chmod(path, info.isDirectory() ? 0o755 : 0o644);
|
|
119
|
+
if (info.isDirectory())
|
|
120
|
+
for (const name of await readdir(path))
|
|
121
|
+
await readableFixtures(join(path, name));
|
|
122
|
+
}
|
|
123
|
+
async function executeNodeExample(root, example) {
|
|
124
|
+
const workingRoot = contained(root, example.workingDirectory);
|
|
125
|
+
const source = contained(workingRoot, example.file);
|
|
126
|
+
if (!(await pathExists(source)))
|
|
127
|
+
return failure(example, 'The declared example file does not exist.');
|
|
128
|
+
const content = await readFile(source, 'utf8');
|
|
129
|
+
if (containsCredential(content))
|
|
130
|
+
return failure(example, 'Execution was refused because the example appears to contain a real credential.');
|
|
131
|
+
if (containsProductionTarget(content))
|
|
132
|
+
return failure(example, 'Execution was refused because the example names a non-example network destination.');
|
|
133
|
+
if (example.network !== 'denied')
|
|
134
|
+
return failure(example, 'Executable examples must declare network: "denied".');
|
|
135
|
+
const parent = await mkdtemp(join(tmpdir(), 'doxloop-example-'));
|
|
136
|
+
try {
|
|
137
|
+
const sandbox = join(parent, 'workspace');
|
|
138
|
+
await mkdir(sandbox);
|
|
139
|
+
const script = join(sandbox, basename(source));
|
|
140
|
+
await cp(source, script);
|
|
141
|
+
for (const fixture of example.fixtures) {
|
|
142
|
+
const fixtureSource = contained(workingRoot, fixture);
|
|
143
|
+
if (!(await pathExists(fixtureSource)))
|
|
144
|
+
return failure(example, `Fixture does not exist: ${fixture}`);
|
|
145
|
+
const unsafe = await unsafeFixture(fixtureSource);
|
|
146
|
+
if (unsafe)
|
|
147
|
+
return failure(example, `Fixture ${fixture} ${unsafe}.`);
|
|
148
|
+
await cp(fixtureSource, join(sandbox, basename(fixtureSource)), { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
const actualSandbox = await realpath(sandbox);
|
|
151
|
+
const actualScript = await realpath(script);
|
|
152
|
+
const result = await run(process.execPath, [
|
|
153
|
+
'--experimental-permission',
|
|
154
|
+
`--allow-fs-read=${actualSandbox}`,
|
|
155
|
+
`--allow-fs-write=${actualSandbox}`,
|
|
156
|
+
actualScript,
|
|
157
|
+
], actualSandbox, 20_000);
|
|
158
|
+
const matches = result.code === example.expected.exitCode &&
|
|
159
|
+
(example.expected.stdoutIncludes === undefined || result.stdout.includes(example.expected.stdoutIncludes)) &&
|
|
160
|
+
(example.expected.stderrIncludes === undefined || result.stderr.includes(example.expected.stderrIncludes));
|
|
161
|
+
return matches
|
|
162
|
+
? { code: QUALITY_CODES.examplePassed, category: 'examples', status: 'pass', message: `${example.id} produced the declared result in an isolated, network-denied Node process.`, file: example.file }
|
|
163
|
+
: failure(example, `Expected exit ${example.expected.exitCode}; received ${result.code}.`, `stdout: ${clip(result.stdout)}\nstderr: ${clip(result.stderr)}`);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
return failure(example, error instanceof Error ? error.message : String(error));
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await rm(parent, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function run(command, args, cwd, timeout) {
|
|
173
|
+
return new Promise((resolveRun, reject) => {
|
|
174
|
+
const child = spawn(command, args, { cwd, env: { PATH: process.env.PATH ?? '' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
175
|
+
let stdout = '';
|
|
176
|
+
let stderr = '';
|
|
177
|
+
let timedOut = false;
|
|
178
|
+
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout);
|
|
179
|
+
child.stdout.on('data', (chunk) => { if (stdout.length < 64_000)
|
|
180
|
+
stdout += chunk; });
|
|
181
|
+
child.stderr.on('data', (chunk) => { if (stderr.length < 64_000)
|
|
182
|
+
stderr += chunk; });
|
|
183
|
+
child.once('error', (error) => { clearTimeout(timer); reject(error); });
|
|
184
|
+
child.once('exit', (code) => { clearTimeout(timer); resolveRun({ code: timedOut ? 124 : (code ?? 1), stdout, stderr }); });
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function contained(root, candidate) {
|
|
188
|
+
const path = resolve(root, candidate);
|
|
189
|
+
const rel = relative(resolve(root), path);
|
|
190
|
+
if (rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || rel.startsWith('../') || rel.startsWith('..\\'))
|
|
191
|
+
throw new Error(`Example path leaves the project: ${candidate}`);
|
|
192
|
+
return path;
|
|
193
|
+
}
|
|
194
|
+
function validManifest(value) {
|
|
195
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
196
|
+
return false;
|
|
197
|
+
const manifest = value;
|
|
198
|
+
return manifest.schemaVersion === 1 && Array.isArray(manifest.examples) && manifest.examples.every((example) => !!example && typeof example === 'object' && typeof example.id === 'string' && ['node', 'python', 'openapi-request', 'shell-source-verified', 'source-verified'].includes(example.runtime) && typeof example.file === 'string' && typeof example.workingDirectory === 'string' && Array.isArray(example.fixtures) && example.fixtures.every((item) => typeof item === 'string') && example.network === 'denied' && !!example.expected && Number.isInteger(example.expected.exitCode));
|
|
199
|
+
}
|
|
200
|
+
function containsCredential(value) { return /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|\bAKIA[0-9A-Z]{16}\b|\bgh[pousr]_[A-Za-z0-9]{36,}\b/.test(value); }
|
|
201
|
+
function containsProductionTarget(value) {
|
|
202
|
+
return [...value.matchAll(/https?:\/\/([^/:\s"'`]+)/gi)].some((match) => {
|
|
203
|
+
const host = match[1]?.toLowerCase() ?? '';
|
|
204
|
+
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== 'example.com' && !host.endsWith('.example');
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async function unsafeFixture(path) {
|
|
208
|
+
const info = await stat(path);
|
|
209
|
+
if (info.isDirectory()) {
|
|
210
|
+
for (const entry of await readdir(path)) {
|
|
211
|
+
const unsafe = await unsafeFixture(join(path, entry));
|
|
212
|
+
if (unsafe)
|
|
213
|
+
return unsafe;
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
if (info.size > 1_000_000)
|
|
218
|
+
return 'exceeds the 1 MB fixture safety limit';
|
|
219
|
+
try {
|
|
220
|
+
const content = await readFile(path, 'utf8');
|
|
221
|
+
if (content.includes('\0'))
|
|
222
|
+
return undefined;
|
|
223
|
+
if (containsCredential(content))
|
|
224
|
+
return 'appears to contain a real credential';
|
|
225
|
+
if (containsProductionTarget(content))
|
|
226
|
+
return 'names a non-example network destination';
|
|
227
|
+
}
|
|
228
|
+
catch { /* Binary fixtures are copied but never interpreted. */ }
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
function failure(example, message, detail) { return { code: QUALITY_CODES.exampleFailed, category: 'examples', status: 'fail', message: `${example.id}: ${message}`, file: example.file, ...(detail ? { detail } : {}) }; }
|
|
232
|
+
function clip(value) { return value.trim().slice(0, 1_000); }
|
|
233
|
+
//# sourceMappingURL=quality-examples.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { QualityReport } from './types.js';
|
|
2
|
+
export declare const QUALITY_REPORT_DIRECTORY: string;
|
|
3
|
+
export declare const LATEST_QUALITY_REPORT: string;
|
|
4
|
+
export interface RunQualityOptions {
|
|
5
|
+
offline?: boolean;
|
|
6
|
+
rendered?: boolean;
|
|
7
|
+
examples?: boolean;
|
|
8
|
+
fix?: boolean;
|
|
9
|
+
updateVisuals?: boolean;
|
|
10
|
+
approveBaseline?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function runQuality(root: string, options?: RunQualityOptions): Promise<QualityReport>;
|
|
13
|
+
export declare function readLatestQualityReport(root: string): Promise<QualityReport | undefined>;
|
|
14
|
+
export declare function qualityInputHash(root: string): Promise<string>;
|
|
15
|
+
export declare function formatQualityReport(report: QualityReport): string;
|
|
16
|
+
//# sourceMappingURL=quality-gates.d.ts.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { assertPublicContract } from './contract-validation.js';
|
|
6
|
+
import { loadGeneratorAdapter } from './generators.js';
|
|
7
|
+
import { pathExists } from './fs.js';
|
|
8
|
+
import { loadPages, loadProject, relativePath } from './project.js';
|
|
9
|
+
import { reverifyClaims } from './quality-claims.js';
|
|
10
|
+
import { loadQualityConfig } from './quality-config.js';
|
|
11
|
+
import { QUALITY_CODES, QUALITY_CONTRACT_VERSION } from './quality-contract.js';
|
|
12
|
+
import { verifyExamples } from './quality-examples.js';
|
|
13
|
+
import { lintDocumentation, fixDocumentation } from './quality-lint.js';
|
|
14
|
+
import { checkExternalLinks } from './quality-links.js';
|
|
15
|
+
import { checkRenderedQuality } from './quality-rendered.js';
|
|
16
|
+
import { lintSchemas } from './quality-schema.js';
|
|
17
|
+
import { validateProject } from './validation.js';
|
|
18
|
+
import { buildDoxbrixStaticSite } from './doxbrix-build.js';
|
|
19
|
+
export const QUALITY_REPORT_DIRECTORY = join('.doxloop', 'quality-reports');
|
|
20
|
+
export const LATEST_QUALITY_REPORT = join(QUALITY_REPORT_DIRECTORY, 'latest.json');
|
|
21
|
+
export async function runQuality(root, options = {}) {
|
|
22
|
+
const project = await loadProject(root);
|
|
23
|
+
const config = await loadQualityConfig(root);
|
|
24
|
+
if (options.rendered !== undefined)
|
|
25
|
+
config.rendered = { ...config.rendered, enabled: options.rendered };
|
|
26
|
+
if (options.examples !== undefined)
|
|
27
|
+
config.examples = { ...config.examples, enabled: options.examples };
|
|
28
|
+
if (options.offline !== undefined)
|
|
29
|
+
config.links = { ...config.links, mode: options.offline ? 'offline' : 'online' };
|
|
30
|
+
if (options.fix)
|
|
31
|
+
await fixDocumentation(root, project);
|
|
32
|
+
const validation = await validateProject(root);
|
|
33
|
+
const checks = validation.issues.map((issue) => ({ code: `validation.${issue.code}`, category: 'validation', status: issue.severity === 'error' ? 'fail' : 'warning', message: issue.message, ...(issue.file ? { file: issue.file } : {}) }));
|
|
34
|
+
if (validation.issues.length === 0)
|
|
35
|
+
checks.push({ code: QUALITY_CODES.validation, category: 'validation', status: 'pass', message: `${validation.pages.length} documentation pages passed deterministic validation.` });
|
|
36
|
+
checks.push(await strictBuild(root, project));
|
|
37
|
+
const [links, examples, schemas, lint, claims] = await Promise.all([
|
|
38
|
+
checkExternalLinks(root, project, config, options.offline === undefined ? {} : { offline: options.offline }),
|
|
39
|
+
verifyExamples(root, config.examples?.enabled === true),
|
|
40
|
+
lintSchemas(root, project),
|
|
41
|
+
lintDocumentation(root, project, config),
|
|
42
|
+
reverifyClaims(root, project, config.readerVerification?.enabled === true),
|
|
43
|
+
]);
|
|
44
|
+
checks.push(...links, ...examples, ...schemas, ...lint, ...claims.checks);
|
|
45
|
+
const rendered = await checkRenderedQuality(root, project, config, options.updateVisuals === true);
|
|
46
|
+
checks.push(...rendered.checks);
|
|
47
|
+
applySuppressions(checks, config);
|
|
48
|
+
await applyRatchet(root, checks, config, options.approveBaseline === true);
|
|
49
|
+
const counts = {
|
|
50
|
+
passed: checks.filter((check) => check.status === 'pass').length,
|
|
51
|
+
warnings: checks.filter((check) => check.status === 'warning').length,
|
|
52
|
+
failed: checks.filter((check) => check.status === 'fail').length,
|
|
53
|
+
skipped: checks.filter((check) => check.status === 'skipped').length,
|
|
54
|
+
};
|
|
55
|
+
const generatedAt = new Date().toISOString();
|
|
56
|
+
const inputHash = await qualityInputHash(root);
|
|
57
|
+
const reportName = `quality-${generatedAt.replace(/[:.]/g, '-')}.json`;
|
|
58
|
+
const report = {
|
|
59
|
+
schemaVersion: 1,
|
|
60
|
+
contractVersion: QUALITY_CONTRACT_VERSION,
|
|
61
|
+
generatedAt,
|
|
62
|
+
inputHash,
|
|
63
|
+
project: project.title,
|
|
64
|
+
generator: project.generator,
|
|
65
|
+
status: counts.failed > 0 ? 'fail' : counts.warnings > 0 ? 'warning' : 'pass',
|
|
66
|
+
checks,
|
|
67
|
+
counts,
|
|
68
|
+
artifacts: {
|
|
69
|
+
report: join(QUALITY_REPORT_DIRECTORY, reportName),
|
|
70
|
+
...(rendered.accessibility ? { accessibility: rendered.accessibility } : {}),
|
|
71
|
+
...(rendered.visuals ? { visuals: rendered.visuals } : {}),
|
|
72
|
+
},
|
|
73
|
+
options: { offline: config.links?.mode === 'offline', rendered: config.rendered?.enabled === true, examples: config.examples?.enabled === true },
|
|
74
|
+
};
|
|
75
|
+
const directory = join(root, QUALITY_REPORT_DIRECTORY);
|
|
76
|
+
await mkdir(directory, { recursive: true });
|
|
77
|
+
await assertPublicContract('quality-report-v1', report);
|
|
78
|
+
const serialized = `${JSON.stringify(report, null, 2)}\n`;
|
|
79
|
+
await Promise.all([writeFile(join(directory, reportName), serialized, 'utf8'), writeFile(join(root, LATEST_QUALITY_REPORT), serialized, 'utf8')]);
|
|
80
|
+
return report;
|
|
81
|
+
}
|
|
82
|
+
async function applyRatchet(root, checks, config, approve) {
|
|
83
|
+
const setting = config.ratchet;
|
|
84
|
+
if (!setting?.enabled && !approve)
|
|
85
|
+
return;
|
|
86
|
+
const baselinePath = join(root, setting?.baselineFile ?? '.doxloop/quality-baseline.json');
|
|
87
|
+
const current = checks.filter((check) => check.status === 'warning' || check.status === 'fail').map(issueIdentity);
|
|
88
|
+
if (approve) {
|
|
89
|
+
await mkdir(dirname(baselinePath), { recursive: true });
|
|
90
|
+
await writeFile(baselinePath, `${JSON.stringify({ schemaVersion: 1, issues: current.sort() }, null, 2)}\n`, 'utf8');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!(await pathExists(baselinePath))) {
|
|
94
|
+
checks.push({ code: QUALITY_CODES.ratchetBaselineMissing, category: 'validation', status: 'warning', message: 'Quality ratcheting is enabled but no approved baseline exists.', detail: `Run doxloop quality --approve-quality-baseline after reviewing current issues.` });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let known = new Set();
|
|
98
|
+
try {
|
|
99
|
+
const value = JSON.parse(await readFile(baselinePath, 'utf8'));
|
|
100
|
+
if (value.schemaVersion === 1 && Array.isArray(value.issues))
|
|
101
|
+
known = new Set(value.issues.filter((item) => typeof item === 'string'));
|
|
102
|
+
}
|
|
103
|
+
catch { /* Malformed baselines fail open as a visible warning below. */ }
|
|
104
|
+
if (known.size === 0 && current.length > 0)
|
|
105
|
+
checks.push({ code: QUALITY_CODES.ratchetBaselineInvalid, category: 'validation', status: 'warning', message: 'The quality baseline is empty or invalid; current issues were not suppressed.' });
|
|
106
|
+
for (const check of checks) {
|
|
107
|
+
if ((check.status === 'warning' || check.status === 'fail') && known.has(issueIdentity(check))) {
|
|
108
|
+
check.detail = [check.detail, 'Present in the approved quality baseline; kept visible for ratcheting.'].filter(Boolean).join('\n');
|
|
109
|
+
check.status = 'skipped';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function applySuppressions(checks, config) {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
for (const check of checks) {
|
|
116
|
+
const suppression = config.suppressions?.find((item) => item.code === check.code && (!item.file || item.file === check.file) && (!item.expires || Date.parse(item.expires) > now));
|
|
117
|
+
if (!suppression || (check.status !== 'warning' && check.status !== 'fail'))
|
|
118
|
+
continue;
|
|
119
|
+
check.status = 'skipped';
|
|
120
|
+
check.detail = [check.detail, `Suppressed: ${suppression.reason}${suppression.expires ? ` (expires ${suppression.expires})` : ''}`].filter(Boolean).join('\n');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function issueIdentity(check) { return `${check.code}\0${check.file ?? ''}\0${check.message}`; }
|
|
124
|
+
export async function readLatestQualityReport(root) {
|
|
125
|
+
const path = join(root, LATEST_QUALITY_REPORT);
|
|
126
|
+
if (!(await pathExists(path)))
|
|
127
|
+
return undefined;
|
|
128
|
+
try {
|
|
129
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
130
|
+
return value.schemaVersion === 1 && value.contractVersion === QUALITY_CONTRACT_VERSION && Array.isArray(value.checks) ? value : undefined;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export async function qualityInputHash(root) {
|
|
137
|
+
const project = await loadProject(root);
|
|
138
|
+
const files = await loadPages(root, project);
|
|
139
|
+
const candidates = [join(root, '.doxloop', 'project.json'), join(root, '.doxloop', 'quality.json'), join(root, '.doxloop', 'examples.json'), join(root, '.doxloop', 'evidence-map.json'), ...files];
|
|
140
|
+
const hash = createHash('sha256');
|
|
141
|
+
for (const path of candidates.sort()) {
|
|
142
|
+
if (!(await pathExists(path)))
|
|
143
|
+
continue;
|
|
144
|
+
hash.update(relativePath(root, path));
|
|
145
|
+
hash.update('\0');
|
|
146
|
+
hash.update(await readFile(path));
|
|
147
|
+
hash.update('\0');
|
|
148
|
+
}
|
|
149
|
+
return hash.digest('hex');
|
|
150
|
+
}
|
|
151
|
+
export function formatQualityReport(report) {
|
|
152
|
+
const icon = report.status === 'pass' ? '✓' : report.status === 'warning' ? '!' : '✗';
|
|
153
|
+
const lines = [`${icon} Release quality: ${report.status}`, `${report.counts.failed} failed · ${report.counts.warnings} warnings · ${report.counts.passed} passed · ${report.counts.skipped} skipped`];
|
|
154
|
+
for (const category of [...new Set(report.checks.map((check) => check.category))]) {
|
|
155
|
+
lines.push('', category[0].toUpperCase() + category.slice(1));
|
|
156
|
+
for (const check of report.checks.filter((item) => item.category === category && item.status !== 'pass'))
|
|
157
|
+
lines.push(` ${check.status === 'fail' ? '✗' : check.status === 'warning' ? '!' : '–'} ${check.code}${check.file ? ` · ${check.file}` : ''}\n ${check.message}${check.detail ? `\n ${check.detail}` : ''}`);
|
|
158
|
+
}
|
|
159
|
+
lines.push('', `Report: ${report.artifacts.report}`);
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
}
|
|
162
|
+
async function strictBuild(root, project) {
|
|
163
|
+
if (project.generator === 'doxbrix') {
|
|
164
|
+
try {
|
|
165
|
+
const result = await buildDoxbrixStaticSite({ root });
|
|
166
|
+
return { code: QUALITY_CODES.buildPassed, category: 'build', status: 'pass', message: `Doxbrix static build passed (${result.pages} pages).` };
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
return { code: QUALITY_CODES.buildFailed, category: 'build', status: 'fail', message: 'Doxbrix static build failed.', detail: error instanceof Error ? error.message : String(error) };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const adapter = await loadGeneratorAdapter(root, project);
|
|
173
|
+
const result = await runShell(adapter.build.command, root);
|
|
174
|
+
return result.code === 0
|
|
175
|
+
? { code: QUALITY_CODES.buildPassed, category: 'build', status: 'pass', message: `${adapter.displayName} strict build passed.` }
|
|
176
|
+
: { code: QUALITY_CODES.buildFailed, category: 'build', status: 'fail', message: `${adapter.displayName} strict build failed with exit code ${result.code}.`, detail: clip(`${result.stdout}\n${result.stderr}`) };
|
|
177
|
+
}
|
|
178
|
+
function runShell(command, cwd) {
|
|
179
|
+
return new Promise((resolveRun, reject) => {
|
|
180
|
+
const child = spawn(command, { cwd, env: process.env, shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
181
|
+
let stdout = '';
|
|
182
|
+
let stderr = '';
|
|
183
|
+
child.stdout.on('data', (chunk) => { if (stdout.length < 128_000)
|
|
184
|
+
stdout += chunk; });
|
|
185
|
+
child.stderr.on('data', (chunk) => { if (stderr.length < 128_000)
|
|
186
|
+
stderr += chunk; });
|
|
187
|
+
child.once('error', reject);
|
|
188
|
+
child.once('exit', (code) => resolveRun({ code: code ?? 1, stdout, stderr }));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function clip(value) { return value.trim().slice(-4_000); }
|
|
192
|
+
//# sourceMappingURL=quality-gates.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DoxloopProject, QualityCheck, QualityConfig } from './types.js';
|
|
2
|
+
export declare function checkExternalLinks(root: string, project: DoxloopProject, config: QualityConfig, options?: {
|
|
3
|
+
offline?: boolean;
|
|
4
|
+
fetch?: typeof globalThis.fetch;
|
|
5
|
+
resolveHostname?: (hostname: string) => Promise<string[]>;
|
|
6
|
+
}): Promise<QualityCheck[]>;
|
|
7
|
+
//# sourceMappingURL=quality-links.d.ts.map
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { contentLinks } from './content-links.js';
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { isPrivateAddress } from './capture.js';
|
|
7
|
+
import { pathExists } from './fs.js';
|
|
8
|
+
import { loadPages, relativePath } from './project.js';
|
|
9
|
+
import { QUALITY_CODES } from './quality-contract.js';
|
|
10
|
+
const CACHE_FILE = join('.doxloop', 'cache', 'external-links.json');
|
|
11
|
+
const MAX_REDIRECTS = 5;
|
|
12
|
+
export async function checkExternalLinks(root, project, config, options = {}) {
|
|
13
|
+
const references = await externalReferences(root, project);
|
|
14
|
+
if (references.size === 0)
|
|
15
|
+
return [{ code: QUALITY_CODES.linkOk, category: 'links', status: 'pass', message: 'No literal external links were found in Markdown, HTML attributes, or reStructuredText. Dynamic links and cross-reference roles require the native generator check.' }];
|
|
16
|
+
const cache = await readCache(root);
|
|
17
|
+
const checks = [];
|
|
18
|
+
const links = config.links ?? {};
|
|
19
|
+
const offline = options.offline ?? links.mode === 'offline';
|
|
20
|
+
const maxAge = (links.cacheHours ?? 24) * 3_600_000;
|
|
21
|
+
for (const [url, files] of references) {
|
|
22
|
+
const file = [...files].sort().join(', ');
|
|
23
|
+
if (ignored(url, links.ignore ?? []) || !allowed(url, links.allowHosts ?? [])) {
|
|
24
|
+
checks.push({ code: QUALITY_CODES.linkSkipped, category: 'links', status: 'skipped', message: `External link is excluded by policy: ${url}`, file });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const cached = cache.links[url];
|
|
28
|
+
const fresh = cached && Date.now() - Date.parse(cached.checkedAt) <= maxAge;
|
|
29
|
+
if (offline) {
|
|
30
|
+
checks.push(cached
|
|
31
|
+
? resultForStatus(url, file, cached.status, `Cached result from ${cached.checkedAt}.`)
|
|
32
|
+
: { code: QUALITY_CODES.linkSkipped, category: 'links', status: 'skipped', message: `External link was not checked in offline mode: ${url}`, file, detail: 'Run without --offline to populate the cache.' });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (fresh) {
|
|
36
|
+
checks.push(resultForStatus(url, file, cached.status, `Cached result from ${cached.checkedAt}.`));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const result = await fetchLink(url, {
|
|
41
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
42
|
+
resolveHostname: options.resolveHostname ?? resolvePublicHostname,
|
|
43
|
+
timeoutMs: links.timeoutMs ?? 8_000,
|
|
44
|
+
retries: links.retries ?? 2,
|
|
45
|
+
});
|
|
46
|
+
cache.links[url] = { checkedAt: new Date().toISOString(), status: result.status, finalUrl: result.url };
|
|
47
|
+
checks.push(resultForStatus(url, file, result.status));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
checks.push({ code: QUALITY_CODES.linkUnavailable, category: 'links', status: 'warning', message: `External link could not be confirmed: ${url}`, file, detail: error instanceof Error ? error.message : String(error) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!offline)
|
|
54
|
+
await writeCache(root, cache);
|
|
55
|
+
return checks;
|
|
56
|
+
}
|
|
57
|
+
async function externalReferences(root, project) {
|
|
58
|
+
const output = new Map();
|
|
59
|
+
for (const path of await loadPages(root, project)) {
|
|
60
|
+
const raw = await readFile(path, 'utf8');
|
|
61
|
+
for (const rawUrl of contentLinks(raw)) {
|
|
62
|
+
if (!/^https?:\/\//i.test(rawUrl))
|
|
63
|
+
continue;
|
|
64
|
+
let url;
|
|
65
|
+
try {
|
|
66
|
+
url = new URL(rawUrl);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
url.hash = '';
|
|
72
|
+
const key = url.toString();
|
|
73
|
+
const files = output.get(key) ?? new Set();
|
|
74
|
+
files.add(relativePath(root, path));
|
|
75
|
+
output.set(key, files);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return output;
|
|
79
|
+
}
|
|
80
|
+
async function fetchLink(url, options) {
|
|
81
|
+
let lastError;
|
|
82
|
+
for (let attempt = 0; attempt <= options.retries; attempt += 1) {
|
|
83
|
+
try {
|
|
84
|
+
return await fetchLinkOnce(url, options);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
lastError = error;
|
|
88
|
+
if (attempt === options.retries)
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw lastError;
|
|
93
|
+
}
|
|
94
|
+
async function fetchLinkOnce(raw, options) {
|
|
95
|
+
let current = new URL(raw);
|
|
96
|
+
for (let count = 0; count <= MAX_REDIRECTS; count += 1) {
|
|
97
|
+
if (!['http:', 'https:'].includes(current.protocol) || current.username || current.password)
|
|
98
|
+
throw new Error('Only credential-free HTTP and HTTPS links are allowed.');
|
|
99
|
+
const addresses = await options.resolveHostname(current.hostname);
|
|
100
|
+
if (addresses.length === 0 || addresses.some(isPrivateAddress))
|
|
101
|
+
throw new Error(`Private or unresolved host blocked: ${current.hostname}`);
|
|
102
|
+
const response = await options.fetch(current, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(options.timeoutMs), headers: { 'user-agent': 'Doxloop-Link-Checker/1' } });
|
|
103
|
+
if (response.status >= 300 && response.status < 400) {
|
|
104
|
+
if (count === MAX_REDIRECTS)
|
|
105
|
+
throw new Error('Redirect limit exceeded.');
|
|
106
|
+
const location = response.headers.get('location');
|
|
107
|
+
if (!location)
|
|
108
|
+
return { status: response.status, url: current.toString() };
|
|
109
|
+
current = new URL(location, current);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (response.status === 405) {
|
|
113
|
+
const get = await options.fetch(current, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(options.timeoutMs), headers: { range: 'bytes=0-0', 'user-agent': 'Doxloop-Link-Checker/1' } });
|
|
114
|
+
return { status: get.status, url: current.toString() };
|
|
115
|
+
}
|
|
116
|
+
if (response.status >= 500 || response.status === 408 || response.status === 429)
|
|
117
|
+
throw new Error(`Temporary HTTP ${response.status}.`);
|
|
118
|
+
return { status: response.status, url: current.toString() };
|
|
119
|
+
}
|
|
120
|
+
throw new Error('Redirect limit exceeded.');
|
|
121
|
+
}
|
|
122
|
+
function resultForStatus(url, file, status, detail) {
|
|
123
|
+
if (status >= 200 && status < 400)
|
|
124
|
+
return { code: QUALITY_CODES.linkOk, category: 'links', status: 'pass', message: `External link responded with HTTP ${status}: ${url}`, file, ...(detail ? { detail } : {}) };
|
|
125
|
+
if (status === 401 || status === 403)
|
|
126
|
+
return { code: QUALITY_CODES.linkUnavailable, category: 'links', status: 'warning', message: `External link requires authentication (HTTP ${status}): ${url}`, file, ...(detail ? { detail } : {}) };
|
|
127
|
+
return { code: QUALITY_CODES.linkBroken, category: 'links', status: 'fail', message: `External link responded with HTTP ${status}: ${url}`, file, ...(detail ? { detail } : {}) };
|
|
128
|
+
}
|
|
129
|
+
function allowed(raw, hosts) { return hosts.length === 0 || hosts.includes(new URL(raw).hostname); }
|
|
130
|
+
function ignored(raw, patterns) { return patterns.some((pattern) => raw === pattern || raw.startsWith(pattern)); }
|
|
131
|
+
async function resolvePublicHostname(hostname) { return isIP(hostname) ? [hostname] : (await lookup(hostname, { all: true, verbatim: true })).map((item) => item.address); }
|
|
132
|
+
async function readCache(root) {
|
|
133
|
+
const path = join(root, CACHE_FILE);
|
|
134
|
+
if (!(await pathExists(path)))
|
|
135
|
+
return { schemaVersion: 1, links: {} };
|
|
136
|
+
try {
|
|
137
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
138
|
+
return value.schemaVersion === 1 && value.links && typeof value.links === 'object' ? value : { schemaVersion: 1, links: {} };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { schemaVersion: 1, links: {} };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function writeCache(root, cache) {
|
|
145
|
+
const path = join(root, CACHE_FILE);
|
|
146
|
+
await mkdir(join(root, '.doxloop', 'cache'), { recursive: true });
|
|
147
|
+
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=quality-links.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DoxloopProject, QualityCheck, QualityConfig } from './types.js';
|
|
2
|
+
export declare function lintDocumentation(root: string, project: DoxloopProject, config: QualityConfig): Promise<QualityCheck[]>;
|
|
3
|
+
export declare function fixDocumentation(root: string, project: DoxloopProject): Promise<{
|
|
4
|
+
changed: string[];
|
|
5
|
+
}>;
|
|
6
|
+
//# sourceMappingURL=quality-lint.d.ts.map
|