@mcamdali/sdkbump 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.de.md +251 -0
- package/README.es.md +252 -0
- package/README.fr.md +252 -0
- package/README.ja.md +250 -0
- package/README.ko.md +250 -0
- package/README.md +322 -2
- package/README.pt-BR.md +252 -0
- package/README.ru.md +252 -0
- package/README.tr.md +338 -0
- package/README.zh-CN.md +250 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +11 -0
- package/dist/cli/run.d.ts +29 -0
- package/dist/cli/run.js +111 -0
- package/dist/collect/app.d.ts +48 -0
- package/dist/collect/app.js +117 -0
- package/dist/collect/blank.d.ts +6 -0
- package/dist/collect/blank.js +75 -0
- package/dist/collect/pbxproj.d.ts +45 -0
- package/dist/collect/pbxproj.js +260 -0
- package/dist/collect/plist-binary.d.ts +2 -0
- package/dist/collect/plist-binary.js +123 -0
- package/dist/collect/plist-value.d.ts +15 -0
- package/dist/collect/plist-value.js +14 -0
- package/dist/collect/plist-xml.d.ts +2 -0
- package/dist/collect/plist-xml.js +195 -0
- package/dist/collect/plist.d.ts +6 -0
- package/dist/collect/plist.js +19 -0
- package/dist/collect/regions.d.ts +9 -0
- package/dist/collect/regions.js +108 -0
- package/dist/collect/sources.d.ts +45 -0
- package/dist/collect/sources.js +99 -0
- package/dist/core/files.d.ts +22 -0
- package/dist/core/files.js +47 -0
- package/dist/core/lines.d.ts +2 -0
- package/dist/core/lines.js +21 -0
- package/dist/core/versions.d.ts +18 -0
- package/dist/core/versions.js +63 -0
- package/dist/detect/project.d.ts +46 -0
- package/dist/detect/project.js +220 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +27 -0
- package/dist/model.d.ts +93 -0
- package/dist/model.js +2 -0
- package/dist/report/index.d.ts +12 -0
- package/dist/report/index.js +22 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +113 -0
- package/dist/report/text.d.ts +27 -0
- package/dist/report/text.js +63 -0
- package/dist/report/tty.d.ts +7 -0
- package/dist/report/tty.js +112 -0
- package/dist/rules/check.d.ts +11 -0
- package/dist/rules/check.js +253 -0
- package/dist/rules/data.d.ts +217 -0
- package/dist/rules/data.js +141 -0
- package/dist/rules/fixes.d.ts +28 -0
- package/dist/rules/fixes.js +161 -0
- package/dist/rules/patterns.d.ts +29 -0
- package/dist/rules/patterns.js +109 -0
- package/dist/rules/query.d.ts +16 -0
- package/dist/rules/query.js +50 -0
- package/dist/scan.d.ts +10 -0
- package/dist/scan.js +115 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +7 -0
- package/package.json +69 -4
- package/rules/ios27.json +613 -0
- package/rules/schema.json +449 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Recognises what kind of project a folder holds (native iOS, Flutter, React Native or Expo),
|
|
2
|
+
// where its iOS app project is, and the versions that decide which fix applies.
|
|
3
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { readProjectText } from '../core/files.js';
|
|
6
|
+
import { lowestAllowed } from '../core/versions.js';
|
|
7
|
+
export class ProjectError extends Error {
|
|
8
|
+
name = 'ProjectError';
|
|
9
|
+
}
|
|
10
|
+
/** npm packages whose version or presence changes the advice. */
|
|
11
|
+
export const WATCHED_PACKAGES = [
|
|
12
|
+
'react-native',
|
|
13
|
+
'expo',
|
|
14
|
+
'expo-build-properties',
|
|
15
|
+
'react-native-uiscene-bridge',
|
|
16
|
+
'react-native-xcode27',
|
|
17
|
+
];
|
|
18
|
+
const SKIPPED_FOLDERS = new Set([
|
|
19
|
+
'Pods',
|
|
20
|
+
'node_modules',
|
|
21
|
+
'build',
|
|
22
|
+
'DerivedData',
|
|
23
|
+
'.build',
|
|
24
|
+
'Carthage',
|
|
25
|
+
'.git',
|
|
26
|
+
'.symlinks',
|
|
27
|
+
]);
|
|
28
|
+
async function exists(path) {
|
|
29
|
+
try {
|
|
30
|
+
await stat(path);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Parses `path` read with readProjectText; undefined when it is not read or is not JSON. */
|
|
38
|
+
async function readJson(path, root) {
|
|
39
|
+
const text = await readProjectText(path, root);
|
|
40
|
+
if (text === null)
|
|
41
|
+
return undefined;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(text);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
50
|
+
? value
|
|
51
|
+
: {};
|
|
52
|
+
async function xcodeProjectsIn(folder) {
|
|
53
|
+
try {
|
|
54
|
+
const entries = await readdir(folder, { withFileTypes: true });
|
|
55
|
+
return entries
|
|
56
|
+
.filter((entry) => entry.isDirectory() && entry.name.endsWith('.xcodeproj'))
|
|
57
|
+
.map((entry) => join(folder, entry.name))
|
|
58
|
+
.sort();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Native apps keep the .xcodeproj at the root or one folder down (App/App.xcodeproj). */
|
|
65
|
+
async function nativeXcodeProjects(root) {
|
|
66
|
+
const top = await xcodeProjectsIn(root);
|
|
67
|
+
if (top.length > 0)
|
|
68
|
+
return top;
|
|
69
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
70
|
+
const nested = await Promise.all(entries
|
|
71
|
+
.filter((entry) => entry.isDirectory() && !SKIPPED_FOLDERS.has(entry.name))
|
|
72
|
+
.map((entry) => xcodeProjectsIn(join(root, entry.name))));
|
|
73
|
+
return nested.flat().sort();
|
|
74
|
+
}
|
|
75
|
+
async function packageVersions(root, manifest) {
|
|
76
|
+
const declared = {
|
|
77
|
+
...record(manifest.devDependencies),
|
|
78
|
+
...record(manifest.dependencies),
|
|
79
|
+
};
|
|
80
|
+
const lock = record(record(await readJson(join(root, 'package-lock.json'), root)).packages);
|
|
81
|
+
const result = new Map();
|
|
82
|
+
for (const name of WATCHED_PACKAGES) {
|
|
83
|
+
const range = declared[name];
|
|
84
|
+
if (typeof range !== 'string')
|
|
85
|
+
continue;
|
|
86
|
+
// pnpm and workspaces link installed packages from outside the app folder.
|
|
87
|
+
const installed = record(await readJson(join(root, 'node_modules', name, 'package.json'), null));
|
|
88
|
+
const locked = record(lock[`node_modules/${name}`]);
|
|
89
|
+
if (typeof installed.version === 'string') {
|
|
90
|
+
result.set(name, {
|
|
91
|
+
name,
|
|
92
|
+
version: installed.version,
|
|
93
|
+
source: 'installed',
|
|
94
|
+
declared: range,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else if (typeof locked.version === 'string') {
|
|
98
|
+
result.set(name, {
|
|
99
|
+
name,
|
|
100
|
+
version: locked.version,
|
|
101
|
+
source: 'lockfile',
|
|
102
|
+
declared: range,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
result.set(name, {
|
|
107
|
+
name,
|
|
108
|
+
version: lowestAllowed(range),
|
|
109
|
+
source: 'declared',
|
|
110
|
+
declared: range,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
function sceneSupportIn(plugins) {
|
|
117
|
+
if (!Array.isArray(plugins))
|
|
118
|
+
return false;
|
|
119
|
+
return plugins.some((plugin) => {
|
|
120
|
+
if (!Array.isArray(plugin) || plugin[0] !== 'expo-build-properties')
|
|
121
|
+
return false;
|
|
122
|
+
return record(record(plugin[1]).ios).enableSceneSupport === true;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function expoConfig(root) {
|
|
126
|
+
for (const file of ['app.config.ts', 'app.config.js', 'app.config.mjs', 'app.config.cjs']) {
|
|
127
|
+
if (await exists(join(root, file)))
|
|
128
|
+
return { file, enableSceneSupport: null };
|
|
129
|
+
}
|
|
130
|
+
const json = await readJson(join(root, 'app.json'), root);
|
|
131
|
+
if (json === undefined)
|
|
132
|
+
return null;
|
|
133
|
+
const config = record(json);
|
|
134
|
+
const expo = 'expo' in config ? record(config.expo) : config;
|
|
135
|
+
return { file: 'app.json', enableSceneSupport: sceneSupportIn(expo.plugins) };
|
|
136
|
+
}
|
|
137
|
+
async function flutterInfo(root, pubspec) {
|
|
138
|
+
const fvmrc = record(await readJson(join(root, '.fvmrc'), root));
|
|
139
|
+
const fvmConfig = record(await readJson(join(root, '.fvm', 'fvm_config.json'), root));
|
|
140
|
+
const pinned = fvmrc.flutter ?? fvmConfig.flutterSdkVersion;
|
|
141
|
+
return {
|
|
142
|
+
version: typeof pinned === 'string' ? pinned : null,
|
|
143
|
+
migrationDisabled: /^\s+enable-uiscene-migration:\s*false\b/m.test(pubspec),
|
|
144
|
+
plugin: /^flutter:\s*\n(?:\s.*\n)*?\s+plugin:/m.test(pubspec),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Detects the project at `path`: a project folder, or an .xcodeproj folder for a native app.
|
|
149
|
+
* Throws ProjectError when there is nothing iOS to check.
|
|
150
|
+
*/
|
|
151
|
+
export async function detectProject(path) {
|
|
152
|
+
const target = resolve(path);
|
|
153
|
+
const info = await stat(target).catch(() => null);
|
|
154
|
+
if (!info)
|
|
155
|
+
throw new ProjectError(`${path} does not exist.`);
|
|
156
|
+
if (info.isDirectory() && target.endsWith('.xcodeproj')) {
|
|
157
|
+
return nativeProject(dirname(target), [target]);
|
|
158
|
+
}
|
|
159
|
+
if (!info.isDirectory())
|
|
160
|
+
throw new ProjectError(`${path} is not a folder.`);
|
|
161
|
+
const pubspec = await readProjectText(join(target, 'pubspec.yaml'), target);
|
|
162
|
+
if (pubspec !== null && /^\s+sdk:\s*flutter\b|^flutter:/m.test(pubspec)) {
|
|
163
|
+
const flutter = await flutterInfo(target, pubspec);
|
|
164
|
+
const iosDir = join(target, flutter.plugin ? join('example', 'ios') : 'ios');
|
|
165
|
+
const xcodeProjects = await xcodeProjectsIn(iosDir);
|
|
166
|
+
if (xcodeProjects.length === 0) {
|
|
167
|
+
throw new ProjectError(`${path} is a Flutter project without an iOS app (no ${iosDir}).`);
|
|
168
|
+
}
|
|
169
|
+
const name = /^name:\s*([\w-]+)/m.exec(pubspec)?.[1] ?? basename(target);
|
|
170
|
+
return {
|
|
171
|
+
root: target,
|
|
172
|
+
name,
|
|
173
|
+
ecosystem: 'flutter',
|
|
174
|
+
iosDir,
|
|
175
|
+
xcodeProjects,
|
|
176
|
+
packages: new Map(),
|
|
177
|
+
flutter,
|
|
178
|
+
expoConfig: null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const manifest = record(await readJson(join(target, 'package.json'), target));
|
|
182
|
+
const packages = await packageVersions(target, manifest);
|
|
183
|
+
if (packages.has('expo') || packages.has('react-native')) {
|
|
184
|
+
const ecosystem = packages.has('expo') ? 'expo' : 'react-native';
|
|
185
|
+
const iosDir = join(target, 'ios');
|
|
186
|
+
const xcodeProjects = await xcodeProjectsIn(iosDir);
|
|
187
|
+
if (xcodeProjects.length === 0 && ecosystem === 'react-native') {
|
|
188
|
+
throw new ProjectError(`${path} is a React Native project without ios/.`);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
root: target,
|
|
192
|
+
name: typeof manifest.name === 'string' ? manifest.name : basename(target),
|
|
193
|
+
ecosystem,
|
|
194
|
+
iosDir: xcodeProjects.length > 0 ? iosDir : null,
|
|
195
|
+
xcodeProjects,
|
|
196
|
+
packages,
|
|
197
|
+
flutter: null,
|
|
198
|
+
expoConfig: ecosystem === 'expo' ? await expoConfig(target) : null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const xcodeProjects = await nativeXcodeProjects(target);
|
|
202
|
+
if (xcodeProjects.length === 0) {
|
|
203
|
+
throw new ProjectError(`No iOS project found in ${path}: expected an .xcodeproj, a Flutter pubspec.yaml or a React Native or Expo package.json.`);
|
|
204
|
+
}
|
|
205
|
+
return nativeProject(target, xcodeProjects);
|
|
206
|
+
}
|
|
207
|
+
function nativeProject(root, xcodeProjects) {
|
|
208
|
+
const first = xcodeProjects[0] ?? root;
|
|
209
|
+
return {
|
|
210
|
+
root,
|
|
211
|
+
name: basename(first).replace(/\.xcodeproj$/, ''),
|
|
212
|
+
ecosystem: 'native',
|
|
213
|
+
iosDir: dirname(first),
|
|
214
|
+
xcodeProjects,
|
|
215
|
+
packages: new Map(),
|
|
216
|
+
flutter: null,
|
|
217
|
+
expoConfig: null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=project.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic API. The CLI is a thin layer over these functions; the JSON report format is
|
|
3
|
+
* `schemaVersion` 0 and may change before 1.0.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { render, scan } from '@mcamdali/sdkbump';
|
|
7
|
+
*
|
|
8
|
+
* const report = await scan('path/to/app');
|
|
9
|
+
* console.log(report.verdict.text);
|
|
10
|
+
* console.log(render(report, 'markdown'));
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
export { type AppModel, type AppTarget, collectApp, type InfoPlistFile, sceneManifest, } from './collect/app.js';
|
|
16
|
+
export { type BuildSettings, generatesInfoPlist, infoPlistFiles, iosAppTargets, type OpenStepDict, type OpenStepValue, parseOpenStep, readXcodeProject, type XcodeProject, XcodeProjectError, type XcodeTarget, } from './collect/pbxproj.js';
|
|
17
|
+
export { isDict, type ParsedPlist, type PlistDict, PlistError, type PlistValue, parsePlist, } from './collect/plist.js';
|
|
18
|
+
export { blankCommentsAndStrings, indexSources, type Language, type SourceFile, type SourceIndex, type TypeRegion, } from './collect/sources.js';
|
|
19
|
+
export { compareVersions, lowestAllowed } from './core/versions.js';
|
|
20
|
+
export { detectProject, type Ecosystem, type ExpoConfig, type FlutterInfo, type PackageVersion, type Project, ProjectError, } from './detect/project.js';
|
|
21
|
+
export { type FixAdvice, type Finding, type InfoPlistSource, type Lifecycle, type Mitigation, type Report, SCHEMA_VERSION, type TargetSummary, type Verdict, type VerdictCode, type VersionInfo, } from './model.js';
|
|
22
|
+
export { FORMATS, type Format, render, type TtyOptions } from './report/index.js';
|
|
23
|
+
export { checkApp, type RawFinding } from './rules/check.js';
|
|
24
|
+
export { loadRuleData, parseRuleData, type RuleData, RuleDataError, RuleDataSchema, RULE_IDS, type RuleId, SEVERITIES, type Severity, } from './rules/data.js';
|
|
25
|
+
export { type ScanOptions, scan, verdictOf } from './scan.js';
|
|
26
|
+
export { VERSION } from './version.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic API. The CLI is a thin layer over these functions; the JSON report format is
|
|
3
|
+
* `schemaVersion` 0 and may change before 1.0.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { render, scan } from '@mcamdali/sdkbump';
|
|
7
|
+
*
|
|
8
|
+
* const report = await scan('path/to/app');
|
|
9
|
+
* console.log(report.verdict.text);
|
|
10
|
+
* console.log(render(report, 'markdown'));
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
export { collectApp, sceneManifest, } from './collect/app.js';
|
|
16
|
+
export { generatesInfoPlist, infoPlistFiles, iosAppTargets, parseOpenStep, readXcodeProject, XcodeProjectError, } from './collect/pbxproj.js';
|
|
17
|
+
export { isDict, PlistError, parsePlist, } from './collect/plist.js';
|
|
18
|
+
export { blankCommentsAndStrings, indexSources, } from './collect/sources.js';
|
|
19
|
+
export { compareVersions, lowestAllowed } from './core/versions.js';
|
|
20
|
+
export { detectProject, ProjectError, } from './detect/project.js';
|
|
21
|
+
export { SCHEMA_VERSION, } from './model.js';
|
|
22
|
+
export { FORMATS, render } from './report/index.js';
|
|
23
|
+
export { checkApp } from './rules/check.js';
|
|
24
|
+
export { loadRuleData, parseRuleData, RuleDataError, RuleDataSchema, RULE_IDS, SEVERITIES, } from './rules/data.js';
|
|
25
|
+
export { scan, verdictOf } from './scan.js';
|
|
26
|
+
export { VERSION } from './version.js';
|
|
27
|
+
//# sourceMappingURL=index.js.map
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Ecosystem, RuleId, RuleSource, Severity } from './rules/data.js';
|
|
2
|
+
export declare const SCHEMA_VERSION = 0;
|
|
3
|
+
/** A fix sdkbump suggests, from the `fixes` (or `workarounds`) of the rule data. */
|
|
4
|
+
export interface FixAdvice {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
steps: string[];
|
|
8
|
+
/** Link to the sdkbump guide for this kind of project. */
|
|
9
|
+
guide: string;
|
|
10
|
+
sources: RuleSource[];
|
|
11
|
+
}
|
|
12
|
+
/** A package in the project that works around the finding at run time. */
|
|
13
|
+
export interface Mitigation {
|
|
14
|
+
package: string;
|
|
15
|
+
version: string | null;
|
|
16
|
+
summary: string;
|
|
17
|
+
limits: string[];
|
|
18
|
+
sources: RuleSource[];
|
|
19
|
+
}
|
|
20
|
+
export interface Finding {
|
|
21
|
+
rule: RuleId;
|
|
22
|
+
severity: Severity;
|
|
23
|
+
title: string;
|
|
24
|
+
message: string;
|
|
25
|
+
/** Path relative to the checked folder, with forward slashes. */
|
|
26
|
+
file: string;
|
|
27
|
+
line: number | null;
|
|
28
|
+
/** The app target the finding belongs to, when it is about one target. */
|
|
29
|
+
target: string | null;
|
|
30
|
+
/** Who owns the code: the app itself (dependencies are planned for 0.2). */
|
|
31
|
+
owner: 'app';
|
|
32
|
+
fix: FixAdvice | null;
|
|
33
|
+
alternatives: FixAdvice[];
|
|
34
|
+
/** Set when an installed workaround package handles this at run time. */
|
|
35
|
+
mitigation: Mitigation | null;
|
|
36
|
+
sources: RuleSource[];
|
|
37
|
+
}
|
|
38
|
+
/** `unknown`: sdkbump could not read the target's Info.plist, so it cannot tell. */
|
|
39
|
+
export type Lifecycle = 'app-delegate' | 'scenes' | 'swiftui' | 'unknown';
|
|
40
|
+
/**
|
|
41
|
+
* Where a target's Info.plist came from: `file` (read from INFOPLIST_FILE), `generated` (Xcode
|
|
42
|
+
* writes it from build settings) or `not-read` (named but not readable, or not named in the
|
|
43
|
+
* project file at all, as when an .xcconfig file sets it).
|
|
44
|
+
*/
|
|
45
|
+
export type InfoPlistSource = 'file' | 'generated' | 'not-read';
|
|
46
|
+
export interface TargetSummary {
|
|
47
|
+
name: string;
|
|
48
|
+
lifecycle: Lifecycle;
|
|
49
|
+
/** Info.plist paths relative to the checked folder; empty unless infoPlistSource is `file`. */
|
|
50
|
+
infoPlists: string[];
|
|
51
|
+
infoPlistSource: InfoPlistSource;
|
|
52
|
+
}
|
|
53
|
+
export type VerdictCode = 'fails-to-launch' | 'launches-through-workaround' | 'unknown' | 'black-screen' | 'review' | 'no-findings';
|
|
54
|
+
export interface Verdict {
|
|
55
|
+
code: VerdictCode;
|
|
56
|
+
text: string;
|
|
57
|
+
}
|
|
58
|
+
export interface VersionInfo {
|
|
59
|
+
name: string;
|
|
60
|
+
version: string | null;
|
|
61
|
+
/** installed, lockfile, declared (lowest version the range allows) or pinned (Flutter). */
|
|
62
|
+
source: string;
|
|
63
|
+
}
|
|
64
|
+
export interface Report {
|
|
65
|
+
schemaVersion: typeof SCHEMA_VERSION;
|
|
66
|
+
tool: {
|
|
67
|
+
name: 'sdkbump';
|
|
68
|
+
version: string;
|
|
69
|
+
};
|
|
70
|
+
rules: {
|
|
71
|
+
ruleSet: string;
|
|
72
|
+
dataVersion: string;
|
|
73
|
+
sdk: string;
|
|
74
|
+
};
|
|
75
|
+
project: {
|
|
76
|
+
/** The folder as given on the command line. */
|
|
77
|
+
path: string;
|
|
78
|
+
name: string;
|
|
79
|
+
ecosystem: Ecosystem;
|
|
80
|
+
versions: VersionInfo[];
|
|
81
|
+
targets: TargetSummary[];
|
|
82
|
+
};
|
|
83
|
+
verdict: Verdict;
|
|
84
|
+
findings: Finding[];
|
|
85
|
+
/** Things sdkbump could not check or that the reader should know. */
|
|
86
|
+
notes: string[];
|
|
87
|
+
summary: {
|
|
88
|
+
errors: number;
|
|
89
|
+
warnings: number;
|
|
90
|
+
info: number;
|
|
91
|
+
mitigated: number;
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Report } from '../model.js';
|
|
2
|
+
import { type TtyOptions } from './tty.js';
|
|
3
|
+
export declare const FORMATS: readonly ['tty', 'markdown', 'json'];
|
|
4
|
+
export type Format = (typeof FORMATS)[number];
|
|
5
|
+
export type { TtyOptions } from './tty.js';
|
|
6
|
+
/**
|
|
7
|
+
* Renders a report as terminal text, Markdown or JSON (the report object as-is). This is the one
|
|
8
|
+
* way out for a report: every string in it has its control characters replaced here, whatever the
|
|
9
|
+
* format, before it reaches a terminal, a file or a pull request comment. JSON.stringify escapes
|
|
10
|
+
* only U+0000 to U+001F, so DEL and C1 controls would otherwise reach the JSON output raw.
|
|
11
|
+
*/
|
|
12
|
+
export declare function render(input: Report, format: Format, options?: TtyOptions): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { renderMarkdown } from './markdown.js';
|
|
2
|
+
import { withoutControlCharacters } from './text.js';
|
|
3
|
+
import { renderTty } from './tty.js';
|
|
4
|
+
export const FORMATS = ['tty', 'markdown', 'json'];
|
|
5
|
+
/**
|
|
6
|
+
* Renders a report as terminal text, Markdown or JSON (the report object as-is). This is the one
|
|
7
|
+
* way out for a report: every string in it has its control characters replaced here, whatever the
|
|
8
|
+
* format, before it reaches a terminal, a file or a pull request comment. JSON.stringify escapes
|
|
9
|
+
* only U+0000 to U+001F, so DEL and C1 controls would otherwise reach the JSON output raw.
|
|
10
|
+
*/
|
|
11
|
+
export function render(input, format, options = { color: false }) {
|
|
12
|
+
const report = withoutControlCharacters(input);
|
|
13
|
+
switch (format) {
|
|
14
|
+
case 'tty':
|
|
15
|
+
return renderTty(report, options);
|
|
16
|
+
case 'markdown':
|
|
17
|
+
return renderMarkdown(report);
|
|
18
|
+
case 'json':
|
|
19
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ECOSYSTEM_LABELS, GUIDES, INFO_PLIST_LABELS, LIFECYCLE_LABELS, plural, REPOSITORY_URL, } from './text.js';
|
|
2
|
+
// Every character that can start Markdown or HTML markup inline: links and images ([ ] ( ) !),
|
|
3
|
+
// emphasis (* _ ~), code (`), headings (#), HTML and autolinks (< >), entities (&), table cells
|
|
4
|
+
// (|) and the backslash itself. A backslash in front makes each one plain text.
|
|
5
|
+
const MARKUP = /[\\`*_[\]()#!<>|~&]/g;
|
|
6
|
+
// GitHub turns bare URLs, www. addresses, e-mail addresses, @names and issue references (#1,
|
|
7
|
+
// owner/repo#1, GH-1) into links or mentions without any markup, also right after a letter or an
|
|
8
|
+
// underscore (x_https://...). A word joiner (U+2060, invisible) after the scheme, the www, the @,
|
|
9
|
+
// the # or the GH stops that and leaves the text as it reads.
|
|
10
|
+
const BARE_LINKS = /(?:https?|ftp)(?=:\/\/)|www(?=\.)|gh(?=-\d)|#(?=\d)|@/gi;
|
|
11
|
+
// A list marker ('- ', '+ ', '12. ') or the start of a rule ('---') at the start of a line.
|
|
12
|
+
const LIST_OR_RULE = /^(?:[-+](?=\s|$)|-(?=-))/;
|
|
13
|
+
const ORDERED_LIST = /^(\d+)\.(?=\s|$)/;
|
|
14
|
+
const WORD_JOINER = '\u2060';
|
|
15
|
+
const BACKTICK_RUNS = /`+/g;
|
|
16
|
+
/**
|
|
17
|
+
* Text from the checked project (names, versions, paths, plist values) as plain text in a
|
|
18
|
+
* paragraph, heading or table cell: no line breaks, markup or bare links. Notes and fix steps
|
|
19
|
+
* start a line, so a leading list marker or `---` is escaped too (it would open a list or a rule),
|
|
20
|
+
* and leading spaces are dropped (four of them would open a code block).
|
|
21
|
+
*/
|
|
22
|
+
const md = (text) => text
|
|
23
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
24
|
+
.trimStart()
|
|
25
|
+
.replace(MARKUP, '\\$&')
|
|
26
|
+
.replace(LIST_OR_RULE, '\\$&')
|
|
27
|
+
.replace(ORDERED_LIST, '$1\\.')
|
|
28
|
+
.replace(BARE_LINKS, `$&${WORD_JOINER}`);
|
|
29
|
+
/**
|
|
30
|
+
* Inline code that the value cannot close early: the fence is one backtick longer than the
|
|
31
|
+
* longest run of backticks in it, and a space pads a value that starts or ends with a backtick or
|
|
32
|
+
* a space (Markdown strips one space from each side).
|
|
33
|
+
*/
|
|
34
|
+
const code = (text) => {
|
|
35
|
+
const value = text.replace(/\n/g, ' ');
|
|
36
|
+
if (!value)
|
|
37
|
+
return '';
|
|
38
|
+
const longestRun = Math.max(0, ...(value.match(BACKTICK_RUNS) ?? []).map((run) => run.length));
|
|
39
|
+
const fence = '`'.repeat(longestRun + 1);
|
|
40
|
+
const padding = /^[ `]|[ `]$/.test(value) && value.trim() !== '' ? ' ' : '';
|
|
41
|
+
return `${fence}${padding}${value}${padding}${fence}`;
|
|
42
|
+
};
|
|
43
|
+
/** GitHub splits a table row on every `|` that has no backslash, inside code spans too. */
|
|
44
|
+
const cell = (text) => text.replace(/\|/g, '\\|');
|
|
45
|
+
const where = (item) => code(`${item.file}${item.line ? `:${item.line}` : ''}`);
|
|
46
|
+
function fixLines(fix, heading) {
|
|
47
|
+
return [
|
|
48
|
+
`**${heading}: ${md(fix.title)}** ([guide](${fix.guide}))`,
|
|
49
|
+
'',
|
|
50
|
+
...fix.steps.map((step, index) => `${index + 1}. ${md(step)}`),
|
|
51
|
+
'',
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
export function renderMarkdown(report) {
|
|
55
|
+
const { project, rules, summary } = report;
|
|
56
|
+
const out = [
|
|
57
|
+
`## sdkbump: ${md(project.name)} (${ECOSYSTEM_LABELS[project.ecosystem]})`,
|
|
58
|
+
'',
|
|
59
|
+
`**${md(report.verdict.text)}**`,
|
|
60
|
+
'',
|
|
61
|
+
];
|
|
62
|
+
const facts = [
|
|
63
|
+
...project.targets.map((target) => {
|
|
64
|
+
const plists = target.infoPlistSource === 'file'
|
|
65
|
+
? target.infoPlists.map(code).join(', ')
|
|
66
|
+
: INFO_PLIST_LABELS[target.infoPlistSource];
|
|
67
|
+
return `Target ${code(target.name)}: ${LIFECYCLE_LABELS[target.lifecycle]} (${plists})`;
|
|
68
|
+
}),
|
|
69
|
+
...project.versions.map((version) => `${code(version.name)} ${md(version.version ?? 'unknown')} (${md(version.source)})`),
|
|
70
|
+
];
|
|
71
|
+
if (facts.length)
|
|
72
|
+
out.push(...facts.map((fact) => `- ${fact}`), '');
|
|
73
|
+
if (report.findings.length > 0) {
|
|
74
|
+
out.push('| Rule | Severity | Where | Finding |', '| --- | --- | --- | --- |');
|
|
75
|
+
for (const item of report.findings) {
|
|
76
|
+
const severity = item.mitigation
|
|
77
|
+
? `${item.severity} (handled by ${md(item.mitigation.package)})`
|
|
78
|
+
: item.severity;
|
|
79
|
+
out.push(`| ${item.rule} | ${severity} | ${cell(where(item))} | ${md(item.title)} |`);
|
|
80
|
+
}
|
|
81
|
+
out.push('', '<details><summary>What each finding means and how to fix it</summary>', '');
|
|
82
|
+
const printed = new Set();
|
|
83
|
+
for (const item of report.findings) {
|
|
84
|
+
out.push(`### ${item.rule}: ${md(item.title)}`, '', `${where(item)}: ${md(item.message)}`, '');
|
|
85
|
+
if (item.mitigation) {
|
|
86
|
+
const { mitigation } = item;
|
|
87
|
+
out.push(`Handled at run time by ${code(mitigation.package)}${mitigation.version ? ` ${md(mitigation.version)}` : ''}. ${md(mitigation.summary)} Limits: ${md(mitigation.limits.join(' '))}`, '');
|
|
88
|
+
}
|
|
89
|
+
[item.fix, ...item.alternatives]
|
|
90
|
+
.filter((fix) => fix !== null)
|
|
91
|
+
.forEach((fix, index) => {
|
|
92
|
+
const heading = index === 0 ? 'Fix' : 'Or';
|
|
93
|
+
if (printed.has(fix.id))
|
|
94
|
+
out.push(`**${heading}: ${md(fix.title)}** (steps above)`, '');
|
|
95
|
+
else
|
|
96
|
+
out.push(...fixLines(fix, heading));
|
|
97
|
+
printed.add(fix.id);
|
|
98
|
+
});
|
|
99
|
+
out.push(`Sources: ${item.sources.map((source) => `[${md(source.title)}](${source.url})`).join(', ')}`, '');
|
|
100
|
+
}
|
|
101
|
+
out.push('</details>', '');
|
|
102
|
+
}
|
|
103
|
+
for (const note of report.notes)
|
|
104
|
+
out.push(`> ${md(note)}`, '');
|
|
105
|
+
const counts = [plural(summary.errors, 'error'), plural(summary.warnings, 'warning')];
|
|
106
|
+
if (summary.info)
|
|
107
|
+
counts.push(`${summary.info} info`);
|
|
108
|
+
if (summary.mitigated)
|
|
109
|
+
counts.push(`${summary.mitigated} handled by a workaround`);
|
|
110
|
+
out.push(`${counts.join(', ')}. Guide: [${ECOSYSTEM_LABELS[project.ecosystem]}](${GUIDES[project.ecosystem]}).`, '', `<sub>[sdkbump](${REPOSITORY_URL}) ${report.tool.version}, rules ${rules.ruleSet}@${rules.dataVersion} (${md(rules.sdk)}). Static checks; they do not build or run the app.</sub>`);
|
|
111
|
+
return `${out.join('\n')}\n`;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=markdown.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Ecosystem } from '../rules/data.js';
|
|
2
|
+
export declare const REPOSITORY_URL = "https://github.com/muhittincamdali/sdkbump";
|
|
3
|
+
/** The guide for each kind of project, linked at the end of every report. */
|
|
4
|
+
export declare const GUIDES: Record<Ecosystem, string>;
|
|
5
|
+
export declare const ECOSYSTEM_LABELS: Record<Ecosystem, string>;
|
|
6
|
+
export declare const LIFECYCLE_LABELS: {
|
|
7
|
+
readonly 'app-delegate': 'app delegate life cycle';
|
|
8
|
+
readonly scenes: 'scene life cycle';
|
|
9
|
+
readonly swiftui: 'SwiftUI App (scenes)';
|
|
10
|
+
readonly unknown: 'life cycle not determined';
|
|
11
|
+
};
|
|
12
|
+
/** How the reports describe a target's Info.plist when it is not a file sdkbump read. */
|
|
13
|
+
export declare const INFO_PLIST_LABELS: {
|
|
14
|
+
readonly generated: 'Info.plist written by Xcode';
|
|
15
|
+
readonly 'not-read': 'Info.plist not read';
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Replaces control characters (C0 except tab and newline, DEL, C1) with U+FFFD. File names,
|
|
19
|
+
* versions and plist values come from the checked project; printed raw, an escape sequence in one
|
|
20
|
+
* of them could rewrite the terminal.
|
|
21
|
+
*/
|
|
22
|
+
export declare function clean(text: string): string;
|
|
23
|
+
/** A copy of a report (or any part of one) with every string passed through clean(). */
|
|
24
|
+
export declare function withoutControlCharacters<T>(value: T): T;
|
|
25
|
+
/** Splits text into lines of at most `width` columns (a longer word gets a line of its own). */
|
|
26
|
+
export declare function wrap(text: string, width: number): string[];
|
|
27
|
+
export declare const plural: (count: number, word: string) => string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const REPOSITORY_URL = 'https://github.com/muhittincamdali/sdkbump';
|
|
2
|
+
/** The guide for each kind of project, linked at the end of every report. */
|
|
3
|
+
export const GUIDES = {
|
|
4
|
+
native: `${REPOSITORY_URL}/blob/main/docs/guides/native-ios.md`,
|
|
5
|
+
flutter: `${REPOSITORY_URL}/blob/main/docs/guides/flutter.md`,
|
|
6
|
+
'react-native': `${REPOSITORY_URL}/blob/main/docs/guides/react-native-expo.md`,
|
|
7
|
+
expo: `${REPOSITORY_URL}/blob/main/docs/guides/react-native-expo.md`,
|
|
8
|
+
};
|
|
9
|
+
export const ECOSYSTEM_LABELS = {
|
|
10
|
+
native: 'native iOS',
|
|
11
|
+
flutter: 'Flutter',
|
|
12
|
+
'react-native': 'React Native',
|
|
13
|
+
expo: 'Expo',
|
|
14
|
+
};
|
|
15
|
+
export const LIFECYCLE_LABELS = {
|
|
16
|
+
'app-delegate': 'app delegate life cycle',
|
|
17
|
+
scenes: 'scene life cycle',
|
|
18
|
+
swiftui: 'SwiftUI App (scenes)',
|
|
19
|
+
unknown: 'life cycle not determined',
|
|
20
|
+
};
|
|
21
|
+
/** How the reports describe a target's Info.plist when it is not a file sdkbump read. */
|
|
22
|
+
export const INFO_PLIST_LABELS = {
|
|
23
|
+
generated: 'Info.plist written by Xcode',
|
|
24
|
+
'not-read': 'Info.plist not read',
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Replaces control characters (C0 except tab and newline, DEL, C1) with U+FFFD. File names,
|
|
28
|
+
* versions and plist values come from the checked project; printed raw, an escape sequence in one
|
|
29
|
+
* of them could rewrite the terminal.
|
|
30
|
+
*/
|
|
31
|
+
export function clean(text) {
|
|
32
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point.
|
|
33
|
+
return text.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '�');
|
|
34
|
+
}
|
|
35
|
+
/** A copy of a report (or any part of one) with every string passed through clean(). */
|
|
36
|
+
export function withoutControlCharacters(value) {
|
|
37
|
+
if (typeof value === 'string')
|
|
38
|
+
return clean(value);
|
|
39
|
+
if (Array.isArray(value))
|
|
40
|
+
return value.map(withoutControlCharacters);
|
|
41
|
+
if (value === null || typeof value !== 'object')
|
|
42
|
+
return value;
|
|
43
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withoutControlCharacters(item)]));
|
|
44
|
+
}
|
|
45
|
+
/** Splits text into lines of at most `width` columns (a longer word gets a line of its own). */
|
|
46
|
+
export function wrap(text, width) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
let line = '';
|
|
49
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
50
|
+
if (line && line.length + 1 + word.length > width) {
|
|
51
|
+
lines.push(line);
|
|
52
|
+
line = word;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
line = line ? `${line} ${word}` : word;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (line)
|
|
59
|
+
lines.push(line);
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
export const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
63
|
+
//# sourceMappingURL=text.js.map
|