pumuki-ast-hooks 5.5.53 → 5.5.54

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.
@@ -13,6 +13,21 @@
13
13
  const { pushFinding } = require('../../ast-core');
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
+ const {
17
+ buildContext,
18
+ checkPackageSwiftExists,
19
+ checkFeatureModulesStructure,
20
+ checkCoreModulesStructure,
21
+ checkPublicAPIExposure,
22
+ checkModuleDependencies,
23
+ checkCrossModuleViolations,
24
+ checkPackageSwiftConfiguration,
25
+ checkTargetNaming,
26
+ checkProductConfiguration,
27
+ checkDependencyVersions,
28
+ checkTestTargets,
29
+ checkModuleBoundaries,
30
+ } = require('./iOSSPMChecks');
16
31
 
17
32
  class iOSSPMRules {
18
33
  constructor(findings, projectRoot) {
@@ -21,450 +36,29 @@ class iOSSPMRules {
21
36
  }
22
37
 
23
38
  analyze() {
24
- this.checkPackageSwiftExists();
25
- this.checkFeatureModulesStructure();
26
- this.checkCoreModulesStructure();
27
- this.checkPublicAPIExposure();
28
- this.checkModuleDependencies();
29
- this.checkCrossModuleViolations();
30
- this.checkPackageSwiftConfiguration();
31
- this.checkTargetNaming();
32
- this.checkProductConfiguration();
33
- this.checkDependencyVersions();
34
- this.checkTestTargets();
35
- this.checkModuleBoundaries();
36
- }
37
-
38
- /**
39
- * 1. Package.swift debe existir para modularización
40
- */
41
- checkPackageSwiftExists() {
42
- const packagePath = path.join(this.projectRoot, 'Package.swift');
43
-
44
- if (!fs.existsSync(packagePath)) {
45
- const swiftFiles = this.findSwiftFiles();
46
-
47
- if (swiftFiles.length > 50) {
48
- pushFinding(this.findings, {
49
- ruleId: 'ios.spm.missing_package_swift',
50
- severity: 'medium',
51
- message: `Proyecto con ${swiftFiles.length} archivos Swift sin Package.swift. Considerar modularización con SPM.`,
52
- filePath: 'PROJECT_ROOT',
53
- line: 1,
54
- suggestion: `Crear Package.swift para modularizar:
55
-
56
- swift package init --type library
57
- swift package init --type executable`
58
- });
59
- }
60
- } else {
61
- this.packageSwiftPath = packagePath;
62
- }
63
- }
64
-
65
- /**
66
- * 2. Feature modules structure
67
- */
68
- checkFeatureModulesStructure() {
69
- const sources = path.join(this.projectRoot, 'Sources');
70
-
71
- if (!fs.existsSync(sources)) return;
72
-
73
- const modules = fs.readdirSync(sources).filter(dir => {
74
- const stats = fs.statSync(path.join(sources, dir));
75
- return stats.isDirectory();
76
- });
77
-
78
- const featureModules = modules.filter(m => m.startsWith('Feature'));
79
- const coreModules = modules.filter(m => m.startsWith('Core'));
80
-
81
- if (modules.length > 5 && featureModules.length === 0) {
82
- pushFinding(this.findings, {
83
- ruleId: 'ios.spm.missing_feature_modules',
84
- severity: 'medium',
85
- message: `${modules.length} módulos sin naming convention Feature*. Considerar renombrar.`,
86
- filePath: 'Sources/',
87
- line: 1,
88
- suggestion: `Naming convention recomendado:
89
-
90
- Sources/
91
- ├── FeatureOrders/
92
- ├── FeatureUsers/
93
- ├── FeatureAuth/
94
- ├── CoreNetworking/
95
- ├── CoreDatabase/
96
- └── CoreUI/`
97
- });
98
- }
99
-
100
- if (modules.length > 10 && coreModules.length === 0) {
101
- pushFinding(this.findings, {
102
- ruleId: 'ios.spm.missing_core_modules',
103
- severity: 'medium',
104
- message: 'Proyecto grande sin módulos Core*. Considerar extraer código compartido.',
105
- filePath: 'Sources/',
106
- line: 1
107
- });
108
- }
109
- }
110
-
111
- /**
112
- * 3. Core modules detection
113
- */
114
- checkCoreModulesStructure() {
115
- const recommendedCoreModules = ['CoreNetworking', 'CoreDatabase', 'CoreUI', 'CoreModels'];
116
- const sources = path.join(this.projectRoot, 'Sources');
117
-
118
- if (!fs.existsSync(sources)) return;
119
-
120
- const existingModules = fs.readdirSync(sources);
121
- const missingCore = recommendedCoreModules.filter(core => !existingModules.includes(core));
122
-
123
- if (missingCore.length > 0 && existingModules.length > 8) {
124
- pushFinding(this.findings, {
125
- ruleId: 'ios.spm.recommended_core_modules_missing',
126
- severity: 'low',
127
- message: `Módulos Core recomendados faltantes: ${missingCore.join(', ')}`,
128
- filePath: 'Sources/',
129
- line: 1,
130
- suggestion: 'CoreNetworking, CoreDatabase, CoreUI ayudan a organizar código compartido'
131
- });
132
- }
133
- }
134
-
135
- /**
136
- * 4. Public API exposure control
137
- */
138
- checkPublicAPIExposure() {
139
- const swiftFiles = this.findSwiftFiles();
140
-
141
- swiftFiles.forEach(file => {
142
- const content = fs.readFileSync(file, 'utf-8');
143
-
144
- const publicCount = (content.match(/\bpublic\s+(class|struct|enum|func|var|let)/g) || []).length;
145
- const totalDeclarations = (content.match(/\b(class|struct|enum|func)\s+\w+/g) || []).length;
146
-
147
- if (totalDeclarations > 0) {
148
- const publicPercentage = (publicCount / totalDeclarations) * 100;
149
-
150
- if (publicPercentage > 70) {
151
- pushFinding(this.findings, {
152
- ruleId: 'ios.spm.excessive_public_api',
153
- severity: 'medium',
154
- message: `${publicPercentage.toFixed(0)}% de declaraciones son public. Minimizar API pública del módulo.`,
155
- filePath: file,
156
- line: 1,
157
- suggestion: `Usar internal por defecto, public solo para API externa:
158
-
159
- // ❌ Todo public
160
- public class Helper { ... }
161
- public func process() { ... }
162
-
163
- internal class Helper { ... }
164
- public func process() { ... } // Solo lo necesario`
165
- });
166
- }
167
- }
168
-
169
- if (file.includes('/API/') && !content.includes('public ')) {
170
- pushFinding(this.findings, {
171
- ruleId: 'ios.spm.api_module_not_public',
172
- severity: 'medium',
173
- message: 'Módulo API sin declaraciones public. API module debe exponer funcionalidad.',
174
- filePath: file,
175
- line: 1
176
- });
177
- }
39
+ const ctx = buildContext({
40
+ findings: this.findings,
41
+ projectRoot: this.projectRoot,
42
+ packageSwiftPath: this.packageSwiftPath,
43
+ findSwiftFiles: this.findSwiftFiles.bind(this),
44
+ findSwiftFilesInDirectory: this.findSwiftFilesInDirectory.bind(this),
45
+ findLineNumber: this.findLineNumber.bind(this),
178
46
  });
179
- }
180
-
181
- /**
182
- * 5. Module dependencies validation
183
- */
184
- checkModuleDependencies() {
185
- if (!this.packageSwiftPath) return;
186
-
187
- const content = fs.readFileSync(this.packageSwiftPath, 'utf-8');
188
-
189
- const targets = content.match(/\.target\([\s\S]*?name:\s*"([^"]+)"[\s\S]*?dependencies:\s*\[([\s\S]*?)\]/g);
190
-
191
- if (targets) {
192
- const dependencies = new Map();
193
-
194
- targets.forEach(target => {
195
- const name = target.match(/name:\s*"([^"]+)"/)?.[1];
196
- const deps = target.match(/dependencies:\s*\[([\s\S]*?)\]/)?.[1];
197
-
198
- if (name && deps) {
199
- const depList = deps.match(/"([^"]+)"/g)?.map(d => d.replace(/"/g, '')) || [];
200
- dependencies.set(name, depList);
201
- }
202
- });
203
-
204
- dependencies.forEach((deps, moduleName) => {
205
- deps.forEach(dep => {
206
- const depDeps = dependencies.get(dep) || [];
207
- if (depDeps.includes(moduleName)) {
208
- pushFinding(this.findings, {
209
- ruleId: 'ios.spm.circular_dependency',
210
- severity: 'critical',
211
- message: `Dependencia circular detectada: ${moduleName} ↔ ${dep}`,
212
- filePath: 'Package.swift',
213
- line: 1,
214
- suggestion: 'Romper dependencia circular extrayendo código compartido a módulo Core'
215
- });
216
- }
217
- });
218
- });
219
-
220
- dependencies.forEach((deps, moduleName) => {
221
- if (moduleName.startsWith('Feature')) {
222
- const featureDeps = deps.filter(d => d.startsWith('Feature'));
223
-
224
- if (featureDeps.length > 0) {
225
- pushFinding(this.findings, {
226
- ruleId: 'ios.spm.feature_to_feature_dependency',
227
- severity: 'high',
228
- message: `Feature module '${moduleName}' depende de otro Feature: ${featureDeps.join(', ')}. Extraer a Core.`,
229
- filePath: 'Package.swift',
230
- line: 1,
231
- suggestion: 'Features NO deben depender entre sí. Usar Core modules para compartir código.'
232
- });
233
- }
234
- }
235
- });
236
- }
237
- }
238
-
239
- /**
240
- * 6. Cross-module violations
241
- */
242
- checkCrossModuleViolations() {
243
- const swiftFiles = this.findSwiftFiles();
244
-
245
- swiftFiles.forEach(file => {
246
- const content = fs.readFileSync(file, 'utf-8');
247
-
248
- const internalImports = content.match(/@_implementationOnly\s+import\s+(\w+)/g);
249
47
 
250
- if (internalImports) {
251
- pushFinding(this.findings, {
252
- ruleId: 'ios.spm.implementation_only_import',
253
- severity: 'low',
254
- message: '@_implementationOnly import detectado. Es válido pero indica posible leak de abstracción.',
255
- filePath: file,
256
- line: this.findLineNumber(content, '@_implementationOnly'),
257
- suggestion: 'Verificar si el import es realmente necesario o hay leak de abstracción'
258
- });
259
- }
260
-
261
- if (content.includes('@testable import') && !file.includes('Tests/') && !file.includes('Test.swift')) {
262
- pushFinding(this.findings, {
263
- ruleId: 'ios.spm.testable_import_in_production',
264
- severity: 'high',
265
- message: '@testable import en código de producción. Solo debe usarse en tests.',
266
- filePath: file,
267
- line: this.findLineNumber(content, '@testable import')
268
- });
269
- }
270
- });
271
- }
272
-
273
- /**
274
- * 7. Package.swift configuration
275
- */
276
- checkPackageSwiftConfiguration() {
277
- if (!this.packageSwiftPath) return;
278
-
279
- const content = fs.readFileSync(this.packageSwiftPath, 'utf-8');
280
-
281
- const toolsVersion = content.match(/\/\/\s*swift-tools-version:\s*(\d+\.\d+)/)?.[1];
282
- if (toolsVersion && parseFloat(toolsVersion) < 5.9) {
283
- pushFinding(this.findings, {
284
- ruleId: 'ios.spm.outdated_tools_version',
285
- severity: 'medium',
286
- message: `Swift tools version ${toolsVersion} desactualizado. Actualizar a 5.9+`,
287
- filePath: 'Package.swift',
288
- line: 1
289
- });
290
- }
291
-
292
- if (!content.includes('platforms:')) {
293
- pushFinding(this.findings, {
294
- ruleId: 'ios.spm.missing_platforms',
295
- severity: 'medium',
296
- message: 'Package.swift sin platforms: especificado. Definir versión mínima de iOS.',
297
- filePath: 'Package.swift',
298
- line: 1,
299
- suggestion: 'platforms: [.iOS(.v15), .macOS(.v12)]'
300
- });
301
- }
302
- }
303
-
304
- /**
305
- * 8. Target naming consistency
306
- */
307
- checkTargetNaming() {
308
- if (!this.packageSwiftPath) return;
309
-
310
- const content = fs.readFileSync(this.packageSwiftPath, 'utf-8');
311
- const targets = content.match(/\.target\([\s\S]*?name:\s*"([^"]+)"/g);
312
-
313
- if (targets) {
314
- const targetNames = targets.map(t => t.match(/name:\s*"([^"]+)"/)?.[1]).filter(Boolean);
315
-
316
- const hasInconsistentNaming = targetNames.some(name => {
317
- return name.includes('_') || name.includes('-') || /[a-z][A-Z]/.test(name);
318
- });
319
-
320
- if (hasInconsistentNaming) {
321
- pushFinding(this.findings, {
322
- ruleId: 'ios.spm.inconsistent_target_naming',
323
- severity: 'low',
324
- message: 'Naming inconsistente en targets. Usar PascalCase sin separadores.',
325
- filePath: 'Package.swift',
326
- line: 1,
327
- suggestion: 'FeatureOrders, CoreNetworking (no Feature-Orders, Core_Networking)'
328
- });
329
- }
330
- }
331
- }
332
-
333
- /**
334
- * 9. Product configuration
335
- */
336
- checkProductConfiguration() {
337
- if (!this.packageSwiftPath) return;
338
-
339
- const content = fs.readFileSync(this.packageSwiftPath, 'utf-8');
340
-
341
- if (!content.includes('.library(')) {
342
- pushFinding(this.findings, {
343
- ruleId: 'ios.spm.missing_products',
344
- severity: 'medium',
345
- message: 'Package.swift sin products definidos. Definir libraries para exponer módulos.',
346
- filePath: 'Package.swift',
347
- line: 1,
348
- suggestion: `.library(name: "FeatureOrders", targets: ["FeatureOrders"])`
349
- });
350
- }
351
- }
352
-
353
- /**
354
- * 10. Dependency versions - debe usar versioning semántico
355
- */
356
- checkDependencyVersions() {
357
- if (!this.packageSwiftPath) return;
358
-
359
- const content = fs.readFileSync(this.packageSwiftPath, 'utf-8');
360
-
361
- if (content.includes('.branch(')) {
362
- pushFinding(this.findings, {
363
- ruleId: 'ios.spm.dependency_branch_instead_version',
364
- severity: 'high',
365
- message: 'Dependencia usando .branch() en lugar de versión específica. Usar .upToNextMajor o .exact.',
366
- filePath: 'Package.swift',
367
- line: this.findLineNumber(content, '.branch('),
368
- suggestion: `Usar versiones semánticas:
369
-
370
- // ❌ Inestable
371
- .package(url: "...", branch: "main")
372
-
373
- // ✅ Estable
374
- .package(url: "...", from: "1.0.0")
375
- .package(url: "...", .upToNextMajor(from: "1.0.0"))`
376
- });
377
- }
378
- }
379
-
380
- /**
381
- * 11. Test targets - debe haber tests para cada módulo
382
- */
383
- checkTestTargets() {
384
- if (!this.packageSwiftPath) return;
385
-
386
- const content = fs.readFileSync(this.packageSwiftPath, 'utf-8');
387
-
388
- const targets = content.match(/\.target\([\s\S]*?name:\s*"([^"]+)"/g) || [];
389
- const testTargets = content.match(/\.testTarget\([\s\S]*?name:\s*"([^"]+)"/g) || [];
390
-
391
- const targetNames = targets.map(t => t.match(/name:\s*"([^"]+)"/)?.[1]).filter(Boolean);
392
- const testTargetNames = testTargets.map(t => t.match(/name:\s*"([^"]+)"/)?.[1]).filter(Boolean);
393
-
394
- const targetsWithoutTests = targetNames.filter(name =>
395
- !testTargetNames.includes(`${name}Tests`) &&
396
- !name.includes('Tests')
397
- );
398
-
399
- if (targetsWithoutTests.length > 0) {
400
- pushFinding(this.findings, {
401
- ruleId: 'ios.spm.targets_without_tests',
402
- severity: 'medium',
403
- message: `${targetsWithoutTests.length} targets sin test targets: ${targetsWithoutTests.slice(0, 3).join(', ')}`,
404
- filePath: 'Package.swift',
405
- line: 1,
406
- suggestion: `Añadir test targets:
407
-
408
- .testTarget(
409
- name: "FeatureOrdersTests",
410
- dependencies: ["FeatureOrders"]
411
- )`
412
- });
413
- }
414
- }
415
-
416
- /**
417
- * 12. Module boundaries - detectar imports indebidos
418
- */
419
- checkModuleBoundaries() {
420
- const sources = path.join(this.projectRoot, 'Sources');
421
- if (!fs.existsSync(sources)) return;
422
-
423
- const modules = fs.readdirSync(sources).filter(dir => {
424
- const stats = fs.statSync(path.join(sources, dir));
425
- return stats.isDirectory();
426
- });
427
-
428
- modules.forEach(moduleName => {
429
- const modulePath = path.join(sources, moduleName);
430
- const swiftFiles = this.findSwiftFilesInDirectory(modulePath);
431
-
432
- swiftFiles.forEach(file => {
433
- const content = fs.readFileSync(file, 'utf-8');
434
-
435
- if (moduleName.startsWith('Feature')) {
436
- modules.forEach(otherModule => {
437
- if (otherModule.startsWith('Feature') && otherModule !== moduleName) {
438
- if (content.includes(`import ${otherModule}`)) {
439
- pushFinding(this.findings, {
440
- ruleId: 'ios.spm.feature_imports_feature',
441
- severity: 'high',
442
- message: `Feature module '${moduleName}' importa otro Feature '${otherModule}'. Violación de boundaries.`,
443
- filePath: file,
444
- line: this.findLineNumber(content, `import ${otherModule}`),
445
- suggestion: 'Extraer código compartido a Core module'
446
- });
447
- }
448
- }
449
- });
450
- }
451
-
452
- if (moduleName.startsWith('Core')) {
453
- modules.forEach(featureModule => {
454
- if (featureModule.startsWith('Feature') && content.includes(`import ${featureModule}`)) {
455
- pushFinding(this.findings, {
456
- ruleId: 'ios.spm.core_imports_feature',
457
- severity: 'critical',
458
- message: `Core module '${moduleName}' importa Feature '${featureModule}'. Dependencia invertida!`,
459
- filePath: file,
460
- line: this.findLineNumber(content, `import ${featureModule}`),
461
- suggestion: 'Core NO debe depender de Features. Invertir dependencia.'
462
- });
463
- }
464
- });
465
- }
466
- });
467
- });
48
+ checkPackageSwiftExists(ctx);
49
+ checkFeatureModulesStructure(ctx);
50
+ checkCoreModulesStructure(ctx);
51
+ checkPublicAPIExposure(ctx);
52
+ checkModuleDependencies(ctx);
53
+ checkCrossModuleViolations(ctx);
54
+ checkPackageSwiftConfiguration(ctx);
55
+ checkTargetNaming(ctx);
56
+ checkProductConfiguration(ctx);
57
+ checkDependencyVersions(ctx);
58
+ checkTestTargets(ctx);
59
+ checkModuleBoundaries(ctx);
60
+
61
+ this.packageSwiftPath = ctx.getPackageSwiftPath();
468
62
  }
