opencode-anti-slop 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
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 { 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';
30
+
31
+ // --- parsing ------------------------------------------------------------------
32
+
33
+ const IMPORT_RE =
34
+ /(?:import|export)\s+(?:[\w*{}\s,]*?\sfrom\s+)?['"]([^'"]+)['"]|(?:require|import)\(\s*['"]([^'"]+)['"]\s*\)/g;
35
+
36
+ const PROBE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts'];
37
+
38
+ /** Resolve a relative specifier to a file inside `root`; null for packages/misses.
39
+ * Probes extensions and /index files; a specifier written with one extension
40
+ * (e.g. './c.js' for a c.ts file, the TS convention) is re-probed with all of them.
41
+ */
42
+ export function resolveSpecifier(fromFull, spec, root) {
43
+ if (!spec.startsWith('.')) return null;
44
+ let base = path.resolve(path.dirname(fromFull), spec);
45
+ const hasExt = PROBE_EXTS.includes(path.extname(base).toLowerCase());
46
+ const stem = hasExt ? base.slice(0, base.length - path.extname(base).length) : base;
47
+ const candidates = [
48
+ ...(hasExt ? [base] : []),
49
+ ...PROBE_EXTS.map((e) => stem + e),
50
+ ...PROBE_EXTS.map((e) => path.join(stem, 'index' + e)),
51
+ ];
52
+ for (const c of candidates) {
53
+ if (existsSync(c) && statSync(c).isFile()) {
54
+ return path.relative(root, c).split(path.sep).join('/');
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Extract import specifiers, skipping statements that begin inside string
62
+ * literals — a code example embedded in a string is not an import edge.
63
+ */
64
+ function parseImports(source) {
65
+ const ranges = stringRanges(source);
66
+ const specs = [];
67
+ for (const m of source.matchAll(IMPORT_RE)) {
68
+ const idx = m.index ?? 0;
69
+ if (ranges.some(([s, e]) => idx >= s && idx <= e)) continue;
70
+ const spec = m[1] ?? m[2];
71
+ if (spec) specs.push(spec);
72
+ }
73
+ return specs;
74
+ }
75
+
76
+ /** [start, end] index ranges of string literals (end = closing quote index). */
77
+ function stringRanges(src) {
78
+ const ranges = [];
79
+ let i = 0;
80
+ while (i < src.length) {
81
+ const c = src[i];
82
+ if (c === '"' || c === "'" || c === '`') {
83
+ const start = i;
84
+ const q = c;
85
+ i++;
86
+ while (i < src.length && src[i] !== q) {
87
+ if (src[i] === '\\') i++;
88
+ i++;
89
+ }
90
+ ranges.push([start, i]);
91
+ i++; // closing quote
92
+ } else {
93
+ i++;
94
+ }
95
+ }
96
+ return ranges;
97
+ }
98
+
99
+ /** Build the internal import graph: relFile -> Set<relFile> (relative imports only). */
100
+ export function buildImportGraph(files, root) {
101
+ const graph = new Map();
102
+ for (const file of files) {
103
+ const source = stripComments(readFileSync(file.full, 'utf8'));
104
+ const edges = graph.get(file.rel) ?? new Set();
105
+ for (const spec of parseImports(source)) {
106
+ const resolved = resolveSpecifier(file.full, spec, root);
107
+ if (resolved && resolved !== file.rel) edges.add(resolved);
108
+ }
109
+ graph.set(file.rel, edges);
110
+ }
111
+ return graph;
112
+ }
113
+
114
+ /**
115
+ * Find circular dependencies. Returns each cycle once, as a chain
116
+ * [a, b, ..., a] rotated so the lexicographically smallest node is first.
117
+ */
118
+ export function findCycles(graph) {
119
+ const cycles = new Map(); // key -> chain
120
+ const state = new Map(); // rel -> 'visiting' | 'done'
121
+ const stack = [];
122
+
123
+ function dfs(node) {
124
+ state.set(node, 'visiting');
125
+ stack.push(node);
126
+ for (const next of graph.get(node) ?? []) {
127
+ const s = state.get(next);
128
+ if (s === 'visiting') {
129
+ const at = stack.indexOf(next);
130
+ const chain = stack.slice(at).concat(next);
131
+ const key = [...chain.slice(0, -1)].sort().join('~');
132
+ if (!cycles.has(key)) cycles.set(key, chain);
133
+ } else if (!s) {
134
+ dfs(next);
135
+ }
136
+ }
137
+ stack.pop();
138
+ state.set(node, 'done');
139
+ }
140
+
141
+ for (const node of graph.keys()) {
142
+ if (!state.get(node)) dfs(node);
143
+ }
144
+ return [...cycles.values()];
145
+ }
146
+
147
+ // --- CLI (only when run directly; exported functions are unit-testable) --------
148
+
149
+ const isMain =
150
+ process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
151
+
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) {
157
+ let violations = 0;
158
+ const cycleKeys = cycles.map((chain) => [...chain.slice(0, -1)].sort().join('~'));
159
+
160
+ if (cycles.length > 0) {
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
+ }
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
+ 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);
233
+
234
+ if (violations === 0) {
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
+ }
240
+ process.exit(0);
241
+ }
242
+
243
+ console.error(
244
+ `\n[x] Import-boundary check FAILED (${violations} violation(s)).\n` +
245
+ ' Fix: break the cycle by extracting the shared part into a module both can import\n' +
246
+ ' (see the modular-architecture skill). Do NOT create a re-export shim that keeps the cycle.'
247
+ );
248
+ process.exit(args.reportOnly ? 0 : 1);
249
+ }
250
+
251
+ if (isMain) main();
@@ -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,213 @@
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 } from 'node:fs';
25
+ import path from 'node:path';
26
+ import {
27
+ stripNoise, lineOf,
28
+ } from './lib.mjs';
29
+ import {
30
+ loadConfig, gateArgs, collectPathFiles, resolveScanTarget, applyIgnorePaths,
31
+ } from './cli.mjs';
32
+ import { baselineGate } from './baseline.mjs';
33
+
34
+ // Defaults < anti-slop.config.json < CLI flags.
35
+ const cfgMod = ((loadConfig().modularity) ?? {});
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' +
39
+ 'Config: anti-slop.config.json "modularity" section (src, maxFileLines, maxFunctionLines, maxClassMethods).\n' +
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.',
42
+ spec: {
43
+ '--src': 'string', '--max-file-lines': 'number', '--max-function-lines': 'number',
44
+ '--max-class-methods': 'number', '--report-only': 'flag',
45
+ '--baseline': 'string', '--update-baseline': 'flag',
46
+ },
47
+ defaults: {
48
+ src: cfgMod.src ?? 'src',
49
+ maxFileLines: cfgMod.maxFileLines ?? 300,
50
+ maxFunctionLines: cfgMod.maxFunctionLines ?? 80,
51
+ maxClassMethods: cfgMod.maxClassMethods ?? 10,
52
+ },
53
+ allowPositionals: true,
54
+ });
55
+ const paths = positionals;
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
+
82
+ function analyze(source, limits) {
83
+ const findings = [];
84
+ const stripped = stripNoise(source);
85
+ const totalLines = source.split('\n').length;
86
+
87
+ if (totalLines > limits.maxFileLines) {
88
+ findings.push({ kind: 'file', line: 1, detail: `${totalLines} lines (> ${limits.maxFileLines})` });
89
+ }
90
+
91
+ // Brace matching; record regions opened by a function-like `{`.
92
+ // A `{` is function-like when the preceding non-space char is `)` (params)
93
+ // or `>` of an arrow `=>`.
94
+ const stack = []; // { depth, start, isFn }
95
+ let depth = 0;
96
+ const regions = []; // { startIdx, endIdx, isFn }
97
+ const classes = []; // { startIdx, endIdx, name, depth }
98
+ let classStart = null; // { name, depth, braceDepth|null, methods }
99
+
100
+ for (let i = 0; i < stripped.length; i++) {
101
+ const c = stripped[i];
102
+ if (c === '{') {
103
+ let j = i - 1;
104
+ while (j >= 0 && /\s/.test(stripped[j])) j--;
105
+ const prev = j >= 0 ? stripped[j] : '';
106
+ // detect `class X ... {`
107
+ const head = stripped.slice(Math.max(0, i - 200), i);
108
+ const cm = /(?:^|[\s;}])class\s+([A-Za-z0-9_$]+)[^{}]*$/.exec(head);
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);
113
+ if (cm && classStart === null) {
114
+ classStart = { name: cm[1], declIdx: i, braceDepth: depth, methods: 0 };
115
+ } else if (classStart && classStart.braceDepth !== null && depth === classStart.braceDepth + 1 && prev === ')') {
116
+ classStart.methods++;
117
+ }
118
+ stack.push({ depth, start: i, isFn, classStart });
119
+ depth++;
120
+ } else if (c === '}') {
121
+ depth--;
122
+ const open = stack.pop();
123
+ if (open) {
124
+ if (open.isFn) regions.push({ startIdx: open.start, endIdx: i });
125
+ if (classStart && open.depth === classStart.braceDepth) {
126
+ classes.push({ ...classStart, endIdx: i });
127
+ classStart = null;
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ for (const r of regions) {
134
+ const startLine = lineOf(stripped, r.startIdx);
135
+ const endLine = lineOf(stripped, r.endIdx);
136
+ const len = endLine - startLine + 1;
137
+ if (len > limits.maxFunctionLines) {
138
+ findings.push({ kind: 'function', line: startLine, detail: `${len} lines (> ${limits.maxFunctionLines})` });
139
+ }
140
+ }
141
+ for (const c of classes) {
142
+ if (c.methods > limits.maxClassMethods) {
143
+ findings.push({
144
+ kind: 'class',
145
+ line: lineOf(stripped, c.declIdx),
146
+ detail: `class ${c.name}: ${c.methods} methods (> ${limits.maxClassMethods})`,
147
+ });
148
+ }
149
+ }
150
+
151
+ return { findings, totalLines };
152
+ }
153
+
154
+ // --- run ---------------------------------------------------------------------
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
+ );
163
+
164
+ const limits = {
165
+ maxFileLines: args.maxFileLines,
166
+ maxFunctionLines: args.maxFunctionLines,
167
+ maxClassMethods: args.maxClassMethods,
168
+ };
169
+
170
+ let violations = 0;
171
+ const perFileKinds = {};
172
+ const fileLines = {};
173
+ for (const file of files) {
174
+ const { findings, totalLines } = analyze(readFileSync(file.full, 'utf8'), limits);
175
+ fileLines[file.rel] = totalLines;
176
+ if (findings.length === 0) continue;
177
+ console.log(`${file.rel} (${totalLines} lines):`);
178
+ const kinds = {};
179
+ for (const f of findings) {
180
+ console.log(` [${f.kind}] line ${f.line}: ${f.detail}`);
181
+ kinds[f.kind] = (kinds[f.kind] ?? 0) + 1;
182
+ violations++;
183
+ }
184
+ perFileKinds[file.rel] = kinds;
185
+ }
186
+
187
+ if (violations === 0) {
188
+ console.log(`[ok] Modularity check passed (${files.length} files within budgets).`);
189
+ process.exit(0);
190
+ }
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
+
205
+ console.log(`\n${violations} size-budget violation(s) in ${files.length} file(s).`);
206
+ console.error(
207
+ '[x] Modularity check FAILED.\n' +
208
+ ' Fix: split by responsibility (see the modular-architecture skill) — transport/service/domain/data,\n' +
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.'
212
+ );
213
+ process.exit(args.reportOnly ? 0 : 1);