From 74c7841c36f230e7862f061618eab8e786ad1c42 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 11:35:23 +1000 Subject: [PATCH 01/15] feat: picomatch --- packages/cli/package.json | 2 + packages/cli/src/cli/utils/buckets.spec.ts | 214 +++++++++++++++++++++ packages/cli/src/cli/utils/buckets.ts | 148 +++++++++++--- pnpm-lock.yaml | 35 ++-- 4 files changed, 356 insertions(+), 43 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e8cecae3f..d2bece054 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -143,6 +143,7 @@ "@openrouter/ai-sdk-provider": "^0.7.1", "@paralleldrive/cuid2": "^2.2.2", "@types/ejs": "^3.1.5", + "@types/picomatch": "^4.0.2", "ai": "^4.3.15", "bitbucket": "^2.12.0", "chalk": "^5.4.1", @@ -191,6 +192,7 @@ "ora": "^8.1.1", "p-limit": "^6.2.0", "php-array-reader": "^2.1.2", + "picomatch": "^4.0.3", "plist": "^3.1.0", "posthog-node": "^5.8.1", "prettier": "^3.4.2", diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index ec2be41fb..8fd1c4fbc 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -167,6 +167,220 @@ describe("getBuckets", () => { }, ]); }); + + it("restores locale placeholder when using recursive globstar patterns", () => { + mockGlobSync([ + "src/modules/core/auth/en/strings/messages.json", + "src/modules/marketing/en/strings/dashboard.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/modules/**/[locale]/strings/*.json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/modules/core/auth/[locale]/strings/messages.json", + delimiter: null, + }, + { + pathPattern: "src/modules/marketing/[locale]/strings/dashboard.json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("restores placeholder when extglob wraps the locale segment", () => { + mockGlobSync([ + "src/modules/core-en.json", + "src/modules/marketing-en.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/modules/@(core|marketing)-[locale].json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { pathPattern: "src/modules/core-[locale].json", delimiter: null }, + { + pathPattern: "src/modules/marketing-[locale].json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("restores placeholder when brace expansion surrounds locale segment", () => { + mockGlobSync([ + "src/modules/core/en/strings/messages.json", + "src/modules/marketing/en/strings/dashboard.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/modules/{core,marketing}/[locale]/strings/*.json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/modules/core/[locale]/strings/messages.json", + delimiter: null, + }, + { + pathPattern: "src/modules/marketing/[locale]/strings/dashboard.json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("preserves glob character classes around locale placeholder", () => { + mockGlobSync(["src/files/id-en.json"]); + + const i18nConfig = makeI18nConfig([ + "src/files/??-[locale].json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/files/id-[locale].json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("supports globstar at the beginning of the pattern", () => { + mockGlobSync([ + "src/modules/core/en/messages.json", + "src/modules/marketing/en/dashboard.json", + ]); + + const i18nConfig = makeI18nConfig(["**/[locale]/*.json"]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/modules/core/[locale]/messages.json", + delimiter: null, + }, + { + pathPattern: "src/modules/marketing/[locale]/dashboard.json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("supports multiple globstars surrounding the locale segment", () => { + mockGlobSync([ + "src/modules/core/services/en/api/messages.json", + "src/modules/marketing/en/email/templates/messages.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/**/[locale]/**/messages.json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: + "src/modules/core/services/[locale]/api/messages.json", + delimiter: null, + }, + { + pathPattern: + "src/modules/marketing/[locale]/email/templates/messages.json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("supports trailing globstar before the file extension", () => { + mockGlobSync([ + "src/files/en/report.json", + "src/files/en/app.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/files/[locale]/**.json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/files/[locale]/report.json", + delimiter: null, + }, + { + pathPattern: "src/files/[locale]/app.json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("handles consecutive globstars before the locale segment", () => { + mockGlobSync([ + "src/a/b/en/messages.json", + "src/en/messages.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/**/**/[locale]/messages.json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/a/b/[locale]/messages.json", + delimiter: null, + }, + { + pathPattern: "src/[locale]/messages.json", + delimiter: null, + }, + ], + }, + ]); + }); }); function mockGlobSync(...args: string[][]) { diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index 962030c09..81dfeb3a2 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import path from "path"; import { glob } from "glob"; +import { makeRe } from "picomatch"; import { CLIError } from "./errors"; import { I18nConfig, @@ -110,14 +111,6 @@ function expandPlaceholderedGlob( }); } - // Throw error if pathPattern contains "**" – we don't support recursive path patterns - if (pathPattern.includes("**")) { - throw new CLIError({ - message: `Invalid path pattern: ${pathPattern}. Recursive path patterns are not supported.`, - docUrl: "invalidPathPattern", - }); - } - // Break down path pattern into parts const pathPatternChunks = pathPattern.split(path.sep); // Find the index of the segment containing "[locale]" @@ -130,8 +123,10 @@ function expandPlaceholderedGlob( }, [] as number[], ); - // substitute [locale] in pathPattern with sourceLocale - const sourcePathPattern = pathPattern.replaceAll(/\[locale\]/g, sourceLocale); + const normalizedLocale = + process.platform === "win32" ? sourceLocale.toLowerCase() : sourceLocale; + // substitute [locale] in pathPattern with normalized locale + const sourcePathPattern = pathPattern.replaceAll(/\[locale\]/g, normalizedLocale); // Convert to Unix-style for Windows compatibility const unixStylePattern = sourcePathPattern.replace(/\\/g, "/"); @@ -153,27 +148,22 @@ function expandPlaceholderedGlob( sourcePath.replace(/\//g, path.sep), ); const sourcePathChunks = normalizedSourcePath.split(path.sep); + const mapping = mapPatternToSource( + pathPatternChunks, + sourcePathChunks, + normalizedLocale, + ); localeSegmentIndexes.forEach((localeSegmentIndex) => { - // Find the position of the "[locale]" placeholder within the segment - const pathPatternChunk = pathPatternChunks[localeSegmentIndex]; - const sourcePathChunk = sourcePathChunks[localeSegmentIndex]; - const regexp = new RegExp( - "(" + - pathPatternChunk - .replaceAll(".", "\\.") - .replaceAll("*", ".*") - .replace("[locale]", `)${sourceLocale}(`) + - ")", - ); - const match = sourcePathChunk.match(regexp); - if (match) { - const [, prefix, suffix] = match; - const placeholderedSegment = prefix + "[locale]" + suffix; - sourcePathChunks[localeSegmentIndex] = placeholderedSegment; + const sourceIndex = mapping.patToSrc[localeSegmentIndex]; + if (sourceIndex >= 0) { + sourcePathChunks[sourceIndex] = buildLocalePlaceholderSegment( + pathPatternChunks[localeSegmentIndex], + sourcePathChunks[sourceIndex], + normalizedLocale, + ); } }); - const placeholderedPath = sourcePathChunks.join(path.sep); - return placeholderedPath; + return sourcePathChunks.join(path.sep); }); // return the placeholdered paths return placeholderedPaths; @@ -185,3 +175,105 @@ function resolveBucketItem(bucketItem: string | BucketItem): BucketItem { } return bucketItem; } + +function mapPatternToSource( + pattern: string[], + source: string[], + locale: string, +): { patToSrc: number[] } { + const patternLength = pattern.length; + const sourceLength = source.length; + const memo = new Map(); + const parent = new Map(); + const isDoubleStar = (segment: string) => segment === "**"; + const segmentMatches = (patternSegment: string, sourceSegment: string) => { + const concrete = patternSegment.replaceAll("[locale]", locale); + return makeRe(concrete, { dot: true }).test(sourceSegment); + }; + const key = (i: number, j: number) => `${i}|${j}`; + const dfs = (i: number, j: number): boolean => { + const memoKey = key(i, j); + if (memo.has(memoKey)) { + return memo.get(memoKey)!; + } + if (i === patternLength) { + const done = j === sourceLength; + memo.set(memoKey, done); + return done; + } + let matched = false; + if (isDoubleStar(pattern[i])) { + for (let k = j; k <= sourceLength; k += 1) { + if (dfs(i + 1, k)) { + parent.set(memoKey, { i2: i + 1, j2: k }); + matched = true; + break; + } + } + } else if (j < sourceLength && segmentMatches(pattern[i], source[j])) { + if (dfs(i + 1, j + 1)) { + parent.set(memoKey, { i2: i + 1, j2: j + 1 }); + matched = true; + } + } + memo.set(memoKey, matched); + return matched; + }; + + if (!dfs(0, 0)) { + return { + patToSrc: pattern.map((_, index) => (index < source.length ? index : -1)), + }; + } + + const patToSrc = Array(patternLength).fill(-1) as number[]; + let i = 0; + let j = 0; + while (i < patternLength) { + const step = parent.get(key(i, j)); + if (!step) { + break; + } + if (!isDoubleStar(pattern[i])) { + patToSrc[i] = j; + } + i = step.i2; + j = step.j2; + } + + return { patToSrc }; +} + +function buildLocalePlaceholderSegment( + patternChunk: string, + sourceChunk: string, + locale: string, +): string { + const placeholder = "[locale]"; + const placeholderIndex = patternChunk.indexOf(placeholder); + if (placeholderIndex === -1) { + return sourceChunk; + } + + const leftGlob = patternChunk.slice(0, placeholderIndex); + const rightGlob = patternChunk.slice(placeholderIndex + placeholder.length); + const leftRegexp = leftGlob + ? makeRe(leftGlob, { dot: true }) + : null; + const rightRegexp = rightGlob + ? makeRe(rightGlob, { dot: true }) + : null; + + let position = -1; + while ((position = sourceChunk.indexOf(locale, position + 1)) !== -1) { + const prefix = sourceChunk.slice(0, position); + const suffix = sourceChunk.slice(position + locale.length); + const leftMatches = leftRegexp ? leftRegexp.test(prefix) : prefix.length === 0; + const rightMatches = rightRegexp ? rightRegexp.test(suffix) : suffix.length === 0; + if (leftMatches && rightMatches) { + return `${prefix}${placeholder}${suffix}`; + } + } + + return patternChunk; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53edd112c..0e418042a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: '@types/ejs': specifier: ^3.1.5 version: 3.1.5 + '@types/picomatch': + specifier: ^4.0.2 + version: 4.0.2 ai: specifier: ^4.3.15 version: 4.3.15(react@18.3.1)(zod@3.25.76) @@ -548,6 +551,9 @@ importers: php-array-reader: specifier: ^2.1.2 version: 2.1.2 + picomatch: + specifier: ^4.0.3 + version: 4.0.3 plist: specifier: ^3.1.0 version: 3.1.0 @@ -4298,6 +4304,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/picomatch@4.0.2': + resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -8346,10 +8355,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} - picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -13567,10 +13572,10 @@ snapshots: '@rollup/pluginutils': 5.1.4(rollup@4.41.1) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.4.4(picomatch@4.0.2) + fdir: 6.4.4(picomatch@4.0.3) is-reference: 1.2.1 magic-string: 0.30.17 - picomatch: 4.0.2 + picomatch: 4.0.3 optionalDependencies: rollup: 4.41.1 @@ -13641,7 +13646,7 @@ snapshots: dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 - picomatch: 4.0.2 + picomatch: 4.0.3 optionalDependencies: rollup: 3.29.4 @@ -13649,7 +13654,7 @@ snapshots: dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 - picomatch: 4.0.2 + picomatch: 4.0.3 optionalDependencies: rollup: 4.41.1 @@ -14265,6 +14270,8 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/picomatch@4.0.2': {} + '@types/plist@3.0.5': dependencies: '@types/node': 22.17.2 @@ -16769,9 +16776,9 @@ snapshots: dependencies: format: 0.2.2 - fdir@6.4.4(picomatch@4.0.2): + fdir@6.4.4(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.2 + picomatch: 4.0.3 fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -19107,8 +19114,6 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.2: {} - picomatch@4.0.3: {} pify@4.0.1: {} @@ -20663,8 +20668,8 @@ snapshots: tinyglobby@0.2.10: dependencies: - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.4.4(picomatch@4.0.3) + picomatch: 4.0.3 tinyglobby@0.2.13: dependencies: @@ -21068,7 +21073,7 @@ snapshots: unplugin@2.3.5: dependencies: acorn: 8.14.1 - picomatch: 4.0.2 + picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 unrs-resolver@1.11.1: From 2d62ff4d0422bc24f0d1042254104c2b70e1a751 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 11:55:37 +1000 Subject: [PATCH 02/15] feat: code --- packages/cli/src/cli/utils/buckets.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index 81dfeb3a2..35d83dbc4 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -1,7 +1,7 @@ import _ from "lodash"; import path from "path"; import { glob } from "glob"; -import { makeRe } from "picomatch"; +import { minimatch } from "minimatch"; import { CLIError } from "./errors"; import { I18nConfig, @@ -188,7 +188,7 @@ function mapPatternToSource( const isDoubleStar = (segment: string) => segment === "**"; const segmentMatches = (patternSegment: string, sourceSegment: string) => { const concrete = patternSegment.replaceAll("[locale]", locale); - return makeRe(concrete, { dot: true }).test(sourceSegment); + return minimatch(sourceSegment, concrete, { dot: true }); }; const key = (i: number, j: number) => `${i}|${j}`; const dfs = (i: number, j: number): boolean => { @@ -257,20 +257,16 @@ function buildLocalePlaceholderSegment( const leftGlob = patternChunk.slice(0, placeholderIndex); const rightGlob = patternChunk.slice(placeholderIndex + placeholder.length); - const leftRegexp = leftGlob - ? makeRe(leftGlob, { dot: true }) - : null; - const rightRegexp = rightGlob - ? makeRe(rightGlob, { dot: true }) - : null; + const leftMatches = (value: string) => + leftGlob ? minimatch(value, leftGlob, { dot: true }) : value.length === 0; + const rightMatches = (value: string) => + rightGlob ? minimatch(value, rightGlob, { dot: true }) : value.length === 0; let position = -1; while ((position = sourceChunk.indexOf(locale, position + 1)) !== -1) { const prefix = sourceChunk.slice(0, position); const suffix = sourceChunk.slice(position + locale.length); - const leftMatches = leftRegexp ? leftRegexp.test(prefix) : prefix.length === 0; - const rightMatches = rightRegexp ? rightRegexp.test(suffix) : suffix.length === 0; - if (leftMatches && rightMatches) { + if (leftMatches(prefix) && rightMatches(suffix)) { return `${prefix}${placeholder}${suffix}`; } } From 31366d46a63c26a8898ac2b25b7f9fa209e19393 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 12:13:50 +1000 Subject: [PATCH 03/15] chore: changes --- packages/cli/src/cli/utils/buckets.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index 35d83dbc4..dca6ef83c 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -71,6 +71,9 @@ function extractPathPatterns( delimiter: pattern.delimiter, })), ); + const getUniqKey = (item: { pathPattern: string; delimiter?: LocaleDelimiter }) => + `${item.pathPattern}::${item.delimiter ?? ""}`; + const uniqueIncludedPatterns = _.uniqBy(includedPatterns, getUniqKey); const excludedPatterns = exclude?.flatMap((pattern) => expandPlaceholderedGlob( pattern.path, @@ -80,9 +83,12 @@ function extractPathPatterns( delimiter: pattern.delimiter, })), ); + const uniqueExcludedPatterns = excludedPatterns + ? _.uniqBy(excludedPatterns, getUniqKey) + : []; const result = _.differenceBy( - includedPatterns, - excludedPatterns ?? [], + uniqueIncludedPatterns, + uniqueExcludedPatterns, (item) => item.pathPattern, ); return result; From f94839bfbfc19bfe2724aa8d4bf8e3e5b4b76d1a Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 12:17:50 +1000 Subject: [PATCH 04/15] chore: tests --- packages/cli/src/cli/utils/buckets.spec.ts | 83 +++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index 8fd1c4fbc..f3de7c378 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { getBuckets } from "./buckets"; import { glob, Path } from "glob"; @@ -9,6 +9,10 @@ vi.mock("glob", () => ({ })); describe("getBuckets", () => { + beforeEach(() => { + vi.mocked(glob.sync).mockReset(); + }); + const makeI18nConfig = (include: any[]) => ({ $schema: "https://lingo.dev/schema/i18n.json", version: 0, @@ -381,6 +385,83 @@ describe("getBuckets", () => { }, ]); }); + + it("deduplicates overlapping include patterns", () => { + mockGlobSync([ + "src/i18n/en.json", + ]); + mockGlobSync([ + "src/i18n/en.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/i18n/**/[locale].json", + "src/i18n/[locale].json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { pathPattern: "src/i18n/[locale].json", delimiter: null }, + ], + }, + ]); + }); + + it("keeps distinct entries for matching paths with different delimiters", () => { + mockGlobSync([ + "src/i18n/en.json", + ]); + mockGlobSync([ + "src/i18n/en.json", + ]); + + const i18nConfig = makeI18nConfig([ + { path: "src/i18n/[locale].json", delimiter: "-" }, + { path: "src/i18n/[locale].json", delimiter: "_" }, + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { pathPattern: "src/i18n/[locale].json", delimiter: "-" }, + { pathPattern: "src/i18n/[locale].json", delimiter: "_" }, + ], + }, + ]); + }); + + it("restores placeholder when locale appears multiple times in a segment", () => { + mockGlobSync([ + "src/files/en-en.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/files/[locale]-[locale].json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { pathPattern: "src/files/[locale]-[locale].json", delimiter: null }, + ], + }, + ]); + }); + + it("throws when pattern resolves outside of the current working directory", () => { + const i18nConfig = makeI18nConfig(["../outside/[locale].json"]); + + expect(() => getBuckets(i18nConfig)).toThrowError( + /Invalid path pattern: \.{2}\//, + ); + }); }); function mockGlobSync(...args: string[][]) { From c4c3fd4d3c5f62f353bc22ebdc009b519cc76d84 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 12:20:34 +1000 Subject: [PATCH 05/15] chore: more tests --- packages/cli/src/cli/utils/buckets.spec.ts | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index f3de7c378..a8c2f6e8a 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -330,6 +330,54 @@ describe("getBuckets", () => { ]); }); + it("supports globstar segments after the locale placeholder", () => { + mockGlobSync([ + "src/i18n/en/deep/messages.json", + ]); + + const i18nConfig = makeI18nConfig([ + "src/i18n/[locale]/**/messages.json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/i18n/[locale]/deep/messages.json", + delimiter: null, + }, + ], + }, + ]); + }); + + it("supports globstar leading directly into the locale file name", () => { + mockGlobSync([ + "src/en.json", + "src/translations/en.json", + ]); + + const i18nConfig = makeI18nConfig([ + "**/[locale].json", + ]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { pathPattern: "src/[locale].json", delimiter: null }, + { + pathPattern: "src/translations/[locale].json", + delimiter: null, + }, + ], + }, + ]); + }); + it("supports trailing globstar before the file extension", () => { mockGlobSync([ "src/files/en/report.json", From 3fab8fcee88497b07e5d29723b381d32693ceb16 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 12:21:40 +1000 Subject: [PATCH 06/15] chore: remove dep --- packages/cli/package.json | 2 -- pnpm-lock.yaml | 11 ----------- 2 files changed, 13 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index d2bece054..e8cecae3f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -143,7 +143,6 @@ "@openrouter/ai-sdk-provider": "^0.7.1", "@paralleldrive/cuid2": "^2.2.2", "@types/ejs": "^3.1.5", - "@types/picomatch": "^4.0.2", "ai": "^4.3.15", "bitbucket": "^2.12.0", "chalk": "^5.4.1", @@ -192,7 +191,6 @@ "ora": "^8.1.1", "p-limit": "^6.2.0", "php-array-reader": "^2.1.2", - "picomatch": "^4.0.3", "plist": "^3.1.0", "posthog-node": "^5.8.1", "prettier": "^3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e418042a..7a45a9475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,9 +404,6 @@ importers: '@types/ejs': specifier: ^3.1.5 version: 3.1.5 - '@types/picomatch': - specifier: ^4.0.2 - version: 4.0.2 ai: specifier: ^4.3.15 version: 4.3.15(react@18.3.1)(zod@3.25.76) @@ -551,9 +548,6 @@ importers: php-array-reader: specifier: ^2.1.2 version: 2.1.2 - picomatch: - specifier: ^4.0.3 - version: 4.0.3 plist: specifier: ^3.1.0 version: 3.1.0 @@ -4304,9 +4298,6 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} - '@types/picomatch@4.0.2': - resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} - '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -14270,8 +14261,6 @@ snapshots: '@types/parse-json@4.0.2': {} - '@types/picomatch@4.0.2': {} - '@types/plist@3.0.5': dependencies: '@types/node': 22.17.2 From 1b8a24e7e27d4519a82f1f9e5d0d5376407e5fb1 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 12:27:03 +1000 Subject: [PATCH 07/15] chore: format --- packages/cli/src/cli/utils/buckets.spec.ts | 85 ++++++---------------- packages/cli/src/cli/utils/buckets.ts | 11 ++- packages/cli/src/locale-codes/index.ts | 2 +- 3 files changed, 32 insertions(+), 66 deletions(-) diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index a8c2f6e8a..abdd5b624 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -192,7 +192,8 @@ describe("getBuckets", () => { delimiter: null, }, { - pathPattern: "src/modules/marketing/[locale]/strings/dashboard.json", + pathPattern: + "src/modules/marketing/[locale]/strings/dashboard.json", delimiter: null, }, ], @@ -201,10 +202,7 @@ describe("getBuckets", () => { }); it("restores placeholder when extglob wraps the locale segment", () => { - mockGlobSync([ - "src/modules/core-en.json", - "src/modules/marketing-en.json", - ]); + mockGlobSync(["src/modules/core-en.json", "src/modules/marketing-en.json"]); const i18nConfig = makeI18nConfig([ "src/modules/@(core|marketing)-[locale].json", @@ -245,7 +243,8 @@ describe("getBuckets", () => { delimiter: null, }, { - pathPattern: "src/modules/marketing/[locale]/strings/dashboard.json", + pathPattern: + "src/modules/marketing/[locale]/strings/dashboard.json", delimiter: null, }, ], @@ -256,9 +255,7 @@ describe("getBuckets", () => { it("preserves glob character classes around locale placeholder", () => { mockGlobSync(["src/files/id-en.json"]); - const i18nConfig = makeI18nConfig([ - "src/files/??-[locale].json", - ]); + const i18nConfig = makeI18nConfig(["src/files/??-[locale].json"]); const buckets = getBuckets(i18nConfig); expect(buckets).toEqual([ @@ -306,9 +303,7 @@ describe("getBuckets", () => { "src/modules/marketing/en/email/templates/messages.json", ]); - const i18nConfig = makeI18nConfig([ - "src/**/[locale]/**/messages.json", - ]); + const i18nConfig = makeI18nConfig(["src/**/[locale]/**/messages.json"]); const buckets = getBuckets(i18nConfig); expect(buckets).toEqual([ @@ -316,8 +311,7 @@ describe("getBuckets", () => { type: "json", paths: [ { - pathPattern: - "src/modules/core/services/[locale]/api/messages.json", + pathPattern: "src/modules/core/services/[locale]/api/messages.json", delimiter: null, }, { @@ -331,13 +325,9 @@ describe("getBuckets", () => { }); it("supports globstar segments after the locale placeholder", () => { - mockGlobSync([ - "src/i18n/en/deep/messages.json", - ]); + mockGlobSync(["src/i18n/en/deep/messages.json"]); - const i18nConfig = makeI18nConfig([ - "src/i18n/[locale]/**/messages.json", - ]); + const i18nConfig = makeI18nConfig(["src/i18n/[locale]/**/messages.json"]); const buckets = getBuckets(i18nConfig); expect(buckets).toEqual([ @@ -354,14 +344,9 @@ describe("getBuckets", () => { }); it("supports globstar leading directly into the locale file name", () => { - mockGlobSync([ - "src/en.json", - "src/translations/en.json", - ]); + mockGlobSync(["src/en.json", "src/translations/en.json"]); - const i18nConfig = makeI18nConfig([ - "**/[locale].json", - ]); + const i18nConfig = makeI18nConfig(["**/[locale].json"]); const buckets = getBuckets(i18nConfig); expect(buckets).toEqual([ @@ -379,14 +364,9 @@ describe("getBuckets", () => { }); it("supports trailing globstar before the file extension", () => { - mockGlobSync([ - "src/files/en/report.json", - "src/files/en/app.json", - ]); + mockGlobSync(["src/files/en/report.json", "src/files/en/app.json"]); - const i18nConfig = makeI18nConfig([ - "src/files/[locale]/**.json", - ]); + const i18nConfig = makeI18nConfig(["src/files/[locale]/**.json"]); const buckets = getBuckets(i18nConfig); expect(buckets).toEqual([ @@ -407,14 +387,9 @@ describe("getBuckets", () => { }); it("handles consecutive globstars before the locale segment", () => { - mockGlobSync([ - "src/a/b/en/messages.json", - "src/en/messages.json", - ]); + mockGlobSync(["src/a/b/en/messages.json", "src/en/messages.json"]); - const i18nConfig = makeI18nConfig([ - "src/**/**/[locale]/messages.json", - ]); + const i18nConfig = makeI18nConfig(["src/**/**/[locale]/messages.json"]); const buckets = getBuckets(i18nConfig); expect(buckets).toEqual([ @@ -435,12 +410,8 @@ describe("getBuckets", () => { }); it("deduplicates overlapping include patterns", () => { - mockGlobSync([ - "src/i18n/en.json", - ]); - mockGlobSync([ - "src/i18n/en.json", - ]); + mockGlobSync(["src/i18n/en.json"]); + mockGlobSync(["src/i18n/en.json"]); const i18nConfig = makeI18nConfig([ "src/i18n/**/[locale].json", @@ -451,20 +422,14 @@ describe("getBuckets", () => { expect(buckets).toEqual([ { type: "json", - paths: [ - { pathPattern: "src/i18n/[locale].json", delimiter: null }, - ], + paths: [{ pathPattern: "src/i18n/[locale].json", delimiter: null }], }, ]); }); it("keeps distinct entries for matching paths with different delimiters", () => { - mockGlobSync([ - "src/i18n/en.json", - ]); - mockGlobSync([ - "src/i18n/en.json", - ]); + mockGlobSync(["src/i18n/en.json"]); + mockGlobSync(["src/i18n/en.json"]); const i18nConfig = makeI18nConfig([ { path: "src/i18n/[locale].json", delimiter: "-" }, @@ -484,13 +449,9 @@ describe("getBuckets", () => { }); it("restores placeholder when locale appears multiple times in a segment", () => { - mockGlobSync([ - "src/files/en-en.json", - ]); + mockGlobSync(["src/files/en-en.json"]); - const i18nConfig = makeI18nConfig([ - "src/files/[locale]-[locale].json", - ]); + const i18nConfig = makeI18nConfig(["src/files/[locale]-[locale].json"]); const buckets = getBuckets(i18nConfig); expect(buckets).toEqual([ diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index dca6ef83c..4d67b949e 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -71,8 +71,10 @@ function extractPathPatterns( delimiter: pattern.delimiter, })), ); - const getUniqKey = (item: { pathPattern: string; delimiter?: LocaleDelimiter }) => - `${item.pathPattern}::${item.delimiter ?? ""}`; + const getUniqKey = (item: { + pathPattern: string; + delimiter?: LocaleDelimiter; + }) => `${item.pathPattern}::${item.delimiter ?? ""}`; const uniqueIncludedPatterns = _.uniqBy(includedPatterns, getUniqKey); const excludedPatterns = exclude?.flatMap((pattern) => expandPlaceholderedGlob( @@ -132,7 +134,10 @@ function expandPlaceholderedGlob( const normalizedLocale = process.platform === "win32" ? sourceLocale.toLowerCase() : sourceLocale; // substitute [locale] in pathPattern with normalized locale - const sourcePathPattern = pathPattern.replaceAll(/\[locale\]/g, normalizedLocale); + const sourcePathPattern = pathPattern.replaceAll( + /\[locale\]/g, + normalizedLocale, + ); // Convert to Unix-style for Windows compatibility const unixStylePattern = sourcePathPattern.replace(/\\/g, "/"); diff --git a/packages/cli/src/locale-codes/index.ts b/packages/cli/src/locale-codes/index.ts index eef362f09..a3c290a65 100644 --- a/packages/cli/src/locale-codes/index.ts +++ b/packages/cli/src/locale-codes/index.ts @@ -1,3 +1,3 @@ // Re-export everything but with type checking export type * from "@lingo.dev/_locales"; -export * from "@lingo.dev/_locales"; \ No newline at end of file +export * from "@lingo.dev/_locales"; From ed9a98e5f18e5851f243b51cc4b97032acb19df1 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 12:31:30 +1000 Subject: [PATCH 08/15] fix: bug --- packages/cli/src/cli/utils/buckets.spec.ts | 25 ++++++++++++++++++++++ packages/cli/src/cli/utils/buckets.ts | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index abdd5b624..d605fb73c 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -448,6 +448,31 @@ describe("getBuckets", () => { ]); }); + it("excludes entries matching both path pattern and delimiter", () => { + mockGlobSync(["src/i18n/en.json"]); + mockGlobSync(["src/i18n/en.json"]); + mockGlobSync(["src/i18n/en.json"]); + + const i18nConfig = makeI18nConfig([ + { path: "src/i18n/[locale].json", delimiter: "-" }, + { path: "src/i18n/[locale].json", delimiter: "_" }, + ]); + i18nConfig.buckets.json.exclude = [ + { path: "src/i18n/[locale].json", delimiter: "-" }, + ]; + + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { pathPattern: "src/i18n/[locale].json", delimiter: "_" }, + ], + }, + ]); + }); + it("restores placeholder when locale appears multiple times in a segment", () => { mockGlobSync(["src/files/en-en.json"]); diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index 4d67b949e..3b4d72e7d 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -91,7 +91,7 @@ function extractPathPatterns( const result = _.differenceBy( uniqueIncludedPatterns, uniqueExcludedPatterns, - (item) => item.pathPattern, + getUniqKey, ); return result; } From 05d5fbd2d25f83613ea18972f9431032f24a6fd0 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 12:32:16 +1000 Subject: [PATCH 09/15] chore: format --- packages/cli/src/cli/utils/buckets.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index d605fb73c..b334f1ddd 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -466,9 +466,7 @@ describe("getBuckets", () => { expect(buckets).toEqual([ { type: "json", - paths: [ - { pathPattern: "src/i18n/[locale].json", delimiter: "_" }, - ], + paths: [{ pathPattern: "src/i18n/[locale].json", delimiter: "_" }], }, ]); }); From a56c5bd929654ef1f83181801f1ca87d9ffc95a2 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Mon, 22 Sep 2025 12:35:54 +1000 Subject: [PATCH 10/15] feat: add globstar support for bucket configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .changeset/improve-glob-patterns.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/improve-glob-patterns.md diff --git a/.changeset/improve-glob-patterns.md b/.changeset/improve-glob-patterns.md new file mode 100644 index 000000000..e238b3c99 --- /dev/null +++ b/.changeset/improve-glob-patterns.md @@ -0,0 +1,5 @@ +--- +"lingo.dev": patch +--- + +Add support for globstar patterns in bucket configuration and improve path matching \ No newline at end of file From 00851fb1f42c182095202ded6fe0edf05a75bef0 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Tue, 23 Sep 2025 10:08:38 +1000 Subject: [PATCH 11/15] chore: refactoring --- packages/cli/src/cli/utils/buckets.spec.ts | 19 +++++ packages/cli/src/cli/utils/buckets.ts | 84 +++++++++++++--------- 2 files changed, 70 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index b334f1ddd..e33f346a5 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -409,6 +409,25 @@ describe("getBuckets", () => { ]); }); + it("restores placeholder for the deepest matching locale segment when duplicates exist", () => { + mockGlobSync(["src/en/module/en/messages.json"]); + + const i18nConfig = makeI18nConfig(["src/**/[locale]/**/messages.json"]); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toEqual([ + { + type: "json", + paths: [ + { + pathPattern: "src/en/module/[locale]/messages.json", + delimiter: null, + }, + ], + }, + ]); + }); + it("deduplicates overlapping include patterns", () => { mockGlobSync(["src/i18n/en.json"]); mockGlobSync(["src/i18n/en.json"]); diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index 3b4d72e7d..51c36b86d 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -194,65 +194,83 @@ function mapPatternToSource( ): { patToSrc: number[] } { const patternLength = pattern.length; const sourceLength = source.length; - const memo = new Map(); - const parent = new Map(); const isDoubleStar = (segment: string) => segment === "**"; const segmentMatches = (patternSegment: string, sourceSegment: string) => { const concrete = patternSegment.replaceAll("[locale]", locale); return minimatch(sourceSegment, concrete, { dot: true }); }; + + const placeholderIndexes = pattern.reduce((acc, segment, index) => { + if (segment.includes("[locale]")) { + acc.push(index); + } + return acc; + }, []); + + const compareMappings = (a: number[], b: number[]) => { + for (const idx of placeholderIndexes) { + const diff = (a[idx] ?? -1) - (b[idx] ?? -1); + if (diff !== 0) { + return diff; + } + } + for (let idx = 0; idx < patternLength; idx += 1) { + const diff = (a[idx] ?? -1) - (b[idx] ?? -1); + if (diff !== 0) { + return diff; + } + } + return 0; + }; + + const memo = new Map(); const key = (i: number, j: number) => `${i}|${j}`; - const dfs = (i: number, j: number): boolean => { + + const dfs = (i: number, j: number): number[] | null => { const memoKey = key(i, j); if (memo.has(memoKey)) { return memo.get(memoKey)!; } if (i === patternLength) { - const done = j === sourceLength; - memo.set(memoKey, done); - return done; + const result = j === sourceLength ? Array(patternLength).fill(-1) : null; + memo.set(memoKey, result); + return result; } - let matched = false; + + let best: number[] | null = null; + if (isDoubleStar(pattern[i])) { for (let k = j; k <= sourceLength; k += 1) { - if (dfs(i + 1, k)) { - parent.set(memoKey, { i2: i + 1, j2: k }); - matched = true; - break; + const candidate = dfs(i + 1, k); + if (!candidate) { + continue; + } + const candidateMapping = candidate.slice(); + if (!best || compareMappings(candidateMapping, best) > 0) { + best = candidateMapping; } } } else if (j < sourceLength && segmentMatches(pattern[i], source[j])) { - if (dfs(i + 1, j + 1)) { - parent.set(memoKey, { i2: i + 1, j2: j + 1 }); - matched = true; + const candidate = dfs(i + 1, j + 1); + if (candidate) { + const mapping = candidate.slice(); + mapping[i] = j; + best = mapping; } } - memo.set(memoKey, matched); - return matched; + + memo.set(memoKey, best); + return best; }; - if (!dfs(0, 0)) { + const mapping = dfs(0, 0); + if (!mapping) { return { patToSrc: pattern.map((_, index) => (index < source.length ? index : -1)), }; } - const patToSrc = Array(patternLength).fill(-1) as number[]; - let i = 0; - let j = 0; - while (i < patternLength) { - const step = parent.get(key(i, j)); - if (!step) { - break; - } - if (!isDoubleStar(pattern[i])) { - patToSrc[i] = j; - } - i = step.i2; - j = step.j2; - } - - return { patToSrc }; + return { patToSrc: mapping }; } function buildLocalePlaceholderSegment( From 59708fe9246e948a28bce7688f6cb8cc6a5ae32f Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Tue, 23 Sep 2025 12:55:56 +1000 Subject: [PATCH 12/15] feat: heuristics --- .../utils/__snapshots__/buckets.spec.ts.snap | 239 ++++++++++++++++++ packages/cli/src/cli/utils/buckets.spec.ts | 108 +++++++- packages/cli/src/cli/utils/buckets.ts | 144 +++++++++-- 3 files changed, 467 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/cli/utils/__snapshots__/buckets.spec.ts.snap diff --git a/packages/cli/src/cli/utils/__snapshots__/buckets.spec.ts.snap b/packages/cli/src/cli/utils/__snapshots__/buckets.spec.ts.snap new file mode 100644 index 000000000..ef3093d66 --- /dev/null +++ b/packages/cli/src/cli/utils/__snapshots__/buckets.spec.ts.snap @@ -0,0 +1,239 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`getBuckets > placeholder restoration matrix > basic segment placeholder 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/[locale]/messages.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > brace expansion containing locale segment 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/modules/core/[locale]/strings/messages.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > character class adjacent to locale placeholder 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/files/id-[locale].json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > deep translations path with static locale duplication 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/[locale]/module/en/translations/[locale].json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > duplicated placeholder within a single segment 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/files/[locale]-[locale].json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > extglob wrapping locale placeholder 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/modules/core-[locale].json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > globstar after placeholder 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/i18n/[locale]/deep/messages.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > globstar before placeholder with extra segments 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/features/[locale]/messages.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > globstar before placeholder with zero extra segments 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/[locale]/messages.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > globstar surrounding placeholder with duplicate locale segment 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/[locale]/module/en/messages.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > multiple placeholder segments 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/[locale]/module/[locale]/messages.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > multiple placeholders separated by globstars 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/[locale]/foo/en/[locale].json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > placeholder between single-segment wildcards 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/app/[locale]/file.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > placeholder inside file extension 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/messages.[locale].json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > placeholder with prefix and suffix in same segment 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/files/pre[locale]post.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > static locale directory after placeholder 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/[locale]/module/en/messages.json", + }, + ], + "type": "json", + }, +] +`; + +exports[`getBuckets > placeholder restoration matrix > static locale directory before placeholder 1`] = ` +[ + { + "paths": [ + { + "delimiter": null, + "pathPattern": "src/en/module/[locale]/messages.json", + }, + ], + "type": "json", + }, +] +`; diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index e33f346a5..3143c6c54 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -409,7 +409,7 @@ describe("getBuckets", () => { ]); }); - it("restores placeholder for the deepest matching locale segment when duplicates exist", () => { + it("prioritizes the earliest matching locale segment when duplicates exist", () => { mockGlobSync(["src/en/module/en/messages.json"]); const i18nConfig = makeI18nConfig(["src/**/[locale]/**/messages.json"]); @@ -420,7 +420,7 @@ describe("getBuckets", () => { type: "json", paths: [ { - pathPattern: "src/en/module/[locale]/messages.json", + pathPattern: "src/[locale]/module/en/messages.json", delimiter: null, }, ], @@ -513,6 +513,110 @@ describe("getBuckets", () => { /Invalid path pattern: \.{2}\//, ); }); + + describe("placeholder restoration matrix", () => { + const cases: Array<{ + name: string; + include: any[]; + globResults: string[][]; + }> = [ + { + name: "basic segment placeholder", + include: ["src/[locale]/messages.json"], + globResults: [["src/en/messages.json"]], + }, + { + name: "placeholder inside file extension", + include: ["src/messages.[locale].json"], + globResults: [["src/messages.en.json"]], + }, + { + name: "placeholder with prefix and suffix in same segment", + include: ["src/files/pre[locale]post.json"], + globResults: [["src/files/preenpost.json"]], + }, + { + name: "duplicated placeholder within a single segment", + include: ["src/files/[locale]-[locale].json"], + globResults: [["src/files/en-en.json"]], + }, + { + name: "multiple placeholder segments", + include: ["src/[locale]/module/[locale]/messages.json"], + globResults: [["src/en/module/en/messages.json"]], + }, + { + name: "static locale directory before placeholder", + include: ["src/en/module/[locale]/messages.json"], + globResults: [["src/en/module/en/messages.json"]], + }, + { + name: "static locale directory after placeholder", + include: ["src/[locale]/module/en/messages.json"], + globResults: [["src/en/module/en/messages.json"]], + }, + { + name: "globstar before placeholder with extra segments", + include: ["src/**/[locale]/messages.json"], + globResults: [["src/features/en/messages.json"]], + }, + { + name: "globstar before placeholder with zero extra segments", + include: ["src/**/[locale]/messages.json"], + globResults: [["src/en/messages.json"]], + }, + { + name: "globstar after placeholder", + include: ["src/i18n/[locale]/**/messages.json"], + globResults: [["src/i18n/en/deep/messages.json"]], + }, + { + name: "globstar surrounding placeholder with duplicate locale segment", + include: ["src/**/[locale]/**/messages.json"], + globResults: [["src/en/module/en/messages.json"]], + }, + { + name: "multiple placeholders separated by globstars", + include: ["src/**/[locale]/**/[locale].json"], + globResults: [["src/en/foo/en/en.json"]], + }, + { + name: "extglob wrapping locale placeholder", + include: ["src/modules/@(core|marketing)-[locale].json"], + globResults: [["src/modules/core-en.json"]], + }, + { + name: "brace expansion containing locale segment", + include: ["src/modules/{core,marketing}/[locale]/strings/*.json"], + globResults: [["src/modules/core/en/strings/messages.json"]], + }, + { + name: "character class adjacent to locale placeholder", + include: ["src/files/??-[locale].json"], + globResults: [["src/files/id-en.json"]], + }, + { + name: "placeholder between single-segment wildcards", + include: ["src/*/[locale]/file.json"], + globResults: [["src/app/en/file.json"]], + }, + { + name: "deep translations path with static locale duplication", + include: ["src/**/[locale]/**/translations/[locale].json"], + globResults: [["src/en/module/en/translations/en.json"]], + }, + ]; + + cases.forEach(({ name, include, globResults }) => { + it(name, () => { + mockGlobSync(...globResults); + const i18nConfig = makeI18nConfig(include); + const buckets = getBuckets(i18nConfig); + + expect(buckets).toMatchSnapshot(); + }); + }); + }); }); function mockGlobSync(...args: string[][]) { diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index 51c36b86d..b69c384d5 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -207,55 +207,155 @@ function mapPatternToSource( return acc; }, []); - const compareMappings = (a: number[], b: number[]) => { - for (const idx of placeholderIndexes) { - const diff = (a[idx] ?? -1) - (b[idx] ?? -1); - if (diff !== 0) { - return diff; + type MappingResult = { + mapping: number[]; + score: { + matchedPlaceholders: number; + placeholderSum: number; + placeholderValues: number[]; + mappedCount: number; + totalSum: number; + mappingValues: number[]; + }; + }; + + const placeholderPenalty = patternLength + sourceLength; + + const evaluateMapping = (mapping: number[]): MappingResult["score"] => { + const mappingValues = mapping.map((value) => + typeof value === "number" ? value : -1, + ); + const placeholderValues = placeholderIndexes.map((idx) => mappingValues[idx]); + + let matchedPlaceholders = 0; + let placeholderSum = 0; + placeholderValues.forEach((value) => { + if (value >= 0) { + matchedPlaceholders += 1; + placeholderSum += value; + } else { + placeholderSum += placeholderPenalty; + } + }); + + let mappedCount = 0; + let totalSum = 0; + mappingValues.forEach((value) => { + if (value >= 0) { + mappedCount += 1; + totalSum += value; + } else { + totalSum += placeholderPenalty; + } + }); + + return { + matchedPlaceholders, + placeholderSum, + placeholderValues, + mappedCount, + totalSum, + mappingValues, + }; + }; + + const isBetterMapping = ( + candidate: MappingResult | null, + current: MappingResult | null, + ) => { + if (!candidate) { + return false; + } + if (!current) { + return true; + } + + if ( + candidate.score.matchedPlaceholders !== current.score.matchedPlaceholders + ) { + return ( + candidate.score.matchedPlaceholders > current.score.matchedPlaceholders + ); + } + + if (candidate.score.placeholderSum !== current.score.placeholderSum) { + return candidate.score.placeholderSum < current.score.placeholderSum; + } + + for (let idx = 0; idx < candidate.score.placeholderValues.length; idx += 1) { + const aVal = candidate.score.placeholderValues[idx]; + const bVal = current.score.placeholderValues[idx]; + if (aVal !== bVal) { + if (aVal < 0) { + return false; + } + if (bVal < 0) { + return true; + } + return aVal < bVal; } } + + if (candidate.score.mappedCount !== current.score.mappedCount) { + return candidate.score.mappedCount > current.score.mappedCount; + } + + if (candidate.score.totalSum !== current.score.totalSum) { + return candidate.score.totalSum < current.score.totalSum; + } + for (let idx = 0; idx < patternLength; idx += 1) { - const diff = (a[idx] ?? -1) - (b[idx] ?? -1); - if (diff !== 0) { - return diff; + const aVal = candidate.score.mappingValues[idx]; + const bVal = current.score.mappingValues[idx]; + if (aVal !== bVal) { + if (aVal < 0) { + return false; + } + if (bVal < 0) { + return true; + } + return aVal < bVal; } } - return 0; + + return false; }; - const memo = new Map(); + const memo = new Map(); const key = (i: number, j: number) => `${i}|${j}`; - const dfs = (i: number, j: number): number[] | null => { + const dfs = (i: number, j: number): MappingResult | null => { const memoKey = key(i, j); if (memo.has(memoKey)) { return memo.get(memoKey)!; } if (i === patternLength) { - const result = j === sourceLength ? Array(patternLength).fill(-1) : null; + const mapping = j === sourceLength ? Array(patternLength).fill(-1) : null; + const result = mapping + ? { mapping, score: evaluateMapping(mapping) } + : null; memo.set(memoKey, result); return result; } - let best: number[] | null = null; + let best: MappingResult | null = null; if (isDoubleStar(pattern[i])) { for (let k = j; k <= sourceLength; k += 1) { const candidate = dfs(i + 1, k); - if (!candidate) { - continue; - } - const candidateMapping = candidate.slice(); - if (!best || compareMappings(candidateMapping, best) > 0) { - best = candidateMapping; + if (isBetterMapping(candidate, best)) { + best = candidate; } } } else if (j < sourceLength && segmentMatches(pattern[i], source[j])) { const candidate = dfs(i + 1, j + 1); if (candidate) { - const mapping = candidate.slice(); + const mapping = candidate.mapping.slice(); mapping[i] = j; - best = mapping; + const result = { mapping, score: evaluateMapping(mapping) }; + if (isBetterMapping(result, best)) { + best = result; + } } } @@ -270,7 +370,7 @@ function mapPatternToSource( }; } - return { patToSrc: mapping }; + return { patToSrc: mapping.mapping }; } function buildLocalePlaceholderSegment( From 1527a4e42c8a438798f1dccb93f4a489b350bd38 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Tue, 23 Sep 2025 13:17:47 +1000 Subject: [PATCH 13/15] feat: template matcher --- packages/cli/src/cli/utils/buckets.spec.ts | 87 +++- packages/cli/src/cli/utils/buckets.ts | 490 +++++++++++---------- 2 files changed, 335 insertions(+), 242 deletions(-) diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts index 3143c6c54..793abb3c4 100644 --- a/packages/cli/src/cli/utils/buckets.spec.ts +++ b/packages/cli/src/cli/utils/buckets.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getBuckets } from "./buckets"; +import { getBuckets, parsePatternTemplate } from "./buckets"; import { glob, Path } from "glob"; vi.mock("glob", () => ({ @@ -8,6 +8,91 @@ vi.mock("glob", () => ({ }, })); +describe("parsePatternTemplate", () => { + it("captures globstars and placeholder segments", () => { + expect(parsePatternTemplate("src/**/[locale]/**/messages.json")).toEqual([ + { + kind: "segment", + original: "src", + parts: [{ kind: "literal", value: "src" }], + hasPlaceholder: false, + hasGlob: false, + }, + { kind: "globstar", original: "**" }, + { + kind: "segment", + original: "[locale]", + parts: [{ kind: "placeholder", name: "locale" }], + hasPlaceholder: true, + hasGlob: false, + }, + { kind: "globstar", original: "**" }, + { + kind: "segment", + original: "messages.json", + parts: [{ kind: "literal", value: "messages.json" }], + hasPlaceholder: false, + hasGlob: false, + }, + ]); + }); + + it("identifies placeholders embedded within segments", () => { + expect(parsePatternTemplate("src/messages.[locale].json")).toEqual([ + { + kind: "segment", + original: "src", + parts: [{ kind: "literal", value: "src" }], + hasPlaceholder: false, + hasGlob: false, + }, + { + kind: "segment", + original: "messages.[locale].json", + parts: [ + { kind: "literal", value: "messages." }, + { kind: "placeholder", name: "locale" }, + { kind: "literal", value: ".json" }, + ], + hasPlaceholder: true, + hasGlob: false, + }, + ]); + }); + + it("marks glob syntax distinct from placeholders", () => { + expect( + parsePatternTemplate("src/modules/@(core|marketing)-[locale].json"), + ).toEqual([ + { + kind: "segment", + original: "src", + parts: [{ kind: "literal", value: "src" }], + hasPlaceholder: false, + hasGlob: false, + }, + { + kind: "segment", + original: "modules", + parts: [{ kind: "literal", value: "modules" }], + hasPlaceholder: false, + hasGlob: false, + }, + { + kind: "segment", + original: "@(core|marketing)-[locale].json", + parts: [ + { kind: "glob", value: "@(core|marketing)-" }, + { kind: "placeholder", name: "locale" }, + { kind: "literal", value: ".json" }, + ], + hasPlaceholder: true, + hasGlob: true, + }, + ]); + }); +}); + describe("getBuckets", () => { beforeEach(() => { vi.mocked(glob.sync).mockReset(); diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index b69c384d5..a9d5bfbba 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -12,6 +12,245 @@ import { import { bucketTypeSchema } from "@lingo.dev/_spec"; import Z from "zod"; +export type TemplateSegmentPart = + | { kind: "literal"; value: string } + | { kind: "glob"; value: string } + | { kind: "placeholder"; name: string }; + +export type TemplateSegment = + | { kind: "globstar"; original: "**" } + | { + kind: "segment"; + original: string; + parts: TemplateSegmentPart[]; + hasPlaceholder: boolean; + hasGlob: boolean; + }; + +const LOCALE_PLACEHOLDER = "[locale]"; +const GLOB_CHARS_REGEX = /[\*\?\[\]\{\}\(\)!+@,]/; + +function isGlobPattern(value: string) { + return GLOB_CHARS_REGEX.test(value); +} + +function flushBuffer( + parts: TemplateSegmentPart[], + buffer: string, +): string { + if (!buffer) { + return ""; + } + if (isGlobPattern(buffer)) { + parts.push({ kind: "glob", value: buffer }); + } else { + parts.push({ kind: "literal", value: buffer }); + } + return ""; +} + +export function parsePatternTemplate(pattern: string): TemplateSegment[] { + const normalized = pattern.replace(/\\/g, "/"); + const rawSegments = normalized.split("/"); + + return rawSegments.map((segment) => { + if (segment === "**") { + return { kind: "globstar", original: "**" }; + } + + const parts: TemplateSegmentPart[] = []; + let buffer = ""; + let index = 0; + while (index < segment.length) { + if (segment.startsWith(LOCALE_PLACEHOLDER, index)) { + buffer = flushBuffer(parts, buffer); + parts.push({ kind: "placeholder", name: "locale" }); + index += LOCALE_PLACEHOLDER.length; + continue; + } + buffer += segment[index]; + index += 1; + } + flushBuffer(parts, buffer); + + const hasPlaceholder = parts.some((part) => part.kind === "placeholder"); + const hasGlob = parts.some((part) => part.kind === "glob"); + + return { + kind: "segment", + original: segment, + parts, + hasPlaceholder, + hasGlob, + }; + }); +} + +function segmentToConcretePattern( + segment: Extract, + locale: string, +): string { + if (!segment.hasPlaceholder) { + return segment.original; + } + return segment.original.split(LOCALE_PLACEHOLDER).join(locale); +} + +function segmentMatchesSource( + segment: Extract, + source: string, + locale: string, +): boolean { + if (!segment.hasPlaceholder && !segment.hasGlob) { + return source === segment.original; + } + const concrete = segmentToConcretePattern(segment, locale); + return minimatch(source, concrete, { dot: true }); +} + +function renderSegment( + segment: Extract, + source: string, + locale: string, +): string | null { + const memo = new Map(); + + const dfs = (partIndex: number, position: number): string | null => { + const memoKey = `${partIndex}|${position}`; + if (memo.has(memoKey)) { + return memo.get(memoKey)!; + } + if (partIndex === segment.parts.length) { + const result = position === source.length ? "" : null; + memo.set(memoKey, result); + return result; + } + + const part = segment.parts[partIndex]; + + if (part.kind === "literal") { + if (source.startsWith(part.value, position)) { + const rest = dfs(partIndex + 1, position + part.value.length); + if (rest !== null) { + const result = part.value + rest; + memo.set(memoKey, result); + return result; + } + } + memo.set(memoKey, null); + return null; + } + + if (part.kind === "placeholder") { + if (source.startsWith(locale, position)) { + const rest = dfs(partIndex + 1, position + locale.length); + if (rest !== null) { + const result = `${LOCALE_PLACEHOLDER}${rest}`; + memo.set(memoKey, result); + return result; + } + } + memo.set(memoKey, null); + return null; + } + + for (let length = 0; position + length <= source.length; length += 1) { + const fragment = source.slice(position, position + length); + if (!minimatch(fragment, part.value, { dot: true })) { + continue; + } + const rest = dfs(partIndex + 1, position + length); + if (rest !== null) { + const result = fragment + rest; + memo.set(memoKey, result); + return result; + } + } + + memo.set(memoKey, null); + return null; + }; + + return dfs(0, 0); +} + +function buildOutputSegment( + segment: Extract, + source: string, + locale: string, +): string { + if (segment.hasPlaceholder) { + return renderSegment(segment, source, locale) ?? segment.original; + } + if (segment.hasGlob) { + return source; + } + return segment.original; +} + +function matchTemplateToSource( + template: TemplateSegment[], + sourceSegments: string[], + locale: string, +): string[] | null { + const memo = new Map(); + + const dfs = (templateIndex: number, sourceIndex: number): string[] | null => { + const memoKey = `${templateIndex}|${sourceIndex}`; + if (memo.has(memoKey)) { + return memo.get(memoKey)!; + } + if (templateIndex === template.length) { + const result = sourceIndex === sourceSegments.length ? [] : null; + memo.set(memoKey, result); + return result; + } + + const segment = template[templateIndex]; + + if (segment.kind === "globstar") { + for (let consume = 0; consume <= sourceSegments.length - sourceIndex; consume += 1) { + const rest = dfs(templateIndex + 1, sourceIndex + consume); + if (rest) { + const consumed = sourceSegments.slice(sourceIndex, sourceIndex + consume); + const combined = [...consumed, ...rest]; + memo.set(memoKey, combined); + return combined; + } + } + memo.set(memoKey, null); + return null; + } + + if (sourceIndex >= sourceSegments.length) { + memo.set(memoKey, null); + return null; + } + + if (!segmentMatchesSource(segment, sourceSegments[sourceIndex], locale)) { + memo.set(memoKey, null); + return null; + } + + const rest = dfs(templateIndex + 1, sourceIndex + 1); + if (!rest) { + memo.set(memoKey, null); + return null; + } + + const current = buildOutputSegment( + segment, + sourceSegments[sourceIndex], + locale, + ); + const combined = [current, ...rest]; + memo.set(memoKey, combined); + return combined; + }; + + return dfs(0, 0); +} + type BucketConfig = { type: Z.infer; paths: Array<{ pathPattern: string; delimiter?: LocaleDelimiter }>; @@ -119,18 +358,7 @@ function expandPlaceholderedGlob( }); } - // Break down path pattern into parts - const pathPatternChunks = pathPattern.split(path.sep); - // Find the index of the segment containing "[locale]" - const localeSegmentIndexes = pathPatternChunks.reduce( - (indexes, segment, index) => { - if (segment.includes("[locale]")) { - indexes.push(index); - } - return indexes; - }, - [] as number[], - ); + const template = parsePatternTemplate(pathPattern.split(path.sep).join("/")); const normalizedLocale = process.platform === "win32" ? sourceLocale.toLowerCase() : sourceLocale; // substitute [locale] in pathPattern with normalized locale @@ -159,22 +387,18 @@ function expandPlaceholderedGlob( sourcePath.replace(/\//g, path.sep), ); const sourcePathChunks = normalizedSourcePath.split(path.sep); - const mapping = mapPatternToSource( - pathPatternChunks, + const matchedSegments = matchTemplateToSource( + template, sourcePathChunks, normalizedLocale, ); - localeSegmentIndexes.forEach((localeSegmentIndex) => { - const sourceIndex = mapping.patToSrc[localeSegmentIndex]; - if (sourceIndex >= 0) { - sourcePathChunks[sourceIndex] = buildLocalePlaceholderSegment( - pathPatternChunks[localeSegmentIndex], - sourcePathChunks[sourceIndex], - normalizedLocale, - ); - } - }); - return sourcePathChunks.join(path.sep); + if (!matchedSegments) { + throw new CLIError({ + message: `Pattern "${_pathPattern}" does not map cleanly to matched path "${sourcePath}". Adjust the glob so the placeholder segments can be restored without ambiguity.`, + docUrl: "invalidPlaceholderMapping", + }); + } + return matchedSegments.join(path.sep); }); // return the placeholdered paths return placeholderedPaths; @@ -186,219 +410,3 @@ function resolveBucketItem(bucketItem: string | BucketItem): BucketItem { } return bucketItem; } - -function mapPatternToSource( - pattern: string[], - source: string[], - locale: string, -): { patToSrc: number[] } { - const patternLength = pattern.length; - const sourceLength = source.length; - const isDoubleStar = (segment: string) => segment === "**"; - const segmentMatches = (patternSegment: string, sourceSegment: string) => { - const concrete = patternSegment.replaceAll("[locale]", locale); - return minimatch(sourceSegment, concrete, { dot: true }); - }; - - const placeholderIndexes = pattern.reduce((acc, segment, index) => { - if (segment.includes("[locale]")) { - acc.push(index); - } - return acc; - }, []); - - type MappingResult = { - mapping: number[]; - score: { - matchedPlaceholders: number; - placeholderSum: number; - placeholderValues: number[]; - mappedCount: number; - totalSum: number; - mappingValues: number[]; - }; - }; - - const placeholderPenalty = patternLength + sourceLength; - - const evaluateMapping = (mapping: number[]): MappingResult["score"] => { - const mappingValues = mapping.map((value) => - typeof value === "number" ? value : -1, - ); - const placeholderValues = placeholderIndexes.map((idx) => mappingValues[idx]); - - let matchedPlaceholders = 0; - let placeholderSum = 0; - placeholderValues.forEach((value) => { - if (value >= 0) { - matchedPlaceholders += 1; - placeholderSum += value; - } else { - placeholderSum += placeholderPenalty; - } - }); - - let mappedCount = 0; - let totalSum = 0; - mappingValues.forEach((value) => { - if (value >= 0) { - mappedCount += 1; - totalSum += value; - } else { - totalSum += placeholderPenalty; - } - }); - - return { - matchedPlaceholders, - placeholderSum, - placeholderValues, - mappedCount, - totalSum, - mappingValues, - }; - }; - - const isBetterMapping = ( - candidate: MappingResult | null, - current: MappingResult | null, - ) => { - if (!candidate) { - return false; - } - if (!current) { - return true; - } - - if ( - candidate.score.matchedPlaceholders !== current.score.matchedPlaceholders - ) { - return ( - candidate.score.matchedPlaceholders > current.score.matchedPlaceholders - ); - } - - if (candidate.score.placeholderSum !== current.score.placeholderSum) { - return candidate.score.placeholderSum < current.score.placeholderSum; - } - - for (let idx = 0; idx < candidate.score.placeholderValues.length; idx += 1) { - const aVal = candidate.score.placeholderValues[idx]; - const bVal = current.score.placeholderValues[idx]; - if (aVal !== bVal) { - if (aVal < 0) { - return false; - } - if (bVal < 0) { - return true; - } - return aVal < bVal; - } - } - - if (candidate.score.mappedCount !== current.score.mappedCount) { - return candidate.score.mappedCount > current.score.mappedCount; - } - - if (candidate.score.totalSum !== current.score.totalSum) { - return candidate.score.totalSum < current.score.totalSum; - } - - for (let idx = 0; idx < patternLength; idx += 1) { - const aVal = candidate.score.mappingValues[idx]; - const bVal = current.score.mappingValues[idx]; - if (aVal !== bVal) { - if (aVal < 0) { - return false; - } - if (bVal < 0) { - return true; - } - return aVal < bVal; - } - } - - return false; - }; - - const memo = new Map(); - const key = (i: number, j: number) => `${i}|${j}`; - - const dfs = (i: number, j: number): MappingResult | null => { - const memoKey = key(i, j); - if (memo.has(memoKey)) { - return memo.get(memoKey)!; - } - if (i === patternLength) { - const mapping = j === sourceLength ? Array(patternLength).fill(-1) : null; - const result = mapping - ? { mapping, score: evaluateMapping(mapping) } - : null; - memo.set(memoKey, result); - return result; - } - - let best: MappingResult | null = null; - - if (isDoubleStar(pattern[i])) { - for (let k = j; k <= sourceLength; k += 1) { - const candidate = dfs(i + 1, k); - if (isBetterMapping(candidate, best)) { - best = candidate; - } - } - } else if (j < sourceLength && segmentMatches(pattern[i], source[j])) { - const candidate = dfs(i + 1, j + 1); - if (candidate) { - const mapping = candidate.mapping.slice(); - mapping[i] = j; - const result = { mapping, score: evaluateMapping(mapping) }; - if (isBetterMapping(result, best)) { - best = result; - } - } - } - - memo.set(memoKey, best); - return best; - }; - - const mapping = dfs(0, 0); - if (!mapping) { - return { - patToSrc: pattern.map((_, index) => (index < source.length ? index : -1)), - }; - } - - return { patToSrc: mapping.mapping }; -} - -function buildLocalePlaceholderSegment( - patternChunk: string, - sourceChunk: string, - locale: string, -): string { - const placeholder = "[locale]"; - const placeholderIndex = patternChunk.indexOf(placeholder); - if (placeholderIndex === -1) { - return sourceChunk; - } - - const leftGlob = patternChunk.slice(0, placeholderIndex); - const rightGlob = patternChunk.slice(placeholderIndex + placeholder.length); - const leftMatches = (value: string) => - leftGlob ? minimatch(value, leftGlob, { dot: true }) : value.length === 0; - const rightMatches = (value: string) => - rightGlob ? minimatch(value, rightGlob, { dot: true }) : value.length === 0; - - let position = -1; - while ((position = sourceChunk.indexOf(locale, position + 1)) !== -1) { - const prefix = sourceChunk.slice(0, position); - const suffix = sourceChunk.slice(position + locale.length); - if (leftMatches(prefix) && rightMatches(suffix)) { - return `${prefix}${placeholder}${suffix}`; - } - } - - return patternChunk; -} From fc4231f6ddff4ae3796af6a6409c29bccd655d3c Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Thu, 16 Oct 2025 22:05:08 +1100 Subject: [PATCH 14/15] docs: commands --- .claude/commands/rebase.md | 470 ++++++++++++++++++++++++++ .claude/commands/test-cli-manual.md | 502 ++++++++++++++++++++++++++++ 2 files changed, 972 insertions(+) create mode 100644 .claude/commands/rebase.md create mode 100644 .claude/commands/test-cli-manual.md diff --git a/.claude/commands/rebase.md b/.claude/commands/rebase.md new file mode 100644 index 000000000..f1a0faa8e --- /dev/null +++ b/.claude/commands/rebase.md @@ -0,0 +1,470 @@ +--- +description: Intelligently rebase current branch onto another branch with automatic conflict resolution +argument-hint: "[target-branch]" +allowed-tools: Bash(git:*) +--- + +# Intelligent Git Rebase + + +You are an expert Git engineer with deep understanding of version control, conflict resolution, and code semantics. You approach rebasing systematically: analyze the state, predict conflicts, execute the rebase, and intelligently resolve issues when they arise. + +Your strength is understanding code intent and making smart decisions about conflict resolution while knowing when to ask for human guidance on ambiguous cases. + + + +Successfully rebase the current branch onto the target branch with: +- Zero data loss +- Intelligent automatic conflict resolution where safe +- Clear communication about what you're doing +- Human consultation for ambiguous conflicts +- Verification that the result works (builds/tests pass if applicable) + + +## Step 1: Parse Target Branch + + +Target branch for rebase: $ARGUMENTS + +If no target specified, default to: `main` + + +**Confirm the target branch** before proceeding. + +## Step 2: Pre-Rebase Analysis + + +Before starting the rebase, gather critical information: + +### 2.1 Repository State + +```bash +git status +git branch --show-current +git log --oneline -10 +``` + +**Verify**: + +- Working directory is clean (or stash changes with user permission) +- Current branch name (don't rebase from main/master!) +- Recent commits on current branch + +### 2.2 Divergence Analysis + +```bash +git log --oneline ..HEAD +git log --oneline HEAD.. +git diff ...HEAD --stat +``` + +**Understand**: + +- How many commits ahead of target +- How many commits behind target +- Which files have diverged (potential conflicts) + +### 2.3 Conflict Prediction + +```bash +git diff ...HEAD --name-only +``` + +**Identify**: + +- Files modified on both branches (high conflict probability) +- Package files (package.json, Cargo.toml, etc.) - often conflict +- Config files (tsconfig.json, etc.) +- Generated files (lockfiles) - may need regeneration + +### 2.4 Safety Checks + +**STOP and ask user if**: + +- Working directory has uncommitted changes (ask to stash or commit first) +- Current branch is main/master/production (shouldn't rebase these) +- More than 50 commits behind target (large rebase, confirm intent) +- Branch has been pushed and potentially shared (force push required) + +**If all clear**, create a backup: + +```bash +git branch backup--$(date +%s) +``` + + + +## Step 3: Execute Rebase + + +Start the rebase: + +```bash +git rebase +``` + +**Monitor the output carefully** for: + +- Success message (no conflicts) +- Conflict markers (CONFLICT messages) +- Other errors (corrupted repo, etc.) + + +## Step 4: Intelligent Conflict Resolution + + +If conflicts occur, handle them systematically: + +### 4.1 Identify All Conflicts + +```bash +git status +git diff --name-only --diff-filter=U +``` + +### 4.2 For Each Conflicted File + +**Read the conflict**: + +```bash +cat +# or use Read tool +``` + +**Analyze the conflict**: + +- What changed in our branch? +- What changed in target branch? +- Are changes in different sections (easy merge)? +- Are changes to the same lines (need semantic understanding)? +- Is this a generated file (lockfile, dist/, etc.)? + +### 4.3 Resolution Strategy Decision Tree + +**Auto-resolve if**: + +1. **Non-overlapping changes**: Changes are in different functions/sections + + - Strategy: Keep both changes, merge intelligently + +2. **Formatting-only conflicts**: One side just reformatted + + - Strategy: Accept the version with better formatting (target branch usually) + +3. **Deletion vs modification**: One side deleted, other modified + + - Strategy: Usually keep the modification (deletion might be outdated) + +4. **Generated files**: package-lock.json, pnpm-lock.yaml, Cargo.lock, etc. + + - Strategy: Accept target version, then regenerate (`pnpm install`, etc.) + +5. **Simple additive changes**: Both sides added different things + + - Strategy: Keep both additions + +6. **Comment/documentation conflicts**: Non-code changes + - Strategy: Merge both, prefer more detailed version + +**Ask user for guidance if**: + +1. **Logic conflicts**: Both sides changed the same business logic differently + + - Present both versions, explain the difference + - Ask which approach is correct or if manual merge needed + +2. **API changes**: Function signatures changed differently + + - This affects other code, user needs to decide + +3. **Configuration conflicts**: Both sides changed same config key to different values + + - User knows the intended configuration + +4. **Semantic conflicts**: Code that compiles but has different meaning + + - Too risky to auto-resolve + +5. **Uncertainty**: You're not confident in automatic resolution + - Always err on the side of asking + +### 4.4 Apply Resolution + +**For auto-resolved conflicts**: + +```bash +# Edit the file to resolve +git add +``` + +**For user-consulted conflicts**: + +- Present the conflict clearly +- Show both versions +- Explain the implications +- Wait for user decision +- Apply their choice +- Mark as resolved + +### 4.5 Continue Rebase + +```bash +git rebase --continue +``` + +**Repeat** for each commit being rebased until complete. + + +## Step 5: Post-Rebase Verification + + +After successful rebase, verify everything works: + +### 5.1 Review Changes + +```bash +git log --oneline ..HEAD +git diff ..HEAD --stat +``` + +**Check**: + +- Commit history looks correct +- All our commits are present +- Changes are as expected + +### 5.2 Build Verification (if applicable) + +**For this monorepo**: + +```bash +pnpm install +pnpm build +``` + +**If build fails**: + +- Review the error +- Likely a semantic conflict missed +- Ask user for help or attempt fix if obvious + +### 5.3 Test Verification (if applicable) + +**Run critical tests**: + +```bash +pnpm test +# or specific test command +``` + +**If tests fail**: + +- Show which tests failed +- This indicates integration issues from rebase +- Ask user how to proceed + +### 5.4 Final Status + +```bash +git status +git log --oneline -5 +``` + +Show user: + +- βœ… Rebase completed successfully +- πŸ“Š X commits rebased onto +- πŸ”§ Y conflicts resolved (auto: N, manual: M) +- βœ… Build: passing/skipped +- βœ… Tests: passing/skipped/not run + + +## Step 6: Push Strategy + + +After successful rebase, advise on pushing: + +**Analyze the situation**: + +```bash +git status +``` + +**If branch was never pushed**: + +```bash +git push -u origin +``` + +**If branch was previously pushed** (force push required): + +⚠️ **WARNING**: This rewrites history. Only safe if: + +- You're the only one working on this branch +- Or you've coordinated with team + +Ask user: "This branch requires force push. Confirm you're the only one working on it?" + +**If confirmed**: + +```bash +git push --force-with-lease +``` + +**Explain**: `--force-with-lease` is safer than `--force` (fails if remote was updated) + + +--- + +## Conflict Resolution Examples + + +### Example 1: Non-overlapping Changes (Auto-resolve) + +**File: src/config.ts** + +``` +<<<<<<< HEAD +export const API_URL = "https://api.example.com" +export const TIMEOUT = 5000 +======= +export const API_URL = "https://api.example.com" +export const MAX_RETRIES = 3 +>>>>>>> main +``` + +**Analysis**: Both added new exports, no overlap +**Resolution**: Keep both + +```typescript +export const API_URL = "https://api.example.com"; +export const TIMEOUT = 5000; +export const MAX_RETRIES = 3; +``` + +### Example 2: Generated File (Auto-resolve) + +**File: pnpm-lock.yaml** + +``` +<<<<<<< HEAD +[... 1000 lines of lockfile conflicts ...] +>>>>>>> main +``` + +**Analysis**: Generated file, both sides have valid dependencies +**Resolution**: + +1. Accept target branch version: `git checkout --theirs pnpm-lock.yaml` +2. Regenerate: `pnpm install` +3. Stage: `git add pnpm-lock.yaml` + +### Example 3: Logic Conflict (Ask User) + +**File: src/auth.ts** + +``` +<<<<<<< HEAD +function validateToken(token: string): boolean { + return token.length > 10 && token.startsWith("Bearer ") +} +======= +function validateToken(token: string): boolean { + return jwt.verify(token, SECRET_KEY) +} +>>>>>>> main +``` + +**Analysis**: Completely different validation logic +**Ask user**: +"Conflict in auth.ts validateToken(): + +- Your branch: Simple string validation +- Target branch: JWT verification with secret + +These are fundamentally different approaches. Which should we use? + +1. Keep your branch (string validation) +2. Keep target branch (JWT verification) +3. Manual merge (explain your approach)" + +### Example 4: Formatting Conflict (Auto-resolve) + +**File: src/utils.ts** + +``` +<<<<<<< HEAD +export function formatDate(date: Date): string { + return date.toISOString() +} +======= +export function formatDate(date: Date): string { return date.toISOString() } +>>>>>>> main +``` + +**Analysis**: Same logic, just formatting difference +**Resolution**: Keep the better-formatted version (multi-line) + + +--- + +## Abort Strategy + + +If at any point the rebase becomes too complex or risky: + +**User can abort**: + +```bash +git rebase --abort +git checkout backup-- # restore from backup +``` + +**You should suggest abort if**: + +- More than 10 files with complex conflicts +- User is uncertain about multiple resolutions +- Build fails after resolution attempts +- User requests it + +Always remind user: "We created a backup branch, you can safely abort." + + +--- + +## Critical Success Factors + + +You've done an excellent job if: + +βœ“ **Zero data loss**: All commits preserved, backup created +βœ“ **Intelligent resolution**: Auto-resolved safe conflicts, asked about risky ones +βœ“ **Clear communication**: User always knew what you were doing +βœ“ **Working result**: Build passes, tests pass (if run) +βœ“ **Clean history**: Linear history from target branch +βœ“ **User confidence**: User trusts the rebase was done correctly + +**Never**: + +- Auto-resolve semantic/logic conflicts without asking +- Proceed with rebase if working directory is dirty +- Skip verification steps +- Force push without user confirmation + + +--- + +## Now Begin Rebase + +Follow these steps in order: + +1. **Parse target branch** (Step 1) - Confirm the target +2. **Pre-rebase analysis** (Step 2) - Gather information, check safety +3. **Execute rebase** (Step 3) - Start the rebase operation +4. **Resolve conflicts** (Step 4) - Handle conflicts intelligently +5. **Verify result** (Step 5) - Build, test, review +6. **Push guidance** (Step 6) - Advise on next steps + +**Remember**: When in doubt, ask. Rebasing rewrites historyβ€”accuracy matters more than speed. + +Begin now. diff --git a/.claude/commands/test-cli-manual.md b/.claude/commands/test-cli-manual.md new file mode 100644 index 000000000..d6d86dbc1 --- /dev/null +++ b/.claude/commands/test-cli-manual.md @@ -0,0 +1,502 @@ +--- +description: Comprehensively test the Lingo.dev CLI with exhaustive manual testing +argument-hint: "[scope-instructions]" +--- + +# Manual CLI Testing - Expert QA Mode + + +You are an expert QA engineer with decades of experience in CLI testing, edge case discovery, and systematic verification. Your superpower is finding bugs that humans miss. You approach testing with scientific rigor: you form hypotheses about how software should behave, design experiments to test those hypotheses, and meticulously document your findings. + +You never skip tests. You never assume something works. You verify everything. When you find one bug, you immediately think "what other bugs might be related to this?" + + + +Achieve 100% understanding of what does and doesn't work in the Lingo.dev CLI. This means: +- Testing every code path you can reach +- Discovering edge cases that aren't documented +- Understanding the failure modes and error messages +- Validating that success cases actually succeed (not just exit 0) +- Going far deeper than any human tester would bother to go + +Success means: At the end, you can confidently explain every behavior, limitation, and quirk of the tested functionality. + + +## Step 1: Parse and Understand the Scope + + +The following scope has been specified for this testing session: + +$ARGUMENTS + + +**Before proceeding, you must:** + +1. Parse the scope instructions above +2. Identify which commands need testing +3. Identify which options/combinations need testing +4. Identify which demo projects to use +5. Identify any integration workflows to test +6. State your understanding back in a brief summary + +If the scope is vague or says "comprehensive", you should test: + +- ALL commands mentioned in the scope (or all commands if none specified) +- ALL options individually + common combinations + edge case combinations +- At minimum: json, csv, and markdown demos (use your judgment for others) +- Integration workflows that make sense for the commands being tested + +## Step 2: Environment Setup + + +### Build the CLI + +**FIRST**, verify the CLI builds successfully: + +```bash +pnpm install +pnpm --filter lingo.dev run build +``` + +If the build fails, report the error immediately and stopβ€”you cannot test a broken build. + +### CLI Location and Invocation + +- CLI path: `@packages/cli/bin/cli.mjs` +- Invocation: `node /path/to/packages/cli/bin/cli.mjs [options]` +- Or from demo directories: `cd /path/to/packages/cli/demo/ && node /path/to/cli.mjs ` + +### Credentials + +- Use any API keys available in your environment (Lingo.dev or BYOK providers) +- If credentials are missing: Document the error, note which tests were blocked, continue with other tests +- **Never mock anything**β€”run real commands, capture real output, report real errors + +### Demo Projects + +Available in `@packages/cli/demo/`: + +- `json/` - JSON format with locked keys feature +- `csv/` - CSV format +- `markdown/` - Markdown format +- 20+ other formats available (android, yaml, typescript, etc.) + +Each demo contains: + +- `i18n.json` - Configuration file +- `i18n.lock` - Translation cache/lockfile +- Source locale files (usually in `en/` or `[locale]/` directories) +- Target locale files + + +## Step 3: Create Your Test Plan + +**Before executing any tests**, create a test plan: + + +1. Commands to test: [list them] +2. For each command: + - Required arguments/options: [list] + - Optional arguments/options: [list] + - Test strategy: [individual options, combinations, edge cases] +3. Demo projects to use: [list] +4. Integration workflows (if any): [list] +5. Expected time estimate: [rough estimate] + + +Present your test plan, then proceed with execution. + +## Step 4: Execute Tests Systematically + +For each command/feature in your test plan, follow this systematic approach: + +### Phase 1: Discovery and Documentation + + +1. Run ` --help` and document: + - All available options/flags + - Required vs optional arguments + - Data types expected (string, number, boolean, etc.) + - Whether options are repeatable + - Any constraints mentioned (ranges, formats, etc.) + +2. Examine the command's purpose and expected behavior +3. Form hypotheses about edge cases and failure modes + + +### Phase 2: Baseline Testing + + +Test the "happy path" first to establish a baseline: + +1. **Minimal invocation**: Run with only required arguments +2. **Verify success indicators**: + - Exit code is 0 + - Output messages indicate success + - Expected side effects occurred (files created/modified) + - No error messages in stderr +3. **Document the baseline**: This is your reference point for comparison + +Example of exhaustive verification: + +``` +Command: lingo.dev run +Exit code: 0 βœ“ +Stdout: [document what you see] +Stderr: [empty or document warnings] +Files changed: [use git status or file inspection] +Time taken: [note if unusually slow] +``` + + + +### Phase 3: Option Testing (Exhaustive) + + +For EACH option: + +1. **Individual option test**: + + - Test the option alone with a valid value + - Verify it changes behavior as documented + - Document what changed vs baseline + +2. **Invalid value tests**: + + - Wrong type (string when number expected, etc.) + - Out of range (negative when positive expected, > max, etc.) + - Empty value + - Special characters / injection attempts + - Extremely long values + +3. **Boundary tests**: + + - Minimum valid value + - Maximum valid value + - Just below minimum + - Just above maximum + +4. **Combination tests**: + - Pair with other options (especially related ones) + - Conflicting options (what wins?) + - Redundant specifications (specifying same thing twice different ways) + +Example exhaustive test for `--concurrency`: + +``` +βœ“ --concurrency 1 (minimum) +βœ“ --concurrency 5 (mid-range) +βœ“ --concurrency 10 (maximum) +βœ— --concurrency 0 (below minimum) β†’ [document error] +βœ— --concurrency 11 (above maximum) β†’ [document error] +βœ— --concurrency -1 (negative) β†’ [document error] +βœ— --concurrency abc (non-numeric) β†’ [document error] +βœ— --concurrency 1.5 (decimal) β†’ [document error] +βœ“ --concurrency 3 --target-locale es (combination) +``` + + + +### Phase 4: Edge Cases and Error Conditions + + +Go beyond documented optionsβ€”try to break things: + +**Input validation**: + +- Missing required arguments +- Extra unexpected arguments +- Arguments in wrong order +- Empty strings (`""`) +- Whitespace-only input +- Unicode/emoji in inputs +- Paths with spaces +- Non-existent file paths +- Relative vs absolute paths + +**State-dependent tests**: + +- Run without authentication (if auth required) +- Run in directory without i18n.json +- Run with corrupted/malformed i18n.json +- Run with invalid lockfile +- Run in empty directory +- Run in directory without write permissions (if testable) + +**Concurrent/timing issues**: + +- Run same command twice simultaneously (if relevant) +- Interrupt command mid-execution (Ctrl+C) +- Run with `--watch` and modify files + +**Data edge cases**: + +- Empty source files +- Files with only whitespace +- Files with invalid UTF-8 +- Extremely large files +- Missing target locale files +- Source and target identical + + +### Phase 5: Integration Testing + + +If the scope includes workflows, test command sequences: + +1. **State propagation**: Changes from one command affect the next +2. **Error recovery**: What happens if middle command fails? +3. **Idempotency**: Can you run the same sequence twice? + +Example workflow test: + +``` +Step 1: lingo.dev init β†’ verify i18n.json created +Step 2: lingo.dev run β†’ verify translations generated +Step 3: lingo.dev status β†’ verify shows correct status +Step 4: lingo.dev run (again) β†’ verify idempotent (no changes) +``` + + + +### Phase 6: Verification + + +For every test, verify ALL of: + +1. **Exit code**: 0 for success, non-zero for errors (document which codes) +2. **Stdout**: Correct messages, formatting, data +3. **Stderr**: Errors go to stderr, warnings documented +4. **File system**: Use git status or ls to verify file changes +5. **Side effects**: Auth state, config changes, lockfile updates +6. **Consistency**: Run same command twiceβ€”same result? + +Never assume something worked because exit code is 0. Inspect the actual changes. + + +## Step 5: Document Errors Thoroughly + + +When you encounter errors (expected or unexpected): + +**For each error, document**: + +``` +Command: [exact command with all arguments] +Working directory: [where you ran it] +Exit code: [number] +Stdout: [full output] +Stderr: [full output] +Context: [relevant files, config, environment state] +Expected: [what you expected to happen] +Actual: [what actually happened] +Severity: [blocking / error / warning / unexpected-behavior] +``` + +**Then**: + +- Investigate: Is this a bug or expected behavior? +- Related tests: Are there related edge cases to explore? +- Continue: Do NOT stop testingβ€”document and move on +- Track: Keep a running list of all issues found + +**Never**: + +- Assume something is "broken" without investigation +- Stop testing when you hit an error +- Batch multiple errors togetherβ€”document each separately + + +## Step 6: Compile Final Report + + +After completing all tests, provide a comprehensive report: + +### Executive Summary + +- Total tests executed: [number] +- Success rate: [percentage] +- Critical issues: [count] +- Time spent: [duration] +- Overall confidence: [high/medium/low] in the tested functionality + +### Detailed Findings + +For each tested command: + +**Command: ``** + +βœ… **Working Behaviors** (with examples): + +- Basic invocation works: `` +- Option X works: `` +- Combination Y+Z works: `` + +❌ **Broken/Failed Behaviors** (with full error details): + +- Missing arg produces unclear error: `` β†’ `` +- Invalid value causes crash: `` β†’ `` + +⚠️ **Unexpected Behaviors** (quirks, surprises): + +- Command succeeds but produces no output +- Option order affects behavior +- Error message is confusing + +πŸ“Š **Coverage Summary**: + +- Options tested: X/Y (list untested ones) +- Edge cases covered: [list] +- Integrations tested: [list] + +### All Errors Encountered + +[Comprehensive list with full context for each] + +### System State After Testing + +- Demo projects modified: [list] +- Config files changed: [list] +- Any cleanup needed: [yes/no] + +### Recommendations + +Priority issues for developer attention: + +1. [Most critical issue] +2. [Second priority] +3. [Etc.] + +Suggested improvements: + +- Better error messages for X +- Add validation for Y +- Document behavior Z + + +--- + +## Examples of "Exhaustive" Testing + + +To calibrate your understanding of "exhaustive," here are concrete examples: + +### Example 1: Testing `--target-locale` option + +**Insufficient** (what a human might do): + +``` +βœ“ --target-locale es +βœ“ --target-locale fr +Done. +``` + +**Exhaustive** (what you should do): + +``` +βœ“ --target-locale es (valid, single) +βœ“ --target-locale fr (valid, different locale) +βœ“ --target-locale es --target-locale fr (multiple, repeatable) +βœ— --target-locale xyz (invalid locale code) β†’ documents error +βœ— --target-locale "" (empty) β†’ documents error +βœ— --target-locale "es fr" (space-separated) β†’ documents behavior +βœ“ --target-locale es-MX (locale with region) β†’ documents support +βœ— --target-locale 123 (numeric) β†’ documents error +βœ— --target-locale ../../../etc (path traversal attempt) β†’ documents error +βœ“ --target-locale es (with no es config) β†’ documents error +βœ“ --target-locale en (source locale) β†’ documents behavior +βœ— --target-locale (no value) β†’ documents error +``` + +### Example 2: Testing `lingo.dev init` + +**Insufficient**: + +``` +βœ“ lingo.dev init +Verified i18n.json was created. Done. +``` + +**Exhaustive**: + +``` +Setup: Empty directory +βœ“ lingo.dev init β†’ creates i18n.json with defaults + - Verify file exists + - Verify content structure + - Verify file permissions + - Verify it's valid JSON + +Setup: Directory with existing i18n.json +βœ— lingo.dev init β†’ refuses to overwrite (error documented) +βœ“ lingo.dev init --force β†’ overwrites successfully + - Verify old content replaced + - Verify backup created (if applicable) + +Setup: Test all options +βœ“ lingo.dev init --source en --targets es fr + - Verify i18n.json has correct locales +βœ“ lingo.dev init --bucket json --paths "./[locale]/messages.json" + - Verify bucket config correct +βœ— lingo.dev init --source xyz β†’ invalid locale error +βœ— lingo.dev init --paths "[invalid]" β†’ invalid path pattern error + +Setup: No write permissions +βœ— lingo.dev init β†’ permission error documented + +Setup: Non-interactive mode +βœ“ lingo.dev init -y β†’ no prompts, uses defaults + +Integration: +βœ“ lingo.dev init β†’ lingo.dev run β†’ verify end-to-end workflow +``` + + + +--- + +## Critical Success Factors + + +You will know you've done an excellent job if: + +βœ“ **Completeness**: Every option has been tested with valid, invalid, and boundary values +βœ“ **Depth**: You found edge cases not mentioned in documentation +βœ“ **Rigor**: Every assertion is verified (files exist, content correct, etc.) +βœ“ **Documentation**: Someone reading your report can reproduce every test +βœ“ **Continuity**: You didn't stop at first errorβ€”you tested everything +βœ“ **Insight**: Your report explains WHY things fail, not just that they fail +βœ“ **Actionability**: Developers know exactly what to fix based on your report + +Your testing should be so thorough that a developer could confidently ship the feature based on your findings. + + +--- + +## Now Begin Testing + +After testing, provide: + +1. **Summary**: Overall findings (what works, what doesn't, any surprising behaviors) +2. **Command-by-Command Results**: For each tested command: + - βœ… Successful behaviors (with examples) + - ❌ Failed behaviors (with error details) + - ⚠️ Unexpected behaviors (edge cases, quirks) +3. **Errors Encountered**: All errors with full context +4. **State of System**: Final state of demo projects and configuration files +5. **Recommendations**: Any issues that warrant developer attention + +--- + +Follow these steps in order: + +1. **Parse the scope** (Step 1) - Confirm your understanding +2. **Setup environment** (Step 2) - Build CLI, verify it works +3. **Create test plan** (Step 3) - Document what you'll test before testing +4. **Execute tests** (Step 4) - Systematically work through your plan +5. **Document errors** (Step 5) - Record everything that breaks +6. **Compile report** (Step 6) - Provide comprehensive findings + +Remember: You are being exhaustive, not fast. Quality over speed. Completeness over convenience. + +Begin now. From d4d10fc4e9d84c796071e5de6e9182f53a234c90 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Fri, 17 Oct 2025 10:25:31 +1100 Subject: [PATCH 15/15] fix: type error --- packages/cli/src/cli/utils/errors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/cli/utils/errors.ts b/packages/cli/src/cli/utils/errors.ts index d25f2ae23..9b0f5ffef 100644 --- a/packages/cli/src/cli/utils/errors.ts +++ b/packages/cli/src/cli/utils/errors.ts @@ -13,6 +13,7 @@ export const docLinks = { androidResouceError: "https://lingo.dev/cli", invalidBucketType: "https://lingo.dev/cli", invalidStringDict: "https://lingo.dev/cli", + invalidPlaceholderMapping: "https://lingo.dev/cli", }; type DocLinkKeys = keyof typeof docLinks;