pumuki 6.3.270 → 6.3.271

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/VERSION +1 -1
  3. package/core/facts/detectors/text/android.test.ts +538 -0
  4. package/core/facts/detectors/text/android.ts +436 -0
  5. package/core/facts/detectors/text/ios.test.ts +328 -1
  6. package/core/facts/detectors/text/ios.ts +241 -0
  7. package/core/facts/detectors/typescript/index.test.ts +393 -0
  8. package/core/facts/detectors/typescript/index.ts +316 -0
  9. package/core/facts/extractHeuristicFacts.ts +70 -1
  10. package/core/rules/presets/heuristics/android.test.ts +91 -1
  11. package/core/rules/presets/heuristics/android.ts +360 -0
  12. package/core/rules/presets/heuristics/ios.test.ts +54 -1
  13. package/core/rules/presets/heuristics/ios.ts +243 -2
  14. package/core/rules/presets/heuristics/typescript.test.ts +50 -2
  15. package/core/rules/presets/heuristics/typescript.ts +162 -0
  16. package/docs/operations/RELEASE_NOTES.md +4 -0
  17. package/integrations/config/skillsDetectorRegistry.ts +501 -0
  18. package/integrations/config/skillsRuleClassification.ts +127 -3
  19. package/integrations/git/runPlatformGate.ts +4 -1
  20. package/integrations/lifecycle/preWriteAutomation.ts +1 -0
  21. package/integrations/lifecycle/preWriteLease.ts +41 -4
  22. package/package.json +1 -1
  23. package/scripts/classify-skills-rules.ts +2 -2
  24. package/scripts/framework-menu-consumer-actions-lib.ts +9 -9
  25. package/scripts/framework-menu-consumer-runtime-actions.ts +53 -117
  26. package/scripts/framework-menu-consumer-runtime-audit.ts +66 -0
  27. package/scripts/framework-menu-consumer-runtime-menu.ts +4 -4
  28. package/scripts/framework-menu-gate-lib.ts +86 -1
  29. package/scripts/framework-menu-layout-data.ts +3 -3
  30. package/scripts/framework-menu-legacy-audit-render-sections.ts +6 -0
  31. package/scripts/framework-menu.ts +10 -6
  32. package/scripts/package-install-smoke-consumer-npm-lib.ts +10 -4
  33. package/scripts/package-install-smoke-lifecycle-lib.ts +19 -0
@@ -20,6 +20,15 @@ const concreteDependencyNames = new Set<string>([
20
20
  'Axios',
21
21
  ]);
22
22
  const networkCallCalleePattern = /^(fetch|axios|get|post|put|patch|delete|request)$/i;
23
+ const unsafeSqlCallNames = new Set<string>([
24
+ 'query',
25
+ 'raw',
26
+ 'execute',
27
+ '$queryRawUnsafe',
28
+ '$executeRawUnsafe',
29
+ ]);
30
+ const urlSensitiveQueryParamPattern =
31
+ /[?&](?:access_token|auth_token|api[_-]?key|client_secret|id_token|jwt|password|secret|session[_-]?token|token)=/i;
23
32
  type AstNode = Record<string, string | number | boolean | bigint | symbol | null | Date | object>;
