Skip to content

Repository files navigation

abap2UI5-linter

Validate abap2UI5 app classes without an SAP system — a CLI, library, and GitHub Action extracted from the CI gates of samples-controls, where they guard 416 generated ports of the official UI5 demo kit samples.

It checks a whole app class, not just the XML it emits: the ABAP source and the view it builds are validated together. The defects that matter most are the ones living between the two — a bound attribute whose ABAP field does not exist, an event argument the control never delivers — and no UI5 tooling can see them, because the view only exists at runtime.

Quick start

npx @abap2ui5/linter src

That is the whole of it: no install, no SAP system, no configuration. ~240 kB and no dependencies, so the line above is a fast one.

src/zcl_my_app.clas.abap
  36:14  error  sap.m.Page has no aggregation contentt - typo?                       unknown-aggregation
  40:22  error  sap.m.Button type="Emphasised" is not a valid value (allowed: ...)   invalid-property-value

Then, when you want it to stay green:

npm install -D @abap2ui5/linter          # pin it for CI and for your machine
npx abap2ui5lint --init                  # write a commented abap2ui5lint.jsonc

Add the render gate to load every view in a real browser as well, and the GitHub Action to run it on every pull request. Starting a new app repository? app-template ships with all of this already wired up.

Full reference below; every rule id also has a page at abap2ui5.github.io/linter.

What it checks

