@aws/nx-plugin 1.0.0-rc.67 → 1.0.0-rc.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/migrations.json +6 -1
  2. package/package.json +1 -1
  3. package/src/migrations/latest/translate-script-whole-file-writes/files/build-system-prompt.ts.fixture +23 -0
  4. package/src/migrations/latest/translate-script-whole-file-writes/files/build-user-prompt.ts.fixture +96 -0
  5. package/src/migrations/latest/translate-script-whole-file-writes/files/config-path.ts.fixture +1 -0
  6. package/src/migrations/latest/translate-script-whole-file-writes/files/file-to-translate.ts.fixture +10 -0
  7. package/src/migrations/latest/translate-script-whole-file-writes/files/get-files-to-translate.ts.fixture +96 -0
  8. package/src/migrations/latest/translate-script-whole-file-writes/files/header.ts.fixture +15 -0
  9. package/src/migrations/latest/translate-script-whole-file-writes/files/log.ts.fixture +18 -0
  10. package/src/migrations/latest/translate-script-whole-file-writes/files/main-call.ts.fixture +6 -0
  11. package/src/migrations/latest/translate-script-whole-file-writes/files/main.ts.fixture +79 -0
  12. package/src/migrations/latest/translate-script-whole-file-writes/files/project-root.ts.fixture +1 -0
  13. package/src/migrations/latest/translate-script-whole-file-writes/files/run-with-concurrency.ts.fixture +57 -0
  14. package/src/migrations/latest/translate-script-whole-file-writes/files/scripts-dir.ts.fixture +1 -0
  15. package/src/migrations/latest/translate-script-whole-file-writes/files/translate-file-for-language.ts.fixture +61 -0
  16. package/src/migrations/latest/translate-script-whole-file-writes/grit/already-migrated.grit +1 -0
  17. package/src/migrations/latest/translate-script-whole-file-writes/grit/build-system-prompt.grit +6 -0
  18. package/src/migrations/latest/translate-script-whole-file-writes/grit/build-user-prompt.grit +6 -0
  19. package/src/migrations/latest/translate-script-whole-file-writes/grit/config-path.grit +1 -0
  20. package/src/migrations/latest/translate-script-whole-file-writes/grit/file-to-translate.grit +11 -0
  21. package/src/migrations/latest/translate-script-whole-file-writes/grit/get-files-to-translate.grit +7 -0
  22. package/src/migrations/latest/translate-script-whole-file-writes/grit/log.grit +3 -0
  23. package/src/migrations/latest/translate-script-whole-file-writes/grit/main-call.grit +1 -0
  24. package/src/migrations/latest/translate-script-whole-file-writes/grit/main.grit +8 -0
  25. package/src/migrations/latest/translate-script-whole-file-writes/grit/project-root.grit +1 -0
  26. package/src/migrations/latest/translate-script-whole-file-writes/grit/run-with-concurrency.grit +6 -0
  27. package/src/migrations/latest/translate-script-whole-file-writes/grit/translate-file-for-language.grit +6 -0
  28. package/src/migrations/latest/translate-script-whole-file-writes/metadata.json +3 -0
  29. package/src/migrations/latest/translate-script-whole-file-writes/migration.d.ts +2 -0
  30. package/src/migrations/latest/translate-script-whole-file-writes/migration.js +103 -0
  31. package/src/migrations/latest/translate-script-whole-file-writes/migration.js.map +1 -0
  32. package/src/migrations/latest/translate-script-whole-file-writes/test-fixtures/released-script-first.ts.fixture +430 -0
  33. package/src/migrations/latest/translate-script-whole-file-writes/test-fixtures/released-script.ts.fixture +431 -0
  34. package/src/ts/astro-docs/files/translation/scripts/translate.ts.template +269 -148
@@ -8,10 +8,10 @@
8
8
  * <%= pkgMgrCmd %> nx translate <%= fullyQualifiedName %> -- --all
