UNPKG

protoml-parser

Version:

ProtoML is a lightweight, declarative markup language designed for writing and structuring meeting protocols, notes and task lists in a human-readable and machine-parseable format.

452 lines (394 loc) 16 kB
const fs = require("fs"); const path = require("path"); const { tokenize } = require("./tokenizer"); const { parseBlocks } = require("./blockParser"); const { parseFile } = require("./parser"); const { BUILTIN_META_KEYS } = require("./metaKeys"); const { analyzePmlTrustSync } = require("./trust"); function normalizeFileRef(value) { return String(value || "").trim().replace(/^["']|["']$/g, ""); } function addIssue(issues, severity, message) { issues.push({ severity, message }); } function countKeys(value) { return value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value).length : 0; } function countItems(value) { return Array.isArray(value) ? value.length : 0; } function findDuplicateDeclarations(tokens, blockName) { const seen = new Set(); const duplicates = []; let currentBlock = null; for (const token of tokens) { if (token.type === "command") { currentBlock = token.value.toLowerCase(); continue; } if (currentBlock === blockName && token.type === "declaration") { if (seen.has(token.key)) { duplicates.push(token.key); } else { seen.add(token.key); } } } return duplicates; } function findDuplicateMetaKeys(tokens) { const seen = new Set(); const duplicates = []; for (const token of tokens) { if (token.type !== "meta" && token.type !== "directive") continue; const key = token.type === "directive" ? token.name : token.key; if (seen.has(key)) { duplicates.push(key); } else { seen.add(key); } } return duplicates; } function validatePmlFile(filename, options = {}) { const fullPath = path.resolve(filename); const issues = []; if (!fs.existsSync(fullPath)) { addIssue(issues, "error", `File not found: ${fullPath}`); return { ok: false, path: fullPath, issues }; } const raw = fs.readFileSync(fullPath, "utf8"); const tokens = tokenize(raw); const localAst = parseBlocks(tokens); const baseDir = path.dirname(fullPath); for (const blockName of ["participants", "subjects", "tags"]) { for (const duplicate of findDuplicateDeclarations(tokens, blockName)) { addIssue(issues, "warning", `Duplicate ${blockName} ID: ${duplicate}`); } } for (const duplicate of findDuplicateMetaKeys(tokens)) { addIssue(issues, "warning", `Duplicate meta key: ${duplicate}`); } for (const token of tokens) { if (token.type === "meta" && BUILTIN_META_KEYS.includes(token.key) && String(token.raw || "").startsWith("@meta=")) { addIssue(issues, "warning", `Built-in meta key "${token.key}" is defined via @meta=. Prefer @${token.key}:... for clarity.`); } if (token.type === "inlineMacro" && token.error) { addIssue(issues, "error", `Invalid inline macro on line ${token.line}: ${token.error}`); } } for (const importFile of Array.isArray(localAst.tags_import) ? localAst.tags_import : []) { const resolved = path.resolve(baseDir, normalizeFileRef(importFile)); if (!fs.existsSync(resolved)) { addIssue(issues, "error", `Missing @tags_import file: ${resolved}`); } } for (const importFile of Array.isArray(localAst.participants_import) ? localAst.participants_import : []) { const resolved = path.resolve(baseDir, normalizeFileRef(importFile)); if (!fs.existsSync(resolved)) { addIssue(issues, "error", `Missing @participants_import file: ${resolved}`); } } for (const importFile of Array.isArray(localAst.macros_import) ? localAst.macros_import : []) { const resolved = path.resolve(baseDir, normalizeFileRef(importFile)); if (!fs.existsSync(resolved)) { addIssue(issues, "error", `Missing @macros_import file: ${resolved}`); } } for (const [name, entry] of Object.entries(localAst.imports || {})) { const resolved = path.resolve(baseDir, normalizeFileRef(entry.file)); if (!fs.existsSync(resolved)) { addIssue(issues, "error", `Missing @import file for "${name}": ${resolved}`); } const format = String(entry.format || "text").toLowerCase(); if (!["html", "pml", "text"].includes(format)) { addIssue(issues, "warning", `Unknown import format for "${name}": ${format}`); } } for (const [name, file] of Object.entries(localAst.macros || {})) { const normalized = String(file || "").replace("{{macro_dir}}", path.resolve(__dirname, "..", "..", "macros")); const resolved = path.isAbsolute(normalized) ? normalized : path.resolve(baseDir, normalizeFileRef(normalized)); if (!fs.existsSync(resolved)) { addIssue(issues, "error", `Missing macro file for "${name}": ${resolved}`); } } try { parseFile(fullPath, { strict: true }); } catch (err) { addIssue(issues, "error", err.message); } const trustReport = analyzePmlTrustSync(fullPath, { registrySources: Array.isArray(options.trustRegistry) ? options.trustRegistry : [], }); for (const macro of trustReport.macros || []) { if (macro.effective_trust === "untrusted") { addIssue( issues, options.trustMode === "strict" ? "error" : "warning", `Untrusted macro "${macro.alias}" detected: ${macro.reasons.join(", ") || "policy violation"}` ); } } for (const imported of trustReport.imports || []) { if (imported.effective_trust === "untrusted") { addIssue( issues, options.trustMode === "strict" ? "error" : "warning", `Imported PML "${imported.name}" is untrusted: ${imported.reasons.join(", ") || "policy violation"}` ); } } const summary = { meta_keys: Object.keys(localAst.meta || {}), blocks_present: [ "participants", "subjects", "tags", "tasks", "notes", "meeting", "signatures", "approvals", "references", "attachments", ].filter((key) => localAst[key] && (Array.isArray(localAst[key]) ? localAst[key].length : Object.keys(localAst[key]).length)), counts: { participants: countKeys(localAst.participants), subjects: countKeys(localAst.subjects), tags: countKeys(localAst.tags), tasks: countItems(localAst.tasks), notes: countItems(localAst.notes), meeting_lines: countItems(localAst.meeting), signatures: countKeys(localAst.signatures), approvals: countKeys(localAst.approvals), references: countItems(localAst.references), attachments: countItems(localAst.attachments), macros: countKeys(localAst.macros), inline_macros: countKeys(localAst.inline_macros), imports: countKeys(localAst.imports), tag_imports: countItems(localAst.tags_import), participants_imports: countItems(localAst.participants_import), macros_imports: countItems(localAst.macros_import), }, imports: Object.entries(localAst.imports || {}).map(([name, entry]) => ({ name, file: normalizeFileRef(entry.file), format: String(entry.format || "text").toLowerCase(), })), tags_imports: (Array.isArray(localAst.tags_import) ? localAst.tags_import : []).map(normalizeFileRef), participants_imports: (Array.isArray(localAst.participants_import) ? localAst.participants_import : []).map(normalizeFileRef), macros_imports: (Array.isArray(localAst.macros_import) ? localAst.macros_import : []).map(normalizeFileRef), macros: Object.entries(localAst.macros || {}).map(([name, file]) => ({ name, file: normalizeFileRef(file), })), inline_macros: Object.entries(localAst.inline_macros || {}).map(([name, entry]) => ({ name, line: entry.line, })), trust: { effective_trust: trustReport.effective_trust, signature_status: trustReport.signature_status, author: trustReport.author, author_trust: trustReport.author_trust, macro_counts: { trusted: (trustReport.macros || []).filter((entry) => entry.effective_trust === "trusted").length, unknown: (trustReport.macros || []).filter((entry) => entry.effective_trust === "unknown").length, untrusted: (trustReport.macros || []).filter((entry) => entry.effective_trust === "untrusted").length, }, imported_pml_untrusted: (trustReport.imports || []).filter((entry) => entry.effective_trust === "untrusted").length, macros: trustReport.macros || [], imports: trustReport.imports || [], }, }; return { ok: !issues.some((issue) => issue.severity === "error"), path: fullPath, issues, summary, }; } function validateTagFile(filename) { const fullPath = path.resolve(filename); const issues = []; if (!fs.existsSync(fullPath)) { addIssue(issues, "error", `File not found: ${fullPath}`); return { ok: false, path: fullPath, issues }; } const raw = fs.readFileSync(fullPath, "utf8"); const ast = parseBlocks(tokenize(raw)); const baseDir = path.dirname(fullPath); if (!ast.tags || !Object.keys(ast.tags).length) { addIssue(issues, "error", "Tag file does not define an @tags block."); } for (const importFile of Array.isArray(ast.tags_import) ? ast.tags_import : []) { const resolved = path.resolve(baseDir, normalizeFileRef(importFile)); if (!fs.existsSync(resolved)) { addIssue(issues, "error", `Missing nested @tags_import file: ${resolved}`); } } for (const sourceRef of Array.isArray(ast.tag_sources) ? ast.tag_sources : []) { const resolved = path.resolve(baseDir, normalizeFileRef(sourceRef)); if (!fs.existsSync(resolved)) { addIssue(issues, "error", `Missing @tag_sources file: ${resolved}`); continue; } const sourceValidation = validatePmlFile(resolved); for (const issue of sourceValidation.issues.filter((entry) => entry.severity === "error")) { addIssue(issues, "warning", `Source validation issue in ${resolved}: ${issue.message}`); } } const summary = { meta_keys: Object.keys(ast.meta || {}), counts: { tags: countKeys(ast.tags), tag_imports: countItems(ast.tags_import), tag_sources: countItems(ast.tag_sources), }, tags_imports: (Array.isArray(ast.tags_import) ? ast.tags_import : []).map(normalizeFileRef), tag_sources: (Array.isArray(ast.tag_sources) ? ast.tag_sources : []).map(normalizeFileRef), }; return { ok: !issues.some((issue) => issue.severity === "error"), path: fullPath, issues, summary, }; } function formatValidationReport(report, title = "Validation", verbosity = 0) { const lines = []; lines.push(`${title}: ${report.path}`); lines.push(`Status: ${report.ok ? "ok" : "failed"}`); if (verbosity > 0 && report.summary) { const counts = report.summary.counts || {}; const countEntries = Object.entries(counts).filter(([, value]) => Number(value) > 0); const metaKeys = Array.isArray(report.summary.meta_keys) ? report.summary.meta_keys : []; if (metaKeys.length) { lines.push(`Meta keys: ${metaKeys.join(", ")}`); } if (Array.isArray(report.summary.blocks_present) && report.summary.blocks_present.length) { lines.push(`Blocks: ${report.summary.blocks_present.join(", ")}`); } if (countEntries.length) { lines.push("Detected:"); for (const [key, value] of countEntries) { lines.push(`- ${key}: ${value}`); } } if (Array.isArray(report.summary.imports) && report.summary.imports.length) { lines.push("Imports:"); for (const entry of report.summary.imports) { lines.push(`- ${entry.name}: ${entry.file} (${entry.format})`); } } if (Array.isArray(report.summary.tags_imports) && report.summary.tags_imports.length) { lines.push("Tag imports:"); for (const entry of report.summary.tags_imports) { lines.push(`- ${entry}`); } } if (Array.isArray(report.summary.participants_imports) && report.summary.participants_imports.length) { lines.push("Participant imports:"); for (const entry of report.summary.participants_imports) { lines.push(`- ${entry}`); } } if (Array.isArray(report.summary.macros_imports) && report.summary.macros_imports.length) { lines.push("Macro imports:"); for (const entry of report.summary.macros_imports) { lines.push(`- ${entry}`); } } if (Array.isArray(report.summary.tag_sources) && report.summary.tag_sources.length) { lines.push("Tag sources:"); for (const entry of report.summary.tag_sources) { lines.push(`- ${entry}`); } } if (verbosity >= 2 && Array.isArray(report.summary.macros) && report.summary.macros.length) { lines.push("Macros:"); for (const entry of report.summary.macros) { lines.push(`- ${entry.name}: ${entry.file}`); } } if (verbosity >= 2 && Array.isArray(report.summary.inline_macros) && report.summary.inline_macros.length) { lines.push("Inline macros:"); for (const entry of report.summary.inline_macros) { lines.push(`- ${entry.name} (line ${entry.line})`); } } if (report.summary.trust) { lines.push(`Trust: ${report.summary.trust.effective_trust} (signature=${report.summary.trust.signature_status}, author=${report.summary.trust.author || "(none)"}, author_trust=${report.summary.trust.author_trust || "unknown"})`); const macroCounts = report.summary.trust.macro_counts || {}; if (macroCounts.trusted || macroCounts.unknown || macroCounts.untrusted) { lines.push(`Macro trust: trusted=${macroCounts.trusted || 0}, unknown=${macroCounts.unknown || 0}, untrusted=${macroCounts.untrusted || 0}`); } } if (verbosity >= 3) { lines.push("Checks:"); if (Array.isArray(report.summary.imports)) { for (const entry of report.summary.imports) { lines.push(`- import detected: ${entry.name} (${entry.format}) -> ${entry.file}`); } } if (Array.isArray(report.summary.tags_imports)) { for (const entry of report.summary.tags_imports) { lines.push(`- tag import detected: ${entry}`); } } if (Array.isArray(report.summary.participants_imports)) { for (const entry of report.summary.participants_imports) { lines.push(`- participant import detected: ${entry}`); } } if (Array.isArray(report.summary.macros_imports)) { for (const entry of report.summary.macros_imports) { lines.push(`- macro import detected: ${entry}`); } } if (Array.isArray(report.summary.tag_sources)) { for (const entry of report.summary.tag_sources) { lines.push(`- tag source detected: ${entry}`); } } if (Array.isArray(report.summary.macros)) { for (const entry of report.summary.macros) { lines.push(`- macro detected: ${entry.name} -> ${entry.file}`); } } if (Array.isArray(report.summary.inline_macros)) { for (const entry of report.summary.inline_macros) { lines.push(`- inline macro detected: ${entry.name} (line ${entry.line})`); } } if (Array.isArray(report.summary.trust?.macros)) { for (const entry of report.summary.trust.macros) { lines.push(`- macro trust: ${entry.alias} => ${entry.effective_trust} (signature=${entry.signature_status}, author=${entry.author_trust})`); } } if (Array.isArray(report.summary.trust?.imports)) { for (const entry of report.summary.trust.imports) { lines.push(`- imported pml trust: ${entry.name} => ${entry.effective_trust}`); } } } } if (!report.issues.length) { lines.push("No issues found."); } else { lines.push("Issues:"); for (const issue of report.issues) { lines.push(`- [${issue.severity}] ${issue.message}`); } } return lines.join("\n"); } module.exports = { validatePmlFile, validateTagFile, formatValidationReport, };