75 lines
4.6 KiB
JavaScript
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; }
|
|
}
|