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,259 @@
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
+ }
134
+
135
+ // --- per-repo config ----------------------------------------------------------
136
+
137
+ /**
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.
145
+ */
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(', ')}).`);
158
+ }
159
+ return kept;
160
+ }
161
+
162
+ const KNOWN_SECTIONS = new Set(['duplicates', 'modularity', 'docs', 'imports', '$schema', '_comment']);
163
+
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)`);
171
+ }
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');
191
+ }
192
+ if (warns.length > 0) {
193
+ console.error(`[warn] anti-slop.config.json: ${warns.join('; ')}.`);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Reads `anti-slop.config.json` from process.cwd() (repo root), returning the
199
+ * parsed object or {} when absent (warn+{} when unparsable).
200
+ * Scripts merge: defaults < config file < CLI flags.
201
+ *
202
+ * Shape:
203
+ * {
204
+ * "duplicates": { "src": "src", "minTokens": 50, "threshold": 3,
205
+ * "ignore": ["collections/**"] },
206
+ * "modularity": { "src": "src", "maxFileLines": 300, "maxFunctionLines": 80,
207
+ * "maxClassMethods": 10 },
208
+ * "docs": { "src": "src", "ignorePaths": ["app/(payload)/**"],
209
+ * "ignoreExports": ["metadata", "dynamic"] },
210
+ * "imports": { "src": "src", "deny": [{ "from": "src/domain/**",
211
+ * "forbid": "src/infra/**" }] }
212
+ * }
213
+ *
214
+ * `ignore` (duplicates) and `ignorePaths` (docs) exclude framework-idiomatic
215
+ * declarative files whose repetition is shape, not logic.
216
+ */
217
+ export function loadConfig() {
218
+ const file = path.join(process.cwd(), 'anti-slop.config.json');
219
+ if (!existsSync(file)) return {};
220
+ let parsed;
221
+ try {
222
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
223
+ } catch (err) {
224
+ console.error(`[warn] Could not parse anti-slop.config.json (${err.message}); using defaults.`);
225
+ return {};
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;
233
+ }
234
+
235
+ /** Convert a glob (`**`, `*`, `?`) into a RegExp tested against '/'-separated rel paths. */
236
+ export function globToRegex(glob) {
237
+ let pattern = '';
238
+ for (let i = 0; i < glob.length; i++) {
239
+ const c = glob[i];
240
+ if (c === '*') {
241
+ if (glob[i + 1] === '*') {
242
+ pattern += '[\\s\\S]*';
243
+ i++;
244
+ } else {
245
+ pattern += '[^/]*';
246
+ }
247
+ } else if (c === '?') {
248
+ pattern += '[^/]';
249
+ } else {
250
+ pattern += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
251
+ }
252
+ }
253
+ return new RegExp(`^${pattern}$`);
254
+ }
255
+
256
+ /** True when relPath matches any ignore glob. */
257
+ export function matchesAnyGlob(relPath, globs) {
258
+ return globs.length > 0 && globs.some((g) => globToRegex(g).test(relPath));
259
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Unit tests for CLI/config plumbing (cli.mjs). Zero dependencies — runs with
3
+ * `node --test`. bootstrapGate's flag-to-key camelCasing and the
4
+ * defaults < config < flags merge are contract tests: the gates' hooks call
5
+ * the scripts bare, so the config file must govern them without flags.
6
+ */
7
+
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import path from 'node:path';
13
+
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';
20
+
21
+ // --- parseFlags ----------------------------------------------------------------
22
+
23
+ test('parseFlags: string, number, and flag types', () => {
24
+ const { values, positionals } = parseFlags(
25
+ ['--src', 'lib', '--min-tokens', '30', '--report-only'],
26
+ { '--src': 'string', '--min-tokens': 'number', '--report-only': 'flag' },
27
+ );
28
+ assert.equal(values['--src'], 'lib');
29
+ assert.equal(values['--min-tokens'], 30);
30
+ assert.equal(values['--report-only'], true);
31
+ assert.deepEqual(positionals, []);
32
+ });
33
+
34
+ test('parseFlags: bare words land in positionals', () => {
35
+ const { values, positionals } = parseFlags(['src/a.ts', 'src/b.ts'], {});
36
+ assert.deepEqual(values, {});
37
+ assert.deepEqual(positionals, ['src/a.ts', 'src/b.ts']);
38
+ });
39
+
40
+ test('parseFlags: missing value throws', () => {
41
+ assert.throws(() => parseFlags(['--src'], { '--src': 'string' }), /Missing value/);
42
+ });
43
+
44
+ test('parseFlags: non-numeric number throws', () => {
45
+ assert.throws(
46
+ () => parseFlags(['--max-file-lines', 'abc'], { '--max-file-lines': 'number' }),
47
+ /expects a number/,
48
+ );
49
+ });
50
+
51
+ // --- bootstrapGate ----------------------------------------------------------------
52
+
53
+ const SPEC = {
54
+ '--src': 'string', '--min-tokens': 'number', '--threshold': 'number',
55
+ '--report-only': 'flag',
56
+ };
57
+
58
+ test('bootstrapGate: flags override defaults, keys camelCased', () => {
59
+ const { args, positionals } = bootstrapGate(
60
+ ['--min-tokens', '20'],
61
+ {
62
+ usage: 'usage',
63
+ spec: SPEC,
64
+ defaults: { src: 'src', minTokens: 50, threshold: 3, reportOnly: false },
65
+ },
66
+ );
67
+ assert.equal(args.src, 'src');
68
+ assert.equal(args.minTokens, 20);
69
+ assert.equal(args.threshold, 3);
70
+ assert.equal(args.reportOnly, false);
71
+ assert.deepEqual(positionals, []);
72
+ });
73
+
74
+ test('bootstrapGate: config defaults flow through unchanged', () => {
75
+ const { args } = bootstrapGate([], {
76
+ usage: 'usage',
77
+ spec: SPEC,
78
+ defaults: { src: 'app', minTokens: 80, threshold: 5 },
79
+ });
80
+ assert.deepEqual(args, { src: 'app', minTokens: 80, threshold: 5, reportOnly: false });
81
+ });
82
+
83
+ test('bootstrapGate: --report-only becomes boolean true', () => {
84
+ const { args } = bootstrapGate(['--report-only'], {
85
+ usage: 'usage', spec: SPEC, defaults: { src: 'src', reportOnly: false },
86
+ });
87
+ assert.equal(args.reportOnly, true);
88
+ });
89
+
90
+ // --- glob matching -----------------------------------------------------------------
91
+
92
+ test('globToRegex: ** crosses directories, * stays within one', () => {
93
+ assert.ok(globToRegex('collections/**').test('collections/AccessGrants.ts'));
94
+ assert.ok(globToRegex('collections/**').test('collections/nested/Clients.ts'));
95
+ assert.ok(!globToRegex('collections/**').test('src/collections/Clients.ts'));
96
+ assert.ok(globToRegex('app/(payload)/**').test('app/(payload)/admin/page.tsx'));
97
+ assert.ok(globToRegex('lib/*.ts').test('lib/date.ts'));
98
+ assert.ok(!globToRegex('lib/*.ts').test('lib/deep/date.ts'));
99
+ assert.ok(globToRegex('use?.ts').test('useA.ts'));
100
+ assert.ok(!globToRegex('use?.ts').test('useAB.ts'));
101
+ assert.ok(globToRegex('a.b.ts').test('a.b.ts'));
102
+ assert.ok(!globToRegex('a.b.ts').test('axbts'));
103
+ });
104
+
105
+ test('matchesAnyGlob: empty list never matches', () => {
106
+ assert.equal(matchesAnyGlob('anything.ts', []), false);
107
+ });
108
+
109
+ test('matchesAnyGlob: any of several globs', () => {
110
+ assert.ok(matchesAnyGlob('x/y.ts', ['nothing/**', 'x/**']));
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
+ });
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-component-index.mjs
4
+ *
5
+ * Scans a source tree and (re)generates docs/COMPONENT_INDEX.md — the
6
+ * inventory that the `reuse-before-create` skill treats as the source of
7
+ * truth for existing layouts, components, hooks, and utilities.
8
+ *
9
+ * Zero dependencies (plain Node >= 18, ESM). Classification is heuristic
10
+ * (paths + export names + file extension); it is a discovery aid, not a parser.
11
+ *
12
+ * Usage:
13
+ * node scripts/anti-slop/generate-component-index.mjs [--src src] [--out docs/COMPONENT_INDEX.md] [--root .]
14
+ *
15
+ * A "## Manual Notes" section at the end of an existing index is preserved.
16
+ */
17
+
18
+ import {
19
+ readFileSync,
20
+ writeFileSync,
21
+ existsSync,
22
+ mkdirSync,
23
+ } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { walk, extractExports } from './lib.mjs';
26
+ import { loadConfig, applyIgnorePaths } from './cli.mjs';
27
+
28
+ // --- args -------------------------------------------------------------------
29
+ function parseArgs(argv) {
30
+ const args = { src: 'src', out: 'docs/COMPONENT_INDEX.md', root: '.', check: false };
31
+ for (let i = 2; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === '--src') args.src = argv[++i];
34
+ else if (a === '--out') args.out = argv[++i];
35
+ else if (a === '--root') args.root = argv[++i];
36
+ else if (a === '--check') args.check = true;
37
+ else if (a === '-h' || a === '--help') {
38
+ console.log('Usage: node generate-component-index.mjs [--src src] [--out docs/COMPONENT_INDEX.md] [--root .] [--check]');
39
+ console.log(' --check compare against the file on disk; exit 1 when stale (CI mode, writes nothing)');
40
+ process.exit(0);
41
+ } else {
42
+ console.error(`Unknown argument: ${a}`);
43
+ process.exit(1);
44
+ }
45
+ }
46
+ return args;
47
+ }
48
+
49
+ const args = parseArgs(process.argv);
50
+
51
+ const SRC_DIR = path.resolve(args.root, args.src);
52
+ const OUT_FILE = path.resolve(args.root, args.out);
53
+
54
+ if (!existsSync(SRC_DIR)) {
55
+ console.error(`Source directory not found: ${SRC_DIR}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ // --- classification ---------------------------------------------------------
60
+ function classify(relPath, exports) {
61
+ const base = path.basename(relPath);
62
+ const dir = path.dirname(relPath).toLowerCase();
63
+ const baseName = path.basename(base, path.extname(base));
64
+ const isTsx = /\.(tsx|jsx)$/.test(base);
65
+
66
+ const hasJsxLike = exports.length > 0 && isTsx;
67
+
68
+ if (/^use[A-Z0-9]/.test(baseName) || /(^|\/)hooks?\//.test(dir)) return 'Hook';
69
+ if (/(^|\/)layouts?(\/|$)/.test(dir) || /layout/i.test(baseName)) return 'Layout';
70
+ if (hasJsxLike && exports.some((e) => /^[A-Z]/.test(e.name))) return 'Component';
71
+ if (/(^|\/)(services?|adapters?)(\/|$)/.test(dir)) return 'Service';
72
+ if (/(^|\/)(utils?|lib|helpers?)(\/|$)/.test(dir)) return 'Util';
73
+ if (exports.some((e) => e.kind === 'type')) return 'Types';
74
+ return 'Module';
75
+ }
76
+
77
+ // --- build ------------------------------------------------------------------
78
+ const files = walk(SRC_DIR, SRC_DIR);
79
+ // The generator and the docs gate must agree on what exists: docs.ignorePaths
80
+ // excludes framework-owned or declarative files from both the listing and the
81
+ // undocumented count, so the two never report different totals.
82
+ const ignorePaths = Array.isArray(loadConfig().docs?.ignorePaths)
83
+ ? loadConfig().docs.ignorePaths
84
+ : [];
85
+ const filtered = applyIgnorePaths(files, ignorePaths, { label: 'docs.ignorePaths', src: args.src });
86
+ const index = new Map(); // kind -> [{rel, name, kind, desc, lines}]
87
+ let missingDocs = 0;
88
+
89
+ for (const file of filtered) {
90
+ const source = readFileSync(file.full, 'utf8');
91
+ const exports = extractExports(source);
92
+ if (exports.length === 0) continue;
93
+ const kind = classify(file.rel, exports);
94
+ const lines = source.split('\n').length;
95
+ const list = index.get(kind) ?? [];
96
+ for (const exp of exports) {
97
+ // The docs gate exempts default exports (route entry points etc.); the
98
+ // generator's count must agree, or the two report different totals.
99
+ if (!exp.desc && exp.kind !== 'default') missingDocs++;
100
+ list.push({ rel: file.rel, name: exp.name, kind: exp.kind, desc: exp.desc, lines });
101
+ }
102
+ index.set(kind, list);
103
+ }
104
+
105
+ // --- preserve manual notes --------------------------------------------------
106
+ let manualNotes = '';
107
+ if (existsSync(OUT_FILE)) {
108
+ const existing = readFileSync(OUT_FILE, 'utf8');
109
+ const marker = existing.indexOf('## Manual Notes');
110
+ if (marker !== -1) manualNotes = existing.slice(marker);
111
+ }
112
+
113
+ // --- render -----------------------------------------------------------------
114
+ const KIND_ORDER = ['Layout', 'Component', 'Hook', 'Service', 'Util', 'Types', 'Module'];
115
+ // Categories already plural keep their name; everything else takes a plain 's'.
116
+ const PLURAL = { Types: 'Types' };
117
+ const heading = (kind) => PLURAL[kind] ?? `${kind}s`;
118
+ const totalExports = KIND_ORDER.reduce((n, k) => n + (index.get(k)?.length ?? 0), 0);
119
+
120
+ let md = `# Component Index\n\n`;
121
+ md += `<!-- generated: run \`node scripts/anti-slop/generate-component-index.mjs\` to regenerate -->\n\n`;
122
+ md += `Inventory of shared layouts, components, hooks, services, and utilities under \`${args.src}/\`. `;
123
+ md += `${filtered.length} files, ${totalExports} exports.\n\n`;
124
+ md += `> Rule of use: before creating any new component, hook, or utility, check this file first (see the \`reuse-before-create\` skill). When you add a shared abstraction, rerun the generator and fill in the "When to use" column.\n\n`;
125
+
126
+ for (const kind of KIND_ORDER) {
127
+ const list = index.get(kind);
128
+ if (!list || list.length === 0) continue;
129
+ md += `## ${heading(kind)} (${list.length})\n\n`;
130
+ md += `| File | Export | When to use |\n|---|---|---|\n`;
131
+ for (const e of list) {
132
+ const doc = e.desc ? e.desc.replace(/\|/g, '\\|') : '';
133
+ md += `| \`${e.rel}\` | \`${e.name}\`${e.kind === 'type' ? ' *(type)*' : ''} | ${doc || '-'} |\n`;
134
+ }
135
+ md += `\n`;
136
+ }
137
+
138
+ md += manualNotes ? `${manualNotes.trimEnd()}\n` : `## Manual Notes\n\n(Add notes here; they are preserved when the index is regenerated.)\n`;
139
+ md = md.replace(/\n+$/, '\n');
140
+
141
+ if (args.check) {
142
+ if (!existsSync(OUT_FILE)) {
143
+ console.error(`[x] Index check FAILED: ${OUT_FILE} does not exist. Run the generator without --check and commit the result.`);
144
+ process.exit(1);
145
+ }
146
+ const onDisk = readFileSync(OUT_FILE, 'utf8').replace(/\n+$/, '\n');
147
+ if (onDisk === md) {
148
+ console.log(`[ok] Component index is up to date (${OUT_FILE}).`);
149
+ process.exit(0);
150
+ }
151
+ console.error(`[x] Index check FAILED: ${OUT_FILE} is stale. Run \`node scripts/anti-slop/generate-component-index.mjs\` and commit the result.`);
152
+ process.exit(1);
153
+ }
154
+
155
+ mkdirSync(path.dirname(OUT_FILE), { recursive: true });
156
+ writeFileSync(OUT_FILE, md);
157
+
158
+ console.log(`Wrote ${OUT_FILE}`);
159
+ console.log(` ${filtered.length} source files, ${totalExports} exports across ${KIND_ORDER.filter((k) => index.has(k)).length} categories.`);
160
+ if (missingDocs > 0) {
161
+ console.log(` [doc] ${missingDocs} export(s) have no JSDoc description. Shared components/hooks/utils must carry a one-line "/** When to use ... */" above the export.`);
162
+ }