|
6 | 6 | VISIBILITY_ROOT_MISLAYERED, |
7 | 7 | VISIBILITY_BARE_IDENTIFIER, |
8 | 8 | VISIBILITY_PREDICATE_SYNTAX, |
| 9 | + VISIBILITY_PREDICATE_OVER_BUDGET, |
9 | 10 | } from './validate-visibility-predicates.js'; |
10 | 11 | import { AUTHORING_RULES } from './authoring-rules.js'; |
11 | 12 |
|
@@ -713,19 +714,15 @@ describe('visibility-predicate-syntax (#6253)', () => { |
713 | 714 | expect(validateVisibilityPredicates(formStack('type(record.x) == string'))).toEqual([]); |
714 | 715 | }); |
715 | 716 |
|
716 | | - it('a `DEFAULT_LIMITS` overrun is reported too, in the front end\'s own words', () => { |
717 | | - // `parseCelToAst` also returns null for a source over the platform bounds. |
718 | | - // That is a bounds fault, not a syntax one, and the message says so rather |
719 | | - // than pretending to have found a typo — the same way ADR-0032 already |
720 | | - // reports it under the "invalid CEL predicate" heading. |
| 717 | + it('a `DEFAULT_LIMITS` overrun is NOT this rule any more — it is `over-budget` (#7217)', () => { |
| 718 | + // Was: "reported too, in the front end's own words", asserted under |
| 719 | + // `visibility-predicate-syntax`. #7217 keeps the verdict and moves the |
| 720 | + // wording and the id; this case is now the exclusivity pin for the split, |
| 721 | + // and its full coverage lives in the `over-budget` block below. |
721 | 722 | const overrun = `record.a${' + record.b'.repeat(400)}`; |
722 | | - const findings = syntaxFindings(formStack(overrun)); |
723 | | - expect(findings).toHaveLength(1); |
724 | | - expect(findings[0].message).toContain('Exceeded maxAstNodes'); |
725 | | - // The echoed predicate is elided, so one runaway expression cannot flood |
726 | | - // the console with a 4KB finding. |
727 | | - expect(findings[0].message).not.toContain(overrun); |
728 | | - expect(findings[0].message).toContain('...'); |
| 723 | + expect(syntaxFindings(formStack(overrun))).toEqual([]); |
| 724 | + expect(validateVisibilityPredicates(formStack(overrun)).map((f) => f.rule)) |
| 725 | + .toEqual([VISIBILITY_PREDICATE_OVER_BUDGET]); |
729 | 726 | }); |
730 | 727 |
|
731 | 728 | it.each([ |
@@ -806,3 +803,146 @@ describe('visibility-predicate-syntax (#6253)', () => { |
806 | 803 | expect([...entry!.commands].sort()).toEqual(['build', 'lint', 'validate']); |
807 | 804 | }); |
808 | 805 | }); |
| 806 | + |
| 807 | +// ───────────────────────────────────────────────────────────────────── |
| 808 | +// `visibility-predicate-over-budget` — #7217. |
| 809 | +// |
| 810 | +// The defect this closes is an INSTRUCTION that makes an obedient author |
| 811 | +// worse. An over-budget `visibleWhen` is flawless bare CEL; the gate refused it |
| 812 | +// (correctly) as "not valid CEL" (false) and prescribed the dialect ("write |
| 813 | +// `==` not `===`, `&&` not `and` …"), which is advice that cannot succeed. An |
| 814 | +// LLM author follows the last sentence it was handed, rewrites operators that |
| 815 | +// were never wrong, and returns with the same 80-clause predicate. |
| 816 | +// |
| 817 | +// The verdict is untouched: the same sources are refused before and after, at |
| 818 | +// the same severity, one finding each. Only the class, the id and the words |
| 819 | +// changed. Both directions are pinned deliberately — a fix that turned EVERY |
| 820 | +// refusal into a size refusal would be green on the first block below, which is |
| 821 | +// why the syntax block re-asserts the dialect wording it must not lose. |
| 822 | +// ───────────────────────────────────────────────────────────────────── |
| 823 | + |
| 824 | +/** Only the over-budget findings. */ |
| 825 | +function overBudgetFindings(stack: Record<string, unknown>, opts?: { layer: 'runtime' | 'metadata' }) { |
| 826 | + return validateVisibilityPredicates(stack, opts).filter((f) => f.rule === VISIBILITY_PREDICATE_OVER_BUDGET); |
| 827 | +} |
| 828 | + |
| 829 | +/** The escalation's own shape — 80-term conjunction, `maxAstNodes` (#6833 / #7073). */ |
| 830 | +const OVER_AST_NODES = Array.from({ length: 80 }, (_, i) => `record.f${i} == ${i}`).join(' && '); |
| 831 | +/** 60-level parenthesis nest — `maxDepth`. Recursion that leaves no AST node. */ |
| 832 | +const OVER_DEPTH = `${'('.repeat(60)}record.a${')'.repeat(60)} == 1`; |
| 833 | +/** 200-element list literal — `maxListElements`. */ |
| 834 | +const OVER_LIST = `record.id in [${Array.from({ length: 200 }, (_, i) => `'u${i}'`).join(',')}]`; |
| 835 | + |
| 836 | +describe('visibility-predicate-over-budget (#7217)', () => { |
| 837 | + describe('the acceptance pair', () => { |
| 838 | + it('an over-budget but valid predicate names the SIZE fault and the bound, never the dialect', () => { |
| 839 | + const findings = validateVisibilityPredicates(formStack(OVER_AST_NODES)); |
| 840 | + |
| 841 | + // The whole reported set: one finding, the new id, still gating. |
| 842 | + expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_PREDICATE_OVER_BUDGET]); |
| 843 | + expect(findings[0].severity).toBe('error'); |
| 844 | + expect(findings[0].path).toBe('views[0].sections[0].fields[0]'); |
| 845 | + expect(findings[0].where).toBe('view "task_form"'); |
| 846 | + |
| 847 | + // ⛔ The headline was FALSE: this IS valid CEL. |
| 848 | + expect(findings[0].message).not.toContain('is not valid CEL'); |
| 849 | + expect(findings[0].message).toContain('syntactically valid CEL'); |
| 850 | + // The front end's own summary is quoted, and the bound is NAMED with the |
| 851 | + // platform's value for it — which is what "shrink it to fit" needs. |
| 852 | + expect(findings[0].message).toContain('Exceeded maxAstNodes (256)'); |
| 853 | + expect(findings[0].message).toContain('`maxAstNodes` budget (platform limit 256)'); |
| 854 | + // The consequence is unchanged: it still falls OPEN on screen. |
| 855 | + expect(findings[0].message).toContain('#5149'); |
| 856 | + |
| 857 | + // ⛔ The defect itself: the dialect prescription must not reach this class. |
| 858 | + expect(findings[0].hint).not.toMatch(/bare CEL/); |
| 859 | + expect(findings[0].hint).not.toContain('`===`'); |
| 860 | + expect(findings[0].hint).toContain('SIZE fault, not a dialect mistake'); |
| 861 | + expect(findings[0].hint).toContain("`record.f in ['a', 'b', …]`"); |
| 862 | + }); |
| 863 | + |
| 864 | + it('the SAME predicate under budget is clean — paired so it cannot pass vacuously', () => { |
| 865 | + // Delete the rule and the first expectation goes red; loosen the verdict |
| 866 | + // (report everything) and the second does. |
| 867 | + expect(overBudgetFindings(formStack(OVER_AST_NODES))).toHaveLength(1); |
| 868 | + const underBudget = Array.from({ length: 8 }, (_, i) => `record.f${i} == ${i}`).join(' && '); |
| 869 | + expect(validateVisibilityPredicates(formStack(underBudget))).toEqual([]); |
| 870 | + }); |
| 871 | + }); |
| 872 | + |
| 873 | + it.each([ |
| 874 | + ['maxAstNodes (80-term conjunction)', OVER_AST_NODES, 'maxAstNodes', 256], |
| 875 | + ['maxDepth (60-level nest)', OVER_DEPTH, 'maxDepth', 32], |
| 876 | + ['maxListElements (200-element list)', OVER_LIST, 'maxListElements', 64], |
| 877 | + ])('%s — the bound that was actually exceeded is the one named', (_name, source, limit, value) => { |
| 878 | + // Not one hard-coded bound: the name and its value are read off the front |
| 879 | + // end's structured overrun, so a source that overruns a DIFFERENT axis is |
| 880 | + // sent to shorten that axis and not `maxAstNodes` by default. |
| 881 | + const findings = overBudgetFindings(formStack(source as string)); |
| 882 | + expect(findings).toHaveLength(1); |
| 883 | + expect(findings[0].message).toContain(`\`${limit}\` budget (platform limit ${value})`); |
| 884 | + expect(findings[0].hint).toContain('SIZE fault, not a dialect mistake'); |
| 885 | + }); |
| 886 | + |
| 887 | + it('a genuine dialect fault keeps the #6253 id, message and hint verbatim', () => { |
| 888 | + // The flipped pin. The obvious way to get #7217 wrong is to turn EVERY |
| 889 | + // refusal into a size refusal — green on every case above, and a total loss |
| 890 | + // of the wording #6253 shipped. |
| 891 | + const findings = validateVisibilityPredicates(formStack('country === "USA"')); |
| 892 | + expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_PREDICATE_SYNTAX]); |
| 893 | + expect(findings[0].message).toContain('is not valid CEL'); |
| 894 | + expect(findings[0].hint).toContain('`===` is not a CEL operator'); |
| 895 | + expect(findings[0].hint).not.toMatch(/SIZE fault/); |
| 896 | + }); |
| 897 | + |
| 898 | + it('a dialect fault with no single-token equivalent keeps the FALLBACK dialect hint', () => { |
| 899 | + // The arm the card names: the fallback hint fires when no NON_CEL_SPELLINGS |
| 900 | + // row matches — which is exactly the arm an over-budget source used to land |
| 901 | + // in. It must still fire for a source that genuinely is not CEL. |
| 902 | + const findings = validateVisibilityPredicates(formStack('record.stage @@ "won"')); |
| 903 | + expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_PREDICATE_SYNTAX]); |
| 904 | + expect(findings[0].hint).toContain('Visibility predicates are bare CEL'); |
| 905 | + expect(findings[0].hint).not.toMatch(/SIZE fault/); |
| 906 | + }); |
| 907 | + |
| 908 | + it('still exactly ONE finding — the bare-identifier rule stays out of the way', () => { |
| 909 | + // An over-budget source yields no AST, so there are no identifiers to judge. |
| 910 | + // The exclusivity property #6253/#6128 established survives the split. |
| 911 | + const rootless = Array.from({ length: 80 }, (_, i) => `f${i} == ${i}`).join(' && '); |
| 912 | + expect(validateVisibilityPredicates(formStack(rootless)).map((f) => f.rule)) |
| 913 | + .toEqual([VISIBILITY_PREDICATE_OVER_BUDGET]); |
| 914 | + }); |
| 915 | + |
| 916 | + it('elides the echoed predicate — one runaway expression cannot flood the console', () => { |
| 917 | + const findings = overBudgetFindings(formStack(OVER_AST_NODES)); |
| 918 | + expect(findings[0].message).not.toContain(OVER_AST_NODES); |
| 919 | + expect(findings[0].message).toContain('...'); |
| 920 | + }); |
| 921 | + |
| 922 | + it('a blank predicate is still not a fault of any class', () => { |
| 923 | + // `parseCelToAstWithReason` answers `empty` rather than `parse`; paired with |
| 924 | + // a live case so the assertion can actually fail. |
| 925 | + expect(overBudgetFindings(formStack(OVER_AST_NODES))).toHaveLength(1); |
| 926 | + expect(validateVisibilityPredicates(formStack(' '))).toEqual([]); |
| 927 | + expect(validateVisibilityPredicates(formStack(undefined))).toEqual([]); |
| 928 | + }); |
| 929 | + |
| 930 | + it('speaks the LAYER\'s binding root in its remedy', () => { |
| 931 | + // A `*.form.ts` author binds `data`, so a `record.`-flavoured example would |
| 932 | + // be a second wrong prescription in a rule whose whole point is that the |
| 933 | + // prescription must be followable. |
| 934 | + const metadata = overBudgetFindings(formStack(OVER_AST_NODES), { layer: 'metadata' }); |
| 935 | + expect(metadata).toHaveLength(1); |
| 936 | + expect(metadata[0].hint).toContain("`data.f in ['a', 'b', …]`"); |
| 937 | + expect(metadata[0].hint).not.toContain('`record.f in'); |
| 938 | + }); |
| 939 | + |
| 940 | + it('reaches page components too — the id is not view-only', () => { |
| 941 | + const stack = { |
| 942 | + pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibleWhen: OVER_AST_NODES }] }] }], |
| 943 | + }; |
| 944 | + const findings = overBudgetFindings(stack); |
| 945 | + expect(findings.map((f) => f.path)).toEqual(['pages[0].regions[0].components[0]']); |
| 946 | + expect(findings[0].where).toBe('page "p"'); |
| 947 | + }); |
| 948 | +}); |
0 commit comments