Skip to content

feat: preferred terminology - #108

Merged
simoncodes-ca merged 22 commits into
developfrom
feat/107-p7-docs
Sep 23, 2026
Merged

simoncodes-ca merged 22 commits into
developfrom
feat/107-p7-docs

Conversation

@simoncodes-ca

@simoncodes-ca simoncodes-ca commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

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

  • Rule file .lingo-tracker-preferred-terminology.json beside .lingo-tracker.json, overridable via preferredTerminologyFile. A rule is discouraged → preferred, with an optional reason.
  • Matching is case-insensitive and whole-word. capital expenditure matches; Expenditures and ExpenditureType do not. Only visible text is scanned — never ICU argument names, selectors, or HTML tags.
  • Resource editor shows one advisory per match under the base-locale field, with a Use "…" button that rewrites the field. It does not save.
  • Settings has a Preferred terminology section that shares the Save bar with protected terms.
  • CLI lingo-tracker preferred-terminology --list | --add | --remove.
  • validate reports the warnings without failing; import, add-resource, and edit-resource warn on base-locale values.

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 baseLocale used 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

  • Adds a domain module with rule normalization, validation, visible-text extraction, whole-word matching, and replacement in preferred-terminology.ts
  • Adds a preferred-terminology CLI command for listing, adding, updating, and removing rules, plus advisory warnings in validate, edit-resource, and import commands
  • Adds API support in config.controller.ts for reading and writing rules, with per-row 400 validation errors
  • Adds a Settings editor in settings.ts for viewing and editing rules with live validation, and a translation-editor advisory component that surfaces findings with a one-click apply action
  • Adds core file loading/writing in preferred-terminology-file.ts with an in-process mtime/size cache; findings are advisory (warnings only) while an unloadable rule file is a validation failure
  • Behavioral Change: validate and import now scan base-locale source values for discouraged terms; validate exit code is unchanged for findings but fails on an unreadable rule file; import prepends 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
  • line 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. [ Cross-file consolidated ]
apps/cli/src/add-resource/add-resource.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 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. [ Cross-file consolidated ]
apps/cli/src/commands/validate.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 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. [ Cross-file consolidated ]
apps/tracker/src/app/settings/settings.html — 0 comments posted, 1 evaluated, 1 filtered
  • line 414: The new Add rule control 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 calls terminology.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
  • line 666: The new note says warnings are printed only when the base value changes, but editResourceCommand emits them whenever editOptions.baseValue is supplied and any edit succeeds. For example, passing an unchanged discouraged --base-value together with a changed --comment yields result.updated === true and 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
  • line 164: Saving from Settings replaces the file with the rules on screen is not true for every Settings save. Settings.onSave() includes preferredTerminology only 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) ]
  • line 174: The claim that --add creates an absent rule file is false when preferredTerminologyFile points into a directory that does not yet exist (for example the documented config/preferred-terminology.json with no config/ directory). writePreferredTerminology rejects 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
  • line 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. [ Cross-file consolidated ]
libs/core/src/lib/import/process-resource-group.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 383: warnAboutPreferredTerminology is called for every existing base-locale import result, including handleBaseLocaleUpdate results with type: 'updated' when oldValue === 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) ]

SimonNodel-AI and others added 15 commits September 21, 2026 20:56
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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

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.

🚀 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[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread apps/tracker/src/i18n/settings/preferredTerminology/error/resource_entries.json Outdated
Comment thread apps/tracker/src/app/settings/settings.ts
}

try {
writePreferredTerminology(result.filePath, next);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread apps/cli/src/commands/import-cmd.ts Outdated
Comment thread libs/domain/src/lib/preferred-terminology.ts Outdated
Comment thread libs/domain/src/lib/preferred-terminology.ts Outdated
const stamp = readStamp(filePath);
const cached = cache.get(filePath);
if (cached) {
if (stamp && sameStamp(cached.stamp, stamp)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

SimonNodel-AI and others added 6 commits September 22, 2026 20:47
…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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment on lines +181 to +187
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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>
@simoncodes-ca
simoncodes-ca merged commit d0175eb into develop Sep 23, 2026
2 checks passed
@simoncodes-ca
simoncodes-ca deleted the feat/107-p7-docs branch September 23, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: preferred terminology warnings for discouraged source terms

2 participants