pumuki 6.3.329 → 6.3.331

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.
@@ -19,6 +19,7 @@ import {
19
19
  collectSwiftAnyViewLines,
20
20
  collectSwiftAnyTypeErasureLines,
21
21
  collectSwiftCallbackStyleSignatureLines,
22
+ collectSwiftCombineSinkWithoutStoreLines,
22
23
  collectSwiftDispatchGroupLines,
23
24
  collectSwiftDispatchSemaphoreLines,
24
25
  collectSwiftDummyAwaitLines,
@@ -528,7 +529,9 @@ let ignoredAssign = ".assign(to: \\\\Model.title, on: model)"
528
529
  `;
529
530
 
530
531
  assert.equal(hasSwiftCombineSinkWithoutStoreUsage(source), true);
532
+ assert.deepEqual(collectSwiftCombineSinkWithoutStoreLines(source), [3, 8]);
531
533
  assert.equal(hasSwiftCombineSinkWithoutStoreUsage(safe), false);
534
+ assert.deepEqual(collectSwiftCombineSinkWithoutStoreLines(safe), []);
532
535
  });
533
536
 
534
537
  test('hasSwiftProductionTestDoubleUsage detecta mocks/spies productivos y preserva strings', () => {
@@ -129,8 +129,9 @@ export const hasSwiftWarningSuppressionUsage = (source: string): boolean => {
129
129
  return collectSwiftWarningSuppressionLines(source).length > 0;
130
130
  };
131
131
 
132
- export const hasSwiftCombineSinkWithoutStoreUsage = (source: string): boolean => {
132
+ export const collectSwiftCombineSinkWithoutStoreLines = (source: string): readonly number[] => {
133
133
  const lines = source.split(/\r?\n/);
134
+ const matches: number[] = [];
134
135
 
135
136
  for (let index = 0; index < lines.length; index += 1) {
136
137
  const line = stripSwiftLineForSemanticScan(lines[index] ?? '');
@@ -144,11 +145,15 @@ export const hasSwiftCombineSinkWithoutStoreUsage = (source: string): boolean =>
144
145
  .join('\n');
145
146
 
146
147
  if (!/\.store\s*\(\s*in\s*:\s*&[A-Za-z_][A-Za-z0-9_]*\s*\)/.test(chain)) {
147
- return true;
148
+ matches.push(index + 1);
148
149
  }
149
150
  }
150
151
 
151
- return false;
152
+ return sortedUniqueLines(matches);
153
+ };
154
+
155
+ export const hasSwiftCombineSinkWithoutStoreUsage = (source: string): boolean => {
156
+ return collectSwiftCombineSinkWithoutStoreLines(source).length > 0;
152
157
  };
153
158
 
154
159
  export const hasSwiftProductionTestDoubleUsage = (source: string): boolean => {
@@ -774,7 +774,7 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
774
774
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftForceTryUsage, locateLines: TextIOS.collectSwiftForceTryLines, primaryNode: (lines) => ({ kind: 'call', name: 'force try expression try!', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: do/catch or throwing boundary', lines }], why: 'Force try converts a throwable operation into an unconditional runtime crash instead of preserving the typed error boundary.', impact: 'A recoverable domain, network, persistence or decoding error can terminate the app and bypass user-facing recovery, telemetry and tests.', expected_fix: 'Replace try! with do/catch, try await propagation, throws on the current boundary, or a guarded fallback that handles the error explicitly.', ruleId: 'heuristics.ios.force-try.ast', code: 'HEURISTICS_IOS_FORCE_TRY_AST', message: 'AST heuristic detected force try usage.' },
775
775
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftForceCastUsage, locateLines: TextIOS.collectSwiftForceCastLines, primaryNode: (lines) => ({ kind: 'call', name: 'force cast expression as!', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: conditional cast or typed boundary', lines }], why: 'Force cast converts a type mismatch into an unconditional runtime crash instead of preserving a checked domain or presentation boundary.', impact: 'Unexpected payloads, dependency substitutions or navigation models can terminate the app instead of producing a recoverable validation path.', expected_fix: 'Replace as! with as?, guard let, pattern matching, generic constraints, protocol boundaries, or a typed mapper that validates the runtime value explicitly.', ruleId: 'heuristics.ios.force-cast.ast', code: 'HEURISTICS_IOS_FORCE_CAST_AST', message: 'AST heuristic detected force cast usage.' },
776
776
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath, isApprovedIOSBridgePath], detect: TextIOS.hasSwiftCallbackStyleSignature, locateLines: TextIOS.collectSwiftCallbackStyleSignatureLines, primaryNode: (lines) => ({ kind: 'call', name: 'escaping callback-style API signature', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: async/await API or explicit bridge adapter', lines }], why: 'Callback-style completion APIs outside bridge layers bypass Swift structured concurrency and make cancellation, isolation and error flow implicit.', impact: 'Consumers must reason about escaping lifetime, actor hops and callback ordering manually, which increases race, leak and flaky-test risk in production iOS flows.', expected_fix: 'Expose async/await or AsyncSequence APIs in production boundaries. Keep callbacks only inside approved bridge/adapters that wrap legacy SDKs and document the conversion point explicitly.', ruleId: 'heuristics.ios.callback-style.ast', code: 'HEURISTICS_IOS_CALLBACK_STYLE_AST', message: 'AST heuristic detected callback-style API signature outside bridge layers.' },
777
- { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftCombineSinkWithoutStoreUsage, ruleId: 'heuristics.ios.combine.sink-without-store.ast', code: 'HEURISTICS_IOS_COMBINE_SINK_WITHOUT_STORE_AST', message: 'AST heuristic detected Combine sink without store(in:); keep cancellables retained explicitly.' },
777
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftCombineSinkWithoutStoreUsage, locateLines: TextIOS.collectSwiftCombineSinkWithoutStoreLines, primaryNode: (lines) => ({ kind: 'call', name: 'Combine sink/assign subscription without store(in:)', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: .store(in: &cancellables)', lines }], why: 'Combine subscriptions returned by sink/assign are cancelled immediately unless the AnyCancellable is retained explicitly.', impact: 'Reactive flows can silently stop receiving values, leak intent across view model lifetimes, or behave differently after navigation if subscription ownership is not clear.', expected_fix: 'Store the returned AnyCancellable with .store(in: &cancellables) or assign it to an explicit cancellable property owned by the view model/service lifetime.', ruleId: 'heuristics.ios.combine.sink-without-store.ast', code: 'HEURISTICS_IOS_COMBINE_SINK_WITHOUT_STORE_AST', message: 'AST heuristic detected Combine sink without store(in:); keep cancellables retained explicitly.' },
778
778
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftProductionTestDoubleUsage, ruleId: 'heuristics.ios.testing.production-test-double.ast', code: 'HEURISTICS_IOS_TESTING_PRODUCTION_TEST_DOUBLE_AST', message: 'AST heuristic detected Mock/Fake/Spy/Stub usage in iOS production code.' },
779
779
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftDispatchQueueUsage, locateLines: TextIOS.collectSwiftDispatchQueueLines, primaryNode: (lines) => ({ kind: 'call', name: 'GCD DispatchQueue call', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: structured concurrency Task/actor/MainActor boundary', lines }], why: 'DispatchQueue introduces unstructured GCD scheduling in production Swift code instead of preserving Swift concurrency cancellation, priority and actor isolation semantics.', impact: 'Manual queue hops make ordering, cancellation and main-actor safety harder to reason about, increasing race and flaky UI update risk.', expected_fix: 'Use async/await, Task, TaskGroup, actors, MainActor.run or isolated async APIs. Keep GCD only inside explicitly approved legacy bridge layers with documented ownership.', ruleId: 'heuristics.ios.dispatchqueue.ast', code: 'HEURISTICS_IOS_DISPATCHQUEUE_AST', message: 'AST heuristic detected DispatchQueue usage.' },
780
780
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftDispatchGroupUsage, locateLines: TextIOS.collectSwiftDispatchGroupLines, primaryNode: (lines) => ({ kind: 'call', name: 'GCD DispatchGroup call', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: structured concurrency TaskGroup or async aggregation boundary', lines }], why: 'DispatchGroup is an unstructured coordination primitive that makes asynchronous control flow and cancellation implicit instead of modeled by Swift concurrency.', impact: 'Group coordination is harder to reason about and can hide waiting or deadlock risks in production code paths that should be expressed through TaskGroup or async aggregation.', expected_fix: 'Use TaskGroup, async let, await aggregation, actors or explicit async APIs. Keep DispatchGroup only inside approved legacy bridge layers with documented ownership and migration scope.', ruleId: 'heuristics.ios.dispatchgroup.ast', code: 'HEURISTICS_IOS_DISPATCHGROUP_AST', message: 'AST heuristic detected DispatchGroup usage.' },
@@ -177,3 +177,38 @@ final class BuyerAPIClient {
177
177
  assert.deepEqual(jsonSerialization.primary_node?.lines, [7]);
178
178
  assert.match(jsonSerialization.expected_fix ?? '', /Codable/i);
179
179
  });
180
+
181
+ test('extractHeuristicFacts emite Combine sink iOS con linea y remediation accionable', () => {
182
+ const params: HeuristicExtractionParams = {
183
+ facts: [
184
+ {
185
+ kind: 'FileContent',
186
+ source: 'repo',
187
+ path: 'apps/ios/Presentation/Features/Orders/OrdersViewModel.swift',
188
+ content: `
189
+ final class OrdersViewModel {
190
+ func bind() {
191
+ publisher
192
+ .sink { value in
193
+ self.render(value)
194
+ }
195
+ }
196
+ }
197
+ `,
198
+ },
199
+ ],
200
+ detectedPlatforms: {
201
+ ios: { detected: true, confidence: 'HIGH' },
202
+ },
203
+ };
204
+
205
+ const extractedFacts = extractHeuristicFacts(params);
206
+ const match = extractedFacts.find(
207
+ (fact) => fact.ruleId === 'heuristics.ios.combine.sink-without-store.ast'
208
+ );
209
+
210
+ assert.ok(match);
211
+ assert.deepEqual(match.lines, [5]);
212
+ assert.deepEqual(match.primary_node?.lines, [5]);
213
+ assert.match(match.expected_fix ?? '', /\.store\(in:/i);
214
+ });
@@ -25,6 +25,14 @@ type AuditSummaryNotificationDependencies = {
25
25
  env: NodeJS.ProcessEnv;
26
26
  };
27
27
 
28
+ type NotificationBlockingCause = {
29
+ code: string;
30
+ message: string;
31
+ ruleId?: string;
32
+ file?: string;
33
+ remediation?: string;
34
+ };
35
+
28
36
  const defaultDependencies: AuditSummaryNotificationDependencies = {
29
37
  readEvidence,
30
38
  emitSystemNotification,
@@ -62,6 +70,38 @@ const normalizeNotificationStage = (stage: string): PumukiNotificationStage => {
62
70
  return 'PRE_COMMIT';
63
71
  };
64
72
 
73
+ const normalizeRuleCode = (ruleId: string): string =>
74
+ ruleId
75
+ .replace(/^skills\./u, 'SKILLS_')
76
+ .replace(/[.-]/gu, '_')
77
+ .toUpperCase();
78
+
79
+ const extractNestedSkillCauseFromWrapper = (cause: NotificationBlockingCause): NotificationBlockingCause => {
80
+ if (cause.ruleId?.startsWith('skills.') || cause.code.startsWith('SKILLS_')) {
81
+ return cause;
82
+ }
83
+ const nested = cause.message.match(/\bBlocking causes:\s*1\)\s*(skills\.[a-z0-9_.-]+)\s+(.+?)$/iu);
84
+ if (!nested?.[1]) {
85
+ return cause;
86
+ }
87
+ const ruleId = nested[1];
88
+ const nestedMessage = nested[2]?.trim() || 'Skill violada.';
89
+ return {
90
+ ...cause,
91
+ code: normalizeRuleCode(ruleId),
92
+ ruleId,
93
+ message: `rule=${ruleId} message=${nestedMessage}`,
94
+ remediation:
95
+ cause.remediation && !/corrige la violaci[oó]n indicada/i.test(cause.remediation)
96
+ ? cause.remediation
97
+ : 'Corrige la violación de la skill indicada en el fichero afectado y vuelve a intentar el commit.',
98
+ };
99
+ };
100
+
101
+ const normalizeNotificationBlockingCause = (
102
+ cause: NotificationBlockingCause
103
+ ): NotificationBlockingCause => extractNestedSkillCauseFromWrapper(cause);
104
+
65
105
  export const shouldEmitAuditSummaryNotificationForStage = (
66
106
  stage: AuditSummaryNotificationStage,
67
107
  env: NodeJS.ProcessEnv = process.env
@@ -87,13 +127,15 @@ export const toAuditSummaryEventFromEvidence = (
87
127
  causeCode: primaryCause.code,
88
128
  causeMessage: formatEvidenceBlockingCause(primaryCause),
89
129
  remediation: primaryCause.remediation ?? 'Corrige la violación indicada y vuelve a intentar el commit.',
90
- blockingCauses: blockingCauses.map((cause) => ({
91
- code: cause.code,
92
- ruleId: cause.ruleId,
93
- file: cause.file,
94
- message: formatEvidenceBlockingCause(cause),
95
- remediation: cause.remediation,
96
- })),
130
+ blockingCauses: blockingCauses.map((cause) =>
131
+ normalizeNotificationBlockingCause({
132
+ code: cause.code,
133
+ ruleId: cause.ruleId,
134
+ file: cause.file,
135
+ message: formatEvidenceBlockingCause(cause),
136
+ remediation: cause.remediation,
137
+ })
138
+ ),
97
139
  };
98
140
  }
99
141
  return {
@@ -125,12 +167,14 @@ export const toAuditSummaryEventFromAiGate = (params: {
125
167
  causeCode: primaryViolation.code,
126
168
  causeMessage: primaryViolation.message,
127
169
  remediation: 'Corrige la violación indicada y vuelve a intentar el commit.',
128
- blockingCauses: params.aiGateResult.violations.map((violation) => ({
129
- code: violation.code,
130
- ruleId: violation.code,
131
- message: violation.message,
132
- remediation: 'Corrige la violación indicada y vuelve a intentar el commit.',
133
- })),
170
+ blockingCauses: params.aiGateResult.violations.map((violation) =>
171
+ normalizeNotificationBlockingCause({
172
+ code: violation.code,
173
+ ruleId: violation.code.startsWith('skills.') ? violation.code : undefined,
174
+ message: violation.message,
175
+ remediation: 'Corrige la violación indicada y vuelve a intentar el commit.',
176
+ })
177
+ ),
134
178
  };
135
179
  }
136
180
  return {
@@ -233,7 +277,7 @@ export const emitGateBlockedNotification = (
233
277
  causeMessage: params.causeMessage,
234
278
  }),
235
279
  remediation: params.remediation,
236
- blockingCauses: params.blockingCauses,
280
+ blockingCauses: params.blockingCauses?.map(normalizeNotificationBlockingCause),
237
281
  },
238
282
  repoRoot: params.repoRoot,
239
283
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.329",
3
+ "version": "6.3.331",
4
4
  "description": "Enterprise-grade AST Intelligence System with multi-platform support (iOS, Android, Backend, Frontend) and Feature-First + DDD + Clean Architecture enforcement. Includes dynamic violations API for intelligent querying.",
5
5
  "main": "index.js",
6
6
  "bin": {