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
171 changes: 171 additions & 0 deletions hydra-gates/scripts/lib/check_manifest_crossref.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
99 changes: 99 additions & 0 deletions hydra-gates/scripts/lib/test_check_manifest_crossref.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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": {}
}
]
}
Original file line number Diff line number Diff line change
@@ -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 },
}
Loading
Loading