agentleFS
abap2UI5abap2UI5/app-template/.claude/skills/ui5-check/SKILL.md

ui5-check

Skill1 starsChanged 12 days agoMITView on GitHub

The catalogue of UI5 problems a green CI does not catch - names that do not exist in the oldest supported release (icons, controls, properties, aggregations, enum values, modules, themes), layout that only works from a newer release on, views that fail to load rather than to render, and CSP/runtime traps. Also the staging area for the abap2UI5 linter - every entry says what a rule would need to decide it. Use when writing or reviewing any XML view, fragment or frontend module, when an icon or control "is simply not there" on an old system, and add the case here whenever a new one is found.

The file

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 3 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    All of these share one shape: a name that is perfectly valid *today* and did not exist in 1.71.

    line 102
  2. Very specific:

    Linter: all three are already decided.

    line 175
  3. Very specific:

    The fix is not a different separator: it is no separator: Put only inline controls into a bar and express grouping with a margin class …

    line 259
  4. Distinctive:

    Not about names or layout — these only show up when the app runs.

    line 324
  5. Very specific:

    Linter: no expression-binding rule — {= … } needs no eval (see the entry above), so there is nothing to report.

    line 518
  6. Distinctive:

    Do not stop at the first cause.

    line 664

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md
<!-- GENERATED by scripts/generate-skills.mjs in abap2UI5/app-template from .claude/skills/ui5-check/SKILL.md in abap2UI5/abap2UI5 - "this repository" in the text is the framework's. Do not edit; regenerate. -->

What a green CI does not prove about the UI5 side

Scope. This skill is about the view — the XML a backend builds, the fragments and modules the frontend ships — and about surviving the oldest UI5 release abap2UI5 supports. It is the sibling of abap-check, which is the same idea for ABAP that has to survive a real SAP system. build-an-app is the third: how to write an app. Build with build-an-app, check the ABAP with abap-check, check the view with this one.

This file is a staging area. The intent is that its entries move into the abap2UI5 linter over time — so every entry below carries a Linter: line saying whether a rule already decides it, what data a rule would need, and what it cannot decide. An entry that can never become a rule says so; that is the honest half of the catalogue.

Everything here is a defect that actually shipped, on main, past a fully green CI. The failure mode that binds them together: UI5 does not complain. An unknown icon name, a property the release does not have, a control laid out by the wrong box model — none of these is an error. Nothing appears in the browser console, nothing in npm run verify, nothing in ui5lint. The app just renders slightly wrong, on somebody else's system, and somebody tells you months later.

The floor

OpenUI5 1.71. Everything abap2UI5 ships must render there. That is not a theme setting or a nice-to-have: it is the release a large part of the installed base is pinned to, and the one the bug reports come from.

How to check a fact against 1.71 without a system

This is the single most useful technique in this file, and it needs no SAP system, no CDN and no network beyond npm. The OpenUI5 sources are on npm, per library, per version:

npm pack @openui5/sap.ui.core@1.71.80    # newest 1.71 patch
npm pack @openui5/sap.m@1.71.80
tar xzf openui5-sap.m-1.71.80.tgz package/src/sap/m/themes/base/Bar.less

What you get, and what each answers:

FileAnswers
sap/ui/core/IconPool.js (1.71) / sap/ui/core/_IconRegistry.js (newer)does this icon name exist? — the name→codepoint map is inline in the source
themes/base/<Control>.lesshow is it laid out? Diff the same file across two versions and the layout change is right there
<Control>Renderer.jsdoes it render a <div> or a <span> — block or inline?
<Control>.js metadata block@since of the control, its properties and aggregations; forwarding: tells you where an aggregation actually lands

Diffing two versions of one .less or one renderer is how both entries in section 2 were found. When something works for you and not for a user on 1.71, pull both versions and diff the file — do not guess.

For the pure metadata questions (@since of a control, property or enum value) there is a shortcut: @abap2ui5/linter ships data/properties.json — 970 controls with per-member @since, generated from the OpenUI5 sources — and, since the icon rules moved there, data/icons.json: every icon name with the minor release it reached the font in, scanned across all 79 minors from 1.71 up. So the name questions are answered without unpacking anything. Layout is what is left: no snapshot can tell you that .sapMBarLeft stopped being position: absolute, and diffing the two .less files is still the only way to see it.

Why the existing tooling is blind to most of this

