@rtorcato/repo-tooling 3.32.0 → 3.33.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.
@@ -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 = {
@@ -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
- try {
128
- const entries = await fs.readdir(path.join(dir, 'Sources'), { withFileTypes: true });
129
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
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
- catch {
132
- return [];
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
- * Pull the two facts CI needs out of a Package.swift. A regex rather than a
17
- * SwiftPM invocation: `swift package dump-package` would need a Swift toolchain
18
- * on the machine running `repo-tooling`, which is usually a Node environment.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.32.0",
3
+ "version": "3.33.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [