Skip to content

Commit 8df490c

Browse files
committed
chore(ci): move the engine version constant with the pins in vendor upgrades
sg-detect.cjs --write and vale-upgrade-detect.cjs --write now also rewrite AST_GREP_VERSION / VALE_VERSION in packages/cli/src/rules/capabilities.ts, via a new bumpVersionConstant in pin-bump.cjs that anchors on the exact declaration line, fails unless it is found exactly once, and leaves every other byte alone. Vale's constant takes the base version the binary reports, not the stamped npm version. engine-version-consistency.test.ts holds the constant to the pins, so a bot commit that moved only the pins failed Validate by construction (#368). The test stays; the bot commit becomes consistent.
1 parent fd896f9 commit 8df490c

6 files changed

Lines changed: 345 additions & 11 deletions

File tree

‎.github/scripts/pin-bump.cjs‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,59 @@ function bumpPins(source, { pattern, from, to }) {
7676
return { source: bumped, count };
7777
}
7878

79-
module.exports = { bumpPins, escapeLiteral };
79+
/**
80+
* Move one `export const NAME = "…";` declaration to a new version, in the
81+
* TypeScript source text of `packages/cli/src/rules/capabilities.ts`.
82+
*
83+
* WHY THIS IS PART OF THE BUMP. `capabilities.ts` publishes `AST_GREP_VERSION`
84+
* and `VALE_VERSION` by hand, and `test/engine-version-consistency.test.ts`
85+
* asserts they agree with the pins. That test is deliberate and the constant
86+
* is deliberately NOT derived from package.json (its docblock says why), so a
87+
* bot commit that moves the pins and not the constant fails Validate BY
88+
* CONSTRUCTION. That is what happened on taskless/cli#368: the base of a stack
89+
* was red before anyone had looked at it, and the child (#372) had to carry
90+
* the constant along with the real work. The bot moves both now, so the
91+
* consistency test is what it was meant to be — a check on humans, not a
92+
* scheduled failure.
93+
*
94+
* WHY TEXT AGAIN. The same reason as `bumpPins`: the file is prettier-formatted
95+
* and the diff a reviewer reads should be one string. The match is anchored on
96+
* the whole declaration line, so a mention of the name in a docblock or a
97+
* `{@link VALE_VERSION}` is not a candidate.
98+
*
99+
* WHY EXACTLY ONE. Zero means the declaration moved or was renamed and the
100+
* workflow would otherwise push a commit the consistency test rejects — the
101+
* failure this exists to remove. Two means the anchor is no longer specific,
102+
* and rewriting both would be a guess. Either is a failed run.
103+
*
104+
* @param source the capabilities.ts text
105+
* @param name the exported constant, e.g. `VALE_VERSION`
106+
* @param to the version to write. For Vale this is the BASE version the
107+
* binary reports (`3.22.0`), not the stamped npm version.
108+
* @returns the rewritten source and the version the declaration held before
109+
*/
110+
function bumpVersionConstant(source, { name, to }) {
111+
if (!/^[A-Z][A-Z0-9_]*$/.test(String(name))) {
112+
throw new Error(
113+
`the constant name must be an UPPER_SNAKE identifier, got ${JSON.stringify(name)}`
114+
);
115+
}
116+
const matcher = new RegExp(
117+
`^(export const ${name} = ")([^"\\n]*)(";)$`,
118+
"gm"
119+
);
120+
const matches = [...source.matchAll(matcher)];
121+
if (matches.length !== 1) {
122+
throw new Error(
123+
`expected exactly one \`export const ${name} = "…";\` declaration, found ${matches.length}`
124+
);
125+
}
126+
const [match] = matches;
127+
const [whole, head, from, tail] = match;
128+
const at = match.index;
129+
const bumped =
130+
source.slice(0, at) + head + to + tail + source.slice(at + whole.length);
131+
return { source: bumped, from };
132+
}
133+
134+
module.exports = { bumpPins, bumpVersionConstant, escapeLiteral };

‎.github/scripts/pin-bump.test.cjs‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
const test = require("node:test");
1515
const assert = require("node:assert/strict");
1616

17-
const { bumpPins } = require("./pin-bump.cjs");
17+
const { bumpPins, bumpVersionConstant } = require("./pin-bump.cjs");
1818

