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.
@@ -21,22 +21,28 @@
21
21
  * --report-only prints findings but always exits 0.
22
22
  */
23
23
 
24
- import { readFileSync, existsSync } from 'node:fs';
24
+ import { readFileSync } from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import {
27
- walk, stripNoise, lineOf,
27
+ stripNoise, lineOf,
28
28
  } from './lib.mjs';
29
- import { loadConfig, bootstrapGate, collectPathFiles, resolveScanTarget } from './cli.mjs';
29
+ import {
30
+ loadConfig, gateArgs, collectPathFiles, resolveScanTarget, applyIgnorePaths,
31
+ } from './cli.mjs';
32
+ import { baselineGate } from './baseline.mjs';
30
33
 
31
34
  // Defaults < anti-slop.config.json < CLI flags.
32
35
  const cfgMod = ((loadConfig().modularity) ?? {});
33
- const { args, positionals } = 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' +
36
+ const { args, positionals } = gateArgs({
37
+ argv: process.argv.slice(2),
38
+ usage: 'Usage: node check-modularity.mjs [paths...] [--src src] [--max-file-lines 300] [--max-function-lines 80] [--max-class-methods 10] [--report-only] [--baseline file] [--update-baseline]\n' +
35
39
  'Config: anti-slop.config.json "modularity" section (src, maxFileLines, maxFunctionLines, maxClassMethods).\n' +
36
- 'With path arguments, checks exactly those files (git-hook staged mode).',
40
+ 'With path arguments, checks exactly those files (git-hook staged mode).\n' +
41
+ 'With a baseline file (default anti-slop.baseline.json) the gate fails only on NEW violations.',
37
42
  spec: {
38
43
  '--src': 'string', '--max-file-lines': 'number', '--max-function-lines': 'number',
39
44
  '--max-class-methods': 'number', '--report-only': 'flag',
45
+ '--baseline': 'string', '--update-baseline': 'flag',
40
46
  },
41
47
  defaults: {
42
48
  src: cfgMod.src ?? 'src',
@@ -44,9 +50,35 @@ const { args, positionals } = bootstrapGate(process.argv.slice(2), {
44
50
  maxFunctionLines: cfgMod.maxFunctionLines ?? 80,
45
51
  maxClassMethods: cfgMod.maxClassMethods ?? 10,
46
52
  },
53
+ allowPositionals: true,
47
54
  });
48
55
  const paths = positionals;
49
56
 
57
+ const CONTROL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch']);
58
+
59
+ /** True when the `{` at openBraceIdx opens a control-flow block (if/for/while/
60
+ * switch/catch): the `{` is preceded by a `)` whose matching `(` follows one of
61
+ * the control keywords. `foo(x) {` (method shorthand) is NOT control flow. */
62
+ function isControlFlow(stripped, openBraceIdx) {
63
+ let j = openBraceIdx - 1;
64
+ let depth = 0;
65
+ while (j >= 0) {
66
+ const ch = stripped[j];
67
+ if (ch === ')') depth++;
68
+ else if (ch === '(') {
69
+ depth--;
70
+ if (depth === 0) break;
71
+ }
72
+ j--;
73
+ }
74
+ if (j < 0) return false;
75
+ let k = j - 1;
76
+ while (k >= 0 && /\s/.test(stripped[k])) k--;
77
+ const end = k;
78
+ while (k >= 0 && /[\w$]/.test(stripped[k])) k--;
79
+ return CONTROL_KEYWORDS.has(stripped.slice(k + 1, end + 1));
80
+ }
81
+
50
82
  function analyze(source, limits) {
51
83
  const findings = [];
52
84
  const stripped = stripNoise(source);
@@ -74,7 +106,10 @@ function analyze(source, limits) {
74
106
  // detect `class X ... {`
75
107
  const head = stripped.slice(Math.max(0, i - 200), i);
76
108
  const cm = /(?:^|[\s;}])class\s+([A-Za-z0-9_$]+)[^{}]*$/.exec(head);
77
- const isFn = prev === ')' || head.trimEnd().endsWith('=>');
109
+ // `if (x) {`, `for (...) {` etc. end in `)` like a function body but are
110
+ // control flow — excluded by the keyword before their opening paren.
111
+ let isFn = head.trimEnd().endsWith('=>');
112
+ if (!isFn && prev === ')') isFn = !isControlFlow(stripped, i);
78
113
  if (cm && classStart === null) {
79
114
  classStart = { name: cm[1], declIdx: i, braceDepth: depth, methods: 0 };
80
115
  } else if (classStart && classStart.braceDepth !== null && depth === classStart.braceDepth + 1 && prev === ')') {
@@ -117,7 +152,14 @@ function analyze(source, limits) {
117
152
  }
118
153
 
119
154
  // --- run ---------------------------------------------------------------------
120
- const files = positionals.length > 0 ? collectPathFiles(positionals) : resolveScanTarget(args.src);
155
+ // modularity.ignorePaths exempts declarative/framework files from size
156
+ // budgets, matching the drift principle (shape, not logic).
157
+ const modIgnore = Array.isArray(cfgMod.ignorePaths) ? cfgMod.ignorePaths : [];
158
+ const files = applyIgnorePaths(
159
+ positionals.length > 0 ? collectPathFiles(positionals) : resolveScanTarget(args.src),
160
+ modIgnore,
161
+ { label: 'modularity.ignorePaths', src: args.src },
162
+ );
121
163
 
122
164
  const limits = {
123
165
  maxFileLines: args.maxFileLines,
@@ -126,14 +168,20 @@ const limits = {
126
168
  };
127
169
 
128
170
  let violations = 0;
171
+ const perFileKinds = {};
172
+ const fileLines = {};
129
173
  for (const file of files) {
130
174
  const { findings, totalLines } = analyze(readFileSync(file.full, 'utf8'), limits);
175
+ fileLines[file.rel] = totalLines;
131
176
  if (findings.length === 0) continue;
132
177
  console.log(`${file.rel} (${totalLines} lines):`);
178
+ const kinds = {};
133
179
  for (const f of findings) {
134
180
  console.log(` [${f.kind}] line ${f.line}: ${f.detail}`);
181
+ kinds[f.kind] = (kinds[f.kind] ?? 0) + 1;
135
182
  violations++;
136
183
  }
184
+ perFileKinds[file.rel] = kinds;
137
185
  }
138
186
 
139
187
  if (violations === 0) {
@@ -141,10 +189,25 @@ if (violations === 0) {
141
189
  process.exit(0);
142
190
  }
143
191
 
192
+ // --- baseline (ratchet) mode ---------------------------------------------------
193
+ const decision = baselineGate({
194
+ args,
195
+ section: 'modularity',
196
+ current: perFileKinds,
197
+ kindCounts: true,
198
+ unit: 'violation(s)',
199
+ showKind: true,
200
+ fixLine: 'Fix: split by responsibility (see the modular-architecture skill).',
201
+ });
202
+ if (decision === 'ok') process.exit(0);
203
+ if (decision === 'fail') process.exit(1);
204
+
144
205
  console.log(`\n${violations} size-budget violation(s) in ${files.length} file(s).`);
145
206
  console.error(
146
207
  '[x] Modularity check FAILED.\n' +
147
208
  ' Fix: split by responsibility (see the modular-architecture skill) — transport/service/domain/data,\n' +
148
- ' smaller functions, smaller public surfaces. Do NOT compress code, raise budgets, or disable the check.'
209
+ ' smaller functions, smaller public surfaces. Do NOT compress code, raise budgets, or disable the check.\n' +
210
+ ' Adopting a repo with legacy violations? Snapshot them once:\n' +
211
+ ' node scripts/anti-slop/check-modularity.mjs --update-baseline — the gate then fails only on NEW ones.'
149
212
  );
150
213
  process.exit(args.reportOnly ? 0 : 1);
@@ -1,128 +1,202 @@
1
- /**
2
- * cli.mjs — shared CLI/config plumbing for the anti-slop gate scripts.
3
- *
4
- * Zero dependencies (Node >= 18, ESM). Source-scanning helpers live in lib.mjs;
5
- * this module handles flag parsing, per-repo config loading, and path-to-file
6
- * resolution. Keep it free of scanning logic.
7
- */
1
+ /**
2
+ * cli.mjs — shared CLI/config plumbing for the anti-slop gate scripts.
3
+ *
4
+ * Zero dependencies (Node >= 18, ESM). Source-scanning helpers live in lib.mjs;
5
+ * this module handles flag parsing, per-repo config loading, and path-to-file
6
+ * resolution. Keep it free of scanning logic.
7
+ */
8
+
9
+ import { existsSync, readFileSync, statSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { walk, SOURCE_EXT, IGNORE_FILE } from './lib.mjs';
12
+
13
+ // --- CLI parsing ---------------------------------------------------------------
14
+
15
+ /**
16
+ * Parse CLI flags uniformly across gate scripts.
17
+ *
18
+ * spec maps flag -> type: 'string' | 'number' | 'flag' (boolean, no value).
19
+ * argv starts after the script path. Unknown flags and bare positional words
20
+ * land in `positionals`; each script decides whether positionals are an error
21
+ * or file paths.
22
+ *
23
+ * Returns { values: Record<flag, string|number|true>, positionals: string[] }.
24
+ */
25
+ export function parseFlags(argv, spec) {
26
+ const values = {};
27
+ const positionals = [];
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const s = argv[i];
30
+ const type = spec[s];
31
+ if (!type) {
32
+ positionals.push(s);
33
+ continue;
34
+ }
35
+ if (type === 'flag') {
36
+ values[s] = true;
37
+ continue;
38
+ }
39
+ const raw = argv[++i];
40
+ if (raw === undefined) {
41
+ throw new Error(`Missing value for ${s}`);
42
+ }
43
+ const v = type === 'number' ? Number(raw) : raw;
44
+ if (type === 'number' && Number.isNaN(v)) {
45
+ throw new Error(`${s} expects a number, got: ${raw}`);
46
+ }
47
+ values[s] = v;
48
+ }
49
+ return { values, positionals };
50
+ }
51
+
52
+ /**
53
+ * Common gate bootstrap: handle -h/--help with `usage`, parse flags per `spec`,
54
+ * and merge flag values over `defaults` (config-supplied). Flag names are
55
+ * camelCased into arg keys: --min-tokens -> args.minTokens, --report-only ->
56
+ * args.reportOnly (flags become true/false).
57
+ *
58
+ * Returns { args, positionals }. Never returns on -h (exits 0) or a parse
59
+ * error (exits 1).
60
+ */
61
+ export function bootstrapGate(argv, { usage, spec, defaults }) {
62
+ if (argv.includes('-h') || argv.includes('--help')) {
63
+ console.log(usage);
64
+ process.exit(0);
65
+ }
66
+ let parsed;
67
+ try {
68
+ parsed = parseFlags(argv, spec);
69
+ } catch (err) {
70
+ console.error(err.message);
71
+ process.exit(1);
72
+ }
73
+ const args = { ...defaults };
74
+ for (const [flag, type] of Object.entries(spec)) {
75
+ const key = flag.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
76
+ args[key] = type === 'flag'
77
+ ? parsed.values[flag] === true
78
+ : (parsed.values[flag] ?? defaults[key]);
79
+ }
80
+ return { args, positionals: parsed.positionals };
81
+ }
82
+
83
+ /**
84
+ * Resolve explicit path arguments (files or directories) into { full, rel }
85
+ * source entries, applying the same filters as walk(). Exits 1 on a missing
86
+ * path. Used by gate scripts in git-hook staged-file mode.
87
+ */
88
+ export function collectPathFiles(paths) {
89
+ const files = [];
90
+ for (const p of paths) {
91
+ const abs = path.resolve(p);
92
+ if (!existsSync(abs)) {
93
+ console.error(`Path not found: ${p}`);
94
+ process.exit(1);
95
+ }
96
+ if (statSync(abs).isDirectory()) {
97
+ files.push(...walk(abs, abs));
98
+ } else if (SOURCE_EXT.has(path.extname(abs)) && !IGNORE_FILE.test(abs)) {
99
+ files.push({ full: abs, rel: path.relative(process.cwd(), abs).split(path.sep).join('/') });
100
+ }
101
+ }
102
+ return files;
103
+ }
104
+
105
+ /**
106
+ * Resolve the scan root given to a gate (--src): accepts a directory (walked)
107
+ * or a single file. Exits 1 when the path does not exist.
108
+ */
109
+ export function resolveScanTarget(src) {
110
+ const abs = path.resolve(src);
111
+ if (!existsSync(abs)) {
112
+ console.error(`Source directory not found: ${abs}`);
113
+ process.exit(1);
114
+ }
115
+ if (statSync(abs).isFile()) {
116
+ return collectPathFiles([src]);
117
+ }
118
+ return walk(abs, abs);
119
+ }
120
+
121
+ /**
122
+ * bootstrapGate plus the standard positional validation most gates share.
123
+ * Pass allowPositionals: true for gates that accept file-path arguments
124
+ * (git-hook staged mode).
125
+ */
126
+ export function gateArgs({ argv, usage, spec, defaults, allowPositionals = false }) {
127
+ const { args, positionals } = bootstrapGate(argv, { usage, spec, defaults });
128
+ if (!allowPositionals && positionals.length > 0) {
129
+ console.error(`Unexpected arguments: ${positionals.join(' ')} (this script takes flags only)`);
130
+ process.exit(1);
131
+ }
132
+ return { args, positionals };
133
+ }
8
134
 
9
- import { readFileSync, existsSync, statSync } from 'node:fs';
10
- import path from 'node:path';
11
- import { walk, SOURCE_EXT, IGNORE_FILE } from './lib.mjs';
12
-
13
- // --- CLI parsing ---------------------------------------------------------------
135
+ // --- per-repo config ----------------------------------------------------------
14
136
 
15
137
  /**
16
- * Parse CLI flags uniformly across gate scripts.
17
- *
18
- * spec maps flag -> type: 'string' | 'number' | 'flag' (boolean, no value).
19
- * argv starts after the script path. Unknown flags and bare positional words
20
- * land in `positionals`; each script decides whether positionals are an error
21
- * or file paths.
22
- *
23
- * Returns { values: Record<flag, string|number|true>, positionals: string[] }.
138
+ * Filter resolved files by a docs/duplicates-style ignore-path config:
139
+ * globs are matched against THREE path candidates so every mode and both
140
+ * glob conventions work — the provided rel, repo-relative, and (when `src`
141
+ * is given) scan-root-relative. Tree-wide walks produce scan-root-relative
142
+ * rels; git-hook staged mode produces repo-relative ones; configs may use
143
+ * either convention. Returns the kept files and prints an exclusion notice
144
+ * when any glob is configured and something matched.
24
145
  */
25
- export function parseFlags(argv, spec) {
26
- const values = {};
27
- const positionals = [];
28
- for (let i = 0; i < argv.length; i++) {
29
- const s = argv[i];
30
- const type = spec[s];
31
- if (!type) {
32
- positionals.push(s);
33
- continue;
34
- }
35
- if (type === 'flag') {
36
- values[s] = true;
37
- continue;
38
- }
39
- const raw = argv[++i];
40
- if (raw === undefined) {
41
- throw new Error(`Missing value for ${s}`);
42
- }
43
- const v = type === 'number' ? Number(raw) : raw;
44
- if (type === 'number' && Number.isNaN(v)) {
45
- throw new Error(`${s} expects a number, got: ${raw}`);
46
- }
47
- values[s] = v;
146
+ export function applyIgnorePaths(files, globs, { label = 'ignorePaths', src } = {}) {
147
+ if (!Array.isArray(globs) || globs.length === 0) return files;
148
+ const srcDir = src ? path.resolve(src) : null;
149
+ const kept = files.filter((f) => {
150
+ const repoRel = path.relative(process.cwd(), f.full).split(path.sep).join('/');
151
+ const scanRel = srcDir ? path.relative(srcDir, f.full).split(path.sep).join('/') : null;
152
+ const candidates = scanRel ? [f.rel, repoRel, scanRel] : [f.rel, repoRel];
153
+ return !candidates.some((p) => matchesAnyGlob(p, globs));
154
+ });
155
+ const excluded = files.length - kept.length;
156
+ if (excluded > 0) {
157
+ console.log(`Ignoring ${excluded} file(s) matching ${label} (${globs.join(', ')}).`);
48
158
  }
49
- return { values, positionals };
159
+ return kept;
50
160
  }
51
161
 
52
- /**
53
- * Common gate bootstrap: handle -h/--help with `usage`, parse flags per `spec`,
54
- * and merge flag values over `defaults` (config-supplied). Flag names are
55
- * camelCased into arg keys: --min-tokens -> args.minTokens, --report-only ->
56
- * args.reportOnly (flags become true/false).
57
- *
58
- * Returns { args, positionals }. Never returns on -h (exits 0) or a parse
59
- * error (exits 1).
60
- */
61
- export function bootstrapGate(argv, { usage, spec, defaults }) {
62
- if (argv.includes('-h') || argv.includes('--help')) {
63
- console.log(usage);
64
- process.exit(0);
65
- }
66
- let parsed;
67
- try {
68
- parsed = parseFlags(argv, spec);
69
- } catch (err) {
70
- console.error(err.message);
71
- process.exit(1);
72
- }
73
- const args = { ...defaults };
74
- for (const [flag, type] of Object.entries(spec)) {
75
- const key = flag.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
76
- args[key] = type === 'flag'
77
- ? parsed.values[flag] === true
78
- : (parsed.values[flag] ?? defaults[key]);
79
- }
80
- return { args, positionals: parsed.positionals };
81
- }
162
+ const KNOWN_SECTIONS = new Set(['duplicates', 'modularity', 'docs', 'imports', '$schema', '_comment']);
82
163
 
83
- /**
84
- * Resolve explicit path arguments (files or directories) into { full, rel }
85
- * source entries, applying the same filters as walk(). Exits 1 on a missing
86
- * path. Used by gate scripts in git-hook staged-file mode.
87
- */
88
- export function collectPathFiles(paths) {
89
- const files = [];
90
- for (const p of paths) {
91
- const abs = path.resolve(p);
92
- if (!existsSync(abs)) {
93
- console.error(`Path not found: ${p}`);
94
- process.exit(1);
95
- }
96
- if (statSync(abs).isDirectory()) {
97
- files.push(...walk(abs, abs));
98
- } else if (SOURCE_EXT.has(path.extname(abs)) && !IGNORE_FILE.test(abs)) {
99
- files.push({ full: abs, rel: path.relative(process.cwd(), abs).split(path.sep).join('/') });
100
- }
164
+ /** Light validation: unknown keys and wrong types warn instead of silently
165
+ * disabling the tuning (a typo like "duplicate" would otherwise run the gates
166
+ * on defaults with no signal why). */
167
+ function validateConfig(parsed) {
168
+ const warns = [];
169
+ for (const key of Object.keys(parsed)) {
170
+ if (!KNOWN_SECTIONS.has(key)) warns.push(`unknown key "${key}" (ignored)`);
101
171
  }
102
- return files;
103
- }
104
-
105
- /**
106
- * Resolve the scan root given to a gate (--src): accepts a directory (walked)
107
- * or a single file. Exits 1 when the path does not exist.
108
- */
109
- export function resolveScanTarget(src) {
110
- const abs = path.resolve(src);
111
- if (!existsSync(abs)) {
112
- console.error(`Source directory not found: ${abs}`);
113
- process.exit(1);
172
+ const num = (section, key) => {
173
+ const v = parsed[section]?.[key];
174
+ if (v !== undefined && (typeof v !== 'number' || Number.isNaN(v))) warns.push(`${section}.${key} must be a number`);
175
+ };
176
+ const arr = (section, key) => {
177
+ const v = parsed[section]?.[key];
178
+ if (v !== undefined && !Array.isArray(v)) warns.push(`${section}.${key} must be an array of globs/names`);
179
+ };
180
+ num('duplicates', 'minTokens');
181
+ num('duplicates', 'threshold');
182
+ arr('duplicates', 'ignore');
183
+ num('modularity', 'maxFileLines');
184
+ num('modularity', 'maxFunctionLines');
185
+ num('modularity', 'maxClassMethods');
186
+ arr('modularity', 'ignorePaths');
187
+ arr('docs', 'ignorePaths');
188
+ arr('docs', 'ignoreExports');
189
+ if (parsed.imports?.deny !== undefined && !Array.isArray(parsed.imports.deny)) {
190
+ warns.push('imports.deny must be an array of { from, forbid } rules');
114
191
  }
115
- if (statSync(abs).isFile()) {
116
- return collectPathFiles([src]);
192
+ if (warns.length > 0) {
193
+ console.error(`[warn] anti-slop.config.json: ${warns.join('; ')}.`);
117
194
  }
118
- return walk(abs, abs);
119
195
  }
120
196
 
121
- // --- per-repo config ----------------------------------------------------------
122
-
123
197
  /**
124
198
  * Reads `anti-slop.config.json` from process.cwd() (repo root), returning the
125
- * parsed object or {} when absent/invalid (with a warning on invalid).
199
+ * parsed object or {} when absent (warn+{} when unparsable).
126
200
  * Scripts merge: defaults < config file < CLI flags.
127
201
  *
128
202
  * Shape:
@@ -131,21 +205,31 @@ export function resolveScanTarget(src) {
131
205
  * "ignore": ["collections/**"] },
132
206
  * "modularity": { "src": "src", "maxFileLines": 300, "maxFunctionLines": 80,
133
207
  * "maxClassMethods": 10 },
134
- * "docs": { "src": "src" }
208
+ * "docs": { "src": "src", "ignorePaths": ["app/(payload)/**"],
209
+ * "ignoreExports": ["metadata", "dynamic"] },
210
+ * "imports": { "src": "src", "deny": [{ "from": "src/domain/**",
211
+ * "forbid": "src/infra/**" }] }
135
212
  * }
136
213
  *
137
- * `ignore` (duplicates only) excludes framework-idiomatic declarative files
138
- * (collection configs, generated tables) whose repetition is shape, not logic.
214
+ * `ignore` (duplicates) and `ignorePaths` (docs) exclude framework-idiomatic
215
+ * declarative files whose repetition is shape, not logic.
139
216
  */
140
217
  export function loadConfig() {
141
218
  const file = path.join(process.cwd(), 'anti-slop.config.json');
142
219
  if (!existsSync(file)) return {};
220
+ let parsed;
143
221
  try {
144
- return JSON.parse(readFileSync(file, 'utf8'));
222
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
145
223
  } catch (err) {
146
224
  console.error(`[warn] Could not parse anti-slop.config.json (${err.message}); using defaults.`);
147
225
  return {};
148
226
  }
227
+ if (!parsed || typeof parsed !== 'object') {
228
+ console.error('[warn] anti-slop.config.json is not an object; using defaults.');
229
+ return {};
230
+ }
231
+ validateConfig(parsed);
232
+ return parsed;
149
233
  }
150
234
 
151
235
  /** Convert a glob (`**`, `*`, `?`) into a RegExp tested against '/'-separated rel paths. */
@@ -172,4 +256,4 @@ export function globToRegex(glob) {
172
256
  /** True when relPath matches any ignore glob. */
173
257
  export function matchesAnyGlob(relPath, globs) {
174
258
  return globs.length > 0 && globs.some((g) => globToRegex(g).test(relPath));
175
- }
259
+ }
@@ -7,11 +7,16 @@
7
7
 
8
8
  import test from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
- import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
10
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { tmpdir } from 'node:os';
12
12
  import path from 'node:path';
13
13
 
14
- import { bootstrapGate, globToRegex, matchesAnyGlob, parseFlags } from './cli.mjs';
14
+ import {
15
+ bootstrapGate, globToRegex, matchesAnyGlob, parseFlags, gateArgs, applyIgnorePaths,
16
+ } from './cli.mjs';
17
+ import {
18
+ compareCounts, compareKindCounts, compareSets, loadBaseline, saveBaseline,
19
+ } from './baseline.mjs';
15
20
 
16
21
  // --- parseFlags ----------------------------------------------------------------
17
22
 
@@ -104,3 +109,94 @@ test('matchesAnyGlob: empty list never matches', () => {
104
109
  test('matchesAnyGlob: any of several globs', () => {
105
110
  assert.ok(matchesAnyGlob('x/y.ts', ['nothing/**', 'x/**']));
106
111
  });
112
+
113
+ // --- applyIgnorePaths -----------------------------------------------------------
114
+
115
+ test('applyIgnorePaths: scan-root-relative globs match repo-relative staged files', async () => {
116
+ // Regression for #18: git-hook staged mode passes repo-relative paths
117
+ // (src/app/...); a config glob written scan-root-relative (app/...) must
118
+ // still match, because tree-wide mode matches it that way.
119
+ const dir = mkdtempSync(path.join(tmpdir(), 'antislop-ignore-'));
120
+ try {
121
+ const repo = path.join(dir, 'repo');
122
+ const nested = path.join(repo, 'src', 'app', '(payload)');
123
+ mkdirSync(nested, { recursive: true });
124
+ const full = path.join(nested, 'importMap.js');
125
+ writeFileSync(full, 'export default 1;');
126
+ const files = [{ full, rel: 'src/app/payload/importMap.js' }];
127
+ const kept = applyIgnorePaths(files, ['app/(payload)/**'], { src: path.join(repo, 'src') });
128
+ assert.equal(kept.length, 0);
129
+ } finally {
130
+ rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ test('applyIgnorePaths: repo-relative globs match scan-root-relative rels', () => {
135
+ // full path under the repo's src/, rel produced by a tree-wide walk
136
+ const files = [{ full: 'src/collections/Users.ts', rel: 'collections/Users.ts' }];
137
+ const kept = applyIgnorePaths(files, ['src/collections/**'], { src: 'src' });
138
+ assert.equal(kept.length, 0);
139
+ });
140
+
141
+ test('applyIgnorePaths: no globs keeps everything silently', () => {
142
+ const files = [{ full: 'a.ts', rel: 'a.ts' }];
143
+ assert.equal(applyIgnorePaths(files, []).length, 1);
144
+ });
145
+
146
+ // --- baseline ratchet ------------------------------------------------------------
147
+
148
+ test('compareCounts: unseen file with duplication is new debt', () => {
149
+ const { newDebt, improved } = compareCounts({ 'a.ts': 5 }, { 'b.ts': 3 });
150
+ assert.deepEqual(newDebt, [{ rel: 'a.ts', count: 5, accepted: 0 }]);
151
+ assert.deepEqual(improved, []);
152
+ });
153
+
154
+ test('compareCounts: growth beyond accepted is new; shrink is improvement', () => {
155
+ const { newDebt, improved } = compareCounts(
156
+ { 'a.ts': 8, 'b.ts': 1 },
157
+ { 'a.ts': 5, 'b.ts': 3 },
158
+ );
159
+ assert.deepEqual(newDebt, [{ rel: 'a.ts', count: 8, accepted: 5 }]);
160
+ assert.deepEqual(improved, [{ rel: 'b.ts', count: 1, accepted: 3 }]);
161
+ });
162
+
163
+ test('compareCounts: equal counts are neither', () => {
164
+ const { newDebt, improved } = compareCounts({ 'a.ts': 5 }, { 'a.ts': 5 });
165
+ assert.deepEqual(newDebt, []);
166
+ assert.deepEqual(improved, []);
167
+ });
168
+
169
+ test('compareKindCounts: new kind on a baselined file is new debt', () => {
170
+ const { newDebt } = compareKindCounts(
171
+ { 'big.ts': { file: 1, function: 2 } },
172
+ { 'big.ts': { file: 1, function: 0 } },
173
+ );
174
+ assert.deepEqual(newDebt, [{ rel: 'big.ts', kind: 'function', count: 2, accepted: 0 }]);
175
+ });
176
+
177
+ test('compareKindCounts: unseen file with any violation is new debt', () => {
178
+ const { newDebt } = compareKindCounts({ 'new.ts': { file: 1 } }, {});
179
+ assert.deepEqual(newDebt, [{ rel: 'new.ts', kind: 'file', count: 1, accepted: 0 }]);
180
+ });
181
+
182
+ test('compareSets: added and removed', () => {
183
+ const { added, removed } = compareSets(['a~b', 'c~d'], ['a~b', 'e~f']);
184
+ assert.deepEqual(added, ['c~d']);
185
+ assert.deepEqual(removed, ['e~f']);
186
+ });
187
+
188
+ test('saveBaseline/loadBaseline: roundtrip with sorted keys', () => {
189
+ const dir = mkdtempSync(path.join(tmpdir(), 'antislop-baseline-'));
190
+ try {
191
+ const file = path.join(dir, 'baseline.json');
192
+ saveBaseline(file, { duplicates: { files: { 'b.ts': 2, 'a.ts': 1 }, totalDuplicatedLines: 3 } });
193
+ const loaded = loadBaseline(file);
194
+ assert.equal(loaded.duplicates.totalDuplicatedLines, 3);
195
+ assert.deepEqual(Object.keys(loaded.duplicates.files), ['a.ts', 'b.ts']);
196
+ const raw = readFileSync(file, 'utf8');
197
+ assert.ok(raw.includes('_comment'));
198
+ assert.ok(raw.includes('Ratchet baseline'));
199
+ } finally {
200
+ rmSync(dir, { recursive: true, force: true });
201
+ }
202
+ });