// Optional action review, separate from the original static Guard verdict. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from '@babel/parser'; import { sourceFingerprint } from './guard-evidence.mjs'; const text = node => { if (node?.type === 'StringLiteral') return node.value; if (node?.type === 'JSXText') return node.value; if (node?.type === 'JSXExpressionContainer') return text(node.expression); return null; }; const walk = (node, fn) => { if (!node || typeof node !== 'object') return; if (node.type) fn(node); for (const [key, value] of Object.entries(node)) { if (['loc', 'start', 'end', 'comments', 'extra'].includes(key)) continue; if (Array.isArray(value)) value.forEach(item => walk(item, fn)); else if (value && typeof value === 'object') walk(value, fn); } }; const normalize = label => label.replace(/\s+/g, ' ').trim(); export function reviewActions(guard, requirements) { const findings = [], actions = []; const allowed = requirements?.actionPolicy?.allowedLabels; if (requirements?.schemaVersion !== 1 || !Array.isArray(allowed) || !allowed.every(label => typeof label === 'string')) { return { status: 'NOT VERIFIED', rawGuardStatus: guard.status, findings: [{ code: 'REQ-001', reason: 'Explicit schemaVersion:1 actionPolicy.allowedLabels is required' }], actions }; } const accepted = new Set(allowed.map(normalize)); try { if (sourceFingerprint(guard.target, guard.fingerprintFiles) !== guard.sourceFingerprint || sourceFingerprint(guard.contractSource, guard.contractFiles) !== guard.contractFingerprint) throw new Error('Guard source / contract changed'); for (const file of guard.checkedFiles.filter(name => /\.[cm]?[jt]sx?$/.test(name))) { const ast = parse(fs.readFileSync(path.resolve(guard.target, file), 'utf8'), { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] }); const bindings = new Set(['button', 'a']); for (const statement of ast.program.body) if (statement.type === 'ImportDeclaration' && statement.source.value === '@hm-design/ui') { for (const specifier of statement.specifiers) if (specifier.imported?.name === 'Button') bindings.add(specifier.local.name); } walk(ast, node => { if (node.type !== 'JSXElement') return; const opening = node.openingElement; if (opening.name.type !== 'JSXIdentifier' || !bindings.has(opening.name.name)) return; const aria = opening.attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.name === 'aria-label'); let label = aria ? text(aria.value) : ''; let dynamic = false; if (!aria) for (const child of node.children) { if (child.type === 'JSXElement') { dynamic = true; continue; } const value = text(child); if (value === null) dynamic = true; else label += value; } if (opening.attributes.some(attr => attr.type === 'JSXSpreadAttribute')) dynamic = true; label = typeof label === 'string' ? normalize(label) : ''; const action = { file, line: node.loc.start.line, label, dynamic }; actions.push(action); if (dynamic || !label) findings.push({ ...action, code: 'REQ-002', reason: 'Dynamic or composite action name needs runtime review' }); else if (!accepted.has(label)) findings.push({ ...action, code: 'REQ-003', reason: 'Action absent from the explicit requirements allowlist' }); }); } } catch (error) { findings.push({ code: 'REQ-001', reason: error.message }); } // No blanket HTML prohibition; supplied policy is explicit, never inferred from prose. const status = findings.some(item => item.code === 'REQ-003') ? 'FAIL' : findings.length || !actions.length || guard.scope !== 'FULL_SCREEN' ? 'NOT VERIFIED' : 'PASS'; return { schemaVersion: 1, status, rawGuardStatus: guard.status, scope: guard.scope, actions, findings, limitations: ['Static literal Button/button/link names only; wrappers, namespace calls, dynamic labels, APIs and all runtime states require review', 'Does not change original Guard issues, status, runtime evidence or design scores'] }; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const [guardFile, requirementsFile] = process.argv.slice(2); const result = reviewActions(JSON.parse(fs.readFileSync(guardFile, 'utf8')), JSON.parse(fs.readFileSync(requirementsFile, 'utf8'))); console.log(JSON.stringify(result, null, 2)); if (result.status === 'FAIL') process.exitCode = 1; } catch (error) { console.log(JSON.stringify({ status: 'NOT VERIFIED', error: error.message })); process.exitCode = 2; } }