Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions engine/skills/draft-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions engine/skills/draft-pr/scripts/drafter-core-flag.mjs
Original file line number Diff line number Diff line change
@@ -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');
}
5 changes: 4 additions & 1 deletion engine/skills/draft-pr/scripts/lint-diff-atomicity.mjs
Original file line number Diff line number Diff line change
@@ -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 <ref>] [--root <path>] [--review-lane <lane>] [--config <file>]');
Expand Down Expand Up @@ -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');
Expand Down
10 changes: 7 additions & 3 deletions engine/skills/draft-pr/scripts/pr-body-template.mjs
Original file line number Diff line number Diff line change
@@ -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();
16 changes: 10 additions & 6 deletions engine/skills/draft-pr/scripts/validate-pr-body.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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));
Expand Down
17 changes: 16 additions & 1 deletion engine/skills/draft-pr/tests/test_draft_pr_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""
from __future__ import annotations

import os
import subprocess
import tempfile
import unittest
Expand Down Expand Up @@ -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")
Expand All @@ -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},
)


Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions scripts/pr/validate-pr-body-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 14 additions & 2 deletions tests/test_validate_pr_body_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -35,6 +39,7 @@
"GIT_COMMITTER_EMAIL": "t@t",
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"CATSTACK_DRAFTER_CORE": "1",
}


Expand Down Expand Up @@ -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):
Expand All @@ -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")
Expand Down
Loading