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>
41 KiB
/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
.mjsfiles are plain Node ESM, liketools/*.mjs. They are NOT covered by the project's ESLint config (which globsapp/resources/js/**), so no ESLint gate applies to them. lefthookpre-commit on a.mjs-only commit runs only gitleaks (markdownlint/cspell glob*.md; pint/larastan/eslint/squawk/stylelint do not match). TheSKILL.mdcommit 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
.mjsistools/ruflo-queen-hook.test.mjs(exported pure functions unit-tested +mainsubprocess-tested viaexecFileSync). - 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 1–7 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:
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:
#!/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
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):
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:
// ── 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
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):
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:
// ── 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
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):
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:
// ── 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
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):
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:
// ── 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
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):
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:
// ── 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
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):
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:
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):
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:
// ── 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
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:
---
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
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-runnode --test, and commit the fix asfix(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 1–3 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 1–5 required a fix)
If any step revealed a defect that you fixed in run.mjs/run.test.mjs, commit it:
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 1–5 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 1–8. - §3 decisions Q1–Q4 + U1–U3 + P1 → encoded in
CHECKStiers (Task 6),.mjsorchestrator (Tasks 1–7),maindefaultfull(Task 7),run.test.mjs(Tasks 1–7). - §4 components (
SKILL.md,run.mjs,run.test.mjs) → Tasks 8, 1–7, 1–7. - §5 check matrix (12 checks, exclusions) →
CHECKSregistry (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.mdCaveats (Task 8). - §9 invocation/self-restraint →
SKILL.md(Task 8); frontmatter has nodisable-model-invocation(Task 8). - §10 testing (
node:testunit + subprocess + functional 1–3) → Tasks 1–7 unit, Task 7 subprocess, Task 9 functional. - §12 normative compliance (no
CLAUDE.mdedit, no schema/Q-item) → enforced by design;SKILL.mdstates 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 2–3 — 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.