doxa-maps/internal/plugin-gallery/discover.mjs
doxa-maps-88655c70 9dc4dda8cf
Some checks are pending
build-gate / build (push) Waiting to run
consolidate docs into documentation/ and deployment shells into deployment/
Driver 2026-09-21: 'a folder called deployment which will store the capacitor
folder, wordpress folder and tauri folder', and 'a folder called documentation
to store the contributing folder so docs are in one place'.

docs/ + contributing/ + CONTRIBUTING.md -> documentation/ (three doc homes into
one). capacitor/ + wordpress/ + src-tauri/ -> deployment/.

The public/ -> dist/ rename the Driver asked about is NOT here: after seeing the
measured blast radius (855 tracked files, 54 source refs, and public/ is the
served web-root four consumers point at) the Driver withdrew it - 'keep the
public folder the same'.

Path references were rewritten mechanically (258 across 87 files), but three
classes needed hand fixes because a prefix regex cannot see them:
  - join(ROOT,'docs',...) in 10 internal scripts - a quoted SEGMENT, not a prefix
  - tauri frontendDist '../public' -> '../../public', since src-tauri is now one
    level deeper; check:tauri caught this exactly as the card predicted
  - the WordPress contract test's dirname(__DIR__,2) -> 3, same reason

Also reverted one over-reach: the rewrite renamed a FINDING-CATEGORY label
'docs' in public-hygiene-check.mjs, which is a rule name in its own header, not
a path.

Verified: full build ALL GUARDS PASS, SRI consistent; check:tauri, check:mobile
(cap sync + webDir OK), check:wp-embed, quoted-scripts, capability-citations,
capability-exemplars, library-index, public-hygiene, parse and drop all pass.
2026-09-21 23:23:23 +00:00

153 lines
8.2 KiB
JavaScript

