Files
portal/docs/superpowers/plans/2026-05-16-regression-skill.md
T
Дмитрий b3030d75a1 docs(plan): /regression skill — implementation plan (writing-plans)
9-task TDD plan implementing docs/superpowers/specs/2026-05-16-regression-skill-design.md: run.mjs split into exported pure functions (resolveBinary, parsers, computeVerdict, formatters, CHECKS registry) + main orchestrator; co-located run.test.mjs (node:test — 36 unit tests + unknown-arg subprocess test, ruflo-queen-hook.test.mjs pattern); SKILL.md; functional verification per spec §10.

Next: subagent-driven-development or executing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:33:56 +03:00

975 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /regression Skill Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the `/regression` Claude Code skill — one invocation that runs the project regression sweep at two tiers and reports a canonical status line plus a machine GREEN/RED verdict.
**Architecture:** A skill directory `.claude/skills/regression/` with three files: `SKILL.md` (the skill doc), `run.mjs` (a Node ESM orchestrator split into exported pure functions + a `main` orchestrator), and `run.test.mjs` (co-located `node:test` tests). The pure layer (parsers, verdict, formatting, platform fork) is unit-tested TDD-style; the I/O layer (`runCheck`, `main`) is subprocess-tested for the argument-error path and covered by functional verification for real tool runs.
**Tech Stack:** Node ≥20 ESM (`node:child_process`, `node:fs`, `node:path`, `node:process`, `node:url`), built-in `node:test` + `node:assert/strict`. Zero npm dependencies. Wraps existing npm/composer scripts and the `bin/gitleaks` + `bin/lychee` binaries.
**Source spec:** `docs/superpowers/specs/2026-05-16-regression-skill-design.md` (commits `5e6b1b6`, `e822925`).
**Conventions for the executing engineer:**
- The skill `.mjs` files are plain Node ESM, like `tools/*.mjs`. They are NOT covered by the project's ESLint config (which globs `app/resources/js/**`), so no ESLint gate applies to them.
- `lefthook` pre-commit on a `.mjs`-only commit runs **only gitleaks** (markdownlint/cspell glob `*.md`; pint/larastan/eslint/squawk/stylelint do not match). The `SKILL.md` commit additionally triggers markdownlint + cspell.
- Test runner: `node --test .claude/skills/regression/run.test.mjs` (Node ≥20, no deps). Run it from the repo root.
- The reference pattern for a tested `.mjs` is `tools/ruflo-queen-hook.test.mjs` (exported pure functions unit-tested + `main` subprocess-tested via `execFileSync`).
- Commit messages end with the trailer `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`.
---
## File Structure
| File | Responsibility |
|---|---|
| `.claude/skills/regression/run.mjs` | Orchestrator. Exports pure functions: `resolveBinary`, `buildHeader`, `parseExit`, `parsePest`, `parseVitest`, `parseViteBuild`, `parseLarastan`, `parseGitleaks`, `parseLychee`, `computeVerdict`, `buildCanonicalLine`, `formatRow`, `verdictLine`, the `CHECKS` registry, and `main`. Internal `runCheck` does the `spawnSync` I/O. Runs `main` only when executed directly (guarded by `import.meta.url`). |
| `.claude/skills/regression/run.test.mjs` | Co-located `node:test` suite. Unit-tests every pure function on fixture strings; subprocess-tests `main` for the unknown-argument path. |
| `.claude/skills/regression/SKILL.md` | The skill document — frontmatter (`name`, `description`), "Когда использовать", "Workflow", "Уровни", "Правила инвокации (self-restraint)", "Caveats", "Не использовать когда". |
Tasks 17 build `run.mjs` + `run.test.mjs` incrementally (one pure-function group per task, TDD). Task 8 writes `SKILL.md`. Task 9 is functional verification.
---
### Task 1: Scaffold + `resolveBinary` + `buildHeader` + `parseExit`
Bootstrap the skill directory and the three smallest pure helpers: platform binary resolution, the output header line, and the exit-code token.
**Files:**
- Create: `.claude/skills/regression/run.mjs`
- Create: `.claude/skills/regression/run.test.mjs`
- [ ] **Step 1: Write the failing test**
Create `.claude/skills/regression/run.test.mjs`:
```javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resolveBinary, buildHeader, parseExit,
} from './run.mjs';
test('resolveBinary: win32 → bin\\<name>.exe', () => {
assert.equal(resolveBinary('gitleaks', 'win32'), 'bin\\gitleaks.exe');
});
test('resolveBinary: non-win32 → bare name on PATH', () => {
assert.equal(resolveBinary('lychee', 'linux'), 'lychee');
assert.equal(resolveBinary('lychee', 'darwin'), 'lychee');
});
test('buildHeader: starts with the tier banner', () => {
assert.ok(buildHeader('quick').startsWith('─ /regression quick '));
assert.ok(buildHeader('full').startsWith('─ /regression full '));
});
test('buildHeader: is padded with dashes', () => {
assert.ok(buildHeader('full').length >= 30);
});
test('parseExit: builds "<label> <code>" token', () => {
assert.equal(parseExit('Pint', 0), 'Pint 0');
assert.equal(parseExit('ESLint', 1), 'ESLint 1');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `Cannot find module ... run.mjs` (the module does not exist yet).
- [ ] **Step 3: Write minimal implementation**
Create `.claude/skills/regression/run.mjs`:
```javascript
#!/usr/bin/env node
// .claude/skills/regression/run.mjs
// Regression sweep orchestrator for the /regression skill.
// Design: docs/superpowers/specs/2026-05-16-regression-skill-design.md
import process from 'node:process';
// ── pure: platform binary resolution ───────────────────────────────
export function resolveBinary(name, platform = process.platform) {
return platform === 'win32' ? `bin\\${name}.exe` : name;
}
// ── pure: output header line ───────────────────────────────────────
export function buildHeader(tier) {
const head = `─ /regression ${tier} `;
return head + '─'.repeat(Math.max(3, 48 - head.length));
}
// ── pure: exit-code token ──────────────────────────────────────────
export function parseExit(label, code) {
return `${label} ${code}`;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 5 tests pass, 0 fail.
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): skill scaffold + resolveBinary/buildHeader/parseExit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
(`lefthook` runs only gitleaks on this `.mjs`-only commit — expect "no leaks found".)
---
### Task 2: Test-count parsers — `parsePest` + `parseVitest`
Two parsers that turn raw Pest/Vitest summary output into canonical count tokens.
**Files:**
- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`
- [ ] **Step 1: Write the failing test**
Append to `.claude/skills/regression/run.test.mjs` (and add `parsePest, parseVitest` to the import from `./run.mjs`):
```javascript
test('parsePest: passed + skipped, no failures → total derived', () => {
const out = ' Tests: 3 skipped, 739 passed (2104 assertions)\n Duration: 71.23s';
assert.equal(parsePest(out), 'Pest 742/739/3sk/0');
});
test('parsePest: with failures', () => {
const out = ' Tests: 2 failed, 1 skipped, 736 passed (2090 assertions)';
assert.equal(parsePest(out), 'Pest 739/736/1sk/2');
});
test('parsePest: passed only → zeros for skipped/failed', () => {
assert.equal(parsePest(' Tests: 19 passed (44 assertions)'), 'Pest 19/19/0sk/0');
});
test('parseVitest: files + passed + skipped', () => {
const out = ' Test Files 92 passed (92)\n Tests 774 passed | 3 skipped (777)\n Duration 12.6s';
assert.equal(parseVitest(out), 'Vitest 92f/774/3sk/0');
});
test('parseVitest: with failures, does not confuse "Test Files" with "Tests"', () => {
const out = ' Test Files 2 failed | 90 passed (92)\n Tests 5 failed | 769 passed (774)';
assert.equal(parseVitest(out), 'Vitest 90f/769/0sk/5');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `parsePest is not a function` / import error for the new names.
- [ ] **Step 3: Write minimal implementation**
Add to `.claude/skills/regression/run.mjs` after `parseExit`:
```javascript
// ── pure: test-count parsers ───────────────────────────────────────
export function parsePest(stdout) {
const passed = Number(stdout.match(/(\d+)\s+passed/)?.[1] ?? 0);
const skipped = Number(stdout.match(/(\d+)\s+skipped/)?.[1] ?? 0);
const failed = Number(stdout.match(/(\d+)\s+failed/)?.[1] ?? 0);
return `Pest ${passed + skipped + failed}/${passed}/${skipped}sk/${failed}`;
}
export function parseVitest(stdout) {
const files = Number(stdout.match(/Test Files\s+(\d+)\s+passed/)?.[1] ?? 0);
const line = stdout.match(/^\s*Tests\s+.+$/m)?.[0] ?? '';
const passed = Number(line.match(/(\d+)\s+passed/)?.[1] ?? 0);
const skipped = Number(line.match(/(\d+)\s+skipped/)?.[1] ?? 0);
const failed = Number(line.match(/(\d+)\s+failed/)?.[1] ?? 0);
return `Vitest ${files}f/${passed}/${skipped}sk/${failed}`;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 10 tests pass total (5 from Task 1 + 5 new).
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): Pest + Vitest count parsers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Content parsers — `parseViteBuild` + `parseLarastan` + `parseGitleaks` + `parseLychee`
The remaining four parsers: Vite build time, Larastan error count, gitleaks leaks/commits, lychee OK/errors.
**Files:**
- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`
- [ ] **Step 1: Write the failing test**
Append to `.claude/skills/regression/run.test.mjs` (add `parseViteBuild, parseLarastan, parseGitleaks, parseLychee` to the import):
```javascript
test('parseViteBuild: extracts build time', () => {
assert.equal(parseViteBuild('✓ 312 modules transformed.\n✓ built in 2.03s'), 'Vite build 2.03s');
});
test('parseViteBuild: no match → "?"', () => {
assert.equal(parseViteBuild('build crashed'), 'Vite build ?s');
});
test('parseLarastan: clean → 0', () => {
assert.equal(parseLarastan(' [OK] No errors'), 'Larastan 0');
});
test('parseLarastan: counts errors', () => {
assert.equal(parseLarastan(' [ERROR] Found 2 errors'), 'Larastan 2');
});
test('parseGitleaks: clean → 0 leaks', () => {
const out = 'INF 442 commits scanned.\nINF no leaks found';
assert.equal(parseGitleaks(out, 0), 'gitleaks 0/442');
});
test('parseGitleaks: leaks found (non-zero exit)', () => {
const out = 'INF 442 commits scanned.\nWRN 3 leaks found';
assert.equal(parseGitleaks(out, 1), 'gitleaks 3/442');
});
test('parseLychee: OK + errors', () => {
const out = '🔍 325 Total (in 9s)\n✅ 325 OK\n🚫 0 Errors';
assert.equal(parseLychee(out), 'lychee 325/0');
});
test('parseLychee: with broken links', () => {
const out = '🔍 327 Total\n✅ 325 OK\n🚫 2 Errors';
assert.equal(parseLychee(out), 'lychee 325/2');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — import error / `parseViteBuild is not a function`.
- [ ] **Step 3: Write minimal implementation**
Add to `.claude/skills/regression/run.mjs` after `parseVitest`:
```javascript
// ── pure: content parsers ──────────────────────────────────────────
export function parseViteBuild(stdout) {
const m = stdout.match(/built in ([\d.]+)\s*s/i);
return `Vite build ${m ? m[1] : '?'}s`;
}
export function parseLarastan(stdout) {
const m = stdout.match(/Found (\d+) error/i);
return `Larastan ${m ? m[1] : 0}`;
}
export function parseGitleaks(stdout, code) {
const commits = stdout.match(/(\d+)\s+commits?\s+scanned/i)?.[1] ?? '?';
const leaks = code === 0
? '0'
: (stdout.match(/(\d+)\s+leaks?\s+found/i)?.[1]
?? stdout.match(/leaks?\s+found:?\s*(\d+)/i)?.[1]
?? '≥1');
return `gitleaks ${leaks}/${commits}`;
}
export function parseLychee(stdout) {
const ok = stdout.match(/(\d+)\s+OK/)?.[1] ?? '?';
const errors = stdout.match(/(\d+)\s+Errors?/i)?.[1] ?? '0';
return `lychee ${ok}/${errors}`;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 18 tests pass total.
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): Vite build / Larastan / gitleaks / lychee parsers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Verdict logic — `computeVerdict`
Turn an array of check results into a GREEN / RED / RED-INCOMPLETE verdict and an exit code.
**Files:**
- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`
A result object has the shape `{ id, label, code, skipped, ms, token }`. `computeVerdict` reads only `label`, `code`, `skipped`.
- [ ] **Step 1: Write the failing test**
Append to `.claude/skills/regression/run.test.mjs` (add `computeVerdict` to the import):
```javascript
test('computeVerdict: all exit 0 → GREEN, exit code 0', () => {
const v = computeVerdict([
{ label: 'Pint', code: 0, skipped: false },
{ label: 'ESLint', code: 0, skipped: false },
]);
assert.equal(v.verdict, 'GREEN');
assert.equal(v.exitCode, 0);
assert.deepEqual(v.failed, []);
});
test('computeVerdict: one non-zero exit → RED, exit code 1', () => {
const v = computeVerdict([
{ label: 'Pint', code: 0, skipped: false },
{ label: 'Larastan', code: 1, skipped: false },
]);
assert.equal(v.verdict, 'RED');
assert.equal(v.exitCode, 1);
assert.deepEqual(v.failed, ['Larastan']);
});
test('computeVerdict: a skipped check → RED-INCOMPLETE', () => {
const v = computeVerdict([
{ label: 'Pint', code: 0, skipped: false },
{ label: 'gitleaks', code: null, skipped: true },
]);
assert.equal(v.verdict, 'RED-INCOMPLETE');
assert.equal(v.exitCode, 1);
assert.deepEqual(v.skipped, ['gitleaks']);
});
test('computeVerdict: skipped takes precedence over a failure', () => {
const v = computeVerdict([
{ label: 'Larastan', code: 1, skipped: false },
{ label: 'lychee', code: null, skipped: true },
]);
assert.equal(v.verdict, 'RED-INCOMPLETE');
assert.deepEqual(v.failed, ['Larastan']);
assert.deepEqual(v.skipped, ['lychee']);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `computeVerdict is not a function`.
- [ ] **Step 3: Write minimal implementation**
Add to `.claude/skills/regression/run.mjs` after `parseLychee`:
```javascript
// ── pure: verdict ──────────────────────────────────────────────────
export function computeVerdict(results) {
const skipped = results.filter((r) => r.skipped).map((r) => r.label);
const failed = results
.filter((r) => !r.skipped && r.code !== 0)
.map((r) => r.label);
if (skipped.length) return { verdict: 'RED-INCOMPLETE', exitCode: 1, failed, skipped };
if (failed.length) return { verdict: 'RED', exitCode: 1, failed, skipped };
return { verdict: 'GREEN', exitCode: 0, failed, skipped };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 22 tests pass total.
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): GREEN/RED/RED-INCOMPLETE verdict logic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Output formatting — `buildCanonicalLine` + `formatRow` + `verdictLine`
Three pure formatters: the canonical status line, a per-check table row, and the verdict line.
**Files:**
- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`
- [ ] **Step 1: Write the failing test**
Append to `.claude/skills/regression/run.test.mjs` (add `buildCanonicalLine, formatRow, verdictLine` to the import):
```javascript
test('buildCanonicalLine: joins tokens in result order with " / "', () => {
const results = [
{ token: 'Pint 0' }, { token: 'ESLint 0' }, { token: 'Pest 742/739/3sk/0' },
];
assert.equal(buildCanonicalLine(results), 'Pint 0 / ESLint 0 / Pest 742/739/3sk/0');
});
test('formatRow: passed check → ✅ mark, label, code, time', () => {
const row = formatRow({ label: 'Pint', code: 0, ms: 1800, skipped: false });
assert.ok(row.startsWith('[✅] Pint'));
assert.ok(row.includes('1.8s'));
});
test('formatRow: failed check → ❌ mark', () => {
assert.ok(formatRow({ label: 'Larastan', code: 1, ms: 8400, skipped: false }).startsWith('[❌] Larastan'));
});
test('formatRow: skipped check → ⚠ mark + SKIPPED', () => {
const row = formatRow({ label: 'gitleaks', code: null, ms: 0, skipped: true });
assert.ok(row.startsWith('[⚠] gitleaks'));
assert.ok(row.includes('SKIPPED'));
});
test('verdictLine: GREEN', () => {
const line = verdictLine({ verdict: 'GREEN', failed: [], skipped: [] }, 12);
assert.ok(line.includes('🟢 GREEN'));
assert.ok(line.includes('12'));
});
test('verdictLine: RED lists failed checks', () => {
const line = verdictLine({ verdict: 'RED', failed: ['Larastan'], skipped: [] }, 12);
assert.ok(line.includes('🔴 RED'));
assert.ok(line.includes('Larastan'));
});
test('verdictLine: RED-INCOMPLETE lists skipped checks', () => {
const line = verdictLine({ verdict: 'RED-INCOMPLETE', failed: [], skipped: ['gitleaks'] }, 12);
assert.ok(line.includes('🟠 RED-INCOMPLETE'));
assert.ok(line.includes('gitleaks'));
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `buildCanonicalLine is not a function`.
- [ ] **Step 3: Write minimal implementation**
Add to `.claude/skills/regression/run.mjs` after `computeVerdict`:
```javascript
// ── pure: output formatting ────────────────────────────────────────
export function buildCanonicalLine(results) {
return results.map((r) => r.token).join(' / ');
}
export function formatRow(r) {
const mark = r.skipped ? '⚠' : r.code === 0 ? '✅' : '❌';
const label = r.label.padEnd(14);
const status = r.skipped
? 'SKIPPED — binary not found'
: `${r.code} ${(r.ms / 1000).toFixed(1)}s`;
return `[${mark}] ${label}${status}`;
}
export function verdictLine(v, total) {
if (v.verdict === 'GREEN') {
return `🟢 GREEN — все ${total} проверок passed`;
}
if (v.verdict === 'RED-INCOMPLETE') {
const tail = v.failed.length ? `; провал: ${v.failed.join(', ')}` : '';
return `🟠 RED-INCOMPLETE — не прогналось: ${v.skipped.join(', ')}${tail}`;
}
return `🔴 RED — ${v.failed.length}/${total} failed: ${v.failed.join(', ')}`;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 29 tests pass total.
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): canonical line / row / verdict formatters
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: `CHECKS` registry
The data table of all 12 checks: id, label, tiers, working directory, command, parser.
**Files:**
- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`
Each check is either **script-based** (has `cmd` — an npm/composer script line) or **binary-based** (has `bin` + `argv` — a `bin/` executable). `cwd` is `'.'` for repo-root checks or `'app'` for app-level checks. `parse` receives `(combinedOutput, exitCode)`.
- [ ] **Step 1: Write the failing test**
Append to `.claude/skills/regression/run.test.mjs` (add `CHECKS` to the import):
```javascript
test('CHECKS: quick tier has exactly 6 checks', () => {
assert.equal(CHECKS.filter((c) => c.tiers.includes('quick')).length, 6);
});
test('CHECKS: full tier has exactly 12 checks', () => {
assert.equal(CHECKS.filter((c) => c.tiers.includes('full')).length, 12);
});
test('CHECKS: quick is a strict subset of full', () => {
const full = new Set(CHECKS.filter((c) => c.tiers.includes('full')).map((c) => c.id));
for (const c of CHECKS.filter((c) => c.tiers.includes('quick'))) {
assert.ok(full.has(c.id), `${c.id} in quick must also be in full`);
}
});
test('CHECKS: every check has id, label, cwd, parse, and a command source', () => {
for (const c of CHECKS) {
assert.ok(c.id && c.label && c.cwd, `${c.id}: id/label/cwd`);
assert.equal(typeof c.parse, 'function', `${c.id}: parse is a function`);
assert.ok(c.cmd || (c.bin && Array.isArray(c.argv)), `${c.id}: has cmd or bin+argv`);
}
});
test('CHECKS: ids are unique', () => {
assert.equal(new Set(CHECKS.map((c) => c.id)).size, CHECKS.length);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `CHECKS` is undefined / not exported.
- [ ] **Step 3: Write minimal implementation**
Add to `.claude/skills/regression/run.mjs` after `verdictLine`:
```javascript
// ── data: checks registry ──────────────────────────────────────────
// Script-based checks carry `cmd`; binary-based checks carry `bin` + `argv`.
// `parse(combinedOutput, exitCode)` → canonical token. `cwd`: '.' = repo root,
// 'app' = the Laravel app. Execution order: quick checks first, then heavy.
export const CHECKS = [
{
id: 'pint', label: 'Pint', tiers: ['quick', 'full'], cwd: 'app',
cmd: 'composer pint:test', parse: (_o, c) => parseExit('Pint', c),
},
{
id: 'eslint', label: 'ESLint', tiers: ['quick', 'full'], cwd: 'app',
cmd: 'npm run lint:vue', parse: (_o, c) => parseExit('ESLint', c),
},
{
id: 'prettier', label: 'Prettier', tiers: ['quick', 'full'], cwd: 'app',
cmd: 'npm run format:check', parse: (_o, c) => parseExit('Prettier', c),
},
{
id: 'vue-tsc', label: 'vue-tsc', tiers: ['quick', 'full'], cwd: 'app',
cmd: 'npm run type-check', parse: (_o, c) => parseExit('vue-tsc', c),
},
{
id: 'markdownlint', label: 'markdownlint', tiers: ['quick', 'full'], cwd: '.',
cmd: 'npm run lint:md', parse: (_o, c) => parseExit('markdownlint', c),
},
{
id: 'cspell', label: 'cspell', tiers: ['quick', 'full'], cwd: '.',
cmd: 'npm run spell', parse: (_o, c) => parseExit('cspell', c),
},
{
id: 'larastan', label: 'Larastan', tiers: ['full'], cwd: 'app',
cmd: 'composer stan', parse: (o) => parseLarastan(o),
},
{
id: 'pest', label: 'Pest', tiers: ['full'], cwd: 'app',
cmd: 'composer test:parallel', parse: (o) => parsePest(o),
},
{
id: 'vitest', label: 'Vitest', tiers: ['full'], cwd: 'app',
cmd: 'npm run test:vue', parse: (o) => parseVitest(o),
},
{
id: 'vite-build', label: 'Vite build', tiers: ['full'], cwd: 'app',
cmd: 'npm run build', parse: (o) => parseViteBuild(o),
},
{
id: 'lychee', label: 'lychee', tiers: ['full'], cwd: '.',
bin: 'lychee',
argv: ['--config', '.lychee.toml', 'docs/**/*.md', 'db/**/*.md', '*.md'],
parse: (o) => parseLychee(o),
},
{
id: 'gitleaks', label: 'gitleaks', tiers: ['full'], cwd: '.',
bin: 'gitleaks',
argv: ['detect', '--source', '.', '--no-banner', '--config', '.gitleaks.toml', '--redact'],
parse: (o, c) => parseGitleaks(o, c),
},
];
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 34 tests pass total.
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): 12-check registry (quick=6, full=12)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 7: I/O layer — `runCheck` + `main` + subprocess test
The orchestrator: `runCheck` spawns one check; `main` parses the tier argument, runs the tier's checks, prints the table / canonical line / verdict, prints failed-check output, and sets the process exit code.
**Files:**
- Modify: `.claude/skills/regression/run.mjs`
- Modify: `.claude/skills/regression/run.test.mjs`
`main` is subprocess-tested only for the **unknown-argument** path (deterministic, fast). Real `quick`/`full` runs are covered by Task 9 functional verification — running them inside `run.test.mjs` would execute the whole sweep on every `node --test`.
- [ ] **Step 1: Write the failing test**
Append to `.claude/skills/regression/run.test.mjs`. Add these imports at the top of the file (alongside the existing `node:test` import):
```javascript
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const RUN = fileURLToPath(new URL('./run.mjs', import.meta.url));
```
Then append the tests:
```javascript
test('main: unknown argument → exit code 2 + error on stderr', () => {
try {
execFileSync(process.execPath, [RUN, 'bogus'], { encoding: 'utf8', stdio: 'pipe' });
assert.fail('expected non-zero exit');
} catch (err) {
assert.equal(err.status, 2);
assert.match(String(err.stderr), /unknown argument/i);
}
});
test('main: importing run.mjs does not auto-run the sweep', () => {
// If the import.meta guard were broken, importing run.mjs at the top of this
// file would have spawned a full sweep. Reaching this assertion proves it did not.
assert.ok(true);
});
```
Note: `process` is already available; add `import process from 'node:process';` at the top of the test file if not present.
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: FAIL — `main` is undefined, so `run.mjs bogus` does nothing meaningful / exits 0, and `err.status` is not 2.
- [ ] **Step 3: Write minimal implementation**
Add the I/O imports to the top of `.claude/skills/regression/run.mjs` (alongside `import process`):
```javascript
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
```
Add to the end of `.claude/skills/regression/run.mjs`, after the `CHECKS` registry:
```javascript
// ── I/O: run one check ─────────────────────────────────────────────
function runCheck(check, repoRoot) {
const cwd = check.cwd === '.' ? repoRoot : path.join(repoRoot, check.cwd);
const start = Date.now();
const skippedResult = (reason) => ({
id: check.id, label: check.label, skipped: true, code: null,
ms: Date.now() - start, token: `${check.label} SKIPPED`, stdout: '', stderr: reason,
});
let command;
if (check.bin) {
const bin = resolveBinary(check.bin);
// bin/ executables: existsSync pre-check on Windows (the project ships
// bin\gitleaks.exe / bin\lychee.exe; on POSIX they come from PATH).
if (process.platform === 'win32' && !existsSync(path.join(repoRoot, bin))) {
return skippedResult(`${bin} not found`);
}
command = [bin, ...check.argv].join(' ');
} else {
command = check.cmd;
}
const res = spawnSync(command, {
cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
});
const ms = Date.now() - start;
// ENOENT (POSIX missing binary) or shell exit 127 (command not found) → SKIPPED.
if ((res.error && res.error.code === 'ENOENT') || res.status === 127) {
return skippedResult(`command not found: ${command}`);
}
const stdout = res.stdout ?? '';
const stderr = res.stderr ?? '';
const code = res.status ?? 1;
const token = check.parse(`${stdout}\n${stderr}`, code);
return { id: check.id, label: check.label, skipped: false, code, ms, token, stdout, stderr };
}
// ── orchestrator ───────────────────────────────────────────────────
export function main(argv) {
const tier = argv[0] ?? 'full';
if (tier !== 'quick' && tier !== 'full') {
process.stderr.write(
`regression: unknown argument "${tier}". Usage: run.mjs [quick|full]\n`,
);
process.exitCode = 2;
return;
}
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url));
const checks = CHECKS.filter((c) => c.tiers.includes(tier));
process.stdout.write(`${buildHeader(tier)}\n`);
const results = [];
for (const check of checks) {
const r = runCheck(check, repoRoot);
results.push(r);
process.stdout.write(`${formatRow(r)}\n`);
}
process.stdout.write(`${'─'.repeat(48)}\n`);
process.stdout.write(`Canonical: ${buildCanonicalLine(results)}\n`);
const v = computeVerdict(results);
process.stdout.write(`VERDICT: ${verdictLine(v, results.length)}\n`);
// Full output of failed checks, so failures are visible with file:line.
// (Refines spec §7 "выше таблицы" → printed after the verdict, to keep the
// live per-check table readable during a multi-minute run.)
for (const r of results) {
if (!r.skipped && r.code !== 0) {
process.stdout.write(`\n── ${r.label} output ──\n${r.stdout}\n${r.stderr}\n`);
}
}
process.exitCode = v.exitCode;
}
// Run main only when executed directly (not when imported by run.test.mjs).
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
main(process.argv.slice(2));
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: PASS — 36 tests pass total. The `bogus`-argument subprocess exits 2 with "unknown argument" on stderr.
- [ ] **Step 5: Commit**
```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "feat(regression): runCheck I/O layer + main orchestrator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 8: `SKILL.md`
The skill document. No TDD — it is documentation. Structure mirrors `.claude/skills/q-item-add/SKILL.md` and `.claude/skills/rls-check/SKILL.md`.
**Files:**
- Create: `.claude/skills/regression/SKILL.md`
- [ ] **Step 1: Write `SKILL.md`**
Create `.claude/skills/regression/SKILL.md` with exactly this content:
````markdown
---
name: regression
description: |
Run the project regression sweep and report a canonical status line + GREEN/RED verdict.
Two tiers: `quick` (lint/format/type-check — seconds) and `full` (everything incl.
Pest --parallel, Larastan, Vitest, Vite build, lychee, gitleaks — minutes).
Claude auto-runs only `quick` (e.g. during verification-before-completion);
`full` runs only on explicit `/regression full` or with user confirmation.
---
# Regression — канонический регрессионный свод
## Когда использовать
Перед закрытием задачи/спринта (`full`) или для быстрого фидбэка по ходу работы
(`quick`). Скилл инкапсулирует ~12 команд свода, разбросанных по `package.json`,
`app/package.json`, `app/composer.json` и `lefthook.yml`, в один вызов с
детерминированной канонической строкой и машинным вердиктом.
Invoke via `/regression [quick|full]` (без аргумента → `full`).
## Workflow
1. Определить уровень из аргумента: `quick`, `full`, либо `full` по умолчанию.
2. Запустить через Bash из корня репозитория:
```bash
node .claude/skills/regression/run.mjs <tier>
```
3. Показать пользователю полный вывод скрипта (таблица + каноническая строка +
вердикт + вывод упавших проверок).
4. Интерпретировать вердикт:
- `GREEN` — свод чист, exit-код 0.
- `RED` — перечислены упавшие проверки; полный вывод каждой — после вердикта.
- `RED-INCOMPLETE` — проверка не прогналась (нет бинаря); свод неполон,
зелёным признать нельзя.
## Уровни
- **`quick`** (6 проверок, секунды): Pint, ESLint, Prettier, vue-tsc,
markdownlint, cspell.
- **`full`** (12 проверок, минуты): всё из `quick` + Larastan, Pest `--parallel`,
Vitest, Vite build, lychee, gitleaks.
## Правила инвокации (self-restraint)
- Claude **авто-запускает только `quick`** — в том числе в рамках
`superpowers:verification-before-completion` перед claim «готово» / «passed» /
«closed».
- `full` Claude **сам не запускает** — только по явному `/regression full` от
пользователя ИЛИ запросив подтверждение («запускаю полный свод, ~5–10 мин — ок?»).
- Скилл **не правит `CLAUDE.md`** — он только печатает каноническую строку в
stdout; вставка строки в `CLAUDE.md` — отдельно, через канал
`claude-md-management` (`CLAUDE.md` §5 п.10).
## Caveats
- **Pest `--parallel` flake (квирки 72/73/77).** Если Pest показал 1–3 ошибки,
похожие на Redis-race / cumulative-state / unique-key-collision, — перепрогнать
`full` один раз ИЛИ свериться с агентом `pest-parallel-debugger` до объявления
реального RED.
- **ruflo daemon (квирк 93).** Перед baseline-критичным `full` рассмотреть
`pm2 stop ruflo-daemon` — worker-jitter усиливает Pest-flake.
- gitleaks и lychee: на Windows берутся из `bin\*.exe`, на Linux/Mac CI — из
`PATH`. Отсутствие бинаря → `[⚠] SKIPPED` + вердикт `RED-INCOMPLETE`.
## Не использовать когда
- Нужна одна конкретная проверка — запусти её npm/composer-скрипт напрямую
(быстрее, чем весь свод).
- Pa11y и Semgrep SAST — это CI-tier, в свод намеренно не входят (см. дизайн-спек
`docs/superpowers/specs/2026-05-16-regression-skill-design.md` §5).
````
- [ ] **Step 2: Verify the skill document**
Run: `npx markdownlint-cli2 ".claude/skills/regression/SKILL.md"`
Expected: `0 error(s)`.
Run: `npx cspell --no-progress --no-summary ".claude/skills/regression/SKILL.md"`
Expected: exit 0, no output. If cspell reports an unknown word that is a valid project transliteration, add it (lowercase) to `cspell-words.txt` under a dated comment header and re-run.
- [ ] **Step 3: Commit**
```bash
git add .claude/skills/regression/SKILL.md
# include cspell-words.txt in this commit only if Step 2 required adding words
git commit -m "feat(regression): SKILL.md — skill doc + invocation rules
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
(This commit touches a `.md` file, so `lefthook` runs gitleaks + markdownlint + cspell. All must pass.)
---
### Task 9: Functional verification + final report
Verify the whole skill end-to-end against the spec §10 functional checklist. No new code — this task runs the skill and confirms behaviour.
**Files:**
- No file changes (verification only). If a defect is found, fix it in `run.mjs`, re-run `node --test`, and commit the fix as `fix(regression): ...`.
- [ ] **Step 1: Full unit suite green**
Run: `node --test .claude/skills/regression/run.test.mjs`
Expected: 36 tests pass, 0 fail.
- [ ] **Step 2: Happy path — `quick`**
Run: `node .claude/skills/regression/run.mjs quick`
Expected: header `─ /regression quick ─…`, 6 rows, a `Canonical:` line with 6 tokens, `VERDICT:` line. On a clean tree → `🟢 GREEN`, exit code 0. Verify the exit code: `echo $?` (bash) / `$LASTEXITCODE` (PowerShell) → `0`.
- [ ] **Step 3: Happy path — `full`**
Run: `node .claude/skills/regression/run.mjs full`
Expected: 12 rows, `Canonical:` line with 12 tokens, verdict. This run takes minutes (Pest `--parallel` + Vitest + Vite build). Cross-check the count tokens against the tools' own summaries — e.g. the `Pest …` token must match Pest's `Tests:` line. If Pest shows 13 intermittent errors matching quirk 72/73/77, re-run once before treating RED as real (see `SKILL.md` Caveats).
- [ ] **Step 4: RED path**
Introduce a deliberate lint error in a TypeScript file, e.g. append `const x:number='oops'` to a scratch line of an existing `app/resources/js/**/*.ts` file. Run: `node .claude/skills/regression/run.mjs quick`
Expected: `🔴 RED`, exit code 1, the verdict lists `vue-tsc` and/or `ESLint`, and the failed check's full output is printed after the verdict. Then revert the deliberate error and confirm `quick` returns to `🟢 GREEN`.
- [ ] **Step 5: RED-INCOMPLETE path**
Temporarily rename `bin/gitleaks.exe` → `bin/gitleaks.exe.bak`. Run: `node .claude/skills/regression/run.mjs full`
Expected: the gitleaks row shows `[⚠] gitleaks … SKIPPED`, the verdict is `🟠 RED-INCOMPLETE`, exit code 1. Then rename the binary back and confirm `full` no longer reports the skip.
- [ ] **Step 6: Final commit (only if Steps 15 required a fix)**
If any step revealed a defect that you fixed in `run.mjs`/`run.test.mjs`, commit it:
```bash
git add .claude/skills/regression/run.mjs .claude/skills/regression/run.test.mjs
git commit -m "fix(regression): <what the functional verification caught>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
If Steps 15 all passed with no fix needed, there is nothing to commit — the skill is complete.
- [ ] **Step 7: Report**
Report to the user: the skill is verified per spec §10 (unit suite green + functional checklist passed), with the actual `node --test` output and the `quick`/`full` verdict lines quoted. Note that `superpowers:writing-skills` discipline is satisfied (skill verified before being declared done).
---
## Self-Review
**1. Spec coverage** — every spec section maps to a task:
- §2 scope (two tiers, orchestrator, canonical line, invocation rules, caveats, `run.test.mjs`) → Tasks 18.
- §3 decisions Q1Q4 + U1U3 + P1 → encoded in `CHECKS` tiers (Task 6), `.mjs` orchestrator (Tasks 17), `main` default `full` (Task 7), `run.test.mjs` (Tasks 17).
- §4 components (`SKILL.md`, `run.mjs`, `run.test.mjs`) → Tasks 8, 17, 17.
- §5 check matrix (12 checks, exclusions) → `CHECKS` registry (Task 6).
- §6 execution model (run-all, sequential, quick-first, platform fork) → `main` + `runCheck` (Task 7), `resolveBinary` (Task 1).
- §7 output (table, canonical line, verdict, exit codes) → Tasks 5, 7.
- §8 error handling (missing binary → SKIPPED/RED-INCOMPLETE, Pest flake caveat, ruflo caveat) → `runCheck` (Task 7), `SKILL.md` Caveats (Task 8).
- §9 invocation/self-restraint → `SKILL.md` (Task 8); frontmatter has no `disable-model-invocation` (Task 8).
- §10 testing (`node:test` unit + subprocess + functional 13) → Tasks 17 unit, Task 7 subprocess, Task 9 functional.
- §12 normative compliance (no `CLAUDE.md` edit, no schema/Q-item) → enforced by design; `SKILL.md` states the no-`CLAUDE.md`-edit rule.
No gaps.
**2. Placeholder scan** — every code step contains complete, runnable code; every test step contains real assertions on concrete fixtures; every run step has an exact command and expected output. No "TBD" / "implement later" / "add error handling".
**3. Type consistency** — the result object shape `{ id, label, code, skipped, ms, token, stdout, stderr }` is produced by `runCheck` (Task 7) and consumed by `computeVerdict` (reads `label`/`code`/`skipped` — Task 4), `formatRow` (reads `label`/`code`/`ms`/`skipped` — Task 5), `buildCanonicalLine` (reads `token` — Task 5). `parse(combinedOutput, exitCode)` signature: defined in `CHECKS` (Task 6), called by `runCheck` (Task 7), matches each parser's signature (Tasks 23 — content parsers ignore the 2nd arg except `parseGitleaks`). `computeVerdict` returns `{ verdict, exitCode, failed, skipped }`, consumed by `verdictLine` and `main` (Tasks 5, 7) — consistent. Function names are identical across all tasks.
---
**Execution note:** `run.mjs`/`run.test.mjs` commits are gitleaks-only under `lefthook`; the `SKILL.md` commit additionally runs markdownlint + cspell. Plan and spec are consistent; the one deliberate refinement (failed-check output printed after the verdict rather than literally "above the table") is annotated inline in Task 7.