Five separate holes, worth knowing precisely:

  • abaplint parses ABAP. A view built by string concatenation or by a builder chain is, to abaplint, a set of valid string literals. There is no UI5 in its model at all.
  • ui5lint only reads app/webapp/, and manifest.json is excluded — which is where minUI5Version: 1.71 lives. It never sees a view the backend builds, which is where nearly every abap2UI5 view comes from.
  • The abap2UI5 linter only reads what it recognizes. It now has an icon gate and one layout rule as well as the 1.71 property gate (sections 1.1 and 2.1 moved into it), but it recognizes only app classes that call z2ui5_cl_ui5_view_builder=>factory — plus raw *.view.xml / *.fragment.xml. The samples repository builds with z2ui5_cl_xml_view, so npx abap2ui5lint there reports "no checkable files" and its CI job is green without checking anything. This is the hole that does not close by adding rules: a gate that scanned nothing looks exactly like a gate that found nothing. Check what it actually scanned before trusting it.
  • The browser says nothing. This is the important one. UI5 logs an unknown module loudly and an unknown icon name, property or enum value not at all — the first 404s, the others are silently dropped.
  • The 1.71 e2e gate only covers what its specs render. The ui5-1.71 Playwright project boots the shell and the roundtrip specs against pinned 1.71, which catches a hard breakage on that path. Popups, fragments, custom controls and every backend-built view are outside it.

1. Names the target release does not have

All of these share one shape: a name that is perfectly valid today and did not exist in 1.71. They differ only in how loudly they fail.

1.1 Icon names — the silent one

An unknown sap-icon:// name is not an error. IconPool looks it up, finds nothing, and the control renders with no icon at all. Nothing is logged.

IconRealityUse instead
informationreached the font after 1.71message-information — same circled "i", exists from 1.71 on
clear-allreached the font after 1.71eraser
textFormattingexists in no release at alltext-formatting

That last row is its own trap: IconPool.getIconInfo resolves the URI through URI.parse( ) and reads parts.hostname, and a hostname is lower-cased. So icon names are effectively case-insensitive, and a camelCase name is not "nearly right" — it matches nothing, in every release, forever.

Where it bit us: the developer tools' help button and the legacy popups in src/99, both on information; abap2UI5/samples sample 446; and abap2UI5/samples-stack sample 489 on clear-all (all in #2576 / #750 / #21, all found by a user reporting "not all icons are shown").

Linter: moved — unknown-icon, icon-too-new, icon-removed (2026-08). Still also gated in this repository by npm run check:icons (.github/scripts/ui5-icon-gate.mjs), which stays because it covers the framework's own src/00–src/02 and app/webapp/ — files the linter does not read (src/99 is exempt as the frozen package; its two known bad icons are only printed). It no longer keeps a list of its own: the 655 names are derived from the linter's data/icons.json (since <= 1.71, minus what the font removed by then) and came out byte-for-byte equal to the hand-kept snapshot they replaced. The linter's data goes further than the snapshot this entry proposed: data/icons.json carries a per-icon since, scanned across every OpenUI5 minor from 1.71 to the pinned version (scripts/generate-icons.mjs), so the rule answers for any target rather than only for the floor, and it separates the two failure modes this section conflates — a name that exists nowhere (unknown-icon, an error: textFormatting) from one that merely arrived later (icon-too-new, a warning: information @1.80, clear-all @1.86). The scan also found a third: the font is not purely additive. binary was in the font for exactly one release (1.104) and gone again after it — the glyph is spelled non-binary (@1.96) everywhere else — which is icon-removed.

Two notes for whoever regenerates the LINTER's list, which is the only one left: the registry declares a few names with capitals (Chart-Tree-Map, Netweaver-business-client) and at least one entry with double quotes ("feedback"), which this repository's old hand-kept snapshot missed until 2026-08 — the gate falsely rejected sap-icon://feedback for months. Both are lower-cased on comparison anyway, but a generator reading only single-quoted entries silently loses names.

And one for anyone comparing versions from that data: 1.138 as a float is less than 1.71. A float compare quietly admits every icon added between 1.100 and 1.199 — made and caught while deriving the set above, and the same shape of bug as any other version-as-number comparison.

1.2 Properties, aggregations and enum values

