Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nested-conditional-validation-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.

The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.

The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,120 @@ describe('validateTranslationReferences — orphan keys', () => {
});
});

describe('validateTranslationReferences — nested conditional validation branches (#14700)', () => {
// Mirrors the card's own fixture: `demo_account` with one `conditional`
// rule whose `then` / `otherwise` are each a full, named rule.
const conditionalStack = (validations: unknown[], objectNode: Record<string, unknown>) => ({
objects: [
{
name: 'demo_account',
label: 'Account',
fields: {
status: { type: 'select', label: 'Status' },
churn_reason: { type: 'text', label: 'Churn reason' },
},
validations,
},
],
translations: [{ 'zh-CN': { objects: { demo_account: objectNode } } }],
});

const churnConsistencyRule = {
type: 'conditional',
name: 'churn_reason_consistency',
message: 'Churn reason must match the account state.',
when: "record.status == 'churned'",
then: {
type: 'script',
name: 'churn_reason_present',
message: 'A churned account needs a churn reason.',
condition: 'record.churn_reason == null',
},
otherwise: {
type: 'script',
name: 'churn_reason_absent',
message: 'A non-churned account must not carry a churn reason.',
condition: 'record.churn_reason != null',
},
};

it('accepts bundle entries for both branch names and the wrapper name at once', () => {
// Before the fix, this reported `translation-target-unknown` on BOTH
// branch keys — the card's own measurement — because the flat walk over
// `obj.validations` never descended into `then` / `otherwise`, even
// though `checkConditional` / `authoredRuleMessage` address the branch by
// exactly this name at runtime.
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
// The wrapper's own message is never rendered by `checkConditional`
// (#14518 keeps a bundle entry for it anyway, deliberately, so the
// bundle mirrors the declared rule set 1:1).
churn_reason_consistency: { message: 'Wrapper message (never rendered)' },
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
},
}),
);
expect(findings).toEqual([]);
});

it('still flags a bundle entry naming no rule at any depth — real orphans stay caught', () => {
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
churn_reason_ghost: { message: 'Nothing declares this.' },
},
}),
);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.demo_account._validations.churn_reason_ghost',
]);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
});

it('descends through a branch that is itself a nested conditional', () => {
const outerGate = {
type: 'conditional',
name: 'outer_gate',
message: 'outer',
when: "record.status == 'churned'",
then: {
type: 'conditional',
name: 'inner_gate',
message: 'inner',
when: 'record.churn_reason != null',
then: {
type: 'script',
name: 'innermost_rule',
message: 'deepest branch of all',
condition: 'true',
},
},
};
const findings = validateTranslationReferences(
conditionalStack([outerGate], { _validations: { innermost_rule: { message: '最深层的分支。' } } }),
);
expect(findings).toEqual([]);
});

it('skips an unnamed branch, same as an unnamed top-level rule already was (#14253)', () => {
const gateWithUnnamedBranch = {
type: 'conditional',
name: 'gate',
message: 'gate',
when: "record.status == 'churned'",
then: { type: 'script', message: 'has no name', condition: 'true' },
};
const findings = validateTranslationReferences(
conditionalStack([gateWithUnnamedBranch], { _validations: { gate: { message: '门。' } } }),
);
expect(findings).toEqual([]);
});
});

