diff --git a/internal/check-instance-config-keys.mjs b/internal/check-instance-config-keys.mjs new file mode 100644 index 000000000..90e280dfd --- /dev/null +++ b/internal/check-instance-config-keys.mjs @@ -0,0 +1,92 @@ +/** + * check-instance-config-keys.mjs — a preset's profile-config keys must be keys the profile READS. + * + * ★ THE DEFECT (rega RQ20): people-groups-grid's "driven" preset declares "show-search":"false" and + * "show-filters":"false", and the page renders the search box and the filters bar anyway. Nothing + * errored — 0 console errors — because nothing was wrong at runtime. The key simply never reached + * the component: + * + * · the preset writes KEBAB ("show-search") into profile-config + * · ProfileLoader PROVIDES that object; it does not bind its keys as props + * · the profile reads props.showSearch (CAMEL), which therefore keeps its default of true + * + * A preset key nobody reads is indistinguishable from a working one by looking at the preset, which + * is why this is a gate and not a comment. It is the same shape as the @element defect: an + * annotation a build copies into a machine-read artifact is an INTERFACE, and a build that accepts + * prose there is missing a validator. + * + * WHAT IT CHECKS, per preset key: either the profile declares a prop of that name, or it reads the + * key off the injected config (cfg.key / cfg['key']). Generator-owned and transport keys are exempt + * — they are consumed by ProfileLoader and the page, not by the profile. + */ +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { join } from 'node:path' + +const ROOT = new URL('../plugin-designs/', import.meta.url).pathname + +// Consumed by ProfileLoader / the generated page itself, never by a profile component. +const TRANSPORT = new Set([ + 'profile', 'instanceId', 'dataSource', 'assetsBase', 'colorSet', 'tk', 'token', + 'apiBaseUrl', 'locale', 'lang', 'provider', 'mapProvider', +]) + +const camel = (k) => k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) +const fail = [] +let checkedPresets = 0 +let checkedKeys = 0 + +for (const bundle of readdirSync(ROOT, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith('_'))) { + const dir = join(ROOT, bundle.name) + for (const file of readdirSync(dir).filter((f) => f.endsWith('.instances.json'))) { + let doc + try { doc = JSON.parse(readFileSync(join(dir, file), 'utf8')) } catch { continue } + const profileName = doc.profile || file.replace('.instances.json', '') + const vue = join(dir, `${profileName}.vue`) + if (!existsSync(vue)) continue + const src = readFileSync(vue, 'utf8') + + for (const inst of doc.instances || []) { + checkedPresets++ + for (const rawKey of Object.keys(inst.props || {})) { + if (TRANSPORT.has(rawKey)) continue + checkedKeys++ + const asCamel = camel(rawKey) + // A prop declaration, in either spelling… + const declaresProp = new RegExp(`\\b${asCamel}\\s*:\\s*\\{`).test(src) + // …or the profile reads the key off the injected config under ANY local name for it. + // ★ THE FIRST VERSION OF THIS LINE LOOKED ONLY FOR `cfg.` AND REPORTED NINE FALSE POSITIVES + // across four plugins: big-picture reads config.value.accent, doxa-simple-map reads + // profileConfig?.value?.tabs. The config object's VARIABLE NAME is the profile's own + // business, so matching on it was measuring my own naming habit rather than the contract. + // What matters is that the key is read off SOMETHING, so the accessor is what gets matched. + // ★ AND `props.` MUST NOT COUNT. Widening it to "any accessor" then matched + // `props.showSearch` — the exact line that DOES NOT work — and the gate went green on the + // defect it was written for. A prop read is the broken path: ProfileLoader provides the + // config, it never binds its keys as props. So the accessor is matched everywhere EXCEPT + // off `props`, which is the one object that cannot carry a profile-config key. + const accessor = new RegExp( + `(? { // ── Cat-2 show/hide (accept the DOM-attribute string 'false') ────────────────── const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0) -const useSearch = computed(() => truthy(props.showSearch)) -const useFilters = computed(() => truthy(props.showFilters)) -const useTotal = computed(() => truthy(props.showTotal)) + +// ★ THE PRESET'S KEYS WERE NEVER REACHING THESE (rega RQ20). The "driven" preset asks for +// show-search:false and show-filters:false and the page rendered both anyway, with 0 console +// errors — because nothing was failing. ProfileLoader PROVIDES profile-config; it does not bind +// its keys as props. So props.showSearch stayed at its default of true and the preset was inert. +// +// Read the config as well as the prop, in the same prop -> cfg ladder researchUrl above already +// uses. BOTH SPELLINGS are accepted: an author writing profile-config by hand reaches for the +// kebab attribute spelling ("show-search") because that is what the docs show on the tag, while +// the prop is camel. Accepting only one is what made this silent — and a preset key that does +// nothing looks exactly like a preset key that works. +const cfgFlag = (camelKey, kebabKey) => { + const c = cfg.value + return c?.[camelKey] !== undefined ? c[camelKey] : c?.[kebabKey] +} +const resolveFlag = (propValue, camelKey, kebabKey) => { + // An explicit prop on the tag still wins; otherwise the config decides; otherwise the default. + if (propValue !== undefined && propValue !== true) return truthy(propValue) + const fromCfg = cfgFlag(camelKey, kebabKey) + return fromCfg === undefined ? truthy(propValue) : truthy(fromCfg) +} +const useSearch = computed(() => resolveFlag(props.showSearch, 'showSearch', 'show-search')) +const useFilters = computed(() => resolveFlag(props.showFilters, 'showFilters', 'show-filters')) +const useTotal = computed(() => resolveFlag(props.showTotal, 'showTotal', 'show-total')) // ── state (copied from UupgsList) ────────────────────────────────────────────── const uupgs = ref([])