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
9 changes: 9 additions & 0 deletions .changeset/lockfile-merge-missing-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"lingo.dev": minor
---

Change the default behavior of `lingo.dev lockfile` so it fills in missing `i18n.lock` sections additively instead of bailing out. Without `--force`, sections that already contain checksums are left untouched (preserving the divergence signal that `--frozen` relies on), and any pathPattern whose section is missing or empty is populated from the current source. `--force` still rebuilds the entire lock as before.

Update the `--frozen` validation error to point users at the recovery command: messages now read "Run `lingo.dev lockfile` to refresh i18n.lock, or run without --frozen."

Together these surface a fix for the false-positive `--frozen` failures that PR #2091 did not cover (new files under `**` globs, new buckets, prior `--target-locale` runs that don't write checksums, and pre-existing empty lock sections).
206 changes: 206 additions & 0 deletions packages/cli/src/cli/cmd/lockfile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import YAML from "yaml";
import { MD5 } from "object-hash";

// The repo's vitest setup (tests/mock-storage.ts) globally mocks fs/promises
// to read/write from an in-memory store. These tests need real disk I/O so the
// JSON bucket loader can `fs.readFile` source files written by the test.
vi.unmock("fs/promises");

let lockfileCmd: any;

async function freshCmd() {
vi.resetModules();
// Dynamic import so each test gets a fresh Commander instance.
const mod = await import("./lockfile");
lockfileCmd = mod.default;
}

async function runLockfile(args: string[] = []) {
await lockfileCmd.parseAsync(["node", "lockfile", ...args]);
}

function writeJson(filePath: string, content: any) {
const dir = path.dirname(filePath);
if (dir !== "." && !fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(content, null, 2));
}

function readLock(): { version: number; checksums: Record<string, Record<string, string>> } {
const raw = fs.readFileSync("i18n.lock", "utf-8");
return YAML.parse(raw);
}

function writeLock(data: any) {
fs.writeFileSync("i18n.lock", YAML.stringify(data));
}

function makeConfig(buckets: any) {
return {
$schema: "https://lingo.dev/schema/i18n.json",
version: 0,
locale: { source: "en", targets: ["es"] },
buckets,
};
}

