diff --git a/localization-qa.js b/localization-qa.js
new file mode 100644
index 0000000..e662b1a
--- /dev/null
+++ b/localization-qa.js
@@ -0,0 +1,458 @@
+/**
+ * Localization QA for Wraith Protocol documentation
+ *
+ * Checks translated documentation pages for:
+ * - Stale links
+ * - Missing sections
+ * - Code drift
+ * - Language metadata
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const DOCS_DIR = path.join(__dirname);
+const MARKDOWN_EXT = ['.md', '.mdx'];
+
+const SKIP_DIRS = new Set(['.git', '.agents', 'node_modules']);
+const SKIP_FILES = new Set(['docs.json', 'CLAUDE.md', 'PR_DESCRIPTION.md']);
+
+function parseFrontMatter(content) {
+ let attrs = {};
+ let body = content;
+
+ const fmMatch = content.match(/^---[\s\S]*?^---/m);
+ if (fmMatch) {
+ const fmContent = content.substring(3, fmMatch[0].lastIndexOf('---')).trim();
+ const attrsObj = {};
+ for (const line of fmContent.split('\n')) {
+ const match = line.match(/^(\w+):\s*(.*)$/);
+ if (match) {
+ let value = match[2].trim();
+ if (value === 'true') value = true;
+ else if (value === 'false') value = false;
+ else if (!isNaN(value)) value = Number(value);
+ attrsObj[match[1]] = value;
+ }
+ }
+ attrs = attrsObj;
+
+ const fmEndIndex = content.indexOf('---', 3);
+ if (fmEndIndex !== -1) {
+ body = content.substring(fmEndIndex + 3).trim();
+ }
+ }
+
+ return { attrs, body };
+}
+
+function extractHeadings(body) {
+ const headings = [];
+ const lines = body.split('\n');
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const match = line.match(/^(#{1,6})\s+(.*)$/);
+ if (match) {
+ headings.push({
+ level: match[1].length,
+ text: match[2].trim(),
+ line: i + 1,
+ });
+ }
+ }
+ return headings;
+}
+
+function countCodeFences(body) {
+ const lines = body.split('\n');
+ let count = 0;
+ let inFence = false;
+ for (const line of lines) {
+ if (line.trim().startsWith('```')) {
+ inFence = !inFence;
+ count++;
+ }
+ }
+ return count;
+}
+
+function extractLinks(body) {
+ const links = [];
+ const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
+ let match;
+ while ((match = regex.exec(body)) !== null) {
+ links.push({
+ text: match[1],
+ url: match[2],
+ line: match.index,
+ });
+ }
+ return links;
+}
+
+function classifyLink(url) {
+ if (!url) return 'external';
+ if (url.startsWith('/') || url.startsWith('#')) return 'internal';
+ if (url.startsWith('http://') || url.startsWith('https://')) return 'external';
+ return 'internal';
+}
+
+function normalizePagePath(page) {
+ if (!page) return '';
+ let p = page.replace(/^\/|\/$/g, '');
+ return p.toLowerCase();
+}
+
+function getNavPages(navigation) {
+ const pages = [];
+
+ if (!navigation || !navigation.tabs || !Array.isArray(navigation.tabs)) return pages;
+
+ for (const tab of navigation.tabs) {
+ const groups = tab.groups;
+ if (!groups || !Array.isArray(groups)) continue;
+
+ for (const group of groups) {
+ const groupPages = group.pages;
+ if (groupPages && Array.isArray(groupPages)) {
+ for (const page of groupPages) {
+ pages.push(normalizePagePath(page));
+ }
+ }
+ if (group.items && Array.isArray(group.items)) {
+ for (const subItem of group.items) {
+ const subPages = subItem.pages;
+ if (subPages && Array.isArray(subPages)) {
+ for (const page of subPages) {
+ pages.push(normalizePagePath(page));
+ }
+ }
+ const subGroups = subItem.groups;
+ if (subGroups && Array.isArray(subGroups)) {
+ for (const subGroup of subGroups) {
+ const sgPages = subGroup.pages;
+ if (sgPages && Array.isArray(sgPages)) {
+ for (const page of sgPages) {
+ pages.push(normalizePagePath(page));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return pages;
+}
+
+function getAllMDXFiles() {
+ const files = [];
+
+ function walk(dir) {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (SKIP_DIRS.has(entry.name)) continue;
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(fullPath);
+ } else if (MARKDOWN_EXT.some(ext => entry.name.endsWith(ext))) {
+ // Skip non-doc files
+ const basename = entry.name.replace(path.extname(entry.name), '');
+ if (SKIP_FILES.has(basename)) continue;
+ files.push(fullPath);
+ }
+ }
+ }
+
+ walk(DOCS_DIR);
+ return files;
+}
+
+function getPageKey(filePath) {
+ const rel = path.relative(DOCS_DIR, filePath);
+ const withoutExt = path.extname(rel) ? rel.replace(path.extname(rel), '') : rel;
+ return withoutExt.replace(/\\/g, '/');
+}
+
+function hasLocaleMetadata(attrs) {
+ return attrs.locale !== undefined && attrs.locale !== null;
+}
+
+function runQA() {
+ console.log('=== Wraith Protocol Localization QA ===\n');
+
+ // 1. Read docs.json
+ const docsJSONPath = path.join(DOCS_DIR, 'docs.json');
+ let navigation;
+ if (fs.existsSync(docsJSONPath)) {
+ const jsonContent = fs.readFileSync(docsJSONPath, 'utf-8');
+ const docsConfig = JSON.parse(jsonContent);
+ navigation = docsConfig.navigation;
+ console.log('Loaded navigation from docs.json\n');
+ } else {
+ console.warn('docs.json not found\n');
+ navigation = null;
+ }
+
+ // Get canonical page paths from navigation
+ const canonicalPages = navigation ? getNavPages(navigation) : [];
+ console.log(`Canonical pages from navigation: ${canonicalPages.length}\n`);
+ console.log(' ', canonicalPages.join(', '), '\n');
+
+ // 2. Get all markdown files
+ const allFiles = getAllMDXFiles();
+ console.log(`Total markdown files found: ${allFiles.length}\n`);
+
+ // 3. Parse all files
+ const fileData = [];
+
+ for (const filePath of allFiles) {
+ try {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ const parsed = parseFrontMatter(content);
+ const pageKey = getPageKey(filePath);
+ const hasLocale = hasLocaleMetadata(parsed.attrs);
+ const locale = hasLocale ? parsed.attrs.locale : null;
+
+ const headings = extractHeadings(parsed.body);
+ const codeFenceCount = countCodeFences(parsed.body);
+ const links = extractLinks(parsed.body);
+ const classifiedLinks = links.map(l => ({
+ ...l,
+ type: classifyLink(l.url),
+ }));
+
+ fileData.push({
+ filePath,
+ pageKey,
+ title: parsed.attrs.title || 'Untitled',
+ description: parsed.attrs.description || '',
+ locale,
+ hasLocaleMetadata: hasLocale,
+ headings,
+ codeFenceCount,
+ links: classifiedLinks,
+ rawBody: parsed.body,
+ });
+ } catch (err) {
+ console.error(`Error parsing ${filePath}:`, err.message);
+ }
+ }
+
+ // Build map of page keys
+ const pageMap = new Map();
+ for (const fd of fileData) {
+ pageMap.set(fd.pageKey, fd);
+ }
+
+ // 4. Identify source vs translated pages
+ const translatedPages = new Set();
+ const sourceLanguagePages = new Set();
+
+ for (const [key, fd] of pageMap) {
+ if (fd.hasLocaleMetadata) {
+ translatedPages.add(key);
+ } else {
+ sourceLanguagePages.add(key);
+ }
+ }
+
+ console.log(`Source language (English) pages: ${sourceLanguagePages.size}`);
+ console.log(`Translated pages: ${translatedPages.size}\n`);
+
+ // 5. Define supported translated pages (from navigation)
+ const supportedTranslated = new Set();
+ if (navigation) {
+ for (const pageKey of canonicalPages) {
+ if (!pageMap.has(pageKey)) {
+ console.log(` Warning: Page in navigation but not found in docs: ${pageKey}`);
+ } else {
+ supportedTranslated.add(pageKey);
+ }
+ }
+ }
+
+ console.log(`Supported pages for translation: ${supportedTranslated.size}\n`);
+
+ // 6. Compare structure between source and translated pages
+ const issues = [];
+
+ for (const pageKey of supportedTranslated) {
+ const fd = pageMap.get(pageKey);
+ if (!fd) {
+ issues.push({
+ type: 'missing_page',
+ page: pageKey,
+ message: 'Page defined in navigation but not found in docs directory',
+ });
+ continue;
+ }
+
+ // Check if translated
+ if (!fd.hasLocaleMetadata) {
+ issues.push({
+ type: 'untranslated',
+ page: pageKey,
+ message: 'Page is missing locale metadata - not marked as translated',
+ });
+ }
+
+ // Compare with source version (same key in English)
+ const sourceFd = pageMap.get(pageKey);
+
+ if (sourceFd) {
+ // Compare headings
+ const sourceHeadingTexts = sourceFd.headings.map(h => h.text);
+ const translatedHeadingTexts = fd.headings.map(h => h.text);
+
+ const missingInTranslation = sourceHeadingTexts.filter(h => !translatedHeadingTexts.includes(h));
+ const extraInTranslation = translatedHeadingTexts.filter(h => !sourceHeadingTexts.includes(h));
+
+ if (missingInTranslation.length > 0) {
+ issues.push({
+ type: 'heading_drift',
+ page: pageKey,
+ message: `Missing headings in translation: ${missingInTranslation.join(', ')}`,
+ details: {
+ sourceHeadings: sourceHeadingTexts,
+ translatedHeadings: translatedHeadingTexts,
+ },
+ });
+ }
+
+ if (extraInTranslation.length > 0) {
+ issues.push({
+ type: 'heading_drift',
+ page: pageKey,
+ message: `Extra headings in translation not in source: ${extraInTranslation.join(', ')}`,
+ details: {
+ sourceHeadings: sourceHeadingTexts,
+ translatedHeadings: translatedHeadingTexts,
+ },
+ });
+ }
+
+ // Compare code fence counts
+ if (sourceFd.codeFenceCount !== fd.codeFenceCount) {
+ issues.push({
+ type: 'code_drift',
+ page: pageKey,
+ message: `Code fence count mismatch: source=${sourceFd.codeFenceCount}, translation=${fd.codeFenceCount}`,
+ details: {
+ sourceFenceCount: sourceFd.codeFenceCount,
+ translatedFenceCount: fd.codeFenceCount,
+ },
+ });
+ }
+
+ // Compare link structure
+ const sourceLinks = sourceFd.links.map(l => ({ ...l, type: classifyLink(l.url) }));
+ const translatedLinks = fd.links.map(l => ({ ...l, type: classifyLink(l.url) }));
+
+ // Links in translation not in source
+ for (const translatedLink of translatedLinks) {
+ const matchingSourceLink = sourceLinks.find(
+ sl => sl.url === translatedLink.url && sl.text === translatedLink.text
+ );
+
+ if (!matchingSourceLink) {
+ if (translatedLink.type === 'external') {
+ issues.push({
+ type: 'link_structural',
+ page: pageKey,
+ message: `Link in translation not in source: ${translatedLink.text} (${translatedLink.url})`,
+ details: { type: translatedLink.type },
+ });
+ } else {
+ issues.push({
+ type: 'link_structural',
+ page: pageKey,
+ message: `Internal link in translation not in source: ${translatedLink.text} (${translatedLink.url})`,
+ details: { type: translatedLink.type },
+ });
+ }
+ }
+ }
+
+ // Links in source missing from translation
+ for (const sourceLink of sourceLinks) {
+ const hasMatching = translatedLinks.some(
+ tl => tl.url === sourceLink.url && tl.text === sourceLink.text
+ );
+ if (!hasMatching && sourceLink.type === 'internal') {
+ issues.push({
+ type: 'link_missing',
+ page: pageKey,
+ message: `Internal link missing from translation: ${sourceLink.text} (${sourceLink.url})`,
+ });
+ }
+ }
+ }
+ }
+
+ // 7. Check locale metadata
+ console.log('\n=== Locale Metadata Check ===');
+ for (const [key, fd] of pageMap) {
+ if (fd.hasLocaleMetadata) {
+ console.log(` ✓ ${key}: locale="${fd.locale}"`);
+ } else {
+ console.log(` ✗ ${key}: missing locale metadata`);
+ }
+ }
+
+// 8. Check canonical links
+console.log('\n=== Canonical Links Check ===');
+ for (const fd of fileData) {
+ // Check for canonical link by looking for tag with rel=canonical
+ const hasCanonicalLink = /