The linter's property gate covers controls and members. It does not cover enum values, and that is where this one hides:

  • sap.m.ButtonType — Critical, Negative, Success, Neutral are 1.73, Attention is 1.77. On 1.71 the value is dropped and the button renders Default. This is why the overview headers use a core:Icon with color= rather than a coloured Button.
  • sap.m.Dialog.footer — public only since 1.110. Use buttons (1.21.1). This one is worse than a dropped property; see section 3.1.
  • sap.m.Page.titleAlignment — 1.72. Harmless (the title just keeps the 1.71 default, centered), but it explains why a 1.71 screenshot has a centered page title and a modern one does not. Not every difference in a screenshot is a bug.
  • sap.m.IllustratedMessage — 1.98, a whole control.

Linter: all three are already decided. Controls and members by the ui5: "1.71" property gate (control-too-new / member-too-new), and enum values — the gap this entry was written about — by enum-value-too-new, off properties.json's per-value enumSince (sap.m.ButtonType.Critical → 1.73). The Dialog.footer row is the exception that earned its own rule: being an aggregation, it does not merely get dropped, so it is aggregation-too-new and an error — see 3.1.

1.3 Modules and themes

  • sap.ui.define([...]) with a module the release lacks 404s and takes the whole component with it — blank app, not a missing feature. Known post-1.71 modules: sap/ui/core/Theming and sap/ui/core/Messaging (both 1.118). Resolve them lazily with sap.ui.require("…") at the point of use and handle undefined (see Component.js).
  • sap_horizon needs ≥ 1.102. A 1.71 run needs sap_fiori_3. This is why the ui5-1.71 Playwright project rewrites the theme as well as the bootstrap.

Linter: the module half is gated — in this repository, not in the linter. npm run check:modules (.github/scripts/frontend-module-gate.mjs, gated in check_gates.yaml) holds every module named in a sap.ui.define dependency array under app/webapp/ to a reviewed present-on-1.71 list, and both sap.ui.require forms (the probing string and the array-plus-callback) to a reviewed-id list of their own — that form returns undefined for a module the release lacks and for a misspelled id, identically and silently. It is a ratchet: the current exposure is zero, because everything version-sensitive is already resolved lazily (the Theming example above), and the gate is what keeps it zero. Deliberately a flat reviewed list rather than a per-release inventory — a new module costs one line and a look. An earlier revision of this section declared the gate not worth building, from a measurement that came out of a grep matching only single-line dependency arrays and undercounting by an order of magnitude; the gate's own header records that lesson, and the numbers are deliberately not repeated here — they rot, and that measurement already misled once.

What the gate does NOT cover is the consumer side: it reads this repository's app/webapp/ only, and the linter reads app classes and view XML — it has no JavaScript input at all — so a consumer repository's own frontend modules (custom controls) are checked by nothing. A linter-side rule would need that input plus a per-release module inventory; nothing else is asking for either, and the blank-screen failure mode is described above for whoever hits it there. The theme half (sap_horizon needing >= 1.102) is configuration, not view content; out of scope for the linter, kept as prose.


2. Layout that only works from a newer release on

The nastiest family, because the view is correct — every control and every property exists in 1.71 — and it still renders wrong. Only the CSS changed.

2.1 Toolbar-only controls inside a sap.m.Bar

sap.m.ToolbarSpacer and sap.m.ToolbarSeparator render a <div> (see their renderers) and are laid out as intended only inside a sap.m.Toolbar, which is a flex container.

sap.m.Bar is not, before 1.76:

Release.sapMBarLeft / .sapMBarRight
1.71 – 1.75position: absolute + text-align, children in normal flow
1.76 and updisplay: inline-flex; align-items: center

In normal flow a block-level child starts a new line, and .sapMBarContainer { overflow: hidden } at the bar's height: 3rem cuts away everything from that line on. So on 1.71 a separator in a bar does not draw a rule between two groups of icons — it deletes every icon after it, without a word.

And you rarely put a control into a Bar on purpose. You get one from:

  • sap.m.Page headerContent — forwarded to the internal Bar's contentRight (forwarding: {getter: "_getInternalHeader", aggregation: "contentRight"})
  • customHeader / footer with an explicit <Bar>

Where it bit us: the framework start page (two of four header icons gone), the abap2UI5/samples overview header (documentation + GitHub gone) and the abap2UI5/samples-stack overview header, where the first separator sits behind the very first button and took all five family icons with it — all three green in CI, all three fine on a modern release.

The fix is not a different separator: it is no separator. Put only inline controls into a bar and express grouping with a margin class (sapUiMediumMarginBegin on the first icon of the next group). A ToolbarSpacer in contentRight is doubly pointless — that container is right-aligned by itself.

