pumuki 6.3.294 → 6.3.296
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/core/facts/detectors/text/ios.test.ts +59 -0
- package/core/facts/detectors/text/ios.ts +25 -0
- package/core/facts/detectors/typescript/index.test.ts +569 -0
- package/core/facts/detectors/typescript/index.ts +312 -0
- package/core/facts/extractHeuristicFacts.ts +26 -0
- package/core/rules/presets/heuristics/ios.test.ts +11 -1
- package/core/rules/presets/heuristics/ios.ts +36 -0
- package/core/rules/presets/heuristics/typescript.test.ts +55 -1
- package/core/rules/presets/heuristics/typescript.ts +165 -0
- package/integrations/config/skillsDetectorRegistry.ts +58 -0
- package/integrations/git/runPlatformGate.ts +45 -3
- package/package.json +1 -1
|
@@ -1910,6 +1910,149 @@ const objectExpressionPropertyNames = (node: AstNode): Set<string> => {
|
|
|
1910
1910
|
);
|
|
1911
1911
|
};
|
|
1912
1912
|
|
|
1913
|
+
const hasPropertyMatching = (propertyNames: ReadonlySet<string>, pattern: RegExp): boolean => {
|
|
1914
|
+
for (const propertyName of propertyNames) {
|
|
1915
|
+
if (pattern.test(propertyName)) {
|
|
1916
|
+
return true;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
return false;
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
const backendAccessTokenPropertyPattern = /^(accessToken|access_token)$/;
|
|
1923
|
+
const backendRefreshTokenPropertyPattern = /^(refreshToken|refresh_token)$/;
|
|
1924
|
+
|
|
1925
|
+
const isBackendAuthResponseObjectWithoutRefreshToken = (node: AstNode): boolean => {
|
|
1926
|
+
if (node.type !== 'ObjectExpression') {
|
|
1927
|
+
return false;
|
|
1928
|
+
}
|
|
1929
|
+
const propertyNames = objectExpressionPropertyNames(node);
|
|
1930
|
+
return (
|
|
1931
|
+
hasPropertyMatching(propertyNames, backendAccessTokenPropertyPattern) &&
|
|
1932
|
+
!hasPropertyMatching(propertyNames, backendRefreshTokenPropertyPattern)
|
|
1933
|
+
);
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
export const findBackendAuthResponseWithoutRefreshTokenLines = (ast: unknown): readonly number[] => {
|
|
1937
|
+
return collectLineMatchesWithAncestors(ast, (node, ancestors) => {
|
|
1938
|
+
if (!isBackendAuthResponseObjectWithoutRefreshToken(node)) {
|
|
1939
|
+
return false;
|
|
1940
|
+
}
|
|
1941
|
+
const parent = ancestors[ancestors.length - 1];
|
|
1942
|
+
if (isObject(parent) && parent.type === 'ReturnStatement' && parent.argument === node) {
|
|
1943
|
+
return true;
|
|
1944
|
+
}
|
|
1945
|
+
return ancestors.some((ancestor) => {
|
|
1946
|
+
if (ancestor.type !== 'CallExpression' || !Array.isArray(ancestor.arguments)) {
|
|
1947
|
+
return false;
|
|
1948
|
+
}
|
|
1949
|
+
if (!ancestor.arguments.includes(node) || !isObject(ancestor.callee)) {
|
|
1950
|
+
return false;
|
|
1951
|
+
}
|
|
1952
|
+
const methodName = memberExpressionPropertyName(ancestor.callee);
|
|
1953
|
+
return methodName !== undefined && httpResponseMethodNames.has(methodName);
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1958
|
+
export const hasBackendAuthResponseWithoutRefreshToken = (ast: unknown): boolean =>
|
|
1959
|
+
findBackendAuthResponseWithoutRefreshTokenLines(ast).length > 0;
|
|
1960
|
+
|
|
1961
|
+
const isProcessEnvMemberExpression = (node: unknown): boolean => {
|
|
1962
|
+
if (!isObject(node) || node.type !== 'MemberExpression') {
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1965
|
+
const propertyName = memberExpressionPropertyName(node);
|
|
1966
|
+
if (typeof propertyName !== 'string' || propertyName.length === 0) {
|
|
1967
|
+
return false;
|
|
1968
|
+
}
|
|
1969
|
+
const objectNode = node.object;
|
|
1970
|
+
return (
|
|
1971
|
+
isObject(objectNode) &&
|
|
1972
|
+
objectNode.type === 'MemberExpression' &&
|
|
1973
|
+
memberExpressionObjectName(objectNode) === 'process' &&
|
|
1974
|
+
memberExpressionPropertyName(objectNode) === 'env'
|
|
1975
|
+
);
|
|
1976
|
+
};
|
|
1977
|
+
|
|
1978
|
+
const isLiteralConfigFallback = (node: unknown): boolean => {
|
|
1979
|
+
if (!isObject(node)) {
|
|
1980
|
+
return false;
|
|
1981
|
+
}
|
|
1982
|
+
return (
|
|
1983
|
+
node.type === 'StringLiteral' ||
|
|
1984
|
+
node.type === 'NumericLiteral' ||
|
|
1985
|
+
node.type === 'BooleanLiteral' ||
|
|
1986
|
+
(node.type === 'TemplateLiteral' &&
|
|
1987
|
+
Array.isArray(node.expressions) &&
|
|
1988
|
+
node.expressions.length === 0)
|
|
1989
|
+
);
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
const isBackendProcessEnvDefaultFallback = (node: AstNode): boolean => {
|
|
1993
|
+
return (
|
|
1994
|
+
node.type === 'LogicalExpression' &&
|
|
1995
|
+
(node.operator === '||' || node.operator === '??') &&
|
|
1996
|
+
isProcessEnvMemberExpression(node.left) &&
|
|
1997
|
+
isLiteralConfigFallback(node.right)
|
|
1998
|
+
);
|
|
1999
|
+
};
|
|
2000
|
+
|
|
2001
|
+
export const findBackendProcessEnvDefaultFallbackLines = (ast: unknown): readonly number[] =>
|
|
2002
|
+
collectLineMatchesWithAncestors(ast, (node) => isBackendProcessEnvDefaultFallback(node), {
|
|
2003
|
+
max: 12,
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
export const hasBackendProcessEnvDefaultFallback = (ast: unknown): boolean =>
|
|
2007
|
+
findBackendProcessEnvDefaultFallbackLines(ast).length > 0;
|
|
2008
|
+
|
|
2009
|
+
export const findBackendDirectProcessEnvReadLines = (ast: unknown): readonly number[] =>
|
|
2010
|
+
collectLineMatchesWithAncestors(ast, (node) => isProcessEnvMemberExpression(node), {
|
|
2011
|
+
max: 12,
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
export const hasBackendDirectProcessEnvRead = (ast: unknown): boolean =>
|
|
2015
|
+
findBackendDirectProcessEnvReadLines(ast).length > 0;
|
|
2016
|
+
|
|
2017
|
+
const isConfigModuleForRootCall = (node: AstNode): boolean => {
|
|
2018
|
+
if (node.type !== 'CallExpression' || !isObject(node.callee)) {
|
|
2019
|
+
return false;
|
|
2020
|
+
}
|
|
2021
|
+
const callee = node.callee;
|
|
2022
|
+
return (
|
|
2023
|
+
callee.type === 'MemberExpression' &&
|
|
2024
|
+
memberExpressionObjectName(callee) === 'ConfigModule' &&
|
|
2025
|
+
memberExpressionPropertyName(callee) === 'forRoot'
|
|
2026
|
+
);
|
|
2027
|
+
};
|
|
2028
|
+
|
|
2029
|
+
const hasConfigValidationOption = (node: unknown): boolean => {
|
|
2030
|
+
if (!isObject(node) || node.type !== 'ObjectExpression') {
|
|
2031
|
+
return false;
|
|
2032
|
+
}
|
|
2033
|
+
const propertyNames = objectExpressionPropertyNames(node);
|
|
2034
|
+
return propertyNames.has('validationSchema') || propertyNames.has('validate');
|
|
2035
|
+
};
|
|
2036
|
+
|
|
2037
|
+
const isBackendConfigModuleForRootWithoutValidation = (node: AstNode): boolean => {
|
|
2038
|
+
if (!isConfigModuleForRootCall(node)) {
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
const args = Array.isArray(node.arguments) ? node.arguments : [];
|
|
2042
|
+
const options = args.find((argument) => isObject(argument) && argument.type === 'ObjectExpression');
|
|
2043
|
+
return !hasConfigValidationOption(options);
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
export const findBackendConfigModuleWithoutValidationLines = (ast: unknown): readonly number[] =>
|
|
2047
|
+
collectLineMatchesWithAncestors(
|
|
2048
|
+
ast,
|
|
2049
|
+
(node) => isBackendConfigModuleForRootWithoutValidation(node),
|
|
2050
|
+
{ max: 12 }
|
|
2051
|
+
);
|
|
2052
|
+
|
|
2053
|
+
export const hasBackendConfigModuleWithoutValidation = (ast: unknown): boolean =>
|
|
2054
|
+
findBackendConfigModuleWithoutValidationLines(ast).length > 0;
|
|
2055
|
+
|
|
1913
2056
|
const isErrorStatusCallExpression = (node: unknown): boolean => {
|
|
1914
2057
|
if (!isObject(node) || node.type !== 'CallExpression') {
|
|
1915
2058
|
return false;
|
|
@@ -2259,6 +2402,34 @@ export const findBackendStringLiteralUnionEnumCandidateLines = (
|
|
|
2259
2402
|
export const hasBackendStringLiteralUnionEnumCandidate = (ast: unknown): boolean =>
|
|
2260
2403
|
findBackendStringLiteralUnionEnumCandidateLines(ast).length > 0;
|
|
2261
2404
|
|
|
2405
|
+
const backendCacheWriteMethodNames = new Set(['hset', 'mset', 'set', 'setex']);
|
|
2406
|
+
const backendCacheObjectPattern = /(cache|cacheManager|redis)/i;
|
|
2407
|
+
|
|
2408
|
+
const isBackendSensitiveCacheWriteCall = (node: AstNode): boolean => {
|
|
2409
|
+
if (node.type !== 'CallExpression' || !isObject(node.callee)) {
|
|
2410
|
+
return false;
|
|
2411
|
+
}
|
|
2412
|
+
const callee = node.callee;
|
|
2413
|
+
const methodName = memberExpressionPropertyName(callee);
|
|
2414
|
+
if (methodName === undefined || !backendCacheWriteMethodNames.has(methodName)) {
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
const objectName =
|
|
2418
|
+
callee.type === 'MemberExpression' ? identifierNameFromNode(callee.object) : undefined;
|
|
2419
|
+
if (objectName === undefined || !backendCacheObjectPattern.test(objectName)) {
|
|
2420
|
+
return false;
|
|
2421
|
+
}
|
|
2422
|
+
const args = Array.isArray(node.arguments) ? node.arguments : [];
|
|
2423
|
+
return args.some(hasSensitiveIdentifierReference);
|
|
2424
|
+
};
|
|
2425
|
+
|
|
2426
|
+
export const findBackendSensitiveCacheWriteLines = (ast: unknown): readonly number[] => {
|
|
2427
|
+
return collectLineMatchesWithAncestors(ast, (node) => isBackendSensitiveCacheWriteCall(node));
|
|
2428
|
+
};
|
|
2429
|
+
|
|
2430
|
+
export const hasBackendSensitiveCacheWrite = (ast: unknown): boolean =>
|
|
2431
|
+
findBackendSensitiveCacheWriteLines(ast).length > 0;
|
|
2432
|
+
|
|
2262
2433
|
const isLoggingCall = (node: AstNode): boolean => {
|
|
2263
2434
|
if (node.type !== 'CallExpression' || !isObject(node.callee)) {
|
|
2264
2435
|
return false;
|
|
@@ -2767,6 +2938,11 @@ const nestJsClassDecoratorNames = new Set([
|
|
|
2767
2938
|
const nestJsRouteDecoratorNames = new Set(['Delete', 'Get', 'Head', 'Options', 'Patch', 'Post', 'Put']);
|
|
2768
2939
|
const nestJsUseGuardsDecoratorNames = new Set(['UseGuards']);
|
|
2769
2940
|
const nestJsRolesDecoratorNames = new Set(['Roles']);
|
|
2941
|
+
const nestJsOnEventDecoratorNames = new Set(['OnEvent']);
|
|
2942
|
+
const nestJsThrottleDecoratorNames = new Set(['Throttle']);
|
|
2943
|
+
const backendEventHandlerNamePattern = /(^handle[A-Z].*Event$|EventHandler$|EventsHandler$)/;
|
|
2944
|
+
const backendAuthRouteNamePattern =
|
|
2945
|
+
/(forgot|login|password|refresh|register|reset|signin|sign-in|signup|sign-up|token)/i;
|
|
2770
2946
|
|
|
2771
2947
|
const decoratorExpressionName = (node: unknown): string | undefined => {
|
|
2772
2948
|
if (!isObject(node)) {
|
|
@@ -2974,6 +3150,85 @@ export const findBackendControllerRouteWithoutRolesLines = (ast: unknown): reado
|
|
|
2974
3150
|
export const hasBackendControllerRouteWithoutRoles = (ast: unknown): boolean =>
|
|
2975
3151
|
findBackendControllerRouteWithoutRolesLines(ast).length > 0;
|
|
2976
3152
|
|
|
3153
|
+
const isBackendEventHandlerClassOrMethod = (node: AstNode): boolean => {
|
|
3154
|
+
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
|
|
3155
|
+
return backendEventHandlerNamePattern.test(classNameFromNode(node));
|
|
3156
|
+
}
|
|
3157
|
+
if (!isClassMethodLikeNode(node)) {
|
|
3158
|
+
return false;
|
|
3159
|
+
}
|
|
3160
|
+
const methodName = methodNameFromNode(node.key);
|
|
3161
|
+
return typeof methodName === 'string' && backendEventHandlerNamePattern.test(methodName);
|
|
3162
|
+
};
|
|
3163
|
+
|
|
3164
|
+
const hasOnEventDecoratedClassMember = (node: AstNode): boolean => {
|
|
3165
|
+
if (node.type !== 'ClassDeclaration' && node.type !== 'ClassExpression') {
|
|
3166
|
+
return false;
|
|
3167
|
+
}
|
|
3168
|
+
const members = isObject(node.body) && Array.isArray(node.body.body) ? node.body.body : [];
|
|
3169
|
+
return members.some((member) => isObject(member) && hasDecoratorFrom(member, nestJsOnEventDecoratorNames));
|
|
3170
|
+
};
|
|
3171
|
+
|
|
3172
|
+
export const findBackendEventHandlerWithoutOnEventLines = (ast: unknown): readonly number[] => {
|
|
3173
|
+
return collectLineMatchesWithAncestors(ast, (node, ancestors) => {
|
|
3174
|
+
if (!isBackendEventHandlerClassOrMethod(node)) {
|
|
3175
|
+
return false;
|
|
3176
|
+
}
|
|
3177
|
+
if (hasDecoratorFrom(node, nestJsOnEventDecoratorNames)) {
|
|
3178
|
+
return false;
|
|
3179
|
+
}
|
|
3180
|
+
if (isClassLikeNode(node) && hasOnEventDecoratedClassMember(node)) {
|
|
3181
|
+
return false;
|
|
3182
|
+
}
|
|
3183
|
+
const ownerClass = [...ancestors].reverse().find(isClassLikeNode);
|
|
3184
|
+
return ownerClass === undefined || !hasDecoratorFrom(ownerClass, nestJsOnEventDecoratorNames);
|
|
3185
|
+
});
|
|
3186
|
+
};
|
|
3187
|
+
|
|
3188
|
+
export const hasBackendEventHandlerWithoutOnEvent = (ast: unknown): boolean =>
|
|
3189
|
+
findBackendEventHandlerWithoutOnEventLines(ast).length > 0;
|
|
3190
|
+
|
|
3191
|
+
const isBackendAuthRouteMethod = (node: AstNode): boolean => {
|
|
3192
|
+
if (!isClassMethodLikeNode(node) || !hasNestJsRouteDecorator(node)) {
|
|
3193
|
+
return false;
|
|
3194
|
+
}
|
|
3195
|
+
const methodName = methodNameFromNode(node.key);
|
|
3196
|
+
if (methodName !== undefined && backendAuthRouteNamePattern.test(methodName)) {
|
|
3197
|
+
return true;
|
|
3198
|
+
}
|
|
3199
|
+
if (!Array.isArray(node.decorators)) {
|
|
3200
|
+
return false;
|
|
3201
|
+
}
|
|
3202
|
+
return node.decorators.some((decorator) => {
|
|
3203
|
+
if (!isObject(decorator)) {
|
|
3204
|
+
return false;
|
|
3205
|
+
}
|
|
3206
|
+
const decoratorName = decoratorExpressionName(decorator.expression);
|
|
3207
|
+
if (decoratorName === undefined || !nestJsRouteDecoratorNames.has(decoratorName)) {
|
|
3208
|
+
return false;
|
|
3209
|
+
}
|
|
3210
|
+
const [firstArgument] = decoratorCallArguments(decorator);
|
|
3211
|
+
const routePath = literalTextFromNode(firstArgument);
|
|
3212
|
+
return routePath !== undefined && backendAuthRouteNamePattern.test(routePath);
|
|
3213
|
+
});
|
|
3214
|
+
};
|
|
3215
|
+
|
|
3216
|
+
export const findBackendAuthRouteWithoutThrottleLines = (ast: unknown): readonly number[] => {
|
|
3217
|
+
return collectLineMatchesWithAncestors(ast, (node, ancestors) => {
|
|
3218
|
+
if (!isBackendAuthRouteMethod(node)) {
|
|
3219
|
+
return false;
|
|
3220
|
+
}
|
|
3221
|
+
if (hasDecoratorFrom(node, nestJsThrottleDecoratorNames)) {
|
|
3222
|
+
return false;
|
|
3223
|
+
}
|
|
3224
|
+
const ownerClass = [...ancestors].reverse().find(isClassLikeNode);
|
|
3225
|
+
return ownerClass === undefined || !hasDecoratorFrom(ownerClass, nestJsThrottleDecoratorNames);
|
|
3226
|
+
});
|
|
3227
|
+
};
|
|
3228
|
+
|
|
3229
|
+
export const hasBackendAuthRouteWithoutThrottle = (ast: unknown): boolean =>
|
|
3230
|
+
findBackendAuthRouteWithoutThrottleLines(ast).length > 0;
|
|
3231
|
+
|
|
2977
3232
|
const persistenceMutationNames = new Set([
|
|
2978
3233
|
'create',
|
|
2979
3234
|
'delete',
|
|
@@ -3176,6 +3431,19 @@ const hasClassValidatorDecorator = (node: AstNode): boolean => {
|
|
|
3176
3431
|
});
|
|
3177
3432
|
};
|
|
3178
3433
|
|
|
3434
|
+
const hasApiPropertyDecorator = (node: AstNode): boolean => {
|
|
3435
|
+
if (!Array.isArray(node.decorators)) {
|
|
3436
|
+
return false;
|
|
3437
|
+
}
|
|
3438
|
+
return node.decorators.some((decorator) => {
|
|
3439
|
+
if (!isObject(decorator)) {
|
|
3440
|
+
return false;
|
|
3441
|
+
}
|
|
3442
|
+
const name = decoratorExpressionName(decorator.expression);
|
|
3443
|
+
return name === 'ApiProperty' || name === 'ApiPropertyOptional';
|
|
3444
|
+
});
|
|
3445
|
+
};
|
|
3446
|
+
|
|
3179
3447
|
const hasDtoNestedValidationDecorator = (node: AstNode): boolean => {
|
|
3180
3448
|
if (!Array.isArray(node.decorators)) {
|
|
3181
3449
|
return false;
|
|
@@ -3275,6 +3543,19 @@ export const findDtoPropertyWithoutValidationMatch = (
|
|
|
3275
3543
|
export const hasDtoPropertyWithoutValidation = (ast: unknown): boolean =>
|
|
3276
3544
|
findDtoPropertyWithoutValidationMatch(ast) !== undefined;
|
|
3277
3545
|
|
|
3546
|
+
export const findDtoPropertyWithoutApiPropertyLines = (ast: unknown): readonly number[] =>
|
|
3547
|
+
collectLineMatchesWithAncestors(ast, (node, ancestors) => {
|
|
3548
|
+
if (!isDtoPropertyNode(node) || hasApiPropertyDecorator(node)) {
|
|
3549
|
+
return false;
|
|
3550
|
+
}
|
|
3551
|
+
return ancestors.some(
|
|
3552
|
+
(ancestor) => ancestor.type === 'ClassDeclaration' && isDtoClassName(classNameFromNode(ancestor))
|
|
3553
|
+
);
|
|
3554
|
+
}, { max: 12 });
|
|
3555
|
+
|
|
3556
|
+
export const hasDtoPropertyWithoutApiProperty = (ast: unknown): boolean =>
|
|
3557
|
+
findDtoPropertyWithoutApiPropertyLines(ast).length > 0;
|
|
3558
|
+
|
|
3278
3559
|
export type TypeScriptDtoNestedPropertyWithoutNestedValidationMatch = {
|
|
3279
3560
|
lines: readonly number[];
|
|
3280
3561
|
primary_node: string;
|
|
@@ -3454,6 +3735,37 @@ export const findBackendMissingHelmetSecurityHeadersLines = (ast: unknown): read
|
|
|
3454
3735
|
export const hasBackendMissingHelmetSecurityHeaders = (ast: unknown): boolean =>
|
|
3455
3736
|
findBackendMissingHelmetSecurityHeadersLines(ast).length > 0;
|
|
3456
3737
|
|
|
3738
|
+
const isCompressionCallee = (node: unknown): boolean => {
|
|
3739
|
+
return isObject(node) && node.type === 'Identifier' && node.name === 'compression';
|
|
3740
|
+
};
|
|
3741
|
+
|
|
3742
|
+
const isCompressionCallExpression = (node: unknown): boolean => {
|
|
3743
|
+
return isObject(node) && node.type === 'CallExpression' && isCompressionCallee(node.callee);
|
|
3744
|
+
};
|
|
3745
|
+
|
|
3746
|
+
const isAppUseCompressionCall = (node: AstNode): boolean => {
|
|
3747
|
+
if (node.type !== 'CallExpression' || callExpressionMemberName(node) !== 'use') {
|
|
3748
|
+
return false;
|
|
3749
|
+
}
|
|
3750
|
+
const args = Array.isArray(node.arguments) ? node.arguments : [];
|
|
3751
|
+
return args.some(isCompressionCallExpression);
|
|
3752
|
+
};
|
|
3753
|
+
|
|
3754
|
+
export const findBackendMissingCompressionMiddlewareLines = (
|
|
3755
|
+
ast: unknown
|
|
3756
|
+
): readonly number[] => {
|
|
3757
|
+
if (!hasNode(ast, isBackendValidationNestFactoryCreateCall)) {
|
|
3758
|
+
return [];
|
|
3759
|
+
}
|
|
3760
|
+
if (hasNode(ast, isAppUseCompressionCall)) {
|
|
3761
|
+
return [];
|
|
3762
|
+
}
|
|
3763
|
+
return collectLineMatchesWithAncestors(ast, isBackendValidationNestFactoryCreateCall, { max: 1 });
|
|
3764
|
+
};
|
|
3765
|
+
|
|
3766
|
+
export const hasBackendMissingCompressionMiddleware = (ast: unknown): boolean =>
|
|
3767
|
+
findBackendMissingCompressionMiddlewareLines(ast).length > 0;
|
|
3768
|
+
|
|
3457
3769
|
const backendRouteDecoratorNames = new Set([
|
|
3458
3770
|
'All',
|
|
3459
3771
|
'Delete',
|
|
@@ -76,6 +76,17 @@ const isBackendTypeScriptPath = (path: string): boolean => {
|
|
|
76
76
|
return isTypeScriptHeuristicTargetPath(path) && path.startsWith('apps/backend/');
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
const isBackendNonConfigTypeScriptPath = (path: string): boolean => {
|
|
80
|
+
const normalized = path.replace(/\\/g, '/').toLowerCase();
|
|
81
|
+
return (
|
|
82
|
+
isBackendTypeScriptPath(path) &&
|
|
83
|
+
!normalized.includes('/config/') &&
|
|
84
|
+
!normalized.endsWith('.config.ts') &&
|
|
85
|
+
!normalized.endsWith('/configuration.ts') &&
|
|
86
|
+
!normalized.endsWith('/environment.ts')
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
79
90
|
const isBackendControllerTypeScriptPath = (path: string): boolean => {
|
|
80
91
|
const normalized = path.replace(/\\/g, '/').toLowerCase();
|
|
81
92
|
return isBackendTypeScriptPath(path) && normalized.endsWith('.controller.ts');
|
|
@@ -95,6 +106,10 @@ const isIOSSwiftPath = (path: string): boolean => {
|
|
|
95
106
|
return path.endsWith('.swift') && path.startsWith('apps/ios/');
|
|
96
107
|
};
|
|
97
108
|
|
|
109
|
+
const isIOSSwiftPackageManifestPath = (path: string): boolean => {
|
|
110
|
+
return path.replace(/\\/g, '/') === 'apps/ios/Package.swift';
|
|
111
|
+
};
|
|
112
|
+
|
|
98
113
|
const isIOSPodfilePath = (path: string): boolean => {
|
|
99
114
|
const normalized = path.replace(/\\/g, '/');
|
|
100
115
|
return (
|
|
@@ -491,6 +506,10 @@ const astDetectorRegistry: ReadonlyArray<ASTDetectorRegistryEntry> = [
|
|
|
491
506
|
{ detect: TS.hasConsoleErrorCall, ruleId: 'heuristics.ts.console-error.ast', code: 'HEURISTICS_CONSOLE_ERROR_AST', message: 'AST heuristic detected console.error usage.' },
|
|
492
507
|
{ detect: TS.hasSensitiveDataLoggingCall, ruleId: 'heuristics.ts.backend.sensitive-data-logging.ast', code: 'HEURISTICS_BACKEND_SENSITIVE_DATA_LOGGING_AST', message: 'AST heuristic detected sensitive data passed to a logging sink.' },
|
|
493
508
|
{ detect: TS.hasBackendLogWithoutContext, locateLines: TS.findBackendLogWithoutContextLines, ruleId: 'heuristics.ts.backend.log-without-context.ast', code: 'HEURISTICS_BACKEND_LOG_WITHOUT_CONTEXT_AST', message: 'AST heuristic detected backend log call without request/user/trace context.', pathCheck: isBackendTypeScriptPath },
|
|
509
|
+
{ detect: TS.hasBackendAuthResponseWithoutRefreshToken, locateLines: TS.findBackendAuthResponseWithoutRefreshTokenLines, ruleId: 'heuristics.ts.backend.auth-response-without-refresh-token.ast', code: 'HEURISTICS_BACKEND_AUTH_RESPONSE_WITHOUT_REFRESH_TOKEN_AST', message: 'AST heuristic detected backend auth response returning an access token without a refresh token.', pathCheck: isBackendTypeScriptPath },
|
|
510
|
+
{ detect: TS.hasBackendProcessEnvDefaultFallback, locateLines: TS.findBackendProcessEnvDefaultFallbackLines, ruleId: 'heuristics.ts.backend.process-env-default-fallback.ast', code: 'HEURISTICS_BACKEND_PROCESS_ENV_DEFAULT_FALLBACK_AST', message: 'AST heuristic detected backend process.env configuration with a literal production fallback.', pathCheck: isBackendTypeScriptPath },
|
|
511
|
+
{ detect: TS.hasBackendDirectProcessEnvRead, locateLines: TS.findBackendDirectProcessEnvReadLines, ruleId: 'heuristics.ts.backend.direct-process-env-read.ast', code: 'HEURISTICS_BACKEND_DIRECT_PROCESS_ENV_READ_AST', message: 'AST heuristic detected direct backend process.env read outside the configuration boundary.', pathCheck: isBackendNonConfigTypeScriptPath },
|
|
512
|
+
{ detect: TS.hasBackendConfigModuleWithoutValidation, locateLines: TS.findBackendConfigModuleWithoutValidationLines, ruleId: 'heuristics.ts.backend.config-module-without-validation.ast', code: 'HEURISTICS_BACKEND_CONFIG_MODULE_WITHOUT_VALIDATION_AST', message: 'AST heuristic detected ConfigModule.forRoot without env validationSchema or validate.', pathCheck: isBackendTypeScriptPath },
|
|
494
513
|
{ detect: TS.hasBackendMagicNumberLiteral, ruleId: 'heuristics.ts.backend.magic-number-literal.ast', code: 'HEURISTICS_BACKEND_MAGIC_NUMBER_LITERAL_AST', message: 'AST heuristic detected an inline numeric literal; use a named constant for backend readability and maintainability.', pathCheck: isBackendTypeScriptPath },
|
|
495
514
|
{ detect: TS.hasBackendGenericErrorThrow, ruleId: 'heuristics.ts.backend.generic-error-throw.ast', code: 'HEURISTICS_BACKEND_GENERIC_ERROR_THROW_AST', message: 'AST heuristic detected throw new Error in backend code; use a specific domain/application exception.', pathCheck: isBackendTypeScriptPath },
|
|
496
515
|
{ detect: TS.hasBackendRawThrowExpression, locateLines: TS.findBackendRawThrowExpressionLines, ruleId: 'heuristics.ts.backend.raw-throw-expression.ast', code: 'HEURISTICS_BACKEND_RAW_THROW_EXPRESSION_AST', message: 'AST heuristic detected raw throw expression in backend code; throw a typed exception.', pathCheck: isBackendTypeScriptPath },
|
|
@@ -504,8 +523,10 @@ const astDetectorRegistry: ReadonlyArray<ASTDetectorRegistryEntry> = [
|
|
|
504
523
|
{ detect: TS.hasBackendAnemicDomainModel, ruleId: 'heuristics.ts.backend.anemic-domain-model.ast', code: 'HEURISTICS_BACKEND_ANEMIC_DOMAIN_MODEL_AST', message: 'AST heuristic detected an anemic backend domain entity without business behavior.', pathCheck: isBackendDomainEntityPath },
|
|
505
524
|
{ detect: TS.hasBackendPermissiveCorsConfiguration, locateLines: TS.findBackendPermissiveCorsConfigurationLines, ruleId: 'heuristics.ts.backend.permissive-cors.ast', code: 'HEURISTICS_BACKEND_PERMISSIVE_CORS_AST', message: 'AST heuristic detected permissive backend CORS configuration; restrict allowed origins explicitly.', pathCheck: isBackendTypeScriptPath },
|
|
506
525
|
{ detect: TS.hasBackendMissingHelmetSecurityHeaders, locateLines: TS.findBackendMissingHelmetSecurityHeadersLines, ruleId: 'heuristics.ts.backend.missing-helmet-security-headers.ast', code: 'HEURISTICS_BACKEND_MISSING_HELMET_SECURITY_HEADERS_AST', message: 'AST heuristic detected NestJS bootstrap without Helmet security headers middleware.', pathCheck: isBackendTypeScriptPath },
|
|
526
|
+
{ detect: TS.hasBackendMissingCompressionMiddleware, locateLines: TS.findBackendMissingCompressionMiddlewareLines, ruleId: 'heuristics.ts.backend.missing-compression-middleware.ast', code: 'HEURISTICS_BACKEND_MISSING_COMPRESSION_MIDDLEWARE_AST', message: 'AST heuristic detected NestJS bootstrap without compression middleware.', pathCheck: isBackendTypeScriptPath },
|
|
507
527
|
{ detect: TS.hasBackendMissingGlobalValidationPipe, locateLines: TS.findBackendMissingGlobalValidationPipeLines, ruleId: 'heuristics.ts.backend.missing-global-validation-pipe.ast', code: 'HEURISTICS_BACKEND_MISSING_GLOBAL_VALIDATION_PIPE_AST', message: 'AST heuristic detected NestJS bootstrap without global ValidationPipe whitelist.', pathCheck: isBackendTypeScriptPath },
|
|
508
528
|
{ detect: TS.hasBackendStringLiteralUnionEnumCandidate, locateLines: TS.findBackendStringLiteralUnionEnumCandidateLines, ruleId: 'heuristics.ts.backend.string-literal-union-enum.ast', code: 'HEURISTICS_BACKEND_STRING_LITERAL_UNION_ENUM_AST', message: 'AST heuristic detected fixed backend string literal union; use an enum or centralized typed constants.', pathCheck: isBackendTypeScriptPath },
|
|
529
|
+
{ detect: TS.hasBackendSensitiveCacheWrite, locateLines: TS.findBackendSensitiveCacheWriteLines, ruleId: 'heuristics.ts.backend.sensitive-cache-write.ast', code: 'HEURISTICS_BACKEND_SENSITIVE_CACHE_WRITE_AST', message: 'AST heuristic detected sensitive data written to backend cache.', pathCheck: isBackendTypeScriptPath },
|
|
509
530
|
{ detect: TS.hasEvalCall, ruleId: 'heuristics.ts.eval.ast', code: 'HEURISTICS_EVAL_AST', message: 'AST heuristic detected eval usage.' },
|
|
510
531
|
{ detect: TS.hasFunctionConstructorUsage, ruleId: 'heuristics.ts.function-constructor.ast', code: 'HEURISTICS_FUNCTION_CONSTRUCTOR_AST', message: 'AST heuristic detected Function constructor usage.' },
|
|
511
532
|
{ 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.' },
|
|
@@ -532,9 +553,12 @@ const astDetectorRegistry: ReadonlyArray<ASTDetectorRegistryEntry> = [
|
|
|
532
553
|
{ detect: TS.hasNestJsConstructorDependencyWithoutDecorator, ruleId: 'heuristics.ts.nestjs.constructor-di-without-decorator.ast', code: 'HEURISTICS_NESTJS_CONSTRUCTOR_DI_WITHOUT_DECORATOR_AST', message: 'AST heuristic detected NestJS constructor dependency injection without an explicit class decorator.' },
|
|
533
554
|
{ detect: TS.hasBackendControllerRouteWithoutGuard, locateLines: TS.findBackendControllerRouteWithoutGuardLines, ruleId: 'heuristics.ts.backend.controller-route-without-guard.ast', code: 'HEURISTICS_BACKEND_CONTROLLER_ROUTE_WITHOUT_GUARD_AST', message: 'AST heuristic detected NestJS controller route without UseGuards at method or class level.', pathCheck: isBackendTypeScriptPath },
|
|
534
555
|
{ detect: TS.hasBackendControllerRouteWithoutRoles, locateLines: TS.findBackendControllerRouteWithoutRolesLines, ruleId: 'heuristics.ts.backend.controller-route-without-roles.ast', code: 'HEURISTICS_BACKEND_CONTROLLER_ROUTE_WITHOUT_ROLES_AST', message: 'AST heuristic detected guarded NestJS controller route without Roles/SetMetadata roles authorization.', pathCheck: isBackendTypeScriptPath },
|
|
556
|
+
{ detect: TS.hasBackendAuthRouteWithoutThrottle, locateLines: TS.findBackendAuthRouteWithoutThrottleLines, ruleId: 'heuristics.ts.backend.auth-route-without-throttle.ast', code: 'HEURISTICS_BACKEND_AUTH_ROUTE_WITHOUT_THROTTLE_AST', message: 'AST heuristic detected authentication route without NestJS throttling/rate limiting.', pathCheck: isBackendTypeScriptPath },
|
|
557
|
+
{ detect: TS.hasBackendEventHandlerWithoutOnEvent, locateLines: TS.findBackendEventHandlerWithoutOnEventLines, ruleId: 'heuristics.ts.backend.event-handler-without-on-event.ast', code: 'HEURISTICS_BACKEND_EVENT_HANDLER_WITHOUT_ON_EVENT_AST', message: 'AST heuristic detected backend event handler without @OnEvent subscription.', pathCheck: isBackendTypeScriptPath },
|
|
535
558
|
{ detect: TS.hasPersistenceMutationWithoutAuditEvent, ruleId: 'heuristics.ts.backend.persistence-mutation-without-audit-event.ast', code: 'HEURISTICS_BACKEND_PERSISTENCE_MUTATION_WITHOUT_AUDIT_EVENT_AST', message: 'AST heuristic detected backend persistence mutation without audit log or domain event.' },
|
|
536
559
|
{ detect: TS.hasBackendHardDeleteWithoutSoftDelete, locateLines: TS.findBackendHardDeleteWithoutSoftDeleteLines, ruleId: 'heuristics.ts.backend.hard-delete-without-soft-delete.ast', code: 'HEURISTICS_BACKEND_HARD_DELETE_WITHOUT_SOFT_DELETE_AST', message: 'AST heuristic detected backend physical delete; use soft delete with deletedAt/deleted_at.', pathCheck: isBackendTypeScriptPath },
|
|
537
560
|
{ detect: TS.hasDtoPropertyWithoutValidation, ruleId: 'heuristics.ts.backend.dto-property-without-validation.ast', code: 'HEURISTICS_BACKEND_DTO_PROPERTY_WITHOUT_VALIDATION_AST', message: 'AST heuristic detected DTO property without class-validator/class-transformer decorator.' },
|
|
561
|
+
{ detect: TS.hasDtoPropertyWithoutApiProperty, locateLines: TS.findDtoPropertyWithoutApiPropertyLines, ruleId: 'heuristics.ts.backend.dto-property-without-api-property.ast', code: 'HEURISTICS_BACKEND_DTO_PROPERTY_WITHOUT_API_PROPERTY_AST', message: 'AST heuristic detected DTO property without Swagger ApiProperty decorator.' },
|
|
538
562
|
{ detect: TS.hasDtoNestedPropertyWithoutNestedValidation, ruleId: 'heuristics.ts.backend.dto-nested-property-without-nested-validation.ast', code: 'HEURISTICS_BACKEND_DTO_NESTED_PROPERTY_WITHOUT_NESTED_VALIDATION_AST', message: 'AST heuristic detected nested DTO property without @ValidateNested/@Type decorators.' },
|
|
539
563
|
{ detect: TS.hasLargeClassDeclaration, ruleId: 'heuristics.ts.god-class-large-class.ast', code: 'HEURISTICS_GOD_CLASS_LARGE_CLASS_AST', message: 'AST heuristic detected God Class candidate by mixed responsibility nodes in a single class declaration.' },
|
|
540
564
|
{ detect: TS.hasRecordStringUnknownType, locateLines: TS.findRecordStringUnknownTypeLines, ruleId: 'common.types.record_unknown_requires_type', code: 'COMMON_TYPES_RECORD_UNKNOWN_REQUIRES_TYPE_AST', message: 'AST heuristic detected Record<string, unknown> without explicit value union.' },
|
|
@@ -723,6 +747,7 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
|
|
|
723
747
|
// iOS
|
|
724
748
|
{ platform: 'ios', pathCheck: isIOSPodfilePath, excludePaths: [], detect: detectsTrackedFilePresence, ruleId: 'heuristics.ios.dependencies.cocoapods.ast', code: 'HEURISTICS_IOS_DEPENDENCIES_COCOAPODS_AST', message: 'AST heuristic detected CocoaPods dependency files in an iOS project; Swift Package Manager remains the preferred baseline for new code.' },
|
|
725
749
|
{ 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.' },
|
|
750
|
+
{ platform: 'ios', pathCheck: isIOSSwiftPackageManifestPath, excludePaths: [], detect: TextIOS.hasSwiftPackageBranchDependencyUsage, locateLines: TextIOS.collectSwiftPackageBranchDependencyLines, primaryNode: (lines) => ({ kind: 'call', name: 'SwiftPM .package(..., branch: ...)', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: .package(..., exact:/from: version)', lines }], why: 'Branch-based SwiftPM dependencies drift over time and do not provide a reproducible iOS dependency graph.', impact: 'A consumer can build different code from the same commit when the remote branch moves, making production audits and regressions non-deterministic.', expected_fix: 'Pin the dependency to an exact version or an approved semantic version requirement in Package.swift; avoid branch-based dependencies outside explicitly approved experiments.', ruleId: 'heuristics.ios.dependencies.swiftpm-branch-dependency.ast', code: 'HEURISTICS_IOS_DEPENDENCIES_SWIFTPM_BRANCH_DEPENDENCY_AST', message: 'AST heuristic detected a branch-based SwiftPM dependency in iOS Package.swift; use specific versions for reproducible builds.' },
|
|
726
751
|
{ 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.' },
|
|
727
752
|
{ 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.' },
|
|
728
753
|
{ 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.' },
|
|
@@ -738,6 +763,7 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
|
|
|
738
763
|
{ platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftTaskDetachedUsage, ruleId: 'heuristics.ios.task-detached.ast', code: 'HEURISTICS_IOS_TASK_DETACHED_AST', message: 'AST heuristic detected Task.detached usage.' },
|
|
739
764
|
{ platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftAsyncWithoutAwaitUsage, ruleId: 'heuristics.ios.concurrency.async-without-await.ast', code: 'HEURISTICS_IOS_CONCURRENCY_ASYNC_WITHOUT_AWAIT_AST', message: 'AST heuristic detected a private async function without await; remove async unless a protocol/override boundary requires it.' },
|
|
740
765
|
{ 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.' },
|
|
766
|
+
{ platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftNSErrorThrowUsage, locateLines: TextIOS.collectSwiftNSErrorThrowLines, primaryNode: (lines) => ({ kind: 'call', name: 'throw NSError(...)', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: typed Swift Error enum case', lines }], why: 'NSError throws bypass the typed Swift error boundary that should model network, domain or infrastructure failures explicitly.', impact: 'Callers receive an untyped Foundation error instead of a remediable enum case, making recovery, tests and user-facing handling less deterministic.', expected_fix: 'Define a domain-specific Error enum such as NetworkError or AppError and throw typed cases instead of constructing NSError directly.', ruleId: 'heuristics.ios.error.nserror-throw.ast', code: 'HEURISTICS_IOS_ERROR_NSERROR_THROW_AST', message: 'AST heuristic detected throw NSError(...) in iOS production code; use typed Swift Error enums such as NetworkError or AppError.' },
|
|
741
767
|
{ platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnAppearTaskUsage, locateLines: TextIOS.collectSwiftOnAppearTaskLines, primaryNode: (lines) => ({ kind: 'call', name: 'Task launched inside SwiftUI onAppear', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: .task { ... } on the view', lines }], why: 'A Task launched from onAppear is not owned by the SwiftUI view lifecycle in the same way as .task.', impact: 'Async work can outlive view disappearance or require manual cancellation, and the gate must point to the exact Task line instead of blocking the whole file.', expected_fix: 'Move the async work from .onAppear { Task { ... } } into .task { ... } so SwiftUI owns automatic cancellation. Keep onAppear only for synchronous side effects such as analytics.', 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.' },
|
|
742
768
|
{ platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnChangeTaskUsage, locateLines: TextIOS.collectSwiftOnChangeTaskLines, primaryNode: (lines) => ({ kind: 'call', name: 'Task launched inside SwiftUI onChange', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: .task(id:) for value-dependent async work', lines }], why: 'A Task launched from onChange makes value-dependent async work manual instead of tying cancellation to the changing value.', impact: 'Search, load or refresh work can race after state changes because cancellation is not expressed through SwiftUI .task(id:).', expected_fix: 'Move value-dependent async work from .onChange { Task { ... } } into .task(id: value) { ... }. Keep onChange only for synchronous derivations or analytics.', 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.' },
|
|
743
769
|
{ 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.' },
|
|
@@ -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,
|
|
6
|
+
assert.equal(iosRules.length, 105);
|
|
7
7
|
|
|
8
8
|
const ids = iosRules.map((rule) => rule.id);
|
|
9
9
|
assert.deepEqual(ids, [
|
|
@@ -21,6 +21,7 @@ test('iosRules define reglas heurísticas locked para plataforma ios', () => {
|
|
|
21
21
|
'heuristics.ios.task-detached.ast',
|
|
22
22
|
'heuristics.ios.concurrency.async-without-await.ast',
|
|
23
23
|
'heuristics.ios.error.empty-catch.ast',
|
|
24
|
+
'heuristics.ios.error.nserror-throw.ast',
|
|
24
25
|
'heuristics.ios.swiftui.onappear-task.ast',
|
|
25
26
|
'heuristics.ios.swiftui.onchange-task.ast',
|
|
26
27
|
'heuristics.ios.swiftui.onchange-readonly-var.ast',
|
|
@@ -43,6 +44,7 @@ test('iosRules define reglas heurísticas locked para plataforma ios', () => {
|
|
|
43
44
|
'heuristics.ios.json.jsonserialization.ast',
|
|
44
45
|
'heuristics.ios.dependencies.cocoapods.ast',
|
|
45
46
|
'heuristics.ios.dependencies.carthage.ast',
|
|
47
|
+
'heuristics.ios.dependencies.swiftpm-branch-dependency.ast',
|
|
46
48
|
'heuristics.ios.security.userdefaults-sensitive-data.ast',
|
|
47
49
|
'heuristics.ios.security.insecure-transport.ast',
|
|
48
50
|
'heuristics.ios.localization.localizable-strings.ast',
|
|
@@ -249,6 +251,14 @@ test('iosRules define reglas heurísticas locked para plataforma ios', () => {
|
|
|
249
251
|
byId.get('heuristics.ios.assume-isolated.ast')?.then.code,
|
|
250
252
|
'HEURISTICS_IOS_ASSUME_ISOLATED_AST'
|
|
251
253
|
);
|
|
254
|
+
assert.equal(
|
|
255
|
+
byId.get('heuristics.ios.dependencies.swiftpm-branch-dependency.ast')?.then.code,
|
|
256
|
+
'HEURISTICS_IOS_DEPENDENCIES_SWIFTPM_BRANCH_DEPENDENCY_AST'
|
|
257
|
+
);
|
|
258
|
+
assert.equal(
|
|
259
|
+
byId.get('heuristics.ios.error.nserror-throw.ast')?.then.code,
|
|
260
|
+
'HEURISTICS_IOS_ERROR_NSERROR_THROW_AST'
|
|
261
|
+
);
|
|
252
262
|
assert.equal(
|
|
253
263
|
byId.get('heuristics.ios.legacy-swiftui-observable-wrapper.ast')?.then.code,
|
|
254
264
|
'HEURISTICS_IOS_LEGACY_SWIFTUI_OBSERVABLE_WRAPPER_AST'
|
|
@@ -255,6 +255,24 @@ export const iosRules: RuleSet = [
|
|
|
255
255
|
code: 'HEURISTICS_IOS_ERROR_EMPTY_CATCH_AST',
|
|
256
256
|
},
|
|
257
257
|
},
|
|
258
|
+
{
|
|
259
|
+
id: 'heuristics.ios.error.nserror-throw.ast',
|
|
260
|
+
description: 'Detects direct NSError throws where typed Swift Error enums should be used.',
|
|
261
|
+
severity: 'WARN',
|
|
262
|
+
platform: 'ios',
|
|
263
|
+
locked: true,
|
|
264
|
+
when: {
|
|
265
|
+
kind: 'Heuristic',
|
|
266
|
+
where: {
|
|
267
|
+
ruleId: 'heuristics.ios.error.nserror-throw.ast',
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
then: {
|
|
271
|
+
kind: 'Finding',
|
|
272
|
+
message: 'AST heuristic detected throw NSError(...) in iOS production code.',
|
|
273
|
+
code: 'HEURISTICS_IOS_ERROR_NSERROR_THROW_AST',
|
|
274
|
+
},
|
|
275
|
+
},
|
|
258
276
|
{
|
|
259
277
|
id: 'heuristics.ios.swiftui.onappear-task.ast',
|
|
260
278
|
description: 'Detects Task launches from SwiftUI onAppear where .task can provide lifecycle cancellation.',
|
|
@@ -667,6 +685,24 @@ export const iosRules: RuleSet = [
|
|
|
667
685
|
code: 'HEURISTICS_IOS_DEPENDENCIES_CARTHAGE_AST',
|
|
668
686
|
},
|
|
669
687
|
},
|
|
688
|
+
{
|
|
689
|
+
id: 'heuristics.ios.dependencies.swiftpm-branch-dependency.ast',
|
|
690
|
+
description: 'Detects branch-based SwiftPM dependencies in iOS Package.swift.',
|
|
691
|
+
severity: 'WARN',
|
|
692
|
+
platform: 'ios',
|
|
693
|
+
locked: true,
|
|
694
|
+
when: {
|
|
695
|
+
kind: 'Heuristic',
|
|
696
|
+
where: {
|
|
697
|
+
ruleId: 'heuristics.ios.dependencies.swiftpm-branch-dependency.ast',
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
then: {
|
|
701
|
+
kind: 'Finding',
|
|
702
|
+
message: 'AST heuristic detected a branch-based SwiftPM dependency.',
|
|
703
|
+
code: 'HEURISTICS_IOS_DEPENDENCIES_SWIFTPM_BRANCH_DEPENDENCY_AST',
|
|
704
|
+
},
|
|
705
|
+
},
|
|
670
706
|
{
|
|
671
707
|
id: 'heuristics.ios.security.userdefaults-sensitive-data.ast',
|
|
672
708
|
description: 'Detects sensitive data stored in UserDefaults/AppStorage; Keychain is the preferred baseline for secrets.',
|