24
33
  type TypeScriptSemanticNode = {
25
34
  kind: 'class' | 'property' | 'call' | 'member';
@@ -201,6 +210,27 @@ const hasNodeWithAncestors = (
201
210
  return matched;
202
211
  };
203
212
 
213
+ const isFunctionExpressionNode = (node: unknown): node is AstNode => {
214
+ return (
215
+ isObject(node) &&
216
+ (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression')
217
+ );
218
+ };
219
+
220
+ const isCallbackArgument = (
221
+ node: AstNode,
222
+ ancestors: ReadonlyArray<AstNode>
223
+ ): boolean => {
224
+ const parent = ancestors[ancestors.length - 1];
225
+ return (
226
+ isFunctionExpressionNode(node) &&
227
+ isObject(parent) &&
228
+ parent.type === 'CallExpression' &&
229
+ Array.isArray(parent.arguments) &&
230
+ parent.arguments.includes(node)
231
+ );
232
+ };
233
+
204
234
  const methodNameFromNode = (node: unknown) => {
205
235
  if (!isObject(node)) {
206
236
  return undefined;
@@ -231,6 +261,30 @@ const memberExpressionPropertyName = (node: unknown) => {
231
261
  return methodNameFromNode(node.property);
232
262
  };
233
263
 
264
+ const memberExpressionObjectName = (node: unknown) => {
265
+ if (!isObject(node) || node.type !== 'MemberExpression') {
266
+ return undefined;
267
+ }
268
+ const objectNode = node.object;
269
+ if (!isObject(objectNode) || objectNode.type !== 'Identifier') {
270
+ return undefined;
271
+ }
272
+ return typeof objectNode.name === 'string' ? objectNode.name : undefined;
273
+ };
274
+
275
+ const numericLiteralValue = (node: unknown): number | undefined => {
276
+ if (!isObject(node)) {
277
+ return undefined;
278
+ }
279
+ if (node.type === 'NumericLiteral' && typeof node.value === 'number') {
280
+ return node.value;
281
+ }
282
+ if (node.type === 'Literal' && typeof node.value === 'number') {
283
+ return node.value;
284
+ }
285
+ return undefined;
286
+ };
287
+
234
288
  const findFirstNode = (
235
289
  node: unknown,
236
290
  predicate: (value: AstNode) => boolean
@@ -338,6 +392,124 @@ const literalTextFromNode = (node: unknown) => {
338
392
  return undefined;
339
393
  };
340
394
 
395
+ const stringFragmentsFromNode = (node: unknown): readonly string[] => {
396
+ if (!isObject(node)) {
397
+ return [];
398
+ }
399
+ if (node.type === 'StringLiteral' && typeof node.value === 'string') {
400
+ return [node.value];
401
+ }
402
+ if (node.type === 'TemplateLiteral' && Array.isArray(node.quasis)) {
403
+ return node.quasis
404
+ .map((quasi) => {
405
+ if (!isObject(quasi) || !isObject(quasi.value)) {
406
+ return '';
407
+ }
408
+ if (typeof quasi.value.cooked === 'string') {
409
+ return quasi.value.cooked;
410
+ }
411
+ return typeof quasi.value.raw === 'string' ? quasi.value.raw : '';
412
+ })
413
+ .filter((fragment) => fragment.length > 0);
414
+ }
415
+ return [];
416
+ };
417
+
418
+ const hasSensitiveQueryParam = (node: unknown): boolean =>
419
+ stringFragmentsFromNode(node).some((fragment) => urlSensitiveQueryParamPattern.test(fragment));
420
+
421
+ const jsxElementName = (node: unknown): string | undefined => {
422
+ if (!isObject(node)) {
423
+ return undefined;
424
+ }
425
+ if (node.type === 'JSXIdentifier') {
426
+ return typeof node.name === 'string' ? node.name : undefined;
427
+ }
428
+ return undefined;
429
+ };
430
+
431
+ const jsxAttributeName = (node: unknown): string | undefined => {
432
+ if (!isObject(node) || node.type !== 'JSXAttribute') {
433
+ return undefined;
434
+ }
435
+ return jsxElementName(node.name);
436
+ };
437
+
438
+ const hasJsxAttribute = (node: AstNode, names: ReadonlySet<string>): boolean => {
439
+ if (!Array.isArray(node.attributes)) {
440
+ return false;
441
+ }
442
+ return node.attributes.some((attribute) => {
443
+ const name = jsxAttributeName(attribute);
444
+ return typeof name === 'string' && names.has(name);
445
+ });
446
+ };
447
+
448
+ const jsxAttributeLiteralValue = (node: unknown): string | undefined => {
449
+ if (!isObject(node)) {
450
+ return undefined;
451
+ }
452
+ if (node.type === 'StringLiteral' || node.type === 'Literal') {
453
+ return typeof node.value === 'string' ? node.value : undefined;
454
+ }
455
+ if (node.type !== 'JSXExpressionContainer') {
456
+ return undefined;
457
+ }
458
+ const expression = node.expression;
459
+ if (!isObject(expression)) {
460
+ return undefined;
461
+ }
462
+ if (expression.type === 'StringLiteral' || expression.type === 'Literal') {
463
+ return typeof expression.value === 'string' ? expression.value : undefined;
464
+ }
465
+ return undefined;
466
+ };
467
+
468
+ const jsxAttributeValue = (node: AstNode, name: string): string | undefined => {
469
+ if (!Array.isArray(node.attributes)) {
470
+ return undefined;
471
+ }
472
+ for (const attribute of node.attributes) {
473
+ if (!isObject(attribute) || jsxAttributeName(attribute) !== name) {
474
+ continue;
475
+ }
476
+ return jsxAttributeLiteralValue(attribute.value);
477
+ }
478
+ return undefined;
479
+ };
480
+
481
+ const hasDescendantNode = (
482
+ node: unknown,
483
+ predicate: (value: AstNode) => boolean
484
+ ): boolean => {
485
+ let matched = false;
486
+
487
+ const walk = (value: unknown): void => {
488
+ if (!isObject(value) || matched) {
489
+ return;
490
+ }
491
+ if (predicate(value)) {
492
+ matched = true;
493
+ return;
494
+ }
495
+ for (const child of Object.values(value)) {
496
+ if (matched) {
497
+ return;
498
+ }
499
+ if (Array.isArray(child)) {
500
+ for (const entry of child) {
501
+ walk(entry);
502
+ }
503
+ continue;
504
+ }
505
+ walk(child);
506
+ }
507
+ };
508
+
509
+ walk(node);
510
+ return matched;
511
+ };
512
+
341
513
  const hasMixedCommandAndQueryNames = (methodNames: ReadonlyArray<string>): boolean => {
342
514
  let hasCommand = false;
343
515
  let hasQuery = false;
@@ -1547,6 +1719,98 @@ export const hasAsyncPromiseExecutor = (node: unknown): boolean => {
1547
1719
  });
1548
1720
  };
1549
1721
 
1722
+ export const hasWeakBcryptSaltRounds = (node: unknown): boolean => {
1723
+ return hasNode(node, (value) => {
1724
+ if (value.type !== 'CallExpression' || !Array.isArray(value.arguments)) {
1725
+ return false;
1726
+ }
1727
+ const callee = value.callee;
1728
+ const objectName = memberExpressionObjectName(callee);
1729
+ const propertyName = memberExpressionPropertyName(callee);
1730
+ if (objectName !== 'bcrypt' || (propertyName !== 'hash' && propertyName !== 'hashSync')) {
1731
+ return false;
1732
+ }
1733
+ const saltRounds = numericLiteralValue(value.arguments[1]);
1734
+ return typeof saltRounds === 'number' && saltRounds < 10;
1735
+ });
1736
+ };
1737
+
1738
+ export const hasInterpolatedUnsafeSqlCall = (node: unknown): boolean => {
1739
+ return hasNode(node, (value) => {
1740
+ if (value.type !== 'CallExpression' || !Array.isArray(value.arguments)) {
1741
+ return false;
1742
+ }
1743
+ const calleeName = methodNameFromNode(value.callee) ?? memberExpressionPropertyName(value.callee);
1744
+ if (typeof calleeName !== 'string' || !unsafeSqlCallNames.has(calleeName)) {
1745
+ return false;
1746
+ }
1747
+ const firstArg = value.arguments[0];
1748
+ return (
1749
+ isObject(firstArg) &&
1750
+ firstArg.type === 'TemplateLiteral' &&
1751
+ Array.isArray(firstArg.expressions) &&
1752
+ firstArg.expressions.length > 0
1753
+ );
1754
+ });
1755
+ };
1756
+
1757
+ export const hasSensitiveTokenInUrl = (node: unknown): boolean => {
1758
+ return hasNode(node, (value) => {
1759
+ if (value.type !== 'CallExpression' || !Array.isArray(value.arguments)) {
1760
+ return false;
1761
+ }
1762
+ const calleeName = methodNameFromNode(value.callee) ?? memberExpressionPropertyName(value.callee);
1763
+ if (typeof calleeName !== 'string' || !networkCallCalleePattern.test(calleeName)) {
1764
+ return false;
1765
+ }
1766
+ return hasSensitiveQueryParam(value.arguments[0]);
1767
+ });
1768
+ };
1769
+
1770
+ export const hasNestedCallbackUsage = (node: unknown): boolean => {
1771
+ return hasNodeWithAncestors(node, (value, ancestors) => {
1772
+ if (!isCallbackArgument(value, ancestors)) {
1773
+ return false;
1774
+ }
1775
+ return ancestors.some((ancestor, index) =>
1776
+ isCallbackArgument(ancestor, ancestors.slice(0, index))
1777
+ );
1778
+ });
1779
+ };
1780
+
1781
+ export const hasNestedIfElseStatement = (node: unknown): boolean => {
1782
+ return hasNode(node, (value) => {
1783
+ if (value.type !== 'IfStatement' || !isObject(value.alternate)) {
1784
+ return false;
1785
+ }
1786
+ if (value.alternate.type === 'IfStatement') {
1787
+ return true;
1788
+ }
1789
+ return (
1790
+ hasDescendantNode(value.consequent, (nested) => nested !== value && nested.type === 'IfStatement') ||
1791
+ hasDescendantNode(value.alternate, (nested) => nested !== value && nested.type === 'IfStatement')
1792
+ );
1793
+ });
1794
+ };
1795
+
1796
+ export const hasDefaultExportedApiRouteHandler = (node: unknown): boolean => {
1797
+ return hasNode(node, (value) => {
1798
+ if (value.type !== 'ExportDefaultDeclaration') {
1799
+ return false;
1800
+ }
1801
+ const declaration = value.declaration;
1802
+ if (!isObject(declaration)) {
1803
+ return false;
1804
+ }
1805
+ return (
1806
+ declaration.type === 'FunctionDeclaration' ||
1807
+ declaration.type === 'ArrowFunctionExpression' ||
1808
+ declaration.type === 'FunctionExpression' ||
1809
+ declaration.type === 'Identifier'
1810
+ );
1811
+ });
1812
+ };
1813
+
1550
1814
  export const hasWithStatement = (node: unknown): boolean => {
1551
1815
  return hasNode(node, (value) => value.type === 'WithStatement');
1552
1816
  };
@@ -1949,6 +2213,58 @@ const hasNetworkCallExpression = (node: unknown): boolean => {
1949
2213
  return hasNode(node, isNetworkCallExpressionNode);
1950
2214
  };
1951
2215
 
2216
+ export const hasDirectNetworkCall = (node: unknown): boolean => {
2217
+ return hasNetworkCallExpression(node);
2218
+ };
2219
+
2220
+ export const hasNonSemanticClickableJsx = (node: unknown): boolean => {
2221
+ const nonSemanticClickableElements = new Set(['div', 'span', 'section', 'article', 'li']);
2222
+ const clickAttributes = new Set(['onClick', 'onMouseDown', 'onPointerDown']);
2223
+ const keyboardAttributes = new Set(['onKeyDown', 'onKeyUp', 'tabIndex', 'role']);
2224
+
2225
+ return hasNode(node, (value) => {
2226
+ if (value.type !== 'JSXOpeningElement') {
2227
+ return false;
2228
+ }
2229
+ const name = jsxElementName(value.name);
2230
+ if (typeof name !== 'string' || !nonSemanticClickableElements.has(name)) {
2231
+ return false;
2232
+ }
2233
+ return hasJsxAttribute(value, clickAttributes) && !hasJsxAttribute(value, keyboardAttributes);
2234
+ });
2235
+ };
2236
+
2237
+ export const hasRedundantAriaRoleOnSemanticJsx = (node: unknown): boolean => {
2238
+ const semanticRoleByElement = new Map([
2239
+ ['button', 'button'],
2240
+ ['nav', 'navigation'],
2241
+ ['main', 'main'],
2242
+ ['form', 'form'],
2243
+ ['section', 'region'],
2244
+ ['article', 'article'],
2245
+ ['aside', 'complementary'],
2246
+ ['header', 'banner'],
2247
+ ['footer', 'contentinfo'],
2248
+ ['ul', 'list'],
2249
+ ['ol', 'list'],
2250
+ ]);
2251
+
2252
+ return hasNode(node, (value) => {
2253
+ if (value.type !== 'JSXOpeningElement') {
2254
+ return false;
2255
+ }
2256
+ const name = jsxElementName(value.name);
2257
+ if (typeof name !== 'string') {
2258
+ return false;
2259
+ }
2260
+ const expectedRole = semanticRoleByElement.get(name);
2261
+ if (typeof expectedRole !== 'string') {
2262
+ return false;
2263
+ }
2264
+ return jsxAttributeValue(value, 'role') === expectedRole;
2265
+ });
2266
+ };
2267
+
1952
2268
  const isNetworkCallHandledInsideTryCatch = (ancestors: ReadonlyArray<AstNode>): boolean => {
1953
2269
  for (let index = 0; index < ancestors.length; index += 1) {
1954
2270
  const ancestor = ancestors[index];
@@ -102,6 +102,14 @@ const isIOSLocalizableStringsPath = (path: string): boolean => {
102
102
  return normalized.startsWith('apps/ios/') && normalized.endsWith('/Localizable.strings');
103
103
  };
104
104
 
105
+ const isIOSInterfaceBuilderPath = (path: string): boolean => {
106
+ const normalized = path.replace(/\\/g, '/');
107
+ return (
108
+ normalized.startsWith('apps/ios/') &&
109
+ (normalized.endsWith('.storyboard') || normalized.endsWith('.xib'))
110
+ );
111
+ };
112
+
105
113
  const isIOSApplicationOrPresentationPath = (path: string): boolean => {
106
114
  return (
107
115
  isIOSSwiftPath(path) &&
@@ -117,6 +125,13 @@ const isAndroidKotlinPath = (path: string): boolean => {
117
125
  return (path.endsWith('.kt') || path.endsWith('.kts')) && path.startsWith('apps/android/');
118
126
  };
119
127
 
128
+ const isAndroidSourcePath = (path: string): boolean => {
129
+ return (
130
+ (path.endsWith('.kt') || path.endsWith('.kts') || path.endsWith('.java')) &&
131
+ path.startsWith('apps/android/')
132
+ );
133
+ };
134
+
120
135
  const isAndroidLocalPropertiesPath = (path: string): boolean => {
121
136
  const normalized = path.replace(/\\/g, '/').toLowerCase();
122
137
  return normalized.startsWith('apps/android/') && normalized.endsWith('/local.properties');
@@ -210,6 +225,20 @@ const isTypeScriptNetworkResiliencePath = (path: string): boolean => {
210
225
  return !/\/(infrastructure\/ast|analyzers?|detectors?|scanner)\//i.test(normalized);
211
226
  };
212
227
 
228
+ const isFrontendTestPath = (path: string): boolean =>
229
+ (path.startsWith('apps/frontend/') || path.startsWith('apps/web/')) && isTestPath(path);
230
+
231
+ const isNextPagesApiPath = (path: string): boolean => {
232
+ if (!isTypeScriptHeuristicTargetPath(path)) {
233
+ return false;
234
+ }
235
+ const normalized = path.replace(/\\/g, '/');
236
+ return (
237
+ normalized.includes('/pages/api/') &&
238
+ (normalized.endsWith('.ts') || normalized.endsWith('.tsx'))
239
+ );
240
+ };
241
+
213
242
  const isWorkflowImplementationPath = (path: string): boolean => {
214
243
  const normalized = path.replace(/\\/g, '/');
215
244
  const lower = normalized.toLowerCase();
@@ -446,6 +475,15 @@ const astDetectorRegistry: ReadonlyArray<ASTDetectorRegistryEntry> = [
446
475
  { detect: TS.hasSetTimeoutStringCallback, ruleId: 'heuristics.ts.set-timeout-string.ast', code: 'HEURISTICS_SET_TIMEOUT_STRING_AST', message: 'AST heuristic detected setTimeout with a string callback.' },
447
476
  { detect: TS.hasSetIntervalStringCallback, ruleId: 'heuristics.ts.set-interval-string.ast', code: 'HEURISTICS_SET_INTERVAL_STRING_AST', message: 'AST heuristic detected setInterval with a string callback.' },
448
477
  { detect: TS.hasAsyncPromiseExecutor, ruleId: 'heuristics.ts.new-promise-async.ast', code: 'HEURISTICS_NEW_PROMISE_ASYNC_AST', message: 'AST heuristic detected async Promise executor usage.' },
478
+ { detect: TS.hasWeakBcryptSaltRounds, ruleId: 'heuristics.ts.bcrypt-weak-salt-rounds.ast', code: 'HEURISTICS_BCRYPT_WEAK_SALT_ROUNDS_AST', message: 'AST heuristic detected bcrypt salt rounds below 10.' },
479
+ { detect: TS.hasInterpolatedUnsafeSqlCall, ruleId: 'heuristics.ts.sql-interpolated-unsafe-call.ast', code: 'HEURISTICS_SQL_INTERPOLATED_UNSAFE_CALL_AST', message: 'AST heuristic detected interpolated SQL passed to an unsafe query/raw call.' },
480
+ { detect: TS.hasSensitiveTokenInUrl, ruleId: 'heuristics.ts.sensitive-token-in-url.ast', code: 'HEURISTICS_SENSITIVE_TOKEN_IN_URL_AST', message: 'AST heuristic detected sensitive token or credential in a network URL.' },
481
+ { detect: TS.hasDirectNetworkCall, ruleId: 'heuristics.ts.frontend-test-direct-network-call.ast', code: 'HEURISTICS_FRONTEND_TEST_DIRECT_NETWORK_CALL_AST', message: 'AST heuristic detected direct fetch/axios/request usage in a frontend test; use MSW handlers instead.', pathCheck: isFrontendTestPath, includeTestPaths: true },
482
+ { detect: TS.hasNonSemanticClickableJsx, ruleId: 'heuristics.tsx.non-semantic-clickable.ast', code: 'HEURISTICS_TSX_NON_SEMANTIC_CLICKABLE_AST', message: 'AST heuristic detected non-semantic clickable JSX without keyboard accessibility.' },
483
+ { detect: TS.hasRedundantAriaRoleOnSemanticJsx, ruleId: 'heuristics.tsx.redundant-aria-role.ast', code: 'HEURISTICS_TSX_REDUNDANT_ARIA_ROLE_AST', message: 'AST heuristic detected redundant ARIA role on semantic JSX.' },
484
+ { detect: TS.hasNestedCallbackUsage, ruleId: 'heuristics.ts.callback-hell.ast', code: 'HEURISTICS_CALLBACK_HELL_AST', message: 'AST heuristic detected nested callback usage; prefer async/await or promise composition.' },
485
+ { detect: TS.hasNestedIfElseStatement, ruleId: 'heuristics.ts.nested-if-else.ast', code: 'HEURISTICS_NESTED_IF_ELSE_AST', message: 'AST heuristic detected nested if/else control flow; prefer early returns.' },
486
+ { detect: TS.hasDefaultExportedApiRouteHandler, ruleId: 'heuristics.ts.next-pages-api-route.ast', code: 'HEURISTICS_NEXT_PAGES_API_ROUTE_AST', message: 'AST heuristic detected a legacy Next.js pages/api default route handler; use app/api route handlers.', pathCheck: isNextPagesApiPath },
449
487
  { detect: TS.hasWithStatement, ruleId: 'heuristics.ts.with-statement.ast', code: 'HEURISTICS_WITH_STATEMENT_AST', message: 'AST heuristic detected with-statement usage.' },
450
488
  { detect: TS.hasDeleteOperator, ruleId: 'heuristics.ts.delete-operator.ast', code: 'HEURISTICS_DELETE_OPERATOR_AST', message: 'AST heuristic detected delete-operator usage.' },
451
489
  { detect: TS.hasDebuggerStatement, ruleId: 'heuristics.ts.debugger.ast', code: 'HEURISTICS_DEBUGGER_AST', message: 'AST heuristic detected debugger statement usage.' },
@@ -644,9 +682,12 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
644
682
  { platform: 'ios', pathCheck: isIOSCartfilePath, excludePaths: [], detect: detectsTrackedFilePresence, ruleId: 'heuristics.ios.dependencies.carthage.ast', code: 'HEURISTICS_IOS_DEPENDENCIES_CARTHAGE_AST', message: 'AST heuristic detected Carthage dependency files in an iOS project; Swift Package Manager remains the preferred baseline for new code.' },
645
683
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftForceUnwrap, locateLines: TextIOS.collectSwiftForceUnwrapLines, primaryNode: (lines) => ({ kind: 'member', name: 'force unwrap postfix !', lines }), relatedNodes: (lines) => [{ kind: 'member', name: 'replacement: guarded optional binding or explicit failure path', lines }], why: 'Force unwrap turns optional handling into a runtime crash path instead of a checked domain, UI or infrastructure decision.', impact: 'A nil value can terminate the app outside the error boundary, making production behavior non-deterministic and hard to recover or test.', expected_fix: 'Replace postfix ! with guard let, if let, nil coalescing, throwing validation, or an explicit fallback. In modern Swift tests prefer #require when the unwrap is part of an assertion contract.', ruleId: 'heuristics.ios.force-unwrap.ast', code: 'HEURISTICS_IOS_FORCE_UNWRAP_AST', message: 'AST heuristic detected force unwrap usage.' },
646
684
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftAnyViewUsage, locateLines: TextIOS.collectSwiftAnyViewLines, primaryNode: (lines) => ({ kind: 'call', name: 'type erasure wrapper AnyView', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: concrete View composition or @ViewBuilder branch', lines }], why: 'AnyView erases SwiftUI view identity and type information, hiding structural changes from the compiler and making diffing less predictable.', impact: 'SwiftUI may lose optimization opportunities, navigation/sheet branches become harder to reason about, and remediating UI regressions requires reading dynamic wrappers instead of concrete view composition.', expected_fix: 'Replace AnyView with concrete some View composition, @ViewBuilder branching, generic View parameters, or small extracted subviews that preserve static SwiftUI identity.', ruleId: 'heuristics.ios.anyview.ast', code: 'HEURISTICS_IOS_ANYVIEW_AST', message: 'AST heuristic detected AnyView usage.' },
685
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftAnyTypeErasureUsage, locateLines: TextIOS.collectSwiftAnyTypeErasureLines, primaryNode: (lines) => ({ kind: 'property', name: 'Swift Any/AnyObject/AnyHashable type erasure', lines }), relatedNodes: (lines) => [{ kind: 'member', name: 'replacement: generics, associated types, protocol boundary or concrete domain type', lines }], why: 'General Swift type erasure hides domain contracts that should be expressed with generics, associated types or explicit protocol boundaries.', impact: 'Callers lose compile-time guarantees, invalid states travel through the codebase and remediation becomes runtime/debug driven instead of type-system driven.', expected_fix: 'Replace Any, AnyObject or AnyHashable usage with a generic parameter, associated type, concrete value object or narrow protocol boundary.', ruleId: 'heuristics.ios.type-erasure.any.ast', code: 'HEURISTICS_IOS_TYPE_ERASURE_ANY_AST', message: 'AST heuristic detected Swift Any/AnyObject/AnyHashable type erasure in production code.' },
647
686
  { 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.' },
648
687
  { 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.' },
649
688
  { 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.' },
689
+ { 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.' },
690
+ { 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.' },
650
691
  { 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.' },
651
692
  { 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.' },
652
693
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftDispatchSemaphoreUsage, locateLines: TextIOS.collectSwiftDispatchSemaphoreLines, primaryNode: (lines) => ({ kind: 'call', name: 'GCD DispatchSemaphore call', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: structured concurrency TaskGroup, AsyncStream or explicit async boundary', lines }], why: 'DispatchSemaphore is a blocking synchronization primitive that hides ordering and backpressure behind manual waits instead of Swift concurrency boundaries.', impact: 'Semaphore waits can stall threads, obscure cancellation and create deadlock-prone coordination in production code paths that should remain async.', expected_fix: 'Use TaskGroup, AsyncStream, async/await or explicit async boundaries. Keep DispatchSemaphore only inside approved legacy bridge layers with documented ownership and bounded waiting.', ruleId: 'heuristics.ios.dispatchsemaphore.ast', code: 'HEURISTICS_IOS_DISPATCHSEMAPHORE_AST', message: 'AST heuristic detected DispatchSemaphore usage.' },
@@ -656,8 +697,12 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
656
697
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftEmptyCatchUsage, ruleId: 'heuristics.ios.error.empty-catch.ast', code: 'HEURISTICS_IOS_ERROR_EMPTY_CATCH_AST', message: 'AST heuristic detected an empty Swift catch block; handle, log, or propagate the error.' },
657
698
  { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnAppearTaskUsage, ruleId: 'heuristics.ios.swiftui.onappear-task.ast', code: 'HEURISTICS_IOS_SWIFTUI_ONAPPEAR_TASK_AST', message: 'AST heuristic detected Task launched from SwiftUI onAppear; .task/.task(id:) provides lifecycle-aware cancellation.' },
658
699
  { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnChangeTaskUsage, ruleId: 'heuristics.ios.swiftui.onchange-task.ast', code: 'HEURISTICS_IOS_SWIFTUI_ONCHANGE_TASK_AST', message: 'AST heuristic detected Task launched from SwiftUI onChange; .task(id:) provides lifecycle-aware cancellation for value-dependent async work.' },
700
+ { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnChangeReadonlyVarUsage, locateLines: TextIOS.collectSwiftOnChangeReadonlyVarLines, primaryNode: (lines) => ({ kind: 'property', name: 'var declared inside SwiftUI onChange closure', lines }), relatedNodes: (lines) => [{ kind: 'property', name: 'replacement: let for read-only derived value inside onChange', lines }], why: 'A local var inside onChange hides whether the closure is deriving a read-only value or mutating state as part of a reactive update.', impact: 'Reactive closures become harder to audit because unnecessary mutability can mask accidental state changes and value-dependent side effects.', expected_fix: 'Use let for read-only derived values inside onChange. Keep var only when the local value is intentionally mutated and extract complex mutation out of the view closure.', ruleId: 'heuristics.ios.swiftui.onchange-readonly-var.ast', code: 'HEURISTICS_IOS_SWIFTUI_ONCHANGE_READONLY_VAR_AST', message: 'AST heuristic detected local var inside SwiftUI onChange; prefer let for read-only derived values.' },
659
701
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftStrongDelegateReferenceUsage, ruleId: 'heuristics.ios.memory.strong-delegate.ast', code: 'HEURISTICS_IOS_MEMORY_STRONG_DELEGATE_AST', message: 'AST heuristic detected a strong delegate/dataSource reference; weak delegates remain the preferred baseline to avoid retain cycles.' },
660
702
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftStrongSelfEscapingClosureUsage, ruleId: 'heuristics.ios.memory.strong-self-escaping-closure.ast', code: 'HEURISTICS_IOS_MEMORY_STRONG_SELF_ESCAPING_CLOSURE_AST', message: 'AST heuristic detected strong self capture in an escaping iOS closure; weak or unowned captures remain the preferred baseline when ownership is not explicit.' },
703
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftUnownedSelfCaptureUsage, ruleId: 'heuristics.ios.memory.unowned-self-capture.ast', code: 'HEURISTICS_IOS_MEMORY_UNOWNED_SELF_CAPTURE_AST', message: 'AST heuristic detected unowned capture in an iOS closure; use weak capture unless lifetime is explicitly guaranteed.' },
704
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftNestedIfPyramidUsage, ruleId: 'heuristics.ios.maintainability.nested-if-pyramid.ast', code: 'HEURISTICS_IOS_MAINTAINABILITY_NESTED_IF_PYRAMID_AST', message: 'AST heuristic detected nested if pyramid in iOS code; prefer guard clauses and early returns.' },
705
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftProductionCommentUsage, ruleId: 'heuristics.ios.maintainability.comment-trivia.ast', code: 'HEURISTICS_IOS_MAINTAINABILITY_COMMENT_TRIVIA_AST', message: 'AST heuristic detected source comments in iOS production code; prefer self-documenting names and extracted concepts.' },
661
706
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftCustomSingletonUsage, ruleId: 'heuristics.ios.architecture.custom-singleton.ast', code: 'HEURISTICS_IOS_ARCHITECTURE_CUSTOM_SINGLETON_AST', message: 'AST heuristic detected a custom static shared singleton in iOS production code; dependency injection remains the preferred baseline for app-owned services.' },
662
707
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftSwinjectUsage, ruleId: 'heuristics.ios.architecture.swinject.ast', code: 'HEURISTICS_IOS_ARCHITECTURE_SWINJECT_AST', message: 'AST heuristic detected Swinject usage; manual dependency injection or SwiftUI Environment remain the preferred native baseline.' },
663
708
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftMassiveViewControllerResponsibilityUsage, ruleId: 'heuristics.ios.architecture.massive-view-controller.ast', code: 'HEURISTICS_IOS_ARCHITECTURE_MASSIVE_VIEW_CONTROLLER_AST', message: 'AST heuristic detected a UIViewController with direct infrastructure/data access; move data access behind application/domain boundaries.' },
@@ -673,18 +718,24 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
673
718
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftInsecureTransportUsage, ruleId: 'heuristics.ios.security.insecure-transport.ast', code: 'HEURISTICS_IOS_SECURITY_INSECURE_TRANSPORT_AST', message: 'AST heuristic detected insecure HTTP transport in iOS production code; HTTPS and ATS remain the preferred baseline.' },
674
719
  { platform: 'ios', pathCheck: isIOSInfoPlistPath, excludePaths: [], detect: TextIOS.hasSwiftInsecureTransportUsage, ruleId: 'heuristics.ios.security.insecure-transport.ast', code: 'HEURISTICS_IOS_SECURITY_INSECURE_TRANSPORT_AST', message: 'AST heuristic detected permissive App Transport Security configuration; HTTPS and ATS remain the preferred baseline.' },
675
720
  { platform: 'ios', pathCheck: isIOSLocalizableStringsPath, excludePaths: [], detect: detectsTrackedFilePresence, ruleId: 'heuristics.ios.localization.localizable-strings.ast', code: 'HEURISTICS_IOS_LOCALIZATION_LOCALIZABLE_STRINGS_AST', message: 'AST heuristic detected Localizable.strings usage; String Catalogs (.xcstrings) remain the preferred baseline for new localization work.' },
721
+ { platform: 'ios', pathCheck: isIOSInterfaceBuilderPath, excludePaths: [], detect: detectsTrackedFilePresence, ruleId: 'heuristics.ios.interface-builder.storyboard-xib.ast', code: 'HEURISTICS_IOS_INTERFACE_BUILDER_STORYBOARD_XIB_AST', message: 'AST heuristic detected Storyboard/XIB usage; programmatic SwiftUI/UIKit UI remains the preferred baseline for versionable iOS code.' },
676
722
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftHardcodedUiStringUsage, ruleId: 'heuristics.ios.localization.hardcoded-ui-string.ast', code: 'HEURISTICS_IOS_LOCALIZATION_HARDCODED_UI_STRING_AST', message: 'AST heuristic detected hardcoded user-facing SwiftUI text; String(localized:) and String Catalogs remain the preferred baseline.' },
677
723
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftLooseAssetResourceUsage, ruleId: 'heuristics.ios.assets.loose-resource.ast', code: 'HEURISTICS_IOS_ASSETS_LOOSE_RESOURCE_AST', message: 'AST heuristic detected loose image resource loading in iOS production code; Asset Catalogs remain the preferred baseline.' },
678
724
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftFixedFontSizeUsage, ruleId: 'heuristics.ios.accessibility.fixed-font-size.ast', code: 'HEURISTICS_IOS_ACCESSIBILITY_FIXED_FONT_SIZE_AST', message: 'AST heuristic detected fixed font sizing in iOS production code; Dynamic Type semantic text styles remain the preferred baseline.' },
679
725
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftPhysicalTextAlignmentUsage, ruleId: 'heuristics.ios.localization.physical-text-alignment.ast', code: 'HEURISTICS_IOS_LOCALIZATION_PHYSICAL_TEXT_ALIGNMENT_AST', message: 'AST heuristic detected physical left/right text alignment in iOS production code; leading/trailing remain the preferred RTL-safe baseline.' },
680
726
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftMainThreadBlockingSleepUsage, ruleId: 'heuristics.ios.performance.blocking-sleep.ast', code: 'HEURISTICS_IOS_PERFORMANCE_BLOCKING_SLEEP_AST', message: 'AST heuristic detected blocking sleep usage in iOS production code; async clocks, suspension or cancellable scheduling remain the preferred baseline.' },
681
727
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftIconOnlyControlWithoutAccessibilityLabelUsage, ruleId: 'heuristics.ios.accessibility.icon-only-control-label.ast', code: 'HEURISTICS_IOS_ACCESSIBILITY_ICON_ONLY_CONTROL_LABEL_AST', message: 'AST heuristic detected an icon-only SwiftUI control without accessibilityLabel; explicit accessible labels remain the preferred baseline.' },
728
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftInteractiveControlWithoutAccessibilityIdentifierUsage, locateLines: TextIOS.collectSwiftInteractiveControlWithoutAccessibilityIdentifierLines, ruleId: 'heuristics.ios.accessibility.missing-accessibility-identifier.ast', code: 'HEURISTICS_IOS_ACCESSIBILITY_MISSING_ACCESSIBILITY_IDENTIFIER_AST', message: 'AST heuristic detected an interactive SwiftUI control without accessibilityIdentifier; stable identifiers are required for UI automation and traceability.' },
729
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftBindableMissingForObservableBindingUsage, locateLines: TextIOS.collectSwiftBindableMissingForObservableBindingUsageLines, ruleId: 'heuristics.ios.swiftui.missing-bindable-observable-binding.ast', code: 'HEURISTICS_IOS_SWIFTUI_MISSING_BINDABLE_OBSERVABLE_BINDING_AST', message: 'AST heuristic detected an injected @Observable used as a binding without @Bindable.' },
682
730
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftUncheckedSendableUsage, ruleId: 'heuristics.ios.unchecked-sendable.ast', code: 'HEURISTICS_IOS_UNCHECKED_SENDABLE_AST', message: 'AST heuristic detected @unchecked Sendable usage.' },
683
731
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftPreconcurrencyUsage, ruleId: 'heuristics.ios.preconcurrency.ast', code: 'HEURISTICS_IOS_PRECONCURRENCY_AST', message: 'AST heuristic detected @preconcurrency usage.' },
684
732
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftNonisolatedUnsafeUsage, ruleId: 'heuristics.ios.nonisolated-unsafe.ast', code: 'HEURISTICS_IOS_NONISOLATED_UNSAFE_AST', message: 'AST heuristic detected nonisolated(unsafe) usage.' },
685
733
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftAssumeIsolatedUsage, ruleId: 'heuristics.ios.assume-isolated.ast', code: 'HEURISTICS_IOS_ASSUME_ISOLATED_AST', message: 'AST heuristic detected assumeIsolated usage.' },
686
734
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftObservableObjectUsage, ruleId: 'heuristics.ios.observable-object.ast', code: 'HEURISTICS_IOS_OBSERVABLE_OBJECT_AST', message: 'AST heuristic detected ObservableObject usage.' },
735
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftLegacyPreviewProviderUsage, ruleId: 'heuristics.ios.swiftui.legacy-preview-provider.ast', code: 'HEURISTICS_IOS_SWIFTUI_LEGACY_PREVIEW_PROVIDER_AST', message: 'AST heuristic detected PreviewProvider usage; use #Preview macros for modern SwiftUI previews.' },
687
736
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftLegacySwiftUiObservableWrapperUsage, ruleId: 'heuristics.ios.legacy-swiftui-observable-wrapper.ast', code: 'HEURISTICS_IOS_LEGACY_SWIFTUI_OBSERVABLE_WRAPPER_AST', message: 'AST heuristic detected @StateObject/@ObservedObject usage in a modern SwiftUI path.' },
737
+ { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [], detect: TextIOS.hasSwiftTestDoubleWithoutProtocolConformanceUsage, ruleId: 'heuristics.ios.testing.test-double-without-protocol.ast', code: 'HEURISTICS_IOS_TESTING_TEST_DOUBLE_WITHOUT_PROTOCOL_AST', message: 'AST heuristic detected a Swift test double class without protocol conformance.' },
738
+ { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftLowContrastStaticColorPairUsage, ruleId: 'heuristics.ios.accessibility.low-contrast-static-color-pair.ast', code: 'HEURISTICS_IOS_ACCESSIBILITY_LOW_CONTRAST_STATIC_COLOR_PAIR_AST', message: 'AST heuristic detected a static SwiftUI foreground/background color pair with insufficient contrast.' },
688
739
  { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftNonPrivateStateOwnershipUsage, ruleId: 'heuristics.ios.swiftui.non-private-state-ownership.ast', code: 'HEURISTICS_IOS_SWIFTUI_NON_PRIVATE_STATE_OWNERSHIP_AST', message: 'AST heuristic detected @State/@StateObject without private visibility; SwiftUI owned state should be private.' },
