From 359f49d3c9020c1af355c88a9fb107d0208e4f74 Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 16 Sep 2026 17:56:51 -0700 Subject: [PATCH] Turn drafter-core PR rules off unless CATSTACK_DRAFTER_CORE=1 Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I3014f5c0e39f0455d272929d35482b1dedc80557 --- .env.example | 6 ++++ engine/skills/draft-pr/SKILL.md | 7 ++++ .../draft-pr/scripts/drafter-core-flag.mjs | 35 +++++++++++++++++++ .../draft-pr/scripts/lint-diff-atomicity.mjs | 5 ++- .../draft-pr/scripts/pr-body-template.mjs | 10 ++++-- .../draft-pr/scripts/validate-pr-body.mjs | 16 +++++---- .../draft-pr/tests/test_draft_pr_scripts.py | 17 ++++++++- scripts/pr/validate-pr-body-local.mjs | 3 ++ tests/test_validate_pr_body_local.py | 16 +++++++-- 9 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 engine/skills/draft-pr/scripts/drafter-core-flag.mjs diff --git a/.env.example b/.env.example index 707d4e02..4e9b6f7e 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,9 @@ CATSTACK_CAT_MODE_DEFAULT=1 # Hooks read it on every run; the rule is installed or removed only when # ./install.sh runs. 0 (or absent) turns it off. CATSTACK_REFLECT_ENFORCEMENT=0 + +# drafter-core PR rules: the draft-pr scripts (validate-pr-body.mjs, +# lint-diff-atomicity.mjs, pr-body-template.mjs) run the +# @neko-catpital-labs/drafter-core rules only when this is on. While off they +# print "UNCHECKED: drafter-core rules skipped". 0 (or absent) turns it off. +CATSTACK_DRAFTER_CORE=0 diff --git a/engine/skills/draft-pr/SKILL.md b/engine/skills/draft-pr/SKILL.md index b22cce71..f6d029a3 100644 --- a/engine/skills/draft-pr/SKILL.md +++ b/engine/skills/draft-pr/SKILL.md @@ -253,6 +253,13 @@ in the repo you're drafting PRs for (`npm install --save-dev @neko-catpital-labs/drafter-core`) so Node can resolve the import — a globally installed skill copy can't resolve a bare import on its own. +In catstack these rules are off unless `CATSTACK_DRAFTER_CORE=1` (same lookup +as the other catstack flags: env, `$CATSTACK_ENV_FILE`, repo `.env`, +`~/.catstack.env`). While it is off, each script prints `UNCHECKED: +drafter-core rules skipped` and skips those rules; `validate-pr-body.mjs` +still runs its own summary checks, and `pr-body-template.mjs` exits 3, so +write the body from the schema in this file. + 1. Branch from your canonical base remote (see `references/branching-workflow.md`). 2. Push the working branch to your publish remote. 3. Start from the canonical template and validate it: diff --git a/engine/skills/draft-pr/scripts/drafter-core-flag.mjs b/engine/skills/draft-pr/scripts/drafter-core-flag.mjs new file mode 100644 index 00000000..6e3cd707 --- /dev/null +++ b/engine/skills/draft-pr/scripts/drafter-core-flag.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const DRAFTER_CORE_FLAG = 'CATSTACK_DRAFTER_CORE'; +const FLAGS_PY = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'hooks', '_flags', 'flags.py'); + +export function drafterCoreFlag(cwd = process.cwd()) { + if (!existsSync(FLAGS_PY)) { + console.error(`drafter-core-flag: ${FLAGS_PY} not found; treating ${DRAFTER_CORE_FLAG} as off.`); + return 'unchecked'; + } + const res = spawnSync('python3', [FLAGS_PY, DRAFTER_CORE_FLAG, '--cwd', cwd], { encoding: 'utf-8' }); + if (res.error || res.status !== 0) { + const reason = res.error ? res.error.message : `exit ${res.status}: ${res.stderr.trim()}`; + console.error(`drafter-core-flag: could not look up ${DRAFTER_CORE_FLAG} (${reason}); treating it as off.`); + return 'unchecked'; + } + if (res.stderr) process.stderr.write(res.stderr); + return res.stdout.trim(); +} + +export function drafterCoreSkippedLine(state) { + return `UNCHECKED: drafter-core rules skipped (${DRAFTER_CORE_FLAG} is ${state}; set ${DRAFTER_CORE_FLAG}=1 to run them)`; +} + +export async function loadDrafterCore(cwd = process.cwd()) { + const state = drafterCoreFlag(cwd); + if (state !== 'on') { + console.log(drafterCoreSkippedLine(state)); + return null; + } + return import('@neko-catpital-labs/drafter-core'); +} diff --git a/engine/skills/draft-pr/scripts/lint-diff-atomicity.mjs b/engine/skills/draft-pr/scripts/lint-diff-atomicity.mjs index 73d167d3..183d40ff 100644 --- a/engine/skills/draft-pr/scripts/lint-diff-atomicity.mjs +++ b/engine/skills/draft-pr/scripts/lint-diff-atomicity.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { loadDrafterConfig, lintDiffAtomicityForGit, formatDiffAtomicityFindings } from '@neko-catpital-labs/drafter-core'; import { execFileSync } from 'node:child_process'; +import { loadDrafterCore } from './drafter-core-flag.mjs'; function usage() { console.error('Usage: node scripts/lint-diff-atomicity.mjs [--base ] [--root ] [--review-lane ] [--config ]'); @@ -51,6 +51,9 @@ async function main() { process.exit(2); } + const drafter = await loadDrafterCore(args.root); + if (!drafter) process.exit(0); + const { loadDrafterConfig, lintDiffAtomicityForGit, formatDiffAtomicityFindings } = drafter; const config = await loadDrafterConfig({ cwd: args.root, explicitPath: args.config || undefined }); const findings = lintDiffAtomicityForGit({ root: args.root, baseRef: base, reviewLane: args.reviewLane, config }); const fatal = findings.filter((f) => f.severity === 'fatal'); diff --git a/engine/skills/draft-pr/scripts/pr-body-template.mjs b/engine/skills/draft-pr/scripts/pr-body-template.mjs index 2d2332ed..e80b1eb3 100644 --- a/engine/skills/draft-pr/scripts/pr-body-template.mjs +++ b/engine/skills/draft-pr/scripts/pr-body-template.mjs @@ -1,10 +1,14 @@ #!/usr/bin/env node -import { loadDrafterConfig, renderPrBodyTemplate } from '@neko-catpital-labs/drafter-core'; +import { loadDrafterCore } from './drafter-core-flag.mjs'; + +const UNCHECKED_EXIT = 3; async function main() { const configPath = process.argv[2]; - const config = await loadDrafterConfig({ explicitPath: configPath || undefined }); - process.stdout.write(renderPrBodyTemplate(config)); + const drafter = await loadDrafterCore(); + if (!drafter) process.exit(UNCHECKED_EXIT); + const config = await drafter.loadDrafterConfig({ explicitPath: configPath || undefined }); + process.stdout.write(drafter.renderPrBodyTemplate(config)); } main(); diff --git a/engine/skills/draft-pr/scripts/validate-pr-body.mjs b/engine/skills/draft-pr/scripts/validate-pr-body.mjs index 70089d5a..d272aabf 100644 --- a/engine/skills/draft-pr/scripts/validate-pr-body.mjs +++ b/engine/skills/draft-pr/scripts/validate-pr-body.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; -import { loadDrafterConfig, validatePrBody, getPrBodyWarnings } from '@neko-catpital-labs/drafter-core'; +import { loadDrafterCore } from './drafter-core-flag.mjs'; import { scoreSummary, readingGradeError, @@ -45,11 +45,15 @@ async function main() { ? readFileSync(args.changedFilesFile, 'utf-8').split('\n').map((l) => l.trim()).filter(Boolean) : undefined; const diffText = args.diffFile ? readFileSync(args.diffFile, 'utf-8') : undefined; - const config = await loadDrafterConfig({ explicitPath: args.config || undefined }); - - const result = await validatePrBody(body, { requiresVisualProof: args.requiresVisualProof, changedFiles, diffText, config }); - const warnings = getPrBodyWarnings(body, { changedFiles, diffText, config }); - const errors = [...result.errors]; + const errors = []; + let warnings = []; + const drafter = await loadDrafterCore(); + if (drafter) { + const config = await drafter.loadDrafterConfig({ explicitPath: args.config || undefined }); + const result = await drafter.validatePrBody(body, { requiresVisualProof: args.requiresVisualProof, changedFiles, diffText, config }); + warnings = drafter.getPrBodyWarnings(body, { changedFiles, diffText, config }); + errors.push(...result.errors); + } const reading = scoreSummary(body); if (reading.status === 'hard') errors.push(readingGradeError(reading)); diff --git a/engine/skills/draft-pr/tests/test_draft_pr_scripts.py b/engine/skills/draft-pr/tests/test_draft_pr_scripts.py index 22b5b8a8..03c01670 100644 --- a/engine/skills/draft-pr/tests/test_draft_pr_scripts.py +++ b/engine/skills/draft-pr/tests/test_draft_pr_scripts.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +import os import subprocess import tempfile import unittest @@ -119,7 +120,10 @@ def _with_summary(summary: str) -> str: CODE_NAME_ERROR = "Summary and Review Claim must not use code names" -def _run_validator(body_text: str, changed_files: list[str] | None = None) -> subprocess.CompletedProcess: +SKIPPED = "UNCHECKED: drafter-core rules skipped (CATSTACK_DRAFTER_CORE is off" + + +def _run_validator(body_text: str, changed_files: list[str] | None = None, flag: str = "1") -> subprocess.CompletedProcess: with tempfile.TemporaryDirectory() as tmp: body_file = Path(tmp) / "body.md" body_file.write_text(body_text, encoding="utf-8") @@ -134,6 +138,7 @@ def _run_validator(body_text: str, changed_files: list[str] | None = None) -> su capture_output=True, text=True, timeout=30, + env={**os.environ, "CATSTACK_DRAFTER_CORE": flag}, ) @@ -143,6 +148,16 @@ def test_well_formed_body_passes(self): self.assertEqual(result.returncode, 0, result.stderr + result.stdout) self.assertIn("PR body validation passed", result.stdout) + def test_flag_off_skips_drafter_core_rules_and_says_so(self): + res = _run_validator(INVALID_BODY, flag="0") + self.assertEqual(res.returncode, 0, res.stdout + res.stderr) + self.assertIn(SKIPPED, res.stdout) + + def test_flag_off_still_runs_local_summary_rules(self): + res = _run_validator(_with_summary(HARD_SUMMARY), flag="0") + self.assertEqual(res.returncode, 1, res.stdout + res.stderr) + self.assertIn(SKIPPED, res.stdout) + def test_invalid_review_unit_fails_closed(self): result = _run_validator(INVALID_BODY) self.assertNotEqual(result.returncode, 0, result.stdout) diff --git a/scripts/pr/validate-pr-body-local.mjs b/scripts/pr/validate-pr-body-local.mjs index 93555bf7..d1f9b6ad 100644 --- a/scripts/pr/validate-pr-body-local.mjs +++ b/scripts/pr/validate-pr-body-local.mjs @@ -91,6 +91,9 @@ function main() { process.exit(UNCHECKED_EXIT); } echo(validator); + if (validator.status === 0 && (validator.stdout || '').includes('UNCHECKED: drafter-core rules skipped')) { + process.exit(UNCHECKED_EXIT); + } if (validator.status === null) { console.error(`validate-pr-body-local: ${VALIDATOR} was killed by ${validator.signal}`); process.exit(1); diff --git a/tests/test_validate_pr_body_local.py b/tests/test_validate_pr_body_local.py index c89292e7..73eac640 100644 --- a/tests/test_validate_pr_body_local.py +++ b/tests/test_validate_pr_body_local.py @@ -25,8 +25,12 @@ "scripts/pr/validate-pr-body-local.mjs", "engine/skills/make-pr/scripts/preflight.py", "engine/skills/draft-pr/scripts/validate-pr-body.mjs", + "engine/skills/draft-pr/scripts/drafter-core-flag.mjs", + "engine/skills/draft-pr/scripts/summary-reading-grade.mjs", + "engine/hooks/_flags/flags.py", ) UNCHECKED = "UNCHECKED: PR body rules not checked (drafter-core not installed)" +SKIPPED = "UNCHECKED: drafter-core rules skipped (CATSTACK_DRAFTER_CORE is off" GIT_ENV = { **os.environ, "GIT_AUTHOR_NAME": "t", @@ -35,6 +39,7 @@ "GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", + "CATSTACK_DRAFTER_CORE": "1", } @@ -66,13 +71,13 @@ def _commit(self, *paths: str) -> None: self._git("add", "-A") self._git("commit", "-qm", "case") - def _run(self, *args: str) -> subprocess.CompletedProcess: + def _run(self, *args: str, flag: str = "1") -> subprocess.CompletedProcess: return subprocess.run( ["node", "scripts/pr/validate-pr-body-local.mjs", *args], cwd=self.repo, capture_output=True, text=True, - env=GIT_ENV, + env={**GIT_ENV, "CATSTACK_DRAFTER_CORE": flag}, ) def test_mixed_review_units_fail_with_split(self): @@ -87,6 +92,13 @@ def test_one_unit_without_drafter_core_fails_as_unchecked(self): self.assertEqual(res.returncode, 3, res.stdout + res.stderr) self.assertIn(UNCHECKED, res.stdout) + def test_flag_off_skips_drafter_core_and_exits_unchecked(self): + self._commit("engine/hooks/x/detect.py") + res = self._run("--body-file", str(self.body), "--base", "main", flag="0") + self.assertEqual(res.returncode, 3, res.stdout + res.stderr) + self.assertIn(SKIPPED, res.stdout) + self.assertNotIn("ERR_MODULE_NOT_FOUND", res.stderr) + def test_checkout_without_unit_rules_fails_as_unchecked(self): self._git("rm", "-q", "drafter.config.json") self._commit("engine/hooks/x/detect.py")