diff --git a/.changeset/lockfile-merge-missing-sections.md b/.changeset/lockfile-merge-missing-sections.md new file mode 100644 index 000000000..1e5ebec75 --- /dev/null +++ b/.changeset/lockfile-merge-missing-sections.md @@ -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). diff --git a/packages/cli/src/cli/cmd/lockfile.spec.ts b/packages/cli/src/cli/cmd/lockfile.spec.ts new file mode 100644 index 000000000..5f9e4acd1 --- /dev/null +++ b/packages/cli/src/cli/cmd/lockfile.spec.ts @@ -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> } { + 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({}); + }); +}); diff --git a/packages/cli/src/cli/cmd/lockfile.ts b/packages/cli/src/cli/cmd/lockfile.ts index 0efa7e56f..afb89ecf4 100644 --- a/packages/cli/src/cli/cmd/lockfile.ts +++ b/packages/cli/src/cli/cmd/lockfile.ts @@ -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.`, + ); } }); diff --git a/packages/cli/src/cli/cmd/run/frozen.ts b/packages/cli/src/cli/cmd/run/frozen.ts index 23fe7c611..3590c33af 100644 --- a/packages/cli/src/cli/cmd/run/frozen.ts +++ b/packages/cli/src/cli/cmd/run/frozen.ts @@ -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.`, ); } @@ -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.`, ); } @@ -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.`, ); } @@ -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.`, ); } } diff --git a/packages/cli/src/cli/utils/lockfile.test.ts b/packages/cli/src/cli/utils/lockfile.test.ts index 1d5d6d055..d21edd671 100644 --- a/packages/cli/src/cli/utils/lockfile.test.ts +++ b/packages/cli/src/cli/utils/lockfile.test.ts @@ -1,6 +1,9 @@ -import { describe, it, expect } from "vitest"; -import { deduplicateLockfileYaml } from "./lockfile"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; import YAML from "yaml"; +import { createLockfileHelper, deduplicateLockfileYaml } from "./lockfile"; describe("deduplicateLockfileYaml", () => { it("should return unchanged content when there are no duplicates", () => { @@ -265,3 +268,53 @@ checksums: expect(Object.keys(parsed.checksums.pathHash1)).toHaveLength(4); }); }); + +describe("createLockfileHelper.hasSourceData", () => { + let tmpDir: string; + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lingo-lock-helper-")); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns false when the lockfile is absent", () => { + const helper = createLockfileHelper(); + expect(helper.hasSourceData("src/[locale].json")).toBe(false); + }); + + it("returns false when the section is absent", () => { + const helper = createLockfileHelper(); + helper.registerSourceData("src/a/[locale].json", { greeting: "hello" }); + expect(helper.hasSourceData("src/b/[locale].json")).toBe(false); + }); + + it("returns false when the section exists but is empty", () => { + const lockfileContent = YAML.stringify({ + version: 1, + checksums: { + deadbeef: {}, + }, + }); + fs.writeFileSync(path.join(tmpDir, "i18n.lock"), lockfileContent); + + const helper = createLockfileHelper(); + // The empty-section md5 key won't match this pathPattern, but we also want + // to confirm that even for the matching key the empty check holds. Build a + // section keyed by the real md5 of the pattern: + helper.registerSourceData("src/[locale].json", {}); + expect(helper.hasSourceData("src/[locale].json")).toBe(false); + }); + + it("returns true when the section has at least one checksum", () => { + const helper = createLockfileHelper(); + helper.registerSourceData("src/[locale].json", { greeting: "hello" }); + expect(helper.hasSourceData("src/[locale].json")).toBe(true); + }); +}); diff --git a/packages/cli/src/cli/utils/lockfile.ts b/packages/cli/src/cli/utils/lockfile.ts index 059a89721..a57181aef 100644 --- a/packages/cli/src/cli/utils/lockfile.ts +++ b/packages/cli/src/cli/utils/lockfile.ts @@ -11,6 +11,12 @@ export function createLockfileHelper() { const lockfilePath = _getLockfilePath(); return fs.existsSync(lockfilePath); }, + hasSourceData: (pathPattern: string): boolean => { + const lockfile = _loadLockfile(); + const sectionKey = MD5(pathPattern); + const section = lockfile.checksums[sectionKey]; + return !!section && Object.keys(section).length > 0; + }, registerSourceData: ( pathPattern: string, sourceData: Record,