pumuki-ast-hooks 5.5.46 → 5.5.48
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.
- package/docs/CODE_STANDARDS.md +5 -0
- package/docs/VIOLATIONS_RESOLUTION_PLAN.md +22 -34
- package/package.json +2 -2
- package/scripts/hooks-system/.audit_tmp/hook-metrics.jsonl +36 -0
- package/scripts/hooks-system/application/services/installation/FileSystemInstallerService.js +1 -1
- package/scripts/hooks-system/application/services/installation/VSCodeTaskConfigurator.js +8 -2
- package/scripts/hooks-system/bin/gitflow-cycle.js +0 -0
- package/scripts/hooks-system/config/project.config.json +1 -1
- package/scripts/hooks-system/infrastructure/ast/android/analyzers/AndroidSOLIDAnalyzer.js +11 -255
- package/scripts/hooks-system/infrastructure/ast/android/detectors/android-solid-detectors.js +227 -0
- package/scripts/hooks-system/infrastructure/ast/ast-core.js +12 -3
- package/scripts/hooks-system/infrastructure/ast/ast-intelligence.js +36 -13
- package/scripts/hooks-system/infrastructure/ast/backend/ast-backend.js +10 -83
- package/scripts/hooks-system/infrastructure/ast/backend/detectors/god-class-detector.js +83 -0
- package/scripts/hooks-system/infrastructure/ast/common/ast-common.js +17 -2
- package/scripts/hooks-system/infrastructure/ast/frontend/analyzers/FrontendArchitectureDetector.js +12 -142
- package/scripts/hooks-system/infrastructure/ast/frontend/detectors/frontend-architecture-strategies.js +126 -0
- package/scripts/hooks-system/infrastructure/ast/ios/analyzers/iOSASTIntelligentAnalyzer.js +30 -783
- package/scripts/hooks-system/infrastructure/ast/ios/analyzers/iOSArchitectureDetector.js +21 -224
- package/scripts/hooks-system/infrastructure/ast/ios/analyzers/iOSArchitectureRules.js +18 -605
- package/scripts/hooks-system/infrastructure/ast/ios/analyzers/iOSModernPracticesRules.js +4 -1
- package/scripts/hooks-system/infrastructure/ast/ios/ast-ios.js +4 -1
- package/scripts/hooks-system/infrastructure/ast/ios/detectors/ios-architecture-rules-strategies.js +595 -0
- package/scripts/hooks-system/infrastructure/ast/ios/detectors/ios-architecture-strategies.js +192 -0
- package/scripts/hooks-system/infrastructure/ast/ios/detectors/ios-ast-intelligent-strategies.js +789 -0
- package/scripts/hooks-system/infrastructure/ast/ios/detectors/ios-god-class-detector.js +79 -0
- package/scripts/hooks-system/infrastructure/ast/ios/native-bridge.js +4 -1
- package/skills/android-guidelines/SKILL.md +1 -0
- package/skills/backend-guidelines/SKILL.md +1 -0
- package/skills/frontend-guidelines/SKILL.md +1 -0
- package/skills/ios-guidelines/SKILL.md +1 -0
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
|
|
2
1
|
const { execSync } = require('child_process');
|
|
3
2
|
const fs = require('fs');
|
|
4
3
|
const path = require('path');
|
|
5
4
|
const crypto = require('crypto');
|
|
6
5
|
const env = require(path.join(__dirname, '../../../../config/env'));
|
|
6
|
+
const {
|
|
7
|
+
resetCollections,
|
|
8
|
+
collectAllNodes,
|
|
9
|
+
analyzeCollectedNodes,
|
|
10
|
+
finalizeGodClassDetection,
|
|
11
|
+
} = require('../detectors/ios-ast-intelligent-strategies');
|
|
7
12
|
|
|
8
13
|
class iOSASTIntelligentAnalyzer {
|
|
9
14
|
constructor(findings) {
|
|
@@ -43,7 +48,10 @@ class iOSASTIntelligentAnalyzer {
|
|
|
43
48
|
readStagedFileContent(repoRoot, relPath) {
|
|
44
49
|
try {
|
|
45
50
|
return execSync(`git show :"${relPath}"`, { encoding: 'utf8', cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
46
|
-
} catch {
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (process.env.DEBUG) {
|
|
53
|
+
console.debug(`[iOSASTIntelligentAnalyzer] Failed to read staged file ${relPath}: ${error.message}`);
|
|
54
|
+
}
|
|
47
55
|
return null;
|
|
48
56
|
}
|
|
49
57
|
}
|
|
@@ -52,7 +60,10 @@ class iOSASTIntelligentAnalyzer {
|
|
|
52
60
|
try {
|
|
53
61
|
execSync(`${this.sourceKittenPath} version`, { stdio: 'pipe' });
|
|
54
62
|
return true;
|
|
55
|
-
} catch {
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (process.env.DEBUG) {
|
|
65
|
+
console.debug(`[iOSASTIntelligentAnalyzer] SourceKitten not available at ${this.sourceKittenPath}: ${error.message}`);
|
|
66
|
+
}
|
|
56
67
|
return false;
|
|
57
68
|
}
|
|
58
69
|
}
|
|
@@ -103,7 +114,10 @@ class iOSASTIntelligentAnalyzer {
|
|
|
103
114
|
{ encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
104
115
|
);
|
|
105
116
|
return JSON.parse(result);
|
|
106
|
-
} catch {
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (process.env.DEBUG) {
|
|
119
|
+
console.debug(`[iOSASTIntelligentAnalyzer] SourceKitten parse failed for ${filePath}: ${error.message}`);
|
|
120
|
+
}
|
|
107
121
|
return null;
|
|
108
122
|
}
|
|
109
123
|
}
|
|
@@ -128,7 +142,10 @@ class iOSASTIntelligentAnalyzer {
|
|
|
128
142
|
fs.writeFileSync(tmpPath, stagedContent, 'utf8');
|
|
129
143
|
parsePath = tmpPath;
|
|
130
144
|
contentOverride = stagedContent;
|
|
131
|
-
} catch {
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (process.env.DEBUG) {
|
|
147
|
+
console.debug(`[iOSASTIntelligentAnalyzer] Failed to write temp staged file ${tmpPath}: ${error.message}`);
|
|
148
|
+
}
|
|
132
149
|
// Fall back to working tree file
|
|
133
150
|
}
|
|
134
151
|
}
|
|
@@ -142,429 +159,23 @@ class iOSASTIntelligentAnalyzer {
|
|
|
142
159
|
if (!ast) return;
|
|
143
160
|
|
|
144
161
|
this.currentFilePath = displayPath;
|
|
145
|
-
|
|
162
|
+
resetCollections(this);
|
|
146
163
|
|
|
147
164
|
try {
|
|
148
165
|
this.fileContent = typeof contentOverride === 'string'
|
|
149
166
|
? contentOverride
|
|
150
167
|
: fs.readFileSync(parsePath, 'utf8');
|
|
151
|
-
} catch {
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (process.env.DEBUG) {
|
|
170
|
+
console.debug(`[iOSASTIntelligentAnalyzer] Failed to read file content ${parsePath}: ${error.message}`);
|
|
171
|
+
}
|
|
152
172
|
this.fileContent = '';
|
|
153
173
|
}
|
|
154
174
|
|
|
155
175
|
const substructure = ast['key.substructure'] || [];
|
|
156
176
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
resetCollections() {
|
|
162
|
-
this.allNodes = [];
|
|
163
|
-
this.imports = [];
|
|
164
|
-
this.classes = [];
|
|
165
|
-
this.structs = [];
|
|
166
|
-
this.protocols = [];
|
|
167
|
-
this.functions = [];
|
|
168
|
-
this.properties = [];
|
|
169
|
-
this.closures = [];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
collectAllNodes(nodes, parent) {
|
|
173
|
-
if (!Array.isArray(nodes)) return;
|
|
174
|
-
|
|
175
|
-
for (const node of nodes) {
|
|
176
|
-
const kind = node['key.kind'] || '';
|
|
177
|
-
node._parent = parent;
|
|
178
|
-
this.allNodes.push(node);
|
|
179
|
-
|
|
180
|
-
if (kind === 'source.lang.swift.decl.class') {
|
|
181
|
-
this.classes.push(node);
|
|
182
|
-
} else if (kind === 'source.lang.swift.decl.struct') {
|
|
183
|
-
this.structs.push(node);
|
|
184
|
-
} else if (kind === 'source.lang.swift.decl.protocol') {
|
|
185
|
-
this.protocols.push(node);
|
|
186
|
-
} else if (kind.includes('function')) {
|
|
187
|
-
this.functions.push(node);
|
|
188
|
-
} else if (kind.includes('var')) {
|
|
189
|
-
this.properties.push(node);
|
|
190
|
-
} else if (kind.includes('closure')) {
|
|
191
|
-
this.closures.push(node);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
this.collectAllNodes(node['key.substructure'] || [], node);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
analyzeCollectedNodes(filePath) {
|
|
199
|
-
this.extractImports();
|
|
200
|
-
|
|
201
|
-
this.analyzeImportsAST(filePath);
|
|
202
|
-
|
|
203
|
-
for (const cls of this.classes) {
|
|
204
|
-
this.analyzeClassAST(cls, filePath);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
for (const struct of this.structs) {
|
|
208
|
-
this.analyzeStructAST(struct, filePath);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
for (const proto of this.protocols) {
|
|
212
|
-
this.analyzeProtocolAST(proto, filePath);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
for (const func of this.functions) {
|
|
216
|
-
this.analyzeFunctionAST(func, filePath);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
for (const prop of this.properties) {
|
|
220
|
-
this.analyzePropertyAST(prop, filePath);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
this.analyzeClosuresAST(filePath);
|
|
224
|
-
this.analyzeCleanArchitectureAST(filePath);
|
|
225
|
-
this.analyzeAdditionalRules(filePath);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
extractImports() {
|
|
229
|
-
const lines = this.fileContent.split('\n');
|
|
230
|
-
for (let i = 0; i < lines.length; i++) {
|
|
231
|
-
const line = lines[i].trim();
|
|
232
|
-
if (line.startsWith('import ')) {
|
|
233
|
-
this.imports.push({
|
|
234
|
-
name: line.replace('import ', '').trim(),
|
|
235
|
-
line: i + 1
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
analyzeImportsAST(filePath) {
|
|
242
|
-
const importNames = this.imports.map(i => i.name);
|
|
243
|
-
|
|
244
|
-
const hasUIKit = importNames.includes('UIKit');
|
|
245
|
-
const hasSwiftUI = importNames.includes('SwiftUI');
|
|
246
|
-
const hasCombine = importNames.includes('Combine');
|
|
247
|
-
|
|
248
|
-
if (hasUIKit && hasSwiftUI) {
|
|
249
|
-
this.pushFinding('ios.architecture.mixed_ui_frameworks', 'medium', filePath, 1,
|
|
250
|
-
'Mixing UIKit and SwiftUI - consider separating concerns');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const hasAsyncFunction = this.functions.some(f =>
|
|
254
|
-
this.hasAttribute(f, 'async')
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
if (hasCombine && !hasAsyncFunction) {
|
|
258
|
-
this.pushFinding('ios.concurrency.combine_without_async', 'low', filePath, 1,
|
|
259
|
-
'Using Combine - consider async/await for simpler async code');
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
for (const imp of this.imports) {
|
|
263
|
-
if (['Foundation', 'SwiftUI', 'UIKit', 'Combine'].includes(imp.name)) continue;
|
|
264
|
-
|
|
265
|
-
const isUsed = this.allNodes.some(n => {
|
|
266
|
-
const typename = n['key.typename'] || '';
|
|
267
|
-
const name = n['key.name'] || '';
|
|
268
|
-
return typename.includes(imp.name) || name.includes(imp.name);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
if (!isUsed) {
|
|
272
|
-
this.pushFinding('ios.imports.unused', 'low', filePath, imp.line,
|
|
273
|
-
`Unused import: ${imp.name}`);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (filePath.includes('/Domain/') && (hasUIKit || hasSwiftUI)) {
|
|
278
|
-
this.pushFinding('ios.architecture.domain_ui_import', 'critical', filePath, 1,
|
|
279
|
-
'Domain layer imports UI framework - violates Clean Architecture');
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
analyzeClassAST(node, filePath) {
|
|
284
|
-
const name = node['key.name'] || '';
|
|
285
|
-
const line = node['key.line'] || 1;
|
|
286
|
-
const bodyLength = node['key.bodylength'] || 0;
|
|
287
|
-
const substructure = node['key.substructure'] || [];
|
|
288
|
-
const inheritedTypes = (node['key.inheritedtypes'] || []).map(t => t['key.name']);
|
|
289
|
-
const attributes = this.getAttributes(node);
|
|
290
|
-
|
|
291
|
-
const methods = substructure.filter(n => (n['key.kind'] || '').includes('function'));
|
|
292
|
-
const properties = substructure.filter(n => (n['key.kind'] || '').includes('var'));
|
|
293
|
-
const inits = methods.filter(m => (m['key.name'] || '').startsWith('init'));
|
|
294
|
-
|
|
295
|
-
if (name && !/Spec$|Test$|Mock/.test(name)) {
|
|
296
|
-
const complexity = this.calculateComplexityAST(substructure);
|
|
297
|
-
this.godClassCandidates.push({
|
|
298
|
-
name,
|
|
299
|
-
filePath,
|
|
300
|
-
line,
|
|
301
|
-
methodsCount: methods.length,
|
|
302
|
-
propertiesCount: properties.length,
|
|
303
|
-
bodyLength,
|
|
304
|
-
complexity,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
if (name.includes('ViewController')) {
|
|
309
|
-
if (bodyLength > 250 || methods.length > 8) {
|
|
310
|
-
this.pushFinding('ios.architecture.massive_viewcontroller', 'high', filePath, line,
|
|
311
|
-
`Massive ViewController '${name}': ${bodyLength} lines - extract to ViewModel`);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const hasBusinessLogic = methods.some(m => {
|
|
315
|
-
const methodName = m['key.name'] || '';
|
|
316
|
-
return /calculate|process|validate|fetch|save|delete|update/i.test(methodName);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
if (hasBusinessLogic) {
|
|
320
|
-
this.pushFinding('ios.architecture.vc_business_logic', 'high', filePath, line,
|
|
321
|
-
`ViewController '${name}' contains business logic - move to UseCase`);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
if (name.includes('ViewModel')) {
|
|
326
|
-
const hasMainActor = attributes.includes('MainActor');
|
|
327
|
-
if (!hasMainActor) {
|
|
328
|
-
this.pushFinding('ios.concurrency.viewmodel_mainactor', 'high', filePath, line,
|
|
329
|
-
`ViewModel '${name}' should be @MainActor for UI safety`);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const hasObservable = inheritedTypes.includes('ObservableObject') ||
|
|
333
|
-
attributes.includes('Observable');
|
|
334
|
-
if (!hasObservable) {
|
|
335
|
-
this.pushFinding('ios.swiftui.viewmodel_observable', 'medium', filePath, line,
|
|
336
|
-
`ViewModel '${name}' should conform to ObservableObject`);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
if (inits.length === 0 && properties.length > 0) {
|
|
340
|
-
this.pushFinding('ios.architecture.viewmodel_no_di', 'high', filePath, line,
|
|
341
|
-
`ViewModel '${name}' has no init - use dependency injection`);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (/Manager$|Helper$|Utils$|Handler$/.test(name)) {
|
|
346
|
-
this.pushFinding('ios.naming.god_naming', 'medium', filePath, line,
|
|
347
|
-
`Suspicious class name '${name}' - often indicates SRP violation`);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const unusedProps = this.findUnusedPropertiesAST(properties, methods);
|
|
351
|
-
for (const prop of unusedProps) {
|
|
352
|
-
this.pushFinding('ios.solid.isp.unused_dependency', 'high', filePath, line,
|
|
353
|
-
`Unused property '${prop}' in '${name}' - ISP violation`);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
this.checkDependencyInjectionAST(properties, filePath, name, line);
|
|
357
|
-
|
|
358
|
-
const hasDeinit = methods.some(m => m['key.name'] === 'deinit');
|
|
359
|
-
const hasObservers = this.closures.some(c => c._parent === node) ||
|
|
360
|
-
properties.some(p => this.hasAttribute(p, 'Published'));
|
|
361
|
-
|
|
362
|
-
if (!hasDeinit && hasObservers && !name.includes('ViewModel')) {
|
|
363
|
-
this.pushFinding('ios.memory.missing_deinit', 'high', filePath, line,
|
|
364
|
-
`Class '${name}' has observers but no deinit - potential memory leak`);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const isFinal = attributes.includes('final');
|
|
368
|
-
if (!isFinal && inheritedTypes.length === 0 && name !== 'AppDelegate') {
|
|
369
|
-
this.pushFinding('ios.performance.non_final_class', 'low', filePath, line,
|
|
370
|
-
`Class '${name}' is not final - consider final for performance`);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const hasSingleton = properties.some(p => {
|
|
374
|
-
const propName = p['key.name'] || '';
|
|
375
|
-
const isStatic = (p['key.kind'] || '').includes('static');
|
|
376
|
-
return isStatic && propName === 'shared';
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
if (hasSingleton) {
|
|
380
|
-
this.pushFinding('ios.antipattern.singleton', 'high', filePath, line,
|
|
381
|
-
`Singleton pattern in '${name}' - use dependency injection`);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
if (filePath.includes('Test') && name.includes('Test')) {
|
|
385
|
-
const hasMakeSUT = methods.some(m => (m['key.name'] || '').includes('makeSUT'));
|
|
386
|
-
if (!hasMakeSUT && methods.length > 2) {
|
|
387
|
-
this.pushFinding('ios.testing.missing_makesut', 'medium', filePath, line,
|
|
388
|
-
`Test class '${name}' missing makeSUT() factory`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (name.includes('Repository') && !name.includes('Protocol')) {
|
|
393
|
-
const hasProtocol = inheritedTypes.some(t => t.includes('Protocol'));
|
|
394
|
-
if (!hasProtocol && !filePath.includes('/Domain/')) {
|
|
395
|
-
this.pushFinding('ios.architecture.repository_no_protocol', 'high', filePath, line,
|
|
396
|
-
`Repository '${name}' should implement a protocol`);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (name.includes('UseCase')) {
|
|
401
|
-
const hasExecute = methods.some(m => (m['key.name'] || '').includes('execute'));
|
|
402
|
-
if (!hasExecute) {
|
|
403
|
-
this.pushFinding('ios.architecture.usecase_no_execute', 'medium', filePath, line,
|
|
404
|
-
`UseCase '${name}' missing execute() method`);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
analyzeStructAST(node, filePath) {
|
|
410
|
-
const name = node['key.name'] || '';
|
|
411
|
-
const line = node['key.line'] || 1;
|
|
412
|
-
const substructure = node['key.substructure'] || [];
|
|
413
|
-
const inheritedTypes = (node['key.inheritedtypes'] || []).map(t => t['key.name']);
|
|
414
|
-
|
|
415
|
-
const methods = substructure.filter(n => (n['key.kind'] || '').includes('function'));
|
|
416
|
-
const properties = substructure.filter(n => (n['key.kind'] || '').includes('var'));
|
|
417
|
-
|
|
418
|
-
if (inheritedTypes.includes('View')) {
|
|
419
|
-
this.analyzeSwiftUIViewAST(node, filePath, name, line, methods, properties);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (inheritedTypes.includes('Codable') || inheritedTypes.includes('Decodable')) {
|
|
423
|
-
const hasOptionalProps = properties.some(p => (p['key.typename'] || '').includes('?'));
|
|
424
|
-
const hasCodingKeys = substructure.some(n => n['key.name'] === 'CodingKeys');
|
|
425
|
-
|
|
426
|
-
if (hasOptionalProps && !hasCodingKeys) {
|
|
427
|
-
this.pushFinding('ios.codable.missing_coding_keys', 'low', filePath, line,
|
|
428
|
-
`Struct '${name}' has optional properties - consider CodingKeys`);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if ((inheritedTypes.includes('Equatable') || inheritedTypes.includes('Hashable')) &&
|
|
433
|
-
properties.length > 5) {
|
|
434
|
-
this.pushFinding('ios.performance.large_equatable', 'low', filePath, line,
|
|
435
|
-
`Struct '${name}' has ${properties.length} properties with Equatable`);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
analyzeSwiftUIViewAST(node, filePath, name, line, methods, properties) {
|
|
440
|
-
const bodyMethod = methods.find(m => m['key.name'] === 'body');
|
|
441
|
-
if (bodyMethod) {
|
|
442
|
-
const bodyLength = bodyMethod['key.bodylength'] || 0;
|
|
443
|
-
if (bodyLength > 100) {
|
|
444
|
-
this.pushFinding('ios.swiftui.complex_body', 'high', filePath, line,
|
|
445
|
-
`View '${name}' has complex body (${bodyLength} lines) - extract subviews`);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const stateProps = properties.filter(p =>
|
|
450
|
-
this.hasAttribute(p, 'State') || this.hasAttribute(p, 'Binding')
|
|
451
|
-
);
|
|
452
|
-
|
|
453
|
-
if (stateProps.length > 5) {
|
|
454
|
-
this.pushFinding('ios.swiftui.too_many_state', 'medium', filePath, line,
|
|
455
|
-
`View '${name}' has ${stateProps.length} @State/@Binding - consider ViewModel`);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const hasObservedObject = properties.some(p => this.hasAttribute(p, 'ObservedObject'));
|
|
459
|
-
const hasStateObject = properties.some(p => this.hasAttribute(p, 'StateObject'));
|
|
460
|
-
|
|
461
|
-
if (hasObservedObject && !hasStateObject) {
|
|
462
|
-
this.pushFinding('ios.swiftui.observed_without_state', 'medium', filePath, line,
|
|
463
|
-
`View '${name}' uses @ObservedObject - consider @StateObject for ownership`);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
analyzeProtocolAST(node, filePath) {
|
|
468
|
-
const name = node['key.name'] || '';
|
|
469
|
-
const line = node['key.line'] || 1;
|
|
470
|
-
const substructure = node['key.substructure'] || [];
|
|
471
|
-
|
|
472
|
-
const requirements = substructure.filter(n =>
|
|
473
|
-
(n['key.kind'] || '').includes('function') || (n['key.kind'] || '').includes('var')
|
|
474
|
-
);
|
|
475
|
-
|
|
476
|
-
if (requirements.length > 5) {
|
|
477
|
-
this.pushFinding('ios.solid.isp.fat_protocol', 'medium', filePath, line,
|
|
478
|
-
`Protocol '${name}' has ${requirements.length} requirements - consider splitting (ISP)`);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
countLinesInBody(node) {
|
|
483
|
-
const offset = Number(node['key.bodyoffset']);
|
|
484
|
-
const length = Number(node['key.bodylength']);
|
|
485
|
-
if (!Number.isFinite(offset) || !Number.isFinite(length) || offset < 0 || length <= 0) {
|
|
486
|
-
return 0;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
try {
|
|
490
|
-
const buf = Buffer.from(this.fileContent || '', 'utf8');
|
|
491
|
-
const slice = buf.subarray(offset, Math.min(buf.length, offset + length));
|
|
492
|
-
const text = slice.toString('utf8');
|
|
493
|
-
if (!text) return 0;
|
|
494
|
-
return text.split('\n').length;
|
|
495
|
-
} catch {
|
|
496
|
-
return 0;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
analyzeFunctionAST(node, filePath) {
|
|
501
|
-
const name = node['key.name'] || '';
|
|
502
|
-
const line = node['key.line'] || 1;
|
|
503
|
-
const bodyLength = this.countLinesInBody(node) || 0;
|
|
504
|
-
const attributes = this.getAttributes(node);
|
|
505
|
-
const substructure = node['key.substructure'] || [];
|
|
506
|
-
|
|
507
|
-
if (bodyLength > 50) {
|
|
508
|
-
this.pushFinding('ios.quality.long_function', 'high', filePath, line,
|
|
509
|
-
`Function '${name}' is too long (${bodyLength} lines)`);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
const complexity = this.calculateComplexityAST(substructure);
|
|
513
|
-
if (complexity > 10) {
|
|
514
|
-
this.pushFinding('ios.quality.high_complexity', 'high', filePath, line,
|
|
515
|
-
`Function '${name}' has high complexity (${complexity})`);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
const params = substructure.filter(n => (n['key.kind'] || '').includes('var.parameter'));
|
|
519
|
-
if (params.length > 5) {
|
|
520
|
-
this.pushFinding('ios.quality.too_many_params', 'medium', filePath, line,
|
|
521
|
-
`Function '${name}' has ${params.length} parameters - use struct`);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
const closuresInFunc = this.countClosuresInNode(node);
|
|
525
|
-
if (closuresInFunc > 3) {
|
|
526
|
-
this.pushFinding('ios.quality.nested_closures', 'medium', filePath, line,
|
|
527
|
-
`Function '${name}' has ${closuresInFunc} nested closures - use async/await`);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const ifStatements = this.countStatementsOfType(substructure, 'stmt.if');
|
|
531
|
-
const guardStatements = this.countStatementsOfType(substructure, 'stmt.guard');
|
|
532
|
-
|
|
533
|
-
if (ifStatements > 3 && guardStatements === 0) {
|
|
534
|
-
this.pushFinding('ios.quality.pyramid_of_doom', 'medium', filePath, line,
|
|
535
|
-
`Function '${name}' has ${ifStatements} nested ifs - use guard clauses`);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const isAsync = attributes.includes('async');
|
|
539
|
-
const parentClass = node._parent;
|
|
540
|
-
const parentIsView = parentClass &&
|
|
541
|
-
(parentClass['key.inheritedtypes'] || []).some(t => t['key.name'] === 'View');
|
|
542
|
-
|
|
543
|
-
if (isAsync && parentIsView && !attributes.includes('MainActor')) {
|
|
544
|
-
this.pushFinding('ios.concurrency.async_ui_update', 'high', filePath, line,
|
|
545
|
-
`Async function '${name}' in View - consider @MainActor`);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
analyzePropertyAST(node, filePath) {
|
|
550
|
-
const name = node['key.name'] || '';
|
|
551
|
-
const line = node['key.line'] || 1;
|
|
552
|
-
const typename = node['key.typename'] || '';
|
|
553
|
-
const attributes = this.getAttributes(node);
|
|
554
|
-
const kind = node['key.kind'] || '';
|
|
555
|
-
|
|
556
|
-
if (typename.includes('!') && !attributes.includes('IBOutlet')) {
|
|
557
|
-
this.pushFinding('ios.safety.force_unwrap_property', 'high', filePath, line,
|
|
558
|
-
`Force unwrapped property '${name}: ${typename}'`);
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
const isPublic = (node['key.accessibility'] || '').includes('public');
|
|
562
|
-
const isInstance = kind.includes('var.instance');
|
|
563
|
-
|
|
564
|
-
if (isPublic && isInstance && !attributes.includes('setter_access')) {
|
|
565
|
-
this.pushFinding('ios.encapsulation.public_mutable', 'medium', filePath, line,
|
|
566
|
-
`Public mutable property '${name}' - consider private(set)`);
|
|
567
|
-
}
|
|
177
|
+
collectAllNodes(this, substructure, null);
|
|
178
|
+
analyzeCollectedNodes(this, displayPath);
|
|
568
179
|
}
|
|
569
180
|
|
|
570
181
|
safeStringify(obj) {
|
|
@@ -579,166 +190,6 @@ class iOSASTIntelligentAnalyzer {
|
|
|
579
190
|
});
|
|
580
191
|
}
|
|
581
192
|
|
|
582
|
-
analyzeClosuresAST(filePath) {
|
|
583
|
-
for (const closure of this.closures) {
|
|
584
|
-
const closureText = this.safeStringify(closure);
|
|
585
|
-
const hasSelfReference = closureText.includes('"self"') ||
|
|
586
|
-
closureText.includes('key.name":"self');
|
|
587
|
-
|
|
588
|
-
const parentFunc = closure._parent;
|
|
589
|
-
const isEscaping = parentFunc &&
|
|
590
|
-
(parentFunc['key.typename'] || '').includes('@escaping');
|
|
591
|
-
|
|
592
|
-
if (hasSelfReference && isEscaping) {
|
|
593
|
-
const hasWeakCapture = closureText.includes('weak') ||
|
|
594
|
-
closureText.includes('unowned');
|
|
595
|
-
|
|
596
|
-
if (!hasWeakCapture) {
|
|
597
|
-
const line = closure['key.line'] || 1;
|
|
598
|
-
this.pushFinding('ios.memory.missing_weak_self', 'high', filePath, line,
|
|
599
|
-
'Escaping closure captures self without [weak self]');
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
analyzeCleanArchitectureAST(filePath) {
|
|
606
|
-
const fileName = path.basename(filePath);
|
|
607
|
-
|
|
608
|
-
if (fileName.includes('UseCase')) {
|
|
609
|
-
const hasVoidReturn = this.functions.some(f => {
|
|
610
|
-
const typename = f['key.typename'] || '';
|
|
611
|
-
return (f['key.name'] || '').includes('execute') && typename.includes('Void');
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
if (hasVoidReturn) {
|
|
615
|
-
this.pushFinding('ios.architecture.usecase_void', 'medium', filePath, 1,
|
|
616
|
-
'UseCase returns Void - consider returning Result');
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
if (!filePath.includes('/Domain/') && !filePath.includes('/Application/')) {
|
|
620
|
-
this.pushFinding('ios.architecture.usecase_wrong_layer', 'high', filePath, 1,
|
|
621
|
-
'UseCase should be in Domain or Application layer');
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (filePath.includes('/Infrastructure/')) {
|
|
626
|
-
const hasUIImport = this.imports.some(i => ['SwiftUI', 'UIKit'].includes(i.name));
|
|
627
|
-
if (hasUIImport) {
|
|
628
|
-
this.pushFinding('ios.architecture.infrastructure_ui', 'critical', filePath, 1,
|
|
629
|
-
'Infrastructure layer imports UI framework - violates Clean Architecture');
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
if (filePath.includes('/Presentation/') || filePath.includes('/Views/')) {
|
|
634
|
-
const hasInfraImport = this.imports.some(i =>
|
|
635
|
-
i.name.includes('Alamofire') || i.name.includes('Realm') || i.name.includes('CoreData')
|
|
636
|
-
);
|
|
637
|
-
if (hasInfraImport) {
|
|
638
|
-
this.pushFinding('ios.architecture.presentation_infra', 'high', filePath, 1,
|
|
639
|
-
'Presentation imports Infrastructure directly - use Domain layer');
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
analyzeAdditionalRules(filePath) {
|
|
645
|
-
const hasSwiftUIViewType = (this.imports || []).some(i => i && i.name === 'SwiftUI') &&
|
|
646
|
-
(this.structs || []).some(s => (s['key.inheritedtypes'] || []).some(t => t && t['key.name'] === 'View'));
|
|
647
|
-
|
|
648
|
-
if (this.fileContent.includes('pushViewController') || this.fileContent.includes('popViewController') || this.fileContent.includes('present(')) {
|
|
649
|
-
const line = this.findLineNumber('pushViewController') || this.findLineNumber('popViewController') || this.findLineNumber('present(');
|
|
650
|
-
this.pushFinding('ios.navigation.imperative_navigation', 'critical', filePath, line,
|
|
651
|
-
'Imperative navigation detected - use event-driven navigation/coordinator');
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const swiftuiNavTokens = ['NavigationLink', 'NavigationStack', 'NavigationSplitView', '.navigationDestination'];
|
|
655
|
-
const hasSwiftUINavigation = swiftuiNavTokens.some(token => this.fileContent.includes(token));
|
|
656
|
-
if (hasSwiftUINavigation && !hasSwiftUIViewType) {
|
|
657
|
-
const line = this.findLineNumber('NavigationLink') || this.findLineNumber('NavigationStack') || this.findLineNumber('.navigationDestination');
|
|
658
|
-
this.pushFinding('ios.navigation.swiftui_navigation_outside_view', 'critical', filePath, line,
|
|
659
|
-
'SwiftUI navigation API detected outside View types');
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
if (filePath.includes('ViewModel') && this.fileContent.includes('NavigationLink')) {
|
|
663
|
-
const hasCoordinator = this.imports.some(i => i.name.includes('Coordinator'));
|
|
664
|
-
if (!hasCoordinator) {
|
|
665
|
-
this.pushFinding('ios.architecture.missing_coordinator', 'medium', filePath, 1,
|
|
666
|
-
'Navigation in ViewModel - consider Coordinator pattern');
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
if (this.fileContent.includes('DispatchQueue.main') || this.fileContent.includes('DispatchQueue.global')) {
|
|
671
|
-
const line = this.findLineNumber('DispatchQueue');
|
|
672
|
-
this.pushFinding('ios.concurrency.dispatch_queue', 'medium', filePath, line,
|
|
673
|
-
'DispatchQueue detected - use async/await in new code');
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
if (/Task\s*\{/.test(this.fileContent) && !/Task\s*\{[^}]*do\s*\{/.test(this.fileContent)) {
|
|
677
|
-
const line = this.findLineNumber('Task {');
|
|
678
|
-
this.pushFinding('ios.concurrency.task_no_error_handling', 'high', filePath, line,
|
|
679
|
-
'Task without do-catch - handle errors');
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
if (this.fileContent.includes('UserDefaults') && /password|token|secret|key/i.test(this.fileContent)) {
|
|
683
|
-
const line = this.findLineNumber('UserDefaults');
|
|
684
|
-
this.pushFinding('ios.security.sensitive_userdefaults', 'critical', filePath, line,
|
|
685
|
-
'Sensitive data in UserDefaults - use Keychain');
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
const hardcodedStrings = this.fileContent.match(/Text\s*\(\s*"[^"]{10,}"\s*\)/g) || [];
|
|
689
|
-
if (hardcodedStrings.length > 3) {
|
|
690
|
-
this.pushFinding('ios.i18n.hardcoded_strings', 'medium', filePath, 1,
|
|
691
|
-
`${hardcodedStrings.length} hardcoded strings - use NSLocalizedString`);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
if (this.fileContent.includes('Image(') && !this.fileContent.includes('.accessibilityLabel')) {
|
|
695
|
-
const imageCount = (this.fileContent.match(/Image\s*\(/g) || []).length;
|
|
696
|
-
const labelCount = (this.fileContent.match(/\.accessibilityLabel/g) || []).length;
|
|
697
|
-
if (imageCount > labelCount + 2) {
|
|
698
|
-
this.pushFinding('ios.accessibility.missing_labels', 'medium', filePath, 1,
|
|
699
|
-
'Images without accessibilityLabel - add for VoiceOver');
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
if (this.fileContent.includes('@IBOutlet') || this.fileContent.includes('@IBAction')) {
|
|
704
|
-
const line = this.findLineNumber('@IB');
|
|
705
|
-
this.pushFinding('ios.deprecated.storyboard', 'low', filePath, line,
|
|
706
|
-
'Storyboard/XIB detected - consider SwiftUI or programmatic UI');
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
const completionCount = (this.fileContent.match(/completion\s*:\s*@escaping/g) || []).length;
|
|
710
|
-
if (completionCount > 2) {
|
|
711
|
-
this.pushFinding('ios.concurrency.completion_handlers', 'medium', filePath, 1,
|
|
712
|
-
`${completionCount} completion handlers - use async/await`);
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
for (const cls of this.classes) {
|
|
716
|
-
const hasSharedState = this.properties.some(p =>
|
|
717
|
-
(p['key.kind'] || '').includes('static') &&
|
|
718
|
-
!this.hasAttribute(p, 'MainActor') &&
|
|
719
|
-
!this.hasAttribute(p, 'nonisolated')
|
|
720
|
-
);
|
|
721
|
-
if (hasSharedState && !this.fileContent.includes('actor ')) {
|
|
722
|
-
const name = cls['key.name'] || '';
|
|
723
|
-
const line = cls['key.line'] || 1;
|
|
724
|
-
this.pushFinding('ios.concurrency.missing_actor', 'high', filePath, line,
|
|
725
|
-
`Class '${name}' has shared state - consider actor for thread safety`);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
for (const cls of this.classes) {
|
|
730
|
-
const name = cls['key.name'] || '';
|
|
731
|
-
const inheritedTypes = (cls['key.inheritedtypes'] || []).map(t => t['key.name']);
|
|
732
|
-
const hasAsync = this.functions.some(f => this.hasAttribute(f, 'async'));
|
|
733
|
-
|
|
734
|
-
if (hasAsync && !inheritedTypes.includes('Sendable') && !name.includes('ViewModel')) {
|
|
735
|
-
const line = cls['key.line'] || 1;
|
|
736
|
-
this.pushFinding('ios.concurrency.missing_sendable', 'medium', filePath, line,
|
|
737
|
-
`Class '${name}' used in async context - consider Sendable conformance`);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
|
|
742
193
|
findLineNumber(text) {
|
|
743
194
|
const idx = this.fileContent.indexOf(text);
|
|
744
195
|
if (idx === -1) return 1;
|
|
@@ -758,107 +209,6 @@ class iOSASTIntelligentAnalyzer {
|
|
|
758
209
|
});
|
|
759
210
|
}
|
|
760
211
|
|
|
761
|
-
findUnusedPropertiesAST(properties, methods) {
|
|
762
|
-
const unused = [];
|
|
763
|
-
|
|
764
|
-
for (const prop of properties) {
|
|
765
|
-
const propName = prop['key.name'];
|
|
766
|
-
if (!propName) continue;
|
|
767
|
-
|
|
768
|
-
if (this.hasAttribute(prop, 'Published') ||
|
|
769
|
-
this.hasAttribute(prop, 'State') ||
|
|
770
|
-
this.hasAttribute(prop, 'Binding')) {
|
|
771
|
-
continue;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
let usageCount = 0;
|
|
775
|
-
for (const method of methods) {
|
|
776
|
-
const methodText = this.safeStringify(method);
|
|
777
|
-
if (methodText.includes(`"${propName}"`)) {
|
|
778
|
-
usageCount++;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
if (usageCount === 0) {
|
|
783
|
-
unused.push(propName);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
return unused;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
calculateComplexityAST(substructure) {
|
|
791
|
-
let complexity = 1;
|
|
792
|
-
|
|
793
|
-
const traverse = (nodes) => {
|
|
794
|
-
if (!Array.isArray(nodes)) return;
|
|
795
|
-
|
|
796
|
-
for (const node of nodes) {
|
|
797
|
-
const kind = node['key.kind'] || '';
|
|
798
|
-
|
|
799
|
-
if (kind.includes('stmt.if') || kind.includes('stmt.guard') ||
|
|
800
|
-
kind.includes('stmt.switch') || kind.includes('stmt.for') ||
|
|
801
|
-
kind.includes('stmt.while') || kind.includes('stmt.catch')) {
|
|
802
|
-
complexity++;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
traverse(node['key.substructure'] || []);
|
|
806
|
-
}
|
|
807
|
-
};
|
|
808
|
-
|
|
809
|
-
traverse(substructure);
|
|
810
|
-
return complexity;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
countClosuresInNode(node) {
|
|
814
|
-
let count = 0;
|
|
815
|
-
const traverse = (n) => {
|
|
816
|
-
if ((n['key.kind'] || '').includes('closure')) count++;
|
|
817
|
-
for (const child of (n['key.substructure'] || [])) {
|
|
818
|
-
traverse(child);
|
|
819
|
-
}
|
|
820
|
-
};
|
|
821
|
-
traverse(node);
|
|
822
|
-
return count;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
countStatementsOfType(substructure, stmtType) {
|
|
826
|
-
let count = 0;
|
|
827
|
-
const traverse = (nodes) => {
|
|
828
|
-
if (!Array.isArray(nodes)) return;
|
|
829
|
-
for (const node of nodes) {
|
|
830
|
-
if ((node['key.kind'] || '').includes(stmtType)) count++;
|
|
831
|
-
traverse(node['key.substructure'] || []);
|
|
832
|
-
}
|
|
833
|
-
};
|
|
834
|
-
traverse(substructure);
|
|
835
|
-
return count;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
checkDependencyInjectionAST(properties, filePath, className, line) {
|
|
839
|
-
if (!className.includes('ViewModel') && !className.includes('Service') &&
|
|
840
|
-
!className.includes('Repository') && !className.includes('UseCase')) {
|
|
841
|
-
return;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
for (const prop of properties) {
|
|
845
|
-
const typename = prop['key.typename'] || '';
|
|
846
|
-
|
|
847
|
-
if (['String', 'Int', 'Bool', 'Double', 'Float', 'Date', 'URL', 'Data'].includes(typename)) {
|
|
848
|
-
continue;
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
const isConcreteService = /Service$|Repository$|UseCase$|Client$/.test(typename) &&
|
|
852
|
-
!typename.includes('Protocol') &&
|
|
853
|
-
!typename.includes('any ') &&
|
|
854
|
-
!typename.includes('some ');
|
|
855
|
-
|
|
856
|
-
if (isConcreteService) {
|
|
857
|
-
this.pushFinding('ios.solid.dip.concrete_dependency', 'high', filePath, line,
|
|
858
|
-
`'${className}' depends on concrete '${typename}' - use protocol`);
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
212
|
|
|
863
213
|
pushFinding(ruleId, severity, filePath, line, message) {
|
|
864
214
|
this.findings.push({
|
|
@@ -872,110 +222,7 @@ class iOSASTIntelligentAnalyzer {
|
|
|
872
222
|
}
|
|
873
223
|
|
|
874
224
|
finalizeGodClassDetection() {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const quantile = (values, p) => {
|
|
878
|
-
if (!values || values.length === 0) return 0;
|
|
879
|
-
const sorted = [...values].filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
|
|
880
|
-
if (sorted.length === 0) return 0;
|
|
881
|
-
const idx = Math.max(0, Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1));
|
|
882
|
-
return sorted[idx];
|
|
883
|
-
};
|
|
884
|
-
|
|
885
|
-
const median = (values) => {
|
|
886
|
-
if (!values || values.length === 0) return 0;
|
|
887
|
-
const sorted = [...values].filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
|
|
888
|
-
if (sorted.length === 0) return 0;
|
|
889
|
-
const mid = Math.floor(sorted.length / 2);
|
|
890
|
-
if (sorted.length % 2 === 0) return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
891
|
-
return sorted[mid];
|
|
892
|
-
};
|
|
893
|
-
|
|
894
|
-
const mad = (values) => {
|
|
895
|
-
const med = median(values);
|
|
896
|
-
const deviations = (values || []).map((v) => Math.abs(v - med));
|
|
897
|
-
return median(deviations);
|
|
898
|
-
};
|
|
899
|
-
|
|
900
|
-
const robustZ = (x, med, madValue) => {
|
|
901
|
-
if (!Number.isFinite(x) || !Number.isFinite(med) || !Number.isFinite(madValue) || madValue === 0) return 0;
|
|
902
|
-
return 0.6745 * (x - med) / madValue;
|
|
903
|
-
};
|
|
904
|
-
|
|
905
|
-
const env = require('../../../../config/env');
|
|
906
|
-
const pOutlier = env.getNumber('AST_GODCLASS_P_OUTLIER', 90);
|
|
907
|
-
const pExtreme = env.getNumber('AST_GODCLASS_P_EXTREME', 97);
|
|
908
|
-
|
|
909
|
-
const methods = this.godClassCandidates.map(c => c.methodsCount);
|
|
910
|
-
const props = this.godClassCandidates.map(c => c.propertiesCount);
|
|
911
|
-
const bodies = this.godClassCandidates.map(c => c.bodyLength);
|
|
912
|
-
const complexities = this.godClassCandidates.map(c => c.complexity);
|
|
913
|
-
|
|
914
|
-
const med = {
|
|
915
|
-
methodsCount: median(methods),
|
|
916
|
-
propertiesCount: median(props),
|
|
917
|
-
bodyLength: median(bodies),
|
|
918
|
-
complexity: median(complexities),
|
|
919
|
-
};
|
|
920
|
-
const madValue = {
|
|
921
|
-
methodsCount: mad(methods),
|
|
922
|
-
propertiesCount: mad(props),
|
|
923
|
-
bodyLength: mad(bodies),
|
|
924
|
-
complexity: mad(complexities),
|
|
925
|
-
};
|
|
926
|
-
|
|
927
|
-
const z = {
|
|
928
|
-
methodsCount: methods.map(v => robustZ(v, med.methodsCount, madValue.methodsCount)),
|
|
929
|
-
propertiesCount: props.map(v => robustZ(v, med.propertiesCount, madValue.propertiesCount)),
|
|
930
|
-
bodyLength: bodies.map(v => robustZ(v, med.bodyLength, madValue.bodyLength)),
|
|
931
|
-
complexity: complexities.map(v => robustZ(v, med.complexity, madValue.complexity)),
|
|
932
|
-
};
|
|
933
|
-
|
|
934
|
-
const thresholds = {
|
|
935
|
-
outlier: {
|
|
936
|
-
methodsCountZ: quantile(z.methodsCount, pOutlier),
|
|
937
|
-
propertiesCountZ: quantile(z.propertiesCount, pOutlier),
|
|
938
|
-
bodyLengthZ: quantile(z.bodyLength, pOutlier),
|
|
939
|
-
complexityZ: quantile(z.complexity, pOutlier),
|
|
940
|
-
},
|
|
941
|
-
extreme: {
|
|
942
|
-
methodsCountZ: quantile(z.methodsCount, pExtreme),
|
|
943
|
-
propertiesCountZ: quantile(z.propertiesCount, pExtreme),
|
|
944
|
-
bodyLengthZ: quantile(z.bodyLength, pExtreme),
|
|
945
|
-
complexityZ: quantile(z.complexity, pExtreme),
|
|
946
|
-
}
|
|
947
|
-
};
|
|
948
|
-
|
|
949
|
-
for (const c of this.godClassCandidates) {
|
|
950
|
-
const methodsZ = robustZ(c.methodsCount, med.methodsCount, madValue.methodsCount);
|
|
951
|
-
const propsZ = robustZ(c.propertiesCount, med.propertiesCount, madValue.propertiesCount);
|
|
952
|
-
const bodyZ = robustZ(c.bodyLength, med.bodyLength, madValue.bodyLength);
|
|
953
|
-
const complexityZ = robustZ(c.complexity, med.complexity, madValue.complexity);
|
|
954
|
-
|
|
955
|
-
const sizeOutlier =
|
|
956
|
-
(methodsZ > 0 && methodsZ >= thresholds.outlier.methodsCountZ) ||
|
|
957
|
-
(propsZ > 0 && propsZ >= thresholds.outlier.propertiesCountZ) ||
|
|
958
|
-
(bodyZ > 0 && bodyZ >= thresholds.outlier.bodyLengthZ);
|
|
959
|
-
const complexityOutlier = complexityZ > 0 && complexityZ >= thresholds.outlier.complexityZ;
|
|
960
|
-
|
|
961
|
-
const extremeOutlier =
|
|
962
|
-
(methodsZ > 0 && methodsZ >= thresholds.extreme.methodsCountZ) ||
|
|
963
|
-
(propsZ > 0 && propsZ >= thresholds.extreme.propertiesCountZ) ||
|
|
964
|
-
(bodyZ > 0 && bodyZ >= thresholds.extreme.bodyLengthZ) ||
|
|
965
|
-
(complexityZ > 0 && complexityZ >= thresholds.extreme.complexityZ);
|
|
966
|
-
|
|
967
|
-
const signalCount = [sizeOutlier, complexityOutlier].filter(Boolean).length;
|
|
968
|
-
|
|
969
|
-
if (extremeOutlier || signalCount >= 2) {
|
|
970
|
-
this.pushFinding(
|
|
971
|
-
'ios.solid.srp.god_class',
|
|
972
|
-
'critical',
|
|
973
|
-
c.filePath,
|
|
974
|
-
c.line,
|
|
975
|
-
`God class '${c.name}': ${c.methodsCount} methods (z=${methodsZ.toFixed(2)}), ${c.propertiesCount} properties (z=${propsZ.toFixed(2)}), body ${c.bodyLength} (z=${bodyZ.toFixed(2)}), complexity ${c.complexity} (z=${complexityZ.toFixed(2)}) - VIOLATES SRP`
|
|
976
|
-
);
|
|
977
|
-
}
|
|
978
|
-
}
|
|
225
|
+
finalizeGodClassDetection(this);
|
|
979
226
|
}
|
|
980
227
|
}
|
|
981
228
|
|