Files
portal/docs/superpowers/plans/2026-05-16-regression-skill.md
T
Дмитрий 112cdc82cd 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

41 KiB
Raw Blame History

/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:

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-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.exebin/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:

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.