Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .ai/local/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ This repository publishes Stella shared TypeScript and oxlint configuration pack
- Do not add stricter lint rules without checking the public repos that consume them.
- Prefer typed config helpers over copied JSON fragments.
- Fixture files should prove rule behavior and make unused disables fail when a rule regresses.
- **Never delete or regenerate `bun.lock` to apply package version bumps.** Run
`bun scripts/check-lockfile-workspace-versions.ts --write`, then
`bun install --frozen-lockfile`. The synchronizer is the sole owner of cached
workspace self-versions; dependency-graph changes belong in an explicit install.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

# Bun validates the resolved graph but does not refresh or validate cached
# workspace self-versions. Release automation uses this same script in
# byte-preserving --write mode; CI keeps it in check-only mode.
- name: Lockfile workspace-version check
run: bun run check:lockfile-versions

- name: Typecheck
run: bun run typecheck

Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,7 @@ This repository publishes Stella shared TypeScript and oxlint configuration pack
- Do not add stricter lint rules without checking the public repos that consume them.
- Prefer typed config helpers over copied JSON fragments.
- Fixture files should prove rule behavior and make unused disables fail when a rule regresses.
- **Never delete or regenerate `bun.lock` to apply package version bumps.** Run
`bun scripts/check-lockfile-workspace-versions.ts --write`, then
`bun install --frozen-lockfile`. The synchronizer is the sole owner of cached
workspace self-versions; dependency-graph changes belong in an explicit install.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"scripts": {
"build": "bun --filter @stll/oxlint-config build",
"changeset": "changeset",
"changeset:version": "changeset version && rm -f bun.lock && bun install",
"changeset:version": "changeset version && bun scripts/check-lockfile-workspace-versions.ts --write && bun install --frozen-lockfile",
"check:lockfile-versions": "bun scripts/check-lockfile-workspace-versions.ts",
"typecheck": "bun --filter @stll/oxlint-config typecheck",
"test": "bun test",
"test:rust-lints": "cd rust-lints/stella_lints && cargo test",
Expand Down
71 changes: 71 additions & 0 deletions scripts/bun-lock-workspace-versions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";

import packageJson from "../package.json";
import { syncWorkspaceVersions } from "./lib/bun-lock-workspace-versions";

const fixture = `{
"lockfileVersion": 1,
"workspaces": {
"packages/core": {
"name": "@stll/core",
"version": "1.0.0",
"dependencies": { "version": "do-not-touch" },
},
"packages/escaped\\u002dname": { "version": "2.0.0" },
},
"packages": [{ "version": "also-do-not-touch" }],
}\n`;

describe("bun.lock workspace self-version synchronization", () => {
test("changes only the exact workspace version string spans", () => {
const result = syncWorkspaceVersions(
fixture,
new Map([
["packages/core", "1.1.0"],
["packages/escaped-name", "2.1.0"],
]),
);

expect(result.mismatches).toHaveLength(2);
expect(result.text).toBe(
fixture
.replace('"version": "1.0.0"', '"version": "1.1.0"')
.replace('"version": "2.0.0"', '"version": "2.1.0"'),
);
expect(result.text).toContain('"version": "do-not-touch"');
expect(result.text).toContain('"version": "also-do-not-touch"');
});

test("version-up/version-down is byte-identical", () => {
const up = syncWorkspaceVersions(
fixture,
new Map([["packages/core", "1.1.0"]]),
).text;
const down = syncWorkspaceVersions(
up,
new Map([["packages/core", "1.0.0"]]),
).text;

expect(down).toBe(fixture);
});

test("refuses to invent missing workspace structure", () => {
const result = syncWorkspaceVersions(
fixture,
new Map([["packages/missing", "1.0.0"]]),
);

expect(result.text).toBe(fixture);
expect(result.mismatches).toEqual([
{ workspace: "packages/missing", expected: "1.0.0", actual: null },
]);
});

test("release versioning cannot delete or regenerate bun.lock", () => {
const command = packageJson.scripts["changeset:version"];

expect(command).not.toMatch(/\brm\b/);
expect(command).toContain("check-lockfile-workspace-versions.ts --write");
expect(command).toEndWith("bun install --frozen-lockfile");
});
});
107 changes: 107 additions & 0 deletions scripts/check-lockfile-workspace-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env bun
// CI gate: catches stale workspace `"version"` fields cached in bun.lock.
//
// Why this exists: `bun install` (even non-frozen) does NOT rewrite the
// `"version"` field bun.lock records for an already-present workspace entry
// when only that package's own package.json version changed — it only
// re-resolves dependency ranges. `bun install --frozen-lockfile` (what CI
// runs everywhere) validates that the dependency graph still satisfies the
// lockfile; it does not compare workspace self-versions either. So neither
// the normal install path nor the frozen-lockfile CI gate ever notices a
// workspace's recorded version drifting behind its package.json — and
// `bun pm pack` reads the *lockfile's* cached version when resolving
// workspace:* ranges for publish, so a stale entry silently ships a wrong
// dependency range (see the @stll/docx-core ^0.4.0-vs-0.5.0 incident this
// script was added to prevent).
//
// This script is the single owner of workspace self-version synchronization:
// check-only by default for CI, or byte-preserving repair with `--write`.

import { readdir } from "node:fs/promises";
import { join } from "node:path";

import { syncWorkspaceVersions } from "./lib/bun-lock-workspace-versions";

const ROOT = join(import.meta.dirname, "..");

const readJson = async (path: string): Promise<Record<string, unknown>> =>
JSON.parse(await Bun.file(path).text());

const packagesDir = join(ROOT, "packages");
const entries = await readdir(packagesDir, { withFileTypes: true });
const workspaceDirs = entries
.filter((entry) => entry.isDirectory())
.map((entry) => `packages/${entry.name}`)
.sort();

const lockText = await Bun.file(join(ROOT, "bun.lock")).text();

const args = process.argv.slice(2);
const invalidArgs = args.filter((arg) => arg !== "--write");
if (invalidArgs.length > 0 || args.length > 1) {
throw new Error(
"Usage: bun scripts/check-lockfile-workspace-versions.ts [--write]",
);
}
const write = args[0] === "--write";
const expectedVersions = new Map<string, string>();
const packageNames = new Map<string, string>();

for (const workspaceDir of workspaceDirs) {
// A directory under packages/ is not necessarily a real workspace: skip
// it (rather than crash the guard) if its package.json is missing or
// fails to parse.
const pkg = await readJson(join(ROOT, workspaceDir, "package.json")).catch(
() => null,
);
if (pkg === null) continue;
const name = pkg.name;
const version = pkg.version;
if (typeof name !== "string" || typeof version !== "string") continue;

expectedVersions.set(workspaceDir, version);
packageNames.set(workspaceDir, name);
}

const result = syncWorkspaceVersions(lockText, expectedVersions);
const unrepairable = result.mismatches.filter(({ actual }) => actual === null);

if (write && unrepairable.length === 0 && result.text !== lockText) {
await Bun.write(join(ROOT, "bun.lock"), result.text);
console.log(
`bun.lock workspace-version sync: updated ${result.mismatches.length} workspace(s).`,
);
process.exit(0);
}

const mismatches = result.mismatches.map(({ workspace, expected, actual }) =>
actual === null
? `${packageNames.get(workspace)} (${workspace}): no writable bun.lock version entry found`
: `${packageNames.get(workspace)} (${workspace}): package.json is ${expected}, bun.lock has ${actual}`,
);

if (mismatches.length > 0) {
console.error(
[
"bun.lock workspace-version drift detected:",
"",
...mismatches.map((line) => ` - ${line}`),
"",
write
? "The lockfile shape is incomplete; workspace entries must exist before they can be synchronized."
: "A plain `bun install` will not fix cached workspace self-versions. Synchronize them with:",
"",
" bun scripts/check-lockfile-workspace-versions.ts --write",
" bun install --frozen-lockfile",
"",
"Then commit the refreshed bun.lock.",
].join("\n"),
);
process.exit(1);
}

console.log(
write
? "bun.lock workspace-version sync: already current. OK."
: "bun.lock workspace-version check: all workspace versions match. OK.",
);
175 changes: 175 additions & 0 deletions scripts/lib/bun-lock-workspace-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
type JsonNode =
| { kind: "object"; properties: Map<string, JsonNode> }
| { kind: "string"; start: number; end: number; value: string }
| { kind: "other" };

export type WorkspaceVersionMismatch = {
workspace: string;
expected: string;
actual: string | null;
};

export type WorkspaceVersionSyncResult = {
text: string;
mismatches: WorkspaceVersionMismatch[];
};

/**
* Synchronize only workspace self-version string values in a Bun lockfile.
*
* Bun does not refresh these cached values when package.json versions change.
* Re-generating the lockfile fixes them, but also needlessly rewrites the
* resolved dependency graph. This parser finds the exact JSON string spans and
* changes nothing else, making version-up/version-down operations reversible.
*/
export const syncWorkspaceVersions = (
source: string,
expectedVersions: ReadonlyMap<string, string>,
): WorkspaceVersionSyncResult => {
let cursor = 0;

const fail = (message: string): never => {
throw new Error(`Invalid bun.lock at byte ${cursor}: ${message}`);
};

const skipTrivia = (): void => {
while (cursor < source.length) {
if (/\s/.test(source[cursor] ?? "")) {
cursor += 1;
continue;
}
if (source.startsWith("//", cursor)) {
const newline = source.indexOf("\n", cursor + 2);
cursor = newline === -1 ? source.length : newline + 1;
continue;
}
if (source.startsWith("/*", cursor)) {
const end = source.indexOf("*/", cursor + 2);
if (end === -1) fail("unterminated block comment");
cursor = end + 2;
continue;
}
break;
}
};

const parseString = (): Extract<JsonNode, { kind: "string" }> => {
skipTrivia();
const start = cursor;
if (source[cursor] !== '"') fail("expected a string");
cursor += 1;
while (cursor < source.length) {
const char = source[cursor];
if (char === "\\") {
cursor += 2;
continue;
}
cursor += 1;
if (char === '"') {
const raw = source.slice(start, cursor);
return {
kind: "string",
start,
end: cursor,
value: JSON.parse(raw) as string,
};
}
}
return fail("unterminated string");
};

const parseValue = (): JsonNode => {
skipTrivia();
if (source[cursor] === '"') return parseString();
if (source[cursor] === "{") return parseObject();
if (source[cursor] === "[") {
cursor += 1;
skipTrivia();
while (source[cursor] !== "]") {
parseValue();
skipTrivia();
if (source[cursor] === ",") {
cursor += 1;
skipTrivia();
if (source[cursor] === "]") break;
} else if (source[cursor] !== "]") {
fail("expected ',' or ']' in array");
}
}
if (source[cursor] !== "]") fail("unterminated array");
cursor += 1;
return { kind: "other" };
}

const start = cursor;
while (cursor < source.length && !/[\s,}\]]/.test(source[cursor] ?? ""))
cursor += 1;
if (cursor === start) fail("expected a value");
return { kind: "other" };
};

