pumuki 6.3.308 → 6.3.310

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.
@@ -58,6 +58,16 @@ import {
58
58
  hasSwiftStrictConcurrencyBelowCompleteUsage,
59
59
  collectSwiftDefaultActorIsolationNotMainActorLines,
60
60
  hasSwiftDefaultActorIsolationNotMainActorUsage,
61
+ collectSwiftUpcomingFeatureDisabledLines,
62
+ hasSwiftUpcomingFeatureDisabledUsage,
63
+ collectSwiftUiStateWithoutMainActorLines,
64
+ hasSwiftUiStateWithoutMainActorUsage,
65
+ collectSwiftSharedMutableStateWithoutActorLines,
66
+ hasSwiftSharedMutableStateWithoutActorUsage,
67
+ collectSwiftMainActorRunPatchLines,
68
+ hasSwiftMainActorRunPatchUsage,
69
+ collectSwiftNavigationPathWithoutRestorationLines,
70
+ hasSwiftNavigationPathWithoutRestorationUsage,
61
71
  hasSwiftForEachIndicesUsage,
62
72
  hasSwiftForEachSelfIdentityUsage,
63
73
  collectSwiftForceCastLines,
@@ -884,6 +894,158 @@ buildSettings = {
884
894
  assert.deepEqual(collectSwiftDefaultActorIsolationNotMainActorLines(safe), []);
885
895
  });
886
896
 
