Files
sar/_bmad/wds/scripts/wds-validate.js
julian 17c08e6392 chore: initial monorepo scaffold + WDS Phase 1+2 artifacts
- Nx 22.7 monorepo (pnpm 11.1, TypeScript 5.9, Node 24)
- apps/api: NestJS 11 (CJS conforme CODING-RULES.md PGD-DB-004)
- apps/web: React 19 + Vite 8 (ESM)
- libs/shared/api-interface: Zod contract base
- Docker Compose dev: Postgres 18, Valkey 8, MinIO, Mailpit
- WDS artifacts:
  - design-artifacts/A-Product-Brief/ (5 docs canônicos + 16 dialogs)
  - design-artifacts/B-Trigger-Map/ (hub + 4 personas + feature impact)
- Stack canon: STACK.md v2.2 + CODING-RULES.md v2.0 + brand.md
- AGENTS.md + README.md como entrada para devs/agentes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:34:20 +00:00

302 lines
8.6 KiB
JavaScript

// wds-validate.js — WDS scaffold: validate page spec files for correctness
// Usage: node src/scripts/wds-validate.js --page "C-UX-Scenarios/01-onboarding/01-start/01-start.md"
// node src/scripts/wds-validate.js --scenario "01 Onboarding"
// node src/scripts/wds-validate.js --all
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true;
args[key] = value;
if (value !== true) i++;
}
}
return args;
}
function toSlug(str) {
return str.toLowerCase().replaceAll(/\s+/g, '-');
}
function printUsage() {
process.stdout.write(
[
'Usage: node src/scripts/wds-validate.js --page <path>',
' node src/scripts/wds-validate.js --scenario "01 Onboarding"',
' node src/scripts/wds-validate.js --all',
'',
'Options:',
' --page Path to a single page spec .md file',
' --scenario Scenario name or slug to validate all pages',
' --all Validate all scenarios',
' --output Base path (default: current directory)',
'',
].join('\n'),
);
}
const REQUIRED_SECTIONS = [
'## Overview',
'## Page Metadata',
'## Layout Structure',
'## Spacing',
'## Typography',
'## Page Sections',
'## Page States',
'## Checklist',
];
const REQUIRED_METADATA_PROPS = ['Scenario', 'Page Number', 'Platform', 'Page Type', 'Viewport', 'Interaction', 'Visibility'];
const KEBAB_CASE_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
// Extract all OBJECT IDs from content: lines matching **OBJECT ID:** `...`
function extractObjectIds(content) {
const ids = [];
const re = /\*\*OBJECT ID:\*\*\s+`([^`]+)`/g;
let m;
while ((m = re.exec(content)) !== null) {
ids.push(m[1]);
}
return ids;
}
// Extract all spacing IDs from content: lines matching #### ↕ `...`
function extractSpacingIds(content) {
const ids = [];
const re = /####\s+[↕↔]\s+`([^`]+)`/g;
let m;
while ((m = re.exec(content)) !== null) {
ids.push(m[1]);
}
return ids;
}
// Count nav rows: lines starting with '←'
function countNavRows(content) {
const lines = content.split('\n');
return lines.filter((l) => l.trim().startsWith('←')).length;
}
// Check Swedish + English content: for each object block, look for SE and EN rows
function checkObjectContent(content) {
const missing = [];
// Split into object blocks by #### heading
const blocks = content.split(/(?=#### )/);
for (const block of blocks) {
const idMatch = block.match(/\*\*OBJECT ID:\*\*\s+`([^`]+)`/);
if (!idMatch) continue;
const objectId = idMatch[1];
// Check for SE row
if (block.includes('| SE |')) {
const seMatch = block.match(/\| SE \| "([^"]*)"/);
if (!seMatch || seMatch[1].trim() === '' || seMatch[1].trim() === '—') {
missing.push(`Object "${objectId}" has empty SE content`);
}
} else {
missing.push(`Object "${objectId}" missing SE content field`);
}
// Check for EN row
if (block.includes('| EN |')) {
const enMatch = block.match(/\| EN \| "([^"]*)"/);
if (!enMatch || enMatch[1].trim() === '' || enMatch[1].trim() === '—') {
missing.push(`Object "${objectId}" has empty EN content`);
}
} else {
missing.push(`Object "${objectId}" missing EN content field`);
}
}
return missing;
}
function validatePage(filePath) {
const errors = [];
const warnings = [];
if (!fs.existsSync(filePath)) {
return { errors: [`File not found: ${filePath}`], warnings: [], objectCount: 0, spacingCount: 0 };
}
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (error) {
return { errors: [`Cannot read file: ${error.message}`], warnings: [], objectCount: 0, spacingCount: 0 };
}
const pageSlug = path.basename(filePath, '.md');
// Derive page prefix (strip leading number): "01-start" -> "start"
const slugParts = pageSlug.split('-');
const pagePrefix = slugParts.length > 1 ? slugParts.slice(1).join('-') : pageSlug;
// 1. Required sections
for (const section of REQUIRED_SECTIONS) {
if (!content.includes(section)) {
errors.push(`Missing section: ${section}`);
}
}
// 2. Object IDs — kebab-case and prefix
const objectIds = extractObjectIds(content);
const seenIds = new Set();
for (const id of objectIds) {
// Kebab-case check
if (!KEBAB_CASE_RE.test(id)) {
errors.push(`Object ID not in kebab-case: \`${id}\``);
}
// Prefix check
if (!id.startsWith(pagePrefix + '-')) {
errors.push(`Object ID missing prefix: \`${id}\` (expected prefix: ${pagePrefix}-)`);
}
// Duplicate check
if (seenIds.has(id)) {
errors.push(`Duplicate Object ID: \`${id}\``);
}
seenIds.add(id);
}
// 3. Navigation rows (expect 3)
const navCount = countNavRows(content);
if (navCount < 3) {
errors.push(`Navigation rows: found ${navCount}, expected 3`);
}
// 4. Metadata table properties
for (const prop of REQUIRED_METADATA_PROPS) {
if (!content.includes(`| ${prop} |`)) {
errors.push(`Metadata table missing property: ${prop}`);
}
}
// 5. Sketches folder
const sketchesDir = path.join(path.dirname(filePath), 'sketches');
if (!fs.existsSync(sketchesDir)) {
warnings.push('Sketches folder does not exist');
}
// 6. Swedish + English content check
const contentWarnings = checkObjectContent(content);
for (const w of contentWarnings) {
warnings.push(w);
}
// Spacing IDs
const spacingIds = extractSpacingIds(content);
return {
errors,
warnings,
objectCount: objectIds.length,
spacingCount: spacingIds.length,
};
}
function formatResult(label, result) {
const { errors, warnings, objectCount, spacingCount } = result;
if (errors.length === 0 && warnings.length === 0) {
return `${label} — valid (${objectCount} objects, ${spacingCount} spacing objects)\n`;
}
const lines = [];
if (errors.length > 0) {
lines.push(`${label}${errors.length} error(s):`);
for (const e of errors) lines.push(` - ${e}`);
}
if (warnings.length > 0) {
lines.push(` ${warnings.length} warning(s):`);
for (const w of warnings) lines.push(` ! ${w}`);
}
return lines.join('\n') + '\n';
}
function getPageFiles(scenarioDir) {
let entries;
try {
entries = fs.readdirSync(scenarioDir, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory())
.map((e) => {
const mdFile = path.join(scenarioDir, e.name, `${e.name}.md`);
return fs.existsSync(mdFile) ? mdFile : null;
})
.filter(Boolean)
.sort();
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
process.exit(0);
}
if (!args.page && !args.scenario && !args.all) {
process.stderr.write('Error: --page, --scenario, or --all is required.\n\n');
printUsage();
process.exit(1);
}
const outputBase = args.output || process.cwd();
const scenariosBase = path.join(outputBase, 'C-UX-Scenarios');
let filesToValidate = [];
if (args.page) {
filesToValidate = [path.resolve(args.page)];
} else if (args.scenario) {
const scenarioSlug = toSlug(args.scenario);
const scenarioDir = path.join(scenariosBase, scenarioSlug);
filesToValidate = getPageFiles(scenarioDir);
if (filesToValidate.length === 0) {
process.stdout.write(`No pages found in scenario: ${scenarioSlug}\n`);
process.exit(0);
}
} else if (args.all) {
if (!fs.existsSync(scenariosBase)) {
process.stderr.write(`Error: C-UX-Scenarios not found at: ${scenariosBase}\n`);
process.exit(1);
}
let entries;
try {
entries = fs.readdirSync(scenariosBase, { withFileTypes: true });
} catch (error) {
process.stderr.write(`Error reading scenarios: ${error.message}\n`);
process.exit(1);
}
for (const e of entries.filter((e) => e.isDirectory()).sort()) {
const pages = getPageFiles(path.join(scenariosBase, e.name));
filesToValidate.push(...pages);
}
if (filesToValidate.length === 0) {
process.stdout.write('No page specs found.\n');
process.exit(0);
}
}
let hasErrors = false;
for (const filePath of filesToValidate) {
const label = path.basename(filePath);
const result = validatePage(filePath);
process.stdout.write(formatResult(label, result));
if (result.errors.length > 0) hasErrors = true;
}
process.exit(hasErrors ? 1 : 0);
}
main();