describe('validateTranslationReferences — option keys', () => {
it('flags an option key that is a near-miss of the stored value', () => {
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
Expand Down
38 changes: 33 additions & 5 deletions packages/lint/src/validate-translation-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,21 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* Add a validation rule's own `name` to the universe, then recurse into a
* `conditional` rule's `then` / `otherwise` branch — each branch is itself a
* full rule (with its own `name`, and possibly its own nested branches), and
* mirrors the recursion `evaluateRule` performs at runtime (#14700). See the
* call site in {@link buildUniverse} for why the wrapper's own name is kept
* too, even though `then` / `otherwise` are what a caller actually reads.
*/
function collectValidationRuleNames(rule: AnyRec, validations: Set<string>): void {
const ruleName = strName(rule.name);
if (ruleName) validations.add(ruleName);
if (isRec(rule.then)) collectValidationRuleNames(rule.then, validations);
if (isRec(rule.otherwise)) collectValidationRuleNames(rule.otherwise, validations);
}

/**
* "Did you mean?" over the known names — a namespace pass the shared helper
* cannot see, falling back to `suggestName`'s containment/edit-distance
Expand DownExpand Up@@ -209,9 +224,12 @@ interface ObjectFacts {
sections: Set<string>;
/**
* `_validations` names — the custom validation rules this object declares
* (`objects[].validations[].name`). `objects.<obj>._validations.<rule>.message`
* (#14253) is keyed by that name, so a ghost here is a rule message that
* renders in the source locale inside an otherwise translated refusal.
* (`objects[].validations[].name`, including a nested `conditional` rule's
* `then` / `otherwise` branch — the branch is itself a full rule and is the
* address `checkConditional` / `authoredRuleMessage` actually key on;
* #14700). `objects.<obj>._validations.<rule>.message` (#14253) is keyed by
* that name, so a ghost here is a rule message that renders in the source
* locale inside an otherwise translated refusal.
*/
validations: Set<string>;
/**
Expand DownExpand Up@@ -605,9 +623,19 @@ function buildUniverse(stack: AnyRec): Universe {
// #14253: `_validations.<rule>` is keyed by the rule's own `name`. A rule
// without a name has no key and is not registered — the resolver cannot
// address it either, so nothing is lost by skipping it here.
// #14700: a `conditional` rule's `then` / `otherwise` branch is itself a
// full rule, and its `name` — not the wrapper's — is the address
// `checkConditional` delegates to and `authoredRuleMessage` keys on (see
// `packages/objectql/src/validation/rule-validator.ts`). A flat walk over
// `obj.validations` never sees a branch name, so a legitimate bundle
// entry for one was reported as an orphan. Descend into `then` /
// `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself
// be a nested `conditional`, so depth is unbounded); the wrapper's own
// name stays in the universe too — its message is structurally
// unreachable at runtime, but #14518 keeps a bundle entry for it
// deliberately so the bundle mirrors the declared rule set 1:1.
for (const rule of asArray(obj.validations)) {
const ruleName = strName(rule.name);
if (ruleName) facts.validations.add(ruleName);
collectValidationRuleNames(rule, facts.validations);
}
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nested-conditional-validation-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.

The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.

The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,120 @@ describe('validateTranslationReferences — orphan keys', () => {
});
});

describe('validateTranslationReferences — nested conditional validation branches (#14700)', () => {
// Mirrors the card's own fixture: `demo_account` with one `conditional`
// rule whose `then` / `otherwise` are each a full, named rule.
const conditionalStack = (validations: unknown[], objectNode: Record<string, unknown>) => ({
objects: [
{
name: 'demo_account',
label: 'Account',
fields: {
status: { type: 'select', label: 'Status' },
churn_reason: { type: 'text', label: 'Churn reason' },
},
validations,
},
],
translations: [{ 'zh-CN': { objects: { demo_account: objectNode } } }],
});

const churnConsistencyRule = {
type: 'conditional',
name: 'churn_reason_consistency',
message: 'Churn reason must match the account state.',
when: "record.status == 'churned'",
then: {
type: 'script',
name: 'churn_reason_present',
message: 'A churned account needs a churn reason.',
condition: 'record.churn_reason == null',
},
otherwise: {
type: 'script',
name: 'churn_reason_absent',
message: 'A non-churned account must not carry a churn reason.',
condition: 'record.churn_reason != null',
},
};

it('accepts bundle entries for both branch names and the wrapper name at once', () => {
// Before the fix, this reported `translation-target-unknown` on BOTH
// branch keys — the card's own measurement — because the flat walk over
// `obj.validations` never descended into `then` / `otherwise`, even
// though `checkConditional` / `authoredRuleMessage` address the branch by
// exactly this name at runtime.
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
// The wrapper's own message is never rendered by `checkConditional`
// (#14518 keeps a bundle entry for it anyway, deliberately, so the
// bundle mirrors the declared rule set 1:1).
churn_reason_consistency: { message: 'Wrapper message (never rendered)' },
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
},
}),
);
expect(findings).toEqual([]);
});

it('still flags a bundle entry naming no rule at any depth — real orphans stay caught', () => {
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
churn_reason_ghost: { message: 'Nothing declares this.' },
},
}),
);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.demo_account._validations.churn_reason_ghost',
]);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
});

it('descends through a branch that is itself a nested conditional', () => {
const outerGate = {
type: 'conditional',
name: 'outer_gate',
message: 'outer',
when: "record.status == 'churned'",
then: {
type: 'conditional',
name: 'inner_gate',
message: 'inner',
when: 'record.churn_reason != null',
then: {
type: 'script',
name: 'innermost_rule',
message: 'deepest branch of all',
condition: 'true',
},
},
};
const findings = validateTranslationReferences(
conditionalStack([outerGate], { _validations: { innermost_rule: { message: '最深层的分支。' } } }),
);
expect(findings).toEqual([]);
});

it('skips an unnamed branch, same as an unnamed top-level rule already was (#14253)', () => {
const gateWithUnnamedBranch = {
type: 'conditional',
name: 'gate',
message: 'gate',
when: "record.status == 'churned'",
then: { type: 'script', message: 'has no name', condition: 'true' },
};
const findings = validateTranslationReferences(
conditionalStack([gateWithUnnamedBranch], { _validations: { gate: { message: '门。' } } }),
);
expect(findings).toEqual([]);
});
});

describe('validateTranslationReferences — option keys', () => {
it('flags an option key that is a near-miss of the stored value', () => {
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
Expand Down
38 changes: 33 additions & 5 deletions packages/lint/src/validate-translation-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,21 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* Add a validation rule's own `name` to the universe, then recurse into a
* `conditional` rule's `then` / `otherwise` branch — each branch is itself a
* full rule (with its own `name`, and possibly its own nested branches), and
* mirrors the recursion `evaluateRule` performs at runtime (#14700). See the
* call site in {@link buildUniverse} for why the wrapper's own name is kept
* too, even though `then` / `otherwise` are what a caller actually reads.
*/
function collectValidationRuleNames(rule: AnyRec, validations: Set<string>): void {
const ruleName = strName(rule.name);
if (ruleName) validations.add(ruleName);
if (isRec(rule.then)) collectValidationRuleNames(rule.then, validations);
if (isRec(rule.otherwise)) collectValidationRuleNames(rule.otherwise, validations);
}

/**
* "Did you mean?" over the known names — a namespace pass the shared helper
* cannot see, falling back to `suggestName`'s containment/edit-distance
Expand DownExpand Up@@ -209,9 +224,12 @@ interface ObjectFacts {
sections: Set<string>;
/**
* `_validations` names — the custom validation rules this object declares
* (`objects[].validations[].name`). `objects.<obj>._validations.<rule>.message`
* (#14253) is keyed by that name, so a ghost here is a rule message that
* renders in the source locale inside an otherwise translated refusal.
* (`objects[].validations[].name`, including a nested `conditional` rule's
* `then` / `otherwise` branch — the branch is itself a full rule and is the
* address `checkConditional` / `authoredRuleMessage` actually key on;
* #14700). `objects.<obj>._validations.<rule>.message` (#14253) is keyed by
* that name, so a ghost here is a rule message that renders in the source
* locale inside an otherwise translated refusal.
*/
validations: Set<string>;
/**
Expand DownExpand Up@@ -605,9 +623,19 @@ function buildUniverse(stack: AnyRec): Universe {
// #14253: `_validations.<rule>` is keyed by the rule's own `name`. A rule
// without a name has no key and is not registered — the resolver cannot
// address it either, so nothing is lost by skipping it here.
// #14700: a `conditional` rule's `then` / `otherwise` branch is itself a
// full rule, and its `name` — not the wrapper's — is the address
// `checkConditional` delegates to and `authoredRuleMessage` keys on (see
// `packages/objectql/src/validation/rule-validator.ts`). A flat walk over
// `obj.validations` never sees a branch name, so a legitimate bundle
// entry for one was reported as an orphan. Descend into `then` /
// `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself
// be a nested `conditional`, so depth is unbounded); the wrapper's own
// name stays in the universe too — its message is structurally
// unreachable at runtime, but #14518 keeps a bundle entry for it
// deliberately so the bundle mirrors the declared rule set 1:1.
for (const rule of asArray(obj.validations)) {
const ruleName = strName(rule.name);
if (ruleName) facts.validations.add(ruleName);
collectValidationRuleNames(rule, facts.validations);
}
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nested-conditional-validation-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.

The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.

The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,120 @@ describe('validateTranslationReferences — orphan keys', () => {
});
});

describe('validateTranslationReferences — nested conditional validation branches (#14700)', () => {
// Mirrors the card's own fixture: `demo_account` with one `conditional`
// rule whose `then` / `otherwise` are each a full, named rule.
const conditionalStack = (validations: unknown[], objectNode: Record<string, unknown>) => ({
objects: [
{
name: 'demo_account',
label: 'Account',
fields: {
status: { type: 'select', label: 'Status' },
churn_reason: { type: 'text', label: 'Churn reason' },
},
validations,
},
],
translations: [{ 'zh-CN': { objects: { demo_account: objectNode } } }],
});

const churnConsistencyRule = {
type: 'conditional',
name: 'churn_reason_consistency',
message: 'Churn reason must match the account state.',
when: "record.status == 'churned'",
then: {
type: 'script',
name: 'churn_reason_present',
message: 'A churned account needs a churn reason.',
condition: 'record.churn_reason == null',
},
otherwise: {
type: 'script',
name: 'churn_reason_absent',
message: 'A non-churned account must not carry a churn reason.',
condition: 'record.churn_reason != null',
},
};

it('accepts bundle entries for both branch names and the wrapper name at once', () => {
// Before the fix, this reported `translation-target-unknown` on BOTH
// branch keys — the card's own measurement — because the flat walk over
// `obj.validations` never descended into `then` / `otherwise`, even
// though `checkConditional` / `authoredRuleMessage` address the branch by
// exactly this name at runtime.
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
// The wrapper's own message is never rendered by `checkConditional`
// (#14518 keeps a bundle entry for it anyway, deliberately, so the
// bundle mirrors the declared rule set 1:1).
churn_reason_consistency: { message: 'Wrapper message (never rendered)' },
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
},
}),
);
expect(findings).toEqual([]);
});

it('still flags a bundle entry naming no rule at any depth — real orphans stay caught', () => {
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
churn_reason_ghost: { message: 'Nothing declares this.' },
},
}),
);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.demo_account._validations.churn_reason_ghost',
]);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
});

it('descends through a branch that is itself a nested conditional', () => {
const outerGate = {
type: 'conditional',
name: 'outer_gate',
message: 'outer',
when: "record.status == 'churned'",
then: {
type: 'conditional',
name: 'inner_gate',
message: 'inner',
when: 'record.churn_reason != null',
then: {
type: 'script',
name: 'innermost_rule',
message: 'deepest branch of all',
condition: 'true',
},
},
};
const findings = validateTranslationReferences(
conditionalStack([outerGate], { _validations: { innermost_rule: { message: '最深层的分支。' } } }),
);
expect(findings).toEqual([]);
});

it('skips an unnamed branch, same as an unnamed top-level rule already was (#14253)', () => {
const gateWithUnnamedBranch = {
type: 'conditional',
name: 'gate',
message: 'gate',
when: "record.status == 'churned'",
then: { type: 'script', message: 'has no name', condition: 'true' },
};
const findings = validateTranslationReferences(
conditionalStack([gateWithUnnamedBranch], { _validations: { gate: { message: '门。' } } }),
);
expect(findings).toEqual([]);
});
});

describe('validateTranslationReferences — option keys', () => {
it('flags an option key that is a near-miss of the stored value', () => {
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
Expand Down
38 changes: 33 additions & 5 deletions packages/lint/src/validate-translation-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,21 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* Add a validation rule's own `name` to the universe, then recurse into a
* `conditional` rule's `then` / `otherwise` branch — each branch is itself a
* full rule (with its own `name`, and possibly its own nested branches), and
* mirrors the recursion `evaluateRule` performs at runtime (#14700). See the
* call site in {@link buildUniverse} for why the wrapper's own name is kept
* too, even though `then` / `otherwise` are what a caller actually reads.
*/
function collectValidationRuleNames(rule: AnyRec, validations: Set<string>): void {
const ruleName = strName(rule.name);
if (ruleName) validations.add(ruleName);
if (isRec(rule.then)) collectValidationRuleNames(rule.then, validations);
if (isRec(rule.otherwise)) collectValidationRuleNames(rule.otherwise, validations);
}

/**
* "Did you mean?" over the known names — a namespace pass the shared helper
* cannot see, falling back to `suggestName`'s containment/edit-distance
Expand DownExpand Up@@ -209,9 +224,12 @@ interface ObjectFacts {
sections: Set<string>;
/**
* `_validations` names — the custom validation rules this object declares
* (`objects[].validations[].name`). `objects.<obj>._validations.<rule>.message`
* (#14253) is keyed by that name, so a ghost here is a rule message that
* renders in the source locale inside an otherwise translated refusal.
* (`objects[].validations[].name`, including a nested `conditional` rule's
* `then` / `otherwise` branch — the branch is itself a full rule and is the
* address `checkConditional` / `authoredRuleMessage` actually key on;
* #14700). `objects.<obj>._validations.<rule>.message` (#14253) is keyed by
* that name, so a ghost here is a rule message that renders in the source
* locale inside an otherwise translated refusal.
*/
validations: Set<string>;
/**
Expand DownExpand Up@@ -605,9 +623,19 @@ function buildUniverse(stack: AnyRec): Universe {
// #14253: `_validations.<rule>` is keyed by the rule's own `name`. A rule
// without a name has no key and is not registered — the resolver cannot
// address it either, so nothing is lost by skipping it here.
// #14700: a `conditional` rule's `then` / `otherwise` branch is itself a
// full rule, and its `name` — not the wrapper's — is the address
// `checkConditional` delegates to and `authoredRuleMessage` keys on (see
// `packages/objectql/src/validation/rule-validator.ts`). A flat walk over
// `obj.validations` never sees a branch name, so a legitimate bundle
// entry for one was reported as an orphan. Descend into `then` /
// `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself
// be a nested `conditional`, so depth is unbounded); the wrapper's own
// name stays in the universe too — its message is structurally
// unreachable at runtime, but #14518 keeps a bundle entry for it
// deliberately so the bundle mirrors the declared rule set 1:1.
for (const rule of asArray(obj.validations)) {
const ruleName = strName(rule.name);
if (ruleName) facts.validations.add(ruleName);
collectValidationRuleNames(rule, facts.validations);
}
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nested-conditional-validation-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.

The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.

The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,120 @@ describe('validateTranslationReferences — orphan keys', () => {
});
});

describe('validateTranslationReferences — nested conditional validation branches (#14700)', () => {
// Mirrors the card's own fixture: `demo_account` with one `conditional`
// rule whose `then` / `otherwise` are each a full, named rule.
const conditionalStack = (validations: unknown[], objectNode: Record<string, unknown>) => ({
objects: [
{
name: 'demo_account',
label: 'Account',
fields: {
status: { type: 'select', label: 'Status' },
churn_reason: { type: 'text', label: 'Churn reason' },
},
validations,
},
],
translations: [{ 'zh-CN': { objects: { demo_account: objectNode } } }],
});

const churnConsistencyRule = {
type: 'conditional',
name: 'churn_reason_consistency',
message: 'Churn reason must match the account state.',
when: "record.status == 'churned'",
then: {
type: 'script',
name: 'churn_reason_present',
message: 'A churned account needs a churn reason.',
condition: 'record.churn_reason == null',
},
otherwise: {
type: 'script',
name: 'churn_reason_absent',
message: 'A non-churned account must not carry a churn reason.',
condition: 'record.churn_reason != null',
},
};

it('accepts bundle entries for both branch names and the wrapper name at once', () => {
// Before the fix, this reported `translation-target-unknown` on BOTH
// branch keys — the card's own measurement — because the flat walk over
// `obj.validations` never descended into `then` / `otherwise`, even
// though `checkConditional` / `authoredRuleMessage` address the branch by
// exactly this name at runtime.
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
// The wrapper's own message is never rendered by `checkConditional`
// (#14518 keeps a bundle entry for it anyway, deliberately, so the
// bundle mirrors the declared rule set 1:1).
churn_reason_consistency: { message: 'Wrapper message (never rendered)' },
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
},
}),
);
expect(findings).toEqual([]);
});

it('still flags a bundle entry naming no rule at any depth — real orphans stay caught', () => {
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
churn_reason_ghost: { message: 'Nothing declares this.' },
},
}),
);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.demo_account._validations.churn_reason_ghost',
]);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
});

it('descends through a branch that is itself a nested conditional', () => {
const outerGate = {
type: 'conditional',
name: 'outer_gate',
message: 'outer',
when: "record.status == 'churned'",
then: {
type: 'conditional',
name: 'inner_gate',
message: 'inner',
when: 'record.churn_reason != null',
then: {
type: 'script',
name: 'innermost_rule',
message: 'deepest branch of all',
condition: 'true',
},
},
};
const findings = validateTranslationReferences(
conditionalStack([outerGate], { _validations: { innermost_rule: { message: '最深层的分支。' } } }),
);
expect(findings).toEqual([]);
});

it('skips an unnamed branch, same as an unnamed top-level rule already was (#14253)', () => {
const gateWithUnnamedBranch = {
type: 'conditional',
name: 'gate',
message: 'gate',
when: "record.status == 'churned'",
then: { type: 'script', message: 'has no name', condition: 'true' },
};
const findings = validateTranslationReferences(
conditionalStack([gateWithUnnamedBranch], { _validations: { gate: { message: '门。' } } }),
);
expect(findings).toEqual([]);
});
});

describe('validateTranslationReferences — option keys', () => {
it('flags an option key that is a near-miss of the stored value', () => {
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
Expand Down
38 changes: 33 additions & 5 deletions packages/lint/src/validate-translation-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,21 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* Add a validation rule's own `name` to the universe, then recurse into a
* `conditional` rule's `then` / `otherwise` branch — each branch is itself a
* full rule (with its own `name`, and possibly its own nested branches), and
* mirrors the recursion `evaluateRule` performs at runtime (#14700). See the
* call site in {@link buildUniverse} for why the wrapper's own name is kept
* too, even though `then` / `otherwise` are what a caller actually reads.
*/
function collectValidationRuleNames(rule: AnyRec, validations: Set<string>): void {
const ruleName = strName(rule.name);
if (ruleName) validations.add(ruleName);
if (isRec(rule.then)) collectValidationRuleNames(rule.then, validations);
if (isRec(rule.otherwise)) collectValidationRuleNames(rule.otherwise, validations);
}

/**
* "Did you mean?" over the known names — a namespace pass the shared helper
* cannot see, falling back to `suggestName`'s containment/edit-distance
Expand DownExpand Up@@ -209,9 +224,12 @@ interface ObjectFacts {
sections: Set<string>;
/**
* `_validations` names — the custom validation rules this object declares
* (`objects[].validations[].name`). `objects.<obj>._validations.<rule>.message`
* (#14253) is keyed by that name, so a ghost here is a rule message that
* renders in the source locale inside an otherwise translated refusal.
* (`objects[].validations[].name`, including a nested `conditional` rule's
* `then` / `otherwise` branch — the branch is itself a full rule and is the
* address `checkConditional` / `authoredRuleMessage` actually key on;
* #14700). `objects.<obj>._validations.<rule>.message` (#14253) is keyed by
* that name, so a ghost here is a rule message that renders in the source
* locale inside an otherwise translated refusal.
*/
validations: Set<string>;
/**
Expand DownExpand Up@@ -605,9 +623,19 @@ function buildUniverse(stack: AnyRec): Universe {
// #14253: `_validations.<rule>` is keyed by the rule's own `name`. A rule
// without a name has no key and is not registered — the resolver cannot
// address it either, so nothing is lost by skipping it here.
// #14700: a `conditional` rule's `then` / `otherwise` branch is itself a
// full rule, and its `name` — not the wrapper's — is the address
// `checkConditional` delegates to and `authoredRuleMessage` keys on (see
// `packages/objectql/src/validation/rule-validator.ts`). A flat walk over
// `obj.validations` never sees a branch name, so a legitimate bundle
// entry for one was reported as an orphan. Descend into `then` /
// `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself
// be a nested `conditional`, so depth is unbounded); the wrapper's own
// name stays in the universe too — its message is structurally
// unreachable at runtime, but #14518 keeps a bundle entry for it
// deliberately so the bundle mirrors the declared rule set 1:1.
for (const rule of asArray(obj.validations)) {
const ruleName = strName(rule.name);
if (ruleName) facts.validations.add(ruleName);
collectValidationRuleNames(rule, facts.validations);
}
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nested-conditional-validation-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.

The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.

The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,120 @@ describe('validateTranslationReferences — orphan keys', () => {
});
});

describe('validateTranslationReferences — nested conditional validation branches (#14700)', () => {
// Mirrors the card's own fixture: `demo_account` with one `conditional`
// rule whose `then` / `otherwise` are each a full, named rule.
const conditionalStack = (validations: unknown[], objectNode: Record<string, unknown>) => ({
objects: [
{
name: 'demo_account',
label: 'Account',
fields: {
status: { type: 'select', label: 'Status' },
churn_reason: { type: 'text', label: 'Churn reason' },
},
validations,
},
],
translations: [{ 'zh-CN': { objects: { demo_account: objectNode } } }],
});

const churnConsistencyRule = {
type: 'conditional',
name: 'churn_reason_consistency',
message: 'Churn reason must match the account state.',
when: "record.status == 'churned'",
then: {
type: 'script',
name: 'churn_reason_present',
message: 'A churned account needs a churn reason.',
condition: 'record.churn_reason == null',
},
otherwise: {
type: 'script',
name: 'churn_reason_absent',
message: 'A non-churned account must not carry a churn reason.',
condition: 'record.churn_reason != null',
},
};

it('accepts bundle entries for both branch names and the wrapper name at once', () => {
// Before the fix, this reported `translation-target-unknown` on BOTH
// branch keys — the card's own measurement — because the flat walk over
// `obj.validations` never descended into `then` / `otherwise`, even
// though `checkConditional` / `authoredRuleMessage` address the branch by
// exactly this name at runtime.
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
// The wrapper's own message is never rendered by `checkConditional`
// (#14518 keeps a bundle entry for it anyway, deliberately, so the
// bundle mirrors the declared rule set 1:1).
churn_reason_consistency: { message: 'Wrapper message (never rendered)' },
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
},
}),
);
expect(findings).toEqual([]);
});

it('still flags a bundle entry naming no rule at any depth — real orphans stay caught', () => {
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
churn_reason_ghost: { message: 'Nothing declares this.' },
},
}),
);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.demo_account._validations.churn_reason_ghost',
]);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
});

it('descends through a branch that is itself a nested conditional', () => {
const outerGate = {
type: 'conditional',
name: 'outer_gate',
message: 'outer',
when: "record.status == 'churned'",
then: {
type: 'conditional',
name: 'inner_gate',
message: 'inner',
when: 'record.churn_reason != null',
then: {
type: 'script',
name: 'innermost_rule',
message: 'deepest branch of all',
condition: 'true',
},
},
};
const findings = validateTranslationReferences(
conditionalStack([outerGate], { _validations: { innermost_rule: { message: '最深层的分支。' } } }),
);
expect(findings).toEqual([]);
});

it('skips an unnamed branch, same as an unnamed top-level rule already was (#14253)', () => {
const gateWithUnnamedBranch = {
type: 'conditional',
name: 'gate',
message: 'gate',
when: "record.status == 'churned'",
then: { type: 'script', message: 'has no name', condition: 'true' },
};
const findings = validateTranslationReferences(
conditionalStack([gateWithUnnamedBranch], { _validations: { gate: { message: '门。' } } }),
);
expect(findings).toEqual([]);
});
});

describe('validateTranslationReferences — option keys', () => {
it('flags an option key that is a near-miss of the stored value', () => {
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
Expand Down
38 changes: 33 additions & 5 deletions packages/lint/src/validate-translation-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,21 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* Add a validation rule's own `name` to the universe, then recurse into a
* `conditional` rule's `then` / `otherwise` branch — each branch is itself a
* full rule (with its own `name`, and possibly its own nested branches), and
* mirrors the recursion `evaluateRule` performs at runtime (#14700). See the
* call site in {@link buildUniverse} for why the wrapper's own name is kept
* too, even though `then` / `otherwise` are what a caller actually reads.
*/
function collectValidationRuleNames(rule: AnyRec, validations: Set<string>): void {
const ruleName = strName(rule.name);
if (ruleName) validations.add(ruleName);
if (isRec(rule.then)) collectValidationRuleNames(rule.then, validations);
if (isRec(rule.otherwise)) collectValidationRuleNames(rule.otherwise, validations);
}

/**
* "Did you mean?" over the known names — a namespace pass the shared helper
* cannot see, falling back to `suggestName`'s containment/edit-distance
Expand DownExpand Up@@ -209,9 +224,12 @@ interface ObjectFacts {
sections: Set<string>;
/**
* `_validations` names — the custom validation rules this object declares
* (`objects[].validations[].name`). `objects.<obj>._validations.<rule>.message`
* (#14253) is keyed by that name, so a ghost here is a rule message that
* renders in the source locale inside an otherwise translated refusal.
* (`objects[].validations[].name`, including a nested `conditional` rule's
* `then` / `otherwise` branch — the branch is itself a full rule and is the
* address `checkConditional` / `authoredRuleMessage` actually key on;
* #14700). `objects.<obj>._validations.<rule>.message` (#14253) is keyed by
* that name, so a ghost here is a rule message that renders in the source
* locale inside an otherwise translated refusal.
*/
validations: Set<string>;
/**
Expand DownExpand Up@@ -605,9 +623,19 @@ function buildUniverse(stack: AnyRec): Universe {
// #14253: `_validations.<rule>` is keyed by the rule's own `name`. A rule
// without a name has no key and is not registered — the resolver cannot
// address it either, so nothing is lost by skipping it here.
// #14700: a `conditional` rule's `then` / `otherwise` branch is itself a
// full rule, and its `name` — not the wrapper's — is the address
// `checkConditional` delegates to and `authoredRuleMessage` keys on (see
// `packages/objectql/src/validation/rule-validator.ts`). A flat walk over
// `obj.validations` never sees a branch name, so a legitimate bundle
// entry for one was reported as an orphan. Descend into `then` /
// `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself
// be a nested `conditional`, so depth is unbounded); the wrapper's own
// name stays in the universe too — its message is structurally
// unreachable at runtime, but #14518 keeps a bundle entry for it
// deliberately so the bundle mirrors the declared rule set 1:1.
for (const rule of asArray(obj.validations)) {
const ruleName = strName(rule.name);
if (ruleName) facts.validations.add(ruleName);
collectValidationRuleNames(rule, facts.validations);
}
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nested-conditional-validation-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.

The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.

The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,120 @@ describe('validateTranslationReferences — orphan keys', () => {
});
});

describe('validateTranslationReferences — nested conditional validation branches (#14700)', () => {
// Mirrors the card's own fixture: `demo_account` with one `conditional`
// rule whose `then` / `otherwise` are each a full, named rule.
const conditionalStack = (validations: unknown[], objectNode: Record<string, unknown>) => ({
objects: [
{
name: 'demo_account',
label: 'Account',
fields: {
status: { type: 'select', label: 'Status' },
churn_reason: { type: 'text', label: 'Churn reason' },
},
validations,
},
],
translations: [{ 'zh-CN': { objects: { demo_account: objectNode } } }],
});

const churnConsistencyRule = {
type: 'conditional',
name: 'churn_reason_consistency',
message: 'Churn reason must match the account state.',
when: "record.status == 'churned'",
then: {
type: 'script',
name: 'churn_reason_present',
message: 'A churned account needs a churn reason.',
condition: 'record.churn_reason == null',
},
otherwise: {
type: 'script',
name: 'churn_reason_absent',
message: 'A non-churned account must not carry a churn reason.',
condition: 'record.churn_reason != null',
},
};

it('accepts bundle entries for both branch names and the wrapper name at once', () => {
// Before the fix, this reported `translation-target-unknown` on BOTH
// branch keys — the card's own measurement — because the flat walk over
// `obj.validations` never descended into `then` / `otherwise`, even
// though `checkConditional` / `authoredRuleMessage` address the branch by
// exactly this name at runtime.
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
// The wrapper's own message is never rendered by `checkConditional`
// (#14518 keeps a bundle entry for it anyway, deliberately, so the
// bundle mirrors the declared rule set 1:1).
churn_reason_consistency: { message: 'Wrapper message (never rendered)' },
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
},
}),
);
expect(findings).toEqual([]);
});

it('still flags a bundle entry naming no rule at any depth — real orphans stay caught', () => {
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
churn_reason_ghost: { message: 'Nothing declares this.' },
},
}),
);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.demo_account._validations.churn_reason_ghost',
]);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
});

it('descends through a branch that is itself a nested conditional', () => {
const outerGate = {
type: 'conditional',
name: 'outer_gate',
message: 'outer',
when: "record.status == 'churned'",
then: {
type: 'conditional',
name: 'inner_gate',
message: 'inner',
when: 'record.churn_reason != null',
then: {
type: 'script',
name: 'innermost_rule',
message: 'deepest branch of all',
condition: 'true',
},
},
};
const findings = validateTranslationReferences(
conditionalStack([outerGate], { _validations: { innermost_rule: { message: '最深层的分支。' } } }),
);
expect(findings).toEqual([]);
});

it('skips an unnamed branch, same as an unnamed top-level rule already was (#14253)', () => {
const gateWithUnnamedBranch = {
type: 'conditional',
name: 'gate',
message: 'gate',
when: "record.status == 'churned'",
then: { type: 'script', message: 'has no name', condition: 'true' },
};
const findings = validateTranslationReferences(
conditionalStack([gateWithUnnamedBranch], { _validations: { gate: { message: '门。' } } }),
);
expect(findings).toEqual([]);
});
});

describe('validateTranslationReferences — option keys', () => {
it('flags an option key that is a near-miss of the stored value', () => {
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
Expand Down
38 changes: 33 additions & 5 deletions packages/lint/src/validate-translation-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,21 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* Add a validation rule's own `name` to the universe, then recurse into a
* `conditional` rule's `then` / `otherwise` branch — each branch is itself a
* full rule (with its own `name`, and possibly its own nested branches), and
* mirrors the recursion `evaluateRule` performs at runtime (#14700). See the
* call site in {@link buildUniverse} for why the wrapper's own name is kept
* too, even though `then` / `otherwise` are what a caller actually reads.
*/
function collectValidationRuleNames(rule: AnyRec, validations: Set<string>): void {
const ruleName = strName(rule.name);
if (ruleName) validations.add(ruleName);
if (isRec(rule.then)) collectValidationRuleNames(rule.then, validations);
if (isRec(rule.otherwise)) collectValidationRuleNames(rule.otherwise, validations);
}

/**
* "Did you mean?" over the known names — a namespace pass the shared helper
* cannot see, falling back to `suggestName`'s containment/edit-distance
Expand DownExpand Up@@ -209,9 +224,12 @@ interface ObjectFacts {
sections: Set<string>;
/**
* `_validations` names — the custom validation rules this object declares
* (`objects[].validations[].name`). `objects.<obj>._validations.<rule>.message`
* (#14253) is keyed by that name, so a ghost here is a rule message that
* renders in the source locale inside an otherwise translated refusal.
* (`objects[].validations[].name`, including a nested `conditional` rule's
* `then` / `otherwise` branch — the branch is itself a full rule and is the
* address `checkConditional` / `authoredRuleMessage` actually key on;
* #14700). `objects.<obj>._validations.<rule>.message` (#14253) is keyed by
* that name, so a ghost here is a rule message that renders in the source
* locale inside an otherwise translated refusal.
*/
validations: Set<string>;
/**
Expand DownExpand Up@@ -605,9 +623,19 @@ function buildUniverse(stack: AnyRec): Universe {
// #14253: `_validations.<rule>` is keyed by the rule's own `name`. A rule
// without a name has no key and is not registered — the resolver cannot
// address it either, so nothing is lost by skipping it here.
// #14700: a `conditional` rule's `then` / `otherwise` branch is itself a
// full rule, and its `name` — not the wrapper's — is the address
// `checkConditional` delegates to and `authoredRuleMessage` keys on (see
// `packages/objectql/src/validation/rule-validator.ts`). A flat walk over
// `obj.validations` never sees a branch name, so a legitimate bundle
// entry for one was reported as an orphan. Descend into `then` /
// `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself
// be a nested `conditional`, so depth is unbounded); the wrapper's own
// name stays in the universe too — its message is structurally
// unreachable at runtime, but #14518 keeps a bundle entry for it
// deliberately so the bundle mirrors the declared rule set 1:1.
for (const rule of asArray(obj.validations)) {
const ruleName = strName(rule.name);
if (ruleName) facts.validations.add(ruleName);
collectValidationRuleNames(rule, facts.validations);
}
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nested-conditional-validation-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.

The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.

The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,120 @@ describe('validateTranslationReferences — orphan keys', () => {
});
});

describe('validateTranslationReferences — nested conditional validation branches (#14700)', () => {
// Mirrors the card's own fixture: `demo_account` with one `conditional`
// rule whose `then` / `otherwise` are each a full, named rule.
const conditionalStack = (validations: unknown[], objectNode: Record<string, unknown>) => ({
objects: [
{
name: 'demo_account',
label: 'Account',
fields: {
status: { type: 'select', label: 'Status' },
churn_reason: { type: 'text', label: 'Churn reason' },
},
validations,
},
],
translations: [{ 'zh-CN': { objects: { demo_account: objectNode } } }],
});

const churnConsistencyRule = {
type: 'conditional',
name: 'churn_reason_consistency',
message: 'Churn reason must match the account state.',
when: "record.status == 'churned'",
then: {
type: 'script',
name: 'churn_reason_present',
message: 'A churned account needs a churn reason.',
condition: 'record.churn_reason == null',
},
otherwise: {
type: 'script',
name: 'churn_reason_absent',
message: 'A non-churned account must not carry a churn reason.',
condition: 'record.churn_reason != null',
},
};

it('accepts bundle entries for both branch names and the wrapper name at once', () => {
// Before the fix, this reported `translation-target-unknown` on BOTH
// branch keys — the card's own measurement — because the flat walk over
// `obj.validations` never descended into `then` / `otherwise`, even
// though `checkConditional` / `authoredRuleMessage` address the branch by
// exactly this name at runtime.
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
// The wrapper's own message is never rendered by `checkConditional`
// (#14518 keeps a bundle entry for it anyway, deliberately, so the
// bundle mirrors the declared rule set 1:1).
churn_reason_consistency: { message: 'Wrapper message (never rendered)' },
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
},
}),
);
expect(findings).toEqual([]);
});

it('still flags a bundle entry naming no rule at any depth — real orphans stay caught', () => {
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
churn_reason_ghost: { message: 'Nothing declares this.' },
},
}),
);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.demo_account._validations.churn_reason_ghost',
]);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
});

it('descends through a branch that is itself a nested conditional', () => {
const outerGate = {
type: 'conditional',
name: 'outer_gate',
message: 'outer',
when: "record.status == 'churned'",
then: {
type: 'conditional',
name: 'inner_gate',
message: 'inner',
when: 'record.churn_reason != null',
then: {
type: 'script',
name: 'innermost_rule',
message: 'deepest branch of all',
condition: 'true',
},
},
};
const findings = validateTranslationReferences(
conditionalStack([outerGate], { _validations: { innermost_rule: { message: '最深层的分支。' } } }),
);
expect(findings).toEqual([]);
});

it('skips an unnamed branch, same as an unnamed top-level rule already was (#14253)', () => {
const gateWithUnnamedBranch = {
type: 'conditional',
name: 'gate',
message: 'gate',
when: "record.status == 'churned'",
then: { type: 'script', message: 'has no name', condition: 'true' },
};
const findings = validateTranslationReferences(
conditionalStack([gateWithUnnamedBranch], { _validations: { gate: { message: '门。' } } }),
);
expect(findings).toEqual([]);
});
});

describe('validateTranslationReferences — option keys', () => {
it('flags an option key that is a near-miss of the stored value', () => {
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
Expand Down
38 changes: 33 additions & 5 deletions packages/lint/src/validate-translation-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,21 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* Add a validation rule's own `name` to the universe, then recurse into a
* `conditional` rule's `then` / `otherwise` branch — each branch is itself a
* full rule (with its own `name`, and possibly its own nested branches), and
* mirrors the recursion `evaluateRule` performs at runtime (#14700). See the
* call site in {@link buildUniverse} for why the wrapper's own name is kept
* too, even though `then` / `otherwise` are what a caller actually reads.
*/
function collectValidationRuleNames(rule: AnyRec, validations: Set<string>): void {
const ruleName = strName(rule.name);
if (ruleName) validations.add(ruleName);
if (isRec(rule.then)) collectValidationRuleNames(rule.then, validations);
if (isRec(rule.otherwise)) collectValidationRuleNames(rule.otherwise, validations);
}

/**
* "Did you mean?" over the known names — a namespace pass the shared helper
* cannot see, falling back to `suggestName`'s containment/edit-distance
Expand DownExpand Up@@ -209,9 +224,12 @@ interface ObjectFacts {
sections: Set<string>;
/**
* `_validations` names — the custom validation rules this object declares
* (`objects[].validations[].name`). `objects.<obj>._validations.<rule>.message`
* (#14253) is keyed by that name, so a ghost here is a rule message that
* renders in the source locale inside an otherwise translated refusal.
* (`objects[].validations[].name`, including a nested `conditional` rule's
* `then` / `otherwise` branch — the branch is itself a full rule and is the
* address `checkConditional` / `authoredRuleMessage` actually key on;
* #14700). `objects.<obj>._validations.<rule>.message` (#14253) is keyed by
* that name, so a ghost here is a rule message that renders in the source
* locale inside an otherwise translated refusal.
*/
validations: Set<string>;
/**
Expand DownExpand Up@@ -605,9 +623,19 @@ function buildUniverse(stack: AnyRec): Universe {
// #14253: `_validations.<rule>` is keyed by the rule's own `name`. A rule
// without a name has no key and is not registered — the resolver cannot
// address it either, so nothing is lost by skipping it here.
// #14700: a `conditional` rule's `then` / `otherwise` branch is itself a
// full rule, and its `name` — not the wrapper's — is the address
// `checkConditional` delegates to and `authoredRuleMessage` keys on (see
// `packages/objectql/src/validation/rule-validator.ts`). A flat walk over
// `obj.validations` never sees a branch name, so a legitimate bundle
// entry for one was reported as an orphan. Descend into `then` /
// `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself
// be a nested `conditional`, so depth is unbounded); the wrapper's own
// name stays in the universe too — its message is structurally
// unreachable at runtime, but #14518 keeps a bundle entry for it
// deliberately so the bundle mirrors the declared rule set 1:1.
for (const rule of asArray(obj.validations)) {
const ruleName = strName(rule.name);
if (ruleName) facts.validations.add(ruleName);
collectValidationRuleNames(rule, facts.validations);
}
}

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nested-conditional-validation-refs.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/lint': patch
---

Fix `validate-translation-references` reporting a nested `conditional` validation branch's legitimate `_validations` bundle entry as an orphan `translation-target-unknown`, with inverted advice.

The rule built its `_validations` universe with a flat walk of `objects[].validations[]`. A `conditional` rule's `then` / `otherwise` branch is itself a full rule carrying its own `name`, and that branch name — not the wrapper's — is the address `checkConditional` delegates to and `authoredRuleMessage` keys on at runtime (`packages/objectql/src/validation/rule-validator.ts`). The flat walk never saw a branch name, so a correct bundle entry for one was flagged as an orphan, and the finding's own text ("keeps its source locale in every refusal") was the opposite of the truth for that key — acting on the advice (deleting the entry) reintroduced the exact defect it fixed.

The walk now descends into `then` / `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself be a nested `conditional`, so depth is unbounded). The wrapper's own name stays in the universe, unchanged: its message is structurally unreachable at runtime, but a bundle entry for it is deliberately kept elsewhere so the bundle mirrors the declared rule set 1:1.
114 changes: 114 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,6 +152,120 @@ describe('validateTranslationReferences — orphan keys', () => {
});
});

describe('validateTranslationReferences — nested conditional validation branches (#14700)', () => {
// Mirrors the card's own fixture: `demo_account` with one `conditional`
// rule whose `then` / `otherwise` are each a full, named rule.
const conditionalStack = (validations: unknown[], objectNode: Record<string, unknown>) => ({
objects: [
{
name: 'demo_account',
label: 'Account',
fields: {
status: { type: 'select', label: 'Status' },
churn_reason: { type: 'text', label: 'Churn reason' },
},
validations,
},
],
translations: [{ 'zh-CN': { objects: { demo_account: objectNode } } }],
});

const churnConsistencyRule = {
type: 'conditional',
name: 'churn_reason_consistency',
message: 'Churn reason must match the account state.',
when: "record.status == 'churned'",
then: {
type: 'script',
name: 'churn_reason_present',
message: 'A churned account needs a churn reason.',
condition: 'record.churn_reason == null',
},
otherwise: {
type: 'script',
name: 'churn_reason_absent',
message: 'A non-churned account must not carry a churn reason.',
condition: 'record.churn_reason != null',
},
};

it('accepts bundle entries for both branch names and the wrapper name at once', () => {
// Before the fix, this reported `translation-target-unknown` on BOTH
// branch keys — the card's own measurement — because the flat walk over
// `obj.validations` never descended into `then` / `otherwise`, even
// though `checkConditional` / `authoredRuleMessage` address the branch by
// exactly this name at runtime.
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
// The wrapper's own message is never rendered by `checkConditional`
// (#14518 keeps a bundle entry for it anyway, deliberately, so the
// bundle mirrors the declared rule set 1:1).
churn_reason_consistency: { message: 'Wrapper message (never rendered)' },
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
},
}),
);
expect(findings).toEqual([]);
});

it('still flags a bundle entry naming no rule at any depth — real orphans stay caught', () => {
const findings = validateTranslationReferences(
conditionalStack([churnConsistencyRule], {
_validations: {
churn_reason_present: { message: '流失账户需要填写流失原因。' },
churn_reason_absent: { message: '未流失账户不应填写流失原因。' },
churn_reason_ghost: { message: 'Nothing declares this.' },
},
}),
);
expect(findings.map((f) => f.path)).toEqual([
'translations[0]["zh-CN"].objects.demo_account._validations.churn_reason_ghost',
]);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
});

it('descends through a branch that is itself a nested conditional', () => {
const outerGate = {
type: 'conditional',
name: 'outer_gate',
message: 'outer',
when: "record.status == 'churned'",
then: {
type: 'conditional',
name: 'inner_gate',
message: 'inner',
when: 'record.churn_reason != null',
then: {
type: 'script',
name: 'innermost_rule',
message: 'deepest branch of all',
condition: 'true',
},
},
};
const findings = validateTranslationReferences(
conditionalStack([outerGate], { _validations: { innermost_rule: { message: '最深层的分支。' } } }),
);
expect(findings).toEqual([]);
});

it('skips an unnamed branch, same as an unnamed top-level rule already was (#14253)', () => {
const gateWithUnnamedBranch = {
type: 'conditional',
name: 'gate',
message: 'gate',
when: "record.status == 'churned'",
then: { type: 'script', message: 'has no name', condition: 'true' },
};
const findings = validateTranslationReferences(
conditionalStack([gateWithUnnamedBranch], { _validations: { gate: { message: '门。' } } }),
);
expect(findings).toEqual([]);
});
});

describe('validateTranslationReferences — option keys', () => {
it('flags an option key that is a near-miss of the stored value', () => {
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
Expand Down
38 changes: 33 additions & 5 deletions packages/lint/src/validate-translation-references.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,21 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/**
* Add a validation rule's own `name` to the universe, then recurse into a
* `conditional` rule's `then` / `otherwise` branch — each branch is itself a
* full rule (with its own `name`, and possibly its own nested branches), and
* mirrors the recursion `evaluateRule` performs at runtime (#14700). See the
* call site in {@link buildUniverse} for why the wrapper's own name is kept
* too, even though `then` / `otherwise` are what a caller actually reads.
*/
function collectValidationRuleNames(rule: AnyRec, validations: Set<string>): void {
const ruleName = strName(rule.name);
if (ruleName) validations.add(ruleName);
if (isRec(rule.then)) collectValidationRuleNames(rule.then, validations);
if (isRec(rule.otherwise)) collectValidationRuleNames(rule.otherwise, validations);
}

/**
* "Did you mean?" over the known names — a namespace pass the shared helper
* cannot see, falling back to `suggestName`'s containment/edit-distance
Expand DownExpand Up@@ -209,9 +224,12 @@ interface ObjectFacts {
sections: Set<string>;
/**
* `_validations` names — the custom validation rules this object declares
* (`objects[].validations[].name`). `objects.<obj>._validations.<rule>.message`
* (#14253) is keyed by that name, so a ghost here is a rule message that
* renders in the source locale inside an otherwise translated refusal.
* (`objects[].validations[].name`, including a nested `conditional` rule's
* `then` / `otherwise` branch — the branch is itself a full rule and is the
* address `checkConditional` / `authoredRuleMessage` actually key on;
* #14700). `objects.<obj>._validations.<rule>.message` (#14253) is keyed by
* that name, so a ghost here is a rule message that renders in the source
* locale inside an otherwise translated refusal.
*/
validations: Set<string>;
/**
Expand DownExpand Up@@ -605,9 +623,19 @@ function buildUniverse(stack: AnyRec): Universe {
// #14253: `_validations.<rule>` is keyed by the rule's own `name`. A rule
// without a name has no key and is not registered — the resolver cannot
// address it either, so nothing is lost by skipping it here.
// #14700: a `conditional` rule's `then` / `otherwise` branch is itself a
// full rule, and its `name` — not the wrapper's — is the address
// `checkConditional` delegates to and `authoredRuleMessage` keys on (see
// `packages/objectql/src/validation/rule-validator.ts`). A flat walk over
// `obj.validations` never sees a branch name, so a legitimate bundle
// entry for one was reported as an orphan. Descend into `then` /
// `otherwise`, mirroring `evaluateRule`'s recursion (a branch may itself
// be a nested `conditional`, so depth is unbounded); the wrapper's own
// name stays in the universe too — its message is structurally
// unreachable at runtime, but #14518 keeps a bundle entry for it
// deliberately so the bundle mirrors the declared rule set 1:1.
for (const rule of asArray(obj.validations)) {
const ruleName = strName(rule.name);
if (ruleName) facts.validations.add(ruleName);
collectValidationRuleNames(rule, facts.validations);
}
}

Expand Down
Loading