1919
/** The boundary-aware pattern sg-detect.cjs enumerates its pins with. */
2020
const AST_GREP = /^@ast-grep\/cli(-|$)/;
@@ -114,3 +114,86 @@ test("a stateful /g pattern is refused rather than silently skipping pins", () =
114114
/must not be \/g/
115115
);
116116
});
117+
118+
/**
119+
* The constant half of the bump. `capabilities.ts` declares `AST_GREP_VERSION`
120+
* and `VALE_VERSION` by hand and `engine-version-consistency.test.ts` holds
121+
* them to the pins, so a bot commit that moves only the pins is red before
122+
* anyone reads it (taskless/cli#368). These pin the rewrite that closes that.
123+
*/
124+
const CAPABILITIES = [
125+
"/**",
126+
" * Pinned against the binary by `test/ast-grep-vendor-contract.test.ts`.",
127+
" */",
128+
'export const AST_GREP_VERSION = "0.45.3";',
129+
"",
130+
"/**",
131+
" * The Vale release, measured against {@link VALE_VERSION}'s binary.",
132+
' * A literal stamp like "3.21.0-20260915061224" lives in package.json.',
133+
" */",
134+
'export const VALE_VERSION = "3.21.0";',
135+
"",
136+
'export const OTHER = "3.21.0";',
137+
"",
138+
].join("\n");
139+
140+
test("the named constant moves to the base version and reports what it held", () => {
141+
const { source, from } = bumpVersionConstant(CAPABILITIES, {
142+
name: "VALE_VERSION",
143+
to: "3.22.0",
144+
});
145+
assert.equal(from, "3.21.0");
146+
assert.match(source, /^export const VALE_VERSION = "3\.22\.0";$/m);
147+
// Every other byte survives: the docblock's mention of the old version, the
148+
// `{@link}`, and the unrelated constant at the same value.
149+
assert.match(source, /"3\.21\.0-20260915061224"/);
150+
assert.match(source, /\{@link VALE_VERSION\}/);
151+
assert.match(source, /^export const OTHER = "3\.21\.0";$/m);
152+
assert.match(source, /^export const AST_GREP_VERSION = "0\.45\.3";$/m);
153+
assert.equal(source.split("\n").length, CAPABILITIES.split("\n").length);
154+
});
155+
156+
test("the ast-grep constant is reached by the same anchor", () => {
157+
const { source, from } = bumpVersionConstant(CAPABILITIES, {
158+
name: "AST_GREP_VERSION",
159+
to: "0.46.0",
160+
});
161+
assert.equal(from, "0.45.3");
162+
assert.match(source, /^export const AST_GREP_VERSION = "0\.46\.0";$/m);
163+
assert.match(source, /^export const VALE_VERSION = "3\.21\.0";$/m);
164+
});
165+
166+
test("a constant already at the target is rewritten to itself", () => {
167+
const { source, from } = bumpVersionConstant(CAPABILITIES, {
168+
name: "VALE_VERSION",
169+
to: "3.21.0",
170+
});
171+
assert.equal(from, "3.21.0");
172+
assert.equal(source, CAPABILITIES);
173+
});
174+
175+
test("a missing declaration fails the run rather than writing nothing", () => {
176+
assert.throws(
177+
() =>
178+
bumpVersionConstant(CAPABILITIES, {
179+
name: "RUFF_VERSION",
180+
to: "1.0.0",
181+
}),
182+
/expected exactly one `export const RUFF_VERSION = "…";` declaration, found 0/
183+
);
184+
});
185+
186+
test("a declaration that appears twice is ambiguous and refused", () => {
187+
const doubled = `${CAPABILITIES}export const VALE_VERSION = "3.20.0";\n`;
188+
assert.throws(
189+
() => bumpVersionConstant(doubled, { name: "VALE_VERSION", to: "3.22.0" }),
190+
/found 2/
191+
);
192+
});
193+
194+
test("a name that is not an identifier cannot become a pattern", () => {
195+
assert.throws(
196+
() => bumpVersionConstant(CAPABILITIES, { name: ".*", to: "3.22.0" }),
197+
/UPPER_SNAKE identifier/
198+
);
199+
});