Linter: moved — toolbar-control-in-bar (2026-08), a warning, reported only for a target below 1.76. Exactly the rule this entry specified, with two things sharpened while implementing it: the parent test is exact (sap.m.Bar, not an inheritance walk — Toolbar does not inherit from Bar, both merely implement sap.m.IBar, so a walk would report the one place these controls belong), and of the three Page aggregations only headerContent needs naming, because it is the one that forwards into an implicit Bar; customHeader/footer hold an explicit <Bar>, whose children the first test already catches. Its first run over the 416-port corpus reported exactly one finding: the overview header named below.

The general form, worth keeping in mind before the next one is found: a control whose layout depends on its parent being a flex box is version- sensitive even when every name in the view exists. When something renders right for you and wrong on 1.71, diff themes/base/<Parent>.less between the two releases before looking at the view.


3. Views that fail to load, not to render

These are the loud ones — and they kill the whole view rather than one control.

3.1 A generic aggregation tag that is not an aggregation

<ns:name> — from heading( ns ), a literal <footer>, _generic( name = … ns = … ) — must name an aggregation the parent actually has. Otherwise UI5 resolves it as a control class and 404s with failed to load sap/<lib>/<name>.js, killing the view.

Real 1.71 crashes fixed this way:

  • <footer> on a sap.m.Dialog — the public footer aggregation is ~1.110, so on 1.71 sap/m/footer.js is requested and does not exist. Use buttons (1.21.1); UI5 lays the buttons out in an overflow toolbar. See DeveloperTools.fragment.xml, where the comment sits at the code site.
  • (samples) heading( 'uxap' ) on a sap.uxap.ObjectPageSection, which has no heading aggregation at all. It is valid on a parent that has one (sap.f.DynamicPageTitle), and sap.m.Page.footer is fine.

This is section 1.2 with a worse blast radius: a post-1.71 property is dropped silently, a post-1.71 aggregation takes the view down.

Linter: moved — aggregation-too-new (2026-08), an error. The aggregation names per control were already in properties.json and the floor was already configured, so what this entry actually bought was the severity: the linter had been reporting the <footer> case as member-too-new, a warning, alongside a post-floor property — which is precisely the conflation the paragraph above warns about. It is now its own rule, so the two blast radii are no longer spelled the same. (A tag naming an aggregation that exists in no release was, and stays, unknown-aggregation.) First run over the 416-port corpus: 24 findings, every one of them already carrying a POST_171 deviation — so the split cost the corpus nothing and would have failed 24 ports had the deviation mechanism not been taught the new type first.


4. Runtime and environment

