opencode-anti-slop 0.0.0-stage → 0.1.1
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/.opencode/commands/audit-ui.md +34 -0
- package/.opencode/skills/abstraction-review/SKILL.md +76 -0
- package/.opencode/skills/layout-composition/SKILL.md +78 -0
- package/.opencode/skills/modular-architecture/SKILL.md +65 -0
- package/.opencode/skills/react-composition-patterns/SKILL.md +43 -0
- package/.opencode/skills/react-state-and-effects/SKILL.md +61 -0
- package/.opencode/skills/reuse-before-create/SKILL.md +108 -0
- package/.opencode/skills/verify-clean-code/SKILL.md +78 -0
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/install.mjs +157 -0
- package/package.json +41 -4
- package/scripts/anti-slop/check-docs.mjs +70 -0
- package/scripts/anti-slop/check-duplicates.mjs +182 -0
- package/scripts/anti-slop/check-imports.mjs +209 -0
- package/scripts/anti-slop/check-imports.test.mjs +128 -0
- package/scripts/anti-slop/check-modularity.mjs +150 -0
- package/scripts/anti-slop/cli.mjs +175 -0
- package/scripts/anti-slop/cli.test.mjs +106 -0
- package/scripts/anti-slop/generate-component-index.mjs +133 -0
- package/scripts/anti-slop/install-hooks.mjs +99 -0
- package/scripts/anti-slop/lib.mjs +204 -0
- package/scripts/anti-slop/lib.test.mjs +178 -0
- package/templates/anti-slop-ci.yml +22 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli.mjs — shared CLI/config plumbing for the anti-slop gate scripts.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies (Node >= 18, ESM). Source-scanning helpers live in lib.mjs;
|
|
5
|
+
* this module handles flag parsing, per-repo config loading, and path-to-file
|
|
6
|
+
* resolution. Keep it free of scanning logic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { walk, SOURCE_EXT, IGNORE_FILE } from './lib.mjs';
|
|
12
|
+
|
|
13
|
+
// --- CLI parsing ---------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse CLI flags uniformly across gate scripts.
|
|
17
|
+
*
|
|
18
|
+
* spec maps flag -> type: 'string' | 'number' | 'flag' (boolean, no value).
|
|
19
|
+
* argv starts after the script path. Unknown flags and bare positional words
|
|
20
|
+
* land in `positionals`; each script decides whether positionals are an error
|
|
21
|
+
* or file paths.
|
|
22
|
+
*
|
|
23
|
+
* Returns { values: Record<flag, string|number|true>, positionals: string[] }.
|
|
24
|
+
*/
|
|
25
|
+
export function parseFlags(argv, spec) {
|
|
26
|
+
const values = {};
|
|
27
|
+
const positionals = [];
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const s = argv[i];
|
|
30
|
+
const type = spec[s];
|
|
31
|
+
if (!type) {
|
|
32
|
+
positionals.push(s);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (type === 'flag') {
|
|
36
|
+
values[s] = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const raw = argv[++i];
|
|
40
|
+
if (raw === undefined) {
|
|
41
|
+
throw new Error(`Missing value for ${s}`);
|
|
42
|
+
}
|
|
43
|
+
const v = type === 'number' ? Number(raw) : raw;
|
|
44
|
+
if (type === 'number' && Number.isNaN(v)) {
|
|
45
|
+
throw new Error(`${s} expects a number, got: ${raw}`);
|
|
46
|
+
}
|
|
47
|
+
values[s] = v;
|
|
48
|
+
}
|
|
49
|
+
return { values, positionals };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Common gate bootstrap: handle -h/--help with `usage`, parse flags per `spec`,
|
|
54
|
+
* and merge flag values over `defaults` (config-supplied). Flag names are
|
|
55
|
+
* camelCased into arg keys: --min-tokens -> args.minTokens, --report-only ->
|
|
56
|
+
* args.reportOnly (flags become true/false).
|
|
57
|
+
*
|
|
58
|
+
* Returns { args, positionals }. Never returns on -h (exits 0) or a parse
|
|
59
|
+
* error (exits 1).
|
|
60
|
+
*/
|
|
61
|
+
export function bootstrapGate(argv, { usage, spec, defaults }) {
|
|
62
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
63
|
+
console.log(usage);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = parseFlags(argv, spec);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(err.message);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const args = { ...defaults };
|
|
74
|
+
for (const [flag, type] of Object.entries(spec)) {
|
|
75
|
+
const key = flag.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
76
|
+
args[key] = type === 'flag'
|
|
77
|
+
? parsed.values[flag] === true
|
|
78
|
+
: (parsed.values[flag] ?? defaults[key]);
|
|
79
|
+
}
|
|
80
|
+
return { args, positionals: parsed.positionals };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve explicit path arguments (files or directories) into { full, rel }
|
|
85
|
+
* source entries, applying the same filters as walk(). Exits 1 on a missing
|
|
86
|
+
* path. Used by gate scripts in git-hook staged-file mode.
|
|
87
|
+
*/
|
|
88
|
+
export function collectPathFiles(paths) {
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const p of paths) {
|
|
91
|
+
const abs = path.resolve(p);
|
|
92
|
+
if (!existsSync(abs)) {
|
|
93
|
+
console.error(`Path not found: ${p}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
if (statSync(abs).isDirectory()) {
|
|
97
|
+
files.push(...walk(abs, abs));
|
|
98
|
+
} else if (SOURCE_EXT.has(path.extname(abs)) && !IGNORE_FILE.test(abs)) {
|
|
99
|
+
files.push({ full: abs, rel: path.relative(process.cwd(), abs).split(path.sep).join('/') });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return files;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the scan root given to a gate (--src): accepts a directory (walked)
|
|
107
|
+
* or a single file. Exits 1 when the path does not exist.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveScanTarget(src) {
|
|
110
|
+
const abs = path.resolve(src);
|
|
111
|
+
if (!existsSync(abs)) {
|
|
112
|
+
console.error(`Source directory not found: ${abs}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
if (statSync(abs).isFile()) {
|
|
116
|
+
return collectPathFiles([src]);
|
|
117
|
+
}
|
|
118
|
+
return walk(abs, abs);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- per-repo config ----------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reads `anti-slop.config.json` from process.cwd() (repo root), returning the
|
|
125
|
+
* parsed object or {} when absent/invalid (with a warning on invalid).
|
|
126
|
+
* Scripts merge: defaults < config file < CLI flags.
|
|
127
|
+
*
|
|
128
|
+
* Shape:
|
|
129
|
+
* {
|
|
130
|
+
* "duplicates": { "src": "src", "minTokens": 50, "threshold": 3,
|
|
131
|
+
* "ignore": ["collections/**"] },
|
|
132
|
+
* "modularity": { "src": "src", "maxFileLines": 300, "maxFunctionLines": 80,
|
|
133
|
+
* "maxClassMethods": 10 },
|
|
134
|
+
* "docs": { "src": "src" }
|
|
135
|
+
* }
|
|
136
|
+
*
|
|
137
|
+
* `ignore` (duplicates only) excludes framework-idiomatic declarative files
|
|
138
|
+
* (collection configs, generated tables) whose repetition is shape, not logic.
|
|
139
|
+
*/
|
|
140
|
+
export function loadConfig() {
|
|
141
|
+
const file = path.join(process.cwd(), 'anti-slop.config.json');
|
|
142
|
+
if (!existsSync(file)) return {};
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(`[warn] Could not parse anti-slop.config.json (${err.message}); using defaults.`);
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Convert a glob (`**`, `*`, `?`) into a RegExp tested against '/'-separated rel paths. */
|
|
152
|
+
export function globToRegex(glob) {
|
|
153
|
+
let pattern = '';
|
|
154
|
+
for (let i = 0; i < glob.length; i++) {
|
|
155
|
+
const c = glob[i];
|
|
156
|
+
if (c === '*') {
|
|
157
|
+
if (glob[i + 1] === '*') {
|
|
158
|
+
pattern += '[\\s\\S]*';
|
|
159
|
+
i++;
|
|
160
|
+
} else {
|
|
161
|
+
pattern += '[^/]*';
|
|
162
|
+
}
|
|
163
|
+
} else if (c === '?') {
|
|
164
|
+
pattern += '[^/]';
|
|
165
|
+
} else {
|
|
166
|
+
pattern += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return new RegExp(`^${pattern}$`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True when relPath matches any ignore glob. */
|
|
173
|
+
export function matchesAnyGlob(relPath, globs) {
|
|
174
|
+
return globs.length > 0 && globs.some((g) => globToRegex(g).test(relPath));
|
|
175
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for CLI/config plumbing (cli.mjs). Zero dependencies — runs with
|
|
3
|
+
* `node --test`. bootstrapGate's flag-to-key camelCasing and the
|
|
4
|
+
* defaults < config < flags merge are contract tests: the gates' hooks call
|
|
5
|
+
* the scripts bare, so the config file must govern them without flags.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { bootstrapGate, globToRegex, matchesAnyGlob, parseFlags } from './cli.mjs';
|
|
15
|
+
|
|
16
|
+
// --- parseFlags ----------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
test('parseFlags: string, number, and flag types', () => {
|
|
19
|
+
const { values, positionals } = parseFlags(
|
|
20
|
+
['--src', 'lib', '--min-tokens', '30', '--report-only'],
|
|
21
|
+
{ '--src': 'string', '--min-tokens': 'number', '--report-only': 'flag' },
|
|
22
|
+
);
|
|
23
|
+
assert.equal(values['--src'], 'lib');
|
|
24
|
+
assert.equal(values['--min-tokens'], 30);
|
|
25
|
+
assert.equal(values['--report-only'], true);
|
|
26
|
+
assert.deepEqual(positionals, []);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('parseFlags: bare words land in positionals', () => {
|
|
30
|
+
const { values, positionals } = parseFlags(['src/a.ts', 'src/b.ts'], {});
|
|
31
|
+
assert.deepEqual(values, {});
|
|
32
|
+
assert.deepEqual(positionals, ['src/a.ts', 'src/b.ts']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('parseFlags: missing value throws', () => {
|
|
36
|
+
assert.throws(() => parseFlags(['--src'], { '--src': 'string' }), /Missing value/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('parseFlags: non-numeric number throws', () => {
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => parseFlags(['--max-file-lines', 'abc'], { '--max-file-lines': 'number' }),
|
|
42
|
+
/expects a number/,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// --- bootstrapGate ----------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const SPEC = {
|
|
49
|
+
'--src': 'string', '--min-tokens': 'number', '--threshold': 'number',
|
|
50
|
+
'--report-only': 'flag',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
test('bootstrapGate: flags override defaults, keys camelCased', () => {
|
|
54
|
+
const { args, positionals } = bootstrapGate(
|
|
55
|
+
['--min-tokens', '20'],
|
|
56
|
+
{
|
|
57
|
+
usage: 'usage',
|
|
58
|
+
spec: SPEC,
|
|
59
|
+
defaults: { src: 'src', minTokens: 50, threshold: 3, reportOnly: false },
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
assert.equal(args.src, 'src');
|
|
63
|
+
assert.equal(args.minTokens, 20);
|
|
64
|
+
assert.equal(args.threshold, 3);
|
|
65
|
+
assert.equal(args.reportOnly, false);
|
|
66
|
+
assert.deepEqual(positionals, []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('bootstrapGate: config defaults flow through unchanged', () => {
|
|
70
|
+
const { args } = bootstrapGate([], {
|
|
71
|
+
usage: 'usage',
|
|
72
|
+
spec: SPEC,
|
|
73
|
+
defaults: { src: 'app', minTokens: 80, threshold: 5 },
|
|
74
|
+
});
|
|
75
|
+
assert.deepEqual(args, { src: 'app', minTokens: 80, threshold: 5, reportOnly: false });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('bootstrapGate: --report-only becomes boolean true', () => {
|
|
79
|
+
const { args } = bootstrapGate(['--report-only'], {
|
|
80
|
+
usage: 'usage', spec: SPEC, defaults: { src: 'src', reportOnly: false },
|
|
81
|
+
});
|
|
82
|
+
assert.equal(args.reportOnly, true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// --- glob matching -----------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
test('globToRegex: ** crosses directories, * stays within one', () => {
|
|
88
|
+
assert.ok(globToRegex('collections/**').test('collections/AccessGrants.ts'));
|
|
89
|
+
assert.ok(globToRegex('collections/**').test('collections/nested/Clients.ts'));
|
|
90
|
+
assert.ok(!globToRegex('collections/**').test('src/collections/Clients.ts'));
|
|
91
|
+
assert.ok(globToRegex('app/(payload)/**').test('app/(payload)/admin/page.tsx'));
|
|
92
|
+
assert.ok(globToRegex('lib/*.ts').test('lib/date.ts'));
|
|
93
|
+
assert.ok(!globToRegex('lib/*.ts').test('lib/deep/date.ts'));
|
|
94
|
+
assert.ok(globToRegex('use?.ts').test('useA.ts'));
|
|
95
|
+
assert.ok(!globToRegex('use?.ts').test('useAB.ts'));
|
|
96
|
+
assert.ok(globToRegex('a.b.ts').test('a.b.ts'));
|
|
97
|
+
assert.ok(!globToRegex('a.b.ts').test('axbts'));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('matchesAnyGlob: empty list never matches', () => {
|
|
101
|
+
assert.equal(matchesAnyGlob('anything.ts', []), false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('matchesAnyGlob: any of several globs', () => {
|
|
105
|
+
assert.ok(matchesAnyGlob('x/y.ts', ['nothing/**', 'x/**']));
|
|
106
|
+
});
|
|
@@ -0,0 +1,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
|
+
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
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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, readFileSync, 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):"
|
|
56
|
+
node scripts/anti-slop/check-duplicates.mjs --min-tokens 20 --threshold 0 || 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 (--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
|
|
73
|
+
|
|
74
|
+
exit 0
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const writeHook = (name, body) => {
|
|
78
|
+
const dest = path.join(hooksDir, name);
|
|
79
|
+
if (existsSync(dest) && !force) {
|
|
80
|
+
console.error(`[skip] ${name} already exists (use --force to overwrite)`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
writeFileSync(dest, body, { mode: 0o755 });
|
|
84
|
+
console.log(`[ok] wrote ${path.relative(cwd, dest)}`);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
writeHook('pre-commit', PRE_COMMIT);
|
|
88
|
+
if (preCommitOnly) {
|
|
89
|
+
console.log('[skip] pre-push (--pre-commit-only)');
|
|
90
|
+
} else {
|
|
91
|
+
writeHook('pre-push', PRE_PUSH);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(`
|
|
95
|
+
Notes:
|
|
96
|
+
- Hooks require core.hooksPath to be unset (default). Verify: git config core.hooksPath
|
|
97
|
+
- Pre-commit only checks staged files, so pre-existing violations elsewhere do not block.
|
|
98
|
+
- To bypass once in an emergency: git commit --no-verify (use sparingly; it defeats the gate).
|
|
99
|
+
`);
|