describe("cmd lockfile (merge-default)", () => {
let tmpDir: string;
let originalCwd: string;

beforeEach(async () => {
originalCwd = process.cwd();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lingo-lockfile-cmd-"));
process.chdir(tmpDir);
await freshCmd();
});

afterEach(() => {
process.chdir(originalCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("fills in a missing section while leaving an existing section byte-identical", async () => {
writeJson("i18n.json", makeConfig({
json: {
include: ["src/a/[locale].json", "src/b/[locale].json"],
},
}));
writeJson("src/a/en.json", { greeting: "hello-a" });
writeJson("src/a/es.json", { greeting: "hola-a" });
writeJson("src/b/en.json", { greeting: "hello-b" });
writeJson("src/b/es.json", { greeting: "hola-b" });

// Pre-populate lock with only section A.
const sectionAKey = MD5("src/a/[locale].json");
const sectionAExisting = { greeting: "preexisting-hash-A" };
writeLock({
version: 1,
checksums: { [sectionAKey]: sectionAExisting },
});

await runLockfile();

const lock = readLock();
const sectionBKey = MD5("src/b/[locale].json");

expect(lock.checksums[sectionAKey]).toEqual(sectionAExisting);
expect(lock.checksums[sectionBKey]).toBeDefined();
expect(lock.checksums[sectionBKey].greeting).toBe(MD5("hello-b"));
});

it("populates sections when the lock exists but checksums map is empty", async () => {
writeJson("i18n.json", makeConfig({
json: { include: ["src/[locale].json"] },
}));
writeJson("src/en.json", { greeting: "hello" });
writeJson("src/es.json", { greeting: "hola" });

writeLock({ version: 1, checksums: {} });

await runLockfile();

const lock = readLock();
const key = MD5("src/[locale].json");
expect(lock.checksums[key]).toBeDefined();
expect(lock.checksums[key].greeting).toBe(MD5("hello"));
});

it("does NOT overwrite a section whose source value changed (preserves the divergence signal for --frozen)", async () => {
writeJson("i18n.json", makeConfig({
json: { include: ["src/[locale].json"] },
}));
writeJson("src/en.json", { greeting: "hello-new" });
writeJson("src/es.json", { greeting: "hola-stale" });

const key = MD5("src/[locale].json");
const stale = { greeting: MD5("hello-old") };
writeLock({ version: 1, checksums: { [key]: stale } });

await runLockfile();

const lock = readLock();
// Section is byte-equivalent (parsed) to before: stale checksum preserved.
expect(lock.checksums[key]).toEqual(stale);
// Sanity: stale entry is genuinely diverged from current source, so a
// subsequent --frozen run would still throw "Source file has been updated"
// (validated by frozen.ts:127-131 via checksum mismatch).
expect(lock.checksums[key].greeting).not.toBe(MD5("hello-new"));
});

it("--force rebuilds existing sections (regression guard)", async () => {
writeJson("i18n.json", makeConfig({
json: { include: ["src/[locale].json"] },
}));
writeJson("src/en.json", { greeting: "hello-new" });
writeJson("src/es.json", { greeting: "hola" });

const key = MD5("src/[locale].json");
const stale = { greeting: MD5("hello-old") };
writeLock({ version: 1, checksums: { [key]: stale } });

await runLockfile(["--force"]);

const lock = readLock();
expect(lock.checksums[key].greeting).toBe(MD5("hello-new"));
});

it("adds only new pathPatterns under a ** recursive glob across two runs", async () => {
writeJson("i18n.json", makeConfig({
json: { include: ["src/[locale]/**/*.json"] },
}));

writeJson("src/en/feature-a.json", { greeting: "hello-a" });
writeJson("src/es/feature-a.json", { greeting: "hola-a" });
writeJson("src/en/feature-b.json", { greeting: "hello-b" });
writeJson("src/es/feature-b.json", { greeting: "hola-b" });

await runLockfile();

const lockAfterFirst = readLock();
const featureAPattern = "src/[locale]/feature-a.json";
const featureBPattern = "src/[locale]/feature-b.json";
const featureCPattern = "src/[locale]/feature-c.json";
const aKey = MD5(featureAPattern);
const bKey = MD5(featureBPattern);
const cKey = MD5(featureCPattern);

expect(lockAfterFirst.checksums[aKey]).toBeDefined();
expect(lockAfterFirst.checksums[bKey]).toBeDefined();
expect(lockAfterFirst.checksums[cKey]).toBeUndefined();

const sectionABefore = lockAfterFirst.checksums[aKey];
const sectionBBefore = lockAfterFirst.checksums[bKey];

// Add a third file.
writeJson("src/en/feature-c.json", { greeting: "hello-c" });
writeJson("src/es/feature-c.json", { greeting: "hola-c" });

await freshCmd();
await runLockfile();

const lockAfterSecond = readLock();
expect(lockAfterSecond.checksums[aKey]).toEqual(sectionABefore);
expect(lockAfterSecond.checksums[bKey]).toEqual(sectionBBefore);
expect(lockAfterSecond.checksums[cKey]).toBeDefined();
expect(lockAfterSecond.checksums[cKey].greeting).toBe(MD5("hello-c"));
});

it("exits gracefully when i18n.json is missing (does not crash)", async () => {
// Intentionally no i18n.json. A stale lock alone simulates the regression
// path from the reviewer: lock exists, config absent. Old code printed a
// warning; the merge-default code must not crash via getBuckets(null).
writeLock({ version: 1, checksums: {} });

await expect(runLockfile()).resolves.not.toThrow();

// Lock file is untouched.
const lock = readLock();
expect(lock.checksums).toEqual({});
});
});
105 changes: 73 additions & 32 deletions packages/cli/src/cli/cmd/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,44 +22,85 @@ export default new Command()
const ora = Ora();

const lockfileHelper = createLockfileHelper();
if (lockfileHelper.isLockfileExists() && !flags.force) {
const lockExisted = lockfileHelper.isLockfileExists();
const i18nConfig = getConfig();

if (!i18nConfig) {
ora.warn(
`Lockfile won't be created because it already exists. Use --force to overwrite.`,
"No i18n.json found in the current directory. Create one before running `lingo.dev lockfile`.",
);
} else {
const i18nConfig = getConfig();
const buckets = getBuckets(i18nConfig!);
return;
}

const buckets = getBuckets(i18nConfig);

let addedCount = 0;
let skippedCount = 0;
let replacedCount = 0;

for (const bucket of buckets) {
for (const bucketConfig of bucket.paths) {
const pathPattern = bucketConfig.pathPattern;

if (!flags.force && lockfileHelper.hasSourceData(pathPattern)) {
console.log(` skipped (already populated): ${pathPattern}`);
skippedCount++;
continue;
}

const sourceLocale = resolveOverriddenLocale(
i18nConfig.locale.source,
bucketConfig.delimiter,
);
const bucketLoader = createBucketLoader(
bucket.type,
pathPattern,
{
defaultLocale: sourceLocale,
formatter: i18nConfig.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
bucket.ignoredKeys,
bucket.preservedKeys,
bucket.localizableKeys,
);
bucketLoader.setDefaultLocale(sourceLocale);

for (const bucket of buckets) {
for (const bucketConfig of bucket.paths) {
const sourceLocale = resolveOverriddenLocale(
i18nConfig!.locale.source,
bucketConfig.delimiter,
);
const bucketLoader = createBucketLoader(
bucket.type,
bucketConfig.pathPattern,
{
defaultLocale: sourceLocale,
formatter: i18nConfig!.formatter,
keyColumn: bucket.keyColumn,
},
bucket.lockedKeys,
bucket.lockedPatterns,
bucket.ignoredKeys,
bucket.preservedKeys,
bucket.localizableKeys,
);
bucketLoader.setDefaultLocale(sourceLocale);
const sourceData = await bucketLoader.pull(sourceLocale);
const sectionExisted = lockfileHelper.hasSourceData(pathPattern);
lockfileHelper.registerSourceData(pathPattern, sourceData);

const sourceData = await bucketLoader.pull(sourceLocale);
lockfileHelper.registerSourceData(
bucketConfig.pathPattern,
sourceData,
);
if (sectionExisted) {
console.log(` replaced (--force): ${pathPattern}`);
replacedCount++;
} else {
console.log(` added: ${pathPattern}`);
addedCount++;
}
}
ora.succeed("Lockfile created");
}

const summary = [
addedCount > 0 ? `added ${addedCount}` : null,
replacedCount > 0 ? `replaced ${replacedCount}` : null,
skippedCount > 0 ? `skipped ${skippedCount}` : null,
]
.filter(Boolean)
.join(", ");

if (!lockExisted) {
ora.succeed(`Lockfile created (${summary || "no sections"})`);
} else if (flags.force) {
ora.succeed(`Lockfile rebuilt (${summary || "no sections"})`);
} else if (addedCount > 0) {
ora.succeed(`Lockfile updated (${summary})`);
} else {
ora.succeed(
`Nothing to refresh (${summary || "no sections"}). ` +
`Run \`lingo.dev run\` if source files have changed, or use --force to overwrite existing checksums.`,
);
}
});

Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/cli/cmd/run/frozen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export default async function frozen(input: CmdRunContext) {
);
if (Object.keys(updatedSourceData).length > 0) {
throw new Error(
`Localization data has changed; please update i18n.lock or run without --frozen. Details: Source file has been updated.`,
`Localization data has changed. Run \`lingo.dev lockfile\` to refresh i18n.lock, or run without --frozen. Details: Source file has been updated.`,
);
}

Expand All @@ -144,7 +144,7 @@ export default async function frozen(input: CmdRunContext) {
);
if (missingKeys.length > 0) {
throw new Error(
`Localization data has changed; please update i18n.lock or run without --frozen. Details: Target file is missing translations.`,
`Localization data has changed. Run \`lingo.dev lockfile\` to refresh i18n.lock, or run without --frozen. Details: Target file is missing translations.`,
);
}

Expand All @@ -154,7 +154,7 @@ export default async function frozen(input: CmdRunContext) {
);
if (extraKeys.length > 0) {
throw new Error(
`Localization data has changed; please update i18n.lock or run without --frozen. Details: Target file has extra translations not present in the source file.`,
`Localization data has changed. Run \`lingo.dev lockfile\` to refresh i18n.lock, or run without --frozen. Details: Target file has extra translations not present in the source file.`,
);
}

Expand All @@ -164,7 +164,7 @@ export default async function frozen(input: CmdRunContext) {
);
if (unlocalizableDataDiff) {
throw new Error(
`Localization data has changed; please update i18n.lock or run without --frozen. Details: Unlocalizable data (such as booleans, dates, URLs, etc.) do not match.`,
`Localization data has changed. Run \`lingo.dev lockfile\` to refresh i18n.lock, or run without --frozen. Details: Unlocalizable data (such as booleans, dates, URLs, etc.) do not match.`,
);
}
}
Expand Down
Loading
Loading