From f06a8bca9fc628704bffa5ae7bc9be09150f9a57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:37:50 +0000 Subject: [PATCH 1/3] Name app-rules.json as the rule set's source, drop a count that will drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two false statements, both about where a fact actually lives. §5 said the abaplint rule block is "byte-identical in three repositories" and "change it here and copy it to the other two". That peer model was abandoned: abap2UI5/abap2UI5's .github/abaplint/app-rules.json is the single source, this block is a checked copy, and scripts/check-app-rules.mjs - already the last step of npm run check here, plus the check-app-rules workflow - compares the two as parsed settings. The reasons are in the script's own header: three peers have no answer to which of them is right, a repository without its own copy of the checker turned the OTHER repositories' CI red when it drifted, and the peer checker compared rule names only, so switching a rule to false read to it as no change at all. Neither this file nor the README mentioned app-rules.json anywhere, so the file the rules live in could not be found from the documentation. The per-repository exception (object_naming with the SMPS token, and the RAP scoped excludes below) is unchanged, now stated as the one rule the checker skips. §4's npm run check comment listed six steps and the run has seven - check:app-rules was missing. README said the overview app "keeps listing all 31 samples". 31 is currently right for what the overview lists (check-overview reports 31 entries; there are 32 app classes because the overview app is not in its own catalogue), so this is not the drift it looks like - but it is a hand-maintained number in prose that goes stale the next time a sample lands, and the sentence is about the branches, not about the count. It now says "every sample in the repository". §6's "are in all 32 apps" about check_on_init / view_display / _bind was measured and is wrong - 24, 32 and 26 of 32 - so it says "nearly every app" instead; the point was always that scaffolding terms separate nothing. npm run check green: abaplint 0 issues, abap2UI5-linter 32 files 0 failing, check-overview 31 entries, check-keywords 32 apps, abapdoc, SAMPLES.md up to date, check-app-rules 187/187 against the source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- AGENTS.md | 46 +++++++++++++++++++++++++++++++--------------- README.md | 6 +++--- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8805da9..c1a0f5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ What that costs you when you edit: ```sh npm ci -npm run check # abaplint + abap2UI5-linter + overview + keywords + abapdoc + SAMPLES.md +npm run check # abaplint + abap2UI5-linter + overview + keywords + abapdoc + SAMPLES.md + app-rules ``` Individually: `npm run lint` (abaplint), `npm run check:abap2ui5` (the app @@ -91,25 +91,41 @@ where a statement ends, so run `npm run lint` first if a chain is mid-edit. ## 5. Conventions that are checked here -**The rule block in `abaplint.jsonc` is byte-identical in three -repositories** — this one, [samples](https://github.com/abap2UI5/samples) and -[samples-controls](https://github.com/abap2UI5/samples-controls) — the same way -`scripts/chain-format.mjs` is shared between the other two. abaplint has no -`extends`, so the copy is the mechanism, and the block carries a header saying -so. **Change it here and copy it to the other two**, then re-run their gates: -what is checked is a joint decision of the three corpora, not a local -preference. +**The rule block below the marker in `abaplint.jsonc` is a CHECKED COPY of the +shared app rule set, and its source is +[abap2UI5/abap2UI5](https://github.com/abap2UI5/abap2UI5) +`.github/abaplint/app-rules.json`** — the repository where the rest of "how to +write an abap2UI5 app" already lives (the `build-an-app` and +`view-chain-layout` skills, `docs/agents/building-apps.md`, `abap-check`, +`ui5-check`), because a shared thing needs one owner. **Change it THERE first, +then copy it here**; this one, [samples](https://github.com/abap2UI5/samples) +and [samples-controls](https://github.com/abap2UI5/samples-controls) are +consumers of that file, not peers of each other. abaplint has no `extends`, so +the checked copy is the mechanism, and the block carries a header saying so. + +**The gate is `scripts/check-app-rules.mjs`** — `npm run check:app-rules`, the +last step of `npm run check`, and the `check-app-rules` workflow on every pull +request and push to `main`. It compares PARSED SETTINGS against the source, +preferring an `abap2UI5` checkout next to this one and otherwise fetching it, +and it is the one check here that needs the network: an unreachable source +SAYS SO and passes, rather than turning this repository red because github.com +is. It replaced a three-way peer comparison, which had no answer to which of +three peers is right, went red in the *other* repositories when one drifted, +and compared rule NAMES only — so flipping a rule to `false` to get a pull +request through, the exact drift it existed to catch, read to it as no change +at all. abap2UI5 checks the same thing from its side (`shared-file-gate.mjs`). Only `global`, `dependencies` and `syntax` are per repository (this one runs at `v757` against the full steampunk API, and silences the RAP event handler abaplint cannot parse) — plus exactly **one** rule: `object_naming`, which -carries the `SMPS` token. It sits last in the file behind a marker that says -so; everything above that marker must stay identical. +carries the `SMPS` token and is the only rule `check-app-rules` excludes from +the comparison. It sits last in the file behind a marker that says so; +everything above that marker must match the source. All 188 rules abaplint ships are named: 171 on, 17 off, each with its reason in a comment. **A rule is never left out of the file** — when an upgrade adds -one, add the key in all three: on if all three corpora pass, off with the -reason if they do not. +one, add the key to `app-rules.json` and copy the block into all three +consumers: on if all three corpora pass, off with the reason if they do not. RAP is what makes this corpus different from the other two, and the shared block carries **scoped excludes** for it rather than turning rules off for @@ -193,8 +209,8 @@ gone. synonyms (`flp` for launchpad, `eml` for the RAP entity API), the controls the sample actually builds (`smartfilterbar`, `feedlistitem`) and the abap2UI5 API it demonstrates (`set_session_stateful`, `nav_app_call`). Leave - out the scaffolding — `check_on_init`, `view_display` and `_bind` are in all - 32 apps and therefore separate none of them. + out the scaffolding — `check_on_init`, `view_display` and `_bind` run through + nearly every app here and therefore separate none of them. Why it is gated: nothing about a missing line is broken. The app compiles, runs, and appears in the overview. The only symptom is that nobody looking diff --git a/README.md b/README.md index 6ec8e2b..7bb3832 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,9 @@ overview app and nothing else: | [`08-mime`](../../tree/08-mime) | [`src/08`](src/08) — MIME Play Audio | Standard only, ≥ 7.50 | | [`09-launchpad`](../../tree/09-launchpad) | [`src/09`](src/09) — Launchpad | Cloud + Standard ≥ 7.40 SP08 | -The overview app ships on every branch and keeps listing **all 31 samples**, so it -stays the catalogue of what the other branches hold — the ones that are not on -your branch simply show up with a disabled Open button. +The overview app ships on every branch and keeps listing **every sample in the +repository**, so it stays the catalogue of what the other branches hold — the +ones that are not on your branch simply show up with a disabled Open button. These branches are **generated**: `create-package-branches` rebuilds and force-pushes every one of them on every push to `main`, and abaplint checks each From 009f9fcc8f294a1e9ce13b7587cd385b8484592e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 22:15:40 +0000 Subject: [PATCH 2/3] Point the contribution link at a page that exists Same dead link as samples: abap2UI5-documentation is now abap2UI5/docs, and the CONTRIBUTING.md it named does not exist there. The guidelines live on the rendered contribution page. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2a44c1..6ffb707 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ _This project is open source and developed alongside other projects or during free time. Contributions are greatly appreciated!_ -Check out the contribution guidelines [here.](https://github.com/abap2UI5/abap2UI5-documentation/blob/main/CONTRIBUTING.md) +Check out the contribution guidelines [here.](https://abap2ui5.github.io/docs/resources/contribution.html) ## Working in this repository From f18075a99fe69f8570d2c827ad4cb2672dea4d37 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 04:43:01 +0000 Subject: [PATCH 3/3] Point the learning path at the overview app that exists, and check it from now on The learning-path table told every reader to run `z2ui5_cl_dmo_app_overview` for samples-controls. That class does not exist: samples-controls renamed its overview to `z2ui5_cl_smpc_app_000`, its own README followed, and this copy of the shared table did not. `samples` carried the identical stale row. The table is duplicated in three repositories on purpose - each one marks itself "you are here" - and nothing read the other two, so a rename over there could not fail anything here. scripts/check-prose-names.mjs, byte-identical in all three, closes that: a foreign sample name resolves through the owning repository's generated SAMPLES.md, a framework name through the abap2UI5 sources, and an unreachable repository is SAID rather than passed silently. Verified by putting the stale name back: the check reports it and exits 1. check:prose 6 names checked, OK npm run check green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XY3AoWMiCC52cuQjbce4SU --- .github/workflows/check-prose-names.yaml | 44 +++++ README.md | 2 +- package.json | 5 +- scripts/check-prose-names.mjs | 226 +++++++++++++++++++++++ scripts/prose-absent.json | 1 + 5 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/check-prose-names.yaml create mode 100644 scripts/check-prose-names.mjs create mode 100644 scripts/prose-absent.json diff --git a/.github/workflows/check-prose-names.yaml b/.github/workflows/check-prose-names.yaml new file mode 100644 index 0000000..5735fbc --- /dev/null +++ b/.github/workflows/check-prose-names.yaml @@ -0,0 +1,44 @@ +name: check-prose-names + +# A class name written in prose is a class that exists - including when it +# lives in a sibling repository. The three sample READMEs name each other's +# overview apps, and when one of them was renamed the other two went on +# pointing at a class that no longer exists, because nothing here reads that +# repository. The script is byte-identical in samples, samples-controls and +# samples-stack; only scripts/prose-absent.json differs. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: check-prose-names-${{ github.ref }} + cancel-in-progress: true + +jobs: + prose_names: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: abap2UI5/abap2UI5 + ref: main + path: .abap2UI5 + fetch-depth: 1 + sparse-checkout: src + sparse-checkout-cone-mode: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + # A runner cannot check a repository out above the workspace, so the + # framework lands inside it and the variable says where - the same + # env-over-sibling resolution abap2UI5/ai-mcp uses. + - run: node scripts/check-prose-names.mjs + env: + A2UI5_HOME: ${{ github.workspace }}/.abap2UI5 diff --git a/README.md b/README.md index 7bb3832..fd76d9d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ earlier: | | Repository | What you learn | Where to start | |------|------------|----------------|----------------| | 1️⃣ | [**samples**](https://github.com/abap2UI5/samples) | **the abap2UI5 basics** — bindings, events, popups, navigation, complete apps | run `Z2UI5_CL_SMP_APP_000` | -| 2️⃣ | [**samples-controls**](https://github.com/abap2UI5/samples-controls) | **how to use every UI5 control** — the UI5 Demo Kit rebuilt with abap2UI5 | run `z2ui5_cl_dmo_app_overview` | +| 2️⃣ | [**samples-controls**](https://github.com/abap2UI5/samples-controls) | **how to use every UI5 control** — the UI5 Demo Kit rebuilt with abap2UI5 | run `z2ui5_cl_smpc_app_000` | | 3️⃣ | **samples-stack** — 📍 *you are here* | **how abap2UI5 plays with your stack** — OData, RAP, WebSockets, the Fiori Launchpad and more | pick your technology in the table above | ### Reading the *Runs on* column diff --git a/package.json b/package.json index e026832..1b65e00 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,13 @@ "check:abap2ui5": "abap2ui5lint", "fmt:chains": "abap2ui5lint --fix", "check:overview": "node .github/scripts/check-overview.mjs", - "check": "npm run lint && npm run check:abap2ui5 && npm run check:overview && npm run check:keywords && npm run check:abapdoc && npm run check:samples-md && npm run check:app-rules", + "check": "npm run lint && npm run check:abap2ui5 && npm run check:overview && npm run check:keywords && npm run check:abapdoc && npm run check:samples-md && npm run check:app-rules && npm run check:prose", "check:keywords": "node .github/scripts/check-keywords.mjs", "check:abapdoc": "node .github/scripts/check-abapdoc.mjs", "samples:md": "node .github/scripts/generate-samples-md.mjs", "check:samples-md": "node .github/scripts/generate-samples-md.mjs --check", - "check:app-rules": "node scripts/check-app-rules.mjs" + "check:app-rules": "node scripts/check-app-rules.mjs", + "check:prose": "node scripts/check-prose-names.mjs" }, "repository": { "type": "git", diff --git a/scripts/check-prose-names.mjs b/scripts/check-prose-names.mjs new file mode 100644 index 0000000..22814c0 --- /dev/null +++ b/scripts/check-prose-names.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +/* + * check-prose-names — an app class named in prose here is an app class that + * exists, including when it lives in a sibling repository. + * + * The three sample READMEs carry the same learning-path table, and two of its + * three rows name the OTHER repositories' overview apps. When samples-controls + * renamed its overview to z2ui5_cl_smpc_app_000, its own README followed and + * the two copies did not: `samples` and `samples-stack` went on telling every + * reader to run `z2ui5_cl_dmo_app_overview`, a class that no longer exists + * anywhere. Nothing failed, because nothing here reads the other repository. + * + * abap2UI5's prose-name-gate is the same idea one repository over, and it + * stops exactly where this has to keep going: a name it does not own goes on + * an EXTERNAL allowlist, which exempts it rather than checking it. The names + * that go stale are precisely the foreign ones — a local rename breaks a local + * gate, a foreign rename breaks nothing. + * + * So a foreign name is resolved against the repository that owns it, through + * its generated SAMPLES.md — the catalogue that lists every class it ships, + * and the same page a reader would look the name up in. + * + * Resolution, in order (shared-file-gate's, including the part that matters): + * a sibling CHECKOUT next to this one (offline, and what a local run sees) + * raw.githubusercontent.com//main (CI, and what is actually published) + * When neither is reachable the run SAYS SO and passes: a gate must not go red + * because github.com is unreachable, and must not claim to have verified + * something it did not. + * + * node scripts/check-prose-names.mjs + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/* Which repository owns which class prefix. The prefix IS the owner here — + * that is what the namespace tokens were chosen for. */ +const OWNER = [ + { re: /^z2ui5_cl_smpc_/i, repo: 'samples-controls' }, + { re: /^z2ui5_cl_smps_/i, repo: 'samples-stack' }, + { re: /^z2ui5_cl_smp_/i, repo: 'samples' }, + /* Everything else in the namespace is the framework's. Resolved by file, + * since abap2UI5 ships no catalogue - and only from a checkout, because a + * raw URL cannot list a directory. Unreachable is a note, not a failure. */ + { re: /^z2ui5_/i, repo: 'abap2UI5', byFile: true }, +]; + +/* Prose only. src/ is ABAP and belongs to abaplint; the generated catalogue is + * written FROM the classes, so checking it would only ever confirm itself. */ +const PROSE = ['README.md', 'CONTRIBUTING.md', 'AGENTS.md', 'CLAUDE.md', 'TRAINING.md', 'STATUS.md']; + +/* A journal records what a class was called when the entry was written; that + * is history, not drift. Same reasoning as abap2UI5's changelog cut-off. */ +const HISTORY = /STATUS-history\.md$/; + +/* Names that are meant to be absent, from `scripts/prose-absent.json` — one + * per repository, because what a repository deliberately names in the past + * tense is its own business. Each needs its reason, so an entry cannot become + * a silent hole; a blank reason fails the run. abap2UI5's prose-name-gate + * carries the same rule, for the same reason: an allowlist without a why is a + * way to make a gate stop asking. + * + * The SCRIPT is byte-identical in samples, samples-controls and samples-stack + * (as check-app-rules.mjs is) - only this file differs. */ +const ABSENT = (() => { + const at = path.join(ROOT, 'scripts', 'prose-absent.json'); + if (!fs.existsSync(at)) return new Map(); + const raw = JSON.parse(fs.readFileSync(at, 'utf8')); + const bad = Object.entries(raw).filter(([, why]) => !String(why).trim()); + if (bad.length) { + console.error(`prose-absent.json: ${bad.map(([n]) => n).join(', ')} carry no reason`); + process.exit(1); + } + return new Map(Object.entries(raw).map(([n, why]) => [n.toLowerCase(), why])); +})(); + +const here = (() => { + const names = new Set(); + const walk = (dir) => { + if (!fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const f = path.join(dir, e.name); + if (e.isDirectory()) walk(f); + else if (e.name.endsWith('.clas.abap')) names.add(e.name.replace('.clas.abap', '').toLowerCase()); + } + }; + walk(path.join(ROOT, 'src')); + return names; +})(); + +/* Where a sibling repository is, if it is here at all. The environment wins, + * the way it does for abap2UI5/ai-mcp's resolvers - and it has to: a CI runner + * cannot check a repository out ABOVE the workspace, so `../` is a local + * convenience and the variable is what CI uses. */ +const HOMES = { + samples: 'SAMPLES_HOME', + 'samples-controls': 'SAMPLES_CONTROLS_HOME', + 'samples-stack': 'SAMPLES_STACK_HOME', + abap2UI5: 'A2UI5_HOME', +}; + +function checkoutOf(repo) { + const fromEnv = process.env[HOMES[repo]]; + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; + const sibling = path.join(ROOT, '..', repo); + return fs.existsSync(sibling) ? sibling : null; +} + +const raw = (repo) => `https://raw.githubusercontent.com/abap2UI5/${repo}/main/SAMPLES.md`; + +/* Every class a checkout ships, by file name. Read once. */ +let frameworkCache = null; +function frameworkClasses(root) { + if (frameworkCache) return frameworkCache; + const names = new Set(); + const walk = (dir) => { + if (!fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const f = path.join(dir, e.name); + if (e.isDirectory()) walk(f); + else if (e.name.endsWith('.clas.abap')) names.add(e.name.replace('.clas.abap', '').toLowerCase()); + } + }; + walk(path.join(root, 'src')); + frameworkCache = names; + return names; +} +const notes = []; +const catalogues = new Map(); + +async function catalogueOf(repo) { + if (catalogues.has(repo)) return catalogues.get(repo); + const at = checkoutOf(repo); + const local = at && path.join(at, 'SAMPLES.md'); + let text = null; + if (local && fs.existsSync(local)) text = fs.readFileSync(local, 'utf8'); + else { + try { + const res = await fetch(raw(repo), { signal: AbortSignal.timeout(15000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + text = await res.text(); + } catch (err) { + notes.push(`${repo}: not reachable (${err.message}) — names it owns were not checked`); + } + } + const set = text ? new Set([...text.matchAll(/\b(z2ui5_cl_[a-z0-9_]+)\b/gi)].map((m) => m[1].toLowerCase())) : null; + catalogues.set(repo, set); + return set; +} + +const problems = []; +let checked = 0; + +for (const file of PROSE) { + const at = path.join(ROOT, file); + if (!fs.existsSync(at) || HISTORY.test(at)) continue; + const text = fs.readFileSync(at, 'utf8'); + const seen = new Set(); + + /* Two shapes look like a name and are not one; both are a PREFIX standing for + * a family: `z2ui5_cl_smps_bp_*` (a glob) and `z2ui5_cl_smpc_app_` (a + * placeholder). An ABAP class name never ends in an underscore, so requiring + * the last character to be a letter or digit rules out every one of them and + * nothing real. */ + for (const m of text.matchAll(/\b(z2ui5_cl_[a-z0-9_]*[a-z0-9])\b(?!\*|_|<)/gi)) { + const name = m[1].toLowerCase(); + if (seen.has(name)) continue; + seen.add(name); + + if (ABSENT.has(name)) continue; + if (here.has(name)) { checked += 1; continue; } + + const owner = OWNER.find((o) => o.re.test(name)); + /* No owner at all means the name carries a token no repository here uses - + * which is what a name left behind by a rename looks like. That was the + * hole this gate shipped with: `z2ui5_cl_dmo_app_overview` matched no + * prefix, so it was skipped silently, which is the one outcome a gate + * against stale names must not have. */ + if (!owner) { + problems.push(`${file}: names \`${name}\`, whose prefix belongs to no repository here\n` + + ' a leftover from a rename, or a name to add to ABSENT with its reason'); + continue; + } + if (owner.repo === path.basename(ROOT)) { + problems.push(`${file}: names \`${name}\`, which this repository does not have`); + continue; + } + if (owner.byFile) { + const at = checkoutOf(owner.repo); + if (!at) { + if (!notes.some((n) => n.startsWith(owner.repo))) { + notes.push(`${owner.repo}: no checkout next to this one — framework names were not checked`); + } + continue; + } + checked += 1; + const found = frameworkClasses(at).has(name); + if (!found) { + problems.push(`${file}: names \`${name}\`, which ${owner.repo} does not ship`); + } + continue; + } + // eslint-disable-next-line no-await-in-loop + const cat = await catalogueOf(owner.repo); + if (!cat) continue; // unreachable, already noted + checked += 1; + if (!cat.has(name)) { + problems.push( + `${file}: names \`${name}\`, which is not in ${owner.repo}'s SAMPLES.md\n` + + ` renamed or removed over there — look it up and correct the sentence`, + ); + } + } +} + +console.log(`prose-names: ${checked} class name(s) checked in ${PROSE.length} prose file(s)`); +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); +} +console.log(checked ? 'every class name in prose exists - OK' : 'nothing reachable to check against'); diff --git a/scripts/prose-absent.json b/scripts/prose-absent.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/scripts/prose-absent.json @@ -0,0 +1 @@ +{}