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
package/install.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* node install.mjs [target-repo-dir] [--force] [--hooks]
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
26
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
29
|
|
|
@@ -104,6 +104,22 @@ for (const entry of readdirSync(commandsSrc, { withFileTypes: true })) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// --- starter config -----------------------------------------------------------
|
|
108
|
+
// Scaffold anti-slop.config.json when the target has none, so the tuning
|
|
109
|
+
// surface exists from day one. JSON has no comments; the reasoning for the
|
|
110
|
+
// starter rules is printed in the manual steps below.
|
|
111
|
+
const configDest = path.join(target, 'anti-slop.config.json');
|
|
112
|
+
if (!existsSync(configDest)) {
|
|
113
|
+
const starter = {
|
|
114
|
+
duplicates: { src: 'src', minTokens: 50, threshold: 3, ignore: [] },
|
|
115
|
+
modularity: { src: 'src', maxFileLines: 300, maxFunctionLines: 80, maxClassMethods: 10 },
|
|
116
|
+
docs: { src: 'src', ignorePaths: [], ignoreExports: [] },
|
|
117
|
+
imports: { deny: [] },
|
|
118
|
+
};
|
|
119
|
+
writeFileSync(configDest, `${JSON.stringify(starter, null, 2)}\n`);
|
|
120
|
+
console.log(`[ok] ${configDest} (starter — tune ignore lists to your framework's declarative files)`);
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
// --- manual steps -----------------------------------------------------------
|
|
108
124
|
console.log(`
|
|
109
125
|
Manual setup in ${target} (one-time):
|
|
@@ -133,13 +149,29 @@ Manual setup in ${target} (one-time):
|
|
|
133
149
|
|
|
134
150
|
4. ESLint: enable eslint-plugin-react-hooks (and the React Compiler eslint
|
|
135
151
|
plugin if the project uses the compiler) so hook misuse fails in the linter.
|
|
152
|
+
Consider ignoring the vendored gate scripts from your repo lint:
|
|
153
|
+
\`{ ignores: ['scripts/anti-slop/**'] }\` in eslint.config — they are
|
|
154
|
+
self-linted by this kit's own CI.
|
|
136
155
|
|
|
137
156
|
5. Generate the first inventory: node scripts/anti-slop/generate-component-index.mjs
|
|
138
157
|
|
|
139
158
|
6. Git gates (recommended): git init && node scripts/anti-slop/install-hooks.mjs
|
|
140
159
|
- pre-commit: modularity + docs on staged files (blocking), duplicate/import reports
|
|
141
|
-
- pre-push: full duplicate + import gates + lint/typecheck/test via
|
|
160
|
+
- pre-push: full duplicate + import gates + lint/typecheck/test via run-project-checks
|
|
142
161
|
- /audit-ui in OpenCode produces a prioritized refactor plan from the same checks
|
|
162
|
+
|
|
163
|
+
7. Layer direction (imports.deny): the import gate detects cycles out of the box;
|
|
164
|
+
it enforces layer direction once you declare rules in anti-slop.config.json.
|
|
165
|
+
Starter rules to adapt (paths must match your layout):
|
|
166
|
+
|
|
167
|
+
"imports": { "deny": [
|
|
168
|
+
{ "from": "src/components/**", "forbid": "src/collections/**" },
|
|
169
|
+
{ "from": "src/app/**", "forbid": "src/collections/**" },
|
|
170
|
+
{ "from": "src/access/**", "forbid": "src/app/**" }
|
|
171
|
+
] }
|
|
172
|
+
|
|
173
|
+
With no rules the gate prints a cycles-only notice so a green result is never
|
|
174
|
+
mistaken for full architectural enforcement.
|
|
143
175
|
`);
|
|
144
176
|
|
|
145
177
|
// --- optional hooks ------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-anti-slop",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "OpenCode enforcement kit: agent skills plus deterministic gates that keep AI-generated React and Node code modular, DRY, and documented",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* baseline.mjs — the ratchet: gates fail only on violations NEWER than the
|
|
3
|
+
* committed `anti-slop.baseline.json` snapshot, so adopting a repo with legacy
|
|
4
|
+
* debt does not require a big-bang cleanup. Counts must only go down; growing
|
|
5
|
+
* a file's count beyond its baseline (or introducing a new file with debt) is
|
|
6
|
+
* a failure. Shrinking is always allowed, with a hint to re-snapshot.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies (Node >= 18, ESM). Used by check-duplicates (per-file
|
|
9
|
+
* duplicated-line counts), check-modularity (per-file/per-kind violation
|
|
10
|
+
* counts), check-docs (per-file undocumented-export counts), and
|
|
11
|
+
* check-imports (cycle identity sets — deny-rule violations are never
|
|
12
|
+
* baselined, since rules are declared deliberately).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** Default baseline path: gates auto-ratchet when this file exists at the repo root. */
|
|
19
|
+
export const BASELINE_FILE = 'anti-slop.baseline.json';
|
|
20
|
+
const BASELINE_COMMENT =
|
|
21
|
+
'Ratchet baseline: gates fail only on violations newer than this snapshot. ' +
|
|
22
|
+
'Shrink it after cleanup with <gate> --update-baseline; do not grow it to absorb new debt.';
|
|
23
|
+
|
|
24
|
+
/** Load a baseline snapshot; null when the file is absent, warn+null when unparsable. */
|
|
25
|
+
export function loadBaseline(file = BASELINE_FILE) {
|
|
26
|
+
const abs = path.resolve(file);
|
|
27
|
+
if (!existsSync(abs)) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(abs, 'utf8'));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error(`[warn] Could not parse baseline ${file} (${err.message}); running without baseline.`);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Write a baseline snapshot with sorted keys so diffs stay stable. Merges with
|
|
37
|
+
* an existing file per top-level section, so each gate's --update-baseline
|
|
38
|
+
* only refreshes its own section instead of clobbering the others. */
|
|
39
|
+
export function saveBaseline(file, snapshot) {
|
|
40
|
+
const abs = path.resolve(file);
|
|
41
|
+
let existing = {};
|
|
42
|
+
if (existsSync(abs)) {
|
|
43
|
+
try {
|
|
44
|
+
existing = JSON.parse(readFileSync(abs, 'utf8')) ?? {};
|
|
45
|
+
} catch {
|
|
46
|
+
existing = {}; // unparsable baseline gets replaced wholesale
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const merged = sortDeep({ ...existing, ...sortDeep(snapshot), _comment: BASELINE_COMMENT });
|
|
50
|
+
const out = `${JSON.stringify(merged, null, 2)}\n`;
|
|
51
|
+
writeFileSync(abs, out);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sortDeep(value) {
|
|
55
|
+
if (Array.isArray(value)) return value.map(sortDeep);
|
|
56
|
+
if (value && typeof value === 'object') {
|
|
57
|
+
return Object.fromEntries(
|
|
58
|
+
Object.entries(value)
|
|
59
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
60
|
+
.map(([k, v]) => [k, sortDeep(v)]),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Compare per-file counts against a baseline { rel: count }. New = file unseen or count grew. */
|
|
67
|
+
export function compareCounts(current, baselineFiles) {
|
|
68
|
+
const base = baselineFiles ?? {};
|
|
69
|
+
const newDebt = [];
|
|
70
|
+
const improved = [];
|
|
71
|
+
for (const [rel, count] of Object.entries(current)) {
|
|
72
|
+
const accepted = base[rel];
|
|
73
|
+
if (accepted === undefined) {
|
|
74
|
+
if (count > 0) newDebt.push({ rel, count, accepted: 0 });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (count > accepted) newDebt.push({ rel, count, accepted });
|
|
78
|
+
else if (count < accepted) improved.push({ rel, count, accepted });
|
|
79
|
+
}
|
|
80
|
+
return { newDebt, improved };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Compare per-file/per-kind counts against a baseline { rel: { kind: count } }. */
|
|
84
|
+
export function compareKindCounts(current, baselineFiles) {
|
|
85
|
+
const base = baselineFiles ?? {};
|
|
86
|
+
const newDebt = [];
|
|
87
|
+
const improved = [];
|
|
88
|
+
for (const [rel, kinds] of Object.entries(current)) {
|
|
89
|
+
const accepted = base[rel] ?? {};
|
|
90
|
+
for (const [kind, count] of Object.entries(kinds)) {
|
|
91
|
+
if (count > (accepted[kind] ?? 0)) newDebt.push({ rel, kind, count, accepted: accepted[kind] ?? 0 });
|
|
92
|
+
else if (count < (accepted[kind] ?? 0)) improved.push({ rel, kind, count, accepted: accepted[kind] ?? 0 });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { newDebt, improved };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Compare an identity set (e.g. cycle keys) against a baseline array. */
|
|
99
|
+
export function compareSets(current, baselineItems) {
|
|
100
|
+
const base = new Set(baselineItems ?? []);
|
|
101
|
+
const added = current.filter((k) => !base.has(k));
|
|
102
|
+
const removed = (baselineItems ?? []).filter((k) => !current.includes(k));
|
|
103
|
+
return { added, removed };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Shared ratchet flow for count-based gates (duplicates/docs/modularity).
|
|
108
|
+
*
|
|
109
|
+
* - `--update-baseline`: writes `{ [section]: { files: current, ...extra } }`
|
|
110
|
+
* and returns 'ok' (exit 0 in the gate).
|
|
111
|
+
* - baseline file exists: fails only on NEW debt (unseen file or grown count)
|
|
112
|
+
* and returns 'fail'; otherwise 'ok' with an --update-baseline hint when
|
|
113
|
+
* files improved.
|
|
114
|
+
* - no baseline: returns 'none' so the gate falls through to its own gate logic.
|
|
115
|
+
*
|
|
116
|
+
* `kindCounts` compares `{ rel: { kind: count } }` instead of `{ rel: count }`.
|
|
117
|
+
*/
|
|
118
|
+
export function baselineGate({ args, section, current, kindCounts = false, extra = {}, unit, showKind = false, fixLine }) {
|
|
119
|
+
if (args.updateBaseline) {
|
|
120
|
+
const file = args.baseline ?? BASELINE_FILE;
|
|
121
|
+
saveBaseline(file, { [section]: { files: current, ...extra } });
|
|
122
|
+
const total = Object.values(current).reduce((n, v) => n + (kindCounts ? Object.values(v).reduce((a, b) => a + b, 0) : v), 0);
|
|
123
|
+
console.log(`[ok] Baseline written to ${file} (${total} ${unit} across ${Object.keys(current).length} file(s)).`);
|
|
124
|
+
console.log(' Commit it. The gate now fails only on NEW debt; shrink the baseline as you clean up.');
|
|
125
|
+
return 'ok';
|
|
126
|
+
}
|
|
127
|
+
const baseline = loadBaseline(args.baseline);
|
|
128
|
+
if (!baseline?.[section]) return 'none';
|
|
129
|
+
const { newDebt, improved } = kindCounts
|
|
130
|
+
? compareKindCounts(current, baseline[section].files)
|
|
131
|
+
: compareCounts(current, baseline[section].files);
|
|
132
|
+
if (newDebt.length === 0) {
|
|
133
|
+
console.log(`[ok] ${section} check passed within baseline (0 new${improved.length > 0 ? ' — consider --update-baseline to lock in the cleanup' : ''}).`);
|
|
134
|
+
return 'ok';
|
|
135
|
+
}
|
|
136
|
+
console.error(`[x] NEW ${unit} beyond baseline (${newDebt.length} file(s)):`);
|
|
137
|
+
for (const d of newDebt.slice(0, 20)) {
|
|
138
|
+
console.error(showKind
|
|
139
|
+
? ` ${d.rel}: [${d.kind}] count ${d.count} (accepted: ${d.accepted})`
|
|
140
|
+
: ` ${d.rel}: ${d.count} ${unit} (accepted: ${d.accepted})`);
|
|
141
|
+
}
|
|
142
|
+
console.error(
|
|
143
|
+
` ${fixLine}\n` +
|
|
144
|
+
' The baseline records accepted debt — do NOT update it to absorb new debt.'
|
|
145
|
+
);
|
|
146
|
+
return 'fail';
|
|
147
|
+
}
|
|
@@ -1,70 +1,109 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* check-docs.mjs
|
|
4
|
-
*
|
|
5
|
-
* Documentation gate for shared exports. Fails (exit 1) when exported
|
|
6
|
-
* declarations in the checked files lack a one-line `/** When to use ... *\/`
|
|
7
|
-
* JSDoc above them. Keeps docs/COMPONENT_INDEX.md (generated from the same
|
|
8
|
-
* JSDoc) a maintained, trustworthy inventory instead of a stale artifact.
|
|
9
|
-
*
|
|
10
|
-
* Zero dependencies.
|
|
11
|
-
*
|
|
12
|
-
* Usage:
|
|
13
|
-
* node scripts/anti-slop/check-docs.mjs [paths...] [--src src] [--report-only]
|
|
14
|
-
*
|
|
15
|
-
* With no path arguments it scans --src (default `src`). With path arguments
|
|
16
|
-
* it checks exactly those files — used by git hooks to check only staged
|
|
17
|
-
* files, so legacy undocumented code never blocks commits.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
21
|
-
import path from 'node:path';
|
|
22
|
-
import {
|
|
23
|
-
import { loadConfig,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
)
|
|
70
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-docs.mjs
|
|
4
|
+
*
|
|
5
|
+
* Documentation gate for shared exports. Fails (exit 1) when exported
|
|
6
|
+
* declarations in the checked files lack a one-line `/** When to use ... *\/`
|
|
7
|
+
* JSDoc above them. Keeps docs/COMPONENT_INDEX.md (generated from the same
|
|
8
|
+
* JSDoc) a maintained, trustworthy inventory instead of a stale artifact.
|
|
9
|
+
*
|
|
10
|
+
* Zero dependencies.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node scripts/anti-slop/check-docs.mjs [paths...] [--src src] [--report-only]
|
|
14
|
+
*
|
|
15
|
+
* With no path arguments it scans --src (default `src`). With path arguments
|
|
16
|
+
* it checks exactly those files — used by git hooks to check only staged
|
|
17
|
+
* files, so legacy undocumented code never blocks commits.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { extractExports } from './lib.mjs';
|
|
23
|
+
import { loadConfig, gateArgs, collectPathFiles, resolveScanTarget, applyIgnorePaths } from './cli.mjs';
|
|
24
|
+
import { baselineGate } from './baseline.mjs';
|
|
25
|
+
|
|
26
|
+
// Defaults < anti-slop.config.json < CLI flags.
|
|
27
|
+
const cfgDocs = loadConfig().docs ?? {};
|
|
28
|
+
const { args, positionals } = gateArgs({
|
|
29
|
+
argv: process.argv.slice(2),
|
|
30
|
+
usage: 'Usage: node check-docs.mjs [paths...] [--src src] [--report-only] [--baseline file] [--update-baseline]\n' +
|
|
31
|
+
'Config: anti-slop.config.json "docs" section (src, ignorePaths, ignoreExports).\n' +
|
|
32
|
+
'With path arguments, checks exactly those files (git-hook staged mode).\n' +
|
|
33
|
+
'With a baseline file (default anti-slop.baseline.json) the gate fails only on NEW undocumented exports.',
|
|
34
|
+
spec: { '--src': 'string', '--report-only': 'flag', '--baseline': 'string', '--update-baseline': 'flag' },
|
|
35
|
+
defaults: { src: cfgDocs.src ?? 'src' },
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Framework-owned exports that are configuration, not shared surface.
|
|
40
|
+
// Defaulted on when a Next.js config is present (segment config exports).
|
|
41
|
+
const NEXTJS_EXPORTS = [
|
|
42
|
+
'metadata', 'generateMetadata', 'viewport', 'generateViewport', 'dynamic',
|
|
43
|
+
'revalidate', 'runtime', 'dynamicParams', 'fetchCache', 'maxDuration',
|
|
44
|
+
'preferredRegion', 'generateStaticParams', 'generateImageMetadata',
|
|
45
|
+
];
|
|
46
|
+
const hasNextConfig = existsSync(path.join(process.cwd(), 'next.config.js')) ||
|
|
47
|
+
existsSync(path.join(process.cwd(), 'next.config.mjs')) ||
|
|
48
|
+
existsSync(path.join(process.cwd(), 'next.config.ts'));
|
|
49
|
+
const ignoreExports = Array.isArray(cfgDocs.ignoreExports)
|
|
50
|
+
? cfgDocs.ignoreExports
|
|
51
|
+
: (hasNextConfig ? NEXTJS_EXPORTS : []);
|
|
52
|
+
const ignorePaths = Array.isArray(cfgDocs.ignorePaths) ? cfgDocs.ignorePaths : [];
|
|
53
|
+
|
|
54
|
+
const allFiles = positionals.length > 0 ? collectPathFiles(positionals) : resolveScanTarget(args.src);
|
|
55
|
+
// ignorePaths applies in both modes (per-file/staged and whole-tree), so the
|
|
56
|
+
// hook never rejects files the config says to ignore.
|
|
57
|
+
const files = applyIgnorePaths(allFiles, ignorePaths, { label: 'docs.ignorePaths', src: args.src });
|
|
58
|
+
|
|
59
|
+
let missing = 0;
|
|
60
|
+
const detail = [];
|
|
61
|
+
const perFileDocs = {};
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
let exports;
|
|
64
|
+
try {
|
|
65
|
+
exports = extractExports(readFileSync(file.full, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// Default exports are entry points (route files, pages) consumed by the
|
|
70
|
+
// framework, not shared surface; exempt them the way the index count does.
|
|
71
|
+
const undocumented = exports.filter(
|
|
72
|
+
(e) => !e.desc && !e.isDefault && !ignoreExports.includes(e.name),
|
|
73
|
+
);
|
|
74
|
+
if (undocumented.length === 0) continue;
|
|
75
|
+
perFileDocs[file.rel] = undocumented.length;
|
|
76
|
+
missing += undocumented.length;
|
|
77
|
+
detail.push({ rel: file.rel, names: undocumented.map((e) => e.name) });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- baseline (ratchet) mode ---------------------------------------------------
|
|
81
|
+
const decision = baselineGate({
|
|
82
|
+
args,
|
|
83
|
+
section: 'docs',
|
|
84
|
+
current: perFileDocs,
|
|
85
|
+
unit: 'undocumented export(s)',
|
|
86
|
+
fixLine: 'Fix: add the one-line "/** When to use ... */" JSDoc — it feeds the component index.',
|
|
87
|
+
});
|
|
88
|
+
if (decision === 'ok') process.exit(0);
|
|
89
|
+
if (decision === 'fail') process.exit(args.reportOnly ? 0 : 1);
|
|
90
|
+
|
|
91
|
+
if (missing === 0) {
|
|
92
|
+
console.log(`[ok] Docs check passed (${files.length} file(s), every export documented).`);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(`${missing} undocumented export(s) in ${detail.length} file(s):`);
|
|
97
|
+
for (const d of detail.slice(0, 20)) {
|
|
98
|
+
console.log(` ${d.rel}: ${d.names.join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
if (detail.length > 20) console.log(` ... and ${detail.length - 20} more file(s)`);
|
|
101
|
+
|
|
102
|
+
console.error(
|
|
103
|
+
'\n[x] Docs check FAILED.\n' +
|
|
104
|
+
' Fix: add a one-line "/** When to use ... */" JSDoc above each export —\n' +
|
|
105
|
+
' this is what feeds docs/COMPONENT_INDEX.md, the inventory the\n' +
|
|
106
|
+
' reuse-before-create pre-flight depends on. Do NOT delete the check or\n' +
|
|
107
|
+
' leave the doc as a bare restatement of the name.'
|
|
108
|
+
);
|
|
109
|
+
process.exit(args.reportOnly ? 0 : 1);
|
|
@@ -20,30 +20,30 @@
|
|
|
20
20
|
* threshold = max allowed duplicated-lines percent; 0 disables failing.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
import path from 'node:path';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
25
24
|
import {
|
|
26
|
-
|
|
25
|
+
stripNoise, tokenize,
|
|
27
26
|
} from './lib.mjs';
|
|
28
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
loadConfig, matchesAnyGlob, gateArgs, resolveScanTarget,
|
|
29
|
+
} from './cli.mjs';
|
|
30
|
+
import { baselineGate } from './baseline.mjs';
|
|
29
31
|
|
|
30
32
|
// --- args -------------------------------------------------------------------
|
|
31
33
|
// Defaults < anti-slop.config.json < CLI flags.
|
|
32
34
|
const cfgDups = (loadConfig().duplicates ?? {});
|
|
33
|
-
const { args
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const { args } = gateArgs({
|
|
36
|
+
argv: process.argv.slice(2),
|
|
37
|
+
usage: 'Usage: node check-duplicates.mjs [--src src] [--min-tokens 50] [--threshold 3] [--baseline file] [--update-baseline] [--summary]\n' +
|
|
38
|
+
'Config: anti-slop.config.json "duplicates" section (src, minTokens, threshold, ignore globs).\n' +
|
|
39
|
+
'With a baseline file (default anti-slop.baseline.json) the gate fails only on NEW clones.',
|
|
40
|
+
spec: { '--src': 'string', '--min-tokens': 'number', '--threshold': 'number', '--baseline': 'string', '--update-baseline': 'flag', '--summary': 'flag' },
|
|
37
41
|
defaults: {
|
|
38
42
|
src: cfgDups.src ?? 'src',
|
|
39
43
|
minTokens: cfgDups.minTokens ?? 50,
|
|
40
44
|
threshold: cfgDups.threshold ?? 3,
|
|
41
45
|
},
|
|
42
46
|
});
|
|
43
|
-
if (positionals.length > 0) {
|
|
44
|
-
console.error(`Unexpected arguments: ${positionals.join(' ')} (this script takes flags only)`);
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
47
47
|
if (args.minTokens < 10) {
|
|
48
48
|
console.error('min-tokens must be a number >= 10');
|
|
49
49
|
process.exit(1);
|
|
@@ -132,7 +132,16 @@ for (const c of clones) {
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
// Same-file pairs whose line ranges overlap are the sliding window matching
|
|
136
|
+
// itself shifted within one line — not clones, and they inflate both the
|
|
137
|
+
// region list and the per-file ranking. Keep disjoint same-file pairs and all
|
|
138
|
+
// cross-file pairs.
|
|
139
|
+
const regions = cloneRegions.filter((r) => {
|
|
140
|
+
if (r.aFile !== r.bFile) return true;
|
|
141
|
+
return r.aLine > r.bEndLine || r.bLine > r.aEndLine;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
for (const r of regions) {
|
|
136
145
|
for (const [rel, s, e] of [[r.aFile, r.aLine, r.aEndLine], [r.bFile, r.bLine, r.bEndLine]]) {
|
|
137
146
|
let set = dupLines.get(rel);
|
|
138
147
|
if (!set) { set = new Set(); dupLines.set(rel, set); }
|
|
@@ -151,14 +160,14 @@ const pct = totalAll > 0 ? (totalDup / totalAll) * 100 : 0;
|
|
|
151
160
|
// --- report -------------------------------------------------------------------
|
|
152
161
|
console.log(`Checked ${files.length} files (${totalAll} lines), min-tokens=${K}, threshold=${args.threshold}%`);
|
|
153
162
|
|
|
154
|
-
if (
|
|
163
|
+
if (regions.length === 0) {
|
|
155
164
|
console.log('[ok] No duplicates found.');
|
|
156
165
|
process.exit(0);
|
|
157
166
|
}
|
|
158
167
|
|
|
159
|
-
const MAX_REPORT = 40;
|
|
160
|
-
console.log(`Found ${
|
|
161
|
-
for (const r of
|
|
168
|
+
const MAX_REPORT = args.summary ? 3 : 40;
|
|
169
|
+
console.log(`Found ${regions.length} clone region(s). Showing up to ${MAX_REPORT}:\n`);
|
|
170
|
+
for (const r of regions.slice(0, MAX_REPORT)) {
|
|
162
171
|
console.log(` ${r.aFile}:${r.aLine}-${r.aEndLine} <-> ${r.bFile}:${r.bLine}-${r.bEndLine} (~${r.tokens} tokens)`);
|
|
163
172
|
}
|
|
164
173
|
|
|
@@ -166,16 +175,32 @@ const worst = [...dupLines.entries()]
|
|
|
166
175
|
.map(([rel, set]) => ({ rel, dup: set.size, total: totalLines.get(rel) }))
|
|
167
176
|
.filter((f) => f.dup > 0)
|
|
168
177
|
.sort((a, b) => b.dup / b.total - a.dup / a.total)
|
|
169
|
-
.slice(0, 5);
|
|
178
|
+
.slice(0, args.summary ? 3 : 5);
|
|
170
179
|
console.log('\nMost duplicated files:');
|
|
171
180
|
for (const w of worst) console.log(` ${w.rel}: ${w.dup}/${w.total} lines (${((w.dup / w.total) * 100).toFixed(1)}%)`);
|
|
172
181
|
console.log(`\nTotal duplicated lines: ${totalDup}/${totalAll} (${pct.toFixed(1)}%)`);
|
|
173
182
|
|
|
183
|
+
// --- baseline (ratchet) mode ---------------------------------------------------
|
|
184
|
+
const perFileDup = {};
|
|
185
|
+
for (const [rel, set] of dupLines) if (set.size > 0) perFileDup[rel] = set.size;
|
|
186
|
+
const decision = baselineGate({
|
|
187
|
+
args,
|
|
188
|
+
section: 'duplicates',
|
|
189
|
+
current: perFileDup,
|
|
190
|
+
extra: { totalDuplicatedLines: totalDup },
|
|
191
|
+
unit: 'duplicated line(s)',
|
|
192
|
+
fixLine: 'Fix: extract into existing shared modules (see the reuse-before-create skill).',
|
|
193
|
+
});
|
|
194
|
+
if (decision === 'ok') process.exit(0);
|
|
195
|
+
if (decision === 'fail') process.exit(1);
|
|
196
|
+
|
|
174
197
|
if (args.threshold > 0 && pct > args.threshold) {
|
|
175
198
|
console.error(
|
|
176
199
|
`\n[x] Duplicate-code check FAILED (${pct.toFixed(1)}% > ${args.threshold}%).\n` +
|
|
177
200
|
' Fix: extract duplicates into existing shared modules (see the reuse-before-create skill).\n' +
|
|
178
|
-
' Do NOT weaken the threshold or add ignore comments to pass
|
|
201
|
+
' Do NOT weaken the threshold or add ignore comments to pass.\n' +
|
|
202
|
+
' Adopting a repo with legacy duplication? Snapshot it once:\n' +
|
|
203
|
+
` node scripts/anti-slop/check-duplicates.mjs --update-baseline — the gate then fails only on NEW clones.`
|
|
179
204
|
);
|
|
180
205
|
process.exit(1);
|
|
181
206
|
}
|
|
@@ -22,8 +22,11 @@
|
|
|
22
22
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { pathToFileURL } from 'node:url';
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
25
|
+
import { stripComments } from './lib.mjs';
|
|
26
|
+
import {
|
|
27
|
+
loadConfig, gateArgs, resolveScanTarget, matchesAnyGlob,
|
|
28
|
+
} from './cli.mjs';
|
|
29
|
+
import { loadBaseline, saveBaseline, compareSets, BASELINE_FILE } from './baseline.mjs';
|
|
27
30
|
|
|
28
31
|
// --- parsing ------------------------------------------------------------------
|
|
29
32
|
|
|
@@ -146,36 +149,33 @@ export function findCycles(graph) {
|
|
|
146
149
|
const isMain =
|
|
147
150
|
process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
148
151
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
spec: { '--src': 'string', '--report-only': 'flag' },
|
|
155
|
-
defaults: { src: cfgImports.src ?? 'src' },
|
|
156
|
-
});
|
|
157
|
-
if (positionals.length > 0) {
|
|
158
|
-
console.error(`Unexpected arguments: ${positionals.join(' ')} (this script takes flags only)`);
|
|
159
|
-
process.exit(1);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const src = path.resolve(args.src);
|
|
163
|
-
if (!existsSync(src) || !statSync(src).isDirectory()) {
|
|
164
|
-
console.error(`Source directory not found: ${src}`);
|
|
165
|
-
process.exit(1);
|
|
166
|
-
}
|
|
167
|
-
const files = resolveScanTarget(args.src);
|
|
168
|
-
const graph = buildImportGraph(files, src);
|
|
169
|
-
const deny = Array.isArray(cfgImports.deny) ? cfgImports.deny : [];
|
|
170
|
-
|
|
152
|
+
/** Evaluate cycles + deny rules against the graph, printing findings.
|
|
153
|
+
* Returns the violation count. Deny-rule violations are never baselined:
|
|
154
|
+
* rules are declared deliberately, so violating one is new debt by definition.
|
|
155
|
+
*/
|
|
156
|
+
function evaluateBoundaries(graph, cycles, deny, baseline) {
|
|
171
157
|
let violations = 0;
|
|
158
|
+
const cycleKeys = cycles.map((chain) => [...chain.slice(0, -1)].sort().join('~'));
|
|
172
159
|
|
|
173
|
-
const cycles = findCycles(graph);
|
|
174
160
|
if (cycles.length > 0) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
161
|
+
if (baseline?.imports) {
|
|
162
|
+
const { added } = compareSets(cycleKeys, baseline.imports.cycles);
|
|
163
|
+
if (added.length === 0) {
|
|
164
|
+
console.log(`[ok] No NEW cycles (${cycles.length} baselined cycle(s) remain; ${graph.size} files).`);
|
|
165
|
+
} else {
|
|
166
|
+
console.log(`NEW circular dependencies (${added.length}):`);
|
|
167
|
+
for (const key of added.slice(0, 20)) {
|
|
168
|
+
const chain = cycles.find((c) => [...c.slice(0, -1)].sort().join('~') === key);
|
|
169
|
+
console.log(` ${chain ? chain.join(' -> ') : key}`);
|
|
170
|
+
}
|
|
171
|
+
violations += added.length;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
console.log(`Circular dependencies (${cycles.length}):`);
|
|
175
|
+
for (const chain of cycles.slice(0, 20)) {
|
|
176
|
+
console.log(` ${chain.join(' -> ')}`);
|
|
177
|
+
violations++;
|
|
178
|
+
}
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
@@ -194,9 +194,49 @@ if (isMain) {
|
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
|
+
return violations;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function main() {
|
|
201
|
+
const cfgImports = loadConfig().imports ?? {};
|
|
202
|
+
const { args } = gateArgs({
|
|
203
|
+
argv: process.argv.slice(2),
|
|
204
|
+
usage: 'Usage: node check-imports.mjs [--src src] [--report-only] [--baseline file] [--update-baseline]\n' +
|
|
205
|
+
'Config: anti-slop.config.json "imports" section (src, deny: [{from, forbid}]).\n' +
|
|
206
|
+
'With a baseline file (default anti-slop.baseline.json) the gate fails only on NEW cycles.',
|
|
207
|
+
spec: { '--src': 'string', '--report-only': 'flag', '--baseline': 'string', '--update-baseline': 'flag' },
|
|
208
|
+
defaults: { src: cfgImports.src ?? 'src' },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const src = path.resolve(args.src);
|
|
212
|
+
if (!existsSync(src) || !statSync(src).isDirectory()) {
|
|
213
|
+
console.error(`Source directory not found: ${src}`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
const files = resolveScanTarget(args.src);
|
|
217
|
+
const graph = buildImportGraph(files, src);
|
|
218
|
+
const deny = Array.isArray(cfgImports.deny) ? cfgImports.deny : [];
|
|
219
|
+
|
|
220
|
+
const cycles = findCycles(graph);
|
|
221
|
+
const cycleKeys = cycles.map((chain) => [...chain.slice(0, -1)].sort().join('~'));
|
|
222
|
+
|
|
223
|
+
if (args.updateBaseline) {
|
|
224
|
+
const file = args.baseline ?? BASELINE_FILE;
|
|
225
|
+
saveBaseline(file, { imports: { cycles: [...cycleKeys].sort() } });
|
|
226
|
+
console.log(`[ok] Baseline written to ${file} (${cycleKeys.length} cycle(s)).`);
|
|
227
|
+
console.log(' Commit it. The gate now fails only on NEW cycles; shrink the baseline as you break them.');
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const baseline = loadBaseline(args.baseline);
|
|
232
|
+
const violations = evaluateBoundaries(graph, cycles, deny, baseline);
|
|
197
233
|
|
|
198
234
|
if (violations === 0) {
|
|
199
|
-
|
|
235
|
+
if (deny.length === 0) {
|
|
236
|
+
console.log(`[ok] No cycles (${graph.size} files). Cycles-only gate: no imports.deny rules configured — declare some (see README "Tuning per repo") to enforce layer direction.`);
|
|
237
|
+
} else {
|
|
238
|
+
console.log(`[ok] Import-boundary check passed (${graph.size} files, no cycles, ${deny.length} layer rule(s)).`);
|
|
239
|
+
}
|
|
200
240
|
process.exit(0);
|
|
201
241
|
}
|
|
202
242
|
|
|
@@ -207,3 +247,5 @@ if (isMain) {
|
|
|
207
247
|
);
|
|
208
248
|
process.exit(args.reportOnly ? 0 : 1);
|
|
209
249
|
}
|
|
250
|
+
|
|
251
|
+
if (isMain) main();
|