Two gates:

  1. Property gate — everything the view writes is resolved against a UI5 metadata snapshot (988 controls with their full member lists and types, 219 enums, generated from the OpenUI5 sources). It reports:

    Finding Example
    unknown-control sap.m.Shell2 — no such control
    unknown-property Button typ="…" — no such property/event/association
    invalid-property-value Button type="Emphasised" — outside sap.m.ButtonType; also non-numeric int/float and non-boolean values
    unknown-aggregation Page contentt — no such aggregation
    too-many-children two controls in a 0..1 aggregation
    invalid-aggregation-child a control the aggregation's type does not accept
    control-too-new / member-too-new introduced after your target UI5 version (default 1.71)
    enum-value-too-new the property is old, the VALUE is not — GenericTile frameType="OneByHalf" is @since 1.83 on a 1.71 target; the snapshot keeps the per-value @since from the enum's JSDoc
    aggregation-too-new an aggregation tag introduced after your target — <footer> on a Dialog is ~1.110, and unlike a too-new property it does not get dropped: UI5 resolves the lowercase tag as a control class and the 404 on sap/m/footer.js takes the whole view down. Use buttons (1.21.1)
    event-parameter-too-new a ${$parameters>/name} read back in a t_arg that the event only gained later — resolved per event, not per name
    unknown-icon sap-icon://textFormatting — a glyph the font has in no release. IconPool reads the name as a URI hostname, which is lower-cased, so a camelCase name is not "nearly right": it matches nothing, forever (the name is text-formatting)
    icon-too-new the glyph reached the font after your target — information in 1.80 (use message-information), clear-all in 1.86 (use eraser). An unknown icon is not an error in UI5: the control simply renders with no icon and nothing is logged
    icon-removed the glyph left the font again — binary (1.104) is non-binary from 1.120 on, same codepoint, renamed
    toolbar-control-in-bar a ToolbarSpacer/ToolbarSeparator inside a sap.m.Bar — before 1.76 a Bar lays its children out in normal flow, so the block-level child starts a new line and the bar's overflow: hidden deletes every sibling after it. Includes Page headerContent, which is forwarded into the internal Bar
    unknown-event-parameter a ${$parameters>/typo} the event does not declare — the value usually arrives empty. Judged only against an event the control declares itself; a hint, because a control can fire more than its metadata declares
    control-deprecated / member-deprecated control or property already deprecated at your target version
    duplicate-aggregation the same aggregation opened twice under one control — the second tag replaces the first
    aggregation-in-aggregation an aggregation directly inside another one — invalid XML, and the signature of a missing shut( ): UI5 then goes looking for a control class by that name
    excess-shut one shut( ) more than the builder tree is deep — asserts at runtime
    duplicate-property the same attribute written twice on one control — the view builder asserts on it
    attribute-without-element a( ) on the bare factory root — nothing to attach it to, asserts too
    source-line-too-long a source line over 255 characters — the class does not fail to lint, it fails to import: abapGit reports the error for that object and carries on, so an empty class stub stays behind in the system. Split the literal into && chunks (and fix the generator, if the file is generated)
    duplicate-id the same id twice — duplicate-ID error at runtime
    undeclared-namespace ns = 'form' without an xmlns:form
    display-root-mismatch a mvc:View handed to popup_display( ), or a core:FragmentDefinition to view_display( ) — the slot decides whether the client uses XMLView.create or Fragment.load
    invalid-expression-binding unbalanced braces/parens in {= … }
    sapui5-only-control needs SAPUI5, absent from OpenUI5 (see below)
    missing-required-aggregation a Table bound to rows but given no columns — renders empty
    collection-bound-to-property a table/structure bound to a scalar property
    settable-property-via-action a CONTROL_BY_ID set…( ) on a control that has a bindable property of that name — bind it two-way instead
    relative-binding-without-context a relative {FIELD} on a control outside any bound aggregation — it resolves against nothing and the control renders empty
    frontend-action-unknown-id an id-addressed wire (CONTROL_BY_ID, SET_FOCUS, SCROLL_TO, SCROLL_INTO_VIEW, KEYBOARD_SET_MODE) whose literal id no view of the class declares — the frontend finds nothing and the wire silently does nothing
    denied-control-method a CONTROL_BY_ID wire naming a method the frontend denylist refuses (destroy, setModel, bindProperty, the generic reflection mutators) — the dispatch logs and returns, the control is never touched
    binding-on-association a binding written into an association attribute — the XML parser takes the value as a control ID, never as a binding, so the association stays empty
    unknown-model a {name>…} binding against a model the app does not have — abap2UI5 serves one default model plus device>/message>, and an unknown prefix leaves the property unset
    date-type-without-source a sap.ui.model.type.Date/DateTime/Time binding with no formatOptions.source — the JSON model can only carry a string, so the type throws on every format
    binding-type-mismatch an ABAP character field bound to a numeric/boolean property — it arrives as "100" where UI5 declared a float, which future mode rejects
    missing-accessibility an icon-only Button with no accessible name (no text, tooltip or ariaLabelledBy), or an Image the author marked meaningful (decorative="false") and left without alt — for a decorative image, which is UI5's default, alt is ignored by the framework

    Bindings and expressions are never value-checked (their value is a runtime matter), custom namespaces stay out of scope, and a control whose inheritance chain leaves the snapshot is never reported as missing a member — no guessing. abap2UI5-specific rules — the defects that stay silent at runtime, which no UI5 tooling can see because they live in the relationship between the ABAP class and the view it builds:

    Finding Why it matters
    unknown-binding-path a hand-written {/TYPO} the derived model has no path for — the field just stays empty. Also judged inside complex binding infos (path: '/TYPO') and expression bindings (${/TYPO}); a numeric segment steps into the bound table's row (/T_ITEMS/9/TEXT). Inside a bound aggregation a relative {TYPO} is resolved against the row, so a misspelled column field is caught too — but only where the row's shape is known from the class's TYPES, never guessed
    binding-for-event / event-for-property _bind( ) on an event (dead control) or _event( ) on a property
    frozen-view-builder the class builds its view with z2ui5_cl_xml_view, the frozen predecessor of z2ui5_cl_ui5_view_builder. The only finding here about what was not judged: there is no view to reconstruct from the old API, so every other rule was silent about the whole class for lack of anything to read. Before this rule, such a file was not even collected — a complete app on the retired builder came back as "no checkable app classes" and exit 0. It matters more than it used to: the old API is what nearly all public abap2UI5 material shows, and therefore what a language model writes when asked for an app
    non-released-api an abap2UI5 object outside the released src/02 package. That package — z2ui5_if_app, z2ui5_if_client, z2ui5_if_exit, z2ui5_cl_ui5_http_handler, z2ui5_cl_ui5_view_builder — is the whole contract; the engine (src/01, "internal use only"), the renamed AJSON/S-RTTI/abap-util copies (src/00) and the frozen legacy package (src/99) carry no compatibility promise and announce no change: one upstream commit renamed the entire core layer and moved the old view builder and HTTP handler into the frozen package. Judged only against names the linter knows are framework objects, so your own z2ui5_-prefixed classes are never reported
    obsolete-binder client->_bind_edit( ) — superseded by client->_bind( ), which is two-way as well. A call carrying custom_mapper_back/custom_filter_back is reported too (they are accepted for source compatibility but no longer evaluated), only without the autofix
    obsolete-model-update view_model_update( ), nest_view_model_update( ), nest2_view_model_update( ), popup_model_update( ), popover_model_update( )empty methods: the framework compares the model before and after main( ) and pushes it to every open slot by itself. The call reads as "the model is pushed here" where nothing happens; delete it
    obsolete-frontend-event client->_event_client( ) — superseded by client->follow_up_action( ). Since it gained a RETURNING parameter it reaches the same get_event_client( ) wherever its result is consumed, so one method both schedules a frontend action and wires one
    unconverted-abap-boolean an ABAP boolean written straight into the view: it arrives as 'X'/' ', and UI5 reads any non-empty string as true — so visible = abap_false makes the control visible. The correction is the builder's own boolean parameter: a( b = flag )
    binding-to-local a local variable bound: the instance is serialized across the roundtrip, the method stack is not, so the value is lost
    binding-to-reference a TYPE REF TO attribute bound without dereferencing — the serializer walks DATA, not references, so the bind throws at runtime; write client->_bind( ref->* )
    manual-init-flag a boolean attribute gating the first render — client->check_on_init( ) already says whether this is the first run, without shipping a flag to the browser on every roundtrip
    event-on-disabled-control an event handler on a control with a literal enabled="false" — the control can never fire, so the handler is dead (a hint: a 1:1 port of a disabled-state demo legitimately carries the original's handler)
    binding-to-nonpublic a PROTECTED/PRIVATE attribute bound — only PUBLIC attributes are serialized into the model, so the first roundtrip fails with BINDING_ERROR; move it to the PUBLIC SECTION
    ui5-internal-access mProperties & friends read from a wire or binding — private UI5 internals with no API contract, they change across UI5 patches without notice
    commercial-ui5-host a URL pinned to ui5.sap.com / *.hana.ondemand.com — use sdk.openui5.org, or the app breaks on an OpenUI5-only landscape
    view-never-displayed a view is built but never handed to the client — an empty page, no error
    event-without-handler an event nothing reacts to — a dead control, unless the roundtrip alone is intended (so: a hint, never an error)
    live-event-roundtrip a liveChange wired to client->_event( ) — round-trips are serialized and an event fired while one is in flight is dropped, so the bound value lags under fast input; prefer a two-way binding or the final-value event (a hint: the wire converges when input pauses, and sometimes every keystroke genuinely must reach ABAP)
    popover-anchor-unknown-id popover_display( by_id = … ) naming an id no view declares — the fragment loads, finds no anchor and is destroyed again; nothing opens, nothing renders red
    unknown-frontend-action a literal action name outside the frontend dispatch table (case-sensitive) — execute looks it up and does nothing at all on a miss, not even a console line. The cs_event- constants are compile-checked; this covers the literal spelling
    unknown-view-slot a literal view slot outside MAIN/NEST/NEST2/POPUP/POPOVER (case-sensitive — cs_view-nested is NEST, not NESTED) — the wire addresses no view, and for CONTROL_BY_ID a wrong slot even suppresses the global id fallback
    invalid-keyboard-shortcut a shortcut combo naming no non-modifier key (Ctrl+Shift) — logged once, never registered, every later keydown does nothing
    invalid-action-payload a JSON payload the runtime silently downgrades: an object-kind method argument (setSticky, setHiddenInPopin, setP13nData) that is not valid JSON becomes {}, an unknown enum key in it is dropped by UI5, and a malformed BINDING_CALL filter-groups payload (or one missing its nesting level) is rejected with only a console line
    json-bind-on-scalar-property _bind( json = abap_true ) landing on a string/int/float/boolean property — the spliced JSON node is the wrong type there, and the splice is outbound-only, so an edit through a two-way binding is silently discarded; json is for object-typed properties
    raw-javascript-to-frontend raw JavaScript shipped to the browser — follow_up_action's escape hatch (a non-name val is inserted verbatim as custom_js), a hand-written handler string on an event attribute, or a <script> tag in an attribute value. The frontend is a renderer: behaviour travels as data (cs_event- actions, bindings), never as code
    get-viewname-removed a read of client->get( )-viewname, removed from ty_s_get — no longer compiles, and nothing in a systemless pipeline says so before activation
    invalid-frontend-action a frontend-action t_arg outside the set the runtime accepts — an unknown CONTROL_GLOBAL object or method, a BINDING_CALL method that is not filter/sort, or CONTROL_BY_ID's obsolete empty view slot. The browser logs and does nothing
    unescaped-brace-in-style literal CSS braces in a <style> block — the XMLView parser reads them as bindings and the view dies; write \{ and \}
    collapsed-brace-in-style the same escape written inside a |…| template — the template collapses \{ to { before the builder sees it, so the view dies anyway; use a backtick literal
    unused-public-attribute a PUBLIC attribute nothing in the class ever touches — only PUBLIC attributes are serialized, so it is shipped to the browser every roundtrip for nothing
    event-arg-out-of-range get_event_arg( n ) past the t_arg the event declares — the read comes back empty (a 500 in the transpiled runtime). Judged only for a literal index, inside the handler of an event the class raises itself
    event-arg-unresolved a bare-brace t_arg literal (`{COL}`): the runtime sends it verbatim but only $-prefixed expressions are resolved by UI5, so get_event_arg( ) receives an empty value with no error anywhere. Write `${COL}` (a template starting with a {0} placeholder is fine — that form is quoted)
    trailing-empty-event-arg the LAST t_arg entry is empty. get_t_arg buffers an empty argument and flushes it only when a later non-empty one follows, so an empty entry between filled ones keeps its slot and a trailing one disappears — get_event_arg( n ) for that position reads initial, with no error anywhere
    json-literal-in-attribute a raw JSON object literal written into a view attribute. UI5 parses a leading { as a binding, so the JSON is read as a binding path and the attribute ends up empty — the classic way to lose an integration Card's manifest. Keep the JSON in the model and bind it
    popover-display-val popover_display( val = … ) does not compile — the parameter is xml, unlike popup_display's val. Caught here because nothing in a systemless pipeline meets a compiler
    escaped-brace-in-backtick a binding written \{ … \} inside a backtick literal — escaping is a
    uncurated-formatter a formatter: 'Formatter.…' naming a function the framework's curated module does not export — UI5 resolves the string at binding time and an unknown name silently yields no value; compute it in ABAP and bind the finished field
    hardcoded-binding-path an absolute binding path written as text ({/PATH}, path: '/PATH') — derive it from client->_bind( var ) so it moves with a variable rename; an OData entity path in a class that switches its default model is exempt
    missing-view-display-on-navigated a check_on_navigated( ) branch that never re-displays — after returning from a called app the browser keeps showing that app's view
    missing-on-navigated-branch a lifecycle dispatcher with no check_on_navigated( ) branch at all. check_on_init( ) means "this app instance never ran", so it is false when a called app or a built-in popup hands control back and when a bookmark is restored — the app goes blank on the first hop into it, having worked perfectly standalone. An ungated display (after the IF chain, or a popup helper's nav_app_leave( )) is exempt
    chain-indentation a builder call whose indentation contradicts the tree it builds — a sibling at a different column than its siblings, or a call written left of the element it belongs to. A chain is the one thing nothing else formats (abaplint's indentation is off for exactly this reason), and the ABAP indentation is the only picture of the view's tree there is. The indent STEP is not judged, only that the chain keeps its own
    chain-element-per-line several controls on one line of a multi-line chain — each is a level of the tree the indentation can no longer show. Only elements count: an attribute may share its control's line ()->tag( \Text` )->a( n = `text` … )`), and closing calls and one-line chains are exempt
    chain-house-layout opt-in — a chain not in the abap2UI5 house layout: one call per line including attributes, four spaces per level of the tree, the closing call in the column of the element it closes. The only rule here that encodes a house style rather than an inconsistency, and the only one that names a step — it catches what chain-indentation structurally cannot, a chain whose every level is uniformly wrong. Carries fixes (--fix). Switch it off with false if your house style is a different one
    separate-lifecycle-ifs lifecycle checks in separate IF blocks instead of one IF/ELSEIF chain — separate blocks can run more than one branch per roundtrip (a guard block that RETURNs is exclusive and fine)
    duplicate-for-iterator the same FOR iterator name twice in one method — a 7.02 downport materializes each as DATA <name> TYPE i and fails activation

The name in the left column is the rule id: it is printed at the end of every reported line, it is the key in the rules block of the config file, and it is what a source directive names. Every rule is documented one page away — abap2ui5.github.io/linter, searchable, one anchor per id. Every finding also carries a severity, a ready-made message and — where the gate could place it — the line and column in the file it came from:

src/zcl_my_app.clas.abap
  20:9   error    a( n = `title` ) without an element to attach it to …            attribute-without-element
  31:18  error    text is set twice on the same control …                          duplicate-property
  44:22  warning  sap.m.GenericTile systemInfo is @since 1.92.0 …                   member-too-new
  51:35  hint     event NO_HANDLER is raised but never handled …                    event-without-handler

4 problems (2 errors, 1 warning, 1 hint)
abap2ui5-linter: 12 file(s), 1 failing, 0 skipped (target SAPUI5 1.71, metadata from 1.151.0, failing on warning)

Files with nothing to report are not printed.

Severity Meaning
error the app breaks: a dump, a control that will not load, a value UI5 rejects, or a defect that silently destroys the view
warning it works where it was written, but not necessarily on the target system (version floor, deprecation) — or the data behind it is not what the author thinks it is
hint worth knowing, never wrong by itself

--fail-on error|warning|hint|never decides which of them break the build (default warning; --advisory is --fail-on never). Everything is always reported — the threshold only sets the exit code.

  1. Render gate — the view is loaded with a real XMLView.create in headless Chromium against the OpenUI5 runtime served locally from the @openui5/* npm packages, with UI5 future mode active — so a typo'd property, an unknown control, a broken expression binding, or a strict property-type violation fails before the app ever reaches a system.

Input can be:

  • ABAP classes building views with the generic builder z2ui5_cl_ui5_view_builder (ele/tag/a/end). The view XML is statically reconstructed from the builder calls, and a typed mock model is derived from the class's TYPES/DATA/model_init seeds, so bindings resolve realistically during the render.

  • Raw *.view.xml / *.fragment.xml files.

    A directory is scanned by the abapGit naming convention (*.clas.abap, *.view.xml, *.fragment.xml); a file you NAME on the command line is checked whatever it is called, as long as it carries a builder chain.

Install

npx @abap2ui5/linter src            # no install, one run
npm install -D @abap2ui5/linter     # in a project
npm install -g @abap2ui5/linter     # everywhere

The binary is abap2ui5lint, the spelling that matches the config file name. The package is ~240 kB and pulls nothing else in, so the npx line above is a fast one.

Adding the render gate

The render gate boots a real XMLView.create in headless Chromium, which needs a real UI5 runtime: ~118 MB of @openui5 sources plus playwright. That ships as a second package, so it is one deliberate install rather than a surprise attached to the first:

npm install -D @abap2ui5/render-runtime   # the UI5 runtime, once
npx playwright install chromium           # and its browser

Why not optionalDependencies? Because npm installs those by default — the name promises the opposite of what it does. Declaring the runtime that way made npx @abap2ui5/linter a ~123 MB download before it linted anything, and --omit=optional, the documented way out, is not a flag npx accepts. It is now an optional peer, which is the one kind npm leaves alone.

Without it, the property gate — every ABAP and view rule that resolves against the metadata snapshot — runs in full:

npx abap2ui5lint src --no-render

That is supported, not a degraded mode. And it is what the npx line at the top of this section does on its own: the render gate is on by default, so a run that never asked for it and has no runtime installed falls back to the property gate, with a warning on stderr naming the one package to install.

The fallback is deliberately limited to a gate nobody asked for. Say you want it — --render, or "render": true in abap2ui5lint.jsonc — and a missing runtime is an error again:

npx abap2ui5lint src --render          # no runtime => exit 2, not a fallback

That is the line to use in CI. Quietly skipping a gate the config promised is how a green pipeline stops meaning anything, so the promise has to be writable — and --render is how it is written.

Seeing the view — --screenshot

The same runtime, asked a different question. The gate loads the view to find out whether it survives creation and throws it away the moment it knows; --screenshot keeps it standing and photographs it:

abap2ui5lint zcl_my_app.clas.abap --screenshot app.png
abap2ui5lint zcl_my_app.clas.abap --screenshot app.png --screenshot-size 390x844
abap2ui5lint zcl_my_app.clas.abap --screenshot app.png --screenshot-size 390x844,1280x900
abap2ui5lint zcl_my_app.clas.abap --screenshot app.png --screenshot-theme sap_horizon_dark

Several viewports render in one browser session — the launch and the UI5 boot cost more than every render together, so a phone-and-desktop matrix is barely more expensive than one picture. Each file carries its viewport in the name.

An abap2UI5 view exists at runtime and nowhere else, so looking at one has meant activating the class on a system and launching the app. Here it is reconstructed from the builder calls, seeded with the model derived from the class's own TYPES/DATA, and rendered against the local OpenUI5 runtime in the theme and viewport you name — no system, no transport, no activation. The same reconstruction the gate renders, so the picture is of the view the gate cleared.

It is a mode: nothing else runs, and stdout carries the written paths and nothing else, one per line, so an editor or a workflow can just read them. Several views in one class (a main view and a popup) number the name after the class they came from. Render errors do not suppress the picture — a view with one broken binding still comes up, and the half that rendered is the part worth looking at — they go to stderr alongside it.

The themes ship as .less in the @openui5 source packages, never as the library.css a browser asks for, so the first picture in a theme compiles it (less-openui5, the UI5 toolchain's own compiler, a few seconds for sap.m) and caches the result per runtime version and theme. The gate itself never asks for a stylesheet and pays none of this.

Preview data — the empty-table problem

The model is derived from what the class seeds literally (model_init, a VALUE #( )), because that is all a static reconstruction can know. A table filled by a SELECT is therefore empty in the picture, and a list view — most real apps — photographs as No data.

So a JSON file next to the source is used as preview data, by convention and without a flag:

src/zcl_travel_list.clas.abap
src/zcl_travel_list.mock.json     ->  { "MT_ROWS": [ { "NAME": "Berlin - Rome" } ] }

It is merged over the derived model rather than replacing it: the derived one knows every field of every declared structure, which is what makes the other bindings resolve, and the mock file only has to name the table you want to see filled. --screenshot-model <file.json> does the same for a run that should not depend on a file lying next to the source. A mock file that does not parse is reported next to the picture it did not fill — silently going back to an empty table would be the one failure nobody would investigate.

What it is not: a preview of the app. Nothing round-trips, no event reaches ABAP, and the data is a mock model rather than what a system would serve. It is the view, rendered.

To work on the linter itself, clone it and use node cli.mjs in place of the binary — the flags below are identical either way. The runtime is an npm workspace here, so a plain npm ci sets up both:

npm ci
npx playwright install chromium
node cli.mjs src

CLI

abap2ui5lint src                          # check everything under src/
abap2ui5lint src --ui5 1.120              # check against UI5 1.120
abap2ui5lint src --allow sap.m.GenericTile.systemInfo   # accepted deviation
abap2ui5lint src --no-render              # property gate only (no browser)
abap2ui5lint src --render                 # require the render gate: no runtime, no run
abap2ui5lint src --no-properties          # the other way round: render gate only
abap2ui5lint src --fail-on error          # only real breakage fails CI
abap2ui5lint src --advisory               # report, never fail the build
abap2ui5lint src --fix                    # correct what is mechanical, report the rest
abap2ui5lint src --quiet                  # errors only (the counts stay complete)
abap2ui5lint src --format json            # machine-readable output (for tools)
abap2ui5lint src --format markdown        # for a PR comment or a job summary
abap2ui5lint src --badge check.json       # the verdict badge for the README
abap2ui5lint src --badge-corpus corpus.json  # and what the corpus is
abap2ui5lint src --no-stats               # drop the run summary under the report
abap2ui5lint src --no-progress            # and the live gate log on stderr
abap2ui5lint src --verbose                # add the reconstruction notes per file
abap2ui5lint zcl_app.clas.abap --screenshot app.png   # SEE the view, without a system
abap2ui5lint --version                    # version and script location
Exit code
0 clean, or nothing above --fail-on
1 a finding at or above --fail-on (default: warning), or a render error
2 bad usage or a broken config file

--format takes stylish (the default shown above), json, markdown and sarif; --json is a shorthand for --format json. The SARIF log is what GitHub code scanning ingests — upload it with github/codeql-action/upload-sarif and findings land in the Security tab and as native PR annotations. Inside GitHub Actions every finding is additionally emitted as a workflow command (alongside stylish only, so the machine formats stay parseable) — --no-annotate turns that off, --annotate forces it on elsewhere.

What a run says about itself

A finding list describes what is wrong. On a corpus that is clean — the state a repo with a baseline lives in — it describes nothing at all, and "148 files, no findings" reads identically whether two thousand controls were judged or the reconstruction quietly produced empty views. So a run over more than one file closes with what it looked at:

Success! No findings detected.

sources    148 app classes
views      172 documents reconstructed, nested 11 deep, 7 classes produced none
judged     2,176 controls of 106 types, 548 bindings, 69 icons, 4,164 attributes
most used  sap.m.Text 250, sap.m.Label 208, sap.m.Button 205, sap.m.Input 189, +102 more
gates      properties 148 files, render 172 documents
findings   none
baselined  476 findings suppressed by abap2ui5lint-baseline.json (chain-element-per-line 339, …)
time       properties 0.5s, render 13.0s, total 13.5s
abap2ui5-linter: 148 files, 0 failing, 0 skipped (target OpenUI5 1.71, metadata from 1.151.0, failing on warning)

7 classes produced none and a judged line of zeroes are the two readings that say the gate is not seeing the corpus — the failure mode a green run otherwise hides. --stats forces the block for a single file, --no-stats drops it, --format json carries the same numbers under stats (per file too, minus the control histogram), and --format markdown renders it as the job summary a workflow writes into $GITHUB_STEP_SUMMARY.

While the run is still going, the gates report on stderr — stdout stays the report, so --json | jq is unaffected. On a terminal that is one rewriting line; inside GitHub Actions it is one line per file inside a collapsed group per gate, with the timing line outside it:

::group::abap2ui5-linter: render gate, 141 files on 4 browser pages
  [  1/141] src/01/z2ui5_cl_smp_app_004.clas.abap
  [  2/141] src/01/z2ui5_cl_smp_app_006.clas.abap — render skipped (built in helper methods)
::endgroup::
abap2ui5-linter: render gate — 141 files in 13.0s

Deliberately no finding counts in that log: at that moment the baseline and the rules block have not had their say, so a fully baselined corpus would log hundreds of findings and then report none. --progress / --no-progress override where it is on (default: a terminal, and GitHub Actions).

The badges

A run can write two shields.io endpoint objects, so a repository can show the state of its corpus in the README instead of only whether some workflow exited zero. They are two badges because they are two statements, and they move on different occasions:

says colour changes when
--badge-corpus <file> what the repository isabap2UI5 | 148 apps · 172 views · 2,176 controls blue, a fact with no verdict in it somebody adds or removes an app
--badge <file> what the gate saidcheck-abap2UI5 | 83 rules passed, or 3 problems, or 7 errors green / yellow / red any run changes the verdict
abap2ui5lint src --badge-corpus .github/badges/abap2ui5.json \
                    --badge .github/badges/check-abap2ui5.json
{
  "schemaVersion": 1,
  "label": "check-abap2UI5",
  "message": "83 rules passed",
  "color": "4c1",
  "labelColor": "555",
  "cacheSeconds": 3600
}
[![abap2UI5](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/OWNER/REPO/main/.github/badges/abap2ui5.json)](https://github.com/abap2UI5/abap2UI5)
[![check-abap2UI5](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/OWNER/REPO/main/.github/badges/check-abap2ui5.json)](https://github.com/abap2UI5/linter)

Each badge keeps the shape every other badge in a README has: a grey name on the left, one thing to read on the right. 83 rules passed counts the rules that ran — the registry minus what your rules block switched off — the way a test badge counts tests; a repository that turns ten rules off does not get credit for them, and findings a baseline swallowed are the run summary's business, not the badge's. On the corpus badge a segment with nothing to say is left out rather than printed as a zero, so a corpus of raw *.view.xml reads abap2UI5 | 12 views · 340 controls.

Both are written on every run — a failing one included, which is the run whose badge matters, and the run that finds nothing checkable, which turns both grey and says so instead of leaving the last good ones standing. Commit them from the job that lints the pull request (that is where the corpus changes) and the badges on the default branch update when that pull request merges; nothing needs to run on a schedule and no service sees your repository.

kind (corpus or checks, default checks), label (the name on the left), labelColor and logo (a simple-icons name — there is none by default, a logo among a row of badges that carry none only draws the eye) are settable through the config's badge block, which is also where the files belong when every run should refresh them:

"badge": [
  { "kind": "corpus", "file": ".github/badges/abap2ui5.json", "label": "samples" },
  { "kind": "checks", "file": ".github/badges/check-abap2ui5.json" }
]

A single path stays what it has always been — the verdict badge:

"badge": ".github/badges/check-abap2ui5.json"

--no-badge suppresses the configured badges for one run — a second pass over the same corpus (a --format markdown job summary, a piped --json) saw fewer gates than the real run, and must not overwrite what that run wrote.

--fix

Seven rules carry an exact correction and are rewritten in place; the run then reports what is left, so --fix can go in front of any other flag (--fix-dry-run reports what it would change without writing a file).

Rule What --fix writes
obsolete-binder client->_bind_edit( … )client->_bind( … ), arguments untouched — except a call carrying custom_mapper_back/custom_filter_back, which is reported without a fix (the arguments have to go too, and dropping one is not a rename)
obsolete-model-update the call is deleted, with its line when it has that line to itself; a shared line or a trailing comment keeps everything but the call
obsolete-frontend-event client->_event_client( … )client->follow_up_action( … ), arguments untouched
unconverted-abap-boolean a bare token moved onto a( b = … ) — an expression is left alone
event-arg-unresolved the missing $ inserted (`{COL}``${COL}`), a |…| template left alone
popover-display-val popover_display( val = … )popover_display( xml = … ), the argument untouched
undeclared-namespace the missing xmlns: declaration inserted at the view root — for the conventional prefixes (core, mvc, l, form, f, table, u, uxap, tnt, html, cc) only; any other prefix could mean any library

Nothing else is touched: a correction that has to guess (which of two duplicate attributes survives, what event a _bind on an event slot meant to raise) is worse than the finding it replaces. A rule waived by a directive or by the config is never rewritten, and overlapping corrections are deferred to the next run rather than merged. ABAP2UI5LINT_FIX_DRY_RUN=true reports what it would change without writing a file.

Waiving a rule

Three scopes, from narrow to wide:

One line — a comment in the source, spelled the way ui5lint spells it and carried by whatever comment syntax the file has:

" abap2ui5lint-disable-next-line unknown-binding-path -- filled in a LOOP
)->a( n = `text` v = `{PRICE}`
<!-- abap2ui5lint-disable-next-line unknown-property -->

-disable-line waives the line the comment sits on, -disable-enable waives a block. Naming no rule waives every rule; everything after -- is a reason and is ignored — which is also what ends an XML comment, so the --> is never read as a rule id.

One repo — the rules block of the config file (see below): switch a rule off, give it another severity, or exclude files from it.

One member--allow sap.m.Avatar.displaySize keeps using a control or member that is newer than the floor, without touching the rule itself.

Adopting the linter on an existing repo — the baseline

Switching a linter on over a grown codebase reports everything at once, and the escapes above all lose information (rules: false drops the rule, directives touch every line). The baseline freezes the debt instead:

npx abap2ui5lint src --update-baseline     # writes abap2ui5lint-baseline.json

Commit that file and point the config at it ("baseline": "abap2ui5lint-baseline.json", or --baseline <file>). From then on the frozen findings are suppressed (counted, never listed), new findings fail normally — and an entry whose finding is gone is stale and fails too, so the baseline only ever shrinks (rerun --update-baseline after fixing things). Keys are line-free (file|rule|control|member|value with a count), so moving code around does not invalidate them. Render errors are not baselineable — rules: { "render-error": { "exclude": […] } } covers those.

SAPUI5 or OpenUI5

--distribution sapui5|openui5 (--openui5 as a shorthand, setting abap2ui5.viewCheck.distribution in the VS Code extension) says which distribution the target system serves. SAPUI5 ships libraries OpenUI5 does not — sap.ui.comp (Smart controls), sap.suite.*, sap.ushell, sap.fe, sap.viz, … — so a SmartTable is perfectly fine on SAPUI5 and a guaranteed runtime error on OpenUI5. With openui5 those controls are reported as sapui5-only-control; the default sapui5 accepts them silently (they are outside the snapshot either way, and are never mistaken for a typo).

Which UI5 version is checked against

--ui5 <version> (alias --min-ui5, setting abap2ui5.viewCheck.minUi5 in the VS Code extension) is the version your system runs. It drives both directions:

  • a control or member introduced after it is a finding (it would not exist on your system),
  • a deprecation is only reported once it is in effect at that version — a control deprecated as of 1.149 is silent for a 1.71 target.

The metadata itself comes from the snapshot in data/properties.json, generated from the @openui5/* sources this repo pins (its version is printed in the CLI summary and stored as ui5Version). Existence checks are therefore made against that snapshot: a control removed in a later UI5 than your target cannot be distinguished from a typo, so keep the snapshot at or above the versions you target.

Configuration file — abap2ui5lint.jsonc

Pin the settings in the checked repo instead of repeating CLI flags — same idea as abaplint.jsonc, and abap2ui5lint.json is discovered too. Discovery is eslint-style: --config <file> wins, otherwise the file is searched upward from the current directory and from each given path. Precedence per option: explicit CLI flag > config file > built-in default (--no-config ignores the file entirely).

abap2ui5lint --init writes a commented starter version of this file for you, with the $schema already pointing at the copy your project installed.

{
  // resolved from the version this project pinned. The raw.githubusercontent
  // URL for `main` also works, but it validates your file against rules the
  // installed CLI may not have yet - the editor then accepts what the run
  // refuses, which is the wrong way round.
  "$schema": "./node_modules/@abap2ui5/linter/data/abap2ui5lint.schema.json",
  "paths": ["src"],          // used when the CLI got no positional paths
  "ui5": "1.71",             // UI5 floor for the property gate
  "distribution": "sapui5",  // or "openui5"
  "failOn": "warning",       // error | warning | hint | never
  "render": true,            // false = skip the render gate (--no-render).
                             // true is also a PROMISE: unlike the default-on
                             // gate, it fails when the runtime is missing
                             // rather than falling back (--render)
  "properties": true,        // false = skip the property gate (--no-properties)
  "allow": [],               // e.g. ["sap.m.Avatar.displaySize"]
  "baseline": "abap2ui5lint-baseline.json",  // adoption-time debt, see above
  "badge": [                 // shields endpoints for the README, see above
    { "kind": "corpus", "file": ".github/badges/abap2ui5.json" },
    { "kind": "checks", "file": ".github/badges/check-abap2ui5.json" }
  ],
  "rules": {
    "missing-accessibility": false,        // off
    "member-deprecated": "hint",           // another severity
    "event-without-handler": {             // both, plus file exclusions
      "severity": "warning",
      "exclude": ["/test/"]                // file regex, case insensitive; matched
                                           // against the path in every form the
                                           // run can produce, so it means the
                                           // same however you invoke the linter
    },
    // the render gate's pseudo-rule: waive render failures per file instead
    // of render:false wholesale. A waived file that renders CLEAN is called
    // out as a stale waiver, so the exclusion cannot quietly outlive its bug.
    "render-error": { "exclude": ["legacy/"] }
  }
}

The $schema line gives editors completion and validation for every key and every rule id — data/abap2ui5lint.schema.json is generated from the rule registry, so it can never drift from the linter (npm run generate-schema).

Unknown keys — and unknown rule ids — fail loudly (typo protection). The GitHub Action defers to the repo's config for every input you leave unset.

GitHub Action

jobs:
  lint-views:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: abap2UI5/linter@v0
        with:
          paths: src
          min-ui5: '1.71'
          fail-on: warning
          badge: .github/badges/check-abap2ui5.json
          badge-corpus: .github/badges/abap2ui5.json
          flags: '--allow sap.m.GenericTile.systemInfo'

Ask for screenshots and the job also photographs every checked view, which is the review artefact CI could not produce before — seeing an abap2UI5 view used to need a system:

      - uses: abap2UI5/linter@v0
        with:
          paths: src
          screenshots: build/screenshots
          screenshot-size: '390x844,1280x900'
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: views
          path: build/screenshots

That step runs whether the check passed or failed — the run that failed is the one where a reviewer most wants to see the view — and a view that cannot be photographed is a warning, never a second reason to fail the job.

Findings are annotated onto the pull request diff by default; set annotations: false to keep the log plain. badge and badge-corpus only write the endpoint files — committing them is the workflow's job, and the pull request that changes the corpus is the right place to do it.

The render gate runs by default, which costs the job the UI5 runtime (~118 MB) and a Chromium download. For a fast property-gate-only job:

      - uses: abap2UI5/linter@v0
        with:
          paths: src
          render: false      # skips both downloads; every static rule still runs

It is on by default on purpose: turning it off quietly would make findings disappear from a pipeline that still reports green.

@v0 is a moving tag: it follows the newest release of the 0.x line, so a new rule can change your verdict without you asking for it — which is the point of a linter, and the reason to pin @v0.1.0 instead where a build has to stay reproducible. @main works too and is what this README documented before there were releases; it now moves on every merge, so prefer either tag.

Library

npm install @abap2ui5/linter — the package is ESM and ships types.d.ts, so the named exports below are typed in an editor without a @types package. It has no dependencies; add @abap2ui5/render-runtime only if you call the render gate (checkFiles with render left on).

import { checkFiles, checkAbapSource, checkXmlSource } from '@abap2ui5/linter';

const results = await checkFiles(['src/zcl_my_app.clas.abap']);
// -> [{ file, findings: [...], renderErrors, docs, model }]
//    finding: { type, control, member, severity, message, line, column, ... }

screenshotFiles is the same runtime taking pictures instead of a verdict — what --screenshot runs, returning the PNGs as buffers rather than writing them, which is what an editor holding an unsaved buffer needs:

import { screenshotFiles } from '@abap2ui5/linter';

const shots = await screenshotFiles(['src/zcl_my_app.clas.abap'], { theme: 'sap_horizon' });
// -> [{ file, index, kind, png: Buffer | undefined, errors: [...] }]

checkFiles/checkAbapSource/checkXmlSource annotate their findings themselves and honour rules plus the source directives — pass rules (and, for exclude, the file the source came from) in the options. Anything driving the gates directly (checkNodes, checkAbapRules) gets the same from the findings subpath, so severity and wording are never reinvented per consumer:

import { annotate, applyRules, applyDirectives, RULES, severityOf, describe }
  from '@abap2ui5/linter/findings';

annotate(findings, source);                       // severity, message, line, column
findings = applyRules(findings, rules, file);     // the repo's rules block
findings = applyDirectives(findings, source);     // abap2ui5lint-disable-* comments

RULES is the full rule-id registry. The report subpath holds the formatters (formatStylish, formatJson, formatMarkdown, githubAnnotations, summarize) if you want the same output elsewhere, plus the run-summary and badge builders (runStats, statsRows, formatStats, badgeEndpoint) and createProgress, the reporter behind the onProgress callback checkFiles calls while it runs:

const results = await checkFiles(files, {
  onProgress: ({ phase, done, total, file }) => { /* 'properties' | 'render' */ },
});

--json output carries the annotated findings plus a totals count per severity, a problems total, and stats — what the run looked at (documents, controls, bindings, icons, the control histogram), per file as well.

Consumers: the ai-mcp server exposes these gates as MCP tools for AI coding agents; the VS Code extension is the natural place to surface findings as editor diagnostics.

What it cannot do (by design)

  • Event round-trips and visual/UX fidelity stay with a live run (see ai-mcp's run_app).
  • A class that builds view parts in helper methods without the handle idiom is not statically reconstructable — the render gate is skipped with a notice (an incomplete reconstruction would validate the wrong view). The property gate still runs on what was reconstructed.
  • Enum values newer than the floor are invisible at the member-name level; members without @since count as always-available (they predate version tracking).
  • A model field the class fills in code (a LOOP in model_init) instead of in a literal seed has no static value. The render gate therefore only ever sees what a seed sets — inventing an empty string for such a field would have UI5's strict mode reject a perfectly good view (state="" is not a ValueState). The property gate asks a second, complete picture of the model instead: every declared field of every declared structure, so a binding path is judged against what a row has, not against what a seed happened to set.

Data

data/properties.json is generated from the OpenUI5 control sources — per control the parent, class-level @since/@deprecated, interfaces, the default aggregation and every declared member with its type, plus the enum tables. The @openui5/* packages this repo already depends on ship those sources, so a plain regenerate needs no OpenUI5 clone:

npm run generate-metadata                              # from node_modules
OPENUI5_DIR=/path/to/openui5 npm run generate-metadata # from a checkout

Two more artefacts are generated from the rule registry in lib/findings.mjs and the prose in lib/rule-docs.mjsnpm test fails while either is stale:

npm run generate-schema      # data/abap2ui5lint.schema.json — editor completion
npm run generate-rules-page  # docs/index.html — the published rule reference

Credits

The reconstruction, mock-model derivation, render harness and property gate were built and battle-tested in samples-controls (scripts/render-smoke.mjs, scripts/property-check.mjs, scripts/generate-properties.mjs) against the official UI5 demo kit corpus. This package is the corpus-independent extraction.

About

A static code analysis tool for abap2UI5

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages