From 3524e066680d96710f71e31251ca56deb4c1df85 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 4 Oct 2026 17:21:38 +0200 Subject: [PATCH 1/4] fix(gate-3): resolve a contract mirrored by a stub, and end a tab-indented body at its own brace learniq's six lifecycle guards implement OpenRegister's LifecycleGuardInterface::check(..., string $userId) and mark $userId unused, but the exemption only looked for the supertype under lib/, so they were reported with no closing action. The helper now also reads stubs/ and any tests/**/[Ss]tubs directory; the stub must still declare the same method with the same parameter, and an unresolvable supertype still reports. The caller-identity body stopped at a four-space closing brace, so in a tab-indented file it ran to EOF and a later method's $userId hid a stub. The body now ends at the brace matching the signature's own indent. --- .../lib/check_caller_identity_exempt.py | 78 ++++-- .../test_gate_3_external_contract_and_tabs.sh | 254 ++++++++++++++++++ hydra-gates/scripts/run-hydra-gates.sh | 17 +- 3 files changed, 328 insertions(+), 21 deletions(-) create mode 100755 hydra-gates/scripts/lib/test_gate_3_external_contract_and_tabs.sh diff --git a/hydra-gates/scripts/lib/check_caller_identity_exempt.py b/hydra-gates/scripts/lib/check_caller_identity_exempt.py index 199a370d3..43ea4d7af 100644 --- a/hydra-gates/scripts/lib/check_caller_identity_exempt.py +++ b/hydra-gates/scripts/lib/check_caller_identity_exempt.py @@ -34,7 +34,8 @@ 1. **The signature is imposed from outside.** The declaring class names a supertype (``implements`` / ``extends``, transitively) that is resolvable in - this repository and declares the same method with the same parameter. This + this repository (under ``lib/`` or a ``stubs``/``Stubs`` directory: the root + one or any under ``tests/``) and declares the same method with the same parameter. This is fully mechanical: nothing the author asserts, only what the type graph says. It is what makes the exemption un-sprinklable — a fixer agent cannot escape the rule by adding a docblock line, because an invented service @@ -62,8 +63,8 @@ FAIL-CLOSED ----------- Anything unresolvable is NOT an exemption: a supertype that lives outside this -repository (an OCP interface, a vendored contract) cannot be inspected, so the -finding stands. Every error path exits non-zero. A gate helper that cannot +repository (an OCP interface, a vendored contract) and that no stub mirrors +cannot be inspected, so the finding stands. Every error path exits non-zero. A gate helper that cannot read its input must not answer "exempt". Usage: @@ -162,25 +163,62 @@ def parse_supertypes(header): return names +# Directories, besides lib/, whose PHP files mirror a contract that lives in +# ANOTHER app (.github: learniq's LifecycleGuardInterface false positive). +# +# Every guard learniq ships implements OpenRegister's +# `OCA\OpenRegister\Lifecycle\LifecycleGuardInterface::check(array $object, +# string $action, string $userId)`. OpenRegister is not a composer dependency, +# so the interface is not in this repository's lib/ and condition 1 could never +# be met: six correctly marked guards were reported with no closing action. +# +# The fleet already carries those contracts as PHP stubs, because psalm, +# phpstan and phpunit need them too (learniq, launchpad and pipelinq under +# tests/Stubs, thematiq under stubs/). A stub is INSPECTABLE, so reading it keeps +# condition 1 mechanical: the stub must declare the same method with the same +# parameter, exactly as a lib/ supertype must. A supertype found nowhere is +# still not an exemption (fail-closed is unchanged). +STUB_DIR_NAMES = ('stubs', 'Stubs') + + +def contract_dirs(root): + """lib/ plus every stub directory: /stubs and any tests/**/stubs.""" + dirs = [] + lib = os.path.join(root, 'lib') + if os.path.isdir(lib): + dirs.append(lib) + for name in STUB_DIR_NAMES: + top = os.path.join(root, name) + if os.path.isdir(top) and top not in dirs: + dirs.append(top) + tests = os.path.join(root, 'tests') + if os.path.isdir(tests): + for dirpath, dirnames, _filenames in os.walk(tests): + dirnames[:] = [d for d in dirnames if d not in ('node_modules', 'vendor')] + if os.path.basename(dirpath) in STUB_DIR_NAMES: + dirs.append(dirpath) + dirnames[:] = [] # its subtree is walked by index_types + return dirs + + def index_types(root): - """Map short type name -> [file paths] for every PHP type declared under lib/.""" + """Map short type name -> [file paths] for every PHP type declared under + lib/ or a stub directory (see STUB_DIR_NAMES).""" index = {} - lib = os.path.join(root, 'lib') - if not os.path.isdir(lib): - return index - for dirpath, _dirnames, filenames in os.walk(lib): - for fn in filenames: - if not fn.endswith('.php'): - continue - path = os.path.join(dirpath, fn) - try: - lines = read_lines(path) - except OSError: - continue - for line in lines: - m = TYPE_DECL.match(line) - if m: - index.setdefault(m.group(2), []).append(path) + for base in contract_dirs(root): + for dirpath, _dirnames, filenames in os.walk(base): + for fn in filenames: + if not fn.endswith('.php'): + continue + path = os.path.join(dirpath, fn) + try: + lines = read_lines(path) + except OSError: + continue + for line in lines: + m = TYPE_DECL.match(line) + if m and path not in index.get(m.group(2), []): + index.setdefault(m.group(2), []).append(path) return index diff --git a/hydra-gates/scripts/lib/test_gate_3_external_contract_and_tabs.sh b/hydra-gates/scripts/lib/test_gate_3_external_contract_and_tabs.sh new file mode 100755 index 000000000..d39cccd72 --- /dev/null +++ b/hydra-gates/scripts/lib/test_gate_3_external_contract_and_tabs.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: EUPL-1.2 +# +# test_gate_3_external_contract_and_tabs.sh: two gate-3 caller-identity defects +# measured on learniq (development @ 9b70119, 2026-10-04). +# +# 1. A CONTRACT FROM ANOTHER APP COULD NEVER BE RESOLVED. +# Six learniq guards implement OpenRegister's LifecycleGuardInterface::check( +# array $object, string $action, string $userId), mark $userId unused on the +# @param line, and were still reported, because the exemption only looked +# for the supertype under lib/. The contract is mirrored as a PHP stub under +# tests/Stubs (the fleet convention psalm, phpstan and phpunit already need), +# so the helper now reads stub directories too. Fail-closed is unchanged: +# a supertype found nowhere, a stub without the parameter, or a missing +# unused marker all still report. +# +# 2. A TAB-INDENTED BODY RAN TO EOF. +# The body stopped at `^ \}`, which a tab-indented file never contains, so +# a stub that ignored $userId was read as using it whenever a LATER method in +# the same file mentioned $userId. The body now ends at the brace that +# matches the signature's own indent. +# +# Run: bash scripts/lib/test_gate_3_external_contract_and_tabs.sh +set -uo pipefail + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +RUNNER="${LIB_DIR}/../run-hydra-gates.sh" + +_fail_count=0 +_pass_count=0 +_ok() { echo " PASS: $1"; _pass_count=$((_pass_count + 1)); } +_bad() { echo " FAIL: $1"; _fail_count=$((_fail_count + 1)); } + +[ -f "${RUNNER}" ] || { echo "FAIL: runner not found at ${RUNNER}"; exit 1; } + +# Build a repo from `path=content` pairs, run the runner, echo gate-3's +# caller-identity findings. The runner's exit code aggregates every gate and +# says nothing about gate-3, so the verdict is the log; gate-3's own verdict +# line must be present, or an unrun gate would read as a clean one. +_run_gate3_multi() { + local root logdir out pair rel content + root="$(mktemp -d "${TMPDIR:-/tmp}/g3ext.XXXXXX")" || return 1 + for pair in "$@"; do + rel="${pair%%=*}" + content="${pair#*=}" + mkdir -p "${root}/$(dirname "${rel}")" + printf '%s' "${content}" > "${root}/${rel}" + done + ( + cd "${root}" || exit 1 + git init -q . + git config user.email t@example.com + git config user.name t + git config commit.gpgsign false + git add -A && git commit -qm base + ) >/dev/null 2>&1 + logdir="$(mktemp -d "${TMPDIR:-/tmp}/g3extlogs.XXXXXX")" + out="$(cd "${root}" && HYDRA_GATE_LOG_DIR="${logdir}" bash "${RUNNER}" . 2>&1)" + if ! printf '%s\n' "${out}" | grep -qE '^\[gate-3\] stub-scan: (PASS|FAIL)'; then + echo "NO-GATE-3-VERDICT" + fi + grep 'caller-identity-ignored' "${logdir}/hydra-gate-stub-scan.log" 2>/dev/null + rm -rf "${root}" "${logdir}" + return 0 +} + +# _expect -- pairs... +_expect() { + local label="$1" want="$2" method="$3" out n + shift 3 + out="$(_run_gate3_multi "$@")" + if printf '%s' "${out}" | grep -q NO-GATE-3-VERDICT; then + _bad "${label}: gate-3 printed no PASS/FAIL verdict" + return + fi + n="$(printf '%s' "${out}" | grep -c . || true)" + if [ "${n}" -eq "${want}" ] && { [ -z "${method}" ] || printf '%s' "${out}" | grep -q "method=${method} "; }; then + _ok "${label}" + else + _bad "${label}: expected ${want} finding(s)${method:+ for ${method}}, got ${n}:" + printf '%s\n' "${out}" | sed 's/^/ /' + fi +} + +# shellcheck disable=SC2016 # PHP source: single quotes are REQUIRED +_STUB_IFACE=' $object The object as it would be saved. + * @param string $action The transition. + * @param string $userId The caller (unused: this guard does not depend on who asks). + * + * @return GuardResult + */ + public function check(array $object, string $action, string $userId): GuardResult { + if ((string)($object['\''learnerRef'\''] ?? '\'''\'') === '\'''\'') { + return GuardResult::deny('\''missing'\''); + } + + return GuardResult::allow(); + }//end check() +}//end class +' + +_GUARD_UNMARKED=' $object The object as it would be saved. + * @param string $action The transition. + * @param string $userId The caller. + * + * @return GuardResult + */ + public function check(array $object, string $action, string $userId): GuardResult { + if ((string)($object['\''learnerRef'\''] ?? '\'''\'') === '\'''\'') { + return GuardResult::deny('\''missing'\''); + } + + return GuardResult::allow(); + }//end check() +}//end class +' + +_FREE_THEN_USE='logger->info('\''authorizing'\''); + $this->bus->dispatch($object); + return true; + }//end authorize() + + /** + * A later method that genuinely uses its own $userId. + * + * @param string $userId The caller. + * + * @return string + */ + public function describe(string $userId): string { + $name = '\''user '\'' . $userId; + $this->logger->info($name); + return $name; + }//end describe() +}//end class +' + +_FREE_MARKED='logger->info('\''authorizing'\''); + $this->bus->dispatch($object); + return true; + }//end authorize() +}//end class +' + +echo "== gate-3: an external contract mirrored by a stub, and tab-indented bodies ==" +echo + +_expect "arm 1: learniq shape (external interface, tests/Stubs mirror, marked unused, tabs) is not reported" 0 "" \ + "tests/Stubs/Lifecycle/LifecycleGuardInterface.php=${_STUB_IFACE}" \ + "lib/Service/WalletClaimSyncService.php=${_GUARD}" + +_expect "arm 2: a root stubs/ mirror resolves the contract too" 0 "" \ + "stubs/LifecycleGuardInterface.php=${_STUB_IFACE}" \ + "lib/Service/WalletClaimSyncService.php=${_GUARD}" + +_expect "arm 3 CONTROL: no stub anywhere, so the supertype is unresolvable and the finding stands" 1 check \ + "lib/Service/WalletClaimSyncService.php=${_GUARD}" + +_expect "arm 4 CONTROL: stub present but the @param is not marked unused" 1 check \ + "tests/Stubs/Lifecycle/LifecycleGuardInterface.php=${_STUB_IFACE}" \ + "lib/Service/WalletClaimSyncService.php=${_GUARD_UNMARKED}" + +_expect "arm 5 CONTROL: the stub declares check() WITHOUT \$userId, so the class chose it" 1 check \ + "tests/Stubs/Lifecycle/LifecycleGuardInterface.php=${_STUB_IFACE_NOPARAM}" \ + "lib/Service/WalletClaimSyncService.php=${_GUARD}" + +_expect "arm 6: a tab-indented stub is reported even when a LATER method uses \$userId" 1 authorize \ + "lib/Service/PermissionService.php=${_FREE_THEN_USE}" + +_expect "arm 7 CONTROL: tab-indented, marked unused, but implements nothing: still reported" 1 authorize \ + "tests/Stubs/Lifecycle/LifecycleGuardInterface.php=${_STUB_IFACE}" \ + "lib/Service/PermissionService.php=${_FREE_MARKED}" + +echo +echo "== summary: ${_pass_count} passed, ${_fail_count} failed ==" +[ "${_fail_count}" -eq 0 ] || exit 1 +exit 0 diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index 278e768e4..29590e789 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -2438,7 +2438,22 @@ while IFS= read -r f; do _sig_region=$(awk -v start="${_line_no}" 'NR >= start { printf "%s", $0; if (/[;{]/) exit }' "${_stub_code}") _sig_term=$(printf '%s' "${_sig_region}" | grep -oE '[;{]' | head -1) [ "${_sig_term}" = ";" ] && continue - _body=$(awk -v start="${_line_no}" 'NR >= start { print; if (NR > start && /^ \}/) exit }' "${_stub_code}") + # THE BODY ENDS AT THE BRACE THAT MATCHES THE SIGNATURE'S OWN + # INDENT, NOT AT FOUR SPACES. The stop used to be `^ \}`, so a + # TAB-indented file (learniq, and anything else on the Nextcloud + # tab style) never matched it: "the body" ran from the signature to + # EOF. A stub that ignores $userId then read as using it whenever a + # LATER method in the file mentioned $userId (a false negative), and + # the `< 4` short-method skip below could never fire. The closing + # brace is the first line that is exactly the signature's leading + # whitespace followed by `}`, which is the same `^ \}` for a + # four-space file and `^\t\}` for a tab one. + _body=$(awk -v start="${_line_no}" ' + NR == start { match($0, /^[ \t]*/); ind = substr($0, 1, RLENGTH) } + NR >= start { + print + if (NR > start && substr($0, 1, length(ind)) == ind && substr($0, length(ind) + 1, 1) == "}") exit + }' "${_stub_code}") _body_lines=$(echo "${_body}" | wc -l) # A legitimate ≥3-line method that accepts a caller-identity # param and never references it is a stub. Skip very short From 567d8b2555e7c78068d974aabd1b274382359d4c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 4 Oct 2026 17:32:20 +0200 Subject: [PATCH 2/4] fix(gates-22,53): vendor manifest schema 2.42.0 from @conduction/nextcloud-vue 2.59.0 The vendored copy was 2.37.0, so gates 22 and 53 rejected learniq's run-action setup step with `onDemand`, a key the library publishes since schema 2.42.0. The file is byte-identical to the one the package ships, which is what hydra-gates-schema-sync.yml would vendor; it supersedes the 2.41.0 bump in #794, which still lacks onDemand. diff_schema_strictness.js names 11 tightened constraints between 2.37.0 and 2.42.0 (the same list as #794; 2.41.0 to 2.42.0 is additive). Validated the base and effective manifests of all 19 fleet apps that ship one against both schemas: no app turns red, dossiq and learniq turn green. --- .../scripts/lib/test_check_manifest.sh | 10 +- .../schemas/app-manifest-v2.schema.json | 195 ++++++++++++++---- .../setup-ondemand.manifest.json | 56 +++++ .../setup-unknown-step-key.manifest.json | 56 +++++ 4 files changed, 274 insertions(+), 43 deletions(-) create mode 100644 hydra-gates/scripts/test-fixtures/manifest-validation/setup-ondemand.manifest.json create mode 100644 hydra-gates/scripts/test-fixtures/manifest-validation/setup-unknown-step-key.manifest.json diff --git a/hydra-gates/scripts/lib/test_check_manifest.sh b/hydra-gates/scripts/lib/test_check_manifest.sh index c9ee24b8f..eb3fdee05 100755 --- a/hydra-gates/scripts/lib/test_check_manifest.sh +++ b/hydra-gates/scripts/lib/test_check_manifest.sh @@ -43,7 +43,7 @@ _no() { echo "FAIL — $1"; _fails=$((_fails + 1)); } # Guard the guard: if the fixtures go missing again, say so rather than quietly # re-running the "Tier 0, skipping" exit-0 path and reporting it as green. -for _f in valid-apphost malformed-apphost non-apphost; do +for _f in valid-apphost malformed-apphost non-apphost setup-ondemand setup-unknown-step-key; do if [ ! -f "${FIX}/${_f}.manifest.json" ]; then echo "FAIL — fixture ${FIX}/${_f}.manifest.json is MISSING; this suite cannot assert anything" exit 1 @@ -87,6 +87,14 @@ else _assert_log '^at /observability/health/checks/0/type' "the failure NAMES the bad health-check type" _assert_log '^at /deepLinks/0' "the failure NAMES the incomplete deepLink" _assert 0 non-apphost.manifest.json "non-AppHost manifest → PASS (unaffected)" + # The vendored schema must keep up with the published one (learniq, + # 2026-10-04): `onDemand` on a run-action setup step is published since + # schema 2.42.0 (@conduction/nextcloud-vue 2.59.0), and the 2.37.0 copy + # rejected a manifest the library accepts. The second fixture differs by one + # misspelt key, so the step stays closed to keys the library does not know. + _assert 0 setup-ondemand.manifest.json "setup step with onDemand (schema >= 2.42.0) → PASS" + _assert 1 setup-unknown-step-key.manifest.json "setup step with an unknown key → FAIL" + _assert_log '^at /setup/steps/1: must NOT have additional properties' "the failure NAMES the step with the unknown key" fi # --- the degraded contract -------------------------------------------------- diff --git a/hydra-gates/scripts/schemas/app-manifest-v2.schema.json b/hydra-gates/scripts/schemas/app-manifest-v2.schema.json index 3edddbcb3..46378cbb3 100644 --- a/hydra-gates/scripts/schemas/app-manifest-v2.schema.json +++ b/hydra-gates/scripts/schemas/app-manifest-v2.schema.json @@ -3,7 +3,7 @@ "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", "title": "Conduction App Manifest v2", "description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).", - "version": "2.37.0", + "version": "2.42.0", "type": "object", "required": ["$schema", "version"], "allOf": [ @@ -98,6 +98,7 @@ "suggestMap": { "type": "object", "description": "choice: map of suggestFrom value → suggested option value. Used to auto-suggest a default (e.g. RGS template suggested from organisation type).", "additionalProperties": { "type": "string" } }, "multiple": { "type": "boolean", "default": false, "description": "choice: allow multi-select." }, "action": { "type": "string", "description": "run-action: actionId POSTed to /api/setup/action/{action}." }, + "onDemand": { "type": "boolean", "default": false, "description": "run-action: the step runs only when the user clicks Run, never automatically, and is never outstanding work (it does not open the wizard or decide where a resumed wizard starts). The summary ticks it only when its action succeeded in the current session and shows it as not run otherwise, whatever the server reports. For destructive steps an app reports as done on purpose so they never auto-run, such as removing example data." }, "healthCheck": { "type": "boolean", "default": false, "description": "summary: render a health recap." }, "component": { "type": "string", "description": "component: app-registered component name (or use the #step- slot)." } } @@ -848,35 +849,6 @@ } } }, - "savedViewPlaces": { - "type": "object", - "additionalProperties": false, - "description": "A saved view of this page is a place. buildManifestRoutes() emits //:viewId under the name __view, the view's own presentation config decides how it opens, and a pinned view hangs under this page's navigation entry rather than beside it. Index pages only, and only meaningful alongside allowSavedViews: without the dropdown there is no view to be a place. Absent, the page keeps the dropdown and the ?view= query it has today.", - "properties": { - "enabled": { - "type": "boolean", - "default": false, - "description": "Whether this page's saved views are places. False reads as a page that considered it and declined, and renders exactly as a page that never named the key." - }, - "routeBase": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*$", - "default": "views", - "description": "The path segment between the page's own route and the view id, so 'views' gives /cases/views/42. Lowercase and dash only, because it is a segment of an address a person reads and sends." - }, - "navGroup": { - "type": "string", - "description": "The id of the navigation entry a pinned view hangs under. Omitted, it hangs under the entry that points at this page. A pinned view never becomes a top-level entry, so an app's navigation budget cannot be spent by a user pinning (ADR-097)." - }, - "pinnedCap": { - "type": "integer", - "minimum": 1, - "maximum": 20, - "default": 5, - "description": "How many pinned views the navigation renders under that entry. Past the cap the rest stay reachable from the page itself, so a person who pins twenty views gets a navigation that still reads (ADR-097)." - } - } - }, "savedViewTree": { "type": "object", "additionalProperties": false, @@ -1379,6 +1351,11 @@ "minimum": 1, "description": "Row span (>= 1)." }, + "sizeToContent": { + "type": ["boolean", "integer"], + "minimum": 1, + "description": "Opt-in: hand this placement's height to whatever it renders, so a widget that decides AT RUNTIME it has nothing to show costs no row. `true` sizes purely to content; an integer is a row minimum. It makes gridHeight advisory for this widget — right for anything whose height is whatever it turned out to be (a banner whose condition is false, a list that came back empty), wrong for anything with a deliberate size. Maps to CnDashboardGrid's per-item `sizeToContent`." + }, "props": { "type": "object", "description": "Optional widget-specific props passed at render time. Shape is widget-specific; not constrained here.", @@ -1400,10 +1377,43 @@ "_note": { "type": "string", "description": "Human-readable note. Optional on most widgets. REQUIRED when the containing page has type 'custom' — in that case this field must document why a standard page type was not feasible." + }, + "ncDashboard": { + "type": "object", + "additionalProperties": false, + "description": "Opt-in: also publish this placement as a widget on the native Nextcloud dashboard, alongside the app's own pages. An object rather than a boolean because a Nextcloud panel is a single narrow column with its own title, icon and link, none of which the grid placement carries. Presence of this key REQUIRES an `id` on the same entry (enforced in allOf below), and that id MUST be unique across the WHOLE manifest, not merely within its own page's widgets[] — Nextcloud's widget namespace is per app, so two pages both declaring id 'recent' derive one shared panel. Manifest-wide uniqueness is beyond JSON Schema; validateManifestV2 enforces it post-schema. Omitting the key leaves the widget visible only inside the app.", + "properties": { + "title": { + "type": "string", + "description": "Panel title on the Nextcloud dashboard. Falls back to the widget's own title when omitted." + }, + "icon": { + "type": "string", + "description": "MDI icon name (PascalCase), e.g. ViewDashboardOutline." + }, + "order": { + "type": "integer", + "description": "Initial ordering hint, passed through as IWidget::getOrder(). It decides where the panel first appears; once a user rearranges their own dashboard their arrangement wins, so this is a hint and never a guarantee." + }, + "link": { + "type": "string", + "pattern": "^(?![a-zA-Z][a-zA-Z0-9+.-]*:)(?!//)\\S+$", + "description": "In-app route the panel header links to, so a reader can open the page where the widget's full context lives. Written the way `pages[].route` is, e.g. '/reporting' — the host resolves it against its own app root with generateUrl() and never renders it bare. A scheme ('https:', and 'javascript:' above all) and a protocol-relative '//host' are both refused: the panel is rendered by Nextcloud's dashboard, outside this app's own pages, so a manifest is not a place to hand it an offsite destination." + } + } } }, "allOf": [ { "$ref": "#/$defs/sentinelGuardedValue" }, + { + "description": "ncDashboard requires a stable id. The host derives a permanent Nextcloud widget id from `id`, and Nextcloud stores each user's chosen widgets by that id in its own namespace, which no migration of this app can reach. A widget identified by array position would move onto a different widget whenever the array is reordered, silently changing what a user sees. `id`'s own pattern already refuses an empty string, so requiring the key is the whole of the rule expressible here; the manifest-wide uniqueness half lives in validateManifestV2.", + "if": { + "required": ["ncDashboard"] + }, + "then": { + "required": ["id"] + } + }, { "description": "sidebar slot: gridWidth MUST equal 1. The sidebar is a fixed-width panel; widgets always span the full width.", "if": { @@ -2079,7 +2089,7 @@ }, "handler": { "type": "string", - "description": "Registry key for type: 'handler'. Resolved against the customComponents map passed to CnAppRoot. Also the built-in dispatch verb when paired with `route` (handler: 'navigate' → $router.push to `route`)." + "description": "Registry key for type: 'handler'. Resolved across both registries passed to CnAppRoot: a v2 `registry` entry of `kind: 'handler'` exposing the function as `.handler` (or `.fn`), a directly function-valued registry entry, or a function-valued entry in the deprecated `customComponents` map. The same resolution serves actions[], bulkActions[] and headerActions[]. Also the built-in dispatch verb when paired with `route` (handler: 'navigate' → $router.push to `route`)." }, "route": { "type": "string", @@ -2367,12 +2377,24 @@ }, "op": { "type": "string", - "enum": ["eq", "neq", "gt", "gte", "lt", "lte"], + "enum": ["eq", "neq", "gt", "gte", "lt", "lte", "empty", "notEmpty"], "default": "eq", - "description": "Comparison operator (default eq). eq/neq compare loosely-normalised primitives; ordering operators coerce both sides to Number." + "description": "Comparison operator (default eq). eq/neq compare loosely-normalised primitives; ordering operators coerce both sides to Number. `empty` / `notEmpty` take no `value` and treat undefined, null, '' and an empty array alike — the one question eq cannot ask, because an unset field arrives in a shape that depends on the store and String(null) is the word 'null' rather than blank." }, "value": { - "description": "The literal right-hand side of the comparison." + "description": "The right-hand side of the comparison, resolved through the shared @-token grammar so `@me` names the signed-in reader rather than a literal only the server knows. Omitted for `empty` / `notEmpty`." + }, + "all": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/visibleWhen" }, + "description": "Every listed condition must hold. Use it where a gate is more than one fact — a Claim button that is both 'not closed' and 'nobody holds it'. Each entry is a full visibleWhen and may compose again." + }, + "any": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/visibleWhen" }, + "description": "At least one listed condition must hold. Ignored when `all` is also present." } } }, @@ -2618,10 +2640,6 @@ "$ref": "#/$defs/splitView", "description": "Index pages only. Opens a row beside the list rather than instead of it, at the sibling route /split/:id that buildManifestRoutes() emits. Refused on any other page type." }, - "savedViewPlaces": { - "$ref": "#/$defs/savedViewPlaces", - "description": "Index pages only. Makes each saved view of this page a place: its own route, the presentations its view config declares, and an entry under this page's navigation entry once a user pins it. Refused on any other page type." - }, "board": { "$ref": "#/$defs/boardView", "description": "Index pages only. The board view's configuration, handed to CnIndexPage as the `board` prop. The segment appears only when `viewModes` also lists `board`. Refused on any other page type." @@ -2632,7 +2650,7 @@ }, "savedViewTree": { "$ref": "#/$defs/savedViewTree", - "description": "Index pages only. Renders this page's saved views as a tree with labels, and declares the views that ship with the app. Refused on any other page type, for the same reason savedViewPlaces is: a page with no saved-view dropdown has nothing to put in a tree." + "description": "Index pages only. Renders this page's saved views as a tree with labels, and declares the views that ship with the app. Refused on any other page type: a page with no saved-view dropdown has nothing to put in a tree." }, "manualOrder": { "type": "boolean", @@ -2997,7 +3015,7 @@ }, "editOpensDetail": { "type": "boolean", - "description": "type='index': send the Edit row action to the record's detail page instead of opening the edit modal. Set automatically by CnPageRenderer when a type='detail' page exists for the same register+schema (or config.rowRoute is set); declare it explicitly only to override that. Maps to CnIndexPage `editOpensDetail`." + "description": "type='index': send the Edit row action to the record's detail page instead of opening the edit modal. OPT-IN — CnPageRenderer used to set this wherever a type='detail' page or config.rowRoute existed, and it no longer does: @edit-open is bound to the same navigation as a row click, so a routed Edit just repeats the row click (the split pane, or the detail page) and the form is no longer reachable from the list. Declare true per page when the modal cannot express the record. Maps to CnIndexPage `editOpensDetail`." }, "showEditAction": { "type": "boolean", @@ -3015,6 +3033,100 @@ "default": 1, "description": "How many columns the page's built-in Add/Edit form flows its fields into. Pair 2 with formSize 'large', or the two columns are merely two narrow ones. CnFormDialog collapses back to one column below 700px, and a textarea or JSON editor spans both columns regardless. Maps to CnIndexPage / CnDetailPage `formColumns`." }, + "excludeFields": { + "type": "array", + "items": { "type": "string" }, + "description": "Property names kept OUT of the page's built-in Add/Edit form dialog. Maps to CnIndexPage / CnDetailPage `excludeFields`." + }, + "includeFields": { + "type": "array", + "items": { "type": "string" }, + "description": "Property names the page's built-in Add/Edit form dialog shows, in this order — a whitelist, so anything absent is hidden regardless of excludeFields. Maps to CnIndexPage / CnDetailPage `includeFields`." + }, + "fieldOverrides": { + "type": "object", + "additionalProperties": true, + "description": "Per-field overrides merged over the schema-derived field descriptors in the page's built-in Add/Edit form dialog (e.g. `{ \"description\": { \"widget\": \"textarea\" } }`). Which widget a field renders as belongs to the surface, not to the schema. Maps to CnIndexPage / CnDetailPage `fieldOverrides`." + }, + "createDefaults": { + "type": "object", + "additionalProperties": true, + "description": "type='index': seed values for the page's built-in create dialog, never applied on edit. Resolves `@me`/`@now`/`@today` at any depth; `@object.*`/`@workspace.*`/`@config.*` need a context the page does not carry and pass through unchanged. Maps to CnIndexPage `createDefaults`." + }, + "createSuccessRoute": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "paramField": { "type": "string" }, + "objectParam": { "type": "string" } + }, + "required": ["name"], + "additionalProperties": false + } + ], + "description": "type='index': route opened after a successful create from the page's built-in Add dialog; the created object's id is merged into the params. Maps to CnIndexPage `createSuccessRoute`." + }, + "createSuccessMessage": { + "type": "string", + "description": "type='index': toast shown after a successful create from the page's built-in Add dialog. Maps to CnIndexPage `createSuccessMessage`." + }, + "savedViewsScope": { + "type": "string", + "description": "type='index': which pages share this page's saved views. By default a view is shared by every page over the same register and schema and by no other, because a view is filters over one schema's fields. Set this to share views across pages over different sources, or to keep two pages over one source apart. Written into the saved view's `query.scope`; views saved before scoping stay visible everywhere. Maps to CnIndexPage `savedViewsScope`." + }, + "splitCloseButton": { + "type": "boolean", + "default": true, + "description": "type='index': whether the split pane draws its own close link. Set false when a `#split-pane` slot draws one from the slot's `close`. Maps to CnIndexPage `splitCloseButton`." + }, + "splitCloseLabel": { + "type": "string", + "description": "type='index': accessible name and tooltip for the split pane's close button. Defaults to \"Close\". Maps to CnIndexPage `splitCloseLabel`." + }, + "mapConfig": { + "type": "object", + "additionalProperties": true, + "description": "type='index': how rows are plotted when the Map view mode is available. Non-empty (and not excluded by an explicit viewModes) adds a Map segment to the view toggle. Maps to CnIndexPage `mapConfig`.", + "properties": { + "latField": { + "type": "string", + "description": "Object (or `@self`) property path holding the marker's latitude. Dotted paths are supported, e.g. `@self.geo.lat`." + }, + "lngField": { + "type": "string", + "description": "Object (or `@self`) property path holding the marker's longitude." + }, + "geoField": { + "type": "string", + "description": "Alternative single property holding a GeoJSON Point geometry (`{ type: 'Point', coordinates: [lng, lat] }`). Takes precedence over latField/lngField when present and resolvable." + }, + "popupField": { + "type": "string", + "description": "Object property rendered in the marker popup." + }, + "center": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2, + "description": "Fallback centre `[lat, lng]` when the filtered set has no plottable rows." + }, + "clustering": { + "type": "boolean", + "default": true, + "description": "Whether coincident markers are grouped into a cluster that spiderfies on click. ON by default here — the opposite of CnMapWidget's own default — because an index map plots whatever the filter returns, and several rows at one address is the normal case: unclustered they land on identical pixels, the topmost takes every click and the rest are unreachable with nothing on screen saying they exist. Non-point geometry skips the cluster group, so areas are unaffected. Set false for the old behaviour." + } + } + }, + "quickFilterMaxVisible": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "type='index', chips mode: how many quickFilters render as pills before the rest move behind one more chip — a `⋯` pill that opens a panel of the hidden lenses, and that wears the label and the active fill of whichever one is on. 0 (the default) renders every lens, which wraps the actions bar onto a second line once a page declares more than a handful. The visible set is the FIRST n entries of quickFilters, so reorder that array to change which lenses stay out. Maps to CnIndexPage `quickFilterMaxVisible`." + }, "actionToggles": { "type": "object", "description": "Built-in action toggles for type='index' pages. Each key is sugar for the matching CnIndexPage prop — manifest authors can hide built-in mass actions / row actions / the Add button without writing a wrapper. Mirrors the CnIndexPage prop names. Explicit config. wins over config.actionToggles. (matches the readOnly precedence rule). Distinct from config.actions[] which is the typed row-actions array.", @@ -3101,7 +3213,7 @@ }, "allOf": [ { - "description": "splitView, manualOrder, savedViewPlaces, savedViewTree, board and dateAxis are index-page mechanics. splitView emits a second route beside an index route and mounts the detail pane inside CnIndexPage; manualOrder holds a per-user row order for a list; savedViewPlaces turns this page's saved views into addressable places. Declared on a detail, dashboard or custom page they would validate and then do nothing, which is the failure this branch refuses out loud (case-page-and-list-as-a-place, saved-view-as-a-place).", + "description": "splitView, manualOrder, savedViewTree, board and dateAxis are index-page mechanics. splitView emits a second route beside an index route and mounts the detail pane inside CnIndexPage; manualOrder holds a per-user row order for a list. Declared on a detail, dashboard or custom page they would validate and then do nothing, which is the failure this branch refuses out loud (case-page-and-list-as-a-place).", "if": { "not": { "properties": { "type": { "const": "index" } }, @@ -3113,7 +3225,6 @@ "anyOf": [ { "required": ["splitView"] }, { "required": ["manualOrder"] }, - { "required": ["savedViewPlaces"] }, { "required": ["savedViewTree"] }, { "required": ["board"] }, { "required": ["dateAxis"] } diff --git a/hydra-gates/scripts/test-fixtures/manifest-validation/setup-ondemand.manifest.json b/hydra-gates/scripts/test-fixtures/manifest-validation/setup-ondemand.manifest.json new file mode 100644 index 000000000..61e17afca --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/manifest-validation/setup-ondemand.manifest.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://conduction.nl/schemas/app-manifest-v2.schema.json", + "version": "1.0.0", + "menu": [ + { + "id": "Widgets", + "label": "Widgets", + "icon": "ViewDashboardOutline", + "route": "Widgets", + "order": 10 + } + ], + "pages": [ + { + "id": "Widgets", + "route": "/widgets", + "type": "index", + "title": "Widgets", + "config": { + "register": "fixture", + "schema": "widget" + } + }, + { + "id": "WidgetDetail", + "route": "/widgets/:id", + "type": "detail", + "title": "Widget", + "config": { + "register": "fixture", + "schema": "widget" + } + } + ], + "setup": { + "version": 1, + "completionConfigKey": "setup_completed_version", + "steps": [ + { + "id": "welcome", + "type": "info", + "title": "Welcome", + "body": "A short setup." + }, + { + "id": "remove-example-set", + "type": "run-action", + "action": "remove-example-set", + "title": "Remove the example data", + "required": false, + "body": "Runs only when you click the button.", + "onDemand": true + } + ] + } +} diff --git a/hydra-gates/scripts/test-fixtures/manifest-validation/setup-unknown-step-key.manifest.json b/hydra-gates/scripts/test-fixtures/manifest-validation/setup-unknown-step-key.manifest.json new file mode 100644 index 000000000..c4d24f575 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/manifest-validation/setup-unknown-step-key.manifest.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://conduction.nl/schemas/app-manifest-v2.schema.json", + "version": "1.0.0", + "menu": [ + { + "id": "Widgets", + "label": "Widgets", + "icon": "ViewDashboardOutline", + "route": "Widgets", + "order": 10 + } + ], + "pages": [ + { + "id": "Widgets", + "route": "/widgets", + "type": "index", + "title": "Widgets", + "config": { + "register": "fixture", + "schema": "widget" + } + }, + { + "id": "WidgetDetail", + "route": "/widgets/:id", + "type": "detail", + "title": "Widget", + "config": { + "register": "fixture", + "schema": "widget" + } + } + ], + "setup": { + "version": 1, + "completionConfigKey": "setup_completed_version", + "steps": [ + { + "id": "welcome", + "type": "info", + "title": "Welcome", + "body": "A short setup." + }, + { + "id": "remove-example-set", + "type": "run-action", + "action": "remove-example-set", + "title": "Remove the example data", + "required": false, + "body": "Runs only when you click the button.", + "onDemandd": true + } + ] + } +} From db3e0610a1fece26f9671c986aeb83f74a239300 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 4 Oct 2026 17:32:20 +0200 Subject: [PATCH 3/4] fix(gate-53): pin the JS helpers to CommonJS Node takes a .js file's module type from the nearest package.json above it. The package shipped none, so a checkout under a "type": "module" tree (the Nextcloud server checkout) loaded every helper as an ES module and gate-53 died with "require is not defined in ES module scope". --- hydra-gates/scripts/lib/package.json | 4 ++ .../scripts/lib/test_lib_commonjs_scope.sh | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 hydra-gates/scripts/lib/package.json create mode 100755 hydra-gates/scripts/lib/test_lib_commonjs_scope.sh diff --git a/hydra-gates/scripts/lib/package.json b/hydra-gates/scripts/lib/package.json new file mode 100644 index 000000000..4ad4d811a --- /dev/null +++ b/hydra-gates/scripts/lib/package.json @@ -0,0 +1,4 @@ +{ + "//": "The helpers here are CommonJS. Without this file Node takes the module type from the nearest package.json ABOVE the checkout, so a package sitting under a \"type\": \"module\" tree (the Nextcloud server checkout) crashed with: require is not defined in ES module scope.", + "type": "commonjs" +} diff --git a/hydra-gates/scripts/lib/test_lib_commonjs_scope.sh b/hydra-gates/scripts/lib/test_lib_commonjs_scope.sh new file mode 100755 index 000000000..c37e1658d --- /dev/null +++ b/hydra-gates/scripts/lib/test_lib_commonjs_scope.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: EUPL-1.2 +# +# test_lib_commonjs_scope.sh: the JS helpers must run wherever the package is +# checked out, including under a directory whose package.json says +# "type": "module". +# +# Node decides whether a .js file is CommonJS or an ES module from the NEAREST +# package.json above it. hydra-gates shipped none, so a checkout under the +# Nextcloud server tree (its package.json is "type": "module") turned every +# helper into an ES module, and gate-53 died with +# "require is not defined in ES module scope" before judging anything. +# scripts/lib/package.json pins the type to commonjs. +# +# Run: bash scripts/lib/test_lib_commonjs_scope.sh +set -uo pipefail + +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PKG_DIR="$(cd "${LIB_DIR}/../.." && pwd)" + +_fails=0 +_ok() { echo " PASS: $1"; } +_bad() { echo " FAIL: $1"; _fails=$((_fails + 1)); } + +work="$(mktemp -d "${TMPDIR:-/tmp}/hg-esm.XXXXXX")" || exit 1 +trap 'rm -rf "${work}"' EXIT +printf '{ "type": "module" }\n' > "${work}/package.json" +mkdir -p "${work}/hydra-gates" +cp -r "${PKG_DIR}/scripts" "${work}/hydra-gates/scripts" + +echo "== the JS helpers run under a parent package.json with \"type\": \"module\" ==" + +if [ -f "${work}/hydra-gates/scripts/lib/package.json" ]; then + _ok "scripts/lib/package.json is present" +else + _bad "scripts/lib/package.json is missing" +fi + +# The three helpers gate-53 chains, each run the way the runner runs it. +fixture="${work}/hydra-gates/scripts/test-fixtures/manifest-validation/non-apphost.manifest.json" +err="${work}/err.txt" +for helper in check_manifest.js build_effective_manifest.js check_manifest_crossref.js; do + case "${helper}" in + check_manifest.js) args=("${fixture}") ;; + *) args=(--help) ;; + esac + ( cd "${work}" && node "${work}/hydra-gates/scripts/lib/${helper}" "${args[@]}" ) >/dev/null 2>"${err}" + if grep -q 'require is not defined in ES module scope' "${err}"; then + _bad "${helper} was loaded as an ES module" + sed 's/^/ /' "${err}" | head -5 + else + _ok "${helper} loads as CommonJS" + fi +done + +echo +[ "${_fails}" -eq 0 ] || { echo "== ${_fails} failed =="; exit 1; } +echo "== all passed ==" +exit 0 From 92ae7225a684a63d3e6ca2117e680691f09a9d4f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 4 Oct 2026 17:49:55 +0200 Subject: [PATCH 4/4] test(gates-22,53): the real-pair strictness arm follows the key the latest bump added The arm stripped savedViewPlaces from the vendored schema, a key the library removed after 2.37.0, so it failed on the 2.42.0 copy. It now strips onDemand, the 2.42.0 addition, and asserts the key and the closed step are still there so a later bump says what to re-point instead of failing obscurely. --- .../lib/test_diff_schema_strictness.js | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/hydra-gates/scripts/lib/test_diff_schema_strictness.js b/hydra-gates/scripts/lib/test_diff_schema_strictness.js index ff6ce9af2..121ae9a7b 100644 --- a/hydra-gates/scripts/lib/test_diff_schema_strictness.js +++ b/hydra-gates/scripts/lib/test_diff_schema_strictness.js @@ -105,24 +105,30 @@ check('a property REMOVED under additionalProperties:false is reported', () => { // --- the real pair this helper was written for ------------------------------ -check('the shipped 2.33.0 -> 2.37.0 bump reads additive in BOTH directions of the check', () => { - // Forward: nothing got stricter, which is why #785 could be merged without - // a warning period. Reverse: the same two files DO produce findings, which - // is the control proving the forward "none" was a measurement and not a - // checker that cannot speak. +check('the shipped 2.41.0 -> 2.42.0 bump reads additive in BOTH directions of the check', () => { + // Forward: nothing got stricter. Reverse: the same two files DO produce + // findings, which is the control proving the forward "none" was a + // measurement and not a checker that cannot speak. + // + // The pair is the vendored schema with and without the key its latest bump + // added. It used to be savedViewPlaces (2.33.0 -> 2.37.0, #785); the + // library has since dropped that key, so the arm now follows the addition + // the current copy was vendored for: `onDemand` on a setup step (2.42.0). const vendored = path.resolve(__dirname, '..', 'schemas', 'app-manifest-v2.schema.json') assert.ok(fs.existsSync(vendored), 'vendored schema is missing') const doc = JSON.parse(fs.readFileSync(vendored, 'utf8')) + const step = doc.properties.setup.properties.steps.items + assert.ok(step.properties.onDemand, 'the vendored schema no longer declares setup.steps[].onDemand; re-point this arm at the latest addition') + assert.strictEqual(step.additionalProperties, false, 'setup steps are no longer closed; the reverse control cannot fire') const stripped = JSON.parse(JSON.stringify(doc)) - delete stripped.$defs.savedViewPlaces - delete stripped.$defs.page.properties.savedViewPlaces + delete stripped.properties.setup.properties.steps.items.properties.onDemand const forward = run(stripped, doc) - assert.strictEqual(forward.code, 0, `adding savedViewPlaces back should be additive, got exit ${forward.code}: ${forward.out}`) + assert.strictEqual(forward.code, 0, `adding onDemand back should be additive, got exit ${forward.code}: ${forward.out}`) const reverse = run(doc, stripped) - assert.strictEqual(reverse.code, 1, 'removing savedViewPlaces must be reported as a tightening') - assert.match(reverse.out, /savedViewPlaces/) + assert.strictEqual(reverse.code, 1, 'removing onDemand must be reported as a tightening') + assert.match(reverse.out, /onDemand/) }) fs.rmSync(TMP, { recursive: true, force: true })