diff --git a/.github/badges/check-abap2ui5.json b/.github/badges/check-abap2ui5.json index c440d93..e6ac100 100644 --- a/.github/badges/check-abap2ui5.json +++ b/.github/badges/check-abap2ui5.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "label": "check-abap2UI5", - "message": "91 rules passed", + "message": "111 rules passed", "color": "4c1", "labelColor": "555", "cacheSeconds": 3600 diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml index 8f8865c..49d8634 100644 --- a/.github/workflows/deploy-web.yaml +++ b/.github/workflows/deploy-web.yaml @@ -112,6 +112,7 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + timeout-minutes: 10 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/README.md b/README.md index a5ce081..496d43a 100644 --- a/README.md +++ b/README.md @@ -246,10 +246,10 @@ and the view it builds, with a headless render of every view) and `npm run check:overview`. `npm run fmt:chains` applies the house chain layout. [`AGENTS.md`](AGENTS.md) has the conventions those checks enforce. -Every check below has a workflow, and every workflow is a step of `npm run -check` — the two lists are the same list, which is the only thing that makes a -green run here mean a green run there. The node checks carry no dependencies, so -they take seconds. +Every `check-*` workflow below is a step of `npm run check`, and every step +has its workflow — which is what makes a green run here mean a green run +there. The last two rows are build-and-publish jobs, not checks, and run only +in CI. The node checks carry no dependencies, so they take seconds. | Workflow | What it does | |---|---| @@ -262,6 +262,8 @@ they take seconds. | `check-abapdoc` | every `"!` block documents the declaration below it, rather than attaching to nothing | | `check-app-rules` | the shared abaplint rule block still matches its source in [abap2UI5](https://github.com/abap2UI5/abap2UI5) | | `check-prose-names` | every class name written in prose exists — including the sibling repositories' | +| `check-framework-pin` | the abaplint config pins abap2UI5 to a release tag, never to whatever is on `main` | +| `check-family-nav` | the learning-path block on the [page](https://abap2ui5.github.io/samples-stack/) links the two sibling repositories correctly | | `check-web` | the data behind the [page](https://abap2ui5.github.io/samples-stack/) can still be generated — every package has a README row that parses, every app sits in a package | | `create-package-branches` | rebuilds the nine per-package branches, each verified with abaplint at its own release before it is pushed | | `deploy-web` | publishes [`web/`](web/README.md) to GitHub Pages on every push to `main` that touches the tree it describes | diff --git a/abaplint.jsonc b/abaplint.jsonc index e164929..815cc84 100644 --- a/abaplint.jsonc +++ b/abaplint.jsonc @@ -25,7 +25,7 @@ }, { "url": "https://github.com/abap2UI5/abap2UI5", - "branch": "1.143.0", + "branch": "1.144.0", "folder": "/abap2UI5", "files": "/src/**/*.*" } @@ -89,7 +89,7 @@ "abapdoc": false, // --- off: the view builder chain owns its own layout -------------- // A view is one statement spanning up to several hundred lines, laid out - // by rules that "npm run check:chains" (scripts/chain-format.mjs) gates - + // by rules that the linter's chain-house-layout ("npm run check:abap2ui5") gates - // and that gate is stricter than anything here. Every one of these rules // reads a chain as a malformed parameter list: 5,087 findings from // line_break_multiple_parameters alone, all of them correct chains. @@ -249,9 +249,9 @@ "try": true, "when": false }, - // The overview app is generated (npm run overview) - its render method is - // one long emit, and shortening it means changing the generator's output - // shape, not the repository's code. + // The overview app is hand-written but held to its shape by + // scripts/check-overview.mjs - its render method is one long emit, and + // shortening it means reworking the checked structure, not one method. "method_length": { "statements": 100, "errorWhenEmpty": false, diff --git a/package-lock.json b/package-lock.json index 5f0c498..a7840bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { - "@abap2ui5/linter": "^0.4.1", - "@abap2ui5/render-runtime": "^0.4.1", + "@abap2ui5/linter": "^0.6.1", + "@abap2ui5/render-runtime": "^0.6.1", "@abaplint/cli": "^2.120.23" }, "engines": { @@ -18,9 +18,9 @@ } }, "node_modules/@abap2ui5/linter": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@abap2ui5/linter/-/linter-0.4.1.tgz", - "integrity": "sha512-lhPjNhRv0/rVNgeI37lJ1ceVh81KuUe2Bcfpz2N/WtHcfVcRDJDMzSzE7dS2/r+uvg+J32Ovez/L0dfuijsbmQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@abap2ui5/linter/-/linter-0.6.1.tgz", + "integrity": "sha512-0VCspV/Mn3m5v66GcoezDLY7GPo9A7YO111uP3qTm4c8uH0pSzp4/LNhYlmPwAmupc3v9kICLdMWTuCi5vGYrg==", "dev": true, "license": "MIT", "workspaces": [ @@ -33,7 +33,7 @@ "node": ">=22" }, "peerDependencies": { - "@abap2ui5/render-runtime": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0" + "@abap2ui5/render-runtime": ">=0.1.0 <0.7.0" }, "peerDependenciesMeta": { "@abap2ui5/render-runtime": { @@ -42,9 +42,9 @@ } }, "node_modules/@abap2ui5/render-runtime": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@abap2ui5/render-runtime/-/render-runtime-0.4.1.tgz", - "integrity": "sha512-DHB4ea/bl7wNMQYBZmr2JeqLzI8IjCpYPLpAI4zEDIOGdlwn/ltMVjD0YVBwng0mA2uEUg073eldJVDF5rvtJQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@abap2ui5/render-runtime/-/render-runtime-0.6.1.tgz", + "integrity": "sha512-s45w9UZ6N0IWRONUcybKL/pYQTmeByy5CWb5mA1AsxCvK7+3xZ8LhuIAYJa2OxJxjRDuY2j1TtqU92m716Kg0g==", "dev": true, "license": "MIT", "dependencies": { @@ -67,9 +67,9 @@ } }, "node_modules/@abaplint/cli": { - "version": "2.120.24", - "resolved": "https://registry.npmjs.org/@abaplint/cli/-/cli-2.120.24.tgz", - "integrity": "sha512-NOIxEvjhFeUDTxa3UYwiAbGiHaEC9r9u/b8eh3LoqWitt6rG7Z/FGAYnGgD8swGfpZe3vXp5atDyOJFhZ2DbUQ==", + "version": "2.120.39", + "resolved": "https://registry.npmjs.org/@abaplint/cli/-/cli-2.120.39.tgz", + "integrity": "sha512-Aq0CyET/VmQXwgLcY5y0IMQtvuYs4va5xRqsbl4X12uR/AbSNWMQuWpv98zJPxSpky0lmcVt3+IFLh19qZuq8Q==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 04033fc..a45b616 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "samples:md": "node scripts/generate-samples-md.mjs", "check:samples-md": "node scripts/generate-samples-md.mjs --check", "catalogue": "node scripts/generate-catalogue.mjs", - "check:catalogue": "node scripts/generate-catalogue.mjs --check", + "check:catalogue": "node scripts/generate-catalogue.mjs --check && node scripts/check-catalogue-contract.mjs", "check:app-rules": "node scripts/check-app-rules.mjs", "check:pin": "node scripts/check-framework-pin.mjs", "check:prose": "node scripts/check-prose-names.mjs", @@ -34,8 +34,8 @@ }, "homepage": "https://github.com/abap2UI5/samples-stack#readme", "devDependencies": { - "@abap2ui5/linter": "^0.4.1", - "@abap2ui5/render-runtime": "^0.4.1", + "@abap2ui5/linter": "^0.6.1", + "@abap2ui5/render-runtime": "^0.6.1", "@abaplint/cli": "^2.120.23" }, "engines": { diff --git a/scripts/check-catalogue-contract.mjs b/scripts/check-catalogue-contract.mjs new file mode 100644 index 0000000..157ea60 --- /dev/null +++ b/scripts/check-catalogue-contract.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/* + * check-catalogue-contract — the root `catalogue.json` is a PUBLISHED + * interface, and this pins its shape. + * + * Three repositories publish one: abap2UI5/samples, abap2UI5/samples-controls + * and abap2UI5/samples-stack. At least three programs outside them parse all + * three shapes independently — abap2UI5/mcp-server's `lib/examples.mjs`, the + * vscode-extension's `src/catalogue.ts` and the playground's examples browser + * fetch these files raw from GitHub main — so a generator "tidying" a key + * breaks consumers whose tests stay green, in repositories that only find out + * from users. Each repository's own `generate-catalogue.mjs --check` proves + * the file matches the TREE it was generated from; nothing proved it still + * matches what the CONSUMERS parse. This does. + * + * The shapes deliberately differ per repository and this file pins them AS + * THEY ARE, divergences included: + * + * - samples names itself under `repository`, the other two under `repo` + * - samples and samples-controls put the source path in `file`, + * samples-stack in `path` + * - `keywords` is an ARRAY of words in samples and samples-stack, and one + * space-joined STRING in samples-controls + * + * Do not "fix" any of these here or in a generator: every consumer above + * handles all three spellings today, and unifying them is a coordinated + * cross-repository decision (producers, their committed catalogues, three + * consumers), not a cleanup. Until that decision is made, changing a spelling + * is drift, and this gate is what makes it a failing check instead of a + * support issue. + * + * One copy per publishing repository, byte-identical; the source is + * abap2UI5/.github/shared/check-catalogue-contract.mjs (`check:shared` gates + * the copies, `sync-shared.yaml` pulls them). The file identifies which + * repository it is running in from the catalogue itself, so the copies carry + * nothing repository-specific. + * + * node scripts/check-catalogue-contract.mjs (from the repository root) + */ +import fs from 'node:fs'; + +const problems = []; +const fail = (msg) => problems.push(msg); + +const text = fs.readFileSync('catalogue.json', 'utf8'); +let cat; +try { + cat = JSON.parse(text); +} catch (err) { + console.error(`catalogue.json is not JSON: ${err.message}`); + process.exit(1); +} + +/* ------------------------------------------------------------- primitives */ + +const isStr = (v) => typeof v === 'string' && v.length > 0; +const isStrArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string'); + +/** Every entry carries EXACTLY the declared keys. Exact, not "at least": + * all three catalogues have had no optional entry keys since they exist, + * a consumer may rely on that, and a new key appearing on some entries only + * is the start of the optionality no parser over there handles. */ +function checkEntries(list, listName, fields) { + const names = Object.keys(fields); + list.forEach((e, i) => { + const id = e?.class ?? `#${i}`; + const keys = Object.keys(e ?? {}); + for (const k of keys) if (!names.includes(k)) fail(`${listName}[${id}]: unexpected key "${k}"`); + for (const [k, ok] of Object.entries(fields)) { + if (!(k in e)) { fail(`${listName}[${id}]: key "${k}" is missing`); continue; } + if (!ok(e[k])) fail(`${listName}[${id}]: "${k}" has the wrong shape (${JSON.stringify(e[k]).slice(0, 60)})`); + } + }); +} + +function checkTop(required, listKey) { + const keys = Object.keys(cat); + for (const k of required) if (!keys.includes(k)) fail(`top level: key "${k}" is missing`); + for (const k of keys) if (!required.includes(k)) fail(`top level: unexpected key "${k}"`); + if (!Array.isArray(cat[listKey])) fail(`top level: "${listKey}" is not an array`); + return Array.isArray(cat[listKey]) ? cat[listKey] : []; +} + +function checkCommon(list, listName, prefix, pathKey) { + const seen = new Set(); + for (const e of list) { + const cls = String(e?.class ?? ''); + if (!cls.toUpperCase().startsWith(prefix)) fail(`${listName}[${cls}]: class does not carry the ${prefix}* prefix`); + if (seen.has(cls.toUpperCase())) fail(`${listName}[${cls}]: class listed twice`); + seen.add(cls.toUpperCase()); + /* the path must point INTO this checkout — a catalogue naming a file the + * tree no longer has is stale, whatever generated it */ + const p = e?.[pathKey]; + if (isStr(p) && !fs.existsSync(p)) fail(`${listName}[${cls}]: ${pathKey} "${p}" does not exist in this repository`); + } +} + +/* ------------------------------------------------- one spec per publisher */ + +const who = cat.repository ?? cat.repo; + +if (who === 'abap2UI5/samples') { + const list = checkTop( + ['purpose', 'repository', 'role', 'family', 'naming', 'scope', 'learningPath', 'counts', 'samples'], + 'samples', + ); + const stages = new Set((cat.learningPath ?? []).map((s) => s.id)); + checkEntries(list, 'samples', { + class: isStr, + file: isStr, + category: isStr, + stage: (v) => isStr(v) && stages.has(v), + title: isStr, + description: isStr, + summary: isStr, + keywords: isStrArray, + docs: isStrArray, + }); + checkCommon(list, 'samples', 'Z2UI5_CL_SMP_', 'file'); + if (cat.counts?.samples !== list.length) fail(`counts.samples says ${cat.counts?.samples}, the array has ${list.length}`); +} else if (who === 'abap2UI5/samples-controls') { + const list = checkTop( + ['note', 'repo', 'role', 'caveat', 'categories', 'statuses', 'deviationTypes', 'ui5Snapshot', 'counts', 'ports'], + 'ports', + ); + const statuses = new Set(Object.keys(cat.statuses ?? {})); + const categories = new Set(Object.keys(cat.categories ?? {})); + const deviationTypes = Object.keys(cat.deviationTypes ?? {}); + checkEntries(list, 'ports', { + class: isStr, + file: isStr, + category: (v) => isStr(v) && categories.has(v), + library: isStr, + // empty on the src/03 SAPUI5 collection - those are free rebuilds with no + // single demo kit original, so the KEY is the contract, not its content + sample: (v) => typeof v === 'string', + entity: (v) => typeof v === 'string', + title: isStr, + summary: isStr, + // one space-joined string here, an array in the sibling repositories — + // pinned as-is, see the header + keywords: (v) => typeof v === 'string', + status: (v) => isStr(v) && statuses.has(v), + deviations: (v) => isStrArray(v) + && v.every((d) => deviationTypes.some((t) => d.startsWith(t))), + }); + checkCommon(list, 'ports', 'Z2UI5_CL_SMPC_', 'file'); + if (cat.counts?.entries !== list.length) fail(`counts.entries says ${cat.counts?.entries}, the array has ${list.length}`); +} else if (who === 'abap2UI5/samples-stack') { + const list = checkTop( + ['comment', 'repo', 'role', 'start', 'overviewApp', 'packages', 'samples'], + 'samples', + ); + const packages = new Set((cat.packages ?? []).map((p) => p.package)); + const branches = new Set((cat.packages ?? []).map((p) => p.branch)); + checkEntries(list, 'samples', { + class: isStr, + path: isStr, + package: (v) => isStr(v) && packages.has(v), + technology: isStr, + title: isStr, + summary: isStr, + keywords: isStrArray, + runsOn: isStr, + cloud: (v) => typeof v === 'boolean', + needs: isStr, + branch: (v) => isStr(v) && branches.has(v), + setup: isStr, + }); + checkCommon(list, 'samples', 'Z2UI5_CL_SMPS_', 'path'); +} else { + fail(`cannot tell which repository this catalogue belongs to — neither "repository" nor "repo" names one this contract knows (got ${JSON.stringify(who)})`); +} + +/* ------------------------------------------------------------------ report */ + +if (problems.length) { + console.error(`catalogue.json breaks the published contract - ${problems.length} problem(s):`); + for (const p of problems.slice(0, 25)) console.error(` ${p}`); + if (problems.length > 25) console.error(` ... and ${problems.length - 25} more`); + console.error('\nthe shape is pinned by scripts/check-catalogue-contract.mjs (source: abap2UI5/.github/shared/) -'); + console.error('mcp-server, the vscode-extension and the playground parse this file from GitHub main.'); + process.exit(1); +} +const n = (cat.samples ?? cat.ports).length; +console.log(`catalogue.json keeps the published contract - ${n} entries in the ${who} shape`);