@workerstack/repo 0.0.0-stage → 0.1.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 +45 -0
- package/LICENSE +21 -0
- package/README.md +0 -3
- package/agents/global-settings/antigravity.json +3 -0
- package/agents/global-settings/claude.json +6 -0
- package/agents/global-settings/codex.toml +2 -0
- package/agents/global-settings/cursor.json +3 -0
- package/agents/global-settings/grok.toml +36 -0
- package/agents/global-settings/manifest.json +36 -0
- package/agents/grok/hooks/orchestrate-parent.json +60 -0
- package/agents/grok/hooks/rtk.json +16 -0
- package/agents/grok/hooks/rulesync.json +3 -0
- package/agents/grok/roles/scout.toml +5 -0
- package/agents/hooks.jsonc +67 -0
- package/agents/mcp-policy.jsonc +16 -0
- package/agents/rules/overview.md +21 -0
- package/agents/settings/claude.json +5 -0
- package/agents/skills/ask/SKILL.md +13 -0
- package/agents/skills/auto/SKILL.md +13 -0
- package/agents/skills/away/SKILL.md +19 -0
- package/agents/skills/blast-radius/SKILL.md +54 -0
- package/agents/skills/bro/SKILL.md +9 -0
- package/agents/skills/browser/SKILL.md +16 -0
- package/agents/skills/clasp/SKILL.md +11 -0
- package/agents/skills/commit/SKILL.md +35 -0
- package/agents/skills/commit-and-push/SKILL.md +9 -0
- package/agents/skills/commit-push/SKILL.md +59 -0
- package/agents/skills/commit-push-deploy/SKILL.md +74 -0
- package/agents/skills/commit-push-release/SKILL.md +75 -0
- package/agents/skills/correct/SKILL.md +39 -0
- package/agents/skills/do/SKILL.md +21 -0
- package/agents/skills/explain/SKILL.md +13 -0
- package/agents/skills/google-workspace/SKILL.md +12 -0
- package/agents/skills/handoff/SKILL.md +81 -0
- package/agents/skills/implement-plan/SKILL.md +29 -0
- package/agents/skills/implement-roadmap/SKILL.md +27 -0
- package/agents/skills/jev/SKILL.md +17 -0
- package/agents/skills/listen/SKILL.md +15 -0
- package/agents/skills/pickup/SKILL.md +58 -0
- package/agents/skills/principle-attack-the-premise/SKILL.md +24 -0
- package/agents/skills/principle-boundary-discipline/SKILL.md +38 -0
- package/agents/skills/principle-build-the-lever/SKILL.md +28 -0
- package/agents/skills/principle-encode-lessons-in-structure/SKILL.md +15 -0
- package/agents/skills/principle-exhaust-the-design-space/SKILL.md +12 -0
- package/agents/skills/principle-experience-first/SKILL.md +22 -0
- package/agents/skills/principle-explain-the-number/SKILL.md +21 -0
- package/agents/skills/principle-fix-root-causes/SKILL.md +24 -0
- package/agents/skills/principle-foundational-thinking/SKILL.md +25 -0
- package/agents/skills/principle-guard-the-context-window/SKILL.md +16 -0
- package/agents/skills/principle-laziness-protocol/SKILL.md +13 -0
- package/agents/skills/principle-make-operations-idempotent/SKILL.md +25 -0
- package/agents/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +18 -0
- package/agents/skills/principle-minimize-reader-load/SKILL.md +22 -0
- package/agents/skills/principle-model-the-domain/SKILL.md +26 -0
- package/agents/skills/principle-never-block-on-the-human/SKILL.md +17 -0
- package/agents/skills/principle-outcome-oriented-execution/SKILL.md +25 -0
- package/agents/skills/principle-prove-it-works/SKILL.md +25 -0
- package/agents/skills/principle-redesign-from-first-principles/SKILL.md +18 -0
- package/agents/skills/principle-separate-before-serializing-shared-state/SKILL.md +20 -0
- package/agents/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
- package/agents/skills/principle-subtract-before-you-add/SKILL.md +21 -0
- package/agents/skills/principle-test-behavior-not-implementation/SKILL.md +24 -0
- package/agents/skills/principle-type-system-discipline/SKILL.md +17 -0
- package/agents/skills/review-plan/SKILL.md +28 -0
- package/agents/skills/review-roadmap/SKILL.md +28 -0
- package/agents/skills/show-me/SKILL.md +21 -0
- package/agents/skills/speak/SKILL.md +15 -0
- package/agents/skills/su/SKILL.md +11 -0
- package/agents/skills/teach/SKILL.md +25 -0
- package/agents/skills/tmp/SKILL.md +54 -0
- package/agents/skills/typescript-best-practices/SKILL.md +32 -0
- package/agents/skills/typescript-best-practices/references/patterns.md +328 -0
- package/agents/skills/unslop/SKILL.md +62 -0
- package/agents/skills/write-plan/SKILL.md +33 -0
- package/agents/skills/write-roadmap/SKILL.md +66 -0
- package/agents/subagents/implementer-deep.md +57 -0
- package/agents/subagents/implementer-fast.md +57 -0
- package/agents/subagents/orchestrator.md +43 -0
- package/agents/subagents/researcher.md +47 -0
- package/agents/subagents/scout.md +44 -0
- package/bin/repo.ts +4 -0
- package/cli/actionlint-default.test.ts +38 -0
- package/cli/actionlint-download.test.ts +162 -0
- package/cli/actionlint-real.test.ts +33 -0
- package/cli/actionlint.constants.ts +43 -0
- package/cli/actionlint.test.ts +108 -0
- package/cli/actionlint.ts +178 -0
- package/cli/bin.test.ts +54 -0
- package/cli/bin.ts +40 -0
- package/cli/check-changelog.test.ts +40 -0
- package/cli/check-changelog.ts +12 -0
- package/cli/check-comments.test.ts +157 -0
- package/cli/check-comments.ts +123 -0
- package/cli/check-env-modules.test.ts +85 -0
- package/cli/check-env-modules.ts +56 -0
- package/cli/check-magic.test.ts +139 -0
- package/cli/check-magic.ts +120 -0
- package/cli/check-manifests.constants.ts +4 -0
- package/cli/check-manifests.test.ts +210 -0
- package/cli/check-manifests.ts +71 -0
- package/cli/check-nightly.test.ts +103 -0
- package/cli/check-nightly.ts +37 -0
- package/cli/check-ratchets-missing.test.ts +46 -0
- package/cli/check-ratchets-mutation.test.ts +49 -0
- package/cli/check-ratchets.constants.ts +20 -0
- package/cli/check-ratchets.test.ts +190 -0
- package/cli/check-ratchets.ts +193 -0
- package/cli/check-rejections.test.ts +220 -0
- package/cli/check-rejections.ts +153 -0
- package/cli/check-reports.ts +37 -0
- package/cli/check-tsconfig.test.ts +507 -0
- package/cli/check-tsconfig.ts +205 -0
- package/cli/commands.test.ts +114 -0
- package/cli/commands.ts +218 -0
- package/cli/commit-changelog.ts +27 -0
- package/cli/commit-msg-changelog.test.ts +93 -0
- package/cli/commit-msg-range.test.ts +126 -0
- package/cli/commit-msg-red-proof.test.ts +147 -0
- package/cli/commit-msg-repo.test.ts +153 -0
- package/cli/commit-msg.test.ts +101 -0
- package/cli/commit-msg.ts +194 -0
- package/cli/commit-range.constants.ts +17 -0
- package/cli/commit-range.ts +57 -0
- package/cli/config-knip.test.ts +70 -0
- package/cli/config-nightly.test.ts +84 -0
- package/cli/config-tdd.test.ts +46 -0
- package/cli/config.test.ts +576 -0
- package/cli/config.ts +261 -0
- package/cli/conventional.ts +9 -0
- package/cli/coverage-config.ts +62 -0
- package/cli/coverage.constants.ts +6 -0
- package/cli/coverage.test.ts +199 -0
- package/cli/coverage.ts +127 -0
- package/cli/env.constants.ts +10 -0
- package/cli/env.test.ts +56 -0
- package/cli/env.ts +72 -0
- package/cli/fixtures/base-config.ts +13 -0
- package/cli/fixtures/gate-world.ts +48 -0
- package/cli/fixtures/knip-run.ts +38 -0
- package/cli/fixtures/mutate-fakes.ts +137 -0
- package/cli/fixtures/planted-repo.ts +50 -0
- package/cli/fixtures/publish-world.ts +71 -0
- package/cli/fixtures/red-proof-repo.ts +72 -0
- package/cli/fixtures/release-context.ts +15 -0
- package/cli/fixtures/release-world.ts +111 -0
- package/cli/fixtures/stub-bin.ts +40 -0
- package/cli/guard-diagnostics.test.ts +157 -0
- package/cli/guard-diagnostics.ts +195 -0
- package/cli/init.test.ts +487 -0
- package/cli/init.ts +275 -0
- package/cli/knip-check-ignores.test.ts +106 -0
- package/cli/knip-check-real.test.ts +65 -0
- package/cli/knip-check.constants.ts +46 -0
- package/cli/knip-check.test.ts +114 -0
- package/cli/knip-check.ts +169 -0
- package/cli/knip-schema.ts +67 -0
- package/cli/known-failures.constants.ts +22 -0
- package/cli/known-failures.test.ts +138 -0
- package/cli/known-failures.ts +131 -0
- package/cli/lint/as-needs-reason.ts +41 -0
- package/cli/lint/boundaries.ts +73 -0
- package/cli/lint/class-file.ts +39 -0
- package/cli/lint/config-file.ts +32 -0
- package/cli/lint/entry-file.ts +54 -0
- package/cli/lint/env-in-config.ts +66 -0
- package/cli/lint/filename-matches-export.ts +63 -0
- package/cli/lint/index-file.ts +22 -0
- package/cli/lint/index.ts +47 -0
- package/cli/lint/is-reason.ts +6 -0
- package/cli/lint/manifest.ts +46 -0
- package/cli/lint/names.ts +59 -0
- package/cli/lint/no-commented-out-code.ts +105 -0
- package/cli/lint/no-mock-of-subject.ts +64 -0
- package/cli/lint/no-module-side-effects.ts +94 -0
- package/cli/lint/no-module-state.ts +58 -0
- package/cli/lint/no-side-effect-import.ts +17 -0
- package/cli/lint/no-v8-ignore.ts +18 -0
- package/cli/lint/script-rules.ts +9 -0
- package/cli/lint/skip-on-platform.constants.ts +8 -0
- package/cli/lint/skip-on-platform.ts +118 -0
- package/cli/lint/todo-needs-id.ts +63 -0
- package/cli/lint/types-file.ts +28 -0
- package/cli/lint-schema.ts +32 -0
- package/cli/magic/fixtures/magic-repo.ts +64 -0
- package/cli/magic/magic-hit.ts +2 -0
- package/cli/magic/magic-nodes.ts +44 -0
- package/cli/magic/magic-numbers.test.ts +99 -0
- package/cli/magic/magic-numbers.ts +117 -0
- package/cli/magic/magic-strings.test.ts +38 -0
- package/cli/magic/magic-strings.ts +81 -0
- package/cli/main.test.ts +853 -0
- package/cli/main.ts +292 -0
- package/cli/mutate-args.ts +79 -0
- package/cli/mutate-check.test.ts +147 -0
- package/cli/mutate-deps.ts +42 -0
- package/cli/mutate-edges.test.ts +150 -0
- package/cli/mutate-files.test.ts +100 -0
- package/cli/mutate-files.ts +31 -0
- package/cli/mutate-floors.test.ts +91 -0
- package/cli/mutate-floors.ts +104 -0
- package/cli/mutate-real-deps.ts +57 -0
- package/cli/mutate-real.test.ts +96 -0
- package/cli/mutate-report.ts +96 -0
- package/cli/mutate-run.ts +128 -0
- package/cli/mutate-shards.ts +64 -0
- package/cli/mutate.constants.ts +67 -0
- package/cli/mutate.test.ts +182 -0
- package/cli/mutate.ts +64 -0
- package/cli/nested-vitest-flags.ts +8 -0
- package/cli/nightly-config.constants.ts +14 -0
- package/cli/nightly-config.ts +62 -0
- package/cli/prompt.test.ts +202 -0
- package/cli/prompt.ts +1 -0
- package/cli/red-proof-edges.test.ts +78 -0
- package/cli/red-proof-failures.test.ts +82 -0
- package/cli/red-proof-tree.test.ts +157 -0
- package/cli/red-proof-verdict.test.ts +44 -0
- package/cli/red-proof.constants.ts +14 -0
- package/cli/red-proof.ts +202 -0
- package/cli/release/changelog-parse.ts +83 -0
- package/cli/release/changelog.test.ts +141 -0
- package/cli/release/changelog.ts +106 -0
- package/cli/release/gate-refusals.test.ts +85 -0
- package/cli/release/gate.test.ts +63 -0
- package/cli/release/gate.ts +70 -0
- package/cli/release/git.ts +66 -0
- package/cli/release/github.test.ts +128 -0
- package/cli/release/github.ts +46 -0
- package/cli/release/list-dir.test.ts +11 -0
- package/cli/release/list-dir.ts +6 -0
- package/cli/release/main.test.ts +48 -0
- package/cli/release/main.ts +51 -0
- package/cli/release/manifest.test.ts +24 -0
- package/cli/release/manifest.ts +34 -0
- package/cli/release/pack.test.ts +107 -0
- package/cli/release/pack.ts +76 -0
- package/cli/release/plan.ts +127 -0
- package/cli/release/publish-run.test.ts +98 -0
- package/cli/release/publish.test.ts +127 -0
- package/cli/release/publish.ts +125 -0
- package/cli/release/refuse.ts +5 -0
- package/cli/release/release-messages.constants.ts +96 -0
- package/cli/release/release-refusals.test.ts +149 -0
- package/cli/release/release.constants.ts +128 -0
- package/cli/release/release.test.ts +199 -0
- package/cli/release/release.ts +145 -0
- package/cli/release/version.test.ts +118 -0
- package/cli/release/version.ts +69 -0
- package/cli/release/wait.test.ts +101 -0
- package/cli/release/wait.ts +95 -0
- package/cli/script-diagnostics.test.ts +76 -0
- package/cli/script-diagnostics.ts +62 -0
- package/cli/scripts/board.ts +519 -0
- package/cli/scripts/browser.ts +994 -0
- package/cli/scripts/change-set.ts +105 -0
- package/cli/scripts/check-globals.ts +199 -0
- package/cli/scripts/ci-skips.constants.ts +17 -0
- package/cli/scripts/ci-skips.ts +118 -0
- package/cli/scripts/ci-wait-run.ts +28 -0
- package/cli/scripts/ci-wait.constants.ts +44 -0
- package/cli/scripts/ci-wait.ts +175 -0
- package/cli/scripts/ci.constants.ts +15 -0
- package/cli/scripts/ci.ts +208 -0
- package/cli/scripts/clean.ts +363 -0
- package/cli/scripts/comments-step.ts +15 -0
- package/cli/scripts/doctor.ts +828 -0
- package/cli/scripts/emit-agent-shell-tools.ts +91 -0
- package/cli/scripts/emit-mcp-policy.ts +402 -0
- package/cli/scripts/folder-map.ts +353 -0
- package/cli/scripts/gate-fat-tools.ts +1095 -0
- package/cli/scripts/gitignore-guard.ts +338 -0
- package/cli/scripts/google-workspace.ts +779 -0
- package/cli/scripts/hook-command.ts +72 -0
- package/cli/scripts/hook-input.ts +178 -0
- package/cli/scripts/hook-io.constants.ts +5 -0
- package/cli/scripts/hook-io.ts +46 -0
- package/cli/scripts/hooks-union.ts +469 -0
- package/cli/scripts/interactive.ts +30 -0
- package/cli/scripts/jev.ts +420 -0
- package/cli/scripts/job.ts +934 -0
- package/cli/scripts/jsonc.ts +9 -0
- package/cli/scripts/lessons.ts +564 -0
- package/cli/scripts/lint-baseline.ts +233 -0
- package/cli/scripts/lint-main.ts +24 -0
- package/cli/scripts/lint-skips.ts +139 -0
- package/cli/scripts/lint.constants.ts +6 -0
- package/cli/scripts/lint.ts +142 -0
- package/cli/scripts/listen.ts +962 -0
- package/cli/scripts/managed-text.ts +47 -0
- package/cli/scripts/orchestrate-parent.ts +1155 -0
- package/cli/scripts/parse-need.ts +81 -0
- package/cli/scripts/persist-handoff.ts +228 -0
- package/cli/scripts/persist-implementation.ts +276 -0
- package/cli/scripts/persist-plan.ts +888 -0
- package/cli/scripts/persist-roadmap.ts +478 -0
- package/cli/scripts/persist-tmp.ts +177 -0
- package/cli/scripts/persist.ts +54 -0
- package/cli/scripts/phase-files.ts +121 -0
- package/cli/scripts/prompt.ts +38 -0
- package/cli/scripts/purge-global-mcps.ts +313 -0
- package/cli/scripts/rejections-step.ts +37 -0
- package/cli/scripts/rtk-grok.ts +102 -0
- package/cli/scripts/scrub-spawn-prompt.ts +103 -0
- package/cli/scripts/scrub-text-lib.ts +58 -0
- package/cli/scripts/session-map.ts +69 -0
- package/cli/scripts/setup.ts +862 -0
- package/cli/scripts/skill-conflicts.ts +82 -0
- package/cli/scripts/speak.ts +370 -0
- package/cli/scripts/strategy.ts +168 -0
- package/cli/scripts/sync-global-settings.ts +316 -0
- package/cli/scripts/turn-steps.ts +249 -0
- package/cli/scripts/turn.ts +157 -0
- package/cli/scripts/typecheck-step.ts +43 -0
- package/cli/scripts/vscode-tasks.ts +175 -0
- package/cli/scripts/workspace-root.ts +160 -0
- package/cli/scripts/worktree.ts +806 -0
- package/cli/spawn.test.ts +34 -0
- package/cli/spawn.ts +27 -0
- package/cli/staged-change.constants.ts +11 -0
- package/cli/staged-change.ts +79 -0
- package/cli/stryker-preload.test.ts +50 -0
- package/cli/stryker-preload.ts +9 -0
- package/cli/sync/agents/frontmatter.test.ts +197 -0
- package/cli/sync/agents/frontmatter.ts +140 -0
- package/cli/sync/agents/hooks.test.ts +517 -0
- package/cli/sync/agents/hooks.ts +401 -0
- package/cli/sync/agents/rules.test.ts +229 -0
- package/cli/sync/agents/rules.ts +84 -0
- package/cli/sync/agents/sources.test.ts +375 -0
- package/cli/sync/agents/sources.ts +244 -0
- package/cli/sync/agents/subagents.test.ts +149 -0
- package/cli/sync/agents/subagents.ts +158 -0
- package/cli/sync/agents-text.test.ts +31 -0
- package/cli/sync/agents-text.ts +36 -0
- package/cli/sync/fixtures/copied.README.md +35 -0
- package/cli/sync/fixtures/copied.tgz +0 -0
- package/cli/sync/fixtures/maps/golden/INDEX.md +22 -0
- package/cli/sync/fixtures/maps-ws.tgz +0 -0
- package/cli/sync/git-files.test.ts +340 -0
- package/cli/sync/git-files.ts +197 -0
- package/cli/sync/git-hooks.test.ts +94 -0
- package/cli/sync/golden.test.ts +15 -0
- package/cli/sync/hooks-mode.test.ts +99 -0
- package/cli/sync/hosts.ts +28 -0
- package/cli/sync/knip-config.constants.ts +4 -0
- package/cli/sync/knip-config.test.ts +147 -0
- package/cli/sync/knip-config.ts +108 -0
- package/cli/sync/manifest.test.ts +78 -0
- package/cli/sync/manifest.ts +60 -0
- package/cli/sync/maps.test.ts +304 -0
- package/cli/sync/maps.ts +297 -0
- package/cli/sync/migrate.test.ts +2000 -0
- package/cli/sync/migrate.ts +1104 -0
- package/cli/sync/nightly-jobs.test.ts +63 -0
- package/cli/sync/nightly.constants.ts +48 -0
- package/cli/sync/nightly.test.ts +100 -0
- package/cli/sync/nightly.ts +75 -0
- package/cli/sync/outputs.test.ts +351 -0
- package/cli/sync/outputs.ts +168 -0
- package/cli/sync/oxlint-config.test.ts +178 -0
- package/cli/sync/oxlint-config.ts +168 -0
- package/cli/sync/release-jobs.test.ts +29 -0
- package/cli/sync/release-reports.test.ts +31 -0
- package/cli/sync/release-workflow.constants.ts +61 -0
- package/cli/sync/release-workflow.test.ts +192 -0
- package/cli/sync/release-workflow.ts +86 -0
- package/cli/sync/release.constants.ts +93 -0
- package/cli/sync/release.test.ts +187 -0
- package/cli/sync/release.ts +112 -0
- package/cli/sync/repo-dir.test.ts +371 -0
- package/cli/sync/repo-dir.ts +205 -0
- package/cli/sync/standards.constants.ts +8 -0
- package/cli/sync/standards.test.ts +206 -0
- package/cli/sync/standards.ts +179 -0
- package/cli/sync/stock-history.json +2796 -0
- package/cli/sync/stock-history.test.ts +66 -0
- package/cli/sync/stock-history.ts +84 -0
- package/cli/sync/stock.test.ts +47 -0
- package/cli/sync/stock.ts +24 -0
- package/cli/sync/trees.test.ts +621 -0
- package/cli/sync/trees.ts +280 -0
- package/cli/sync/validate.test.ts +299 -0
- package/cli/sync/validate.ts +402 -0
- package/cli/sync/workspace.test.ts +180 -0
- package/cli/sync/workspace.ts +138 -0
- package/cli/sync/write.test.ts +341 -0
- package/cli/sync/write.ts +231 -0
- package/cli/sync.test.ts +1495 -0
- package/cli/sync.ts +360 -0
- package/cli/tdd-scope.test.ts +36 -0
- package/cli/tdd-scope.ts +41 -0
- package/cli/test-files.ts +16 -0
- package/cli/ts-program.ts +69 -0
- package/cli/tsconfig.constants.ts +8 -0
- package/cli/workerstack-config.types.ts +21 -0
- package/dist/cli/env.d.ts +10 -0
- package/dist/cli/env.js +64 -0
- package/dist/presets/temp-root-env.d.ts +10 -0
- package/dist/presets/temp-root-env.js +20 -0
- package/dist/presets/temp-root.constants.d.ts +6 -0
- package/dist/presets/temp-root.constants.js +6 -0
- package/dist/presets/temp-root.d.ts +2 -0
- package/dist/presets/temp-root.js +60 -0
- package/dist/presets/vitest.constants.d.ts +16 -0
- package/dist/presets/vitest.constants.js +16 -0
- package/dist/presets/vitest.d.ts +11 -0
- package/dist/presets/vitest.js +67 -0
- package/dist/presets/workers-plugin.d.ts +10 -0
- package/dist/presets/workers-plugin.js +54 -0
- package/package.json +141 -4
- package/references/INDEX.md +100 -0
- package/references/browser.md +66 -0
- package/references/cascade.md +15 -0
- package/references/clasp.md +21 -0
- package/references/cloudflare.md +36 -0
- package/references/google-workspace.md +79 -0
- package/references/hooks.md +103 -0
- package/references/implementation-format.md +119 -0
- package/references/jev.md +56 -0
- package/references/lessons.md +73 -0
- package/references/listen.md +80 -0
- package/references/map.md +107 -0
- package/references/output.md +47 -0
- package/references/plan-format.md +264 -0
- package/references/review-loop.md +48 -0
- package/references/roadmap-format.md +104 -0
- package/references/rtk.md +27 -0
- package/references/scope.md +76 -0
- package/references/setup.md +42 -0
- package/references/speak.md +59 -0
- package/tsconfig/base.json +28 -0
- package/tsconfig/bun.d.ts +1 -0
- package/tsconfig/bun.json +8 -0
- package/tsconfig/reset.d.ts +1 -0
- package/tsconfig/strict.json +12 -0
- package/tsconfig/web.json +7 -0
- package/tsconfig/worker.d.ts +1 -0
- package/tsconfig/worker.json +8 -0
package/cli/red-proof.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { childEnv } from "./env.ts";
|
|
6
|
+
import { MAX_BUFFER, REPORT_FAILED, STATUS_ADDED, STATUS_DELETED, STDERR_TAIL_LINES } from "./red-proof.constants.ts";
|
|
7
|
+
import { NESTED_VITEST_FLAGS } from "./nested-vitest-flags.ts";
|
|
8
|
+
import { toolEnv } from "./scripts/lint.ts";
|
|
9
|
+
import { type GitRun, runGit } from "./sync/standards.ts";
|
|
10
|
+
import { isCodeFile, isSource, isTestFile } from "./test-files.ts";
|
|
11
|
+
|
|
12
|
+
/** One `git diff --cached --name-status --no-renames -z` entry: a status letter (A, M, D, T) and a path. */
|
|
13
|
+
export type NameStatus = { status: string; path: string };
|
|
14
|
+
/** Runs vitest with `args` (after the binary) in `cwd`, giving up after `timeoutMs`; a run that did not finish has a non-zero code. */
|
|
15
|
+
export type VitestRun = (args: string[], cwd: string, timeoutMs: number) => { code: number; stderr: string };
|
|
16
|
+
export type RedProofDeps = { git?: GitRun; vitest?: VitestRun; timeoutMs?: number };
|
|
17
|
+
export type RedProofResult = { ok: true; line: string } | { ok: false; problem: string };
|
|
18
|
+
|
|
19
|
+
const TIMEOUT_MS = 5 * 60_000;
|
|
20
|
+
const NO_FILE =
|
|
21
|
+
"red-proof: no changed vitest test file outside e2e/ to run on HEAD; add one that fails without the change";
|
|
22
|
+
|
|
23
|
+
/** The entries of `git diff --name-status -z` output. */
|
|
24
|
+
export function parseNameStatus(text: string): NameStatus[] {
|
|
25
|
+
const parts = text.split("\0").filter((part) => part !== "");
|
|
26
|
+
return Array.from({ length: parts.length / 2 }, (_, n) => ({
|
|
27
|
+
status: parts[2 * n] /* entries come in status, path pairs */ as string,
|
|
28
|
+
path: parts[2 * n + 1] /* entries come in status, path pairs */ as string,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A vitest test file the proof runs: a code file `isTest` matches, outside `e2e/`. */
|
|
33
|
+
const isRunnable = (file: string): boolean => isCodeFile(file) && isTestFile(file) && !/(^|\/)e2e\//.test(file);
|
|
34
|
+
|
|
35
|
+
/** Vitest from the package at `cwd`, run under bun as the package's own tests would; null-free: a run that ends early reports why. */
|
|
36
|
+
const runVitest: VitestRun = (args, cwd, timeoutMs) => {
|
|
37
|
+
let manifest: string;
|
|
38
|
+
try {
|
|
39
|
+
manifest = Bun.resolveSync("vitest/package.json", cwd);
|
|
40
|
+
} catch {
|
|
41
|
+
return { code: 1, stderr: `cannot resolve vitest from ${cwd}` };
|
|
42
|
+
}
|
|
43
|
+
const { bin } = JSON.parse(fs.readFileSync(manifest, "utf8")) /* vitest's manifest has a bin map */ as {
|
|
44
|
+
bin: Record<string, string>;
|
|
45
|
+
};
|
|
46
|
+
const script = path.join(path.dirname(manifest), bin["vitest"] /* vitest declares its bin */ as string);
|
|
47
|
+
const result = spawnSync(process.execPath, ["--bun", script, ...args], {
|
|
48
|
+
cwd,
|
|
49
|
+
env: toolEnv(childEnv({ NO_COLOR: "1" })),
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
timeout: timeoutMs,
|
|
52
|
+
maxBuffer: MAX_BUFFER,
|
|
53
|
+
});
|
|
54
|
+
if (result.error) return { code: 1, stderr: `vitest did not finish within ${timeoutMs}ms (${result.error.message})` };
|
|
55
|
+
return { code: result.status /* null only when result.error is set */ as number, stderr: result.stderr };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Unpacks a tar file into `cwd`; shaped like a git runner so a failure reads the same. */
|
|
59
|
+
const untar: GitRun = (cwd, args) => {
|
|
60
|
+
const result = spawnSync("tar", [...args], { cwd, encoding: "utf8" });
|
|
61
|
+
return { status: result.status, stdout: result.stdout, stderr: `${result.stderr}` };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** The result's stdout, or a throw naming `what` and git's reason. */
|
|
65
|
+
function must(what: string, result: ReturnType<GitRun>): string {
|
|
66
|
+
if (result.status !== 0) throw new Error(`red-proof: ${what} failed: ${result.stderr.trim()}`);
|
|
67
|
+
return result.stdout;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ARCHIVE = ".red-proof.tar";
|
|
71
|
+
|
|
72
|
+
type Tree = { root: string; tree: string; git: GitRun };
|
|
73
|
+
|
|
74
|
+
/** Unpacks the staged tree into the HEAD tree dir. */
|
|
75
|
+
function unpack({ root, tree, git }: Tree): void {
|
|
76
|
+
const archive = path.join(tree, ARCHIVE);
|
|
77
|
+
const sha = must("git write-tree", git(root, ["write-tree"])).trim();
|
|
78
|
+
// No eol conversion: `git show` in putBack writes the blob as is, so the tree must hold blobs as they are too.
|
|
79
|
+
must("git archive", git(root, ["-c", "core.autocrlf=false", "archive", "--format=tar", `--output=${archive}`, sha]));
|
|
80
|
+
// Relative to the tree: GNU tar reads an absolute `C:\\…` as `host:path` and tries to connect to the host `C`.
|
|
81
|
+
must("tar", untar(tree, ["-xf", ARCHIVE]));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Puts every changed source file back from HEAD, or removes it when new. */
|
|
85
|
+
function putBack({ root, tree, git }: Tree, change: NameStatus[]): void {
|
|
86
|
+
for (const { status, path: file } of change.filter((entry) => isSource(entry.path))) {
|
|
87
|
+
const target = path.join(tree, file);
|
|
88
|
+
if (status === STATUS_ADDED) fs.rmSync(target, { force: true });
|
|
89
|
+
else {
|
|
90
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
91
|
+
fs.writeFileSync(target, must(`git show HEAD:${file}`, git(root, ["show", `HEAD:${file}`])));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Links each package dir's checkout `node_modules` into the tree; returns the package dirs (`.` is the root). */
|
|
97
|
+
function linkModules({ root, tree, git }: Tree): Set<string> {
|
|
98
|
+
const listed = must("git ls-files", git(root, ["ls-files", "-z", "--", ":(glob)**/package.json"]));
|
|
99
|
+
const dirs = new Set(["."]);
|
|
100
|
+
for (const manifest of listed.split("\0").filter((entry) => entry !== "")) dirs.add(path.dirname(manifest));
|
|
101
|
+
for (const dir of dirs) {
|
|
102
|
+
const modules = path.join(root, dir, "node_modules");
|
|
103
|
+
if (fs.existsSync(modules) && fs.existsSync(path.join(tree, dir)))
|
|
104
|
+
fs.symlinkSync(modules, path.join(tree, dir, "node_modules"), "junction"); // no symlink privilege needed on Windows
|
|
105
|
+
}
|
|
106
|
+
return dirs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The nearest package dir at or above the file, `.` when none. */
|
|
110
|
+
function ownerOf(file: string, dirs: Set<string>): string {
|
|
111
|
+
let dir = path.dirname(file);
|
|
112
|
+
while (dir !== "." && !dirs.has(dir)) dir = path.dirname(dir);
|
|
113
|
+
return dir;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The test files grouped by owner package dir, each path relative to that dir. */
|
|
117
|
+
function groupByOwner(files: string[], dirs: Set<string>): Map<string, string[]> {
|
|
118
|
+
const groups = new Map<string, string[]>();
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
const dir = ownerOf(file, dirs);
|
|
121
|
+
groups.set(dir, [...(groups.get(dir) ?? []), path.posix.relative(dir, file)]);
|
|
122
|
+
}
|
|
123
|
+
return groups;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The shape of the vitest json report this reads. */
|
|
127
|
+
const reportSchema = () => z.object({ testResults: z.array(z.object({ name: z.string(), status: z.string() })) });
|
|
128
|
+
|
|
129
|
+
/** The vitest results in the report file, or null when it is missing, unreadable or not a vitest report. */
|
|
130
|
+
function readReport(file: string): { name: string; status: string }[] | null {
|
|
131
|
+
try {
|
|
132
|
+
return reportSchema().parse(JSON.parse(fs.readFileSync(file, "utf8"))).testResults;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type Ran = { problem: string } | { failed: string[]; passed: string[] };
|
|
139
|
+
type Run = (args: string[], cwd: string) => ReturnType<VitestRun>;
|
|
140
|
+
|
|
141
|
+
/** Runs the groups' test files in the HEAD tree; the problem when a run wrote no report, else who failed and who passed. */
|
|
142
|
+
function runGroups(tree: string, groups: Map<string, string[]>, vitest: Run): Ran {
|
|
143
|
+
const failed: string[] = [];
|
|
144
|
+
const passed: string[] = [];
|
|
145
|
+
let n = 0;
|
|
146
|
+
for (const [dir, files] of groups) {
|
|
147
|
+
const output = path.join(tree, `.red-proof-report-${n++}.json`);
|
|
148
|
+
const args = ["run", ...files, ...NESTED_VITEST_FLAGS, "--reporter=json", `--outputFile=${output}`];
|
|
149
|
+
const ran = vitest(args, path.join(tree, dir));
|
|
150
|
+
const results = readReport(output);
|
|
151
|
+
if (results === null) {
|
|
152
|
+
const tail = ran.stderr.trim().split("\n").slice(STDERR_TAIL_LINES).join(" | ");
|
|
153
|
+
return { problem: `red-proof: vitest wrote no report in ${dir} (exit ${ran.code}): ${tail}`.trimEnd() };
|
|
154
|
+
}
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const red = results.some(
|
|
157
|
+
(r) => r.status === REPORT_FAILED && r.name.split(path.sep).join("/").endsWith(`/${file}`),
|
|
158
|
+
);
|
|
159
|
+
(red ? failed : passed).push(path.posix.join(dir, file));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { failed, passed };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Red when any test file failed on HEAD, else a refusal naming the ones that passed. */
|
|
166
|
+
function verdict(ran: { failed: string[]; passed: string[] }, total: number): RedProofResult {
|
|
167
|
+
if (ran.failed.length > 0)
|
|
168
|
+
return { ok: true, line: `red-proof: ${ran.failed.length} of ${total} changed test files failed on HEAD` };
|
|
169
|
+
const named = ran.passed.join(", ");
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
problem: `red-proof: no changed test file fails on HEAD's source (${named} passed); a feat or fix needs one that fails without the change`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Runs the changed vitest test files against HEAD's source and says whether one fails there. The staged tree is built
|
|
178
|
+
* under `<root>/.tmp/repo/red-proof-<pid>/` with every changed source file put back; the dir is removed on the way out.
|
|
179
|
+
*/
|
|
180
|
+
export function redProof(root: string, change: NameStatus[], deps: RedProofDeps = {}): RedProofResult {
|
|
181
|
+
const { git = runGit, vitest = runVitest, timeoutMs = TIMEOUT_MS } = deps;
|
|
182
|
+
if (git(root, ["rev-parse", "--verify", "--quiet", "HEAD"]).status !== 0)
|
|
183
|
+
return { ok: true, line: "red-proof: skipped, no HEAD to run against" };
|
|
184
|
+
const tests = change.filter((entry) => entry.status !== STATUS_DELETED && isRunnable(entry.path));
|
|
185
|
+
if (tests.length === 0) return { ok: false, problem: NO_FILE };
|
|
186
|
+
const tree = path.join(root, ".tmp", "repo", `red-proof-${process.pid}`);
|
|
187
|
+
fs.rmSync(tree, { recursive: true, force: true });
|
|
188
|
+
fs.mkdirSync(tree, { recursive: true });
|
|
189
|
+
try {
|
|
190
|
+
const context = { root, tree, git };
|
|
191
|
+
unpack(context);
|
|
192
|
+
putBack(context, change);
|
|
193
|
+
const groups = groupByOwner(
|
|
194
|
+
tests.map((entry) => entry.path),
|
|
195
|
+
linkModules(context),
|
|
196
|
+
);
|
|
197
|
+
const ran = runGroups(tree, groups, (args, cwd) => vitest(args, cwd, timeoutMs));
|
|
198
|
+
return "problem" in ran ? { ok: false, problem: ran.problem } : verdict(ran, tests.length);
|
|
199
|
+
} finally {
|
|
200
|
+
fs.rmSync(tree, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { FENCE, HEADING, UNRELEASED, VERSION_HASHES, VERSION_HEADING } from "./release.constants.ts";
|
|
2
|
+
import type { Parts } from "./version.ts";
|
|
3
|
+
|
|
4
|
+
/** A `##` (version) or `###` (section) heading outside a code fence: its line (0-based) and the text after the hashes. */
|
|
5
|
+
export type Heading = { at: number; kind: "version" | "section"; text: string };
|
|
6
|
+
/** A `##` section: its heading line, the version in it (null when it names none) and the lines below. */
|
|
7
|
+
export type Section = { heading: string; version: string | null; body: string[] };
|
|
8
|
+
/** The text before the first section, and each section in file order. */
|
|
9
|
+
export type Parsed = { preamble: string[]; sections: Section[] };
|
|
10
|
+
|
|
11
|
+
/** The lines of a file, without the newline that ends it. */
|
|
12
|
+
export const linesOf = (text: string): string[] => text.replace(/\n$/, "").split("\n");
|
|
13
|
+
|
|
14
|
+
/** Every heading of `lines` that is not inside a fenced code block. */
|
|
15
|
+
export function headings(lines: readonly string[]): Heading[] {
|
|
16
|
+
let fenced = false;
|
|
17
|
+
const found: Heading[] = [];
|
|
18
|
+
lines.forEach((line, at) => {
|
|
19
|
+
if (line.startsWith(FENCE)) fenced = !fenced;
|
|
20
|
+
const [, hashes, text] = (fenced ? null : HEADING.exec(line)) ?? [];
|
|
21
|
+
if (text !== undefined) found.push({ at, kind: hashes === VERSION_HASHES ? "version" : "section", text });
|
|
22
|
+
});
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Splits a changelog at its `##` headings. */
|
|
27
|
+
export function parseChangelog(text: string): Parsed {
|
|
28
|
+
const lines = linesOf(text);
|
|
29
|
+
const starts = headings(lines).filter((heading) => heading.kind === "version");
|
|
30
|
+
const first = starts[0]?.at ?? lines.length;
|
|
31
|
+
const sections = starts.map(({ at, text: heading }, i) => ({
|
|
32
|
+
heading: `${VERSION_HASHES} ${heading}`,
|
|
33
|
+
version: VERSION_HEADING.exec(heading)?.[1] ?? null,
|
|
34
|
+
body: lines.slice(at + 1, starts[i + 1]?.at ?? lines.length),
|
|
35
|
+
}));
|
|
36
|
+
return { preamble: lines.slice(0, first), sections };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `##` section of `version` in a changelog's text; null when it has none. */
|
|
40
|
+
export function sectionFor(text: string, version: string): Section | null {
|
|
41
|
+
return parseChangelog(text).sections.find((section) => section.version === version) ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `###` groups of a section body, in order: the loose text before the first has the name "". */
|
|
45
|
+
export function groupsOf(body: readonly string[]): [string, string[]][] {
|
|
46
|
+
let current: [string, string[]] = ["", []];
|
|
47
|
+
const groups = [current];
|
|
48
|
+
const starts = new Map(
|
|
49
|
+
headings(body)
|
|
50
|
+
.filter((h) => h.kind === "section")
|
|
51
|
+
.map((h) => [h.at, h.text]),
|
|
52
|
+
);
|
|
53
|
+
body.forEach((line, at) => {
|
|
54
|
+
const name = starts.get(at);
|
|
55
|
+
if (name === undefined) {
|
|
56
|
+
current[1].push(line);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
current = [name, []];
|
|
60
|
+
groups.push(current);
|
|
61
|
+
});
|
|
62
|
+
return groups.map(([name, lines]) => [name, trimBlank(lines)]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `lines` without the blank lines at either end. */
|
|
66
|
+
export function trimBlank(lines: readonly string[]): string[] {
|
|
67
|
+
const text = lines.join("\n").replace(/^\n+|\n+$/g, "");
|
|
68
|
+
return text === "" ? [] : text.split("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the Unreleased section of `parsed` has any entry text. */
|
|
72
|
+
export function unreleasedHasText({ sections }: Parsed): boolean {
|
|
73
|
+
const unreleased = sections.find((section) => section.version === UNRELEASED);
|
|
74
|
+
return unreleased?.body.some((line) => line.trim() !== "" && !HEADING.test(line)) ?? false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A positive number when `x` is the newer version, negative when `y` is, 0 when equal; a prerelease is below its release. */
|
|
78
|
+
export function compareVersions(x: Parts, y: Parts): number {
|
|
79
|
+
const core = Math.sign(x.major - y.major) || Math.sign(x.minor - y.minor) || Math.sign(x.patch - y.patch);
|
|
80
|
+
if (core !== 0) return core;
|
|
81
|
+
if (x.id === undefined || y.id === undefined) return Number(x.id === undefined) - Number(y.id === undefined);
|
|
82
|
+
return x.id === y.id ? x.n - y.n : x.id.localeCompare(y.id);
|
|
83
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { changelogFormatProblems, hasUnreleased, stampChangelog } from "./changelog.ts";
|
|
3
|
+
|
|
4
|
+
const HEAD = "# Changelog\n\nAll notable changes.\n\n";
|
|
5
|
+
|
|
6
|
+
describe("changelogFormatProblems headings", () => {
|
|
7
|
+
it("passes a changelog in the Keep a Changelog format", () => {
|
|
8
|
+
const text = `${HEAD}## [Unreleased]\n\n### Added\n\n- x\n\n## [0.2.0] - 2026-11-01\n\n### Fixed\n\n- y\n\n## [0.1.0] - 2026-10-01\n\n### Added\n\n- z\n`;
|
|
9
|
+
expect(changelogFormatProblems(text)).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("says a missing file is missing", () => {
|
|
13
|
+
expect(changelogFormatProblems(null)).toEqual(["CHANGELOG.md is missing; add one in the Keep a Changelog format"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("asks for an Unreleased section", () => {
|
|
17
|
+
expect(changelogFormatProblems(`${HEAD}## [0.1.0] - 2026-10-01\n`)).toEqual([
|
|
18
|
+
"CHANGELOG.md has no ## [Unreleased] section; add it above the newest version",
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("wants a date on every version", () => {
|
|
23
|
+
const text = `${HEAD}## [Unreleased]\n\n## [0.2.0]\n\n### Added\n\n- x\n`;
|
|
24
|
+
expect(changelogFormatProblems(text)).toEqual([
|
|
25
|
+
'CHANGELOG.md:7: "## [0.2.0]" needs a date; write ## [0.2.0] - YYYY-MM-DD',
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("accepts only the six Keep a Changelog sections", () => {
|
|
30
|
+
const text = `${HEAD}## [Unreleased]\n\n### Improved\n\n- x\n\n### Security\n\n- y\n`;
|
|
31
|
+
expect(changelogFormatProblems(text)).toEqual([
|
|
32
|
+
'CHANGELOG.md:7: "### Improved" is not a section; use Added, Changed, Deprecated, Removed, Fixed or Security',
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("changelogFormatProblems order", () => {
|
|
38
|
+
it("lists versions newest first, a prerelease below its release", () => {
|
|
39
|
+
const text = `${HEAD}## [Unreleased]\n\n## [0.2.0] - 2026-11-01\n\n## [0.3.0] - 2026-11-02\n\n## [0.3.0-rc.0] - 2026-11-01\n`;
|
|
40
|
+
expect(changelogFormatProblems(text)).toEqual(["CHANGELOG.md:9: 0.3.0 is below 0.2.0; list versions newest first"]);
|
|
41
|
+
const ok = `${HEAD}## [Unreleased]\n\n## [1.0.0] - 2026-11-02\n\n## [1.0.0-rc.1] - 2026-11-01\n\n## [1.0.0-rc.0] - 2026-10-30\n`;
|
|
42
|
+
expect(changelogFormatProblems(ok)).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("changelogFormatProblems prereleases", () => {
|
|
47
|
+
it("puts a release above its prereleases and orders other prerelease ids by name", () => {
|
|
48
|
+
const pre = `${HEAD}## [Unreleased]\n\n## [1.0.0-rc.0] - 2026-11-01\n\n## [1.0.0] - 2026-11-02\n`;
|
|
49
|
+
expect(changelogFormatProblems(pre)).toEqual([
|
|
50
|
+
"CHANGELOG.md:9: 1.0.0 is below 1.0.0-rc.0; list versions newest first",
|
|
51
|
+
]);
|
|
52
|
+
const ids = `${HEAD}## [Unreleased]\n\n## [1.0.0-beta.0] - 2026-11-01\n\n## [1.0.0-rc.0] - 2026-11-02\n`;
|
|
53
|
+
expect(changelogFormatProblems(ids)).toEqual([
|
|
54
|
+
"CHANGELOG.md:9: 1.0.0-rc.0 is below 1.0.0-beta.0; list versions newest first",
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("names a version listed twice", () => {
|
|
59
|
+
const text = `${HEAD}## [Unreleased]\n\n## [0.2.0] - 2026-11-01\n\n## [0.2.0] - 2026-11-01\n`;
|
|
60
|
+
expect(changelogFormatProblems(text)).toEqual(["CHANGELOG.md:9: 0.2.0 is listed twice; merge the sections"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("asks for a version heading where a section has another name", () => {
|
|
64
|
+
expect(changelogFormatProblems(`${HEAD}## [Unreleased]\n\n## Notes\n`)).toEqual([
|
|
65
|
+
'CHANGELOG.md:7: "## Notes" is not a version; write ## [x.y.z] - YYYY-MM-DD',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not read headings inside a code fence", () => {
|
|
70
|
+
const text = `${HEAD}## [Unreleased]\n\n\`\`\`md\n### Improved\n## [9.9.9]\n\`\`\`\n`;
|
|
71
|
+
expect(changelogFormatProblems(text)).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("hasUnreleased", () => {
|
|
76
|
+
it("is true when Unreleased holds any text under a heading", () => {
|
|
77
|
+
expect(hasUnreleased(`${HEAD}## [Unreleased]\n\n### Added\n\n- x\n\n## [0.1.0] - 2026-10-01\n`)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("is false for an empty Unreleased, a heading with no entries and a missing section", () => {
|
|
81
|
+
expect(hasUnreleased(`${HEAD}## [Unreleased]\n\n## [0.1.0] - 2026-10-01\n\n- old\n`)).toBe(false);
|
|
82
|
+
expect(hasUnreleased(`${HEAD}## [Unreleased]\n\n### Added\n\n`)).toBe(false);
|
|
83
|
+
expect(hasUnreleased(HEAD)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const unreleased = "## [Unreleased]\n\n### Added\n\n- a\n\n### Fixed\n\n- f\n\n";
|
|
88
|
+
|
|
89
|
+
describe("stampChangelog", () => {
|
|
90
|
+
it("moves the Unreleased entries under the new version, leaving an empty Unreleased", () => {
|
|
91
|
+
const text = `${HEAD}${unreleased}## [0.1.0] - 2026-10-01\n\n### Added\n\n- old\n`;
|
|
92
|
+
expect(stampChangelog(text, "0.1.1", "2026-11-09")).toBe(
|
|
93
|
+
`${HEAD}## [Unreleased]\n\n## [0.1.1] - 2026-11-09\n\n### Added\n\n- a\n\n### Fixed\n\n- f\n\n## [0.1.0] - 2026-10-01\n\n### Added\n\n- old\n`,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("works on a changelog with no older version", () => {
|
|
98
|
+
expect(stampChangelog(`${HEAD}${unreleased}`, "0.1.0", "2026-11-09")).toBe(
|
|
99
|
+
`${HEAD}## [Unreleased]\n\n## [0.1.0] - 2026-11-09\n\n### Added\n\n- a\n\n### Fixed\n\n- f\n`,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("folds Unreleased into an existing section by heading, in Keep a Changelog order", () => {
|
|
104
|
+
const text = `${HEAD}## [Unreleased]\n\n### Security\n\n- s\n\n### Added\n\n- new\n\n## [0.1.1] - 2026-11-08\n\n### Fixed\n\n- f\n\n### Added\n\n- old\n\n## [0.1.0] - 2026-10-01\n`;
|
|
105
|
+
expect(stampChangelog(text, "0.1.1", "2026-11-09")).toBe(
|
|
106
|
+
`${HEAD}## [Unreleased]\n\n## [0.1.1] - 2026-11-08\n\n### Added\n\n- new\n- old\n\n### Fixed\n\n- f\n\n### Security\n\n- s\n\n## [0.1.0] - 2026-10-01\n`,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("stampChangelog edge cases", () => {
|
|
112
|
+
it("changes nothing in the section when Unreleased is empty", () => {
|
|
113
|
+
const text = `${HEAD}## [Unreleased]\n\n## [0.1.1] - 2026-11-08\n\n### Added\n\n- old\n`;
|
|
114
|
+
expect(stampChangelog(text, "0.1.1", "2026-11-09")).toBe(text);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("folds the prerelease sections of the same base into the release", () => {
|
|
118
|
+
const text = `${HEAD}## [Unreleased]\n\n### Fixed\n\n- last\n\n## [1.1.0-rc.1] - 2026-11-02\n\n### Fixed\n\n- two\n\n## [1.1.0-rc.0] - 2026-11-01\n\n### Added\n\n- one\n\n## [1.0.0] - 2026-10-01\n\n### Added\n\n- base\n`;
|
|
119
|
+
expect(stampChangelog(text, "1.1.0", "2026-11-09")).toBe(
|
|
120
|
+
`${HEAD}## [Unreleased]\n\n## [1.1.0] - 2026-11-09\n\n### Added\n\n- one\n\n### Fixed\n\n- last\n- two\n\n## [1.0.0] - 2026-10-01\n\n### Added\n\n- base\n`,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("keeps text outside any ### heading and an unknown heading after the known ones", () => {
|
|
125
|
+
const text = `${HEAD}## [Unreleased]\n\nLoose note.\n\n### Extras\n\n- e\n\n### Added\n\n- a\n`;
|
|
126
|
+
expect(stampChangelog(text, "0.1.0", "2026-11-09")).toBe(
|
|
127
|
+
`${HEAD}## [Unreleased]\n\n## [0.1.0] - 2026-11-09\n\nLoose note.\n\n### Added\n\n- a\n\n### Extras\n\n- e\n`,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("keeps a section that names no version where it was", () => {
|
|
132
|
+
const text = `${HEAD}## [Unreleased]\n\n### Added\n\n- a\n\n## Notes\n\nSee below.\n`;
|
|
133
|
+
expect(stampChangelog(text, "0.1.0", "2026-11-09")).toBe(
|
|
134
|
+
`${HEAD}## [Unreleased]\n\n## [0.1.0] - 2026-11-09\n\n### Added\n\n- a\n\n## Notes\n\nSee below.\n`,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("adds Unreleased when the file has none", () => {
|
|
139
|
+
expect(stampChangelog(HEAD, "0.1.0", "2026-11-09")).toBe(`${HEAD}## [Unreleased]\n\n## [0.1.0] - 2026-11-09\n`);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
compareVersions,
|
|
3
|
+
groupsOf,
|
|
4
|
+
headings,
|
|
5
|
+
linesOf,
|
|
6
|
+
parseChangelog,
|
|
7
|
+
trimBlank,
|
|
8
|
+
unreleasedHasText,
|
|
9
|
+
type Parsed,
|
|
10
|
+
type Section,
|
|
11
|
+
} from "./changelog-parse.ts";
|
|
12
|
+
import {
|
|
13
|
+
CHANGELOG_FILE,
|
|
14
|
+
DATE_FORMAT,
|
|
15
|
+
DATED,
|
|
16
|
+
SECTION_LIST,
|
|
17
|
+
SECTIONS,
|
|
18
|
+
UNRELEASED,
|
|
19
|
+
VERSION_HEADING,
|
|
20
|
+
} from "./release.constants.ts";
|
|
21
|
+
import { parseSemver, type Parts } from "./version.ts";
|
|
22
|
+
|
|
23
|
+
/** Whether the file's Unreleased section holds any entry. */
|
|
24
|
+
export function hasUnreleased(text: string): boolean {
|
|
25
|
+
return unreleasedHasText(parseChangelog(text));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The versions listed so far, and the one listed last. */
|
|
29
|
+
type Listed = { seen: Set<string>; previous: { version: string; parts: Parts } | null };
|
|
30
|
+
|
|
31
|
+
/** The problem with one `##` heading, given the versions listed above it. */
|
|
32
|
+
function versionProblem(text: string, line: number, state: Listed): string | null {
|
|
33
|
+
const where = `${CHANGELOG_FILE}:${line}`;
|
|
34
|
+
const version = VERSION_HEADING.exec(text)?.[1] ?? "";
|
|
35
|
+
if (version === UNRELEASED) return null;
|
|
36
|
+
const parts = parseSemver(version);
|
|
37
|
+
if (parts === null) return `${where}: "## ${text}" is not a version; write ## [x.y.z] - ${DATE_FORMAT}`;
|
|
38
|
+
if (!DATED.test(text)) return `${where}: "## ${text}" needs a date; write ## [${version}] - ${DATE_FORMAT}`;
|
|
39
|
+
const { seen, previous } = state;
|
|
40
|
+
const twice = seen.has(version);
|
|
41
|
+
const newer = previous !== null && compareVersions(parts, previous.parts) > 0 ? previous.version : null;
|
|
42
|
+
seen.add(version);
|
|
43
|
+
state.previous = { version, parts };
|
|
44
|
+
if (twice) return `${where}: ${version} is listed twice; merge the sections`;
|
|
45
|
+
return newer === null ? null : `${where}: ${version} is below ${newer}; list versions newest first`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One line per way the changelog text strays from Keep a Changelog; the single line "is missing" for no file. */
|
|
49
|
+
export function changelogFormatProblems(text: string | null): string[] {
|
|
50
|
+
if (text === null) return [`${CHANGELOG_FILE} is missing; add one in the Keep a Changelog format`];
|
|
51
|
+
const state: Listed = { seen: new Set(), previous: null };
|
|
52
|
+
const problems: string[] = [];
|
|
53
|
+
for (const { at, kind, text: heading } of headings(linesOf(text))) {
|
|
54
|
+
if (kind === "section" && !SECTIONS.includes(heading)) {
|
|
55
|
+
problems.push(`${CHANGELOG_FILE}:${at + 1}: "### ${heading}" is not a section; use ${SECTION_LIST}`);
|
|
56
|
+
}
|
|
57
|
+
const problem = kind === "version" ? versionProblem(heading, at + 1, state) : null;
|
|
58
|
+
if (problem !== null) problems.push(problem);
|
|
59
|
+
}
|
|
60
|
+
if (!parseChangelog(text).sections.some(({ version }) => version === UNRELEASED)) {
|
|
61
|
+
problems.push(`${CHANGELOG_FILE} has no ## [${UNRELEASED}] section; add it above the newest version`);
|
|
62
|
+
}
|
|
63
|
+
return problems;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The `###` groups of `bodies` merged by name: loose text, then the Keep a Changelog sections, then other names. */
|
|
67
|
+
function mergeBodies(bodies: readonly (readonly string[])[]): string {
|
|
68
|
+
const merged = new Map<string, string[]>();
|
|
69
|
+
for (const body of bodies) {
|
|
70
|
+
for (const [name, lines] of groupsOf(body)) {
|
|
71
|
+
if (lines.length > 0) merged.set(name, [...(merged.get(name) ?? []), ...lines]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const known = ["", ...SECTIONS];
|
|
75
|
+
const rank = (name: string): number => (known.includes(name) ? known.indexOf(name) : known.length);
|
|
76
|
+
return [...merged]
|
|
77
|
+
.sort(([a], [b]) => rank(a) - rank(b))
|
|
78
|
+
.map(([name, lines]) => (name === "" ? "" : `### ${name}\n\n`) + lines.join("\n"))
|
|
79
|
+
.join("\n\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether `section` is a prerelease of the stable `version`. */
|
|
83
|
+
const prereleaseOf = (section: Section, version: string): boolean =>
|
|
84
|
+
!version.includes("-") && section.version !== null && section.version.startsWith(`${version}-`);
|
|
85
|
+
|
|
86
|
+
/** Text of one section as a block: the heading, a blank line, the body. */
|
|
87
|
+
const block = (heading: string, body: readonly string[]): string =>
|
|
88
|
+
[heading, ...(trimBlank(body).length > 0 ? ["", ...trimBlank(body)] : [])].join("\n");
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The changelog with its Unreleased entries moved under `## [version] - date` and an empty Unreleased above it. A
|
|
92
|
+
* section for the version already there takes the entries by `###` heading, and for a stable version the sections of
|
|
93
|
+
* its prereleases fold in and go.
|
|
94
|
+
*/
|
|
95
|
+
export function stampChangelog(text: string, version: string, date: string): string {
|
|
96
|
+
const { preamble, sections }: Parsed = parseChangelog(text);
|
|
97
|
+
const unreleased = sections.find((section) => section.version === UNRELEASED);
|
|
98
|
+
const existing = sections.find((section) => section.version === version);
|
|
99
|
+
const folded = sections.filter((section) => prereleaseOf(section, version));
|
|
100
|
+
const taken = new Set<Section | undefined>([unreleased, existing, ...folded]);
|
|
101
|
+
const bodies = [unreleased, existing, ...folded].filter((section) => section !== undefined).map((s) => s.body);
|
|
102
|
+
const stamped = block(existing?.heading ?? `## [${version}] - ${date}`, mergeBodies(bodies).split("\n"));
|
|
103
|
+
const rest = sections.filter((section) => !taken.has(section)).map(({ heading, body }) => block(heading, body));
|
|
104
|
+
const head = trimBlank(preamble).join("\n");
|
|
105
|
+
return `${[head, `## [${UNRELEASED}]`, stamped, ...rest].filter((part) => part !== "").join("\n\n")}\n`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { ghFor, refused, runGate, tagRelease } from "../fixtures/gate-world.ts";
|
|
3
|
+
import { removePlanted } from "../fixtures/planted-repo.ts";
|
|
4
|
+
import { commit, gitIn, NAME, world } from "../fixtures/release-world.ts";
|
|
5
|
+
|
|
6
|
+
afterEach(removePlanted);
|
|
7
|
+
|
|
8
|
+
describe("repo release gate refuses a tag that disagrees with its commit", () => {
|
|
9
|
+
it("whose version differs from package.json at its commit", async () => {
|
|
10
|
+
const w = world({}, false);
|
|
11
|
+
const short = tagRelease(w, { version: "0.1.5", tagVersion: "0.2.0" });
|
|
12
|
+
expect(await runGate(w, [`${NAME}@0.2.0`])).toEqual(
|
|
13
|
+
refused(
|
|
14
|
+
`the tag says 0.2.0 but package.json at ${short} says 0.1.5; a tag is never moved, so release the next version`,
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("whose package name differs from package.json at its commit", async () => {
|
|
20
|
+
const w = world({}, false);
|
|
21
|
+
const short = tagRelease(w, { version: "0.2.0" });
|
|
22
|
+
gitIn(w.work, "tag", "-a", "@other/app@0.2.0", "-m", "t");
|
|
23
|
+
expect(await runGate(w, ["@other/app@0.2.0"])).toEqual(
|
|
24
|
+
refused(`the tag names @other/app but package.json at ${short} names ${NAME}`),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("repo release gate refuses a tag whose commit lacks what a release needs", () => {
|
|
30
|
+
it("a version with no changelog section", async () => {
|
|
31
|
+
const w = world({}, false);
|
|
32
|
+
const short = tagRelease(w, { version: "0.2.0", changelog: false });
|
|
33
|
+
expect(await runGate(w, [`${NAME}@0.2.0`])).toEqual(
|
|
34
|
+
refused(`CHANGELOG.md at ${short} has no ## [0.2.0] section; publish is refused without one`),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("a package.json that is not a manifest", async () => {
|
|
39
|
+
const w = world({}, false);
|
|
40
|
+
commit(w, "chore: release", { "package.json": "{}\n" });
|
|
41
|
+
gitIn(w.work, "tag", "-a", `${NAME}@0.2.0`, "-m", "t");
|
|
42
|
+
const short = gitIn(w.work, "rev-parse", "--short=7", "HEAD");
|
|
43
|
+
expect(await runGate(w, [`${NAME}@0.2.0`])).toEqual(refused(`package.json at ${short} needs a name and a version`));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("repo release gate refuses a commit that is not on the default branch", () => {
|
|
48
|
+
it("when gh names another branch", async () => {
|
|
49
|
+
const w = world({}, false);
|
|
50
|
+
const short = tagRelease(w, { version: "0.2.0" });
|
|
51
|
+
expect(await runGate(w, [`${NAME}@0.2.0`], ghFor("trunk"))).toEqual(
|
|
52
|
+
refused(`${short} is not on trunk; tag a commit on trunk after its CI passed`),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("when it was never pushed", async () => {
|
|
57
|
+
const w = world({}, false);
|
|
58
|
+
const short = tagRelease(w, { version: "0.2.0", push: false });
|
|
59
|
+
expect(await runGate(w, [`${NAME}@0.2.0`])).toEqual(
|
|
60
|
+
refused(`${short} is not on main; tag a commit on main after its CI passed`),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("when gh cannot name the default branch", async () => {
|
|
65
|
+
const w = world({}, false);
|
|
66
|
+
tagRelease(w, { version: "0.2.0" });
|
|
67
|
+
expect(await runGate(w, [`${NAME}@0.2.0`], ghFor(null))).toEqual(
|
|
68
|
+
refused("gh could not read the default branch (not found); check gh auth status"),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("repo release gate refuses its arguments", () => {
|
|
74
|
+
it("a tag that does not exist", async () => {
|
|
75
|
+
expect(await runGate(world({}, false), [`${NAME}@0.2.0`])).toEqual(
|
|
76
|
+
refused(`the tag ${NAME}@0.2.0 does not exist here; fetch the tags`),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("a missing or malformed tag with the tag format", async () => {
|
|
81
|
+
const line = "name the tag as <name>@<version>, e.g. @workerstack/repo@1.0.0";
|
|
82
|
+
expect(await runGate(world(), [])).toEqual(refused(line));
|
|
83
|
+
expect(await runGate(world(), ["x", "y"])).toEqual(refused(line));
|
|
84
|
+
});
|
|
85
|
+
});
|