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/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/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 diff --git a/README.md b/README.md index 6ec8e2b..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 @@ -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 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 @@ +{}