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,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
+ }
@@ -0,0 +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 { 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);
@@ -0,0 +1,207 @@
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 { readFileSync } from 'node:fs';
24
+ import {
25
+ stripNoise, tokenize,
26
+ } from './lib.mjs';
27
+ import {
28
+ loadConfig, matchesAnyGlob, gateArgs, resolveScanTarget,
29
+ } from './cli.mjs';
30
+ import { baselineGate } from './baseline.mjs';
31
+
32
+ // --- args -------------------------------------------------------------------
33
+ // Defaults < anti-slop.config.json < CLI flags.
34
+ const cfgDups = (loadConfig().duplicates ?? {});
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' },
41
+ defaults: {
42
+ src: cfgDups.src ?? 'src',
43
+ minTokens: cfgDups.minTokens ?? 50,
44
+ threshold: cfgDups.threshold ?? 3,
45
+ },
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
+ // 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) {
145
+ for (const [rel, s, e] of [[r.aFile, r.aLine, r.aEndLine], [r.bFile, r.bLine, r.bEndLine]]) {
146
+ let set = dupLines.get(rel);
147
+ if (!set) { set = new Set(); dupLines.set(rel, set); }
148
+ for (let l = s; l <= e; l++) set.add(l);
149
+ }
150
+ }
151
+
152
+ let totalDup = 0;
153
+ let totalAll = 0;
154
+ for (const f of files) {
155
+ totalAll += f.totalLines;
156
+ totalDup += dupLines.get(f.rel)?.size ?? 0;
157
+ }
158
+ const pct = totalAll > 0 ? (totalDup / totalAll) * 100 : 0;
159
+
160
+ // --- report -------------------------------------------------------------------
161
+ console.log(`Checked ${files.length} files (${totalAll} lines), min-tokens=${K}, threshold=${args.threshold}%`);
162
+
163
+ if (regions.length === 0) {
164
+ console.log('[ok] No duplicates found.');
165
+ process.exit(0);
166
+ }
167
+
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)) {
171
+ console.log(` ${r.aFile}:${r.aLine}-${r.aEndLine} <-> ${r.bFile}:${r.bLine}-${r.bEndLine} (~${r.tokens} tokens)`);
172
+ }
173
+
174
+ const worst = [...dupLines.entries()]
175
+ .map(([rel, set]) => ({ rel, dup: set.size, total: totalLines.get(rel) }))
176
+ .filter((f) => f.dup > 0)
177
+ .sort((a, b) => b.dup / b.total - a.dup / a.total)
178
+ .slice(0, args.summary ? 3 : 5);
179
+ console.log('\nMost duplicated files:');
180
+ for (const w of worst) console.log(` ${w.rel}: ${w.dup}/${w.total} lines (${((w.dup / w.total) * 100).toFixed(1)}%)`);
181
+ console.log(`\nTotal duplicated lines: ${totalDup}/${totalAll} (${pct.toFixed(1)}%)`);
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
+
197
+ if (args.threshold > 0 && pct > args.threshold) {
198
+ console.error(
199
+ `\n[x] Duplicate-code check FAILED (${pct.toFixed(1)}% > ${args.threshold}%).\n` +
200
+ ' Fix: extract duplicates into existing shared modules (see the reuse-before-create skill).\n' +
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.`
204
+ );
205
+ process.exit(1);
206
+ }
207
+ console.log('[ok] Duplicate-code check passed.');