doxa-maps/internal/check-profile-descriptions.mjs
doxa-maps-88655c70 44ef31b2c6 Tooling reads the tree's name from the build config, in one place
The output tree's name has always been configurable (vite.doxa-maps-build-config.json "name"),
but 61 tooling files ignored it and hardcoded 'doxa-maps' (181 literals), so a rename would have
moved the build while the gates kept reading the old folder.

- internal/_tree-name.mjs reads it once (validated as one URL-safe path segment; falls back to the
  historical name) and exports TREE_NAME / TREE_NAME_RE; `node internal/_tree-name.mjs` prints it
  for shell scripts.
- A codemod rewrote 171 literals in internal/ to TREE_NAME; the regex literals, messages with
  backticks, atomic-public-swap.sh (fails loudly if the name cannot be read), build-lock.sh and
  vite.config.js (its duplicate readMfeName is gone) were done by hand.
- The name is unchanged: old and new tooling produce all 1408 files under public/ byte for byte.
  Code lines still naming the tree in internal/ + vite.config.js: 2, both intended (the helper's
  fallback, the private-repo-name pattern in public-hygiene-check).

This renames nothing visible; the eventual rename becomes a one-line config change.
2026-09-25 16:25:22 +00:00

118 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
/**
* check-profile-descriptions.mjs — every shipped profile must say what it is.
*
* WHY (measured 2026-09-22)
* 15 of 26 profiles shipped with an EMPTY `description` in manifest.json. In the plugin-studio —
* the surface a Doxa inheritor opens to learn what exists — those rendered as a bare
* "bundle · <tag>" and nothing else:
*
* doxa-research-map · <doxa-research-map>
*
* while `stack-demo`, a demo bundle, explained itself fully. The hand-off was inverted: the least
* important artifact was the best documented. The built gallery was 15 empty / 11 populated, and
* the cap-5 SPA fell back to labelling such a tab "experimental bundle".
*
* ★ WHY A GATE AND NOT JUST THE FIX. The repo already validates `@element` — but only against a
* MALFORMED value. An ABSENT `@description` fails silently: the generator writes '', every gate
* passes, and the defect only appears to a human who opens the studio and reads. That is the worst
* shape for a hand-off property, because the north star ("develop a plugin and see it live") means
* NEW PLUGINS ARRIVE OFTEN — each one a fresh chance to regress this with no signal. A fix without
* a gate is a fix with a half-life.
*
* THE TRAP THAT MOTIVATES THE IMPLEMENTATION: read the MANIFEST, not the sources.
* The description reaches users through manifest.json. Re-deriving it here by parsing .vue headers
* would mean reimplementing generate-tree's TAG_RE — and that is exactly the mistake that hid two
* of the 15: a hand-written copy of that regex reported "13 extractable, 0 problems" while the real
* rule (which requires a leading `*` on the line) extracted nothing from two HTML-comment headers.
* ★ CHECK THE ARTIFACT THE CONSUMER READS, never a reimplementation of how it was produced.
*
* NEGATIVE-TESTED: blanking one description in manifest.json makes this FAIL naming that profile,
* EXIT=1; restoring it returns PASS. A manifest with zero profiles FAILS (vacuous-pass guard)
* rather than passing for lack of anything to check.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
// A description shorter than this is a placeholder, not an explanation. Calibrated against the
// REAL corpus rather than a guess (measured 2026-09-22): shortest genuine description 62 chars,
// median 81, longest 192. 25 therefore sits well clear of every legitimate entry while still
// catching a one-word stub. The bar is deliberately low — this gate catches ABSENCE, it does not
// grade prose, and a gate that argues about writing quality gets disabled.
const MIN_CHARS = 25
// Vacuous-pass guard. If the manifest is missing or empty this gate must FAIL, because "no
// profiles to check" is indistinguishable from "the build did not run" — and a gate that passes
// when it measured nothing is worse than no gate (this repo has already been bitten by exactly
// that shape in the settled-tree check).
const MIN_EXPECTED_PROFILES = 20
if (!existsSync(MANIFEST)) {
console.error(`PROFILE-DESCRIPTIONS: FAIL — no manifest.json at public/${TREE_NAME}/.`)
console.error(' This gate reads the BUILT manifest (the artifact consumers actually read).')
console.error(' Run `bun run build` first; an absent manifest is a failure, not a pass.')
process.exit(1)
}
let manifest
try {
manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
} catch (e) {
console.error(`PROFILE-DESCRIPTIONS: FAIL — manifest.json does not parse: ${e.message}`)
process.exit(1)
}
const bundles = Array.isArray(manifest.bundles) ? manifest.bundles : []
const offenders = []
let checked = 0
for (const b of bundles) {
const bundleName = b.name || b.bundle || '(unnamed bundle)'
for (const p of (b.profiles || [])) {
checked++
const desc = String(p.description || '').trim()
if (desc.length >= MIN_CHARS) continue
offenders.push({
id: `${bundleName}/${p.profile}`,
file: p.file || `${p.profile}.vue`,
bundle: bundleName,
why: desc.length === 0 ? 'empty' : `only ${desc.length} chars`,
})
}
}
console.log(`PROFILE-DESCRIPTIONS: ${checked} profile(s) across ${bundles.length} bundle(s), ` +
`${checked - offenders.length} described.`)
if (checked < MIN_EXPECTED_PROFILES) {
console.error(`\nPROFILE-DESCRIPTIONS: FAIL — only ${checked} profile(s) found, expected at least ` +
`${MIN_EXPECTED_PROFILES}.`)
console.error(' This is the VACUOUS-PASS GUARD. Too few profiles means the manifest is stale or')
console.error(' the build was interrupted, so a green result here would certify nothing.')
process.exit(1)
}
if (offenders.length) {
console.error(`\nPROFILE-DESCRIPTIONS: FAIL — ${offenders.length} profile(s) ship without an explanation:`)
for (const o of offenders) {
console.error(` ✗ ${o.id} (${o.why}) → plugin-designs/${o.bundle}/${o.file}`)
}
console.error(
'\n Add a JSDoc @description tag to the profile\'s .vue header. THE TAG, NOT THE PROSE:\n' +
' generate-tree matches /^\\s*\\*\\s*@([\\w-]+)\\s+(.+?)\\s*$/gm, so the line MUST begin with an\n' +
' asterisk — a rich prose header with no tag extracts to an empty string. If the file uses an\n' +
' HTML comment (<!-- … -->) rather than JSDoc, write the line as "* @description …" anyway.\n' +
'\n' +
' Say what the plugin IS and what a reader would use it for, in one or two sentences. This text\n' +
' is what a new developer sees in the plugin-studio, the gallery, and the SPA\'s Experimental\n' +
' rail — it is the hand-off, not decoration.'
)
process.exit(1)
}
console.log('PROFILE-DESCRIPTIONS: PASS — every shipped profile explains what it is.')