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.
- 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 +81 -0
- package/LICENSE +21 -0
- package/README.md +315 -3
- package/install.mjs +189 -0
- package/package.json +41 -4
- package/scripts/anti-slop/baseline.mjs +147 -0
- package/scripts/anti-slop/check-docs.mjs +109 -0
- package/scripts/anti-slop/check-duplicates.mjs +207 -0
- package/scripts/anti-slop/check-imports.mjs +251 -0
- package/scripts/anti-slop/check-imports.test.mjs +128 -0
- package/scripts/anti-slop/check-modularity.mjs +213 -0
- package/scripts/anti-slop/cli.mjs +259 -0
- package/scripts/anti-slop/cli.test.mjs +202 -0
- package/scripts/anti-slop/generate-component-index.mjs +162 -0
- package/scripts/anti-slop/install-hooks.mjs +97 -0
- package/scripts/anti-slop/lib.mjs +210 -0
- package/scripts/anti-slop/lib.test.mjs +178 -0
- package/scripts/anti-slop/run-project-checks.mjs +73 -0
- package/templates/anti-slop-ci.yml +22 -0
|
@@ -0,0 +1,259 @@
|
|
|
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 { existsSync, readFileSync, 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
|
+
/**
|
|
122
|
+
* bootstrapGate plus the standard positional validation most gates share.
|
|
123
|
+
* Pass allowPositionals: true for gates that accept file-path arguments
|
|
124
|
+
* (git-hook staged mode).
|
|
125
|
+
*/
|
|
126
|
+
export function gateArgs({ argv, usage, spec, defaults, allowPositionals = false }) {
|
|
127
|
+
const { args, positionals } = bootstrapGate(argv, { usage, spec, defaults });
|
|
128
|
+
if (!allowPositionals && positionals.length > 0) {
|
|
129
|
+
console.error(`Unexpected arguments: ${positionals.join(' ')} (this script takes flags only)`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
return { args, positionals };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- per-repo config ----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Filter resolved files by a docs/duplicates-style ignore-path config:
|
|
139
|
+
* globs are matched against THREE path candidates so every mode and both
|
|
140
|
+
* glob conventions work — the provided rel, repo-relative, and (when `src`
|
|
141
|
+
* is given) scan-root-relative. Tree-wide walks produce scan-root-relative
|
|
142
|
+
* rels; git-hook staged mode produces repo-relative ones; configs may use
|
|
143
|
+
* either convention. Returns the kept files and prints an exclusion notice
|
|
144
|
+
* when any glob is configured and something matched.
|
|
145
|
+
*/
|
|
146
|
+
export function applyIgnorePaths(files, globs, { label = 'ignorePaths', src } = {}) {
|
|
147
|
+
if (!Array.isArray(globs) || globs.length === 0) return files;
|
|
148
|
+
const srcDir = src ? path.resolve(src) : null;
|
|
149
|
+
const kept = files.filter((f) => {
|
|
150
|
+
const repoRel = path.relative(process.cwd(), f.full).split(path.sep).join('/');
|
|
151
|
+
const scanRel = srcDir ? path.relative(srcDir, f.full).split(path.sep).join('/') : null;
|
|
152
|
+
const candidates = scanRel ? [f.rel, repoRel, scanRel] : [f.rel, repoRel];
|
|
153
|
+
return !candidates.some((p) => matchesAnyGlob(p, globs));
|
|
154
|
+
});
|
|
155
|
+
const excluded = files.length - kept.length;
|
|
156
|
+
if (excluded > 0) {
|
|
157
|
+
console.log(`Ignoring ${excluded} file(s) matching ${label} (${globs.join(', ')}).`);
|
|
158
|
+
}
|
|
159
|
+
return kept;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const KNOWN_SECTIONS = new Set(['duplicates', 'modularity', 'docs', 'imports', '$schema', '_comment']);
|
|
163
|
+
|
|
164
|
+
/** Light validation: unknown keys and wrong types warn instead of silently
|
|
165
|
+
* disabling the tuning (a typo like "duplicate" would otherwise run the gates
|
|
166
|
+
* on defaults with no signal why). */
|
|
167
|
+
function validateConfig(parsed) {
|
|
168
|
+
const warns = [];
|
|
169
|
+
for (const key of Object.keys(parsed)) {
|
|
170
|
+
if (!KNOWN_SECTIONS.has(key)) warns.push(`unknown key "${key}" (ignored)`);
|
|
171
|
+
}
|
|
172
|
+
const num = (section, key) => {
|
|
173
|
+
const v = parsed[section]?.[key];
|
|
174
|
+
if (v !== undefined && (typeof v !== 'number' || Number.isNaN(v))) warns.push(`${section}.${key} must be a number`);
|
|
175
|
+
};
|
|
176
|
+
const arr = (section, key) => {
|
|
177
|
+
const v = parsed[section]?.[key];
|
|
178
|
+
if (v !== undefined && !Array.isArray(v)) warns.push(`${section}.${key} must be an array of globs/names`);
|
|
179
|
+
};
|
|
180
|
+
num('duplicates', 'minTokens');
|
|
181
|
+
num('duplicates', 'threshold');
|
|
182
|
+
arr('duplicates', 'ignore');
|
|
183
|
+
num('modularity', 'maxFileLines');
|
|
184
|
+
num('modularity', 'maxFunctionLines');
|
|
185
|
+
num('modularity', 'maxClassMethods');
|
|
186
|
+
arr('modularity', 'ignorePaths');
|
|
187
|
+
arr('docs', 'ignorePaths');
|
|
188
|
+
arr('docs', 'ignoreExports');
|
|
189
|
+
if (parsed.imports?.deny !== undefined && !Array.isArray(parsed.imports.deny)) {
|
|
190
|
+
warns.push('imports.deny must be an array of { from, forbid } rules');
|
|
191
|
+
}
|
|
192
|
+
if (warns.length > 0) {
|
|
193
|
+
console.error(`[warn] anti-slop.config.json: ${warns.join('; ')}.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Reads `anti-slop.config.json` from process.cwd() (repo root), returning the
|
|
199
|
+
* parsed object or {} when absent (warn+{} when unparsable).
|
|
200
|
+
* Scripts merge: defaults < config file < CLI flags.
|
|
201
|
+
*
|
|
202
|
+
* Shape:
|
|
203
|
+
* {
|
|
204
|
+
* "duplicates": { "src": "src", "minTokens": 50, "threshold": 3,
|
|
205
|
+
* "ignore": ["collections/**"] },
|
|
206
|
+
* "modularity": { "src": "src", "maxFileLines": 300, "maxFunctionLines": 80,
|
|
207
|
+
* "maxClassMethods": 10 },
|
|
208
|
+
* "docs": { "src": "src", "ignorePaths": ["app/(payload)/**"],
|
|
209
|
+
* "ignoreExports": ["metadata", "dynamic"] },
|
|
210
|
+
* "imports": { "src": "src", "deny": [{ "from": "src/domain/**",
|
|
211
|
+
* "forbid": "src/infra/**" }] }
|
|
212
|
+
* }
|
|
213
|
+
*
|
|
214
|
+
* `ignore` (duplicates) and `ignorePaths` (docs) exclude framework-idiomatic
|
|
215
|
+
* declarative files whose repetition is shape, not logic.
|
|
216
|
+
*/
|
|
217
|
+
export function loadConfig() {
|
|
218
|
+
const file = path.join(process.cwd(), 'anti-slop.config.json');
|
|
219
|
+
if (!existsSync(file)) return {};
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error(`[warn] Could not parse anti-slop.config.json (${err.message}); using defaults.`);
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
228
|
+
console.error('[warn] anti-slop.config.json is not an object; using defaults.');
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
validateConfig(parsed);
|
|
232
|
+
return parsed;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Convert a glob (`**`, `*`, `?`) into a RegExp tested against '/'-separated rel paths. */
|
|
236
|
+
export function globToRegex(glob) {
|
|
237
|
+
let pattern = '';
|
|
238
|
+
for (let i = 0; i < glob.length; i++) {
|
|
239
|
+
const c = glob[i];
|
|
240
|
+
if (c === '*') {
|
|
241
|
+
if (glob[i + 1] === '*') {
|
|
242
|
+
pattern += '[\\s\\S]*';
|
|
243
|
+
i++;
|
|
244
|
+
} else {
|
|
245
|
+
pattern += '[^/]*';
|
|
246
|
+
}
|
|
247
|
+
} else if (c === '?') {
|
|
248
|
+
pattern += '[^/]';
|
|
249
|
+
} else {
|
|
250
|
+
pattern += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return new RegExp(`^${pattern}$`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** True when relPath matches any ignore glob. */
|
|
257
|
+
export function matchesAnyGlob(relPath, globs) {
|
|
258
|
+
return globs.length > 0 && globs.some((g) => globToRegex(g).test(relPath));
|
|
259
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
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 { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
bootstrapGate, globToRegex, matchesAnyGlob, parseFlags, gateArgs, applyIgnorePaths,
|
|
16
|
+
} from './cli.mjs';
|
|
17
|
+
import {
|
|
18
|
+
compareCounts, compareKindCounts, compareSets, loadBaseline, saveBaseline,
|
|
19
|
+
} from './baseline.mjs';
|
|
20
|
+
|
|
21
|
+
// --- parseFlags ----------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
test('parseFlags: string, number, and flag types', () => {
|
|
24
|
+
const { values, positionals } = parseFlags(
|
|
25
|
+
['--src', 'lib', '--min-tokens', '30', '--report-only'],
|
|
26
|
+
{ '--src': 'string', '--min-tokens': 'number', '--report-only': 'flag' },
|
|
27
|
+
);
|
|
28
|
+
assert.equal(values['--src'], 'lib');
|
|
29
|
+
assert.equal(values['--min-tokens'], 30);
|
|
30
|
+
assert.equal(values['--report-only'], true);
|
|
31
|
+
assert.deepEqual(positionals, []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('parseFlags: bare words land in positionals', () => {
|
|
35
|
+
const { values, positionals } = parseFlags(['src/a.ts', 'src/b.ts'], {});
|
|
36
|
+
assert.deepEqual(values, {});
|
|
37
|
+
assert.deepEqual(positionals, ['src/a.ts', 'src/b.ts']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('parseFlags: missing value throws', () => {
|
|
41
|
+
assert.throws(() => parseFlags(['--src'], { '--src': 'string' }), /Missing value/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('parseFlags: non-numeric number throws', () => {
|
|
45
|
+
assert.throws(
|
|
46
|
+
() => parseFlags(['--max-file-lines', 'abc'], { '--max-file-lines': 'number' }),
|
|
47
|
+
/expects a number/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- bootstrapGate ----------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const SPEC = {
|
|
54
|
+
'--src': 'string', '--min-tokens': 'number', '--threshold': 'number',
|
|
55
|
+
'--report-only': 'flag',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
test('bootstrapGate: flags override defaults, keys camelCased', () => {
|
|
59
|
+
const { args, positionals } = bootstrapGate(
|
|
60
|
+
['--min-tokens', '20'],
|
|
61
|
+
{
|
|
62
|
+
usage: 'usage',
|
|
63
|
+
spec: SPEC,
|
|
64
|
+
defaults: { src: 'src', minTokens: 50, threshold: 3, reportOnly: false },
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
assert.equal(args.src, 'src');
|
|
68
|
+
assert.equal(args.minTokens, 20);
|
|
69
|
+
assert.equal(args.threshold, 3);
|
|
70
|
+
assert.equal(args.reportOnly, false);
|
|
71
|
+
assert.deepEqual(positionals, []);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('bootstrapGate: config defaults flow through unchanged', () => {
|
|
75
|
+
const { args } = bootstrapGate([], {
|
|
76
|
+
usage: 'usage',
|
|
77
|
+
spec: SPEC,
|
|
78
|
+
defaults: { src: 'app', minTokens: 80, threshold: 5 },
|
|
79
|
+
});
|
|
80
|
+
assert.deepEqual(args, { src: 'app', minTokens: 80, threshold: 5, reportOnly: false });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('bootstrapGate: --report-only becomes boolean true', () => {
|
|
84
|
+
const { args } = bootstrapGate(['--report-only'], {
|
|
85
|
+
usage: 'usage', spec: SPEC, defaults: { src: 'src', reportOnly: false },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(args.reportOnly, true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// --- glob matching -----------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
test('globToRegex: ** crosses directories, * stays within one', () => {
|
|
93
|
+
assert.ok(globToRegex('collections/**').test('collections/AccessGrants.ts'));
|
|
94
|
+
assert.ok(globToRegex('collections/**').test('collections/nested/Clients.ts'));
|
|
95
|
+
assert.ok(!globToRegex('collections/**').test('src/collections/Clients.ts'));
|
|
96
|
+
assert.ok(globToRegex('app/(payload)/**').test('app/(payload)/admin/page.tsx'));
|
|
97
|
+
assert.ok(globToRegex('lib/*.ts').test('lib/date.ts'));
|
|
98
|
+
assert.ok(!globToRegex('lib/*.ts').test('lib/deep/date.ts'));
|
|
99
|
+
assert.ok(globToRegex('use?.ts').test('useA.ts'));
|
|
100
|
+
assert.ok(!globToRegex('use?.ts').test('useAB.ts'));
|
|
101
|
+
assert.ok(globToRegex('a.b.ts').test('a.b.ts'));
|
|
102
|
+
assert.ok(!globToRegex('a.b.ts').test('axbts'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('matchesAnyGlob: empty list never matches', () => {
|
|
106
|
+
assert.equal(matchesAnyGlob('anything.ts', []), false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('matchesAnyGlob: any of several globs', () => {
|
|
110
|
+
assert.ok(matchesAnyGlob('x/y.ts', ['nothing/**', 'x/**']));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// --- applyIgnorePaths -----------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
test('applyIgnorePaths: scan-root-relative globs match repo-relative staged files', async () => {
|
|
116
|
+
// Regression for #18: git-hook staged mode passes repo-relative paths
|
|
117
|
+
// (src/app/...); a config glob written scan-root-relative (app/...) must
|
|
118
|
+
// still match, because tree-wide mode matches it that way.
|
|
119
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'antislop-ignore-'));
|
|
120
|
+
try {
|
|
121
|
+
const repo = path.join(dir, 'repo');
|
|
122
|
+
const nested = path.join(repo, 'src', 'app', '(payload)');
|
|
123
|
+
mkdirSync(nested, { recursive: true });
|
|
124
|
+
const full = path.join(nested, 'importMap.js');
|
|
125
|
+
writeFileSync(full, 'export default 1;');
|
|
126
|
+
const files = [{ full, rel: 'src/app/payload/importMap.js' }];
|
|
127
|
+
const kept = applyIgnorePaths(files, ['app/(payload)/**'], { src: path.join(repo, 'src') });
|
|
128
|
+
assert.equal(kept.length, 0);
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(dir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('applyIgnorePaths: repo-relative globs match scan-root-relative rels', () => {
|
|
135
|
+
// full path under the repo's src/, rel produced by a tree-wide walk
|
|
136
|
+
const files = [{ full: 'src/collections/Users.ts', rel: 'collections/Users.ts' }];
|
|
137
|
+
const kept = applyIgnorePaths(files, ['src/collections/**'], { src: 'src' });
|
|
138
|
+
assert.equal(kept.length, 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('applyIgnorePaths: no globs keeps everything silently', () => {
|
|
142
|
+
const files = [{ full: 'a.ts', rel: 'a.ts' }];
|
|
143
|
+
assert.equal(applyIgnorePaths(files, []).length, 1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// --- baseline ratchet ------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
test('compareCounts: unseen file with duplication is new debt', () => {
|
|
149
|
+
const { newDebt, improved } = compareCounts({ 'a.ts': 5 }, { 'b.ts': 3 });
|
|
150
|
+
assert.deepEqual(newDebt, [{ rel: 'a.ts', count: 5, accepted: 0 }]);
|
|
151
|
+
assert.deepEqual(improved, []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('compareCounts: growth beyond accepted is new; shrink is improvement', () => {
|
|
155
|
+
const { newDebt, improved } = compareCounts(
|
|
156
|
+
{ 'a.ts': 8, 'b.ts': 1 },
|
|
157
|
+
{ 'a.ts': 5, 'b.ts': 3 },
|
|
158
|
+
);
|
|
159
|
+
assert.deepEqual(newDebt, [{ rel: 'a.ts', count: 8, accepted: 5 }]);
|
|
160
|
+
assert.deepEqual(improved, [{ rel: 'b.ts', count: 1, accepted: 3 }]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('compareCounts: equal counts are neither', () => {
|
|
164
|
+
const { newDebt, improved } = compareCounts({ 'a.ts': 5 }, { 'a.ts': 5 });
|
|
165
|
+
assert.deepEqual(newDebt, []);
|
|
166
|
+
assert.deepEqual(improved, []);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('compareKindCounts: new kind on a baselined file is new debt', () => {
|
|
170
|
+
const { newDebt } = compareKindCounts(
|
|
171
|
+
{ 'big.ts': { file: 1, function: 2 } },
|
|
172
|
+
{ 'big.ts': { file: 1, function: 0 } },
|
|
173
|
+
);
|
|
174
|
+
assert.deepEqual(newDebt, [{ rel: 'big.ts', kind: 'function', count: 2, accepted: 0 }]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('compareKindCounts: unseen file with any violation is new debt', () => {
|
|
178
|
+
const { newDebt } = compareKindCounts({ 'new.ts': { file: 1 } }, {});
|
|
179
|
+
assert.deepEqual(newDebt, [{ rel: 'new.ts', kind: 'file', count: 1, accepted: 0 }]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('compareSets: added and removed', () => {
|
|
183
|
+
const { added, removed } = compareSets(['a~b', 'c~d'], ['a~b', 'e~f']);
|
|
184
|
+
assert.deepEqual(added, ['c~d']);
|
|
185
|
+
assert.deepEqual(removed, ['e~f']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('saveBaseline/loadBaseline: roundtrip with sorted keys', () => {
|
|
189
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'antislop-baseline-'));
|
|
190
|
+
try {
|
|
191
|
+
const file = path.join(dir, 'baseline.json');
|
|
192
|
+
saveBaseline(file, { duplicates: { files: { 'b.ts': 2, 'a.ts': 1 }, totalDuplicatedLines: 3 } });
|
|
193
|
+
const loaded = loadBaseline(file);
|
|
194
|
+
assert.equal(loaded.duplicates.totalDuplicatedLines, 3);
|
|
195
|
+
assert.deepEqual(Object.keys(loaded.duplicates.files), ['a.ts', 'b.ts']);
|
|
196
|
+
const raw = readFileSync(file, 'utf8');
|
|
197
|
+
assert.ok(raw.includes('_comment'));
|
|
198
|
+
assert.ok(raw.includes('Ratchet baseline'));
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(dir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
@@ -0,0 +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
|
+
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
|
+
}
|