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/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 --if-present
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.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 { walk, extractExports } from './lib.mjs';
23
- import { loadConfig, bootstrapGate, collectPathFiles, resolveScanTarget } from './cli.mjs';
24
-
25
- // Defaults < anti-slop.config.json < CLI flags.
26
- const cfgDocs = loadConfig().docs ?? {};
27
- const { args, positionals } = bootstrapGate(process.argv.slice(2), {
28
- usage: 'Usage: node check-docs.mjs [paths...] [--src src] [--report-only]\n' +
29
- 'Config: anti-slop.config.json "docs" section (src).\n' +
30
- 'With path arguments, checks exactly those files (git-hook staged mode).',
31
- spec: { '--src': 'string', '--report-only': 'flag' },
32
- defaults: { src: cfgDocs.src ?? 'src' },
33
- });
34
-
35
- const files = positionals.length > 0 ? collectPathFiles(positionals) : resolveScanTarget(args.src);
36
-
37
- let missing = 0;
38
- const detail = [];
39
- for (const file of files) {
40
- let exports;
41
- try {
42
- exports = extractExports(readFileSync(file.full, 'utf8'));
43
- } catch {
44
- continue;
45
- }
46
- const undocumented = exports.filter((e) => !e.desc && e.name !== 'default');
47
- if (undocumented.length === 0) continue;
48
- missing += undocumented.length;
49
- detail.push({ rel: file.rel, names: undocumented.map((e) => e.name) });
50
- }
51
-
52
- if (missing === 0) {
53
- console.log(`[ok] Docs check passed (${files.length} file(s), every export documented).`);
54
- process.exit(0);
55
- }
56
-
57
- console.log(`${missing} undocumented export(s) in ${detail.length} file(s):`);
58
- for (const d of detail.slice(0, 20)) {
59
- console.log(` ${d.rel}: ${d.names.join(', ')}`);
60
- }
61
- if (detail.length > 20) console.log(` ... and ${detail.length - 20} more file(s)`);
62
-
63
- console.error(
64
- '\n[x] Docs check FAILED.\n' +
65
- ' Fix: add a one-line "/** When to use ... */" JSDoc above each export —\n' +
66
- ' this is what feeds docs/COMPONENT_INDEX.md, the inventory the\n' +
67
- ' reuse-before-create pre-flight depends on. Do NOT delete the check or\n' +
68
- ' leave the doc as a bare restatement of the name.'
69
- );
70
- process.exit(args.reportOnly ? 0 : 1);
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 { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
24
- import path from 'node:path';
23
+ import { readFileSync } from 'node:fs';
25
24
  import {
26
- walk, stripNoise, tokenize, lineOf,
25
+ stripNoise, tokenize,
27
26
  } from './lib.mjs';
28
- import { loadConfig, matchesAnyGlob, bootstrapGate, resolveScanTarget } from './cli.mjs';
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, positionals } = bootstrapGate(process.argv.slice(2), {
34
- usage: 'Usage: node check-duplicates.mjs [--src src] [--min-tokens 50] [--threshold 3]\n' +
35
- 'Config: anti-slop.config.json "duplicates" section (src, minTokens, threshold, ignore globs).',
36
- spec: { '--src': 'string', '--min-tokens': 'number', '--threshold': 'number' },
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
- for (const r of cloneRegions) {
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 (cloneRegions.length === 0) {
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 ${cloneRegions.length} clone region(s). Showing up to ${MAX_REPORT}:\n`);
161
- for (const r of cloneRegions.slice(0, MAX_REPORT)) {
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 { walk, stripComments } from './lib.mjs';
26
- import { loadConfig, bootstrapGate, resolveScanTarget, matchesAnyGlob } from './cli.mjs';
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
- if (isMain) {
150
- const cfgImports = loadConfig().imports ?? {};
151
- const { args, positionals } = bootstrapGate(process.argv.slice(2), {
152
- usage: 'Usage: node check-imports.mjs [--src src] [--report-only]\n' +
153
- 'Config: anti-slop.config.json "imports" section (src, deny: [{from, forbid}]).',
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
- console.log(`Circular dependencies (${cycles.length}):`);
176
- for (const chain of cycles.slice(0, 20)) {
177
- console.log(` ${chain.join(' -> ')}`);
178
- violations++;
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
- console.log(`[ok] Import-boundary check passed (${graph.size} files, no cycles, ${deny.length} layer rule(s)).`);
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();