689
740
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftPassedValueStateWrapperUsage, ruleId: 'heuristics.ios.passed-value-state-wrapper.ast', code: 'HEURISTICS_IOS_PASSED_VALUE_STATE_WRAPPER_AST', message: 'AST heuristic detected a passed value stored as @State/@StateObject via init wrapper ownership.' },
690
741
  { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftForEachIndicesUsage, ruleId: 'heuristics.ios.foreach-indices.ast', code: 'HEURISTICS_IOS_FOREACH_INDICES_AST', message: 'AST heuristic detected ForEach(...indices...) usage where stable element identity may be preferred.' },
@@ -720,7 +771,7 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
720
771
  { platform: 'ios', pathCheck: isIOSSwiftTestPath, excludePaths: [], detect: TextIOS.hasSwiftModernizableXCTestSuiteUsage, locateLines: TextIOS.collectSwiftModernizableXCTestSuiteLines, primaryNode: (lines) => ({ kind: 'class', name: 'modernizable XCTestCase test suite', lines }), relatedNodes: (lines) => [{ kind: 'class', name: 'replacement: Swift Testing @Suite/@Test suite', lines }], why: 'XCTestCase suites with test... methods keep unit tests on the legacy XCTest lifecycle even when the file can be expressed as a native Swift Testing suite.', impact: 'New or modernizable tests drift away from the preferred Swift Testing contract, making suite migration, diagnostics and rule enforcement harder to audit.', expected_fix: 'Replace import XCTest and XCTestCase/test... unit suites with import Testing, @Suite where useful and @Test functions. Keep XCTestCase only for explicit UI, performance or brownfield compatibility cases.', ruleId: 'heuristics.ios.testing.xctest-suite-modernizable.ast', code: 'HEURISTICS_IOS_TESTING_XCTEST_SUITE_MODERNIZABLE_AST', message: 'AST heuristic detected modernizable XCTestCase/test... suite; use native Swift Testing where applicable.' },
