feat: improve lightweight AI generation guard
This commit is contained in:
1 parent
c86d12bebc
commit
4daca6d357
7 files changed
+327
-162
No files matched your search
@@ -17,13 +17,13 @@ const within = (root, p) => { const r = path.relative(root, p); return r === ''
|
||||
const literal = n => n?.type === 'StringLiteral' ? n.value : n?.type === 'TemplateLiteral' && n.expressions.length === 0 ? n.quasis[0].value.cooked : undefined;
|
||||
const nameOf = n => n?.name ?? n?.value;
|
||||
const tagName = n => n?.type === 'JSXMemberExpression' || n?.type === 'MemberExpression' ? `${tagName(n.object)}.${nameOf(n.property)}` : nameOf(n);
|
||||
function walk(node, fn) {
|
||||
function walk(node, fn, parent = null, field = null) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (node.type) fn(node);
|
||||
if (node.type) fn(node, parent, field);
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (['loc', 'start', 'end', 'comments', 'tokens', 'extra'].includes(key)) continue;
|
||||
if (Array.isArray(value)) value.forEach(x => walk(x, fn));
|
||||
else if (value && typeof value === 'object') walk(value, fn);
|
||||
if (Array.isArray(value)) value.forEach(x => walk(x, fn, node, key));
|
||||
else if (value && typeof value === 'object') walk(value, fn, node, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function argumentsFrom(argv) {
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
if (key === '--json' || key === '--help') { options[key.slice(2)] = true; continue; }
|
||||
if (!['--target', '--scope', '--mode', '--entry', '--files'].includes(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`Invalid option: ${key}`);
|
||||
if (!['--target', '--scope', '--mode', '--entry', '--files', '--result'].includes(key) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`Invalid option: ${key}`);
|
||||
const value = argv[++i];
|
||||
if (key === '--entry' || key === '--files') options[key.slice(2)].push(...value.split(',').filter(Boolean));
|
||||
else options[key.slice(2)] = value;
|
||||
@@ -57,6 +57,7 @@ async function inspect(options) {
|
||||
const selected = options.files.map(p => path.resolve(target, p));
|
||||
if (options.scope === 'PARTIAL' && isFile(targetPath) && !selected.length) selected.push(targetPath);
|
||||
const selectedFile = p => !selected.length || selectedDependencies?.has(p) || selected.some(s => s === p || (!isFile(s) && within(s, p)));
|
||||
const changedFile = p => !selected.length || selected.some(s => s === p || (!isFile(s) && within(s, p)));
|
||||
const add = (severity, code, file, node, message, contextual = false) => {
|
||||
const line = node?.loc?.start?.line ?? node?.source?.start?.line ?? 1;
|
||||
const issue = { severity, code, file: file ? normalize(path.relative(target, file)) || path.basename(file) : '.', line, message };
|
||||
@@ -100,6 +101,10 @@ async function inspect(options) {
|
||||
spec = spec.split(/[?#]/)[0];
|
||||
for (const [pkg, contract] of contracts) if (spec === pkg || spec.startsWith(`${pkg}/`)) return { pkg, key: spec === pkg ? '.' : `.${spec.slice(pkg.length)}`, contract };
|
||||
};
|
||||
// Public component names supply lightweight slot vocabulary, without reading implementations.
|
||||
const componentNames = new Set([...contracts.values()].flatMap(c => [...c.entries.values()].flatMap(names => names ? [...names].filter(n => /^[A-Z]/.test(n)) : [])));
|
||||
const slots = new Set([...componentNames].map(n => n.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()));
|
||||
const knownSlot = value => typeof value === 'string' && (slots.has(value) || /^(?:select|gnb|lnb|dashboard)-/.test(value));
|
||||
const aliases = [];
|
||||
// JSONC paths are read as data. Config files are never executed.
|
||||
for (const file of ['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json']) {
|
||||
@@ -171,8 +176,8 @@ async function inspect(options) {
|
||||
} else if (edge.resolved && privatePath(edge.resolved)) add('FAIL', 'IMP-001', record.file, edge.node, 'Use the public HM package entry instead of an internal source path');
|
||||
};
|
||||
function visit(file) {
|
||||
if (graph.has(file)) return;
|
||||
const record = { file, edges: [], bindings: new Map(), exports: new Map(), stars: [], renders: [], members: [], classes: [], status: false, css: false, features: new Set() };
|
||||
if (graph.has(file) || privatePath(file)) return;
|
||||
const record = { file, edges: [], bindings: new Map(), exports: new Map(), stars: [], renders: [], members: [], classes: [], references: new Set(), mounts: false, status: false, css: false, features: new Set() };
|
||||
graph.set(file, record);
|
||||
let text;
|
||||
try { text = fs.readFileSync(file, 'utf8'); } catch { add('NOT VERIFIED', 'ENTRY-002', file, null, 'Cannot read production dependency', true); return; }
|
||||
@@ -199,6 +204,8 @@ async function inspect(options) {
|
||||
css.walkAtRules('tailwind', n => { if (n.params === 'utilities') record.features.add('utilities'); });
|
||||
css.walkRules(n => {
|
||||
if (/\.sb-(?:page(?:-inner|-header)?|section|board)(?![\w-])/.test(n.selector)) add('FAIL', 'SB-003', file, n, 'Production uses Storybook-only CSS');
|
||||
if ([...n.selector.matchAll(/data-slot\s*=\s*["']?([\w-]+)/g)].some(m => knownSlot(m[1]))) add('NOT VERIFIED', 'REUSE-003', file, n, 'HM slot styling: confirm approved customization, not a CSS replica');
|
||||
if ([...n.selector.matchAll(/\.hm-([\w-]+)/g)].some(m => knownSlot(m[1]))) add('NOT VERIFIED', 'REUSE-003', file, n, 'HM-named custom CSS: confirm it is not a component replica');
|
||||
});
|
||||
css.walkDecls(n => { if (n.value.includes('--hm-dashboard-status-')) record.status = true; });
|
||||
} catch { add('NOT VERIFIED', 'CSS-005', file, null, 'Cannot parse production CSS', true); }
|
||||
@@ -221,7 +228,10 @@ async function inspect(options) {
|
||||
stmt.declaration?.declarations?.forEach(d => { if (d.id.type === 'Identifier') record.exports.set(d.id.name, { local: d.id.name }); });
|
||||
} else if (stmt.type === 'ExportDefaultDeclaration') record.exports.set('default', { local: stmt.declaration.id?.name ?? (stmt.declaration.type === 'Identifier' ? stmt.declaration.name : 'default') });
|
||||
}
|
||||
walk(ast, n => {
|
||||
walk(ast, (n, parent, field) => {
|
||||
if (n.type === 'Identifier' && !parent?.type.startsWith('Import') && !parent?.type.startsWith('TS') && !((parent?.type === 'MemberExpression' || parent?.type === 'ObjectProperty' || parent?.type === 'ObjectMethod') && field === (parent.type === 'MemberExpression' ? 'property' : 'key') && !parent.computed) && !(parent?.type === 'VariableDeclarator' && field === 'id')) record.references.add(n.name);
|
||||
if (n.type === 'JSXIdentifier' && /^[A-Z]/.test(n.name)) record.references.add(n.name);
|
||||
if (n.type === 'CallExpression' && /^(?:(?:ReactDOM|ReactDOMClient)\.)?(?:createRoot|hydrateRoot)$|^ReactDOM\.(?:render|hydrate)$/.test(tagName(n.callee))) record.mounts = true;
|
||||
if (n.type === 'ImportExpression' || n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require') {
|
||||
const value = literal(n.source ?? n.arguments[0]);
|
||||
if (value) { edge(value, n); if (packageRef(value)) add('NOT VERIFIED', 'IMP-003', file, n, 'HM dynamic import / require bindings need runtime confirmation'); }
|
||||
@@ -245,12 +255,14 @@ async function inspect(options) {
|
||||
for (const e of record.edges) if (e.runtime && e.resolved && !privatePath(e.resolved) && !normalize(e.resolved).includes('/node_modules/')) visit(e.resolved);
|
||||
}
|
||||
const entries = [];
|
||||
const htmlFiles = options.entry.length ? options.entry.map(e => path.resolve(target, e)) : isFile(targetPath) ? [targetPath] : [path.join(target, 'index.html')];
|
||||
const htmlFiles = options.result ? [path.resolve(target, options.result)] : options.entry.length ? options.entry.map(e => path.resolve(target, e)) : isFile(targetPath) ? [targetPath] : [path.join(target, 'index.html')];
|
||||
for (const entry of htmlFiles) {
|
||||
if (!isFile(entry)) { add('NOT VERIFIED', 'ENTRY-001', entry, null, 'Production entry unavailable; specify --entry bootstrap-path', true); continue; }
|
||||
if (isStory(entry)) { add('NOT VERIFIED', 'ENTRY-001', entry, null, 'Storybook is not a production entry', true); continue; }
|
||||
if (!entry.endsWith('.html')) { entries.push({ file: entry, roots: [entry] }); continue; }
|
||||
const html = fs.readFileSync(entry, 'utf8').replace(/<!--[\s\S]*?-->/g, '');
|
||||
const markup = html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, s => s.replace(/[^\n]/g, ' '));
|
||||
for (const m of markup.matchAll(/<([a-z][\w-]*)\b[^>]*\bdata-slot\s*=\s*["']([^"']+)["'][^>]*>/gi)) if (knownSlot(m[2])) add('FAIL', 'REUSE-001', entry, { loc: { start: { line: markup.slice(0, m.index).split('\n').length } } }, `Native <${m[1]}> claims HM slot ${m[2]}; use the public React component`, true);
|
||||
const roots = [];
|
||||
for (const m of html.matchAll(/<(script|link)\b([^>]+)>/gi)) {
|
||||
const attrs = Object.fromEntries([...m[2].matchAll(/([\w-]+)\s*=\s*["']([^"']*)["']/g)].map(a => [a[1].toLowerCase(), a[2]]));
|
||||
@@ -266,6 +278,13 @@ async function inspect(options) {
|
||||
entries.push({ file: entry, roots });
|
||||
}
|
||||
entries.forEach(e => e.roots.forEach(visit));
|
||||
if (options.result) {
|
||||
const entry = entries[0];
|
||||
const records = entry ? closure(entry.roots) : [];
|
||||
const react = records.some(r => r.mounts && r.edges.some(e => /^react-dom(?:\/client)?$/.test(e.spec)));
|
||||
if (!react) add('NOT VERIFIED', 'RENDER-001', entry?.file ?? path.resolve(target, options.result), null, 'Final artifact has no proven React mount; HTML imitation is not React rendering evidence', true);
|
||||
if (react) add('NOT VERIFIED', 'RENDER-002', entry.file, null, 'React bootstrap found; confirm this artifact in the browser once (layout / utilities / interaction)', true);
|
||||
}
|
||||
for (const s of selected) {
|
||||
if (!fs.existsSync(s)) add('NOT VERIFIED', 'SCOPE-001', s, null, 'Selected change path unavailable', true);
|
||||
else if (![...graph.keys()].some(f => s === f || within(s, f))) add('NOT VERIFIED', 'SCOPE-001', s, null, 'Selected path is outside the proven production graph; supply --entry', true);
|
||||
@@ -303,6 +322,20 @@ async function inspect(options) {
|
||||
selectedDependencies = new Set(closure(changedRoots).map(r => r.file));
|
||||
}
|
||||
const directlyReused = r => r.edges.some(e => e.runtime && (packageRef(e.spec) || e.resolved && privatePath(e.resolved))) || r.renders.some(x => origin(r.file, x.name)?.pkg);
|
||||
for (const r of graph.values()) {
|
||||
if (!changedFile(r.file) || privatePath(r.file) || isStory(r.file)) continue;
|
||||
for (const render of r.renders) {
|
||||
const slot = literal(render.attrs.get('data-slot'));
|
||||
if (knownSlot(slot) && origin(r.file, render.name)?.pkg !== '@hm-design/ui' && origin(r.file, render.name)?.pkg !== '@hm-design/center-graphic') add(/^[a-z]/.test(render.name) ? 'FAIL' : 'NOT VERIFIED', 'REUSE-001', r.file, render.node, `${render.name} claims HM slot ${slot}; confirm public component provenance`);
|
||||
if (render.attrs.has('data-slot') && slot === undefined) add('NOT VERIFIED', 'REUSE-005', r.file, render.node, 'Computed data-slot requires component provenance confirmation');
|
||||
if (/^(?:select|input|textarea|button)$/.test(render.name)) add('NOT VERIFIED', 'REUSE-004', r.file, render.node, `Native <${render.name}>: confirm it does not replace an indexed HM component`);
|
||||
}
|
||||
for (const e of r.edges) for (const b of e.names) {
|
||||
const identity = origin(r.file, b.local);
|
||||
if (e.node.type !== 'ImportDeclaration' || !e.runtime || b.typeOnly || !(identity?.pkg || packageRef(e.spec)) || !(componentNames.has(identity?.name ?? b.imported) || b.imported === '*')) continue;
|
||||
if (!r.references.has(b.local)) add('FAIL', 'REUSE-002', r.file, e.node, `${identity?.name ?? b.imported} imported but unused; import alone is not component reuse`);
|
||||
}
|
||||
}
|
||||
// Related ancestors supply bootstrap / CSS evidence. A new sibling UI is not
|
||||
// made an HM element simply because the screen also renders an HM component.
|
||||
applicableFiles = new Set();
|
||||
@@ -327,7 +360,7 @@ async function inspect(options) {
|
||||
let selectedDashboardLayout = null;
|
||||
let officialDashboardHeader = false;
|
||||
for (const r of reused) {
|
||||
for (const e of r.edges.filter(e => e.runtime && packageRef(e.spec))) for (const b of e.names) if (!b.typeOnly && packageRef(e.spec).contract.entries.get(packageRef(e.spec).key)?.has(b.imported)) remember(b.imported);
|
||||
for (const e of r.edges.filter(e => e.runtime && packageRef(e.spec))) for (const b of e.names) if (!b.typeOnly && r.references.has(b.local) && packageRef(e.spec).contract.entries.get(packageRef(e.spec).key)?.has(b.imported)) remember(b.imported);
|
||||
for (const render of r.renders) { const identity = origin(r.file, render.name); if (identity?.pkg) remember(identity.name); }
|
||||
}
|
||||
for (const r of records) for (const render of r.renders) {
|
||||
@@ -375,6 +408,7 @@ async function inspect(options) {
|
||||
for (let i = issues.length - 1; i >= 0; i--) {
|
||||
const item = issues[i];
|
||||
const file = path.resolve(target, item.file);
|
||||
if (item.code.startsWith('REUSE-') && selected.length && !changedFile(file) && !(options.result && file === path.resolve(target, options.result))) { issues.splice(i, 1); continue; }
|
||||
if (selected.length && !contextualIssues.has(item) && !selectedFile(file)) { issues.splice(i, 1); continue; }
|
||||
if (/^(?:SB|CSS|NAV)-/.test(item.code) && !applicableFiles.has(file) && !item.code.startsWith('NAV-003')) issues.splice(i, 1);
|
||||
}
|
||||
@@ -386,21 +420,20 @@ async function inspect(options) {
|
||||
const related = issues.filter(x => x.code.startsWith(['NAV', 'SB', 'CSS', 'IMP'][i]) || x.code.startsWith('ENTRY') || x.code.startsWith('CORE') || x.code.startsWith('SCOPE'));
|
||||
return { name, status: related.length ? statusFor(related) : groupApplicability[i] ? 'PASS' : 'NOT APPLICABLE' };
|
||||
});
|
||||
return { status: issues.length ? statusFor(issues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', scope: options.scope, mode: options.mode ?? null, target, contractSource: workspace, checkedGroups: groupResults, checkedHMElements: [...checkedElements].sort(), issues, durationMs: Math.round((performance.now() - start) * 100) / 100, note: 'Reused HM elements only. New UI/layout design, browser rendering, generated utility output and runtime behavior are not verified.' };
|
||||
return { status: issues.length ? statusFor(issues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', scope: options.scope, mode: options.mode ?? null, target, contractSource: workspace, checkedGroups: groupResults, checkedHMElements: [...checkedElements].sort(), issues, durationMs: Math.round((performance.now() - start) * 100) / 100, note: 'Static contracts only. Native controls / CSS intent, actual rendered component usage, browser layout and behavior require verification. --result checks a final artifact bootstrap, not browser evidence.' };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const options = argumentsFrom(process.argv.slice(2));
|
||||
if (options.help) console.log('node scripts/ai-generation-guard.mjs --target PATH --scope FULL_SCREEN|PARTIAL [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]\n[--entry BOOTSTRAP] [--files CHANGED_FILE,... (PARTIAL)] [--json]\nPARTIAL checks reachable selected files; no Navigation creation required. PASS covers static contracts only.');
|
||||
if (options.help) console.log('node scripts/ai-generation-guard.mjs --target PATH --scope FULL_SCREEN|PARTIAL [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]\n[--entry BOOTSTRAP] [--files CHANGED_FILE,... (PARTIAL)] [--result FINAL_HTML_OR_BOOTSTRAP] [--json]\n--result selects the submitted React artifact. PARTIAL checks reachable selected files; PASS covers static contracts only.');
|
||||
else {
|
||||
const report = await inspect(options);
|
||||
if (options.json) console.log(JSON.stringify(report, null, 2));
|
||||
else if (!report.issues.length) console.log(`HMDesign FAST CORE: ${report.status}\nHMDesign: ${report.checkedHMElements.join(', ') || 'none'}\nChecked: 4 groups; Issues: 0`);
|
||||
else if (!report.issues.length) console.log(report.status);
|
||||
else {
|
||||
console.log(`HMDesign FAST CORE: ${report.status}`);
|
||||
console.log(`HMDesign: ${report.checkedHMElements.join(', ') || 'none'}`);
|
||||
for (const issue of report.issues.slice(0, 5)) console.log(`${issue.severity} ${issue.code} ${issue.file}:${issue.line}\n${issue.message}`);
|
||||
console.log(report.status);
|
||||
for (const issue of report.issues.slice(0, 5)) console.log(`${issue.severity} ${issue.code} ${issue.file}:${issue.line} ${issue.message}`);
|
||||
if (report.issues.length > 5) console.log(`Issues: ${report.issues.length} (showing 5)`);
|
||||
}
|
||||
if (report.status === 'FAIL') process.exitCode = 1;
|
||||
|
||||
Reference in new issue
Block a user