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.
- package/.opencode/skills/verify-clean-code/SKILL.md +4 -1
- package/README.md +315 -275
- package/install.mjs +34 -2
- package/package.json +1 -1
- package/scripts/anti-slop/baseline.mjs +147 -0
- package/scripts/anti-slop/check-docs.mjs +109 -70
- package/scripts/anti-slop/check-duplicates.mjs +44 -19
- package/scripts/anti-slop/check-imports.mjs +72 -30
- package/scripts/anti-slop/check-modularity.mjs +72 -9
- package/scripts/anti-slop/cli.mjs +199 -115
- package/scripts/anti-slop/cli.test.mjs +98 -2
- package/scripts/anti-slop/generate-component-index.mjs +162 -133
- package/scripts/anti-slop/install-hooks.mjs +5 -7
- package/scripts/anti-slop/lib.mjs +47 -41
- package/scripts/anti-slop/run-project-checks.mjs +73 -0
- package/templates/anti-slop-ci.yml +1 -1
|
@@ -21,22 +21,28 @@
|
|
|
21
21
|
* --report-only prints findings but always exits 0.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { readFileSync
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
import {
|
|
27
|
-
|
|
27
|
+
stripNoise, lineOf,
|
|
28
28
|
} from './lib.mjs';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
loadConfig, gateArgs, collectPathFiles, resolveScanTarget, applyIgnorePaths,
|
|
31
|
+
} from './cli.mjs';
|
|
32
|
+
import { baselineGate } from './baseline.mjs';
|
|
30
33
|
|
|
31
34
|
// Defaults < anti-slop.config.json < CLI flags.
|
|
32
35
|
const cfgMod = ((loadConfig().modularity) ?? {});
|
|
33
|
-
const { args, positionals } =
|
|
34
|
-
|
|
36
|
+
const { args, positionals } = gateArgs({
|
|
37
|
+
argv: process.argv.slice(2),
|
|
38
|
+
usage: 'Usage: node check-modularity.mjs [paths...] [--src src] [--max-file-lines 300] [--max-function-lines 80] [--max-class-methods 10] [--report-only] [--baseline file] [--update-baseline]\n' +
|
|
35
39
|
'Config: anti-slop.config.json "modularity" section (src, maxFileLines, maxFunctionLines, maxClassMethods).\n' +
|
|
36
|
-
'With path arguments, checks exactly those files (git-hook staged mode)
|
|
40
|
+
'With path arguments, checks exactly those files (git-hook staged mode).\n' +
|
|
41
|
+
'With a baseline file (default anti-slop.baseline.json) the gate fails only on NEW violations.',
|
|
37
42
|
spec: {
|
|
38
43
|
'--src': 'string', '--max-file-lines': 'number', '--max-function-lines': 'number',
|
|
39
44
|
'--max-class-methods': 'number', '--report-only': 'flag',
|
|
45
|
+
'--baseline': 'string', '--update-baseline': 'flag',
|
|
40
46
|
},
|
|
41
47
|
defaults: {
|
|
42
48
|
src: cfgMod.src ?? 'src',
|
|
@@ -44,9 +50,35 @@ const { args, positionals } = bootstrapGate(process.argv.slice(2), {
|
|
|
44
50
|
maxFunctionLines: cfgMod.maxFunctionLines ?? 80,
|
|
45
51
|
maxClassMethods: cfgMod.maxClassMethods ?? 10,
|
|
46
52
|
},
|
|
53
|
+
allowPositionals: true,
|
|
47
54
|
});
|
|
48
55
|
const paths = positionals;
|
|
49
56
|
|
|
57
|
+
const CONTROL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch']);
|
|
58
|
+
|
|
59
|
+
/** True when the `{` at openBraceIdx opens a control-flow block (if/for/while/
|
|
60
|
+
* switch/catch): the `{` is preceded by a `)` whose matching `(` follows one of
|
|
61
|
+
* the control keywords. `foo(x) {` (method shorthand) is NOT control flow. */
|
|
62
|
+
function isControlFlow(stripped, openBraceIdx) {
|
|
63
|
+
let j = openBraceIdx - 1;
|
|
64
|
+
let depth = 0;
|
|
65
|
+
while (j >= 0) {
|
|
66
|
+
const ch = stripped[j];
|
|
67
|
+
if (ch === ')') depth++;
|
|
68
|
+
else if (ch === '(') {
|
|
69
|
+
depth--;
|
|
70
|
+
if (depth === 0) break;
|
|
71
|
+
}
|
|
72
|
+
j--;
|
|
73
|
+
}
|
|
74
|
+
if (j < 0) return false;
|
|
75
|
+
let k = j - 1;
|
|
76
|
+
while (k >= 0 && /\s/.test(stripped[k])) k--;
|
|
77
|
+
const end = k;
|
|
78
|
+
while (k >= 0 && /[\w$]/.test(stripped[k])) k--;
|
|
79
|
+
return CONTROL_KEYWORDS.has(stripped.slice(k + 1, end + 1));
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
function analyze(source, limits) {
|
|
51
83
|
const findings = [];
|
|
52
84
|
const stripped = stripNoise(source);
|
|
@@ -74,7 +106,10 @@ function analyze(source, limits) {
|
|
|
74
106
|
// detect `class X ... {`
|
|
75
107
|
const head = stripped.slice(Math.max(0, i - 200), i);
|
|
76
108
|
const cm = /(?:^|[\s;}])class\s+([A-Za-z0-9_$]+)[^{}]*$/.exec(head);
|
|
77
|
-
|
|
109
|
+
// `if (x) {`, `for (...) {` etc. end in `)` like a function body but are
|
|
110
|
+
// control flow — excluded by the keyword before their opening paren.
|
|
111
|
+
let isFn = head.trimEnd().endsWith('=>');
|
|
112
|
+
if (!isFn && prev === ')') isFn = !isControlFlow(stripped, i);
|
|
78
113
|
if (cm && classStart === null) {
|
|
79
114
|
classStart = { name: cm[1], declIdx: i, braceDepth: depth, methods: 0 };
|
|
80
115
|
} else if (classStart && classStart.braceDepth !== null && depth === classStart.braceDepth + 1 && prev === ')') {
|
|
@@ -117,7 +152,14 @@ function analyze(source, limits) {
|
|
|
117
152
|
}
|
|
118
153
|
|
|
119
154
|
// --- run ---------------------------------------------------------------------
|
|
120
|
-
|
|
155
|
+
// modularity.ignorePaths exempts declarative/framework files from size
|
|
156
|
+
// budgets, matching the drift principle (shape, not logic).
|
|
157
|
+
const modIgnore = Array.isArray(cfgMod.ignorePaths) ? cfgMod.ignorePaths : [];
|
|
158
|
+
const files = applyIgnorePaths(
|
|
159
|
+
positionals.length > 0 ? collectPathFiles(positionals) : resolveScanTarget(args.src),
|
|
160
|
+
modIgnore,
|
|
161
|
+
{ label: 'modularity.ignorePaths', src: args.src },
|
|
162
|
+
);
|
|
121
163
|
|
|
122
164
|
const limits = {
|
|
123
165
|
maxFileLines: args.maxFileLines,
|
|
@@ -126,14 +168,20 @@ const limits = {
|
|
|
126
168
|
};
|
|
127
169
|
|
|
128
170
|
let violations = 0;
|
|
171
|
+
const perFileKinds = {};
|
|
172
|
+
const fileLines = {};
|
|
129
173
|
for (const file of files) {
|
|
130
174
|
const { findings, totalLines } = analyze(readFileSync(file.full, 'utf8'), limits);
|
|
175
|
+
fileLines[file.rel] = totalLines;
|
|
131
176
|
if (findings.length === 0) continue;
|
|
132
177
|
console.log(`${file.rel} (${totalLines} lines):`);
|
|
178
|
+
const kinds = {};
|
|
133
179
|
for (const f of findings) {
|
|
134
180
|
console.log(` [${f.kind}] line ${f.line}: ${f.detail}`);
|
|
181
|
+
kinds[f.kind] = (kinds[f.kind] ?? 0) + 1;
|
|
135
182
|
violations++;
|
|
136
183
|
}
|
|
184
|
+
perFileKinds[file.rel] = kinds;
|
|
137
185
|
}
|
|
138
186
|
|
|
139
187
|
if (violations === 0) {
|
|
@@ -141,10 +189,25 @@ if (violations === 0) {
|
|
|
141
189
|
process.exit(0);
|
|
142
190
|
}
|
|
143
191
|
|
|
192
|
+
// --- baseline (ratchet) mode ---------------------------------------------------
|
|
193
|
+
const decision = baselineGate({
|
|
194
|
+
args,
|
|
195
|
+
section: 'modularity',
|
|
196
|
+
current: perFileKinds,
|
|
197
|
+
kindCounts: true,
|
|
198
|
+
unit: 'violation(s)',
|
|
199
|
+
showKind: true,
|
|
200
|
+
fixLine: 'Fix: split by responsibility (see the modular-architecture skill).',
|
|
201
|
+
});
|
|
202
|
+
if (decision === 'ok') process.exit(0);
|
|
203
|
+
if (decision === 'fail') process.exit(1);
|
|
204
|
+
|
|
144
205
|
console.log(`\n${violations} size-budget violation(s) in ${files.length} file(s).`);
|
|
145
206
|
console.error(
|
|
146
207
|
'[x] Modularity check FAILED.\n' +
|
|
147
208
|
' Fix: split by responsibility (see the modular-architecture skill) — transport/service/domain/data,\n' +
|
|
148
|
-
' smaller functions, smaller public surfaces. Do NOT compress code, raise budgets, or disable the check
|
|
209
|
+
' smaller functions, smaller public surfaces. Do NOT compress code, raise budgets, or disable the check.\n' +
|
|
210
|
+
' Adopting a repo with legacy violations? Snapshot them once:\n' +
|
|
211
|
+
' node scripts/anti-slop/check-modularity.mjs --update-baseline — the gate then fails only on NEW ones.'
|
|
149
212
|
);
|
|
150
213
|
process.exit(args.reportOnly ? 0 : 1);
|
|
@@ -1,128 +1,202 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cli.mjs
|
|
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
|
-
*/
|
|
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
|
+
}
|
|
8
134
|
|
|
9
|
-
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import { walk, SOURCE_EXT, IGNORE_FILE } from './lib.mjs';
|
|
12
|
-
|
|
13
|
-
// --- CLI parsing ---------------------------------------------------------------
|
|
135
|
+
// --- per-repo config ----------------------------------------------------------
|
|
14
136
|
|
|
15
137
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* Returns { values: Record<flag, string|number|true>, positionals: string[] }.
|
|
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.
|
|
24
145
|
*/
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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;
|
|
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(', ')}).`);
|
|
48
158
|
}
|
|
49
|
-
return
|
|
159
|
+
return kept;
|
|
50
160
|
}
|
|
51
161
|
|
|
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
|
-
}
|
|
162
|
+
const KNOWN_SECTIONS = new Set(['duplicates', 'modularity', 'docs', 'imports', '$schema', '_comment']);
|
|
82
163
|
|
|
83
|
-
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
}
|
|
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)`);
|
|
101
171
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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');
|
|
114
191
|
}
|
|
115
|
-
if (
|
|
116
|
-
|
|
192
|
+
if (warns.length > 0) {
|
|
193
|
+
console.error(`[warn] anti-slop.config.json: ${warns.join('; ')}.`);
|
|
117
194
|
}
|
|
118
|
-
return walk(abs, abs);
|
|
119
195
|
}
|
|
120
196
|
|
|
121
|
-
// --- per-repo config ----------------------------------------------------------
|
|
122
|
-
|
|
123
197
|
/**
|
|
124
198
|
* Reads `anti-slop.config.json` from process.cwd() (repo root), returning the
|
|
125
|
-
* parsed object or {} when absent
|
|
199
|
+
* parsed object or {} when absent (warn+{} when unparsable).
|
|
126
200
|
* Scripts merge: defaults < config file < CLI flags.
|
|
127
201
|
*
|
|
128
202
|
* Shape:
|
|
@@ -131,21 +205,31 @@ export function resolveScanTarget(src) {
|
|
|
131
205
|
* "ignore": ["collections/**"] },
|
|
132
206
|
* "modularity": { "src": "src", "maxFileLines": 300, "maxFunctionLines": 80,
|
|
133
207
|
* "maxClassMethods": 10 },
|
|
134
|
-
* "docs": { "src": "src"
|
|
208
|
+
* "docs": { "src": "src", "ignorePaths": ["app/(payload)/**"],
|
|
209
|
+
* "ignoreExports": ["metadata", "dynamic"] },
|
|
210
|
+
* "imports": { "src": "src", "deny": [{ "from": "src/domain/**",
|
|
211
|
+
* "forbid": "src/infra/**" }] }
|
|
135
212
|
* }
|
|
136
213
|
*
|
|
137
|
-
* `ignore` (duplicates
|
|
138
|
-
*
|
|
214
|
+
* `ignore` (duplicates) and `ignorePaths` (docs) exclude framework-idiomatic
|
|
215
|
+
* declarative files whose repetition is shape, not logic.
|
|
139
216
|
*/
|
|
140
217
|
export function loadConfig() {
|
|
141
218
|
const file = path.join(process.cwd(), 'anti-slop.config.json');
|
|
142
219
|
if (!existsSync(file)) return {};
|
|
220
|
+
let parsed;
|
|
143
221
|
try {
|
|
144
|
-
|
|
222
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
145
223
|
} catch (err) {
|
|
146
224
|
console.error(`[warn] Could not parse anti-slop.config.json (${err.message}); using defaults.`);
|
|
147
225
|
return {};
|
|
148
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;
|
|
149
233
|
}
|
|
150
234
|
|
|
151
235
|
/** Convert a glob (`**`, `*`, `?`) into a RegExp tested against '/'-separated rel paths. */
|
|
@@ -172,4 +256,4 @@ export function globToRegex(glob) {
|
|
|
172
256
|
/** True when relPath matches any ignore glob. */
|
|
173
257
|
export function matchesAnyGlob(relPath, globs) {
|
|
174
258
|
return globs.length > 0 && globs.some((g) => globToRegex(g).test(relPath));
|
|
175
|
-
}
|
|
259
|
+
}
|
|
@@ -7,11 +7,16 @@
|
|
|
7
7
|
|
|
8
8
|
import test from 'node:test';
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
|
-
import { mkdtempSync,
|
|
10
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
|
-
import {
|
|
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';
|
|
15
20
|
|
|
16
21
|
// --- parseFlags ----------------------------------------------------------------
|
|
17
22
|
|
|
@@ -104,3 +109,94 @@ test('matchesAnyGlob: empty list never matches', () => {
|
|
|
104
109
|
test('matchesAnyGlob: any of several globs', () => {
|
|
105
110
|
assert.ok(matchesAnyGlob('x/y.ts', ['nothing/**', 'x/**']));
|
|
106
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
|
+
});
|