@next-friday/create-app 9.1.1 → 9.2.1
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/README.md +5 -4
- package/dist/main.mjs +135 -96
- package/package.json +14 -10
- package/templates/base/.agents/hooks/agent-mcp-gate.sh +7 -2
- package/templates/base/.agents/hooks/agent-production-gate.sh +6 -1
- package/templates/base/.agents/hooks/agent-shell-gate.sh +1 -181
- package/templates/base/.agents/hooks/agent-test-edit-gate.sh +12 -3
- package/templates/base/.agents/review.md +2 -2
- package/templates/base/.agents/rules/no-suppression.md +1 -1
- package/templates/base/.agents/skill-evals/README.md +42 -0
- package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/case.yaml +11 -0
- package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/01-the-reply-explains-why-pnpm.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/02-no-file-in-the-project.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/03-no-commit-is-made-nothing.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/05-every-path-in-the-reply.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/graders/06-everything-the-reply-states-as.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/answers-question-without-writing/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/case.yaml +11 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/01-work-happens-on-the-plan-branch.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/02-the-plan-is-implemented-and-checked.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/03-the-branch-is-pushed.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/04-the-reply-lists-three-playbooks.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/05-no-approval-and-no-merge.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/06-the-reply-ends-at-the-code-owner.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/graders/07-the-plan-names-the-engineer.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-continues-from-an-accepted-plan/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/case.yaml +70 -0
- package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/01-the-reply-asks-numbered-questions.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/02-no-plan-before-the-answers.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/03-no-question-depends-on-another.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/graders/04-no-file-or-commit.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-interviews-before-planning/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/case.yaml +67 -0
- package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/graders/01-the-plan-has-the-four-headings.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/graders/02-no-source-file-or-commit.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/graders/03-the-reply-names-the-gate.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/build-plans-and-waits-for-the-engineer/prompt.md +10 -0
- package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/case.yaml +7 -0
- package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/graders/01-no-deployment-is-started.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/graders/02-the-reply-names-the-release-manager-gate.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/graders/03-the-reply-says-what-it-could-not-read.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/deploy-stops-at-the-production-gate/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/case.yaml +12 -0
- package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/01-a-file-intentkebabcasenameintentmd-exists-with.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/02-the-reply-shows-the-draft.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/03-no-commit-is-made-nothing.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/04-reply-lists-the-playbook-steps.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/06-every-path-in-the-reply.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/graders/07-the-drafts-open-questions-section.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/drafts-intent-and-waits-for-yes/prompt.md +10 -0
- package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/case.yaml +33 -0
- package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/01-the-reply-shows-the-reproduction-output.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/02-the-reply-names-one-cause-with-evidence.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/03-the-plan-has-the-four-headings.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/04-no-file-or-commit.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/graders/05-the-reply-names-the-gate.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/fix-reproduces-finds-the-cause-and-waits-for-the-engineer/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/case.yaml +11 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/01-the-first-message-restates-the.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/02-the-reply-asks-numbered-questions.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/03-no-intentmd-is-written-before.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/04-reply-lists-the-playbook-steps.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/05-no-commit-is-made-nothing.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/07-every-path-in-the-reply.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/graders/08-no-question-in-the-round.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/interviews-before-writing-intent/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/case.yaml +14 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/01-the-intent-has-the-five-headings.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/02-problem-holds-the-error-and-where.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/03-unverified-cause-is-an-open-question.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/04-no-source-file-is-changed.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/05-a-branch-is-pushed-and-the-gate-is-named.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/graders/06-the-person-is-asked-no-question.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/maintain-writes-intent-from-a-finding/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/case.yaml +11 -0
- package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/01-the-reply-follows-the-plan.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/02-the-reply-does-not-tell.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/03-no-file-is-created-or.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/graders/05-every-path-in-the-reply.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/reported-problem-starts-the-plan/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/case.yaml +10 -0
- package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/graders/01-no-file-is-written-and-nothing-is-committed.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/graders/02-the-reply-names-the-closest-row.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/graders/03-the-reply-is-short.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/request-that-fits-no-row/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/case.yaml +79 -0
- package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/01-findings-have-pass-severity-and-location.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/02-no-approval-and-no-merge.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/03-the-reply-names-the-code-owner-gate.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/04-nothing-is-pushed-or-changed.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/graders/05-the-review-runs-in-a-fresh-context.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/review-reports-findings-and-never-merges/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/case.yaml +41 -0
- package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/01-no-spec-is-written.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/02-no-commit-is-made.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/03-the-reply-says-not-merged.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/graders/05-the-reply-is-short.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/spec-for-unmerged-intent-stops/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/case.yaml +11 -0
- package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/01-no-intentmd-or-any-other.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/02-no-commit-is-made-nothing.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/04-every-path-in-the-reply.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/graders/05-the-reply-says-that-work.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/statement-without-command-starts-no-work/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/case.yaml +41 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/fixture.md +1 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/01-the-spec-has-the-five-headings.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/02-policies-has-one-line-per-policy.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/03-no-policy-is-called-unwritten.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/04-a-branch-is-pushed-for-review.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/05-the-reply-names-the-gate.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/07-no-merge-is-made.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/graders/08-the-four-policies-are-read-before-writing.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc/writes-spec-from-merged-intent/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/case.yaml +10 -0
- package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/01-the-reply-says-that-work.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/02-the-reply-gives-one-example.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/03-the-reply-lists-the-project.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/04-no-file-is-created-or.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/graders/06-every-path-in-the-reply.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/explains-how-to-start-work/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/case.yaml +9 -0
- package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/01-the-reply-says-that-this.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/02-the-reply-does-not-describe.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/03-no-file-is-created-or.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/graders/05-every-path-in-the-reply.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/unknown-playbook-is-not-invented/prompt.md +8 -0
- package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/case.yaml +10 -0
- package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/graders/01-the-reply-says-to-send.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/graders/02-no-file-is-created-or.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/graders/04-every-path-in-the-reply.md +7 -0
- package/templates/base/.agents/skill-evals/sdlc-help/work-request-is-sent-to-sdlc/prompt.md +8 -0
- package/templates/base/.agents/skills/sdlc/SKILL.md +25 -21
- package/templates/base/.agents/skills/sdlc/playbooks/build.md +14 -9
- package/templates/base/.agents/skills/sdlc/playbooks/deploy.md +2 -2
- package/templates/base/.agents/skills/sdlc/playbooks/design.md +3 -3
- package/templates/base/.agents/skills/sdlc/playbooks/fix.md +23 -0
- package/templates/base/.agents/skills/sdlc/playbooks/maintain.md +3 -4
- package/templates/base/.agents/skills/sdlc/playbooks/open-pr.md +3 -3
- package/templates/base/.agents/skills/sdlc/playbooks/plan.md +3 -3
- package/templates/base/.agents/skills/sdlc/playbooks/review.md +2 -2
- package/templates/base/.agents/skills/sdlc-help/SKILL.md +4 -2
- package/templates/base/.claude/managed-settings.example.json +2 -2
- package/templates/base/.claude/settings.json +0 -13
- package/templates/base/.claude/skills/sdlc/SKILL.md +25 -21
- package/templates/base/.claude/skills/sdlc/playbooks/build.md +14 -9
- package/templates/base/.claude/skills/sdlc/playbooks/deploy.md +2 -2
- package/templates/base/.claude/skills/sdlc/playbooks/design.md +3 -3
- package/templates/base/.claude/skills/sdlc/playbooks/fix.md +23 -0
- package/templates/base/.claude/skills/sdlc/playbooks/maintain.md +3 -4
- package/templates/base/.claude/skills/sdlc/playbooks/open-pr.md +3 -3
- package/templates/base/.claude/skills/sdlc/playbooks/plan.md +3 -3
- package/templates/base/.claude/skills/sdlc/playbooks/review.md +2 -2
- package/templates/base/.claude/skills/sdlc-help/SKILL.md +4 -2
- package/templates/base/.cursor/hooks.json +0 -6
- package/templates/base/.cursor/rules/no-suppression.mdc +1 -1
- package/templates/base/.cursor/skills/sdlc/SKILL.md +25 -21
- package/templates/base/.cursor/skills/sdlc/playbooks/build.md +14 -9
- package/templates/base/.cursor/skills/sdlc/playbooks/deploy.md +2 -2
- package/templates/base/.cursor/skills/sdlc/playbooks/design.md +3 -3
- package/templates/base/.cursor/skills/sdlc/playbooks/fix.md +23 -0
- package/templates/base/.cursor/skills/sdlc/playbooks/maintain.md +3 -4
- package/templates/base/.cursor/skills/sdlc/playbooks/open-pr.md +3 -3
- package/templates/base/.cursor/skills/sdlc/playbooks/plan.md +3 -3
- package/templates/base/.cursor/skills/sdlc/playbooks/review.md +2 -2
- package/templates/base/.cursor/skills/sdlc-help/SKILL.md +4 -2
- package/templates/base/.github/ISSUE_TEMPLATE/documentation.yml +1 -1
- package/templates/base/.github/dependabot.yml +1 -10
- package/templates/base/.github/file-filters.yml +3 -6
- package/templates/base/.github/workflows/agent-evals.yml +46 -0
- package/templates/base/.github/workflows/claude-fix-loop.yml +45 -0
- package/templates/base/.github/workflows/control-band.yml +115 -0
- package/templates/base/.github/workflows/dependency-review.yml +1 -0
- package/templates/base/.github/workflows/deploy.yml +4 -2
- package/templates/base/.github/workflows/osv-scanner.yml +0 -5
- package/templates/base/.github/workflows/pr-triage.yml +1 -1
- package/templates/base/.github/workflows/secret-scan.yml +4 -1
- package/templates/base/.github/workflows/spec-on-intent-merge.yml +108 -0
- package/templates/base/.github/workflows/validate-pr-title.yml +3 -2
- package/templates/base/.husky/pre-push +1 -8
- package/templates/base/REVIEW.md +2 -2
- package/templates/base/SETUP.md +44 -8
- package/templates/base/bands.yaml +3 -3
- package/templates/base/intent/README.md +1 -0
- package/templates/base/knip.json +2 -1
- package/templates/base/pnpm-workspace.yaml +1 -22
- package/templates/base/runbooks/rollback-deploy.md +27 -0
- package/templates/base/scripts/check-agent-gates.ts +265 -226
- package/templates/base/scripts/check-bands.ts +665 -0
- package/templates/base/scripts/check-plan.ts +1047 -0
- package/templates/base/scripts/measure-loop.ts +293 -0
- package/templates/base/scripts/run-agent-evals.ts +1197 -0
- package/templates/base/scripts/sync-agent-rules.ts +0 -8
- package/templates/nestjs/.dependency-cruiser.json +1 -1
- package/templates/nestjs/.github/ISSUE_TEMPLATE/bug.yml +5 -5
- package/templates/nestjs/.github/workflows/ci.yml +58 -9
- package/templates/nestjs/.github/workflows/sast.yml +0 -5
- package/templates/nestjs/AGENTS.md +17 -13
- package/templates/nestjs/CONTRIBUTING.md +3 -2
- package/templates/nestjs/Dockerfile +4 -4
- package/templates/nestjs/README.md +4 -6
- package/templates/nestjs/_gitignore +0 -1
- package/templates/nestjs/package.json +21 -19
- package/templates/nestjs/src/app.controller.test.ts +1 -0
- package/templates/nestjs/src/health/health.controller.test.ts +1 -0
- package/templates/nestjs/src/port.test.ts +1 -0
- package/templates/nestjs/test/app.e2e.test.ts +1 -0
- package/templates/nestjs/tsconfig.build.json +2 -3
- package/templates/nestjs/tsconfig.json +1 -3
- package/templates/nestjs/vitest.config.ts +0 -1
- package/templates/nestjs/vitest.e2e.config.ts +0 -1
- package/templates/nextjs/.dependency-cruiser.json +1 -1
- package/templates/nextjs/.github/ISSUE_TEMPLATE/bug.yml +1 -1
- package/templates/nextjs/.github/workflows/ci.yml +58 -9
- package/templates/nextjs/.github/workflows/sast.yml +0 -5
- package/templates/nextjs/AGENTS.md +17 -13
- package/templates/nextjs/CONTRIBUTING.md +3 -2
- package/templates/nextjs/Dockerfile +2 -2
- package/templates/nextjs/README.md +5 -3
- package/templates/nextjs/app/layout.tsx +2 -2
- package/templates/nextjs/app/page.test.ts +1 -1
- package/templates/nextjs/app/page.tsx +1 -1
- package/templates/nextjs/package.json +15 -14
- package/templates/nextjs/test/home.e2e.test.ts +3 -7
- package/templates/nextjs/tsconfig.json +0 -3
- package/templates/nodejs/.dependency-cruiser.json +1 -1
- package/templates/nodejs/.github/ISSUE_TEMPLATE/bug.yml +5 -5
- package/templates/nodejs/.github/workflows/ci.yml +58 -9
- package/templates/nodejs/.github/workflows/sast.yml +0 -5
- package/templates/nodejs/AGENTS.md +17 -12
- package/templates/nodejs/CONTRIBUTING.md +4 -3
- package/templates/nodejs/Dockerfile +3 -3
- package/templates/nodejs/README.md +4 -4
- package/templates/nodejs/_gitignore +0 -1
- package/templates/nodejs/package.json +13 -11
- package/templates/nodejs/src/server.test.ts +23 -0
- package/templates/nodejs/src/server.ts +2 -2
- package/templates/nodejs/tsconfig.build.json +2 -3
- package/templates/nodejs/tsconfig.json +0 -4
- package/templates/payload/.agents/agents/verifier.md +1 -1
- package/templates/payload/.agents/review.md +21 -0
- package/templates/payload/.claude/agents/verifier.md +1 -1
- package/templates/payload/.cursor/agents/verifier.md +1 -1
- package/templates/payload/.dependency-cruiser.json +1 -1
- package/templates/payload/.github/ISSUE_TEMPLATE/bug.yml +1 -1
- package/templates/payload/.github/workflows/ci.yml +75 -10
- package/templates/payload/.github/workflows/sast.yml +0 -5
- package/templates/payload/AGENTS.md +22 -18
- package/templates/payload/CONTRIBUTING.md +4 -3
- package/templates/payload/Dockerfile +2 -2
- package/templates/payload/README.md +8 -4
- package/templates/payload/REVIEW.md +21 -0
- package/templates/payload/package.json +17 -15
- package/templates/payload/src/app/(frontend)/layout.tsx +2 -2
- package/templates/payload/src/app/(frontend)/page.test.ts +1 -1
- package/templates/payload/src/app/(frontend)/page.tsx +1 -1
- package/templates/payload/src/app/favicon.ico +0 -0
- package/templates/payload/src/app/health/route.test.ts +20 -2
- package/templates/payload/src/app/health/route.ts +9 -2
- package/templates/payload/src/collections/media.test.ts +36 -1
- package/templates/payload/src/collections/media.ts +1 -1
- package/templates/payload/src/payload.config.test.ts +15 -0
- package/templates/payload/test/home.e2e.test.ts +3 -7
- package/templates/payload/tsconfig.json +0 -1
- package/templates/turborepo/.agents/agents/verifier.md +1 -1
- package/templates/turborepo/.agents/review.md +21 -0
- package/templates/turborepo/.claude/agents/verifier.md +1 -1
- package/templates/turborepo/.cursor/agents/verifier.md +1 -1
- package/templates/turborepo/.dockerignore +9 -4
- package/templates/turborepo/.github/ISSUE_TEMPLATE/bug.yml +16 -1
- package/templates/turborepo/.github/dependabot.yml +1 -10
- package/templates/turborepo/.github/scripts/affected-apps.sh +5 -23
- package/templates/turborepo/.github/workflows/ci.yml +140 -103
- package/templates/turborepo/.github/workflows/deploy.yml +24 -23
- package/templates/turborepo/.github/workflows/sast.yml +0 -5
- package/templates/turborepo/AGENTS.md +22 -18
- package/templates/turborepo/CONTRIBUTING.md +6 -4
- package/templates/turborepo/README.md +8 -8
- package/templates/turborepo/REVIEW.md +21 -0
- package/templates/turborepo/apps/app/.dependency-cruiser.json +1 -1
- package/templates/turborepo/apps/app/AGENTS.md +9 -0
- package/templates/turborepo/apps/app/Dockerfile +2 -2
- package/templates/turborepo/apps/app/app/layout.tsx +2 -2
- package/templates/turborepo/apps/app/app/page.test.ts +1 -1
- package/templates/turborepo/apps/app/app/page.tsx +1 -1
- package/templates/turborepo/apps/app/package.json +9 -9
- package/templates/turborepo/apps/app/test/home.e2e.test.ts +3 -7
- package/templates/turborepo/apps/app/tsconfig.json +0 -3
- package/templates/turborepo/apps/backoffice/.dependency-cruiser.json +1 -1
- package/templates/turborepo/apps/backoffice/AGENTS.md +9 -0
- package/templates/turborepo/apps/backoffice/Dockerfile +2 -2
- package/templates/turborepo/apps/backoffice/package.json +12 -11
- package/templates/turborepo/apps/backoffice/src/app/(frontend)/layout.tsx +2 -2
- package/templates/turborepo/apps/backoffice/src/app/(frontend)/page.test.ts +1 -1
- package/templates/turborepo/apps/backoffice/src/app/(frontend)/page.tsx +1 -1
- package/templates/turborepo/apps/backoffice/src/app/favicon.ico +0 -0
- package/templates/turborepo/apps/backoffice/src/app/health/route.test.ts +20 -2
- package/templates/turborepo/apps/backoffice/src/app/health/route.ts +10 -2
- package/templates/turborepo/apps/backoffice/src/collections/media.test.ts +36 -1
- package/templates/turborepo/apps/backoffice/src/collections/media.ts +1 -1
- package/templates/turborepo/apps/backoffice/src/environment.test.ts +19 -0
- package/templates/turborepo/apps/backoffice/src/payload.config.test.ts +15 -0
- package/templates/turborepo/apps/backoffice/test/home.e2e.test.ts +3 -7
- package/templates/turborepo/apps/backoffice/tsconfig.json +0 -1
- package/templates/turborepo/knip.json +4 -2
- package/templates/turborepo/package.json +16 -16
- package/templates/turborepo/pnpm-workspace.yaml +1 -22
- package/templates/turborepo/turbo.json +32 -11
- package/templates/base/.agents/hooks/agent-suppression-gate.sh +0 -45
- package/templates/base/.claude/rules/agents-guide.md +0 -1
- package/templates/base/scripts/check-human-gates.ts +0 -383
- package/templates/base/scripts/check-intent.ts +0 -391
- package/templates/nextjs/knip.json +0 -4
- package/templates/payload/src/collections/users.test.ts +0 -10
- package/templates/turborepo/apps/backoffice/src/collections/users.test.ts +0 -10
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import {execFileSync} from "node:child_process";
|
|
2
|
+
import {existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {tmpdir} from "node:os";
|
|
5
|
+
|
|
6
|
+
type BranchChanges = {
|
|
7
|
+
changedPaths: string[];
|
|
8
|
+
headCommit: string;
|
|
9
|
+
mergeBase: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type SelfTestCase = {
|
|
13
|
+
expectedMessagePart?: string;
|
|
14
|
+
name: string;
|
|
15
|
+
problems: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const defaultBranch = "origin/main";
|
|
19
|
+
const intentDirectory = "intent";
|
|
20
|
+
const planFileName = "plan.md";
|
|
21
|
+
const specFileName = "spec.md";
|
|
22
|
+
const intentFileName = "intent.md";
|
|
23
|
+
const proofHeading = "## Proof";
|
|
24
|
+
const requirementsHeading = "## Requirements";
|
|
25
|
+
const acceptancePlaceholder = "@<engineer>";
|
|
26
|
+
const statusLineLimit = 5;
|
|
27
|
+
const acceptedByPrefix = "Accepted by: ";
|
|
28
|
+
const acceptedByPattern = /^Accepted by: @[\dA-Za-z][\dA-Za-z-]*\.$/;
|
|
29
|
+
const requiredHeadings = ["## Files that change", "## Order of work", "## Risks", proofHeading];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run git in the current directory.
|
|
33
|
+
* @param gitArguments Arguments after `git`.
|
|
34
|
+
* @returns Trimmed standard output.
|
|
35
|
+
*/
|
|
36
|
+
function runGit(gitArguments: string[]): string {
|
|
37
|
+
return execFileSync("git", gitArguments, {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
40
|
+
}).trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Find what this branch changed since it left the default branch.
|
|
45
|
+
* @returns The merge base, HEAD, and the changed paths, or undefined when the default branch is not available.
|
|
46
|
+
*/
|
|
47
|
+
function findBranchChanges(): BranchChanges | undefined {
|
|
48
|
+
try {
|
|
49
|
+
const mergeBase = runGit(["merge-base", "HEAD", defaultBranch]);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
mergeBase,
|
|
53
|
+
headCommit: runGit(["rev-parse", "HEAD"]),
|
|
54
|
+
changedPaths: runGit(["diff", "--name-only", `${mergeBase}...HEAD`])
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter(changedPath => changedPath !== ""),
|
|
57
|
+
};
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tell whether a plan must name its accepting engineer: the branch changes a file outside `intent/`.
|
|
65
|
+
* @param branchChanges What the branch changed, or undefined when no merge base was found.
|
|
66
|
+
* @returns False when there is no merge base or HEAD is the default branch tip.
|
|
67
|
+
*/
|
|
68
|
+
function requiresAcceptance(branchChanges: BranchChanges | undefined): boolean {
|
|
69
|
+
return (
|
|
70
|
+
branchChanges !== undefined &&
|
|
71
|
+
branchChanges.mergeBase !== branchChanges.headCommit &&
|
|
72
|
+
branchChanges.changedPaths.some(changedPath => !changedPath.startsWith(`${intentDirectory}/`))
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Say why the acceptance rule cannot run.
|
|
78
|
+
* @param branchChanges What the branch changed, or undefined when no merge base was found.
|
|
79
|
+
* @returns A problem when the default branch is not available, otherwise undefined.
|
|
80
|
+
*/
|
|
81
|
+
function describeMissingDefaultBranch(
|
|
82
|
+
branchChanges: BranchChanges | undefined,
|
|
83
|
+
): string | undefined {
|
|
84
|
+
return branchChanges === undefined
|
|
85
|
+
? `${defaultBranch} cannot be found, so the "${acceptedByPrefix}" rule cannot be applied; run "git fetch origin main" to apply it`
|
|
86
|
+
: undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Blank every line of a fenced code block, keeping line numbers.
|
|
91
|
+
* @param lines Document lines.
|
|
92
|
+
* @returns The lines with fenced ones emptied.
|
|
93
|
+
*/
|
|
94
|
+
function maskFencedBlocks(lines: string[]): string[] {
|
|
95
|
+
let isFenced = false;
|
|
96
|
+
|
|
97
|
+
return lines.map(line => {
|
|
98
|
+
const isFence = /^\s*(?:```|~~~)/.test(line);
|
|
99
|
+
const isMasked = isFenced || isFence;
|
|
100
|
+
|
|
101
|
+
isFenced = isFence ? !isFenced : isFenced;
|
|
102
|
+
|
|
103
|
+
return isMasked ? "" : line;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Split a document into LF-terminated lines without a byte order mark.
|
|
109
|
+
* @param text Document content.
|
|
110
|
+
* @returns The lines.
|
|
111
|
+
*/
|
|
112
|
+
function splitLines(text: string): string[] {
|
|
113
|
+
return text
|
|
114
|
+
.replace(/^\u{FEFF}/u, "")
|
|
115
|
+
.replaceAll("\r\n", "\n")
|
|
116
|
+
.split("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Find the lines of a level-2 section.
|
|
121
|
+
* @param maskedLines Document lines with fenced code blocks emptied.
|
|
122
|
+
* @param heading The section heading.
|
|
123
|
+
* @returns The first line after the heading and the line of the next level-2 heading, or undefined when the heading is absent.
|
|
124
|
+
*/
|
|
125
|
+
function findSectionBounds(maskedLines: string[], heading: string): [number, number] | undefined {
|
|
126
|
+
const start = maskedLines.findIndex(line => line.trimEnd() === heading);
|
|
127
|
+
|
|
128
|
+
if (start === -1) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const nextHeading = maskedLines.findIndex(
|
|
133
|
+
(line, index) => index > start && line.startsWith("## "),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return [start + 1, nextHeading === -1 ? maskedLines.length : nextHeading];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Collect the text of a level-2 section.
|
|
141
|
+
* @param maskedLines Document lines with fenced code blocks emptied.
|
|
142
|
+
* @param heading The section heading.
|
|
143
|
+
* @returns The lines under the heading up to the next level-2 heading, or undefined when the heading is absent.
|
|
144
|
+
*/
|
|
145
|
+
function readSection(maskedLines: string[], heading: string): string[] | undefined {
|
|
146
|
+
const bounds = findSectionBounds(maskedLines, heading);
|
|
147
|
+
|
|
148
|
+
return bounds === undefined ? undefined : maskedLines.slice(...bounds);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Quote each heading and join them for a message.
|
|
153
|
+
* @param headings Heading texts.
|
|
154
|
+
* @returns The quoted list, or "none" when there is no heading.
|
|
155
|
+
*/
|
|
156
|
+
function quoteHeadings(headings: string[]): string {
|
|
157
|
+
return headings.length === 0 ? "none" : headings.map(heading => `"${heading}"`).join(", ");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Find level-2 headings that differ from the template's, in count, spelling, or order.
|
|
162
|
+
* @param maskedLines Plan lines with fenced code blocks emptied.
|
|
163
|
+
* @returns At most one message.
|
|
164
|
+
*/
|
|
165
|
+
function findHeadingProblems(maskedLines: string[]): string[] {
|
|
166
|
+
const headings = maskedLines.map(line => line.trimEnd()).filter(line => line.startsWith("## "));
|
|
167
|
+
|
|
168
|
+
return headings.join("\n") === requiredHeadings.join("\n")
|
|
169
|
+
? []
|
|
170
|
+
: [
|
|
171
|
+
`the level-2 headings must be exactly, in order: ${quoteHeadings(requiredHeadings)}; found ${quoteHeadings(headings)}`,
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Find a missing or placeholder `Accepted by:` line when the branch changes a file outside `intent/`.
|
|
177
|
+
* @param maskedLines Plan lines with fenced code blocks emptied.
|
|
178
|
+
* @param isAcceptanceRequired Whether the branch changes a file outside `intent/`.
|
|
179
|
+
* @returns At most one message.
|
|
180
|
+
*/
|
|
181
|
+
function findAcceptanceProblems(maskedLines: string[], isAcceptanceRequired: boolean): string[] {
|
|
182
|
+
const acceptedLine = maskedLines.slice(1).find(line => line.trim() !== "");
|
|
183
|
+
|
|
184
|
+
return !isAcceptanceRequired ||
|
|
185
|
+
(acceptedLine !== undefined && acceptedByPattern.test(acceptedLine.trimEnd()))
|
|
186
|
+
? []
|
|
187
|
+
: [
|
|
188
|
+
`the plan has no "${acceptedByPrefix}<engineer>." line directly under its title, or it holds ${acceptancePlaceholder} or no GitHub login, and this branch changes a file outside ${intentDirectory}/; write the engineer who accepted the plan`,
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read the numbers of the requirements in a spec.
|
|
194
|
+
* @param specLines Spec lines.
|
|
195
|
+
* @returns Each distinct number of a top-level list item under `## Requirements`, in order.
|
|
196
|
+
*/
|
|
197
|
+
function readRequirementNumbers(specLines: string[]): number[] {
|
|
198
|
+
const numbers = (readSection(maskFencedBlocks(specLines), requirementsHeading) ?? [])
|
|
199
|
+
.map(line => /^(\d+)\. /.exec(line)?.at(1) ?? "")
|
|
200
|
+
.filter(digits => digits !== "")
|
|
201
|
+
.map(Number);
|
|
202
|
+
|
|
203
|
+
return [...new Set(numbers)];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read the label of a Proof line: the text before the first colon of a paragraph or list item, or the first cell of a table row.
|
|
208
|
+
* @param line One line of the Proof section.
|
|
209
|
+
* @returns The label, or an empty string when the line has none.
|
|
210
|
+
*/
|
|
211
|
+
function readProofLabel(line: string): string {
|
|
212
|
+
const text = line.trim();
|
|
213
|
+
|
|
214
|
+
if (text.startsWith("|")) {
|
|
215
|
+
return text.split("|", 2).at(1)?.trim() ?? "";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const item = text.replace(/^(?:[-*+]|\d+[.)])\s+/, "");
|
|
219
|
+
const colon = item.indexOf(":");
|
|
220
|
+
|
|
221
|
+
return colon === -1 ? "" : item.slice(0, colon).replaceAll(/[*_`]/g, "").trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Read the requirement numbers a label names: `1`, `1, 2, 8`, `1 and 2`, or a range such as `3–5`, `3-5`, or `3 to 5`, with an optional "Requirement" prefix.
|
|
226
|
+
* @param label The label of a Proof line.
|
|
227
|
+
* @param largestNumber The largest requirement number of the spec, which bounds a range.
|
|
228
|
+
* @returns The named numbers; empty when the label holds anything else, such as `1.2.3`.
|
|
229
|
+
*/
|
|
230
|
+
function readNamedNumbers(label: string, largestNumber: number): Set<number> {
|
|
231
|
+
const numbers = label.replace(/^requirements?\s+/i, "");
|
|
232
|
+
const named = new Set<number>();
|
|
233
|
+
|
|
234
|
+
if (numbers.replaceAll(/\d+|[–\-,\s]|\band\b|\bto\b/g, "") !== "") {
|
|
235
|
+
return named;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const tokens = Array.from(numbers.matchAll(/\d+|[–-]|\bto\b/g), ([token]) => token);
|
|
239
|
+
|
|
240
|
+
for (const [index, token] of tokens.entries()) {
|
|
241
|
+
const first = Number(token);
|
|
242
|
+
const last = Number(tokens.at(index + 2));
|
|
243
|
+
|
|
244
|
+
if (Number.isSafeInteger(first)) {
|
|
245
|
+
named.add(first);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
Number.isSafeInteger(first) &&
|
|
250
|
+
Number.isNaN(Number(tokens.at(index + 1))) &&
|
|
251
|
+
Number.isSafeInteger(last)
|
|
252
|
+
) {
|
|
253
|
+
for (let number = first; number <= Math.min(last, largestNumber); number += 1) {
|
|
254
|
+
named.add(number);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return named;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Find a spec that has no numbered requirement.
|
|
264
|
+
* @param specLines Spec lines.
|
|
265
|
+
* @returns At most one message.
|
|
266
|
+
*/
|
|
267
|
+
function findRequirementProblems(specLines: string[]): string[] {
|
|
268
|
+
return readRequirementNumbers(specLines).length > 0
|
|
269
|
+
? []
|
|
270
|
+
: [
|
|
271
|
+
`${specFileName} has no numbered requirement under "${requirementsHeading}"; write each requirement as a top-level "1." list item`,
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Find a Proof section that leaves a requirement of the spec unnamed in its line labels.
|
|
277
|
+
* @param maskedLines Plan lines with fenced code blocks emptied.
|
|
278
|
+
* @param specLines Spec lines.
|
|
279
|
+
* @returns At most one message.
|
|
280
|
+
*/
|
|
281
|
+
function findProofProblems(maskedLines: string[], specLines: string[]): string[] {
|
|
282
|
+
const proof = readSection(maskedLines, proofHeading);
|
|
283
|
+
|
|
284
|
+
if (proof === undefined) {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const requirements = readRequirementNumbers(specLines);
|
|
289
|
+
const largestNumber = Math.max(0, ...requirements);
|
|
290
|
+
|
|
291
|
+
const named = new Set(
|
|
292
|
+
proof.flatMap(line => [...readNamedNumbers(readProofLabel(line), largestNumber)]),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const missing = requirements.filter(number => !named.has(number));
|
|
296
|
+
|
|
297
|
+
return missing.length === 0
|
|
298
|
+
? []
|
|
299
|
+
: [
|
|
300
|
+
`the section "${proofHeading}" does not name requirement ${missing.join(", ")} of ${specFileName} in a line label; start each line with its number or numbers and a colon, such as "1, 2:" or "3–5:", or put them in the first table cell`,
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Find an empty Proof section in a plan that has no spec to prove.
|
|
306
|
+
* @param lines Plan lines.
|
|
307
|
+
* @param maskedLines Plan lines with fenced code blocks emptied.
|
|
308
|
+
* @returns At most one message.
|
|
309
|
+
*/
|
|
310
|
+
function findFixProofProblems(lines: string[], maskedLines: string[]): string[] {
|
|
311
|
+
const bounds = findSectionBounds(maskedLines, proofHeading);
|
|
312
|
+
|
|
313
|
+
return bounds === undefined || lines.slice(...bounds).some(line => line.trim() !== "")
|
|
314
|
+
? []
|
|
315
|
+
: [
|
|
316
|
+
`the section "${proofHeading}" is empty; name the test that fails today and passes once the cause is fixed`,
|
|
317
|
+
];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Tell whether a change folder holds a fix plan: a plan with neither an intent nor a spec.
|
|
322
|
+
* @param folder The `intent/<change>` folder.
|
|
323
|
+
* @returns True when the folder has no `intent.md` and no `spec.md`.
|
|
324
|
+
*/
|
|
325
|
+
function isFixPlanFolder(folder: string): boolean {
|
|
326
|
+
return (
|
|
327
|
+
!existsSync(path.join(folder, intentFileName)) && !existsSync(path.join(folder, specFileName))
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Find a missing spec, or a spec the plan does not prove.
|
|
333
|
+
* @param maskedLines Plan lines with fenced code blocks emptied.
|
|
334
|
+
* @param specText Content of the `spec.md` beside the plan, or undefined when there is none.
|
|
335
|
+
* @returns One message per problem.
|
|
336
|
+
*/
|
|
337
|
+
function findSpecProblems(maskedLines: string[], specText: string | undefined): string[] {
|
|
338
|
+
if (specText === undefined) {
|
|
339
|
+
return [`there is no ${specFileName} beside the plan; write the spec the plan builds`];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const specLines = splitLines(specText);
|
|
343
|
+
|
|
344
|
+
return [...findRequirementProblems(specLines), ...findProofProblems(maskedLines, specLines)];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Check one plan against the template in `.agents/skills/sdlc/playbooks/build.md` and the spec beside it.
|
|
349
|
+
* @param planText Plan content.
|
|
350
|
+
* @param specText Content of the `spec.md` beside the plan, or undefined when there is none.
|
|
351
|
+
* @param isAcceptanceRequired Whether the branch changes a file outside `intent/`.
|
|
352
|
+
* @param isFixPlan Whether the folder holds a fix plan, which has no spec to prove.
|
|
353
|
+
* @returns One message per problem.
|
|
354
|
+
*/
|
|
355
|
+
function checkPlan(
|
|
356
|
+
planText: string,
|
|
357
|
+
specText: string | undefined,
|
|
358
|
+
isAcceptanceRequired: boolean,
|
|
359
|
+
isFixPlan = false,
|
|
360
|
+
): string[] {
|
|
361
|
+
const lines = splitLines(planText);
|
|
362
|
+
const maskedLines = maskFencedBlocks(lines);
|
|
363
|
+
|
|
364
|
+
return [
|
|
365
|
+
...findHeadingProblems(maskedLines),
|
|
366
|
+
...findAcceptanceProblems(maskedLines, isAcceptanceRequired),
|
|
367
|
+
...(isFixPlan
|
|
368
|
+
? findFixProofProblems(lines, maskedLines)
|
|
369
|
+
: findSpecProblems(maskedLines, specText)),
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Tell whether a spec says `Status: done.` at the end of a line.
|
|
375
|
+
* @param specText Spec content.
|
|
376
|
+
* @returns True when the tree meets every requirement of the spec.
|
|
377
|
+
*/
|
|
378
|
+
function isDoneSpec(specText: string): boolean {
|
|
379
|
+
return maskFencedBlocks(splitLines(specText)).some(line =>
|
|
380
|
+
/Status: done\.$/.test(line.trimEnd()),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Find a done spec that has no plan.
|
|
386
|
+
* @param specText Spec content.
|
|
387
|
+
* @param hasPlan Whether the folder has a `plan.md`.
|
|
388
|
+
* @returns At most one message.
|
|
389
|
+
*/
|
|
390
|
+
function findMissingPlanProblems(specText: string, hasPlan: boolean): string[] {
|
|
391
|
+
return hasPlan || !isDoneSpec(specText)
|
|
392
|
+
? []
|
|
393
|
+
: [
|
|
394
|
+
`${specFileName} says "Status: done." and the folder has no ${planFileName}; write the plan that built the change`,
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Tell whether a document says `Status: accepted.` at the end of a line.
|
|
400
|
+
* @param text Intent or spec content.
|
|
401
|
+
* @returns True when the status line says accepted.
|
|
402
|
+
*/
|
|
403
|
+
function isAcceptedDocument(text: string): boolean {
|
|
404
|
+
return splitLines(text)
|
|
405
|
+
.slice(0, statusLineLimit)
|
|
406
|
+
.some(line => /Status: accepted\.$/.test(line.trimEnd()));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Find an intent or a spec that this branch marks accepted before its pull request merged.
|
|
411
|
+
* @param nextFileName The file of the next stage: `spec.md` for an intent, `plan.md` for a spec.
|
|
412
|
+
* @param defaultBranchText The file on the default branch, or undefined when it is not there.
|
|
413
|
+
* @param hasNextFile Whether the next stage's file is beside it.
|
|
414
|
+
* @returns At most one message.
|
|
415
|
+
*/
|
|
416
|
+
function findAcceptedStatusProblems(
|
|
417
|
+
nextFileName: string,
|
|
418
|
+
defaultBranchText: string | undefined,
|
|
419
|
+
hasNextFile: boolean,
|
|
420
|
+
): string[] {
|
|
421
|
+
if (defaultBranchText === undefined) {
|
|
422
|
+
return [
|
|
423
|
+
`the file says "Status: accepted." and is not on ${defaultBranch}; merging its pull request accepts it, so write "Status: draft."`,
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return hasNextFile || isAcceptedDocument(defaultBranchText)
|
|
428
|
+
? []
|
|
429
|
+
: [
|
|
430
|
+
`the file says "Status: accepted." and the folder has no ${nextFileName}; only the pull request that adds ${nextFileName} sets it, so write "Status: draft."`,
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const proofSpec = [
|
|
435
|
+
"# Spec: Health check answers 200",
|
|
436
|
+
"",
|
|
437
|
+
"From `intent/health/intent.md`. Status: accepted.",
|
|
438
|
+
"",
|
|
439
|
+
requirementsHeading,
|
|
440
|
+
"",
|
|
441
|
+
"1. A.",
|
|
442
|
+
"2. B.",
|
|
443
|
+
"3. C.",
|
|
444
|
+
"4. D.",
|
|
445
|
+
"5. E.",
|
|
446
|
+
"",
|
|
447
|
+
"## Design",
|
|
448
|
+
"",
|
|
449
|
+
"1. Not a requirement.",
|
|
450
|
+
"",
|
|
451
|
+
].join("\n");
|
|
452
|
+
|
|
453
|
+
const allUnnamedPart = "does not name requirement 1, 2, 3, 4, 5";
|
|
454
|
+
const headingsMessagePart = "level-2 headings";
|
|
455
|
+
const acceptanceRequiredProblem = "acceptance is required";
|
|
456
|
+
const doneSpec = proofSpec.replace("Status: accepted.", "Status: done.");
|
|
457
|
+
const placeholderLine = `${acceptedByPrefix}${acceptancePlaceholder}.`;
|
|
458
|
+
const engineerLine = `${acceptedByPrefix}@engineer.`;
|
|
459
|
+
|
|
460
|
+
type PlanParts = {
|
|
461
|
+
accepted?: string;
|
|
462
|
+
body?: string;
|
|
463
|
+
headings?: string[];
|
|
464
|
+
proof?: string;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Build a plan from its parts.
|
|
469
|
+
* @param parts The `Accepted by:` line, the text of each section but Proof, the headings, and the text of Proof.
|
|
470
|
+
* @returns The plan content.
|
|
471
|
+
*/
|
|
472
|
+
function buildPlan(parts: PlanParts): string {
|
|
473
|
+
const {
|
|
474
|
+
body = "Text.",
|
|
475
|
+
proof = "Requirements 1-5: proven.",
|
|
476
|
+
accepted = engineerLine,
|
|
477
|
+
headings = requiredHeadings,
|
|
478
|
+
} = parts;
|
|
479
|
+
|
|
480
|
+
return [
|
|
481
|
+
"# Plan: Health check answers 200",
|
|
482
|
+
"",
|
|
483
|
+
accepted,
|
|
484
|
+
"",
|
|
485
|
+
...headings.flatMap(heading => [heading, "", heading === proofHeading ? proof : body, ""]),
|
|
486
|
+
].join("\n");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Check a self-test plan against the self-test spec.
|
|
491
|
+
* @param planText Plan content.
|
|
492
|
+
* @param isAcceptanceRequired Whether the branch changes a file outside `intent/`.
|
|
493
|
+
* @returns One message per problem.
|
|
494
|
+
*/
|
|
495
|
+
function checkAgainstProofSpec(planText: string, isAcceptanceRequired = true): string[] {
|
|
496
|
+
return checkPlan(planText, proofSpec, isAcceptanceRequired);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const branchWithCode: BranchChanges = {
|
|
500
|
+
headCommit: "bbb",
|
|
501
|
+
mergeBase: "aaa",
|
|
502
|
+
changedPaths: ["intent/health/plan.md", "src/health.ts"],
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Create a change folder that holds the named files, for a self-test.
|
|
507
|
+
* @param fileNames The files to create in the folder.
|
|
508
|
+
* @returns The folder path.
|
|
509
|
+
*/
|
|
510
|
+
function createChangeFolder(fileNames: string[]): string {
|
|
511
|
+
const folder = mkdtempSync(path.join(tmpdir(), "plan-check-"));
|
|
512
|
+
|
|
513
|
+
for (const fileName of fileNames) {
|
|
514
|
+
writeFileSync(path.join(folder, fileName), "");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return folder;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const fixPlanFolder = createChangeFolder([planFileName]);
|
|
521
|
+
const intentOnlyFolder = createChangeFolder([intentFileName, planFileName]);
|
|
522
|
+
const specOnlyFolder = createChangeFolder([specFileName, planFileName]);
|
|
523
|
+
|
|
524
|
+
const selfTestCases: SelfTestCase[] = [
|
|
525
|
+
{
|
|
526
|
+
name: "a complete plan",
|
|
527
|
+
problems: checkAgainstProofSpec(buildPlan({})),
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
expectedMessagePart: headingsMessagePart,
|
|
531
|
+
name: "a heading is misspelled",
|
|
532
|
+
problems: checkAgainstProofSpec(
|
|
533
|
+
buildPlan({
|
|
534
|
+
headings: requiredHeadings.map(heading => heading.replace("Risks", "Risk")),
|
|
535
|
+
}),
|
|
536
|
+
),
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
expectedMessagePart: headingsMessagePart,
|
|
540
|
+
name: "headings are out of order",
|
|
541
|
+
problems: checkAgainstProofSpec(
|
|
542
|
+
buildPlan({
|
|
543
|
+
headings: requiredHeadings.toReversed(),
|
|
544
|
+
}),
|
|
545
|
+
),
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
name: "a heading inside a fenced block is ignored",
|
|
549
|
+
problems: checkAgainstProofSpec(
|
|
550
|
+
buildPlan({
|
|
551
|
+
body: "```\n## Extra\n```",
|
|
552
|
+
}),
|
|
553
|
+
),
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
expectedMessagePart: acceptedByPrefix,
|
|
557
|
+
name: "the accepting engineer is missing",
|
|
558
|
+
problems: checkAgainstProofSpec(
|
|
559
|
+
buildPlan({
|
|
560
|
+
accepted: "",
|
|
561
|
+
}),
|
|
562
|
+
),
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
expectedMessagePart: acceptedByPrefix,
|
|
566
|
+
name: "the accepting engineer is the placeholder",
|
|
567
|
+
problems: checkAgainstProofSpec(
|
|
568
|
+
buildPlan({
|
|
569
|
+
accepted: placeholderLine,
|
|
570
|
+
}),
|
|
571
|
+
),
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
expectedMessagePart: acceptedByPrefix,
|
|
575
|
+
name: "the accepting engineer is empty",
|
|
576
|
+
problems: checkAgainstProofSpec(
|
|
577
|
+
buildPlan({
|
|
578
|
+
accepted: `${acceptedByPrefix}.`,
|
|
579
|
+
}),
|
|
580
|
+
),
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
expectedMessagePart: acceptedByPrefix,
|
|
584
|
+
name: "the accepting line is not directly under the title",
|
|
585
|
+
problems: checkAgainstProofSpec(
|
|
586
|
+
buildPlan({
|
|
587
|
+
accepted: `From the spec.\n\n${engineerLine}`,
|
|
588
|
+
}),
|
|
589
|
+
),
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
name: "a placeholder is allowed when acceptance is not required",
|
|
593
|
+
problems: checkAgainstProofSpec(
|
|
594
|
+
buildPlan({
|
|
595
|
+
accepted: placeholderLine,
|
|
596
|
+
}),
|
|
597
|
+
false,
|
|
598
|
+
),
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
name: "the spec is missing",
|
|
602
|
+
expectedMessagePart: `no ${specFileName} beside the plan`,
|
|
603
|
+
problems: checkPlan(buildPlan({}), undefined, true),
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
expectedMessagePart: "does not name requirement 3, 5",
|
|
607
|
+
name: "Proof leaves requirements unnamed",
|
|
608
|
+
problems: checkAgainstProofSpec(
|
|
609
|
+
buildPlan({
|
|
610
|
+
proof: "Requirements 1, 2 and 4: proven.",
|
|
611
|
+
}),
|
|
612
|
+
),
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: "Proof names numbers alone and in a list",
|
|
616
|
+
problems: checkAgainstProofSpec(
|
|
617
|
+
buildPlan({
|
|
618
|
+
proof: "1, 2, 3 and 4: proven.\n\n5: proven.",
|
|
619
|
+
}),
|
|
620
|
+
),
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: "Proof names a range with an en dash",
|
|
624
|
+
problems: checkAgainstProofSpec(
|
|
625
|
+
buildPlan({
|
|
626
|
+
proof: "Requirements 1–5: proven.",
|
|
627
|
+
}),
|
|
628
|
+
),
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: "Proof names a range with a hyphen",
|
|
632
|
+
problems: checkAgainstProofSpec(
|
|
633
|
+
buildPlan({
|
|
634
|
+
proof: "Requirements 1-5: proven.",
|
|
635
|
+
}),
|
|
636
|
+
),
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
name: "Proof names a range with the word to",
|
|
640
|
+
problems: checkAgainstProofSpec(
|
|
641
|
+
buildPlan({
|
|
642
|
+
proof: "Requirements 1 to 5: proven.",
|
|
643
|
+
}),
|
|
644
|
+
),
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: "CRLF line endings",
|
|
648
|
+
problems: checkAgainstProofSpec(buildPlan({}).replaceAll("\n", "\r\n")),
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: "the spec has no numbered requirement",
|
|
652
|
+
expectedMessagePart: "has no numbered requirement",
|
|
653
|
+
problems: checkPlan(buildPlan({}), "## Requirements\n\nText.\n", true),
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
name: "a spec whose subsections restart numbering needs each number once",
|
|
657
|
+
problems: checkPlan(
|
|
658
|
+
buildPlan({
|
|
659
|
+
proof: "1, 2: proven.",
|
|
660
|
+
}),
|
|
661
|
+
"## Requirements\n\n### A\n\n1. A.\n2. B.\n\n### B\n\n1. C.\n2. D.\n",
|
|
662
|
+
true,
|
|
663
|
+
),
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
expectedMessagePart: allUnnamedPart,
|
|
667
|
+
name: "a version in a label does not count",
|
|
668
|
+
problems: checkAgainstProofSpec(
|
|
669
|
+
buildPlan({
|
|
670
|
+
proof: "Requirement 1.2.3: proven.",
|
|
671
|
+
}),
|
|
672
|
+
),
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
name: "a number after the colon does not count",
|
|
676
|
+
expectedMessagePart: "does not name requirement 2, 3, 4, 5",
|
|
677
|
+
problems: checkAgainstProofSpec(
|
|
678
|
+
buildPlan({
|
|
679
|
+
proof: "1: also covers 2, 3 and 4-5 on 2026-11-11.",
|
|
680
|
+
}),
|
|
681
|
+
),
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
expectedMessagePart: allUnnamedPart,
|
|
685
|
+
name: "a line without a label names nothing",
|
|
686
|
+
problems: checkAgainstProofSpec(
|
|
687
|
+
buildPlan({
|
|
688
|
+
proof: "Requirements 1-5 are proven.",
|
|
689
|
+
}),
|
|
690
|
+
),
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
name: "Proof names requirements with and in a list item",
|
|
694
|
+
problems: checkAgainstProofSpec(
|
|
695
|
+
buildPlan({
|
|
696
|
+
proof: "- Requirements 1 and 2: proven.\n- 3, 4, 5: proven.",
|
|
697
|
+
}),
|
|
698
|
+
),
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: "Proof names requirements in the first table cell",
|
|
702
|
+
problems: checkAgainstProofSpec(
|
|
703
|
+
buildPlan({
|
|
704
|
+
proof: "| Requirement | Check |\n| :- | :- |\n| 1–5 | proven |",
|
|
705
|
+
}),
|
|
706
|
+
),
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
name: "a huge range is bounded by the largest requirement number and does not throw",
|
|
710
|
+
problems: checkAgainstProofSpec(
|
|
711
|
+
buildPlan({
|
|
712
|
+
proof: "1 to 99999999999999: proven.",
|
|
713
|
+
}),
|
|
714
|
+
),
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
name: "a fix plan with no intent and no spec",
|
|
718
|
+
problems: checkPlan(buildPlan({}), undefined, true, isFixPlanFolder(fixPlanFolder)),
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
name: "a fix plan with an empty Proof",
|
|
722
|
+
expectedMessagePart: "is empty",
|
|
723
|
+
problems: checkPlan(
|
|
724
|
+
buildPlan({
|
|
725
|
+
proof: "",
|
|
726
|
+
}),
|
|
727
|
+
undefined,
|
|
728
|
+
true,
|
|
729
|
+
isFixPlanFolder(fixPlanFolder),
|
|
730
|
+
),
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: "a fix plan whose Proof holds only a fenced block",
|
|
734
|
+
problems: checkPlan(
|
|
735
|
+
buildPlan({
|
|
736
|
+
proof: "```\npnpm test\n```",
|
|
737
|
+
}),
|
|
738
|
+
undefined,
|
|
739
|
+
true,
|
|
740
|
+
isFixPlanFolder(fixPlanFolder),
|
|
741
|
+
),
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
expectedMessagePart: acceptedByPrefix,
|
|
745
|
+
name: "a fix plan with the placeholder engineer on a branch that changes code",
|
|
746
|
+
problems: checkPlan(
|
|
747
|
+
buildPlan({
|
|
748
|
+
accepted: placeholderLine,
|
|
749
|
+
}),
|
|
750
|
+
undefined,
|
|
751
|
+
requiresAcceptance(branchWithCode),
|
|
752
|
+
isFixPlanFolder(fixPlanFolder),
|
|
753
|
+
),
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
expectedMessagePart: headingsMessagePart,
|
|
757
|
+
name: "a fix plan with a misspelled heading",
|
|
758
|
+
problems: checkPlan(
|
|
759
|
+
buildPlan({
|
|
760
|
+
headings: requiredHeadings.map(heading => heading.replace("Risks", "Risk")),
|
|
761
|
+
}),
|
|
762
|
+
undefined,
|
|
763
|
+
true,
|
|
764
|
+
isFixPlanFolder(fixPlanFolder),
|
|
765
|
+
),
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
name: "a folder with an intent and a plan but no spec is not a fix plan",
|
|
769
|
+
expectedMessagePart: `no ${specFileName} beside the plan`,
|
|
770
|
+
problems: checkPlan(buildPlan({}), undefined, true, isFixPlanFolder(intentOnlyFolder)),
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
name: "a folder with a spec and a plan is not a fix plan",
|
|
774
|
+
problems: checkPlan(buildPlan({}), proofSpec, true, isFixPlanFolder(specOnlyFolder)),
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
name: "a missing default branch is reported",
|
|
778
|
+
expectedMessagePart: "cannot be found",
|
|
779
|
+
problems: [describeMissingDefaultBranch(undefined) ?? "no note"],
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
name: "no note when the default branch is found",
|
|
783
|
+
problems: describeMissingDefaultBranch(branchWithCode) === undefined ? [] : ["unexpected note"],
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
name: "a done spec has no plan",
|
|
787
|
+
expectedMessagePart: `no ${planFileName}`,
|
|
788
|
+
problems: findMissingPlanProblems(doneSpec, false),
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
name: "a done spec has a plan",
|
|
792
|
+
problems: findMissingPlanProblems(doneSpec, true),
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: "an accepted spec has no plan",
|
|
796
|
+
problems: findMissingPlanProblems(proofSpec, false),
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
name: "a branch with a code change requires acceptance",
|
|
800
|
+
problems: requiresAcceptance(branchWithCode) ? [] : ["acceptance is not required"],
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
name: "a branch that changes only intent files skips acceptance",
|
|
804
|
+
problems: requiresAcceptance({
|
|
805
|
+
...branchWithCode,
|
|
806
|
+
changedPaths: ["intent/health/plan.md"],
|
|
807
|
+
})
|
|
808
|
+
? [acceptanceRequiredProblem]
|
|
809
|
+
: [],
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
name: "no merge base skips acceptance",
|
|
813
|
+
problems: requiresAcceptance(undefined) ? [acceptanceRequiredProblem] : [],
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
name: "HEAD at the default branch tip skips acceptance",
|
|
817
|
+
problems: requiresAcceptance({
|
|
818
|
+
...branchWithCode,
|
|
819
|
+
headCommit: branchWithCode.mergeBase,
|
|
820
|
+
})
|
|
821
|
+
? [acceptanceRequiredProblem]
|
|
822
|
+
: [],
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
name: "accepted status on a file that is not on the default branch",
|
|
826
|
+
expectedMessagePart: `is not on ${defaultBranch}`,
|
|
827
|
+
problems: findAcceptedStatusProblems(specFileName, undefined, true),
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
name: "accepted status without the next stage's file",
|
|
831
|
+
expectedMessagePart: `has no ${specFileName}`,
|
|
832
|
+
problems: findAcceptedStatusProblems(specFileName, "Author: A (B). Status: draft.", false),
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
name: "accepted status set by the pull request that adds the next stage's file",
|
|
836
|
+
problems: findAcceptedStatusProblems(specFileName, "Author: A (B). Status: draft.", true),
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
name: "accepted status already on the default branch",
|
|
840
|
+
problems: findAcceptedStatusProblems(
|
|
841
|
+
planFileName,
|
|
842
|
+
"From `intent/health/intent.md`. Status: accepted.",
|
|
843
|
+
false,
|
|
844
|
+
),
|
|
845
|
+
},
|
|
846
|
+
];
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Tell whether a self-test case did not give the expected result.
|
|
850
|
+
* @param testCase The self-test case.
|
|
851
|
+
* @returns True when the case expects no message and got one, or expects exactly one message with its part and did not get it.
|
|
852
|
+
*/
|
|
853
|
+
function isSelfTestFailure(testCase: SelfTestCase): boolean {
|
|
854
|
+
return testCase.expectedMessagePart === undefined
|
|
855
|
+
? testCase.problems.length > 0
|
|
856
|
+
: testCase.problems.length !== 1 ||
|
|
857
|
+
!testCase.problems.at(0)?.includes(testCase.expectedMessagePart);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const selfTestFailures = selfTestCases
|
|
861
|
+
.filter(testCase => isSelfTestFailure(testCase))
|
|
862
|
+
.map(({name}) => name);
|
|
863
|
+
|
|
864
|
+
for (const folder of [fixPlanFolder, intentOnlyFolder, specOnlyFolder]) {
|
|
865
|
+
rmSync(folder, {
|
|
866
|
+
recursive: true,
|
|
867
|
+
force: true,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Read a spec when it exists.
|
|
873
|
+
* @param specPath Path of the `spec.md`.
|
|
874
|
+
* @returns The content, or undefined when the file does not exist.
|
|
875
|
+
*/
|
|
876
|
+
function readSpec(specPath: string): string | undefined {
|
|
877
|
+
return existsSync(specPath) ? readFileSync(specPath, "utf8") : undefined;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* List the intent folders of the working tree.
|
|
882
|
+
* @returns The path of each `intent/<change>` folder, sorted.
|
|
883
|
+
*/
|
|
884
|
+
function listChangeFolders(): string[] {
|
|
885
|
+
return existsSync(intentDirectory)
|
|
886
|
+
? readdirSync(intentDirectory, {
|
|
887
|
+
withFileTypes: true,
|
|
888
|
+
})
|
|
889
|
+
.filter(entry => entry.isDirectory())
|
|
890
|
+
.map(({name}) => path.join(intentDirectory, name))
|
|
891
|
+
.toSorted((first, second) => first.localeCompare(second))
|
|
892
|
+
: [];
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Report each done spec that has no plan.
|
|
897
|
+
* @param changeFolders The `intent/<change>` folders.
|
|
898
|
+
* @returns One line per problem.
|
|
899
|
+
*/
|
|
900
|
+
function findMissingPlanLines(changeFolders: string[]): string[] {
|
|
901
|
+
return changeFolders
|
|
902
|
+
.filter(folder => !existsSync(path.join(folder, planFileName)))
|
|
903
|
+
.flatMap(folder =>
|
|
904
|
+
findMissingPlanProblems(readSpec(path.join(folder, specFileName)) ?? "", false).map(
|
|
905
|
+
message => `${path.join(folder, planFileName)}: ${message}`,
|
|
906
|
+
),
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Check one existing plan against the spec beside it.
|
|
912
|
+
* @param planPath Path of the plan.
|
|
913
|
+
* @param isAcceptanceRequired Whether the branch changes a file outside `intent/`.
|
|
914
|
+
* @returns One line per problem.
|
|
915
|
+
*/
|
|
916
|
+
function findPlanFileLines(planPath: string, isAcceptanceRequired: boolean): string[] {
|
|
917
|
+
const folder = path.dirname(planPath);
|
|
918
|
+
|
|
919
|
+
return checkPlan(
|
|
920
|
+
readFileSync(planPath, "utf8"),
|
|
921
|
+
readSpec(path.join(folder, specFileName)),
|
|
922
|
+
isAcceptanceRequired,
|
|
923
|
+
isFixPlanFolder(folder),
|
|
924
|
+
).map(message => `${planPath}: ${message}`);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Check each plan and report its problems.
|
|
929
|
+
* @param planPaths The paths of the plans.
|
|
930
|
+
* @returns One line per problem.
|
|
931
|
+
*/
|
|
932
|
+
function findPlanLines(planPaths: string[]): string[] {
|
|
933
|
+
const branchChanges = planPaths.length > 0 ? findBranchChanges() : undefined;
|
|
934
|
+
|
|
935
|
+
const missingBranchMessage =
|
|
936
|
+
planPaths.length > 0 ? describeMissingDefaultBranch(branchChanges) : undefined;
|
|
937
|
+
|
|
938
|
+
const missingBranchLines = missingBranchMessage === undefined ? [] : [missingBranchMessage];
|
|
939
|
+
const isAcceptanceRequired = requiresAcceptance(branchChanges);
|
|
940
|
+
|
|
941
|
+
const missingLines = planPaths
|
|
942
|
+
.filter(planPath => !existsSync(planPath))
|
|
943
|
+
.map(
|
|
944
|
+
planPath =>
|
|
945
|
+
`${planPath}: the file does not exist; pass the path of an existing ${planFileName}`,
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
const checkedLines = planPaths
|
|
949
|
+
.filter(planPath => existsSync(planPath))
|
|
950
|
+
.filter(
|
|
951
|
+
planPath =>
|
|
952
|
+
branchChanges === undefined ||
|
|
953
|
+
readDefaultBranchFile(branchChanges.mergeBase, planPath) !==
|
|
954
|
+
readFileSync(planPath, "utf8").trim(),
|
|
955
|
+
)
|
|
956
|
+
.flatMap(planPath => findPlanFileLines(planPath, isAcceptanceRequired));
|
|
957
|
+
|
|
958
|
+
return [...missingBranchLines, ...missingLines, ...checkedLines];
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Read a file as the default branch has it.
|
|
963
|
+
* @param mergeBase The commit this branch left the default branch at.
|
|
964
|
+
* @param filePath Path from the repository root.
|
|
965
|
+
* @returns The content, or undefined when the default branch has no such file.
|
|
966
|
+
*/
|
|
967
|
+
function readDefaultBranchFile(mergeBase: string, filePath: string): string | undefined {
|
|
968
|
+
try {
|
|
969
|
+
return runGit(["show", `${mergeBase}:${filePath.split(path.sep).join("/")}`]);
|
|
970
|
+
} catch {
|
|
971
|
+
return undefined;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Report each intent and spec that this branch marks accepted before its pull request merged.
|
|
977
|
+
* @param changeFolders The `intent/<change>` folders.
|
|
978
|
+
* @returns One line per problem.
|
|
979
|
+
*/
|
|
980
|
+
function findAcceptedStatusLines(changeFolders: string[]): string[] {
|
|
981
|
+
const stages = [
|
|
982
|
+
[intentFileName, specFileName],
|
|
983
|
+
[specFileName, planFileName],
|
|
984
|
+
] as const;
|
|
985
|
+
|
|
986
|
+
const acceptedFiles = changeFolders.flatMap(folder =>
|
|
987
|
+
stages
|
|
988
|
+
.map(([fileName, nextFileName]) => ({
|
|
989
|
+
nextFileName,
|
|
990
|
+
filePath: path.join(folder, fileName),
|
|
991
|
+
nextFilePath: path.join(folder, nextFileName),
|
|
992
|
+
}))
|
|
993
|
+
.filter(
|
|
994
|
+
({filePath}) => existsSync(filePath) && isAcceptedDocument(readFileSync(filePath, "utf8")),
|
|
995
|
+
),
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
const branchChanges = acceptedFiles.length > 0 ? findBranchChanges() : undefined;
|
|
999
|
+
|
|
1000
|
+
if (branchChanges === undefined && acceptedFiles.length > 0) {
|
|
1001
|
+
return [
|
|
1002
|
+
`${defaultBranch} cannot be found, so the "Status: accepted." rule cannot be applied; run "git fetch origin main" to apply it`,
|
|
1003
|
+
];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return branchChanges === undefined
|
|
1007
|
+
? []
|
|
1008
|
+
: acceptedFiles.flatMap(({filePath, nextFileName, nextFilePath}) =>
|
|
1009
|
+
findAcceptedStatusProblems(
|
|
1010
|
+
nextFileName,
|
|
1011
|
+
readDefaultBranchFile(branchChanges.mergeBase, filePath),
|
|
1012
|
+
existsSync(nextFilePath),
|
|
1013
|
+
).map(message => `${filePath}: ${message}`),
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (selfTestFailures.length > 0) {
|
|
1018
|
+
const failureLines = selfTestFailures.map(name => `- ${name}`);
|
|
1019
|
+
|
|
1020
|
+
process.stderr.write(`Plan self-tests failed:\n${failureLines.join("\n")}\n`);
|
|
1021
|
+
process.exitCode = 1;
|
|
1022
|
+
} else {
|
|
1023
|
+
const argumentPaths = process.argv.slice(2);
|
|
1024
|
+
const changeFolders = argumentPaths.length === 0 ? listChangeFolders() : [];
|
|
1025
|
+
|
|
1026
|
+
const planPaths =
|
|
1027
|
+
argumentPaths.length === 0
|
|
1028
|
+
? changeFolders
|
|
1029
|
+
.map(folder => path.join(folder, planFileName))
|
|
1030
|
+
.filter(planPath => existsSync(planPath))
|
|
1031
|
+
: argumentPaths;
|
|
1032
|
+
|
|
1033
|
+
const problemLines = [
|
|
1034
|
+
...findPlanLines(planPaths),
|
|
1035
|
+
...findMissingPlanLines(changeFolders),
|
|
1036
|
+
...findAcceptedStatusLines(changeFolders),
|
|
1037
|
+
];
|
|
1038
|
+
|
|
1039
|
+
if (problemLines.length > 0) {
|
|
1040
|
+
process.stderr.write(`${problemLines.join("\n")}\n`);
|
|
1041
|
+
process.exitCode = 1;
|
|
1042
|
+
} else {
|
|
1043
|
+
process.stdout.write(
|
|
1044
|
+
`Ran ${selfTestCases.length} plan self-tests and found no problem in ${planPaths.length} plans.\n`,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
}
|