‎.github/scripts/sg-detect.cjs‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@
4141
* nothing. This is what update-badges.cjs calls.
4242
*
4343
* --write rewrite every `@ast-grep/cli*` pin in packages/cli/package.json to
44-
* the upstream version. `ast-grep-upgrade.yml` then regenerates the
45-
* lockfile and opens a pull request.
44+
* the upstream version, and `AST_GREP_VERSION` in
45+
* packages/cli/src/rules/capabilities.ts with it.
46+
* `ast-grep-upgrade.yml` then regenerates the lockfile and the rule
47+
* schema, checks the result, and opens a pull request.
4648
*
4749
* THIS REVERSES AN EARLIER DECISION, deliberately. This script used
4850
* to refuse to write on the grounds that a lockfile-touching bump
@@ -84,7 +86,7 @@
8486
const { appendFileSync, readFileSync, writeFileSync } = require("node:fs");
8587
const { join } = require("node:path");
8688

87-
const { bumpPins } = require("./pin-bump.cjs");
89+
const { bumpPins, bumpVersionConstant } = require("./pin-bump.cjs");
8890
const {
8991
fetchReleaseByTag,
9092
formatReleaseNotes,
@@ -101,6 +103,25 @@ const PACKAGE_JSON_PATH = join(
101103
"package.json"
102104
);
103105

106+
/**
107+
* Where `AST_GREP_VERSION` is declared by hand. `--write` moves it with the
108+
* pins, because `test/engine-version-consistency.test.ts` holds the two
109+
* together and a bump that moved only the pins failed Validate before anyone
110+
* had looked at it (taskless/cli#368, the Vale instance of the same shape).
111+
*/
112+
const CAPABILITIES_PATH = join(
113+
__dirname,
114+
"..",
115+
"..",
116+
"packages",
117+
"cli",
118+
"src",
119+
"rules",
120+
"capabilities.ts"
121+
);
122+
123+
const VERSION_CONSTANT = "AST_GREP_VERSION";
124+
104125
/** `@ast-grep/cli` itself and its per-platform siblings. */
105126
const PIN_PATTERN = /^@ast-grep\/cli(-|$)/;
106127

@@ -242,6 +263,7 @@ async function main({
242263
releaseFor = fetchReleaseByTag,
243264
packageJsonPath = PACKAGE_JSON_PATH,
244265
packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")),
266+
capabilitiesPath = CAPABILITIES_PATH,
245267
} = {}) {
246268
const json = argv.includes("--json");
247269
const write = argv.includes("--write");
@@ -294,6 +316,19 @@ async function main({
294316
}
295317
writeFileSync(packageJsonPath, bumped);
296318
log(`Rewrote ${count} pins in ${packageJsonPath} to ${upstream}.`);
319+
320+
// Upstream's own packages, so the constant is the pin verbatim. Rewritten
321+
// in the same run as the pins so the bot commit is self-consistent: a
322+
// missing declaration throws here, before anything is pushed, rather than
323+
// failing Validate later.
324+
const { source: constants, from } = bumpVersionConstant(
325+
readFileSync(capabilitiesPath, "utf8"),
326+
{ name: VERSION_CONSTANT, to: upstream }
327+
);
328+
writeFileSync(capabilitiesPath, constants);
329+
log(
330+
`Rewrote ${VERSION_CONSTANT} in ${capabilitiesPath}: ${from} -> ${upstream}.`
331+
);
297332
}
298333

299334
// Only the ahead path has a bump to describe. A second request, unlike Vale's

‎.github/scripts/sg-detect.test.cjs‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ const CLI_PACKAGE_JSON = JSON.parse(
2626
)
2727
);
2828

29+
/**
30+
* A capabilities.ts with the declaration `--write` rewrites, plus the kind of
31+
* neighbour it must not touch: the Vale constant, and a docblock naming the
32+
* old version.
33+
*/
34+
const CAPABILITIES_AT = (version) =>
35+
[
36+
"/** Measured at 0.45.2; see {@link AST_GREP_VERSION}. */",
37+
`export const AST_GREP_VERSION = "${version}";`,
38+
"",
39+
'export const VALE_VERSION = "3.21.0";',
40+
"",
41+
].join("\n");
42+
2943
/** Run main() with the registry stubbed and $GITHUB_OUTPUT captured. */
3044
async function runDetect({
3145
upstream,
@@ -34,6 +48,7 @@ async function runDetect({
3448
argv = [],
3549
release,
3650
wantNotes = false,
51+
capabilitiesSource = CAPABILITIES_AT("0.45.2"),
3752
}) {
3853
const directory = mkdtempSync(join(tmpdir(), "sg-detect-test-"));
3954
const outputPath = join(directory, "github-output");
@@ -44,6 +59,11 @@ async function runDetect({
4459
if (packageJsonSource !== undefined) {
4560
writeFileSync(packageJsonPath, packageJsonSource);
4661
}
62+
// --write also rewrites AST_GREP_VERSION, so the fixture carries a
63+
// capabilities.ts of its own for the same reason. The committed one is
64+
// never written to here either.
65+
const capabilitiesPath = join(directory, "capabilities.ts");
66+
writeFileSync(capabilitiesPath, capabilitiesSource);
4767
const previous = process.env.GITHUB_OUTPUT;
4868
const releasesFetched = [];
4969
process.env.GITHUB_OUTPUT = outputPath;
@@ -57,6 +77,7 @@ async function runDetect({
5777
},
5878
packageJsonPath,
5979
packageJson,
80+
capabilitiesPath,
6081
});
6182
const outputs = Object.fromEntries(
6283
readFileSync(outputPath, "utf8")
@@ -81,12 +102,14 @@ async function runDetect({
81102
packageJsonSource === undefined
82103
? undefined
83104
: readFileSync(packageJsonPath, "utf8");
105+
const capabilitiesWritten = readFileSync(capabilitiesPath, "utf8");
84106
return {
85107
comparison,
86108
outputs,
87109
notesWritten,
88110
releasesFetched,
89111
packageJsonWritten,
112+
capabilitiesWritten,
90113
};
91114
} finally {
92115
if (previous === undefined) {
@@ -289,7 +312,7 @@ const sourcePinnedAt = (version) =>
289312
)}\n`;
290313

291314
test("sg-detect: --write bumps every pin in the file on disk", async () => {
292-
const { packageJsonWritten, outputs } = await runDetect({
315+
const { packageJsonWritten, capabilitiesWritten, outputs } = await runDetect({
293316
upstream: "0.45.3",
294317
packageJson: pinnedAt("0.45.2"),
295318
packageJsonSource: sourcePinnedAt("0.45.2"),
@@ -299,18 +322,42 @@ test("sg-detect: --write bumps every pin in the file on disk", async () => {
299322
assert.equal(outputs.update, "true");
300323
assert.doesNotMatch(packageJsonWritten, /0\.45\.2/);
301324
assert.equal(packageJsonWritten.match(/0\.45\.3/g).length, 3);
325+
// The constant moves with the pins, and nothing around it does. This is
326+
// what keeps the bot commit green under engine-version-consistency.test.ts.
327+
assert.equal(capabilitiesWritten, CAPABILITIES_AT("0.45.3"));
328+
assert.match(capabilitiesWritten, /Measured at 0\.45\.2/);
329+
assert.match(capabilitiesWritten, /VALE_VERSION = "3\.21\.0"/);
302330
});
303331

304-
test("sg-detect: --write leaves the file alone when the pin is current", async () => {
332+
test("sg-detect: --write leaves the files alone when the pin is current", async () => {
305333
const before = sourcePinnedAt("0.45.2");
306-
const { packageJsonWritten } = await runDetect({
334+
const { packageJsonWritten, capabilitiesWritten } = await runDetect({
307335
upstream: "0.45.2",
308336
packageJson: pinnedAt("0.45.2"),
309337
packageJsonSource: before,
310338
argv: ["--write"],
311339
});
312340

313341
assert.equal(packageJsonWritten, before);
342+
assert.equal(capabilitiesWritten, CAPABILITIES_AT("0.45.2"));
343+
});
344+
345+
/**
346+
* The declaration the workflow depends on has moved or been renamed. Failing
347+
* here is the point: pushing the pins without the constant is exactly the
348+
* red-by-construction pull request this rewrite exists to end.
349+
*/
350+
test("sg-detect: --write fails when AST_GREP_VERSION is not declared once", async () => {
351+
await assert.rejects(
352+
runDetect({
353+
upstream: "0.45.3",
354+
packageJson: pinnedAt("0.45.2"),
355+
packageJsonSource: sourcePinnedAt("0.45.2"),
356+
argv: ["--write"],
357+
capabilitiesSource: 'export const VALE_VERSION = "3.21.0";\n',
358+
}),
359+
/expected exactly one `export const AST_GREP_VERSION = "…";` declaration, found 0/
360+
);
314361
});
315362

316363
/**

0 commit comments

Comments
 (0)