ios-app-review-plugin 1.0.0
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/.claude/settings.local.json +42 -0
- package/.github/actions/ios-review/action.yml +106 -0
- package/.github/workflows/ci.yml +103 -0
- package/.github/workflows/publish.yml +57 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +175 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bitrise/step.sh +128 -0
- package/bitrise/step.yml +101 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzers/asc-iap.d.ts.map +1 -0
- package/dist/analyzers/asc-metadata.d.ts.map +1 -0
- package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
- package/dist/analyzers/asc-version.d.ts.map +1 -0
- package/dist/analyzers/code-scanner.d.ts.map +1 -0
- package/dist/analyzers/deprecated-api.d.ts.map +1 -0
- package/dist/analyzers/entitlements.d.ts.map +1 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/info-plist.d.ts.map +1 -0
- package/dist/analyzers/privacy.d.ts.map +1 -0
- package/dist/analyzers/private-api.d.ts.map +1 -0
- package/dist/analyzers/security.d.ts.map +1 -0
- package/dist/analyzers/ui-ux.d.ts.map +1 -0
- package/dist/asc/auth.d.ts.map +1 -0
- package/dist/asc/client.d.ts.map +1 -0
- package/dist/asc/endpoints/apps.d.ts.map +1 -0
- package/dist/asc/endpoints/iap.d.ts.map +1 -0
- package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
- package/dist/asc/endpoints/versions.d.ts.map +1 -0
- package/dist/asc/errors.d.ts.map +1 -0
- package/dist/asc/index.d.ts.map +1 -0
- package/dist/asc/types.d.ts.map +1 -0
- package/dist/badge/generator.d.ts.map +1 -0
- package/dist/badge/index.d.ts.map +1 -0
- package/dist/badge/types.d.ts.map +1 -0
- package/dist/cache/file-cache.d.ts.map +1 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/scan.d.ts.map +1 -0
- package/dist/cli/commands/version.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/git/diff.d.ts.map +1 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/types.d.ts.map +1 -0
- package/dist/guidelines/database.d.ts.map +1 -0
- package/dist/guidelines/index.d.ts.map +1 -0
- package/dist/guidelines/matcher.d.ts.map +1 -0
- package/dist/guidelines/types.d.ts.map +1 -0
- package/dist/history/comparator.d.ts.map +1 -0
- package/dist/history/index.d.ts.map +1 -0
- package/dist/history/store.d.ts.map +1 -0
- package/dist/history/types.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +994 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/plist.d.ts.map +1 -0
- package/dist/parsers/xcodeproj.d.ts.map +1 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/reporter.d.ts.map +1 -0
- package/dist/progress/types.d.ts.map +1 -0
- package/dist/reports/html.d.ts.map +1 -0
- package/dist/reports/index.d.ts.map +1 -0
- package/dist/reports/json.d.ts.map +1 -0
- package/dist/reports/markdown.d.ts.map +1 -0
- package/dist/reports/types.d.ts.map +1 -0
- package/dist/rules/engine.d.ts.map +1 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/loader.d.ts.map +1 -0
- package/dist/rules/types.d.ts.map +1 -0
- package/dist/types/index.d.ts.map +1 -0
- package/docs/ANALYZERS.md +237 -0
- package/docs/API.md +308 -0
- package/docs/BADGES.md +130 -0
- package/docs/CI_CD.md +283 -0
- package/docs/CLI.md +140 -0
- package/docs/REPORTS.md +212 -0
- package/docs/ROADMAP.md +267 -0
- package/docs/RULES.md +182 -0
- package/docs/SECURITY.md +89 -0
- package/docs/TROUBLESHOOTING.md +227 -0
- package/docs/tutorials/ASC_SETUP.md +188 -0
- package/docs/tutorials/CI_INTEGRATION.md +292 -0
- package/docs/tutorials/CUSTOM_RULES.md +291 -0
- package/docs/tutorials/GETTING_STARTED.md +226 -0
- package/docs/video-scripts/01-introduction.md +106 -0
- package/docs/video-scripts/02-cli-usage.md +120 -0
- package/docs/video-scripts/03-ci-integration.md +198 -0
- package/eslint.config.js +33 -0
- package/examples/.ios-review-rules.json +82 -0
- package/examples/bitrise-workflow.yml +129 -0
- package/examples/fastlane-lane.rb +71 -0
- package/examples/github-action.yml +147 -0
- package/fastlane/Fastfile.example +114 -0
- package/fastlane/README.md +99 -0
- package/jest.config.js +36 -0
- package/package.json +65 -0
- package/scripts/benchmark.ts +112 -0
- package/scripts/debug-parser.ts +37 -0
- package/scripts/debug-pbxproj.ts +36 -0
- package/scripts/debug-specific.ts +47 -0
- package/scripts/test-analyze.ts +67 -0
- package/scripts/xcode-cloud-review.sh +167 -0
- package/src/analyzer.ts +227 -0
- package/src/analyzers/asc-iap.ts +300 -0
- package/src/analyzers/asc-metadata.ts +326 -0
- package/src/analyzers/asc-screenshots.ts +310 -0
- package/src/analyzers/asc-version.ts +368 -0
- package/src/analyzers/code-scanner.ts +408 -0
- package/src/analyzers/deprecated-api.ts +390 -0
- package/src/analyzers/entitlements.ts +345 -0
- package/src/analyzers/index.ts +12 -0
- package/src/analyzers/info-plist.ts +409 -0
- package/src/analyzers/privacy.ts +376 -0
- package/src/analyzers/private-api.ts +377 -0
- package/src/analyzers/security.ts +327 -0
- package/src/analyzers/ui-ux.ts +509 -0
- package/src/asc/auth.ts +204 -0
- package/src/asc/client.ts +258 -0
- package/src/asc/endpoints/apps.ts +115 -0
- package/src/asc/endpoints/iap.ts +171 -0
- package/src/asc/endpoints/screenshots.ts +164 -0
- package/src/asc/endpoints/versions.ts +174 -0
- package/src/asc/errors.ts +109 -0
- package/src/asc/index.ts +108 -0
- package/src/asc/types.ts +369 -0
- package/src/badge/generator.ts +48 -0
- package/src/badge/index.ts +2 -0
- package/src/badge/types.ts +5 -0
- package/src/cache/file-cache.ts +75 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/types.ts +10 -0
- package/src/cli/commands/help.ts +41 -0
- package/src/cli/commands/scan.ts +44 -0
- package/src/cli/commands/version.ts +12 -0
- package/src/cli/index.ts +92 -0
- package/src/cli/types.ts +17 -0
- package/src/git/diff.ts +21 -0
- package/src/git/index.ts +2 -0
- package/src/git/types.ts +5 -0
- package/src/guidelines/database.ts +344 -0
- package/src/guidelines/index.ts +4 -0
- package/src/guidelines/matcher.ts +84 -0
- package/src/guidelines/types.ts +28 -0
- package/src/history/comparator.ts +114 -0
- package/src/history/index.ts +3 -0
- package/src/history/store.ts +135 -0
- package/src/history/types.ts +40 -0
- package/src/index.ts +1113 -0
- package/src/parsers/index.ts +3 -0
- package/src/parsers/plist.ts +253 -0
- package/src/parsers/xcodeproj.ts +265 -0
- package/src/progress/index.ts +2 -0
- package/src/progress/reporter.ts +65 -0
- package/src/progress/types.ts +9 -0
- package/src/reports/html.ts +322 -0
- package/src/reports/index.ts +20 -0
- package/src/reports/json.ts +92 -0
- package/src/reports/markdown.ts +187 -0
- package/src/reports/types.ts +26 -0
- package/src/rules/engine.ts +121 -0
- package/src/rules/index.ts +3 -0
- package/src/rules/loader.ts +83 -0
- package/src/rules/types.ts +25 -0
- package/src/types/index.ts +247 -0
- package/tests/analyzer.test.ts +142 -0
- package/tests/analyzers/asc-iap.test.ts +228 -0
- package/tests/analyzers/asc-metadata.test.ts +210 -0
- package/tests/analyzers/asc-screenshots.test.ts +135 -0
- package/tests/analyzers/asc-version.test.ts +259 -0
- package/tests/analyzers/code-scanner.test.ts +745 -0
- package/tests/analyzers/deprecated-api.test.ts +286 -0
- package/tests/analyzers/entitlements.test.ts +411 -0
- package/tests/analyzers/info-plist.test.ts +148 -0
- package/tests/analyzers/privacy.test.ts +623 -0
- package/tests/analyzers/private-api.test.ts +255 -0
- package/tests/analyzers/security.test.ts +300 -0
- package/tests/analyzers/ui-ux.test.ts +357 -0
- package/tests/asc/auth.test.ts +189 -0
- package/tests/asc/client.test.ts +207 -0
- package/tests/asc/endpoints.test.ts +1359 -0
- package/tests/badge/generator.test.ts +73 -0
- package/tests/cache/file-cache.test.ts +124 -0
- package/tests/cli/cli-index.test.ts +510 -0
- package/tests/cli/commands.test.ts +67 -0
- package/tests/cli/scan.test.ts +152 -0
- package/tests/git/diff.test.ts +69 -0
- package/tests/guidelines/matcher.test.ts +209 -0
- package/tests/history/comparator.test.ts +272 -0
- package/tests/history/store.test.ts +200 -0
- package/tests/integration/cli.test.ts +95 -0
- package/tests/integration/e2e.test.ts +130 -0
- package/tests/parsers/plist.test.ts +240 -0
- package/tests/parsers/xcodeproj.test.ts +289 -0
- package/tests/progress/reporter.test.ts +117 -0
- package/tests/reports/html.test.ts +176 -0
- package/tests/reports/json.test.ts +235 -0
- package/tests/reports/markdown.test.ts +196 -0
- package/tests/rules/engine.test.ts +229 -0
- package/tests/rules/loader.test.ts +187 -0
- package/tests/setup.ts +15 -0
- package/tsconfig.json +27 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import type { AnalysisResult, Issue, Severity } from '../types/index.js';
|
|
5
|
+
import type { CompiledRule, CustomRuleConfig } from './types.js';
|
|
6
|
+
|
|
7
|
+
const SOURCE_EXTENSIONS = ['.swift', '.m', '.mm', '.h', '.c', '.cpp'];
|
|
8
|
+
const DISABLE_COMMENT_PATTERN = /\/\/\s*ios-review-disable-next-line\s+([\w-]+(?:\s*,\s*[\w-]+)*)/;
|
|
9
|
+
|
|
10
|
+
export class CustomRuleEngine {
|
|
11
|
+
private disabledRules: Set<string>;
|
|
12
|
+
private severityOverrides: Record<string, Severity>;
|
|
13
|
+
|
|
14
|
+
constructor(config?: CustomRuleConfig) {
|
|
15
|
+
this.disabledRules = new Set(config?.disabledRules ?? []);
|
|
16
|
+
this.severityOverrides = config?.severityOverrides ?? {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
isRuleDisabled(ruleId: string): boolean {
|
|
20
|
+
return this.disabledRules.has(ruleId);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getSeverityOverride(ruleId: string): Severity | undefined {
|
|
24
|
+
return this.severityOverrides[ruleId];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async scan(scanPath: string, compiledRules: CompiledRule[]): Promise<AnalysisResult> {
|
|
28
|
+
const startTime = Date.now();
|
|
29
|
+
const issues: Issue[] = [];
|
|
30
|
+
|
|
31
|
+
// Filter out disabled rules
|
|
32
|
+
const activeRules = compiledRules.filter((r) => !this.isRuleDisabled(r.id));
|
|
33
|
+
|
|
34
|
+
if (activeRules.length === 0) {
|
|
35
|
+
return {
|
|
36
|
+
analyzer: 'Custom Rules',
|
|
37
|
+
passed: true,
|
|
38
|
+
issues: [],
|
|
39
|
+
duration: Date.now() - startTime,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Determine files to scan
|
|
44
|
+
const stat = await fs.stat(scanPath);
|
|
45
|
+
let files: string[];
|
|
46
|
+
|
|
47
|
+
if (stat.isFile()) {
|
|
48
|
+
files = [scanPath];
|
|
49
|
+
} else {
|
|
50
|
+
const patterns = SOURCE_EXTENSIONS.map((ext) => `**/*${ext}`);
|
|
51
|
+
files = await fg(patterns, {
|
|
52
|
+
cwd: scanPath,
|
|
53
|
+
absolute: true,
|
|
54
|
+
ignore: ['**/node_modules/**', '**/Pods/**', '**/build/**', '**/.build/**'],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const filePath of files) {
|
|
59
|
+
const ext = path.extname(filePath);
|
|
60
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
61
|
+
const lines = content.split('\n');
|
|
62
|
+
|
|
63
|
+
for (const rule of activeRules) {
|
|
64
|
+
// Check fileTypes filter
|
|
65
|
+
if (rule.fileTypes && rule.fileTypes.length > 0 && !rule.fileTypes.includes(ext)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Reset regex lastIndex for global patterns
|
|
70
|
+
rule.regex.lastIndex = 0;
|
|
71
|
+
|
|
72
|
+
let match: RegExpExecArray | null;
|
|
73
|
+
while ((match = rule.regex.exec(content)) !== null) {
|
|
74
|
+
// Find line number
|
|
75
|
+
const upToMatch = content.substring(0, match.index);
|
|
76
|
+
const lineNumber = upToMatch.split('\n').length;
|
|
77
|
+
|
|
78
|
+
// Check for disable comment on previous line
|
|
79
|
+
if (lineNumber >= 2) {
|
|
80
|
+
const prevLine = lines[lineNumber - 2]; // -2 because lineNumber is 1-based and we want prev line
|
|
81
|
+
if (prevLine) {
|
|
82
|
+
const disableMatch = DISABLE_COMMENT_PATTERN.exec(prevLine);
|
|
83
|
+
if (disableMatch?.[1]) {
|
|
84
|
+
const disabledIds = disableMatch[1].split(',').map((s) => s.trim());
|
|
85
|
+
if (disabledIds.includes(rule.id)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const severity = this.getSeverityOverride(rule.id) ?? rule.severity;
|
|
93
|
+
|
|
94
|
+
issues.push({
|
|
95
|
+
id: rule.id,
|
|
96
|
+
title: rule.title,
|
|
97
|
+
description: rule.description,
|
|
98
|
+
severity,
|
|
99
|
+
filePath,
|
|
100
|
+
lineNumber,
|
|
101
|
+
guideline: rule.guideline,
|
|
102
|
+
suggestion: rule.suggestion,
|
|
103
|
+
category: rule.category ?? 'custom',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// For non-global regex, break after first match
|
|
107
|
+
if (!rule.regex.global) {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
analyzer: 'Custom Rules',
|
|
116
|
+
passed: issues.every((i) => i.severity !== 'error'),
|
|
117
|
+
issues,
|
|
118
|
+
duration: Date.now() - startTime,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { CustomRuleConfig, CompiledRule } from './types.js';
|
|
5
|
+
|
|
6
|
+
const CustomRuleSchema = z.object({
|
|
7
|
+
id: z.string().min(1),
|
|
8
|
+
title: z.string().min(1),
|
|
9
|
+
description: z.string().min(1),
|
|
10
|
+
severity: z.enum(['error', 'warning', 'info']),
|
|
11
|
+
pattern: z.string().min(1),
|
|
12
|
+
flags: z.string().optional(),
|
|
13
|
+
fileTypes: z.array(z.string()).optional(),
|
|
14
|
+
guideline: z.string().optional(),
|
|
15
|
+
suggestion: z.string().optional(),
|
|
16
|
+
category: z.string().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const CustomRuleConfigSchema = z.object({
|
|
20
|
+
version: z.literal(1),
|
|
21
|
+
rules: z.array(CustomRuleSchema),
|
|
22
|
+
disabledRules: z.array(z.string()).optional(),
|
|
23
|
+
severityOverrides: z.record(z.enum(['error', 'warning', 'info'])).optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const CONFIG_FILENAME = '.ios-review-rules.json';
|
|
27
|
+
|
|
28
|
+
export class RuleLoader {
|
|
29
|
+
/**
|
|
30
|
+
* Find config file by walking up from the given directory
|
|
31
|
+
*/
|
|
32
|
+
async findConfig(startDir: string): Promise<string | null> {
|
|
33
|
+
let dir = path.resolve(startDir);
|
|
34
|
+
const root = path.parse(dir).root;
|
|
35
|
+
|
|
36
|
+
while (dir !== root) {
|
|
37
|
+
const configPath = path.join(dir, CONFIG_FILENAME);
|
|
38
|
+
try {
|
|
39
|
+
await fs.access(configPath);
|
|
40
|
+
return configPath;
|
|
41
|
+
} catch {
|
|
42
|
+
dir = path.dirname(dir);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load and validate a config file
|
|
50
|
+
*/
|
|
51
|
+
async loadConfig(configPath: string): Promise<CustomRuleConfig> {
|
|
52
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
return CustomRuleConfigSchema.parse(parsed) as CustomRuleConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compile regex patterns from rules
|
|
59
|
+
*/
|
|
60
|
+
compileRules(config: CustomRuleConfig): CompiledRule[] {
|
|
61
|
+
const compiled: CompiledRule[] = [];
|
|
62
|
+
|
|
63
|
+
for (const rule of config.rules) {
|
|
64
|
+
const flags = rule.flags ?? 'g';
|
|
65
|
+
const regex = new RegExp(rule.pattern, flags);
|
|
66
|
+
compiled.push({ ...rule, regex });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return compiled;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Find, load, validate, and compile rules from project directory
|
|
74
|
+
*/
|
|
75
|
+
async loadFromProject(projectDir: string): Promise<{ config: CustomRuleConfig; rules: CompiledRule[] } | null> {
|
|
76
|
+
const configPath = await this.findConfig(projectDir);
|
|
77
|
+
if (!configPath) return null;
|
|
78
|
+
|
|
79
|
+
const config = await this.loadConfig(configPath);
|
|
80
|
+
const rules = this.compileRules(config);
|
|
81
|
+
return { config, rules };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Severity, IssueCategory } from '../types/index.js';
|
|
2
|
+
|
|
3
|
+
export interface CustomRule {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
severity: Severity;
|
|
8
|
+
pattern: string;
|
|
9
|
+
flags?: string | undefined;
|
|
10
|
+
fileTypes?: string[] | undefined;
|
|
11
|
+
guideline?: string | undefined;
|
|
12
|
+
suggestion?: string | undefined;
|
|
13
|
+
category?: IssueCategory | 'custom' | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CustomRuleConfig {
|
|
17
|
+
version: 1;
|
|
18
|
+
rules: CustomRule[];
|
|
19
|
+
disabledRules?: string[] | undefined;
|
|
20
|
+
severityOverrides?: Record<string, Severity> | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CompiledRule extends CustomRule {
|
|
24
|
+
regex: RegExp;
|
|
25
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Severity levels for analysis issues
|
|
5
|
+
*/
|
|
6
|
+
export type Severity = 'error' | 'warning' | 'info';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An issue found during analysis
|
|
10
|
+
*/
|
|
11
|
+
export interface Issue {
|
|
12
|
+
/** Unique identifier for the issue type */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Human-readable title */
|
|
15
|
+
title: string;
|
|
16
|
+
/** Detailed description of the issue */
|
|
17
|
+
description: string;
|
|
18
|
+
/** Severity level */
|
|
19
|
+
severity: Severity;
|
|
20
|
+
/** File path where the issue was found (if applicable) */
|
|
21
|
+
filePath?: string | undefined;
|
|
22
|
+
/** Line number in the file (if applicable) */
|
|
23
|
+
lineNumber?: number | undefined;
|
|
24
|
+
/** Related App Store Guideline (if applicable) */
|
|
25
|
+
guideline?: string | undefined;
|
|
26
|
+
/** Suggested fix or remediation */
|
|
27
|
+
suggestion?: string | undefined;
|
|
28
|
+
/** Category of the issue */
|
|
29
|
+
category: IssueCategory;
|
|
30
|
+
/** URL to the relevant App Store Review Guideline */
|
|
31
|
+
guidelineUrl?: string | undefined;
|
|
32
|
+
/** Excerpt from the relevant guideline */
|
|
33
|
+
guidelineExcerpt?: string | undefined;
|
|
34
|
+
/** Calculated severity score based on guideline weight */
|
|
35
|
+
severityScore?: number | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Categories for grouping issues
|
|
40
|
+
*/
|
|
41
|
+
export type IssueCategory =
|
|
42
|
+
| 'info-plist'
|
|
43
|
+
| 'privacy'
|
|
44
|
+
| 'entitlements'
|
|
45
|
+
| 'code'
|
|
46
|
+
| 'security'
|
|
47
|
+
| 'metadata'
|
|
48
|
+
| 'screenshots'
|
|
49
|
+
| 'version'
|
|
50
|
+
| 'iap'
|
|
51
|
+
| 'asc'
|
|
52
|
+
| 'deprecated-api'
|
|
53
|
+
| 'private-api'
|
|
54
|
+
| 'ui-ux'
|
|
55
|
+
| 'custom';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Result from an analyzer
|
|
59
|
+
*/
|
|
60
|
+
export interface AnalysisResult {
|
|
61
|
+
/** Name of the analyzer */
|
|
62
|
+
analyzer: string;
|
|
63
|
+
/** Whether the analysis passed (no errors) */
|
|
64
|
+
passed: boolean;
|
|
65
|
+
/** List of issues found */
|
|
66
|
+
issues: Issue[];
|
|
67
|
+
/** Time taken in milliseconds */
|
|
68
|
+
duration: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Complete analysis report
|
|
73
|
+
*/
|
|
74
|
+
export interface AnalysisReport {
|
|
75
|
+
/** Project path that was analyzed */
|
|
76
|
+
projectPath: string;
|
|
77
|
+
/** Timestamp of the analysis */
|
|
78
|
+
timestamp: string;
|
|
79
|
+
/** Results from each analyzer */
|
|
80
|
+
results: AnalysisResult[];
|
|
81
|
+
/** Summary statistics */
|
|
82
|
+
summary: AnalysisSummary;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Summary statistics for an analysis
|
|
87
|
+
*/
|
|
88
|
+
export interface AnalysisSummary {
|
|
89
|
+
/** Total number of issues */
|
|
90
|
+
totalIssues: number;
|
|
91
|
+
/** Number of errors */
|
|
92
|
+
errors: number;
|
|
93
|
+
/** Number of warnings */
|
|
94
|
+
warnings: number;
|
|
95
|
+
/** Number of info items */
|
|
96
|
+
info: number;
|
|
97
|
+
/** Overall pass/fail status */
|
|
98
|
+
passed: boolean;
|
|
99
|
+
/** Total analysis duration in milliseconds */
|
|
100
|
+
duration: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Xcode project information
|
|
105
|
+
*/
|
|
106
|
+
export interface XcodeProject {
|
|
107
|
+
/** Path to the .xcodeproj directory */
|
|
108
|
+
path: string;
|
|
109
|
+
/** Project name */
|
|
110
|
+
name: string;
|
|
111
|
+
/** Build targets */
|
|
112
|
+
targets: XcodeTarget[];
|
|
113
|
+
/** Build configurations */
|
|
114
|
+
configurations: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Xcode build target
|
|
119
|
+
*/
|
|
120
|
+
export interface XcodeTarget {
|
|
121
|
+
/** Target name */
|
|
122
|
+
name: string;
|
|
123
|
+
/** Target type (app, framework, extension, etc.) */
|
|
124
|
+
type: TargetType;
|
|
125
|
+
/** Bundle identifier */
|
|
126
|
+
bundleIdentifier?: string | undefined;
|
|
127
|
+
/** Path to Info.plist */
|
|
128
|
+
infoPlistPath?: string | undefined;
|
|
129
|
+
/** Path to entitlements file */
|
|
130
|
+
entitlementsPath?: string | undefined;
|
|
131
|
+
/** Minimum deployment target */
|
|
132
|
+
deploymentTarget?: string | undefined;
|
|
133
|
+
/** Source files */
|
|
134
|
+
sourceFiles: string[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type TargetType =
|
|
138
|
+
| 'application'
|
|
139
|
+
| 'framework'
|
|
140
|
+
| 'staticLibrary'
|
|
141
|
+
| 'dynamicLibrary'
|
|
142
|
+
| 'appExtension'
|
|
143
|
+
| 'watchApp'
|
|
144
|
+
| 'watchExtension'
|
|
145
|
+
| 'tvExtension'
|
|
146
|
+
| 'unitTest'
|
|
147
|
+
| 'uiTest'
|
|
148
|
+
| 'unknown';
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parsed Info.plist contents
|
|
152
|
+
*/
|
|
153
|
+
export interface InfoPlist {
|
|
154
|
+
/** Bundle identifier */
|
|
155
|
+
CFBundleIdentifier?: string;
|
|
156
|
+
/** Bundle name */
|
|
157
|
+
CFBundleName?: string;
|
|
158
|
+
/** Bundle display name */
|
|
159
|
+
CFBundleDisplayName?: string;
|
|
160
|
+
/** Bundle version (build number) */
|
|
161
|
+
CFBundleVersion?: string;
|
|
162
|
+
/** Short version string (marketing version) */
|
|
163
|
+
CFBundleShortVersionString?: string;
|
|
164
|
+
/** Minimum iOS version */
|
|
165
|
+
MinimumOSVersion?: string;
|
|
166
|
+
/** Privacy usage descriptions */
|
|
167
|
+
[key: `NS${string}UsageDescription`]: string | undefined;
|
|
168
|
+
/** App Transport Security settings */
|
|
169
|
+
NSAppTransportSecurity?: {
|
|
170
|
+
NSAllowsArbitraryLoads?: boolean;
|
|
171
|
+
NSExceptionDomains?: Record<string, unknown>;
|
|
172
|
+
};
|
|
173
|
+
/** All other properties */
|
|
174
|
+
[key: string]: unknown;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Privacy manifest (PrivacyInfo.xcprivacy) contents
|
|
179
|
+
*/
|
|
180
|
+
export interface PrivacyManifest {
|
|
181
|
+
/** Privacy tracking enabled */
|
|
182
|
+
NSPrivacyTracking?: boolean;
|
|
183
|
+
/** Tracking domains */
|
|
184
|
+
NSPrivacyTrackingDomains?: string[];
|
|
185
|
+
/** Collected data types */
|
|
186
|
+
NSPrivacyCollectedDataTypes?: PrivacyCollectedDataType[];
|
|
187
|
+
/** Accessed API types */
|
|
188
|
+
NSPrivacyAccessedAPITypes?: PrivacyAccessedAPIType[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface PrivacyCollectedDataType {
|
|
192
|
+
NSPrivacyCollectedDataType: string;
|
|
193
|
+
NSPrivacyCollectedDataTypeLinked: boolean;
|
|
194
|
+
NSPrivacyCollectedDataTypeTracking: boolean;
|
|
195
|
+
NSPrivacyCollectedDataTypePurposes: string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface PrivacyAccessedAPIType {
|
|
199
|
+
NSPrivacyAccessedAPIType: string;
|
|
200
|
+
NSPrivacyAccessedAPITypeReasons: string[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Input schema for the analyze tool
|
|
205
|
+
*/
|
|
206
|
+
export const AnalyzeInputSchema = z.object({
|
|
207
|
+
projectPath: z.string().describe('Path to the .xcodeproj or .xcworkspace directory'),
|
|
208
|
+
analyzers: z
|
|
209
|
+
.array(z.enum(['all', 'info-plist', 'privacy', 'entitlements', 'code', 'deprecated-api', 'private-api', 'security', 'ui-ux', 'asc-metadata', 'asc-screenshots', 'asc-version', 'asc-iap']))
|
|
210
|
+
.optional()
|
|
211
|
+
.describe('Specific analyzers to run (default: all)'),
|
|
212
|
+
targetName: z.string().optional().describe('Specific target to analyze'),
|
|
213
|
+
includeASC: z.boolean().optional().describe('Include App Store Connect validation (requires ASC credentials)'),
|
|
214
|
+
bundleId: z.string().optional().describe('Bundle ID for ASC validation (auto-detected if not provided)'),
|
|
215
|
+
format: z
|
|
216
|
+
.enum(['markdown', 'html', 'json'])
|
|
217
|
+
.optional()
|
|
218
|
+
.describe('Report output format (default: markdown)'),
|
|
219
|
+
saveToHistory: z.boolean().optional().describe('Save scan results to history for future comparison'),
|
|
220
|
+
customRulesPath: z.string().optional().describe('Path to custom rules config file (.ios-review-rules.json)'),
|
|
221
|
+
changedSince: z.string().optional().describe('Git ref to compare against for incremental scanning'),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
export type AnalyzeInput = z.infer<typeof AnalyzeInputSchema>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Analyzer interface that all analyzers must implement
|
|
228
|
+
*/
|
|
229
|
+
export interface Analyzer {
|
|
230
|
+
/** Unique name of the analyzer */
|
|
231
|
+
name: string;
|
|
232
|
+
/** Description of what the analyzer checks */
|
|
233
|
+
description: string;
|
|
234
|
+
/** Run the analysis on a project */
|
|
235
|
+
analyze(project: XcodeProject, options?: AnalyzerOptions): Promise<AnalysisResult>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface AnalyzerOptions {
|
|
239
|
+
/** Specific target to analyze */
|
|
240
|
+
targetName?: string | undefined;
|
|
241
|
+
/** Base path for resolving relative paths */
|
|
242
|
+
basePath: string;
|
|
243
|
+
/** Bundle ID for ASC validation (auto-detected from project if not provided) */
|
|
244
|
+
bundleId?: string | undefined;
|
|
245
|
+
/** List of changed files for incremental scanning */
|
|
246
|
+
changedFiles?: string[] | undefined;
|
|
247
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ProgressEvent } from '../src/progress/types.js';
|
|
2
|
+
|
|
3
|
+
// Mock the Xcode project parser
|
|
4
|
+
jest.mock('../src/parsers/xcodeproj.js', () => ({
|
|
5
|
+
parseXcodeProject: jest.fn().mockResolvedValue({
|
|
6
|
+
path: '/test/TestApp.xcodeproj',
|
|
7
|
+
name: 'TestApp',
|
|
8
|
+
targets: [
|
|
9
|
+
{
|
|
10
|
+
name: 'TestApp',
|
|
11
|
+
type: 'application',
|
|
12
|
+
bundleIdentifier: 'com.test.app',
|
|
13
|
+
deploymentTarget: '15.0',
|
|
14
|
+
sourceFiles: [],
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
configurations: ['Debug', 'Release'],
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Mock the git module
|
|
22
|
+
jest.mock('../src/git/diff.js', () => ({
|
|
23
|
+
getChangedFiles: jest.fn().mockReturnValue([]),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// Mock the rules module
|
|
27
|
+
jest.mock('../src/rules/index.js', () => ({
|
|
28
|
+
RuleLoader: jest.fn().mockImplementation(() => ({
|
|
29
|
+
loadFromProject: jest.fn().mockResolvedValue(null),
|
|
30
|
+
})),
|
|
31
|
+
CustomRuleEngine: jest.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Mock the guidelines module
|
|
35
|
+
jest.mock('../src/guidelines/index.js', () => ({
|
|
36
|
+
GuidelineMatcher: jest.fn().mockImplementation(() => ({
|
|
37
|
+
enrichReport: jest.fn().mockImplementation((report) => ({
|
|
38
|
+
...report,
|
|
39
|
+
score: 85,
|
|
40
|
+
enrichedIssues: [],
|
|
41
|
+
guidelinesCited: [],
|
|
42
|
+
})),
|
|
43
|
+
})),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Use require to avoid dynamic import issues with jest
|
|
47
|
+
const { runAnalysis } = require('../src/analyzer.js') as typeof import('../src/analyzer.js');
|
|
48
|
+
|
|
49
|
+
describe('runAnalysis', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
jest.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should run all core analyzers by default', async () => {
|
|
55
|
+
const report = await runAnalysis({
|
|
56
|
+
projectPath: '/test/TestApp.xcodeproj',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Should have results from all 8 core analyzers
|
|
60
|
+
expect(report.results.length).toBe(8);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should run specific analyzers when specified', async () => {
|
|
64
|
+
const report = await runAnalysis({
|
|
65
|
+
projectPath: '/test/TestApp.xcodeproj',
|
|
66
|
+
analyzers: ['code', 'security'],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(report.results.length).toBe(2);
|
|
70
|
+
const analyzerNames = report.results.map((r) => r.analyzer);
|
|
71
|
+
expect(analyzerNames).toContain('Code Scanner');
|
|
72
|
+
expect(analyzerNames).toContain('Security Analyzer');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should calculate summary correctly', async () => {
|
|
76
|
+
const report = await runAnalysis({
|
|
77
|
+
projectPath: '/test/TestApp.xcodeproj',
|
|
78
|
+
analyzers: ['code'],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(report.summary).toBeDefined();
|
|
82
|
+
expect(typeof report.summary.totalIssues).toBe('number');
|
|
83
|
+
expect(typeof report.summary.errors).toBe('number');
|
|
84
|
+
expect(typeof report.summary.warnings).toBe('number');
|
|
85
|
+
expect(typeof report.summary.info).toBe('number');
|
|
86
|
+
expect(typeof report.summary.passed).toBe('boolean');
|
|
87
|
+
expect(typeof report.summary.duration).toBe('number');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should report progress when callback provided', async () => {
|
|
91
|
+
const events: ProgressEvent[] = [];
|
|
92
|
+
await runAnalysis(
|
|
93
|
+
{ projectPath: '/test/TestApp.xcodeproj', analyzers: ['code'] },
|
|
94
|
+
{ onProgress: (e: ProgressEvent) => events.push(e) }
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const types = events.map((e) => e.type);
|
|
98
|
+
expect(types).toContain('scan:start');
|
|
99
|
+
expect(types).toContain('analyzer:start');
|
|
100
|
+
expect(types).toContain('analyzer:complete');
|
|
101
|
+
expect(types).toContain('scan:complete');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should handle analyzer failures gracefully', async () => {
|
|
105
|
+
const report = await runAnalysis({
|
|
106
|
+
projectPath: '/test/TestApp.xcodeproj',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(report).toBeDefined();
|
|
110
|
+
expect(report.summary).toBeDefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should enrich report with score', async () => {
|
|
114
|
+
const report = await runAnalysis({
|
|
115
|
+
projectPath: '/test/TestApp.xcodeproj',
|
|
116
|
+
analyzers: ['code'],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(report.score).toBeDefined();
|
|
120
|
+
expect(typeof report.score).toBe('number');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should set passed to true when no errors', async () => {
|
|
124
|
+
const report = await runAnalysis({
|
|
125
|
+
projectPath: '/test/TestApp.xcodeproj',
|
|
126
|
+
analyzers: ['code'],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// With mocked empty project, code scanner should find no issues
|
|
130
|
+
expect(report.summary.passed).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should include timestamp', async () => {
|
|
134
|
+
const report = await runAnalysis({
|
|
135
|
+
projectPath: '/test/TestApp.xcodeproj',
|
|
136
|
+
analyzers: ['code'],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(report.timestamp).toBeDefined();
|
|
140
|
+
expect(typeof report.timestamp).toBe('string');
|
|
141
|
+
});
|
|
142
|
+
});
|