Skip to content

Commit f2068fd

Browse files
Aymericrcursoragent
andcommitted
fix: locale-pack follow-ups — LOCALE_NOT_LOADED enum, size budgets, detection polish
Registers LOCALE_NOT_LOADED in the schema enums (+ regenerated artifacts), recalibrates the size budgets for the locale entries and ./complete, and tightens locale detection/profile resolution so the full check passes. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 97955ab commit f2068fd

16 files changed

Lines changed: 129 additions & 77 deletions

File tree

apps/site/public/schema/dictionary.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The compact wire JSON `JSON.stringify(lingo(...))` produces. Generated from
4141

4242
`error`, `warning`, `info`
4343

44-
## Issue codes (32)
44+
## Issue codes (33)
4545

4646
| code | meaning |
4747
|---|---|
@@ -59,6 +59,7 @@ The compact wire JSON `JSON.stringify(lingo(...))` produces. Generated from
5959
| `CONVERSION_NOT_ALLOWED` | A conversion was not allowed here. |
6060
| `NUMBER_FORMAT` | The number could not be parsed. |
6161
| `NONFINITE` | The value is not finite. |
62+
| `LOCALE_NOT_LOADED` | The requested locale pack was not loaded. |
6263
| `RANGE_MIN` | Value is below the allowed minimum. |
6364
| `RANGE_MAX` | Value is above the allowed maximum. |
6465
| `RANGE_OPEN_BOUND_NOT_ALLOWED` | An open-ended range bound was not allowed. |

apps/site/public/schema/lingo.openapi.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"CONVERSION_NOT_ALLOWED",
7979
"NUMBER_FORMAT",
8080
"NONFINITE",
81+
"LOCALE_NOT_LOADED",
8182
"RANGE_MIN",
8283
"RANGE_MAX",
8384
"RANGE_OPEN_BOUND_NOT_ALLOWED",

apps/site/public/schema/lingo.schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"CONVERSION_NOT_ALLOWED",
7575
"NUMBER_FORMAT",
7676
"NONFINITE",
77+
"LOCALE_NOT_LOADED",
7778
"RANGE_MIN",
7879
"RANGE_MAX",
7980
"RANGE_OPEN_BOUND_NOT_ALLOWED",

