opencode-anti-slop 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * install-hooks.mjs — wire the anti-slop gates into git hooks of a repo.
4
+ *
5
+ * pre-commit : checks ONLY staged JS/TS files with check-modularity
6
+ * (blocking) + reports duplicates (non-blocking). Fast, and
7
+ * never blocked by legacy code.
8
+ * pre-push : full duplicates gate (blocking) + lint/typecheck/test via
9
+ * `npm run <script> --if-present`.
10
+ *
11
+ * Run from the target repo root: node scripts/anti-slop/install-hooks.mjs [--force]
12
+ * Existing hooks are never overwritten without --force.
13
+ */
14
+
15
+ import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const force = process.argv.includes('--force');
19
+ const preCommitOnly = process.argv.includes('--pre-commit-only');
20
+ if (process.argv.includes('-h') || process.argv.includes('--help')) {
21
+ console.log('Usage: node scripts/anti-slop/install-hooks.mjs [--force] [--pre-commit-only]');
22
+ console.log(' --pre-commit-only: skip the pre-push gate. Use when legacy duplication or');
23
+ console.log(' size violations exist repo-wide — pre-push would block every push until');
24
+ console.log(' the cleanup lands. Install the full pair once the gates pass.');
25
+ process.exit(0);
26
+ }
27
+
28
+ const cwd = process.cwd();
29
+ const gitDir = existsSync(path.join(cwd, '.git')) ? path.join(cwd, '.git') : null;
30
+
31
+ if (!gitDir) {
32
+ console.error('No .git directory here. Run from the root of a git repository.');
33
+ process.exit(1);
34
+ }
35
+
36
+ const hooksDir = path.join(gitDir, 'hooks');
37
+ mkdirSync(hooksDir, { recursive: true });
38
+
39
+ const PRE_COMMIT = `#!/bin/sh
40
+ # anti-slop pre-commit gate (installed by scripts/anti-slop/install-hooks.mjs)
41
+ staged=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\\.(ts|tsx|js|jsx|mjs|mts)$')
42
+ [ -z "$staged" ] && exit 0
43
+
44
+ echo "[anti-slop] modularity check on staged files..."
45
+ # shellcheck disable=SC2086
46
+ node scripts/anti-slop/check-modularity.mjs $staged || exit 1
47
+
48
+ echo "[anti-slop] docs check on staged files..."
49
+ # shellcheck disable=SC2086
50
+ node scripts/anti-slop/check-docs.mjs $staged || exit 1
51
+
52
+ echo "[anti-slop] import-boundary report (non-blocking; cycles are tree-wide)..."
53
+ node scripts/anti-slop/check-imports.mjs --report-only || true
54
+
55
+ echo "[anti-slop] duplicate-code report (non-blocking; sensitivity from anti-slop.config.json):"
56
+ node scripts/anti-slop/check-duplicates.mjs --threshold 0 --summary || true
57
+
58
+ exit 0
59
+ `;
60
+
61
+ const PRE_PUSH = `#!/bin/sh
62
+ # anti-slop pre-push gate (installed by scripts/anti-slop/install-hooks.mjs)
63
+ echo "[anti-slop] duplicate-code gate..."
64
+ node scripts/anti-slop/check-duplicates.mjs || exit 1
65
+
66
+ echo "[anti-slop] import-boundary gate..."
67
+ node scripts/anti-slop/check-imports.mjs || exit 1
68
+
69
+ echo "[anti-slop] project checks (lint/typecheck/test; skips are printed, not hidden)..."
70
+ node scripts/anti-slop/run-project-checks.mjs || exit 1
71
+
72
+ exit 0
73
+ `;
74
+
75
+ const writeHook = (name, body) => {
76
+ const dest = path.join(hooksDir, name);
77
+ if (existsSync(dest) && !force) {
78
+ console.error(`[skip] ${name} already exists (use --force to overwrite)`);
79
+ return;
80
+ }
81
+ writeFileSync(dest, body, { mode: 0o755 });
82
+ console.log(`[ok] wrote ${path.relative(cwd, dest)}`);
83
+ };
84
+
85
+ writeHook('pre-commit', PRE_COMMIT);
86
+ if (preCommitOnly) {
87
+ console.log('[skip] pre-push (--pre-commit-only)');
88
+ } else {
89
+ writeHook('pre-push', PRE_PUSH);
90
+ }
91
+
92
+ console.log(`
93
+ Notes:
94
+ - Hooks require core.hooksPath to be unset (default). Verify: git config core.hooksPath
95
+ - Pre-commit only checks staged files, so pre-existing violations elsewhere do not block.
96
+ - To bypass once in an emergency: git commit --no-verify (use sparingly; it defeats the gate).
97
+ `);
@@ -0,0 +1,210 @@
1
+ /**
2
+ * lib.mjs — shared source-scanning helpers for the anti-slop gate scripts.
3
+ *
4
+ * Zero dependencies (Node >= 18, ESM). CLI/config plumbing lives in cli.mjs;
5
+ * this module is pure scanning: file discovery, noise stripping, tokenizing,
6
+ * export extraction. Keep it free of I/O decisions and argument parsing.
7
+ */
8
+
9
+ import { readdirSync } from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ /** Directories never scanned by the gates (deps, build output, tests, tooling). */
13
+ export const EXCLUDE_DIRS = new Set([
14
+ 'node_modules', 'dist', 'build', 'out', '.next', '.turbo',
15
+ 'coverage', 'storybook-static', '__tests__', '__mocks__',
16
+ ]);
17
+ /** Filename patterns never gated: test/spec/story files (any JS/TS extension incl. mjs/mts) and type declaration shims. */
18
+ export const IGNORE_FILE = /(\.test|\.spec|\.stories|\.d)\.[mc]?[tj]sx?$/;
19
+
20
+ /** Generated files that must never be gated (sizes/counts are machine-owned). */
21
+ export const GENERATED_FILE = /(^|\/)(payload-types|next-env)(\.d)?\.ts$/;
22
+
23
+ /** True when relPath is a generated artifact (see GENERATED_FILE). */
24
+ export function isGenerated(relPath) {
25
+ return GENERATED_FILE.test(relPath);
26
+ }
27
+
28
+ /** Source file extensions the gates scan. */
29
+ export const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts']);
30
+
31
+ /** Recursively list source files under `dir`, as { full, rel } with '/'-separated rel paths. */
32
+ export function walk(dir, base) {
33
+ const files = [];
34
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
35
+ if (entry.name.startsWith('.') || EXCLUDE_DIRS.has(entry.name)) continue;
36
+ const full = path.join(dir, entry.name);
37
+ const rel = path.relative(base, full).split(path.sep).join('/');
38
+ if (entry.isDirectory()) files.push(...walk(full, base));
39
+ else if (SOURCE_EXT.has(path.extname(entry.name)) && !IGNORE_FILE.test(entry.name) && !isGenerated(rel)) {
40
+ files.push({ full, rel });
41
+ }
42
+ }
43
+ return files;
44
+ }
45
+
46
+ /**
47
+ * Comment/string handling shared by both strippers below. Newlines (including
48
+ * those inside strings) are preserved, so line numbers computed on the output
49
+ * match the original source.
50
+ */
51
+ function stripSource(src, blankStrings) {
52
+ let out = '';
53
+ let i = 0;
54
+ const n = src.length;
55
+ while (i < n) {
56
+ const c = src[i];
57
+ const next = src[i + 1];
58
+ if (c === '/' && next === '/') {
59
+ while (i < n && src[i] !== '\n') i++;
60
+ } else if (c === '/' && next === '*') {
61
+ i += 2;
62
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) {
63
+ if (src[i] === '\n') out += '\n';
64
+ i++;
65
+ }
66
+ i += 2;
67
+ } else if (c === '"' || c === "'" || c === '`') {
68
+ const quote = c;
69
+ i++;
70
+ if (blankStrings) {
71
+ while (i < n && src[i] !== quote) {
72
+ if (src[i] === '\\') i++;
73
+ if (src[i] === '\n') out += '\n';
74
+ i++;
75
+ }
76
+ i++; // closing quote
77
+ out += '""';
78
+ } else {
79
+ out += quote;
80
+ while (i < n && src[i] !== quote) {
81
+ if (src[i] === '\\' && i + 1 < n) {
82
+ out += src[i] + src[i + 1];
83
+ i += 2;
84
+ continue;
85
+ }
86
+ out += src[i];
87
+ i++;
88
+ }
89
+ out += quote;
90
+ i++; // closing quote
91
+ }
92
+ } else {
93
+ out += c;
94
+ i++;
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Remove comments and blank string contents so brace/regex analysis is
102
+ * reliable. Newlines are preserved, so line numbers computed on the output
103
+ * match the original source.
104
+ */
105
+ export function stripNoise(src) {
106
+ return stripSource(src, true);
107
+ }
108
+
109
+ /**
110
+ * Remove comments while KEEPING string contents verbatim — used where the
111
+ * text inside strings matters (import specifier parsing) but comment noise
112
+ * must not create false edges.
113
+ */
114
+ export function stripComments(src) {
115
+ return stripSource(src, false);
116
+ }
117
+
118
+ const TOKEN_RE =
119
+ /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`|[A-Za-z_$][\w$]*|\d+(?:\.\d+)?|\S/g;
120
+
121
+ /** Tokenize stripped source into normalized tokens [{ t, line }] (1-based lines): strings -> `<str>`, numbers -> `<num>`, identifiers/symbols stay literal. */
122
+ export function tokenize(stripped) {
123
+ const tokens = [];
124
+ for (const m of stripped.matchAll(TOKEN_RE)) {
125
+ let nl = 0;
126
+ for (let k = 0; k < m.index; k++) if (m.input[k] === '\n') nl++;
127
+ const raw = m[0];
128
+ const t = raw[0] === '"' || raw[0] === "'" || raw[0] === '`' ? '<str>'
129
+ : /^\d/.test(raw) ? '<num>'
130
+ : raw;
131
+ tokens.push({ t, line: 1 + nl });
132
+ }
133
+ return tokens;
134
+ }
135
+
136
+ /** 1-based line number of char index `idx` within comment/string-stripped source. */
137
+ export function lineOf(stripped, idx) {
138
+ let line = 1;
139
+ for (let i = 0; i < idx && i < stripped.length; i++) {
140
+ if (stripped[i] === '\n') line++;
141
+ }
142
+ return line;
143
+ }
144
+
145
+ /**
146
+ * Returns the first line of the JSDoc/TSDoc block immediately preceding
147
+ * source index `idx`, or null if there is none.
148
+ *
149
+ * Why this is not a one-line regex: the previous implementation scanned with
150
+ * `/\/\*\*([\s\S]*?)\*\/\s*$/`. The `$` anchor plus a lazy capture makes that
151
+ * match start at the FIRST docblock in the file and end at the LAST closing
152
+ * terminator before the declaration, so every export reported the module
153
+ * header — in a real repo, four server actions all described as "Carer-facing
154
+ * mutations." and a type's doc attached to the function above it. A one-line
155
+ * block also leaked its closing terminator into docs/COMPONENT_INDEX.md.
156
+ * check-docs only requires a non-null first line, so the gate passed while the
157
+ * inventory that `reuse-before-create` is grounded in was wrong.
158
+ *
159
+ * Keep both properties: the NEAREST block, and only when nothing but whitespace
160
+ * sits between its close and the declaration. Anything else must return null —
161
+ * that is the "undocumented" verdict the gate exists to report, and it is safer
162
+ * than borrowing a neighbouring comment.
163
+ */
164
+ export function jsdocBefore(source, idx) {
165
+ const slice = source.slice(0, idx);
166
+ const start = slice.lastIndexOf('/**');
167
+ if (start === -1) return null;
168
+ const end = slice.indexOf('*/', start);
169
+ if (end === -1) return null;
170
+ if (slice.slice(end + 2).trim() !== '') return null;
171
+
172
+ const lines = slice
173
+ .slice(start + 3, end)
174
+ .split('\n')
175
+ .map((l) => l.replace(/^\s*\*+\s?/, '').trim())
176
+ .filter(Boolean);
177
+ if (lines.length === 0) return null;
178
+ const desc = lines[0];
179
+ return desc.startsWith('@') ? null : desc; // tag-only blocks are not usage docs
180
+ }
181
+
182
+ /**
183
+ * Extract exported declarations from source: [{ name, kind, index, desc }].
184
+ * `desc` is the JSDoc first line (null when undocumented).
185
+ */
186
+ export function extractExports(source) {
187
+ const found = [];
188
+ const add = (name, kind, index, isDefault) => {
189
+ if (name && !found.some((e) => e.name === name)) {
190
+ found.push({ name, kind, index, isDefault: Boolean(isDefault) });
191
+ }
192
+ };
193
+
194
+ for (const m of source.matchAll(/export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z0-9_$]+)/g)) {
195
+ add(m[1], 'function', m.index, m[0].startsWith('export default'));
196
+ }
197
+ for (const m of source.matchAll(/export\s+(?:default\s+)?const\s+([A-Za-z0-9_$]+)/g)) {
198
+ add(m[1], /=>/.test(source.slice(m.index, m.index + 200)) ? 'function' : 'value', m.index, m[0].startsWith('export default'));
199
+ }
200
+ for (const m of source.matchAll(/export\s+(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)/g)) {
201
+ add(m[1], 'class', m.index, m[0].startsWith('export default'));
202
+ }
203
+ for (const m of source.matchAll(/export\s+(?:type|interface)\s+([A-Za-z0-9_$]+)/g)) {
204
+ add(m[1], 'type', m.index, false);
205
+ }
206
+ if (/export\s+default\s+(?!function|class|const)/.test(source)) {
207
+ add('default', 'default', source.length, true);
208
+ }
209
+ return found.map((e) => ({ ...e, desc: jsdocBefore(source, e.index) }));
210
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Unit tests for the shared scanning helpers (lib.mjs). Zero dependencies —
3
+ * runs with `node --test`. The jsdocBefore cases are regression tests for a
4
+ * real failure: a `$`-anchored lazy regex attached the file's FIRST docblock
5
+ * to every export, and the gate could not notice (it only required a non-null
6
+ * first line). If you "simplify" jsdocBackfore back into a one-line regex,
7
+ * these tests are the ones that will catch it.
8
+ */
9
+
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ import {
17
+ extractExports,
18
+ isGenerated,
19
+ jsdocBefore,
20
+ lineOf,
21
+ stripNoise,
22
+ tokenize,
23
+ walk,
24
+ } from './lib.mjs';
25
+
26
+ // --- jsdocBefore -------------------------------------------------------------
27
+
28
+ test('jsdocBefore: attached block supplies its first line', () => {
29
+ const src = '/** Formats dates for tables. */\nexport function f() {}';
30
+ const idx = src.indexOf('export');
31
+ assert.equal(jsdocBefore(src, idx), 'Formats dates for tables.');
32
+ });
33
+
34
+ test('jsdocBefore: multi-line block, first meaningful line wins', () => {
35
+ const src = [
36
+ '/**',
37
+ ' * Renders the standard page shell.',
38
+ ' * @param props - ignored',
39
+ ' */',
40
+ 'export function f() {}',
41
+ ].join('\n');
42
+ const idx = src.indexOf('export');
43
+ assert.equal(jsdocBefore(src, idx), 'Renders the standard page shell.');
44
+ });
45
+
46
+ test('jsdocBefore: one-line block must not leak its terminator', () => {
47
+ const src = '/** doc */ export const x = 1';
48
+ const idx = src.indexOf('export');
49
+ const desc = jsdocBefore(src, idx);
50
+ assert.ok(!desc.includes('*/'), `terminator leaked: ${desc}`);
51
+ });
52
+
53
+ test('jsdocBefore: nearest of several blocks wins (regression: first-block bug)', () => {
54
+ const src = [
55
+ '/** Module header. */',
56
+ 'export const a = 1;',
57
+ '/** Second helper. */',
58
+ 'export const b = 2;',
59
+ ].join('\n');
60
+ const idxA = src.indexOf('export const a');
61
+ const idxB = src.indexOf('export const b');
62
+ assert.equal(jsdocBefore(src, idxA), 'Module header.');
63
+ assert.equal(jsdocBefore(src, idxB), 'Second helper.');
64
+ });
65
+
66
+ test('jsdocBefore: code between block and export means undocumented', () => {
67
+ const src = '/** Module header. */\nconst internal = 1;\nexport function f() {}';
68
+ const idx = src.indexOf('export');
69
+ assert.equal(jsdocBefore(src, idx), null);
70
+ });
71
+
72
+ test('jsdocBefore: blank lines between block and export are allowed', () => {
73
+ const src = '/** Doc. */\n\n\nexport const x = 1';
74
+ const idx = src.indexOf('export');
75
+ assert.equal(jsdocBefore(src, idx), 'Doc.');
76
+ });
77
+
78
+ test('jsdocBefore: tag-only block is not a usage doc', () => {
79
+ const src = '/** @internal */\nexport const x = 1';
80
+ const idx = src.indexOf('export');
81
+ assert.equal(jsdocBefore(src, idx), null);
82
+ });
83
+
84
+ test('jsdocBefore: no block at all -> null', () => {
85
+ assert.equal(jsdocBefore('export const x = 1', 0), null);
86
+ });
87
+
88
+ // --- extractExports -----------------------------------------------------------
89
+
90
+ test('extractExports: classifies functions, consts, classes, types, default', () => {
91
+ const src = [
92
+ 'export async function doThing() {}',
93
+ 'export const handler = () => 1;',
94
+ 'export const LIMIT = 3;',
95
+ 'export class Repo {}',
96
+ 'export type Shape = { a: number };',
97
+ 'export interface Person { id: number }',
98
+ 'export default config;',
99
+ ].join('\n');
100
+ const byName = new Map(extractExports(src).map((e) => [e.name, e]));
101
+ assert.equal(byName.get('doThing').kind, 'function');
102
+ assert.equal(byName.get('handler').kind, 'function');
103
+ assert.equal(byName.get('LIMIT').kind, 'value');
104
+ assert.equal(byName.get('Repo').kind, 'class');
105
+ assert.equal(byName.get('Shape').kind, 'type');
106
+ assert.equal(byName.get('Person').kind, 'type');
107
+ assert.equal(byName.get('default').kind, 'default');
108
+ });
109
+
110
+ test('extractExports: attached docs flow through', () => {
111
+ const src = '/** Doubles a value. */\nexport function double(x: number) { return x * 2; }';
112
+ const [e] = extractExports(src);
113
+ assert.equal(e.desc, 'Doubles a value.');
114
+ });
115
+
116
+ // --- stripNoise / tokenize / lineOf -------------------------------------------
117
+
118
+ test('stripNoise: removes comments, blanks strings, preserves newlines', () => {
119
+ const src = [
120
+ '// line comment',
121
+ 'const a = "two', // inside string newline
122
+ 'words";',
123
+ '/* block',
124
+ 'comment */',
125
+ 'const b = 1;',
126
+ ].join('\n');
127
+ const out = stripNoise(src);
128
+ assert.ok(!out.includes('line comment'));
129
+ assert.ok(!out.includes('block'));
130
+ assert.equal(out.split('\n').length, src.split('\n').length);
131
+ assert.ok(out.includes('const b = 1;'));
132
+ });
133
+
134
+ test('tokenize: strings and numbers normalized, lines tracked', () => {
135
+ const stripped = 'const msg = "hello";\nconst n = 42;';
136
+ const tokens = tokenize(stripped);
137
+ const values = tokens.map((t) => t.t);
138
+ assert.deepEqual(values, ['const', 'msg', '=', '<str>', ';', 'const', 'n', '=', '<num>', ';']);
139
+ const numLine = tokens.find((t) => t.t === '<num>').line;
140
+ assert.equal(numLine, 2);
141
+ });
142
+
143
+ test('lineOf: char index to 1-based line', () => {
144
+ const s = 'one\ntwo\nthree';
145
+ assert.equal(lineOf(s, 0), 1);
146
+ assert.equal(lineOf(s, 5), 2);
147
+ assert.equal(lineOf(s, 9), 3);
148
+ });
149
+
150
+ // --- walk / isGenerated ---------------------------------------------------------
151
+
152
+ test('walk: skips excluded dirs, tests, generated files', () => {
153
+ const root = mkdtempSync(path.join(tmpdir(), 'antislop-walk-'));
154
+ try {
155
+ mkdirSync(path.join(root, 'components'), { recursive: true });
156
+ mkdirSync(path.join(root, 'node_modules', 'pkg'), { recursive: true });
157
+ mkdirSync(path.join(root, 'dist'), { recursive: true });
158
+ writeFileSync(path.join(root, 'components', 'Card.tsx'), 'export const Card = 1;');
159
+ writeFileSync(path.join(root, 'components', 'Card.test.tsx'), 'export const t = 1;');
160
+ writeFileSync(path.join(root, 'components', 'util.test.mjs'), 'export const u = 1;');
161
+ writeFileSync(path.join(root, 'payload-types.ts'), 'export type X = 1;');
162
+ writeFileSync(path.join(root, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1;');
163
+ writeFileSync(path.join(root, 'dist', 'out.js'), 'export const o = 1;');
164
+
165
+ const rels = walk(root, root).map((f) => f.rel);
166
+ assert.deepEqual(rels, ['components/Card.tsx']);
167
+ } finally {
168
+ rmSync(root, { recursive: true, force: true });
169
+ }
170
+ });
171
+
172
+ test('isGenerated: payload-types and next-env, nothing else', () => {
173
+ assert.ok(isGenerated('payload-types.ts'));
174
+ assert.ok(isGenerated('src/app/payload-types.ts'));
175
+ assert.ok(isGenerated('next-env.d.ts'));
176
+ assert.ok(!isGenerated('src/lib/dbs.ts'));
177
+ assert.ok(!isGenerated('types.ts'));
178
+ });
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * run-project-checks.mjs — pre-push project checks, without silent skips.
4
+ *
5
+ * Runs the repo's own lint/typecheck/test scripts with the package manager
6
+ * detected from the lockfile, and:
7
+ * - prints every check as present or skipped, so "nothing ran" is visible;
8
+ * - falls back to `tsc --noEmit` when there is no typecheck script but a
9
+ * tsconfig.json exists (a TypeScript repo must not pass untypechecked);
10
+ * - works with npm, pnpm, yarn and bun.
11
+ *
12
+ * Zero dependencies. Exits non-zero on the first failing check.
13
+ */
14
+
15
+ import { readFileSync, existsSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+
19
+ const cwd = process.cwd();
20
+ const pkg = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
21
+ const scripts = pkg.scripts ?? {};
22
+
23
+ function detectPackageManager() {
24
+ if (existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
25
+ if (existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn';
26
+ if (existsSync(path.join(cwd, 'bun.lockb')) || existsSync(path.join(cwd, 'bun.lock'))) return 'bun';
27
+ return 'npm';
28
+ }
29
+
30
+ const pm = detectPackageManager();
31
+ const run = (cmd, args) => spawnSync(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32' });
32
+
33
+ /** Run `<pm> run <script>` when it exists. Returns 'ran' | 'skipped'. */
34
+ function runScript(name) {
35
+ if (!scripts[name]) {
36
+ console.log(`[skip] ${name}: no "${name}" script in package.json`);
37
+ return 'skipped';
38
+ }
39
+ console.log(`[run] ${name} (${pm} run ${name})`);
40
+ const res = run(pm, ['run', name]);
41
+ if (res.status !== 0) {
42
+ console.error(`\n[x] "${name}" failed (exit ${res.status ?? 'signal'}).`);
43
+ process.exit(res.status ?? 1);
44
+ }
45
+ return 'ran';
46
+ }
47
+
48
+ /** tsc --noEmit fallback for TypeScript repos without a typecheck script. */
49
+ function runTypecheck() {
50
+ if (scripts.typecheck) return runScript('typecheck');
51
+
52
+ const hasTs = existsSync(path.join(cwd, 'tsconfig.json')) ||
53
+ scripts.typescript !== undefined ||
54
+ (pkg.devDependencies && pkg.devDependencies.typescript);
55
+ if (!hasTs) {
56
+ console.log('[skip] typecheck: no typecheck script and no TypeScript setup detected');
57
+ return 'skipped';
58
+ }
59
+ console.log('[run] typecheck (fallback: tsc --noEmit — no "typecheck" script was found)');
60
+ const exec = pm === 'bun' ? 'bunx' : pm === 'npm' ? 'npx' : pm;
61
+ const res = run(exec, ['--no-install', 'tsc', '--noEmit']);
62
+ if (res.status !== 0) {
63
+ console.error(`\n[x] typecheck failed (exit ${res.status ?? 'signal'}).`);
64
+ process.exit(res.status ?? 1);
65
+ }
66
+ return 'ran';
67
+ }
68
+
69
+ console.log(`[anti-slop] project checks via ${pm}`);
70
+ runScript('lint');
71
+ runTypecheck();
72
+ runScript('test');
73
+ console.log('[ok] project checks done.');
@@ -0,0 +1,22 @@
1
+ # Anti-slop gates for this repository (installed by opencode-anti-slop).
2
+ # Runs the deterministic checks on every push and pull request. Tune the
3
+ # checks via anti-slop.config.json at the repo root.
4
+ name: anti-slop
5
+
6
+ on:
7
+ push:
8
+ pull_request:
9
+
10
+ jobs:
11
+ gates:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: 22
18
+ - run: node scripts/anti-slop/check-modularity.mjs
19
+ - run: node scripts/anti-slop/check-imports.mjs
20
+ - run: node scripts/anti-slop/check-docs.mjs
21
+ - run: node scripts/anti-slop/check-duplicates.mjs
22
+ - run: node scripts/anti-slop/generate-component-index.mjs --check