Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/check-prose-names.yaml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 31 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
226 changes: 226 additions & 0 deletions scripts/check-prose-names.mjs
Original file line number Diff line number Diff line change
@@ -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/<repo>/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 `../<repo>` 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_<n>` (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');
1 change: 1 addition & 0 deletions scripts/prose-absent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}