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.
npx @abap2ui5/linter srcThat 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.jsoncAdd 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.
Two gates:
-
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-controlsap.m.Shell2— no such controlunknown-propertyButton typ="…"— no such property/event/associationinvalid-property-valueButton type="Emphasised"— outsidesap.m.ButtonType; also non-numericint/floatand non-boolean valuesunknown-aggregationPage contentt— no such aggregationtoo-many-childrentwo controls in a 0..1 aggregation invalid-aggregation-childa control the aggregation's type does not accept control-too-new/member-too-newintroduced after your target UI5 version (default 1.71) enum-value-too-newthe 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@sincefrom the enum's JSDocaggregation-too-newan aggregation tag introduced after your target — <footer>on aDialogis ~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 onsap/m/footer.jstakes the whole view down. Usebuttons(1.21.1)event-parameter-too-newa ${$parameters>/name}read back in at_argthat the event only gained later — resolved per event, not per nameunknown-iconsap-icon://textFormatting— a glyph the font has in no release.IconPoolreads the name as a URI hostname, which is lower-cased, so a camelCase name is not "nearly right": it matches nothing, forever (the name istext-formatting)icon-too-newthe glyph reached the font after your target — informationin 1.80 (usemessage-information),clear-allin 1.86 (useeraser). An unknown icon is not an error in UI5: the control simply renders with no icon and nothing is loggedicon-removedthe glyph left the font again — binary(1.104) isnon-binaryfrom 1.120 on, same codepoint, renamedtoolbar-control-in-bara ToolbarSpacer/ToolbarSeparatorinside asap.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'soverflow: hiddendeletes every sibling after it. IncludesPage headerContent, which is forwarded into the internal Barunknown-event-parametera ${$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 declarescontrol-deprecated/member-deprecatedcontrol or property already deprecated at your target version duplicate-aggregationthe same aggregation opened twice under one control — the second tag replaces the first aggregation-in-aggregationan aggregation directly inside another one — invalid XML, and the signature of a missing shut( ): UI5 then goes looking for a control class by that nameexcess-shutone shut( )more than the builder tree is deep — asserts at runtimeduplicate-propertythe same attribute written twice on one control — the view builder asserts on it attribute-without-elementa( )on the bare factory root — nothing to attach it to, asserts toosource-line-too-longa 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-idthe same idtwice — duplicate-ID error at runtimeundeclared-namespacens = 'form'without anxmlns:formdisplay-root-mismatcha mvc:Viewhanded topopup_display( ), or acore:FragmentDefinitiontoview_display( )— the slot decides whether the client usesXMLView.createorFragment.loadinvalid-expression-bindingunbalanced braces/parens in {= … }sapui5-only-controlneeds SAPUI5, absent from OpenUI5 (see below) missing-required-aggregationa Tablebound to rows but given nocolumns— renders emptycollection-bound-to-propertya table/structure bound to a scalar property settable-property-via-actiona CONTROL_BY_IDset…( )on a control that has a bindable property of that name — bind it two-way insteadrelative-binding-without-contexta relative {FIELD}on a control outside any bound aggregation — it resolves against nothing and the control renders emptyfrontend-action-unknown-idan 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 nothingdenied-control-methoda CONTROL_BY_IDwire naming a method the frontend denylist refuses (destroy,setModel,bindProperty, the generic reflection mutators) — the dispatch logs and returns, the control is never touchedbinding-on-associationa 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-modela {name>…}binding against a model the app does not have — abap2UI5 serves one default model plusdevice>/message>, and an unknown prefix leaves the property unsetdate-type-without-sourcea sap.ui.model.type.Date/DateTime/Timebinding with noformatOptions.source— the JSON model can only carry a string, so the type throws on every formatbinding-type-mismatchan ABAP character field bound to a numeric/boolean property — it arrives as "100"where UI5 declared a float, which future mode rejectsmissing-accessibilityan icon-only Buttonwith no accessible name (notext,tooltiporariaLabelledBy), or anImagethe author marked meaningful (decorative="false") and left withoutalt— for a decorative image, which is UI5's default,altis ignored by the frameworkBindings 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-patha 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'sTYPES, never guessedbinding-for-event/event-for-property_bind( )on an event (dead control) or_event( )on a propertyfrozen-view-builderthe class builds its view with z2ui5_cl_xml_view, the frozen predecessor ofz2ui5_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 appnon-released-apian abap2UI5 object outside the released src/02package. 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 ownz2ui5_-prefixed classes are never reportedobsolete-binderclient->_bind_edit( )— superseded byclient->_bind( ), which is two-way as well. A call carryingcustom_mapper_back/custom_filter_backis reported too (they are accepted for source compatibility but no longer evaluated), only without the autofixobsolete-model-updateview_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 aftermain( )and pushes it to every open slot by itself. The call reads as "the model is pushed here" where nothing happens; delete itobsolete-frontend-eventclient->_event_client( )— superseded byclient->follow_up_action( ). Since it gained aRETURNINGparameter it reaches the sameget_event_client( )wherever its result is consumed, so one method both schedules a frontend action and wires oneunconverted-abap-booleanan ABAP boolean written straight into the view: it arrives as 'X'/' ', and UI5 reads any non-empty string as true — sovisible = abap_falsemakes the control visible. The correction is the builder's own boolean parameter:a( b = flag )binding-to-locala local variable bound: the instance is serialized across the roundtrip, the method stack is not, so the value is lost binding-to-referencea TYPE REF TOattribute bound without dereferencing — the serializer walks DATA, not references, so the bind throws at runtime; writeclient->_bind( ref->* )manual-init-flaga 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 roundtripevent-on-disabled-controlan 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-nonpublica PROTECTED/PRIVATE attribute bound — only PUBLIC attributes are serialized into the model, so the first roundtrip fails with BINDING_ERROR; move it to thePUBLIC SECTIONui5-internal-accessmProperties& friends read from a wire or binding — private UI5 internals with no API contract, they change across UI5 patches without noticecommercial-ui5-hosta URL pinned to ui5.sap.com/*.hana.ondemand.com— usesdk.openui5.org, or the app breaks on an OpenUI5-only landscapeview-never-displayeda view is built but never handed to the client — an empty page, no error event-without-handleran event nothing reacts to — a dead control, unless the roundtrip alone is intended (so: a hint, never an error) live-event-roundtripa liveChangewired toclient->_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-idpopover_display( by_id = … )naming an id no view declares — the fragment loads, finds no anchor and is destroyed again; nothing opens, nothing renders redunknown-frontend-actiona literal action name outside the frontend dispatch table (case-sensitive) — executelooks it up and does nothing at all on a miss, not even a console line. Thecs_event-constants are compile-checked; this covers the literal spellingunknown-view-slota literal view slot outside MAIN/NEST/NEST2/POPUP/POPOVER(case-sensitive —cs_view-nestedisNEST, notNESTED) — the wire addresses no view, and forCONTROL_BY_IDa wrong slot even suppresses the global id fallbackinvalid-keyboard-shortcuta shortcut combo naming no non-modifier key ( Ctrl+Shift) — logged once, never registered, every later keydown does nothinginvalid-action-payloada 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 malformedBINDING_CALLfilter-groups payload (or one missing its nesting level) is rejected with only a console linejson-bind-on-scalar-property_bind( json = abap_true )landing on astring/int/float/booleanproperty — 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 forobject-typed propertiesraw-javascript-to-frontendraw JavaScript shipped to the browser — follow_up_action's escape hatch (a non-namevalis inserted verbatim ascustom_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 codeget-viewname-removeda read of client->get( )-viewname, removed fromty_s_get— no longer compiles, and nothing in a systemless pipeline says so before activationinvalid-frontend-actiona frontend-action t_argoutside the set the runtime accepts — an unknownCONTROL_GLOBALobject or method, aBINDING_CALLmethod that is notfilter/sort, orCONTROL_BY_ID's obsolete empty view slot. The browser logs and does nothingunescaped-brace-in-styleliteral CSS braces in a <style>block — the XMLView parser reads them as bindings and the view dies; write\{and\}collapsed-brace-in-stylethe same escape written inside a |…|template — the template collapses\{to{before the builder sees it, so the view dies anyway; use a backtick literalunused-public-attributea 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-rangeget_event_arg( n )past thet_argthe 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 itselfevent-arg-unresolveda bare-brace t_argliteral (`{COL}`): the runtime sends it verbatim but only$-prefixed expressions are resolved by UI5, soget_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-argthe LAST t_argentry is empty.get_t_argbuffers 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 anywherejson-literal-in-attributea 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 itpopover-display-valpopover_display( val = … )does not compile — the parameter isxml, unlikepopup_display'sval. Caught here because nothing in a systemless pipeline meets a compilerescaped-brace-in-backticka binding written \{ … \}inside abacktickliteral — escaping is auncurated-formattera 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 fieldhardcoded-binding-pathan absolute binding path written as text ( {/PATH},path: '/PATH') — derive it fromclient->_bind( var )so it moves with a variable rename; an OData entity path in a class that switches its default model is exemptmissing-view-display-on-navigateda check_on_navigated( )branch that never re-displays — after returning from a called app the browser keeps showing that app's viewmissing-on-navigated-brancha 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 theIFchain, or a popup helper'snav_app_leave( )) is exemptchain-indentationa 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 indentationis 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 ownchain-element-per-lineseveral 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 exemptchain-house-layoutopt-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-indentationstructurally cannot, a chain whose every level is uniformly wrong. Carries fixes (--fix). Switch it off withfalseif your house style is a different oneseparate-lifecycle-ifslifecycle checks in separate IFblocks instead of oneIF/ELSEIFchain — separate blocks can run more than one branch per roundtrip (a guard block thatRETURNs is exclusive and fine)duplicate-for-iteratorthe same FORiterator name twice in one method — a 7.02 downport materializes each asDATA <name> TYPE iand 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.
- Render gate — the view is loaded with a real
XMLView.createin 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'sTYPES/DATA/model_initseeds, so bindings resolve realistically during the render. -
Raw
*.view.xml/*.fragment.xmlfiles.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.
npx @abap2ui5/linter src # no install, one run
npm install -D @abap2ui5/linter # in a project
npm install -g @abap2ui5/linter # everywhereThe 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.
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 browserWhy not
optionalDependencies? Because npm installs those by default — the name promises the opposite of what it does. Declaring the runtime that way madenpx @abap2ui5/lintera ~123 MB download before it linted anything, and--omit=optional, the documented way out, is not a flagnpxaccepts. 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-renderThat 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 fallbackThat 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.
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_darkSeveral 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.
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 srcabap2ui5lint 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.
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).
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 is — abap2UI5 | 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 said — check-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
}[](https://github.com/abap2UI5/abap2UI5)
[](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:
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.
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.
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.
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.jsonCommit 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.
--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).
--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.
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.
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/screenshotsThat 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 runsIt 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.
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-* commentsRULES 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.
- 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
@sincecount as always-available (they predate version tracking). - A model field the class fills in code (a
LOOPinmodel_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 aValueState). 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/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 checkoutTwo more artefacts are generated from the rule registry in lib/findings.mjs
and the prose in lib/rule-docs.mjs — npm 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 referenceThe 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.