opencode-anti-slop 0.1.1 → 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.
@@ -1,133 +1,162 @@
1
- #!/usr/bin/env node
2
- /**
3
- * generate-component-index.mjs
4
- *
5
- * Scans a source tree and (re)generates docs/COMPONENT_INDEX.md — the
6
- * inventory that the `reuse-before-create` skill treats as the source of
7
- * truth for existing layouts, components, hooks, and utilities.
8
- *
9
- * Zero dependencies (plain Node >= 18, ESM). Classification is heuristic
10
- * (paths + export names + file extension); it is a discovery aid, not a parser.
11
- *
12
- * Usage:
13
- * node scripts/anti-slop/generate-component-index.mjs [--src src] [--out docs/COMPONENT_INDEX.md] [--root .]
14
- *
15
- * A "## Manual Notes" section at the end of an existing index is preserved.
16
- */
17
-
18
- import {
19
- readdirSync,
20
- readFileSync,
21
- writeFileSync,
22
- existsSync,
23
- mkdirSync,
24
- } from 'node:fs';
25
- import path from 'node:path';
26
- import { walk, extractExports } from './lib.mjs';
27
-
28
- // --- args -------------------------------------------------------------------
29
- function parseArgs(argv) {
30
- const args = { src: 'src', out: 'docs/COMPONENT_INDEX.md', root: '.' };
31
- for (let i = 2; i < argv.length; i++) {
32
- const a = argv[i];
33
- if (a === '--src') args.src = argv[++i];
34
- else if (a === '--out') args.out = argv[++i];
35
- else if (a === '--root') args.root = argv[++i];
36
- else if (a === '-h' || a === '--help') {
37
- console.log('Usage: node generate-component-index.mjs [--src src] [--out docs/COMPONENT_INDEX.md] [--root .]');
38
- process.exit(0);
39
- } else {
40
- console.error(`Unknown argument: ${a}`);
41
- process.exit(1);
42
- }
43
- }
44
- return args;
45
- }
46
-
47
- const args = parseArgs(process.argv);
48
-
49
- const SRC_DIR = path.resolve(args.root, args.src);
50
- const OUT_FILE = path.resolve(args.root, args.out);
51
-
52
- if (!existsSync(SRC_DIR)) {
53
- console.error(`Source directory not found: ${SRC_DIR}`);
54
- process.exit(1);
55
- }
56
-
57
- // --- classification ---------------------------------------------------------
58
- function classify(relPath, exports) {
59
- const base = path.basename(relPath);
60
- const dir = path.dirname(relPath).toLowerCase();
61
- const baseName = path.basename(base, path.extname(base));
62
- const isTsx = /\.(tsx|jsx)$/.test(base);
63
-
64
- const hasJsxLike = exports.length > 0 && isTsx;
65
-
66
- if (/^use[A-Z0-9]/.test(baseName) || /(^|\/)hooks?\//.test(dir)) return 'Hook';
67
- if (/(^|\/)layouts?(\/|$)/.test(dir) || /layout/i.test(baseName)) return 'Layout';
68
- if (hasJsxLike && exports.some((e) => /^[A-Z]/.test(e.name))) return 'Component';
69
- if (/(^|\/)(services?|adapters?)(\/|$)/.test(dir)) return 'Service';
70
- if (/(^|\/)(utils?|lib|helpers?)(\/|$)/.test(dir)) return 'Util';
71
- if (exports.some((e) => e.kind === 'type')) return 'Types';
72
- return 'Module';
73
- }
74
-
75
- // --- build ------------------------------------------------------------------
76
- const files = walk(SRC_DIR, SRC_DIR);
77
- const index = new Map(); // kind -> [{rel, name, kind, desc, lines}]
78
- let missingDocs = 0;
79
-
80
- for (const file of files) {
81
- const source = readFileSync(file.full, 'utf8');
82
- const exports = extractExports(source);
83
- if (exports.length === 0) continue;
84
- const kind = classify(file.rel, exports);
85
- const lines = source.split('\n').length;
86
- const list = index.get(kind) ?? [];
87
- for (const exp of exports) {
88
- if (!exp.desc) missingDocs++;
89
- list.push({ rel: file.rel, name: exp.name, kind: exp.kind, desc: exp.desc, lines });
90
- }
91
- index.set(kind, list);
92
- }
93
-
94
- // --- preserve manual notes --------------------------------------------------
95
- let manualNotes = '';
96
- if (existsSync(OUT_FILE)) {
97
- const existing = readFileSync(OUT_FILE, 'utf8');
98
- const marker = existing.indexOf('## Manual Notes');
99
- if (marker !== -1) manualNotes = existing.slice(marker);
100
- }
101
-
102
- // --- render -----------------------------------------------------------------
103
- const KIND_ORDER = ['Layout', 'Component', 'Hook', 'Service', 'Util', 'Types', 'Module'];
104
- const totalExports = KIND_ORDER.reduce((n, k) => n + (index.get(k)?.length ?? 0), 0);
105
-
106
- let md = `# Component Index\n\n`;
107
- md += `<!-- generated: run \`node scripts/anti-slop/generate-component-index.mjs\` to regenerate -->\n\n`;
108
- md += `Inventory of shared layouts, components, hooks, services, and utilities under \`${args.src}/\`. `;
109
- md += `Generated ${new Date().toISOString().slice(0, 10)}, ${files.length} files, ${totalExports} exports.\n\n`;
110
- md += `> Rule of use: before creating any new component, hook, or utility, check this file first (see the \`reuse-before-create\` skill). When you add a shared abstraction, rerun the generator and fill in the "When to use" column.\n\n`;
111
-
112
- for (const kind of KIND_ORDER) {
113
- const list = index.get(kind);
114
- if (!list || list.length === 0) continue;
115
- md += `## ${kind}s (${list.length})\n\n`;
116
- md += `| File | Export | When to use |\n|---|---|---|\n`;
117
- for (const e of list) {
118
- const doc = e.desc ? e.desc.replace(/\|/g, '\\|') : '';
119
- md += `| \`${e.rel}\` | \`${e.name}\`${e.kind === 'type' ? ' *(type)*' : ''} | ${doc || '-'} |\n`;
120
- }
121
- md += `\n`;
122
- }
123
-
124
- md += manualNotes ? `${manualNotes}\n` : `## Manual Notes\n\n(Add notes here; they are preserved when the index is regenerated.)\n`;
125
-
126
- mkdirSync(path.dirname(OUT_FILE), { recursive: true });
127
- writeFileSync(OUT_FILE, md);
128
-
129
- console.log(`Wrote ${OUT_FILE}`);
130
- console.log(` ${files.length} source files, ${totalExports} exports across ${KIND_ORDER.filter((k) => index.has(k)).length} categories.`);
131
- if (missingDocs > 0) {
132
- console.log(` [doc] ${missingDocs} export(s) have no JSDoc description. Shared components/hooks/utils must carry a one-line "/** When to use ... */" above the export.`);
133
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-component-index.mjs
4
+ *
5
+ * Scans a source tree and (re)generates docs/COMPONENT_INDEX.md — the
6
+ * inventory that the `reuse-before-create` skill treats as the source of
7
+ * truth for existing layouts, components, hooks, and utilities.
8
+ *
9
+ * Zero dependencies (plain Node >= 18, ESM). Classification is heuristic
10
+ * (paths + export names + file extension); it is a discovery aid, not a parser.
11
+ *
12
+ * Usage:
13
+ * node scripts/anti-slop/generate-component-index.mjs [--src src] [--out docs/COMPONENT_INDEX.md] [--root .]
14
+ *
15
+ * A "## Manual Notes" section at the end of an existing index is preserved.
16
+ */
17
+
18
+ import {
19
+ readFileSync,
20
+ writeFileSync,
21
+ existsSync,
22
+ mkdirSync,
23
+ } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { walk, extractExports } from './lib.mjs';
26
+ import { loadConfig, applyIgnorePaths } from './cli.mjs';
27
+
28
+ // --- args -------------------------------------------------------------------
29
+ function parseArgs(argv) {
30
+ const args = { src: 'src', out: 'docs/COMPONENT_INDEX.md', root: '.', check: false };
31
+ for (let i = 2; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === '--src') args.src = argv[++i];
34
+ else if (a === '--out') args.out = argv[++i];
35
+ else if (a === '--root') args.root = argv[++i];
36
+ else if (a === '--check') args.check = true;
37
+ else if (a === '-h' || a === '--help') {
38
+ console.log('Usage: node generate-component-index.mjs [--src src] [--out docs/COMPONENT_INDEX.md] [--root .] [--check]');
39
+ console.log(' --check compare against the file on disk; exit 1 when stale (CI mode, writes nothing)');
40
+ process.exit(0);
41
+ } else {
42
+ console.error(`Unknown argument: ${a}`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+ return args;
47
+ }
48
+
49
+ const args = parseArgs(process.argv);
50
+
51
+ const SRC_DIR = path.resolve(args.root, args.src);
52
+ const OUT_FILE = path.resolve(args.root, args.out);
53
+
54
+ if (!existsSync(SRC_DIR)) {
55
+ console.error(`Source directory not found: ${SRC_DIR}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ // --- classification ---------------------------------------------------------
60
+ function classify(relPath, exports) {
61
+ const base = path.basename(relPath);
62
+ const dir = path.dirname(relPath).toLowerCase();
63
+ const baseName = path.basename(base, path.extname(base));
64
+ const isTsx = /\.(tsx|jsx)$/.test(base);
65
+
66
+ const hasJsxLike = exports.length > 0 && isTsx;
67
+
68
+ if (/^use[A-Z0-9]/.test(baseName) || /(^|\/)hooks?\//.test(dir)) return 'Hook';
69
+ if (/(^|\/)layouts?(\/|$)/.test(dir) || /layout/i.test(baseName)) return 'Layout';
70
+ if (hasJsxLike && exports.some((e) => /^[A-Z]/.test(e.name))) return 'Component';
71
+ if (/(^|\/)(services?|adapters?)(\/|$)/.test(dir)) return 'Service';
72
+ if (/(^|\/)(utils?|lib|helpers?)(\/|$)/.test(dir)) return 'Util';
73
+ if (exports.some((e) => e.kind === 'type')) return 'Types';
74
+ return 'Module';
75
+ }
76
+
77
+ // --- build ------------------------------------------------------------------
78
+ const files = walk(SRC_DIR, SRC_DIR);
79
+ // The generator and the docs gate must agree on what exists: docs.ignorePaths
80
+ // excludes framework-owned or declarative files from both the listing and the
81
+ // undocumented count, so the two never report different totals.
82
+ const ignorePaths = Array.isArray(loadConfig().docs?.ignorePaths)
83
+ ? loadConfig().docs.ignorePaths
84
+ : [];
85
+ const filtered = applyIgnorePaths(files, ignorePaths, { label: 'docs.ignorePaths', src: args.src });
86
+ const index = new Map(); // kind -> [{rel, name, kind, desc, lines}]
87
+ let missingDocs = 0;
88
+
89
+ for (const file of filtered) {
90
+ const source = readFileSync(file.full, 'utf8');
91
+ const exports = extractExports(source);
92
+ if (exports.length === 0) continue;
93
+ const kind = classify(file.rel, exports);
94
+ const lines = source.split('\n').length;
95
+ const list = index.get(kind) ?? [];
96
+ for (const exp of exports) {
97
+ // The docs gate exempts default exports (route entry points etc.); the
98
+ // generator's count must agree, or the two report different totals.
99
+ if (!exp.desc && exp.kind !== 'default') missingDocs++;
100
+ list.push({ rel: file.rel, name: exp.name, kind: exp.kind, desc: exp.desc, lines });
101
+ }
102
+ index.set(kind, list);
103
+ }
104
+
105
+ // --- preserve manual notes --------------------------------------------------
106
+ let manualNotes = '';
107
+ if (existsSync(OUT_FILE)) {
108
+ const existing = readFileSync(OUT_FILE, 'utf8');
109
+ const marker = existing.indexOf('## Manual Notes');
110
+ if (marker !== -1) manualNotes = existing.slice(marker);
111
+ }
112
+
113
+ // --- render -----------------------------------------------------------------
114
+ const KIND_ORDER = ['Layout', 'Component', 'Hook', 'Service', 'Util', 'Types', 'Module'];
115
+ // Categories already plural keep their name; everything else takes a plain 's'.
116
+ const PLURAL = { Types: 'Types' };
117
+ const heading = (kind) => PLURAL[kind] ?? `${kind}s`;
118
+ const totalExports = KIND_ORDER.reduce((n, k) => n + (index.get(k)?.length ?? 0), 0);
119
+
120
+ let md = `# Component Index\n\n`;
121
+ md += `<!-- generated: run \`node scripts/anti-slop/generate-component-index.mjs\` to regenerate -->\n\n`;
122
+ md += `Inventory of shared layouts, components, hooks, services, and utilities under \`${args.src}/\`. `;
123
+ md += `${filtered.length} files, ${totalExports} exports.\n\n`;
124
+ md += `> Rule of use: before creating any new component, hook, or utility, check this file first (see the \`reuse-before-create\` skill). When you add a shared abstraction, rerun the generator and fill in the "When to use" column.\n\n`;
125
+
126
+ for (const kind of KIND_ORDER) {
127
+ const list = index.get(kind);
128
+ if (!list || list.length === 0) continue;
129
+ md += `## ${heading(kind)} (${list.length})\n\n`;
130
+ md += `| File | Export | When to use |\n|---|---|---|\n`;
131
+ for (const e of list) {
132
+ const doc = e.desc ? e.desc.replace(/\|/g, '\\|') : '';
133
+ md += `| \`${e.rel}\` | \`${e.name}\`${e.kind === 'type' ? ' *(type)*' : ''} | ${doc || '-'} |\n`;
134
+ }
135
+ md += `\n`;
136
+ }
137
+
138
+ md += manualNotes ? `${manualNotes.trimEnd()}\n` : `## Manual Notes\n\n(Add notes here; they are preserved when the index is regenerated.)\n`;
139
+ md = md.replace(/\n+$/, '\n');
140
+
141
+ if (args.check) {
142
+ if (!existsSync(OUT_FILE)) {
143
+ console.error(`[x] Index check FAILED: ${OUT_FILE} does not exist. Run the generator without --check and commit the result.`);
144
+ process.exit(1);
145
+ }
146
+ const onDisk = readFileSync(OUT_FILE, 'utf8').replace(/\n+$/, '\n');
147
+ if (onDisk === md) {
148
+ console.log(`[ok] Component index is up to date (${OUT_FILE}).`);
149
+ process.exit(0);
150
+ }
151
+ console.error(`[x] Index check FAILED: ${OUT_FILE} is stale. Run \`node scripts/anti-slop/generate-component-index.mjs\` and commit the result.`);
152
+ process.exit(1);
153
+ }
154
+
155
+ mkdirSync(path.dirname(OUT_FILE), { recursive: true });
156
+ writeFileSync(OUT_FILE, md);
157
+
158
+ console.log(`Wrote ${OUT_FILE}`);
159
+ console.log(` ${filtered.length} source files, ${totalExports} exports across ${KIND_ORDER.filter((k) => index.has(k)).length} categories.`);
160
+ if (missingDocs > 0) {
161
+ console.log(` [doc] ${missingDocs} export(s) have no JSDoc description. Shared components/hooks/utils must carry a one-line "/** When to use ... */" above the export.`);
162
+ }
@@ -12,7 +12,7 @@
12
12
  * Existing hooks are never overwritten without --force.
13
13
  */
14
14
 
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
15
+ import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
16
16
  import path from 'node:path';
17
17
 
18
18
  const force = process.argv.includes('--force');
@@ -52,8 +52,8 @@ node scripts/anti-slop/check-docs.mjs $staged || exit 1
52
52
  echo "[anti-slop] import-boundary report (non-blocking; cycles are tree-wide)..."
53
53
  node scripts/anti-slop/check-imports.mjs --report-only || true
54
54
 
55
- echo "[anti-slop] duplicate-code report (non-blocking):"
56
- node scripts/anti-slop/check-duplicates.mjs --min-tokens 20 --threshold 0 || true
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
57
 
58
58
  exit 0
59
59
  `;
@@ -66,10 +66,8 @@ node scripts/anti-slop/check-duplicates.mjs || exit 1
66
66
  echo "[anti-slop] import-boundary gate..."
67
67
  node scripts/anti-slop/check-imports.mjs || exit 1
68
68
 
69
- echo "[anti-slop] project checks (--if-present)..."
70
- npm run lint --if-present || exit 1
71
- npm run typecheck --if-present || exit 1
72
- npm test --if-present || exit 1
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
73
71
 
74
72
  exit 0
75
73
  `;
@@ -44,11 +44,11 @@ export function walk(dir, base) {
44
44
  }
45
45
 
46
46
  /**
47
- * Remove comments and string contents so brace/regex analysis is reliable.
48
- * Newlines (including those inside strings) are preserved, so line numbers
49
- * computed on the output match the original source.
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
50
  */
51
- export function stripNoise(src) {
51
+ function stripSource(src, blankStrings) {
52
52
  let out = '';
53
53
  let i = 0;
54
54
  const n = src.length;
@@ -67,13 +67,28 @@ export function stripNoise(src) {
67
67
  } else if (c === '"' || c === "'" || c === '`') {
68
68
  const quote = c;
69
69
  i++;
70
- while (i < n && src[i] !== quote) {
71
- if (src[i] === '\\') i++;
72
- if (src[i] === '\n') out += '\n';
73
- 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
74
91
  }
75
- i++; // closing quote
76
- out += '""';
77
92
  } else {
78
93
  out += c;
79
94
  i++;
@@ -83,32 +98,21 @@ export function stripNoise(src) {
83
98
  }
84
99
 
85
100
  /**
86
- * Remove comments while KEEPING string contents, preserving newlines. Used
87
- * where the text inside strings matters (import specifier parsing) but
88
- * comment noise must not create false edges.
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.
89
113
  */
90
114
  export function stripComments(src) {
91
- let out = '';
92
- let i = 0;
93
- const n = src.length;
94
- while (i < n) {
95
- const c = src[i];
96
- const next = src[i + 1];
97
- if (c === '/' && next === '/') {
98
- while (i < n && src[i] !== '\n') i++;
99
- } else if (c === '/' && next === '*') {
100
- i += 2;
101
- while (i < n && !(src[i] === '*' && src[i + 1] === '/')) {
102
- if (src[i] === '\n') out += '\n';
103
- i++;
104
- }
105
- i += 2;
106
- } else {
107
- out += c;
108
- i++;
109
- }
110
- }
111
- return out;
115
+ return stripSource(src, false);
112
116
  }
113
117
 
114
118
  const TOKEN_RE =
@@ -181,24 +185,26 @@ export function jsdocBefore(source, idx) {
181
185
  */
182
186
  export function extractExports(source) {
183
187
  const found = [];
184
- const add = (name, kind, index) => {
185
- if (name && !found.some((e) => e.name === name)) found.push({ name, kind, index });
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
+ }
186
192
  };
187
193
 
188
194
  for (const m of source.matchAll(/export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z0-9_$]+)/g)) {
189
- add(m[1], 'function', m.index);
195
+ add(m[1], 'function', m.index, m[0].startsWith('export default'));
190
196
  }
191
197
  for (const m of source.matchAll(/export\s+(?:default\s+)?const\s+([A-Za-z0-9_$]+)/g)) {
192
- add(m[1], /=>/.test(source.slice(m.index, m.index + 200)) ? 'function' : 'value', m.index);
198
+ add(m[1], /=>/.test(source.slice(m.index, m.index + 200)) ? 'function' : 'value', m.index, m[0].startsWith('export default'));
193
199
  }
194
200
  for (const m of source.matchAll(/export\s+(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)/g)) {
195
- add(m[1], 'class', m.index);
201
+ add(m[1], 'class', m.index, m[0].startsWith('export default'));
196
202
  }
197
203
  for (const m of source.matchAll(/export\s+(?:type|interface)\s+([A-Za-z0-9_$]+)/g)) {
198
- add(m[1], 'type', m.index);
204
+ add(m[1], 'type', m.index, false);
199
205
  }
200
206
  if (/export\s+default\s+(?!function|class|const)/.test(source)) {
201
- add('default', 'default', source.length);
207
+ add('default', 'default', source.length, true);
202
208
  }
203
209
  return found.map((e) => ({ ...e, desc: jsdocBefore(source, e.index) }));
204
210
  }
@@ -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.');
@@ -19,4 +19,4 @@ jobs:
19
19
  - run: node scripts/anti-slop/check-imports.mjs
20
20
  - run: node scripts/anti-slop/check-docs.mjs
21
21
  - run: node scripts/anti-slop/check-duplicates.mjs
22
- - run: node scripts/anti-slop/generate-component-index.mjs
22
+ - run: node scripts/anti-slop/generate-component-index.mjs --check