721
772
  { platform: 'ios', pathCheck: isIOSSwiftTestPath, excludePaths: [], detect: TextIOS.hasSwiftXCTestAssertionUsage, locateLines: TextIOS.collectSwiftXCTestAssertionLines, primaryNode: (lines) => ({ kind: 'call', name: 'legacy XCTest assertion call', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: #expect(...)', lines }], why: 'XCTAssert* and XCTFail keep modern unit tests tied to XCTest assertion APIs even when Swift Testing can express the same expectation natively.', impact: 'Assertion style drifts across the test suite and makes migration to Swift Testing harder to audit because failures use mixed vocabularies and diagnostics.', expected_fix: 'Replace XCTAssert* and XCTFail with Swift Testing #expect(...) or a thrown test failure pattern when the target already supports Swift Testing. Keep XCTest assertions only for explicit legacy, UI or performance test compatibility.', ruleId: 'heuristics.ios.testing.xctassert.ast', code: 'HEURISTICS_IOS_TESTING_XCTASSERT_AST', message: 'AST heuristic detected legacy XCTest assertion calls; use native Swift Testing #expect where applicable.' },
722
773
  { platform: 'ios', pathCheck: isIOSSwiftTestPath, excludePaths: [], detect: TextIOS.hasSwiftXCTUnwrapUsage, locateLines: TextIOS.collectSwiftXCTUnwrapLines, primaryNode: (lines) => ({ kind: 'call', name: 'legacy XCTest unwrap call', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: #require(...)', lines }], why: 'XCTUnwrap keeps optional unwrapping tied to XCTest even when Swift Testing can express required values natively.', impact: 'Tests retain mixed assertion vocabulary and weaker migration traceability because required optional values are not represented through Swift Testing diagnostics.', expected_fix: 'Replace try XCTUnwrap(optionalValue) with try #require(optionalValue) when the target already supports Swift Testing. Keep XCTUnwrap only for explicit XCTest compatibility, UI or performance test cases.', ruleId: 'heuristics.ios.testing.xctunwrap.ast', code: 'HEURISTICS_IOS_TESTING_XCTUNWRAP_AST', message: 'AST heuristic detected legacy XCTUnwrap calls; use native Swift Testing #require where applicable.' },
723
- { platform: 'ios', pathCheck: isIOSSwiftTestPath, excludePaths: [], detect: TextIOS.hasSwiftWaitForExpectationsUsage, locateLines: TextIOS.collectSwiftWaitForExpectationsLines, primaryNode: (lines) => ({ kind: 'call', name: 'legacy XCTest wait call', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: await fulfillment(of:timeout:)', lines }], why: 'Legacy XCTest wait APIs block the current test thread and hide async intent that Swift concurrency can express directly.', impact: 'Async tests become less deterministic, harder to cancel and easier to keep tied to XCTest-only migration paths.', expected_fix: 'Replace wait(for:timeout:), self.wait(for:timeout:) or waitForExpectations(timeout:) with await fulfillment(of:timeout:) when the test target supports async XCTest migration.', ruleId: 'heuristics.ios.testing.wait-for-expectations.ast', code: 'HEURISTICS_IOS_TESTING_WAIT_FOR_EXPECTATIONS_AST', message: 'AST heuristic detected wait(for:)/waitForExpectations usage where await fulfillment(of:) may be preferred.' },
774
+ { platform: 'ios', pathCheck: isIOSSwiftTestPath, excludePaths: [], detect: TextIOS.hasSwiftWaitForExpectationsUsage, locateLines: TextIOS.collectSwiftWaitForExpectationsLines, primaryNode: (lines) => ({ kind: 'call', name: 'legacy XCTest wait call', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: await fulfillment(of:timeout:) or predicate-driven UI wait wrapper', lines }], why: 'Legacy XCTest wait APIs block the current test thread and hide async intent that Swift concurrency can express directly.', impact: 'Async tests become less deterministic, harder to cancel and easier to keep tied to XCTest-only migration paths.', expected_fix: 'Replace wait(for:timeout:), self.wait(for:timeout:), waitForExpectations(timeout:) or raw waitForExistence(timeout:) calls with await fulfillment(of:timeout:) or a repository-approved async UI wait helper.', ruleId: 'heuristics.ios.testing.wait-for-expectations.ast', code: 'HEURISTICS_IOS_TESTING_WAIT_FOR_EXPECTATIONS_AST', message: 'AST heuristic detected wait(for:)/waitForExpectations/waitForExistence usage where an explicit async wait contract may be preferred.' },
724
775
  { platform: 'ios', pathCheck: isIOSSwiftTestPath, excludePaths: [], detect: TextIOS.hasSwiftLegacyExpectationDescriptionUsage, locateLines: TextIOS.collectSwiftLegacyExpectationDescriptionLines, primaryNode: (lines) => ({ kind: 'call', name: 'legacy XCTest expectation(description:) call', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: await confirmation or awaited fulfillment flow', lines }], why: 'Legacy expectation(description:) scaffolding keeps async tests coupled to XCTest-style callbacks instead of expressing confirmation intent directly.', impact: 'Tests can remain harder to read and migrate because the assertion flow is split between expectation creation, callback fulfillment and a later wait.', expected_fix: 'Prefer await confirmation(...) for callback confirmation, or pair legacy expectations with await fulfillment(of:timeout:) when the target still requires XCTest compatibility.', ruleId: 'heuristics.ios.testing.legacy-expectation-description.ast', code: 'HEURISTICS_IOS_TESTING_LEGACY_EXPECTATION_DESCRIPTION_AST', message: 'AST heuristic detected expectation(description:) usage without modern fulfillment/confirmation flow.' },
725
776
  { platform: 'ios', pathCheck: isIOSSwiftTestPath, excludePaths: [], detect: TextIOS.hasSwiftMixedTestingFrameworksUsage, locateLines: TextIOS.collectSwiftMixedTestingFrameworkLines, primaryNode: (lines) => ({ kind: 'class', name: 'mixed XCTestCase and Swift Testing suite', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: isolate XCTest compatibility from Swift Testing suites', lines }], why: 'Mixing XCTestCase and Swift Testing markers in the same file makes the test contract ambiguous and hides whether the target is legacy XCTest compatibility or modern Swift Testing.', impact: 'Migration work becomes harder to audit because one file can carry two lifecycle models, assertion styles and setup conventions at once.', expected_fix: 'Split XCTest compatibility tests and Swift Testing suites into separate files, or migrate the legacy XCTestCase suite fully to import Testing with @Suite/@Test and #expect/#require.', ruleId: 'heuristics.ios.testing.mixed-frameworks.ast', code: 'HEURISTICS_IOS_TESTING_MIXED_FRAMEWORKS_AST', message: 'AST heuristic detected XCTestCase and Swift Testing markers mixed in the same test file without explicit compatibility reason.' },
726
777
  { platform: 'ios', pathCheck: isIOSSwiftTestPath, excludePaths: [], detect: TextIOS.hasSwiftQuickNimbleUsage, locateLines: TextIOS.collectSwiftQuickNimbleLines, primaryNode: (lines) => ({ kind: 'class', name: 'QuickSpec legacy test suite', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: Swift Testing @Suite/@Test with #expect/#require', lines }], why: 'Quick and Nimble introduce a third-party BDD lifecycle and matcher vocabulary that diverges from the native Swift Testing contract expected for new tests.', impact: 'New tests become harder to migrate, audit and run consistently because they depend on legacy DSL hooks instead of Swift Testing suites and assertions.', expected_fix: 'For new tests, replace QuickSpec/describe/context/it/expect with native Swift Testing @Suite/@Test and #expect/#require. Keep existing Quick/Nimble only as explicit brownfield legacy until migrated.', ruleId: 'heuristics.ios.testing.quick-nimble.ast', code: 'HEURISTICS_IOS_TESTING_QUICK_NIMBLE_AST', message: 'AST heuristic detected Quick/Nimble legacy test nodes; use native Swift Testing for new test code.' },
@@ -734,11 +785,29 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
734
785
  { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinThreadSleepCall, ruleId: 'heuristics.android.thread-sleep.ast', code: 'HEURISTICS_ANDROID_THREAD_SLEEP_AST', message: 'AST heuristic detected Thread.sleep usage in production Kotlin code.' },
735
786
  { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinGlobalScopeUsage, ruleId: 'heuristics.android.globalscope.ast', code: 'HEURISTICS_ANDROID_GLOBAL_SCOPE_AST', message: 'AST heuristic detected GlobalScope coroutine usage in production Kotlin code.' },
736
787
  { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinRunBlockingUsage, ruleId: 'heuristics.android.run-blocking.ast', code: 'HEURISTICS_ANDROID_RUN_BLOCKING_AST', message: 'AST heuristic detected runBlocking usage in production Kotlin code.' },
788
+ { platform: 'android', pathCheck: isAndroidSourcePath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasAndroidAsyncTaskUsage, ruleId: 'heuristics.android.concurrency.asynctask.ast', code: 'HEURISTICS_ANDROID_CONCURRENCY_ASYNCTASK_AST', message: 'AST heuristic detected deprecated AsyncTask usage in Android production code; use coroutines.' },
789
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinGodActivityUsage, ruleId: 'heuristics.android.architecture.god-activity.ast', code: 'HEURISTICS_ANDROID_ARCHITECTURE_GOD_ACTIVITY_AST', message: 'AST heuristic detected an Android Activity mixing UI entrypoint with product responsibilities; keep Activity thin and move features to composables/ViewModels/use cases.' },
790
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinNonLazyScrollableCollectionUsage, ruleId: 'heuristics.android.compose.non-lazy-scrollable-collection.ast', code: 'HEURISTICS_ANDROID_COMPOSE_NON_LAZY_SCROLLABLE_COLLECTION_AST', message: 'AST heuristic detected a scrollable Column/Row rendering a collection; use LazyColumn/LazyRow for virtualized lists.' },
791
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinUnstableLaunchedEffectKeyUsage, ruleId: 'heuristics.android.compose.unstable-launched-effect-key.ast', code: 'HEURISTICS_ANDROID_COMPOSE_UNSTABLE_LAUNCHED_EFFECT_KEY_AST', message: 'AST heuristic detected LaunchedEffect without a stable state key; use a meaningful key that controls when the effect restarts.' },
792
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinLaunchedEffectBusyLoopUsage, ruleId: 'heuristics.android.compose.launched-effect-busy-loop.ast', code: 'HEURISTICS_ANDROID_COMPOSE_LAUNCHED_EFFECT_BUSY_LOOP_AST', message: 'AST heuristic detected non-cooperative loop inside LaunchedEffect; add suspension/cancellation cooperation or move work to lifecycle-aware flow.' },
793
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinProductionLoggingUsage, ruleId: 'heuristics.android.observability.production-logging.ast', code: 'HEURISTICS_ANDROID_OBSERVABILITY_PRODUCTION_LOGGING_AST', message: 'AST heuristic detected unguarded Android production logging; use structured logging guarded by BuildConfig.DEBUG or remove it.' },
794
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinModifierBackgroundBeforePaddingUsage, ruleId: 'heuristics.android.compose.modifier-background-before-padding.ast', code: 'HEURISTICS_ANDROID_COMPOSE_MODIFIER_BACKGROUND_BEFORE_PADDING_AST', message: 'AST heuristic detected Modifier.background before padding; apply padding before background when the content inset should not be painted.' },
795
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinMissingContentDescriptionUsage, ruleId: 'heuristics.android.accessibility.missing-content-description.ast', code: 'HEURISTICS_ANDROID_ACCESSIBILITY_MISSING_CONTENT_DESCRIPTION_AST', message: 'AST heuristic detected Image/Icon without contentDescription; provide an accessibility label or explicit null for decorative content.' },
796
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinFontScaleDisabledUsage, ruleId: 'heuristics.android.accessibility.fontscale-disabled.ast', code: 'HEURISTICS_ANDROID_ACCESSIBILITY_FONTSCALE_DISABLED_AST', message: 'AST heuristic detected disabled Android font scaling; respect system fontScale for text accessibility.' },
797
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinIncompleteMaterialThemeUsage, ruleId: 'heuristics.android.compose.incomplete-material-theme.ast', code: 'HEURISTICS_ANDROID_COMPOSE_INCOMPLETE_MATERIAL_THEME_AST', message: 'AST heuristic detected MaterialTheme without colorScheme, typography and shapes.' },
798
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinLegacyBottomNavigationUsage, ruleId: 'heuristics.android.compose.legacy-bottom-navigation.ast', code: 'HEURISTICS_ANDROID_COMPOSE_LEGACY_BOTTOM_NAVIGATION_AST', message: 'AST heuristic detected Material 2 BottomNavigation usage; use Material 3 NavigationBar/NavigationBarItem.' },
799
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinImperativeNavigationUsage, ruleId: 'heuristics.android.navigation.imperative-navigation.ast', code: 'HEURISTICS_ANDROID_NAVIGATION_IMPERATIVE_NAVIGATION_AST', message: 'AST heuristic detected imperative Android navigation; use Navigation Compose with NavHost and typed routes.' },
800
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinComposableObjectCreationWithoutRememberUsage, ruleId: 'heuristics.android.compose.object-creation-without-remember.ast', code: 'HEURISTICS_ANDROID_COMPOSE_OBJECT_CREATION_WITHOUT_REMEMBER_AST', message: 'AST heuristic detected object creation inside a Composable without remember; hoist or wrap stable objects in remember.' },
801
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinComposableStateCreationWithoutRememberUsage, ruleId: 'heuristics.android.compose.state-creation-without-remember.ast', code: 'HEURISTICS_ANDROID_COMPOSE_STATE_CREATION_WITHOUT_REMEMBER_AST', message: 'AST heuristic detected Compose state creation without remember; keep state stable across recompositions.' },
802
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinForceUnwrapUsage, ruleId: 'heuristics.android.null-safety.force-unwrap.ast', code: 'HEURISTICS_ANDROID_NULL_SAFETY_FORCE_UNWRAP_AST', message: 'AST heuristic detected Kotlin force unwrap (!!); use safe calls, Elvis, let or requireNotNull.' },
737
803
  { platform: 'android', pathCheck: isAndroidLocalPropertiesPath, excludePaths: [], detect: detectsTrackedFilePresence, ruleId: 'heuristics.android.security.local-properties-tracked.ast', code: 'HEURISTICS_ANDROID_SECURITY_LOCAL_PROPERTIES_TRACKED_AST', message: 'AST heuristic detected tracked Android local.properties file.' },
738
804
  { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinSharedPreferencesUsage, ruleId: 'heuristics.android.persistence.shared-preferences-usage.ast', code: 'HEURISTICS_ANDROID_PERSISTENCE_SHARED_PREFERENCES_USAGE_AST', message: 'AST heuristic detected SharedPreferences usage in Android production Kotlin code.' },
739
805
  { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinProductionMockUsage, ruleId: 'heuristics.android.testing.production-mock-usage.ast', code: 'HEURISTICS_ANDROID_TESTING_PRODUCTION_MOCK_USAGE_AST', message: 'AST heuristic detected mock or spy usage in Android production Kotlin code.' },
806
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinHardcodedUiStringUsage, locateLines: TextAndroid.collectKotlinHardcodedUiStringLines, ruleId: 'heuristics.android.ui.hardcoded-string.ast', code: 'HEURISTICS_ANDROID_UI_HARDCODED_STRING_AST', message: 'AST heuristic detected hardcoded user-facing Android UI string; use stringResource/R.string resources.' },
740
807
  { platform: 'android', pathCheck: isAndroidKotlinTestPath, excludePaths: [], detect: TextAndroid.hasKotlinJUnit4Usage, ruleId: 'heuristics.android.testing.junit4-usage.ast', code: 'HEURISTICS_ANDROID_TESTING_JUNIT4_USAGE_AST', message: 'AST heuristic detected JUnit4 usage in Android Kotlin tests where JUnit5 is preferred.' },
741
808
  { platform: 'android', pathCheck: isAndroidPresentationPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinLiveDataStateExposureUsage, ruleId: 'heuristics.android.flow.livedata-state-exposure.ast', code: 'HEURISTICS_ANDROID_FLOW_LIVEDATA_STATE_EXPOSURE_AST', message: 'AST heuristic detected LiveData state exposure in Android presentation code where StateFlow or SharedFlow should be preferred.' },
809
+ { platform: 'android', pathCheck: isAndroidPresentationPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinViewModelFlowWithoutStateInUsage, ruleId: 'heuristics.android.flow.viewmodel-flow-without-statein.ast', code: 'HEURISTICS_ANDROID_FLOW_VIEWMODEL_FLOW_WITHOUT_STATEIN_AST', message: 'AST heuristic detected ViewModel exposing cold Flow state without stateIn; expose StateFlow for UI state.' },
810
+ { platform: 'android', pathCheck: isAndroidPresentationPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinSharedFlowUsedAsStateUsage, ruleId: 'heuristics.android.flow.sharedflow-used-as-state.ast', code: 'HEURISTICS_ANDROID_FLOW_SHAREDFLOW_USED_AS_STATE_AST', message: 'AST heuristic detected SharedFlow used as ViewModel state; use StateFlow for state and SharedFlow for events.' },
742
811
  { platform: 'android', pathCheck: isAndroidPresentationPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinManualCoroutineScopeInViewModelUsage, ruleId: 'heuristics.android.coroutines.manual-scope-in-viewmodel.ast', code: 'HEURISTICS_ANDROID_COROUTINES_MANUAL_SCOPE_IN_VIEWMODEL_AST', message: 'AST heuristic detected manual CoroutineScope inside an Android ViewModel where viewModelScope should be preferred.' },
743
812
  { platform: 'android', pathCheck: isAndroidNonPresentationKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinDispatcherMainBoundaryLeakUsage, ruleId: 'heuristics.android.coroutines.dispatchers-main-boundary-leak.ast', code: 'HEURISTICS_ANDROID_COROUTINES_DISPATCHERS_MAIN_BOUNDARY_LEAK_AST', message: 'AST heuristic detected Dispatchers.Main outside Android presentation code.' },
744
813
  { platform: 'android', pathCheck: isAndroidDomainOrApplicationPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinHardcodedBackgroundDispatcherUsage, ruleId: 'heuristics.android.coroutines.hardcoded-background-dispatcher.ast', code: 'HEURISTICS_ANDROID_COROUTINES_HARDCODED_BACKGROUND_DISPATCHER_AST', message: 'AST heuristic detected hard-coded Dispatchers.IO or Dispatchers.Default in Android domain/application code.' },