@rtorcato/repo-tooling 3.31.0 → 3.31.2
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/generators/claude-skills.js +1 -41
- package/dist/cli/generators/package-json.js +28 -4
- package/dist/cli/index.js +2 -2
- package/dist/cli/utils/lockfile.js +2 -2
- package/dist/cli/utils/version.js +68 -0
- package/dist/languages/swift/scaffold.js +27 -6
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +17 -3
|
@@ -11,6 +11,7 @@ import fs from 'fs-extra';
|
|
|
11
11
|
import { realGitExec } from '../../base/git-identity.js';
|
|
12
12
|
import { getPackageRoot } from '../utils/copy-preset.js';
|
|
13
13
|
import { shellQuote } from '../utils/shell.js';
|
|
14
|
+
import { isNewerVersion, resolveShippedVersion } from '../utils/version.js';
|
|
14
15
|
/**
|
|
15
16
|
* Skills this package owns the content of and keeps up to date. The loop first —
|
|
16
17
|
* it is the pipeline; the other three are its drivers (burst, on-ramp, status).
|
|
@@ -115,47 +116,6 @@ export function classifySkillContent(installed, shipped) {
|
|
|
115
116
|
return 'unknown';
|
|
116
117
|
return recorded === hashSkillContent(installed) ? 'pristine' : 'modified';
|
|
117
118
|
}
|
|
118
|
-
function versionParts(version) {
|
|
119
|
-
return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
120
|
-
}
|
|
121
|
-
/** True when `a` is a strictly higher version than `b`. Prerelease tags are ignored. */
|
|
122
|
-
export function isNewerVersion(a, b) {
|
|
123
|
-
const [left, right] = [versionParts(a), versionParts(b)];
|
|
124
|
-
for (let i = 0; i < 3; i++) {
|
|
125
|
-
const [x, y] = [left[i] ?? 0, right[i] ?? 0];
|
|
126
|
-
if (x !== y)
|
|
127
|
-
return x > y;
|
|
128
|
-
}
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* The version to stamp, given what `package.json` claims.
|
|
133
|
-
*
|
|
134
|
-
* In a published tarball that field is authoritative — `@semantic-release/npm`
|
|
135
|
-
* rewrites it before packing. In a *git checkout* it is not: this repo runs
|
|
136
|
-
* semantic-release without `@semantic-release/git` (#417), so nothing ever
|
|
137
|
-
* writes the released version back and the field sits at whatever it was last
|
|
138
|
-
* hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
|
|
139
|
-
* ten minor versions of drift.
|
|
140
|
-
*
|
|
141
|
-
* That mattered because the stamp feeds the downgrade guard below: a skill
|
|
142
|
-
* installed from npm could not be updated from a local checkout, and the
|
|
143
|
-
* refusal claimed the installed copy was "newer" when only its *label* was.
|
|
144
|
-
*
|
|
145
|
-
* So when the package root is a git checkout, take the nearest tag as well and
|
|
146
|
-
* keep whichever is higher. Monotonic on purpose — this can only ever raise the
|
|
147
|
-
* answer, so a tagless, shallow, or git-less environment keeps today's
|
|
148
|
-
* behaviour rather than silently stamping something lower.
|
|
149
|
-
*/
|
|
150
|
-
export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
|
|
151
|
-
if (!(await fs.pathExists(path.join(root, '.git'))))
|
|
152
|
-
return pkgVersion;
|
|
153
|
-
const described = await git(['describe', '--tags', '--abbrev=0'], root);
|
|
154
|
-
const tag = described?.trim().replace(/^v/, '');
|
|
155
|
-
if (!tag || !/^\d+\.\d+/.test(tag))
|
|
156
|
-
return pkgVersion;
|
|
157
|
-
return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
|
|
158
|
-
}
|
|
159
119
|
/** The skill source and the package version that will be stamped into it. */
|
|
160
120
|
export async function readShippedSkill(name = SHIPPED_SKILL, git = realGitExec) {
|
|
161
121
|
const root = getPackageRoot();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import fs from 'fs-extra';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { PNPM_FALLBACK_VERSION, detectPnpmVersion } from './misc.js';
|
|
@@ -8,6 +9,8 @@ export async function generatePackageJson(config, targetDir) {
|
|
|
8
9
|
existingPackageJson = await fs.readJson(packageJsonPath);
|
|
9
10
|
}
|
|
10
11
|
const includeTreeshake = Boolean(config.treeshakeCheck && config.projectType === 'library');
|
|
12
|
+
const generatedScripts = getScripts(config, { includeTreeshake });
|
|
13
|
+
const existingScripts = existingPackageJson?.scripts ?? {};
|
|
11
14
|
const packageJson = {
|
|
12
15
|
name: config.projectName,
|
|
13
16
|
version: '0.1.0',
|
|
@@ -17,10 +20,7 @@ export async function generatePackageJson(config, targetDir) {
|
|
|
17
20
|
// generated workflows carry no `version:` input (#364).
|
|
18
21
|
packageManager: `pnpm@${detectPnpmVersion() ?? PNPM_FALLBACK_VERSION}`,
|
|
19
22
|
...existingPackageJson,
|
|
20
|
-
scripts:
|
|
21
|
-
...getScripts(config, { includeTreeshake }),
|
|
22
|
-
...existingPackageJson?.scripts,
|
|
23
|
-
},
|
|
23
|
+
scripts: resolveScripts(config, generatedScripts, existingScripts),
|
|
24
24
|
dependencies: {
|
|
25
25
|
...existingPackageJson?.dependencies,
|
|
26
26
|
},
|
|
@@ -66,6 +66,30 @@ export async function generatePackageJson(config, targetDir) {
|
|
|
66
66
|
// (see ensureBuildApprovals in build.ts).
|
|
67
67
|
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Merge the generated scripts with what the package.json already had. Existing
|
|
71
|
+
* values win — that merge-don't-clobber policy is what makes re-running `setup`
|
|
72
|
+
* on a live repo safe.
|
|
73
|
+
*
|
|
74
|
+
* `build` on a library is the one exception. The library branch of
|
|
75
|
+
* generatePackageJson overwrites the whole publish contract
|
|
76
|
+
* (main/module/types/exports) unconditionally, and that contract names files
|
|
77
|
+
* only the preset's bundler emits: a preserved `build: tsc` produces
|
|
78
|
+
* dist/index.js + dist/index.d.ts and never the ./dist/index.cjs that `main`
|
|
79
|
+
* and every `require` condition point at, so `pnpm build && npm publish` ships
|
|
80
|
+
* a package whose CJS entry points 404 (#570). Owning the contract means owning
|
|
81
|
+
* its producer; the replacement is announced rather than silent.
|
|
82
|
+
*/
|
|
83
|
+
function resolveScripts(config, generated, existing) {
|
|
84
|
+
const scripts = { ...generated, ...existing };
|
|
85
|
+
if (config.projectType !== 'library' || !generated.build)
|
|
86
|
+
return scripts;
|
|
87
|
+
if (existing.build && existing.build !== generated.build) {
|
|
88
|
+
console.warn(chalk.yellow(`⚠️ Replaced "build": "${existing.build}" with "${generated.build}" — the library exports map names files only ${config.bundler} emits.`));
|
|
89
|
+
}
|
|
90
|
+
scripts.build = generated.build;
|
|
91
|
+
return scripts;
|
|
92
|
+
}
|
|
69
93
|
function getScripts(config, opts = {}) {
|
|
70
94
|
const scripts = {};
|
|
71
95
|
// TypeScript scripts
|
package/dist/cli/index.js
CHANGED
|
@@ -3,12 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
5
|
import fs from 'fs-extra';
|
|
6
|
-
import packageJson from '../../package.json' with { type: 'json' };
|
|
7
6
|
import { doctorCommand } from './commands/doctor.js';
|
|
8
7
|
import { fixCommand } from './commands/fix.js';
|
|
9
8
|
import { loopGuardCommand } from './commands/loop-guard.js';
|
|
10
9
|
import { setupProject } from './commands/setup.js';
|
|
11
10
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
11
|
+
import { getToolVersion } from './utils/version.js';
|
|
12
12
|
async function isSelfRepo(dir) {
|
|
13
13
|
try {
|
|
14
14
|
const pkg = await fs.readJson(path.join(dir, 'package.json'));
|
|
@@ -22,7 +22,7 @@ const program = new Command();
|
|
|
22
22
|
program
|
|
23
23
|
.name('@rtorcato/repo-tooling')
|
|
24
24
|
.description('🛠️ JavaScript and TypeScript tooling setup for modern projects')
|
|
25
|
-
.version(
|
|
25
|
+
.version(await getToolVersion());
|
|
26
26
|
program
|
|
27
27
|
.command('setup')
|
|
28
28
|
.alias('init')
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
|
-
import packageJson from '../../../package.json' with { type: 'json' };
|
|
4
3
|
import { CONFIG_SCHEMA, validateProjectConfig } from '../commands/setup-presets.js';
|
|
5
4
|
import { SHIPPED_SKILLS } from '../generators/claude-skills.js';
|
|
5
|
+
import { getToolVersion } from './version.js';
|
|
6
6
|
export const LOCKFILE_NAME = '.repo-tooling.json';
|
|
7
7
|
// Package and bin name used before the js-tooling→repo-tooling rename (#272).
|
|
8
8
|
// The bin no longer exists and the package is 404 on the registry, so any
|
|
@@ -243,7 +243,7 @@ export async function writeLockfile(dir, config, assets) {
|
|
|
243
243
|
record: {
|
|
244
244
|
config,
|
|
245
245
|
...(carried && Object.keys(carried).length > 0 ? { assets: carried } : {}),
|
|
246
|
-
writtenBy: `@rtorcato/repo-tooling@${
|
|
246
|
+
writtenBy: `@rtorcato/repo-tooling@${await getToolVersion()}`,
|
|
247
247
|
writtenAt: new Date().toISOString(),
|
|
248
248
|
},
|
|
249
249
|
...(existing?.rules ? { rules: existing.rules } : {}),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one answer to "what version of this package is running" (#572).
|
|
3
|
+
*
|
|
4
|
+
* There were two. `--version` and the lockfile's `writtenBy` read the imported
|
|
5
|
+
* `package.json` directly; the Claude-skills stamp went through
|
|
6
|
+
* `resolveShippedVersion`, which corrects for the drift documented below. A
|
|
7
|
+
* single `doctor` run could report both — the skills check saying 3.31.0 while
|
|
8
|
+
* `writtenBy` in the same run said 3.11.0. Everything now calls
|
|
9
|
+
* `getToolVersion()`.
|
|
10
|
+
*/
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import packageJson from '../../../package.json' with { type: 'json' };
|
|
14
|
+
import { realGitExec } from '../../base/git-identity.js';
|
|
15
|
+
import { getPackageRoot } from './copy-preset.js';
|
|
16
|
+
function versionParts(version) {
|
|
17
|
+
return version.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
18
|
+
}
|
|
19
|
+
/** True when `a` is a strictly higher version than `b`. Prerelease tags are ignored. */
|
|
20
|
+
export function isNewerVersion(a, b) {
|
|
21
|
+
const [left, right] = [versionParts(a), versionParts(b)];
|
|
22
|
+
for (let i = 0; i < 3; i++) {
|
|
23
|
+
const [x, y] = [left[i] ?? 0, right[i] ?? 0];
|
|
24
|
+
if (x !== y)
|
|
25
|
+
return x > y;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The version to report, given what `package.json` claims.
|
|
31
|
+
*
|
|
32
|
+
* In a published tarball that field is authoritative — `@semantic-release/npm`
|
|
33
|
+
* rewrites it before packing. In a *git checkout* it is not: this repo runs
|
|
34
|
+
* semantic-release without `@semantic-release/git` (#417), so nothing ever
|
|
35
|
+
* writes the released version back and the field sits at whatever it was last
|
|
36
|
+
* hand-set to. Observed 2026-08-22: `package.json` 3.11.0 against npm 3.21.1,
|
|
37
|
+
* ten minor versions of drift.
|
|
38
|
+
*
|
|
39
|
+
* That mattered first because the stamp feeds the skills downgrade guard: a
|
|
40
|
+
* skill installed from npm could not be updated from a local checkout, and the
|
|
41
|
+
* refusal claimed the installed copy was "newer" when only its *label* was.
|
|
42
|
+
*
|
|
43
|
+
* So when the package root is a git checkout, take the nearest tag as well and
|
|
44
|
+
* keep whichever is higher. Monotonic on purpose — this can only ever raise the
|
|
45
|
+
* answer, so a tagless, shallow, or git-less environment keeps today's
|
|
46
|
+
* behaviour rather than silently reporting something lower.
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveShippedVersion(root, pkgVersion, git = realGitExec) {
|
|
49
|
+
if (!(await fs.pathExists(path.join(root, '.git'))))
|
|
50
|
+
return pkgVersion;
|
|
51
|
+
const described = await git(['describe', '--tags', '--abbrev=0'], root);
|
|
52
|
+
const tag = described?.trim().replace(/^v/, '');
|
|
53
|
+
if (!tag || !/^\d+\.\d+/.test(tag))
|
|
54
|
+
return pkgVersion;
|
|
55
|
+
return isNewerVersion(tag, pkgVersion) ? tag : pkgVersion;
|
|
56
|
+
}
|
|
57
|
+
let cached;
|
|
58
|
+
/**
|
|
59
|
+
* This package's version, for `--version`, `writtenBy`, and the skill stamps.
|
|
60
|
+
*
|
|
61
|
+
* Memoized: in a checkout `resolveShippedVersion` spawns `git describe`, and
|
|
62
|
+
* nothing about the answer changes mid-process. An npm install has no `.git`
|
|
63
|
+
* under the package root, so that path costs one `pathExists` and no subprocess.
|
|
64
|
+
*/
|
|
65
|
+
export function getToolVersion() {
|
|
66
|
+
cached ??= resolveShippedVersion(getPackageRoot(), String(packageJson.version));
|
|
67
|
+
return cached;
|
|
68
|
+
}
|
|
@@ -18,7 +18,7 @@ import { generateEditorConfig } from '../../cli/generators/misc.js';
|
|
|
18
18
|
import { generateCodeQLWorkflow, generateDependabotConfig } from '../../cli/generators/security.js';
|
|
19
19
|
import { copyPreset } from '../../cli/utils/copy-preset.js';
|
|
20
20
|
import { LANGUAGES } from '../registry.js';
|
|
21
|
-
import { readSwiftPackage, renderSwiftReleaseWorkflow, renderSwiftWorkflow } from './ci.js';
|
|
21
|
+
import { parsePackageSwift, readSwiftPackage, renderSwiftReleaseWorkflow, renderSwiftWorkflow, } from './ci.js';
|
|
22
22
|
import { SWIFT_HOOKS_DIR, installSwiftGitHooks } from './git-hooks.js';
|
|
23
23
|
import { ensureSwiftGitignore } from './gitignore.js';
|
|
24
24
|
/**
|
|
@@ -196,6 +196,10 @@ git tag 1.0.0 && git push origin 1.0.0
|
|
|
196
196
|
/**
|
|
197
197
|
* Relative paths `setup --dry-run` reports for a Swift config, in write order.
|
|
198
198
|
* Kept beside the writer so the two can't drift.
|
|
199
|
+
*
|
|
200
|
+
* This is the greenfield list. Against a directory that already has a
|
|
201
|
+
* Package.swift the writer preserves it and skips the target scaffolding, so the
|
|
202
|
+
* preview over-reports there — the safe direction for a dry run.
|
|
199
203
|
*/
|
|
200
204
|
export function swiftFileList(config) {
|
|
201
205
|
const module = swiftModuleName(config.projectName);
|
|
@@ -225,12 +229,29 @@ export function swiftFileList(config) {
|
|
|
225
229
|
files.push('README.md');
|
|
226
230
|
return files;
|
|
227
231
|
}
|
|
228
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* Scaffold a SwiftPM library. The Swift arm of `generateConfigs`.
|
|
234
|
+
*
|
|
235
|
+
* Package.swift is source, not config (#574): rewriting one that already exists
|
|
236
|
+
* renames the package after the *directory* and leaves the real `Sources/<Target>/`
|
|
237
|
+
* declared by no target — and `swift build` still exits 0, because SwiftPM just
|
|
238
|
+
* ignores the orphaned directory. So an existing manifest is left byte-identical
|
|
239
|
+
* and the module name is read back off it rather than derived from the directory,
|
|
240
|
+
* the same way the JS presets merge into a package.json instead of replacing it.
|
|
241
|
+
*/
|
|
229
242
|
export async function generateSwiftProject(config, targetDir) {
|
|
230
|
-
const
|
|
231
|
-
await fs.
|
|
232
|
-
|
|
233
|
-
|
|
243
|
+
const manifestPath = path.join(targetDir, 'Package.swift');
|
|
244
|
+
const existingManifest = (await fs.pathExists(manifestPath))
|
|
245
|
+
? parsePackageSwift(await fs.readFile(manifestPath, 'utf-8'))
|
|
246
|
+
: null;
|
|
247
|
+
const module = existingManifest?.products[0] ?? swiftModuleName(config.projectName);
|
|
248
|
+
// Nothing is scaffolded under Sources/ or Tests/ either: those would be
|
|
249
|
+
// directories for a target the existing manifest doesn't declare.
|
|
250
|
+
if (!existingManifest) {
|
|
251
|
+
await fs.writeFile(manifestPath, packageSwift(module));
|
|
252
|
+
await fs.outputFile(path.join(targetDir, 'Sources', module, `${module}.swift`), sourceFile(module));
|
|
253
|
+
await fs.outputFile(path.join(targetDir, 'Tests', `${module}Tests`, `${module}Tests.swift`), testFile(module));
|
|
254
|
+
}
|
|
234
255
|
await copyPreset('swiftlint', targetDir);
|
|
235
256
|
await copyPreset('periphery', targetDir);
|
|
236
257
|
await ensureSwiftGitignore(targetDir);
|
package/package.json
CHANGED
|
@@ -964,14 +964,15 @@ review that already exists — `<ARM>` is `code` or `sec`:
|
|
|
964
964
|
|
|
965
965
|
```bash
|
|
966
966
|
ME=$(gh api user --jq .login) # the identity every loop agent posts as
|
|
967
|
+
HEAD=$(gh pr view <N> --json headRefOid --jq .headRefOid)
|
|
967
968
|
VERDICT=$(gh api "repos/$OWNER_REPO/pulls/<N>/reviews" --paginate --slurp \
|
|
968
|
-
| jq -r --arg me "$ME" '[add[]
|
|
969
|
-
| select(.user.login==$me)
|
|
969
|
+
| jq -r --arg me "$ME" --arg head "$HEAD" '[add[]
|
|
970
|
+
| select(.user.login==$me and .commit_id==$head)
|
|
970
971
|
| (.body // "")
|
|
971
972
|
| capture("<!-- ai-issue-loop:verdict:<ARM>:(?<v>[A-Z-]+) -->").v] | last // empty')
|
|
972
973
|
```
|
|
973
974
|
|
|
974
|
-
|
|
975
|
+
Five details there are load-bearing:
|
|
975
976
|
|
|
976
977
|
- **`pulls/<N>/reviews`, because the prompt posts with `gh pr review --comment`.**
|
|
977
978
|
That creates a *review*, which never appears under `issues/<N>/comments`. The
|
|
@@ -993,6 +994,19 @@ Four details there are load-bearing:
|
|
|
993
994
|
count. Login, not `author_association`, because association wobbles with repo
|
|
994
995
|
ownership (an org-owned repo never yields `OWNER`, even for its admins) while
|
|
995
996
|
`gh api user` names exactly who this loop posts as.
|
|
997
|
+
- **The head gate — `.commit_id==$head`, so a verdict expires with the diff it
|
|
998
|
+
read.** Every review carries the commit it was submitted against; ungated, the
|
|
999
|
+
read takes `last` over all of them, so after a fix round the newest marker is
|
|
1000
|
+
still the *pre-fix* one and the tick adopts a verdict about a diff that no
|
|
1001
|
+
longer exists. Both directions bite: a stale `CHANGES` re-applies `ai-changes`
|
|
1002
|
+
for a finding the fix round already resolved, burning a round of two and
|
|
1003
|
+
pushing the PR toward `ai-blocked` over nothing; a stale `PASS` is worse, since
|
|
1004
|
+
it marks a rewritten diff reviewed when nothing read it. Scoped to the head, an
|
|
1005
|
+
older marker reads as absent and that arm re-spawns — which is already the
|
|
1006
|
+
behaviour for an arm that never posted. **This does not cost the #497 recovery
|
|
1007
|
+
case** the read exists for: a reviewer that died between posting and labelling
|
|
1008
|
+
posted against the head that is still current, so its marker still matches.
|
|
1009
|
+
Only genuinely stale markers stop matching, which is the point.
|
|
996
1010
|
- **`(.body // "")` and `// empty`.** A review can have a null body, which
|
|
997
1011
|
`capture` throws on, aborting the whole filter; and `jq -r` prints a missing
|
|
998
1012
|
value as the literal string `null`, which is not empty and would read as a
|