opencode-anti-slop 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-duplicates.mjs
4
+ *
5
+ * Built-in copy/paste detector for JS/TS/React code. Fails (exit 1) when the
6
+ * percentage of duplicated lines across the source tree exceeds `threshold`.
7
+ * Used as a pre-completion / pre-commit gate by the `verify-clean-code` skill.
8
+ *
9
+ * Zero dependencies. Why not jscpd: verified in testing that jscpd (v3 and v4
10
+ * via npx) can analyze 0 files on some Windows setups; a built-in token-stream
11
+ * detector is deterministic and needs no downloads.
12
+ *
13
+ * Method: strip comments, tokenize, compare sliding windows of `min-tokens`
14
+ * consecutive tokens between files (and within a file). Windows are
15
+ * normalized: string/number literals are abstracted so clones with changed
16
+ * text/numbers are still caught.
17
+ *
18
+ * Usage:
19
+ * node scripts/anti-slop/check-duplicates.mjs [--src src] [--min-tokens 50] [--threshold 3]
20
+ * threshold = max allowed duplicated-lines percent; 0 disables failing.
21
+ */
22
+
23
+ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
24
+ import path from 'node:path';
25
+ import {
26
+ walk, stripNoise, tokenize, lineOf,
27
+ } from './lib.mjs';
28
+ import { loadConfig, matchesAnyGlob, bootstrapGate, resolveScanTarget } from './cli.mjs';
29
+
30
+ // --- args -------------------------------------------------------------------
31
+ // Defaults < anti-slop.config.json < CLI flags.
32
+ 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' },
37
+ defaults: {
38
+ src: cfgDups.src ?? 'src',
39
+ minTokens: cfgDups.minTokens ?? 50,
40
+ threshold: cfgDups.threshold ?? 3,
41
+ },
42
+ });
43
+ if (positionals.length > 0) {
44
+ console.error(`Unexpected arguments: ${positionals.join(' ')} (this script takes flags only)`);
45
+ process.exit(1);
46
+ }
47
+ if (args.minTokens < 10) {
48
+ console.error('min-tokens must be a number >= 10');
49
+ process.exit(1);
50
+ }
51
+ const IGNORE_GLOBS = Array.isArray(cfgDups.ignore) ? cfgDups.ignore : [];
52
+
53
+ // --- detect -------------------------------------------------------------------
54
+ const allFiles = resolveScanTarget(args.src);
55
+ const files = allFiles.filter((f) => !matchesAnyGlob(f.rel, IGNORE_GLOBS));
56
+ const excludedCount = allFiles.length - files.length;
57
+ if (IGNORE_GLOBS.length > 0) {
58
+ console.log(`Ignoring paths relative to '${args.src}/': ${IGNORE_GLOBS.join(', ')} (${excludedCount} file(s) excluded).`);
59
+ }
60
+ const K = args.minTokens;
61
+
62
+ // Map: window key -> [{ file, idx }]
63
+ const windows = new Map();
64
+
65
+ for (const file of files) {
66
+ const stripped = stripNoise(readFileSync(file.full, 'utf8'));
67
+ file.tokens = tokenize(stripped);
68
+ file.totalLines = stripped.split('\n').length;
69
+ if (file.tokens.length < K) continue;
70
+ for (let i = 0; i + K <= file.tokens.length; i++) {
71
+ const key = file.tokens.slice(i, i + K).map((x) => x.t).join('\u0000');
72
+ let list = windows.get(key);
73
+ if (!list) { list = []; windows.set(key, list); }
74
+ list.push({ file, idx: i });
75
+ }
76
+ }
77
+
78
+ // Merge overlapping window hits into clone regions (per file pair).
79
+ const clones = []; // { aFile, bFile, aStart, aEnd, bStart, bEnd, tokens }
80
+ const seenPairs = new Set();
81
+
82
+ for (const list of windows.values()) {
83
+ if (list.length < 2) continue;
84
+ for (let x = 0; x < list.length; x++) {
85
+ for (let y = x + 1; y < list.length; y++) {
86
+ const A = list[x];
87
+ const B = list[y];
88
+ if (A.file === B.file && Math.abs(A.idx - B.idx) < K) continue; // overlapping self-match
89
+ const pairKey = `${A.file.rel}|${A.idx}|${B.file.rel}|${B.idx}`;
90
+ // skip if this (A,B) pair region is already covered by a longer clone
91
+ const idPair = `${A.file.rel}~${B.file.rel}`;
92
+ let covered = false;
93
+ for (const c of clones) {
94
+ if (c.aFile !== A.file.rel || c.bFile !== B.file.rel) continue;
95
+ if (A.idx >= c.aStart - K && A.idx <= c.aEnd + K && B.idx >= c.bStart - K && B.idx <= c.bEnd + K) {
96
+ // extend if adjacent
97
+ if (A.idx <= c.aEnd + 1 || B.idx <= c.bEnd + 1) {
98
+ c.aEnd = Math.max(c.aEnd, A.idx + K - 1);
99
+ c.bEnd = Math.max(c.bEnd, B.idx + K - 1);
100
+ c.tokens = Math.max(c.tokens, c.aEnd - c.aStart + 1);
101
+ covered = true;
102
+ break;
103
+ }
104
+ }
105
+ }
106
+ if (covered) continue;
107
+ if (seenPairs.has(idPair) && clones.length > 200) continue; // hard cap on pathological input
108
+ clones.push({
109
+ aFile: A.file.rel, bFile: B.file.rel,
110
+ aStart: A.idx, aEnd: A.idx + K - 1,
111
+ bStart: B.idx, bEnd: B.idx + K - 1,
112
+ tokens: K,
113
+ });
114
+ seenPairs.add(idPair);
115
+ }
116
+ }
117
+ }
118
+
119
+ // --- compute duplicated lines per file ---------------------------------------
120
+ const dupLines = new Map(); // rel -> Set<line>
121
+ const totalLines = new Map();
122
+ for (const f of files) totalLines.set(f.rel, f.totalLines);
123
+
124
+ const cloneRegions = [];
125
+ for (const c of clones) {
126
+ const aTok = files.find((f) => f.rel === c.aFile).tokens;
127
+ const bTok = files.find((f) => f.rel === c.bFile).tokens;
128
+ cloneRegions.push({
129
+ aFile: c.aFile, aLine: aTok[c.aStart]?.line ?? 1, aEndLine: aTok[Math.min(c.aEnd, aTok.length - 1)]?.line ?? aTok.length,
130
+ bFile: c.bFile, bLine: bTok[c.bStart]?.line ?? 1, bEndLine: bTok[Math.min(c.bEnd, bTok.length - 1)]?.line ?? bTok.length,
131
+ tokens: c.tokens,
132
+ });
133
+ }
134
+
135
+ for (const r of cloneRegions) {
136
+ for (const [rel, s, e] of [[r.aFile, r.aLine, r.aEndLine], [r.bFile, r.bLine, r.bEndLine]]) {
137
+ let set = dupLines.get(rel);
138
+ if (!set) { set = new Set(); dupLines.set(rel, set); }
139
+ for (let l = s; l <= e; l++) set.add(l);
140
+ }
141
+ }
142
+
143
+ let totalDup = 0;
144
+ let totalAll = 0;
145
+ for (const f of files) {
146
+ totalAll += f.totalLines;
147
+ totalDup += dupLines.get(f.rel)?.size ?? 0;
148
+ }
149
+ const pct = totalAll > 0 ? (totalDup / totalAll) * 100 : 0;
150
+
151
+ // --- report -------------------------------------------------------------------
152
+ console.log(`Checked ${files.length} files (${totalAll} lines), min-tokens=${K}, threshold=${args.threshold}%`);
153
+
154
+ if (cloneRegions.length === 0) {
155
+ console.log('[ok] No duplicates found.');
156
+ process.exit(0);
157
+ }
158
+
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)) {
162
+ console.log(` ${r.aFile}:${r.aLine}-${r.aEndLine} <-> ${r.bFile}:${r.bLine}-${r.bEndLine} (~${r.tokens} tokens)`);
163
+ }
164
+
165
+ const worst = [...dupLines.entries()]
166
+ .map(([rel, set]) => ({ rel, dup: set.size, total: totalLines.get(rel) }))
167
+ .filter((f) => f.dup > 0)
168
+ .sort((a, b) => b.dup / b.total - a.dup / a.total)
169
+ .slice(0, 5);
170
+ console.log('\nMost duplicated files:');
171
+ for (const w of worst) console.log(` ${w.rel}: ${w.dup}/${w.total} lines (${((w.dup / w.total) * 100).toFixed(1)}%)`);
172
+ console.log(`\nTotal duplicated lines: ${totalDup}/${totalAll} (${pct.toFixed(1)}%)`);
173
+
174
+ if (args.threshold > 0 && pct > args.threshold) {
175
+ console.error(
176
+ `\n[x] Duplicate-code check FAILED (${pct.toFixed(1)}% > ${args.threshold}%).\n` +
177
+ ' 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.'
179
+ );
180
+ process.exit(1);
181
+ }
182
+ console.log('[ok] Duplicate-code check passed.');
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-imports.mjs
4
+ *
5
+ * Import-boundary gate: detects circular dependencies (default, blocking)
6
+ * and configurable layer violations (`imports.deny` in anti-slop.config.json).
7
+ * Catches the architecture drift that size budgets cannot: a cycle between
8
+ * modules is two responsibilities fused, and a `from`/`forbid` rule encodes
9
+ * the ports-and-adapters direction the modular-architecture skill teaches.
10
+ *
11
+ * Zero dependencies. Resolution is relative-import only (package specifiers
12
+ * are ignored); candidates probe extensions and /index files.
13
+ *
14
+ * Usage:
15
+ * node scripts/anti-slop/check-imports.mjs [--src src] [--report-only]
16
+ *
17
+ * Config:
18
+ * "imports": { "src": "src", "deny": [{ "from": "src/app/**", "forbid": "src/collections/**" }] }
19
+ * `from`/`forbid` are globs matched against repo-relative paths ('/' separators).
20
+ */
21
+
22
+ import { readFileSync, existsSync, statSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+ import { walk, stripComments } from './lib.mjs';
26
+ import { loadConfig, bootstrapGate, resolveScanTarget, matchesAnyGlob } from './cli.mjs';
27
+
28
+ // --- parsing ------------------------------------------------------------------
29
+
30
+ const IMPORT_RE =
31
+ /(?:import|export)\s+(?:[\w*{}\s,]*?\sfrom\s+)?['"]([^'"]+)['"]|(?:require|import)\(\s*['"]([^'"]+)['"]\s*\)/g;
32
+
33
+ const PROBE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts'];
34
+
35
+ /** Resolve a relative specifier to a file inside `root`; null for packages/misses.
36
+ * Probes extensions and /index files; a specifier written with one extension
37
+ * (e.g. './c.js' for a c.ts file, the TS convention) is re-probed with all of them.
38
+ */
39
+ export function resolveSpecifier(fromFull, spec, root) {
40
+ if (!spec.startsWith('.')) return null;
41
+ let base = path.resolve(path.dirname(fromFull), spec);
42
+ const hasExt = PROBE_EXTS.includes(path.extname(base).toLowerCase());
43
+ const stem = hasExt ? base.slice(0, base.length - path.extname(base).length) : base;
44
+ const candidates = [
45
+ ...(hasExt ? [base] : []),
46
+ ...PROBE_EXTS.map((e) => stem + e),
47
+ ...PROBE_EXTS.map((e) => path.join(stem, 'index' + e)),
48
+ ];
49
+ for (const c of candidates) {
50
+ if (existsSync(c) && statSync(c).isFile()) {
51
+ return path.relative(root, c).split(path.sep).join('/');
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Extract import specifiers, skipping statements that begin inside string
59
+ * literals — a code example embedded in a string is not an import edge.
60
+ */
61
+ function parseImports(source) {
62
+ const ranges = stringRanges(source);
63
+ const specs = [];
64
+ for (const m of source.matchAll(IMPORT_RE)) {
65
+ const idx = m.index ?? 0;
66
+ if (ranges.some(([s, e]) => idx >= s && idx <= e)) continue;
67
+ const spec = m[1] ?? m[2];
68
+ if (spec) specs.push(spec);
69
+ }
70
+ return specs;
71
+ }
72
+
73
+ /** [start, end] index ranges of string literals (end = closing quote index). */
74
+ function stringRanges(src) {
75
+ const ranges = [];
76
+ let i = 0;
77
+ while (i < src.length) {
78
+ const c = src[i];
79
+ if (c === '"' || c === "'" || c === '`') {
80
+ const start = i;
81
+ const q = c;
82
+ i++;
83
+ while (i < src.length && src[i] !== q) {
84
+ if (src[i] === '\\') i++;
85
+ i++;
86
+ }
87
+ ranges.push([start, i]);
88
+ i++; // closing quote
89
+ } else {
90
+ i++;
91
+ }
92
+ }
93
+ return ranges;
94
+ }
95
+
96
+ /** Build the internal import graph: relFile -> Set<relFile> (relative imports only). */
97
+ export function buildImportGraph(files, root) {
98
+ const graph = new Map();
99
+ for (const file of files) {
100
+ const source = stripComments(readFileSync(file.full, 'utf8'));
101
+ const edges = graph.get(file.rel) ?? new Set();
102
+ for (const spec of parseImports(source)) {
103
+ const resolved = resolveSpecifier(file.full, spec, root);
104
+ if (resolved && resolved !== file.rel) edges.add(resolved);
105
+ }
106
+ graph.set(file.rel, edges);
107
+ }
108
+ return graph;
109
+ }
110
+
111
+ /**
112
+ * Find circular dependencies. Returns each cycle once, as a chain
113
+ * [a, b, ..., a] rotated so the lexicographically smallest node is first.
114
+ */
115
+ export function findCycles(graph) {
116
+ const cycles = new Map(); // key -> chain
117
+ const state = new Map(); // rel -> 'visiting' | 'done'
118
+ const stack = [];
119
+
120
+ function dfs(node) {
121
+ state.set(node, 'visiting');
122
+ stack.push(node);
123
+ for (const next of graph.get(node) ?? []) {
124
+ const s = state.get(next);
125
+ if (s === 'visiting') {
126
+ const at = stack.indexOf(next);
127
+ const chain = stack.slice(at).concat(next);
128
+ const key = [...chain.slice(0, -1)].sort().join('~');
129
+ if (!cycles.has(key)) cycles.set(key, chain);
130
+ } else if (!s) {
131
+ dfs(next);
132
+ }
133
+ }
134
+ stack.pop();
135
+ state.set(node, 'done');
136
+ }
137
+
138
+ for (const node of graph.keys()) {
139
+ if (!state.get(node)) dfs(node);
140
+ }
141
+ return [...cycles.values()];
142
+ }
143
+
144
+ // --- CLI (only when run directly; exported functions are unit-testable) --------
145
+
146
+ const isMain =
147
+ process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
148
+
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
+
171
+ let violations = 0;
172
+
173
+ const cycles = findCycles(graph);
174
+ 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++;
179
+ }
180
+ }
181
+
182
+ for (const rule of deny) {
183
+ if (!rule?.from || !rule?.forbid) {
184
+ console.error(`[warn] ignoring malformed imports.deny rule: ${JSON.stringify(rule)}`);
185
+ continue;
186
+ }
187
+ for (const [from, edges] of graph) {
188
+ if (!matchesAnyGlob(from, [rule.from])) continue;
189
+ for (const to of edges) {
190
+ if (matchesAnyGlob(to, [rule.forbid])) {
191
+ console.log(`Layer violation: ${from} imports ${to} (deny: from '${rule.from}' forbid '${rule.forbid}')`);
192
+ violations++;
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ if (violations === 0) {
199
+ console.log(`[ok] Import-boundary check passed (${graph.size} files, no cycles, ${deny.length} layer rule(s)).`);
200
+ process.exit(0);
201
+ }
202
+
203
+ console.error(
204
+ `\n[x] Import-boundary check FAILED (${violations} violation(s)).\n` +
205
+ ' Fix: break the cycle by extracting the shared part into a module both can import\n' +
206
+ ' (see the modular-architecture skill). Do NOT create a re-export shim that keeps the cycle.'
207
+ );
208
+ process.exit(args.reportOnly ? 0 : 1);
209
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Unit tests for the import-boundary gate's graph logic (check-imports.mjs).
3
+ * The script guards its CLI behind an isMain check so these can import it.
4
+ */
5
+
6
+ import test from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ import { buildImportGraph, findCycles, resolveSpecifier } from './check-imports.mjs';
13
+
14
+ function fixture(files) {
15
+ const root = mkdtempSync(path.join(tmpdir(), 'antislop-imports-'));
16
+ for (const [rel, content] of Object.entries(files)) {
17
+ const full = path.join(root, rel);
18
+ mkdirSync(path.dirname(full), { recursive: true });
19
+ writeFileSync(full, content);
20
+ }
21
+ return root;
22
+ }
23
+
24
+ test('resolveSpecifier: probes extensions and index files', () => {
25
+ const root = fixture({
26
+ 'lib/date.ts': 'export const d = 1;',
27
+ 'lib/util.ts': 'export const u = 1;',
28
+ 'lib/legacy.mjs': 'export const l = 1;',
29
+ 'lib/store/index.ts': 'export const s = 1;',
30
+ });
31
+ const from = path.join(root, 'lib', 'date.ts');
32
+ assert.equal(resolveSpecifier(from, './util', root), 'lib/util.ts');
33
+ // TS convention: a .js specifier may point at a .ts file
34
+ assert.equal(resolveSpecifier(from, './util.js', root), 'lib/util.ts');
35
+ assert.equal(resolveSpecifier(from, './legacy.mjs', root), 'lib/legacy.mjs');
36
+ assert.equal(resolveSpecifier(from, './store', root), 'lib/store/index.ts');
37
+ assert.equal(resolveSpecifier(from, 'react', root), null);
38
+ assert.equal(resolveSpecifier(from, './missing', root), null);
39
+ assert.equal(resolveSpecifier(from, './missing.js', root), null);
40
+ rmSync(root, { recursive: true, force: true });
41
+ });
42
+
43
+ test('buildImportGraph: edges from import/export-from/require/dynamic', () => {
44
+ const root = fixture({
45
+ 'src/a.ts': [
46
+ "import { b } from './b';",
47
+ "export * from './c.js';",
48
+ "const d = require('./d');",
49
+ "async () => { const e = await import('./e'); };",
50
+ ].join('\n'),
51
+ 'src/b.ts': "import { c } from './c';",
52
+ 'src/c.ts': 'export const c = 1;',
53
+ 'src/d.ts': 'export const d = 1;',
54
+ 'src/e.ts': 'export const e = 1;',
55
+ });
56
+ const files = [
57
+ 'src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts', 'src/e.ts',
58
+ ].map((rel) => ({ full: path.join(root, rel), rel }));
59
+ const graph = buildImportGraph(files, root);
60
+ assert.deepEqual([...graph.get('src/a.ts')].sort(), ['src/b.ts', 'src/c.ts', 'src/d.ts', 'src/e.ts']);
61
+ assert.deepEqual([...graph.get('src/b.ts')], ['src/c.ts']);
62
+ rmSync(root, { recursive: true, force: true });
63
+ });
64
+
65
+ test('buildImportGraph: imports inside comments and strings are ignored', () => {
66
+ const root = fixture({
67
+ 'src/a.ts': [
68
+ "// import { ghost } from './ghost';",
69
+ '/* import { ghost2 } from "./ghost2"; */',
70
+ "const tip = \"import x from './ghost3';\";",
71
+ "import { b } from './b';",
72
+ ].join('\n'),
73
+ 'src/b.ts': 'export const b = 1;',
74
+ 'src/ghost.ts': 'export const g = 1;',
75
+ 'src/ghost2.ts': 'export const g2 = 1;',
76
+ 'src/ghost3.ts': 'export const g3 = 1;',
77
+ });
78
+ const files = ['src/a.ts', 'src/b.ts', 'src/ghost.ts', 'src/ghost2.ts', 'src/ghost3.ts']
79
+ .map((rel) => ({ full: path.join(root, rel), rel }));
80
+ const graph = buildImportGraph(files, root);
81
+ assert.deepEqual([...graph.get('src/a.ts')], ['src/b.ts']);
82
+ rmSync(root, { recursive: true, force: true });
83
+ });
84
+
85
+ test('findCycles: two-node cycle found once', () => {
86
+ const graph = new Map([
87
+ ['a.ts', new Set(['b.ts'])],
88
+ ['b.ts', new Set(['a.ts'])],
89
+ ['c.ts', new Set(['a.ts'])],
90
+ ]);
91
+ const cycles = findCycles(graph);
92
+ assert.equal(cycles.length, 1);
93
+ const chain = cycles[0];
94
+ assert.equal(chain[0], chain[chain.length - 1]);
95
+ assert.deepEqual([...chain.slice(0, -1)].sort(), ['a.ts', 'b.ts']);
96
+ });
97
+
98
+ test('findCycles: three-node cycle and self-import', () => {
99
+ const graph = new Map([
100
+ ['a.ts', new Set(['b.ts'])],
101
+ ['b.ts', new Set(['c.ts'])],
102
+ ['c.ts', new Set(['a.ts'])],
103
+ ['d.ts', new Set(['d.ts'])],
104
+ ]);
105
+ const cycles = findCycles(graph);
106
+ assert.equal(cycles.length, 2);
107
+ const three = cycles.find((c) => c.length === 4);
108
+ assert.deepEqual(three, ['a.ts', 'b.ts', 'c.ts', 'a.ts']);
109
+ const self = cycles.find((c) => c.length === 2);
110
+ assert.deepEqual(self, ['d.ts', 'd.ts']);
111
+ });
112
+
113
+ test('findCycles: acyclic graph -> no cycles', () => {
114
+ const graph = new Map([
115
+ ['a.ts', new Set(['b.ts', 'c.ts'])],
116
+ ['b.ts', new Set(['c.ts'])],
117
+ ['c.ts', new Set([])],
118
+ ]);
119
+ assert.deepEqual(findCycles(graph), []);
120
+ });
121
+
122
+ test('findCycles: deep cycle through many nodes does not blow the stack guard', () => {
123
+ const graph = new Map();
124
+ for (let i = 0; i < 200; i++) graph.set(`f${i}.ts`, new Set([`f${(i + 1) % 200}.ts`]));
125
+ const cycles = findCycles(graph);
126
+ assert.equal(cycles.length, 1);
127
+ assert.equal(cycles[0].length, 201);
128
+ });
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-modularity.mjs
4
+ *
5
+ * Deterministic monolith gate. Flags structural size violations:
6
+ * - files exceeding --max-file-lines (default 300)
7
+ * - functions/methods exceeding --max-function-lines (default 80)
8
+ * - classes exceeding --max-class-methods (default 10 public methods)
9
+ *
10
+ * Zero dependencies. Parsing is heuristic (brace matching on comment/string-
11
+ * stripped source); thresholds are budgets, not exact science — treat hits as
12
+ * "split by responsibility" signals.
13
+ *
14
+ * Usage:
15
+ * node scripts/anti-slop/check-modularity.mjs [paths...] [--src src] [--max-file-lines 300]
16
+ * [--max-function-lines 80] [--max-class-methods 10] [--report-only]
17
+ *
18
+ * With no path arguments it scans --src (default `src`).
19
+ * With path arguments it checks exactly those files (used by git hooks to
20
+ * check only staged files, so legacy violations don't block commits).
21
+ * --report-only prints findings but always exits 0.
22
+ */
23
+
24
+ import { readFileSync, existsSync } from 'node:fs';
25
+ import path from 'node:path';
26
+ import {
27
+ walk, stripNoise, lineOf,
28
+ } from './lib.mjs';
29
+ import { loadConfig, bootstrapGate, collectPathFiles, resolveScanTarget } from './cli.mjs';
30
+
31
+ // Defaults < anti-slop.config.json < CLI flags.
32
+ const cfgMod = ((loadConfig().modularity) ?? {});
33
+ const { args, positionals } = bootstrapGate(process.argv.slice(2), {
34
+ usage: 'Usage: node check-modularity.mjs [paths...] [--src src] [--max-file-lines 300] [--max-function-lines 80] [--max-class-methods 10] [--report-only]\n' +
35
+ 'Config: anti-slop.config.json "modularity" section (src, maxFileLines, maxFunctionLines, maxClassMethods).\n' +
36
+ 'With path arguments, checks exactly those files (git-hook staged mode).',
37
+ spec: {
38
+ '--src': 'string', '--max-file-lines': 'number', '--max-function-lines': 'number',
39
+ '--max-class-methods': 'number', '--report-only': 'flag',
40
+ },
41
+ defaults: {
42
+ src: cfgMod.src ?? 'src',
43
+ maxFileLines: cfgMod.maxFileLines ?? 300,
44
+ maxFunctionLines: cfgMod.maxFunctionLines ?? 80,
45
+ maxClassMethods: cfgMod.maxClassMethods ?? 10,
46
+ },
47
+ });
48
+ const paths = positionals;
49
+
50
+ function analyze(source, limits) {
51
+ const findings = [];
52
+ const stripped = stripNoise(source);
53
+ const totalLines = source.split('\n').length;
54
+
55
+ if (totalLines > limits.maxFileLines) {
56
+ findings.push({ kind: 'file', line: 1, detail: `${totalLines} lines (> ${limits.maxFileLines})` });
57
+ }
58
+
59
+ // Brace matching; record regions opened by a function-like `{`.
60
+ // A `{` is function-like when the preceding non-space char is `)` (params)
61
+ // or `>` of an arrow `=>`.
62
+ const stack = []; // { depth, start, isFn }
63
+ let depth = 0;
64
+ const regions = []; // { startIdx, endIdx, isFn }
65
+ const classes = []; // { startIdx, endIdx, name, depth }
66
+ let classStart = null; // { name, depth, braceDepth|null, methods }
67
+
68
+ for (let i = 0; i < stripped.length; i++) {
69
+ const c = stripped[i];
70
+ if (c === '{') {
71
+ let j = i - 1;
72
+ while (j >= 0 && /\s/.test(stripped[j])) j--;
73
+ const prev = j >= 0 ? stripped[j] : '';
74
+ // detect `class X ... {`
75
+ const head = stripped.slice(Math.max(0, i - 200), i);
76
+ const cm = /(?:^|[\s;}])class\s+([A-Za-z0-9_$]+)[^{}]*$/.exec(head);
77
+ const isFn = prev === ')' || head.trimEnd().endsWith('=>');
78
+ if (cm && classStart === null) {
79
+ classStart = { name: cm[1], declIdx: i, braceDepth: depth, methods: 0 };
80
+ } else if (classStart && classStart.braceDepth !== null && depth === classStart.braceDepth + 1 && prev === ')') {
81
+ classStart.methods++;
82
+ }
83
+ stack.push({ depth, start: i, isFn, classStart });
84
+ depth++;
85
+ } else if (c === '}') {
86
+ depth--;
87
+ const open = stack.pop();
88
+ if (open) {
89
+ if (open.isFn) regions.push({ startIdx: open.start, endIdx: i });
90
+ if (classStart && open.depth === classStart.braceDepth) {
91
+ classes.push({ ...classStart, endIdx: i });
92
+ classStart = null;
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ for (const r of regions) {
99
+ const startLine = lineOf(stripped, r.startIdx);
100
+ const endLine = lineOf(stripped, r.endIdx);
101
+ const len = endLine - startLine + 1;
102
+ if (len > limits.maxFunctionLines) {
103
+ findings.push({ kind: 'function', line: startLine, detail: `${len} lines (> ${limits.maxFunctionLines})` });
104
+ }
105
+ }
106
+ for (const c of classes) {
107
+ if (c.methods > limits.maxClassMethods) {
108
+ findings.push({
109
+ kind: 'class',
110
+ line: lineOf(stripped, c.declIdx),
111
+ detail: `class ${c.name}: ${c.methods} methods (> ${limits.maxClassMethods})`,
112
+ });
113
+ }
114
+ }
115
+
116
+ return { findings, totalLines };
117
+ }
118
+
119
+ // --- run ---------------------------------------------------------------------
120
+ const files = positionals.length > 0 ? collectPathFiles(positionals) : resolveScanTarget(args.src);
121
+
122
+ const limits = {
123
+ maxFileLines: args.maxFileLines,
124
+ maxFunctionLines: args.maxFunctionLines,
125
+ maxClassMethods: args.maxClassMethods,
126
+ };
127
+
128
+ let violations = 0;
129
+ for (const file of files) {
130
+ const { findings, totalLines } = analyze(readFileSync(file.full, 'utf8'), limits);
131
+ if (findings.length === 0) continue;
132
+ console.log(`${file.rel} (${totalLines} lines):`);
133
+ for (const f of findings) {
134
+ console.log(` [${f.kind}] line ${f.line}: ${f.detail}`);
135
+ violations++;
136
+ }
137
+ }
138
+
139
+ if (violations === 0) {
140
+ console.log(`[ok] Modularity check passed (${files.length} files within budgets).`);
141
+ process.exit(0);
142
+ }
143
+
144
+ console.log(`\n${violations} size-budget violation(s) in ${files.length} file(s).`);
145
+ console.error(
146
+ '[x] Modularity check FAILED.\n' +
147
+ ' 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.'
149
+ );
150
+ process.exit(args.reportOnly ? 0 : 1);