feat: preferred terminology - #108
Conversation
Phase 1 of #107. Adds rule normalization, validation (empty, invalid-type, self-mapping, duplicate, chain, cycle, contains-discouraged; all case-insensitive), sorting, visible-text extraction for ICU/Transloco/HTML values, whole-word finding, and in-place replacement. Adds maskTranslocoPlaceholders, a length-preserving variant of the Transloco-to-ICU conversion, so parsed token offsets point into the raw value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erms Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Validate scans each collection's base-locale values against the preferred terminology rules and lists one warning per collection, key, and rule. Findings never fail validation; a rule file that cannot be loaded does. The CLI loads the rules and passes them in, so core validate stays pure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base-locale imports scan each value they write (or would write, in a dry run) and add one warning per discouraged term found. Nothing is skipped or failed. The CLI import command loads the rules and passes them via ImportOptions.preferredTerminology; a broken rule file adds one warning and skips the check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After add-resource or edit-resource writes a base value, the CLI checks it against the preferred terminology rules and prints one warning per discouraged term, with the reason on its own line. edit-resource only checks when the base value was part of the edit. The exit code is unchanged; a broken rule file prints one warning and skips the check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GET /config now exposes the preferred-terminology rules, the backing file
path, and a load error (broken file) or warning (missing explicit file).
PUT /config accepts the full rule list, validates it together with any
protected terms before writing either, and answers invalid rules with
400 { message, errors } indexed by submitted row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New Preferred Terminology section below protected terms: one editable row per rule (discouraged, preferred, optional reason), add and remove, the backing file path, and a banner when the file failed to load. Rules are validated live with the domain check; errors show per field once touched or on a save attempt, and server 400 errors map back onto the submitted rows. The page save bar now covers both lists and sends only the lists that changed, so saving protected terms never rewrites a broken terminology file. After a save, rows re-seed from the reloaded config. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Show one amber advisory per matched rule beneath the base-locale field, with the optional reason and a Use button that rewrites the value via applyPreferredTerm without saving. Findings follow the value after a 300 ms pause and are computed at once on open. The advisories join the textarea's aria-describedby while present and are not a live region. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bare amber text was mistaken for part of the advisory, so the one-click fix went unnoticed. Give it a border, a tinted fill and hover/active states. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| if (!loaded) return; | ||
| const { config, cwd } = loaded; | ||
|
|
||
| const result = loadPreferredTerminology(config, cwd); |
There was a problem hiding this comment.
🟠 High commands/preferred-terminology.ts:69
--list, --add, and --remove terminate with an uncaught filesystem exception when the configured terminology file or its parent directory is unreadable, instead of reporting the file as an in-band load error. loadPreferredTerminology(config, cwd) can throw from its initial statSync; make that failure return the documented { error } result (or catch and translate it here) so the command reports the configuration problem cleanly.
Also found in 4 other location(s)
apps/api/src/app/config/config.controller.ts:35
getConfigcallsloadPreferredTerminologywithout handling filesystem failures.loadPreferredTerminologybegins withstatSync, which can throw (for exampleEACCESwhen the configured rule file or one of its directories is unreadable) before its in-band{ error }result is constructed. Thus a merely unreadable terminology file makesGET /configfail instead of returning the documented load error that Settings needs to display and repair it.
apps/cli/src/add-resource/add-resource.ts:109
warnAboutPreferredTerminologyruns afteraddResourcehas synchronously written both resource files, but it remains inside the operation'stryblock. If an explicitly configured terminology path cannot be statted (for example an inaccessible parent directory), thestatSyncinloadPreferredTerminologythrows; the command then prints a failed-add error even though the resource was already persisted. The advisory failure should be caught/skipped independently of the completed write.
apps/cli/src/commands/validate.ts:192
loadPreferredTerminologyis invoked outside any error handling. ItsreadStampcallsstatSync, which throws (rather than returningundefined) when an explicitly configured terminology file, or one of its parent directories, is inaccessible. Thuslingo-tracker validaterejects before creating the intended validation result/summary for a broken rule file instead of reporting the configuration failure cleanly and exiting with code 1.
libs/core/src/lib/config/preferred-terminology-file.ts:186
readStampletsstatSyncerrors other than a missing entry escape. For example, if the configured file (or one of its parent directories) is unreadable,statSync(..., { throwIfNoEntry: false })still throwsEACCES; this bypassesloadPreferredTerminology's in-band error contract and aborts callers such as config loading/import instead of returning an advisory broken-file result.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/cli/src/commands/preferred-terminology.ts around line 69:
`--list`, `--add`, and `--remove` terminate with an uncaught filesystem exception when the configured terminology file or its parent directory is unreadable, instead of reporting the file as an in-band load error. `loadPreferredTerminology(config, cwd)` can throw from its initial `statSync`; make that failure return the documented `{ error }` result (or catch and translate it here) so the command reports the configuration problem cleanly.
Also found in 4 other location(s):
- apps/api/src/app/config/config.controller.ts:35 -- `getConfig` calls `loadPreferredTerminology` without handling filesystem failures. `loadPreferredTerminology` begins with `statSync`, which can throw (for example `EACCES` when the configured rule file or one of its directories is unreadable) before its in-band `{ error }` result is constructed. Thus a merely unreadable terminology file makes `GET /config` fail instead of returning the documented load error that Settings needs to display and repair it.
- apps/cli/src/add-resource/add-resource.ts:109 -- `warnAboutPreferredTerminology` runs after `addResource` has synchronously written both resource files, but it remains inside the operation's `try` block. If an explicitly configured terminology path cannot be statted (for example an inaccessible parent directory), the `statSync` in `loadPreferredTerminology` throws; the command then prints a failed-add error even though the resource was already persisted. The advisory failure should be caught/skipped independently of the completed write.
- apps/cli/src/commands/validate.ts:192 -- `loadPreferredTerminology` is invoked outside any error handling. Its `readStamp` calls `statSync`, which throws (rather than returning `undefined`) when an explicitly configured terminology file, or one of its parent directories, is inaccessible. Thus `lingo-tracker validate` rejects before creating the intended validation result/summary for a broken rule file instead of reporting the configuration failure cleanly and exiting with code 1.
- libs/core/src/lib/config/preferred-terminology-file.ts:186 -- `readStamp` lets `statSync` errors other than a missing entry escape. For example, if the configured file (or one of its parent directories) is unreadable, `statSync(..., { throwIfNoEntry: false })` still throws `EACCES`; this bypasses `loadPreferredTerminology`'s in-band error contract and aborts callers such as config loading/import instead of returning an advisory broken-file result.
| * The full preferred-terminology rule list, replacing the file's contents. Validated | ||
| * server-side; the file is written sorted by discouraged term. | ||
| */ | ||
| preferredTerminology?: PreferredTermRuleDto[]; |
There was a problem hiding this comment.
🟡 Medium lib/update-config.dto.ts:17
When a Save-bar request contains both preferredTerminology and protectedTerms, a failure writing the protected-terms file returns 400 even though the terminology rules were already persisted, leaving a partially applied update while the UI reports failure. The controller writes preferredTerminology before calling setGlobalProtectedTerms; make these writes atomic, or ensure the terminology write is rolled back when the protected-terms write fails.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @libs/data-transfer/src/lib/update-config.dto.ts around line 17:
When a Save-bar request contains both `preferredTerminology` and `protectedTerms`, a failure writing the protected-terms file returns `400` even though the terminology rules were already persisted, leaving a partially applied update while the UI reports failure. The controller writes `preferredTerminology` before calling `setGlobalProtectedTerms`; make these writes atomic, or ensure the terminology write is rolled back when the protected-terms write fails.
| } | ||
|
|
||
| try { | ||
| writePreferredTerminology(result.filePath, next); |
There was a problem hiding this comment.
🟠 High commands/preferred-terminology.ts:131
Concurrent preferred-terminology --add or --remove invocations can silently lose one another’s rule changes: both read the same result.rules, and the later writePreferredTerminology replaces the file with its stale next. Serialize updates with a file lock or use compare-and-retry before writing.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/cli/src/commands/preferred-terminology.ts around line 131:
Concurrent `preferred-terminology --add` or `--remove` invocations can silently lose one another’s rule changes: both read the same `result.rules`, and the later `writePreferredTerminology` replaces the file with its stale `next`. Serialize updates with a file lock or use compare-and-retry before writing.
| const stamp = readStamp(filePath); | ||
| const cached = cache.get(filePath); | ||
| if (cached) { | ||
| if (stamp && sameStamp(cached.stamp, stamp)) { |
There was a problem hiding this comment.
🟡 Medium config/preferred-terminology-file.ts:100
An out-of-band edit that keeps the same mtimeMs and JSON byte length leaves the cache serving the old rules, so the next load does not refresh terminology as documented. Compare a content fingerprint (or reread the file) in addition to the timestamp and size.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @libs/core/src/lib/config/preferred-terminology-file.ts around line 100:
An out-of-band edit that keeps the same `mtimeMs` and JSON byte length leaves the cache serving the old rules, so the next load does not refresh terminology as documented. Compare a content fingerprint (or reread the file) in addition to the timestamp and size.
…read A stat failure other than ENOENT (ENOTDIR, EACCES, ELOOP) used to escape loadPreferredTerminology. It now comes back as an in-band "cannot be read" error, and read failures such as EISDIR are no longer reported as invalid JSON. A stat failure after a successful write only drops the cache entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ral branch Inside plural and selectordinal branches, and selects nested in them, a bare "#" is the count, so a preferred term like "C#" read back as "C<count>". When the preferred term contains "#", the affected branch text is now decoded, edited, and re-encoded with ICU apostrophe quoting, so it parses back to exactly the preferred term. Elsewhere, and for values that do not parse as ICU, the term is still inserted verbatim. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed-term checks The tag mask stopped at the first ">", so in <a title="x > Expenditure"> the tail of the attribute was treated as visible text and could be flagged or rewritten. Tags named in a non-Latin script, such as <étiquette>, were not masked at all. Tag names may now start with any letter, "_" or ":", and quoted attribute values are scanned through their closing quote. A "<" that does not open a tag stays visible, and an unclosed tag or quote does not swallow the rest of the value. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rget locale The interactive locale prompt listed the project-wide locales even when the chosen collection defines its own. It now offers the collection's locales, falling back to the project's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stored value "Cannot contain { } < or >" is not valid ICU, so the
bundler warned on every locale and passed it through unconverted. Quote
the braces ICU-style; the bundle still renders the literal text.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ve is in flight Both config arrivals reseed the protected-terms and preferred-terminology lists, so a rule or term entered before the first config, or after clicking Save, was silently wiped. Disable every edit control, Revert and Save in those windows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| config: Pick<LingoTrackerConfig, 'preferredTerminologyFile'>, | ||
| cwd: string = process.cwd(), | ||
| ): LoadPreferredTerminologyResult { | ||
| const filePath = resolvePreferredTerminologyFilePath(config, cwd); |
There was a problem hiding this comment.
🟠 High config/preferred-terminology-file.ts:95
loadPreferredTerminology throws a TypeError instead of returning an in-band error when preferredTerminologyFile is a non-string value such as 42 or null, causing callers like GET /config and CLI commands to abort. Validate the runtime value before calling resolvePreferredTerminologyFilePath and return a load error for invalid configuration.
- const filePath = resolvePreferredTerminologyFilePath(config, cwd);
+ const configuredPointer = config.preferredTerminologyFile;
+ if (typeof configuredPointer !== 'undefined' && typeof configuredPointer !== 'string') {
+ return {
+ rules: [],
+ filePath: resolve(cwd, DEFAULT_PREFERRED_TERMINOLOGY_FILENAME),
+ error: 'Preferred terminology file path must be a string',
+ };
+ }
+ const filePath = resolvePreferredTerminologyFilePath(config, cwd);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @libs/core/src/lib/config/preferred-terminology-file.ts around line 95:
`loadPreferredTerminology` throws a `TypeError` instead of returning an in-band `error` when `preferredTerminologyFile` is a non-string value such as `42` or `null`, causing callers like `GET /config` and CLI commands to abort. Validate the runtime value before calling `resolvePreferredTerminologyFilePath` and return a load error for invalid configuration.
| const parent = dirname(filePath); | ||
| if (!existsSync(parent)) { | ||
| throw new Error(`Cannot write preferred terminology file — directory does not exist: ${parent}`); | ||
| } | ||
|
|
||
| const sorted = sortPreferredTermRules(normalizePreferredTermRules(rules)); | ||
| writeFileSync(filePath, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8'); |
There was a problem hiding this comment.
🟠 High config/preferred-terminology-file.ts:181
writeFileSync can block the entire Node process indefinitely when filePath already points to a FIFO with no reader. The parent-directory check does not validate the target, so reject existing non-regular targets before the synchronous write.
const parent = dirname(filePath);
+ if (existsSync(filePath) && !statSync(filePath).isFile()) {
+ throw new Error(`Cannot write preferred terminology file — target is not a regular file: ${filePath}`);
+ }
if (!existsSync(parent)) {
throw new Error(`Cannot write preferred terminology file — directory does not exist: ${parent}`);
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @libs/core/src/lib/config/preferred-terminology-file.ts around lines 181-187:
`writeFileSync` can block the entire Node process indefinitely when `filePath` already points to a FIFO with no reader. The parent-directory check does not validate the target, so reject existing non-regular targets before the synchronous write.
.lingo-tracker.json is hand-edited and not schema-validated. A pointer
such as 42, true, {} or [] reached path.isAbsolute() and threw a raw
TypeError out of loadPreferredTerminology, which is documented as never
throwing, crashing GET /config, validate, add/edit-resource, import and
the preferred-terminology command.
loadPreferredTerminology now returns the problem in-band (no rules,
default path, a clear error), so readers warn or fail as they already do
for a broken file, and the CLI refuses --add/--remove. The resolver
throws a descriptive Error instead of the TypeError, so PUT /config
answers 400 with a readable message. A null pointer still reads as
unset, and no longer triggers the missing-file warning.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Closes #107.
Teams have words they'd rather authors used: Investment, not Expenditure. This adds a rule file that records those preferences and surfaces them wherever base-locale copy is written. The warnings are advisory — nothing here ever blocks a save, an import, or a build.
What's in it
.lingo-tracker-preferred-terminology.jsonbeside.lingo-tracker.json, overridable viapreferredTerminologyFile. A rule isdiscouraged→preferred, with an optionalreason.capital expenditurematches;ExpendituresandExpenditureTypedo not. Only visible text is scanned — never ICU argument names, selectors, or HTML tags.lingo-tracker preferred-terminology --list | --add | --remove.A broken rule file warns and skips the checks everywhere except
validate, which fails on it.Behaviour change worth flagging
Importing into a collection that sets its own
baseLocaleused to write the value to the wrong field. It now writes the base value, like every other base-locale import. That is a bug fix, but it changes what an existing import does.Review notes
This was built as seven phases, one commit series each: domain matching → rule file → CLI → validate/import/authoring → API + Settings → editor → docs. Reading the commits in order is easier than reading the diff.
Full spec, including the settled edge cases, is in
docs/features/preferred-terminology.md.Testing
All suites pass: domain, core, cli, api, tracker. The editor and Settings screens were checked in a browser in light and dark themes.
🤖 Generated with Claude Code
Note
Add preferred terminology feature across CLI, API, validation, and UI
preferred-terminologyCLI command for listing, adding, updating, and removing rules, plus advisory warnings invalidate,edit-resource, andimportcommandsvalidateandimportnow scan base-locale source values for discouraged terms;validateexit code is unchanged for findings but fails on an unreadable rule file;importprepends a terminology warning to the summary only for base-locale imports📊 Macroscope summarized f358790. 52 files reviewed, 19 issues evaluated, 9 issues filtered, 9 comments posted
🗂️ Filtered Issues
apps/api/src/app/config/config.controller.ts — 0 comments posted, 1 evaluated, 1 filtered
getConfigcallsloadPreferredTerminologywithout handling filesystem failures.loadPreferredTerminologybegins withstatSync, which can throw (for exampleEACCESwhen the configured rule file or one of its directories is unreadable) before its in-band{ error }result is constructed. Thus a merely unreadable terminology file makesGET /configfail instead of returning the documented load error that Settings needs to display and repair it. [ Cross-file consolidated ]apps/cli/src/add-resource/add-resource.ts — 0 comments posted, 1 evaluated, 1 filtered
warnAboutPreferredTerminologyruns afteraddResourcehas synchronously written both resource files, but it remains inside the operation'stryblock. If an explicitly configured terminology path cannot be statted (for example an inaccessible parent directory), thestatSyncinloadPreferredTerminologythrows; the command then prints a failed-add error even though the resource was already persisted. The advisory failure should be caught/skipped independently of the completed write. [ Cross-file consolidated ]apps/cli/src/commands/validate.ts — 0 comments posted, 1 evaluated, 1 filtered
loadPreferredTerminologyis invoked outside any error handling. ItsreadStampcallsstatSync, which throws (rather than returningundefined) when an explicitly configured terminology file, or one of its parent directories, is inaccessible. Thuslingo-tracker validaterejects before creating the intended validation result/summary for a broken rule file instead of reporting the configuration failure cleanly and exiting with code 1. [ Cross-file consolidated ]apps/tracker/src/app/settings/settings.html — 0 comments posted, 1 evaluated, 1 filtered
Add rulecontrol is enabled while the initial configuration is still absent. A user can add and type a terminology rule during the initial load, but the settings seeding effect unconditionally callsterminology.seed(config.preferredTerminology ?? [])on that first config arrival, replacing the draft and silently discarding the entered rule. Disable or defer this control until config is loaded, or preserve a pre-load draft. [ Cross-file consolidated ]docs/cli.md — 0 comments posted, 1 evaluated, 1 filtered
editResourceCommandemits them whenevereditOptions.baseValueis supplied and any edit succeeds. For example, passing an unchanged discouraged--base-valuetogether with a changed--commentyieldsresult.updated === trueand prints the terminology warning although the base value was not changed. [ Out of scope (post-validation triage) ]docs/features/preferred-terminology.md — 0 comments posted, 2 evaluated, 2 filtered
Saving from Settings replaces the file with the rules on screenis not true for every Settings save.Settings.onSave()includespreferredTerminologyonly when the terminology draft changed; saving only protected-term edits while a terminology file is broken leaves that file untouched. Users following this recovery advice can save successfully but retain the broken file. [ Out of scope (post-validation triage) ]--addcreates an absent rule file is false whenpreferredTerminologyFilepoints into a directory that does not yet exist (for example the documentedconfig/preferred-terminology.jsonwith noconfig/directory).writePreferredTerminologyrejects a missing parent directory, so the documented command exits with an error instead of creating the file. [ Out of scope (post-validation triage) ]libs/core/src/lib/config/preferred-terminology-file.ts — 1 comment posted, 2 evaluated, 1 filtered
readStampletsstatSyncerrors other than a missing entry escape. For example, if the configured file (or one of its parent directories) is unreadable,statSync(..., { throwIfNoEntry: false })still throwsEACCES; this bypassesloadPreferredTerminology's in-band error contract and aborts callers such as config loading/import instead of returning an advisory broken-file result. [ Cross-file consolidated ]libs/core/src/lib/import/process-resource-group.ts — 0 comments posted, 1 evaluated, 1 filtered
warnAboutPreferredTerminologyis called for every existing base-locale import result, includinghandleBaseLocaleUpdateresults withtype: 'updated'whenoldValue === resource.value. Thus re-importing an unchanged, already-stored discouraged value emits a warning even though no base value was written (contrary to the import option's documented "value written" behavior), producing spurious terminology warnings on no-op imports. [ Out of scope (post-validation triage) ]