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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 58 additions & 20 deletions hydra-gates/scripts/lib/check_caller_identity_exempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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: <root>/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


Expand Down
4 changes: 4 additions & 0 deletions hydra-gates/scripts/lib/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
10 changes: 9 additions & 1 deletion hydra-gates/scripts/lib/test_check_manifest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 --------------------------------------------------
Expand Down
26 changes: 16 additions & 10 deletions hydra-gates/scripts/lib/test_diff_schema_strictness.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading
Loading