515 lines
46 KiB
JavaScript
515 lines
46 KiB
JavaScript
#!/usr/bin/env node
|
|
// FAST CORE only. Read-only static contracts; PASS does not verify browser quality.
|
|
// Run from this workspace (existing Babel/PostCSS dependencies; no target install needed).
|
|
// --entry selects a production bootstrap; --files narrows PARTIAL findings to changed files.
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { performance } from 'node:perf_hooks';
|
|
import { navigationAnalysis } from './guard-navigation-analysis.mjs';
|
|
import { themeAnalysis } from './guard-theme-analysis.mjs';
|
|
import { sourceFingerprint } from './guard-evidence.mjs';
|
|
import { styleAnalysis, verdict } from './guard-style-analysis.mjs';
|
|
|
|
const workspace = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const groups = ['Navigation', 'Storybook / Production', 'Production CSS Entry', 'Official Component Import', 'Theme Token', 'Component Reuse'];
|
|
const modes = ['GNB_ONLY', 'LNB_ONLY', 'DASHBOARD_ONLY'];
|
|
const normalize = p => p.replaceAll('\\', '/');
|
|
const isFile = p => { try { return fs.statSync(p).isFile(); } catch { return false; } };
|
|
const within = (root, p) => { const r = path.relative(root, p); return r === '' || (!r.startsWith('..') && !path.isAbsolute(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, parent = null, field = null) {
|
|
if (!node || typeof node !== 'object') return;
|
|
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, node, key));
|
|
else if (value && typeof value === 'object') walk(value, fn, node, key);
|
|
}
|
|
}
|
|
|
|
function argumentsFrom(argv) {
|
|
const options = { scope: 'FULL_SCREEN', profile: 'PRODUCTION', entry: [], files: [], json: false };
|
|
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', '--profile', '--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;
|
|
}
|
|
if (!options.help && (!options.target || !['FULL_SCREEN', 'PARTIAL'].includes(options.scope) || (options.mode && !modes.includes(options.mode)))) throw new Error('Use --target PATH --scope FULL_SCREEN|PARTIAL [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]');
|
|
if (options.files.length && options.scope !== 'PARTIAL') throw new Error('--files is for PARTIAL scope only');
|
|
if (!['PRODUCTION', 'STORYBOOK'].includes(options.profile)) throw new Error('--profile must be PRODUCTION|STORYBOOK');
|
|
if (options.profile === 'STORYBOOK' && options.result) throw new Error('--result is a Production artifact option');
|
|
return options;
|
|
}
|
|
|
|
async function inspect(options) {
|
|
const start = performance.now();
|
|
const targetPath = path.resolve(options.target);
|
|
if (!fs.existsSync(targetPath)) throw new Error('Target does not exist');
|
|
const target = isFile(targetPath) ? path.dirname(targetPath) : targetPath;
|
|
const storybook = options.profile === 'STORYBOOK';
|
|
const issues = [];
|
|
const graph = new Map();
|
|
const entries = [];
|
|
const contextualIssues = new Set();
|
|
let selectedDependencies = null;
|
|
let applicableFiles = null;
|
|
let groupApplicability = [false, false, false, false, false, false];
|
|
const tokenEvidence = [];
|
|
let styleChecks = [];
|
|
const contractFiles = ['packages/ui/src/index.ts', 'packages/ui/package.json', 'packages/center-graphic/src/index.ts', 'packages/center-graphic/package.json', 'scripts/ai-generation-guard.mjs', 'scripts/guard-navigation-analysis.mjs', 'scripts/guard-theme-analysis.mjs', 'scripts/guard-style-analysis.mjs', 'scripts/guard-style-evidence.mjs', 'scripts/guard-evidence.mjs'];
|
|
// Hash package implementation identity; these files are never consumer graph roots.
|
|
const identityFiles = dir => fs.readdirSync(path.join(workspace, dir), { withFileTypes: true }).flatMap(e => e.isDirectory() ? identityFiles(`${dir}/${e.name}`) : /\.(?:css|tsx?|json)$/.test(e.name) ? [`${dir}/${e.name}`] : []);
|
|
contractFiles.push(...identityFiles('packages/ui/src'), ...identityFiles('packages/center-graphic/src'));
|
|
const contractFingerprint = sourceFingerprint(workspace, contractFiles);
|
|
const checkedElements = new Set();
|
|
const seenIssues = new Set();
|
|
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 };
|
|
const key = JSON.stringify(issue);
|
|
if (!seenIssues.has(key)) { seenIssues.add(key); issues.push(issue); if (contextual) contextualIssues.add(issue); }
|
|
};
|
|
let parse, postcss;
|
|
try { ({ parse } = await import('@babel/parser')); postcss = (await import('postcss')).default; }
|
|
catch { add('NOT VERIFIED', 'CORE-001', null, null, 'Workspace parser dependencies unavailable; restore workspace dependencies', true); return finish(); }
|
|
if (options.scope === 'PARTIAL' && !selected.length) {
|
|
add('NOT VERIFIED', 'SCOPE-002', null, null, 'PARTIAL requires --files changed-path,... or a file --target; changed region is unknown', true);
|
|
return finish();
|
|
}
|
|
const parseJS = (text, file) => parse(text, { sourceType: 'unambiguous', plugins: [...(/\.[cm]?tsx?$/.test(file) ? ['typescript'] : []), 'jsx'], createImportExpressions: true });
|
|
const contracts = new Map();
|
|
// Parse public barrels and manifest exports for import contracts.
|
|
// Implementation text is only a conservative blocker for CSS scope exclusions.
|
|
for (const [pkg, dir] of [['@hm-design/ui', 'ui'], ['@hm-design/center-graphic', 'center-graphic']]) {
|
|
const base = path.join(workspace, 'packages', dir);
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(base, 'package.json'), 'utf8'));
|
|
const entries = new Map();
|
|
for (const [key, spec] of Object.entries(manifest.exports)) {
|
|
if (key.endsWith('.css')) { entries.set(key, null); continue; }
|
|
const exported = typeof spec === 'string' ? spec : spec.import ?? spec.default;
|
|
const source = path.join(base, exported.replace(/^\.\/dist\//, 'src/').replace(/\.js$/, '.ts'));
|
|
const names = new Set();
|
|
if (isFile(source)) {
|
|
for (const stmt of parseJS(fs.readFileSync(source, 'utf8'), source).program.body) {
|
|
if (stmt.type === 'ExportNamedDeclaration') {
|
|
stmt.specifiers.forEach(s => names.add(nameOf(s.exported)));
|
|
if (stmt.declaration?.id) names.add(stmt.declaration.id.name);
|
|
stmt.declaration?.declarations?.forEach(d => { if (d.id.type === 'Identifier') names.add(d.id.name); });
|
|
} else if (stmt.type === 'ExportDefaultDeclaration') names.add('default');
|
|
else if (stmt.type === 'ExportAllDeclaration') add('NOT VERIFIED', 'IMP-004', null, null, `${pkg}${key.slice(1)} uses an unexpanded public barrel`, true);
|
|
}
|
|
entries.set(key, names);
|
|
} else { entries.set(key, undefined); add('NOT VERIFIED', 'IMP-004', null, null, `${pkg}${key.slice(1)} public barrel unavailable`, true); }
|
|
}
|
|
contracts.set(pkg, { base, entries });
|
|
}
|
|
const packageRef = spec => {
|
|
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|tabs|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']) {
|
|
const config = path.join(target, file);
|
|
if (!isFile(config)) continue;
|
|
try {
|
|
const ast = parseJS(`(${fs.readFileSync(config, 'utf8')})`, 'config.js');
|
|
const toData = n => n.type === 'ObjectExpression' ? Object.fromEntries(n.properties.filter(p => p.type === 'ObjectProperty').map(p => [nameOf(p.key), toData(p.value)])) : n.type === 'ArrayExpression' ? n.elements.map(toData) : literal(n);
|
|
const compiler = toData(ast.program.body[0].expression).compilerOptions ?? {};
|
|
for (const [key, values] of Object.entries(compiler.paths ?? {})) aliases.push({ key, values: values.map(v => path.resolve(target, compiler.baseUrl ?? '.', v)) });
|
|
if (compiler.baseUrl) aliases.push({ key: '*', values: [path.join(target, compiler.baseUrl, '*')] });
|
|
if (toData(ast.program.body[0].expression).extends) add('NOT VERIFIED', 'ENTRY-003', config, null, 'Inherited path aliases are not expanded', true);
|
|
} catch { add('NOT VERIFIED', 'ENTRY-003', config, null, 'Cannot read path alias configuration statically', true); }
|
|
}
|
|
// Support common Vite aliases: { '@': path.resolve(import.meta.dirname, './src') }.
|
|
for (const file of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) {
|
|
const config = path.join(target, file);
|
|
if (!isFile(config)) continue;
|
|
try {
|
|
walk(parseJS(fs.readFileSync(config, 'utf8'), config), n => {
|
|
if (n.type !== 'ObjectProperty' || nameOf(n.key) !== 'alias') return;
|
|
if (n.value.type !== 'ObjectExpression') { add('NOT VERIFIED', 'ENTRY-003', config, n, 'Computed Vite aliases are not expanded', true); return; }
|
|
for (const prop of n.value.properties) {
|
|
const v = literal(prop.value);
|
|
const call = prop.value?.type === 'CallExpression' && tagName(prop.value.callee) === 'path.resolve' ? prop.value : null;
|
|
const relative = call && literal(call.arguments.at(-1));
|
|
const base = call?.arguments[0];
|
|
if (v || (relative && (tagName(base) === '__dirname' || (base?.type === 'MemberExpression' && base.object.type === 'MetaProperty' && nameOf(base.property) === 'dirname')))) aliases.unshift({ key: `${nameOf(prop.key)}/*`, values: [path.join(path.resolve(target, v ?? relative), '*')] });
|
|
else add('NOT VERIFIED', 'ENTRY-003', config, prop, 'Vite alias needs runtime evaluation', true);
|
|
}
|
|
});
|
|
} catch { add('NOT VERIFIED', 'ENTRY-003', config, null, 'Cannot parse Vite aliases', true); }
|
|
}
|
|
function resolve(spec, from) {
|
|
const clean = spec.split(/[?#]/)[0];
|
|
if (/^(?:https?:|data:|\/\/)/.test(clean)) return null;
|
|
const bases = [];
|
|
if (clean.startsWith('.')) bases.push(path.resolve(path.dirname(from), clean));
|
|
else if (clean.startsWith('/')) bases.push(path.resolve(target, `.${clean}`));
|
|
else for (const { key, values } of aliases) {
|
|
const [prefix, suffix = ''] = key.split('*');
|
|
if (key.includes('*') ? clean.startsWith(prefix) && clean.endsWith(suffix) : clean === key) {
|
|
const part = key.includes('*') ? clean.slice(prefix.length, suffix ? -suffix.length : undefined) : '';
|
|
bases.push(...values.map(v => v.replace('*', part)));
|
|
}
|
|
}
|
|
for (const base of bases) {
|
|
const candidates = [base, ...['.tsx', '.ts', '.jsx', '.js', '.mts', '.mjs', '.cts', '.cjs', '.css'].map(e => base + e), ...['index.tsx', 'index.ts', 'index.jsx', 'index.js'].map(e => path.join(base, e))];
|
|
if (/\.[cm]?js$/.test(base)) candidates.push(base.replace(/\.[cm]?js$/, '.ts'), base.replace(/\.[cm]?js$/, '.tsx'));
|
|
const found = candidates.find(isFile);
|
|
if (found) return fs.realpathSync(found);
|
|
}
|
|
return null;
|
|
}
|
|
const isStory = s => /(?:^|\/)(?:\.storybook|storybook)(?:\/|$)|\.stories\.[cm]?[jt]sx?$|^@storybook(?:\/|$)|(?:^|\/)(?:story-page|story-board|center-shared|center-stories)(?:\.|\/|$)/i.test(normalize(s));
|
|
const privatePath = p => [...contracts.values()].some(c => within(c.base, p) || /\/packages\/(?:ui|center-graphic)\/(?:src|dist)\//.test(normalize(p)));
|
|
const checkImport = (record, edge) => {
|
|
if (!edge.runtime) return;
|
|
const ref = packageRef(edge.spec);
|
|
if (!storybook && (isStory(edge.spec) || isStory(edge.resolved ?? '') || ref?.pkg === '@hm-design/center-graphic' && ref.key === './reference')) add('FAIL', 'SB-001', record.file, edge.node, 'Production cannot import a Storybook-only entry');
|
|
if (!storybook && edge.names.some(n => /^Story(?:Page|Section|Board)/.test(n.imported))) add('FAIL', 'SB-002', record.file, edge.node, 'Production cannot use StoryPage / StorySection helpers');
|
|
if (ref) {
|
|
if (!ref.contract.entries.has(ref.key)) add('FAIL', 'IMP-001', record.file, edge.node, `${edge.spec} is not a public package export`);
|
|
else {
|
|
const names = ref.contract.entries.get(ref.key);
|
|
if (names) for (const binding of edge.names) if (binding.imported !== '*' && !names.has(binding.imported)) add('FAIL', 'IMP-002', record.file, edge.node, `${binding.imported} is not exported by ${edge.spec}`);
|
|
}
|
|
} 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) || 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; }
|
|
const edge = (spec, node, names = [], runtime = true) => {
|
|
// Vite raw/inline/url CSS imports yield a value, not a connected stylesheet.
|
|
const cssValueOnly = /\.css(?:[?#]|$)/.test(spec) && /[?&](?:raw|inline|url)(?:[=&]|$)/.test(spec);
|
|
const item = { spec, node, names, runtime: runtime && !cssValueOnly, resolved: packageRef(spec) ? null : resolve(spec, file) };
|
|
record.edges.push(item);
|
|
checkImport(record, item);
|
|
const aliased = aliases.some(a => spec.startsWith(a.key.split('*')[0]));
|
|
if (runtime && !packageRef(spec) && !item.resolved && (/^[./#]/.test(spec) || aliased || /\.(?:css|s[ac]ss|less)$/.test(spec))) add('NOT VERIFIED', 'ENTRY-002', file, node, `Unresolved production import: ${spec}`, true);
|
|
return item;
|
|
};
|
|
if (/\.css$/.test(file)) {
|
|
record.css = true;
|
|
try {
|
|
const css = postcss.parse(text, { from: file });
|
|
css.walkAtRules('import', n => {
|
|
const match = n.params.match(/^(?:url\(\s*)?["']([^"']+)["']/);
|
|
if (match) edge(match[1], n);
|
|
else add('NOT VERIFIED', 'CSS-005', file, n, 'CSS import cannot be resolved statically', true);
|
|
if (/\b(?:supports|layer)\s*\(|\)\s+(?:screen|print)|["']\s+(?:screen|print)/.test(n.params)) add('NOT VERIFIED', 'CSS-005', file, n, 'Conditional CSS entry requires runtime confirmation', true);
|
|
});
|
|
css.walkAtRules('tailwind', n => { if (n.params === 'utilities') record.features.add('utilities'); });
|
|
css.walkRules(n => {
|
|
if (!storybook && /\.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); }
|
|
} else if (/\.[cm]?[jt]sx?$/.test(file)) {
|
|
let ast;
|
|
try { ast = parseJS(text, file); } catch { add('NOT VERIFIED', 'ENTRY-004', file, null, 'Cannot parse production module', true); return; }
|
|
record.ast = ast;
|
|
for (const stmt of ast.program.body) {
|
|
if (stmt.type === 'ImportDeclaration') {
|
|
const names = stmt.specifiers.map(s => ({ local: s.local.name, imported: s.type === 'ImportSpecifier' ? nameOf(s.imported) : s.type === 'ImportDefaultSpecifier' ? 'default' : '*', typeOnly: stmt.importKind === 'type' || s.importKind === 'type' }));
|
|
const e = edge(stmt.source.value, stmt, names, stmt.importKind !== 'type' && (!names.length || names.some(n => !n.typeOnly)));
|
|
for (const b of names) record.bindings.set(b.local, { edge: e, imported: b.imported });
|
|
} else if ((stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportAllDeclaration') && stmt.source) {
|
|
const names = (stmt.specifiers ?? []).map(s => ({ imported: nameOf(s.local) ?? '*', local: nameOf(s.exported), typeOnly: stmt.exportKind === 'type' || s.exportKind === 'type' }));
|
|
const e = edge(stmt.source.value, stmt, names, stmt.exportKind !== 'type');
|
|
if (stmt.type === 'ExportAllDeclaration') record.stars.push(e);
|
|
else names.forEach(b => record.exports.set(b.local, { edge: e, imported: b.imported }));
|
|
} else if (stmt.type === 'ExportNamedDeclaration') {
|
|
stmt.specifiers.forEach(s => record.exports.set(nameOf(s.exported), { local: nameOf(s.local) }));
|
|
if (stmt.declaration?.id) record.exports.set(stmt.declaration.id.name, { local: stmt.declaration.id.name });
|
|
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, 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'); }
|
|
else add('NOT VERIFIED', 'ENTRY-005', file, n, 'Computed import needs runtime resolution', true);
|
|
}
|
|
if (n.type === 'CallExpression' && tagName(n.callee) === 'import.meta.glob') add('NOT VERIFIED', 'ENTRY-005', file, n, 'Glob imports need bundler resolution', true);
|
|
if (n.type === 'VariableDeclarator' && n.id.type === 'Identifier' && ['Identifier', 'MemberExpression'].includes(n.init?.type)) record.bindings.set(n.id.name, { local: tagName(n.init) });
|
|
if (n.type === 'MemberExpression' && n.object.type === 'Identifier') record.members.push({ name: tagName(n), node: n });
|
|
if (n.type === 'JSXOpeningElement') {
|
|
const attrs = new Map(n.attributes.filter(a => a.type === 'JSXAttribute').map(a => [nameOf(a.name), a.value?.type === 'JSXExpressionContainer' ? a.value.expression : a.value]));
|
|
record.renders.push({ name: tagName(n.name), node: n, attrs });
|
|
for (const key of ['className', 'class']) if (attrs.has(key)) {
|
|
walk(attrs.get(key), x => { const v = literal(x); if (v !== undefined) record.classes.push({ value: v, node: x }); });
|
|
}
|
|
if (['active', 'hold', 'done', 'issue'].includes(literal(attrs.get('data-status')))) record.status = true;
|
|
}
|
|
if (n.type === 'CallExpression' && /^(?:React\.)?createElement$|^(?:jsx|jsxs|jsxDEV|_jsx|_jsxs)$/.test(tagName(n.callee))) record.renders.push({ name: tagName(n.arguments[0]), node: n, attrs: new Map((n.arguments[1]?.properties ?? []).filter(p => p.type === 'ObjectProperty').map(p => [nameOf(p.key), p.value])) });
|
|
});
|
|
for (const c of record.classes) if (!storybook && c.value.split(/\s+/).some(v => /^sb-(?:page(?:-inner|-header)?|section|board)$/.test(v))) add('FAIL', 'SB-003', file, c.node, 'Production uses Storybook-only class names');
|
|
} else if (/\.(?:scss|sass|less)$/.test(file)) add('NOT VERIFIED', 'CSS-005', file, null, 'CSS preprocessor entry is not statically expanded', true);
|
|
for (const e of record.edges) if (e.runtime && e.resolved && !privatePath(e.resolved) && !normalize(e.resolved).includes('/node_modules/')) visit(e.resolved);
|
|
}
|
|
let storyEntries = [];
|
|
let storyPreview;
|
|
if (storybook) {
|
|
const { default: glob } = await import('fast-glob');
|
|
const config = ['main.ts', 'main.js', 'main.mjs'].map(f => path.join(target, '.storybook', f)).find(isFile);
|
|
if (options.entry.length) storyEntries = options.entry.map(e => path.resolve(target, e));
|
|
else if (config) {
|
|
try {
|
|
walk(parseJS(fs.readFileSync(config, 'utf8'), config), n => {
|
|
if (n.type !== 'ObjectProperty' || nameOf(n.key) !== 'stories') return;
|
|
if (n.value.type !== 'ArrayExpression' || n.value.elements.some(x => literal(x) === undefined)) { add('NOT VERIFIED', 'SB-004', config, n, 'Computed Storybook discovery requires explicit --entry story paths', true); return; }
|
|
for (const pattern of n.value.elements.map(literal)) storyEntries.push(...glob.sync(pattern, { cwd: path.dirname(config), onlyFiles: true, followSymbolicLinks: false, ignore: ['**/node_modules/**', '**/_local/**', '**/dist/**'] }).map(f => path.resolve(path.dirname(config), f)));
|
|
});
|
|
} catch { add('NOT VERIFIED', 'SB-004', config, null, 'Storybook discovery unavailable; supply explicit --entry story paths', true); }
|
|
}
|
|
const preview = ['preview.tsx', 'preview.ts', 'preview.jsx', 'preview.js'].map(f => path.join(target, '.storybook', f)).find(isFile);
|
|
if (preview) { storyEntries.push(preview); storyPreview = preview; }
|
|
else add('NOT VERIFIED', 'SB-004', null, null, 'Storybook preview unavailable; CSS/decorator connection is not proven', true);
|
|
if (!storyEntries.some(isStory)) add('NOT VERIFIED', 'SB-004', config, null, 'No Storybook entries discovered', true);
|
|
}
|
|
const htmlFiles = storybook ? [...new Set(storyEntries)] : 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 (!storybook && 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]]));
|
|
const spec = m[1].toLowerCase() === 'script' ? attrs.src : attrs.rel === 'stylesheet' ? attrs.href : null;
|
|
if (!spec) continue;
|
|
if (isStory(spec)) add('FAIL', 'SB-001', entry, null, 'Production HTML references a Storybook entry');
|
|
const resolved = resolve(spec.startsWith('/') || spec.startsWith('.') || /^(?:https?:|\/\/)/.test(spec) ? spec : `./${spec}`, entry);
|
|
if (resolved) roots.push(resolved);
|
|
else add('NOT VERIFIED', 'ENTRY-002', entry, null, `Unresolved HTML entry: ${spec}`, true);
|
|
}
|
|
if (/<(?:script|style)\b[^>]*>\s*[^<\s]/i.test(html)) add('NOT VERIFIED', 'ENTRY-005', entry, null, 'Inline script/style needs separate runtime verification', true);
|
|
if (!roots.length) add('NOT VERIFIED', 'ENTRY-001', entry, null, 'No static production bootstrap found', true);
|
|
entries.push({ file: entry, roots });
|
|
}
|
|
entries.forEach(e => e.roots.forEach(visit));
|
|
// Each Story has the shared preview, but does not borrow CSS evidence from
|
|
// another lazily loaded Story. Discovery never extends the Production roots.
|
|
if (storybook && entries.length) {
|
|
const storyRoots = entries.filter(e => e.file !== storyPreview);
|
|
if (storyRoots.length) entries.splice(0, entries.length, ...storyRoots.map(e => ({ ...e, roots: [...e.roots, ...(storyPreview ? [storyPreview] : [])] })));
|
|
}
|
|
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 ${storybook ? 'Storybook' : 'production'} graph; package implementations need separate build/tests; supply --entry for consumer files`, true);
|
|
}
|
|
function exportedOrigin(file, exported, seen = new Set()) {
|
|
const key = `${file}:${exported}`;
|
|
if (seen.has(key)) return null;
|
|
seen.add(key);
|
|
const r = graph.get(file);
|
|
const binding = r?.exports.get(exported);
|
|
if (binding) return binding.edge ? edgeOrigin(binding.edge, binding.imported, seen) : origin(file, binding.local, seen);
|
|
for (const e of r?.stars ?? []) { const found = edgeOrigin(e, exported, seen); if (found) return found; }
|
|
return null;
|
|
}
|
|
function edgeOrigin(e, imported, seen) {
|
|
const ref = packageRef(e.spec);
|
|
if (ref) return ref.contract.entries.get(ref.key)?.has(imported) ? { name: imported, pkg: ref.pkg } : null;
|
|
return e.resolved ? exportedOrigin(e.resolved, imported, seen) : null;
|
|
}
|
|
function origin(file, local, seen = new Set()) {
|
|
if (typeof local !== 'string') return null;
|
|
const [base, member] = local.split('.');
|
|
const b = graph.get(file)?.bindings.get(base);
|
|
if (!b) return { name: local };
|
|
if (b.local) { const key = `${file}:local:${local}`; if (seen.has(key)) return null; seen.add(key); return origin(file, b.local, seen); }
|
|
return edgeOrigin(b.edge, member ?? b.imported, seen);
|
|
}
|
|
function closure(roots) {
|
|
const result = new Set();
|
|
const follow = f => { if (result.has(f) || !graph.has(f)) return; result.add(f); for (const e of graph.get(f).edges) if (e.runtime && e.resolved) follow(e.resolved); };
|
|
roots.forEach(follow); return [...result].map(f => graph.get(f));
|
|
}
|
|
if (selected.length) {
|
|
const changedRoots = [...graph.keys()].filter(selectedFile);
|
|
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) || !storybook && 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') {
|
|
// A Reference can document internal layout wrappers that have no public
|
|
// component counterpart. Review these; never count them as reuse or
|
|
// grant the same allowance to a Production consumer.
|
|
const referenceWrapper = storybook && !slots.has(slot);
|
|
add(/^[a-z]/.test(render.name) && !referenceWrapper ? 'FAIL' : 'NOT VERIFIED', 'REUSE-001', r.file, render.node, referenceWrapper ? `${render.name} uses internal Reference slot ${slot} without a public component counterpart; verify documented composition` : `${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|label)$/.test(render.name)) add('NOT VERIFIED', 'REUSE-004', r.file, render.node, `Native <${render.name}>: confirm it does not replace an indexed HM component`);
|
|
if (/^[a-z]/.test(render.name) && ['button', 'checkbox', 'switch', 'combobox', 'dialog', 'alertdialog'].includes(literal(render.attrs.get('role')))) add('NOT VERIFIED', 'REUSE-006', r.file, render.node, `Native ${literal(render.attrs.get('role'))} widget: confirm why the public HM component cannot be composed`);
|
|
}
|
|
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();
|
|
for (const r of graph.values()) if (closure([r.file]).some(directlyReused)) applicableFiles.add(r.file);
|
|
for (const r of graph.values()) if (applicableFiles.has(r.file)) for (const e of r.edges) if (e.resolved && /\.(?:css|scss|sass|less)$/.test(e.resolved)) closure([e.resolved]).forEach(c => applicableFiles.add(c.file));
|
|
// Namespace access is checked against the same public barrel as named imports.
|
|
for (const r of graph.values()) for (const member of r.members) {
|
|
const binding = r.bindings.get(member.node.object.name);
|
|
const ref = binding?.edge && packageRef(binding.edge.spec);
|
|
if (!ref || binding.imported !== '*') continue;
|
|
if (member.node.computed && literal(member.node.property) === undefined) add('NOT VERIFIED', 'IMP-003', r.file, member.node, 'Computed HM namespace access cannot be verified');
|
|
else if (ref.contract.entries.get(ref.key) && !ref.contract.entries.get(ref.key).has(nameOf(member.node.property))) add('FAIL', 'IMP-002', r.file, member.node, `${nameOf(member.node.property)} is not exported by ${binding.edge.spec}`);
|
|
}
|
|
const remember = name => { if (/^[A-Z][a-z]|^(?:GNB|LNB)(?:$|[A-Z])/.test(name) && !name.includes('_')) checkedElements.add(name); };
|
|
for (const entry of entries) {
|
|
const records = closure(entry.roots);
|
|
const graphIncomplete = issues.some(issue => /^(?:ENTRY-00[1245]|CSS-005|CORE-|SB-004)/.test(issue.code) && (records.some(r => normalize(path.relative(target, r.file)) === issue.file) || issue.file === normalize(path.relative(target, entry.file))));
|
|
const tokens = themeAnalysis({ records, workspace, postcss, walk, selectedFile, add, graphIncomplete });
|
|
tokenEvidence.push({ entry: normalize(path.relative(target, entry.file)), definitionCount: tokens.definitions.length, definitions: tokens.dynamic.length ? tokens.definitions : undefined, references: tokens.references.map(r => ({ ...r, file: normalize(path.relative(target, r.file)) })), dynamic: tokens.dynamic.map(r => ({ ...r, file: normalize(path.relative(target, r.file)), id: r.id.replace(r.file, normalize(path.relative(target, r.file))) })), incomplete: tokens.incomplete });
|
|
if (tokens.references.length) groupApplicability[4] = true;
|
|
const navigation = !storybook ? navigationAnalysis(records, entry.roots, origin, walk) : null;
|
|
const focus = records.filter(r => selectedFile(r.file));
|
|
const reused = focus.filter(r => directlyReused(r) || r.edges.some(e => e.runtime && e.resolved && applicableFiles.has(e.resolved)));
|
|
if (!reused.length) continue;
|
|
groupApplicability[1] = groupApplicability[3] = groupApplicability[4] = groupApplicability[5] = true;
|
|
let selectedNavigation = false;
|
|
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 && 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.flatMap(render => (navigation?.identities.get(render.node) ?? [null]).map(identity => ({ ...render, identity })))) {
|
|
const identity = render.identity ?? origin(r.file, render.name);
|
|
const name = identity?.name ?? render.name;
|
|
if (!storybook && /^Story(?:Page|Section|Board)/.test(name) && applicableFiles.has(r.file)) add('FAIL', 'SB-002', r.file, render.node, 'Production renders a Storybook-only helper');
|
|
if (identity?.pkg !== '@hm-design/ui') continue;
|
|
// Reference Storybook combinations preserve their API. An explicit mode
|
|
// opts a Storybook audit into the same single-mode navigation checks.
|
|
if (storybook && !options.mode) continue;
|
|
const reach = navigation?.state.get(render.node) ?? 'active';
|
|
if (!storybook && reach === 'dead') continue;
|
|
const navSeverity = !storybook && reach === 'maybe' ? 'NOT VERIFIED' : 'FAIL';
|
|
if (name === 'DashboardHeader') officialDashboardHeader = true;
|
|
if (name === 'DashboardLayout' && selectedFile(r.file)) selectedDashboardLayout = { record: r, render };
|
|
const category = /^GNB/.test(name) ? 'GNB_ONLY' : /^LNB/.test(name) ? 'LNB_ONLY' : /^Dashboard(?:Header|Layout|Main|Content|Sidebar)$/.test(name) ? 'DASHBOARD_ONLY' : null;
|
|
if (category && selectedFile(r.file)) selectedNavigation = true;
|
|
if (category && render.node.attributes?.some(a => a.type === 'JSXSpreadAttribute')) add('NOT VERIFIED', 'NAV-005', r.file, render.node, 'Reused HM Navigation spread props need runtime confirmation');
|
|
const sidebar = name === 'DashboardSidebar';
|
|
const sidebarProp = render.attrs.get('sidebar');
|
|
const sidebarSlot = name === 'DashboardLayout' && render.attrs.has('sidebar');
|
|
const slotValue = navigation?.sidebarValues.get(render.node);
|
|
const hasSidebarProp = sidebarSlot && (slotValue === 'present' || !slotValue && ['JSXElement', 'JSXFragment', 'StringLiteral', 'ArrayExpression', 'ObjectExpression'].includes(sidebarProp?.type));
|
|
const emptySidebarProp = slotValue === 'empty' || !slotValue && (sidebarProp?.type === 'NullLiteral' || sidebarProp?.type === 'BooleanLiteral' && sidebarProp.value === false || tagName(sidebarProp) === 'undefined');
|
|
if (sidebarSlot && !hasSidebarProp && !emptySidebarProp) add('NOT VERIFIED', 'NAV-005', r.file, render.node, 'DashboardLayout sidebar slot value is not statically proven');
|
|
if (options.mode && category && category !== options.mode) add(navSeverity, 'NAV-002', r.file, render.node, `${options.mode} cannot render ${name}${navSeverity === 'NOT VERIFIED' ? '; render path unresolved' : ''}`);
|
|
if (['GNB_ONLY', 'DASHBOARD_ONLY'].includes(options.mode) && (sidebar || hasSidebarProp)) add(navSeverity, 'NAV-001', r.file, render.node, `${options.mode} cannot render Sidebar / LNB${navSeverity === 'NOT VERIFIED' ? '; render path unresolved' : ''}`);
|
|
if (options.mode === 'LNB_ONLY' && sidebar) add(navSeverity, 'NAV-002', r.file, render.node, 'LNB_ONLY cannot render DashboardSidebar');
|
|
}
|
|
if (selectedNavigation) groupApplicability[0] = true;
|
|
if (!options.mode && selectedNavigation) add('NOT VERIFIED', 'NAV-003', entry.file, null, 'Reused HM Navigation mode is unspecified; no mode inferred', true);
|
|
if (options.mode === 'DASHBOARD_ONLY' && selectedDashboardLayout && !officialDashboardHeader) add('NOT VERIFIED', 'NAV-006', selectedDashboardLayout.record.file, selectedDashboardLayout.render.node, 'Reused DashboardLayout official DashboardHeader connection is not proven');
|
|
// Evidence is collected from the whole bootstrap graph, requirements from the selected region.
|
|
const cssFeatures = new Set(records.flatMap(r => [...r.features]));
|
|
for (const r of records) for (const e of r.edges.filter(e => e.runtime)) {
|
|
if (e.spec === '@hm-design/ui/theme.css') cssFeatures.add('theme');
|
|
if (e.spec === '@hm-design/ui/dashboard-status.css') cssFeatures.add('status');
|
|
if (e.spec === 'tailwindcss' || e.spec === 'tailwindcss/utilities' || e.spec === 'tailwindcss/utilities.css') cssFeatures.add('utilities');
|
|
if (e.resolved && fs.realpathSync(e.resolved) === fs.realpathSync(path.join(workspace, 'packages/ui/src/theme.css'))) cssFeatures.add('theme');
|
|
}
|
|
const runtimeUI = reused.some(r => r.edges.some(e => e.runtime && packageRef(e.spec)?.pkg === '@hm-design/ui' && !packageRef(e.spec).key.endsWith('.css')));
|
|
const uiComponents = focus.some(r => r.renders.some(x => origin(r.file, x.name)?.pkg === '@hm-design/ui'));
|
|
const utilities = uiComponents;
|
|
const statusName = name => /^(?:dashboard(?:Status|Filter)|countDashboardFilter|matchesDashboardFilter)/.test(name);
|
|
const status = reused.some(r => r.status && r.renders.some(x => origin(r.file, x.name)?.pkg === '@hm-design/ui' && origin(r.file, x.name)?.name === 'Badge') || r.edges.some(e => e.runtime && e.spec === '@hm-design/ui' && e.names.some(b => !b.typeOnly && statusName(b.imported))) || r.members.some(m => { const id = origin(r.file, m.name); return id?.pkg === '@hm-design/ui' && statusName(id.name); }));
|
|
if (runtimeUI || uiComponents || status) groupApplicability[2] = true;
|
|
const incompleteCSSGraph = issues.some(issue => /^(?:ENTRY-|CORE-|CSS-005|SB-004)/.test(issue.code)
|
|
&& (records.some(record => normalize(path.relative(target, record.file)) === issue.file) || issue.file === normalize(path.relative(target, entry.file)) || !issue.file));
|
|
for (const [required, feature, code, label] of [[runtimeUI || uiComponents, 'theme', 'CSS-001', 'Theme CSS'], [utilities, 'utilities', 'CSS-002', 'Tailwind utility CSS entry'], [status, 'status', 'CSS-003', 'Public @hm-design/ui/dashboard-status.css entry']]) if (required && !cssFeatures.has(feature)) add(incompleteCSSGraph ? 'NOT VERIFIED' : 'FAIL', code, focus[0]?.file ?? entry.file, null, incompleteCSSGraph ? `${label} connection is not proven; resolve the incomplete bootstrap / CSS graph` : `Required ${label} is missing from the resolved bootstrap / CSS graph`, true);
|
|
}
|
|
styleChecks = styleAnalysis({ records: [...graph.values()].filter(r => applicableFiles.has(r.file)), target, postcss, walk, selectedFile, knownSlot, origin, add, storybook, scopeRecords: [...graph.values()], implementationFiles: contractFiles.filter(f => f.startsWith('packages/')).map(f => path.join(workspace, f)), entries: entries.map(e => e.file), scopeIncomplete: storybook || issues.some(i => /^(?:ENTRY-|CORE-|CSS-005|SB-004)/.test(i.code)) });
|
|
return finish();
|
|
function finish() {
|
|
const styleEvidence = { staticStatus: verdict(styleChecks.map(c => c.staticStatus)), runtimeStatus: 'NOT VERIFIED', designIntentStatus: verdict(styleChecks.map(c => c.designIntentStatus)), finalStatus: styleChecks.some(c => c.staticStatus === 'FAIL') ? 'FAIL' : 'NOT VERIFIED', checks: styleChecks };
|
|
if (applicableFiles) {
|
|
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 (!contextualIssues.has(item) && /^(?:SB|CSS|NAV)-/.test(item.code) && !applicableFiles.has(file) && !item.code.startsWith('NAV-003')) issues.splice(i, 1);
|
|
}
|
|
}
|
|
const priority = { FAIL: 0, 'NOT VERIFIED': 1, WARNING: 2 };
|
|
issues.sort((a, b) => priority[a.severity] - priority[b.severity] || a.code.localeCompare(b.code) || a.file.localeCompare(b.file) || a.line - b.line);
|
|
const statusFor = list => list.some(i => i.severity === 'FAIL') ? 'FAIL' : list.some(i => i.severity === 'NOT VERIFIED') ? 'NOT VERIFIED' : list.length ? 'WARNING' : 'PASS';
|
|
const groupResults = groups.map((name, i) => {
|
|
const related = issues.filter(x => x.code.startsWith(['NAV', 'SB', 'CSS', 'IMP', 'TOKEN', 'REUSE'][i]) || i === 5 && x.code.startsWith('STYLE-') || x.code.startsWith('ENTRY') || x.code.startsWith('CORE') || x.code.startsWith('SCOPE'));
|
|
return { name: storybook && i === 2 ? 'Storybook CSS Entry' : name, status: related.length ? statusFor(related) : groupApplicability[i] ? 'PASS' : 'NOT APPLICABLE', evidence: related.map(x => ({ code: x.code, file: x.file, line: x.line })), basis: related.length ? 'See issue evidence' : groupApplicability[i] ? 'Resolved entry import graph and current public contracts' : 'No applicable usage in selected scope' };
|
|
});
|
|
const staticIssues = issues.filter(x => !x.code.startsWith('RENDER-'));
|
|
const fingerprintFiles = [...graph.keys(), ...entries.map(e => e.file)].filter(isFile).map(f => normalize(path.relative(target, f)));
|
|
const identity = { fingerprintFiles, sourceFingerprint: sourceFingerprint(target, fingerprintFiles), contractFiles, contractFingerprint };
|
|
return { ...identity, schemaVersion: 2, status: issues.length ? statusFor(issues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', staticStatus: staticIssues.length ? statusFor(staticIssues) : groupApplicability.some(Boolean) ? 'PASS' : 'NOT APPLICABLE', runtimeStatus: 'NOT VERIFIED', runtimeRequired: Boolean(options.result), profile: options.profile, scope: options.scope, mode: options.mode ?? null, target, contractSource: workspace, checkedFiles: typeof graph === 'undefined' ? [] : [...graph.keys()].map(f => normalize(path.relative(target, f))).sort(), checkedGroups: groupResults, checkedHMElements: [...checkedElements].sort(), tokenEvidence, styleEvidence, 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 [--profile PRODUCTION|STORYBOOK] [--mode GNB_ONLY|LNB_ONLY|DASHBOARD_ONLY]\n[--entry BOOTSTRAP_OR_STORY] [--files CHANGED_FILE,... (PARTIAL)] [--result FINAL_HTML_OR_BOOTSTRAP (PRODUCTION)] [--json]\nStorybook discovery uses .storybook/main stories and preview; it does not extend the Production graph. 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(report.status);
|
|
else {
|
|
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;
|
|
}
|
|
} catch (error) {
|
|
// Configuration / tooling failures are not evidence of a contract violation.
|
|
if (process.argv.includes('--json')) console.log(JSON.stringify({ status: 'NOT VERIFIED', checkedHMElements: [], issues: [{ severity: 'NOT VERIFIED', code: 'CORE-002', file: '.', line: 1, message: error.message }] }, null, 2));
|
|
else console.log(`HMDesign FAST CORE: NOT VERIFIED\nCORE-002 .:1\n${error.message}`);
|
|
process.exitCode = 2;
|
|
}
|