scripts/primitives-map.mjs
scripts/primitives-map.mjsBrowse 6 files
2,333 tokens
8,328 bytes
Token encoding: o200k_base
Snapshot 8beb6cd
← Back to SKILL.md
1/**2 * Shared parser + path classifier for docs/contributing/architecture/primitives.yaml.3 *4 * Constrained YAML subset (version/groups/primitives/invariants with scalar fields5 * and string lists). Not a general YAML parser.6 */7import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'8import { resolve } from 'node:path'9 10export const defaultMapPath = resolve(11 import.meta.dirname,12 '../../../../docs/contributing/architecture/primitives.yaml',13)14 15/**16 * @typedef {{17 * id: string18 * name: string19 * group?: string20 * summary?: string21 * code: Array<string>22 * docs: Array<string>23 * }} Primitive24 */25 26/**27 * @typedef {{28 * version: number29 * groups: Array<{ id: string, name: string }>30 * primitives: Array<Primitive>31 * invariants: Array<Primitive>32 * }} PrimitivesMap33 */34 35/**36 * @param {string} text37 * @returns {PrimitivesMap}38 */39export function parsePrimitivesMap(text) {40 const lines = text.split(/\r?\n/)41 /** @type {PrimitivesMap} */42 const map = {43 version: 1,44 groups: [],45 primitives: [],46 invariants: [],47 }48 49 /** @type {'root' | 'groups' | 'primitives' | 'invariants'} */50 let section = 'root'51 /** @type {Primitive | { id: string, name: string } | null} */52 let current = null53 /** @type {'code' | 'docs' | null} */54 let listField = null55 56 function finishCurrent() {57 if (!current || !('id' in current)) return58 if (section === 'groups' && 'name' in current) {59 map.groups.push({ id: current.id, name: current.name })60 } else if (section === 'primitives') {61 const primitive = /** @type {Primitive} */ (current)62 primitive.code ??= []63 primitive.docs ??= []64 map.primitives.push(primitive)65 } else if (section === 'invariants') {66 const invariant = /** @type {Primitive} */ (current)67 invariant.code ??= []68 invariant.docs ??= []69 map.invariants.push(invariant)70 }71 current = null72 listField = null73 }74 75 for (const rawLine of lines) {76 const commentIndex = rawLine.indexOf('#')77 const line =78 commentIndex === -179 ? rawLine80 : // Keep `#` inside quoted scalars; our map never quotes.81 /^\s*#/.test(rawLine)82 ? ''83 : rawLine84 if (!line.trim()) continue85 86 const sectionMatch = line.match(/^(groups|primitives|invariants):\s*$/)87 if (sectionMatch) {88 finishCurrent()89 section = /** @type {'groups' | 'primitives' | 'invariants'} */ (90 sectionMatch[1]91 )92 continue93 }94 95 const versionMatch = line.match(/^version:\s+(\d+)\s*$/)96 if (versionMatch) {97 map.version = Number(versionMatch[1])98 continue99 }100 101 const itemMatch = line.match(/^ {2}- id:\s+(.+?)\s*$/)102 if (itemMatch) {103 finishCurrent()104 current = {105 id: unquote(itemMatch[1]),106 name: '',107 code: [],108 docs: [],109 }110 listField = null111 continue112 }113 114 if (!current) continue115 116 const listStart = line.match(/^ {4}(code|docs):\s*$/)117 if (listStart) {118 listField = /** @type {'code' | 'docs'} */ (listStart[1])119 continue120 }121 122 const listItem = line.match(/^ {6}- (.+?)\s*$/)123 if (listItem && listField && 'code' in current) {124 const value = unquote(listItem[1])125 if (listField === 'code') current.code.push(value)126 else current.docs.push(value)127 continue128 }129 130 const emptySummary = line.match(/^ {4}summary:\s*$/)131 if (emptySummary) {132 listField = null133 current.summary = ''134 continue135 }136 137 const summaryContinuation = line.match(/^ {6}(.+?)\s*$/)138 if (139 summaryContinuation &&140 listField === null &&141 'code' in current &&142 current.summary === ''143 ) {144 current.summary = unquote(summaryContinuation[1])145 continue146 }147 148 const fieldMatch = line.match(/^ {4}(group|name|summary):\s+(.+?)\s*$/)149 if (fieldMatch) {150 listField = null151 const key = fieldMatch[1]152 const value = unquote(fieldMatch[2])153 if (key === 'group') current.group = value154 else if (key === 'name') current.name = value155 else current.summary = value156 continue157 }158 }159 160 finishCurrent()161 return map162}163 164/**165 * @param {string} value166 */167function unquote(value) {168 if (169 (value.startsWith("'") && value.endsWith("'")) ||170 (value.startsWith('"') && value.endsWith('"'))171 ) {172 return value.slice(1, -1)173 }174 return value175}176 177/**178 * @param {string} filePath179 * @param {string} root180 */181export function pathMatchesRoot(filePath, root) {182 const normalizedFile = filePath.replaceAll('\\', '/')183 const isDirRoot = root.endsWith('/')184 const normalizedRoot = root.replaceAll('\\', '/').replace(/\/$/, '')185 if (!normalizedRoot) return false186 if (isDirRoot) {187 return (188 normalizedFile === normalizedRoot ||189 normalizedFile.startsWith(`${normalizedRoot}/`)190 )191 }192 return (193 normalizedFile === normalizedRoot ||194 normalizedFile.startsWith(normalizedRoot)195 )196}197 198/**199 * Longest-prefix match: each path maps to the primitive(s) whose matching200 * `code` root is longest. Equal-length ties return every tied primitive.201 *202 * @param {Array<string>} paths203 * @param {PrimitivesMap} map204 */205export function classifyPaths(paths, map) {206 /** @type {Map<string, { primitive: Primitive, root: string, files: Array<string> }>} */207 const byId = new Map()208 /** @type {Array<string>} */209 const unmatched = []210 211 for (const rawPath of paths) {212 const filePath = rawPath.replaceAll('\\', '/').replace(/^\.\//, '')213 if (!filePath || filePath.endsWith('/')) continue214 215 /** @type {Array<{ primitive: Primitive, root: string }>} */216 const matches = []217 for (const primitive of map.primitives) {218 for (const root of primitive.code) {219 if (pathMatchesRoot(filePath, root)) {220 matches.push({ primitive, root })221 }222 }223 }224 225 if (matches.length === 0) {226 unmatched.push(filePath)227 continue228 }229 230 const maxLen = Math.max(...matches.map((match) => match.root.length))231 const winners = matches.filter((match) => match.root.length === maxLen)232 const seen = new Set()233 for (const winner of winners) {234 if (seen.has(winner.primitive.id)) continue235 seen.add(winner.primitive.id)236 const existing = byId.get(winner.primitive.id)237 if (existing) {238 existing.files.push(filePath)239 } else {240 byId.set(winner.primitive.id, {241 primitive: winner.primitive,242 root: winner.root,243 files: [filePath],244 })245 }246 }247 }248 249 const matched = [...byId.values()].sort((a, b) =>250 a.primitive.id.localeCompare(b.primitive.id),251 )252 return { matched, unmatched }253}254 255/**256 * @param {string} [mapPath]257 */258export function loadPrimitivesMap(mapPath = defaultMapPath) {259 return parsePrimitivesMap(readFileSync(mapPath, 'utf8'))260}261 262/**263 * Validate that every `code` and `docs` path resolves on disk.264 * Directory roots may end with `/`. Prefix roots (no trailing `/`) must match265 * at least one existing file or directory under the repo root.266 *267 * @param {PrimitivesMap} map268 * @param {string} [repoRoot]269 */270export function checkPrimitivesMapPaths(271 map,272 repoRoot = resolve(import.meta.dirname, '../../../..'),273) {274 /** @type {Array<{ kind: string, id: string, path: string, reason: string }>} */275 const errors = []276 277 /**278 * @param {Primitive} entry279 * @param {'primitive' | 'invariant'} kind280 */281 function checkEntry(entry, kind) {282 for (const docPath of entry.docs) {283 const absolute = resolve(repoRoot, docPath)284 if (!existsSync(absolute) || !statSync(absolute).isFile()) {285 errors.push({286 kind,287 id: entry.id,288 path: docPath,289 reason: 'docs path missing or not a file',290 })291 }292 }293 294 for (const codePath of entry.code) {295 const absolute = resolve(repoRoot, codePath.replace(/\/$/, ''))296 if (existsSync(absolute)) continue297 298 // Prefix roots (e.g. handlers/community, app/oauth-) must hit something.299 if (!codePath.endsWith('/')) {300 const parent = resolve(301 repoRoot,302 codePath.split('/').slice(0, -1).join('/'),303 )304 const prefix = codePath.split('/').at(-1) ?? ''305 if (existsSync(parent) && statSync(parent).isDirectory()) {306 const hit = readdirSafe(parent).some((name) =>307 name.startsWith(prefix),308 )309 if (hit) continue310 }311 }312 313 errors.push({314 kind,315 id: entry.id,316 path: codePath,317 reason: 'code root missing on disk',318 })319 }320 }321 322 for (const primitive of map.primitives) checkEntry(primitive, 'primitive')323 for (const invariant of map.invariants) checkEntry(invariant, 'invariant')324 return errors325}326 327/**328 * @param {string} dir329 */330function readdirSafe(dir) {331 try {332 return readdirSync(dir)333 } catch {334 return []335 }336}337