super-productivity/eslint-local-rules/rules/require-text-locale.js
Johannes Millan 57cee868bf
fix(locale): consolidate textLocale, fix planner month label, enforce via lint (#8987) (#9065)
* refactor(locale): collapse inlined textLocale copies into the helper

No behavior change: textLocale() is defined as isoTextLocale() ?? currentLocale(),
which is exactly what these five sites had inlined.

 #9056 was based on master and so could not use the helper #9055 adds — it
re-inlined the expression at focus-session, habit-tracker, worklog,
scheduled-list and scheduled-date-group. Now that both have landed, collapse
them so there is one canonical spelling instead of six.

Caller specs mock textLocale() directly rather than the isoTextLocale/
currentLocale pair, mirroring what each SUT actually calls.

* fix(locale): planner month label followed the browser locale, not the app's

monthLabel passed no locale to toLocaleDateString, so the spelled-out month
followed the *browser's* locale and ignored both the configured date locale
and the UI language: a German browser rendered 'Juli 2026' in an English app.
Same family as #8987 but reachable without the ISO option at all.

Route it through textLocale(), which is the UI language under the ISO option
and currentLocale() otherwise.

The two existing specs computed their expected value with the same undefined
locale, so they mirrored the bug and could never have caught it. They now pin
a fixed app locale, deliberately not the runner's browser locale — otherwise
they would pass either way.

* test(lint): add require-text-locale rule and enforce it over src/app

Guards the #8987 invariant that kept recurring: spelled-out weekday/month
names must be formatted with textLocale(), never currentLocale() (the ISO
option's 'sv' sentinel) and never the implicit browser locale. Three PRs
chased this bug class site-by-site because currentLocale() is the
obvious-looking default at every new call site.

- Resolves `const locale = ...currentLocale()` through the scope chain: that
  is the shape the original bug had in plannedStartDateStr, so a rule matching
  only direct calls would have missed the very bug it exists to prevent.
- Covers new Intl.DateTimeFormat() too — the same trap in constructor form,
  used at ~9 sites. Stays silent on clock times (hour/minute/dayPeriod), which
  must keep currentLocale() so the ISO 24h format survives.
- Specs excluded: computing an expected string against an explicit locale is a
  legitimate test technique; the invariant is about what the product renders.
- Documents its own blind spots (locale threaded through a parameter, reassigned
  variables, non-literal options) and pins them as valid cases, per the
  no-multi-entity-effect convention.

Error severity is safe: textLocale() equals currentLocale() for every non-ISO
option, so for spelled-out names it is never worse. Zero violations remain.

* fix(lint): keep require-text-locale silent on clock-time formats

The rule documented itself as staying silent on clock times, but `dayPeriod`
sat in ALWAYS_SPELLED_OUT and only `.toLocaleTimeString()` was excluded — so
`{ hour, minute, dayPeriod }` via `Intl.DateTimeFormat`/`toLocaleString` did
fire, and its message told the reader to switch to textLocale(). Following
that advice flips the ISO 24h clock to 12h: "13:05" -> "1:05 in the
afternoon". The same holds for any mixed date+time options object:
`{ weekday, hour }` goes from "onsdag 13:05" to "Wednesday 1:05 PM" — a
Swedish name traded for a broken clock, the exact ISO regression this rule
family exists to prevent.

A format that mixes a spelled-out name with a clock has no single correct
locale; it has to be split (names on textLocale(), clock on currentLocale(),
as plannedStartDateStr does), which is more than a one-locale message can
advise. So skip any options object containing `hour`, and say so. This costs
a blind spot on `{ weekday, hour }` — cheaper than confidently wrong advice
at `error` severity.

No call site changes: `dayPeriod`/`era` have zero uses in src/, so this was
latent. All four real bug shapes are still caught; src/app stays at zero
violations.

The two clock-time `valid` cases named dayPeriod in their comments but only
ever tested `{ hour, minute }` in their code, which is how this slipped
through — pin the actual shapes instead.

* fix(lint): catch dateStyle in require-text-locale

The rule missed `dateStyle` entirely, so the canonical #8987 shape walked
straight past it: `toLocaleDateString(currentLocale(), { dateStyle: 'full' })`
renders "onsdag 15 juli 2026" under the sentinel — a spelled-out weekday and
month — without naming weekday or month at all. Zero call sites today, so this
was latent, but guarding call sites that do not exist yet is the rule's whole
job.

`dateStyle` needs its own value set rather than month's: the two invert.
`month: 'short'` is "Jul" (spelled out) but `dateStyle: 'short'` is
"2026-07-15" (numeric), so reusing SPELLED_OUT_VALUES would have flagged
dateStyle:'short' and pushed the reader to route ISO's YYYY-MM-DD through
textLocale() — the mirror of the clock-time trap. Modelled as a per-field map
so the inversion is stated where it can't be conflated, and pinned from both
sides: 'short' as valid, 'full'/'medium'/'long' as invalid. Sabotage-verified —
swapping in month's value set fails the spec.

`timeStyle` joins `hour` as a clock-time field: `{ dateStyle, timeStyle }` is
the mixed date+time case again ("onsdag 15 juli 2026 kl. 13:05" -> "Wednesday,
July 15, 2026 at 1:05 PM"), and it carries no `hour` key for the existing guard
to catch.

Verified: 6/6 real bug shapes flagged, 0/6 false positives on correct usage,
src/app still at zero violations.
2026-07-16 22:33:23 +02:00

230 lines
9.3 KiB
JavaScript

/**
* ESLint rule: require-text-locale
*
* Spelled-out weekday/month names must be formatted with
* `DateTimeFormatService.textLocale()`, never `currentLocale()` and never the
* implicit browser locale.
*
* Why: the ISO 8601 date option persists `dateTimeLocale = 'sv'` as a
* backward-compatible sync marker (it yields YYYY-MM-DD + a 24h clock). ISO has
* no spelled-out names of its own, so any name formatted with `currentLocale()`
* comes out Swedish regardless of the UI language — "ons 15 juli 2026",
* "Weekly on onsdag". That is #8987, which recurred across three PRs (#9013,
* #9055, #9056) because `currentLocale()` is the obvious-looking default at
* every new call site.
*
* `textLocale()` is `isoTextLocale() ?? currentLocale()`, so for every non-ISO
* option it IS `currentLocale()`. For spelled-out names it is therefore never
* worse and sometimes right — which is why this is an error, not a heuristic.
*
* Flagged: `.toLocaleDateString()` / `.toLocaleString()` / `new
* Intl.DateTimeFormat()` whose options contain a spelled-out field (`weekday`,
* `month: 'long'|'short'|'narrow'`, `dateStyle: 'full'|'long'|'medium'`, `era`,
* `dayPeriod`) and no clock time, when the locale argument is `currentLocale()`
* — directly, or via a `const` initialised from it (the shape the original
* #8987 bug had) — or is absent/`undefined`, which silently uses the browser
* locale and ignores both the configured locale AND the UI language.
*
* Deliberately NOT detected (pinned as `valid` cases in the spec so the boundary
* is explicit and a change that starts catching them trips the spec):
* - a locale threaded through a parameter — `formatDayStr(dateStr, locale)`,
* `getWeekdaysMin(locale)`: the rule cannot see what the caller passed, so
* the obligation sits with the caller
* - a reassigned locale variable, or one built by a helper/ternary
* - a non-literal options object (variable or spread)
* - `.toLocaleTimeString()`: a clock time, whose locale is pinned by the 24h
* rule below
* - ANY options object that also formats a clock time (`hour`, `timeStyle`),
* whatever else it renders — `{ hour, minute }`, `{ hour, minute, dayPeriod }`,
* `{ weekday, hour }`, `{ dateStyle, timeStyle }`. See
* `rendersSpelledOutName` for why the rule cannot advise on these.
*
* A clean run does NOT prove a file is free of #8987 — it proves the direct
* call sites are.
*/
/** Options fields that render a spelled-out name whatever their value. */
const ALWAYS_SPELLED_OUT = new Set(['weekday', 'era', 'dayPeriod']);
/**
* Fields that render a spelled-out name only for certain values — each with its
* OWN value set, because the two invert and a shared set would be a bug:
* `month: 'short'` is "Jul" (spelled out) but `dateStyle: 'short'` is
* "2026-07-15" (numeric). Flagging `dateStyle: 'short'` would push the reader to
* route ISO's YYYY-MM-DD through textLocale() and break it — the mirror of the
* clock-time trap below. Values not listed are digits and must keep
* `currentLocale()` so ISO day-first ordering survives.
*/
const SPELLED_OUT_BY_VALUE = new Map([
['month', new Set(['long', 'short', 'narrow'])],
['dateStyle', new Set(['full', 'long', 'medium'])],
]);
/** Options fields that format a clock time. */
const CLOCK_TIME_FIELDS = new Set(['hour', 'timeStyle']);
const NAME_FORMATTERS = new Set(['toLocaleDateString', 'toLocaleString']);
/** The key of a statically-readable options property, or `null`. */
const propKey = (prop) => {
if (prop.type !== 'Property' || prop.computed) return null;
return prop.key.name || prop.key.value;
};
/** True for an options object literal that also formats a clock time. */
const rendersClockTime = (optsNode) =>
optsNode.properties.some((prop) => CLOCK_TIME_FIELDS.has(propKey(prop)));
/** True when the property renders a spelled-out name rather than digits. */
const isSpelledOutProp = (prop) => {
const key = propKey(prop);
if (key === null) return false;
if (ALWAYS_SPELLED_OUT.has(key)) return true;
const spelledOutValues = SPELLED_OUT_BY_VALUE.get(key);
return (
!!spelledOutValues &&
prop.value.type === 'Literal' &&
spelledOutValues.has(prop.value.value)
);
};
/**
* True for an options object literal that renders at least one spelled-out name
* and no clock time.
*
* A clock time in the same options object pins the locale: it renders 24h under
* the ISO `sv` sentinel but 12h under most UI languages, so swapping the whole
* call to `textLocale()` would trade a Swedish name for a broken clock — "onsdag
* 13:05" becomes "Wednesday 1:05 PM", the very ISO regression this rule family
* exists to prevent. Such a format has no single correct locale; it has to be
* split (names on `textLocale()`, clock on `currentLocale()` — see
* `plannedStartDateStr`), which is more than a one-locale message can advise.
* Staying silent costs a blind spot on `{ weekday, hour }`; firing would cost
* confidently wrong advice at `error` severity.
*/
const rendersSpelledOutName = (optsNode) => {
if (!optsNode || optsNode.type !== 'ObjectExpression') return false;
if (rendersClockTime(optsNode)) return false;
return optsNode.properties.some(isSpelledOutProp);
};
/** True for `<anything>.currentLocale()`. */
const isCurrentLocaleCall = (node) =>
node &&
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.property.name === 'currentLocale';
const findVariable = (scope, name) => {
for (let s = scope; s; s = s.upper) {
const found = s.variables.find((v) => v.name === name);
if (found) return found;
}
return null;
};
/**
* True when `node` is `currentLocale()` or an identifier that can only hold its
* result. The single-write check keeps this to variables that are never
* reassigned, so we never guess at a value the rule cannot actually see.
*/
const resolvesToCurrentLocale = (node, scope) => {
if (isCurrentLocaleCall(node)) return true;
if (!node || node.type !== 'Identifier' || !scope) return false;
const variable = findVariable(scope, node.name);
if (!variable || variable.defs.length !== 1) return false;
const def = variable.defs[0];
if (def.type !== 'Variable' || !def.node.init) return false;
if (variable.references.filter((ref) => ref.isWrite()).length !== 1) return false;
return isCurrentLocaleCall(def.node.init);
};
/** `undefined` / omitted / `null` all fall back to the browser's locale. */
const isImplicitLocale = (node) =>
!node ||
(node.type === 'Identifier' && node.name === 'undefined') ||
(node.type === 'Literal' && node.value === null);
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Spelled-out weekday/month names must be formatted with textLocale(), not currentLocale() or the implicit browser locale',
category: 'Possible Errors',
recommended: false,
},
messages: {
numericLocaleForName:
'This formats a spelled-out {{field}} with currentLocale(). Under the ISO 8601 option currentLocale() is the `sv` sentinel, so the name renders in Swedish whatever the UI language (#8987). Use DateTimeFormatService.textLocale() — it equals currentLocale() for every non-ISO option. Numeric-only parts (month: "numeric", dateStyle: "short", day, year) should keep currentLocale().',
implicitLocaleForName:
'This formats a spelled-out {{field}} with no locale, so it follows the *browser* locale and ignores both the configured date locale and the UI language. Use DateTimeFormatService.textLocale().',
},
schema: [],
},
create(context) {
const sourceCode = context.sourceCode || context.getSourceCode();
/** Name the offending field so the message points at the actual culprit. */
const spelledOutField = (optsNode) => {
const prop = optsNode.properties.find(isSpelledOutProp);
return prop ? propKey(prop) : 'name';
};
/** Both `d.toLocaleDateString(locale, opts)` and `new Intl.DateTimeFormat(locale, opts)`. */
const check = (node) => {
const [localeArg, optsArg] = node.arguments;
if (!rendersSpelledOutName(optsArg)) return;
const field = spelledOutField(optsArg);
const scope = sourceCode.getScope ? sourceCode.getScope(node) : null;
if (isImplicitLocale(localeArg)) {
context.report({
node: localeArg || node,
messageId: 'implicitLocaleForName',
data: { field },
});
return;
}
if (resolvesToCurrentLocale(localeArg, scope)) {
context.report({
node: localeArg,
messageId: 'numericLocaleForName',
data: { field },
});
}
};
return {
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
NAME_FORMATTERS.has(node.callee.property.name)
) {
check(node);
}
},
// `new Intl.DateTimeFormat(locale, opts)` is the same trap in constructor form.
NewExpression(node) {
const callee = node.callee;
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Intl' &&
callee.property.name === 'DateTimeFormat'
) {
check(node);
}
},
};
},
};