const parseObject = (): Extract<JsonNode, { kind: "object" }> => {
skipTrivia();
if (source[cursor] !== "{") fail("expected an object");
cursor += 1;
const properties = new Map<string, JsonNode>();
skipTrivia();
while (source[cursor] !== "}") {
const key = parseString().value;
skipTrivia();
if (source[cursor] !== ":") fail("expected ':' after object key");
cursor += 1;
properties.set(key, parseValue());
skipTrivia();
if (source[cursor] === ",") {
cursor += 1;
skipTrivia();
if (source[cursor] === "}") break;
} else if (source[cursor] !== "}") {
fail("expected ',' or '}' in object");
}
}
if (source[cursor] !== "}") fail("unterminated object");
cursor += 1;
return { kind: "object", properties };
};

const root = parseValue();
skipTrivia();
if (cursor !== source.length) fail("unexpected content after root value");
if (root.kind !== "object")
throw new Error("Invalid bun.lock: root must be an object");
const workspaces = root.properties.get("workspaces");
if (workspaces?.kind !== "object") {
throw new Error(
"Invalid bun.lock: root workspaces property must be an object",
);
}

const mismatches: WorkspaceVersionMismatch[] = [];
const replacements: Array<{ start: number; end: number; value: string }> = [];
for (const [workspace, expected] of expectedVersions) {
const entry = workspaces.properties.get(workspace);
const version =
entry?.kind === "object" ? entry.properties.get("version") : undefined;
const actual = version?.kind === "string" ? version.value : null;
if (actual === expected) continue;
mismatches.push({ workspace, expected, actual });
if (version?.kind === "string") {
replacements.push({
start: version.start,
end: version.end,
value: JSON.stringify(expected),
});
}
}

let text = source;
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
text =
text.slice(0, replacement.start) +
replacement.value +
text.slice(replacement.end);
}
return { text, mismatches };
};
Loading