From 08b3a8224c0172829faa0d5291554594079bf0b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:23:38 +0000 Subject: [PATCH 1/6] Catch a frozen-builder call written on its receiver, and fix the four pages The fragment gate anchored on the closing paren of a previous chain step, so it saw `)->input( )` and was blind to the identical `page->input( )`. That is the shape a fragment showing ONE control is naturally written in - which is why the migration that cleared every mid-chain call in the tree left seven frozen calls standing on four pages, published to every reader and to the generated llms.txt: cheat_sheet.md view->button( ), view->_generic( ) style_css.md view->button( ) device_model.md page->input( ) smart_controls.md page->smart_variant_management( ), _filter_bar( ), _table( ) All seven are members of z2ui5_cl_xml_view (src/99) and of no other class; none exists on z2ui5_cl_ui5_view_builder (src/02), whose whole API is ele, tag, a, end, stringify. Teaching them is the one regression this ecosystem guards hardest against, because a model trained on old material writes them by default - the linter carries a frozen-view-builder rule for exactly this. Rewritten with the current builder. The cheat sheet's warning was about a `_generic( t_prop = )` quirk that no longer exists, so it now covers the trap that replaced it: an ABAP flag reaches the view as `X` through `v` and as `true`/`false` only through `b`. smart_controls.md gained the `xmlns` declarations its controls need, taken from samples-stack app 478. The gate now also reads receiver-style calls. Receiver names cannot be recognised in general, so the set of view-node variables is derived per fence - anything assigned out of the builder, anything calling one of the five verbs - and seeded with `view` and `page`, which a one-control fragment uses with no chain around it to derive from. Verified: it reports all seven original calls and exits 1, and passes clean on the corrected tree. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- docs/configuration/setup/style_css.md | 4 +- docs/cookbook/cheat_sheet.md | 21 ++++---- docs/cookbook/expert_more/smart_controls.md | 54 +++++++++++++-------- docs/cookbook/model/device_model.md | 6 +-- scripts/check-examples.mjs | 47 +++++++++++++++++- 5 files changed, 95 insertions(+), 37 deletions(-) diff --git a/docs/configuration/setup/style_css.md b/docs/configuration/setup/style_css.md index 85d0b89f..0d77a57c 100644 --- a/docs/configuration/setup/style_css.md +++ b/docs/configuration/setup/style_css.md @@ -18,7 +18,9 @@ ENDMETHOD. In the XML view you then reference your class via the `class` property: ```abap -view->button( text = `Delete` class = `myRedButton` ). + )->tag( `Button` + )->a( n = `text` v = `Delete` + )->a( n = `class` v = `myRedButton` ) ``` ## When to Use Custom CSS diff --git a/docs/cookbook/cheat_sheet.md b/docs/cookbook/cheat_sheet.md index 6c07068e..8aa2c1db 100644 --- a/docs/cookbook/cheat_sheet.md +++ b/docs/cookbook/cheat_sheet.md @@ -18,25 +18,24 @@ A one-page recap of the rules that decide whether an abap2UI5 app works or misbe | Check the built-in popups before building a custom dialog | Roughly twenty ready-made dialogs ship with the framework — confirm, select, file up/download, ranges, PDF, … → [Built-In](/cookbook/popup_popover/built_in) | | Use backtick string literals (`` ` ``) | Project-wide convention in the framework, the samples and this documentation; keeps ABAP string handling consistent | -::: warning `abap_false` in `_generic( )` disappears from the view -In the **fluent API** both flags work as expected — the builder inspects the type of the value it receives and writes `true` or `false`: +::: warning An ABAP flag passed as `v` does not reach the view as a boolean +`a( )` takes **either** `v` — any string expression — **or** `b`, an ABAP boolean. Only `b` converts, and it is the form to use whenever the value comes out of ABAP: ```abap -view->button( text = `Save` enabled = abap_false ). " → enabled="false" + )->tag( `Button` + )->a( n = `text` v = `Save` + )->a( n = `enabled` b = abap_false ) " → enabled="false" ``` -In **`_generic( t_prop = ... )`** they do not. The property table stores values as `string`, so the boolean type is lost on the way in: +Through `v` the flag is written verbatim: `abap_true` arrives in the view as `enabled="X"` and `abap_false` as an empty value. Neither is the `true` / `false` UI5 expects, and neither is a syntax error — the view renders, with the control in the wrong state. -- `abap_true` still ends up correct — the serializer renders a value of `X` as `true`. -- `abap_false` is a blank and becomes an empty string, and properties with an empty value are dropped from the XML entirely. The attribute is never written, so a control whose UI5 default is `true` (`enabled`, `visible`, …) silently stays enabled. - -Write the literal instead — it is unambiguous in both directions: +A **literal** is a string and belongs in `v`, unquoted by any flag variable: ```abap -view->_generic( name = `Button` ns = `sap.m` - t_prop = VALUE #( ( n = `text` v = `Save` ) - ( n = `enabled` v = `false` ) ) ). + )->a( n = `enabled` v = `false` ) ``` + +Any expression that yields a flag works in `b`, so there is no reason to convert by hand: `` )->a( n = `visible` b = xsdbool( lines( mt_item ) > 0 ) ) ``. ::: ## Next Steps diff --git a/docs/cookbook/expert_more/smart_controls.md b/docs/cookbook/expert_more/smart_controls.md index 5264408a..4ad04f4b 100644 --- a/docs/cookbook/expert_more/smart_controls.md +++ b/docs/cookbook/expert_more/smart_controls.md @@ -9,9 +9,21 @@ Smart controls from the `sap.ui.comp` library (SmartFilterBar, SmartTable, Smart The `sap.ui.comp` library ships with SAPUI5 but not with OpenUI5 — apps using smart controls require a SAPUI5 bootstrap. See [UI5 Bootstrapping](/configuration/setup/ui5_bootstrapping). ::: -## Supported Controls +## Declaring the Namespaces -The XML view builder covers the `sap.ui.comp` namespaces `smartfilterbar`, `smarttable`, `smartvariants`, `smartform`, `smartfield`, `smartchart` and `navpopover`. Since smart controls are metadata-driven, the app usually carries no ABAP data at all — it switches the default model to an OData service instead (see [OData](/cookbook/expert_more/odata)): +Smart controls live in their own `sap.ui.comp` sub-namespaces, so each one you use needs its `xmlns` on the root `View` — exactly as in a hand-written UI5 view. The three below cover a list report: + +```abap + DATA(view) = z2ui5_cl_ui5_view_builder=>factory( + )->ele( n = `View` ns = `mvc` + )->a( n = `xmlns` v = `sap.m` + )->a( n = `xmlns:mvc` v = `sap.ui.core.mvc` + )->a( n = `xmlns:smartFilterBar` v = `sap.ui.comp.smartfilterbar` + )->a( n = `xmlns:smartTable` v = `sap.ui.comp.smarttable` + )->a( n = `xmlns:smartVariantManagement` v = `sap.ui.comp.smartvariants` ). +``` + +`smartform`, `smartfield`, `smartchart` and `navpopover` are declared the same way. Since smart controls are metadata-driven, the app usually carries no ABAP data at all — it switches the default model to an OData service instead (see [OData](/cookbook/expert_more/odata)): ```abap client->view_display( val = view->stringify( ) @@ -23,26 +35,28 @@ client->view_display( val = view->stringify( ) A page variant is one `SmartVariantManagement` that owns the persistency for the whole page; SmartFilterBar and SmartTable register with it through their `smartvariant` association, each contributing its own `persistencykey`: ```abap -page->smart_variant_management( - id = `pageVariantId` - persistencykey = `PageVariantPKey` ). - -page->smart_filter_bar( - id = `smartFilterBar` - entityset = `ProductSet` - smartvariant = `pageVariantId` - persistencykey = `SmartFilterPKey` ). - -page->smart_table( - id = `smartTable` - smartfilterid = `smartFilterBar` - smartvariant = `pageVariantId` - entityset = `ProductSet` - initiallyvisiblefields = `ProductID,Name,Category,Price` - usevariantmanagement = `true` - persistencykey = `SmartTablePKey` ). + page->tag( n = `SmartVariantManagement` ns = `smartVariantManagement` + )->a( n = `id` v = `pageVariantId` + )->a( n = `persistencyKey` v = `PageVariantPKey` ). + + page->tag( n = `SmartFilterBar` ns = `smartFilterBar` + )->a( n = `id` v = `smartFilterBar` + )->a( n = `entitySet` v = `ProductSet` + )->a( n = `smartVariant` v = `pageVariantId` + )->a( n = `persistencyKey` v = `SmartFilterPKey` ). + + page->tag( n = `SmartTable` ns = `smartTable` + )->a( n = `id` v = `smartTable` + )->a( n = `smartFilterId` v = `smartFilterBar` + )->a( n = `smartVariant` v = `pageVariantId` + )->a( n = `entitySet` v = `ProductSet` + )->a( n = `initiallyVisibleFields` v = `ProductID,Name,Category,Price` + )->a( n = `useVariantManagement` v = `true` + )->a( n = `persistencyKey` v = `SmartTablePKey` ). ``` +Without an annotated `UI.LineItem` the SmartTable starts with no columns at all, so `initiallyVisibleFields` is not optional in practice — name the columns the service is meant to show. + ### The `SMART_VARIANT_INIT` Handshake In a classic UI5 app, the controller calls `initialise( )` on the variant management once the smart controls have registered. Without it, the page variant never gets a personalizable control — saving a view fails in `sap.ui.fl` and stored views are never loaded. In abap2UI5, the `smart_variant_init` frontend event performs this handshake; it waits until the smart controls have registered (which they do once their OData metadata has arrived): diff --git a/docs/cookbook/model/device_model.md b/docs/cookbook/model/device_model.md index 3fc20d80..1a81ba92 100644 --- a/docs/cookbook/model/device_model.md +++ b/docs/cookbook/model/device_model.md @@ -11,9 +11,9 @@ abap2UI5 offers two ways to access device information: directly in the view via By default, the device model binds to the view under the name `device`. Use standard UI5 binding syntax to show device properties directly — no backend roundtrip needed: ```abap -page->input( - description = `device model - resize - width` - value = `{device>/resize/width}` ). + )->tag( `Input` + )->a( n = `description` v = `device model - resize - width` + )->a( n = `value` v = `{device>/resize/width}` ) ``` For all parameters, see the [UI5 docs](https://sapui5.hana.ondemand.com/sdk/#/api/sap.ui.Device). diff --git a/scripts/check-examples.mjs b/scripts/check-examples.mjs index 15876a7f..9bbaaaf1 100644 --- a/scripts/check-examples.mjs +++ b/scripts/check-examples.mjs @@ -76,6 +76,40 @@ const unmigrated = []; * anything else is calling a method of the frozen one. */ const VERBS = new Set(['ele', 'tag', 'a', 'end', 'stringify']); +/* The gate had one blind spot, and it cost four more pages. `)->input( )` is + * caught from its first character, but the SAME call written on its receiver - + * `page->input( )` - is not, because the regex needs the closing paren of a + * previous step to anchor on. A fragment that shows a single control naturally + * opens with the variable, so the shape that escaped the gate is the shape a + * one-control example is written in: `view->button( )` on the cheat sheet and + * on style_css, `page->input( )` on device_model and three `page->smart_*( )` + * calls on smart_controls outlived the migration that cleared every mid-chain + * call in the tree. + * + * A receiver name cannot be recognised in general - it is whatever the page + * called its variable - so the set of names that denote a view node is built + * from the fence itself: anything assigned out of the builder, and anything + * that calls one of the five verbs. `view` and `page` are seeded on top, + * because a fragment showing one control has no chain around it to derive + * from, and those two are the view root and the page node in every sample and + * on every page here. */ +const VIEW_NODE_SEED = ['view', 'page']; + +/** The variables that hold a view node in this fence. */ +function viewNodes(code) { + const names = new Set(VIEW_NODE_SEED); + /* assigned straight out of the builder, or out of a step of one */ + for (const m of code.matchAll(/DATA\(\s*([a-z_][a-z0-9_]*)\s*\)\s*=\s*(?:z2ui5_cl_ui5_view_builder=>|[a-z_][a-z0-9_]*->(?:ele|tag|end)\()/gi)) { + names.add(m[1].toLowerCase()); + } + /* or simply seen calling a verb - which no other object in this + * documentation does, and which is the whole current API */ + for (const m of code.matchAll(/\b([a-z_][a-z0-9_]*)->(?:ele|tag|a|end|stringify)\(/gi)) { + names.add(m[1].toLowerCase()); + } + return names; +} + /* Pages that show the previous builder ON PURPOSE. The deprecation record is * a before/after table - "this is what you had, this is what you write now" - * so the old call is the content, not a leftover. */ @@ -89,16 +123,25 @@ function legacyFragments() { const md = readFileSync(file, 'utf8'); if (md.includes('This page still shows the previous view builder')) continue; for (const m of md.matchAll(/```abap\n([\s\S]*?)```/g)) { + const code = m[1]; /* The other fluent API in this documentation. `z2ui5_cl_ajson` chains * the same way and its verbs are its own, so a fence building JSON is * not a view chain. Listed rather than guessed at from the receiver * name: most view fragments here start mid-chain, with no receiver to * read, and a heuristic that needs one would skip exactly the fragments * this gate exists for. */ - if (/z2ui5_cl_ajson/i.test(m[1])) continue; - for (const call of m[1].matchAll(/\)->([a-z_][a-z0-9_]*)\(/gi)) { + if (/z2ui5_cl_ajson/i.test(code)) continue; + /* mid-chain: `)->input( )` */ + for (const call of code.matchAll(/\)->([a-z_][a-z0-9_]*)\(/gi)) { if (!VERBS.has(call[1].toLowerCase())) out.push({ page, call: `)->${call[1]}(` }); } + /* on the receiver: `page->input( )` */ + const nodes = viewNodes(code); + for (const call of code.matchAll(/\b([a-z_][a-z0-9_]*)->([a-z_][a-z0-9_]*)\(/gi)) { + if (!nodes.has(call[1].toLowerCase())) continue; + if (VERBS.has(call[2].toLowerCase())) continue; + out.push({ page, call: `${call[1]}->${call[2]}(` }); + } } } return out; From 11c167d6beffd64cd7316beea0608c8b817d7626 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:24:55 +0000 Subject: [PATCH 2/6] Run in CI the five checks npm run check runs package.json defines `check` as five steps; check.yml ran four. The missing one was `npm test` - the pin on the sample-catalogue parser, added because that parser stopped matching TWICE and both times answered wrongly ("`...app_493` is not in the sample catalogue") instead of failing. So the one check that exists because something broke in silence was the only one no pull request had to pass, which is the same failure mode a second time. check:version gains the `!cancelled()` guard the later steps already have, so a failing unit test still lets the rest of the run report rather than hiding four answers behind the first one. README.md and AGENTS.md both described `check` as three things. Corrected to the five, with the note that the workflow and the script have to stay in step, and AGENTS.md now records what check:examples refuses after the previous commit - a chain step on its receiver as well as mid-chain - and where the catalogue parser and its pin live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- .github/workflows/check.yml | 13 +++++++++++-- AGENTS.md | 16 ++++++++++++---- README.md | 9 ++++++--- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 36de7817..80fde645 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -41,12 +41,21 @@ jobs: cache: 'npm' - run: npm ci + # the catalogue parser, against a row of every shape the three sample + # repositories generate. `npm run check` has run this since the parser + # stopped matching for the second time; CI did not, so the one check that + # exists BECAUSE something broke twice in silence was the only one no pull + # request had to pass. Seconds, and no network. + - name: unit tests + run: npm test + # the release number in the nav bar, the deprecations page and the # changelog, against the newest release tag of the framework. This one # goes stale WITHOUT anybody touching this repository - a release happens - # over there - which is why it runs first: it is the check most likely to - # be true yesterday and false today. + # over there - which is why it runs before the build: it is the check most + # likely to be true yesterday and false today. - name: release version + if: ${{ !cancelled() }} run: npm run check:version # a page that does not build is a page nobody can read diff --git a/AGENTS.md b/AGENTS.md index b3b79459..d118e182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,19 @@ person reads the page. Do not put "as an AI, …" prose back into `docs/`. | `scripts/link-samples.mjs` | Generates the *Working Samples* block on a page from its `samples:` frontmatter plus `SAMPLES.md` in an `abap2UI5/samples` checkout, and checks the link in both directions | | `scripts/generate-llms.mjs` | Builds `llms.txt` / `llms-full.txt` / per-page markdown from the sidebar. Runs inside `docs:build`, so the deploy publishes them | | `scripts/check-version.mjs` | The release number in the nav bar, the deprecations page and the changelog, against the newest release tag of the framework | +| `scripts/lib/catalogue.mjs` | Parses a sample catalogue's rows; pinned by `test/catalogue.test.mjs`, because it has stopped matching twice and both times answered wrongly instead of failing | ## Build & verify — run before every commit ```bash -npm run check # docs:build + check:examples + check:samples +npm run check # test + check:version + docs:build + check:examples + check:samples ``` +`.github/workflows/check.yml` runs the same five, in the same order. Keep the +two in step: a step that exists only in `package.json` is a step no pull +request has to pass, which is how `npm test` — the pin added *because* the +catalogue parser broke twice in silence — went a release without CI. + `check:samples` needs an `abap2UI5/samples` checkout — set `SAMPLES_HOME`, or clone it as a sibling. Without one it *skips* rather than fails, so verify the output says what you think it says. CI checks out `abap2UI5/samples@main` @@ -39,9 +45,11 @@ explicitly for this reason. grepping the source. - **A fenced ABAP example is code, and it is checked.** `check:examples` compiles it and lints the view. It also refuses `z2ui5_cl_xml_view=>` — the - frozen builder — unless the page carries the migration banner. Examples are - the most-copied ABAP in the project; that gate is the reason the pages could - be migrated at all. + frozen builder — unless the page carries the migration banner, and refuses a + chain step calling anything but `ele` / `tag` / `a` / `end` / `stringify`, + whether it is written mid-chain (`)->input( )`) or on its receiver + (`page->input( )`). Examples are the most-copied ABAP in the project; that + gate is the reason the pages could be migrated at all. - **`llms.txt` is generated from the SIDEBAR, not from a directory walk.** A page in no sidebar is reported as an orphan and published anyway. If you add a page, add it to the sidebar or accept that nothing navigates to it. diff --git a/README.md b/README.md index 18bbe6f9..92f41086 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,19 @@ Every contribution makes the documentation better for the community! ```sh npm ci npm run docs:dev # the site, with hot reload -npm run check # what CI runs: build + ABAP examples + sample links +npm run check # what CI runs, all five steps ``` ### What CI checks -A documentation repository has no compiler for its prose, but three things in -it are decidable, and all three are decided before a merge: +A documentation repository has no compiler for its prose, but five things in +it are decidable, and all five are decided before a merge — `npm run check` +and `.github/workflows/check.yml` run the same list, in the same order: | | | |---|---| +| `test` | the sample-catalogue parser in `scripts/lib/`, against a row of every shape the three sample repositories generate | +| `check:version` | the release number in the nav bar, the deprecations page and the changelog, against the newest release tag of the framework — this one goes stale without anybody touching this repository | | `docs:build` | a page that does not build is a page nobody can read | | `check:examples` | the ABAP in the fenced blocks, against the real framework: does it compile, and does the view it builds name controls and properties that exist on the UI5 floor this documentation targets | | `check:samples` | the **Working Samples** blocks, against [abap2UI5/samples](https://github.com/abap2UI5/samples) | From 50751b9c50008ffaac0761899664fbe81ce5424d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:25:17 +0000 Subject: [PATCH 3/6] Send an agent to the llms.txt that maps the prose The tip in Getting Started -> Tooling pointed at the framework repository's llms.txt and called it "a map of this documentation". That file says otherwise in its own opening lines: it points readers who arrive from a search back here, calls this site's llms.txt "the better map of the prose", and describes itself as "the map of the CODE". This site generates and publishes its own llms.txt and llms-full.txt on every build - the README documents them - and no page linked either one. So the one tip aimed at agents sent them away from the index built for exactly that purpose. Both are now named, with one sentence on which answers which question. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- docs/get_started/tooling.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/get_started/tooling.md b/docs/get_started/tooling.md index 418286ff..80474fb9 100644 --- a/docs/get_started/tooling.md +++ b/docs/get_started/tooling.md @@ -73,11 +73,18 @@ a few checkouts and a first build that takes a while — the README says which tools need what, and validating views alone needs almost nothing. ::: tip Building with an AI agent? -Point it at [`llms.txt`](https://github.com/abap2UI5/abap2UI5/blob/main/llms.txt) -in the framework repository. It is a map of this documentation and the sample -repositories written for agents, and the -[app-template](https://github.com/abap2UI5/app-template) ships an `AGENTS.md` -that states the conventions an agent should follow in your project. +Point it at [`llms.txt`](https://abap2ui5.github.io/docs/llms.txt) — this site +generates one on every build: every chapter with a one-line summary, and +[`llms-full.txt`](https://abap2ui5.github.io/docs/llms-full.txt) for all of it +in a single fetch. There is a second one in the +[framework repository](https://github.com/abap2UI5/abap2UI5/blob/main/llms.txt); +the difference is what each maps — this site's is the map of the **prose**, the +framework's is the map of the **code**, down to the interface files an agent +should read instead of guessing at a signature. + +The [app-template](https://github.com/abap2UI5/app-template) then ships an +`AGENTS.md` that states the conventions an agent should follow in your own +project. ::: ## Where the samples live From 7a9e5c32cb636cb95c27eb5f375f66cbb5ff445d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:28:45 +0000 Subject: [PATCH 4/6] Count the sample corpora instead of typing their sizes into llms.txt generate-llms.mjs opens with the reason it generates rather than commits: "nothing here is a claim about another repository that could go stale between builds". Twelve lines of it were exactly that - "152 complete apps" and "~400 ports", hand-typed, next to docs/resources/samples.md giving different figures for the same corpora. llms.txt is the one file in this repository written to be quoted verbatim by something that cannot check it, so a stale number there is the most expensive kind to keep. They are counted now, through the same parser the sample links go through - which counts APPS: samples-stack lists eight supporting classes in tables of their own, and those are not samples and do not parse into a pointer. When a catalogue is not at hand the phrase carries no number at all. That path is real, not theoretical: deploy.yml checked out no sample repository, so the published file would have been the numberless one every time. It now takes SAMPLES.md alone out of samples and samples-controls, sparse and shallow, with continue-on-error - an unreachable repository costs the deploy a figure, never the site. The generator prints which counts it took, because "no checkout" and "counted" produce different files and both are valid. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- .github/workflows/deploy.yml | 27 +++++++++++++++++++++++++++ .gitignore | 5 ++++- scripts/generate-llms.mjs | 31 ++++++++++++++++++++++++++++--- scripts/lib/catalogue.mjs | 36 ++++++++++++++++++++++++++++++++++++ test/catalogue.test.mjs | 25 ++++++++++++++++++++++++- 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 34d6db51..e00e06c5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,6 +32,33 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Not needed if lastUpdated is not enabled + + # The sample catalogues, for the corpus sizes in the generated llms.txt - + # one file out of each repository, and nothing else. `continue-on-error` + # is the point: generate-llms.mjs leaves the number out when a catalogue + # is not here, so a repository that is unreachable costs the deploy a + # figure, never the site. + - name: Sample catalogues + continue-on-error: true + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: abap2UI5/samples + ref: main + path: .samples + fetch-depth: 1 + sparse-checkout: SAMPLES.md + sparse-checkout-cone-mode: false + - name: Sample catalogues (controls) + continue-on-error: true + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: abap2UI5/samples-controls + ref: main + path: .samples-controls + fetch-depth: 1 + sparse-checkout: SAMPLES.md + sparse-checkout-cone-mode: false + - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.gitignore b/.gitignore index 6bca04c2..401a00a9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,11 @@ docs/.vitepress/cache docs/.vitepress/dist -# the sample catalogue, cloned by CI for scripts/link-samples.mjs +# the sample catalogues, cloned by CI for scripts/link-samples.mjs and for the +# corpus sizes in the generated llms.txt .samples +.samples-controls +.samples-stack # generated at build time by scripts/generate-llms.mjs - a projection of the # pages next to them, so the only correct copy is the one this build made diff --git a/scripts/generate-llms.mjs b/scripts/generate-llms.mjs index b9886113..b5b83204 100644 --- a/scripts/generate-llms.mjs +++ b/scripts/generate-llms.mjs @@ -42,6 +42,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import config from '../docs/.vitepress/config.mjs'; +import { countCatalogue } from './lib/catalogue.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DOCS = path.join(ROOT, 'docs'); @@ -131,6 +132,23 @@ const title = (body, fallback) => /* -------------------------------------------------------------------- run */ +/* How big each sample corpus is. These were typed into the index by hand and + * went stale the way a hand-typed number does: "152 complete apps" and "~400 + * ports", next to a PAGE of this same site giving three different figures. + * A wrong count in the one file written to be quoted verbatim is the worst + * place in the repository to keep one. + * + * So it is counted, through the same parser the sample links go through - and + * when the catalogue is not at hand the phrase simply carries no number. The + * deploy workflow checks out no sample repository, so that is the normal case + * for the published file, and it is the right outcome: the sentence an agent + * needs is "ask SAMPLES.md before writing an app", not the size of the + * haystack. */ +const counted = (repo, phrase) => { + const n = countCatalogue(repo, ROOT); + return n === null ? phrase : `${n} ${phrase}`; +}; + const pages = sidebarPages(); const linked = new Set(pages.map((p) => p.link)); @@ -194,11 +212,11 @@ const index = [ '- [abap2UI5](https://github.com/abap2UI5/abap2UI5): the framework. `AGENTS.md`', ' is the briefing for changing it; `src/02/z2ui5_if_client.intf.abap` is the', ' complete API an app may call, with inline documentation', - '- [samples](https://github.com/abap2UI5/samples): 152 complete apps, one class', - ' each. [`SAMPLES.md`](https://github.com/abap2UI5/samples/blob/main/SAMPLES.md)', + `- [samples](https://github.com/abap2UI5/samples): ${counted('samples', 'complete apps, one')}`, + ' class each. [`SAMPLES.md`](https://github.com/abap2UI5/samples/blob/main/SAMPLES.md)', ' lists all of them with search terms - ask it "is there a sample for X?"', ' before writing one', - '- [samples-controls](https://github.com/abap2UI5/samples-controls): ~400 ports', + `- [samples-controls](https://github.com/abap2UI5/samples-controls): ${counted('samples-controls', 'ports')}`, ' of the official UI5 demo kit, plus `CAPABILITIES.md` - what abap2UI5 can and', ' cannot express', '- [samples-stack](https://github.com/abap2UI5/samples-stack): abap2UI5 combined', @@ -258,6 +276,13 @@ fs.writeFileSync(path.join(PUBLIC, 'llms-full.txt'), full); const kb = (s) => `${Math.round(s / 1024)} kB`; console.log(`llms.txt: ${pages.length} pages in ${bySection.size} sections (${kb(index.length)})`); +/* Say it out loud, because "no checkout" and "counted" produce different files + * and both are valid - the only way to notice a number went missing that + * should have been there is to be told which ones were taken. */ +for (const repo of ['samples', 'samples-controls']) { + const n = countCatalogue(repo, ROOT); + console.log(` ${repo}: ${n === null ? 'no catalogue here — published without a count' : `${n} apps`}`); +} console.log(`llms-full.txt: ${kb(full.length)}`); console.log(`${written} page(s) published as raw markdown under docs/public/`); if (orphans.length) { diff --git a/scripts/lib/catalogue.mjs b/scripts/lib/catalogue.mjs index 86e7a523..0c8f4b37 100644 --- a/scripts/lib/catalogue.mjs +++ b/scripts/lib/catalogue.mjs @@ -10,6 +10,8 @@ * /samples-stack - three repositories, none of them this one - and read here * and in abap2UI5/ai-mcp. It is a contract between five programs. */ +import fs from 'node:fs'; +import path from 'node:path'; /* One catalogue row, as generate-samples-md.js writes it: * @@ -35,6 +37,40 @@ * know about must cost it nothing. */ const ROW = /^\|\s*(?:\*\*(?[^*]+)\*\*\s*(?:—|--)\s*)?(?<sub>[^|<]*?)\s*(?<small>(?:<br>(?:<[a-z]+>[^<]*<\/[a-z]+>|[^<]*))*)\s*\|\s*\[`(?<cls>[A-Z0-9_]+)`\]\((?<path>[^)]+)\)\s*\|/; +/* Where each sample repository's catalogue is, if it is at hand at all. The + * `samples` row is the list link-samples.mjs has always resolved against, so a + * checkout that works for one works for the other; CI puts it in `.samples`. + * + * Nothing here is required. A count that cannot be taken is reported as `null` + * and the caller leaves the number out - the deploy workflow checks out no + * sample repository whatsoever, and a stale number in a file an agent cites is + * worse than no number at all. */ +const HOMES = { + samples: ['SAMPLES_HOME', '.samples', '../samples', '../abap2UI5-samples'], + 'samples-controls': ['SAMPLES_CONTROLS_HOME', '.samples-controls', '../samples-controls'], + 'samples-stack': ['SAMPLES_STACK_HOME', '.samples-stack', '../samples-stack'], +}; + +/** How many apps `repo` lists today, or null if its catalogue is not here. + * + * Counted through the same parser the sample links go through, so the number + * is "what this repository can resolve", never a second opinion: a catalogue + * lists supporting classes in tables of their own (samples-stack has eight), + * and those are not apps and do not parse into a pointer. */ +export function countCatalogue(repo, root) { + const dirs = HOMES[repo]; + if (!dirs) throw new Error(`no catalogue location known for ${repo}`); + for (const dir of dirs) { + const at = dir.endsWith('_HOME') ? process.env[dir] : path.join(root, dir); + if (!at) continue; + const file = path.join(at, 'SAMPLES.md'); + if (!fs.existsSync(file)) continue; + const size = parseCatalogue(fs.readFileSync(file, 'utf8')).size; + if (size > 0) return size; + } + return null; +} + /** class name (lower case) -> { label, path } for every app in the catalogue. */ export function parseCatalogue(text) { const byClass = new Map(); diff --git a/test/catalogue.test.mjs b/test/catalogue.test.mjs index 69448dfc..05228cfb 100644 --- a/test/catalogue.test.mjs +++ b/test/catalogue.test.mjs @@ -19,7 +19,10 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { parseCatalogue } from '../scripts/lib/catalogue.mjs'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parseCatalogue, countCatalogue } from '../scripts/lib/catalogue.mjs'; const ROWS = [ '## Basics', @@ -65,3 +68,23 @@ test('a page link survives a block this parser has never seen', () => { assert.ok(byClass.has('z2ui5_cl_smp_app_999'), 'the row with an unknown block was dropped'); assert.equal(byClass.get('z2ui5_cl_smp_app_999').path, 'src/01/z2ui5_cl_smp_app_999.clas.abap'); }); + +/* The corpus sizes in the generated llms.txt are counted rather than typed, + * and the deploy workflow may fail to reach a catalogue. Both outcomes are + * legitimate; what must never happen is a number that is not the count. */ +test('a corpus size is counted where the catalogue is, and absent where it is not', () => { + // an explicit checkout wins over the sibling directories, and a contributor + // who has one set would otherwise be told the wrong number by this test + delete process.env.SAMPLES_HOME; + delete process.env.SAMPLES_CONTROLS_HOME; + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui5-catalogue-')); + fs.mkdirSync(path.join(dir, '.samples')); + fs.writeFileSync(path.join(dir, '.samples', 'SAMPLES.md'), ROWS); + + assert.equal(countCatalogue('samples', dir), 5); + // no checkout at all - the caller leaves the number out rather than guessing + assert.equal(countCatalogue('samples-controls', dir), null); + + fs.rmSync(dir, { recursive: true, force: true }); +}); From 85623f1cb17c257242bf65e681482b3054c65035 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 17 Aug 2026 21:36:29 +0000 Subject: [PATCH 5/6] Give the onboarding path its three missing pages Getting Started ran Introduction -> Use Cases -> Quickstart -> Hello World -> Full Example -> Tooling -> What's Next, and Tooling was the first mention of the template, the linter, the extension and the MCP server. A reader who stopped once their first app ran - which is the point Quickstart ends at - met none of them. There was also no documented step between "I typed a class into my system", where Quickstart stops, and "I have an abapGit repository with gates", which is where app-template starts. Three pages: Your Project (/get_started/project_setup) closes that gap: what the template contains, the four commands to a first green check with no SAP system in the loop, `npm run rename` and the two decisions it deliberately leaves open. It sits between Hello World and Full Example, because that is where the reader's question changes from "how do I write this" to "where does it live" - after Full Example would repeat the same mistake one chapter later. abap2UI5-linter (/technical/tools/linter) is the reference the Tooling summary had no room for: the two gates, why a view nothing can see before runtime needs its own linter at all, --fix, and the baseline that makes adoption on a grown codebase possible. It goes under Technical Insight -> Tool with abapGit, abaplint and the rest - the only tool there this project wrote itself. Building with AI (/get_started/ai) states the priming problem plainly - a model asked for an abap2UI5 app writes the frozen builder, because that is what the public corpus shows - and then the corrections in rising order of effort: the two llms.txt and which maps what, AGENTS.md in the repository, the gates an agent can run itself, ai-mcp, and the extension's two MCP servers. Written for the person setting the assistant up, not for the assistant: the agent-facing copy stays in llms.txt, per this repository's AGENTS.md. Every object, command, flag, rule id and setting named was read out of app-template, linter, ai-mcp and vscode-extension. Quickstart, Tooling and What's Next now point at the three. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- docs/.vitepress/config.mjs | 13 ++++ docs/get_started/ai.md | 98 +++++++++++++++++++++++++++++++ docs/get_started/next.md | 2 +- docs/get_started/project_setup.md | 80 +++++++++++++++++++++++++ docs/get_started/quickstart.md | 6 ++ docs/get_started/tooling.md | 9 ++- docs/technical/tools/linter.md | 93 +++++++++++++++++++++++++++++ 7 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 docs/get_started/ai.md create mode 100644 docs/get_started/project_setup.md create mode 100644 docs/technical/tools/linter.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 7c7caffd..e1c5f577 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -176,10 +176,18 @@ export default defineConfig({ items: [ { text: "Installation", link: "/get_started/quickstart" }, { text: "Hello World", link: "/get_started/hello_world" }, + // Between the smallest app and the complete one, because that is + // where the reader's question changes from "how do I write this" + // to "where does it live". Quickstart ends with a class typed + // into a system and nothing said the next step out of it; a + // reader who stopped once the app ran met the template, the + // linter and the agent setup on no page at all. + { text: "Your Project", link: "/get_started/project_setup" }, { text: "Full Example", link: "/get_started/full_example" }, ], }, { text: "Tooling", link: "/get_started/tooling" }, + { text: "Building with AI", link: "/get_started/ai" }, { text: `What's Next?`, link: "/get_started/next" }, ], }, @@ -455,6 +463,11 @@ export default defineConfig({ text: "Tool", collapsed: true, items: [ + // The project's own linter, next to the tools it borrows. Every + // other gate in this section is somebody else's; this one is + // the only thing that can read a view that does not exist until + // the app runs. + { text: "abap2UI5-linter", link: "/technical/tools/linter" }, { text: "abapGit", link: "/technical/tools/abapgit" }, { text: "ajson", link: "/technical/tools/ajson" }, { text: "S-RTTI", link: "/technical/tools/srtti" }, diff --git a/docs/get_started/ai.md b/docs/get_started/ai.md new file mode 100644 index 00000000..c6389d5a --- /dev/null +++ b/docs/get_started/ai.md @@ -0,0 +1,98 @@ +--- +outline: [2, 4] +--- +# Building with AI + +An AI assistant writing abap2UI5 starts at a disadvantage nothing about your +project causes. Almost every abap2UI5 example on the public web builds its view +with `z2ui5_cl_xml_view`, the frozen predecessor of +`z2ui5_cl_ui5_view_builder` — so that is what a model writes when asked for an +app, confidently, and in an API that is no longer the one to use. + +Everything below is a way of telling it otherwise, in rising order of effort. + +## Point it at the right index + +Two files describe this project to a machine, and they answer different +questions: + +| | | +| --- | --- | +| [`abap2ui5.github.io/docs/llms.txt`](https://abap2ui5.github.io/docs/llms.txt) | the map of the **prose** — every chapter of this site with one line of what it covers, and [`llms-full.txt`](https://abap2ui5.github.io/docs/llms-full.txt) for all of it in one fetch | +| [`github.com/abap2UI5/abap2UI5/llms.txt`](https://github.com/abap2UI5/abap2UI5/blob/main/llms.txt) | the map of the **code** — the interface files to read instead of guessing at a signature, and the guide for building apps that ships with the framework | + +Both are short and both are free to give an assistant that has web access. It +is the cheapest correction available: an agent that has read either one does +not reach for the frozen builder. + +## Put the conventions in the repository + +An index tells an agent what abap2UI5 is. `AGENTS.md` tells it what *your +project* is — and it is read automatically, by every session, without anybody +remembering to paste anything. + +The [app-template](/get_started/project_setup) ships one written for +app-building: the class shape, the lifecycle, the view builder, binding, +events, and the gates to run before calling the work done. It also ships a +`.claude/settings.json` allowlist so an agent can run `npm run check` itself +instead of stopping to ask. + +## Give it the gates + +An agent that cannot check its own work will hand you an app that does not +render. The two gates of the template need no SAP system, which means an agent +can run them on its own: + +```sh +npm run check +``` + +The [abap2UI5-linter](/technical/tools/linter) half is the one that matters +here: it reconstructs the view from the builder chain and reports the names UI5 +does not have, the bindings that point at nothing — and a class still built on +the frozen builder. + +## Give it the loop + +[**abap2UI5/ai-mcp**](https://github.com/abap2UI5/ai-mcp) is an MCP server that +turns the checks into a development loop, still without a system. It works with +any MCP client — Claude Code, Cursor, VS Code: + +```sh +claude mcp add abap2ui5 -- node /path/to/ai-mcp/server.mjs +``` + +The tools an agent then has: + +| | | +| --- | --- | +| `examples` | search the three sample catalogues — *has somebody already built a value help, a tree, navigation between two apps?* Answers with a class to read, never with a snippet to trust | +| `capabilities` | whether abap2UI5 can express a UI5 feature at all, from the verified capability map | +| `validate_view` | the linter's gates, in seconds, against your project's own config | +| `deploy_app` | write the class into a local sandbox and compile it | +| `build_backend` / `run_app` | transpile the framework and the app to Node, boot it headless, and hand back the errors **and a screenshot** | +| `pitfalls` | the defects a green run still does not catch — abapGit import, activation, the oldest UI5 release | + +Set-up is levelled: validating views needs one small checkout and a minute; +the screenshot loop needs a browser and a first build measured in tens of +minutes. Stop where the value stops for you. + +## From the editor + +The [VS Code extension](/get_started/tooling#run-the-app-from-the-editor) +registers that same MCP server for every client in the window — Copilot agent +mode, Claude Code, anything else speaking MCP — so an agent working in your +editor has the loop without any separate configuration. Point +`abap2ui5.mcp.reposRoot` at the folder holding the checkouts and the extension +passes the paths through. + +It adds a second server of its own for the half ai-mcp deliberately does not +have: your configured **systems**. An agent can list them, search app classes +over ADT and get the app rendered on the real system as a screenshot — while +every credential prompt stays an ordinary VS Code dialog the agent never sees. + +## Next Steps + +- [Your Project](/get_started/project_setup) — the repository all of this + assumes +- [Tooling](/get_started/tooling) — the same four tools, for a human diff --git a/docs/get_started/next.md b/docs/get_started/next.md index 3be8ac94..ad29c8c7 100644 --- a/docs/get_started/next.md +++ b/docs/get_started/next.md @@ -19,7 +19,7 @@ The samples evolve all the time. Have one to share? Open a PR so others can lear ::: ## Tooling -Optional, and worth the ten minutes: a project [template](https://github.com/abap2UI5/app-template) with the checks already wired up, a [linter](https://github.com/abap2UI5/linter) that finds broken views without a system, an [extension](https://github.com/abap2UI5/vscode-extension) that runs your app on `F9` next to the code, and an [MCP server](https://github.com/abap2UI5/ai-mcp) that lets an AI agent build and *look at* the app. See [Tooling](/get_started/tooling). +Optional, and worth the ten minutes: a project [template](/get_started/project_setup) with the checks already wired up, a [linter](/technical/tools/linter) that finds broken views without a system, an [extension](https://github.com/abap2UI5/vscode-extension) that runs your app on `F9` next to the code, and an [MCP server](https://github.com/abap2UI5/ai-mcp) that lets an AI agent build and *look at* the app. See [Tooling](/get_started/tooling), and [Building with AI](/get_started/ai) if an assistant writes some of it. ## Development Build views, handle events, share data, and work with tables. The [Cookbook](/cookbook/overview) walks through the patterns you need for everyday work — start with the [Life Cycle](/cookbook/event_navigation/life_cycle) page. diff --git a/docs/get_started/project_setup.md b/docs/get_started/project_setup.md new file mode 100644 index 00000000..a785d95f --- /dev/null +++ b/docs/get_started/project_setup.md @@ -0,0 +1,80 @@ +--- +outline: [2, 4] +--- +# Your Project + +The class you just built lives in your system and nowhere else. That is fine +for a first app and not fine for a second one: nothing versions it, nobody +reviews it, and the mistakes an abap2UI5 app makes — a control that does not +exist on the release your users are on, a binding pointing at nothing — are +invisible to the ABAP compiler and show up as a blank screen. + +A real project is the same class in a git repository that abapGit pulls into +the system, with the checks running before it gets there. +[**abap2UI5/app-template**](https://github.com/abap2UI5/app-template) is that +repository, already assembled. + +## What it gives you + +| | | +| --- | --- | +| `src/zcl_app_001` | a working app — an input, a bound table, an event — in the canonical shape its `AGENTS.md` describes | +| `abaplint.jsonc` | ABAP syntax and style, with the framework resolved as a dependency, so it compiles the app **without an SAP system** | +| `abap2ui5lint.jsonc` | the [abap2UI5-linter](/technical/tools/linter) — the view your ABAP builds, judged against the UI5 API and against your own class | +| `.github/workflows/check.yml` | both gates on every push and pull request, at the versions `package-lock.json` pins — so CI and your machine run the same thing | +| `AGENTS.md` | the conventions an AI assistant should follow in this project, plus a `.claude/settings.json` allowlist so it can run the gates without asking | + +## From template to first green check + +Press **Use this template** on GitHub, then: + +```sh +git clone <your new repository> +cd <your new repository> +npm ci +npm run check +``` + +That is the first green check, and no SAP system was involved. Two more +commands are worth knowing: + +```sh +npm run check:abap2ui5:fast # skip the browser half — seconds, not a minute +npm run fix # apply the corrections the linter can make itself +``` + +The full check loads every view in a headless browser, which needs Chromium +once: `npx playwright install chromium`. + +## Make it yours + +Fresh from the template the repository is still called *app-template*, the +ABAP package still says *abap2UI5 app*, and the app is still `ZCL_APP_001`. +One command changes all three: + +```sh +npm run rename -- --class zcl_my_app --package "My App" --repo my-app +``` + +Add `--dry` first to see what it would touch. It renames the class in the ABAP +**and** the `CLSNAME` in its `.clas.xml` sidecar — changing only one gives you +an object abapGit imports under one name and ABAP activates under another. + +Two things it deliberately leaves to you: the **namespace** (`abaplint.jsonc` +requires `^ZCL_` or `^ZCX_`; change `object_naming` there if you develop behind +a company prefix or a registered namespace), and the **LICENSE**, which still +names abap2UI5 as the copyright holder. + +## Then into the system + +Install the [framework](/get_started/quickstart) and then your own repository, +both with [abapGit](https://abapgit.org). The starter app needs framework +**1.143.0** or newer — that is the release the gates lint against. Open +`<your endpoint>?app_start=zcl_my_app` and you are looking at the same app the +checks just passed. + +## Next Steps + +- [Tooling](/get_started/tooling) — the editor and agent side of the same loop +- [abap2UI5-linter](/technical/tools/linter) — what the view gate actually + checks, and how to adopt it on a codebase that already exists diff --git a/docs/get_started/quickstart.md b/docs/get_started/quickstart.md index 8d19bacb..e91cbcb2 100644 --- a/docs/get_started/quickstart.md +++ b/docs/get_started/quickstart.md @@ -84,3 +84,9 @@ Back on the startup page, enter your class name `ZCL_MY_APP` in the input field ::: tip **Naming** Name your own apps in your customer namespace (`Z...`/`Y...`). The `Z2UI5_` prefix is reserved for the framework and its samples. ::: + +## Next Steps + +[Hello World](/get_started/hello_world) explains what that class actually did. +Once it is more than one class, [Your Project](/get_started/project_setup) is +where the source moves into a git repository with the checks in front of it. diff --git a/docs/get_started/tooling.md b/docs/get_started/tooling.md index 80474fb9..9215350b 100644 --- a/docs/get_started/tooling.md +++ b/docs/get_started/tooling.md @@ -26,6 +26,9 @@ half a year later that they were never running. Use this template → clone → npm ci → npm run check ``` +[Your Project](/get_started/project_setup) walks through it — what is in the +repository, the rename that makes it yours, and the way into your system. + ## Check the view without a system [**abap2UI5/linter**](https://github.com/abap2UI5/linter) — the view your app @@ -46,7 +49,9 @@ npx @abap2ui5/linter src No SAP system, no install beyond npm. It also ships as a GitHub Action, and the [app-template](https://github.com/abap2UI5/app-template) has it wired into -CI already. +CI already. The [linter page](/technical/tools/linter) has the rest: the two +gates, `--fix`, and the baseline for switching it on over a codebase that +already exists. ## Run the app from the editor @@ -84,7 +89,7 @@ should read instead of guessing at a signature. The [app-template](https://github.com/abap2UI5/app-template) then ships an `AGENTS.md` that states the conventions an agent should follow in your own -project. +project. [Building with AI](/get_started/ai) puts the whole setup in order. ::: ## Where the samples live diff --git a/docs/technical/tools/linter.md b/docs/technical/tools/linter.md new file mode 100644 index 00000000..0c48573b --- /dev/null +++ b/docs/technical/tools/linter.md @@ -0,0 +1,93 @@ +--- +outline: [2, 4] +--- +# abap2UI5-linter + +The view an abap2UI5 app shows does not exist until the app runs. It is built +by a chain of `z2ui5_cl_ui5_view_builder` calls and handed to the browser as a +string, so the ABAP compiler never sees a control name, and no UI5 tooling ever +sees the class. That gap is what +[`@abap2ui5/linter`](https://github.com/abap2UI5/linter) closes: it +reconstructs the view from the builder chain and judges the class and the view +**together**. + +```sh +npx abap2ui5lint src +``` + +No SAP system, no install, no configuration. The package is small and pulls in +nothing else, so that line is a fast one. + +## What it checks + +Two gates. + +**The property gate** resolves everything the view writes against a UI5 +metadata snapshot generated from the OpenUI5 sources: controls, properties, +aggregations, enum values, icons. It reports a name UI5 does not have, and — the +part that matters on a real landscape — a name UI5 does not have **yet** on the +release you target. The floor is `1.71` by default, which is what most systems +serve. + +**The render gate** then loads every view with a real `XMLView.create` in +headless Chromium, with UI5 future mode on. That is the only way to catch a +view that does not merely render wrongly but fails to load at all. It needs a +UI5 runtime, which ships as a separate package: + +```sh +npm install -D @abap2ui5/render-runtime +npx playwright install chromium +``` + +Without it the property gate still runs in full — `--no-render` says so +explicitly. In CI, write `--render` instead, so a missing runtime is an error +rather than a silently skipped gate. + +On top of the UI5 rules sit the abap2UI5-specific ones, which are the reason +this exists rather than a UI5 linter: the defects that live *between* the class +and its view and stay silent at runtime. A binding path the model has no field +for. A `PROTECTED` attribute bound, which only `PUBLIC` attributes survive. An +ABAP boolean written into the view raw, where UI5 reads `'X'` and `' '` both as +strings. A `check_on_navigated( )` branch that never re-displays. And +`frozen-view-builder` — a class still built with `z2ui5_cl_xml_view`, the +frozen predecessor, which is what most public abap2UI5 material still shows. + +Every finding carries a severity (`error`, `warning`, `hint`), a message, the +line and column, and a **rule id**. The id is the key everywhere else: in the +`rules` block of the config, in a source directive that waives it, and as the +anchor of its page in the rule reference at +[abap2ui5.github.io/linter](https://abap2ui5.github.io/linter/). + +## Fixing and adopting + +Some rules carry an exact correction and are rewritten in place: + +```sh +abap2ui5lint src --fix # or --fix-dry-run to see it first +``` + +Only corrections that need no guessing — an obsolete call renamed, a missing +`$` in an event argument, a missing `xmlns:` declaration. Anything that would +have to choose between two plausible outcomes is reported instead. + +Switching a linter on over a codebase that already exists reports everything at +once, which is the moment most adoptions stop. The **baseline** freezes that +debt instead of hiding it: + +```sh +npx abap2ui5lint src --update-baseline +``` + +Commit the resulting `abap2ui5lint-baseline.json` and name it in the config. +The frozen findings are then counted but not listed, **new** findings fail +normally, and an entry whose finding is gone fails too — so the baseline only +ever shrinks. + +## Where it runs + +The CLI, a GitHub Action (`abap2UI5/linter@v0`), a library, inside the +[VS Code extension](/get_started/tooling#run-the-app-from-the-editor) as +diagnostics while you type, and as the `validate_view` tool of the +[MCP server](/get_started/ai). The +[app-template](/get_started/project_setup) has the CLI and the workflow wired +up already. From 0ec4e749e629d20f5fa300e73c92b726aa844455 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 17 Aug 2026 21:41:23 +0000 Subject: [PATCH 6/6] Hold the catalogue page's four figures to the catalogues resources/samples.md is the page that answers "which of the three sample repositories do I search?", and it opens with a total - "615 working apps, in three repositories" - over a table giving one count per repository. Every one of those is typed by hand and describes a repository whose CI cannot see this page. Two had drifted: samples-controls stood at 431 against a published 430, and the total at 615 against 614. Verified against raw.githubusercontent.com/<repo>/main, not against the local checkouts, because that is what the page's own links point at. generate-llms.mjs answers this by counting and leaving the number out when the catalogue is absent. A prose page cannot do that - the sentence is written, not generated - so the figure stays and check:counts holds it, through the same countCatalogue( ) the generated file uses. Nothing here gets a second opinion about what a sample is. Skipping is per repository: with only `samples` at hand it checks that one, names the two it could not read, and leaves the total alone, since a total needs all three. CI now sparse-checks out SAMPLES.md from samples-controls and samples-stack for the full check - both continue-on-error, so an unreachable repository costs a figure and never the run. check:counts 4 claims, 4 checked - OK npm run check six steps, all green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- .github/workflows/check.yml | 31 +++++++++ AGENTS.md | 11 ++- README.md | 7 +- docs/resources/samples.md | 4 +- package.json | 5 +- scripts/check-corpus-counts.mjs | 120 ++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 scripts/check-corpus-counts.mjs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 80fde645..2eda7382 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -35,6 +35,29 @@ jobs: path: .samples fetch-depth: 1 + # The other two catalogues, for the counts on resources/samples.md. Only + # SAMPLES.md is needed, so only SAMPLES.md is fetched — and an unreachable + # repository costs one verified figure, never the run: check:counts skips + # what it cannot read and says which ones those were. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + continue-on-error: true + with: + repository: abap2UI5/samples-controls + ref: main + path: .samples-controls + fetch-depth: 1 + sparse-checkout: SAMPLES.md + sparse-checkout-cone-mode: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + continue-on-error: true + with: + repository: abap2UI5/samples-stack + ref: main + path: .samples-stack + fetch-depth: 1 + sparse-checkout: SAMPLES.md + sparse-checkout-cone-mode: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' @@ -77,3 +100,11 @@ jobs: - name: sample links if: ${{ !cancelled() }} run: npm run check:samples + + # the four figures on resources/samples.md - three per-repository counts + # and the total they add up to - against the catalogues themselves. The + # generated llms.txt leaves a number out when it cannot count it; a prose + # page cannot, so the number stays and this is what holds it + - name: corpus counts + if: ${{ !cancelled() }} + run: npm run check:counts diff --git a/AGENTS.md b/AGENTS.md index d118e182..8f2b0b68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,10 +23,10 @@ person reads the page. Do not put "as an AI, …" prose back into `docs/`. ## Build & verify — run before every commit ```bash -npm run check # test + check:version + docs:build + check:examples + check:samples +npm run check # test + check:version + docs:build + check:examples + check:samples + check:counts ``` -`.github/workflows/check.yml` runs the same five, in the same order. Keep the +`.github/workflows/check.yml` runs the same six, in the same order. Keep the two in step: a step that exists only in `package.json` is a step no pull request has to pass, which is how `npm test` — the pin added *because* the catalogue parser broke twice in silence — went a release without CI. @@ -36,6 +36,13 @@ clone it as a sibling. Without one it *skips* rather than fails, so verify the output says what you think it says. CI checks out `abap2UI5/samples@main` explicitly for this reason. +`check:counts` reads all three catalogues the same way, and skips per +repository: with only `samples` at hand it verifies that one figure, says the +other two were not verified, and leaves the total alone (it needs all three). +CI sparse-checks out `SAMPLES.md` from `samples-controls` and `samples-stack` +so the page is fully checked; both are `continue-on-error`, because an +unreachable repository must cost a figure and not the run. + ## Things that will trip you up - **The nav bar and the sidebar contain byte-identical lines.** diff --git a/README.md b/README.md index 92f41086..dfe2083f 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ Every contribution makes the documentation better for the community! ```sh npm ci npm run docs:dev # the site, with hot reload -npm run check # what CI runs, all five steps +npm run check # what CI runs, all six steps ``` ### What CI checks -A documentation repository has no compiler for its prose, but five things in -it are decidable, and all five are decided before a merge — `npm run check` +A documentation repository has no compiler for its prose, but six things in +it are decidable, and all six are decided before a merge — `npm run check` and `.github/workflows/check.yml` run the same list, in the same order: | | | @@ -31,6 +31,7 @@ and `.github/workflows/check.yml` run the same list, in the same order: | `docs:build` | a page that does not build is a page nobody can read | | `check:examples` | the ABAP in the fenced blocks, against the real framework: does it compile, and does the view it builds name controls and properties that exist on the UI5 floor this documentation targets | | `check:samples` | the **Working Samples** blocks, against [abap2UI5/samples](https://github.com/abap2UI5/samples) | +| `check:counts` | the four figures on `resources/samples.md` — one count per sample repository and the total they add up to — against the catalogues themselves | ### What the site publishes for machines diff --git a/docs/resources/samples.md b/docs/resources/samples.md index 40ab2356..478da5b4 100644 --- a/docs/resources/samples.md +++ b/docs/resources/samples.md @@ -3,7 +3,7 @@ outline: [2, 4] --- # Sample Catalogues -**615 working apps, in three repositories.** Every one is a single ABAP class +**614 working apps, in three repositories.** Every one is a single ABAP class that compiles, renders, and is downported to three releases — so a sample is never a fragment you have to trust, it is an app you can pull and run. @@ -13,7 +13,7 @@ time than reading this page. | | | you are asking | |---|--:|---| | [**samples**](https://github.com/abap2UI5/samples/blob/main/SAMPLES.md) | 152 | *has somebody already built this pattern?* — value help, navigation between apps, trees, tables, timers, file up- and download. Runs on a bare abap2UI5 install. | -| [**samples-controls**](https://github.com/abap2UI5/samples-controls/blob/main/SAMPLES.md) | 431 | *how is this UI5 control expressed in ABAP?* — the UI5 demo kit, rebuilt control by control, grouped by library. | +| [**samples-controls**](https://github.com/abap2UI5/samples-controls/blob/main/SAMPLES.md) | 430 | *how is this UI5 control expressed in ABAP?* — the UI5 demo kit, rebuilt control by control, grouped by library. | | [**samples-stack**](https://github.com/abap2UI5/samples-stack/blob/main/SAMPLES.md) | 32 | *how do I reach my system from an app?* — OData, RAP, APC, MIME, the Fiori Launchpad. Each needs something the framework alone does not give you. | Start with **samples** if you are learning abap2UI5, with **samples-controls** diff --git a/package.json b/package.json index 86777b6c..b62ba3de 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,10 @@ "link:samples": "node scripts/link-samples.mjs", "check:samples": "node scripts/link-samples.mjs --check", "test": "node --test test/*.test.mjs", - "check": "npm run test && npm run check:version && npm run docs:build && npm run check:examples && npm run check:samples", + "check": "npm run test && npm run check:version && npm run docs:build && npm run check:examples && npm run check:samples && npm run check:counts", "llms": "node scripts/generate-llms.mjs", - "check:version": "node scripts/check-version.mjs" + "check:version": "node scripts/check-version.mjs", + "check:counts": "node scripts/check-corpus-counts.mjs" }, "devDependencies": { "@abap2ui5/linter": "^0.2.1", diff --git a/scripts/check-corpus-counts.mjs b/scripts/check-corpus-counts.mjs new file mode 100644 index 00000000..68a29aca --- /dev/null +++ b/scripts/check-corpus-counts.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/* + * check-corpus-counts — the sample-catalogue page states four numbers, and + * this is what holds them to the catalogues. + * + * `resources/samples.md` is the page that answers "which of the three sample + * repositories do I search?", and it opens with a total: "614 working apps, in + * three repositories", then one count per repository in the table below. Every + * one of those is typed by hand, and every one of them describes a repository + * whose CI cannot see this page. + * + * They had drifted - the page said 431 for samples-controls and 615 in total + * while the published catalogue said 430 and the three summed to 614. Small, + * and exactly the kind of small that a reader has no way to detect: a figure + * on the page that introduces the catalogues is taken on trust. + * + * generate-llms.mjs solved the same problem for the generated file by counting + * instead of typing, and leaving the number out when the catalogue is absent. + * A prose page cannot do that - the sentence is written, not generated - so + * the number stays and this checks it. + * + * Counted through `countCatalogue`, the same parser link-samples.mjs resolves + * against, so the answer is always "what this repository can resolve" and + * never a second opinion. + * + * A catalogue that is not checked out is NOT a failure: the count is skipped + * and the run says which ones it could not take. The total needs all three, so + * it is skipped unless all three are there. A check must not go red because a + * sibling repository is absent, and must not claim to have verified something + * it did not. + * + * node scripts/check-corpus-counts.mjs (npm run check:counts) + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { countCatalogue } from './lib/catalogue.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PAGE = 'docs/resources/samples.md'; + +/* Each claim names the repository it is about and the pattern that carries the + * number. Declared one by one rather than scanned, so that this file has no + * opinion about "1.71" or any other figure on the page, and so that adding a + * count to the page is a decision somebody makes here. */ +const CLAIMS = [ + { repo: 'samples', find: /\/samples\/blob\/main\/SAMPLES\.md\)\s*\|\s*(\d+)\s*\|/ }, + { repo: 'samples-controls', find: /\/samples-controls\/blob\/main\/SAMPLES\.md\)\s*\|\s*(\d+)\s*\|/ }, + { repo: 'samples-stack', find: /\/samples-stack\/blob\/main\/SAMPLES\.md\)\s*\|\s*(\d+)\s*\|/ }, +]; + +const TOTAL = { find: /\*\*(\d+) working apps, in three repositories\.\*\*/ }; + +const file = path.join(ROOT, PAGE); +if (!fs.existsSync(file)) { + console.error(`${PAGE} is not here — this check names it directly`); + process.exit(1); +} +const text = fs.readFileSync(file, 'utf8'); + +const problems = []; +const notes = []; +let checked = 0; + +const truth = {}; +for (const claim of CLAIMS) { + const found = text.match(claim.find); + if (!found) { + /* The table was restructured. Failing is right: silently no longer + * checking a number that is still on the page is the outcome to avoid. */ + problems.push( + `${PAGE}: no match for ${claim.find} (${claim.repo})\n` + + ' the row carrying this count changed shape — update the pattern here,' + + ' or drop the claim if the number is gone', + ); + continue; + } + + const actual = countCatalogue(claim.repo, ROOT); + if (actual === null) { + notes.push(`${claim.repo}: catalogue not here — ${found[1]} not verified`); + continue; + } + + truth[claim.repo] = actual; + checked += 1; + if (Number(found[1]) !== actual) { + problems.push(`${PAGE}: says ${found[1]} for ${claim.repo}, its catalogue lists ${actual}`); + } +} + +const totalFound = text.match(TOTAL.find); +if (!totalFound) { + problems.push( + `${PAGE}: no match for ${TOTAL.find}\n` + + ' the opening sentence changed shape — update the pattern here', + ); +} else if (Object.keys(truth).length === CLAIMS.length) { + const sum = Object.values(truth).reduce((a, b) => a + b, 0); + checked += 1; + if (Number(totalFound[1]) !== sum) { + problems.push(`${PAGE}: opens with ${totalFound[1]} apps in total, the three catalogues list ${sum}`); + } +} else { + notes.push(`total: not verified — it needs all three catalogues`); +} + +console.log(`check-corpus-counts: ${CLAIMS.length + 1} claim(s) on ${PAGE}, ${checked} checked`); +for (const n of notes) console.log(` ${n}`); + +if (problems.length) { + console.error(`\n${problems.length} problem(s):`); + for (const p of problems) console.error(` ${p}`); + process.exit(1); +} +if (!checked) { + console.log('no catalogue reachable — not a failure, but nothing was verified'); +} else { + console.log('every count matches its catalogue - OK'); +}