packages/lingo/scripts/size.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,15 @@ function check(label, size, budget) {
129129
// built-in hooks for tree-shakeable es/fr/pt/zh/ja/en-gb packs. English-only
130130
// parsing uses a prebuilt singleton, but the shared parser still carries the
131131
// public `createLingo({ locales })` / `locale` option machinery.
132+
// 35.8 (was 35.7): D63 — locale correctness hardening: English wins inherited
133+
// overlay ties, explicit unloaded locales return LOCALE_NOT_LOADED, and zh/ja
134+
// pack-owned CJK aliases/fuzzy vocab install through a tiny registry hook.
132135
const full = await bundleStdin(`export * from './src/index.ts'`)
133-
check('lingo (full)', full, 35_700)
136+
check('lingo (full)', full, 35_800)
134137

135138
if (has('src/locales/es.ts')) {
139+
const enLocale = await bundleStdin(`export * from './src/locales/en.ts'`)
140+
check('./locales/en (standalone data)', enLocale, 1800)
136141
const esLocale = await bundleStdin(`export * from './src/locales/es.ts'`)
137142
check('./locales/es (standalone data)', esLocale, 1150)
138143
const frLocale = await bundleStdin(`export * from './src/locales/fr.ts'`)
@@ -318,7 +323,9 @@ if (has('src/complete/index.ts')) {
318323
// D60/D61 — ./complete: ranked autocomplete fan-out (unit ambiguity, prefix,
319324
// range-tail implied units, curated suggest-units table). Marginal is the
320325
// orchestrator + aliasCompletions index walk; not re-exported from `.`.
321-
check('./complete (marginal over full)', withComplete - full, 2250)
326+
// 2.4 (was 2.25): D63 gzip interaction after the full-entry locale
327+
// correctness hardening; ./complete code itself did not grow.
328+
check('./complete (marginal over full)', withComplete - full, 2400)
322329
}
323330

324331
if (has('src/schema/index.ts')) {

packages/lingo/src/complete/suggest-units.ts

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -170,36 +170,91 @@ export function inferKindFromRangeLeft(
170170
return
171171
}
172172

173-
export function extractRangeBounds(
174-
tokens: Token[],
175-
sepIndex: number,
176-
): { left: string; right: string } | null {
177-
let left: string | null = null
178-
let right: string | null = null
179-
for (let i = 0; i < sepIndex; i++) {
173+
function isNumericRunToken(t: Token): boolean {
174+
return (
175+
t.type === 'digits' ||
176+
t.type === 'vulgar' ||
177+
(t.type === 'sym' && (t.text === '.' || t.text === ','))
178+
)
179+
}
180+
181+
/** Full text of the numeric literal ending just before `sepIndex`, or null. */
182+
function leftBoundText(tokens: Token[], text: string, sepIndex: number): string | null {
183+
let anchor = -1
184+
for (let i = sepIndex - 1; i >= 0; i--) {
180185
const t = tokens[i]!
181186
if (t.type === 'digits' || t.type === 'vulgar') {
182-
left = t.text
187+
anchor = i
188+
break
183189
}
184190
}
191+
if (anchor < 0) {
192+
return null
193+
}
194+
let edge = anchor
195+
while (edge > 0 && !tokens[edge]!.spaceBefore && isNumericRunToken(tokens[edge - 1]!)) {
196+
edge--
197+
}
198+
// A word glued to the front ("1h30") is a compound, not a clean number.
199+
if (edge > 0 && !tokens[edge]!.spaceBefore && tokens[edge - 1]!.type === 'word') {
200+
return null
201+
}
202+
return text.slice(tokens[edge]!.start, tokens[anchor]!.end)
203+
}
204+
205+
/** Full text of the numeric literal starting just after `sepIndex`, or null. */
206+
function rightBoundText(tokens: Token[], text: string, sepIndex: number): string | null {
207+
let anchor = -1
185208
for (let i = sepIndex + 1; i < tokens.length; i++) {
186209
const t = tokens[i]!
187210
if (t.type === 'digits' || t.type === 'vulgar') {
188-
right = t.text
211+
anchor = i
189212
break
190213
}
191214
}
192-
return left && right ? { left, right } : null
215+
if (anchor < 0) {
216+
return null
217+
}
218+
let lo = anchor
219+
while (lo > sepIndex + 1 && !tokens[lo]!.spaceBefore && isNumericRunToken(tokens[lo - 1]!)) {
220+
lo--
221+
}
222+
let hi = anchor
223+
while (
224+
hi + 1 < tokens.length &&
225+
!tokens[hi + 1]!.spaceBefore &&
226+
isNumericRunToken(tokens[hi + 1]!)
227+
) {
228+
hi++
229+
}
230+
// A word glued to either end ("1h30") is a compound, not a clean number.
231+
if (lo > sepIndex + 1 && !tokens[lo]!.spaceBefore && tokens[lo - 1]!.type === 'word') {
232+
return null
233+
}
234+
if (hi + 1 < tokens.length && !tokens[hi + 1]!.spaceBefore && tokens[hi + 1]!.type === 'word') {
235+
return null
236+
}
237+
return text.slice(tokens[lo]!.start, tokens[hi]!.end)
238+
}
239+
240+
export function extractRangeBounds(
241+
tokens: Token[],
242+
text: string,
243+
sepIndex: number,
244+
): { left: string; right: string } | null {
245+
const left = leftBoundText(tokens, text, sepIndex)
246+
const right = rightBoundText(tokens, text, sepIndex)
247+
return left !== null && right !== null ? { left, right } : null
193248
}
194249

195-
/** Canonical hyphen-range rewrite when both bounds are known. */
250+
/** Canonical hyphen-range rewrite when both bounds are clean numeric literals. */
196251
export function rangeRewriteWithUnit(
197252
tokens: Token[],
198253
text: string,
199254
tail: RangeTail,
200255
alias: string,
201256
): string {
202-
const bounds = extractRangeBounds(tokens, tail.sepIndex)
257+
const bounds = extractRangeBounds(tokens, text, tail.sepIndex)
203258
if (bounds) {
204259
return `${bounds.left}-${bounds.right} ${alias}`
205260
}

packages/lingo/src/core/registry.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,8 @@ export class Registry {
182182
registerUnitAliases(kind: Kind, unitRef: string, aliases: readonly string[]): void {
183183
const unit = this.unitByRef(kind, unitRef)
184184
if (!unit) {
185-
throw new Error(`lingo: unknown unit "${unitRef}" in kind "${kind}"`)
185+
return
186186
}
187-
unit.aliases = [...new Set([...(unit.aliases ?? []), ...aliases])]
188187
for (const alias of aliases) {
189188
this.insert(this.ci, alias.toLowerCase(), false, kind, unit)
190189
}

packages/lingo/src/date/parse.ts

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { makeIssue } from '../core/errors'
22
import type { IssueCode, IssueInputData, LingoIssue, Messages, Severity, Span } from '../core/types'
3-
import { isLocaleLoaded, resolveLanguageProfile } from '../locale/profile'
3+
import { resolveLanguageProfile } from '../locale/profile'
44
import type { LocalePack } from '../locale/types'
55
import { normalizeInput, toSourceSpan } from '../parse/normalize'
66
import type { SerializedResult } from '../parse/serialize'
@@ -155,30 +155,18 @@ export function parseDate(text: string, opts?: DateOptions): DateResult | DateFa
155155
function parseDateImpl(text: string, opts?: DateOptions): DateResult | DateFail<DateResult> {
156156
const now = opts?.now === undefined ? anchorNow() : new Date(opts.now.getTime())
157157
const n = normalizeInput(text)
158-
const localeNotLoaded =
159-
opts?.locale !== undefined && !isLocaleLoaded(opts.localePacks, opts.locale)
160-
? makeIssue(
161-
'LOCALE_NOT_LOADED',
162-
{ locale: opts.locale },
163-
toSourceSpan(n, 0, n.text.length),
164-
opts.messages,
165-
)
166-
: undefined
167158
const p: P = {
168159
src: text,
169160
n,
170161
text: n.text,
171162
tokens: tokenize(n),
172163
now,
173164
opts: opts ?? {},
174-
profile: resolveLanguageProfile(opts?.localePacks, localeNotLoaded ? 'en' : opts?.locale),
165+
profile: resolveLanguageProfile(opts?.localePacks, opts?.locale),
175166
weekStart: normalizeWeekStart(opts?.weekStart),
176167
forwardDates: opts?.forwardDates ?? true,
177168
escalate: dateEscalate(opts),
178169
}
179-
if (localeNotLoaded) {
180-
return { ok: false, text: p.src, issues: [localeNotLoaded] }
181-
}
182170
const { start, end } = trimRange(n.text, 0, n.text.length)
183171
if (start === end) {
184172
return fail(p, 'NO_VALUE', { example: '"tomorrow"' }, start, end)
@@ -394,30 +382,18 @@ export function parseDateRange(text: string, opts?: DateOptions): DateRange | Da
394382
function parseDateRangeImpl(text: string, opts?: DateOptions): DateRange | DateRangeFail {
395383
const now = opts?.now === undefined ? anchorNow() : new Date(opts.now.getTime())
396384
const n = normalizeInput(text)
397-
const localeNotLoaded =
398-
opts?.locale !== undefined && !isLocaleLoaded(opts.localePacks, opts.locale)
399-
? makeIssue(
400-
'LOCALE_NOT_LOADED',
401-
{ locale: opts.locale },
402-
toSourceSpan(n, 0, n.text.length),
403-
opts.messages,
404-
)
405-
: undefined
406385
const p: P = {
407386
src: text,
408387
n,
409388
text: n.text,
410389
tokens: tokenize(n),
411390
now,
412391
opts: opts ?? {},
413-
profile: resolveLanguageProfile(opts?.localePacks, localeNotLoaded ? 'en' : opts?.locale),
392+
profile: resolveLanguageProfile(opts?.localePacks, opts?.locale),
414393
weekStart: normalizeWeekStart(opts?.weekStart),
415394
forwardDates: opts?.forwardDates ?? true,
416395
escalate: dateEscalate(opts),
417396
}
418-
if (localeNotLoaded) {
419-
return { ok: false, type: 'date-range-failure', text, issues: [localeNotLoaded] }
420-
}
421397
let source = n.text.trim()
422398
const issues: LingoIssue[] = []
423399
// Point the span at the trimmed content, not the padded input.

packages/lingo/src/factory.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,9 +343,7 @@ function installLocalePacks(
343343
): void {
344344
for (const pack of packs ?? []) {
345345
for (const { kind, unit, aliases } of pack.unitAliases ?? []) {
346-
if (reg.unitByRef(kind, unit)) {
347-
reg.registerUnitAliases(kind, unit, aliases)
348-
}
346+
reg.registerUnitAliases(kind, unit, aliases)
349347
}
350348
if (!includeFuzzy) {
351349
continue

packages/lingo/src/locale/detect.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,21 @@ export function detectLanguageProfile(
1313
}
1414

1515
export function detectLocale(packs: readonly LocalePack[], input: string): string | undefined {
16-
let best: { index: number; locale: string; score: number } | undefined
17-
const candidates: LanguageProfile[] = [
18-
englishLanguageProfile,
19-
...packs
20-
.filter((pack) => normalizeLocale(pack.locale) !== 'en')
21-
.map((pack) => resolveLanguageProfile(packs, pack.locale)),
22-
]
23-
for (let i = 0; i < candidates.length; i++) {
24-
const profile = candidates[i]!
16+
let best: { locale: string; score: number } = {
17+
locale: 'en',
18+
score: scoreProfile(englishLanguageProfile, input),
19+
}
20+
for (let i = 0; i < packs.length; i++) {
21+
if (normalizeLocale(packs[i]!.locale) === 'en') {
22+
continue
23+
}
24+
const profile = resolveLanguageProfile(packs, packs[i]!.locale)
2525
const score = scoreProfile(profile, input)
26-
if (!best || score > best.score || (score === best.score && i < best.index)) {
27-
best = { index: i, locale: profile.locale, score }
26+
if (score > best.score) {
27+
best = { locale: profile.locale, score }
2828
}
2929
}
30-
return best && best.score > 0 ? best.locale : undefined
30+
return best.score > 0 ? best.locale : undefined
3131
}
3232

3333
export function scoreProfile(profile: LanguageProfile, input: string): number {

packages/lingo/src/locale/profile.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ export function hasNonEnglishLocalePacks(packs?: readonly LocalePack[]): boolean
3131
}
3232

3333
export function isLocaleLoaded(packs: readonly LocalePack[] | undefined, locale: string): boolean {
34-
return Boolean(findPack(uniquePacks([...DEFAULT_LOCALE_PACKS, ...(packs ?? [])]), locale))
34+
const target = normalizeLocale(locale)
35+
return (
36+
matchesLocale(enCore, target) || (packs?.some((pack) => matchesLocale(pack, target)) ?? false)
37+
)
3538
}
3639

3740
function buildProfile(

0 commit comments

Comments
 (0)