9
9
  * <%= pkgMgrCmd %> nx translate <%= fullyQualifiedName %> # only files changed since last translate commit
10
10
  *
11
- * The driver gathers the set of changed source docs + their git diffs and hands
12
- * them to an agent that has read/write access (scoped to the docs directory)
13
- * via a wrapped version of the built-in `fileEditor` tool. The agent decides
14
- * how to translate each file and writes the output itself.
11
+ * The driver works out which source docs to translate and spawns one Strands
12
+ * agent per (file × target language). Each agent gets the `fileEditor` tool and
13
+ * the paths it needs the source file, the target file, and a diff of what
14
+ * changed then reads, translates and writes the file itself.
15
15
  */
16
16
  import { Command } from 'commander';
17
17
  import fs from 'fs-extra';
@@ -76,9 +76,12 @@ function rejectOutsideDocsDir(event: BeforeToolCallEvent): void {
76
76
  interface FileToTranslate {
77
77
  relativePath: string;
78
78
  sourceAbsPath: string;
79
- sourceContent: string;
80
- /** Empty when this is a newly-added file. */
81
- diff: string;
79
+ /**
80
+ * Path to a file holding the source diff, or undefined when there is no prior
81
+ * translation to update. Passed to the agent as a path rather than inlined
82
+ * into the prompt, so it reads only as much of it as it needs.
83
+ */
84
+ diffPath?: string;
82
85
  }
83
86
 
84
87
  const program = new Command();
@@ -102,36 +105,42 @@ const log = {
102
105
  info: (m: string) => console.log(`[translate] ${m}`),
103
106
  warn: (m: string) => console.warn(`[translate] ${m}`),
104
107
  error: (m: string) => console.error(`[translate] ERROR ${m}`),
105
- verbose: (m: string) =>
106
- options.verbose && console.log(`[translate] ${m}`),
108
+ verbose: (m: string) => options.verbose && console.log(`[translate] ${m}`),
107
109
  };
108
110
 
111
+ /**
112
+ * Diffs live under the docs directory so the agent's file access — which is
113
+ * scoped to that directory — can read them. Removed when the run finishes.
114
+ */
115
+ const DIFF_DIR = path.join(DOCS_DIR, '.translate-diffs');
116
+
117
+ async function writeDiff(relativePath: string, diff: string): Promise<string> {
118
+ const diffPath = path.join(DIFF_DIR, `${relativePath}.diff`);
119
+ await fs.outputFile(diffPath, diff, 'utf-8');
120
+ return diffPath;
121
+ }
122
+
109
123
  /**
110
124
  * Gather the set of source-language files to translate, with their diffs.
111
125
  */
112
126
  async function getFilesToTranslate(): Promise<FileToTranslate[]> {
113
127
  const sourceLangRoot = `${DOCS_DIR}/${config.sourceLanguage}`;
114
- const includePatterns = config.include.map(
115
- (p) => `${sourceLangRoot}/${p}`,
116
- );
117
- const ignorePatterns = config.exclude.map(
118
- (p) => `${sourceLangRoot}/${p}`,
119
- );
128
+ const includePatterns = config.include.map((p) => `${sourceLangRoot}/${p}`);
129
+ const ignorePatterns = config.exclude.map((p) => `${sourceLangRoot}/${p}`);
120
130
 
121
131
  if (options.all) {
122
132
  log.info('Translating all source documentation files');
123
133
  const files = await glob(includePatterns, { ignore: ignorePatterns });
124
- return Promise.all(
125
- files.map(async (file) => ({
126
- relativePath: path.relative(sourceLangRoot, file),
127
- sourceAbsPath: file,
128
- sourceContent: await fs.readFile(file, 'utf-8'),
129
- diff: '',
130
- })),
131
- );
134
+ return files.map((file) => ({
135
+ relativePath: path.relative(sourceLangRoot, file),
136
+ sourceAbsPath: file,
137
+ }));
132
138
  }
133
139
 
134
140
  const git = simpleGit();
141
+ // git reports paths relative to the repo root, which is not necessarily the
142
+ // directory this script runs from.
143
+ const repoRoot = (await git.revparse(['--show-toplevel'])).trim();
135
144
 
136
145
  let currentBranch: string;
137
146
  let mainBranch: string;
@@ -146,9 +155,7 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
146
155
  try {
147
156
  await git.raw(['rev-parse', '--verify', mainBranch]);
148
157
  } catch {
149
- log.warn(
150
- `Could not find "${mainBranch}"; falling back to --all behaviour`,
151
- );
158
+ log.warn(`Could not find "${mainBranch}"; falling back to --all behaviour`);
152
159
  options.all = true;
153
160
  return getFilesToTranslate();
154
161
  }
@@ -162,9 +169,7 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
162
169
  ).all.filter((c) => c.message.includes(TRANSLATION_COMMIT_MESSAGE));
163
170
 
164
171
  const baseCommit =
165
- translationCommits.length > 0
166
- ? translationCommits[0].hash
167
- : mergeBase;
172
+ translationCommits.length > 0 ? translationCommits[0].hash : mergeBase;
168
173
 
169
174
  log.info(
170
175
  translationCommits.length > 0
@@ -173,11 +178,7 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
173
178
  );
174
179
 
175
180
  const diffNames = (
176
- await git.diff([
177
- `${baseCommit}..HEAD`,
178
- '--name-only',
179
- '--diff-filter=d',
180
- ])
181
+ await git.diff([`${baseCommit}..HEAD`, '--name-only', '--diff-filter=d'])
181
182
  )
182
183
  .split('\n')
183
184
  .filter(Boolean);
@@ -185,9 +186,9 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
185
186
  const { files: uncommitted } = await git.status();
186
187
  const uncommittedNames = uncommitted.map((f) => f.path);
187
188
 
188
- const allCandidates = [
189
- ...new Set([...diffNames, ...uncommittedNames]),
190
- ].map((p) => path.resolve(process.cwd(), p));
189
+ const allCandidates = [...new Set([...diffNames, ...uncommittedNames])].map(
190
+ (p) => path.resolve(repoRoot, p),
191
+ );
191
192
 
192
193
  // Filter to files inside the source language dir that match include/exclude
193
194
  const includedGlob = await glob(includePatterns, {
@@ -204,135 +205,202 @@ async function getFilesToTranslate(): Promise<FileToTranslate[]> {
204
205
 
205
206
  return Promise.all(
206
207
  changed.map(async (file) => {
207
- const sourceContent = await fs.readFile(file, 'utf-8');
208
- let diff = '';
209
- try {
210
- diff = await git.diff([
211
- `${baseCommit}..HEAD`,
212
- '--',
213
- path.relative(process.cwd(), file),
214
- ]);
215
- } catch {
216
- // treat as new file
217
- }
208
+ const relativePath = path.relative(sourceLangRoot, file);
209
+ const diff = await git.diff([
210
+ `${baseCommit}..HEAD`,
211
+ '--',
212
+ path.relative(repoRoot, file),
213
+ ]);
218
214
  return {
219
- relativePath: path.relative(sourceLangRoot, file),
215
+ relativePath,
220
216
  sourceAbsPath: file,
221
- sourceContent,
222
- diff,
217
+ diffPath: diff ? await writeDiff(relativePath, diff) : undefined,
223
218
  };
224
219
  }),
225
220
  );
226
221
  }
227
222
 
228
223
  function buildSystemPrompt(targetLang: string): string {
229
- return `You are an expert technical-documentation translator. Your job is to translate a single MDX documentation file from the source locale \`${config.sourceLanguage}\` into the target locale \`${targetLang}\`.
230
-
231
- Both values are locale codes (e.g. ISO 639-1 / BCP-47 style, or common short forms like \`jp\`, \`zh\`, \`pt\`). Interpret them yourself and translate naturally into the language they identify. If a code is ambiguous, prefer the most widely used written form.
232
-
233
- You have one tool available: \`fileEditor\`. Use it to:
234
- - Read the source file (\`command: "view"\`). For large files you can read a portion at a time using \`view_range\`, then request the next range.
235
- - Read the existing translation if one is provided (\`command: "view"\`).
236
- - Write the translated file (\`command: "create"\` — it overwrites).
237
-
238
- Translation rules:
239
- 1. Translate natural-language prose into the target language. Keep technical accuracy.
240
- 2. DO NOT translate:
241
- - Code blocks and inline code (text inside backticks)
242
- - URLs, link paths, and HTML/JSX/MDX tag names and attributes
243
- - \`import\` statements, component names, and frontmatter keys
244
- - Proper names of people, products, or AWS services
245
- 3. Preserve every aspect of the MDX structure exactly: frontmatter delimiters, headings, lists, code blocks, MDX components, JSX, whitespace, blank lines.
246
- 4. For frontmatter:
247
- - Translate only string values for the \`title\` and \`description\` keys.
248
- - Leave \`date\`, \`authors\`, \`template\`, \`slug\`, and all other keys untouched.
249
- 5. If any localised link paths embed the source locale (e.g. \`/${config.sourceLanguage}/foo\`), rewrite them to the target locale (\`/${targetLang}/foo\`).
250
- 6. Efficiency rule: if an existing translation is provided AND a diff is provided, reuse the existing translation verbatim for any section of the document that is NOT touched by the diff. Only retranslate sections that actually changed. Still emit the complete final file.
251
- 7. If no existing translation exists, translate the whole file.
252
- 8. Never wrap the file output in triple backticks.
253
- 9. Always use absolute paths with \`fileEditor\`.
254
-
255
- When the translated file has been written, reply with a one-line summary and stop.`;
256
- }
224
+ return `You are an expert technical-documentation translator. You translate MDX documentation files from the source locale \`${config.sourceLanguage}\` into the target locale \`${targetLang}\`.
257
225
 
258
- function buildUserPrompt(
259
- file: FileToTranslate,
260
- targetLang: string,
261
- ): string {
262
- const targetLangRoot = path.join(DOCS_DIR, targetLang);
263
- const targetAbsPath = path.join(targetLangRoot, file.relativePath);
264
- const existingTranslationExists = fs.existsSync(targetAbsPath);
226
+ Both are locale codes (ISO 639-1 / BCP-47 style, or short forms like \`jp\`, \`zh\`, \`pt\`). Translate naturally into the language the target code identifies.
265
227
 
266
- const diffBlock = file.diff
267
- ? `Git diff showing what changed in the source since the last translation:\n\`\`\`diff\n${file.diff.slice(0, 40_000)}\n\`\`\``
268
- : 'There is no prior diff for this file — treat it as new content and translate in full.';
228
+ Use the \`fileEditor\` tool to read the files you are given and to write your translation. Read a long file in slices with \`view_range\`. Use the absolute paths exactly as given — write to the target path verbatim, never to a variation of it.
269
229
 
270
- const existingBlock = existingTranslationExists
271
- ? `An existing translation for locale \`${targetLang}\` already lives at:\n \`${targetAbsPath}\`\nRead it with \`fileEditor\` (\`command: "view"\`) before writing so you can reuse any sections that have not changed.`
272
- : `No existing translation exists yet — you will create it fresh.`;
230
+ Translate the prose. Everything else is copied from the source exactly as it is:
273
231
 
274
- return `Translate one file from source locale \`${config.sourceLanguage}\` into target locale \`${targetLang}\`.
232
+ - Code blocks. Reproduce each one character for character: the language and \`title\` on the opening fence, and every line of the body. Comments inside code are code — a \`# Creates a new subsegment\` stays in English. Never translate anything between the fences.
233
+ - Inline code, URLs, link paths, \`import\` statements, component names, JSX attributes, and \`<Snippet name="..." />\` values.
234
+ - Product, service, and people's names, and package names such as \`@aws/nx-plugin\` — translate no part of them, even where they appear in prose or in a \`title\`.
235
+ - The MDX structure itself: frontmatter delimiters, heading levels, lists, and the blank lines between blocks. Add none and remove none.
275
236
 
276
- - Source file (read from here): \`${file.sourceAbsPath}\`
277
- - Target file (write the translation here, absolute path): \`${targetAbsPath}\`
237
+ Two things do change:
278
238
 
279
- ${existingBlock}
239
+ - In frontmatter, translate only \`title\` and \`description\`. Leave every other key and value alone. Frontmatter is YAML, so wrap a value in double quotes whenever it would otherwise begin with a character YAML reserves — for example a title starting with \`@aws/nx-plugin\` must be written as \`"@aws/nx-plugin ..."\`.
240
+ - Rewrite link paths that embed the source locale (\`/${config.sourceLanguage}/foo\` becomes \`/${targetLang}/foo\`), and make a \`parentHeading\` match the translated heading it sits under.
280
241
 
281
- ${diffBlock}
242
+ Write the finished file in one \`create\` call, and never with \`str_replace\` — a partial replacement silently duplicates or splices sections. Do not wrap the file in triple backticks.
282
243
 
283
- Steps:
284
- 1. Read the source file. If it is long, read it in slices using \`view_range\`.
285
- 2. If an existing translation is present, read it first so you can reuse unchanged sections.
286
- 3. Write the full translated file to the target path using \`command: "create"\`.
287
- 4. Reply with a single short confirmation line.`;
244
+ When the file is written, reply with a one-line summary and stop.`;
288
245
  }
289
246
 
290
- async function translateFileForLanguage(
291
- file: FileToTranslate,
292
- targetLang: string,
293
- ): Promise<void> {
294
- const targetAbsPath = path.join(
295
- DOCS_DIR,
296
- targetLang,
297
- file.relativePath,
298
- );
299
- const beforeMtimeMs = fs.existsSync(targetAbsPath)
300
- ? (await fs.stat(targetAbsPath)).mtimeMs
301
- : 0;
302
-
303
- const agent = new Agent({
304
- model: new BedrockModel({
305
- modelId: config.modelId,
306
- region: process.env.AWS_REGION ?? config.awsRegion,
307
- maxTokens: 64_000,
308
- temperature: 0.2,
309
- }),
310
- systemPrompt: buildSystemPrompt(targetLang),
311
- tools: [fileEditor],
312
- printer: !!options.verbose,
313
- });
314
- agent.addHook(BeforeToolCallEvent, rejectOutsideDocsDir);
247
+ function buildUserPrompt(file: FileToTranslate, targetLang: string): string {
248
+ const targetAbsPath = path.join(DOCS_DIR, targetLang, file.relativePath);
315
249
 
316
- const result = await agent.invoke(buildUserPrompt(file, targetLang));
250
+ if (!file.diffPath) {
251
+ return `Translate \`${file.sourceAbsPath}\` into locale \`${targetLang}\` and write it to \`${targetAbsPath}\`.
317
252
 
318
- if (result.stopReason !== 'endTurn') {
319
- log.warn(
320
- `agent stopped with reason=${result.stopReason} while translating ${file.relativePath} → ${targetLang} — inspect output above`,
321
- );
253
+ Read the source, then write the whole translated file with \`create\`. Always write it, even if a translation is already there — it may be out of date.`;
322
254
  }
323
255
 
324
- // Sanity check: the target file should now exist and have been written during this run.
325
- if (!fs.existsSync(targetAbsPath)) {
256
+ return `Update the \`${targetLang}\` translation of \`${file.sourceAbsPath}\`.
257
+
258
+ - Source: \`${file.sourceAbsPath}\`
259
+ - Existing translation to update: \`${targetAbsPath}\`
260
+ - Diff of what changed in the source: \`${file.diffPath}\`
261
+
262
+ Read all three. Retranslate only what the diff touched, keep the existing translated wording everywhere else, then write the whole file back with \`create\`.`;
263
+ }
264
+
265
+ const FENCE_RE = /^\s*(?:`{3,}|~{3,})/;
266
+
267
+ /**
268
+ * Split a document into alternating prose and fenced-code segments.
269
+ */
270
+ function splitOnFences(text: string): { prose: string[]; code: string[] } {
271
+ const prose: string[] = [];
272
+ const code: string[] = [];
273
+ let current: string[] = [];
274
+ let inFence = false;
275
+ for (const line of text.split('\n')) {
276
+ if (FENCE_RE.test(line)) {
277
+ (inFence ? code : prose).push(current.concat(line).join('\n'));
278
+ current = [];
279
+ inFence = !inFence;
280
+ continue;
281
+ }
282
+ current.push(line);
283
+ }
284
+ (inFence ? code : prose).push(current.join('\n'));
285
+ return { prose, code };
286
+ }
287
+
288
+ /**
289
+ * YAML frontmatter must still parse after translation. A value that begins with
290
+ * a character YAML reserves — most often a title starting with \`@aws/...\` —
291
+ * breaks the docs build, so reject it here and let the retry quote it.
292
+ */
293
+ function assertFrontmatterIsParseable(text: string): void {
294
+ const lines = text.split('\n');
295
+ if (lines[0]?.trim() !== '---') return;
296
+ for (let i = 1; i < lines.length && lines[i].trim() !== '---'; i++) {
297
+ const value = lines[i].match(/^[A-Za-z0-9_-]+:\s+(\S)/)?.[1];
298
+ if (value && '@*&%!|>'.includes(value)) {
299
+ throw new Error(
300
+ `frontmatter line ${i + 1} starts with the reserved YAML character "${value}" and must be quoted: ${lines[i].trim()}`,
301
+ );
302
+ }
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Copy the source's code blocks over the translated file's. The prompt tells the
308
+ * agent to reproduce them verbatim, but it occasionally translates a comment
309
+ * anyway, which would ship stale or broken code.
310
+ *
311
+ * A differing block count means the translation lost the document's shape —
312
+ * usually a section duplicated or dropped — so this throws rather than stitching
313
+ * mismatched blocks together, letting the caller retry.
314
+ */
315
+ async function restoreCodeBlocks(
316
+ sourceAbsPath: string,
317
+ targetAbsPath: string,
318
+ ): Promise<void> {
319
+ const source = splitOnFences(await fs.readFile(sourceAbsPath, 'utf-8'));
320
+ const target = splitOnFences(await fs.readFile(targetAbsPath, 'utf-8'));
321
+
322
+ if (source.code.length !== target.code.length) {
326
323
  throw new Error(
327
- `agent did not write target file ${targetAbsPath} for ${file.relativePath} → ${targetLang}`,
324
+ `translation has ${target.code.length} code block(s) but the source has ${source.code.length}`,
328
325
  );
329
326
  }
330
- const afterMtimeMs = (await fs.stat(targetAbsPath)).mtimeMs;
331
- if (afterMtimeMs <= beforeMtimeMs) {
332
- log.warn(
333
- `target ${file.relativePath} ${targetLang} was not updated (mtime unchanged) — the agent may have decided no change was needed`,
334
- );
327
+ if (source.code.length === 0) return;
328
+
329
+ const merged = target.prose.flatMap((chunk, i) =>
330
+ i < source.code.length ? [chunk, source.code[i]] : [chunk],
331
+ );
332
+ await fs.writeFile(targetAbsPath, merged.join('\n'), 'utf-8');
333
+ }
334
+
335
+ /**
336
+ * Number of times to retry a single (file x language) translation. Large files
337
+ * occasionally hit transient Bedrock streaming errors; a fresh agent invocation
338
+ * re-reads the files from disk, so retries are idempotent.
339
+ */
340
+ const TRANSLATE_MAX_ATTEMPTS = 4;
341
+
342
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
343
+
344
+ async function translateFileForLanguage(
345
+ file: FileToTranslate,
346
+ targetLang: string,
347
+ ): Promise<void> {
348
+ const targetAbsPath = path.join(DOCS_DIR, targetLang, file.relativePath);
349
+
350
+ let lastError: unknown;
351
+ for (let attempt = 1; attempt <= TRANSLATE_MAX_ATTEMPTS; attempt++) {
352
+ const agent = new Agent({
353
+ model: new BedrockModel({
354
+ modelId: config.modelId,
355
+ region: process.env.AWS_REGION ?? config.awsRegion,
356
+ maxTokens: 64_000,
357
+ temperature: 0.2,
358
+ // Translating a long file can stream for several minutes, well past the
359
+ // AWS SDK's two-minute default socket timeout.
360
+ clientConfig: { requestHandler: { requestTimeout: 15 * 60_000 } },
361
+ }),
362
+ systemPrompt: buildSystemPrompt(targetLang),
363
+ tools: [fileEditor],
364
+ printer: !!options.verbose,
365
+ });
366
+ agent.addHook(BeforeToolCallEvent, rejectOutsideDocsDir);
367
+
368
+ try {
369
+ const result = await agent.invoke(buildUserPrompt(file, targetLang));
370
+ if (result.stopReason !== 'endTurn') {
371
+ log.warn(
372
+ `agent stopped with reason=${result.stopReason} while translating ${file.relativePath} → ${targetLang} — inspect output above`,
373
+ );
374
+ }
375
+ lastError = undefined;
376
+ } catch (err) {
377
+ lastError = err;
378
+ }
379
+
380
+ // Judge the file on disk rather than on whether the agent wrote it: the
381
+ // stream often drops after a successful write, and an agent handed an
382
+ // already-correct translation may rightly decide there is nothing to do.
383
+ if (fs.existsSync(targetAbsPath)) {
384
+ try {
385
+ assertFrontmatterIsParseable(await fs.readFile(targetAbsPath, 'utf-8'));
386
+ await restoreCodeBlocks(file.sourceAbsPath, targetAbsPath);
387
+ return;
388
+ } catch (err) {
389
+ lastError = err;
390
+ }
391
+ }
392
+ lastError ??= new Error('no translation exists at the target path');
393
+
394
+ if (attempt < TRANSLATE_MAX_ATTEMPTS) {
395
+ const delayMs = 2000 * attempt;
396
+ log.warn(
397
+ `translation attempt ${attempt}/${TRANSLATE_MAX_ATTEMPTS} for ${file.relativePath} → ${targetLang} failed (${lastError instanceof Error ? lastError.message : String(lastError)}); retrying in ${delayMs}ms`,
398
+ );
399
+ await sleep(delayMs);
400
+ }
335
401
  }
402
+
403
+ throw lastError;
336
404
  }
337
405
 
338
406
  /**
@@ -356,6 +424,46 @@ async function runWithConcurrency<T>(
356
424
  return results;
357
425
  }
358
426
 
427
+ /**
428
+ * Delete translated files whose source document no longer exists. Without this
429
+ * a source file that is renamed or removed leaves its translations behind in
430
+ * every locale, where they still resolve as snippets and pages.
431
+ */
432
+ async function pruneOrphanedTranslations(
433
+ targetLanguages: string[],
434
+ ): Promise<void> {
435
+ const sourceLangRoot = `${DOCS_DIR}/${config.sourceLanguage}`;
436
+ const sourceFiles = new Set(
437
+ (
438
+ await glob(
439
+ config.include.map((p) => `${sourceLangRoot}/${p}`),
440
+ { ignore: config.exclude.map((p) => `${sourceLangRoot}/${p}`) },
441
+ )
442
+ ).map((p) => path.relative(sourceLangRoot, p)),
443
+ );
444
+
445
+ for (const lang of targetLanguages) {
446
+ const langRoot = path.join(DOCS_DIR, lang);
447
+ if (!fs.existsSync(langRoot)) continue;
448
+ const translated = await glob(
449
+ config.include.map((p) => `${langRoot}/${p}`),
450
+ { ignore: config.exclude.map((p) => `${langRoot}/${p}`) },
451
+ );
452
+ for (const abs of translated) {
453
+ const rel = path.relative(langRoot, abs);
454
+ if (sourceFiles.has(rel)) continue;
455
+ if (options.dryRun) {
456
+ log.info(`[dry-run] would delete orphaned ${lang}/${rel}`);
457
+ } else {
458
+ log.info(
459
+ `Deleting orphaned ${lang}/${rel} (no ${config.sourceLanguage} source)`,
460
+ );
461
+ await fs.remove(abs);
462
+ }
463
+ }
464
+ }
465
+ }
466
+
359
467
  async function main() {
360
468
  const requestedLanguages: string[] = options.languages
361
469
  ? options.languages
@@ -376,17 +484,19 @@ async function main() {
376
484
  log.info(`Source: ${config.sourceLanguage}`);
377
485
  log.info(`Targets: ${targetLanguages.join(', ')}`);
378
486
 
487
+ await pruneOrphanedTranslations(targetLanguages);
488
+
379
489
  const files = await getFilesToTranslate();
380
490
 
381
491
  if (files.length === 0) {
382
- log.info('Nothing to translate.');
492
+ log.info('No documentation files to translate.');
383
493
  return;
384
494
  }
385
495
 
386
496
  log.info(`Files to translate: ${files.length}`);
387
497
  for (const f of files) {
388
498
  log.verbose(
389
- ` - ${f.relativePath}${f.diff ? ' (changed)' : ' (full)'}`,
499
+ ` - ${f.relativePath}${f.diffPath ? ' (changed)' : ' (full)'}`,
390
500
  );
391
501
  }
392
502
 
@@ -401,7 +511,10 @@ async function main() {
401
511
  }
402
512
 
403
513
  // Build one task per (file × target language) — each is a fresh agent invocation,
404
- // so context windows stay small no matter how big the docs site is.
514
+ // so context windows stay small no matter how big the docs site is. A single
515
+ // failing translation records its error rather than aborting the whole run, so
516
+ // every other (file × language) still completes and gets written to disk.
517
+ const failures: string[] = [];
405
518
  const tasks = targetLanguages.flatMap((lang) =>
406
519
  files.map((file) => async () => {
407
520
  log.info(` ${file.relativePath} → ${lang}`);
@@ -411,7 +524,7 @@ async function main() {
411
524
  log.error(
412
525
  `${file.relativePath} → ${lang}: ${err instanceof Error ? err.message : String(err)}`,
413
526
  );
414
- throw err;
527
+ failures.push(`${file.relativePath} → ${lang}`);
415
528
  }
416
529
  }),
417
530
  );
@@ -422,10 +535,18 @@ async function main() {
422
535
  );
423
536
  await runWithConcurrency(tasks, concurrency);
424
537
 
538
+ if (failures.length > 0) {
539
+ throw new Error(
540
+ `${failures.length} translation(s) failed after retries:\n ${failures.join('\n ')}`,
541
+ );
542
+ }
543
+
425
544
  log.info('Done.');
426
545
  }
427
546
 
428
- main().catch((err) => {
429
- log.error(err instanceof Error ? err.message : String(err));
430
- process.exit(1);
431
- });
547
+ main()
548
+ .finally(() => fs.remove(DIFF_DIR))
549
+ .catch((err) => {
550
+ log.error(err instanceof Error ? err.message : String(err));
551
+ process.exit(1);
552
+ });