Skip to content

Commit 3413dad

Browse files
feat: add --frozen flag to run command for CI validation (#1211)
1 parent adb1c34 commit 3413dad

File tree

3 files changed

+186
-0
lines changed

3 files changed

+186
-0
lines changed

.changeset/add-run-frozen-flag.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
"lingo.dev": minor
3+
---
4+
Add `--frozen` mode to validate translations without writing changes.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import chalk from "chalk";
2+
import { Listr } from "listr2";
3+
import _ from "lodash";
4+
import { minimatch } from "minimatch";
5+
6+
import { colors } from "../../constants";
7+
import { CmdRunContext } from "./_types";
8+
import { commonTaskRendererOptions } from "./_const";
9+
import { getBuckets } from "../../utils/buckets";
10+
import createBucketLoader from "../../loaders";
11+
import { createDeltaProcessor } from "../../utils/delta";
12+
import { resolveOverriddenLocale } from "@lingo.dev/_spec";
13+
14+
export default async function frozen(input: CmdRunContext) {
15+
console.log(chalk.hex(colors.orange)("[Frozen]"));
16+
17+
// Prepare filtered buckets consistently with the planning step
18+
let buckets = getBuckets(input.config!);
19+
if (input.flags.bucket?.length) {
20+
buckets = buckets.filter((b) => input.flags.bucket!.includes(b.type));
21+
}
22+
23+
if (input.flags.file?.length) {
24+
buckets = buckets
25+
.map((bucket: any) => {
26+
const paths = bucket.paths.filter((p: any) =>
27+
input.flags.file!.some(
28+
(f) => p.pathPattern.includes(f) || minimatch(p.pathPattern, f),
29+
),
30+
);
31+
return { ...bucket, paths };
32+
})
33+
.filter((bucket: any) => bucket.paths.length > 0);
34+
}
35+
36+
const _sourceLocale = input.flags.sourceLocale || input.config!.locale.source;
37+
const _targetLocales =
38+
input.flags.targetLocale || input.config!.locale.targets;
39+
40+
return new Listr<CmdRunContext>(
41+
[
42+
{
43+
title: "Setting up localization cache",
44+
task: async (_ctx, task) => {
45+
const checkLockfileProcessor = createDeltaProcessor("");
46+
const lockfileExists =
47+
await checkLockfileProcessor.checkIfLockExists();
48+
if (!lockfileExists) {
49+
for (const bucket of buckets) {
50+
for (const bucketPath of bucket.paths) {
51+
const resolvedSourceLocale = resolveOverriddenLocale(
52+
_sourceLocale,
53+
bucketPath.delimiter,
54+
);
55+
56+
const loader = createBucketLoader(
57+
bucket.type,
58+
bucketPath.pathPattern,
59+
{
60+
defaultLocale: resolvedSourceLocale,
61+
injectLocale: bucket.injectLocale,
62+
formatter: input.config!.formatter,
63+
},
64+
bucket.lockedKeys,
65+
bucket.lockedPatterns,
66+
bucket.ignoredKeys,
67+
);
68+
loader.setDefaultLocale(resolvedSourceLocale);
69+
await loader.init();
70+
71+
const sourceData = await loader.pull(_sourceLocale);
72+
73+
const delta = createDeltaProcessor(bucketPath.pathPattern);
74+
const checksums = await delta.createChecksums(sourceData);
75+
await delta.saveChecksums(checksums);
76+
}
77+
}
78+
task.title = "Localization cache initialized";
79+
} else {
80+
task.title = "Localization cache loaded";
81+
}
82+
},
83+
},
84+
{
85+
title: "Validating frozen state",
86+
enabled: () => !!input.flags.frozen,
87+
task: async (_ctx, task) => {
88+
for (const bucket of buckets) {
89+
for (const bucketPath of bucket.paths) {
90+
const resolvedSourceLocale = resolveOverriddenLocale(
91+
_sourceLocale,
92+
bucketPath.delimiter,
93+
);
94+
95+
const loader = createBucketLoader(
96+
bucket.type,
97+
bucketPath.pathPattern,
98+
{
99+
defaultLocale: resolvedSourceLocale,
100+
returnUnlocalizedKeys: true,
101+
injectLocale: bucket.injectLocale,
102+
},
103+
bucket.lockedKeys,
104+
);
105+
loader.setDefaultLocale(resolvedSourceLocale);
106+
await loader.init();
107+
108+
const { unlocalizable: srcUnlocalizable, ...src } =
109+
await loader.pull(_sourceLocale);
110+
111+
const delta = createDeltaProcessor(bucketPath.pathPattern);
112+
const sourceChecksums = await delta.createChecksums(src);
113+
const savedChecksums = await delta.loadChecksums();
114+
115+
const updatedSourceData = _.pickBy(
116+
src,
117+
(value, key) => sourceChecksums[key] !== savedChecksums[key],
118+
);
119+
if (Object.keys(updatedSourceData).length > 0) {
120+
throw new Error(
121+
`Localization data has changed; please update i18n.lock or run without --frozen. Details: Source file has been updated.`,
122+
);
123+
}
124+
125+
for (const _tgt of _targetLocales) {
126+
const resolvedTargetLocale = resolveOverriddenLocale(
127+
_tgt,
128+
bucketPath.delimiter,
129+
);
130+
const { unlocalizable: tgtUnlocalizable, ...tgt } =
131+
await loader.pull(resolvedTargetLocale);
132+
133+
const missingKeys = _.difference(
134+
Object.keys(src),
135+
Object.keys(tgt),
136+
);
137+
if (missingKeys.length > 0) {
138+
throw new Error(
139+
`Localization data has changed; please update i18n.lock or run without --frozen. Details: Target file is missing translations.`,
140+
);
141+
}
142+
143+
const extraKeys = _.difference(
144+
Object.keys(tgt),
145+
Object.keys(src),
146+
);
147+
if (extraKeys.length > 0) {
148+
throw new Error(
149+
`Localization data has changed; please update i18n.lock or run without --frozen. Details: Target file has extra translations not present in the source file.`,
150+
);
151+
}
152+
153+
const unlocalizableDataDiff = !_.isEqual(
154+
srcUnlocalizable,
155+
tgtUnlocalizable,
156+
);
157+
if (unlocalizableDataDiff) {
158+
throw new Error(
159+
`Localization data has changed; please update i18n.lock or run without --frozen. Details: Unlocalizable data (such as booleans, dates, URLs, etc.) do not match.`,
160+
);
161+
}
162+
}
163+
}
164+
}
165+
166+
task.title = "No lockfile updates required";
167+
},
168+
},
169+
],
170+
{
171+
rendererOptions: commonTaskRendererOptions,
172+
},
173+
).run(input);
174+
}

packages/cli/src/cli/cmd/run/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import plan from "./plan";
88
import execute from "./execute";
99
import watch from "./watch";
1010
import { CmdRunContext, flagsSchema } from "./_types";
11+
import frozen from "./frozen";
1112
import {
1213
renderClear,
1314
renderSpacer,
@@ -89,6 +90,10 @@ export default new Command()
8990
"--force",
9091
"Force re-translation of all keys, bypassing change detection. Useful when you want to regenerate translations with updated AI models or translation settings",
9192
)
93+
.option(
94+
"--frozen",
95+
"Validate translations are up-to-date without making changes - fails if source files, target files, or lockfile are out of sync. Ideal for CI/CD to ensure translation consistency before deployment",
96+
)
9297
.option(
9398
"--api-key <api-key>",
9499
"Override API key from settings or environment variables",
@@ -144,6 +149,9 @@ export default new Command()
144149
await plan(ctx);
145150
await renderSpacer();
146151

152+
await frozen(ctx);
153+
await renderSpacer();
154+
147155
await execute(ctx);
148156
await renderSpacer();
149157

0 commit comments

Comments
 (0)