@galaxyproject/orbit 0.0.0 → 0.9.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 +123 -0
- package/LICENSE +21 -0
- package/README.md +760 -5
- package/bin/legacy-mcp-config.js +363 -0
- package/bin/orbit.js +810 -0
- package/bin/pi-extension-path.d.ts +4 -0
- package/bin/pi-extension-path.js +44 -0
- package/bin/provider-auth.d.ts +22 -0
- package/bin/provider-auth.js +77 -0
- package/bin/thinking-pref.js +38 -0
- package/bin/update-check.d.ts +15 -0
- package/bin/update-check.js +137 -0
- package/extensions/cli-update/index.ts +32 -0
- package/extensions/orbit/activity-hooks.ts +141 -0
- package/extensions/orbit/activity.ts +116 -0
- package/extensions/orbit/agent-dir.ts +11 -0
- package/extensions/orbit/auto-resume.ts +352 -0
- package/extensions/orbit/config.ts +3 -0
- package/extensions/orbit/confusables.ts +1 -0
- package/extensions/orbit/context.ts +957 -0
- package/extensions/orbit/dashboard-commands.ts +191 -0
- package/extensions/orbit/dashboard-store.ts +473 -0
- package/extensions/orbit/dashboard-tools.ts +1185 -0
- package/extensions/orbit/empty-completion.ts +99 -0
- package/extensions/orbit/evidence-gate.ts +1276 -0
- package/extensions/orbit/evidence-override-command.ts +294 -0
- package/extensions/orbit/exec-guard/bash-risk.ts +1408 -0
- package/extensions/orbit/exec-guard/gate.ts +181 -0
- package/extensions/orbit/exec-guard/guardian-config.ts +54 -0
- package/extensions/orbit/exec-guard/index.ts +1 -0
- package/extensions/orbit/exec-guard/model-tier.ts +36 -0
- package/extensions/orbit/exec-guard/path-jail.ts +54 -0
- package/extensions/orbit/exec-guard/policy.ts +342 -0
- package/extensions/orbit/exec-guard/runtime-state.ts +15 -0
- package/extensions/orbit/exec-guard/sensitive-read.ts +525 -0
- package/extensions/orbit/exec-guard/types.ts +42 -0
- package/extensions/orbit/execution-commands.ts +134 -0
- package/extensions/orbit/feedback-command.ts +81 -0
- package/extensions/orbit/feedback.ts +96 -0
- package/extensions/orbit/galaxy-api.test.ts +122 -0
- package/extensions/orbit/galaxy-api.ts +606 -0
- package/extensions/orbit/galaxy-artifact-link-guidance.ts +5 -0
- package/extensions/orbit/galaxy-destructive-gate.ts +113 -0
- package/extensions/orbit/galaxy-enrich.ts +1108 -0
- package/extensions/orbit/galaxy-failure.ts +101 -0
- package/extensions/orbit/galaxy-fixture.ts +126 -0
- package/extensions/orbit/galaxy-history-guard.ts +233 -0
- package/extensions/orbit/galaxy-job-block.ts +347 -0
- package/extensions/orbit/galaxy-job-ops.ts +288 -0
- package/extensions/orbit/galaxy-live-source.ts +929 -0
- package/extensions/orbit/galaxy-markdown-adapter.ts +248 -0
- package/extensions/orbit/galaxy-op-policy.ts +332 -0
- package/extensions/orbit/galaxy-ops-tools.ts +326 -0
- package/extensions/orbit/galaxy-output-store.ts +80 -0
- package/extensions/orbit/galaxy-output.ts +346 -0
- package/extensions/orbit/galaxy-page-binding.ts +222 -0
- package/extensions/orbit/galaxy-page-markdown-guidance.ts +15 -0
- package/extensions/orbit/galaxy-page-ops.ts +159 -0
- package/extensions/orbit/galaxy-page-sync.test.ts +417 -0
- package/extensions/orbit/galaxy-page-sync.ts +318 -0
- package/extensions/orbit/galaxy-pages-api.ts +203 -0
- package/extensions/orbit/galaxy-pages-sync.ts +409 -0
- package/extensions/orbit/galaxy-poll-guard.ts +189 -0
- package/extensions/orbit/galaxy-poller.ts +706 -0
- package/extensions/orbit/galaxy-provenance.ts +623 -0
- package/extensions/orbit/galaxy-reconcile.ts +956 -0
- package/extensions/orbit/galaxy-recovery.ts +176 -0
- package/extensions/orbit/galaxy-repeat-guard.ts +55 -0
- package/extensions/orbit/galaxy-submission-capture.ts +707 -0
- package/extensions/orbit/galaxy-submission.ts +502 -0
- package/extensions/orbit/galaxy-tool-source.ts +78 -0
- package/extensions/orbit/galaxy-tool-wrap.ts +234 -0
- package/extensions/orbit/galaxy-udt-block.ts +127 -0
- package/extensions/orbit/galaxy-upload-tus.ts +334 -0
- package/extensions/orbit/galaxy-upload.ts +300 -0
- package/extensions/orbit/galaxy-version-capture.ts +65 -0
- package/extensions/orbit/git.ts +173 -0
- package/extensions/orbit/harness-block-fields.ts +597 -0
- package/extensions/orbit/index.ts +593 -0
- package/extensions/orbit/init-gate.ts +284 -0
- package/extensions/orbit/instructions-command.ts +136 -0
- package/extensions/orbit/invocation-failure-hint.ts +57 -0
- package/extensions/orbit/iwc-plan-check.ts +94 -0
- package/extensions/orbit/lesson-command.ts +303 -0
- package/extensions/orbit/lesson-hint.ts +245 -0
- package/extensions/orbit/lesson-nudge.ts +106 -0
- package/extensions/orbit/lessons/compose.ts +177 -0
- package/extensions/orbit/lessons/counters.ts +120 -0
- package/extensions/orbit/lessons/enabled.ts +116 -0
- package/extensions/orbit/lessons/matcher.ts +346 -0
- package/extensions/orbit/lessons/paths.ts +286 -0
- package/extensions/orbit/lessons/pi-event-contract.ts +105 -0
- package/extensions/orbit/lessons/propose-replay.ts +103 -0
- package/extensions/orbit/lessons/propose-tool.ts +222 -0
- package/extensions/orbit/lessons/propose.ts +502 -0
- package/extensions/orbit/lessons/replay.ts +89 -0
- package/extensions/orbit/lessons/reproduction-index.ts +83 -0
- package/extensions/orbit/lessons/search-tool.ts +99 -0
- package/extensions/orbit/lessons/search.ts +136 -0
- package/extensions/orbit/lessons/store.ts +415 -0
- package/extensions/orbit/lessons/text.ts +36 -0
- package/extensions/orbit/lessons/types.ts +69 -0
- package/extensions/orbit/lessons/user-dir.ts +24 -0
- package/extensions/orbit/lessons/user-lesson.ts +88 -0
- package/extensions/orbit/lessons/wrapper.ts +61 -0
- package/extensions/orbit/lessons-command.ts +147 -0
- package/extensions/orbit/local-exec.ts +39 -0
- package/extensions/orbit/mcp-output.ts +179 -0
- package/extensions/orbit/mcp-servers.test.ts +24 -0
- package/extensions/orbit/mcp-servers.ts +25 -0
- package/extensions/orbit/model-script.ts +351 -0
- package/extensions/orbit/notebook-anchors.ts +342 -0
- package/extensions/orbit/notebook-writer.ts +863 -0
- package/extensions/orbit/observation-allowlists.ts +1109 -0
- package/extensions/orbit/observation-replay.ts +148 -0
- package/extensions/orbit/observation-triggers.ts +563 -0
- package/extensions/orbit/observation-ui.ts +175 -0
- package/extensions/orbit/observations-command.ts +421 -0
- package/extensions/orbit/observations-config.ts +200 -0
- package/extensions/orbit/observations.ts +1381 -0
- package/extensions/orbit/profiles.ts +270 -0
- package/extensions/orbit/progress-updates.ts +95 -0
- package/extensions/orbit/proposal-commands.ts +882 -0
- package/extensions/orbit/provenance-fs-file.ts +59 -0
- package/extensions/orbit/provenance-fs.ts +32 -0
- package/extensions/orbit/provenance-memory-fs.ts +59 -0
- package/extensions/orbit/record-store-file.ts +177 -0
- package/extensions/orbit/record-store-page.ts +77 -0
- package/extensions/orbit/record-store-seam.ts +47 -0
- package/extensions/orbit/record-store.ts +149 -0
- package/extensions/orbit/registry-carrier.ts +107 -0
- package/extensions/orbit/registry-eligibility.ts +95 -0
- package/extensions/orbit/registry-evaluation.ts +403 -0
- package/extensions/orbit/registry-evaluator.ts +519 -0
- package/extensions/orbit/registry-import.ts +91 -0
- package/extensions/orbit/registry-lock.ts +221 -0
- package/extensions/orbit/registry-memory-fs.ts +42 -0
- package/extensions/orbit/registry-page-carrier.ts +103 -0
- package/extensions/orbit/registry-proposal.ts +1348 -0
- package/extensions/orbit/registry-runtime.ts +234 -0
- package/extensions/orbit/registry-schema.ts +735 -0
- package/extensions/orbit/registry-submitter.ts +59 -0
- package/extensions/orbit/registry-templates.ts +346 -0
- package/extensions/orbit/registry.ts +476 -0
- package/extensions/orbit/sandbox/index.ts +120 -0
- package/extensions/orbit/sandbox/sandbox-bash.ts +93 -0
- package/extensions/orbit/sandbox/sandbox-config.ts +87 -0
- package/extensions/orbit/sandbox/sandbox-init-message.ts +28 -0
- package/extensions/orbit/secret-redaction.ts +247 -0
- package/extensions/orbit/session-index/cwd.ts +18 -0
- package/extensions/orbit/session-index/db.ts +56 -0
- package/extensions/orbit/session-index/indexer.ts +154 -0
- package/extensions/orbit/session-index/is-enabled.ts +17 -0
- package/extensions/orbit/session-index/parse.ts +254 -0
- package/extensions/orbit/session-index/query.ts +209 -0
- package/extensions/orbit/session-index/schema.ts +72 -0
- package/extensions/orbit/session-index/tools.ts +152 -0
- package/extensions/orbit/session-lifecycle.ts +321 -0
- package/extensions/orbit/skill-triggers.ts +159 -0
- package/extensions/orbit/skills-command.ts +48 -0
- package/extensions/orbit/skills-discovery.ts +435 -0
- package/extensions/orbit/skills.ts +84 -0
- package/extensions/orbit/sra-import-gate.ts +77 -0
- package/extensions/orbit/state.ts +265 -0
- package/extensions/orbit/submission-replay.ts +147 -0
- package/extensions/orbit/sync-command.ts +157 -0
- package/extensions/orbit/teams/critic-parser.ts +93 -0
- package/extensions/orbit/teams/dispatcher.ts +210 -0
- package/extensions/orbit/teams/is-enabled.ts +22 -0
- package/extensions/orbit/teams/tool.ts +187 -0
- package/extensions/orbit/teams/types.ts +57 -0
- package/extensions/orbit/teams/validate.ts +42 -0
- package/extensions/orbit/tester-id-command.ts +133 -0
- package/extensions/orbit/tools-sync.ts +279 -0
- package/extensions/orbit/tools.ts +1300 -0
- package/extensions/orbit/types.ts +41 -0
- package/extensions/orbit/ui-bridge.ts +55 -0
- package/extensions/orbit/ulid.ts +60 -0
- package/extensions/orbit/user-instructions.ts +388 -0
- package/extensions/orbit/vendor/skills/_catalog.json +68 -0
- package/extensions/orbit/vendor/skills/_manifest.json +1106 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/SKILL.md +112 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_provenance.json +218 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_required_tools.json +33 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/add.json +50 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-extract.json +51 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-next-step.json +42 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-validate.json +140 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/list.json +32 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/notes/galaxy-tool-job-failure-reference.md +137 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/notes/open-requirements-ledger.md +173 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/schemas/galaxy-tool-summary.schema.json +175 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/schemas/galaxy-workflow-draft.schema.json +2727 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/SKILL.md +82 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_provenance.json +133 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_required_tools.json +16 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/cli/draft-validate.json +140 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/notes/galaxy-workflow-draft-format.md +135 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/notes/open-requirements-ledger.md +173 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/schemas/galaxy-workflow-draft.schema.json +2727 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/SKILL.md +84 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/_provenance.json +166 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/_verify.json +29 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/galaxy-workflow-testability-design.md +136 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/iwc-test-data-conventions.md +308 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/planemo-asserts-idioms.md +246 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/planemo-workflow-test-architecture.md +151 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/galaxy-workflow-test-plan.schema.json +552 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/summary-galaxy-workflow.schema.json +687 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/tests-format.schema.json +7328 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/SKILL.md +127 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_provenance.json +198 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_required_tools.json +16 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/cli/convert.json +123 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/galaxy-data-flow-draft-contract.md +129 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/iwc-test-data-conventions.md +308 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/open-requirements-ledger.md +173 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/SKILL.md +73 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/_provenance.json +160 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-collection-semantics.md +43 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-collection-semantics.yml +1250 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-tool-job-failure-reference.md +137 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/nextflow-operators-to-galaxy-collection-recipes.md +98 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/planemo-asserts-idioms.md +246 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/planemo-workflow-test-architecture.md +151 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/SKILL.md +174 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/_provenance.json +130 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/_required_tools.json +18 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/_verify.json +17 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-revisions.json +48 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-search.json +95 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-versions.json +39 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/notes/component-tool-shed-search.md +230 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/schemas/galaxy-tool-discovery.schema.json +231 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/SKILL.md +93 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_provenance.json +201 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_required_tools.json +28 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_verify.json +17 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/cli/planemo.md +44 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/cli/validate-tests.json +40 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/galaxy-workflow-testability-design.md +136 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/iwc-test-data-conventions.md +308 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/planemo-asserts-idioms.md +246 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/planemo-workflow-test-architecture.md +151 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/schemas/galaxy-workflow-test-plan.schema.json +552 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/schemas/tests-format.schema.json +7328 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/SKILL.md +81 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/_provenance.json +93 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/_verify.json +17 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/references/notes/open-requirements-ledger.md +173 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/references/schemas/summary-galaxy-workflow.schema.json +687 -0
- package/extensions/orbit/vendor/skills/pipeline-update-interview-to-galaxy/SKILL.md +81 -0
- package/extensions/orbit/vendor/skills/pipeline-update-interview-to-galaxy/_assembly.json +23 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/SKILL.md +79 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/_provenance.json +125 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/_required_tools.json +28 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/cli/planemo.md +44 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/cli/validate-tests.json +40 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/planemo-asserts-idioms.md +246 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/planemo-workflow-test-architecture.md +151 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/schemas/tests-format.schema.json +7328 -0
- package/extensions/orbit/vendor/skills/skills/LICENSE +21 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/README.md +39 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/SKILL.md +892 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/api-patterns.md +927 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/apply-rules.md +1482 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/test-patterns.md +996 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/tools.md +609 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-data-libraries/SKILL.md +170 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/README.md +162 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/SKILL.md +99 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/README.md +119 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/tool-checking.md +419 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/workflow-testing.md +617 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/galaxy-integration.md +614 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/SKILL.md +144 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/average_col3.ipynb +68 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/extract_sample_metadata.ipynb +190 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/variant_annotation.ipynb +816 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/vcp_variant_map.ipynb +443 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/scripts/README.md +165 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/scripts/galaxy_tool_checker.py +521 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/SKILL.md +128 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/gotchas.md +135 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/history-access.md +132 -0
- package/extensions/orbit/vendor/skills/skills/reproduciblify/SKILL.md +146 -0
- package/extensions/orbit/vendor/skills/skills/reproduciblify/references/directives.yml +496 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/SKILL.md +245 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/01-minimal-echo.yml +27 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/02-single-data-in-out.yml +27 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/03-multiple-data-js.yml +28 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/04-select-integer-boolean.yml +41 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/05-resource-requirements.yml +25 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/06-biocontainer-tool.yml +38 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/07-inline-python-script.yml +56 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/08-configfile-script.yml +74 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/references/common-mistakes.md +76 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/references/schema-reference.md +155 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/references/templating.md +196 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/scripts/validate.py +109 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/README.md +71 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/SKILL.md +190 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/examples/histology-staining.md +145 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/examples/tissue-microarray-analysis.md +176 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/references/directives.md +415 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/SKILL.md +98 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_provenance.json +93 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_required_tools.json +28 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_verify.json +17 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/convert.json +123 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/foundry.md +25 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/validate.json +142 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/schemas/summary-galaxy-workflow.schema.json +687 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/SKILL.md +74 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_provenance.json +71 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_required_tools.json +16 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/cli/validate.json +142 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/notes/galaxy-workflow-testability-design.md +136 -0
- package/extensions/orbit/vendor-skills.ts +283 -0
- package/extensions/orbit/workspace-state-dir.ts +46 -0
- package/extensions/orbit-handoff/index.ts +73 -0
- package/extensions/orbit-handoff/orbit-launcher.ts +142 -0
- package/extensions/whats-new/index.ts +98 -0
- package/lessons/LICENSE +17 -0
- package/lessons/README.md +117 -0
- package/lessons/SCHEMA.md +187 -0
- package/lessons/build-snapshot.mjs +311 -0
- package/lessons/data/downloaded-file-is-not-what-its-extension-says.md +61 -0
- package/lessons/galaxy-api/403-history-is-a-hard-stop.md +57 -0
- package/lessons/galaxy-api/collection-into-single-dataset-input.md +61 -0
- package/lessons/galaxy-api/connectedvalue-in-command-line.md +62 -0
- package/lessons/galaxy-api/hid-is-not-an-id.md +59 -0
- package/lessons/galaxy-api/invoke-workflow-inputs-not-params.md +60 -0
- package/lessons/galaxy-api/repeat-param-pipe-keys.md +63 -0
- package/lessons/galaxy-api/run-tool-returns-on-submit.md +61 -0
- package/lessons/galaxy-tools/reference-index-not-on-server.md +62 -0
- package/lessons/log.md +42 -0
- package/lessons/reproduction/input-population-mismatch.md +59 -0
- package/lessons/reproduction/methods-text-vs-executed-parameters.md +62 -0
- package/lessons/snapshot.json +1004 -0
- package/lessons/stats/de-contrast-direction-and-sample-labels.md +63 -0
- package/lessons/stats/na-coerced-to-zero-in-filters.md +59 -0
- package/lessons/validate.mjs +1219 -0
- package/package.json +103 -5
- package/shared/agent-prompt.d.ts +8 -0
- package/shared/agent-prompt.js +15 -0
- package/shared/approval-prompt.d.ts +4 -0
- package/shared/approval-prompt.js +61 -0
- package/shared/brain-env.d.ts +14 -0
- package/shared/brain-env.js +108 -0
- package/shared/brain-exit.d.ts +20 -0
- package/shared/brain-exit.js +149 -0
- package/shared/capability-profile.d.ts +16 -0
- package/shared/capability-profile.js +75 -0
- package/shared/config-source.d.ts +13 -0
- package/shared/config-source.js +17 -0
- package/shared/confusables.d.ts +3 -0
- package/shared/confusables.js +93 -0
- package/shared/custom-provider.d.ts +45 -0
- package/shared/custom-provider.js +169 -0
- package/shared/dashboard-contract.d.ts +123 -0
- package/shared/dashboard-contract.js +560 -0
- package/shared/dashboard-layout-store.d.ts +44 -0
- package/shared/dashboard-layout-store.js +281 -0
- package/shared/feedback-contract.d.ts +61 -0
- package/shared/feedback-contract.js +146 -0
- package/shared/galaxy-artifact-links.d.ts +30 -0
- package/shared/galaxy-artifact-links.js +166 -0
- package/shared/galaxy-credentials.d.ts +26 -0
- package/shared/galaxy-credentials.js +71 -0
- package/shared/galaxy-destructive.d.ts +21 -0
- package/shared/galaxy-destructive.js +269 -0
- package/shared/galaxy-guidance.d.ts +2 -0
- package/shared/galaxy-guidance.js +53 -0
- package/shared/galaxy-live-contract.d.ts +92 -0
- package/shared/galaxy-live-contract.js +180 -0
- package/shared/galaxy-page-cells.d.ts +2 -0
- package/shared/galaxy-page-cells.js +22 -0
- package/shared/galaxy-tool-family.d.ts +5 -0
- package/shared/galaxy-tool-family.js +27 -0
- package/shared/galaxy-url.d.ts +1 -0
- package/shared/galaxy-url.js +48 -0
- package/shared/gtn.d.ts +31 -0
- package/shared/gtn.js +253 -0
- package/shared/handled-prompt.d.ts +3 -0
- package/shared/handled-prompt.js +26 -0
- package/shared/known-provider.d.ts +11 -0
- package/shared/known-provider.js +89 -0
- package/shared/lesson-hint-marker.d.ts +7 -0
- package/shared/lesson-hint-marker.js +32 -0
- package/shared/lesson-rules.d.ts +150 -0
- package/shared/lesson-rules.js +203 -0
- package/shared/malformed-object-ids.d.ts +1 -0
- package/shared/malformed-object-ids.js +37 -0
- package/shared/markdown-sections.d.ts +10 -0
- package/shared/markdown-sections.js +118 -0
- package/shared/notebook-fences.d.ts +12 -0
- package/shared/notebook-fences.js +53 -0
- package/shared/observation-contract.d.ts +91 -0
- package/shared/observation-contract.js +544 -0
- package/shared/orbit-config.d.ts +226 -0
- package/shared/orbit-config.js +189 -0
- package/shared/orbit-env.d.ts +13 -0
- package/shared/orbit-env.js +124 -0
- package/shared/orbit-shell-contract.d.ts +74 -0
- package/shared/orbit-shell-contract.js +30 -0
- package/shared/page-content-hash.d.ts +1 -0
- package/shared/page-content-hash.js +13 -0
- package/shared/pi-default-model.d.ts +7 -0
- package/shared/pi-default-model.js +41 -0
- package/shared/prompt-blocks.d.ts +87 -0
- package/shared/prompt-blocks.js +898 -0
- package/shared/provider-auth-caps.d.ts +22 -0
- package/shared/provider-auth-caps.js +57 -0
- package/shared/random.d.ts +2 -0
- package/shared/random.js +15 -0
- package/shared/record-excerpt.d.ts +4 -0
- package/shared/record-excerpt.js +16 -0
- package/shared/redirect-guard.d.ts +36 -0
- package/shared/redirect-guard.js +235 -0
- package/shared/skills-pin.d.ts +8 -0
- package/shared/skills-pin.js +9 -0
- package/shared/skills-router.d.ts +17 -0
- package/shared/skills-router.js +63 -0
- package/shared/sra-import.d.ts +34 -0
- package/shared/sra-import.js +206 -0
- package/shared/state-dir.d.ts +21 -0
- package/shared/state-dir.js +240 -0
- package/shared/team-dispatch-contract.d.ts +28 -0
- package/shared/team-dispatch-contract.js +1 -0
- package/shared/truncate-middle.d.ts +11 -0
- package/shared/truncate-middle.js +68 -0
- package/shared/version-compare.d.ts +10 -0
- package/shared/version-compare.js +74 -0
- package/shared/whats-new.d.ts +31 -0
- package/shared/whats-new.js +83 -0
- package/shared/wsl.d.ts +10 -0
- package/shared/wsl.js +24 -0
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Schema validator for the `lessons/` corpus. The rules here ARE the schema.
|
|
4
|
+
*
|
|
5
|
+
* node lessons/validate.mjs # validate lessons/
|
|
6
|
+
* node lessons/validate.mjs <dir> # validate some other corpus (the tests do)
|
|
7
|
+
*
|
|
8
|
+
* Exit 0 when clean; exit 1 with one `path:line: message` line per violation.
|
|
9
|
+
*
|
|
10
|
+
* Plain Node plus `yaml` and `marked` (both runtime dependencies: this ships
|
|
11
|
+
* and checks user-local lessons too). Nothing from `extensions/`, so this runs
|
|
12
|
+
* before anything is built and can be imported by a plain `.mjs` script.
|
|
13
|
+
*
|
|
14
|
+
* Every bound below is a content control rather than a tidiness preference. A
|
|
15
|
+
* lesson ships inside the package to every install, and the published snapshot
|
|
16
|
+
* goes into a public index, so a lesson must not carry anything to follow (no
|
|
17
|
+
* URLs, links, images, HTML or character references, judged by a real markdown
|
|
18
|
+
* parser rather than line regexes), anything to run (no code blocks), or
|
|
19
|
+
* anything identifying (no paths, hostnames, hex ids, uuids, IPs, email
|
|
20
|
+
* addresses or credential shapes anywhere, and no URLs outside the fields the
|
|
21
|
+
* schema says may hold a link, which take https to an allowed host only).
|
|
22
|
+
* These are shape checks, not meaning checks: a private hostname written as
|
|
23
|
+
* prose, a person's name, a copied data value or an instruction aimed at the
|
|
24
|
+
* model still needs a human reviewer.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { Lexer, marked } from "marked";
|
|
31
|
+
import { isAlias, parseDocument, visit } from "yaml";
|
|
32
|
+
|
|
33
|
+
export const LESSONS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
|
|
35
|
+
/** `galaxy-api` holds lessons that graduated upstream and are kept unsurfaced. */
|
|
36
|
+
export const NAMESPACES = ["stats", "reproduction", "data", "galaxy-tools", "galaxy-api"];
|
|
37
|
+
export const STATUSES = ["draft", "stable", "deprecated"];
|
|
38
|
+
export const KINDS = ["pitfall", "expectation", "choice", "source-quirk", "reproduction"];
|
|
39
|
+
export const STAGES = [
|
|
40
|
+
"data-acquisition",
|
|
41
|
+
"metadata-reconciliation",
|
|
42
|
+
"tool-parameterization",
|
|
43
|
+
"job-execution",
|
|
44
|
+
"result-interpretation",
|
|
45
|
+
];
|
|
46
|
+
export const EVIDENCE = {
|
|
47
|
+
symptom: ["verified", "reported"],
|
|
48
|
+
cause: ["verified", "hypothesized", "unknown"],
|
|
49
|
+
outcome: ["validated", "unvalidated"],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const REQUIRED_KEYS = [
|
|
53
|
+
"type",
|
|
54
|
+
"title",
|
|
55
|
+
"description",
|
|
56
|
+
"tags",
|
|
57
|
+
"status",
|
|
58
|
+
"generated",
|
|
59
|
+
"stale_after",
|
|
60
|
+
"sources",
|
|
61
|
+
"kind",
|
|
62
|
+
"stage",
|
|
63
|
+
"trigger",
|
|
64
|
+
"cues",
|
|
65
|
+
"applies_to",
|
|
66
|
+
"evidence",
|
|
67
|
+
"graduated_to",
|
|
68
|
+
"upstream",
|
|
69
|
+
"supersedes",
|
|
70
|
+
];
|
|
71
|
+
export const OPTIONAL_KEYS = ["verified"];
|
|
72
|
+
|
|
73
|
+
export const TRIGGER_KEYS = [
|
|
74
|
+
"signatures",
|
|
75
|
+
"tools",
|
|
76
|
+
"mcp_tools",
|
|
77
|
+
"formats",
|
|
78
|
+
"hosts",
|
|
79
|
+
"extensions",
|
|
80
|
+
"step_keywords",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** The body's six sections, in the order they must appear. */
|
|
84
|
+
export const SECTIONS = [
|
|
85
|
+
{ heading: "## Symptom", key: "symptom", required: true },
|
|
86
|
+
{ heading: "## Cause", key: "cause", required: false },
|
|
87
|
+
{ heading: "## Check first", key: "check_first", required: true },
|
|
88
|
+
{ heading: "## Intervention", key: "intervention", required: true },
|
|
89
|
+
{ heading: "## Validate", key: "validate", required: true },
|
|
90
|
+
{ heading: "## Does NOT apply when", key: "not_when", required: true },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
export const LIMITS = {
|
|
94
|
+
slug: 80,
|
|
95
|
+
title: 120,
|
|
96
|
+
description: 200,
|
|
97
|
+
tag: 40,
|
|
98
|
+
cues: 300,
|
|
99
|
+
signature: 200,
|
|
100
|
+
tool: 200,
|
|
101
|
+
mcpTool: 80,
|
|
102
|
+
format: 40,
|
|
103
|
+
host: 100,
|
|
104
|
+
extension: 20,
|
|
105
|
+
stepKeyword: 40,
|
|
106
|
+
appliesTo: 200,
|
|
107
|
+
method: 300,
|
|
108
|
+
sourceId: 100,
|
|
109
|
+
sourceText: 200,
|
|
110
|
+
freeText: 200,
|
|
111
|
+
section: 600,
|
|
112
|
+
listItems: 20,
|
|
113
|
+
minSignature: 8,
|
|
114
|
+
fileBytes: 16384,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const UNKNOWN_SIGNATURE = "unknown";
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The signature normalizer, in the locked order: url, email, path, id, n, then
|
|
121
|
+
* truncate, then the empty fallback. The observation contract in `shared/`
|
|
122
|
+
* holds the same table. It is duplicated rather than imported because this
|
|
123
|
+
* script must run with nothing from the brain, and a lesson's signatures and a
|
|
124
|
+
* tool result's signature have to normalize byte for byte the same or the
|
|
125
|
+
* matcher never fires. Change both together.
|
|
126
|
+
*
|
|
127
|
+
* URL before path matters: the other way round, the path rule eats a URL's
|
|
128
|
+
* `//host/a/b` and leaves an `https:` stub behind.
|
|
129
|
+
*/
|
|
130
|
+
const NORMALIZERS = Object.freeze([
|
|
131
|
+
[/https?:\/\/\S+/g, "<url>"],
|
|
132
|
+
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>"],
|
|
133
|
+
// Two separators required, so ordinary prose ("and/or") is not read as a
|
|
134
|
+
// path. A single-segment absolute path like /etc is not identifying.
|
|
135
|
+
[/(?:[A-Za-z]:[\\/]|~[\\/]|\/)[^\s"'`<>|]*[\\/][^\s"'`<>|]*/g, "<path>"],
|
|
136
|
+
[/[0-9a-fA-F]{16,}/g, "<id>"],
|
|
137
|
+
[/\d{5,}/g, "<n>"],
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
export function normalizeSignature(text) {
|
|
141
|
+
let s = String(text ?? "")
|
|
142
|
+
.split(/\r?\n/)[0]
|
|
143
|
+
.replace(/\s+/g, " ")
|
|
144
|
+
.trim();
|
|
145
|
+
for (const [re, repl] of NORMALIZERS) s = s.replace(re, repl);
|
|
146
|
+
s = s.slice(0, LIMITS.signature).trim();
|
|
147
|
+
return s || UNKNOWN_SIGNATURE;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Hosts a link field may point at. A link is provenance for a reviewer to
|
|
152
|
+
* follow, so it goes to the project's own sites or to the public repositories
|
|
153
|
+
* the lessons already name as trigger hosts, and nowhere else: an arbitrary
|
|
154
|
+
* host is itself identifying (an internal Galaxy, a lab's file server) and can
|
|
155
|
+
* carry anything in its path. Exact hostnames, no wildcard subdomains. To add
|
|
156
|
+
* one, add it here and say why in the pull request.
|
|
157
|
+
*/
|
|
158
|
+
export const LINK_HOSTS = Object.freeze([
|
|
159
|
+
"github.com",
|
|
160
|
+
"galaxyproject.org",
|
|
161
|
+
"docs.galaxyproject.org",
|
|
162
|
+
"help.galaxyproject.org",
|
|
163
|
+
"training.galaxyproject.org",
|
|
164
|
+
"doi.org",
|
|
165
|
+
"zenodo.org",
|
|
166
|
+
"figshare.com",
|
|
167
|
+
"journals.plos.org",
|
|
168
|
+
"static-content.springer.com",
|
|
169
|
+
"www.ncbi.nlm.nih.gov",
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
/** Fields C3 allows to carry a link. Everything else in a lesson may not. */
|
|
173
|
+
const LINK_FIELDS = ["graduated_to", "upstream", "sources.resource"];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Credential shapes from the provider-key list. Not anchored at a word
|
|
177
|
+
* boundary on the left because `_` is a word character and `node_sk-...` would
|
|
178
|
+
* slip past; the lookbehind only stops `disk-quota-...` reading as `sk-`.
|
|
179
|
+
*/
|
|
180
|
+
const CREDENTIALS = [
|
|
181
|
+
/(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]{16,}/,
|
|
182
|
+
/(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}/,
|
|
183
|
+
/(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{20,}/,
|
|
184
|
+
/(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{10,}/,
|
|
185
|
+
/(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}/,
|
|
186
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
187
|
+
/(?<![A-Za-z0-9])eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/,
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
// Pseudonyms in generated.by and verified.by have their own strict pattern.
|
|
191
|
+
const AUTHOR = /^(?:agent|human):[A-Za-z0-9._/@-]{1,80}$/;
|
|
192
|
+
|
|
193
|
+
// Schemes that are a link whatever follows them. Any other `word:` is only a
|
|
194
|
+
// scheme when what follows looks like a host or an address, because `word:`
|
|
195
|
+
// is also an R interaction term (`batch:condition`), a Galaxy collection type
|
|
196
|
+
// (`list:paired`) and a slice (`arr[i:j]`).
|
|
197
|
+
const SCHEMES =
|
|
198
|
+
"https?|s?ftps?|mailto|file|data|javascript|vbscript|ssh|git|svn|s3|gs|hdfs|tel|sms|callto|wss?|irc|ldap|smb|nfs|telnet|gopher|news|nntp|blob|about|chrome|view-source|jar|magnet|xmpp|sip|urn|rtsp|webcal|feed|intent|ms-[a-z-]+";
|
|
199
|
+
const KNOWN_SCHEME = new RegExp(`(?<![A-Za-z0-9+.-])(?:${SCHEMES}):(?=\\S)`, "i");
|
|
200
|
+
|
|
201
|
+
function hasScheme(text) {
|
|
202
|
+
if (KNOWN_SCHEME.test(text)) return true;
|
|
203
|
+
// The lookbehind pins each match to the start of a word, which keeps this
|
|
204
|
+
// linear on a long run of letters.
|
|
205
|
+
const re = /(?<![A-Za-z0-9+.:-])[A-Za-z][A-Za-z0-9+.-]*:(?!:)(\S*)/g;
|
|
206
|
+
for (const m of text.matchAll(re)) {
|
|
207
|
+
if (AUTHOR.test(m[0].replace(/[`'"),.;\]}>]+$/, ""))) continue;
|
|
208
|
+
if (/[@%]|[A-Za-z0-9-]\.[A-Za-z]{2,}/.test(m[1])) return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Top-level domains that mean a network name rather than a file extension:
|
|
214
|
+
// `.gz`, `.md`, `.ts`, `.sh`, `.py` and friends are country codes too, and
|
|
215
|
+
// lessons talk about files all the time. A host on a TLD not listed here gets
|
|
216
|
+
// past; this is a shape check.
|
|
217
|
+
const PRIVATE_TLDS =
|
|
218
|
+
"internal|local|localdomain|lan|corp|intranet|private|home|arpa|example|test|invalid|localhost";
|
|
219
|
+
const TLDS = `com|org|net|edu|gov|mil|int|io|dev|cloud|info|biz|ai|co|ly|xyz|us|app|ru|me|tv|cc|gg|site|online|tech|top|ws|to|${PRIVATE_TLDS}|uk|de|fr|nl|ch|eu|ca|au|jp|cn|se|dk|fi|es|nz|br`;
|
|
220
|
+
|
|
221
|
+
const URL_SHAPES = ["a URL", "a hostname"];
|
|
222
|
+
|
|
223
|
+
/** An IPv4 octet, and the four of them with nothing numeric on either side. */
|
|
224
|
+
const OCTET = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
|
|
225
|
+
// The first octet is never 0: 0.0.0.0/8 identifies nothing, and four-part
|
|
226
|
+
// tool versions like 0.7.17.4 would otherwise read as addresses.
|
|
227
|
+
const IPV4 = new RegExp(
|
|
228
|
+
`(?<![\\d.])(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d?)(?:\\.${OCTET}){3}(?!\\.?\\d)`,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Shapes that point at a person, a machine, a dataset or a credential. The
|
|
233
|
+
* observation validator's list, minus its hid/dataset/history-followed-by-a-
|
|
234
|
+
* number rule: lessons talk about hids in the abstract. Every unbounded
|
|
235
|
+
* repetition is pinned to a word start with a lookbehind, so a 16 KB run of
|
|
236
|
+
* letters costs one pass rather than one pass per character.
|
|
237
|
+
*/
|
|
238
|
+
const IDENTIFYING = [
|
|
239
|
+
[
|
|
240
|
+
"a URL",
|
|
241
|
+
(s) =>
|
|
242
|
+
// A scheme with slashes, a protocol-relative //host, www., a scheme-less
|
|
243
|
+
// host/path, or a `scheme:` (`https:example.org` and `mailto:` both
|
|
244
|
+
// resolve).
|
|
245
|
+
/(?<![A-Za-z0-9+.-])[A-Za-z][A-Za-z0-9+.-]*:\/\/|(?:^|[^A-Za-z0-9:])\/\/[A-Za-z0-9]|(?<![A-Za-z0-9])www\.|(?<![A-Za-z0-9.-])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\/\S/i.test(
|
|
246
|
+
s,
|
|
247
|
+
) || hasScheme(s),
|
|
248
|
+
],
|
|
249
|
+
[
|
|
250
|
+
"a hostname",
|
|
251
|
+
new RegExp(`(?<![A-Za-z0-9.-])(?:[A-Za-z0-9-]+\\.)+(?:${TLDS})(?![A-Za-z0-9-])`, "i"),
|
|
252
|
+
],
|
|
253
|
+
[
|
|
254
|
+
"a home-directory path",
|
|
255
|
+
/\/(?:Users|home|root)\/|~[A-Za-z0-9._-]*[\\/]|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?[\\/]|%[A-Za-z_][A-Za-z0-9_]*%[\\/]/i,
|
|
256
|
+
],
|
|
257
|
+
// Any lead-in that is not itself part of a word or a relative path (`./`,
|
|
258
|
+
// `../`, `a/b`), and any segment characters at all: `/data@lab/` is a path.
|
|
259
|
+
["an absolute path", /(?:^|[^A-Za-z0-9_~/.])\/[^\s/\\]+\/[^\s/]/],
|
|
260
|
+
[
|
|
261
|
+
"a Windows path",
|
|
262
|
+
/\b[A-Za-z]:[\\/]|\\\\[A-Za-z0-9.-]+\\|(?:^|[^A-Za-z0-9_\\])\\[^\s\\]+\\[^\s\\]/,
|
|
263
|
+
],
|
|
264
|
+
["a hex id of 16+ characters", /[0-9a-fA-F]{16,}/],
|
|
265
|
+
["a uuid", /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i],
|
|
266
|
+
[
|
|
267
|
+
"an IP address",
|
|
268
|
+
(s) =>
|
|
269
|
+
IPV4.test(s) ||
|
|
270
|
+
// The full form, then the `::` forms. Nothing word-like or `[` may hug
|
|
271
|
+
// a `::`, so `dplyr::filter`, `base::c()`, `std::vector` and `x[::2]`
|
|
272
|
+
// stay prose; `_` may, so `node_2001:db8::1` is still an address.
|
|
273
|
+
/(?<![0-9A-Za-z:])(?:[0-9a-f]{1,4}:){3,7}[0-9a-f]{1,4}(?![0-9A-Za-z:])/i.test(s) ||
|
|
274
|
+
/(?<![0-9A-Za-z:[])(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4})*::(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4})*)?|::[0-9a-f]{1,4}(?::[0-9a-f]{1,4})*)(?![A-Za-z0-9_:(\]])/i.test(
|
|
275
|
+
s,
|
|
276
|
+
),
|
|
277
|
+
],
|
|
278
|
+
// Anything@domain, quoted local parts included.
|
|
279
|
+
[
|
|
280
|
+
"an email address",
|
|
281
|
+
/(?<![A-Za-z0-9._%+"-])[A-Za-z0-9._%+"-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/,
|
|
282
|
+
],
|
|
283
|
+
["a credential-shaped string", (s) => CREDENTIALS.some((re) => re.test(s))],
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
/** Names of the identifying shapes in `text`, minus any in `skip`. */
|
|
287
|
+
function shapesIn(text, skip = []) {
|
|
288
|
+
const s = String(text ?? "");
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const [name, test] of IDENTIFYING) {
|
|
291
|
+
if (skip.includes(name)) continue;
|
|
292
|
+
if (typeof test === "function" ? test(s) : test.test(s)) out.push(name);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Identifying-data problems in one string. Exported so the local-lesson rules
|
|
299
|
+
* use this table rather than a copy of it. Each problem reads `<where>
|
|
300
|
+
* contains <shape>; lessons carry none`.
|
|
301
|
+
*/
|
|
302
|
+
export function identifyingProblems(text, where) {
|
|
303
|
+
if (tooLongToCheck(text)) return [`${where} is too long to check`];
|
|
304
|
+
return shapesIn(text).map((shape) => `${where} contains ${shape}; lessons carry none`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Nothing in a lesson is longer than the file cap, and the parser and the
|
|
309
|
+
* shape table both cost more than linear time on some inputs, so a longer
|
|
310
|
+
* string is refused rather than checked.
|
|
311
|
+
*/
|
|
312
|
+
function tooLongToCheck(text) {
|
|
313
|
+
return String(text ?? "").length > LIMITS.fileBytes;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A string that is, or carries, a URL or a hostname. */
|
|
317
|
+
function looksLikeLink(text) {
|
|
318
|
+
return shapesIn(text).some((name) => URL_SHAPES.includes(name));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Problems with a link in a field that may hold one. The raw string is what
|
|
323
|
+
* ships, so it is judged as written: canonical https to an allowed host, no
|
|
324
|
+
* port, credentials, query, fragment, percent-escapes or dot segments, and the
|
|
325
|
+
* whole string still gets the identifying table. The absolute-path rule is
|
|
326
|
+
* left out because every URL path is one; the host allowlist bounds where a
|
|
327
|
+
* path can point instead.
|
|
328
|
+
*/
|
|
329
|
+
export function linkProblems(link, where) {
|
|
330
|
+
const raw = String(link ?? "");
|
|
331
|
+
const out = [];
|
|
332
|
+
let url;
|
|
333
|
+
try {
|
|
334
|
+
url = new URL(raw);
|
|
335
|
+
} catch {
|
|
336
|
+
url = undefined;
|
|
337
|
+
}
|
|
338
|
+
if (!url || /\s/.test(raw)) {
|
|
339
|
+
out.push(`${where} is not a single URL`);
|
|
340
|
+
} else {
|
|
341
|
+
if (url.protocol !== "https:") out.push(`${where} is a non-https URL`);
|
|
342
|
+
if (url.username || url.password) out.push(`${where} has credentials in a URL`);
|
|
343
|
+
if (url.port) out.push(`${where} has a port in a URL`);
|
|
344
|
+
if (/[?#]/.test(raw)) out.push(`${where} has a query or fragment in a URL`);
|
|
345
|
+
// Messages never echo the host or the path: a refused link is exactly
|
|
346
|
+
// the text that must not reach a log.
|
|
347
|
+
if (url.protocol === "https:" && !LINK_HOSTS.includes(url.hostname)) {
|
|
348
|
+
out.push(`${where} links to a host that is not in LINK_HOSTS`);
|
|
349
|
+
}
|
|
350
|
+
// The origin-only form is the one difference new URL() is allowed to make.
|
|
351
|
+
if (url.href !== raw && url.href !== `${raw}/`) {
|
|
352
|
+
out.push(
|
|
353
|
+
`${where} is not a canonical URL (lowercase host, no default port, no dot segments)`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
// A path is words, digits and separators. Anything else (`[`, `!`, `:`,
|
|
357
|
+
// `@`, `<`) is how markup or an address rides along under an allowed host.
|
|
358
|
+
if (!/^[A-Za-z0-9._~/+-]*$/.test(url.pathname)) {
|
|
359
|
+
out.push(`${where} has a character in its path that a link does not need`);
|
|
360
|
+
}
|
|
361
|
+
// The host is pinned by the allowlist, so the path is what can still name
|
|
362
|
+
// a machine; only the absolute-path rule is left out, since every URL path
|
|
363
|
+
// is one.
|
|
364
|
+
for (const shape of shapesIn(url.pathname, ["an absolute path"])) {
|
|
365
|
+
out.push(`${where} contains ${shape} inside a URL path`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (/%/.test(raw)) out.push(`${where} has a percent-escape in a URL`);
|
|
369
|
+
if (/\\/.test(raw)) out.push(`${where} has a backslash in a URL`);
|
|
370
|
+
if (/\.\./.test(raw)) out.push(`${where} has a dot segment in a URL`);
|
|
371
|
+
for (const shape of shapesIn(raw, [...URL_SHAPES, "an absolute path"])) {
|
|
372
|
+
out.push(`${where} contains ${shape} inside a URL`);
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* What markdown a lesson may not contain, keyed by what a finding is called in
|
|
379
|
+
* a message. `rule` reads as a refusal in a body message; `noun` reads after
|
|
380
|
+
* "contains" in a field message.
|
|
381
|
+
*/
|
|
382
|
+
const MARKUP = {
|
|
383
|
+
link: { rule: "no markdown links", noun: "a markdown link" },
|
|
384
|
+
image: { rule: "no images", noun: "an image" },
|
|
385
|
+
html: { rule: "no HTML", noun: "HTML" },
|
|
386
|
+
def: { rule: "no markdown links", noun: "a link reference definition" },
|
|
387
|
+
escape: { rule: "no backslash escapes", noun: "a backslash escape" },
|
|
388
|
+
charref: { rule: "no character references", noun: "a character reference" },
|
|
389
|
+
codespan: { rule: "no links or HTML inside a code span", noun: "a link or HTML in a code span" },
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// `@`, `@` and `&`. Numeric ones without the semicolon too:
|
|
393
|
+
// browsers decode those in text.
|
|
394
|
+
const CHAR_REF = /&#[0-9]+;?|&#[xX][0-9A-Fa-f]+;?|&[A-Za-z][A-Za-z0-9]*;/;
|
|
395
|
+
|
|
396
|
+
// A code span may show a placeholder like `<collection id>`, but nothing a
|
|
397
|
+
// renderer that disagreed about where the span ends could turn into a live
|
|
398
|
+
// tag: no closing tags, comments, attribute values, quotes or schemes.
|
|
399
|
+
const INERT_PLACEHOLDER = /^<[A-Za-z][A-Za-z0-9 _-]*>$/;
|
|
400
|
+
// Elements that change how everything after them parses, or fetch something,
|
|
401
|
+
// even with no attributes. Never a placeholder.
|
|
402
|
+
const LIVE_ELEMENTS =
|
|
403
|
+
/^<\s*(?:script|style|iframe|frame|frameset|object|embed|applet|plaintext|xmp|textarea|title|noscript|noembed|noframes|svg|math|base|link|meta|form|input|button|img|image|video|audio|source|track|picture|template|portal|select|option|marquee)\b/i;
|
|
404
|
+
// What normalizeSignature writes. A stored signature carries these as text,
|
|
405
|
+
// so they are not HTML.
|
|
406
|
+
const NORMALIZER_PLACEHOLDERS = new Set(["<url>", "<email>", "<path>", "<id>", "<n>"]);
|
|
407
|
+
|
|
408
|
+
function inertPlaceholder(raw) {
|
|
409
|
+
return INERT_PLACEHOLDER.test(raw) && !LIVE_ELEMENTS.test(raw);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function childTokens(token) {
|
|
413
|
+
const out = [];
|
|
414
|
+
if (Array.isArray(token.tokens)) out.push(...token.tokens);
|
|
415
|
+
if (Array.isArray(token.items)) out.push(...token.items);
|
|
416
|
+
if (token.type === "table") {
|
|
417
|
+
for (const cell of token.header ?? []) out.push(...(cell.tokens ?? []));
|
|
418
|
+
for (const row of token.rows ?? []) for (const cell of row) out.push(...(cell.tokens ?? []));
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Lex `text` and call `visit(token, offset)` for every token at every depth,
|
|
425
|
+
* where `offset` is the token's position in `text` when its raw text can be
|
|
426
|
+
* found there, and the enclosing block's when the lexer rewrote it (it strips
|
|
427
|
+
* the `>` from blockquote contents, for one). Returns the lexer's output, or
|
|
428
|
+
* undefined when the lexer threw.
|
|
429
|
+
*/
|
|
430
|
+
function walkTokens(text, visit) {
|
|
431
|
+
let tokens;
|
|
432
|
+
try {
|
|
433
|
+
tokens = marked.lexer(text, { gfm: true });
|
|
434
|
+
} catch {
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
const walk = (list, base, depth) => {
|
|
438
|
+
let cursor = base;
|
|
439
|
+
for (const token of list) {
|
|
440
|
+
const at = typeof token.raw === "string" ? text.indexOf(token.raw, cursor) : -1;
|
|
441
|
+
const offset = at === -1 ? base : at;
|
|
442
|
+
if (at !== -1) cursor = at + token.raw.length;
|
|
443
|
+
visit(token, offset, depth);
|
|
444
|
+
walk(childTokens(token), offset, depth + 1);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
walk(tokens, 0, 0);
|
|
448
|
+
return tokens;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Every refused piece of markup in `text`, as `{ kind, offset }`. */
|
|
452
|
+
function markupFindings(text) {
|
|
453
|
+
const out = [];
|
|
454
|
+
for (const m of text.matchAll(new RegExp(CHAR_REF.source, "g"))) {
|
|
455
|
+
out.push({ kind: "charref", offset: m.index });
|
|
456
|
+
}
|
|
457
|
+
const tokens = walkTokens(text, (token, offset) => {
|
|
458
|
+
if (token.type === "html" && NORMALIZER_PLACEHOLDERS.has(token.raw)) return;
|
|
459
|
+
if (["link", "image", "html", "def", "escape"].includes(token.type)) {
|
|
460
|
+
out.push({ kind: token.type, offset });
|
|
461
|
+
} else if (token.type === "codespan" && !inertCodeSpan(token.text)) {
|
|
462
|
+
out.push({ kind: "codespan", offset });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
// Fail closed: text the parser cannot read is text nobody checked.
|
|
466
|
+
if (!tokens) out.push({ kind: "html", offset: 0 });
|
|
467
|
+
// A definition anywhere makes some `[label]` live. The walk sees it as a
|
|
468
|
+
// def token wherever it sits; this is the backstop if a lexer version
|
|
469
|
+
// records the definition without emitting one.
|
|
470
|
+
else if (Object.keys(tokens.links ?? {}).length > 0 && !out.some((f) => f.kind === "def")) {
|
|
471
|
+
out.push({ kind: "def", offset: 0 });
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Containers whose children are separate blocks rather than one run of text.
|
|
477
|
+
const BLOCK_CONTAINERS = new Set(["blockquote", "list", "list_item", "table"]);
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* What a reader sees once `tokens` render: inline runs concatenated with no
|
|
481
|
+
* separator, so `alice@exa**mple**.org` comes out as the address it is.
|
|
482
|
+
*/
|
|
483
|
+
function plainText(tokens, sep = "\n") {
|
|
484
|
+
return tokens
|
|
485
|
+
.map((t) => {
|
|
486
|
+
const kids = childTokens(t);
|
|
487
|
+
if (kids.length > 0) return plainText(kids, BLOCK_CONTAINERS.has(t.type) ? "\n" : "");
|
|
488
|
+
return typeof t.text === "string" ? t.text : "";
|
|
489
|
+
})
|
|
490
|
+
.join(sep);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function inertCodeSpan(content) {
|
|
494
|
+
let inner;
|
|
495
|
+
try {
|
|
496
|
+
inner = Lexer.lexInline(content, { gfm: true });
|
|
497
|
+
} catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
const bad = (list) =>
|
|
501
|
+
list.some(
|
|
502
|
+
(t) =>
|
|
503
|
+
t.type === "link" ||
|
|
504
|
+
t.type === "image" ||
|
|
505
|
+
(t.type === "html" && !inertPlaceholder(t.raw)) ||
|
|
506
|
+
// A span inside a span is still read by some renderer as markup.
|
|
507
|
+
(t.type === "codespan" && !inertCodeSpan(t.text)) ||
|
|
508
|
+
bad(childTokens(t)),
|
|
509
|
+
);
|
|
510
|
+
return !bad(inner);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Markup problems in one string, for any field or body. Exported so the
|
|
515
|
+
* local-lesson rules apply the same parser instead of keeping regexes of their
|
|
516
|
+
* own. Each problem reads `<where> contains <what>`.
|
|
517
|
+
*/
|
|
518
|
+
export function markupProblems(text, where) {
|
|
519
|
+
if (tooLongToCheck(text)) return [`${where} is too long to check`];
|
|
520
|
+
const kinds = [...new Set(markupFindings(String(text ?? "")).map((f) => f.kind))];
|
|
521
|
+
return kinds.map((k) => `${where} contains ${MARKUP[k].noun}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Parse frontmatter YAML strictly. Comments are refused because they ship in
|
|
526
|
+
* the raw file and nothing else checks them; anchors, aliases and explicit tags
|
|
527
|
+
* because a lesson has no use for them and each one is a way to make the
|
|
528
|
+
* parsed value differ from what a reviewer reads.
|
|
529
|
+
*/
|
|
530
|
+
export function loadFrontmatter(fmText) {
|
|
531
|
+
const doc = parseDocument(fmText, { uniqueKeys: true, prettyErrors: false });
|
|
532
|
+
const problems = [...doc.errors, ...doc.warnings].map(
|
|
533
|
+
(e) => `frontmatter is not valid YAML: ${e.message.split("\n")[0]}`,
|
|
534
|
+
);
|
|
535
|
+
let comments = Boolean(doc.commentBefore || doc.comment);
|
|
536
|
+
let fancy = false;
|
|
537
|
+
visit(doc, {
|
|
538
|
+
Node(_, node) {
|
|
539
|
+
if (node.commentBefore || node.comment) comments = true;
|
|
540
|
+
if (isAlias(node) || node.anchor || node.tag) fancy = true;
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
if (comments) problems.push("no YAML comments in frontmatter; they ship unchecked");
|
|
544
|
+
if (fancy) problems.push("no YAML anchors, aliases or explicit tags in frontmatter");
|
|
545
|
+
return { value: problems.length > 0 ? undefined : doc.toJS(), problems };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function eachString(value, label, visit) {
|
|
549
|
+
if (typeof value === "string") visit(label, value);
|
|
550
|
+
else if (Array.isArray(value)) value.forEach((v, i) => eachString(v, `${label}[${i}]`, visit));
|
|
551
|
+
else if (value !== null && typeof value === "object") {
|
|
552
|
+
for (const [k, v] of Object.entries(value)) eachString(v, label ? `${label}.${k}` : k, visit);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
557
|
+
|
|
558
|
+
/** A plain `YYYY-MM-DD` that is also a real calendar date. */
|
|
559
|
+
export function isIsoDate(value) {
|
|
560
|
+
if (typeof value !== "string" || !ISO_DATE.test(value)) return false;
|
|
561
|
+
const ms = Date.parse(`${value}T00:00:00Z`);
|
|
562
|
+
return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 10) === value;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Split `---` frontmatter from the body. Line numbers are 1-based so a
|
|
567
|
+
* violation can be clicked in an editor.
|
|
568
|
+
*/
|
|
569
|
+
export function splitFrontmatter(text) {
|
|
570
|
+
const lines = text.split("\n");
|
|
571
|
+
if (lines[0] !== "---") return null;
|
|
572
|
+
const end = lines.indexOf("---", 1);
|
|
573
|
+
if (end === -1) return null;
|
|
574
|
+
return {
|
|
575
|
+
fmText: lines.slice(1, end).join("\n"),
|
|
576
|
+
fmLines: lines.slice(1, end),
|
|
577
|
+
fmFirstLine: 2,
|
|
578
|
+
body: lines.slice(end + 1).join("\n"),
|
|
579
|
+
bodyFirstLine: end + 2,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Line of the first frontmatter entry for `key`, at any nesting depth. */
|
|
584
|
+
function lineOfKey(split, key) {
|
|
585
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
586
|
+
const re = new RegExp(`^\\s*${escaped}\\s*:`);
|
|
587
|
+
const i = split.fmLines.findIndex((line) => re.test(line));
|
|
588
|
+
return i === -1 ? split.fmFirstLine : split.fmFirstLine + i;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Split the body on its headings. Returns what was found, not what is legal. */
|
|
592
|
+
export function parseSections(body, bodyFirstLine = 1) {
|
|
593
|
+
const lines = body.split("\n");
|
|
594
|
+
const found = [];
|
|
595
|
+
const unexpected = [];
|
|
596
|
+
lines.forEach((line, i) => {
|
|
597
|
+
if (!line.startsWith("#")) return;
|
|
598
|
+
const spec = SECTIONS.find((s) => s.heading === line.trimEnd());
|
|
599
|
+
if (spec) found.push({ spec, index: i, line: bodyFirstLine + i });
|
|
600
|
+
else unexpected.push({ text: line, line: bodyFirstLine + i });
|
|
601
|
+
});
|
|
602
|
+
const sections = {};
|
|
603
|
+
found.forEach((hit, n) => {
|
|
604
|
+
const start = hit.index + 1;
|
|
605
|
+
const end = n + 1 < found.length ? found[n + 1].index : lines.length;
|
|
606
|
+
if (sections[hit.spec.key] !== undefined) return; // first wins; the dup is reported
|
|
607
|
+
sections[hit.spec.key] = { text: lines.slice(start, end).join("\n").trim(), line: hit.line };
|
|
608
|
+
});
|
|
609
|
+
return { found, unexpected, sections };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Parse a lesson that has already passed `validateLessonFile`. Throws on
|
|
614
|
+
* anything the validator would have rejected, so the build can stay simple.
|
|
615
|
+
*/
|
|
616
|
+
export function parseLesson(raw) {
|
|
617
|
+
const text = raw.replace(/\r\n/g, "\n");
|
|
618
|
+
const split = splitFrontmatter(text);
|
|
619
|
+
if (!split) throw new Error("missing YAML frontmatter");
|
|
620
|
+
const { value: frontmatter, problems } = loadFrontmatter(split.fmText);
|
|
621
|
+
if (problems.length > 0) throw new Error(problems[0]);
|
|
622
|
+
const { sections } = parseSections(split.body, split.bodyFirstLine);
|
|
623
|
+
const out = {};
|
|
624
|
+
for (const spec of SECTIONS) {
|
|
625
|
+
if (sections[spec.key] !== undefined) out[spec.key] = sections[spec.key].text;
|
|
626
|
+
}
|
|
627
|
+
return { frontmatter, sections: out };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function checkStringList(add, line, label, value, { max, pattern, hint, maxItems }) {
|
|
631
|
+
if (!Array.isArray(value)) {
|
|
632
|
+
add(line, `${label} must be a list`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const cap = maxItems ?? LIMITS.listItems;
|
|
636
|
+
if (value.length > cap) add(line, `${label} has ${value.length} entries, max ${cap}`);
|
|
637
|
+
value.forEach((item, i) => {
|
|
638
|
+
const at = `${label}[${i}]`;
|
|
639
|
+
if (typeof item !== "string") return add(line, `${at} must be a string`);
|
|
640
|
+
if (item.length === 0) return add(line, `${at} is empty`);
|
|
641
|
+
if (item.includes("\n")) return add(line, `${at} must be a single line`);
|
|
642
|
+
if (item.length > max) return add(line, `${at} is ${item.length} chars, max ${max}`);
|
|
643
|
+
if (pattern && !pattern.test(item)) add(line, `${at} ${hint} (got ${JSON.stringify(item)})`);
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function checkScalarString(add, line, label, value, { max, allowEmpty = false }) {
|
|
648
|
+
if (typeof value !== "string") {
|
|
649
|
+
add(line, `${label} must be a string`);
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
if (!allowEmpty && value.trim().length === 0) {
|
|
653
|
+
add(line, `${label} is empty`);
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
if (value.includes("\n")) {
|
|
657
|
+
add(line, `${label} must be a single line`);
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
if (value.length > max) {
|
|
661
|
+
add(line, `${label} is ${value.length} chars, max ${max}`);
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function checkEnum(add, line, label, value, allowed) {
|
|
668
|
+
if (!allowed.includes(value)) {
|
|
669
|
+
add(line, `${label} must be one of ${allowed.join(", ")} (got ${JSON.stringify(value)})`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function checkExactKeys(add, line, label, value, keys) {
|
|
674
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
675
|
+
add(line, `${label} must be a mapping`);
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
for (const k of keys) if (!(k in value)) add(line, `${label} is missing "${k}"`);
|
|
679
|
+
for (const k of Object.keys(value)) {
|
|
680
|
+
if (!keys.includes(k)) add(line, `${label} has unknown key "${k}"`);
|
|
681
|
+
}
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function codepoint(ch) {
|
|
686
|
+
return `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Validate one lesson. `relPath` is `<namespace>/<slug>.md`. */
|
|
690
|
+
export function validateLessonFile(relPath, raw) {
|
|
691
|
+
const rel = relPath.split(path.sep).join("/");
|
|
692
|
+
const out = [];
|
|
693
|
+
const add = (line, message) => out.push(`${rel}:${line}: ${message}`);
|
|
694
|
+
const text = raw.replace(/\r\n/g, "\n");
|
|
695
|
+
|
|
696
|
+
const parts = rel.split("/");
|
|
697
|
+
if (parts.length !== 2) {
|
|
698
|
+
add(1, "a lesson lives at lessons/<namespace>/<slug>.md");
|
|
699
|
+
} else {
|
|
700
|
+
const [ns, file] = parts;
|
|
701
|
+
const slug = file.replace(/\.md$/, "");
|
|
702
|
+
if (!NAMESPACES.includes(ns)) {
|
|
703
|
+
add(1, `namespace must be one of ${NAMESPACES.join(", ")} (got ${JSON.stringify(ns)})`);
|
|
704
|
+
}
|
|
705
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
|
706
|
+
add(1, `slug must be lowercase words joined by hyphens (got ${JSON.stringify(slug)})`);
|
|
707
|
+
}
|
|
708
|
+
if (slug.length > LIMITS.slug) {
|
|
709
|
+
add(1, `slug is ${slug.length} chars, max ${LIMITS.slug}`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Printable ASCII only, everywhere. A smart quote or a Unicode em-dash in a
|
|
714
|
+
// lesson reaches the model as whatever the consumer's encoding makes of it,
|
|
715
|
+
// and the repo writes em-dashes as `--` anyway.
|
|
716
|
+
if (Buffer.byteLength(raw, "utf8") > LIMITS.fileBytes) {
|
|
717
|
+
add(1, `file is ${Buffer.byteLength(raw, "utf8")} bytes, max ${LIMITS.fileBytes}`);
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Every one, not the first: a pasted paragraph usually brings several, and
|
|
722
|
+
// fixing them one run at a time is how the last one gets missed.
|
|
723
|
+
for (const odd of text.matchAll(/[^\t\n\x20-\x7e]/gu)) {
|
|
724
|
+
const line = text.slice(0, odd.index).split("\n").length;
|
|
725
|
+
// Reported as a codepoint: an em-dash, an en-dash and a non-breaking hyphen
|
|
726
|
+
// are indistinguishable in a terminal and the fix differs for each.
|
|
727
|
+
add(line, `non-ASCII character ${codepoint(odd[0])}; write em-dashes as --`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const split = splitFrontmatter(text);
|
|
731
|
+
if (!split) {
|
|
732
|
+
add(1, "missing YAML frontmatter: the file must open with --- and close it with ---");
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// The raw lines too, so anything the parser drops is still held to the
|
|
737
|
+
// identifying-data rules. URLs and hostnames are left to the parsed check
|
|
738
|
+
// below, which knows which field may hold a link and which holds a host.
|
|
739
|
+
split.fmLines.forEach((line, i) => {
|
|
740
|
+
for (const shape of shapesIn(line, URL_SHAPES)) {
|
|
741
|
+
add(split.fmFirstLine + i, `frontmatter line contains ${shape}; lessons carry none`);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
let fm;
|
|
746
|
+
try {
|
|
747
|
+
const loaded = loadFrontmatter(split.fmText);
|
|
748
|
+
if (loaded.problems.length > 0) {
|
|
749
|
+
for (const p of loaded.problems) add(split.fmFirstLine, p);
|
|
750
|
+
return out;
|
|
751
|
+
}
|
|
752
|
+
fm = loaded.value;
|
|
753
|
+
} catch (err) {
|
|
754
|
+
const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
755
|
+
add(split.fmFirstLine, `frontmatter is not valid YAML: ${message}`);
|
|
756
|
+
return out;
|
|
757
|
+
}
|
|
758
|
+
if (fm === null || typeof fm !== "object" || Array.isArray(fm)) {
|
|
759
|
+
add(split.fmFirstLine, "frontmatter must be a YAML mapping");
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const at = (key) => lineOfKey(split, key);
|
|
764
|
+
for (const key of REQUIRED_KEYS) {
|
|
765
|
+
if (!(key in fm)) add(split.fmFirstLine, `missing required frontmatter key "${key}"`);
|
|
766
|
+
}
|
|
767
|
+
for (const key of Object.keys(fm)) {
|
|
768
|
+
if (!REQUIRED_KEYS.includes(key) && !OPTIONAL_KEYS.includes(key)) {
|
|
769
|
+
add(at(key), `unknown frontmatter key "${key}"`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if ("type" in fm && fm.type !== "Lesson") {
|
|
774
|
+
add(at("type"), `type must be exactly "Lesson" (got ${JSON.stringify(fm.type)})`);
|
|
775
|
+
}
|
|
776
|
+
if ("title" in fm) checkScalarString(add, at("title"), "title", fm.title, { max: LIMITS.title });
|
|
777
|
+
if ("description" in fm) {
|
|
778
|
+
checkScalarString(add, at("description"), "description", fm.description, {
|
|
779
|
+
max: LIMITS.description,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
if ("tags" in fm) {
|
|
783
|
+
checkStringList(add, at("tags"), "tags", fm.tags, { max: LIMITS.tag });
|
|
784
|
+
}
|
|
785
|
+
if ("status" in fm) checkEnum(add, at("status"), "status", fm.status, STATUSES);
|
|
786
|
+
|
|
787
|
+
if (
|
|
788
|
+
"generated" in fm &&
|
|
789
|
+
checkExactKeys(add, at("generated"), "generated", fm.generated, ["by", "at"])
|
|
790
|
+
) {
|
|
791
|
+
const line = at("generated");
|
|
792
|
+
if (
|
|
793
|
+
typeof fm.generated.by !== "string" ||
|
|
794
|
+
!/^(?:agent|human):[A-Za-z0-9._/@-]{1,80}$/.test(fm.generated.by)
|
|
795
|
+
) {
|
|
796
|
+
add(line, 'generated.by must look like "agent:loom/0.8.0" or "human:loom-maintainers"');
|
|
797
|
+
}
|
|
798
|
+
if (!isIsoDate(fm.generated.at)) add(line, "generated.at must be a YYYY-MM-DD date string");
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if ("verified" in fm) {
|
|
802
|
+
const line = at("verified");
|
|
803
|
+
if (!Array.isArray(fm.verified)) add(line, "verified must be a list");
|
|
804
|
+
else {
|
|
805
|
+
if (fm.verified.length > LIMITS.listItems) {
|
|
806
|
+
add(line, `verified has ${fm.verified.length} entries, max ${LIMITS.listItems}`);
|
|
807
|
+
}
|
|
808
|
+
fm.verified.forEach((entry, i) => {
|
|
809
|
+
if (!checkExactKeys(add, line, `verified[${i}]`, entry, ["by", "at"])) return;
|
|
810
|
+
// Maintainer pseudonyms only. A contributor id here would publish an
|
|
811
|
+
// identity into the snapshot and the public index.
|
|
812
|
+
if (typeof entry.by !== "string" || !/^human:[A-Za-z0-9._-]{1,60}$/.test(entry.by)) {
|
|
813
|
+
add(line, `verified[${i}].by must look like "human:<pseudonym>"`);
|
|
814
|
+
}
|
|
815
|
+
if (!isIsoDate(entry.at)) add(line, `verified[${i}].at must be a YYYY-MM-DD date string`);
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if ("stale_after" in fm && !isIsoDate(fm.stale_after)) {
|
|
821
|
+
add(at("stale_after"), "stale_after must be a YYYY-MM-DD date string");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if ("sources" in fm) {
|
|
825
|
+
const line = at("sources");
|
|
826
|
+
if (!Array.isArray(fm.sources)) add(line, "sources must be a list");
|
|
827
|
+
else {
|
|
828
|
+
if (fm.sources.length > LIMITS.listItems) {
|
|
829
|
+
add(line, `sources has ${fm.sources.length} entries, max ${LIMITS.listItems}`);
|
|
830
|
+
}
|
|
831
|
+
fm.sources.forEach((entry, i) => {
|
|
832
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
833
|
+
add(line, `sources[${i}] must be a mapping with an id`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
for (const k of Object.keys(entry)) {
|
|
837
|
+
if (!["id", "resource", "title"].includes(k)) {
|
|
838
|
+
add(line, `sources[${i}] has unknown key "${k}"`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
checkScalarString(add, line, `sources[${i}].id`, entry.id, { max: LIMITS.sourceId });
|
|
842
|
+
for (const k of ["resource", "title"]) {
|
|
843
|
+
if (entry[k] !== undefined) {
|
|
844
|
+
checkScalarString(add, line, `sources[${i}].${k}`, entry[k], {
|
|
845
|
+
max: LIMITS.sourceText,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if ("kind" in fm) checkEnum(add, at("kind"), "kind", fm.kind, KINDS);
|
|
854
|
+
|
|
855
|
+
if ("stage" in fm) {
|
|
856
|
+
const line = at("stage");
|
|
857
|
+
if (!Array.isArray(fm.stage) || fm.stage.length === 0) {
|
|
858
|
+
add(line, "stage must be a non-empty list");
|
|
859
|
+
} else {
|
|
860
|
+
const seen = new Set();
|
|
861
|
+
fm.stage.forEach((s, i) => {
|
|
862
|
+
checkEnum(add, line, `stage[${i}]`, s, STAGES);
|
|
863
|
+
if (seen.has(s)) add(line, `stage lists ${JSON.stringify(s)} twice`);
|
|
864
|
+
seen.add(s);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if ("trigger" in fm && checkExactKeys(add, at("trigger"), "trigger", fm.trigger, TRIGGER_KEYS)) {
|
|
870
|
+
const t = fm.trigger;
|
|
871
|
+
const rules = {
|
|
872
|
+
// A signature has to be stored the way the matcher will see it, so a
|
|
873
|
+
// signature that normalization would change can never match anything.
|
|
874
|
+
signatures: { max: LIMITS.signature },
|
|
875
|
+
tools: {
|
|
876
|
+
max: LIMITS.tool,
|
|
877
|
+
pattern: /^[A-Za-z0-9][A-Za-z0-9._+-]*$/,
|
|
878
|
+
hint: "must be a Galaxy tool id or family",
|
|
879
|
+
},
|
|
880
|
+
mcp_tools: {
|
|
881
|
+
max: LIMITS.mcpTool,
|
|
882
|
+
pattern: /^galaxy_[a-z0-9_]+$/,
|
|
883
|
+
hint: "must be a galaxy_* MCP tool name",
|
|
884
|
+
},
|
|
885
|
+
formats: {
|
|
886
|
+
max: LIMITS.format,
|
|
887
|
+
pattern: /^[a-z0-9][a-z0-9._-]*$/,
|
|
888
|
+
hint: "must be a lowercase Galaxy datatype or extension name",
|
|
889
|
+
},
|
|
890
|
+
hosts: {
|
|
891
|
+
max: LIMITS.host,
|
|
892
|
+
pattern: /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/,
|
|
893
|
+
hint: "must be a bare hostname, no scheme and no path",
|
|
894
|
+
},
|
|
895
|
+
extensions: {
|
|
896
|
+
max: LIMITS.extension,
|
|
897
|
+
pattern: /^\.[a-z0-9]+(?:\.[a-z0-9]+)*$/,
|
|
898
|
+
hint: "must be a lowercase dotted extension",
|
|
899
|
+
},
|
|
900
|
+
step_keywords: {
|
|
901
|
+
max: LIMITS.stepKeyword,
|
|
902
|
+
pattern: /^[a-z0-9][a-z0-9 '+./-]*$/,
|
|
903
|
+
hint: "must be lowercase words matched against plan-step text",
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
for (const key of TRIGGER_KEYS) {
|
|
907
|
+
checkStringList(add, at(key), `trigger.${key}`, t[key], rules[key]);
|
|
908
|
+
}
|
|
909
|
+
if (Array.isArray(t.signatures)) {
|
|
910
|
+
t.signatures.forEach((sig, i) => {
|
|
911
|
+
if (typeof sig !== "string") return;
|
|
912
|
+
const normalized = normalizeSignature(sig);
|
|
913
|
+
if (sig === UNKNOWN_SIGNATURE || sig.length < LIMITS.minSignature) {
|
|
914
|
+
add(
|
|
915
|
+
at("signatures"),
|
|
916
|
+
`trigger.signatures[${i}] is too generic to match on; quote the distinctive part of the error`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
if (normalized !== sig) {
|
|
920
|
+
add(
|
|
921
|
+
at("signatures"),
|
|
922
|
+
`trigger.signatures[${i}] is not normalized; store ${JSON.stringify(normalized)}`,
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
const matchable = TRIGGER_KEYS.some((k) => Array.isArray(t[k]) && t[k].length > 0);
|
|
928
|
+
if (!matchable) {
|
|
929
|
+
add(
|
|
930
|
+
at("trigger"),
|
|
931
|
+
"trigger has nothing machine-matchable; a lesson nothing can match is documentation",
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if ("cues" in fm) checkScalarString(add, at("cues"), "cues", fm.cues, { max: LIMITS.cues });
|
|
937
|
+
|
|
938
|
+
if (
|
|
939
|
+
"applies_to" in fm &&
|
|
940
|
+
checkExactKeys(add, at("applies_to"), "applies_to", fm.applies_to, ["versions", "tested"])
|
|
941
|
+
) {
|
|
942
|
+
for (const k of ["versions", "tested"]) {
|
|
943
|
+
checkScalarString(add, at("applies_to"), `applies_to.${k}`, fm.applies_to[k], {
|
|
944
|
+
max: LIMITS.appliesTo,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (
|
|
950
|
+
"evidence" in fm &&
|
|
951
|
+
checkExactKeys(add, at("evidence"), "evidence", fm.evidence, [
|
|
952
|
+
"symptom",
|
|
953
|
+
"cause",
|
|
954
|
+
"outcome",
|
|
955
|
+
"method",
|
|
956
|
+
])
|
|
957
|
+
) {
|
|
958
|
+
const line = at("evidence");
|
|
959
|
+
for (const k of ["symptom", "cause", "outcome"]) {
|
|
960
|
+
checkEnum(add, line, `evidence.${k}`, fm.evidence[k], EVIDENCE[k]);
|
|
961
|
+
}
|
|
962
|
+
checkScalarString(add, line, "evidence.method", fm.evidence.method, { max: LIMITS.method });
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
for (const key of ["graduated_to", "upstream"]) {
|
|
966
|
+
if (key in fm) checkStringList(add, at(key), key, fm[key], { max: LIMITS.freeText });
|
|
967
|
+
}
|
|
968
|
+
if ("supersedes" in fm) {
|
|
969
|
+
checkStringList(add, at("supersedes"), "supersedes", fm.supersedes, {
|
|
970
|
+
max: LIMITS.sourceId,
|
|
971
|
+
pattern: new RegExp(`^(?:${NAMESPACES.join("|")})/[a-z0-9]+(?:-[a-z0-9]+)*$`),
|
|
972
|
+
hint: "must be a lesson id like stats/na-coerced-to-zero-in-filters",
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// The namespace is the contract: galaxy-api is where lessons go once the
|
|
977
|
+
// durable fix ships somewhere else, and `graduated_to` is what stops the
|
|
978
|
+
// matcher surfacing them.
|
|
979
|
+
if (parts[0] === "galaxy-api" && Array.isArray(fm.graduated_to) && fm.graduated_to.length === 0) {
|
|
980
|
+
add(
|
|
981
|
+
at("graduated_to"),
|
|
982
|
+
"a galaxy-api lesson must say where the durable fix lives in graduated_to",
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Every string, not just the prose fields: a title, a cue or a source id
|
|
987
|
+
// reaches the snapshot and the public index exactly like the body does.
|
|
988
|
+
eachString(fm, "", (label, value) => {
|
|
989
|
+
// trigger's lists each have a line of their own; elsewhere the nested key
|
|
990
|
+
// names repeat (`by` is in generated and in verified) so the parent's line
|
|
991
|
+
// is the honest one.
|
|
992
|
+
const [top, sub] = label.split(/[.[]/);
|
|
993
|
+
const line = top === "trigger" && sub ? at(sub) : at(top);
|
|
994
|
+
// The file-level ASCII check reads raw bytes, and a YAML escape like "\u202e"
|
|
995
|
+
// is ASCII on disk. This is the check that sees the decoded value.
|
|
996
|
+
for (const bad of value.matchAll(/[^\x20-\x7e]/gu)) {
|
|
997
|
+
add(line, `${label} contains control or non-ASCII character ${codepoint(bad[0])}`);
|
|
998
|
+
}
|
|
999
|
+
const field = label.replace(/\[\d+\]/g, "");
|
|
1000
|
+
if (LINK_FIELDS.includes(field)) {
|
|
1001
|
+
// Free text is allowed here too ("galaxy-mcp#55"); only a string that
|
|
1002
|
+
// tries to be a link is held to the link rules.
|
|
1003
|
+
const problems = looksLikeLink(value)
|
|
1004
|
+
? linkProblems(value, label)
|
|
1005
|
+
: [...identifyingProblems(value, label), ...markupProblems(value, label)];
|
|
1006
|
+
for (const p of problems) add(line, p);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (field === "trigger.hosts") {
|
|
1010
|
+
// A host is the whole point of this field; its own pattern pins the
|
|
1011
|
+
// shape. A private one names somebody's network, so it never ships.
|
|
1012
|
+
for (const shape of shapesIn(value, URL_SHAPES)) {
|
|
1013
|
+
add(line, `${label} contains ${shape}; lessons carry none`);
|
|
1014
|
+
}
|
|
1015
|
+
if (new RegExp(`\\.(?:${PRIVATE_TLDS})$`, "i").test(value)) {
|
|
1016
|
+
add(line, `${label} is a private hostname; only public hosts go in a lesson`);
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
for (const p of identifyingProblems(value, label)) add(line, p);
|
|
1021
|
+
// A pseudonym's own pattern already pins it, and `@` in it would read as
|
|
1022
|
+
// a GFM email autolink.
|
|
1023
|
+
if (field === "generated.by" || field === "verified.by") return;
|
|
1024
|
+
for (const p of markupProblems(value, label)) add(line, p);
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
out.push(...validateBody(rel, split));
|
|
1028
|
+
return out;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function validateBody(rel, split) {
|
|
1032
|
+
const out = [];
|
|
1033
|
+
const add = (line, message) => out.push(`${rel}:${line}: ${message}`);
|
|
1034
|
+
const { found, unexpected, sections } = parseSections(split.body, split.bodyFirstLine);
|
|
1035
|
+
|
|
1036
|
+
for (const bad of unexpected) {
|
|
1037
|
+
add(
|
|
1038
|
+
bad.line,
|
|
1039
|
+
`unexpected heading ${JSON.stringify(bad.text.trimEnd())}; only the six lesson sections are allowed`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const seen = new Set();
|
|
1044
|
+
for (const hit of found) {
|
|
1045
|
+
if (seen.has(hit.spec.key))
|
|
1046
|
+
add(hit.line, `duplicate section ${JSON.stringify(hit.spec.heading)}`);
|
|
1047
|
+
seen.add(hit.spec.key);
|
|
1048
|
+
}
|
|
1049
|
+
for (const spec of SECTIONS) {
|
|
1050
|
+
if (spec.required && !seen.has(spec.key)) {
|
|
1051
|
+
add(split.bodyFirstLine, `missing required section ${JSON.stringify(spec.heading)}`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const order = SECTIONS.map((s) => s.key).filter((k) => seen.has(k));
|
|
1056
|
+
const actual = [];
|
|
1057
|
+
for (const hit of found) if (!actual.includes(hit.spec.key)) actual.push(hit.spec.key);
|
|
1058
|
+
if (actual.join(",") !== order.join(",")) {
|
|
1059
|
+
add(
|
|
1060
|
+
split.bodyFirstLine,
|
|
1061
|
+
`sections are out of order; the order is ${SECTIONS.map((s) => s.heading).join(", ")}`,
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
for (const [key, section] of Object.entries(sections)) {
|
|
1066
|
+
if (section.text.length === 0) add(section.line, `section ${key} is empty`);
|
|
1067
|
+
else if (section.text.length > LIMITS.section) {
|
|
1068
|
+
add(section.line, `section ${key} is ${section.text.length} chars, max ${LIMITS.section}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const bodyLines = split.body.split("\n");
|
|
1073
|
+
// Everything before the first heading is dropped from the snapshot but still
|
|
1074
|
+
// ships in the raw file, so it has to be empty.
|
|
1075
|
+
const firstHeading = found.length > 0 ? found[0].index : bodyLines.length;
|
|
1076
|
+
const preamble = bodyLines.slice(0, firstHeading).findIndex((l) => l.trim() !== "");
|
|
1077
|
+
if (preamble !== -1) {
|
|
1078
|
+
add(split.bodyFirstLine + preamble, "no text before the first section heading");
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
bodyLines.forEach((line, i) => {
|
|
1082
|
+
const at = split.bodyFirstLine + i;
|
|
1083
|
+
// Anywhere on the line, so a fence inside a blockquote or a list item counts.
|
|
1084
|
+
if (/`{3,}|~{3,}/.test(line)) {
|
|
1085
|
+
add(at, "no fenced code blocks in a lesson body; a short inline span is fine");
|
|
1086
|
+
}
|
|
1087
|
+
if (/^(?: {4,}|\t)\S/.test(line)) add(at, "no indented code blocks in a lesson body");
|
|
1088
|
+
if (!line.startsWith("#") && /^[ \t>*+-]*#{1,6}(?:\s|$)/.test(line)) {
|
|
1089
|
+
add(at, "unexpected heading; only the six lesson sections are allowed");
|
|
1090
|
+
}
|
|
1091
|
+
if (/^ {0,3}(?:=+|-+|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
1092
|
+
add(at, "no setext headings or horizontal rules in a lesson body");
|
|
1093
|
+
}
|
|
1094
|
+
if (/<[A-Za-z!/?]/.test(line.replace(/`[^`]*`/g, ""))) {
|
|
1095
|
+
add(at, "no HTML in a lesson body");
|
|
1096
|
+
}
|
|
1097
|
+
for (const shape of shapesIn(line)) {
|
|
1098
|
+
if (shape === "a URL") add(at, "no URLs in a lesson body; put provenance in sources");
|
|
1099
|
+
else add(at, `${shape} in a lesson body; lessons carry none`);
|
|
1100
|
+
}
|
|
1101
|
+
// `](` and `][` rather than a whole `[text](target)`: link text and
|
|
1102
|
+
// target can be split across lines, and reference definitions stand alone.
|
|
1103
|
+
if (/\]\(|\]\[|^\s*\[[^\]]+\]:/.test(line)) add(at, "no markdown links in a lesson body");
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// The line rules above are cheap and catch the obvious; the parser is the
|
|
1107
|
+
// one that agrees with a renderer about what is a link, HTML or code.
|
|
1108
|
+
const lineOf = (offset) =>
|
|
1109
|
+
split.bodyFirstLine + split.body.slice(0, offset).split("\n").length - 1;
|
|
1110
|
+
for (const f of markupFindings(split.body)) {
|
|
1111
|
+
add(lineOf(f.offset), `${MARKUP[f.kind].rule} in a lesson body`);
|
|
1112
|
+
}
|
|
1113
|
+
const lineShapes = (from, to) =>
|
|
1114
|
+
new Set(bodyLines.slice(from, to + 1).flatMap((l) => shapesIn(l)));
|
|
1115
|
+
const tokens = walkTokens(split.body, (token, offset, depth) => {
|
|
1116
|
+
const at = lineOf(offset);
|
|
1117
|
+
if (token.type === "code") {
|
|
1118
|
+
add(
|
|
1119
|
+
at,
|
|
1120
|
+
token.codeBlockStyle === "indented"
|
|
1121
|
+
? "no indented code blocks in a lesson body"
|
|
1122
|
+
: "no fenced code blocks in a lesson body; a short inline span is fine",
|
|
1123
|
+
);
|
|
1124
|
+
} else if (token.type === "hr") {
|
|
1125
|
+
add(at, "no setext headings or horizontal rules in a lesson body");
|
|
1126
|
+
} else if (
|
|
1127
|
+
token.type === "heading" &&
|
|
1128
|
+
(depth > 0 || !SECTIONS.some((spec) => spec.heading === token.raw.trimEnd()))
|
|
1129
|
+
) {
|
|
1130
|
+
add(at, "unexpected heading; only the six lesson sections are allowed");
|
|
1131
|
+
}
|
|
1132
|
+
if (depth !== 0 || typeof token.raw !== "string") return;
|
|
1133
|
+
// Formatting can split a shape so no single raw line holds it whole;
|
|
1134
|
+
// judge the rendered text of each block as well.
|
|
1135
|
+
const first = at - split.bodyFirstLine;
|
|
1136
|
+
const seen = lineShapes(first, first + token.raw.split("\n").length - 1);
|
|
1137
|
+
for (const shape of shapesIn(plainText([token]))) {
|
|
1138
|
+
if (seen.has(shape)) continue;
|
|
1139
|
+
if (shape === "a URL") add(at, "no URLs in a lesson body; put provenance in sources");
|
|
1140
|
+
else add(at, `${shape} in a lesson body; lessons carry none`);
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
if (!tokens) add(split.bodyFirstLine, "the lesson body could not be parsed as markdown");
|
|
1144
|
+
|
|
1145
|
+
return [...new Set(out)];
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Lesson paths under `dir`, as sorted `<namespace>/<slug>.md`. */
|
|
1149
|
+
export function collectLessonFiles(dir) {
|
|
1150
|
+
const out = [];
|
|
1151
|
+
for (const ns of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1152
|
+
if (!ns.isDirectory() || ns.name.startsWith(".")) continue;
|
|
1153
|
+
for (const entry of fs.readdirSync(path.join(dir, ns.name), { withFileTypes: true })) {
|
|
1154
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
1155
|
+
out.push(`${ns.name}/${entry.name}`);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return out.sort();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Validate a whole corpus directory. Returns `path:line: message` lines. */
|
|
1162
|
+
export function validateLessonsDir(dir) {
|
|
1163
|
+
const out = [];
|
|
1164
|
+
if (!fs.existsSync(dir)) return [`${dir}:1: no such lessons directory`];
|
|
1165
|
+
const entries = fs
|
|
1166
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
1167
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
1168
|
+
for (const ns of entries) {
|
|
1169
|
+
if (ns.name.startsWith(".")) continue;
|
|
1170
|
+
if (ns.isSymbolicLink()) {
|
|
1171
|
+
out.push(`${ns.name}:1: no symlinks in the lessons directory`);
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
if (!ns.isDirectory()) continue; // README, SCHEMA, LICENSE, log, the scripts
|
|
1175
|
+
if (!NAMESPACES.includes(ns.name)) {
|
|
1176
|
+
out.push(
|
|
1177
|
+
`${ns.name}:1: unknown namespace directory; the namespaces are ${NAMESPACES.join(", ")}`,
|
|
1178
|
+
);
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
const inner = fs
|
|
1182
|
+
.readdirSync(path.join(dir, ns.name), { withFileTypes: true })
|
|
1183
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
1184
|
+
for (const entry of inner) {
|
|
1185
|
+
if (entry.isDirectory()) {
|
|
1186
|
+
out.push(`${ns.name}/${entry.name}:1: a namespace holds lesson files only, one level deep`);
|
|
1187
|
+
} else if (!entry.name.endsWith(".md")) {
|
|
1188
|
+
out.push(`${ns.name}/${entry.name}:1: a namespace holds .md lesson files only`);
|
|
1189
|
+
} else if (!entry.isFile()) {
|
|
1190
|
+
out.push(`${ns.name}/${entry.name}:1: not a regular file`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
const files = collectLessonFiles(dir);
|
|
1195
|
+
if (files.length === 0) out.push(`${path.basename(dir)}:1: no lesson files found`);
|
|
1196
|
+
for (const rel of files) {
|
|
1197
|
+
out.push(...validateLessonFile(rel, fs.readFileSync(path.join(dir, rel), "utf8")));
|
|
1198
|
+
}
|
|
1199
|
+
return out;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Node 22.18+ sets import.meta.main on the entry module (engines pins >= 22.19);
|
|
1203
|
+
// a bundler or vite-node leaves it undefined, so importing this file -- which
|
|
1204
|
+
// shared/lesson-rules.js does from inside the brain -- never runs the CLI.
|
|
1205
|
+
if (import.meta.main) {
|
|
1206
|
+
const args = process.argv.slice(2);
|
|
1207
|
+
if (args.length > 1) {
|
|
1208
|
+
console.error(`usage: validate.mjs [dir] (got ${args.join(" ")})`);
|
|
1209
|
+
process.exit(2);
|
|
1210
|
+
}
|
|
1211
|
+
const dir = args[0] ? path.resolve(args[0]) : LESSONS_DIR;
|
|
1212
|
+
const violations = validateLessonsDir(dir);
|
|
1213
|
+
if (violations.length > 0) {
|
|
1214
|
+
console.error(`lessons/validate: FAILED -- ${violations.length} violation(s)`);
|
|
1215
|
+
for (const v of violations) console.error(v);
|
|
1216
|
+
process.exit(1);
|
|
1217
|
+
}
|
|
1218
|
+
console.log(`lessons/validate: OK -- ${collectLessonFiles(dir).length} lesson(s)`);
|
|
1219
|
+
}
|