897
+ test('hasSwiftUpcomingFeatureDisabledUsage detecta upcoming features Swift desactivadas', () => {
898
+ const source = `
899
+ buildSettings = {
900
+ SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = NO;
901
+ SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = 0;
902
+ SWIFT_ENABLE_EXPERIMENTAL_FEATURES = ;
903
+ };
904
+ `;
905
+ const safe = `
906
+ buildSettings = {
907
+ SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES;
908
+ SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = true;
909
+ SWIFT_ENABLE_EXPERIMENTAL_FEATURES = StrictConcurrency;
910
+ let sample = "SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = NO;"
911
+ // SWIFT_ENABLE_EXPERIMENTAL_FEATURES = ;
912
+ };
913
+ `;
914
+
915
+ assert.equal(hasSwiftUpcomingFeatureDisabledUsage(source), true);
916
+ assert.deepEqual(collectSwiftUpcomingFeatureDisabledLines(source), [3, 4, 5]);
917
+ assert.equal(hasSwiftUpcomingFeatureDisabledUsage(safe), false);
918
+ assert.deepEqual(collectSwiftUpcomingFeatureDisabledLines(safe), []);
919
+ });
920
+
921
+ test('hasSwiftUiStateWithoutMainActorUsage detecta estado UI observable sin MainActor', () => {
922
+ const source = `
923
+ final class BuyerAuthViewModel: ObservableObject {
924
+ @Published var title = "Login"
925
+ }
926
+ struct BuyerSessionStore {
927
+ var token: String?
928
+ }
929
+ `;
930
+ const safe = `
931
+ @MainActor
932
+ @Observable
933
+ final class BuyerAuthViewModel {
934
+ var title = "Login"
935
+ }
936
+ final class BackgroundStore {
937
+ let cache = NSCache<NSString, NSString>()
938
+ }
939
+ let sample = "final class FakeViewModel: ObservableObject"
940
+ // final class CommentedViewModel: ObservableObject {}
941
+ `;
942
+
943
+ assert.equal(hasSwiftUiStateWithoutMainActorUsage(source), true);
944
+ assert.deepEqual(collectSwiftUiStateWithoutMainActorLines(source), [2]);
945
+ assert.equal(hasSwiftUiStateWithoutMainActorUsage(safe), false);
946
+ assert.deepEqual(collectSwiftUiStateWithoutMainActorLines(safe), []);
947
+ });
948
+
949
+ test('hasSwiftSharedMutableStateWithoutActorUsage detecta estado compartido mutable sin actor', () => {
950
+ const source = `
951
+ final class UserSessionManager {
952
+ private var token: String?
953
+
954
+ func update(token: String) {
955
+ self.token = token
956
+ }
957
+ }
958
+ `;
959
+ const safe = `
960
+ actor UserSessionManager {
961
+ private var token: String?
962
+
963
+ func update(token: String) {
964
+ self.token = token
965
+ }
966
+ }
967
+ @MainActor
968
+ final class BuyerSessionStore {
969
+ private var route: String?
970
+ func update(route: String) { self.route = route }
971
+ }
972
+ final class ValueFormatter {
973
+ func title() -> String { "OK" }
974
+ }
975
+ let sample = "final class TokenCache { var token: String? }"
976
+ // final class CommentedCache { var value = "" }
977
+ `;
978
+
979
+ assert.equal(hasSwiftSharedMutableStateWithoutActorUsage(source), true);
980
+ assert.deepEqual(collectSwiftSharedMutableStateWithoutActorLines(source), [2]);
981
+ assert.equal(hasSwiftSharedMutableStateWithoutActorUsage(safe), false);
982
+ assert.deepEqual(collectSwiftSharedMutableStateWithoutActorLines(safe), []);
983
+ });
984
+
985
+ test('hasSwiftMainActorRunPatchUsage detecta MainActor.run como parche en owners no aislados', () => {
986
+ const source = `
987
+ final class BuyerAuthViewModel {
988
+ func load() async {
989
+ await MainActor.run {
990
+ self.title = "Ready"
991
+ }
992
+ }
993
+ }
994
+ `;
995
+ const safe = `
996
+ @MainActor
997
+ final class BuyerAuthViewModel {
998
+ func load() async {
999
+ await MainActor.run { self.title = "Ready" }
1000
+ }
1001
+ }
1002
+ actor BuyerSessionManager {
1003
+ func load() async {
1004
+ await MainActor.run { print("bridge") }
1005
+ }
1006
+ }
1007
+ func bridgeToUi() async {
1008
+ await MainActor.run { print("approved explicit boundary") }
1009
+ }
1010
+ let sample = "MainActor.run { value = 1 }"
1011
+ // await MainActor.run { value = 2 }
1012
+ `;
1013
+
1014
+ assert.equal(hasSwiftMainActorRunPatchUsage(source), true);
1015
+ assert.deepEqual(collectSwiftMainActorRunPatchLines(source), [4]);
1016
+ assert.equal(hasSwiftMainActorRunPatchUsage(safe), false);
1017
+ assert.deepEqual(collectSwiftMainActorRunPatchLines(safe), []);
1018
+ });
1019
+
1020
+ test('hasSwiftNavigationPathWithoutRestorationUsage detecta NavigationPath sin restauracion', () => {
1021
+ const source = `
1022
+ struct ShellView: View {
1023
+ @State private var path = NavigationPath()
1024
+
1025
+ var body: some View {
1026
+ NavigationStack(path: $path) { EmptyView() }
1027
+ }
1028
+ }
1029
+ `;
1030
+ const safe = `
1031
+ struct ShellView: View {
1032
+ @SceneStorage("navigation.path") private var encodedPath = Data()
1033
+ @State private var path = NavigationPath()
1034
+
1035
+ func restoreNavigationPath() {
1036
+ _ = path.codable
1037
+ }
1038
+ }
1039
+ let sample = "NavigationPath()"
1040
+ // let ignored = NavigationPath()
1041
+ `;
1042
+
1043
+ assert.equal(hasSwiftNavigationPathWithoutRestorationUsage(source), true);
1044
+ assert.deepEqual(collectSwiftNavigationPathWithoutRestorationLines(source), [3]);
1045
+ assert.equal(hasSwiftNavigationPathWithoutRestorationUsage(safe), false);
1046
+ assert.deepEqual(collectSwiftNavigationPathWithoutRestorationLines(safe), []);
1047
+ });
1048
+
887
1049
  test('hasSwiftNonLazyScrollForEachUsage detecta ScrollView con stack no lazy y preserva LazyVStack', () => {
888
1050
  const source = `
889
1051
  struct FeedView: View {
@@ -1142,6 +1142,244 @@ export const hasSwiftDefaultActorIsolationNotMainActorUsage = (source: string):
1142
1142
  return collectSwiftDefaultActorIsolationNotMainActorLines(source).length > 0;
1143
1143
  };
1144
1144
 
1145
+ export const collectSwiftUpcomingFeatureDisabledLines = (source: string): readonly number[] => {
1146
+ const lines: number[] = [];
1147
+
1148
+ source.split(/\r?\n/).forEach((rawLine, index) => {
1149
+ const line = stripSwiftLineForSemanticScan(rawLine);
1150
+ const upcomingFeatureMatch =
1151
+ /\bSWIFT_UPCOMING_FEATURE_[A-Za-z0-9_]+\s*=\s*([A-Za-z0-9_]+)\s*;?/.exec(line);
1152
+ if (upcomingFeatureMatch) {
1153
+ const value = (upcomingFeatureMatch[1] ?? '').trim().toLowerCase();
1154
+ if (value === 'no' || value === 'false' || value === '0') {
1155
+ lines.push(index + 1);
1156
+ }
1157
+ return;
1158
+ }
1159
+
1160
+ const experimentalFeaturesMatch =
1161
+ /\bSWIFT_ENABLE_EXPERIMENTAL_FEATURES\s*=\s*(.*?)\s*;?$/.exec(line);
1162
+ if (!experimentalFeaturesMatch) {
1163
+ return;
1164
+ }
1165
+
1166
+ const value = (experimentalFeaturesMatch[1] ?? '').trim().toLowerCase();
1167
+ if (value === '' || value === 'no' || value === 'false' || value === '0') {
1168
+ lines.push(index + 1);
1169
+ }
1170
+ });
1171
+
1172
+ return sortedUniqueLines(lines);
1173
+ };
1174
+
1175
+ export const hasSwiftUpcomingFeatureDisabledUsage = (source: string): boolean => {
1176
+ return collectSwiftUpcomingFeatureDisabledLines(source).length > 0;
1177
+ };
1178
+
1179
+ const swiftUiStateOwnerDeclarationPattern =
1180
+ /\b(?:final\s+)?(?:class|struct)\s+([A-Za-z_][A-Za-z0-9_]*(?:ViewModel|Presenter|Store))\b/;
1181
+
1182
+ const hasSwiftMainActorAnnotationNear = (
1183
+ lines: readonly string[],
1184
+ declarationIndex: number
1185
+ ): boolean => {
1186
+ const start = Math.max(0, declarationIndex - 3);
1187
+ const context = lines
1188
+ .slice(start, declarationIndex + 1)
1189
+ .map((line) => stripSwiftLineForSemanticScan(line))
1190
+ .join('\n');
1191
+ return /@MainActor\b/.test(context);
1192
+ };
1193
+
1194
+ const hasSwiftObservableUiStateEvidence = (
1195
+ lines: readonly string[],
1196
+ declarationIndex: number
1197
+ ): boolean => {
1198
+ const annotationStart = Math.max(0, declarationIndex - 2);
1199
+ const end = Math.min(lines.length, declarationIndex + 60);
1200
+ const annotationContext = lines
1201
+ .slice(annotationStart, declarationIndex + 1)
1202
+ .map((line) => stripSwiftLineForSemanticScan(line))
1203
+ .join('\n');
1204
+ const bodyContext = lines
1205
+ .slice(declarationIndex, end)
1206
+ .map((line) => stripSwiftLineForSemanticScan(line))
1207
+ .join('\n');
1208
+
1209
+ return (
1210
+ /@Observable\b/.test(annotationContext) ||
1211
+ /\bObservableObject\b/.test(bodyContext) ||
1212
+ /@Published\b/.test(bodyContext)
1213
+ );
1214
+ };
1215
+
1216
+ export const collectSwiftUiStateWithoutMainActorLines = (source: string): readonly number[] => {
1217
+ const matches: number[] = [];
1218
+ const lines = source.split(/\r?\n/);
1219
+
1220
+ lines.forEach((rawLine, index) => {
1221
+ const line = stripSwiftLineForSemanticScan(rawLine);
1222
+ if (!swiftUiStateOwnerDeclarationPattern.test(line)) {
1223
+ return;
1224
+ }
1225
+ if (hasSwiftMainActorAnnotationNear(lines, index)) {
1226
+ return;
1227
+ }
1228
+ if (!hasSwiftObservableUiStateEvidence(lines, index)) {
1229
+ return;
1230
+ }
1231
+ matches.push(index + 1);
1232
+ });
1233
+
1234
+ return sortedUniqueLines(matches);
1235
+ };
1236
+
1237
+ export const hasSwiftUiStateWithoutMainActorUsage = (source: string): boolean => {
1238
+ return collectSwiftUiStateWithoutMainActorLines(source).length > 0;
1239
+ };
1240
+
1241
+ const swiftSharedStateOwnerDeclarationPattern =
1242
+ /\b(?:final\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*(?:Cache|Manager|Session|Store|Repository))\b/;
1243
+
1244
+ const findSwiftDeclarationBlockEnd = (lines: readonly string[], declarationIndex: number): number => {
1245
+ let depth = 0;
1246
+ let started = false;
1247
+
1248
+ for (let index = declarationIndex; index < lines.length; index += 1) {
1249
+ const line = stripSwiftLineForSemanticScan(lines[index] ?? '');
1250
+ for (const char of line) {
1251
+ if (char === '{') {
1252
+ depth += 1;
1253
+ started = true;
1254
+ } else if (char === '}') {
1255
+ depth -= 1;
1256
+ if (started && depth <= 0) {
1257
+ return index + 1;
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ return Math.min(lines.length, declarationIndex + 80);
1264
+ };
1265
+
1266
+ const hasSwiftSharedMutableStateBody = (body: string): boolean => {
1267
+ return (
1268
+ /\b(?:private\s+|fileprivate\s+|internal\s+|public\s+|open\s+)?var\s+[A-Za-z_][A-Za-z0-9_]*\b/.test(
1269
+ body
1270
+ ) &&
1271
+ /\bfunc\s+[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(body) &&
1272
+ !/@(?:Observable|Published)\b/.test(body) &&
1273
+ !/\bObservableObject\b/.test(body)
1274
+ );
1275
+ };
1276
+
1277
+ export const collectSwiftSharedMutableStateWithoutActorLines = (
1278
+ source: string
1279
+ ): readonly number[] => {
1280
+ const matches: number[] = [];
1281
+ const lines = source.split(/\r?\n/);
1282
+
1283
+ lines.forEach((rawLine, index) => {
1284
+ const line = stripSwiftLineForSemanticScan(rawLine);
1285
+ if (!swiftSharedStateOwnerDeclarationPattern.test(line)) {
1286
+ return;
1287
+ }
1288
+ if (hasSwiftMainActorAnnotationNear(lines, index)) {
1289
+ return;
1290
+ }
1291
+
1292
+ const blockEnd = findSwiftDeclarationBlockEnd(lines, index);
1293
+ const body = lines
1294
+ .slice(index, blockEnd)
1295
+ .map((candidate) => stripSwiftLineForSemanticScan(candidate))
1296
+ .join('\n');
1297
+
1298
+ if (!hasSwiftSharedMutableStateBody(body)) {
1299
+ return;
1300
+ }
1301
+
1302
+ matches.push(index + 1);
1303
+ });
1304
+
1305
+ return sortedUniqueLines(matches);
1306
+ };
1307
+
1308
+ export const hasSwiftSharedMutableStateWithoutActorUsage = (source: string): boolean => {
1309
+ return collectSwiftSharedMutableStateWithoutActorLines(source).length > 0;
1310
+ };
1311
+
1312
+ const swiftActorPatchOwnerDeclarationPattern =
1313
+ /\b(?:final\s+)?(?:class|struct)\s+([A-Za-z_][A-Za-z0-9_]*(?:ViewModel|Presenter|Store|Manager))\b/;
1314
+
1315
+ const findNearestSwiftActorPatchOwnerIndex = (
1316
+ lines: readonly string[],
1317
+ usageIndex: number
1318
+ ): number | undefined => {
1319
+ const start = Math.max(0, usageIndex - 80);
1320
+ for (let index = usageIndex; index >= start; index -= 1) {
1321
+ const line = stripSwiftLineForSemanticScan(lines[index] ?? '');
1322
+ if (swiftActorPatchOwnerDeclarationPattern.test(line)) {
1323
+ return index;
1324
+ }
1325
+ if (/^\s*(?:actor|enum|protocol)\s+[A-Za-z_][A-Za-z0-9_]*\b/.test(line)) {
1326
+ return undefined;
1327
+ }
1328
+ }
1329
+ return undefined;
1330
+ };
1331
+
1332
+ export const collectSwiftMainActorRunPatchLines = (source: string): readonly number[] => {
1333
+ const matches: number[] = [];
1334
+ const lines = source.split(/\r?\n/);
1335
+
1336
+ lines.forEach((rawLine, index) => {
1337
+ const line = stripSwiftLineForSemanticScan(rawLine);
1338
+ if (!/\bMainActor\s*\.\s*run\s*(?:\(|\{)/.test(line)) {
1339
+ return;
1340
+ }
1341
+
1342
+ const ownerIndex = findNearestSwiftActorPatchOwnerIndex(lines, index);
1343
+ if (ownerIndex === undefined) {
1344
+ return;
1345
+ }
1346
+ if (hasSwiftMainActorAnnotationNear(lines, ownerIndex)) {
1347
+ return;
1348
+ }
1349
+
1350
+ matches.push(index + 1);
1351
+ });
1352
+
1353
+ return sortedUniqueLines(matches);
1354
+ };
1355
+
1356
+ export const hasSwiftMainActorRunPatchUsage = (source: string): boolean => {
1357
+ return collectSwiftMainActorRunPatchLines(source).length > 0;
1358
+ };
1359
+
1360
+ const swiftNavigationPathRestorationPattern =
1361
+ /@(?:SceneStorage|AppStorage)\b|\bCodableRepresentation\b|\.codable\b|\b(?:restore|rehydrate|persist|save|load)[A-Za-z0-9_]*(?:Navigation)?Path\b/i;
1362
+
1363
+ export const collectSwiftNavigationPathWithoutRestorationLines = (
1364
+ source: string
1365
+ ): readonly number[] => {
1366
+ const lines = collectSwiftRegexLines(source, /\bNavigationPath\s*\(/);
1367
+ if (lines.length === 0) {
1368
+ return [];
1369
+ }
1370
+
1371
+ const sanitized = sanitizeSwiftSourceForMultilineRegex(source);
1372
+ if (swiftNavigationPathRestorationPattern.test(sanitized)) {
1373
+ return [];
1374
+ }
1375
+
1376
+ return sortedUniqueLines(lines);
1377
+ };
1378
+
1379
+ export const hasSwiftNavigationPathWithoutRestorationUsage = (source: string): boolean => {
1380
+ return collectSwiftNavigationPathWithoutRestorationLines(source).length > 0;
1381
+ };
1382
+
1145
1383
  export const hasSwiftOnAppearTaskUsage = (source: string): boolean => {
1146
1384
  return collectSwiftOnAppearTaskLines(source).length > 0;
1147
1385
  };
@@ -758,6 +758,10 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
758
758
  { platform: 'ios', pathCheck: isIOSSwiftPackageManifestPath, excludePaths: [], detect: TextIOS.hasSwiftPackageStrictConcurrencyBelowCompleteUsage, locateLines: TextIOS.collectSwiftPackageStrictConcurrencyBelowCompleteLines, primaryNode: (lines) => ({ kind: 'call', name: 'SwiftPM StrictConcurrency below complete', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: complete strict concurrency baseline', lines }], why: 'SwiftPM strict concurrency settings below complete can leave sendability and actor diagnostics unenforced in iOS packages.', impact: 'Minimal or targeted strict concurrency in Package.swift weakens the same safety baseline that Xcode build settings must enforce.', expected_fix: 'Remove targeted/minimal StrictConcurrency overrides and run the package under the complete Swift concurrency baseline expected by the repo.', ruleId: 'heuristics.ios.concurrency.swiftpm-strict-concurrency-below-complete.ast', code: 'HEURISTICS_IOS_CONCURRENCY_SWIFTPM_STRICT_CONCURRENCY_BELOW_COMPLETE_AST', message: 'AST heuristic detected Package.swift StrictConcurrency below complete.' },
759
759
  { platform: 'ios', pathCheck: isIOSXcodeProjectFilePath, excludePaths: [], detect: TextIOS.hasSwiftStrictConcurrencyBelowCompleteUsage, locateLines: TextIOS.collectSwiftStrictConcurrencyBelowCompleteLines, primaryNode: (lines) => ({ kind: 'property', name: 'SWIFT_STRICT_CONCURRENCY below complete', lines }), relatedNodes: (lines) => [{ kind: 'property', name: 'replacement: SWIFT_STRICT_CONCURRENCY = complete', lines }], why: 'The iOS skill contract requires Complete strict concurrency checking so unsafe actor/sendability issues cannot pass silently.', impact: 'Minimal or targeted strict concurrency leaves parts of the module below the Swift concurrency safety baseline and can hide data-race warnings.', expected_fix: 'Set SWIFT_STRICT_CONCURRENCY = complete for the affected iOS build configuration after addressing surfaced warnings.', ruleId: 'heuristics.ios.concurrency.strict-concurrency-below-complete.ast', code: 'HEURISTICS_IOS_CONCURRENCY_STRICT_CONCURRENCY_BELOW_COMPLETE_AST', message: 'AST heuristic detected SWIFT_STRICT_CONCURRENCY below complete in an iOS Xcode project.' },
760
760
  { platform: 'ios', pathCheck: isIOSXcodeProjectFilePath, excludePaths: [], detect: TextIOS.hasSwiftDefaultActorIsolationNotMainActorUsage, locateLines: TextIOS.collectSwiftDefaultActorIsolationNotMainActorLines, primaryNode: (lines) => ({ kind: 'property', name: 'SWIFT_DEFAULT_ACTOR_ISOLATION not MainActor', lines }), relatedNodes: (lines) => [{ kind: 'property', name: 'replacement: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor', lines }], why: 'The iOS concurrency skill requires projects to validate default actor isolation instead of leaving UI-heavy code under an unsafe or ambiguous isolation baseline.', impact: 'A non-MainActor default isolation can let SwiftUI and presentation state cross actor boundaries without the project-wide protection expected by the skills contract.', expected_fix: 'Set SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor for the affected iOS build configuration, or isolate exceptional modules explicitly with documented actor boundaries.', ruleId: 'heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast', code: 'HEURISTICS_IOS_CONCURRENCY_DEFAULT_ACTOR_ISOLATION_NOT_MAINACTOR_AST', message: 'AST heuristic detected SWIFT_DEFAULT_ACTOR_ISOLATION not set to MainActor in an iOS Xcode project.' },
761
+ { platform: 'ios', pathCheck: isIOSXcodeProjectFilePath, excludePaths: [], detect: TextIOS.hasSwiftUpcomingFeatureDisabledUsage, locateLines: TextIOS.collectSwiftUpcomingFeatureDisabledLines, primaryNode: (lines) => ({ kind: 'property', name: 'SWIFT_UPCOMING_FEATURE disabled or experimental features empty', lines }), relatedNodes: (lines) => [{ kind: 'property', name: 'replacement: enable required Swift upcoming features explicitly', lines }], why: 'The iOS Swift 6.2 skill requires validating SWIFT_UPCOMING_FEATURE_* and experimental feature settings instead of leaving required language diagnostics disabled.', impact: 'Disabled upcoming features let modules compile below the reviewed Swift language baseline, hiding migration and concurrency diagnostics that the gate expects to enforce.', expected_fix: 'Enable the required SWIFT_UPCOMING_FEATURE_* setting with YES/true and keep SWIFT_ENABLE_EXPERIMENTAL_FEATURES populated only with approved Swift feature names; remove explicit NO/0/false overrides.', ruleId: 'heuristics.ios.concurrency.upcoming-feature-disabled.ast', code: 'HEURISTICS_IOS_CONCURRENCY_UPCOMING_FEATURE_DISABLED_AST', message: 'AST heuristic detected disabled Swift upcoming feature settings in an iOS Xcode project.' },
762
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftUiStateWithoutMainActorUsage, locateLines: TextIOS.collectSwiftUiStateWithoutMainActorLines, primaryNode: (lines) => ({ kind: 'class', name: 'observable UI state owner without MainActor', lines }), relatedNodes: (lines) => [{ kind: 'property', name: 'replacement: @MainActor isolated ViewModel/Presenter/Store', lines }], why: 'The iOS concurrency skill requires UI-facing state owners to be isolated on MainActor instead of relying on implicit thread discipline.', impact: 'Observable presentation state without MainActor can be mutated from background executors, causing SwiftUI updates off the main actor and nondeterministic UI behavior.', expected_fix: 'Annotate the ViewModel/Presenter/Store with @MainActor, or move non-UI shared state behind an explicit actor boundary and keep UI adapters MainActor-isolated.', ruleId: 'heuristics.ios.concurrency.ui-state-without-mainactor.ast', code: 'HEURISTICS_IOS_CONCURRENCY_UI_STATE_WITHOUT_MAINACTOR_AST', message: 'AST heuristic detected observable iOS UI state without @MainActor isolation.' },
763
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftSharedMutableStateWithoutActorUsage, locateLines: TextIOS.collectSwiftSharedMutableStateWithoutActorLines, primaryNode: (lines) => ({ kind: 'class', name: 'mutable shared state owner without actor isolation', lines }), relatedNodes: (lines) => [{ kind: 'class', name: 'replacement: actor or explicit MainActor boundary', lines }], why: 'The iOS concurrency skill requires shared mutable state to be protected by actor isolation or an explicit main-actor boundary.', impact: 'Mutable Store/Cache/Manager/Session classes can be accessed from multiple tasks without serialization, creating data races and nondeterministic state transitions.', expected_fix: 'Convert the shared mutable owner to an actor, isolate the UI-facing owner with @MainActor, or move mutable state behind a documented actor boundary.', ruleId: 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast', code: 'HEURISTICS_IOS_CONCURRENCY_SHARED_MUTABLE_STATE_WITHOUT_ACTOR_AST', message: 'AST heuristic detected shared mutable iOS state without actor isolation.' },
764
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftMainActorRunPatchUsage, locateLines: TextIOS.collectSwiftMainActorRunPatchLines, primaryNode: (lines) => ({ kind: 'call', name: 'MainActor.run patch inside non-isolated owner', lines }), relatedNodes: (lines) => [{ kind: 'class', name: 'replacement: justified @MainActor/actor isolation boundary', lines }], why: 'The Swift concurrency skill forbids using MainActor as a blanket patch instead of modelling the real isolation boundary.', impact: 'Scattered MainActor.run calls inside non-isolated ViewModel/Store/Manager types hide ownership and allow the rest of the type to remain callable from background executors.', expected_fix: 'Move the owner to @MainActor when it owns UI state, move shared mutable state to an actor, or isolate the exact boundary with a documented adapter instead of sprinkling MainActor.run patches.', ruleId: 'heuristics.ios.concurrency.mainactor-run-patch.ast', code: 'HEURISTICS_IOS_CONCURRENCY_MAINACTOR_RUN_PATCH_AST', message: 'AST heuristic detected MainActor.run used as an isolation patch in iOS production code.' },
761
765
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftNonPascalCaseTypeDeclarationUsage, locateLines: TextIOS.collectSwiftNonPascalCaseTypeDeclarationLines, primaryNode: (lines) => ({ kind: 'class', name: 'Swift type declaration without PascalCase', lines }), relatedNodes: (lines) => [{ kind: 'class', name: 'replacement: PascalCase type name', lines }], why: 'Swift type declarations should use PascalCase so public and internal APIs remain idiomatic, searchable and reviewable.', impact: 'Non-PascalCase type names make ownership boundaries less consistent and weaken automated remediation because the gate cannot rely on the declaration node name.', expected_fix: 'Rename the Swift class, struct, enum, actor or protocol declaration to PascalCase and update its references in the same slice.', ruleId: 'heuristics.ios.naming.non-pascal-case-type.ast', code: 'HEURISTICS_IOS_NAMING_NON_PASCAL_CASE_TYPE_AST', message: 'AST heuristic detected Swift type declaration without PascalCase.' },
762
766
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftCrossFeatureImportUsage, locateLines: TextIOS.collectSwiftCrossFeatureImportLines, primaryNode: (lines) => ({ kind: 'member', name: 'cross-feature Swift import', lines }), relatedNodes: (lines) => [{ kind: 'member', name: 'replacement: SharedKernel / routing contract / local feature boundary', lines }], why: 'Feature-first iOS modules must not import sibling features directly; bounded contexts communicate through shared kernel contracts, navigation routes or application-level orchestration.', impact: 'A direct feature-to-feature import couples release cadence, state ownership and navigation behavior across bounded contexts, making product slices harder to isolate and review.', expected_fix: 'Move the shared type to SharedKernel, expose a narrow route/command contract, or orchestrate the collaboration from an application/root layer instead of importing a sibling feature.', ruleId: 'heuristics.ios.architecture.cross-feature-import.ast', code: 'HEURISTICS_IOS_ARCHITECTURE_CROSS_FEATURE_IMPORT_AST', message: 'AST heuristic detected a Swift feature importing another feature module directly.' },
763
767
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftLayerDirectionViolationUsage, locateLines: TextIOS.collectSwiftLayerDirectionViolationLines, primaryNode: (lines) => ({ kind: 'member', name: 'forbidden import for Clean Architecture layer', lines }), relatedNodes: (lines) => [{ kind: 'member', name: 'replacement: move dependency to allowed layer or depend on protocol/value object', lines }], why: 'Clean Architecture iOS layers must point inward: Domain cannot import UI, persistence or concrete networking frameworks; Application cannot import UI or concrete infrastructure; Presentation cannot import persistence/network implementation frameworks directly.', impact: 'Layer direction violations make feature slices depend on concrete frameworks instead of domain/application contracts, increasing coupling and making remediation unsafe across bounded contexts.', expected_fix: 'Move framework-specific code to Infrastructure or Presentation as appropriate, expose a narrow protocol/value object in Domain/Application, and inject the implementation from the composition root.', ruleId: 'heuristics.ios.architecture.layer-direction-violation.ast', code: 'HEURISTICS_IOS_ARCHITECTURE_LAYER_DIRECTION_VIOLATION_AST', message: 'AST heuristic detected an import that violates Clean Architecture layer direction in iOS code.' },
@@ -855,6 +859,7 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
855
859
  { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftAnimationWithoutReduceMotionUsage, locateLines: TextIOS.collectSwiftAnimationWithoutReduceMotionLines, primaryNode: (lines) => ({ kind: 'call', name: 'SwiftUI animation without reduce motion guard', lines }), relatedNodes: (lines) => [{ kind: 'property', name: 'replacement: accessibilityReduceMotion / UIAccessibility.isReduceMotionEnabled', lines }], why: 'SwiftUI animations must respect the system reduce motion preference.', impact: 'Users who reduce motion can still receive animated transitions, making the UI less accessible.', expected_fix: 'Read @Environment(\\.accessibilityReduceMotion) or UIAccessibility.isReduceMotionEnabled and disable or replace animations when reduce motion is enabled.', ruleId: 'heuristics.ios.accessibility.animation-without-reduce-motion.ast', code: 'HEURISTICS_IOS_ACCESSIBILITY_ANIMATION_WITHOUT_REDUCE_MOTION_AST', message: 'AST heuristic detected SwiftUI animation without reduce motion handling.' },
856
860
  { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftUiInlineActionLogicUsage, locateLines: TextIOS.collectSwiftUiInlineActionLogicLines, primaryNode: (lines) => ({ kind: 'call', name: 'SwiftUI Button action with inline logic', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: extracted action method', lines }], why: 'Inline branching or async work in a SwiftUI action makes the view declaration own behavior instead of delegating to a named action.', impact: 'Reviewers and agents cannot remediate a blocked view safely when the finding lacks the exact Button action node.', expected_fix: 'Extract the action body to a named method or view model command and reference that method from Button.', ruleId: 'heuristics.ios.swiftui.inline-action-logic.ast', code: 'HEURISTICS_IOS_SWIFTUI_INLINE_ACTION_LOGIC_AST', message: 'AST heuristic detected inline logic inside a SwiftUI action handler; action handlers should reference methods and keep view declarations focused.' },
857
861
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftNavigationViewUsage, ruleId: 'heuristics.ios.navigation-view.ast', code: 'HEURISTICS_IOS_NAVIGATION_VIEW_AST', message: 'AST heuristic detected NavigationView usage.' },
862
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftNavigationPathWithoutRestorationUsage, locateLines: TextIOS.collectSwiftNavigationPathWithoutRestorationLines, primaryNode: (lines) => ({ kind: 'property', name: 'NavigationPath state without restoration contract', lines }), relatedNodes: (lines) => [{ kind: 'property', name: 'replacement: persisted/rehydrated NavigationPath codable state', lines }], why: 'The iOS navigation skill requires NavigationPath state to be restorable when the app owns path-based navigation.', impact: 'A raw NavigationPath() without restoration loses deep-link and navigation state across process death, scene restoration or app relaunch, making user journeys non-deterministic.', expected_fix: 'Persist and rehydrate the path through SceneStorage/AppStorage or an approved route-store using NavigationPath.CodableRepresentation, or document why the local path is intentionally ephemeral.', ruleId: 'heuristics.ios.navigation.path-without-restoration.ast', code: 'HEURISTICS_IOS_NAVIGATION_PATH_WITHOUT_RESTORATION_AST', message: 'AST heuristic detected NavigationPath without an explicit restoration contract.' },
858
863
  { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftUntypedNavigationLinkDestinationUsage, ruleId: 'heuristics.ios.swiftui.untyped-navigation-link-destination.ast', code: 'HEURISTICS_IOS_SWIFTUI_UNTYPED_NAVIGATION_LINK_DESTINATION_AST', message: 'AST heuristic detected untyped NavigationLink destination usage; prefer NavigationLink(value:) with navigationDestination(for:) for type-safe navigation.' },
859
864
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftForegroundColorUsage, locateLines: TextIOS.collectSwiftForegroundColorLines, primaryNode: (lines) => ({ kind: 'call', name: 'SwiftUI foregroundColor modifier', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: foregroundStyle or design token', lines }], why: 'foregroundColor is a legacy SwiftUI styling API compared with foregroundStyle and tokenized style values.', impact: 'Modernization blockers need the exact modifier line to avoid whole-file refactors.', expected_fix: 'Replace .foregroundColor(...) with .foregroundStyle(...) or the repository-approved design token style.', ruleId: 'heuristics.ios.foreground-color.ast', code: 'HEURISTICS_IOS_FOREGROUND_COLOR_AST', message: 'AST heuristic detected foregroundColor usage.' },
860
865
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftCornerRadiusUsage, locateLines: TextIOS.collectSwiftCornerRadiusLines, primaryNode: (lines) => ({ kind: 'call', name: 'SwiftUI cornerRadius modifier', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: clipShape with rounded rectangle style', lines }], why: 'cornerRadius is a legacy shape shortcut that hides the shape semantics used by modern SwiftUI styling.', impact: 'The fix is local, but without line evidence the gate forces unsafe file-wide remediation.', expected_fix: 'Replace .cornerRadius(...) with .clipShape(.rect(cornerRadius: ...)) or a named reusable shape/style token.', ruleId: 'heuristics.ios.corner-radius.ast', code: 'HEURISTICS_IOS_CORNER_RADIUS_AST', message: 'AST heuristic detected cornerRadius usage.' },
@@ -3,7 +3,7 @@ import test from 'node:test';
3
3
  import { iosRules } from './ios';
4
4
 
5
5
  test('iosRules define reglas heurísticas locked para plataforma ios', () => {
6
- assert.equal(iosRules.length, 131);
6
+ assert.equal(iosRules.length, 136);
7
7
 
8
8
  const ids = iosRules.map((rule) => rule.id);
9
9
  assert.deepEqual(ids, [
@@ -61,6 +61,10 @@ test('iosRules define reglas heurísticas locked para plataforma ios', () => {
61
61
  'heuristics.ios.concurrency.swiftpm-strict-concurrency-below-complete.ast',
62
62
  'heuristics.ios.concurrency.strict-concurrency-below-complete.ast',
63
63
  'heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast',
64
+ 'heuristics.ios.concurrency.upcoming-feature-disabled.ast',
65
+ 'heuristics.ios.concurrency.ui-state-without-mainactor.ast',
66
+ 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast',
67
+ 'heuristics.ios.concurrency.mainactor-run-patch.ast',
64
68
  'heuristics.ios.naming.non-pascal-case-type.ast',
65
69
  'heuristics.ios.uikit.cell-without-reuse.ast',
66
70
  'heuristics.ios.security.userdefaults-sensitive-data.ast',
@@ -111,6 +115,7 @@ test('iosRules define reglas heurísticas locked para plataforma ios', () => {
111
115
  'heuristics.ios.accessibility.animation-without-reduce-motion.ast',
112
116
  'heuristics.ios.swiftui.inline-action-logic.ast',
113
117
  'heuristics.ios.navigation-view.ast',
118
+ 'heuristics.ios.navigation.path-without-restoration.ast',
114
119
  'heuristics.ios.swiftui.untyped-navigation-link-destination.ast',
115
120
  'heuristics.ios.foreground-color.ast',
116
121
  'heuristics.ios.corner-radius.ast',
@@ -201,6 +206,22 @@ test('iosRules define reglas heurísticas locked para plataforma ios', () => {
201
206
  byId.get('heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast')?.then.code,
202
207
  'HEURISTICS_IOS_CONCURRENCY_DEFAULT_ACTOR_ISOLATION_NOT_MAINACTOR_AST'
203
208
  );
209
+ assert.equal(
210
+ byId.get('heuristics.ios.concurrency.upcoming-feature-disabled.ast')?.then.code,
211
+ 'HEURISTICS_IOS_CONCURRENCY_UPCOMING_FEATURE_DISABLED_AST'
212
+ );
213
+ assert.equal(
214
+ byId.get('heuristics.ios.concurrency.ui-state-without-mainactor.ast')?.then.code,
215
+ 'HEURISTICS_IOS_CONCURRENCY_UI_STATE_WITHOUT_MAINACTOR_AST'
216
+ );
217
+ assert.equal(
218
+ byId.get('heuristics.ios.concurrency.shared-mutable-state-without-actor.ast')?.then.code,
219
+ 'HEURISTICS_IOS_CONCURRENCY_SHARED_MUTABLE_STATE_WITHOUT_ACTOR_AST'
220
+ );
221
+ assert.equal(
222
+ byId.get('heuristics.ios.concurrency.mainactor-run-patch.ast')?.then.code,
223
+ 'HEURISTICS_IOS_CONCURRENCY_MAINACTOR_RUN_PATCH_AST'
224
+ );
204
225
  assert.equal(
205
226
  byId.get('heuristics.ios.architecture.cross-feature-import.ast')?.then.code,
206
227
  'HEURISTICS_IOS_ARCHITECTURE_CROSS_FEATURE_IMPORT_AST'
@@ -245,6 +266,10 @@ test('iosRules define reglas heurísticas locked para plataforma ios', () => {
245
266
  byId.get('heuristics.ios.accessibility.on-tap-without-button-trait.ast')?.then.code,
246
267
  'HEURISTICS_IOS_ACCESSIBILITY_ON_TAP_WITHOUT_BUTTON_TRAIT_AST'
247
268
  );
269
+ assert.equal(
270
+ byId.get('heuristics.ios.navigation.path-without-restoration.ast')?.then.code,
271
+ 'HEURISTICS_IOS_NAVIGATION_PATH_WITHOUT_RESTORATION_AST'
272
+ );
248
273
  assert.equal(
249
274
  byId.get('heuristics.ios.swiftui.glasseffectid-without-namespace.ast')?.then.code,
250
275
  'HEURISTICS_IOS_SWIFTUI_GLASSEFFECTID_WITHOUT_NAMESPACE_AST'
@@ -1001,6 +1001,78 @@ export const iosRules: RuleSet = [
1001
1001
  code: 'HEURISTICS_IOS_CONCURRENCY_DEFAULT_ACTOR_ISOLATION_NOT_MAINACTOR_AST',
1002
1002
  },
1003
1003
  },
1004
+ {
1005
+ id: 'heuristics.ios.concurrency.upcoming-feature-disabled.ast',
1006
+ description: 'Detects Xcode iOS targets with required Swift upcoming features disabled.',
1007
+ severity: 'WARN',
1008
+ platform: 'ios',
1009
+ locked: true,
1010
+ when: {
1011
+ kind: 'Heuristic',
1012
+ where: {
1013
+ ruleId: 'heuristics.ios.concurrency.upcoming-feature-disabled.ast',
1014
+ },
1015
+ },
1016
+ then: {
1017
+ kind: 'Finding',
1018
+ message: 'AST heuristic detected disabled Swift upcoming feature settings in an iOS Xcode project.',
1019
+ code: 'HEURISTICS_IOS_CONCURRENCY_UPCOMING_FEATURE_DISABLED_AST',
1020
+ },
1021
+ },
1022
+ {
1023
+ id: 'heuristics.ios.concurrency.ui-state-without-mainactor.ast',
1024
+ description: 'Detects observable iOS UI state owners without @MainActor isolation.',
1025
+ severity: 'WARN',
1026
+ platform: 'ios',
1027
+ locked: true,
1028
+ when: {
1029
+ kind: 'Heuristic',
1030
+ where: {
1031
+ ruleId: 'heuristics.ios.concurrency.ui-state-without-mainactor.ast',
1032
+ },
1033
+ },
1034
+ then: {
1035
+ kind: 'Finding',
1036
+ message: 'AST heuristic detected observable iOS UI state without @MainActor isolation.',
1037
+ code: 'HEURISTICS_IOS_CONCURRENCY_UI_STATE_WITHOUT_MAINACTOR_AST',
1038
+ },
1039
+ },
1040
+ {
1041
+ id: 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast',
1042
+ description: 'Detects shared mutable iOS state owners without actor isolation.',
1043
+ severity: 'WARN',
1044
+ platform: 'ios',
1045
+ locked: true,
1046
+ when: {
1047
+ kind: 'Heuristic',
1048
+ where: {
1049
+ ruleId: 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast',
1050
+ },
1051
+ },
1052
+ then: {
1053
+ kind: 'Finding',
1054
+ message: 'AST heuristic detected shared mutable iOS state without actor isolation.',
1055
+ code: 'HEURISTICS_IOS_CONCURRENCY_SHARED_MUTABLE_STATE_WITHOUT_ACTOR_AST',
1056
+ },
1057
+ },
1058
+ {
1059
+ id: 'heuristics.ios.concurrency.mainactor-run-patch.ast',
1060
+ description: 'Detects MainActor.run used as an isolation patch inside non-isolated iOS state owners.',
1061
+ severity: 'WARN',
1062
+ platform: 'ios',
1063
+ locked: true,
1064
+ when: {
1065
+ kind: 'Heuristic',
1066
+ where: {
1067
+ ruleId: 'heuristics.ios.concurrency.mainactor-run-patch.ast',
1068
+ },
1069
+ },
1070
+ then: {
1071
+ kind: 'Finding',
1072
+ message: 'AST heuristic detected MainActor.run used as an isolation patch in iOS production code.',
1073
+ code: 'HEURISTICS_IOS_CONCURRENCY_MAINACTOR_RUN_PATCH_AST',
1074
+ },
1075
+ },
1004
1076
  {
1005
1077
  id: 'heuristics.ios.naming.non-pascal-case-type.ast',
1006
1078
  description: 'Detects Swift type declarations that are not PascalCase.',
@@ -1920,6 +1992,24 @@ export const iosRules: RuleSet = [
1920
1992
  code: 'HEURISTICS_IOS_NAVIGATION_VIEW_AST',
1921
1993
  },
1922
1994
  },
1995
+ {
1996
+ id: 'heuristics.ios.navigation.path-without-restoration.ast',
1997
+ description: 'Detects SwiftUI NavigationPath state without an explicit restoration contract.',
1998
+ severity: 'WARN',
1999
+ platform: 'ios',
2000
+ locked: true,
2001
+ when: {
2002
+ kind: 'Heuristic',
2003
+ where: {
2004
+ ruleId: 'heuristics.ios.navigation.path-without-restoration.ast',
2005
+ },
2006
+ },
2007
+ then: {
2008
+ kind: 'Finding',
2009
+ message: 'AST heuristic detected NavigationPath without an explicit restoration contract.',
2010
+ code: 'HEURISTICS_IOS_NAVIGATION_PATH_WITHOUT_RESTORATION_AST',
2011
+ },
2012
+ },
1923
2013
  {
1924
2014
  id: 'heuristics.ios.swiftui.untyped-navigation-link-destination.ast',
1925
2015
  description: 'Detects untyped SwiftUI NavigationLink destination usage.',
@@ -63,16 +63,19 @@ const registryByRuleId: Record<string, SkillsDetectorBinding> = {
63
63
  'skills.ios.guideline.ios.mainactor-para-co-digo-que-debe-ejecutarse-en-el-hilo-principal':
64
64
  heuristicDetector('ios.concurrency.mainactor-dispatchqueue', [
65
65
  'heuristics.ios.dispatchqueue.ast',
66
+ 'heuristics.ios.concurrency.ui-state-without-mainactor.ast',
66
67
  ]),
67
68
  'skills.ios.guideline.ios.actor-para-estado-compartido-thread-safe':
68
69
  heuristicDetector('ios.concurrency.shared-state-actor-boundary', [
69
70
  'heuristics.ios.dispatchqueue.ast',
70
71
  'heuristics.ios.dispatchsemaphore.ast',
72
+ 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast',
71
73
  ]),
72
74
  'skills.ios.guideline.ios.sincronizacio-n-nativa-actors-para-estado-compartido-osallocatedunfair':
73
75
  heuristicDetector('ios.concurrency.shared-state-actor-boundary', [
74
76
  'heuristics.ios.dispatchqueue.ast',
75
77
  'heuristics.ios.dispatchsemaphore.ast',
78
+ 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast',
76
79
  ]),
77
80
  'skills.ios.no-dispatchgroup': heuristicDetector('ios.dispatchgroup', [
78
81
  'heuristics.ios.dispatchgroup.ast',
@@ -467,6 +470,7 @@ const registryByRuleId: Record<string, SkillsDetectorBinding> = {
467
470
  heuristicDetector('ios.concurrency.project-settings', [
468
471
  'heuristics.ios.concurrency.strict-concurrency-below-complete.ast',
469
472
  'heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast',
473
+ 'heuristics.ios.concurrency.upcoming-feature-disabled.ast',
470
474
  'heuristics.ios.concurrency.swiftpm-default-isolation-not-mainactor.ast',
471
475
  'heuristics.ios.concurrency.swiftpm-strict-concurrency-below-complete.ast',
472
476
  ]),
@@ -474,6 +478,7 @@ const registryByRuleId: Record<string, SkillsDetectorBinding> = {
474
478
  heuristicDetector('ios.concurrency.project-settings', [
475
479
  'heuristics.ios.concurrency.strict-concurrency-below-complete.ast',
476
480
  'heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast',
481
+ 'heuristics.ios.concurrency.upcoming-feature-disabled.ast',
477
482
  'heuristics.ios.concurrency.swiftpm-default-isolation-not-mainactor.ast',
478
483
  'heuristics.ios.concurrency.swiftpm-strict-concurrency-below-complete.ast',
479
484
  ]),
@@ -519,7 +524,10 @@ const registryByRuleId: Record<string, SkillsDetectorBinding> = {
519
524
  'heuristics.ios.assume-isolated.ast',
520
525
  ]),
521
526
  'skills.ios.guideline.ios.no-usar-mainactor-como-parche-justificar-el-aislamiento':
522
- heuristicDetector('ios.assume-isolated', ['heuristics.ios.assume-isolated.ast']),
527
+ heuristicDetector('ios.assume-isolated', [
528
+ 'heuristics.ios.assume-isolated.ast',
529
+ 'heuristics.ios.concurrency.mainactor-run-patch.ast',
530
+ ]),
523
531
  'skills.ios.no-observable-object': heuristicDetector('ios.observable-object', [
524
532
  'heuristics.ios.observable-object.ast',
525
533
  ]),
@@ -607,6 +615,14 @@ const registryByRuleId: Record<string, SkillsDetectorBinding> = {
607
615
  'skills.ios.no-navigation-view': heuristicDetector('ios.navigation-view', [
608
616
  'heuristics.ios.navigation-view.ast',
609
617
  ]),
618
+ 'skills.ios.guideline.ios.deep-links-deben-mapear-a-rutas-del-path':
619
+ heuristicDetector('ios.navigation.path-restoration', [
620
+ 'heuristics.ios.navigation.path-without-restoration.ast',
621
+ ]),
622
+ 'skills.ios.guideline.ios.state-restoration-rehidratar-path-desde-estado-persistido-si-aplica':
623
+ heuristicDetector('ios.navigation.path-restoration', [
624
+ 'heuristics.ios.navigation.path-without-restoration.ast',
625
+ ]),
610
626
  'skills.ios.guideline.ios-swiftui-expert.use-modern-apis-no-deprecated-modifiers-or-patterns-see-references-mod':
611
627
  heuristicDetector('ios.swiftui.modern-api-replacements', [
612
628
  'heuristics.ios.navigation-view.ast',
@@ -265,16 +265,33 @@ const equivalentRuleFamilies: ReadonlyArray<ReadonlyArray<string>> = [
265
265
  'heuristics.ios.callback-style.ast',
266
266
  ],
267
267
  ['ios.no-gcd', 'skills.ios.no-dispatchqueue', 'heuristics.ios.dispatchqueue.ast'],
268
+ [
269
+ 'skills.ios.guideline.ios.mainactor-para-co-digo-que-debe-ejecutarse-en-el-hilo-principal',
270
+ 'heuristics.ios.concurrency.ui-state-without-mainactor.ast',
271
+ ],
272
+ [
273
+ 'skills.ios.guideline.ios.actor-para-estado-compartido-thread-safe',
274
+ 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast',
275
+ ],
276
+ [
277
+ 'skills.ios.guideline.ios.sincronizacio-n-nativa-actors-para-estado-compartido-osallocatedunfair',
278
+ 'heuristics.ios.concurrency.shared-mutable-state-without-actor.ast',
279
+ ],
268
280
  ['ios.no-gcd', 'skills.ios.no-dispatchgroup', 'heuristics.ios.dispatchgroup.ast'],
269
281
  ['ios.no-gcd', 'skills.ios.no-dispatchsemaphore', 'heuristics.ios.dispatchsemaphore.ast'],
270
282
  ['ios.no-gcd', 'skills.ios.no-operation-queue', 'heuristics.ios.operation-queue.ast'],
271
283
  ['skills.ios.no-task-detached', 'heuristics.ios.task-detached.ast'],
272
284
  ['skills.ios.no-unchecked-sendable', 'heuristics.ios.unchecked-sendable.ast'],
273
285
  ['skills.ios.no-preconcurrency', 'heuristics.ios.preconcurrency.ast'],
286
+ [
287
+ 'skills.ios.guideline.ios.no-usar-mainactor-como-parche-justificar-el-aislamiento',
288
+ 'heuristics.ios.concurrency.mainactor-run-patch.ast',
289
+ ],
274
290
  [
275
291
  'skills.ios.guideline.ios-concurrency.use-grep-for-swiftstrictconcurrency-or-swiftdefaultactorisolation-in-p',
276
292
  'heuristics.ios.concurrency.strict-concurrency-below-complete.ast',
277
293
  'heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast',
294
+ 'heuristics.ios.concurrency.upcoming-feature-disabled.ast',
278
295
  'heuristics.ios.concurrency.swiftpm-default-isolation-not-mainactor.ast',
279
296
  'heuristics.ios.concurrency.swiftpm-strict-concurrency-below-complete.ast',
280
297
  ],
@@ -282,6 +299,7 @@ const equivalentRuleFamilies: ReadonlyArray<ReadonlyArray<string>> = [
282
299
  'skills.ios.guideline.ios.validar-configuracio-n-de-concurrencia-swiftstrictconcurrency-swiftdef',
283
300
  'heuristics.ios.concurrency.strict-concurrency-below-complete.ast',
284
301
  'heuristics.ios.concurrency.default-actor-isolation-not-mainactor.ast',
302
+ 'heuristics.ios.concurrency.upcoming-feature-disabled.ast',
285
303
  'heuristics.ios.concurrency.swiftpm-default-isolation-not-mainactor.ast',
286
304
  'heuristics.ios.concurrency.swiftpm-strict-concurrency-below-complete.ast',
287
305
  ],
@@ -325,6 +343,14 @@ const equivalentRuleFamilies: ReadonlyArray<ReadonlyArray<string>> = [
325
343
  'heuristics.ios.passed-value-state-wrapper.ast',
326
344
  ],
327
345
  ['skills.ios.no-navigation-view', 'heuristics.ios.navigation-view.ast'],
346
+ [
347
+ 'skills.ios.guideline.ios.deep-links-deben-mapear-a-rutas-del-path',
348
+ 'heuristics.ios.navigation.path-without-restoration.ast',
349
+ ],
350
+ [
351
+ 'skills.ios.guideline.ios.state-restoration-rehidratar-path-desde-estado-persistido-si-aplica',
352
+ 'heuristics.ios.navigation.path-without-restoration.ast',
353
+ ],
328
354
  ['skills.ios.no-foreground-color', 'heuristics.ios.foreground-color.ast'],
329
355
  ['skills.ios.no-corner-radius', 'heuristics.ios.corner-radius.ast'],
330
356
  ['skills.ios.no-tab-item', 'heuristics.ios.tab-item.ast'],
@@ -93,7 +93,7 @@ export const appendTrackingActionableContext = (params: {
93
93
  return params.message;
94
94
  };
95
95
 
96
- const toRepoPolicyFinding = (params: {
96
+ export const toRepoPolicyFinding = (params: {
97
97
  code: string;
98
98
  message: string;
99
99
  severity: 'ERROR' | 'WARN';
@@ -104,6 +104,7 @@ const toRepoPolicyFinding = (params: {
104
104
  message: params.message,
105
105
  matchedBy: 'RepoPolicy',
106
106
  source: 'ai_gate:repo_policy',
107
+ ...(params.code === 'EVIDENCE_PREWRITE_WORKTREE_WARN' ? { blocking: false } : {}),
107
108
  });
108
109
 
109
110
  export const collectAiGateRepoPolicyFindings = (params: {
@@ -2019,8 +2019,11 @@ export const prioritizePreWriteAiGateViolations = (
2019
2019
  const rightMatches = right.code === nextAction.reason ? 0 : 1;
2020
2020
  return leftMatches - rightMatches;
2021
2021
  });
2022
+ const blocked = violations.some((violation) => violation.severity === 'ERROR');
2022
2023
  return {
2023
2024
  ...aiGate,
2025
+ status: blocked ? 'BLOCKED' : 'ALLOWED',
2026
+ allowed: !blocked,
2024
2027
  violations,
2025
2028
  };
2026
2029
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.308",
3
+ "version": "6.3.310",
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": {
@@ -61,15 +61,35 @@ const formatLocation = (cause: NonNullable<Extract<PumukiCriticalNotificationEve
61
61
  return line ? `${cause.file}:${line}` : cause.file;
62
62
  };
63
63
 
64
+ const isWorktreeHygieneCause = (
65
+ cause: NonNullable<Extract<PumukiCriticalNotificationEvent, { kind: 'gate.blocked' }>['blockingCauses']>[number]
66
+ ): boolean =>
67
+ cause.code === 'EVIDENCE_PREWRITE_WORKTREE_WARN' ||
68
+ cause.code === 'EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT' ||
69
+ (cause.ruleId ?? '').includes('EVIDENCE_PREWRITE_WORKTREE');
70
+
71
+ const WORKTREE_HYGIENE_REMEDIATION =
72
+ 'Reduce el worktree pendiente a un slice atómico: stagea solo la tarea activa o guarda el resto en stash nombrado, y reejecuta PRE_WRITE.';
73
+
64
74
  const formatBlockingCauseForDialog = (
65
75
  cause: NonNullable<Extract<PumukiCriticalNotificationEvent, { kind: 'gate.blocked' }>['blockingCauses']>[number],
66
76
  index: number
67
77
  ): readonly string[] => {
68
78
  const rule = cause.ruleId ?? cause.code;
69
79
  const problem = stripTechnicalFieldsFromMessage(cause.message) || cause.code;
70
- const remediation = cause.remediation
80
+ const remediation = isWorktreeHygieneCause(cause)
81
+ ? WORKTREE_HYGIENE_REMEDIATION
82
+ : cause.remediation
71
83
  ? normalizeNotificationText(cause.remediation)
72
84
  : 'Corrige la violación indicada y vuelve a intentar el commit.';
85
+ if (isWorktreeHygieneCause(cause)) {
86
+ return [
87
+ `${index + 1}. Aviso: higiene de worktree PRE_WRITE`,
88
+ ' Alcance: repo/worktree',
89
+ ` Detalle: ${truncateNotificationText(problem, 160)}`,
90
+ ` Solución: ${truncateNotificationText(WORKTREE_HYGIENE_REMEDIATION, 180)}`,
91
+ ];
92
+ }
73
93
 
74
94
  return [
75
95
  `${index + 1}. Regla: ${truncateNotificationText(rule, 96)}`,
@@ -79,6 +99,11 @@ const formatBlockingCauseForDialog = (
79
99
  ];
80
100
  };
81
101
 
102
+ const hasOnlyWorktreeHygieneCauses = (
103
+ causes: Extract<PumukiCriticalNotificationEvent, { kind: 'gate.blocked' }>['blockingCauses']
104
+ ): boolean =>
105
+ Boolean(causes?.length) && causes!.every(isWorktreeHygieneCause);
106
+
82
107
  export const buildBlockedDialogPayload = (params: {
83
108
  event: Extract<PumukiCriticalNotificationEvent, { kind: 'gate.blocked' }>;
84
109
  repoRoot: string;
@@ -87,7 +112,9 @@ export const buildBlockedDialogPayload = (params: {
87
112
  const causeCode = params.event.causeCode ?? 'GATE_BLOCKED';
88
113
  const cause = buildBlockingCausesDetails(params.event.blockingCauses)
89
114
  ?? resolveBlockedCauseSummary(params.event, causeCode);
90
- const remediation = params.event.blockingCauses && params.event.blockingCauses.length > 0
115
+ const remediation = hasOnlyWorktreeHygieneCauses(params.event.blockingCauses)
116
+ ? WORKTREE_HYGIENE_REMEDIATION
117
+ : params.event.blockingCauses && params.event.blockingCauses.length > 0
91
118
  ? 'Corrige las violaciones listadas y vuelve a intentar el commit.'
92
119
  : resolveBlockedRemediation(params.event, causeCode);
93
120
  const projectLabel = resolveProjectLabel({
@@ -20,8 +20,10 @@ const BLOCKED_REMEDIATION_BY_CODE: Readonly<Record<string, string>> = {
20
20
  EVIDENCE_BRANCH_MISMATCH: 'La evidencia no corresponde a esta rama. Regenera el receipt/evidencia y vuelve a validar.',
21
21
  EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera la evidencia desde este repositorio y vuelve a validar.',
22
22
  PRE_PUSH_UPSTREAM_MISSING: 'Configura upstream con `git push --set-upstream origin <branch>` y repite PRE_PUSH.',
23
- SDD_SESSION_MISSING: 'Abre la sesión SDD del change activo y repite la validación.',
24
- SDD_SESSION_INVALID: 'Refresca la sesión SDD activa y vuelve a validar esta fase.',
23
+ SDD_SESSION_MISSING:
24
+ 'Abre una sesión SDD válida para el change activo (`pumuki sdd session --open --change=<id>`) y reejecuta PRE_WRITE. Agente IA: STOP hasta que exista esa sesión.',
25
+ SDD_SESSION_INVALID:
26
+ 'Refresca la sesión SDD activa (`pumuki sdd session --refresh --ttl-minutes=90`) y reejecuta PRE_WRITE. Agente IA: STOP hasta que sea válida.',
25
27
  OPENSPEC_MISSING: 'Instala OpenSpec en este repositorio y vuelve a validar el gate.',
26
28
  MCP_ENTERPRISE_RECEIPT_MISSING: 'Genera el receipt enterprise de MCP y vuelve a validar.',
27
29
  BACKEND_AVOID_EXPLICIT_ANY: 'Sustituye `any` por tipos concretos en backend y relanza el gate.',
@@ -31,6 +33,10 @@ const BLOCKED_REMEDIATION_BY_CODE: Readonly<Record<string, string>> = {
31
33
  TRACKING_CANONICAL_SOURCE_CONFLICT: TRACKING_BLOCKED_REMEDIATION,
32
34
  ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES_HIGH:
33
35
  'Ejecuta `pumuki policy reconcile --strict --json` y revalida antes de continuar.',
36
+ EVIDENCE_PREWRITE_WORKTREE_WARN:
37
+ 'Reduce el worktree pendiente a un slice atómico antes del commit: stagea solo la tarea activa o guarda el resto en stash nombrado, y reejecuta PRE_WRITE.',
38
+ EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT:
39
+ 'El worktree supera el límite permitido: divide cambios por scope en commits atómicos o stashes nombrados antes de continuar.',
34
40
  };
35
41
 
36
42
  const BLOCKED_REMEDIATION_MAX_LENGTH_BY_VARIANT: Readonly<Record<BlockedRemediationVariant, number>> = {
@@ -54,6 +60,81 @@ const normalizeBlockedRemediation = (value: string): string =>
54
60
  const resolveFallbackRemediation = (causeCode: string): string =>
55
61
  BLOCKED_REMEDIATION_BY_CODE[causeCode] ?? GENERIC_BLOCKED_REMEDIATION;
56
62
 
63
+ const resolveSddSessionCauseCode = (
64
+ event: Extract<PumukiCriticalNotificationEvent, { kind: 'gate.blocked' }>,
65
+ causeCode: string
66
+ ): 'SDD_SESSION_MISSING' | 'SDD_SESSION_INVALID' | null => {
67
+ if (causeCode === 'SDD_SESSION_MISSING' || causeCode === 'SDD_SESSION_INVALID') {
68
+ return causeCode;
69
+ }
70
+ const searchable = [
71
+ event.causeMessage,
72
+ event.remediation,
73
+ ...(event.blockingCauses ?? []).flatMap((cause) => [
74
+ cause.code,
75
+ cause.ruleId,
76
+ cause.message,
77
+ cause.remediation,
78
+ ]),
79
+ ]
80
+ .filter((value): value is string => typeof value === 'string')
81
+ .join(' ')
82
+ .toLowerCase();
83
+ if (
84
+ searchable.includes('sdd_session_missing') ||
85
+ searchable.includes('no hay sesión sdd activa') ||
86
+ searchable.includes('no hay sesion sdd activa') ||
87
+ searchable.includes('no active sdd session')
88
+ ) {
89
+ return 'SDD_SESSION_MISSING';
90
+ }
91
+ if (
92
+ searchable.includes('sdd_session_invalid') ||
93
+ searchable.includes('sesión sdd actual no es válida') ||
94
+ searchable.includes('sesion sdd actual no es valida') ||
95
+ searchable.includes('invalid sdd session')
96
+ ) {
97
+ return 'SDD_SESSION_INVALID';
98
+ }
99
+ return null;
100
+ };
101
+
102
+ const resolveWorktreeHygieneCauseCode = (
103
+ event: Extract<PumukiCriticalNotificationEvent, { kind: 'gate.blocked' }>,
104
+ causeCode: string
105
+ ): 'EVIDENCE_PREWRITE_WORKTREE_WARN' | 'EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT' | null => {
106
+ if (
107
+ causeCode === 'EVIDENCE_PREWRITE_WORKTREE_WARN' ||
108
+ causeCode === 'EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT'
109
+ ) {
110
+ return causeCode;
111
+ }
112
+ const searchable = [
113
+ event.causeMessage,
114
+ event.remediation,
115
+ ...(event.blockingCauses ?? []).flatMap((cause) => [
116
+ cause.code,
117
+ cause.ruleId,
118
+ cause.message,
119
+ cause.remediation,
120
+ ]),
121
+ ]
122
+ .filter((value): value is string => typeof value === 'string')
123
+ .join(' ')
124
+ .toLowerCase();
125
+ if (searchable.includes('evidence_prewrite_worktree_over_limit')) {
126
+ return 'EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT';
127
+ }
128
+ if (
129
+ searchable.includes('evidence_prewrite_worktree_warn') ||
130
+ searchable.includes('worktree hygiene warning') ||
131
+ searchable.includes('warn_threshold=')
132
+ ) {
133
+ return 'EVIDENCE_PREWRITE_WORKTREE_WARN';
134
+ }
135
+ return null;
136
+ };
137
+
57
138
  const isGenericPolicyReconcileRemediation = (message: string): boolean => {
58
139
  const normalized = message.toLowerCase();
59
140
  return normalized.includes('policy reconcile') && normalized.includes('sdd validate');
@@ -105,6 +186,20 @@ export const resolveBlockedRemediation = (
105
186
  ): string => {
106
187
  const variant = options?.variant ?? 'dialog';
107
188
  const maxLength = BLOCKED_REMEDIATION_MAX_LENGTH_BY_VARIANT[variant];
189
+ const sddSessionCauseCode = resolveSddSessionCauseCode(event, causeCode);
190
+ if (sddSessionCauseCode) {
191
+ return truncateNotificationText(
192
+ resolveFallbackRemediation(sddSessionCauseCode),
193
+ maxLength
194
+ );
195
+ }
196
+ const worktreeHygieneCauseCode = resolveWorktreeHygieneCauseCode(event, causeCode);
197
+ if (worktreeHygieneCauseCode) {
198
+ return truncateNotificationText(
199
+ resolveFallbackRemediation(worktreeHygieneCauseCode),
200
+ maxLength
201
+ );
202
+ }
108
203
  const blockingCausesCount = event.blockingCauses?.length ?? 0;
109
204
  if (blockingCausesCount > 1) {
110
205
  return truncateNotificationText(