Not about names or layout — these only show up when the app runs.

  • Expression binding ({= … }) needs no 'unsafe-eval'. UI5 parses it with its own sap/ui/base/ExpressionParser — no eval/new Function in 1.71 through 1.120 (npm sources, and a browser run under a CSP without 'unsafe-eval': the binding resolved, no violation). An earlier version of this entry forbade it in framework XML for that reason; the reason was wrong. What does hit eval is the ui5loader executing a module it holds as a SOURCE STRING — synchronous loading (sap.ui.requireSync, a sync Fragment.load of a module no bundle carries) or a resource missing from the preload bundles. Under a CSP without 'unsafe-eval' that is a CSP EvalError, on every release. The framework default has no 'unsafe-eval': measured with the same page on 1.71.81, 1.75.7, 1.78.18, 1.80.1, 1.81.7, 1.82.2 (all EvalError: a Fragment.load whose XML needs sap.ui.layout/sap.ui.table, not loaded yet) and 1.84.0, 1.84.58, 1.96.48, 1.108.54, 1.120.50 (all clean). The same XML as an XMLView.create view is clean on 1.71 and 1.82 too - only fragments (popup, popover) are affected, and actions/Slots closes that gap: Env.preloadFragmentModules( ) requires the fragment's control classes asynchronously before Fragment.load (the popup test above is clean on 1.71 with it). Filtering by getLoadedLibraries( ) is NOT enough: sap.m pulls sap.ui.layout in without its preload bundle, so the library reads as loaded and VerticalLayout.js is still fetched and eval'd. What the preload cannot see - a module named only in a binding type or a core:require - still needs 'unsafe-eval' below 1.84. Since 2026-10 Env.loadViewLibraries( ) runs before both: every xmlns of a view or fragment is mapped to its library (through sap-ui-version.json) and the ones not loaded yet come as their library-preload.js - on every release, for the request count (library-preload e2e), and on 1.71-1.82 it also makes the module requires above cache hits.
  • No inline script runs, on any release. The default CSP's script-src carries no 'unsafe-inline'; the page's own inline script (the embedded preload) runs by its SHA-256 hash, which also makes every browser ignore an 'unsafe-inline' an exit left next to it. A <script> or an onclick= inside a core:HTML content, a javascript: href, a handler attribute in any markup a custom control writes - refused, and the console names the CSP. Measured 2026-09-24 in Chromium against the transpiled backend: the shell boots with no violation on the built UI5 bundle. The source-only sap-ui-core.js of the @openui5/sap.ui.core npm package is the DEV bootstrap and document.write()s two inline scripts of its own (1.71 and 1.144 alike) - an offline run on such a tree lists them by hash (node/tests/e2e/fixtures.js), the CDN build runs the same calls inside itself. Linter: moved — raw-javascript-to-frontend (2026-10, abap2UI5/linter#138): its view half reports a <script> tag, an inline on…= handler in markup and a javascript: URL in a URI-typed property, for builder classes.
  • WebAssembly needs 'wasm-unsafe-eval' in the script-src. The camera scanner of sap.ndc.BarcodeScannerButton decodes with ZXing-C++ compiled to WebAssembly (sap/ndc/thirdparty/zxingcpp/zxing_reader.wasm, read in the @sapui5/sap.ndc npm sources 1.108 to 1.152), and so does the BarcodeScanner of abap2UI5-addons/custom-controls. Under a script-src with neither 'wasm-unsafe-eval' nor 'unsafe-eval' the compile fails with a CompileError ("... because 'unsafe-eval' is not an allowed source of script"). Measured 2026-09-28 in Chromium, with a minimal module and with the custom-controls scanner. The default policy carries the keyword; a policy an exit writes itself has to keep it. Linter: not decidable from the view - the policy belongs to the installation.
  • Keep "async": true on the manifest's rootView. 1.71 does not know IAsyncContentCreation (since 1.89), so a rootView without the flag is built synchronously: the App controller's dependencies (sap/m/MessageBox) are fetched one by one before the sap.m preload arrives and eval'd — under the default CSP the shell does not boot at all on 1.71.
  • A fragment dialog with a fixed id is loaded once and reused. Destroying it on close and re-loading on the next open races the close animation on 1.71: the fragment-scoped id is still registered and you get "adding element with duplicate id". show() reuses, close() closes, only exit() destroys (see DeveloperTools.js).
  • sap.m.MessageBox always closes on Escape and offers no way to suppress it. A popup that must not be Escape-dismissable — the fatal-error overlay — is a sap.m.Dialog with escapeHandler: (oPromise) => oPromise.reject().
  • escapeHandler as an XML attribute is a string on 1.71, and Escape closes without it. sap.m.Dialog.escapeHandler is declared {type: "any"} in 1.71 (sap/m/Dialog.js of @openui5/sap.m@1.71.0; it became "function" later, 1.120 has it so), and the XML template processor runs DataType("any").parseValue on an attribute of that type
    • which keeps the literal ".onEscape". Nothing resolves it against the controller, nothing complains, and Dialog.onsapescape takes its plain close branch because the property holds no function. Shipped in DeveloperTools.fragment.xml (2026-10): the tools opened from the fatal overlay's Details closed on Escape straight into the broken app, and the error popup the reopenErrorOnClose flag was armed for never came back (rule 16's failure mode, one dialog further). Newer releases resolve the attribute to the controller method, so every spec and every e2e leg but 1.71 passed. Set the handler in code after the load - oDialog.setEscapeHandler((p) => this.onEscape(p)), as core/ErrorView.js builds its dialog with a function - and assert the wire through what the dialog received, not by calling the method (developerTools.spec.js). Linter: ready to move for a fragment the linter reads: an escapeHandler="..." attribute on a sap.m.Dialog is the whole detection, and data/properties.json already carries the member's type per release; the view half cannot occur, a backend-built view has no controller to name.
  • An enum property REFUSES an empty string, and an EMPTIED aggregation hands it one. Not "falls back to the default" — the control throws:

    "" is of type string, expected sap.ui.unified.CalendarDayType
     for property "secondaryType"

    Found in abap2UI5/samples-controls app 308 (2026-08-16) by an e2e interaction, and the trigger is worth stating precisely, because the obvious reading is wrong. It is not the row that leaves the field unset: an unset field never reaches the model (_bind( … omit_initial_paths = … ) keeps it out). It is the table being cleared. UI5 then evaluates the aggregation TEMPLATE with no row behind it, {SECONDARY_TYPE} resolves to `` — and the enum-typed property rejects it.

    Isolated rather than guessed: the port boots clean, survives the first press of its toggle, and dies on the second, which is the press that empties the table.

    The fix is at the view, not at the data — fall back to the enum's own default in the binding, which the same port already does for a nullable date one line above:

    )->a( n = `secondaryType` v = `{= ${SECONDARY_TYPE} ? ${SECONDARY_TYPE} : 'None' }`

    Write an expression binding as a backtick literal, not as a string template. A template would have to escape every brace — \{, \} and the \} of each $\{…\} — and one missed escape is a parser_error on the whole statement, not a wrong string. Measured on abaplint 2.120.24: the half-escaped forms are rejected, the fully escaped |\{= $\{X\} ? $\{X\} : 'None' \}| parses, and the backtick literal above says the same thing with nothing to escape. Nothing in the expression needs interpolating from ABAP — the $ paths are resolved by UI5 — so the template buys nothing anyway.

    And it happens without a template at all. A sweep of all 417 samples-controls apps (2026-08-17) found two DEAD on their first render from this class — and app 121 is the sharper one, because the ORIGINAL UI5 sample does the same thing and works. It binds visibility="{visibility}" against data that is {"type":"Draft"} — no visibility anywhere. In JavaScript that is undefined and UI5 leaves the property at its default; in ABAP the unfilled TYPE string serialises as "" and the enum rejects it. So a 1:1 port of a correct sample is fatal, and nothing at either end looks wrong. Keep the value out of the model (_bind( … omit_initial_paths = … )) or give the binding a fallback.

    Both apps had been reporting green: the framework catches a fatal error and RENDERS it, so nothing reaches pageerror. samples-controls' e2e gate reads the overlay directly now.

    The trap is structural rather than particular to that control: ABAP has no null, an unfilled TYPE string serialises as "", and "" is a member of no UI5 enum. So it applies to every enum-typed property in an aggregation TEMPLATE — type, state, design, valueState, highlight — whenever the bound table can be empty. A first render passes and the emptying fails, which is why nothing offline sees it.

  • A numeric property can REJECT the ABAP-initial 0, and every ABAP structure sends one. Sibling of the entry above with a different mechanism: not a type check, a hand-written setter that validates its range.

    Error: recurrencePattern must be >= 1
      at u.setRecurrencePattern (…/sap/ui/unified/library-preload.js)
      at c.updateProperty

    sap.ui.unified.RecurringCalendarAppointment.recurrencePattern is {type: "int", defaultValue: 1} and its setter throws for anything below 1 (RecurringCalendarAppointment.js:163, read in the OpenUI5 sources at 1.151). The ORIGINAL sample is correct and works: sap.m.sample.PlanningCalendarRecurringItem's onCreateDialogSave writes the property only inside if (oData.recurrenceType), so a non-recurring new appointment carries no such key at all, the binding resolves to undefined and the control keeps its default. A port cannot reproduce "no such key" — recurrencepattern TYPE i serialises as 0 on every row — so the first appointment created without a recurrence took the app down. Reported by a user on 2026-08-29 from a Developer Tools export (UI5 1.150, abap2UI5/samples-controls apps 548 and 555) and measured standalone on OpenUI5 1.151: bound 0 throws while the view is created, bound 1 renders.

    Note where the throw lands: ManagedObject.updateProperty rethrows anything that is not a FormatException, so a validating setter kills the whole render — the enum entry's blast radius, from a value that reads as perfectly ordinary data.

    The fix is in the backend, where this repository wants it anyway: seed the control's own default into the model (recurrencepattern = 1) instead of leaving the field initial. A view-side {= ${X} || 1 } fallback works too and is the right shape only when no ABAP writes the row — which is exactly the sibling sap.ui.unified.RecurringNonWorkingPeriod, same setter (RecurringNonWorkingPeriod.js:145), whose rows both ports only ever seed: there the default rides in the binding. Measured on 1.151, both forms verified: bound 0 throws, the expression renders 0 as 1 and leaves a real 2 alone. An EMPTY bound table is safe either way here — unlike the enum case above, a template with no row behind it leaves an int property at its default rather than handing the setter a "" (which "" < 1 would have rejected, so the difference is luck of the coercion, not of the design).

    This is the type-level twin of the empty-string trap: ABAP has no null, an unfilled TYPE i serialises as 0, and 0 is out of range for every property whose setter demands >= 1. The tell is a property documented as an interval or a count — recurrencePattern, and any control property whose own setter is written by hand rather than generated.