469
63
 
470
64
  findSwiftFiles() {
@@ -0,0 +1,146 @@
1
+ class SourceKittenExtractor {
2
+ extractClasses(ast) {
3
+ const classes = [];
4
+ this.traverse(ast?.substructure, (node) => {
5
+ const kind = node['key.kind'];
6
+ if (kind === 'source.lang.swift.decl.class') {
7
+ classes.push({
8
+ name: node['key.name'],
9
+ line: node['key.line'],
10
+ column: node['key.column'],
11
+ accessibility: node['key.accessibility'],
12
+ inheritedTypes: node['key.inheritedtypes'] || [],
13
+ substructure: node['key.substructure'] || [],
14
+ });
15
+ }
16
+ });
17
+ return classes;
18
+ }
19
+
20
+ extractFunctions(ast) {
21
+ const functions = [];
22
+ const functionKinds = new Set([
23
+ 'source.lang.swift.decl.function.method.instance',
24
+ 'source.lang.swift.decl.function.method.class',
25
+ 'source.lang.swift.decl.function.method.static',
26
+ 'source.lang.swift.decl.function.free',
27
+ ]);
28
+ this.traverse(ast?.substructure, (node) => {
29
+ const kind = node['key.kind'];
30
+ if (functionKinds.has(kind)) {
31
+ functions.push({
32
+ name: node['key.name'],
33
+ line: node['key.line'],
34
+ column: node['key.column'],
35
+ kind,
36
+ accessibility: node['key.accessibility'],
37
+ typename: node['key.typename'],
38
+ length: node['key.length'],
39
+ bodyLength: node['key.bodylength'],
40
+ });
41
+ }
42
+ });
43
+ return functions;
44
+ }
45
+
46
+ extractProperties(ast) {
47
+ const properties = [];
48
+ const propertyKinds = new Set([
49
+ 'source.lang.swift.decl.var.instance',
50
+ 'source.lang.swift.decl.var.class',
51
+ 'source.lang.swift.decl.var.static',
52
+ ]);
53
+ this.traverse(ast?.substructure, (node) => {
54
+ const kind = node['key.kind'];
55
+ if (propertyKinds.has(kind)) {
56
+ properties.push({
57
+ name: node['key.name'],
58
+ line: node['key.line'],
59
+ column: node['key.column'],
60
+ kind,
61
+ typename: node['key.typename'],
62
+ accessibility: node['key.accessibility'],
63
+ });
64
+ }
65
+ });
66
+ return properties;
67
+ }
68
+
69
+ extractProtocols(ast) {
70
+ const protocols = [];
71
+ this.traverse(ast?.substructure, (node) => {
72
+ const kind = node['key.kind'];
73
+ if (kind === 'source.lang.swift.decl.protocol') {
74
+ protocols.push({
75
+ name: node['key.name'],
76
+ line: node['key.line'],
77
+ column: node['key.column'],
78
+ accessibility: node['key.accessibility'],
79
+ inheritedTypes: node['key.inheritedtypes'] || [],
80
+ substructure: node['key.substructure'] || [],
81
+ });
82
+ }
83
+ });
84
+ return protocols;
85
+ }
86
+
87
+ usesSwiftUI(ast) {
88
+ const hasViewProtocol = (nodes) => {
89
+ if (!Array.isArray(nodes)) return false;
90
+ return nodes.some(node => {
91
+ const inheritedTypes = node['key.inheritedtypes'] || [];
92
+ if (inheritedTypes.some(t => t['key.name'] === 'View')) {
93
+ return true;
94
+ }
95
+ return hasViewProtocol(node['key.substructure'] || []);
96
+ });
97
+ };
98
+ return hasViewProtocol(ast?.substructure);
99
+ }
100
+
101
+ usesUIKit(ast) {
102
+ const hasUIKitBase = (nodes) => {
103
+ if (!Array.isArray(nodes)) return false;
104
+ return nodes.some(node => {
105
+ const inheritedTypes = node['key.inheritedtypes'] || [];
106
+ if (inheritedTypes.some(t =>
107
+ t['key.name'] === 'UIViewController' ||
108
+ t['key.name'] === 'UIView'
109
+ )) {
110
+ return true;
111
+ }
112
+ return hasUIKitBase(node['key.substructure'] || []);
113
+ });
114
+ };
115
+ return hasUIKitBase(ast?.substructure);
116
+ }
117
+
118
+ detectForceUnwraps(_syntaxMap, fileContent) {
119
+ const forceUnwraps = [];
120
+ const lines = (fileContent || '').split('\n');
121
+ lines.forEach((line, index) => {
122
+ const matches = [...line.matchAll(/(\w+)\s*!/g)];
123
+ matches.forEach(match => {
124
+ forceUnwraps.push({
125
+ line: index + 1,
126
+ column: match.index + 1,
127
+ variable: match[1],
128
+ context: line.trim(),
129
+ });
130
+ });
131
+ });
132
+ return forceUnwraps;
133
+ }
134
+
135
+ traverse(nodes, visitor) {
136
+ if (!Array.isArray(nodes)) return;
137
+ nodes.forEach(node => {
138
+ visitor(node);
139
+ if (node['key.substructure']) {
140
+ this.traverse(node['key.substructure'], visitor);
141
+ }
142
+ });
143
+ }
144
+ }
145
+
146
+ module.exports = SourceKittenExtractor;