diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 36de7817..2eda7382 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -35,18 +35,50 @@ 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' 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 @@ -68,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/.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/AGENTS.md b/AGENTS.md index b3b79459..8f2b0b68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,18 +18,31 @@ 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 + check:counts ``` +`.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. + `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` 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.** @@ -39,9 +52,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..dfe2083f 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,23 @@ 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 six 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 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: | | | |---|---| +| `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) | +| `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/.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/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/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 +cd +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 +`?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 418286ff..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 @@ -73,11 +78,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. [Building with AI](/get_started/ai) puts the whole setup in order. ::: ## Where the samples live 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/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. 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'); +} 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; 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 }); +});