Linter: no expression-binding rule — {= … } needs no eval (see the entry above), so there is nothing to report. (The linter does check {= … } for balance — invalid-expression-binding — which is a different question.) The two fragment/MessageBox entries are about JS lifecycle, not about a view: they stay prose.

The empty-string-into-an-enum entry is the one worth a rule, and once the trigger is stated correctly the rule gets easier, not harder. It does not need to know anything about the data: an enum-typed property inside an aggregation template, bound to a plain path with no fallback, is decidable from the view alone. The enum-valued properties are already known — enum-value-too-new reads their enumSince out of properties.json — and "inside a template" is the ele( <aggregation> ) the builder chain already models. The fix the rule would point at is the one this entry shows: an expression binding with the enum's default as the else branch.

What it must NOT do is fire on every enum binding: a property bound outside a template, or one whose table can never be emptied, is fine, and a rule that reported all of them would be routed around.

Linter: enum-field-unset-on-insert ships, and attacks this from the CONSTRUCTION side rather than from the binding side. It reads the three seed shapes (an INSERT/APPEND of a VALUE #( ) literal, the t = VALUE #( ( … ) ) seed in model_init, a work-area row) and reports a field the row is built WITHOUT that the view binds to an enum-typed property. What it does not cover is a field filled by a LOOP, and a table emptied at RUNTIME rather than seeded short — those still need the data flow this entry describes below.

The BINDING-side version of the same rule was implemented against the linter on 2026-08-17 and reverted, and the reason is worth keeping so nobody spends the afternoon again. The rule needs to know whether a bound field arrives as "", and nothing static answers it: the render model holds only what a seed sets, so an unassigned field is ABSENT — which is also what omit_initial_paths produces, i.e. the fix; and the model SHAPE has every declared field present and empty, so the rule fires on every enum binding there is. The linter's own comment above deriveModel closes it: "A field the class fills in code (a LOOP in model_init) cannot be followed statically, and inventing an empty string for it makes UI5's strict mode reject a perfectly good view." It would need "is this component ever assigned anywhere in the class" — data flow over the ABAP, not the view.

Measured 2026-09-12 on the samples-controls corpus (622 ports), so the next person does not re-measure it: 194 bindings in 98 classes bind an enum-typed property to a bare {PATH} with no fallback (type 79, state 40, ariaHasPopup 14, infoState 8, valueState 6, …), and only 4 bindings in the whole corpus carry the expression-binding fallback this entry shows. That 194:4 ratio is the shape the reverted rule hit: unscoped, the binding-side rule reports nearly every enum binding there is, and every one of the 194 is a correct port. The only viable narrowing is the one named above and nothing has built — inside an aggregation template AND the bound table can be emptied at runtime — which is data flow over the ABAP, not a view test. Do not attempt the binding side a third time without that half.

Linter: validating-setter-out-of-range ships (2026-08-30), and the harvest this entry asked for is what made it possible — with one correction that matters. Recording "the setter throws" was too coarse to act on: 23 properties throw somewhere in their setter, and for most of them the initial 0 is perfectly legal (MonthPicker.setMonth throws in a nested callback while 0 is January; TimesRow.setIntervalMinutes guards >= 720, and 0 makes its modulo NaN and throws nothing). Both would have been reported for the initial value. What is harvested instead is the LOWER BOUND, from the guard shape the defect actually has — an opening if (<param> < N) throw — which is exactly what makes an unfilled ABAP field illegal, and which is two properties in the whole snapshot. The same generator pass now also records defaultValue (only where it is true) and widensAggregation. The rule fires only on a target release from 1.149 on, and that is a property of the subject rather than a limitation: both properties carrying a bound are @since 1.149, so member-too-new reports and stops the walk below that floor — the user report this came from was on 1.150 too.

The original analysis, kept because it is what the harvest was designed from: the entry was ready to move, but it needed data nobody had harvested yet. The view side is decidable exactly like the enum one — an int-typed property inside an aggregation template, bound to a plain path with no fallback — but on its own that fires on every bound int in the corpus, and almost all of them are fine. What makes it decidable is the list of properties whose SETTER throws, and that is nowhere in properties.json (which carries {"type": "int"} for recurrencePattern and nothing else — not even the defaultValue). It is, however, in the sources the linter's generate-metadata.mjs already walks for @since/@deprecated: a prototype.set<Property> = function body containing a throw is a small, honest pattern to record alongside them, together with the bound it enforces. That column exists now (setterMin), and the same harvest gave the boolean case its own rule: absent-boolean-overrides-default, which needs defaultValue plus a SECOND signal — the seed has to be inconsistent, some rows of the table setting the field and others not — because an unseeded boolean shipping false is correct almost everywhere and wrong only against a true default.

Second limit found on the way: sap.m.ObjectStatus.state is declared type: "string" in the metadata, because UI5 validates it at runtime against ValueState or IndicationColor. Even a working rule would not see it.

  • A curly brace in a value handed to a( v = … ) is parsed as a BINDING, never shown as text. The page bootstraps with complex binding syntax, so a { in an attribute value — think a user-supplied search string rendered into a title — is resolved by UI5 as a binding path (or, as {= … }, an expression) instead of displayed, and a crafted string can read arbitrary paths out of the view model. The builder deliberately does not escape inside a( ) itself: a deliberate binding in v — client->_bind( ), an event, a template — is its bread and butter, and only the app knows which values are literals. So the app passes any user- or external-supplied string it renders as t instead of v — a( n = … t = … ) applies z2ui5_cl_ui5_view_builder=>escape_literal( ) to the whole value, which backslash-escapes the braces (UI5's own convention for literal text; XML escaping is a separate concern and always applied on render). The direct escape_literal( ) call stays for the one attribute that mixes text with a binding. The mechanism and the split of responsibility are documented at the method's own ABAP Doc in src/02/z2ui5_cl_ui5_view_builder.clas.abap (#2698), and the app guide carries the rule as "text from data goes through t".

Linter: ready in shape, blocked on origin — staged so the scope argument is not lost. The chain side is decidable: the linter already models a( ) calls, so "a v = argument that is not a string literal or template, and not already wrapped in escape_literal( )" is a syntactic test — and the t parameter gives it a fix to name (t = is never a finding: the builder escapes it). What no view-side test can see is the half that matters — whether that non-literal value carries user or external input (must be escaped) or IS a deliberate binding the app assembled in a variable (must NOT be: escaping it breaks the binding). The corpus assembles bindings in variables all the time, so reporting every bare variable at v = would drown its users and be switched off — the same wall as the reverted binding-side enum rule above: it needs "where was this value assigned from", data flow over the ABAP class, not the view. What a rule would need to decide it: per-argument origin tracking (literal / template / _bind-_event result / escape_literal( ) result / anything else), plus a signal that separates request-derived values (the bound model fields a roundtrip writes back) from app-computed ones — the first is where the rule should start if it ever ships, because a value the client can write IS external input by definition. No rule id is minted here on purpose: the id comes with the rule, and a backticked id that resolves to nothing is exactly what check:skills exists to refuse.


5. Reading a bug report about a missing icon

The two failure modes look identical from the outside — "the icon is not there" — and have nothing to do with each other. Tell them apart first:

  1. One icon missing, others fine → a name (section 1.1). Check it against the 1.71 list.
  2. A run of icons missing, always from the same position on → layout (section 2.1). Look for a block-level control just before the first missing one.

The report that produced this file was both at once, in the same screenshot. Do not stop at the first cause.


Adding a new case

Same discipline as abap-check: one entry, one defect that actually happened.

  1. Say what UI5 does, not what it should do. "Renders no icon and logs nothing" is the entry; "the icon is wrong" is not.
  2. Name the release boundary and how you established it. @since from the control metadata, or the two npm packages you diffed. An entry that says "newer UI5" without a version is not actionable and will be wrong.
  3. Name the evidence — the PR, commit or issue. Say when a user found it; those are the expensive ones.
  4. Say why the existing tooling missed it, against the five holes above. If the answer is "it did not, we ignored it", it belongs in a lint config, not here.
  5. Write the Linter: line. This is the point of the file. Three honest answers: already decided (say by what), ready to move (say what data and what detection a rule needs), or cannot be decided (say why). An entry without this line is a note, not a candidate.
  6. When an entry does move into the linter, keep it here and change its Linter: line to point at the rule. The reasoning is why the rule is allowed to exist; deleting it invites somebody to relax the rule later.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.