diff --git a/hydra-gates/scripts/lib/check_manifest_crossref.js b/hydra-gates/scripts/lib/check_manifest_crossref.js index 1b65bfdf..e2aa406b 100644 --- a/hydra-gates/scripts/lib/check_manifest_crossref.js +++ b/hydra-gates/scripts/lib/check_manifest_crossref.js @@ -33,6 +33,17 @@ // menu-layout.json#removals must, after assembly, leave // its route reachable via another surviving menu entry. // FAIL on an orphaned route. +// (f) registry-crossref— the manifest and src/registry.js must agree about +// which components exist. A manifest `component` / +// slot-override naming no registry export renders +// NOTHING at runtime (FAIL); a registry export of kind +// section/page/widget that no manifest position names +// is unreachable UI (WARN — an orphan is either wired +// or deleted and the gate cannot know which). +// Cn* names resolve from the nextcloud-vue library, not +// the app registry, and are exempt. Skipped entirely +// when the app ships no src/registry.js. +// Closes #238 / larpingapp#286. // // Report shape (mirrors gate-22 / check_manifest.js): on findings, ONE // machine-parseable per-file JSON line, then always the JSON summary line — @@ -194,6 +205,140 @@ function collectSlugPairs(node, ptr, nearestId, out) { } } +// --- (f) component-registry cross-reference ---------------------------------- +// +// WHY THIS IS STATICALLY CHECKABLE AFTER ALL +// +// The gate used to decline the component registry wholesale — "src/registry.js +// is app code, not statically checkable" — and that blind spot shipped +// larpingapp#286: `EventRoster` was registered, resolvable, and named by no +// manifest position, so the event check-in surface had no entry point. It was +// unreachable UI long enough for its openspec task to be ticked over it, and +// BOTH gates that exist to catch manifest cross-reference defects were silent, +// in both directions. +// +// src/registry.js is app code but it is not opaque. It is a fixed-shape ES +// module whose top-level `export default { … }` keys are the registry's public +// surface. We cannot `import` it — it pulls in `.vue` SFCs — but the keys are +// extractable with brace-depth tracking, which is exactly how the app-local +// test in larpingapp#288 does it. +// +// DIRECTIONS, AND WHY THEY HAVE DIFFERENT SEVERITIES +// +// 2 → FAIL. A manifest `component` naming no registry key renders NOTHING. +// CnObjectSidebar.resolveTabComponent() logs `component "…" not found in +// registry or customComponents` and the tab comes up blank. This is +// gate-14 route-reachability one layer up: unambiguously broken. +// 1 → WARN. A registered component no manifest position names is either a +// component that should be wired or one that should be deleted, and the +// gate cannot know which — the same "zero callers has two opposite +// fixes" property that made this worth reporting rather than prescribing. +const REGISTRY_KINDS_REQUIRING_A_POSITION = new Set(['section', 'page', 'widget']) + +// `Cn[A-Z]…` names resolve from the nextcloud-vue library, not the app +// registry. Treating them as unresolved would fail every well-formed manifest +// in the fleet — the widening that would make this check useless on arrival. +const LIB_COMPONENT = /^Cn[A-Z]\w*$/ + +// Strip line and block comments so a commented-out entry is NOT counted as a +// registration. A commented-out prelude counting as a prelude was a real +// false-GREEN in gate-64; the same mistake here would let a deleted component +// vouch for a manifest reference that resolves to nothing at runtime. +function stripJsComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1 ') +} + +// Top-level keys of the `export default { … }` object, with their `kind`. +// Brace-depth tracking keeps nested object keys (`component:`, `props:`) out. +function parseRegistry(appDir) { + const file = path.join(appDir, 'src', 'registry.js') + let raw + try { + raw = fs.readFileSync(file, 'utf8') + } catch (e) { + return null // no registry — check (f) is not applicable + } + const src = stripJsComments(raw) + const start = src.search(/export\s+default\s*\{/) + if (start === -1) return { file, entries: new Map(), parsed: false } + + const open = src.indexOf('{', start) + const entries = new Map() + let depth = 0 + let i = open + let bodyStart = -1 + for (; i < src.length; i++) { + const c = src[i] + if (c === '{') { depth++; if (depth === 1) bodyStart = i + 1 } else if (c === '}') { + depth-- + if (depth === 0) break + } + } + if (depth !== 0 || bodyStart === -1) return { file, entries: new Map(), parsed: false } + const body = src.slice(bodyStart, i) + + // Walk the body, recording `Name:` / `'Name':` / `"Name":` at depth 0 only. + depth = 0 + const KEY = /(?:^|[,{\s])(?:['"]?)([A-Za-z_$][\w$]*)(?:['"]?)\s*:/g + // Depth map: for each index, how deep we are. Cheap enough for these files. + const depthAt = new Array(body.length).fill(0) + for (let j = 0; j < body.length; j++) { + const c = body[j] + if (c === '{' || c === '[') depth++ + depthAt[j] = depth + if (c === '}' || c === ']') depth-- + } + let m + while ((m = KEY.exec(body)) !== null) { + const at = m.index + m[0].indexOf(m[1]) + if (depthAt[at] !== 0) continue + const name = m[1] + // `kind: 'section'` inside this entry's own braces. + const tail = body.slice(m.index, m.index + 400) + const km = /\bkind\s*:\s*['"]([a-z-]+)['"]/.exec(tail) + entries.set(name, { kind: km ? km[1] : null }) + } + // Shorthand `Name,` entries (no colon) — a registration all the same. + const SHORT = /(?:^|[,{])\s*([A-Za-z_$][\w$]*)\s*(?=[,}])/g + while ((m = SHORT.exec(body)) !== null) { + const at = m.index + m[0].indexOf(m[1]) + if (depthAt[at] !== 0) continue + if (!entries.has(m[1])) entries.set(m[1], { kind: null }) + } + return { file, entries, parsed: true } +} + +// Every manifest position that names a component by string. Covers +// pages[].component, config.sections[].component, config.sidebar.tabs[]. +// component, widget component fields and `slots` overrides, at any depth. +function collectComponentRefs(node, ptr, out) { + if (Array.isArray(node)) { + node.forEach((v, i) => collectComponentRefs(v, `${ptr}/${i}`, out)) + return + } + if (!node || typeof node !== 'object') return + for (const [k, v] of Object.entries(node)) { + if (k === '_note' || k === '_meta') continue + if (k === 'component' && typeof v === 'string' && v !== '') { + out.push({ ptr: `${ptr}/component`, name: v }) + continue + } + // `slots: { 'photos-leaf': 'ObjectDetail' }` — slot-override map whose + // VALUES are registry names. + if (k === 'slots' && v && typeof v === 'object' && !Array.isArray(v)) { + for (const [slot, target] of Object.entries(v)) { + if (typeof target === 'string' && target !== '') { + out.push({ ptr: `${ptr}/slots/${slot}`, name: target }) + } + } + continue + } + collectComponentRefs(v, `${ptr}/${k}`, out) + } +} + // Recursively collect menu entries carrying a `route` (any nesting depth). function collectMenuRoutes(items, ptr, out) { if (!Array.isArray(items)) return @@ -404,6 +549,32 @@ function main() { } } + // (f) component-registry cross-reference — larpingapp#286, both directions. + const registry = parseRegistry(APP_DIR) + if (registry && registry.parsed) { + const refs = [] + collectComponentRefs({ pages: manifest.pages }, '', refs) + const named = new Set(refs.map((r) => r.name)) + + // Direction 2 — a manifest position naming a component nobody registers. + // Renders nothing at runtime, so this FAILS. + for (const { ptr, name } of refs) { + if (LIB_COMPONENT.test(name)) continue + if (registry.entries.has(name)) continue + fail('registry-crossref', ptr, + `component '${name}' is named by the manifest but is not exported by src/registry.js — resolveTabComponent() falls through and renders NOTHING`) + } + + // Direction 1 — a registered component no manifest position names. + // Either wire it or delete it; the gate cannot know which, so WARN. + for (const [name, meta] of registry.entries) { + if (named.has(name)) continue + if (!REGISTRY_KINDS_REQUIRING_A_POSITION.has(meta.kind)) continue + warn('registry-crossref', '/pages', + `src/registry.js exports '${name}' (kind '${meta.kind}') but no manifest tabs[]/sections[]/page entry names it — the surface it renders has no entry point. Wire it, or delete it`) + } + } + report(manifestLabel, manifest) } diff --git a/hydra-gates/scripts/lib/test_check_manifest_crossref.js b/hydra-gates/scripts/lib/test_check_manifest_crossref.js index d9e6a3ed..f275c52c 100644 --- a/hydra-gates/scripts/lib/test_check_manifest_crossref.js +++ b/hydra-gates/scripts/lib/test_check_manifest_crossref.js @@ -61,6 +61,12 @@ const VALIDATOR = path.join(LIB, 'check_manifest.js') 'broken/src/manifest.d/10-besluiten.json', 'broken/src/menu-layout.json', 'broken/lib/Settings/zaken-register.json', + 'registry-wired/src/manifest.json', + 'registry-wired/src/registry.js', + 'registry-orphan/src/manifest.json', + 'registry-orphan/src/registry.js', + 'registry-missing/src/manifest.json', + 'registry-missing/src/registry.js', ] const missing = required.filter((rel) => !fs.existsSync(path.join(FIX, rel))) if (missing.length > 0) { @@ -195,6 +201,99 @@ function parseReport(stdout) { } } +// --- (f) component-registry cross-reference (larpingapp#286) ----------------- +// +// The acceptance test for ConductionNL/.github#238. Both directions, each with +// its opposite as the control: a gate that only ever fires is as useless as one +// that never does, so `registry-wired` must stay silent while the other two +// speak. +{ + const REG = (name) => path.join(FIX, name) + + // registry-wired — everything registered is positioned. Silence expected. + { + const check = run([CHECKER, '--app-dir', REG('registry-wired')]) + const rep = parseReport(check.stdout) + const rx = rep.findings.filter((f) => f.check === 'registry-crossref') + assert(rx.length === 0, `registry-wired: zero registry-crossref findings (got ${rx.length})`) + assert(check.status === 0, 'registry-wired: checker exits 0') + } + + // DIRECTION 1 — registered, positioned by nothing. larpingapp#286 as shipped. + { + const check = run([CHECKER, '--app-dir', REG('registry-orphan')]) + const rep = parseReport(check.stdout) + const rx = rep.findings.filter((f) => f.check === 'registry-crossref') + assert(rx.length === 1 && rx[0].message.includes("'EventRoster'"), + `registry-orphan: exactly one registry-crossref finding naming EventRoster (got ${rx.length})`) + assert(rx.length === 1 && rx[0].severity === 'warn', + 'registry-orphan: DIRECTION 1 is a WARN — an orphan is either wired or deleted and the gate cannot know which') + assert(check.status === 0, + 'registry-orphan: a warn does not set the exit code') + } + + // DIRECTION 2 — positioned, registered by nothing. Renders a blank tab. + { + const check = run([CHECKER, '--app-dir', REG('registry-missing')]) + const rep = parseReport(check.stdout) + const errs = rep.findings.filter((f) => f.check === 'registry-crossref' && f.severity === 'error') + assert(errs.length === 1 && errs[0].message.includes("'ThisComponentDoesNotExistAnywhere'"), + `registry-missing: exactly one registry-crossref ERROR naming the unresolvable component (got ${errs.length})`) + assert(errs.length === 1 && errs[0].path === '/pages/0/config/sidebar/tabs/0/component', + 'registry-missing: the error points at the exact manifest position, not just the page') + assert(check.status === 1, + 'registry-missing: DIRECTION 2 sets the exit code — a component that resolves to nothing renders nothing') + } + + // THE FALSE-POSITIVE CONTROLS. Each of these, if it regressed, would fail + // every well-formed manifest in the fleet — the widening that would make + // this check useless on arrival rather than after a slow drift. + { + const check = run([CHECKER, '--app-dir', REG('registry-wired')]) + const rep = parseReport(check.stdout) + const msgs = rep.findings.map((f) => f.message).join(' | ') + assert(!msgs.includes('CnSearchPage'), + 'control: a Cn* lib component is NOT reported unresolved — it resolves from nextcloud-vue, not the app registry') + assert(!msgs.includes('ConfirmDialog'), + "control: a kind:'modal' entry is NOT reported orphaned — open-modal targets are runtime-resolved and gate (b) already warns") + assert(!msgs.includes('featureFlags'), + 'control: a metadata-only registry entry with no kind is NOT reported orphaned') + } + + // The parser must not count a COMMENTED-OUT registration. A commented-out + // prelude counting as a prelude was a real false-GREEN in gate-64; here it + // would let a deleted component vouch for a manifest reference that + // resolves to nothing at runtime. + { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gate30-reg-')) + fs.mkdirSync(path.join(tmp, 'src'), { recursive: true }) + fs.copyFileSync(path.join(REG('registry-wired'), 'src', 'manifest.json'), + path.join(tmp, 'src', 'manifest.json')) + fs.writeFileSync(path.join(tmp, 'src', 'registry.js'), + 'export default {\n' + + '\t// EventRoster: { kind: "section", component: EventRoster },\n' + + '\t/* SkillTree: { kind: "page", component: SkillTree }, */\n' + + '}\n') + const check = run([CHECKER, '--app-dir', tmp]) + const errs = parseReport(check.stdout).findings + .filter((f) => f.check === 'registry-crossref' && f.severity === 'error') + assert(errs.length === 2 + && errs.some((e) => e.message.includes("'EventRoster'")) + && errs.some((e) => e.message.includes("'SkillTree'")), + `commented-out registrations do NOT count as registrations (expected 2 errors, got ${errs.length})`) + fs.rmSync(tmp, { recursive: true, force: true }) + } + + // No src/registry.js at all → check (f) is simply not applicable. The + // `good` fixture has none, so this also pins that the existing assertions + // above were not silently altered by adding this check. + { + const check = run([CHECKER, '--app-dir', path.join(FIX, 'good')]) + const rx = parseReport(check.stdout).findings.filter((f) => f.check === 'registry-crossref') + assert(rx.length === 0, 'no src/registry.js → check (f) not applicable, zero findings') + } +} + console.log('') if (fails === 0) { console.log('ALL gate-30 effective-manifest-crossref assertions PASSED') diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/registry-missing/src/manifest.json b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-missing/src/manifest.json new file mode 100644 index 00000000..b5be310c --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-missing/src/manifest.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": "2.0", + "app": { + "id": "regfix", + "name": "Registry Fixture" + }, + "menu": [ + { + "id": "events", + "label": "Events", + "icon": "Calendar", + "route": "EventDetail" + }, + { + "id": "skills", + "label": "Skill tree", + "icon": "Toolbox", + "route": "SkillTree" + } + ], + "pages": [ + { + "id": "EventDetail", + "route": "EventDetail", + "type": "detail", + "title": "Event", + "config": { + "register": "larping", + "schema": "event", + "sidebar": { + "show": true, + "tabs": [ + { + "id": "checkin", + "label": "Check-in", + "icon": "AccountCheck", + "component": "ThisComponentDoesNotExistAnywhere" + } + ] + } + } + }, + { + "id": "SkillTree", + "route": "SkillTree", + "type": "custom", + "title": "Skill tree", + "component": "SkillTree", + "_note": "Read-only skill-tree visualisation; no standard page type renders a DAG.", + "config": {} + }, + { + "id": "LibPage", + "route": "LibPage", + "type": "custom", + "title": "Lib page", + "component": "CnSearchPage", + "config": {} + } + ] +} diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/registry-missing/src/registry.js b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-missing/src/registry.js new file mode 100644 index 00000000..fae0845e --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-missing/src/registry.js @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: EUPL-1.2 +// +// Fixture: DIRECTION 2 — the manifest names a component nobody registers. +// +// The Check-in tab in this fixture's manifest names +// `ThisComponentDoesNotExistAnywhere`. At runtime +// CnObjectSidebar.resolveTabComponent() logs `component "…" not found in +// registry or customComponents` and renders a BLANK TAB. Unambiguously broken, +// so check (f) FAILS rather than warns — this is gate-14 route-reachability +// one layer up. +// +// `EventRoster` is still registered here and still named by nothing (the tab +// that used to name it now names the missing component), so this fixture +// carries BOTH directions at once — which is also the realistic shape of a +// botched rename. +import EventRoster from './views/EventRoster.vue' +import SkillTree from './views/SkillTree.vue' +import HelperThing from './lib/HelperThing.js' + +export default { + EventRoster: { kind: 'section', component: EventRoster }, + SkillTree: { kind: 'page', component: SkillTree }, + ConfirmDialog: { kind: 'modal', component: HelperThing }, + featureFlags: { enabled: true }, +} diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/registry-orphan/src/manifest.json b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-orphan/src/manifest.json new file mode 100644 index 00000000..7d6c5535 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-orphan/src/manifest.json @@ -0,0 +1,60 @@ +{ + "schemaVersion": "2.0", + "app": { + "id": "regfix", + "name": "Registry Fixture" + }, + "menu": [ + { + "id": "events", + "label": "Events", + "icon": "Calendar", + "route": "EventDetail" + }, + { + "id": "skills", + "label": "Skill tree", + "icon": "Toolbox", + "route": "SkillTree" + } + ], + "pages": [ + { + "id": "EventDetail", + "route": "EventDetail", + "type": "detail", + "title": "Event", + "config": { + "register": "larping", + "schema": "event", + "sidebar": { + "show": true, + "tabs": [ + { + "id": "checkin", + "label": "Check-in", + "icon": "AccountCheck" + } + ] + } + } + }, + { + "id": "SkillTree", + "route": "SkillTree", + "type": "custom", + "title": "Skill tree", + "component": "SkillTree", + "_note": "Read-only skill-tree visualisation; no standard page type renders a DAG.", + "config": {} + }, + { + "id": "LibPage", + "route": "LibPage", + "type": "custom", + "title": "Lib page", + "component": "CnSearchPage", + "config": {} + } + ] +} diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/registry-orphan/src/registry.js b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-orphan/src/registry.js new file mode 100644 index 00000000..b79a9105 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-orphan/src/registry.js @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: EUPL-1.2 +// +// Fixture: DIRECTION 1 — ConductionNL/larpingapp#286, exactly as it shipped. +// +// `EventRoster` is registered, resolvable, and named by NO manifest position. +// The event check-in surface it renders therefore has no entry point: the UI +// was unreachable long enough for its openspec task to be ticked over it, and +// both gate-22 and gate-53 reported PASS the whole time, in both directions. +// +// Identical to registry-wired/src/registry.js. The ONLY difference between the +// two fixtures is the manifest: here the Check-in tab omits `component`. +import EventRoster from './views/EventRoster.vue' +import SkillTree from './views/SkillTree.vue' +import HelperThing from './lib/HelperThing.js' + +export default { + EventRoster: { kind: 'section', component: EventRoster }, + SkillTree: { kind: 'page', component: SkillTree }, + ConfirmDialog: { kind: 'modal', component: HelperThing }, + featureFlags: { enabled: true }, +} diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/registry-wired/src/manifest.json b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-wired/src/manifest.json new file mode 100644 index 00000000..70fa56c0 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-wired/src/manifest.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": "2.0", + "app": { "id": "regfix", "name": "Registry Fixture" }, + "menu": [ + { "id": "events", "label": "Events", "icon": "Calendar", "route": "EventDetail" }, + { "id": "skills", "label": "Skill tree", "icon": "Toolbox", "route": "SkillTree" } + ], + "pages": [ + { + "id": "EventDetail", + "route": "EventDetail", + "type": "detail", + "title": "Event", + "config": { + "register": "larping", + "schema": "event", + "sidebar": { + "show": true, + "tabs": [ + { "id": "checkin", "label": "Check-in", "icon": "AccountCheck", "component": "EventRoster" } + ] + } + } + }, + { + "id": "SkillTree", + "route": "SkillTree", + "type": "custom", + "title": "Skill tree", + "component": "SkillTree", + "_note": "Read-only skill-tree visualisation; no standard page type renders a DAG.", + "config": {} + }, + { + "id": "LibPage", + "route": "LibPage", + "type": "custom", + "title": "Lib page", + "component": "CnSearchPage", + "config": {} + } + ] +} diff --git a/hydra-gates/scripts/test-fixtures/effective-manifest/registry-wired/src/registry.js b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-wired/src/registry.js new file mode 100644 index 00000000..92396979 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/effective-manifest/registry-wired/src/registry.js @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: EUPL-1.2 +// +// Fixture: larpingapp#286 shape, CORRECTLY WIRED. Every kind:'section'/'page' +// entry is named by a manifest position, so check (f) reports nothing. +// +// The real file imports .vue SFCs, which is why the gate parses the export +// block textually instead of require()-ing it. Kept here for fidelity. +import EventRoster from './views/EventRoster.vue' +import SkillTree from './views/SkillTree.vue' +import HelperThing from './lib/HelperThing.js' + +export default { + // Event check-in roster — a sidebar-tab section on the event detail page. + EventRoster: { kind: 'section', component: EventRoster }, + // Skill-tree visualization — a read-only type:"custom" page. + SkillTree: { kind: 'page', component: SkillTree }, + // kind:'modal' is deliberately NOT required to have a manifest position: + // open-modal targets are resolved at runtime and gate (b) already WARNs + // about them. If this ever starts producing an orphan warning, direction 1 + // has begun reporting on a surface it cannot see. + ConfirmDialog: { kind: 'modal', component: HelperThing }, + // A metadata-only entry with no kind at all — not a renderable surface. + featureFlags: { enabled: true }, +}