/**
* discover.mjs — the auto-generated SPA's registry: every bundle under plugin-designs/,
* every profile inside it, classified into a composition tier.
*
* single → parameterized → monolithic → nested-elements-mfe → nested-mfe-spa
*
* Conventions it reads (all optional, all per bundle folder):
* <profile>.meta.json {"type": "<tier>", "label": "Human name"} — the ONLY way to declare a
* composite tier (monolithic / nested-elements-mfe / nested-mfe-spa).
* <name>.instances.json [{id, title, props}] or {"instances": [...]} — marks the bundle's
* profiles PARAMETERIZED and expands one element per instance.
* Pure + side-effect free: the dev plugin (dev-plugin.mjs) turns its result into the
* manifest-shaped model the plugin gallery renders; any tool that needs the catalog can use it.
*/
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs'
export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
// Composition tiers, most-composed → individual. Drives ordering + labels.
export const PROFILE_TIER = {
'nested-mfe-spa': 0,
'nested-elements-mfe': 1,
'monolithic': 2,
'parameterized': 3,
'single': 4,
}
// Reader-facing names (validated vocabulary, see documentation/reference.md "Profile types"): the machine
// keys above stay canonical; a <profile>.meta.json may use either spelling. Pattern:
// "<attested head word>-<what the file yields>" — single-element → preset-element →
// multi-view-element → composite-element → app-shell.
export const TYPE_DISPLAY = {
'single': 'single-element',
'parameterized': 'preset-element',
'monolithic': 'multi-view-element',
'nested-elements-mfe': 'composite-element',
'nested-mfe-spa': 'app-shell',
}
export const TYPE_ALIASES = Object.fromEntries(Object.entries(TYPE_DISPLAY).map(([k, v]) => [v, k]))
export const normalizeType = (t) => (t && PROFILE_TIER[t] != null) ? t : (TYPE_ALIASES[t] || null)
function readJsonSafe(p) { try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null } }
// Classify one profile into a type. Precedence: explicit <profile>.meta.json (machine key
// or display alias) → parameterized (the bundle ships an *.instances.json) → single (the
// simplest, default). Composite kinds are never inferred from source.
export function detectProfileType(dir, profile, ownTag, allTags, hasInstances) {
const meta = readJsonSafe(resolve(dir, `${profile}.meta.json`))
const declared = meta && normalizeType(meta.type)
if (declared) return { type: declared, label: meta.label || profile }
// Reliable auto-signals ONLY (source tag-scanning caused false positives):
// a bundle with an *.instances.json -> parameterized; otherwise the simplest tier, single.
// Composite tiers (monolithic / nested-elements-mfe / nested-mfe-spa) are AUTHORED via an
// explicit <profile>.meta.json {"type": ...} — the single source of truth (documentation/reference.md,
// 'Profile types — the composability ladder'). See eco-dx (internal): PS001-composition-model.
const type = hasInstances ? 'parameterized' : 'single'
// ★ TIER-vs-COMPLEXITY WARNING — an INFERENCE FALLBACK is not a DECLARATION.
// Hit for real 2026-09-20: research-dashboard had NO meta.json, so it fell through to
// `single` = CAPABILITY 1 — while actually nesting TEN internal view components behind a
// responsive shell and rendering ~1,100 elements. It is monolithic (cap-3). Nothing warned;
// a missing file is indistinguishable from a deliberate choice, and the understatement points
// the direction nobody audits (the manifest, the gallery and every capability claim read this).
//
// THE SIGNAL IS MEASURED, NOT GUESSED. Counting child .vue files under the bundle (excluding
// its root profiles) across all 18 bundles gives: research-dashboard 10, big-picture 1, and
// ZERO for every other bundle. The intended catch sits an order of magnitude above every other
// sample, so this is a real gap in the data rather than a tuned cutoff.
//
// ONLY FIRES ON AN INFERRED TYPE — never second-guesses an explicit meta.json. doxa-research-map
// is DECLARED monolithic with 0 child .vue files, which is perfectly legitimate: complexity can
// live in one big file. Absence of the signal proves nothing; presence of it plus no declaration
// is what is suspicious. WARNS rather than fails: this is a heuristic about authoring intent,
// and the codebase's standing rule is that composite tiers are AUTHORED, not inferred.
try {
const childVue = countChildVue(dir)
if (childVue >= 5) {
console.warn(
`[discover] ${profile}: type INFERRED as "${type}" but the bundle has ${childVue} child ` +
'.vue components. Tier 3+ is never inferred — if this nests multiple views internally it ' +
`is "monolithic"; if its children are independently embeddable it is "nested-elements-mfe". ` +
`Declare it in ${profile}.meta.json {"type": ...} or this ships understated.`
)
}
} catch { /* advisory only — never let the warning break a build */ }
return { type, label: (meta && meta.label) || profile }
}
/** Count .vue files BELOW a bundle dir (its root profiles do not count as nested views). */
function countChildVue(dir) {
let n = 0
const walk = (d, depth) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name.startsWith('.') || e.name === 'node_modules') continue
const p = resolve(d, e.name)
if (e.isDirectory()) walk(p, depth + 1)
else if (depth > 0 && e.name.endsWith('.vue')) n++
}
}
walk(dir, 0)
return n
}
export function discoverSpaRegistry(root = REPO_ROOT) {
const APP_DIR = resolve(root, 'plugin-designs')
const bundles = []
if (!existsSync(APP_DIR)) return bundles
const dirs = readdirSync(APP_DIR).sort().filter(n => {
const d = resolve(APP_DIR, n)
return statSync(d).isDirectory() && n !== 'template-bundle' && existsSync(resolve(d, 'index.js'))
})
// First pass: every bundle's element tag (needed for composite detection).
const tagOf = {}
for (const n of dirs) {
try {
const m = readFileSync(resolve(APP_DIR, n, 'index.js'), 'utf8').match(/customElements\.define\(\s*['"]([^'"]+)['"]/)
if (m) tagOf[n] = m[1]
} catch { /* ignore */ }
}
const allTags = Object.values(tagOf)
for (const n of dirs) {
const dir = resolve(APP_DIR, n), tag = tagOf[n]
if (!tag) continue
const files = readdirSync(dir)
const instFile = files.find(f => /\.instances\.json$/i.test(f))
const rawInst = instFile ? readJsonSafe(resolve(dir, instFile)) : null
const instances = Array.isArray(rawInst) ? rawInst : (rawInst && Array.isArray(rawInst.instances) ? rawInst.instances : null)
const hasInstances = !!(instances && instances.length)
// The table's data API (its top-level apiBaseUrl), baked per instance as generate-tree does.
const instApi = rawInst && !Array.isArray(rawInst) && typeof rawInst.apiBaseUrl === 'string' ? rawInst.apiBaseUrl : ''
const items = []
for (const profile of files.filter(f => f.endsWith('.vue')).map(f => f.replace(/\.vue$/, '')).sort()) {
const t = detectProfileType(dir, profile, tag, allTags, hasInstances)
if (t.type === 'parameterized' && hasInstances) {
// Expand the parameterized table into one element PER preconfigured instance.
for (const inst of instances) {
items.push({ profile, id: inst.id || inst.title || profile, label: inst.title || inst.id || profile, type: 'parameterized', props: inst.props || {}, apiBaseUrl: instApi })
}
} else {
items.push({ profile, id: profile, label: t.label, type: t.type, props: null })
}
}
if (!items.length) continue
// Sort by composability tier ONLY (stable) so parameterized instances keep their
// declared table order — e.g. Prayer Map stays first, as the landing view.
items.sort((a, b) => (PROFILE_TIER[a.type] ?? 9) - (PROFILE_TIER[b.type] ?? 9))
const bestTier = Math.min(...items.map(i => PROFILE_TIER[i.type] ?? 9))
bundles.push({ bundle: n, tag, items, bestTier })
}
bundles.sort((a, b) => a.bestTier - b.bestTier || a.bundle.localeCompare(b.bundle))
return bundles
}