@alexcodeplace/slopgate 0.1.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/LICENSE +21 -0
- package/README.md +720 -0
- package/bin/slopgate +44 -0
- package/hooks/baseline-guard.sh +60 -0
- package/hooks/commit-hook.sh +12 -0
- package/hooks/edit-hook.sh +19 -0
- package/hooks/session-start.sh +25 -0
- package/package.json +35 -0
- package/rules/baseline/ast/empty-catch-block-ts.yml +7 -0
- package/rules/baseline/ast/empty-catch-block-tsx.yml +7 -0
- package/rules/baseline/ast/inner-html.yml +9 -0
- package/rules/baseline/ast/slopgate-canary.yml +9 -0
- package/rules/baseline/ast/target-blank-norel.yml +25 -0
- package/rules/baseline/ast/window-in-render.yml +38 -0
- package/rules/baseline/selftest.config.toml +23 -0
- package/rules/ux/ast/ux-anchor-no-href.yml +17 -0
- package/rules/ux/ast/ux-async-onclick-no-disable.yml +17 -0
- package/rules/ux/ast/ux-button-no-type.yml +11 -0
- package/rules/ux/ast/ux-div-onclick.yml +17 -0
- package/rules/ux/ast/ux-img-no-alt.yml +15 -0
- package/rules/ux/ast/ux-img-no-dimensions.yml +15 -0
- package/rules/ux/ast/ux-media-no-dimensions.yml +15 -0
- package/rules/ux/ast/ux-modal-no-close.yml +15 -0
- package/skills/slopgate-improve/SKILL.md +155 -0
- package/skills/slopgate-init/SKILL.md +218 -0
- package/skills/slopgate-ux/SKILL.md +118 -0
- package/vendor/darwin-arm64/slopgate-rs +0 -0
- package/vendor/darwin-x64/slopgate-rs +0 -0
- package/vendor/linux-arm64/slopgate-rs +0 -0
- package/vendor/linux-x64/slopgate-rs +0 -0
- package/vendor/win32-x64/slopgate-rs.exe +0 -0
package/bin/slopgate
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher: locate the native slopgate engine binary and exec it, passing argv
|
|
3
|
+
// through and propagating the exit code. The engine is Rust (crates/slopgate-rs);
|
|
4
|
+
// this thin node shim is the npm `bin` entry (cross-platform).
|
|
5
|
+
//
|
|
6
|
+
// One package ships every platform binary under vendor/<platform>-<arch>/; npm
|
|
7
|
+
// delivers them all and this shim picks the one matching the host.
|
|
8
|
+
//
|
|
9
|
+
// Resolution order:
|
|
10
|
+
// 1. $SLOPGATE_BIN (explicit override)
|
|
11
|
+
// 2. <pkg>/vendor/<platform>-<arch>/slopgate-rs (bundled prebuilt; npm-install path)
|
|
12
|
+
// 3. <pkg>/target/release/slopgate-rs (cargo build --release; dev)
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const binName = process.platform === 'win32' ? 'slopgate-rs.exe' : 'slopgate-rs';
|
|
20
|
+
const host = `${process.platform}-${process.arch}`;
|
|
21
|
+
|
|
22
|
+
const candidates = [
|
|
23
|
+
process.env.SLOPGATE_BIN,
|
|
24
|
+
join(pkgRoot, 'vendor', host, binName),
|
|
25
|
+
join(pkgRoot, 'target', 'release', binName),
|
|
26
|
+
].filter(Boolean);
|
|
27
|
+
|
|
28
|
+
const bin = candidates.find((p) => existsSync(p));
|
|
29
|
+
if (!bin) {
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
`slopgate: native engine binary not found for ${host}.\n` +
|
|
32
|
+
` This platform may not be supported, or the package install was incomplete.\n` +
|
|
33
|
+
` For development, build the engine with \`cargo build --release\`.\n` +
|
|
34
|
+
` looked in: ${candidates.join(', ') || '(none)'}\n`,
|
|
35
|
+
);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const r = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' });
|
|
40
|
+
if (r.error) {
|
|
41
|
+
process.stderr.write(`slopgate: failed to exec engine (${bin}): ${r.error.message}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
process.exit(r.status ?? 1);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Slopgate baseline-guard — PreToolUse hook blocking agent bypass of the gate.
|
|
3
|
+
# Covers: Edit/Write to baseline.json/suppressions.json, Bash rm/mv on baseline*,
|
|
4
|
+
# Bash `slopgate baseline` in any form.
|
|
5
|
+
# Exit 2 = block the tool use (PreToolUse protocol). Exit 0 = allow. Always fail-open on errors.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
INPUT=$(cat)
|
|
9
|
+
|
|
10
|
+
tool_name=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.tool_name||'')}catch{}" "$INPUT" 2>/dev/null || echo '')
|
|
11
|
+
tool_input=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(JSON.stringify(j.tool_input||{}))}catch{}" "$INPUT" 2>/dev/null || echo '{}')
|
|
12
|
+
|
|
13
|
+
blocked=0
|
|
14
|
+
reason=''
|
|
15
|
+
|
|
16
|
+
case "$tool_name" in
|
|
17
|
+
Edit|Write)
|
|
18
|
+
file_path=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.file_path||'')}catch{}" "$tool_input" 2>/dev/null || echo '')
|
|
19
|
+
if echo "$file_path" | grep -qE '\.slopgate/(baseline|suppressions)\.json$'; then
|
|
20
|
+
blocked=1
|
|
21
|
+
reason="direct write to $file_path blocked — edit baseline/suppressions via slopgate CLI only"
|
|
22
|
+
fi
|
|
23
|
+
;;
|
|
24
|
+
Bash)
|
|
25
|
+
cmd=$(node -e "try{const j=JSON.parse(process.argv[1]);process.stdout.write(j.command||'')}catch{}" "$tool_input" 2>/dev/null || echo '')
|
|
26
|
+
# Block: slopgate baseline (create or --update)
|
|
27
|
+
if echo "$cmd" | grep -qE '(^|[;&|[:space:]])(npx[[:space:]]+|pnpm[[:space:]]+exec[[:space:]]+|yarn[[:space:]]+)?(node[[:space:]]+[^ ]+/bin/slopgate|slopgate)[[:space:]]+baseline([[:space:]]|$)'; then
|
|
28
|
+
blocked=1
|
|
29
|
+
reason="slopgate baseline blocked — agent cannot update/create baseline; run in your own terminal"
|
|
30
|
+
fi
|
|
31
|
+
# Block: rm/mv targeting .slopgate/baseline*
|
|
32
|
+
if echo "$cmd" | grep -qE '(rm|mv)\s+.*\.slopgate/baseline'; then
|
|
33
|
+
blocked=1
|
|
34
|
+
reason="rm/mv of .slopgate/baseline blocked — baseline integrity guard"
|
|
35
|
+
fi
|
|
36
|
+
;;
|
|
37
|
+
esac
|
|
38
|
+
|
|
39
|
+
if [ "$blocked" -eq 1 ]; then
|
|
40
|
+
echo "⛔ SLOPGATE GUARD: $reason" >&2
|
|
41
|
+
# Write bypass-attempt stats row (fail-open)
|
|
42
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
43
|
+
node -e "
|
|
44
|
+
const crypto=require('crypto'),fs=require('fs'),path=require('path'),os=require('os');
|
|
45
|
+
const root=process.argv[1], reason=process.argv[2];
|
|
46
|
+
try {
|
|
47
|
+
const key=crypto.createHash('sha256').update(root).digest('hex').slice(0,16);
|
|
48
|
+
const sess=JSON.parse(fs.readFileSync(path.join(os.homedir(),'.slopgate','sessions',key+'.json'),'utf8'));
|
|
49
|
+
const row=JSON.stringify({ts:new Date().toISOString(),project:path.basename(root),projectPath:root,
|
|
50
|
+
model:sess.model||'unknown',sessionId:sess.sessionId||null,mode:'bypass-attempt',
|
|
51
|
+
ruleId:'baseline-tamper',severity:'critical',category:'security',engine:'bypass-attempt',
|
|
52
|
+
file:null,line:null,reason});
|
|
53
|
+
const gp=path.join(os.homedir(),'.slopgate','stats.jsonl');
|
|
54
|
+
fs.appendFileSync(gp,row+'\n');
|
|
55
|
+
} catch { /* fail-open */ }
|
|
56
|
+
" "$ROOT" "$reason" 2>/dev/null || true
|
|
57
|
+
exit 2
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
exit 0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Slopgate PreToolUse hook — runs --staged before a git commit. Exit 1 → commit blocked.
|
|
3
|
+
TOOL_JSON=$(cat)
|
|
4
|
+
CMD=$(node -e "
|
|
5
|
+
let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{process.stdout.write('')}});" <<< "$TOOL_JSON" 2>/dev/null)
|
|
6
|
+
echo "$CMD" | grep -qE 'git[[:space:]]+commit' || exit 0
|
|
7
|
+
|
|
8
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
9
|
+
CONFIG="$ROOT/.slopgate/config.toml"
|
|
10
|
+
[ -f "$CONFIG" ] || exit 0
|
|
11
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
12
|
+
exec node "$HERE/../bin/slopgate" --staged --config "$CONFIG"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Slopgate PostToolUse hook — single-file scan after Edit/Write.
|
|
3
|
+
# Exit 2 → stderr feeds back into the agent turn. FAIL-OPEN: any error/timeout → exit 0.
|
|
4
|
+
TOOL_JSON=$(cat)
|
|
5
|
+
FILE=$(node -e "
|
|
6
|
+
let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{process.stdout.write('')}});" <<< "$TOOL_JSON" 2>/dev/null) || exit 0
|
|
7
|
+
[ -n "$FILE" ] || exit 0
|
|
8
|
+
case "$FILE" in *.test.ts|*.test.tsx) exit 0 ;; *.ts|*.tsx|*.astro) ;; *) exit 0 ;; esac
|
|
9
|
+
# Skip fixture files — they are intentional violation examples for slopgate self-test.
|
|
10
|
+
case "$FILE" in */.slopgate/fixtures/*|*/slopgate/*/fixtures/*) exit 0 ;; esac
|
|
11
|
+
|
|
12
|
+
ROOT=$(git -C "$(dirname "$FILE")" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
13
|
+
CONFIG="$ROOT/.slopgate/config.toml"
|
|
14
|
+
[ -f "$CONFIG" ] || exit 0
|
|
15
|
+
|
|
16
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
17
|
+
OUT=$(timeout 5 node "$HERE/../bin/slopgate" --file "$FILE" --config "$CONFIG" 2>&1)
|
|
18
|
+
[ "$?" -eq 1 ] && { echo "$OUT" >&2; exit 2; }
|
|
19
|
+
exit 0
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Slopgate SessionStart hook — capture the session-start model for stats attribution.
|
|
3
|
+
# Only SessionStart receives a `model` field; mid-session /model switches are invisible.
|
|
4
|
+
# Fail-open: any error leaves no file -> stats resolves model to 'unknown'.
|
|
5
|
+
ROOT=$(realpath "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" 2>/dev/null || git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
6
|
+
exec node -e '
|
|
7
|
+
const crypto = require("crypto"), fs = require("fs"), path = require("path"), os = require("os");
|
|
8
|
+
const root = process.argv[1];
|
|
9
|
+
let d = "";
|
|
10
|
+
process.stdin.on("data", (c) => (d += c)).on("end", () => {
|
|
11
|
+
let m;
|
|
12
|
+
try {
|
|
13
|
+
const j = JSON.parse(d);
|
|
14
|
+
m = { model: j.model || "unknown", sessionId: j.session_id || null, startedAt: new Date().toISOString(), cwd: j.cwd || root };
|
|
15
|
+
} catch {
|
|
16
|
+
m = { model: "unknown", sessionId: null, startedAt: new Date().toISOString(), cwd: root };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const key = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
20
|
+
const dir = path.join(process.env.HOME || os.homedir(), ".slopgate", "sessions");
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(path.join(dir, key + ".json"), JSON.stringify(m));
|
|
23
|
+
} catch { /* fail-open */ }
|
|
24
|
+
});
|
|
25
|
+
' "$ROOT"
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alexcodeplace/slopgate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Global code-quality / anti-slop gate — engine shared, rules per-project.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Alex <git@alex.org.il>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/alexcodeplace/slopgate.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/alexcodeplace/slopgate#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/alexcodeplace/slopgate/issues"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"slopgate": "bin/slopgate"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"hooks",
|
|
22
|
+
"rules",
|
|
23
|
+
"skills",
|
|
24
|
+
"vendor",
|
|
25
|
+
"!rules/baseline/fixtures",
|
|
26
|
+
"!bin/slopgate-rs"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"self-test": "node bin/slopgate --self-test --config rules/baseline/selftest.config.toml",
|
|
33
|
+
"test": "cargo test --workspace"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
id: empty-catch-block-ts
|
|
2
|
+
language: typescript
|
|
3
|
+
severity: error
|
|
4
|
+
message: Empty catch block swallows error silently
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Handle or rethrow. Minimum: log with context (console.error). Comment-only bodies count as empty."}'
|
|
6
|
+
rule:
|
|
7
|
+
pattern: 'try { $A } catch ($E) {}'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
id: empty-catch-block-tsx
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: Empty catch block swallows error silently
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Handle or rethrow. Minimum: log with context (console.error). Comment-only bodies count as empty."}'
|
|
6
|
+
rule:
|
|
7
|
+
pattern: 'try { $A } catch ($E) {}'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
id: inner-html-assignment
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: innerHTML / insertAdjacentHTML assignment — XSS surface
|
|
5
|
+
note: '{"severity":"high","category":"security","resolution":"Build DOM via safe APIs or sanitize with isomorphic-dompurify."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- pattern: $X.innerHTML = $$$
|
|
9
|
+
- pattern: $X.insertAdjacentHTML($$$)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
id: slopgate-canary
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: slopgate self-test canary
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"self-test only — must only ever match fixtures/"}'
|
|
6
|
+
files:
|
|
7
|
+
- '**/fixtures/**'
|
|
8
|
+
rule:
|
|
9
|
+
pattern: __SLOPGATE_AST_CANARY__
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
id: target-blank-norel
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: target=_blank without rel=noopener noreferrer (reverse-tabnabbing)
|
|
5
|
+
note: '{"severity":"high","category":"security","resolution":"Add rel=\"noopener noreferrer\" to the element."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_opening_element
|
|
10
|
+
- has:
|
|
11
|
+
kind: jsx_attribute
|
|
12
|
+
regex: '^target=["\x27]_blank["\x27]$'
|
|
13
|
+
- not:
|
|
14
|
+
has:
|
|
15
|
+
kind: jsx_attribute
|
|
16
|
+
regex: '^rel='
|
|
17
|
+
- all:
|
|
18
|
+
- kind: jsx_self_closing_element
|
|
19
|
+
- has:
|
|
20
|
+
kind: jsx_attribute
|
|
21
|
+
regex: '^target=["\x27]_blank["\x27]$'
|
|
22
|
+
- not:
|
|
23
|
+
has:
|
|
24
|
+
kind: jsx_attribute
|
|
25
|
+
regex: '^rel='
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
id: window-in-render
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: window/document accessed during render — SSR crash
|
|
5
|
+
note: '{"severity":"medium","category":"convention","resolution":"Move into useEffect/useLayoutEffect, an event handler, or guard with typeof window !== \"undefined\"."}'
|
|
6
|
+
files:
|
|
7
|
+
- '**/src/components/**'
|
|
8
|
+
- '**/src/features/**'
|
|
9
|
+
- '**/fixtures/src/components/**'
|
|
10
|
+
ignores:
|
|
11
|
+
- '**/features/design-system/**'
|
|
12
|
+
rule:
|
|
13
|
+
any:
|
|
14
|
+
- pattern: window.$PROP
|
|
15
|
+
- pattern: document.$PROP
|
|
16
|
+
not:
|
|
17
|
+
any:
|
|
18
|
+
- inside:
|
|
19
|
+
stopBy: end
|
|
20
|
+
pattern: useEffect($$$)
|
|
21
|
+
- inside:
|
|
22
|
+
stopBy: end
|
|
23
|
+
pattern: useLayoutEffect($$$)
|
|
24
|
+
- inside:
|
|
25
|
+
stopBy: end
|
|
26
|
+
kind: jsx_attribute
|
|
27
|
+
- inside:
|
|
28
|
+
stopBy: end
|
|
29
|
+
pattern: if (typeof window !== 'undefined') { $$$ }
|
|
30
|
+
- inside:
|
|
31
|
+
stopBy: end
|
|
32
|
+
pattern: typeof window
|
|
33
|
+
- inside:
|
|
34
|
+
stopBy: end
|
|
35
|
+
kind: variable_declarator
|
|
36
|
+
has:
|
|
37
|
+
field: name
|
|
38
|
+
regex: ^(handle|on)[A-Z]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Canonical self-test config for the Rust engine.
|
|
2
|
+
# Behavior frozen against the former JS oracle via the Stage-0b golden regression test.
|
|
3
|
+
# Path resolution: roots are repo-root-relative; suppressions/fixtures are config-dir-relative.
|
|
4
|
+
# astRules is intentionally omitted; the resolver always adds
|
|
5
|
+
# rules/baseline/ast, and UX-high adds rules/ux/ast.
|
|
6
|
+
roots = ["rules/baseline/fixtures/src"]
|
|
7
|
+
exts = [".ts", ".tsx"]
|
|
8
|
+
skipDirs = ["node_modules"]
|
|
9
|
+
baseline = ["no-stubs", "ts-suppress", "as-any", "raw-hex", "kv-ban", "live-secrets", "eval-ban", "pii-logs", "weak-hash", "sql-safety"]
|
|
10
|
+
rules = []
|
|
11
|
+
suppressions = "./fixtures/suppressions.json"
|
|
12
|
+
fixtures = "./fixtures"
|
|
13
|
+
|
|
14
|
+
[ux]
|
|
15
|
+
a11y = "high"
|
|
16
|
+
cls = "high"
|
|
17
|
+
feedback = "high"
|
|
18
|
+
taste = "advisory"
|
|
19
|
+
advisory = "advisory"
|
|
20
|
+
|
|
21
|
+
[gate]
|
|
22
|
+
file = ["critical", "high"]
|
|
23
|
+
staged = ["critical", "high"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: ux-anchor-no-href
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: <a onClick> without href — not a real link, not keyboard-focusable
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Use a <button> for actions, or give the <a> a real href. An anchor with only onClick is invisible to keyboard and screen readers (ANTI-SLOP UX §11)."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_opening_element
|
|
10
|
+
- has: { field: name, regex: '^a$' }
|
|
11
|
+
- has: { kind: jsx_attribute, regex: '^onClick' }
|
|
12
|
+
- not: { has: { kind: jsx_attribute, regex: '^href=' } }
|
|
13
|
+
- all:
|
|
14
|
+
- kind: jsx_self_closing_element
|
|
15
|
+
- has: { field: name, regex: '^a$' }
|
|
16
|
+
- has: { kind: jsx_attribute, regex: '^onClick' }
|
|
17
|
+
- not: { has: { kind: jsx_attribute, regex: '^href=' } }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: ux-async-onclick-no-disable
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: async onClick on a <button> with no disabled state — double-submit / silent wait
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Disable the trigger while the promise is in flight (disabled={pending}) and show an inline loading indicator. Prevents double-submit and ghost interactions (ANTI-SLOP UX §3/§12)."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_opening_element
|
|
10
|
+
- has: { field: name, regex: '^button$' }
|
|
11
|
+
- has: { kind: jsx_attribute, regex: 'onClick=\{\s*async' }
|
|
12
|
+
- not: { has: { kind: jsx_attribute, regex: '^disabled' } }
|
|
13
|
+
- all:
|
|
14
|
+
- kind: jsx_self_closing_element
|
|
15
|
+
- has: { field: name, regex: '^button$' }
|
|
16
|
+
- has: { kind: jsx_attribute, regex: 'onClick=\{\s*async' }
|
|
17
|
+
- not: { has: { kind: jsx_attribute, regex: '^disabled' } }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
id: ux-button-no-type
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: <button> without an explicit type — defaults to "submit" and can fire forms
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Add type=\"button\" (or type=\"submit\" intentionally). A typeless button inside a form submits it on click — a classic silent bug (ANTI-SLOP UX §3/§11)."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_opening_element
|
|
10
|
+
- has: { field: name, regex: '^button$' }
|
|
11
|
+
- not: { has: { kind: jsx_attribute, regex: '^type=' } }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: ux-div-onclick
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: onClick on a <div>/<span> — not keyboard-focusable, breaks a11y
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Use a native <button> (or add role + tabIndex + onKeyDown). Semantic HTML first — ANTI-SLOP UX §11."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_opening_element
|
|
10
|
+
- has: { field: name, regex: '^(div|span)$' }
|
|
11
|
+
- has: { kind: jsx_attribute, regex: '^onClick' }
|
|
12
|
+
- not: { has: { kind: jsx_attribute, regex: '^role=' } }
|
|
13
|
+
- all:
|
|
14
|
+
- kind: jsx_self_closing_element
|
|
15
|
+
- has: { field: name, regex: '^(div|span)$' }
|
|
16
|
+
- has: { kind: jsx_attribute, regex: '^onClick' }
|
|
17
|
+
- not: { has: { kind: jsx_attribute, regex: '^role=' } }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
id: ux-img-no-alt
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: <img> without an alt attribute — invisible to screen readers
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Add alt text (alt=\"…\"), or alt=\"\" for purely decorative images (ANTI-SLOP UX §11). Semantic/accessible by default."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_self_closing_element
|
|
10
|
+
- has: { field: name, regex: '^img$' }
|
|
11
|
+
- not: { has: { kind: jsx_attribute, regex: '^alt' } }
|
|
12
|
+
- all:
|
|
13
|
+
- kind: jsx_opening_element
|
|
14
|
+
- has: { field: name, regex: '^img$' }
|
|
15
|
+
- not: { has: { kind: jsx_attribute, regex: '^alt' } }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
id: ux-img-no-dimensions
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: <img> without width/height — causes layout shift (CLS) as it loads
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Reserve the footprint: add width and height attributes, or a CSS aspect-ratio / min-height (ANTI-SLOP UX §13). Use a framework <Image> that sets dimensions."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_self_closing_element
|
|
10
|
+
- has: { field: name, regex: '^img$' }
|
|
11
|
+
- not: { has: { kind: jsx_attribute, regex: '^width=' } }
|
|
12
|
+
- all:
|
|
13
|
+
- kind: jsx_opening_element
|
|
14
|
+
- has: { field: name, regex: '^img$' }
|
|
15
|
+
- not: { has: { kind: jsx_attribute, regex: '^width=' } }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
id: ux-media-no-dimensions
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: error
|
|
4
|
+
message: <video>/<iframe> without width/height — causes layout shift (CLS)
|
|
5
|
+
note: '{"severity":"high","category":"convention","resolution":"Reserve the footprint: set width and height (or a CSS aspect-ratio / fixed min-height) so the page does not jump as the media loads (ANTI-SLOP UX §13)."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_self_closing_element
|
|
10
|
+
- has: { field: name, regex: '^(video|iframe)$' }
|
|
11
|
+
- not: { has: { kind: jsx_attribute, regex: '^width=' } }
|
|
12
|
+
- all:
|
|
13
|
+
- kind: jsx_opening_element
|
|
14
|
+
- has: { field: name, regex: '^(video|iframe)$' }
|
|
15
|
+
- not: { has: { kind: jsx_attribute, regex: '^width=' } }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
id: ux-modal-no-close
|
|
2
|
+
language: tsx
|
|
3
|
+
severity: warning
|
|
4
|
+
message: <Dialog>/<Modal> without an onClose/onDismiss handler — possible dead end
|
|
5
|
+
note: '{"severity":"medium","category":"convention","resolution":"Give every modal an explicit escape: onClose / onOpenChange / onDismiss plus a visible Close control. No dead ends (ANTI-SLOP UX §10)."}'
|
|
6
|
+
rule:
|
|
7
|
+
any:
|
|
8
|
+
- all:
|
|
9
|
+
- kind: jsx_opening_element
|
|
10
|
+
- has: { field: name, regex: '^(Dialog|Modal)$' }
|
|
11
|
+
- not: { has: { kind: jsx_attribute, regex: '^(onClose|onDismiss|onOpenChange)' } }
|
|
12
|
+
- all:
|
|
13
|
+
- kind: jsx_self_closing_element
|
|
14
|
+
- has: { field: name, regex: '^(Dialog|Modal)$' }
|
|
15
|
+
- not: { has: { kind: jsx_attribute, regex: '^(onClose|onDismiss|onOpenChange)' } }
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: slopgate-improve
|
|
3
|
+
description: Mine a project's institutional memory (skills, agents, commands, CLAUDE.md, editor rules) for conventions not yet caught by slopgate, and add them to the right rule tier. Invoke on /slopgate-improve or when a recurring agent mistake is documented but undetected.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /slopgate-improve
|
|
7
|
+
|
|
8
|
+
**Goal:** Find real agent mistakes documented in the project's institutional memory that slopgate doesn't yet catch, and add them to the right engine tier.
|
|
9
|
+
|
|
10
|
+
Scope / focus area (optional): $ARGUMENTS — blank = all sources.
|
|
11
|
+
|
|
12
|
+
## Rule tier model
|
|
13
|
+
|
|
14
|
+
| Tier | Pack location | Who opts in |
|
|
15
|
+
|------|--------------|-------------|
|
|
16
|
+
| **baseline** | built-in: `crates/slopgate-core/src/rules/baseline.json` (compiled into the engine) | Any project — enabled by name in TOML (`baseline = ["no-stubs", ...]`) |
|
|
17
|
+
| **stack** | built-in: `crates/slopgate-core/src/rules/stack.json` (compiled into the engine) | Projects using that runtime — enabled by name in TOML (`stack = ["cloudflare"]`) |
|
|
18
|
+
| **project** | ast-grep YAML in the `astRules` dir (`<repo>/.slopgate/rules/ast/<id>.yml`) | That repo only. NOTE: project custom **regex** packs (non-empty `rules = [...]`) are NOT yet supported by the native engine — they error (`PHASE-2: project rule packs`). Use ast-grep YAML, or defer the rule. |
|
|
19
|
+
|
|
20
|
+
When proposing a new rule, assign the lowest tier where it applies without FPs:
|
|
21
|
+
- Applies to any TypeScript/web project → baseline
|
|
22
|
+
- Applies to all projects using a specific runtime/framework → stack
|
|
23
|
+
- Project-specific convention or business logic → project
|
|
24
|
+
|
|
25
|
+
## Phase 1 — Inventory current coverage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Baseline packs enabled (TOML array — `baseline = [...]`):
|
|
29
|
+
grep 'baseline' <repo>/.slopgate/config.toml
|
|
30
|
+
# Stack packs enabled (TOML array — `stack = [...]`):
|
|
31
|
+
grep 'stack' <repo>/.slopgate/config.toml
|
|
32
|
+
# Project ast-grep rule IDs (ids live in the .yml files under the astRules dir):
|
|
33
|
+
grep -rho "^id:.*" <repo>/.slopgate/rules/ast/*.yml 2>/dev/null
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Built-in (baseline/stack) pack IDs are engine-internal — they are not in a grep-able shipped file. The authoritative set of what is enabled = the pack names in `config.toml` plus the project ast `.yml` ids above; to see exactly what fires, run the engine with `--file` / `--self-test` (Phase 5).
|
|
37
|
+
|
|
38
|
+
Build a set of already-covered rule IDs. Do NOT propose rules with IDs already present.
|
|
39
|
+
|
|
40
|
+
## Phase 2 — Extract rule candidates from ALL convention sources
|
|
41
|
+
|
|
42
|
+
Read `.slopgate/convention-sources.json` and open every file it lists:
|
|
43
|
+
- `claudeMd` roots and subtrees
|
|
44
|
+
- `skills/` — every SKILL.md
|
|
45
|
+
- `agents/` — every agent definition
|
|
46
|
+
- `commands/` — every command file
|
|
47
|
+
- `editorRules` (`.cursorrules`, `.windsurfrules`)
|
|
48
|
+
- `knowledgeDocs`
|
|
49
|
+
|
|
50
|
+
Also read: `git log --oneline -50` for recent commit messages that describe mistakes.
|
|
51
|
+
|
|
52
|
+
For each convention/mistake found, record:
|
|
53
|
+
- **source**: which file + section stated it
|
|
54
|
+
- **what**: the mistake or convention in one sentence
|
|
55
|
+
- **already covered**: yes/no (check Phase 1 set)
|
|
56
|
+
|
|
57
|
+
## Phase 3 — Bucket triage
|
|
58
|
+
|
|
59
|
+
For each uncovered convention:
|
|
60
|
+
|
|
61
|
+
| Bucket | Description | Action |
|
|
62
|
+
|--------|-------------|--------|
|
|
63
|
+
| A — regex | Token/pattern/import detectable by line scan | Author regex rule |
|
|
64
|
+
| B — ast | Structural pattern (call shape, attribute presence) | Author ast-grep rule |
|
|
65
|
+
| C — semantic | Judgment/intent — regex/AST would FP constantly | Add to judge-rules.md only |
|
|
66
|
+
| skip | Too noisy, too specific, or already enforced by other tooling | Document why, discard |
|
|
67
|
+
|
|
68
|
+
**Detectable (A/B):** banned token, hardcoded value where a utility is required, required attribute absence, import boundary violation, file-shape constraint.
|
|
69
|
+
|
|
70
|
+
**NOT detectable (C/skip):** semantic conventions ("use the knowledge graph first"), runtime behavior, data-shape requirements, anything needing type information a regex can't see.
|
|
71
|
+
|
|
72
|
+
## Phase 4A — Author regex candidates
|
|
73
|
+
|
|
74
|
+
For each bucket-A rule, draft:
|
|
75
|
+
```js
|
|
76
|
+
{
|
|
77
|
+
id: '<kebab-id>',
|
|
78
|
+
title: '<short title>',
|
|
79
|
+
category: 'security|convention|duplication|boundary|api|i18n',
|
|
80
|
+
severity: 'critical|high|medium|low',
|
|
81
|
+
pattern: '<regex>',
|
|
82
|
+
description: '<why this is wrong>',
|
|
83
|
+
resolution: '<what to do instead>',
|
|
84
|
+
excludeGlobs: ['<legit exception globs>'],
|
|
85
|
+
canary: '<string the pattern must match>',
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Confidence check:** test the pattern against 3 real examples from the codebase:
|
|
90
|
+
- Does it match the violation? ✓
|
|
91
|
+
- Does it match anything it shouldn't? If yes → add excludeGlobs or raise threshold to medium/low.
|
|
92
|
+
- Low confidence → defer to C (judge-only), do not ship noise.
|
|
93
|
+
|
|
94
|
+
**Tier decision:** assign to baseline / stack / project per the tier model above. NOTE: a **project-tier regex** rule cannot ship on the native engine (non-empty `rules = [...]` errors — `PHASE-2: project rule packs`). If a regex candidate is genuinely project-specific, either re-express it as an ast-grep rule (bucket B, Phase 4B) or defer it (bucket C / report as deferred). Only baseline/stack regex packs are loadable today.
|
|
95
|
+
|
|
96
|
+
## Phase 4B — Author ast-grep candidates
|
|
97
|
+
|
|
98
|
+
For each bucket-B rule, draft a YAML rule in ast-grep syntax:
|
|
99
|
+
- `language: typescript|tsx`
|
|
100
|
+
- `severity: error|warning`
|
|
101
|
+
- `note:` JSON with `{"severity":"high","category":"...","resolution":"..."}`
|
|
102
|
+
- `rule:` structural pattern
|
|
103
|
+
- `files:` / `ignores:` scope guards if needed
|
|
104
|
+
|
|
105
|
+
Add to correct dir:
|
|
106
|
+
- Baseline-quality → `slopgate/rules/baseline/ast/<id>.yml`
|
|
107
|
+
- Project-specific → `<repo>/.slopgate/rules/ast/<id>.yml`
|
|
108
|
+
|
|
109
|
+
Always add a fixture file with a canary hit.
|
|
110
|
+
|
|
111
|
+
## Phase 4C — Add judge-only candidates
|
|
112
|
+
|
|
113
|
+
For bucket-C rules: append to `<repo>/scripts/code-quality/judge-rules.md` (or equivalent):
|
|
114
|
+
```
|
|
115
|
+
| <id> | <one-line description of what to flag> |
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Phase 5 — Verify
|
|
119
|
+
|
|
120
|
+
For each new regex rule:
|
|
121
|
+
```bash
|
|
122
|
+
# Canary must match (approximation — the engine matches via fancy-regex for JS-RegExp parity)
|
|
123
|
+
node -e "console.log(new RegExp('<pattern>').test('<canary>'))"
|
|
124
|
+
# Run self-test
|
|
125
|
+
slopgate --self-test --config <repo>/.slopgate/config.toml
|
|
126
|
+
# Dry-run on repo (count hits; review for FPs)
|
|
127
|
+
slopgate --config <repo>/.slopgate/config.toml 2>&1 | grep '<id>'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If FP count > 5% of hits → add excludeGlobs or demote to medium/low. If still noisy → demote to bucket C.
|
|
131
|
+
|
|
132
|
+
## Phase 6 — Apply
|
|
133
|
+
|
|
134
|
+
- Baseline/stack rules: edit the built-in JSON in the slopgate repo (`crates/slopgate-core/src/rules/baseline.json` or `stack.json`, loaded by `crates/slopgate-core/src/rules/packs.rs`), rebuild (`cargo build --release`), commit (`feat(rules): add <id> to baseline|stack/cloudflare`). Note: the packs are compiled in via `include_str!`, so the rebuild is required for the new rule to take effect. The file is a JSON object keyed by pack name (`"no-stubs": [ {...}, ... ]`); add your rule object to the right pack's array, writing it as **JSON** — double-quoted keys and strings — matching the existing entries (the Phase 4A draft is shown as a JS literal; translate it to JSON, not the other way around).
|
|
135
|
+
- Project ast-grep rules: write the `.yml` to `<repo>/.slopgate/rules/ast/` (the `astRules` dir), commit to project repo. (Project regex packs are not loadable — see Phase 4A note.)
|
|
136
|
+
- Run self-test one final time; must exit 0.
|
|
137
|
+
|
|
138
|
+
## Phase 7 — Report (conversation text only, no files)
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
## /slopgate-improve results
|
|
142
|
+
### Added — regex (N): `<id>` (tier:sev) — desc
|
|
143
|
+
### Added — ast (N): `<id>` (tier:sev) — desc
|
|
144
|
+
### Added — judge-only (N): `<id>` — desc
|
|
145
|
+
### Deferred (N): `<id>`: <why — too noisy / not statically expressible / already covered>
|
|
146
|
+
### Self-test: pass|fail
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Constraints
|
|
150
|
+
|
|
151
|
+
- NEVER author a rule that fires on > ~5% false positives without excluding the FP sources.
|
|
152
|
+
- NEVER duplicate an existing rule ID — Phase 1 set is authoritative.
|
|
153
|
+
- NEVER add a baseline rule for a project-specific business concept (e.g. "agorot math").
|
|
154
|
+
- NEVER skip the canary check — a rule without a matching canary is untestable and will rot.
|
|
155
|
+
- implementer reports candidates; orchestrator + user decide on tier and enable. Do NOT self-approve critical+ rules without user confirmation.
|