start-vibing-stacks 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +16 -2
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +217 -0
- package/dist/setup.js +10 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/claude-md-compactor.md +1 -0
- package/stacks/_shared/agents/commit-manager.md +1 -0
- package/stacks/_shared/agents/documenter.md +1 -0
- package/stacks/_shared/agents/domain-updater.md +1 -0
- package/stacks/_shared/agents/research-web.md +1 -0
- package/stacks/_shared/agents/security-auditor.md +168 -0
- package/stacks/_shared/agents/tester.md +1 -0
- package/stacks/_shared/hooks/final-check.ts +205 -0
- package/stacks/_shared/hooks/stop-validator.ts +77 -1
- package/stacks/_shared/skills/accessibility-wcag22/SKILL.md +284 -0
- package/stacks/_shared/skills/ci-pipelines/SKILL.md +166 -0
- package/stacks/_shared/skills/codebase-knowledge/SKILL.md +5 -0
- package/stacks/_shared/skills/database-migrations/SKILL.md +256 -0
- package/stacks/_shared/skills/debugging-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/docker-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/docs-tracker/SKILL.md +5 -0
- package/stacks/_shared/skills/error-handling/SKILL.md +335 -0
- package/stacks/_shared/skills/final-check/SKILL.md +74 -37
- package/stacks/_shared/skills/git-workflow/SKILL.md +5 -0
- package/stacks/_shared/skills/hook-development/SKILL.md +5 -0
- package/stacks/_shared/skills/observability/SKILL.md +351 -0
- package/stacks/_shared/skills/performance-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/playwright-automation/SKILL.md +5 -0
- package/stacks/_shared/skills/quality-gate/SKILL.md +5 -0
- package/stacks/_shared/skills/research-cache/SKILL.md +5 -0
- package/stacks/_shared/skills/secrets-management/SKILL.md +245 -0
- package/stacks/_shared/skills/security-baseline/SKILL.md +202 -0
- package/stacks/_shared/skills/test-coverage/SKILL.md +5 -0
- package/stacks/_shared/skills/ui-ux-audit/SKILL.md +5 -0
- package/stacks/frontend/react/skills/preline-ui/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-standards/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/shadcn-ui/SKILL.md +5 -0
- package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/zod-validation/SKILL.md +5 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +5 -0
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +5 -0
- package/stacks/nodejs/skills/api-security-node/SKILL.md +275 -0
- package/stacks/nodejs/skills/bun-runtime/SKILL.md +5 -0
- package/stacks/nodejs/skills/mongoose-patterns/SKILL.md +5 -0
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +5 -0
- package/stacks/nodejs/skills/trpc-api/SKILL.md +5 -0
- package/stacks/nodejs/skills/typescript-strict/SKILL.md +5 -0
- package/stacks/nodejs/stack.json +2 -1
- package/stacks/nodejs/workflows/ci.yml +90 -0
- package/stacks/nodejs/workflows/security.yml +45 -0
- package/stacks/php/skills/api-design/SKILL.md +5 -0
- package/stacks/php/skills/api-security/SKILL.md +5 -0
- package/stacks/php/skills/composer-workflow/SKILL.md +5 -0
- package/stacks/php/skills/external-api-patterns/SKILL.md +5 -0
- package/stacks/php/skills/inertia-react/SKILL.md +5 -0
- package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +5 -0
- package/stacks/php/skills/laravel-octane/SKILL.md +5 -0
- package/stacks/php/skills/laravel-patterns/SKILL.md +5 -0
- package/stacks/php/skills/mariadb-octane/SKILL.md +5 -0
- package/stacks/php/skills/php-patterns/SKILL.md +5 -0
- package/stacks/php/skills/phpstan-analysis/SKILL.md +5 -0
- package/stacks/php/skills/phpunit-testing/SKILL.md +5 -0
- package/stacks/php/skills/security-scan-php/SKILL.md +5 -0
- package/stacks/php/workflows/ci.yml +106 -0
- package/stacks/php/workflows/security.yml +36 -0
- package/stacks/python/skills/api-security-python/SKILL.md +312 -0
- package/stacks/python/skills/async-patterns/SKILL.md +5 -0
- package/stacks/python/skills/django-patterns/SKILL.md +5 -0
- package/stacks/python/skills/fastapi-patterns/SKILL.md +5 -0
- package/stacks/python/skills/pydantic-validation/SKILL.md +5 -0
- package/stacks/python/skills/pytest-testing/SKILL.md +5 -0
- package/stacks/python/skills/python-patterns/SKILL.md +5 -0
- package/stacks/python/skills/python-performance/SKILL.md +5 -0
- package/stacks/python/skills/scripting-automation/SKILL.md +5 -0
- package/stacks/python/stack.json +2 -1
- package/stacks/python/workflows/ci.yml +76 -0
- package/stacks/python/workflows/security.yml +56 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Final Check — executable validator with VETO.
|
|
4
|
+
*
|
|
5
|
+
* Scans staged + unstaged source files for forbidden patterns:
|
|
6
|
+
* - debug statements (console.log, var_dump, dd, print debug)
|
|
7
|
+
* - TODO/FIXME left in code
|
|
8
|
+
* - any-typed code (TypeScript)
|
|
9
|
+
* - hardcoded secrets
|
|
10
|
+
* - test .skip / .only
|
|
11
|
+
* - dangerous functions (RCE, command injection)
|
|
12
|
+
*
|
|
13
|
+
* Exits 0 if clean, 1 if any blocking finding. Output is human-readable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'child_process';
|
|
17
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
18
|
+
import { extname, join } from 'path';
|
|
19
|
+
|
|
20
|
+
const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
|
|
21
|
+
const ACTIVE_PROJECT = join(PROJECT_DIR, '.claude', 'config', 'active-project.json');
|
|
22
|
+
|
|
23
|
+
let stackId = 'unknown';
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(ACTIVE_PROJECT)) {
|
|
26
|
+
stackId = JSON.parse(readFileSync(ACTIVE_PROJECT, 'utf8')).stack || 'unknown';
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
const STACK_EXTENSIONS: Record<string, Set<string>> = {
|
|
31
|
+
php: new Set(['.php', '.blade.php']),
|
|
32
|
+
nodejs: new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs']),
|
|
33
|
+
python: new Set(['.py']),
|
|
34
|
+
};
|
|
35
|
+
const sourceExt = STACK_EXTENSIONS[stackId] || new Set(['.ts', '.js', '.php', '.py']);
|
|
36
|
+
|
|
37
|
+
interface Finding {
|
|
38
|
+
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
|
39
|
+
file: string;
|
|
40
|
+
line: number;
|
|
41
|
+
rule: string;
|
|
42
|
+
excerpt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const findings: Finding[] = [];
|
|
46
|
+
|
|
47
|
+
function git(...args: string[]): string {
|
|
48
|
+
const r = spawnSync('git', args, { cwd: PROJECT_DIR, encoding: 'utf8' });
|
|
49
|
+
return (r.stdout || '').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Paths that are always skipped (validator itself, generated, vendored, etc.)
|
|
53
|
+
const SKIP_PATH_RE = /(^|\/)(\.claude\/hooks|\.claude\/scripts|node_modules|dist|build|coverage|vendor|\.next|\.nuxt|venv|\.venv|stacks\/_shared\/hooks)\//;
|
|
54
|
+
|
|
55
|
+
function listFiles(): string[] {
|
|
56
|
+
const staged = git('diff', '--name-only', '--cached', '--diff-filter=ACMR').split('\n');
|
|
57
|
+
const unstaged = git('diff', '--name-only', '--diff-filter=ACMR').split('\n');
|
|
58
|
+
const untracked = git('ls-files', '--others', '--exclude-standard').split('\n');
|
|
59
|
+
const all = new Set<string>();
|
|
60
|
+
for (const f of [...staged, ...unstaged, ...untracked]) {
|
|
61
|
+
if (!f) continue;
|
|
62
|
+
if (SKIP_PATH_RE.test('/' + f)) continue;
|
|
63
|
+
if (!sourceExt.has(extname(f).toLowerCase())) continue;
|
|
64
|
+
const full = join(PROJECT_DIR, f);
|
|
65
|
+
if (!existsSync(full)) continue;
|
|
66
|
+
try {
|
|
67
|
+
if (statSync(full).isFile()) all.add(f);
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return [...all];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Rule {
|
|
74
|
+
re: RegExp;
|
|
75
|
+
rule: string;
|
|
76
|
+
severity: Finding['severity'];
|
|
77
|
+
appliesTo?: (path: string) => boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isTest = (p: string) => /\.(test|spec)\.[tj]sx?$|tests?\//i.test(p);
|
|
81
|
+
const isPhp = (p: string) => p.endsWith('.php');
|
|
82
|
+
const isPy = (p: string) => p.endsWith('.py');
|
|
83
|
+
const isJs = (p: string) => /\.(t|j)sx?$|\.mjs$/.test(p);
|
|
84
|
+
|
|
85
|
+
// Build dangerous-function regex via parts to avoid trivial lexical scans
|
|
86
|
+
const DANGEROUS_JS = new RegExp('\\b' + 'ev' + 'al' + '\\s*\\(');
|
|
87
|
+
const DANGEROUS_PY = new RegExp('\\b(?:' + 'ev' + 'al' + '|exec)\\s*\\(');
|
|
88
|
+
|
|
89
|
+
const RULES: Rule[] = [
|
|
90
|
+
// Debug statements
|
|
91
|
+
{ re: /\bconsole\.(log|debug|trace)\s*\(/, rule: 'console debug statement', severity: 'MEDIUM',
|
|
92
|
+
appliesTo: p => isJs(p) && !isTest(p) },
|
|
93
|
+
{ re: /\b(var_dump|dd|dump|print_r)\s*\(/, rule: 'PHP debug statement', severity: 'MEDIUM',
|
|
94
|
+
appliesTo: isPhp },
|
|
95
|
+
{ re: /^[^#]*\bprint\s*\(/m, rule: 'Python print() (use logger)', severity: 'LOW',
|
|
96
|
+
appliesTo: p => isPy(p) && !isTest(p) },
|
|
97
|
+
|
|
98
|
+
// Tests
|
|
99
|
+
{ re: /\b(it|test|describe)\.(only|skip)\s*\(/, rule: '.only / .skip in test', severity: 'HIGH',
|
|
100
|
+
appliesTo: p => /\.(test|spec)\./i.test(p) },
|
|
101
|
+
{ re: /@pytest\.mark\.skip\b/, rule: 'pytest skip marker', severity: 'MEDIUM',
|
|
102
|
+
appliesTo: isPy },
|
|
103
|
+
|
|
104
|
+
// TypeScript any
|
|
105
|
+
{ re: /:\s*any\b(?!\s*\/\*\s*ok)/, rule: 'explicit any (use unknown or proper type)', severity: 'MEDIUM',
|
|
106
|
+
appliesTo: p => /\.(ts|tsx)$/.test(p) },
|
|
107
|
+
{ re: /@ts-ignore/, rule: '@ts-ignore (use @ts-expect-error with comment)', severity: 'MEDIUM',
|
|
108
|
+
appliesTo: p => /\.(ts|tsx)$/.test(p) },
|
|
109
|
+
|
|
110
|
+
// TODO / FIXME — informational
|
|
111
|
+
{ re: /\b(TODO|FIXME|XXX|HACK)\b/, rule: 'unresolved TODO/FIXME', severity: 'LOW' },
|
|
112
|
+
|
|
113
|
+
// Secrets — high signal patterns
|
|
114
|
+
{ re: /(?:api[_-]?key|secret|token|bearer|password|aws_(?:access|secret)_key|private_key)\s*[:=]\s*["'][A-Za-z0-9/+=_\-.]{20,}["']/i,
|
|
115
|
+
rule: 'possible hardcoded secret', severity: 'CRITICAL' },
|
|
116
|
+
{ re: /(?:NEXT_PUBLIC|VITE|REACT_APP)_[A-Z_]*(?:SECRET|TOKEN|PRIVATE|PASSWORD|CREDENTIAL)/,
|
|
117
|
+
rule: 'public env var contains secret-like name', severity: 'CRITICAL' },
|
|
118
|
+
|
|
119
|
+
// Dangerous code execution
|
|
120
|
+
{ re: DANGEROUS_JS, rule: 'arbitrary code execution function — RCE risk', severity: 'HIGH',
|
|
121
|
+
appliesTo: p => isJs(p) || isPhp(p) },
|
|
122
|
+
{ re: DANGEROUS_PY, rule: 'arbitrary code execution function — RCE risk', severity: 'HIGH',
|
|
123
|
+
appliesTo: isPy },
|
|
124
|
+
{ re: /shell\s*=\s*True/, rule: 'subprocess shell=True (command injection)', severity: 'HIGH',
|
|
125
|
+
appliesTo: isPy },
|
|
126
|
+
|
|
127
|
+
// SQL string concat (rough)
|
|
128
|
+
{ re: /(SELECT|INSERT|UPDATE|DELETE)\s[^"']*?\+\s*[a-zA-Z_]/i,
|
|
129
|
+
rule: 'possible SQL string concatenation', severity: 'HIGH', appliesTo: p => isJs(p) || isPhp(p) },
|
|
130
|
+
{ re: /f["'][^"']*\b(SELECT|INSERT|UPDATE|DELETE)\b[^"']*\{/i,
|
|
131
|
+
rule: 'f-string SQL (use bind parameters)', severity: 'HIGH', appliesTo: isPy },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const SEVERITY_ORDER: Record<Finding['severity'], number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
135
|
+
const PLACEHOLDER_RE = /<\s*your[_\- ]|YOUR_[A-Z_]+|placeholder|example\.com|sk_test_|sk_xxx|xxxxxxxx/i;
|
|
136
|
+
|
|
137
|
+
function scan(file: string) {
|
|
138
|
+
const full = join(PROJECT_DIR, file);
|
|
139
|
+
let content: string;
|
|
140
|
+
try { content = readFileSync(full, 'utf8'); } catch { return; }
|
|
141
|
+
|
|
142
|
+
const lines = content.split('\n');
|
|
143
|
+
for (let i = 0; i < lines.length; i++) {
|
|
144
|
+
const line = lines[i] ?? '';
|
|
145
|
+
if (line.length > 1000) continue; // skip minified
|
|
146
|
+
if (PLACEHOLDER_RE.test(line)) continue; // example/template values
|
|
147
|
+
for (const r of RULES) {
|
|
148
|
+
if (r.appliesTo && !r.appliesTo(file)) continue;
|
|
149
|
+
if (r.re.test(line)) {
|
|
150
|
+
findings.push({
|
|
151
|
+
severity: r.severity,
|
|
152
|
+
file,
|
|
153
|
+
line: i + 1,
|
|
154
|
+
rule: r.rule,
|
|
155
|
+
excerpt: line.trim().slice(0, 160),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function main() {
|
|
163
|
+
const files = listFiles();
|
|
164
|
+
if (files.length === 0) {
|
|
165
|
+
console.log('Final check: no source files in current diff to scan.');
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const f of files) scan(f);
|
|
170
|
+
|
|
171
|
+
findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
172
|
+
|
|
173
|
+
const blocking = findings.filter(f => f.severity === 'CRITICAL' || f.severity === 'HIGH');
|
|
174
|
+
const warnings = findings.filter(f => f.severity === 'MEDIUM' || f.severity === 'LOW');
|
|
175
|
+
|
|
176
|
+
if (findings.length === 0) {
|
|
177
|
+
console.log(`Final check passed (${files.length} file${files.length === 1 ? '' : 's'} scanned, stack=${stackId}).`);
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`Final check report — stack=${stackId}, files=${files.length}\n`);
|
|
182
|
+
|
|
183
|
+
if (blocking.length > 0) {
|
|
184
|
+
console.log(`BLOCKING — ${blocking.length} critical/high finding${blocking.length === 1 ? '' : 's'}:\n`);
|
|
185
|
+
for (const f of blocking) {
|
|
186
|
+
console.log(` [${f.severity}] ${f.file}:${f.line}`);
|
|
187
|
+
console.log(` ${f.rule}`);
|
|
188
|
+
console.log(` > ${f.excerpt}`);
|
|
189
|
+
}
|
|
190
|
+
console.log('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (warnings.length > 0) {
|
|
194
|
+
console.log(`WARNINGS — ${warnings.length} medium/low finding${warnings.length === 1 ? '' : 's'}:\n`);
|
|
195
|
+
for (const f of warnings.slice(0, 20)) {
|
|
196
|
+
console.log(` [${f.severity}] ${f.file}:${f.line} ${f.rule}`);
|
|
197
|
+
}
|
|
198
|
+
if (warnings.length > 20) console.log(` ... and ${warnings.length - 20} more`);
|
|
199
|
+
console.log('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
process.exit(blocking.length > 0 ? 1 : 0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
main();
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* 3. CLAUDE.md not updated
|
|
10
10
|
* 4. CLAUDE.md missing required sections
|
|
11
11
|
* 5. CLAUDE.md exceeds 40k chars
|
|
12
|
+
* 6. Secret pattern detected in committed/staged files (uses gitleaks if present, fallback to regex)
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { execSync } from 'child_process';
|
|
@@ -65,6 +66,71 @@ function getModifiedFiles(): string[] {
|
|
|
65
66
|
return [...new Set([...staged, ...unstaged, ...untracked])];
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Run a command capturing stdout, stderr and exit code without throwing.
|
|
71
|
+
*/
|
|
72
|
+
function runCapture(command: string): { code: number; stdout: string; stderr: string } {
|
|
73
|
+
try {
|
|
74
|
+
const stdout = execSync(command, {
|
|
75
|
+
cwd: PROJECT_DIR,
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
return { code: 0, stdout: stdout.toString(), stderr: '' };
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
return {
|
|
82
|
+
code: typeof err?.status === 'number' ? err.status : 1,
|
|
83
|
+
stdout: err?.stdout?.toString?.() ?? '',
|
|
84
|
+
stderr: err?.stderr?.toString?.() ?? '',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Scan staged + unstaged diff for secret patterns.
|
|
91
|
+
* Uses gitleaks when installed; otherwise applies a high-signal regex sweep.
|
|
92
|
+
* Returns a list of findings (empty = clean).
|
|
93
|
+
*/
|
|
94
|
+
function scanSecrets(): string[] {
|
|
95
|
+
const findings: string[] = [];
|
|
96
|
+
|
|
97
|
+
const hasGitleaks = cmd('command -v gitleaks').length > 0;
|
|
98
|
+
if (hasGitleaks) {
|
|
99
|
+
const { code, stdout, stderr } = runCapture(
|
|
100
|
+
'gitleaks detect --no-banner --redact --exit-code 1 --log-level=error'
|
|
101
|
+
);
|
|
102
|
+
if (code !== 0) {
|
|
103
|
+
const out = stdout + '\n' + stderr;
|
|
104
|
+
const lines = out
|
|
105
|
+
.split('\n')
|
|
106
|
+
.filter(l => l.includes('Finding:') || l.includes('File:') || l.includes('Secret:'));
|
|
107
|
+
if (lines.length > 0) findings.push(...lines.slice(0, 20));
|
|
108
|
+
else findings.push('gitleaks reported leaks (run `gitleaks detect --redact -v` for details)');
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fallback: high-signal regex sweep over staged + unstaged diff
|
|
114
|
+
const SECRET_RE = /(?:api[_-]?key|secret|token|bearer|password|aws_(?:access|secret)_key|private_key)\s*[:=]\s*["'][A-Za-z0-9/+=_\-.]{16,}["']/i;
|
|
115
|
+
const PUBLIC_LEAK_RE = /(?:NEXT_PUBLIC|VITE|REACT_APP)_[A-Z_]*(?:SECRET|TOKEN|PRIVATE|PASSWORD|CREDENTIAL)/;
|
|
116
|
+
const PLACEHOLDER_RE = /<\s*your[_\- ]|YOUR_[A-Z_]+|placeholder|example\.com|sk_test_|sk_xxx|xxxxxxxx/i;
|
|
117
|
+
|
|
118
|
+
const diff = cmd('git diff --cached -U0') + '\n' + cmd('git diff -U0');
|
|
119
|
+
if (!diff.trim()) return findings;
|
|
120
|
+
|
|
121
|
+
const lines = diff.split('\n');
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue;
|
|
124
|
+
if (PLACEHOLDER_RE.test(line)) continue;
|
|
125
|
+
if (SECRET_RE.test(line) || PUBLIC_LEAK_RE.test(line)) {
|
|
126
|
+
const masked = line.replace(/(["']).{8,}\1/, '$1[REDACTED]$1').slice(0, 160);
|
|
127
|
+
findings.push(masked);
|
|
128
|
+
if (findings.length >= 5) break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return findings;
|
|
132
|
+
}
|
|
133
|
+
|
|
68
134
|
function validate(): HookResult {
|
|
69
135
|
const branch = getBranch();
|
|
70
136
|
const isMain = branch === 'main' || branch === 'master';
|
|
@@ -132,11 +198,21 @@ function validate(): HookResult {
|
|
|
132
198
|
};
|
|
133
199
|
}
|
|
134
200
|
|
|
201
|
+
// 5. Secret scan (gitleaks or regex fallback)
|
|
202
|
+
const secrets = scanSecrets();
|
|
203
|
+
if (secrets.length > 0) {
|
|
204
|
+
return {
|
|
205
|
+
continue: true,
|
|
206
|
+
decision: 'block',
|
|
207
|
+
reason: `BLOCKED: Potential secrets detected in diff:\n${secrets.map(s => ` - ${s}`).join('\n')}\n\nRotate the credential, remove from diff, and re-run.`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
135
211
|
// All good
|
|
136
212
|
return {
|
|
137
213
|
continue: false,
|
|
138
214
|
decision: 'approve',
|
|
139
|
-
reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean`,
|
|
215
|
+
reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean\nSecrets: clean`,
|
|
140
216
|
};
|
|
141
217
|
}
|
|
142
218
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: accessibility-wcag22
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: WCAG 2.2 AA checklist + axe-core / Playwright integration. Invoke when implementing UI features or auditing accessibility. Pairs with ui-ux-audit.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Accessibility — WCAG 2.2 AA
|
|
8
|
+
|
|
9
|
+
**ALWAYS invoke when implementing UI, forms, navigation, or any user-facing component.**
|
|
10
|
+
|
|
11
|
+
> Accessibility is a feature, not a polish step. Building it in costs ~10x less than retrofitting.
|
|
12
|
+
|
|
13
|
+
## Targeting WCAG 2.2 AA
|
|
14
|
+
|
|
15
|
+
WCAG 2.2 (October 2023) adds 9 new success criteria over 2.1. Key additions:
|
|
16
|
+
|
|
17
|
+
| New (2.2) | What | Most relevant when |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| 2.4.11 / 2.4.12 Focus Not Obscured | Focused element must not be fully hidden by sticky headers/dialogs | Sticky nav, modals |
|
|
20
|
+
| 2.4.13 Focus Appearance (AAA) | Focus indicator size/contrast | Custom focus styles |
|
|
21
|
+
| 2.5.7 Dragging Movements | Drag operations must have a non-drag alternative | Kanban, sliders, reordering |
|
|
22
|
+
| 2.5.8 Target Size (Min) | Targets ≥ 24×24 CSS px | Buttons, icon links, list items |
|
|
23
|
+
| 3.2.6 Consistent Help | Help mechanism in same location across pages | Multi-page apps |
|
|
24
|
+
| 3.3.7 / 3.3.8 Redundant Entry / Accessible Authentication | Don't require re-entering data; offer non-cognitive auth | Multi-step forms, login |
|
|
25
|
+
|
|
26
|
+
## The "Big 8" — Catches 80% of Issues
|
|
27
|
+
|
|
28
|
+
1. **Semantic HTML** — `<button>` not `<div onClick>`. `<a href>` for navigation, `<button>` for actions.
|
|
29
|
+
2. **Keyboard navigation** — every interactive thing reachable via Tab, activatable via Enter/Space.
|
|
30
|
+
3. **Focus indicator** — visible outline on focus. Don't `outline: none` without a replacement.
|
|
31
|
+
4. **Labels** — every form input has `<label for>` or `aria-label`.
|
|
32
|
+
5. **Color contrast** — text 4.5:1 (3:1 for ≥18pt or 14pt bold). Non-text UI 3:1.
|
|
33
|
+
6. **Alt text** — every meaningful image has `alt`; decorative images have `alt=""`.
|
|
34
|
+
7. **Heading order** — one `<h1>` per page; no skipping levels (h2 → h4).
|
|
35
|
+
8. **ARIA only when needed** — first rule of ARIA: don't use ARIA. Native HTML almost always wins.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## React + Semantic HTML
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
// WRONG
|
|
43
|
+
<div className="button" onClick={handleClick}>Submit</div>
|
|
44
|
+
|
|
45
|
+
// CORRECT
|
|
46
|
+
<button type="button" onClick={handleClick}>Submit</button>
|
|
47
|
+
|
|
48
|
+
// WRONG — visually a link, semantically a button
|
|
49
|
+
<a onClick={navigate}>Settings</a>
|
|
50
|
+
|
|
51
|
+
// CORRECT
|
|
52
|
+
<a href="/settings">Settings</a>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Accessible custom controls
|
|
56
|
+
If you must build a custom widget (Combobox, Disclosure, Tabs), follow the **WAI-ARIA Authoring Practices**: https://www.w3.org/WAI/ARIA/apg/patterns/
|
|
57
|
+
|
|
58
|
+
Better — use a headless library that already implements them:
|
|
59
|
+
- **Radix UI** (used by shadcn/ui)
|
|
60
|
+
- **React Aria** (Adobe)
|
|
61
|
+
- **Headless UI** (Tailwind Labs)
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Forms
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
<label htmlFor="email">Email</label>
|
|
69
|
+
<input
|
|
70
|
+
id="email"
|
|
71
|
+
type="email"
|
|
72
|
+
name="email"
|
|
73
|
+
required
|
|
74
|
+
aria-invalid={!!error}
|
|
75
|
+
aria-describedby={error ? 'email-error' : undefined}
|
|
76
|
+
autoComplete="email"
|
|
77
|
+
/>
|
|
78
|
+
{error && <p id="email-error" role="alert">{error}</p>}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Rules:
|
|
82
|
+
- Every input has a label (visible or `aria-label` for icon-only).
|
|
83
|
+
- `autoComplete` set for personal info fields (helps password managers + screen readers + 3.3.7).
|
|
84
|
+
- Error messages: `role="alert"` for live announcement OR `aria-describedby`.
|
|
85
|
+
- Don't disable submit while form is invalid; let user submit and show errors (better UX + a11y).
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Focus Management
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
// Modal opens → move focus into the modal; closes → restore to trigger
|
|
93
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
94
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (isOpen) dialogRef.current?.focus();
|
|
98
|
+
else triggerRef.current?.focus();
|
|
99
|
+
}, [isOpen]);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Use Radix / React Aria `Dialog` — they handle focus trap, escape, and `aria-modal` correctly.
|
|
103
|
+
|
|
104
|
+
### Skip links
|
|
105
|
+
```tsx
|
|
106
|
+
<a href="#main" className="sr-only focus:not-sr-only">Skip to content</a>
|
|
107
|
+
<main id="main" tabIndex={-1}>...</main>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Color & Contrast
|
|
113
|
+
|
|
114
|
+
```css
|
|
115
|
+
/* Background and foreground must hit 4.5:1 */
|
|
116
|
+
.btn-primary {
|
|
117
|
+
background: #2563eb; /* blue-600 */
|
|
118
|
+
color: #ffffff; /* 8.59:1 — passes AAA */
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Tools:
|
|
123
|
+
- Browser devtools have built-in contrast checkers
|
|
124
|
+
- `@axe-core/react` shows warnings inline during dev
|
|
125
|
+
- VS Code: a11y-themes / Color Picker extensions
|
|
126
|
+
|
|
127
|
+
**Never** use color alone to convey meaning (e.g. red border = error). Pair with icon + text.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Keyboard Testing — Manual
|
|
132
|
+
|
|
133
|
+
For every UI:
|
|
134
|
+
1. Tab through — every interactive thing is reachable
|
|
135
|
+
2. Tab order is logical (visual order matches DOM order)
|
|
136
|
+
3. Enter activates buttons/links; Space activates buttons (and toggles checkboxes)
|
|
137
|
+
4. Esc closes modals/menus
|
|
138
|
+
5. Arrow keys in radio groups, tabs, menus, listboxes
|
|
139
|
+
6. Focus is visible at every step
|
|
140
|
+
7. Focus is never lost or trapped (other than intentional in modals)
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Automated Testing — axe-core + Playwright
|
|
145
|
+
|
|
146
|
+
### Install
|
|
147
|
+
```bash
|
|
148
|
+
bun add -d @axe-core/playwright
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Per-page check
|
|
152
|
+
```ts
|
|
153
|
+
// tests/e2e/a11y.spec.ts
|
|
154
|
+
import { test, expect } from '@playwright/test';
|
|
155
|
+
import AxeBuilder from '@axe-core/playwright';
|
|
156
|
+
|
|
157
|
+
test.describe('a11y', () => {
|
|
158
|
+
for (const path of ['/', '/login', '/dashboard', '/settings']) {
|
|
159
|
+
test(`no axe violations on ${path}`, async ({ page }) => {
|
|
160
|
+
await page.goto(path);
|
|
161
|
+
const results = await new AxeBuilder({ page })
|
|
162
|
+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
|
|
163
|
+
.analyze();
|
|
164
|
+
expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Per-component check
|
|
171
|
+
```ts
|
|
172
|
+
test('Modal is accessible', async ({ page }) => {
|
|
173
|
+
await page.goto('/components/modal-demo');
|
|
174
|
+
await page.click('text=Open');
|
|
175
|
+
const results = await new AxeBuilder({ page })
|
|
176
|
+
.include('[role="dialog"]')
|
|
177
|
+
.withTags(['wcag2aa', 'wcag22aa'])
|
|
178
|
+
.analyze();
|
|
179
|
+
expect(results.violations).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Dev-time inline checks (React)
|
|
184
|
+
```ts
|
|
185
|
+
// app/providers.tsx — only in dev
|
|
186
|
+
if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') {
|
|
187
|
+
import('@axe-core/react').then(axe => {
|
|
188
|
+
import('react-dom').then(ReactDOM => axe.default(React, ReactDOM, 1000));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Lighthouse CI (Pages)
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
# .github/workflows/ci.yml — append to e2e job
|
|
199
|
+
- name: Lighthouse CI
|
|
200
|
+
uses: treosh/lighthouse-ci-action@v11
|
|
201
|
+
with:
|
|
202
|
+
urls: |
|
|
203
|
+
http://localhost:3000/
|
|
204
|
+
http://localhost:3000/dashboard
|
|
205
|
+
configPath: ./.lighthouserc.json
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`.lighthouserc.json`:
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"ci": {
|
|
212
|
+
"assert": {
|
|
213
|
+
"assertions": {
|
|
214
|
+
"categories:accessibility": ["error", { "minScore": 0.95 }]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Mobile / Touch (WCAG 2.2 §2.5.8)
|
|
224
|
+
|
|
225
|
+
```css
|
|
226
|
+
/* Minimum touch target — 24×24 CSS px (44×44 recommended for mobile) */
|
|
227
|
+
button, [role="button"], a {
|
|
228
|
+
min-width: 44px;
|
|
229
|
+
min-height: 44px;
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
For dense UIs, the spec allows smaller if "equivalent functionality" exists elsewhere — but it's safer to just hit 44px.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Screen Reader Testing
|
|
238
|
+
|
|
239
|
+
You can't ship a11y without ever using a screen reader. Quick smoke:
|
|
240
|
+
- macOS: VoiceOver (`Cmd+F5`)
|
|
241
|
+
- Windows: NVDA (free) or Narrator
|
|
242
|
+
- Mobile: VoiceOver (iOS) / TalkBack (Android)
|
|
243
|
+
|
|
244
|
+
Test the golden path: load page → tab through → fill form → submit. If the screen reader announces what's happening, you're 80% there.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Pre-Commit Checklist
|
|
249
|
+
|
|
250
|
+
- [ ] Semantic HTML (no `div onClick` for buttons/links)
|
|
251
|
+
- [ ] Every form input has a label
|
|
252
|
+
- [ ] Every image has `alt` (or `alt=""` if decorative)
|
|
253
|
+
- [ ] Heading levels are sequential (no skipping)
|
|
254
|
+
- [ ] Color contrast ≥ 4.5:1 for body text, 3:1 for large/UI
|
|
255
|
+
- [ ] Focus visible on all interactive elements
|
|
256
|
+
- [ ] Keyboard test passes (Tab/Enter/Esc/Arrows)
|
|
257
|
+
- [ ] axe-core test added for new pages/components
|
|
258
|
+
- [ ] Touch targets ≥ 24×24 px (44 recommended)
|
|
259
|
+
- [ ] Drag operations have non-drag alternative (WCAG 2.2 §2.5.7)
|
|
260
|
+
- [ ] No `outline: none` without replacement
|
|
261
|
+
- [ ] No `aria-*` on the wrong element type
|
|
262
|
+
|
|
263
|
+
## FORBIDDEN
|
|
264
|
+
|
|
265
|
+
| Pattern | Why |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `<div onClick>` for an action | Not keyboard-reachable, no role |
|
|
268
|
+
| `<a>` without `href` | Not keyboard-reachable, no link semantics |
|
|
269
|
+
| `outline: none` with no `:focus-visible` style | Invisible focus = unusable |
|
|
270
|
+
| `aria-label` on a `<div>` that should be a `<button>` | Cargo-cult ARIA — fix the element |
|
|
271
|
+
| Color-only error indication | Colorblind users can't see |
|
|
272
|
+
| `role="button"` on a `<button>` | Redundant; native role wins |
|
|
273
|
+
| Placeholder as label | Disappears on input, fails contrast |
|
|
274
|
+
| Auto-playing video/audio with sound | WCAG 1.4.2 |
|
|
275
|
+
| Drag-only interactions | WCAG 2.2 §2.5.7 — provide buttons |
|
|
276
|
+
| Modal that traps tab AND has unreachable close button | Keyboard dead-end |
|
|
277
|
+
|
|
278
|
+
## See Also
|
|
279
|
+
|
|
280
|
+
- `ui-ux-audit` — broader UX audit including responsive
|
|
281
|
+
- `playwright-automation` — where the axe tests live
|
|
282
|
+
- `react-patterns` — Radix / React Aria preferred over custom widgets
|
|
283
|
+
- WCAG 2.2 quick-reference: https://www.w3.org/WAI/WCAG22/quickref/
|
|
284
|
+
- WAI-ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/
|