pumuki-ast-hooks 5.3.30 → 5.4.1

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,153 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { recordMetric } = require('../../../infrastructure/telemetry/metrics-logger');
4
+ const env = require('../../../config/env.js');
5
+
6
+ class EvidenceManager {
7
+ constructor(evidencePath, notifier, auditLogger) {
8
+ this.evidencePath = evidencePath;
9
+ this.notifier = notifier;
10
+ this.auditLogger = auditLogger;
11
+ this.staleThresholdMs = env.getNumber('HOOK_GUARD_EVIDENCE_STALE_THRESHOLD', 60000);
12
+ this.reminderIntervalMs = env.getNumber('HOOK_GUARD_EVIDENCE_REMINDER_INTERVAL', 60000);
13
+ this.inactivityGraceMs = env.getNumber('HOOK_GUARD_INACTIVITY_GRACE_MS', 120000);
14
+ this.pollIntervalMs = env.getNumber('HOOK_GUARD_EVIDENCE_POLL_INTERVAL', 30000);
15
+ this.pollTimer = null;
16
+ this.lastStaleNotification = 0;
17
+ this.lastUserActivityAt = 0;
18
+ this.autoRefreshCooldownMs = env.getNumber('HOOK_GUARD_EVIDENCE_AUTO_REFRESH_COOLDOWN', 180000);
19
+ this.lastAutoRefresh = 0;
20
+ this.autoRefreshInFlight = false;
21
+ }
22
+
23
+ startPolling(onStale, onRefresh) {
24
+ this.onStale = onStale;
25
+ this.onRefresh = onRefresh;
26
+
27
+ if (this.pollTimer) {
28
+ clearInterval(this.pollTimer);
29
+ }
30
+
31
+ if (this.pollIntervalMs <= 0) return;
32
+
33
+ this.pollTimer = setInterval(() => {
34
+ this.evaluateEvidenceAge('polling');
35
+ }, this.pollIntervalMs);
36
+ }
37
+
38
+ stopPolling() {
39
+ if (this.pollTimer) {
40
+ clearInterval(this.pollTimer);
41
+ this.pollTimer = null;
42
+ }
43
+ }
44
+
45
+ readEvidenceTimestamp() {
46
+ try {
47
+ if (!fs.existsSync(this.evidencePath)) {
48
+ return null;
49
+ }
50
+ const raw = fs.readFileSync(this.evidencePath, 'utf8');
51
+ const json = JSON.parse(raw);
52
+ const ts = json?.timestamp;
53
+ if (!ts) return null;
54
+ const ms = new Date(ts).getTime();
55
+ if (Number.isNaN(ms)) return null;
56
+ return ms;
57
+ } catch (error) {
58
+ const msg = error && error.message ? error.message : String(error);
59
+ this.notifier.appendDebugLog(`EVIDENCE_TIMESTAMP_ERROR|${msg}`);
60
+ return null;
61
+ }
62
+ }
63
+
64
+ evaluateEvidenceAge(source = 'manual', notifyFresh = false) {
65
+ const now = Date.now();
66
+ const timestamp = this.readEvidenceTimestamp();
67
+ if (!timestamp) return;
68
+
69
+ const ageMs = now - timestamp;
70
+ const isStale = ageMs > this.staleThresholdMs;
71
+ const isRecentlyActive = this.lastUserActivityAt && (now - this.lastUserActivityAt) < this.inactivityGraceMs;
72
+
73
+ if (isStale && !isRecentlyActive) {
74
+ this.triggerStaleAlert(source, ageMs);
75
+ return;
76
+ }
77
+
78
+ if (notifyFresh && this.lastStaleNotification > 0 && !isStale) {
79
+ this.notifier.notify('Evidence updated; back within SLA.', 'info');
80
+ this.lastStaleNotification = 0;
81
+ }
82
+ }
83
+
84
+ triggerStaleAlert(source, ageMs) {
85
+ const now = Date.now();
86
+ if (this.lastStaleNotification && (now - this.lastStaleNotification) < this.reminderIntervalMs) {
87
+ return;
88
+ }
89
+
90
+ this.lastStaleNotification = now;
91
+ const ageSec = Math.floor(ageMs / 1000);
92
+ this.notifier.notify(`Evidence has been stale for ${ageSec}s (source: ${source}).`, 'warn', { forceDialog: true });
93
+ this.auditLogger.record({
94
+ action: 'guard.evidence.stale',
95
+ resource: 'evidence',
96
+ status: 'warning',
97
+ meta: { ageSec, source }
98
+ });
99
+ recordMetric({ hook: 'evidence', status: 'stale', ageSec, source });
100
+ this.attemptAutoRefresh('stale');
101
+
102
+ if (this.onStale) this.onStale();
103
+ }
104
+
105
+ async attemptAutoRefresh(reason = 'manual') {
106
+ if (!env.getBool('HOOK_GUARD_AUTO_REFRESH', false)) return;
107
+
108
+ const updateScriptCandidates = [
109
+ path.join(process.cwd(), 'scripts/hooks-system/bin/update-evidence.sh'),
110
+ path.join(process.cwd(), 'node_modules/@pumuki/ast-intelligence-hooks/bin/update-evidence.sh'),
111
+ path.join(process.cwd(), 'bin/update-evidence.sh')
112
+ ];
113
+
114
+ const updateScript = updateScriptCandidates.find(p => fs.existsSync(p));
115
+ if (!updateScript) return;
116
+
117
+ const now = Date.now();
118
+ const ts = this.readEvidenceTimestamp();
119
+ if (ts && (now - ts) <= this.staleThresholdMs) return;
120
+
121
+ if (this.lastAutoRefresh && (now - this.lastAutoRefresh) < this.autoRefreshCooldownMs) return;
122
+
123
+ if (this.autoRefreshInFlight) return;
124
+
125
+ this.autoRefreshInFlight = true;
126
+ try {
127
+ await this.runDirectEvidenceRefresh(reason);
128
+ this.lastAutoRefresh = now;
129
+ this.auditLogger.record({
130
+ action: 'guard.evidence.auto_refresh',
131
+ resource: 'evidence',
132
+ status: 'success',
133
+ meta: { reason }
134
+ });
135
+ recordMetric({ hook: 'evidence', status: 'auto_refresh_success', reason });
136
+
137
+ if (this.onRefresh) this.onRefresh();
138
+ } finally {
139
+ this.autoRefreshInFlight = false;
140
+ }
141
+ }
142
+
143
+ async runDirectEvidenceRefresh(_reason) {
144
+ // Specific implementation if needed
145
+ return;
146
+ }
147
+
148
+ updateUserActivity() {
149
+ this.lastUserActivityAt = Date.now();
150
+ }
151
+ }
152
+
153
+ module.exports = EvidenceManager;
@@ -0,0 +1,129 @@
1
+ const { getGitTreeState, isTreeBeyondLimit } = require('../GitTreeState');
2
+ const { recordMetric } = require('../../../infrastructure/telemetry/metrics-logger');
3
+ const env = require('../../../config/env.js');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ class GitTreeManager {
8
+ constructor(notifier, auditLogger) {
9
+ this.notifier = notifier;
10
+ this.auditLogger = auditLogger;
11
+ this.gitTreeStagedThreshold = env.getNumber('HOOK_GUARD_DIRTY_TREE_STAGED_LIMIT', 10);
12
+ this.gitTreeUnstagedThreshold = env.getNumber('HOOK_GUARD_DIRTY_TREE_UNSTAGED_LIMIT', 15);
13
+ this.gitTreeTotalThreshold = env.getNumber('HOOK_GUARD_DIRTY_TREE_TOTAL_LIMIT', 20);
14
+ this.gitTreeCheckIntervalMs = env.getNumber('HOOK_GUARD_DIRTY_TREE_INTERVAL', 60000);
15
+ this.gitTreeReminderMs = env.getNumber('HOOK_GUARD_DIRTY_TREE_REMINDER', 300000);
16
+ this.gitTreeTimer = null;
17
+ this.lastDirtyTreeNotification = 0;
18
+ this.dirtyTreeActive = false;
19
+ }
20
+
21
+ startMonitoring(onStateChange) {
22
+ this.onStateChange = onStateChange;
23
+
24
+ if (env.getBool('HOOK_GUARD_DIRTY_TREE_DISABLED', false)) return;
25
+
26
+ if (this.gitTreeTimer) {
27
+ clearInterval(this.gitTreeTimer);
28
+ }
29
+
30
+ const thresholdsValid = this.gitTreeStagedThreshold > 0 || this.gitTreeUnstagedThreshold > 0 || this.gitTreeTotalThreshold > 0;
31
+ if (!thresholdsValid || this.gitTreeCheckIntervalMs <= 0) return;
32
+
33
+ this.evaluateGitTree();
34
+ this.gitTreeTimer = setInterval(() => {
35
+ this.evaluateGitTree();
36
+ }, this.gitTreeCheckIntervalMs);
37
+ }
38
+
39
+ stopMonitoring() {
40
+ if (this.gitTreeTimer) {
41
+ clearInterval(this.gitTreeTimer);
42
+ this.gitTreeTimer = null;
43
+ }
44
+ }
45
+
46
+ async evaluateGitTree() {
47
+ try {
48
+ const state = getGitTreeState();
49
+ const limits = {
50
+ stagedLimit: this.gitTreeStagedThreshold,
51
+ unstagedLimit: this.gitTreeUnstagedThreshold,
52
+ totalLimit: this.gitTreeTotalThreshold
53
+ };
54
+
55
+ if (isTreeBeyondLimit(state, limits)) {
56
+ this.handleDirtyTree(state, limits);
57
+ return;
58
+ }
59
+ this.resolveDirtyTree(state, limits);
60
+ } catch (error) {
61
+ this.notifier.appendDebugLog(`DIRTY_TREE_ERROR|${error.message}`);
62
+ }
63
+ }
64
+
65
+ resolveDirtyTree(_state, _limits) {
66
+ this.dirtyTreeActive = false;
67
+ this.notifier.notify('✅ Git tree is clean', 'success');
68
+ this.auditLogger.record({
69
+ action: 'guard.git_tree.clean',
70
+ resource: 'git_tree',
71
+ status: 'success'
72
+ });
73
+ recordMetric({ hook: 'git_tree', status: 'clean' });
74
+
75
+ if (this.onStateChange) {
76
+ this.onStateChange({ isBeyondLimit: false, ..._state });
77
+ }
78
+ }
79
+
80
+ handleDirtyTree(_state, limitOrLimits) {
81
+ const now = Date.now();
82
+ const limits = typeof limitOrLimits === 'number'
83
+ ? { totalLimit: limitOrLimits }
84
+ : (limitOrLimits || {});
85
+
86
+ if (this.lastDirtyTreeNotification && (now - this.lastDirtyTreeNotification) < this.gitTreeReminderMs) {
87
+ return;
88
+ }
89
+
90
+ this.lastDirtyTreeNotification = now;
91
+ this.dirtyTreeActive = true;
92
+ const message = `Git tree has too many files: ${_state.total} total (${_state.staged} staged, ${_state.unstaged} unstaged)`;
93
+ this.notifier.notify(message, 'error', { forceDialog: true, ...limits });
94
+ this.auditLogger.record({
95
+ action: 'guard.git_tree.dirty',
96
+ resource: 'git_tree',
97
+ status: 'warning',
98
+ meta: { total: _state.total, staged: _state.staged, unstaged: _state.unstaged }
99
+ });
100
+ recordMetric({ hook: 'git_tree', status: 'dirty', total: _state.total, staged: _state.staged, unstaged: _state.unstaged });
101
+ this.persistDirtyTreeState();
102
+
103
+ if (this.onStateChange) {
104
+ this.onStateChange({ isBeyondLimit: true, ..._state });
105
+ }
106
+ }
107
+
108
+ persistDirtyTreeState() {
109
+ const persistPath = path.join(process.cwd(), '.audit_tmp', 'dirty-tree-state.json');
110
+ const state = {
111
+ timestamp: new Date().toISOString(),
112
+ lastNotification: this.lastDirtyTreeNotification,
113
+ isActive: this.dirtyTreeActive,
114
+ thresholds: {
115
+ staged: this.gitTreeStagedThreshold,
116
+ unstaged: this.gitTreeUnstagedThreshold,
117
+ total: this.gitTreeTotalThreshold
118
+ }
119
+ };
120
+
121
+ try {
122
+ fs.writeFileSync(persistPath, JSON.stringify(state, null, 2));
123
+ } catch (error) {
124
+ this.notifier.appendDebugLog(`PERSIST_DIRTY_TREE_ERROR|${error.message}`);
125
+ }
126
+ }
127
+ }
128
+
129
+ module.exports = GitTreeManager;
@@ -0,0 +1,54 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ class GuardNotifier {
5
+ constructor(logger, notificationService, notifier = null, notificationsEnabled = true) {
6
+ this.logger = logger;
7
+ this.notificationService = notificationService;
8
+ this.notifier = typeof notifier === 'function' ? notifier : null;
9
+ this.notificationsEnabled = notificationsEnabled;
10
+ }
11
+
12
+ setDebugLogPath(debugLogPath) {
13
+ this.debugLogPath = debugLogPath;
14
+ }
15
+
16
+ notify(message, level = 'info', options = {}) {
17
+ const { forceDialog = false, ...metadata } = options;
18
+ this._appendDebugLog(`NOTIFY|${level}|${forceDialog ? 'force-dialog|' : ''}${message}`);
19
+
20
+ if (this.notifier && this.notificationsEnabled) {
21
+ try {
22
+ this.notifier(message, level, { ...metadata, forceDialog });
23
+ } catch (error) {
24
+ const msg = error && error.message ? error.message : String(error);
25
+ this._appendDebugLog(`NOTIFIER_ERROR|${msg}`);
26
+ this.logger?.debug?.('REALTIME_GUARD_NOTIFIER_ERROR', { error: msg });
27
+ }
28
+ }
29
+
30
+ if (this.notificationService) {
31
+ this.notificationService.enqueue({
32
+ message,
33
+ level,
34
+ metadata: { ...metadata, forceDialog }
35
+ });
36
+ }
37
+ }
38
+
39
+ _appendDebugLog(entry) {
40
+ if (!this.debugLogPath) return;
41
+ try {
42
+ const timestamp = new Date().toISOString();
43
+ fs.appendFileSync(this.debugLogPath, `[${timestamp}] ${entry}\n`);
44
+ } catch (error) {
45
+ console.error('[GuardNotifier] Failed to write debug log:', error.message);
46
+ }
47
+ }
48
+
49
+ appendDebugLog(entry) {
50
+ this._appendDebugLog(entry);
51
+ }
52
+ }
53
+
54
+ module.exports = GuardNotifier;
@@ -61,8 +61,9 @@ class McpConfigurator {
61
61
  for (const c of candidates) {
62
62
  try {
63
63
  if (fs.existsSync(c)) return c;
64
- } catch {
65
- // ignore
64
+ } catch (error) {
65
+ // Log error but continue checking other candidates
66
+ console.warn(`Failed to check path ${c}:`, error.message);
66
67
  }
67
68
  }
68
69
 
@@ -28,6 +28,9 @@ async function runASTIntelligence() {
28
28
  const { getRepoRoot } = require('./ast-core');
29
29
  const root = getRepoRoot();
30
30
 
31
+ const stagingOnlyMode = env.get('STAGING_ONLY_MODE', '0') === '1';
32
+ const stagedRelFiles = stagingOnlyMode ? getStagedFilesRel(root) : [];
33
+
31
34
  const allFiles = listSourceFiles(root);
32
35
 
33
36
  const project = createProject(allFiles);
@@ -58,7 +61,7 @@ async function runASTIntelligence() {
58
61
  await runPlatformAnalysis(project, findings, context);
59
62
 
60
63
  // Generate output
61
- generateOutput(findings, context, project, root);
64
+ generateOutput(findings, { ...context, stagingOnlyMode, stagedFiles: stagedRelFiles }, project, root);
62
65
 
63
66
  } catch (error) {
64
67
  console.error("AST Intelligence Error:", error.message);
@@ -69,6 +72,23 @@ async function runASTIntelligence() {
69
72
  }
70
73
  }
71
74
 
75
+ function getStagedFilesRel(root) {
76
+ try {
77
+ const { execSync } = require('child_process');
78
+ return execSync('git diff --cached --name-only --diff-filter=ACM', {
79
+ encoding: 'utf8',
80
+ cwd: root,
81
+ stdio: ['ignore', 'pipe', 'pipe']
82
+ })
83
+ .trim()
84
+ .split('\n')
85
+ .map(s => s.trim())
86
+ .filter(Boolean);
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
72
92
  function runProjectHardcodedThresholdAudit(root, allFiles, findings) {
73
93
  if (env.get('AST_INSIGHTS', '0') !== '1') return;
74
94
 
@@ -368,36 +388,18 @@ function generateOutput(findings, context, project, root) {
368
388
 
369
389
  // Top violations
370
390
  const grouped = {};
371
- const levelRank = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
372
- const emojiForLevel = (level) => (level === 'CRITICAL' || level === 'HIGH' ? '🔴' : '🔵');
373
-
374
391
  findings.forEach(f => {
375
- if (!f || !f.ruleId) return;
376
- const level = mapToLevel(f.severity);
377
- if (!grouped[f.ruleId]) {
378
- grouped[f.ruleId] = { count: 0, worstLevel: level };
379
- }
380
- grouped[f.ruleId].count += 1;
381
- if ((levelRank[level] || 0) > (levelRank[grouped[f.ruleId].worstLevel] || 0)) {
382
- grouped[f.ruleId].worstLevel = level;
383
- }
384
- });
385
-
386
- const entries = Object.entries(grouped)
387
- .map(([ruleId, data]) => ({ ruleId, count: data.count, worstLevel: data.worstLevel }))
388
- .sort((a, b) => b.count - a.count);
389
-
390
- const blockers = entries.filter(e => e.worstLevel === 'CRITICAL' || e.worstLevel === 'HIGH');
391
- const nonBlockers = entries.filter(e => e.worstLevel !== 'CRITICAL' && e.worstLevel !== 'HIGH');
392
-
393
- blockers.forEach(({ ruleId, count, worstLevel }) => {
394
- console.error(`${emojiForLevel(worstLevel)} ${ruleId} - ${count} violations`);
392
+ grouped[f.ruleId] = (grouped[f.ruleId] || 0) + 1;
395
393
  });
396
394
 
397
- nonBlockers
398
- .slice(0, Math.max(0, 20 - blockers.length))
399
- .forEach(({ ruleId, count, worstLevel }) => {
400
- console.error(`${emojiForLevel(worstLevel)} ${ruleId} - ${count} violations`);
395
+ Object.entries(grouped)
396
+ .sort((a, b) => b[1] - a[1])
397
+ .slice(0, 20)
398
+ .forEach(([ruleId, count]) => {
399
+ const severity = ruleId.includes("types.any") || ruleId.includes("security.") || ruleId.includes("architecture.") ? "error" :
400
+ ruleId.includes("performance.") || ruleId.includes("debug.") ? "warning" : "info";
401
+ const emoji = severity === "error" ? "🔴" : severity === "warning" ? "🟡" : "🔵";
402
+ console.error(`${emoji} ${ruleId} - ${count} violations`);
401
403
  });
402
404
 
403
405
  // Summary
@@ -474,6 +476,8 @@ function saveDetailedReport(findings, levelTotals, platformTotals, project, root
474
476
  totalFiles: project.getSourceFiles().length,
475
477
  timestamp: new Date().toISOString(),
476
478
  root,
479
+ stagingOnlyMode: !!(context && context.stagingOnlyMode),
480
+ stagedFiles: Array.isArray(context && context.stagedFiles) ? context.stagedFiles : [],
477
481
  },
478
482
  };
479
483
 
@@ -593,7 +597,7 @@ function listSourceFiles(root) {
593
597
  })
594
598
  .filter(f => fs.existsSync(f) && !shouldIgnore(f.replace(/\\/g, "/")));
595
599
 
596
- // Si hay staged files pero ninguno es compatible con AST
600
+ // If there are staged files but none are compatible with AST
597
601
  if (stagedFiles.length === 0 && allStaged.length > 0) {
598
602
  console.error('\n⚠️ No AST-compatible files in staging area');
599
603
  console.error(' Staged files found:', allStaged.length);
@@ -2,6 +2,8 @@
2
2
  const { execSync } = require('child_process');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
6
+ const env = require('../../../config/env');
5
7
 
6
8
  class iOSASTIntelligentAnalyzer {
7
9
  constructor(findings) {
@@ -22,6 +24,30 @@ class iOSASTIntelligentAnalyzer {
22
24
  this.closures = [];
23
25
  }
24
26
 
27
+ resolveAuditTmpDir(repoRoot) {
28
+ const configured = String(env.get('AUDIT_TMP', '') || '').trim();
29
+ if (configured.length > 0) {
30
+ return path.isAbsolute(configured) ? configured : path.join(repoRoot, configured);
31
+ }
32
+ return path.join(repoRoot, '.audit_tmp');
33
+ }
34
+
35
+ safeTempFilePath(repoRoot, displayPath) {
36
+ const tmpDir = this.resolveAuditTmpDir(repoRoot);
37
+ const hash = crypto.createHash('sha1').update(String(displayPath)).digest('hex').slice(0, 10);
38
+ const base = path.basename(displayPath, '.swift');
39
+ const filename = `${base}.${hash}.staged.swift`;
40
+ return path.join(tmpDir, filename);
41
+ }
42
+
43
+ readStagedFileContent(repoRoot, relPath) {
44
+ try {
45
+ return execSync(`git show :"${relPath}"`, { encoding: 'utf8', cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] });
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
25
51
  checkSourceKitten() {
26
52
  try {
27
53
  execSync(`${this.sourceKittenPath} version`, { stdio: 'pipe' });
@@ -50,7 +76,7 @@ class iOSASTIntelligentAnalyzer {
50
76
  return false;
51
77
  }
52
78
 
53
- analyzeWithSwiftSyntax(filePath) {
79
+ analyzeWithSwiftSyntax(filePath, displayPath = null) {
54
80
  if (!this.swiftSyntaxPath) return;
55
81
  try {
56
82
  const result = execSync(`"${this.swiftSyntaxPath}" "${filePath}"`, {
@@ -58,8 +84,9 @@ class iOSASTIntelligentAnalyzer {
58
84
  });
59
85
  const violations = JSON.parse(result);
60
86
  for (const v of violations) {
87
+ const reportedPath = displayPath || filePath;
61
88
  this.findings.push({
62
- ruleId: v.ruleId, severity: v.severity, filePath,
89
+ ruleId: v.ruleId, severity: v.severity, filePath: reportedPath,
63
90
  line: v.line, column: v.column, message: v.message
64
91
  });
65
92
  }
@@ -81,21 +108,46 @@ class iOSASTIntelligentAnalyzer {
81
108
  }
82
109
  }
83
110
 
84
- analyzeFile(filePath) {
85
- if (!filePath.endsWith('.swift')) return;
111
+ analyzeFile(filePath, options = {}) {
112
+ if (!filePath || !String(filePath).endsWith('.swift')) return;
113
+
114
+ const repoRoot = options.repoRoot || require('../ast-core').getRepoRoot();
115
+ const displayPath = options.displayPath || filePath;
116
+ const stagedRelPath = options.stagedRelPath || null;
117
+ const stagingOnly = env.get('STAGING_ONLY_MODE', '0') === '1';
118
+
119
+ let parsePath = filePath;
120
+ let contentOverride = null;
121
+
122
+ if (stagingOnly && stagedRelPath) {
123
+ const stagedContent = this.readStagedFileContent(repoRoot, stagedRelPath);
124
+ if (typeof stagedContent === 'string') {
125
+ const tmpPath = this.safeTempFilePath(repoRoot, stagedRelPath);
126
+ try {
127
+ fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
128
+ fs.writeFileSync(tmpPath, stagedContent, 'utf8');
129
+ parsePath = tmpPath;
130
+ contentOverride = stagedContent;
131
+ } catch {
132
+ // Fall back to working tree file
133
+ }
134
+ }
135
+ }
86
136
 
87
137
  if (this.hasSwiftSyntax) {
88
- this.analyzeWithSwiftSyntax(filePath);
138
+ this.analyzeWithSwiftSyntax(parsePath, displayPath);
89
139
  }
90
140
 
91
- const ast = this.parseFile(filePath);
141
+ const ast = this.parseFile(parsePath);
92
142
  if (!ast) return;
93
143
 
94
- this.currentFilePath = filePath;
144
+ this.currentFilePath = displayPath;
95
145
  this.resetCollections();
96
146
 
97
147
  try {
98
- this.fileContent = fs.readFileSync(filePath, 'utf8');
148
+ this.fileContent = typeof contentOverride === 'string'
149
+ ? contentOverride
150
+ : fs.readFileSync(parsePath, 'utf8');
99
151
  } catch {
100
152
  this.fileContent = '';
101
153
  }
@@ -103,7 +155,7 @@ class iOSASTIntelligentAnalyzer {
103
155
  const substructure = ast['key.substructure'] || [];
104
156
 
105
157
  this.collectAllNodes(substructure, null);
106
- this.analyzeCollectedNodes(filePath);
158
+ this.analyzeCollectedNodes(displayPath);
107
159
  }
108
160
 
109
161
  resetCollections() {
@@ -427,10 +479,28 @@ class iOSASTIntelligentAnalyzer {
427
479
  }
428
480
  }
429
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
+
430
500
  analyzeFunctionAST(node, filePath) {
431
501
  const name = node['key.name'] || '';
432
502
  const line = node['key.line'] || 1;
433
- const bodyLength = node['key.bodylength'] || 0;
503
+ const bodyLength = this.countLinesInBody(node) || 0;
434
504
  const attributes = this.getAttributes(node);
435
505
  const substructure = node['key.substructure'] || [];
436
506