tlc-claude-code 1.7.0 → 1.8.0

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,224 @@
1
+ /**
2
+ * Wall of Shame Registry
3
+ *
4
+ * Document bugs with root causes, creating a project-level learning registry.
5
+ * Gate rules can be suggested from shame entries to prevent recurrence.
6
+ *
7
+ * @module shame/shame-registry
8
+ */
9
+
10
+ const path = require('path');
11
+ const defaultFs = require('fs').promises;
12
+ const crypto = require('crypto');
13
+
14
+ /** Shame file path relative to project root */
15
+ const SHAME_FILE = '.tlc/shame.json';
16
+
17
+ /** Valid shame categories */
18
+ const SHAME_CATEGORIES = [
19
+ 'architecture',
20
+ 'type-safety',
21
+ 'duplication',
22
+ 'docker',
23
+ 'security',
24
+ 'data-loss',
25
+ ];
26
+
27
+ /** Gate rule suggestions per category */
28
+ const RULE_SUGGESTIONS = {
29
+ 'architecture': [
30
+ { rule: 'single-writer', description: 'Enforce single-writer pattern — one service per DB table' },
31
+ { rule: 'no-raw-api', description: 'Use API helper instead of raw fetch/axios calls' },
32
+ { rule: 'no-flat-folders', description: 'Organize code by entity, not by type' },
33
+ ],
34
+ 'type-safety': [
35
+ { rule: 'tsc-noEmit', description: 'Run tsc --noEmit before push to catch type errors' },
36
+ { rule: 'zod-coerce-date', description: 'Use z.coerce.date() instead of z.date() for API schemas' },
37
+ { rule: 'no-any', description: 'Disallow explicit any types' },
38
+ ],
39
+ 'duplication': [
40
+ { rule: 'no-duplicate-logic', description: 'Extract shared logic to common utility modules' },
41
+ { rule: 'single-source-of-truth', description: 'Constants and config in one place' },
42
+ ],
43
+ 'docker': [
44
+ { rule: 'volume-names', description: 'All Docker volumes must have explicit name: property' },
45
+ { rule: 'no-external-volumes', description: 'Do not use external: true in volumes' },
46
+ { rule: 'no-dangerous-commands', description: 'Block docker system prune in scripts' },
47
+ ],
48
+ 'security': [
49
+ { rule: 'no-hardcoded-secrets', description: 'Never hardcode API keys, passwords, or tokens' },
50
+ { rule: 'input-validation', description: 'Validate all external input at API boundaries' },
51
+ { rule: 'no-eval', description: 'Never use eval() or Function() constructor' },
52
+ ],
53
+ 'data-loss': [
54
+ { rule: 'backup-before-migrate', description: 'Require backup before destructive migrations' },
55
+ { rule: 'soft-delete', description: 'Prefer soft-delete over hard-delete for user data' },
56
+ { rule: 'transaction-wrap', description: 'Wrap multi-step DB operations in transactions' },
57
+ ],
58
+ };
59
+
60
+ /**
61
+ * Add a new shame entry to the entries array
62
+ * @param {Array} entries - Current entries
63
+ * @param {Object} data - Entry data
64
+ * @param {string} data.title - Bug title
65
+ * @param {string} data.rootCause - Root cause description
66
+ * @param {string} data.category - Category from SHAME_CATEGORIES
67
+ * @param {string} data.fix - How it was fixed
68
+ * @param {string} data.lesson - Lesson learned
69
+ * @returns {Object} The new entry with id and timestamp
70
+ */
71
+ function addEntry(entries, data) {
72
+ const entry = {
73
+ id: crypto.randomBytes(4).toString('hex'),
74
+ title: data.title,
75
+ rootCause: data.rootCause,
76
+ category: data.category,
77
+ fix: data.fix,
78
+ lesson: data.lesson,
79
+ timestamp: new Date().toISOString(),
80
+ };
81
+
82
+ entries.push(entry);
83
+ return entry;
84
+ }
85
+
86
+ /**
87
+ * Load entries from .tlc/shame.json
88
+ * @param {string} projectPath - Path to project root
89
+ * @param {Object} options - Injectable dependencies
90
+ * @param {Object} options.fs - File system module
91
+ * @returns {Promise<Array>} Loaded entries
92
+ */
93
+ async function loadEntries(projectPath, options = {}) {
94
+ const fsModule = options.fs || defaultFs;
95
+ const filePath = path.join(projectPath, SHAME_FILE);
96
+
97
+ try {
98
+ const content = await fsModule.readFile(filePath, 'utf-8');
99
+ const parsed = JSON.parse(content);
100
+ return Array.isArray(parsed) ? parsed : [];
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Save entries to .tlc/shame.json
108
+ * @param {string} projectPath - Path to project root
109
+ * @param {Array} entries - Entries to save
110
+ * @param {Object} options - Injectable dependencies
111
+ * @param {Object} options.fs - File system module
112
+ * @returns {Promise<void>}
113
+ */
114
+ async function saveEntries(projectPath, entries, options = {}) {
115
+ const fsModule = options.fs || defaultFs;
116
+ const filePath = path.join(projectPath, SHAME_FILE);
117
+ const dirPath = path.dirname(filePath);
118
+
119
+ await fsModule.mkdir(dirPath, { recursive: true });
120
+ await fsModule.writeFile(filePath, JSON.stringify(entries, null, 2));
121
+ }
122
+
123
+ /**
124
+ * Validate a category
125
+ * @param {string} category - Category to validate
126
+ * @returns {Object} { valid: boolean, category: string }
127
+ */
128
+ function categorizeEntry(category) {
129
+ const valid = SHAME_CATEGORIES.includes(category);
130
+ return { valid, category };
131
+ }
132
+
133
+ /**
134
+ * Generate a markdown report grouped by category
135
+ * @param {Array} entries - Shame entries
136
+ * @returns {string} Markdown report
137
+ */
138
+ function generateReport(entries) {
139
+ if (entries.length === 0) {
140
+ return '# Wall of Shame\n\nNo entries yet.\n';
141
+ }
142
+
143
+ // Group by category
144
+ const groups = {};
145
+ for (const entry of entries) {
146
+ const cat = entry.category || 'uncategorized';
147
+ if (!groups[cat]) groups[cat] = [];
148
+ groups[cat].push(entry);
149
+ }
150
+
151
+ let report = '# Wall of Shame\n\n';
152
+ report += `Total entries: ${entries.length}\n\n`;
153
+
154
+ for (const [category, categoryEntries] of Object.entries(groups)) {
155
+ report += `## ${category}\n\n`;
156
+
157
+ for (const entry of categoryEntries) {
158
+ report += `### ${entry.title}\n\n`;
159
+ report += `- **Root Cause:** ${entry.rootCause}\n`;
160
+ report += `- **Fix:** ${entry.fix}\n`;
161
+ report += `- **Lesson:** ${entry.lesson}\n`;
162
+ report += `- **Date:** ${entry.timestamp}\n\n`;
163
+ }
164
+ }
165
+
166
+ return report;
167
+ }
168
+
169
+ /**
170
+ * Suggest gate rules based on shame category
171
+ * @param {string} category - Shame category
172
+ * @returns {Array<{rule: string, description: string}>} Suggested rules
173
+ */
174
+ function suggestGateRules(category) {
175
+ return RULE_SUGGESTIONS[category] || [];
176
+ }
177
+
178
+ /**
179
+ * Track recurrence counts per category
180
+ * @param {Array} entries - All shame entries
181
+ * @returns {Object} Category → count mapping
182
+ */
183
+ function trackRecurrence(entries) {
184
+ const counts = {};
185
+ for (const entry of entries) {
186
+ const cat = entry.category || 'uncategorized';
187
+ counts[cat] = (counts[cat] || 0) + 1;
188
+ }
189
+ return counts;
190
+ }
191
+
192
+ /**
193
+ * Create a shame registry instance with dependencies
194
+ * @param {Object} deps - Injectable dependencies
195
+ * @param {Object} deps.fs - File system module
196
+ * @returns {Object} Registry instance
197
+ */
198
+ function createShameRegistry(deps = {}) {
199
+ let entries = [];
200
+
201
+ return {
202
+ add: (data) => addEntry(entries, data),
203
+ load: async (projectPath) => {
204
+ entries = await loadEntries(projectPath, deps);
205
+ return entries;
206
+ },
207
+ save: (projectPath) => saveEntries(projectPath, entries, deps),
208
+ report: () => generateReport(entries),
209
+ suggest: (category) => suggestGateRules(category),
210
+ recurrence: () => trackRecurrence(entries),
211
+ };
212
+ }
213
+
214
+ module.exports = {
215
+ createShameRegistry,
216
+ addEntry,
217
+ loadEntries,
218
+ saveEntries,
219
+ categorizeEntry,
220
+ generateReport,
221
+ suggestGateRules,
222
+ trackRecurrence,
223
+ SHAME_CATEGORIES,
224
+ };
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Wall of Shame Registry Tests
3
+ *
4
+ * Document bugs with root causes, creating a project-level learning registry.
5
+ */
6
+ import { describe, it, expect, vi } from 'vitest';
7
+
8
+ const {
9
+ createShameRegistry,
10
+ addEntry,
11
+ loadEntries,
12
+ saveEntries,
13
+ categorizeEntry,
14
+ generateReport,
15
+ suggestGateRules,
16
+ trackRecurrence,
17
+ SHAME_CATEGORIES,
18
+ } = require('./shame-registry.js');
19
+
20
+ describe('Wall of Shame Registry', () => {
21
+ describe('addEntry', () => {
22
+ it('adds shame entry with all fields', () => {
23
+ const entries = [];
24
+ const entry = addEntry(entries, {
25
+ title: 'API returns 500 on empty body',
26
+ rootCause: 'No input validation on POST handler',
27
+ category: 'architecture',
28
+ fix: 'Added Zod schema validation middleware',
29
+ lesson: 'Always validate input at API boundaries',
30
+ });
31
+
32
+ expect(entry.title).toBe('API returns 500 on empty body');
33
+ expect(entry.rootCause).toBeDefined();
34
+ expect(entry.category).toBe('architecture');
35
+ expect(entry.fix).toBeDefined();
36
+ expect(entry.lesson).toBeDefined();
37
+ });
38
+
39
+ it('entry includes timestamp and ID', () => {
40
+ const entries = [];
41
+ const entry = addEntry(entries, {
42
+ title: 'Test bug',
43
+ rootCause: 'Reason',
44
+ category: 'type-safety',
45
+ fix: 'Fixed it',
46
+ lesson: 'Learned something',
47
+ });
48
+
49
+ expect(entry.id).toBeDefined();
50
+ expect(entry.timestamp).toBeDefined();
51
+ expect(typeof entry.id).toBe('string');
52
+ });
53
+ });
54
+
55
+ describe('loadEntries', () => {
56
+ it('loads entries from file', async () => {
57
+ const mockFs = {
58
+ readFile: vi.fn().mockResolvedValue(JSON.stringify([
59
+ { id: '1', title: 'Bug A', category: 'architecture' },
60
+ { id: '2', title: 'Bug B', category: 'security' },
61
+ ])),
62
+ };
63
+
64
+ const entries = await loadEntries('/project', { fs: mockFs });
65
+ expect(entries).toHaveLength(2);
66
+ expect(entries[0].title).toBe('Bug A');
67
+ });
68
+
69
+ it('handles empty registry', async () => {
70
+ const mockFs = {
71
+ readFile: vi.fn().mockRejectedValue(new Error('ENOENT')),
72
+ };
73
+
74
+ const entries = await loadEntries('/project', { fs: mockFs });
75
+ expect(entries).toHaveLength(0);
76
+ });
77
+
78
+ it('handles malformed file gracefully', async () => {
79
+ const mockFs = {
80
+ readFile: vi.fn().mockResolvedValue('not json {{{'),
81
+ };
82
+
83
+ const entries = await loadEntries('/project', { fs: mockFs });
84
+ expect(entries).toHaveLength(0);
85
+ });
86
+ });
87
+
88
+ describe('saveEntries', () => {
89
+ it('saves entries to file', async () => {
90
+ const mockFs = {
91
+ mkdir: vi.fn().mockResolvedValue(undefined),
92
+ writeFile: vi.fn().mockResolvedValue(undefined),
93
+ };
94
+
95
+ const entries = [
96
+ { id: '1', title: 'Bug', category: 'architecture' },
97
+ ];
98
+
99
+ await saveEntries('/project', entries, { fs: mockFs });
100
+
101
+ expect(mockFs.writeFile).toHaveBeenCalledWith(
102
+ expect.stringContaining('shame.json'),
103
+ expect.any(String)
104
+ );
105
+ });
106
+ });
107
+
108
+ describe('categorizeEntry', () => {
109
+ it('categorizes entries correctly', () => {
110
+ expect(SHAME_CATEGORIES).toContain('architecture');
111
+ expect(SHAME_CATEGORIES).toContain('type-safety');
112
+ expect(SHAME_CATEGORIES).toContain('duplication');
113
+ expect(SHAME_CATEGORIES).toContain('docker');
114
+ expect(SHAME_CATEGORIES).toContain('security');
115
+ expect(SHAME_CATEGORIES).toContain('data-loss');
116
+ });
117
+
118
+ it('validates category is in allowed list', () => {
119
+ const result = categorizeEntry('architecture');
120
+ expect(result.valid).toBe(true);
121
+
122
+ const invalid = categorizeEntry('invalid-category');
123
+ expect(invalid.valid).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('generateReport', () => {
128
+ it('generates markdown report', () => {
129
+ const entries = [
130
+ { id: '1', title: 'Bug A', rootCause: 'Cause A', category: 'architecture', fix: 'Fix A', lesson: 'Lesson A', timestamp: '2024-01-01' },
131
+ { id: '2', title: 'Bug B', rootCause: 'Cause B', category: 'security', fix: 'Fix B', lesson: 'Lesson B', timestamp: '2024-01-02' },
132
+ ];
133
+
134
+ const report = generateReport(entries);
135
+ expect(report).toContain('Bug A');
136
+ expect(report).toContain('Bug B');
137
+ expect(report).toContain('architecture');
138
+ expect(report).toContain('security');
139
+ });
140
+
141
+ it('report groups by category', () => {
142
+ const entries = [
143
+ { id: '1', title: 'Bug A', category: 'architecture', rootCause: 'r', fix: 'f', lesson: 'l', timestamp: '2024-01-01' },
144
+ { id: '2', title: 'Bug B', category: 'architecture', rootCause: 'r', fix: 'f', lesson: 'l', timestamp: '2024-01-02' },
145
+ { id: '3', title: 'Bug C', category: 'security', rootCause: 'r', fix: 'f', lesson: 'l', timestamp: '2024-01-03' },
146
+ ];
147
+
148
+ const report = generateReport(entries);
149
+ // architecture section should come before its entries
150
+ const archIndex = report.indexOf('architecture');
151
+ const bugAIndex = report.indexOf('Bug A');
152
+ const secIndex = report.indexOf('security');
153
+ expect(archIndex).toBeLessThan(bugAIndex);
154
+ expect(archIndex).toBeLessThan(secIndex);
155
+ });
156
+ });
157
+
158
+ describe('suggestGateRules', () => {
159
+ it('suggests gate rules for architecture category', () => {
160
+ const rules = suggestGateRules('architecture');
161
+ expect(rules.length).toBeGreaterThan(0);
162
+ expect(rules[0]).toMatchObject({
163
+ rule: expect.any(String),
164
+ description: expect.any(String),
165
+ });
166
+ });
167
+
168
+ it('suggests gate rules for type-safety category', () => {
169
+ const rules = suggestGateRules('type-safety');
170
+ expect(rules.length).toBeGreaterThan(0);
171
+ });
172
+ });
173
+
174
+ describe('trackRecurrence', () => {
175
+ it('tracks recurrence count per category', () => {
176
+ const entries = [
177
+ { id: '1', category: 'architecture' },
178
+ { id: '2', category: 'architecture' },
179
+ { id: '3', category: 'security' },
180
+ { id: '4', category: 'architecture' },
181
+ ];
182
+
183
+ const recurrence = trackRecurrence(entries);
184
+ expect(recurrence.architecture).toBe(3);
185
+ expect(recurrence.security).toBe(1);
186
+ });
187
+ });
188
+
189
+ describe('createShameRegistry', () => {
190
+ it('creates registry with injectable dependencies', () => {
191
+ const registry = createShameRegistry({
192
+ fs: { readFile: vi.fn(), writeFile: vi.fn(), mkdir: vi.fn() },
193
+ });
194
+
195
+ expect(registry).toBeDefined();
196
+ expect(registry.add).toBeDefined();
197
+ expect(registry.load).toBeDefined();
198
+ expect(registry.save).toBeDefined();
199
+ expect(registry.report).toBeDefined();
200
+ });
201
+ });
202
+ });
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Cleanup Dry-Run Mode
3
+ *
4
+ * Preview what /tlc:cleanup would change without making
5
+ * any modifications. Returns a structured report of planned changes.
6
+ *
7
+ * @module standards/cleanup-dry-run
8
+ */
9
+
10
+ const path = require('path');
11
+
12
+ /** Flat folders that should be reorganized */
13
+ const FLAT_FOLDERS = ['services', 'interfaces', 'controllers'];
14
+
15
+ /**
16
+ * List files that would be moved from flat folders to entity structure
17
+ * @param {string} projectPath - Path to project root
18
+ * @param {Object} options - Injectable dependencies
19
+ * @param {Function} options.glob - Glob function
20
+ * @returns {Promise<Array>} Files with source, destination, reason
21
+ */
22
+ async function listFilesToMove(projectPath, options = {}) {
23
+ const { glob } = options;
24
+ const results = [];
25
+
26
+ for (const folder of FLAT_FOLDERS) {
27
+ const pattern = `src/${folder}/*.*`;
28
+ const files = await glob(pattern, { cwd: projectPath });
29
+
30
+ for (const file of files) {
31
+ const fileName = path.basename(file);
32
+ // Derive entity name from file (e.g., userService.js → user)
33
+ const entity = fileName
34
+ .replace(/\.(js|ts|jsx|tsx)$/, '')
35
+ .replace(/(Service|Controller|Interface)$/i, '')
36
+ .toLowerCase();
37
+
38
+ results.push({
39
+ source: file,
40
+ destination: `src/${entity}/${fileName}`,
41
+ reason: `Move from flat ${folder}/ to entity-based structure`,
42
+ });
43
+ }
44
+ }
45
+
46
+ return results;
47
+ }
48
+
49
+ /**
50
+ * List hardcoded URLs/ports that would be extracted to env vars
51
+ * @param {string} projectPath - Path to project root
52
+ * @param {Object} options - Injectable dependencies
53
+ * @param {Function} options.glob - Glob function
54
+ * @param {Function} options.readFile - File reader
55
+ * @returns {Promise<Array>} Hardcoded values with suggested env vars
56
+ */
57
+ async function listHardcodedUrls(projectPath, options = {}) {
58
+ const { glob, readFile } = options;
59
+ const results = [];
60
+
61
+ const pattern = 'src/**/*.{js,ts,jsx,tsx}';
62
+ const files = await glob(pattern, { cwd: projectPath });
63
+
64
+ for (const file of files) {
65
+ const filePath = path.join(projectPath, file);
66
+ const content = await readFile(filePath);
67
+
68
+ // Check URLs
69
+ const urlPattern = /['"`](https?:\/\/[^'"`]+)['"`]/g;
70
+ let match;
71
+ while ((match = urlPattern.exec(content)) !== null) {
72
+ results.push({
73
+ file,
74
+ value: match[1],
75
+ type: 'url',
76
+ suggestedEnvVar: generateEnvVarName(match[1], 'url'),
77
+ });
78
+ }
79
+
80
+ // Check ports
81
+ const portPattern = /\b(?:const|let|var)\s+port\s*=\s*(\d+)/g;
82
+ while ((match = portPattern.exec(content)) !== null) {
83
+ results.push({
84
+ file,
85
+ value: match[1],
86
+ type: 'port',
87
+ suggestedEnvVar: 'PORT',
88
+ });
89
+ }
90
+ }
91
+
92
+ return results;
93
+ }
94
+
95
+ /**
96
+ * Generate env var name from a value
97
+ * @param {string} value - The value
98
+ * @param {string} type - Type (url, port)
99
+ * @returns {string} Suggested env var name
100
+ */
101
+ function generateEnvVarName(value, type) {
102
+ if (type === 'port') return 'PORT';
103
+ try {
104
+ const url = new URL(value);
105
+ const host = url.hostname.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
106
+ return host === 'LOCALHOST' ? 'API_URL' : `${host}_URL`;
107
+ } catch {
108
+ return 'API_URL';
109
+ }
110
+ }
111
+
112
+ /**
113
+ * List inline interfaces that would be extracted
114
+ * @param {string} projectPath - Path to project root
115
+ * @param {Object} options - Injectable dependencies
116
+ * @param {Function} options.glob - Glob function
117
+ * @param {Function} options.readFile - File reader
118
+ * @returns {Promise<Array>} Interfaces with file, name, target path
119
+ */
120
+ async function listInterfacesToExtract(projectPath, options = {}) {
121
+ const { glob, readFile } = options;
122
+ const results = [];
123
+
124
+ const pattern = '**/*.service.ts';
125
+ const files = await glob(pattern, { cwd: projectPath });
126
+
127
+ for (const file of files) {
128
+ const filePath = path.join(projectPath, file);
129
+ const content = await readFile(filePath);
130
+
131
+ const interfacePattern = /\binterface\s+(\w+)\s*\{/g;
132
+ let match;
133
+ while ((match = interfacePattern.exec(content)) !== null) {
134
+ const entity = path.basename(file, '.service.ts');
135
+ results.push({
136
+ file,
137
+ interfaceName: match[1],
138
+ targetPath: `src/${entity}/types/${entity}.types.ts`,
139
+ });
140
+ }
141
+ }
142
+
143
+ return results;
144
+ }
145
+
146
+ /**
147
+ * List exported functions missing JSDoc
148
+ * @param {string} projectPath - Path to project root
149
+ * @param {Object} options - Injectable dependencies
150
+ * @param {Function} options.glob - Glob function
151
+ * @param {Function} options.readFile - File reader
152
+ * @returns {Promise<Array>} Functions needing JSDoc
153
+ */
154
+ async function listFunctionsNeedingJsDoc(projectPath, options = {}) {
155
+ const { glob, readFile } = options;
156
+ const results = [];
157
+
158
+ const pattern = 'src/**/*.{js,ts}';
159
+ const files = await glob(pattern, { cwd: projectPath });
160
+
161
+ for (const file of files) {
162
+ const filePath = path.join(projectPath, file);
163
+ const content = await readFile(filePath);
164
+ const lines = content.split('\n');
165
+
166
+ for (let i = 0; i < lines.length; i++) {
167
+ const exportMatch = lines[i].match(/export\s+function\s+(\w+)/);
168
+ if (!exportMatch) continue;
169
+
170
+ // Check if previous non-empty line is end of JSDoc
171
+ let hasJsDoc = false;
172
+ for (let j = i - 1; j >= 0; j--) {
173
+ const prev = lines[j].trim();
174
+ if (prev === '') continue;
175
+ if (prev.endsWith('*/')) {
176
+ hasJsDoc = true;
177
+ }
178
+ break;
179
+ }
180
+
181
+ if (!hasJsDoc) {
182
+ results.push({
183
+ file,
184
+ functionName: exportMatch[1],
185
+ line: i + 1,
186
+ });
187
+ }
188
+ }
189
+ }
190
+
191
+ return results;
192
+ }
193
+
194
+ /**
195
+ * Generate planned commit messages based on planned changes
196
+ * @param {Object} plan - Cleanup plan
197
+ * @returns {string[]} Commit messages
198
+ */
199
+ function planCommitMessages(plan) {
200
+ const commits = [];
201
+
202
+ if (plan.filesToMove.length > 0) {
203
+ commits.push(`refactor(cleanup): migrate ${plan.filesToMove.length} files from flat folders to entity structure`);
204
+ }
205
+ if (plan.hardcodedUrls.length > 0) {
206
+ commits.push(`refactor(cleanup): extract ${plan.hardcodedUrls.length} hardcoded URLs/ports to environment variables`);
207
+ }
208
+ if (plan.interfacesToExtract.length > 0) {
209
+ commits.push(`refactor(cleanup): extract ${plan.interfacesToExtract.length} inline interfaces to type files`);
210
+ }
211
+ if (plan.functionsNeedingJsDoc.length > 0) {
212
+ commits.push(`docs(cleanup): add JSDoc to ${plan.functionsNeedingJsDoc.length} exported functions`);
213
+ }
214
+
215
+ return commits;
216
+ }
217
+
218
+ /**
219
+ * Plan cleanup without executing — dry-run mode
220
+ * @param {string} projectPath - Path to project root
221
+ * @param {Object} options - Injectable dependencies (no writeFile, no exec)
222
+ * @param {Function} options.glob - Glob function
223
+ * @param {Function} options.readFile - File reader
224
+ * @returns {Promise<Object>} Structured cleanup plan
225
+ */
226
+ async function planCleanup(projectPath, options = {}) {
227
+ const { glob, readFile } = options;
228
+
229
+ const filesToMove = await listFilesToMove(projectPath, { glob });
230
+ const hardcodedUrls = await listHardcodedUrls(projectPath, { glob, readFile });
231
+ const interfacesToExtract = await listInterfacesToExtract(projectPath, { glob, readFile });
232
+ const functionsNeedingJsDoc = await listFunctionsNeedingJsDoc(projectPath, { glob, readFile });
233
+
234
+ const plan = {
235
+ filesToMove,
236
+ hardcodedUrls,
237
+ interfacesToExtract,
238
+ functionsNeedingJsDoc,
239
+ plannedCommits: [],
240
+ };
241
+
242
+ plan.plannedCommits = planCommitMessages(plan);
243
+
244
+ return plan;
245
+ }
246
+
247
+ module.exports = {
248
+ planCleanup,
249
+ listFilesToMove,
250
+ listHardcodedUrls,
251
+ listInterfacesToExtract,
252
+ listFunctionsNeedingJsDoc,
253
+ planCommitMessages,
254
+ };