diff --git a/.claude/skills/view-chain-layout/SKILL.md b/.claude/skills/view-chain-layout/SKILL.md index 64129ab..15a07ff 100644 --- a/.claude/skills/view-chain-layout/SKILL.md +++ b/.claude/skills/view-chain-layout/SKILL.md @@ -1,6 +1,6 @@ --- name: view-chain-layout -description: The layout rules for a z2ui5_cl_ui5_view_builder chain - one call per line, four spaces per tree level, the end( ) column, which factory( ) shape goes with which chain shape, blank lines, and the chain-format gate that checks them. Identical in abap2UI5, abap2UI5/samples and abap2UI5/samples-controls. Use when writing, reviewing or reformatting any view built with the builder, when a chain has drifted, and when chain-format, chain-indentation or chain-element-per-line fails. +description: The layout rules for a z2ui5_cl_ui5_view_builder chain - one call per line, four spaces per tree level, the end( ) column, which factory( ) shape goes with which chain shape, blank lines, and the linter rule that checks them. Identical in abap2UI5, abap2UI5/samples, abap2UI5/samples-controls and abap2UI5/samples-stack. Use when writing, reviewing or reformatting any view built with the builder, when a chain has drifted, and when chain-house-layout fails. --- # The layout of a view-builder chain @@ -122,23 +122,28 @@ not emitted at all until a config asks for it, because it encodes one house style — this one — and because its fixes span a whole chain, which would defer any other rule's fix inside the same chain to a second `--fix` pass. -How to run it depends on the repository, and they are not yet the same: +All four repositories run the rule itself. `scripts/chain-format.mjs`, which +used to be the same algorithm written a second time, is gone: | | how | |---|---| -| `abap2UI5` | `npm run check:abap2ui5` / `npm run fmt:chains` — the linter, enabled in `abap2ui5lint.jsonc` | -| `abap2UI5/samples` | `npm run check:chains` / `npm run fmt:chains` — still `scripts/chain-format.mjs` | -| `abap2UI5/samples-controls` | `npm run check:chains` / `npm run fmt:chains` — the same script; also the first step of `npm run gates` | - -`scripts/chain-format.mjs` is the same algorithm as the rule, kept -byte-identical in the two sample repositories. **It is meant to go away**: both -repos pin the linter by SHA at a version that predates the rule, and bumping -that pin also moves them onto the linter's new packaging, where the UI5 render -runtime is a separate optional peer (`@abap2ui5/render-runtime`) rather than an -optional dependency. Until that package resolves for them, bumping would take -their render gate — 172 and 416 documents — down with it. So the script stays -until the pin can move, and then it is deleted in the same change that adds -`"chain-house-layout": "warning"` to their configs. +| `abap2UI5` | `npm run check:abap2ui5` / `npm run fmt:chains` — enabled in `abap2ui5lint.jsonc` | +| `abap2UI5/samples` | `npm run check:abap2ui5` / `npm run fmt:chains` — enabled in `abap2ui5lint.jsonc` | +| `abap2UI5/samples-stack` | `npm run check:abap2ui5` / `npm run fmt:chains` — enabled in `abap2ui5lint.jsonc` | +| `abap2UI5/samples-controls` | `npm run check:chains` / `npm run fmt:chains` — `abap2ui5lint-chains.jsonc`; also the first step of `npm run gates` | + +samples-controls needs a config of its own because its corpus gate +(`view-gates.mjs`) only sees files that have a meta sidecar, while the layout +is a property of the source and has to cover the whole tree — including the +fourteen hand-written `src/03` classes and the generated overview app. That +config switches the property-gate rules off, because view-gates judges those +against the sidecars and a second opinion here would only be a worse one. + +Two things that config must NOT do, both measured rather than assumed: +`properties: false` looks like the obvious way to isolate the layout and takes +the chain rules down with it — the check then passes everything, silently. And +`chain-house-layout` is opt-in, so a `rules` entry is what turns it on; without +one the run is green no matter how mangled the chain. Nothing else covers this, which is why the rule exists: diff --git a/.github/workflows/bump-framework-pin.yaml b/.github/workflows/bump-framework-pin.yaml new file mode 100644 index 0000000..86e0355 --- /dev/null +++ b/.github/workflows/bump-framework-pin.yaml @@ -0,0 +1,96 @@ +name: bump-framework-pin + +# The abaplint configs here pin abap2UI5 to a RELEASE tag, which is what makes +# this repository lint against the framework its readers actually installed. +# A pin nobody moves is a different defect from a pin nobody set, though: the +# samples would keep proving themselves against an ever-older release while the +# framework moves on, and the first person to notice would be a reader whose +# new install behaves differently from the corpus. +# +# So: weekly, resolve the newest release tag, move every config that carries +# one (check-framework-pin --set knows which those are - the 702 config keeps +# its branch), and PROVE the new pin by running the abaplint gates before the +# pull request exists. A framework release that breaks a sample fails this +# workflow instead of landing on main. +# +# A release is published twice - `X.Y.Z` and, minutes later, `X.Y.Z-702` for +# the downported distribution. Both are the same version of the framework and +# the tag list is sorted by version, so the -702 suffix is stripped and the +# highest remaining x.y.z wins. + +on: + schedule: + - cron: '17 5 * * 2' # Tuesdays 05:17 UTC — clear of the Monday linter bumps + workflow_dispatch: + +permissions: + contents: read + +jobs: + bump-framework-pin: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Resolve the newest abap2UI5 release and move the pin + id: pin + run: | + set -euo pipefail + # --exit-code: `git ls-remote` prints nothing and still exits 0 when no + # ref matches, so a repository that stopped tagging releases would + # resolve to an EMPTY version and this step would happily write it. + TAG=$(git ls-remote --exit-code --tags --refs https://github.com/abap2UI5/abap2UI5 \ + | sed 's#.*refs/tags/##' \ + | sed 's/-702$//' \ + | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V | tail -1) + test -n "$TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + BEFORE=$(git diff --quiet && echo clean || echo dirty) + node scripts/check-framework-pin.mjs --set "$TAG" + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "already on $TAG ($BEFORE before)" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - if: steps.pin.outputs.changed == 'true' + run: node scripts/check-framework-pin.mjs + + # The proof. A release that dropped or changed API a sample uses fails + # HERE, where the diff is one line and the cause is obvious, instead of on + # somebody's unrelated pull request. + - if: steps.pin.outputs.changed == 'true' + run: npm ci + - if: steps.pin.outputs.changed == 'true' + name: Lint the corpus against the new release + run: | + set -euo pipefail + npx abaplint abaplint.jsonc + + - if: steps.pin.outputs.changed == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + branch: bump-framework-pin + title: 'chore: lint against abap2UI5 ${{ steps.pin.outputs.tag }}' + commit-message: 'chore: pin the abaplint configs to abap2UI5 ${{ steps.pin.outputs.tag }}' + body: | + Weekly move of the framework pin in the abaplint configs to the newest + published release, `${{ steps.pin.outputs.tag }}`. + + The abaplint gates ran against the new release in the workflow that + opened this pull request, so a red check here means the release + changed something a sample depends on — worth reading before merging, + because a reader on that release hits the same thing. + add-paths: | + abaplint.jsonc + .github/abaplint + delete-branch: true diff --git a/.github/workflows/check-framework-pin.yaml b/.github/workflows/check-framework-pin.yaml new file mode 100644 index 0000000..18843ee --- /dev/null +++ b/.github/workflows/check-framework-pin.yaml @@ -0,0 +1,45 @@ +name: check-framework-pin + +# The abaplint configs here resolve abap2UI5 as a git dependency, and abaplint +# clones the DEFAULT branch unless a `"branch"` key says otherwise. So these +# samples used to be linted against the framework's development tip while every +# reader of them has a RELEASE installed - a gap that hides two defects, both +# silent: +# +# a sample uses API that exists only on main, this repository stays green, +# and the reader who copies it gets a syntax error. That is not theoretical: +# z2ui5_cl_ui5_view_builder was on main from 2026-08-12 and in no release +# until 1.143.0, three weeks later. +# +# the framework changes something on main and the abaplint workflows here go +# red overnight, with no commit in this repository to explain it. +# +# The configs now name a release tag, and this gate is what keeps them naming +# ONE - a bump has to move every config, and a forgotten file cannot quietly +# lint against a different framework than its neighbours. +# +# Plain node, no dependencies, so it stays a few seconds and can run before any +# install - which is also why `npm run check` puts it first. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: check-framework-pin-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-framework-pin: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - run: node scripts/check-framework-pin.mjs diff --git a/.github/workflows/sync-shared.yaml b/.github/workflows/sync-shared.yaml new file mode 100644 index 0000000..afe1bf8 --- /dev/null +++ b/.github/workflows/sync-shared.yaml @@ -0,0 +1,115 @@ +name: sync-shared + +# The files this repository carries a COPY of, pulled from the repository that +# owns them. +# +# abap2UI5 declares itself the source of everything that exists byte-identical +# in several repositories here - the view-chain-layout skill, the shared gate +# scripts - and its `check:shared` notices when a copy stops matching. Noticing +# was as far as it went: the fix lived in another repository, so a change to a +# shared script meant somebody remembering to carry it into three others by +# hand, and the gate went red in the meantime in repositories that had done +# nothing wrong. The copies had already drifted that way once - chain-format +# differed between two of them while a skill in a third still called them +# byte-identical. +# +# So the copies are pulled rather than pushed. No cross-repository credentials +# exist anywhere in this: the repository updates its own files with its own +# token, which is the same shape as every other bump workflow in this +# organisation. The manifest comes from the source repository, so which files +# are shared is still decided in exactly one place. +# +# Section-shared content is deliberately NOT synced: the app rule block inside +# this repository's own abaplint.jsonc, the AGENTS.md sections. Those live +# inside files this repository owns the rest of, and copying a source over them +# would destroy what it owns. `check:shared` still reports them. + +on: + schedule: + - cron: '47 5 * * 2' # Tuesdays 05:47 UTC, after the framework pin bump + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the source repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: abap2UI5/abap2UI5 + ref: main + path: .abap2UI5 + sparse-checkout: | + .claude + .github + docs + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Copy every shared file this repository carries + id: sync + run: | + set -euo pipefail + GATE=.abap2UI5/.github/scripts/shared-file-gate.mjs + test -f "$GATE" || { + echo "::error::$GATE is missing — the source repository moved the gate." + echo "::error::Fix the path here; a silent skip would let the copies drift again." + exit 1 + } + # The consumer name is this repository's own, taken from + # GITHUB_REPOSITORY rather than from the event payload: this workflow + # only ever runs on `schedule` and `workflow_dispatch`, and a scheduled + # run carries no repository object to read a name off. + NAME="${GITHUB_REPOSITORY#*/}" + # Tab-separated: source path in abap2UI5, path in this repository. + node "$GATE" --manifest "$NAME" > manifest.txt + test -s manifest.txt || { + echo "::error::the manifest is empty for $NAME —" + echo "::error::either this repository is no longer a consumer, or its name changed." + exit 1 + } + while IFS=$'\t' read -r src dst; do + [ -n "$src" ] || continue + test -f ".abap2UI5/$src" || { echo "::error::.abap2UI5/$src does not exist"; exit 1; } + mkdir -p "$(dirname "$dst")" + cp ".abap2UI5/$src" "$dst" + echo " $src -> $dst" + done < manifest.txt + rm -f manifest.txt + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "every copy already matches its source" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + git diff --stat + fi + + - if: steps.sync.outputs.changed == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + branch: sync-shared + title: 'chore: pull the shared files from abap2UI5' + commit-message: 'chore: sync the shared files from abap2UI5/abap2UI5' + body: | + A file this repository carries a copy of moved in + [abap2UI5/abap2UI5](https://github.com/abap2UI5/abap2UI5), which owns + it. This pull request carries the change across. + + The copies are byte-equal by design and `check:shared` in the source + repository is what notices when they stop being — so this is the fix + for a gate that would otherwise be red there while nothing was wrong + here. Read the diff as you would any other change to these files: if + it looks wrong, the argument belongs in the source repository, not in + a local edit that will be pulled over next week. + delete-branch: true diff --git a/abaplint.jsonc b/abaplint.jsonc index 2afe14b..5f5053f 100644 --- a/abaplint.jsonc +++ b/abaplint.jsonc @@ -25,6 +25,7 @@ }, { "url": "https://github.com/abap2UI5/abap2UI5", + "branch": "1.143.0", "folder": "/abap2UI5", "files": "/src/**/*.*" } diff --git a/package-lock.json b/package-lock.json index 1cc7783..25d2f44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,18 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { - "@abap2ui5/linter": "^0.2.1", - "@abap2ui5/render-runtime": "^0.1.1", - "@abaplint/cli": "^2.119.66" + "@abap2ui5/linter": "^0.2.2", + "@abap2ui5/render-runtime": "^0.2.2", + "@abaplint/cli": "^2.120.23" }, "engines": { "node": ">=22" } }, "node_modules/@abap2ui5/linter": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@abap2ui5/linter/-/linter-0.2.1.tgz", - "integrity": "sha512-48uMFctR78nogNai1w6kE7Ho4IXLuEnGPq5BJkstpWL69ToL1G+OJ1t4fDOBmOcJ36uFejDn2ofnoBqEkt+9sA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@abap2ui5/linter/-/linter-0.2.2.tgz", + "integrity": "sha512-WegxIRN5M5gH13Ye5Mo13fbpjAZ183J0ei1iArCfEiKUPAGqHot20NnckpNnXHX+YPHCkehA+Z0QapXjaLK1Tw==", "dev": true, "license": "MIT", "workspaces": [ @@ -33,7 +33,7 @@ "node": ">=22" }, "peerDependencies": { - "@abap2ui5/render-runtime": "^0.1.0" + "@abap2ui5/render-runtime": "^0.1.0 || ^0.2.0" }, "peerDependenciesMeta": { "@abap2ui5/render-runtime": { @@ -42,9 +42,9 @@ } }, "node_modules/@abap2ui5/render-runtime": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@abap2ui5/render-runtime/-/render-runtime-0.1.1.tgz", - "integrity": "sha512-41O4amDSbHWynzXiTjWYp1XC+dVbWc58flTkTTCJUjXwYXW6mJBmMiLxBSpCz1pMXa4LL3QAZq8IXk7Yy2664g==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@abap2ui5/render-runtime/-/render-runtime-0.2.2.tgz", + "integrity": "sha512-6DTHLxaIUoFb1I7qPLqjFFBtLNbgOE7cFpAlWKRBLzM/x+3mmm6JG5uIAGJaS3AFmFMV/J9coAxCXez1hqL7Tw==", "dev": true, "license": "MIT", "dependencies": { @@ -59,6 +59,7 @@ "@openui5/sap.ui.unified": "1.151.0", "@openui5/sap.uxap": "1.151.0", "@openui5/themelib_sap_horizon": "1.151.0", + "less-openui5": "^0.12.0", "playwright": "^1.61.1" }, "engines": { @@ -81,6 +82,13 @@ "url": "https://github.com/sponsors/larshp" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@openui5/sap.f": { "version": "1.151.0", "resolved": "https://registry.npmjs.org/@openui5/sap.f/-/sap.f-1.151.0.tgz", @@ -213,6 +221,34 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/less-openui5": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/less-openui5/-/less-openui5-0.12.0.tgz", + "integrity": "sha512-7CvQYmuODBCWAh9RBKevdx2+aNIjVPaWpS/XxPfxe8hJNvOY5IcXdTchFahwhVmILzPMOqZDKykGQr5GMq6iJg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@adobe/css-tools": "^4.5.0", + "mime": "^1.6.0" + }, + "engines": { + "node": "^20.11.0 || >=22.0.0", + "npm": ">= 8" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/playwright": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", diff --git a/package.json b/package.json index b6579cf..1d74f88 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ "check:abap2ui5": "abap2ui5lint", "fmt:chains": "abap2ui5lint --fix", "check:overview": "node 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 && npm run check:prose && npm run check:web && npm run check:family-nav", + "check": "npm run check:pin && 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 && npm run check:web && npm run check:family-nav", "check:keywords": "node scripts/check-keywords.mjs", "check:abapdoc": "node scripts/check-abapdoc.mjs", "samples:md": "node scripts/generate-samples-md.mjs", "check:samples-md": "node scripts/generate-samples-md.mjs --check", "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", "check:family-nav": "node scripts/check-family-nav.mjs", "web:index": "node scripts/generate-web-index.mjs", @@ -30,9 +31,9 @@ }, "homepage": "https://github.com/abap2UI5/samples-stack#readme", "devDependencies": { - "@abap2ui5/linter": "^0.2.1", - "@abap2ui5/render-runtime": "^0.1.1", - "@abaplint/cli": "^2.119.66" + "@abap2ui5/linter": "^0.2.2", + "@abap2ui5/render-runtime": "^0.2.2", + "@abaplint/cli": "^2.120.23" }, "engines": { "node": ">=22" diff --git a/scripts/check-framework-pin.mjs b/scripts/check-framework-pin.mjs new file mode 100644 index 0000000..b9731d5 --- /dev/null +++ b/scripts/check-framework-pin.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/* + * check-framework-pin — the sample repositories compile against a RELEASE of + * abap2UI5, and this is what says so. + * + * ONE SOURCE, and it is abap2UI5's `.github/shared/check-framework-pin.mjs`. + * The repositories that run it carry it byte-equal; abap2UI5's + * `npm run check:shared` is what notices when that did not happen. + * + * WHY: abaplint resolves a git dependency by URL, and without a `"branch"` key + * it clones the DEFAULT branch. So every one of these configs was linting the + * corpus against abap2UI5 main — the development tip — while every reader of + * these samples has a release installed. Two failure directions, both silent: + * + * a sample uses API that only exists on main, CI is green, and the reader + * who copies it gets a syntax error. z2ui5_cl_ui5_view_builder is the case + * that happened: on main from 2026-08-12, not in a release until 1.143.0 + * three weeks later. + * + * the framework changes something on main, and nine workflows across three + * repositories go red overnight without a single commit in any of them. + * + * A release tag fixes both: the gate answers the question a reader actually + * has. abaplint's `"branch"` is the only key it offers for this, and it feeds + * `git clone --branch`, which takes a tag just as happily as a branch name. + * (A `"tag"` key would be silently ignored — abaplint does not have one.) + * + * Policy: + * 1. Every abaplint config's abap2UI5 dependency carries a `"branch"` key. + * No key means the default branch, which is the drift this exists for. + * 2. Never two `"branch"` keys in one dependency entry. JSON parsing takes + * the LAST of duplicate keys, so a stale pin left NEXT to the intended + * one is invisible to every consumer that just parses the file. + * 3. All configs name the SAME release, so a bump moves them together and + * one forgotten file cannot lint against a different framework than its + * neighbours. ALLOWED_BRANCHES carries the exceptions. + * + * The 702 exception: the downported build must resolve the framework against + * its downported branch. abap2UI5 force-pushes `702` from main on every push + * (auto_downport), so it is not a release pin — it is the only branch whose + * content is v702-parseable at all, and main is not. + * + * Run: node scripts/check-framework-pin.mjs (offline, exit 1) + * node scripts/check-framework-pin.mjs --set 1.144.0 (move the pin) + * + * `--set` is here rather than in the bump workflow so the policy has ONE + * implementation: which configs carry the release, which carry an allowlisted + * branch, and what a release tag looks like are the same three answers whether + * they are being checked or written. A workflow doing it with sed would be a + * second, silent copy of that policy. + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// config path (repo-relative) -> the branch value its abap2UI5 dependency must +// carry INSTEAD of the release tag. Absent = must carry the release tag. +const ALLOWED_BRANCHES = new Map([ + ['.github/abaplint/abap_702.jsonc', '702'], +]); + +const A2UI5_URL_RE = /github\.com\/abap2UI5\/abap2UI5/i; +const RELEASE_RE = /^\d+\.\d+\.\d+$/; + +let errors = 0; +const err = (m) => { console.log(`ERROR ${m}`); errors++; }; + +/* Comment-stripping that leaves string contents alone — the configs are + * .jsonc and a `//` inside a URL must survive. */ +const stripComments = (s) => s + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/([,{[\s])\/\/.*$/gm, '$1'); + +// balanced top-level {...} slices of an array body (strings are quote-aware) +function objectSlices(body) { + const out = []; + let depth = 0; + let start = -1; + let inStr = false; + for (let i = 0; i < body.length; i++) { + const c = body[i]; + if (inStr) { + if (c === '\\') i++; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') { inStr = true; continue; } + if (c === '{') { if (depth === 0) start = i; depth++; } + else if (c === '}') { depth--; if (depth === 0 && start >= 0) { out.push(body.slice(start, i + 1)); start = -1; } } + } + return out; +} + +function configFiles() { + const files = ['abaplint.jsonc']; + const dir = path.join(ROOT, '.github', 'abaplint'); + if (fs.existsSync(dir)) { + for (const f of fs.readdirSync(dir).sort()) { + if (f.endsWith('.jsonc') || f.endsWith('.json')) files.push(path.posix.join('.github/abaplint', f)); + } + } + return files.filter((f) => fs.existsSync(path.join(ROOT, f))); +} + +const SET = (() => { + const i = process.argv.indexOf('--set'); + return i !== -1 ? process.argv[i + 1] : null; +})(); + +if (SET !== null) { + if (!RELEASE_RE.test(SET || '')) { + console.log(`ERROR --set wants a release tag (x.y.z), got ${JSON.stringify(SET)}`); + process.exit(1); + } + let written = 0; + for (const rel of configFiles()) { + if (ALLOWED_BRANCHES.has(rel)) continue; + const full = path.join(ROOT, rel); + const raw = fs.readFileSync(full, 'utf8'); + /* Rewrite in the RAW text, not a parsed tree: these are .jsonc files whose + * comments carry the reasoning, and a parse/serialise round trip would + * drop every one of them. Anchored on the abap2UI5 url line so a `branch` + * belonging to another dependency is left alone. */ + const next = raw.replace( + /("url"\s*:\s*"https:\/\/github\.com\/abap2UI5\/abap2UI5"\s*,\s*\n\s*"branch"\s*:\s*")[^"]*(")/, + `$1${SET}$2`, + ); + if (next !== raw) { fs.writeFileSync(full, next); written++; } + } + console.log(`check-framework-pin: set ${written} config(s) to ${SET}`); +} + +const releases = new Map(); // config -> release tag it names +let checked = 0; + +for (const rel of configFiles()) { + const txt = stripComments(fs.readFileSync(path.join(ROOT, rel), 'utf8')); + const depsM = txt.match(/"dependencies"\s*:\s*\[([\s\S]*?)\]/); + if (!depsM) continue; + for (const entry of objectSlices(depsM[1])) { + if (!A2UI5_URL_RE.test(entry)) continue; + checked++; + const branches = [...entry.matchAll(/"branch"\s*:\s*"([^"]*)"/g)].map((m) => m[1]); + const expected = ALLOWED_BRANCHES.get(rel); + + if (branches.length > 1) { + err(`${rel}: abap2UI5 dependency carries ${branches.length} "branch" keys (${branches.map((b) => JSON.stringify(b)).join(', ')}) — duplicate keys silently shadow each other; keep exactly one`); + continue; + } + if (branches.length === 0) { + err(`${rel}: abap2UI5 dependency carries no "branch" key — abaplint then clones the DEFAULT branch, so this config lints against the framework's development tip instead of a release${expected ? `; this config wants "branch": ${JSON.stringify(expected)}` : ''}`); + continue; + } + + const [branch] = branches; + if (expected !== undefined) { + if (branch !== expected) { + err(`${rel}: abap2UI5 dependency carries "branch": ${JSON.stringify(branch)}, but this config is allowlisted for ${JSON.stringify(expected)} — change ALLOWED_BRANCHES in the same commit if that is deliberate`); + } + continue; + } + if (!RELEASE_RE.test(branch)) { + err(`${rel}: abap2UI5 dependency is pinned to ${JSON.stringify(branch)}, which is not a release tag (x.y.z) — only the allowlisted configs may name a branch`); + continue; + } + releases.set(rel, branch); + } +} + +if (!checked) { + err('no abap2UI5 dependency entry found in any abaplint config — did the dependency URL change? (this check would go blind)'); +} + +// --- 3. one release across the repository ----------------------------------- +const distinct = [...new Set(releases.values())]; +if (distinct.length > 1) { + err(`the configs name ${distinct.length} different releases (${distinct.join(', ')}) — a bump has to move all of them:\n` + + [...releases].map(([f, v]) => ` ${v} ${f}`).join('\n')); +} + +if (errors) { + console.log(`check-framework-pin: ${errors} error(s).`); + process.exit(1); +} +console.log(`check-framework-pin: ok (${checked} abap2UI5 dependency entr${checked === 1 ? 'y' : 'ies'}, release ${distinct[0] ?? 'none'}${ALLOWED_BRANCHES.size ? `, ${ALLOWED_BRANCHES.size} allowlisted branch pin(s)` : ''})`);