Skip to content

Commit 4ea921c

Browse files
Jack Qclaude
andauthored
fix(plugin-security,plugin-sharing): repair the ADR-0090 position rename in the es-ES bundles, bounded and guarded (#8788)
* wip(recovered): es-ES position rename repairs + consistency test (pre-verification) * fix(plugin-sharing): repair remaining ADR-0090 rename misses in sys_record_share recipient enum * test(plugin-sharing): guard shared recipient vocabulary; changeset --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 159e299 commit 4ea921c

5 files changed

Lines changed: 272 additions & 14 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/plugin-sharing": patch
4+
---
5+
6+
Repair the ADR-0090 `sys_role``sys_position` rename in the es-ES object
7+
translation bundles, and guard it mechanically.
8+
9+
The rename half-landed in Spanish: an unreviewed substring find-replace produced
10+
two non-words (`Puestoes` as the plural of `Puesto`, and `contpuesto` where the
11+
replace ate the unrelated word `control`), while nine further leaves in
12+
`plugin-security` and three in `plugin-sharing` were missed entirely and still
13+
named the pre-rename concept. In `plugin-sharing` the same picklist key rendered
14+
two different ways in one file — `position` was `Puesto` on the sharing rule and
15+
`posición` on the record share, and `unit_and_subordinates` read `Rol y
16+
subordinados` (naming the removed role concept) against `Unidad de negocio y
17+
subordinados` on its sibling.
18+
19+
Spanish-facing admins saw `Puestoes` as the object's plural label in navigation
20+
and list views, and two different words for one recipient kind across two Setup
21+
screens.
22+
23+
Two regression guards now cover the classes involved: a malformed-compound and
24+
stale-term check on the renamed security objects, and a self-consistency check
25+
asserting that a picklist option key shared by several sharing objects renders
26+
identically within a locale. Neither needs a reader of the locale to review it.

packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import type { TranslationData } from '@objectstack/spec/system';
1111
export const esESObjects: NonNullable<TranslationData['objects']> = {
1212
sys_position: {
1313
label: "Puesto",
14-
pluralLabel: "Puestoes",
15-
description: "Definiciones de puesto para el contpuesto de acceso RBAC",
14+
pluralLabel: "Puestos",
15+
description: "Definiciones de puesto para el control de acceso RBAC",
1616
fields: {
1717
label: {
1818
label: "Nombre visible"
@@ -49,7 +49,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
4949
}
5050
},
5151
id: {
52-
label: "ID de rol"
52+
label: "ID de puesto"
5353
},
5454
created_at: {
5555
label: "Creado el"
@@ -74,12 +74,12 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
7474
},
7575
_actions: {
7676
activate_position: {
77-
label: "Activar rol",
77+
label: "Activar puesto",
7878
successMessage: "Puesto activado"
7979
},
8080
deactivate_position: {
81-
label: "Desactivar rol",
82-
confirmText: "¿Desactivar este rol? Los usuarios con el puesto conservan su asignación, pero el puesto deja de otorgar permisos hasta que se vuelva a activar.",
81+
label: "Desactivar puesto",
82+
confirmText: "¿Desactivar este puesto? Los usuarios con el puesto conservan su asignación, pero el puesto deja de otorgar permisos hasta que se vuelva a activar.",
8383
successMessage: "Puesto desactivado"
8484
},
8585
set_default_position: {
@@ -88,7 +88,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
8888
successMessage: "Puesto predeterminado actualizado"
8989
},
9090
clone_position: {
91-
label: "Clonar rol",
91+
label: "Clonar puesto",
9292
successMessage: "Puesto clonado",
9393
params: {
9494
label: {
@@ -338,13 +338,13 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
338338
}
339339
},
340340
sys_position_permission_set: {
341-
label: "Conjunto de permisos de rol",
342-
pluralLabel: "Conjuntos de permisos de rol",
343-
description: "Vincula un conjunto de permisos a un rol.",
341+
label: "Conjunto de permisos de puesto",
342+
pluralLabel: "Conjuntos de permisos de puesto",
343+
description: "Vincula un conjunto de permisos a un puesto.",
344344
fields: {
345345
id: {
346346
label: "ID de vinculación",
347-
help: "UUID de la vinculación rol-conjunto de permisos."
347+
help: "UUID de la vinculación puesto-conjunto de permisos."
348348
},
349349
position_id: {
350350
label: "Puesto",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Regression guard for the ADR-0090 P1 rename (sys_role → sys_position). The
4+
// rename half-landed in es-ES and shipped for a month: the object label said
5+
// "Puesto" while the plural said "Puestoes", the description said "contpuesto"
6+
// (the rename caught the unrelated word `control` — con+trol → con+tpuesto), and
7+
// nine leaf values still said "rol" — including one string that says both, in the
8+
// same sentence.
9+
//
10+
// Nothing mechanical caught it: `check:i18n` compares bundle STRUCTURE against
11+
// what the extractor emits and is green either way, and the generator's merge
12+
// mode never rewrites an existing translated leaf, so both drift and damage
13+
// survive a green gate indefinitely. This test is the missing judgement, and it
14+
// is deliberately modelled on `bu-rename-consistency.test.ts` (ADR-0057), which
15+
// exists because that rename half-landed the same way.
16+
//
17+
// Two independent failure modes are asserted, because the damage produced both:
18+
//
19+
// 1. STALE TERM — a leaf still naming the pre-rename concept ("rol", "Role",
20+
// "角色", "ロール"). Scope is the three renamed objects, walked to every
21+
// string leaf (labels, descriptions, field help, action confirm text,
22+
// action-param help) — unlike the ADR-0057 guard this cannot be
23+
// labels-only, because the damage reached descriptions and help text.
24+
// 2. MALFORMED COMPOUND — a Spanish word that merely CONTAINS "puesto"
25+
// without being one. `Puestoes` and `contpuesto` are both of this class and
26+
// neither is a word; a word-level allowlist catches them without tripping
27+
// on the legitimate Spanish words that do contain the substring
28+
// (`presupuesto`, `expuesto`, `compuesto`, …).
29+
30+
import { describe, it, expect } from 'vitest';
31+
import { enObjects } from './en.objects.generated.js';
32+
import { zhCNObjects } from './zh-CN.objects.generated.js';
33+
import { jaJPObjects } from './ja-JP.objects.generated.js';
34+
import { esESObjects } from './es-ES.objects.generated.js';
35+
36+
/** The objects ADR-0090 P1 renamed — the exact surface the rename had to land on. */
37+
const RENAMED_OBJECTS = ['sys_position', 'sys_position_permission_set', 'sys_user_position'];
38+
39+
const LOCALES = [
40+
// `\b` keeps this off `control` / `controlar`, which legitimately contain "rol".
41+
{ name: 'en', objs: enObjects as Record<string, unknown>, stale: /\brole?s?\b/i },
42+
{ name: 'es-ES', objs: esESObjects as Record<string, unknown>, stale: /\brol(es)?\b/i },
43+
{ name: 'zh-CN', objs: zhCNObjects as Record<string, unknown>, stale: // },
44+
{ name: 'ja-JP', objs: jaJPObjects as Record<string, unknown>, stale: // },
45+
];
46+
47+
/** Every string leaf under `node`, as `[dottedPath, value]`. */
48+
function stringLeaves(node: unknown, path = ''): Array<[string, string]> {
49+
if (typeof node === 'string') return [[path, node]];
50+
if (node === null || typeof node !== 'object') return [];
51+
return Object.entries(node as Record<string, unknown>).flatMap(([k, v]) =>
52+
stringLeaves(v, path ? `${path}.${k}` : k),
53+
);
54+
}
55+
56+
describe('ADR-0090 position rename — no stale role term in the renamed objects', () => {
57+
for (const { name, objs, stale } of LOCALES) {
58+
for (const objName of RENAMED_OBJECTS) {
59+
it(`${name}: ${objName} carries no pre-rename term`, () => {
60+
const obj = objs[objName];
61+
expect(obj, `${objName} missing from the ${name} bundle`).toBeTruthy();
62+
const offenders = stringLeaves(obj)
63+
.filter(([, value]) => stale.test(value))
64+
.map(([leafPath, value]) => `${objName}.${leafPath} = ${JSON.stringify(value)}`);
65+
expect(
66+
offenders,
67+
`${name}: leaves below still name the pre-rename concept — ADR-0090 renamed ` +
68+
`sys_role to sys_position, so no translated leaf on these objects may say "role".`,
69+
).toEqual([]);
70+
});
71+
}
72+
}
73+
});
74+
75+
// Spanish words that legitimately contain the substring "puesto" but are not it.
76+
// A word containing "puesto" and absent here is find-replace residue, not Spanish.
77+
const LEGITIMATE_PUESTO_WORDS = new Set([
78+
'puesto',
79+
'puestos',
80+
'presupuesto',
81+
'presupuestos',
82+
'expuesto',
83+
'expuestos',
84+
'supuesto',
85+
'supuestos',
86+
'dispuesto',
87+
'dispuestos',
88+
'compuesto',
89+
'compuestos',
90+
'impuesto',
91+
'impuestos',
92+
'propuesto',
93+
'propuestos',
94+
'repuesto',
95+
'repuestos',
96+
'opuesto',
97+
'opuestos',
98+
]);
99+
100+
describe('ADR-0090 position rename — es-ES carries no malformed "puesto" compound', () => {
101+
it('every word containing "puesto" is a real Spanish word', () => {
102+
const offenders: string[] = [];
103+
for (const [leafPath, value] of stringLeaves(esESObjects)) {
104+
// Split on anything that is not a Spanish letter, so punctuation and the
105+
// machine names embedded in help text do not masquerade as words.
106+
for (const word of value.split(/[^\p{L}]+/u)) {
107+
const lower = word.toLowerCase();
108+
if (lower.includes('puesto') && !LEGITIMATE_PUESTO_WORDS.has(lower)) {
109+
offenders.push(`${leafPath}: ${JSON.stringify(word)}`);
110+
}
111+
}
112+
}
113+
expect(
114+
offenders,
115+
'malformed "puesto" compounds — these are find-replace residue from the ADR-0090 ' +
116+
'rename (e.g. "Puestoes" for the plural, or "contpuesto" where the rename ate the ' +
117+
'unrelated word "control"), not Spanish words.',
118+
).toEqual([]);
119+
});
120+
});

packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
3131
options: {
3232
user: "Usuario",
3333
group: "Grupo",
34-
position: "posición",
35-
unit_and_subordinates: "Rol y subordinados",
34+
position: "Puesto",
35+
unit_and_subordinates: "Unidad de negocio y subordinados",
3636
guest: "Invitado"
3737
}
3838
},
3939
recipient_id: {
4040
label: "Destinatario",
41-
help: "ID del usuario/grupo/rol que recibe acceso."
41+
help: "ID del usuario/grupo/puesto que recibe acceso."
4242
},
4343
access_level: {
4444
label: "Nivel de acceso",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Recipient-vocabulary guard for the ADR-0090 P1 rename (sys_role → sys_position),
4+
// the plugin-sharing half of #8735.
5+
//
6+
// `sys_record_share` and `sys_sharing_rule` describe the SAME recipient
7+
// vocabulary — both carry a `recipient_type` picklist over the same principal
8+
// kinds. The rename landed on `sys_sharing_rule` and was missed on
9+
// `sys_record_share`, so for a month the es-ES bundle rendered one enum key two
10+
// different ways in one file: `position` was "Puesto" in the rule object and
11+
// "posición" in the share object, and `unit_and_subordinates` was "Unidad de
12+
// negocio y subordinados" in one and "Rol y subordinados" — naming the
13+
// pre-rename *role* concept — in the other. An admin switching between the two
14+
// screens saw two different words for one thing.
15+
//
16+
// Nothing mechanical caught it: `check:i18n` compares bundle STRUCTURE against
17+
// what the extractor emits and is green either way, and the generator's merge
18+
// mode never rewrites an existing translated leaf (#8543), so a half-landed
19+
// rename survives a green gate indefinitely.
20+
//
21+
// The invariant asserted here is the one that would have caught it, and it needs
22+
// no per-string judgement: within one locale, an option key shared by more than
23+
// one object must render identically everywhere it appears. That is a fact about
24+
// self-consistency, not a translation-quality opinion, so it stays reviewable by
25+
// someone who does not read the locale.
26+
27+
import { describe, it, expect } from 'vitest';
28+
import { enObjects } from './en.objects.generated.js';
29+
import { esESObjects } from './es-ES.objects.generated.js';
30+
import { zhCNObjects } from './zh-CN.objects.generated.js';
31+
import { jaJPObjects } from './ja-JP.objects.generated.js';
32+
33+
const LOCALES: Array<[string, Record<string, unknown>]> = [
34+
['en', enObjects as Record<string, unknown>],
35+
['es-ES', esESObjects as Record<string, unknown>],
36+
['zh-CN', zhCNObjects as Record<string, unknown>],
37+
['ja-JP', jaJPObjects as Record<string, unknown>],
38+
];
39+
40+
/**
41+
* `field.optionKey` → the distinct renderings seen for it, each tagged with the
42+
* object it came from. Walks `<object>.fields.<field>.options.<key>` only, which
43+
* is where the picklist vocabulary lives.
44+
*/
45+
function optionRenderings(objs: Record<string, unknown>): Map<string, Map<string, string[]>> {
46+
const seen = new Map<string, Map<string, string[]>>();
47+
for (const [objName, obj] of Object.entries(objs)) {
48+
const fields = (obj as { fields?: Record<string, unknown> })?.fields;
49+
if (!fields || typeof fields !== 'object') continue;
50+
for (const [fieldName, field] of Object.entries(fields)) {
51+
const options = (field as { options?: Record<string, unknown> })?.options;
52+
if (!options || typeof options !== 'object') continue;
53+
for (const [optKey, value] of Object.entries(options)) {
54+
if (typeof value !== 'string') continue;
55+
const id = `${fieldName}.${optKey}`;
56+
const byValue = seen.get(id) ?? new Map<string, string[]>();
57+
byValue.set(value, [...(byValue.get(value) ?? []), objName]);
58+
seen.set(id, byValue);
59+
}
60+
}
61+
}
62+
return seen;
63+
}
64+
65+
describe('ADR-0090 — shared recipient vocabulary renders consistently (#8735)', () => {
66+
for (const [locale, objs] of LOCALES) {
67+
it(`${locale}: an option key shared by several objects has one rendering`, () => {
68+
const conflicts: string[] = [];
69+
for (const [id, byValue] of optionRenderings(objs)) {
70+
if (byValue.size < 2) continue;
71+
const shown = [...byValue]
72+
.map(([value, owners]) => `${owners.join('+')}=${JSON.stringify(value)}`)
73+
.join(' vs ');
74+
conflicts.push(`${id}: ${shown}`);
75+
}
76+
expect(
77+
conflicts,
78+
`${locale}: the same picklist option key renders differently across objects. ` +
79+
'Both sharing objects describe one recipient vocabulary, so a key that disagrees ' +
80+
'with itself means a rename landed on one object and was missed on the other ' +
81+
'(ADR-0090 renamed sys_role to sys_position). Pick the rendering the renamed ' +
82+
'concept actually uses and make both objects say it.',
83+
).toEqual([]);
84+
});
85+
}
86+
});
87+
88+
// The en bundle contains no "role" at all — the concept is `position` throughout —
89+
// so any surviving `rol` in the Spanish bundle is a missed rename, not a
90+
// legitimate word. `\b` keeps this off `control` / `controlar`, which contain
91+
// "rol" innocently.
92+
describe('ADR-0090 — es-ES carries no pre-rename "rol" (#8735)', () => {
93+
it('no leaf in the es-ES bundle still names the role concept', () => {
94+
const offenders: string[] = [];
95+
const walk = (node: unknown, path: string): void => {
96+
if (typeof node === 'string') {
97+
if (/\brol(es)?\b/i.test(node)) offenders.push(`${path} = ${JSON.stringify(node)}`);
98+
return;
99+
}
100+
if (node === null || typeof node !== 'object') return;
101+
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
102+
walk(v, path ? `${path}.${k}` : k);
103+
}
104+
};
105+
walk(esESObjects, '');
106+
expect(
107+
offenders,
108+
'es-ES leaves still naming the pre-rename concept — the en bundle for this package ' +
109+
'contains no "role" anywhere, so every one of these is a missed ADR-0090 rename.',
110+
).toEqual([]);
111+
});
112+
});

0 commit comments

Comments
 (0)