|
| 1 | +'use strict' |
| 2 | + |
| 3 | +/** |
| 4 | + * Docs × public-surface inventory scanner (live disk truth). |
| 5 | + * Used by test-docs-surface-inventory and site-consistency gates. |
| 6 | + */ |
| 7 | + |
| 8 | +const fs = require('fs') |
| 9 | +const path = require('path') |
| 10 | + |
| 11 | +const REQUIRED_WORKFLOWS = Object.freeze([ |
| 12 | + 'analyze', |
| 13 | + 'audit', |
| 14 | + 'chat', |
| 15 | + 'dev', |
| 16 | + 'fix', |
| 17 | + 'other', |
| 18 | + 'resume', |
| 19 | + 'self-fix' |
| 20 | +]) |
| 21 | + |
| 22 | +const REQUIRED_HOOK_EVENTS = Object.freeze([ |
| 23 | + 'UserPromptSubmit', |
| 24 | + 'PreToolUse', |
| 25 | + 'PostToolUse', |
| 26 | + 'PreCompact', |
| 27 | + 'Stop' |
| 28 | +]) |
| 29 | + |
| 30 | +const REQUIRED_PROCESS_FILES = Object.freeze([ |
| 31 | + 'scripts/lib/process-enforcement.js', |
| 32 | + 'scripts/lib/host-enforcement-matrix.js', |
| 33 | + 'scripts/test-process-enforcement-e2e.js' |
| 34 | +]) |
| 35 | + |
| 36 | +function listDirs (root, dir) { |
| 37 | + const full = path.join(root, dir) |
| 38 | + if (!fs.existsSync(full)) return [] |
| 39 | + return fs |
| 40 | + .readdirSync(full, { withFileTypes: true }) |
| 41 | + .filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.')) |
| 42 | + .map((e) => e.name) |
| 43 | + .sort() |
| 44 | +} |
| 45 | + |
| 46 | +function listFiles (root, dir, re) { |
| 47 | + const full = path.join(root, dir) |
| 48 | + if (!fs.existsSync(full)) return [] |
| 49 | + return fs |
| 50 | + .readdirSync(full) |
| 51 | + .filter((f) => re.test(f)) |
| 52 | + .sort() |
| 53 | +} |
| 54 | + |
| 55 | +function walkMd (root, relDir, acc = []) { |
| 56 | + const full = path.join(root, relDir) |
| 57 | + if (!fs.existsSync(full)) return acc |
| 58 | + for (const e of fs.readdirSync(full, { withFileTypes: true })) { |
| 59 | + const r = path.join(relDir, e.name).replace(/\\/g, '/') |
| 60 | + const p = path.join(full, e.name) |
| 61 | + if (e.isDirectory()) walkMd(root, r, acc) |
| 62 | + else if (/\.mdx?$/i.test(e.name)) acc.push(r) |
| 63 | + } |
| 64 | + return acc |
| 65 | +} |
| 66 | + |
| 67 | +function extractMcpToolNames (root, file) { |
| 68 | + const p = path.join(root, file) |
| 69 | + if (!fs.existsSync(p)) return [] |
| 70 | + const text = fs.readFileSync(p, 'utf8') |
| 71 | + const names = new Set() |
| 72 | + const re = /name:\s*'([a-z][a-z0-9_]*)'/g |
| 73 | + let m |
| 74 | + while ((m = re.exec(text))) { |
| 75 | + if (m[1].startsWith('memory_') || m[1].startsWith('profile_')) names.add(m[1]) |
| 76 | + } |
| 77 | + return [...names].sort() |
| 78 | +} |
| 79 | + |
| 80 | +/** |
| 81 | + * @param {string} root package root |
| 82 | + * @returns {object} live surface inventory |
| 83 | + */ |
| 84 | +function scanDocsSurfaceInventory (root) { |
| 85 | + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) |
| 86 | + const plugin = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf8')) |
| 87 | + const pluginSkillIds = (plugin.skills || []) |
| 88 | + .map((s) => (typeof s === 'string' ? s : s.id)) |
| 89 | + .filter(Boolean) |
| 90 | + .sort() |
| 91 | + const skillDirs = listDirs(root, 'skills') |
| 92 | + const skillWithMd = skillDirs.filter((d) => |
| 93 | + fs.existsSync(path.join(root, 'skills', d, 'SKILL.md')) |
| 94 | + ) |
| 95 | + |
| 96 | + const wf = JSON.parse( |
| 97 | + fs.readFileSync(path.join(root, 'skills/routing/workflow-capabilities.json'), 'utf8') |
| 98 | + ) |
| 99 | + const workflowIds = (Array.isArray(wf.workflows) ? wf.workflows : []) |
| 100 | + .map((w) => w.id) |
| 101 | + .filter(Boolean) |
| 102 | + .sort() |
| 103 | + |
| 104 | + const life = JSON.parse(fs.readFileSync(path.join(root, 'hooks/devcodex.lifecycle.json'), 'utf8')) |
| 105 | + const hookEvents = Object.keys(life.hooks || {}).sort() |
| 106 | + |
| 107 | + const hookRuntime = listFiles(root, 'hooks/_runtime', /\.cjs$/i) |
| 108 | + const prompts = listFiles(root, 'prompts', /\.prompt\.md$/i) |
| 109 | + const instructionsMain = listFiles(root, 'instructions', /\.instructions\.md$/i) |
| 110 | + const scriptsLib = listFiles(root, 'scripts/lib', /\.js$/i) |
| 111 | + const scriptsTop = listFiles(root, 'scripts', /\.(js|cjs|mjs)$/i) |
| 112 | + const npmScripts = Object.keys(pkg.scripts || {}).sort() |
| 113 | + const websiteMd = walkMd(root, 'website/docs').sort() |
| 114 | + |
| 115 | + const gates = JSON.parse( |
| 116 | + fs.readFileSync(path.join(root, 'skills/spec-governance/gate-registry.json'), 'utf8') |
| 117 | + ) |
| 118 | + let gateGroups = 0 |
| 119 | + if (Array.isArray(gates.groups)) gateGroups = gates.groups.length |
| 120 | + else if (gates.groups && typeof gates.groups === 'object') gateGroups = Object.keys(gates.groups).length |
| 121 | + else if (gates.gateGroups) gateGroups = Object.keys(gates.gateGroups).length |
| 122 | + |
| 123 | + const manifest = JSON.parse( |
| 124 | + fs.readFileSync(path.join(root, 'scripts/validation-manifest.json'), 'utf8') |
| 125 | + ) |
| 126 | + const validationNodes = (manifest.nodes || []).map((n) => n.id).filter(Boolean) |
| 127 | + |
| 128 | + const memoryTools = extractMcpToolNames(root, 'mcp/memory-server.js') |
| 129 | + const profileTools = extractMcpToolNames(root, 'mcp/profile-server.js') |
| 130 | + |
| 131 | + const processFiles = {} |
| 132 | + for (const f of REQUIRED_PROCESS_FILES) { |
| 133 | + processFiles[f] = fs.existsSync(path.join(root, f)) |
| 134 | + } |
| 135 | + |
| 136 | + return { |
| 137 | + schemaVersion: 'DocsSurfaceInventoryV1', |
| 138 | + skillDirs: skillDirs.length, |
| 139 | + skillWithMd: skillWithMd.length, |
| 140 | + pluginSkillIds: pluginSkillIds.length, |
| 141 | + skillOnlyPlugin: pluginSkillIds.filter((id) => !skillDirs.includes(id)), |
| 142 | + skillOnlyDisk: skillDirs.filter((id) => !pluginSkillIds.includes(id)), |
| 143 | + workflowIds, |
| 144 | + workflowCount: workflowIds.length, |
| 145 | + mcpTools: [...memoryTools, ...profileTools], |
| 146 | + mcpToolCount: memoryTools.length + profileTools.length, |
| 147 | + hookEvents, |
| 148 | + hookRuntimeCount: hookRuntime.length, |
| 149 | + prompts: prompts.length, |
| 150 | + instructionsMain: instructionsMain.length, |
| 151 | + scriptsLib: scriptsLib.length, |
| 152 | + scriptsTop: scriptsTop.length, |
| 153 | + npmScripts: npmScripts.length, |
| 154 | + websiteMd: websiteMd.length, |
| 155 | + gateGroups, |
| 156 | + validationNodes: validationNodes.length, |
| 157 | + processFiles, |
| 158 | + hasProcessEnforcementScript: Boolean(pkg.scripts && pkg.scripts['test:process-enforcement-e2e']) |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +/** |
| 163 | + * @param {object} inv |
| 164 | + * @returns {{ ok: boolean, failures: string[] }} |
| 165 | + */ |
| 166 | +function assertDocsSurfaceInventory (inv) { |
| 167 | + const failures = [] |
| 168 | + |
| 169 | + if (inv.skillDirs !== 84) failures.push(`skills dirs expected 84 got ${inv.skillDirs}`) |
| 170 | + if (inv.pluginSkillIds !== 84) failures.push(`plugin skills expected 84 got ${inv.pluginSkillIds}`) |
| 171 | + if (inv.skillWithMd !== inv.skillDirs) { |
| 172 | + failures.push(`SKILL.md missing for some skill dirs (${inv.skillWithMd}/${inv.skillDirs})`) |
| 173 | + } |
| 174 | + if (inv.skillOnlyPlugin.length) { |
| 175 | + failures.push(`plugin-only skills: ${inv.skillOnlyPlugin.join(',')}`) |
| 176 | + } |
| 177 | + if (inv.skillOnlyDisk.length) { |
| 178 | + failures.push(`disk-only skills: ${inv.skillOnlyDisk.join(',')}`) |
| 179 | + } |
| 180 | + |
| 181 | + const wfSorted = [...inv.workflowIds].sort() |
| 182 | + const reqSorted = [...REQUIRED_WORKFLOWS].sort() |
| 183 | + if (JSON.stringify(wfSorted) !== JSON.stringify(reqSorted)) { |
| 184 | + failures.push(`workflows mismatch got [${wfSorted.join(',')}]`) |
| 185 | + } |
| 186 | + if (inv.workflowIds.includes('plan')) { |
| 187 | + failures.push('workflow id "plan" must not exist (use other + plan Skill)') |
| 188 | + } |
| 189 | + |
| 190 | + if (inv.mcpToolCount !== 15) failures.push(`mcp tools expected 15 got ${inv.mcpToolCount}`) |
| 191 | + |
| 192 | + for (const ev of REQUIRED_HOOK_EVENTS) { |
| 193 | + if (!inv.hookEvents.includes(ev)) failures.push(`missing hook event ${ev}`) |
| 194 | + } |
| 195 | + if (inv.hookRuntimeCount < 26) { |
| 196 | + failures.push(`hook runtime cjs expected >=26 got ${inv.hookRuntimeCount}`) |
| 197 | + } |
| 198 | + |
| 199 | + if (inv.prompts !== 30) failures.push(`prompts expected 30 got ${inv.prompts}`) |
| 200 | + if (inv.instructionsMain !== 15) { |
| 201 | + failures.push(`instructions main expected 15 got ${inv.instructionsMain}`) |
| 202 | + } |
| 203 | + if (inv.websiteMd < 156) failures.push(`website md expected >=156 got ${inv.websiteMd}`) |
| 204 | + if (inv.gateGroups !== 51) failures.push(`gate groups expected 51 got ${inv.gateGroups}`) |
| 205 | + if (inv.validationNodes < 83) { |
| 206 | + failures.push(`validation nodes expected >=83 got ${inv.validationNodes}`) |
| 207 | + } |
| 208 | + if (inv.npmScripts < 109) failures.push(`npm scripts expected >=109 got ${inv.npmScripts}`) |
| 209 | + if (inv.scriptsLib < 102) failures.push(`scripts/lib expected >=102 got ${inv.scriptsLib}`) |
| 210 | + |
| 211 | + for (const [f, ok] of Object.entries(inv.processFiles || {})) { |
| 212 | + if (!ok) failures.push(`missing required file ${f}`) |
| 213 | + } |
| 214 | + if (!inv.hasProcessEnforcementScript) { |
| 215 | + failures.push('package.json missing script test:process-enforcement-e2e') |
| 216 | + } |
| 217 | + |
| 218 | + return { ok: failures.length === 0, failures } |
| 219 | +} |
| 220 | + |
| 221 | +module.exports = { |
| 222 | + REQUIRED_WORKFLOWS, |
| 223 | + REQUIRED_HOOK_EVENTS, |
| 224 | + REQUIRED_PROCESS_FILES, |
| 225 | + scanDocsSurfaceInventory, |
| 226 | + assertDocsSurfaceInventory |
| 227 | +} |
0 commit comments