Files
HMDesign/scripts/guard-requirements.mjs

75 lines
4.6 KiB
JavaScript

// 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; }
}