@rtorcato/repo-tooling 3.31.2 → 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.
- package/dist/cli/commands/doctor.js +4 -2
- package/dist/cli/commands/fix-targets.js +2 -0
- package/dist/cli/generators/index.js +8 -1
- package/dist/cli/generators/pnpm-workspace.js +7 -1
- package/dist/cli/utils/lockfile.js +17 -1
- package/dist/languages/swift/checks.js +70 -5
- package/dist/languages/swift/ci.js +20 -5
- package/package.json +1 -1
|
@@ -232,9 +232,11 @@ async function runBaseChecks(dir, lock, opts) {
|
|
|
232
232
|
results.push(await checkAiSetup(dir));
|
|
233
233
|
// User-global, not repo state — see checkClaudeSkills on why it never returns drift.
|
|
234
234
|
results.push(await checkClaudeSkills(opts.skillsDir));
|
|
235
|
-
// #533: gated on `aiLoop`, which is
|
|
235
|
+
// #533: gated on `aiLoop.agentUser`, which is the "this repo uses the pipeline"
|
|
236
236
|
// signal, so a repo that doesn't gets no line at all rather than an empty one.
|
|
237
|
-
|
|
237
|
+
// The key itself no longer says that — since #571 every repo is scaffolded with
|
|
238
|
+
// an empty `aiLoop`, and the skills have always read the login, not the key.
|
|
239
|
+
if (lock?.rules?.aiLoop?.agentUser && lock.rules.requiredSkills?.length) {
|
|
238
240
|
results.push(await checkRequiredSkills(lock.rules.requiredSkills, opts.skillsDir));
|
|
239
241
|
}
|
|
240
242
|
// #534: advisory. Absent `mcp.recommended` means the repo has nothing to say
|
|
@@ -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 = {
|
|
@@ -91,7 +91,14 @@ export async function generateConfigs(config, targetDir) {
|
|
|
91
91
|
await ensureBuildApprovals(config, targetDir);
|
|
92
92
|
// Family-wide pnpm settings (#314). Runs last of the workspace writers so it
|
|
93
93
|
// merges into whatever they wrote rather than racing them for the file.
|
|
94
|
-
|
|
94
|
+
//
|
|
95
|
+
// The scope is read back off the emitted package.json rather than taken from
|
|
96
|
+
// config.projectName: under `--preset` that name is only the directory
|
|
97
|
+
// basename, while generatePackageJson preserves an existing scoped `name`.
|
|
98
|
+
// doctor derives the glob from that same field, so reading the basename left
|
|
99
|
+
// the two disagreeing on every scoped repo (#573).
|
|
100
|
+
const { name } = (await fs.readJson(path.join(targetDir, 'package.json')));
|
|
101
|
+
await ensurePnpmSettings(targetDir, bundlerNeedsEsbuild(config), familyGlob(name));
|
|
95
102
|
// Turborepo task pipeline (pnpm-workspace monorepos, when opted-in)
|
|
96
103
|
if (config.turborepo) {
|
|
97
104
|
await generateTurborepo(targetDir);
|
|
@@ -24,10 +24,16 @@ export const WORKSPACE_FILE = 'pnpm-workspace.yaml';
|
|
|
24
24
|
*
|
|
25
25
|
* One glob covers a whole scope: pnpm matches these entries with
|
|
26
26
|
* `@pnpm/config.matcher`, so there's no package list to keep in sync.
|
|
27
|
+
*
|
|
28
|
+
* The scope is matched against npm's own name charset, not just "anything up
|
|
29
|
+
* to the slash": the name comes verbatim from a pre-existing `package.json`
|
|
30
|
+
* and is never validated as an npm name on the way here, so a scope of a lone
|
|
31
|
+
* wildcard would otherwise be taken as a glob and exempt every scoped package
|
|
32
|
+
* from the release-age delay. An unparseable scope gets no setting at all.
|
|
27
33
|
*/
|
|
28
34
|
export function familyGlob(packageName) {
|
|
29
35
|
const name = typeof packageName === 'string' ? packageName : '';
|
|
30
|
-
const scope = /^(@[
|
|
36
|
+
const scope = /^(@[a-z0-9-][a-z0-9._-]*)\//i.exec(name)?.[1];
|
|
31
37
|
return scope ? `${scope}/*` : null;
|
|
32
38
|
}
|
|
33
39
|
/** Bundlers that pull in esbuild, whose install script pnpm 11 refuses to run unapproved. */
|
|
@@ -27,6 +27,21 @@ const LOCKFILE_SCHEMA_URL = 'https://rtorcato.github.io/repo-tooling/schemas/loc
|
|
|
27
27
|
* branches on it beyond printing it.
|
|
28
28
|
*/
|
|
29
29
|
export const MCP_IMPORTANCE = ['nice-to-have', 'important', 'critical'];
|
|
30
|
+
/**
|
|
31
|
+
* The empty ruleset a repo with no stated rules gets (#571). `rules` used to
|
|
32
|
+
* appear only via v1–v3 migration, so the repos this tool creates were exactly
|
|
33
|
+
* the ones with nothing to edit and nothing to compare. Writing the containers
|
|
34
|
+
* — with `$schema` stamped alongside — makes the file teach its own shape.
|
|
35
|
+
*
|
|
36
|
+
* Values, never defaults: every entry is empty, because a populated one would
|
|
37
|
+
* be this tool asserting a rule on a repo whose humans have not stated any.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_RULES = {
|
|
40
|
+
aiLoop: {},
|
|
41
|
+
requiredSkills: [],
|
|
42
|
+
mcp: { recommended: [] },
|
|
43
|
+
exceptions: {},
|
|
44
|
+
};
|
|
30
45
|
/**
|
|
31
46
|
* JSON Schema for the lockfile, published with the docs site at the exact URL
|
|
32
47
|
* every written lockfile's `$schema` points to (#529). The `satisfies` clauses
|
|
@@ -246,7 +261,8 @@ export async function writeLockfile(dir, config, assets) {
|
|
|
246
261
|
writtenBy: `@rtorcato/repo-tooling@${await getToolVersion()}`,
|
|
247
262
|
writtenAt: new Date().toISOString(),
|
|
248
263
|
},
|
|
249
|
-
|
|
264
|
+
// A repo that has stated no rules gets the empty scaffold, not nothing (#571).
|
|
265
|
+
rules: existing?.rules ?? DEFAULT_RULES,
|
|
250
266
|
};
|
|
251
267
|
await fs.writeJson(filepath, lockfile, { spaces: 2 });
|
|
252
268
|
// Migrate a pre-rename repo to the new name: now that the canonical file is
|
|
@@ -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