@rtorcato/repo-tooling 3.32.0 → 3.34.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.
|
@@ -22,7 +22,7 @@ import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
|
22
22
|
import { compareRulesWithReference } from '../utils/reference-rules.js';
|
|
23
23
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
24
24
|
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
25
|
-
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
25
|
+
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkExportsBuildable, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
26
26
|
export { evaluateNodeVersion };
|
|
27
27
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
28
28
|
// Detects the broken-release-on-protected-main footgun: a workflow that runs
|
|
@@ -385,6 +385,8 @@ export async function runDoctor(dir, skillsDir) {
|
|
|
385
385
|
results.push(await checkTypedoc(targetDir, pkg));
|
|
386
386
|
results.push(await checkAreTheTypesWrong(targetDir, pkg));
|
|
387
387
|
results.push(await checkPublint(targetDir, pkg));
|
|
388
|
+
// The setup/doctor-time half of what publint catches at release time (#578).
|
|
389
|
+
results.push(await checkExportsBuildable(targetDir, pkg));
|
|
388
390
|
results.push(await checkTreeshakeSetup(targetDir, pkg));
|
|
389
391
|
results.push(await checkPnpmWorkspace(targetDir, pkg));
|
|
390
392
|
results.push(await checkBuildApprovals(targetDir, pkg));
|
|
@@ -76,6 +76,8 @@ const SWIFT_FIX_TARGETS = {
|
|
|
76
76
|
// `Swift tests` is deliberately absent: when it fails for the manifest half
|
|
77
77
|
// (no `.testTarget(`) there's nothing to run, and rewriting Package.swift
|
|
78
78
|
// isn't safe. The check's own hint covers both halves.
|
|
79
|
+
// `Swift targets` too (#575): the repair is either adding a target or
|
|
80
|
+
// deleting the directory, and only the project knows which.
|
|
79
81
|
};
|
|
80
82
|
/** The same shadowing for the Python module (#290). */
|
|
81
83
|
const PYTHON_FIX_TARGETS = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { checkFile, hookHasUncommented } from '../../base/checks.js';
|
|
4
|
+
import { realGitExec } from '../../base/git-identity.js';
|
|
4
5
|
import { CLAUDE_SETTINGS_FILE, readClaudeSettings, workspaceSymlinkDirs, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
|
|
5
6
|
import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
|
|
6
7
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
@@ -964,6 +965,202 @@ export async function checkPublint(_dir, pkg) {
|
|
|
964
965
|
hint: 'Run `npx @rtorcato/repo-tooling fix publint` to lint your package before publishing',
|
|
965
966
|
};
|
|
966
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* The file extensions each recognised build command drops in the output
|
|
970
|
+
* directory. Inferring this from an arbitrary shell command is inherently
|
|
971
|
+
* partial, so the table is deliberately small and anything missing from it
|
|
972
|
+
* makes the whole check stand down (see `emittedExtensions`) — a doctor check
|
|
973
|
+
* that cries wolf on a valid config is worse than one with a stated blind spot.
|
|
974
|
+
*/
|
|
975
|
+
const BUILD_EMITS = {
|
|
976
|
+
// tsc mirrors its input extension: .ts → .js + .d.ts. It reaches .cjs/.d.cts
|
|
977
|
+
// only from .cts sources, which is why a repo that has any stands the check
|
|
978
|
+
// down below.
|
|
979
|
+
tsc: ['.js', '.d.ts'],
|
|
980
|
+
// tsup's real set is whatever `format`/`dts` say in a config file this does
|
|
981
|
+
// not parse, so it claims the broadest set tsup could emit. A tsup build is
|
|
982
|
+
// therefore passed rather than guessed at, while `rimraf dist && tsup` still
|
|
983
|
+
// resolves instead of standing the check down.
|
|
984
|
+
tsup: ['.js', '.cjs', '.mjs', '.d.ts', '.d.cts', '.d.mts'],
|
|
985
|
+
// Cleaners emit nothing. Recognised only so the very common
|
|
986
|
+
// `rimraf dist && tsc` is still judged on its tsc half.
|
|
987
|
+
rimraf: [],
|
|
988
|
+
rm: [],
|
|
989
|
+
del: [],
|
|
990
|
+
};
|
|
991
|
+
/** Package-manager noise to strip before the command name: `pnpm exec tsup`. */
|
|
992
|
+
const RUNNER_TOKENS = new Set([
|
|
993
|
+
'npx',
|
|
994
|
+
'npm',
|
|
995
|
+
'pnpm',
|
|
996
|
+
'yarn',
|
|
997
|
+
'bun',
|
|
998
|
+
'exec',
|
|
999
|
+
'dlx',
|
|
1000
|
+
'run',
|
|
1001
|
+
'x',
|
|
1002
|
+
'-s',
|
|
1003
|
+
'--silent',
|
|
1004
|
+
]);
|
|
1005
|
+
/**
|
|
1006
|
+
* Extensions this check will judge, longest suffix first. Anything else — a
|
|
1007
|
+
* package publishing `./src/index.ts` directly, a `./dist/style.css` from a
|
|
1008
|
+
* non-JS step — is left alone rather than measured against a JS emit table.
|
|
1009
|
+
*/
|
|
1010
|
+
const ARTEFACT_EXTENSIONS = ['.d.cts', '.d.mts', '.d.ts', '.cjs', '.mjs', '.js'];
|
|
1011
|
+
function artefactExtension(p) {
|
|
1012
|
+
return ARTEFACT_EXTENSIONS.find((ext) => p.endsWith(ext)) ?? null;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Every file path the publish contract names. Shared with the #570 regression
|
|
1016
|
+
* test so doctor and that test agree on what the contract is.
|
|
1017
|
+
*/
|
|
1018
|
+
export function declaredEntryPoints(pkg) {
|
|
1019
|
+
const found = [];
|
|
1020
|
+
const walk = (node) => {
|
|
1021
|
+
if (typeof node === 'string')
|
|
1022
|
+
found.push(node);
|
|
1023
|
+
else if (node && typeof node === 'object')
|
|
1024
|
+
Object.values(node).forEach(walk);
|
|
1025
|
+
};
|
|
1026
|
+
walk(pkg.exports);
|
|
1027
|
+
for (const field of ['main', 'module', 'types']) {
|
|
1028
|
+
const value = pkg[field];
|
|
1029
|
+
if (typeof value === 'string')
|
|
1030
|
+
found.push(value);
|
|
1031
|
+
}
|
|
1032
|
+
return [...new Set(found)];
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* What `command` puts in the output directory, or null when any part of it is
|
|
1036
|
+
* absent from BUILD_EMITS. Null means "don't know", and every caller stands
|
|
1037
|
+
* down on it rather than guessing.
|
|
1038
|
+
*/
|
|
1039
|
+
function emittedExtensions(scripts, command, seen = new Set()) {
|
|
1040
|
+
// Only a plain `a && b` chain is read. Pipes, `||`, subshells and redirects
|
|
1041
|
+
// are not something to infer an output set from.
|
|
1042
|
+
if (/[|;`<>]|\$\(/.test(command))
|
|
1043
|
+
return null;
|
|
1044
|
+
const emitted = new Set();
|
|
1045
|
+
for (const segment of command.split('&&')) {
|
|
1046
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
1047
|
+
const before = tokens.length;
|
|
1048
|
+
while (tokens.length > 0 && RUNNER_TOKENS.has(tokens[0]))
|
|
1049
|
+
tokens.shift();
|
|
1050
|
+
const name = tokens[0];
|
|
1051
|
+
if (!name)
|
|
1052
|
+
return null;
|
|
1053
|
+
// `"build": "pnpm build-cli"` — one script delegating to another. Followed
|
|
1054
|
+
// only when a runner was actually stripped, because a bare `tsc` runs the
|
|
1055
|
+
// binary even in a repo that also happens to have a script by that name.
|
|
1056
|
+
const nested = tokens.length < before ? scripts[name] : undefined;
|
|
1057
|
+
if (nested !== undefined) {
|
|
1058
|
+
if (seen.has(name))
|
|
1059
|
+
return null;
|
|
1060
|
+
seen.add(name);
|
|
1061
|
+
const inner = emittedExtensions(scripts, nested, seen);
|
|
1062
|
+
if (!inner)
|
|
1063
|
+
return null;
|
|
1064
|
+
for (const ext of inner)
|
|
1065
|
+
emitted.add(ext);
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
const known = BUILD_EMITS[name];
|
|
1069
|
+
if (!known)
|
|
1070
|
+
return null;
|
|
1071
|
+
for (const ext of known)
|
|
1072
|
+
emitted.add(ext);
|
|
1073
|
+
}
|
|
1074
|
+
return [...emitted];
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Catches a publish contract naming files the repo's own `build` cannot produce
|
|
1078
|
+
* (#578) — the class of bug that shipped `main: ./dist/index.cjs` alongside
|
|
1079
|
+
* `build: tsc`, which emits only .js and .d.ts, so every CJS consumer of the
|
|
1080
|
+
* published package got a 404 (#570). #577 fixed the preset that wrote that
|
|
1081
|
+
* pair; this catches it however else it arises, e.g. a repo editing `build` or
|
|
1082
|
+
* the contract by hand afterwards.
|
|
1083
|
+
*
|
|
1084
|
+
* Judged from the build *script*, never from `dist/` on disk: a clean checkout
|
|
1085
|
+
* has no dist/ and a stale one has whatever the last build left, so neither
|
|
1086
|
+
* answers the question. Git is what separates the two kinds of path a contract
|
|
1087
|
+
* may name — a tracked `./tooling/preset.mjs` ships as committed source and
|
|
1088
|
+
* needs no build, while an untracked `./dist/index.cjs` has to come out of
|
|
1089
|
+
* `build` or it will not exist at publish time.
|
|
1090
|
+
*
|
|
1091
|
+
* This overlaps publint, which the generated `verify` already runs — but not in
|
|
1092
|
+
* time. publint fires at release, against a dist/ just built on a machine where
|
|
1093
|
+
* it happens to work; this fires at setup/doctor time, on a checkout, before
|
|
1094
|
+
* anything is built.
|
|
1095
|
+
*/
|
|
1096
|
+
export async function checkExportsBuildable(dir, pkg, exec) {
|
|
1097
|
+
const check = 'Exports buildable';
|
|
1098
|
+
if (!pkg || !isPublishableLibrary(pkg)) {
|
|
1099
|
+
return { check, status: 'ok', detail: 'not applicable (private or no published exports)' };
|
|
1100
|
+
}
|
|
1101
|
+
const scripts = pkg.scripts ?? {};
|
|
1102
|
+
const build = scripts.build;
|
|
1103
|
+
if (!build) {
|
|
1104
|
+
return { check, status: 'ok', detail: 'no build script — nothing is expected to be generated' };
|
|
1105
|
+
}
|
|
1106
|
+
const emitted = emittedExtensions(scripts, build);
|
|
1107
|
+
if (!emitted) {
|
|
1108
|
+
return {
|
|
1109
|
+
check,
|
|
1110
|
+
status: 'ok',
|
|
1111
|
+
detail: `not checked — cannot infer what \`${build}\` emits (recognised: ${Object.keys(BUILD_EMITS).join(', ')})`,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const declared = declaredEntryPoints(pkg).filter((p) => artefactExtension(p));
|
|
1115
|
+
if (declared.length === 0) {
|
|
1116
|
+
return { check, status: 'ok', detail: 'no JS entry points named in main/module/types/exports' };
|
|
1117
|
+
}
|
|
1118
|
+
if (!(await fs.pathExists(path.join(dir, '.git')))) {
|
|
1119
|
+
return {
|
|
1120
|
+
check,
|
|
1121
|
+
status: 'ok',
|
|
1122
|
+
detail: 'not checked — not a git repository, so committed assets and build output are indistinguishable',
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
// Declared paths are `./dist/index.js`; git speaks `dist/index.js`.
|
|
1126
|
+
const declaredByPath = new Map(declared.map((p) => [p.replace(/^\.\//, ''), p]));
|
|
1127
|
+
const git = exec ?? ((args) => realGitExec(args, dir));
|
|
1128
|
+
// One call answers both questions: which declared paths are committed, and
|
|
1129
|
+
// whether the repo has .cts/.mts sources. Every argument is a pathspec behind
|
|
1130
|
+
// `--`, so a package.json path beginning with `-` cannot become a flag.
|
|
1131
|
+
const listed = await git(['ls-files', '-z', '--', ...declaredByPath.keys(), '*.cts', '*.mts']);
|
|
1132
|
+
if (listed === null) {
|
|
1133
|
+
return { check, status: 'ok', detail: 'not checked — `git ls-files` is unavailable here' };
|
|
1134
|
+
}
|
|
1135
|
+
const tracked = new Set(listed.split('\0').filter(Boolean));
|
|
1136
|
+
const unbuildable = [...declaredByPath]
|
|
1137
|
+
.filter(([bare]) => !tracked.has(bare))
|
|
1138
|
+
.map(([, declaredPath]) => declaredPath)
|
|
1139
|
+
.filter((p) => !emitted.includes(artefactExtension(p)));
|
|
1140
|
+
if (unbuildable.length === 0) {
|
|
1141
|
+
return {
|
|
1142
|
+
check,
|
|
1143
|
+
status: 'ok',
|
|
1144
|
+
detail: `every declared entry point is committed or emitted by \`${build}\``,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
// tsc emits .cjs/.d.cts from a .cts input, so its narrow BUILD_EMITS entry is
|
|
1148
|
+
// wrong for a repo that has any. Checked last, and only when there is
|
|
1149
|
+
// something to report, so the common repo pays nothing for it.
|
|
1150
|
+
if ([...tracked].some((p) => !declaredByPath.has(p) && /\.[cm]ts$/.test(p))) {
|
|
1151
|
+
return {
|
|
1152
|
+
check,
|
|
1153
|
+
status: 'ok',
|
|
1154
|
+
detail: 'not checked — repo has .cts/.mts sources, whose emitted extensions this cannot infer',
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
check,
|
|
1159
|
+
status: 'drift',
|
|
1160
|
+
detail: `\`${build}\` emits ${emitted.join(', ')}; package.json names ${unbuildable.length} entry point(s) nothing produces: ${unbuildable.join(', ')}`,
|
|
1161
|
+
hint: 'The published package will 404 on those paths. Either point `build` at a bundler that emits those formats (tsup with `format: ["cjs", "esm"]`), or narrow main/module/exports to the formats the current build produces. No autofix — both directions are valid and only the package author knows which one it promises.',
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
967
1164
|
/** Who this package's README badges are for — feeds the base badge check (#309). */
|
|
968
1165
|
export function jsBadgeAudience(pkg) {
|
|
969
1166
|
if (!pkg || pkg.private === true)
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import fs from 'fs-extra';
|
|
18
18
|
import { checkFile } from '../../base/checks.js';
|
|
19
|
+
import { parsePackageSwift } from './ci.js';
|
|
19
20
|
import { SWIFT_HOOKS_DIR } from './git-hooks.js';
|
|
20
21
|
const SWIFT_FILE_CHECKS = [
|
|
21
22
|
{
|
|
@@ -118,19 +119,82 @@ export async function checkPackageSwift(dir) {
|
|
|
118
119
|
detail: `Package.swift declares tools ${toolsVersion} and explicit platforms`,
|
|
119
120
|
};
|
|
120
121
|
}
|
|
122
|
+
/** Subdirectory names of `<dir>/<root>`, or none when that root doesn't exist. */
|
|
123
|
+
async function subdirectories(dir, root) {
|
|
124
|
+
try {
|
|
125
|
+
const entries = await fs.readdir(path.join(dir, root), { withFileTypes: true });
|
|
126
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
121
132
|
/**
|
|
122
133
|
* Directory names under `Sources/`. For a SwiftPM package these are the target
|
|
123
134
|
* names — a target's sources and its DocC catalogue live in the same folder,
|
|
124
135
|
* so this is where both the check and the `docc` fixer have to look.
|
|
125
136
|
*/
|
|
126
137
|
export async function swiftSourceTargets(dir) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
138
|
+
return subdirectories(dir, 'Sources');
|
|
139
|
+
}
|
|
140
|
+
/** Where SwiftPM looks for a target's sources when the manifest gives no `path:`. */
|
|
141
|
+
const TARGET_ROOTS = ['Sources', 'Tests'];
|
|
142
|
+
/**
|
|
143
|
+
* Sources that belong to no declared target (#575). SwiftPM silently ignores a
|
|
144
|
+
* directory under `Sources/` that no `.target(` names — so `swift build` and
|
|
145
|
+
* `swift test` both exit 0 while the code in it is never compiled and the tests
|
|
146
|
+
* in it are never run. There is no red X anywhere, which is the whole reason
|
|
147
|
+
* this check exists: a renamed target, a dropped `.target(...)` line, or a
|
|
148
|
+
* generator that rewrote the manifest all leave the same invisible hole.
|
|
149
|
+
*
|
|
150
|
+
* No fixer: repairing it means either adding a target or deleting the
|
|
151
|
+
* directory, and nothing outside the project can tell which.
|
|
152
|
+
*/
|
|
153
|
+
export async function checkSwiftTargets(dir) {
|
|
154
|
+
const check = 'Swift targets';
|
|
155
|
+
const filepath = path.join(dir, 'Package.swift');
|
|
156
|
+
if (!(await fs.pathExists(filepath))) {
|
|
157
|
+
return { check, status: 'missing', detail: 'no Package.swift' };
|
|
130
158
|
}
|
|
131
|
-
|
|
132
|
-
|
|
159
|
+
const contents = await fs.readFile(filepath, 'utf-8');
|
|
160
|
+
// A custom `path:` puts a target's sources anywhere, so directory names stop
|
|
161
|
+
// meaning target names and every comparison below becomes a guess. Bail out
|
|
162
|
+
// rather than report something we can't stand behind. Deliberately crude:
|
|
163
|
+
// `.package(path:)` for a local dependency trips this too, which costs a
|
|
164
|
+
// skipped check on repos that use one — cheaper than a false accusation.
|
|
165
|
+
if (/\bpath:\s*"/.test(contents)) {
|
|
166
|
+
return {
|
|
167
|
+
check,
|
|
168
|
+
status: 'ok',
|
|
169
|
+
detail: 'not checked — Package.swift uses a custom `path:`, so directories need not match targets',
|
|
170
|
+
};
|
|
133
171
|
}
|
|
172
|
+
const declared = new Set(parsePackageSwift(contents).targets);
|
|
173
|
+
const orphans = [];
|
|
174
|
+
let scanned = 0;
|
|
175
|
+
for (const root of TARGET_ROOTS) {
|
|
176
|
+
for (const name of await subdirectories(dir, root)) {
|
|
177
|
+
scanned++;
|
|
178
|
+
if (!declared.has(name))
|
|
179
|
+
orphans.push(`${root}/${name}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (scanned === 0) {
|
|
183
|
+
return { check, status: 'ok', detail: 'no directories under Sources/ or Tests/' };
|
|
184
|
+
}
|
|
185
|
+
if (orphans.length > 0) {
|
|
186
|
+
return {
|
|
187
|
+
check,
|
|
188
|
+
status: 'drift',
|
|
189
|
+
detail: `no target declares ${orphans.join(', ')} — SwiftPM ignores them, so \`swift build\` passes without building them`,
|
|
190
|
+
hint: `Add a target named after each directory to Package.swift, or delete the directory: ${orphans.join(', ')}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
check,
|
|
195
|
+
status: 'ok',
|
|
196
|
+
detail: `all ${scanned} source directories are declared as targets`,
|
|
197
|
+
};
|
|
134
198
|
}
|
|
135
199
|
async function findDoccCatalogue(dir) {
|
|
136
200
|
for (const target of await swiftSourceTargets(dir)) {
|
|
@@ -270,6 +334,7 @@ export async function runSwiftChecks(dir) {
|
|
|
270
334
|
await checkPackageSwift(dir),
|
|
271
335
|
...(await Promise.all(SWIFT_FILE_CHECKS.map((spec) => checkFile(dir, spec)))),
|
|
272
336
|
await checkSwiftGitignore(dir),
|
|
337
|
+
await checkSwiftTargets(dir),
|
|
273
338
|
await checkSwiftTests(dir),
|
|
274
339
|
await checkDocC(dir),
|
|
275
340
|
await checkSwiftRelease(dir),
|
|
@@ -13,23 +13,38 @@ import { renderGitHubWorkflow, renderGitLabCI } from '../../base/ci.js';
|
|
|
13
13
|
/** SwiftPM runs on macOS runners — Xcode isn't available anywhere else. */
|
|
14
14
|
const MACOS = 'macos-latest';
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Every SwiftPM target factory. `name:` is their first parameter and Swift
|
|
17
|
+
* enforces argument order, so a literal name always follows the open paren.
|
|
18
|
+
* `binaryTarget` owns no source directory but is matched anyway: the only
|
|
19
|
+
* consumer uses this set to *suppress* a report, so over-matching costs a
|
|
20
|
+
* missed finding while under-matching costs a false one.
|
|
21
|
+
*/
|
|
22
|
+
const TARGET_DECL = /\.(?:target|executableTarget|testTarget|macro|plugin|systemLibrary|binaryTarget)\(\s*name:\s*"([^"]+)"/g;
|
|
23
|
+
/**
|
|
24
|
+
* Pull the facts doctor and CI need out of a Package.swift. A regex rather than
|
|
25
|
+
* a SwiftPM invocation: `swift package dump-package` would need a Swift
|
|
26
|
+
* toolchain on the machine running `repo-tooling`, which is usually a Node
|
|
27
|
+
* environment.
|
|
28
|
+
*
|
|
29
|
+
* ponytail: literal string arguments only. A manifest that builds its target
|
|
30
|
+
* list in a loop, behind `#if`, or from a variable parses as fewer targets than
|
|
31
|
+
* it declares — callers that act on the result must fail open. Upgrade path is
|
|
32
|
+
* `swift package dump-package` on a machine that has Swift.
|
|
19
33
|
*/
|
|
20
34
|
export function parsePackageSwift(contents) {
|
|
21
35
|
const products = [...contents.matchAll(/\.library\(\s*name:\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
36
|
+
const targets = [...contents.matchAll(TARGET_DECL)].map((m) => m[1]);
|
|
22
37
|
// Only the `platforms:` array declares deployment targets — matching
|
|
23
38
|
// `.iOS(` across the whole manifest would also catch `.iOS` in target
|
|
24
39
|
// conditionals.
|
|
25
40
|
const platformsClause = contents.match(/platforms:\s*\[([^\]]*)\]/s)?.[1] ?? '';
|
|
26
41
|
const platforms = [...platformsClause.matchAll(/\.(iOS|macOS|tvOS|watchOS|visionOS)\s*\(/g)].map((m) => m[1]);
|
|
27
|
-
return { products, platforms: [...new Set(platforms)] };
|
|
42
|
+
return { products, platforms: [...new Set(platforms)], targets };
|
|
28
43
|
}
|
|
29
44
|
export async function readSwiftPackage(dir) {
|
|
30
45
|
const filepath = path.join(dir, 'Package.swift');
|
|
31
46
|
if (!(await fs.pathExists(filepath)))
|
|
32
|
-
return { products: [], platforms: [] };
|
|
47
|
+
return { products: [], platforms: [], targets: [] };
|
|
33
48
|
return parsePackageSwift(await fs.readFile(filepath, 'utf-8'));
|
|
34
49
|
}
|
|
35
50
|
/** Checkout + pin Xcode. Every Swift job needs both before it can do anything. */
|
package/package.json
CHANGED