init project
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const manifestPath = path.join(root, 'PHASE_10_PACKAGE_MANIFEST_v1.0.json');
|
||||
const excludedDirectories = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
]);
|
||||
const excludedFiles = new Set(['PHASE_10_PACKAGE_MANIFEST_v1.0.json', 'tsconfig.tsbuildinfo']);
|
||||
|
||||
async function walk(directory) {
|
||||
const files = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await walk(fullPath)));
|
||||
else {
|
||||
const relative = path.relative(root, fullPath).split(path.sep).join('/');
|
||||
if (!excludedFiles.has(relative)) files.push({ fullPath, relative });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = (await walk(root)).sort((a, b) => a.relative.localeCompare(b.relative));
|
||||
const entries = [];
|
||||
for (const file of files) {
|
||||
const contents = await readFile(file.fullPath);
|
||||
const details = await stat(file.fullPath);
|
||||
entries.push({
|
||||
path: file.relative,
|
||||
bytes: details.size,
|
||||
sha256: createHash('sha256').update(contents).digest('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
schema_version: '1.0',
|
||||
project: 'RentalDriveGo',
|
||||
phase: 10,
|
||||
generated_at: '2026-06-25',
|
||||
excludes: [...excludedDirectories, ...excludedFiles],
|
||||
file_count: entries.length,
|
||||
files: entries,
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Generated Phase 10 manifest for ${entries.length} files.`);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const manifestPath = path.join(root, 'PHASE_11_PACKAGE_MANIFEST_v1.0.json');
|
||||
const excludedDirectories = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
]);
|
||||
const excludedFiles = new Set(['PHASE_11_PACKAGE_MANIFEST_v1.0.json', 'tsconfig.tsbuildinfo']);
|
||||
|
||||
async function walk(directory) {
|
||||
const files = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await walk(fullPath)));
|
||||
else {
|
||||
const relative = path.relative(root, fullPath).split(path.sep).join('/');
|
||||
if (!excludedFiles.has(relative)) files.push({ fullPath, relative });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = (await walk(root)).sort((a, b) => a.relative.localeCompare(b.relative));
|
||||
const entries = [];
|
||||
for (const file of files) {
|
||||
const contents = await readFile(file.fullPath);
|
||||
const details = await stat(file.fullPath);
|
||||
entries.push({
|
||||
path: file.relative,
|
||||
bytes: details.size,
|
||||
sha256: createHash('sha256').update(contents).digest('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
schema_version: '1.0',
|
||||
project: 'RentalDriveGo',
|
||||
phase: 11,
|
||||
generated_at: '2026-06-25',
|
||||
excludes: [...excludedDirectories, ...excludedFiles],
|
||||
file_count: entries.length,
|
||||
files: entries,
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Generated Phase 11 manifest for ${entries.length} files.`);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const manifestPath = path.join(root, 'PHASE_12_PACKAGE_MANIFEST_v1.0.json');
|
||||
const excludedDirectories = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
]);
|
||||
const excludedFiles = new Set(['PHASE_12_PACKAGE_MANIFEST_v1.0.json', 'tsconfig.tsbuildinfo']);
|
||||
|
||||
async function walk(directory) {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await walk(fullPath)));
|
||||
else {
|
||||
const relative = path.relative(root, fullPath).split(path.sep).join('/');
|
||||
if (!excludedFiles.has(relative)) files.push({ fullPath, relative });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = (await walk(root)).sort((a, b) => a.relative.localeCompare(b.relative));
|
||||
const entries = [];
|
||||
for (const file of files) {
|
||||
const contents = await readFile(file.fullPath);
|
||||
const details = await stat(file.fullPath);
|
||||
entries.push({
|
||||
path: file.relative,
|
||||
bytes: details.size,
|
||||
sha256: createHash('sha256').update(contents).digest('hex'),
|
||||
});
|
||||
}
|
||||
const manifest = {
|
||||
schema_version: '1.0',
|
||||
project: 'RentalDriveGo',
|
||||
phase: 12,
|
||||
generated_at: '2026-06-25',
|
||||
excludes: [...excludedDirectories, ...excludedFiles],
|
||||
file_count: entries.length,
|
||||
files: entries,
|
||||
};
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Generated Phase 12 manifest for ${entries.length} files.`);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
const manifestPath = path.join(root, 'PHASE_13_PACKAGE_MANIFEST_v1.0.json');
|
||||
const excludedDirectories = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
]);
|
||||
const excludedFiles = new Set([
|
||||
'PHASE_13_PACKAGE_MANIFEST_v1.0.json',
|
||||
'package-lock.json',
|
||||
'tsconfig.tsbuildinfo',
|
||||
]);
|
||||
|
||||
async function walk(directory) {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await walk(fullPath)));
|
||||
else {
|
||||
const relative = path.relative(root, fullPath).split(path.sep).join('/');
|
||||
if (!excludedFiles.has(relative)) files.push({ fullPath, relative });
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = (await walk(root)).sort((a, b) => a.relative.localeCompare(b.relative));
|
||||
const entries = [];
|
||||
for (const file of files) {
|
||||
const contents = await readFile(file.fullPath);
|
||||
const details = await stat(file.fullPath);
|
||||
entries.push({
|
||||
path: file.relative,
|
||||
bytes: details.size,
|
||||
sha256: createHash('sha256').update(contents).digest('hex'),
|
||||
});
|
||||
}
|
||||
const manifest = {
|
||||
schema_version: '1.0',
|
||||
project: 'RentalDriveGo',
|
||||
phase: 13,
|
||||
generated_at: '2026-06-25',
|
||||
excludes: [...excludedDirectories, ...excludedFiles],
|
||||
file_count: entries.length,
|
||||
files: entries,
|
||||
};
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`Generated Phase 13 manifest for ${entries.length} files.`);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
export async function sha256(filePath) {
|
||||
const contents = await readFile(filePath);
|
||||
return createHash('sha256').update(contents).digest('hex');
|
||||
}
|
||||
|
||||
export async function walk(directory, predicate = () => true) {
|
||||
const output = [];
|
||||
for (const entry of await readdir(directory)) {
|
||||
const fullPath = path.join(directory, entry);
|
||||
const details = await stat(fullPath);
|
||||
if (details.isDirectory()) output.push(...(await walk(fullPath, predicate)));
|
||||
else if (predicate(fullPath)) output.push(fullPath);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function flatten(value, prefix = '', output = new Map()) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry, index) => flatten(entry, `${prefix}[${index}]`, output));
|
||||
if (value.length === 0) output.set(prefix, value);
|
||||
return output;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
flatten(entry, prefix ? `${prefix}.${key}` : key, output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
output.set(prefix, value);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function fail(messages) {
|
||||
for (const message of messages) console.error(`ERROR: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function pass(message) {
|
||||
console.log(`PASS: ${message}`);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const standalone = path.join(root, '.next', 'standalone');
|
||||
|
||||
if (!existsSync(standalone)) {
|
||||
throw new Error('Missing .next/standalone. Run next build before preparing the server.');
|
||||
}
|
||||
|
||||
const copies = [
|
||||
[path.join(root, '.next', 'static'), path.join(standalone, '.next', 'static')],
|
||||
[path.join(root, 'public'), path.join(standalone, 'public')],
|
||||
];
|
||||
|
||||
for (const [source, destination] of copies) {
|
||||
if (!existsSync(source)) continue;
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('Prepared standalone server assets.');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const commands = [
|
||||
['pnpm', ['validate']],
|
||||
['pnpm', ['format:check']],
|
||||
['pnpm', ['lint']],
|
||||
['pnpm', ['typecheck']],
|
||||
['pnpm', ['test:unit']],
|
||||
['pnpm', ['build'], { SITE_ORIGIN: 'http://127.0.0.1:3000', PUBLIC_RELEASE_APPROVED: 'false' }],
|
||||
['pnpm', ['audit']],
|
||||
];
|
||||
for (const [command, args, extraEnv = {}] of commands) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: { ...process.env, ...extraEnv },
|
||||
});
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const scripts = [
|
||||
'validate-phase9-contract.mjs',
|
||||
'validate-runtime-dependencies.mjs',
|
||||
'validate-locales.mjs',
|
||||
'validate-routes.mjs',
|
||||
'validate-tokens.mjs',
|
||||
'validate-phase11-foundation.mjs',
|
||||
'validate-css-logical.mjs',
|
||||
'validate-prohibited-content.mjs',
|
||||
'validate-metadata.mjs',
|
||||
'validate-phase12-components.mjs',
|
||||
'validate-phase13-homepage.mjs',
|
||||
];
|
||||
for (const script of scripts) {
|
||||
const result = spawnSync(process.execPath, [`scripts/${script}`], { stdio: 'inherit' });
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, walk } from './lib.mjs';
|
||||
|
||||
const cssFiles = await walk(path.join(repositoryRoot, 'src'), (file) => file.endsWith('.css'));
|
||||
const errors = [];
|
||||
const forbidden = [
|
||||
/(^|[;{]\s*)(left|right)\s*:/gim,
|
||||
/(^|[;{]\s*)(margin|padding|border)-(left|right)(?:-[a-z]+)?\s*:/gim,
|
||||
/(^|[;{]\s*)inset-(left|right)\s*:/gim,
|
||||
/(^|[;{]\s*)text-align\s*:\s*(left|right)/gim,
|
||||
/(^|[;{]\s*)float\s*:\s*(left|right)/gim,
|
||||
];
|
||||
for (const file of cssFiles) {
|
||||
const text = await readFile(file, 'utf8');
|
||||
for (const expression of forbidden) {
|
||||
const matches = text.match(expression);
|
||||
if (matches)
|
||||
errors.push(
|
||||
`${path.relative(repositoryRoot, file)} uses physical CSS: ${matches.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) fail(errors);
|
||||
else pass(`${cssFiles.length} CSS files use direction-safe logical declarations.`);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, flatten, pass, repositoryRoot } from './lib.mjs';
|
||||
|
||||
const localeRoot = path.join(repositoryRoot, 'src', 'content', 'locales');
|
||||
const locales = ['en', 'fr', 'ar'];
|
||||
const files = ['homepage.json', 'shell.json'];
|
||||
const errors = [];
|
||||
|
||||
for (const file of files) {
|
||||
const catalogs = new Map();
|
||||
for (const locale of locales) {
|
||||
const parsed = JSON.parse(await readFile(path.join(localeRoot, locale, file), 'utf8'));
|
||||
catalogs.set(locale, flatten(parsed));
|
||||
}
|
||||
const sourceKeys = [...catalogs.get('en').keys()];
|
||||
for (const locale of locales) {
|
||||
const catalog = catalogs.get(locale);
|
||||
const keys = [...catalog.keys()];
|
||||
const missing = sourceKeys.filter((key) => !catalog.has(key));
|
||||
const extra = keys.filter((key) => !catalogs.get('en').has(key));
|
||||
if (missing.length) errors.push(`${locale}/${file} missing: ${missing.join(', ')}`);
|
||||
if (extra.length) errors.push(`${locale}/${file} extra: ${extra.join(', ')}`);
|
||||
for (const [key, value] of catalog) {
|
||||
if (typeof value === 'string' && value.trim().length === 0) {
|
||||
errors.push(`${locale}/${file}:${key} is empty.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedDirections = { en: 'ltr', fr: 'ltr', ar: 'rtl' };
|
||||
for (const locale of locales) {
|
||||
const homepage = JSON.parse(
|
||||
await readFile(path.join(localeRoot, locale, 'homepage.json'), 'utf8'),
|
||||
);
|
||||
if (homepage.meta.dir !== expectedDirections[locale]) {
|
||||
errors.push(`${locale} metadata direction must be ${expectedDirections[locale]}.`);
|
||||
}
|
||||
if (JSON.stringify(homepage).includes('homePageCar')) {
|
||||
errors.push(`${locale} implementation catalog still contains the retired project name.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) fail(errors);
|
||||
else
|
||||
pass('English, French, and Arabic catalogs have exact key parity and valid direction metadata.');
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot } from './lib.mjs';
|
||||
|
||||
const errors = [];
|
||||
const metadata = await readFile(
|
||||
path.join(repositoryRoot, 'src', 'lib', 'metadata', 'build-metadata.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const homepage = await readFile(
|
||||
path.join(repositoryRoot, 'src', 'app', '[locale]', 'page.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const pending = await readFile(
|
||||
path.join(repositoryRoot, 'src', 'app', '[locale]', '[slug]', 'page.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
for (const required of [
|
||||
'alternates',
|
||||
'canonical',
|
||||
'hreflangMap',
|
||||
'openGraph',
|
||||
'alternateLocale',
|
||||
'twitter',
|
||||
'robots',
|
||||
]) {
|
||||
if (!metadata.includes(required)) errors.push(`Metadata foundation omits ${required}.`);
|
||||
}
|
||||
if (!homepage.includes("routeId: 'home'")) errors.push('Homepage metadata is not route-ID driven.');
|
||||
if (!pending.includes('indexable: false')) {
|
||||
errors.push('Unapproved route shells must remain noindex.');
|
||||
}
|
||||
if (/application\/ld\+json|aggregateRating|priceCurrency/.test(metadata + homepage + pending)) {
|
||||
errors.push('Unsupported structured data or pricing metadata was introduced.');
|
||||
}
|
||||
|
||||
if (errors.length) fail(errors);
|
||||
else pass('Localized canonical, hreflang, Open Graph, social, and robots foundations are present.');
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { repositoryRoot, sha256, fail, pass } from './lib.mjs';
|
||||
|
||||
const manifestPath = path.join(repositoryRoot, 'PHASE_10_PACKAGE_MANIFEST_v1.0.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
const failures = [];
|
||||
|
||||
if (manifest.project !== 'RentalDriveGo' || manifest.phase !== 10) {
|
||||
failures.push('Phase 10 manifest identity is invalid.');
|
||||
}
|
||||
if (manifest.file_count !== manifest.files.length) {
|
||||
failures.push(
|
||||
`Manifest file_count ${manifest.file_count} does not match ${manifest.files.length} entries.`,
|
||||
);
|
||||
}
|
||||
for (const entry of manifest.files) {
|
||||
const filePath = path.join(repositoryRoot, entry.path);
|
||||
try {
|
||||
const details = await stat(filePath);
|
||||
if (details.size !== entry.bytes) failures.push(`${entry.path}: byte size changed.`);
|
||||
const digest = await sha256(filePath);
|
||||
if (digest !== entry.sha256) failures.push(`${entry.path}: SHA-256 changed.`);
|
||||
} catch {
|
||||
failures.push(`${entry.path}: file is missing.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) fail(failures);
|
||||
else pass(`Phase 10 package manifest verifies ${manifest.files.length} repository files.`);
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, walk } from './lib.mjs';
|
||||
|
||||
const errors = [];
|
||||
const componentTokenPath = path.join(repositoryRoot, 'src', 'styles', 'component-tokens.css');
|
||||
const componentTokens = await readFile(componentTokenPath, 'utf8');
|
||||
const requiredTokens = [
|
||||
'--surface-page',
|
||||
'--surface-primary',
|
||||
'--surface-elevated',
|
||||
'--surface-muted',
|
||||
'--text-primary',
|
||||
'--text-secondary',
|
||||
'--text-inverse',
|
||||
'--border-standard',
|
||||
'--border-strong',
|
||||
'--interactive-primary',
|
||||
'--interactive-conversion',
|
||||
'--interactive-danger',
|
||||
'--focus-ring',
|
||||
'--status-success-surface',
|
||||
'--status-warning-surface',
|
||||
'--status-error-surface',
|
||||
'--status-info-surface',
|
||||
'--overlay-scrim',
|
||||
'--control-disabled-surface',
|
||||
'--selection-surface',
|
||||
'--skeleton-base',
|
||||
'--shadow-card',
|
||||
'--container-wide',
|
||||
'--layer-overlay',
|
||||
'--type-display-size',
|
||||
];
|
||||
|
||||
for (const token of requiredTokens) {
|
||||
if (!componentTokens.includes(token)) errors.push(`Missing Phase 11 component token ${token}.`);
|
||||
}
|
||||
if (!componentTokens.includes("html[data-theme='dark']")) {
|
||||
errors.push('Component token layer has no explicit dark-theme mapping.');
|
||||
}
|
||||
if (!componentTokens.includes("html[data-theme-preference='system']")) {
|
||||
errors.push('Component token layer has no CSS-only system-theme fallback.');
|
||||
}
|
||||
|
||||
const cssFiles = await walk(path.join(repositoryRoot, 'src'), (file) => file.endsWith('.css'));
|
||||
const declarationFiles = new Set([
|
||||
path.join(repositoryRoot, 'src', 'styles', 'tokens.css'),
|
||||
componentTokenPath,
|
||||
]);
|
||||
const literalColor = /(?:#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\()/gi;
|
||||
const rawPaletteReference = /var\(--color-(?:blue|orange|navy|gray|green|amber|red)-/gi;
|
||||
|
||||
for (const file of cssFiles) {
|
||||
if (declarationFiles.has(file)) continue;
|
||||
const text = await readFile(file, 'utf8');
|
||||
if (literalColor.test(text)) {
|
||||
errors.push(
|
||||
`${path.relative(repositoryRoot, file)} contains a literal color outside token declarations.`,
|
||||
);
|
||||
}
|
||||
literalColor.lastIndex = 0;
|
||||
if (rawPaletteReference.test(text)) {
|
||||
errors.push(`${path.relative(repositoryRoot, file)} consumes a raw palette token.`);
|
||||
}
|
||||
rawPaletteReference.lastIndex = 0;
|
||||
}
|
||||
|
||||
const shellFiles = [
|
||||
'src/components/app-shell/SiteHeader.tsx',
|
||||
'src/components/app-shell/MobileNavigation.tsx',
|
||||
'src/components/app-shell/SiteFooter.tsx',
|
||||
'src/app/[locale]/loading.tsx',
|
||||
'src/app/[locale]/error.tsx',
|
||||
'src/app/[locale]/not-found.tsx',
|
||||
'src/app/[locale]/[slug]/page.tsx',
|
||||
];
|
||||
for (const relativePath of shellFiles) {
|
||||
try {
|
||||
await readFile(path.join(repositoryRoot, relativePath), 'utf8');
|
||||
} catch {
|
||||
errors.push(`Required Phase 11 shell file is missing: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) fail(errors);
|
||||
else {
|
||||
pass('Phase 11 component tokens, theme fallback, and application-shell foundations are present.');
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, sha256 } from './lib.mjs';
|
||||
|
||||
const manifestName = 'PHASE_11_PACKAGE_MANIFEST_v1.0.json';
|
||||
const manifestPath = path.join(repositoryRoot, manifestName);
|
||||
const excludedDirectories = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
]);
|
||||
const excludedFiles = new Set([manifestName, 'tsconfig.tsbuildinfo']);
|
||||
|
||||
async function walk(directory) {
|
||||
const files = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
continue;
|
||||
}
|
||||
const relative = path.relative(repositoryRoot, fullPath).split(path.sep).join('/');
|
||||
if (!excludedFiles.has(relative)) files.push(relative);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
const failures = [];
|
||||
|
||||
if (manifest.project !== 'RentalDriveGo' || manifest.phase !== 11) {
|
||||
failures.push('Phase 11 manifest identity is invalid.');
|
||||
}
|
||||
if (!Array.isArray(manifest.files)) {
|
||||
failures.push('Manifest files property must be an array.');
|
||||
} else if (manifest.file_count !== manifest.files.length) {
|
||||
failures.push(
|
||||
`Manifest file_count ${manifest.file_count} does not match ${manifest.files.length} entries.`,
|
||||
);
|
||||
}
|
||||
|
||||
const listedPaths = new Set();
|
||||
for (const entry of manifest.files ?? []) {
|
||||
if (
|
||||
typeof entry.path !== 'string' ||
|
||||
entry.path.length === 0 ||
|
||||
path.isAbsolute(entry.path) ||
|
||||
entry.path.split('/').includes('..')
|
||||
) {
|
||||
failures.push(`Unsafe manifest path: ${String(entry.path)}`);
|
||||
continue;
|
||||
}
|
||||
if (listedPaths.has(entry.path)) {
|
||||
failures.push(`${entry.path}: duplicate manifest entry.`);
|
||||
continue;
|
||||
}
|
||||
listedPaths.add(entry.path);
|
||||
|
||||
const filePath = path.join(repositoryRoot, entry.path);
|
||||
try {
|
||||
const details = await stat(filePath);
|
||||
if (!details.isFile()) failures.push(`${entry.path}: is not a regular file.`);
|
||||
if (details.size !== entry.bytes) failures.push(`${entry.path}: byte size changed.`);
|
||||
const digest = await sha256(filePath);
|
||||
if (digest !== entry.sha256) failures.push(`${entry.path}: SHA-256 changed.`);
|
||||
} catch {
|
||||
failures.push(`${entry.path}: file is missing.`);
|
||||
}
|
||||
}
|
||||
|
||||
const actualPaths = new Set(await walk(repositoryRoot));
|
||||
for (const actual of actualPaths) {
|
||||
if (!listedPaths.has(actual)) failures.push(`${actual}: unlisted repository file.`);
|
||||
}
|
||||
for (const listed of listedPaths) {
|
||||
if (!actualPaths.has(listed)) failures.push(`${listed}: listed but excluded or absent.`);
|
||||
}
|
||||
|
||||
if (failures.length) fail(failures);
|
||||
else {
|
||||
pass(`Phase 11 package manifest verifies ${listedPaths.size} repository files with no extras.`);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, walk } from './lib.mjs';
|
||||
|
||||
const inventoryPath = path.join(repositoryRoot, 'docs/phase12/COMPONENT_INVENTORY_v1.0.json');
|
||||
const inventory = JSON.parse(await readFile(inventoryPath, 'utf8'));
|
||||
const errors = [];
|
||||
const allowedStatus = new Set(['implemented', 'partially-implemented', 'blocked', 'excluded']);
|
||||
const names = new Set();
|
||||
|
||||
if (inventory.phase !== 12 || !Array.isArray(inventory.entries)) {
|
||||
errors.push('Component inventory must identify Phase 12 and contain an entries array.');
|
||||
}
|
||||
for (const entry of inventory.entries ?? []) {
|
||||
if (names.has(entry.name)) errors.push(`Duplicate inventory component: ${entry.name}`);
|
||||
names.add(entry.name);
|
||||
if (!allowedStatus.has(entry.status))
|
||||
errors.push(`${entry.name}: invalid status ${entry.status}`);
|
||||
for (const field of ['sourceSpecification', 'variants', 'openIssues', 'homepageConsumers']) {
|
||||
if (!Array.isArray(entry[field])) errors.push(`${entry.name}: ${field} must be an array.`);
|
||||
}
|
||||
if (
|
||||
!entry.tests ||
|
||||
!['unit', 'interaction', 'accessibility', 'visual'].every(
|
||||
(key) => typeof entry.tests[key] === 'boolean',
|
||||
)
|
||||
) {
|
||||
errors.push(`${entry.name}: test coverage flags are incomplete.`);
|
||||
}
|
||||
if (entry.status === 'blocked' && entry.openIssues.length === 0) {
|
||||
errors.push(`${entry.name}: blocked components require an issue reference.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
'Button',
|
||||
'ActionLink',
|
||||
'FormField',
|
||||
'Tabs',
|
||||
'Accordion',
|
||||
'Dialog',
|
||||
'Alert',
|
||||
'Card',
|
||||
'Workflow',
|
||||
'Comparison',
|
||||
'Metric',
|
||||
'CTA',
|
||||
'ProductPreview',
|
||||
]) {
|
||||
if (!names.has(required)) errors.push(`Required component missing from inventory: ${required}`);
|
||||
}
|
||||
|
||||
const componentFiles = await walk(path.join(repositoryRoot, 'src/components'), (file) =>
|
||||
/\.(?:ts|tsx|css)$/.test(file),
|
||||
);
|
||||
const forbiddenRawToken = /var\(--color-(?:blue|orange|gray|green|amber|red|navy)-/g;
|
||||
const forbiddenLiteralColor = /(?:#[0-9a-f]{3,8}\b|rgba?\(|hsla?\()/gi;
|
||||
for (const file of componentFiles) {
|
||||
const text = await readFile(file, 'utf8');
|
||||
const relative = path.relative(repositoryRoot, file);
|
||||
if (file.endsWith('.css') && forbiddenRawToken.test(text))
|
||||
errors.push(`${relative}: consumes a raw palette token.`);
|
||||
if (file.endsWith('.css') && forbiddenLiteralColor.test(text))
|
||||
errors.push(`${relative}: contains a literal color.`);
|
||||
if (/href=["']#["']/.test(text)) errors.push(`${relative}: contains a placeholder href="#".`);
|
||||
}
|
||||
|
||||
for (const artifact of [
|
||||
'COMPONENT_SPECIFICATION_TRACEABILITY_v1.0.csv',
|
||||
'VARIANT_INVENTORY_v1.0.csv',
|
||||
'ACCESSIBILITY_BEHAVIOR_MATRIX_v1.0.csv',
|
||||
'RTL_BEHAVIOR_MATRIX_UPDATE_v1.0.csv',
|
||||
'RESPONSIVE_VALIDATION_MATRIX_v1.0.csv',
|
||||
'STORY_AND_FIXTURE_INVENTORY_v1.0.csv',
|
||||
'TEST_COVERAGE_REPORT_v1.0.csv',
|
||||
'COMPONENT_CATALOG_v1.0.md',
|
||||
]) {
|
||||
try {
|
||||
await stat(path.join(repositoryRoot, 'docs/phase12', artifact));
|
||||
} catch {
|
||||
errors.push(`Required Phase 12 artifact missing: ${artifact}`);
|
||||
}
|
||||
}
|
||||
|
||||
const fixturePage = await readFile(
|
||||
path.join(repositoryRoot, 'src/app/[locale]/component-lab/page.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
if (!fixturePage.includes("COMPONENT_FIXTURES_ENABLED !== 'true'")) {
|
||||
errors.push('Component fixture route is not protected by the production environment guard.');
|
||||
}
|
||||
|
||||
if (errors.length > 0) fail(errors);
|
||||
else
|
||||
pass(
|
||||
`${inventory.entries.length} inventory entries and ${componentFiles.length} component source files satisfy Phase 12 static controls.`,
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, sha256 } from './lib.mjs';
|
||||
|
||||
const manifestName = 'PHASE_12_PACKAGE_MANIFEST_v1.0.json';
|
||||
const excludedDirectories = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
]);
|
||||
const excludedFiles = new Set([manifestName, 'tsconfig.tsbuildinfo']);
|
||||
async function walk(directory) {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await walk(fullPath)));
|
||||
else {
|
||||
const relative = path.relative(repositoryRoot, fullPath).split(path.sep).join('/');
|
||||
if (!excludedFiles.has(relative)) files.push(relative);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
const manifest = JSON.parse(await readFile(path.join(repositoryRoot, manifestName), 'utf8'));
|
||||
const failures = [];
|
||||
if (manifest.project !== 'RentalDriveGo' || manifest.phase !== 12)
|
||||
failures.push('Phase 12 manifest identity is invalid.');
|
||||
if (!Array.isArray(manifest.files) || manifest.file_count !== manifest.files.length)
|
||||
failures.push('Manifest file count is invalid.');
|
||||
const listed = new Set();
|
||||
for (const entry of manifest.files ?? []) {
|
||||
if (
|
||||
typeof entry.path !== 'string' ||
|
||||
path.isAbsolute(entry.path) ||
|
||||
entry.path.split('/').includes('..')
|
||||
) {
|
||||
failures.push(`Unsafe manifest path: ${String(entry.path)}`);
|
||||
continue;
|
||||
}
|
||||
if (listed.has(entry.path)) {
|
||||
failures.push(`${entry.path}: duplicate manifest entry.`);
|
||||
continue;
|
||||
}
|
||||
listed.add(entry.path);
|
||||
try {
|
||||
const filePath = path.join(repositoryRoot, entry.path);
|
||||
const details = await stat(filePath);
|
||||
if (!details.isFile() || details.size !== entry.bytes)
|
||||
failures.push(`${entry.path}: file metadata changed.`);
|
||||
if ((await sha256(filePath)) !== entry.sha256) failures.push(`${entry.path}: SHA-256 changed.`);
|
||||
} catch {
|
||||
failures.push(`${entry.path}: file is missing.`);
|
||||
}
|
||||
}
|
||||
const actual = new Set(await walk(repositoryRoot));
|
||||
for (const file of actual)
|
||||
if (!listed.has(file)) failures.push(`${file}: unlisted repository file.`);
|
||||
for (const file of listed)
|
||||
if (!actual.has(file)) failures.push(`${file}: listed but excluded or absent.`);
|
||||
if (failures.length) fail(failures);
|
||||
else pass(`Phase 12 package manifest verifies ${listed.size} repository files with no extras.`);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot } from './lib.mjs';
|
||||
|
||||
const errors = [];
|
||||
const pagePath = path.join(repositoryRoot, 'src/app/[locale]/page.tsx');
|
||||
const cssPath = path.join(repositoryRoot, 'src/app/[locale]/page.module.css');
|
||||
const modelPath = path.join(repositoryRoot, 'src/content/homepage-model.ts');
|
||||
const page = await readFile(pagePath, 'utf8');
|
||||
const css = await readFile(cssPath, 'utf8');
|
||||
const model = await readFile(modelPath, 'utf8');
|
||||
|
||||
for (const sectionId of ['product', 'workflow', 'modules', 'pricing', 'faq']) {
|
||||
if (!page.includes(`id="${sectionId}"`))
|
||||
errors.push(`Homepage section #${sectionId} is missing.`);
|
||||
}
|
||||
|
||||
for (const component of [
|
||||
'ActionLink',
|
||||
'Accordion',
|
||||
'CTA',
|
||||
'Comparison',
|
||||
'FeatureCard',
|
||||
'ProductPreview',
|
||||
'SectionHeader',
|
||||
'Workflow',
|
||||
]) {
|
||||
if (!page.includes(component))
|
||||
errors.push(`Homepage does not compose the approved ${component}.`);
|
||||
}
|
||||
|
||||
if (!page.includes('buildHomepageContent'))
|
||||
errors.push('Homepage does not consume the typed route content model.');
|
||||
if (!page.includes("localizedPath('home', locale)"))
|
||||
errors.push('Homepage actions do not use the locale route helper.');
|
||||
if (page.includes('messages.foundation'))
|
||||
errors.push('Phase 11 foundation placeholder remains in the homepage route.');
|
||||
if (/href=["']#["']/.test(page)) errors.push('Homepage contains href="#".');
|
||||
if (/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i.test(css))
|
||||
errors.push('Homepage CSS contains literal colors.');
|
||||
if (/var\(--color-(?:blue|orange|gray|green|amber|red|navy)-/i.test(css)) {
|
||||
errors.push('Homepage CSS consumes a raw palette token.');
|
||||
}
|
||||
if (!model.includes('disabledReason'))
|
||||
errors.push('Homepage content model does not encode unavailable destinations.');
|
||||
if (!model.includes('PreviewRow'))
|
||||
errors.push('Homepage content model does not type product preview rows.');
|
||||
|
||||
for (const artifact of [
|
||||
'tests/unit/homepage-content.test.ts',
|
||||
'tests/browser/homepage.spec.ts',
|
||||
'tests/a11y/homepage.a11y.spec.ts',
|
||||
'tests/visual/homepage.visual.spec.ts',
|
||||
'docs/phase13/HOMEPAGE_SECTION_MATRIX_v1.0.csv',
|
||||
'docs/phase13/HOMEPAGE_CONTENT_MODEL_v1.0.md',
|
||||
]) {
|
||||
try {
|
||||
await stat(path.join(repositoryRoot, artifact));
|
||||
} catch {
|
||||
errors.push(`Required Phase 13 artifact missing: ${artifact}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) fail(errors);
|
||||
else
|
||||
pass(
|
||||
'Phase 13 homepage composition, content model, destination gates, and test artifacts satisfy static controls.',
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, sha256 } from './lib.mjs';
|
||||
|
||||
const manifestName = 'PHASE_13_PACKAGE_MANIFEST_v1.0.json';
|
||||
const excludedDirectories = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
]);
|
||||
const excludedFiles = new Set([manifestName, 'package-lock.json', 'tsconfig.tsbuildinfo']);
|
||||
|
||||
async function walk(directory) {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await walk(fullPath)));
|
||||
else {
|
||||
const relative = path.relative(repositoryRoot, fullPath).split(path.sep).join('/');
|
||||
if (!excludedFiles.has(relative)) files.push(relative);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(await readFile(path.join(repositoryRoot, manifestName), 'utf8'));
|
||||
const failures = [];
|
||||
if (manifest.project !== 'RentalDriveGo' || manifest.phase !== 13) {
|
||||
failures.push('Phase 13 manifest identity is invalid.');
|
||||
}
|
||||
if (!Array.isArray(manifest.files) || manifest.file_count !== manifest.files.length) {
|
||||
failures.push('Manifest file count is invalid.');
|
||||
}
|
||||
const listed = new Set();
|
||||
for (const entry of manifest.files ?? []) {
|
||||
if (
|
||||
typeof entry.path !== 'string' ||
|
||||
path.isAbsolute(entry.path) ||
|
||||
entry.path.split('/').includes('..')
|
||||
) {
|
||||
failures.push(`Unsafe manifest path: ${String(entry.path)}`);
|
||||
continue;
|
||||
}
|
||||
if (listed.has(entry.path)) {
|
||||
failures.push(`${entry.path}: duplicate manifest entry.`);
|
||||
continue;
|
||||
}
|
||||
listed.add(entry.path);
|
||||
try {
|
||||
const filePath = path.join(repositoryRoot, entry.path);
|
||||
const details = await stat(filePath);
|
||||
if (!details.isFile() || details.size !== entry.bytes)
|
||||
failures.push(`${entry.path}: file metadata changed.`);
|
||||
if ((await sha256(filePath)) !== entry.sha256) failures.push(`${entry.path}: SHA-256 changed.`);
|
||||
} catch {
|
||||
failures.push(`${entry.path}: file is missing.`);
|
||||
}
|
||||
}
|
||||
const actual = new Set(await walk(repositoryRoot));
|
||||
for (const file of actual)
|
||||
if (!listed.has(file)) failures.push(`${file}: unlisted repository file.`);
|
||||
for (const file of listed)
|
||||
if (!actual.has(file)) failures.push(`${file}: listed but excluded or absent.`);
|
||||
if (failures.length) fail(failures);
|
||||
else pass(`Phase 13 package manifest verifies ${listed.size} repository files with no extras.`);
|
||||
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, sha256 } from './lib.mjs';
|
||||
|
||||
const contractRoot = path.join(repositoryRoot, 'contracts', 'phase9');
|
||||
const manifestPath = path.join(contractRoot, 'PACKAGE_MANIFEST_v1.0.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
const errors = [];
|
||||
|
||||
if (manifest.file_count !== manifest.files.length) {
|
||||
errors.push(
|
||||
`Manifest count ${manifest.file_count} differs from ${manifest.files.length} entries.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of manifest.files) {
|
||||
const filePath = path.join(contractRoot, entry.path);
|
||||
try {
|
||||
const details = await stat(filePath);
|
||||
if (details.size !== entry.bytes) errors.push(`${entry.path}: byte count changed.`);
|
||||
if ((await sha256(filePath)) !== entry.sha256) errors.push(`${entry.path}: checksum changed.`);
|
||||
} catch {
|
||||
errors.push(`${entry.path}: missing.`);
|
||||
}
|
||||
}
|
||||
|
||||
const validator = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(contractRoot, 'qa', 'validate-phase9-package_v1.0.mjs'), contractRoot],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
if (validator.status !== 0) {
|
||||
errors.push(`Phase 9 validator failed: ${validator.stderr || validator.stdout}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) fail(errors);
|
||||
else
|
||||
pass(
|
||||
`Phase 9 contract package is immutable and ${manifest.files.length} payload checksums match.`,
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, walk } from './lib.mjs';
|
||||
|
||||
const sourceFiles = await walk(path.join(repositoryRoot, 'src'), (file) =>
|
||||
/\.(?:ts|tsx|js|jsx|css)$/.test(file),
|
||||
);
|
||||
const errors = [];
|
||||
const prohibited = [
|
||||
{ label: 'placeholder domain', pattern: /example\.(?:com|org|net)|placeholder\.(?:com|test)/i },
|
||||
{ label: 'simulated submission', pattern: /simulate(?:d|Submission)|fakeSubmit/i },
|
||||
{ label: 'research-only state backdoor', pattern: /[?&](?:state|scenario|fixture)=/i },
|
||||
{ label: 'prototype research logger', pattern: /researchLog|prototypeLog/i },
|
||||
];
|
||||
for (const file of sourceFiles) {
|
||||
const text = await readFile(file, 'utf8');
|
||||
for (const rule of prohibited) {
|
||||
if (rule.pattern.test(text))
|
||||
errors.push(`${path.relative(repositoryRoot, file)} contains ${rule.label}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) fail(errors);
|
||||
else
|
||||
pass(
|
||||
'Runtime source contains no placeholder destination, simulated submission, ' +
|
||||
'or research-only backdoor.',
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot } from './lib.mjs';
|
||||
|
||||
const manifest = JSON.parse(
|
||||
await readFile(
|
||||
path.join(repositoryRoot, 'src', 'contracts', 'phase9', 'route-manifest.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const errors = [];
|
||||
const requiredLocales = ['en', 'fr', 'ar'];
|
||||
const ids = new Set();
|
||||
|
||||
if (JSON.stringify(manifest.locales) !== JSON.stringify(requiredLocales)) {
|
||||
errors.push('Route locales must remain exactly en, fr, ar.');
|
||||
}
|
||||
for (const route of manifest.routes) {
|
||||
if (ids.has(route.id)) errors.push(`Duplicate route ID: ${route.id}`);
|
||||
ids.add(route.id);
|
||||
for (const locale of requiredLocales) {
|
||||
if (typeof route.slugs[locale] !== 'string') errors.push(`${route.id} has no ${locale} slug.`);
|
||||
}
|
||||
}
|
||||
for (const locale of requiredLocales) {
|
||||
if (!manifest.hreflang.values.includes(locale)) errors.push(`hreflang omits ${locale}.`);
|
||||
}
|
||||
if (manifest.hreflang.xDefaultLocale !== 'en') errors.push('x-default must resolve to English.');
|
||||
if (manifest.switching.dropUnknownQuery !== true) {
|
||||
errors.push('Unknown query parameters must be dropped.');
|
||||
}
|
||||
|
||||
for (const routeFile of [
|
||||
'src/app/[locale]/page.tsx',
|
||||
'src/app/[locale]/[slug]/page.tsx',
|
||||
'src/app/[locale]/not-found.tsx',
|
||||
]) {
|
||||
try {
|
||||
await stat(path.join(repositoryRoot, routeFile));
|
||||
} catch {
|
||||
errors.push(`Runtime route implementation missing: ${routeFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) fail(errors);
|
||||
else
|
||||
pass(
|
||||
`${manifest.routes.length} route IDs have complete locale, runtime, hash, query, ` +
|
||||
'and hreflang contracts.',
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot } from './lib.mjs';
|
||||
|
||||
const expected = {
|
||||
node: '24.17.0',
|
||||
pnpm: '11.9.0',
|
||||
};
|
||||
const packageJson = JSON.parse(await readFile(path.join(repositoryRoot, 'package.json'), 'utf8'));
|
||||
const lockfile = await readFile(path.join(repositoryRoot, 'pnpm-lock.yaml'), 'utf8');
|
||||
const nodeVersion = (await readFile(path.join(repositoryRoot, '.node-version'), 'utf8')).trim();
|
||||
const nvmVersion = (await readFile(path.join(repositoryRoot, '.nvmrc'), 'utf8')).trim();
|
||||
const errors = [];
|
||||
|
||||
if (packageJson.packageManager !== `pnpm@${expected.pnpm}`) {
|
||||
errors.push(`packageManager must be pnpm@${expected.pnpm}.`);
|
||||
}
|
||||
if (packageJson.engines?.node !== expected.node) {
|
||||
errors.push(`package.json engines.node must be ${expected.node}.`);
|
||||
}
|
||||
if (packageJson.engines?.pnpm !== expected.pnpm) {
|
||||
errors.push(`package.json engines.pnpm must be ${expected.pnpm}.`);
|
||||
}
|
||||
if (nodeVersion !== expected.node || nvmVersion !== expected.node) {
|
||||
errors.push('.node-version and .nvmrc must match the approved Node.js runtime.');
|
||||
}
|
||||
|
||||
const directDependencies = {
|
||||
...(packageJson.dependencies ?? {}),
|
||||
...(packageJson.devDependencies ?? {}),
|
||||
};
|
||||
const exactVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
for (const [name, version] of Object.entries(directDependencies)) {
|
||||
if (typeof version !== 'string' || !exactVersion.test(version)) {
|
||||
errors.push(`${name} must use an exact direct version, received ${String(version)}.`);
|
||||
continue;
|
||||
}
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const lockSpecifier = new RegExp(
|
||||
`(?:^|\\n)\\s{6}['\"]?${escapedName}['\"]?:\\n\\s{8}specifier: ${escapedVersion}(?:\\n|$)`,
|
||||
);
|
||||
if (!lockSpecifier.test(lockfile)) {
|
||||
errors.push(
|
||||
`${name}@${version} is not represented by an exact importer specifier in pnpm-lock.yaml.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) fail(errors);
|
||||
else {
|
||||
pass(
|
||||
`Pinned Node ${expected.node}, pnpm ${expected.pnpm}, and ${Object.keys(directDependencies).length} exact direct dependency versions match the lockfile.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fail, pass, repositoryRoot, walk } from './lib.mjs';
|
||||
|
||||
const sourceCss = await readFile(
|
||||
path.join(repositoryRoot, 'contracts', 'phase9', 'tokens', 'semantic-tokens_v1.0.css'),
|
||||
'utf8',
|
||||
);
|
||||
const implementationCssPath = path.join(repositoryRoot, 'src', 'styles', 'tokens.css');
|
||||
const implementationCss = await readFile(implementationCssPath, 'utf8');
|
||||
const designTokens = JSON.parse(
|
||||
await readFile(
|
||||
path.join(repositoryRoot, 'src', 'contracts', 'phase9', 'design-tokens.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const errors = [];
|
||||
|
||||
if (sourceCss !== implementationCss)
|
||||
errors.push('Implementation tokens.css differs from Phase 9 source.');
|
||||
const rawColors = new Set();
|
||||
for (const scale of Object.values(designTokens.raw.color)) {
|
||||
for (const value of Object.values(scale)) rawColors.add(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
const applicationStyles = await walk(
|
||||
path.join(repositoryRoot, 'src'),
|
||||
(file) => file.endsWith('.css') && file !== implementationCssPath,
|
||||
);
|
||||
for (const file of applicationStyles) {
|
||||
const text = (await readFile(file, 'utf8')).toLowerCase();
|
||||
for (const color of rawColors) {
|
||||
if (text.includes(color))
|
||||
errors.push(`${path.relative(repositoryRoot, file)} embeds raw brand color ${color}.`);
|
||||
}
|
||||
}
|
||||
for (const required of [
|
||||
'--color-canvas',
|
||||
'--color-text',
|
||||
'--color-action-primary',
|
||||
'--color-focus',
|
||||
]) {
|
||||
if (!implementationCss.includes(required)) errors.push(`Missing semantic token ${required}.`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) fail(errors);
|
||||
else
|
||||
pass(
|
||||
'Phase 9 semantic tokens are unchanged and application styles contain no raw palette colors.',
|
||||
);
|
||||
Reference in New Issue
Block a user