managarten/scripts/validate-dockerfiles.mjs
Till JS d6eacc1c4b fix(todo): add ReorderTasksDto to fix validation error on task reorder
The reorder endpoint used @Body('taskIds') without a DTO, which caused
"property taskIds should not exist" when the global ValidationPipe with
forbidNonWhitelisted rejected the request body.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:26:30 +01:00

238 lines
7.4 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* Validate that all web app Dockerfiles include COPY statements
* for every workspace dependency listed in package.json.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dirname, '..');
// Build a map of package name -> directory path (relative to repo root)
// for both packages/* and apps/*/packages/*
function buildPackageMap() {
const map = new Map();
// packages/*
const packagesDir = join(ROOT, 'packages');
if (existsSync(packagesDir)) {
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const pkgJsonPath = join(packagesDir, entry.name, 'package.json');
if (!existsSync(pkgJsonPath)) continue;
try {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
map.set(pkg.name, `packages/${entry.name}`);
} catch {}
}
}
// apps/*/packages/*
const appsDir = join(ROOT, 'apps');
if (existsSync(appsDir)) {
for (const appEntry of readdirSync(appsDir, { withFileTypes: true })) {
if (!appEntry.isDirectory()) continue;
const appPkgsDir = join(appsDir, appEntry.name, 'packages');
if (!existsSync(appPkgsDir)) continue;
for (const pkgEntry of readdirSync(appPkgsDir, { withFileTypes: true })) {
if (!pkgEntry.isDirectory()) continue;
const pkgJsonPath = join(appPkgsDir, pkgEntry.name, 'package.json');
if (!existsSync(pkgJsonPath)) continue;
try {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
map.set(pkg.name, `apps/${appEntry.name}/packages/${pkgEntry.name}`);
} catch {}
}
}
}
return map;
}
// Extract workspace deps from package.json
function getWorkspaceDeps(pkgJsonPath) {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
const deps = [];
for (const section of ['dependencies', 'devDependencies']) {
if (!pkg[section]) continue;
for (const [name, version] of Object.entries(pkg[section])) {
if (typeof version === 'string' && version.startsWith('workspace:')) {
deps.push(name);
}
}
}
return deps;
}
// Extract COPY paths from Dockerfile (only from builder stage, before RUN install)
function getDockerfileCopyPaths(dockerfilePath) {
const content = readFileSync(dockerfilePath, 'utf8');
const lines = content.split('\n');
const copyPaths = new Set();
let hasPatchesCopy = false;
for (const line of lines) {
const trimmed = line.trim();
// Match COPY statements like: COPY packages/shared-utils ./packages/shared-utils
// or COPY apps/zitare/packages/content ./apps/zitare/packages/content
const copyMatch = trimmed.match(/^COPY\s+((?:packages|apps)\/\S+)/);
if (copyMatch) {
copyPaths.add(copyMatch[1]);
}
// Check for patches copy
if (trimmed.match(/^COPY\s+patches[\s/]/)) {
hasPatchesCopy = true;
}
}
return { copyPaths, hasPatchesCopy };
}
// Extract @scope/package imports from a source file
function extractImports(filePath) {
if (!existsSync(filePath)) return [];
const content = readFileSync(filePath, 'utf8');
const imports = new Set();
// Match import ... from '@scope/package' and import '@scope/package'
// Also match dynamic imports and require
const patterns = [
new RegExp('from\\s+[\'"](@[^\'"/]+/[^\'"/]+)', 'g'),
new RegExp('import\\s*\\(\\s*[\'"](@[^\'"/]+/[^\'"/]+)', 'g'),
new RegExp('import\\s+[\'"](@[^\'"/]+/[^\'"/]+)', 'g'),
new RegExp('require\\s*\\(\\s*[\'"](@[^\'"/]+/[^\'"/]+)', 'g'),
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
imports.add(match[1]);
}
}
return [...imports];
}
function main() {
const packageMap = buildPackageMap();
const appsDir = join(ROOT, 'apps');
let hasErrors = false;
const results = [];
// Find all web app Dockerfiles
const appDirs = readdirSync(appsDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
for (const appName of appDirs) {
const dockerfilePath = join(appsDir, appName, 'apps', 'web', 'Dockerfile');
if (!existsSync(dockerfilePath)) continue;
const pkgJsonPath = join(appsDir, appName, 'apps', 'web', 'package.json');
if (!existsSync(pkgJsonPath)) {
results.push({
path: `apps/${appName}/apps/web/Dockerfile`,
errors: ['package.json not found in same directory'],
warnings: [],
depCount: 0,
});
hasErrors = true;
continue;
}
const workspaceDeps = getWorkspaceDeps(pkgJsonPath);
const { copyPaths, hasPatchesCopy } = getDockerfileCopyPaths(dockerfilePath);
const errors = [];
const warnings = [];
// Check each workspace dep has a corresponding COPY
for (const dep of workspaceDeps) {
const dirPath = packageMap.get(dep);
if (!dirPath) {
warnings.push(`UNKNOWN PACKAGE: ${dep} (not found in workspace)`);
continue;
}
// Check if any COPY path matches or is a parent directory of this package
// e.g. "apps/calendar/packages" covers "apps/calendar/packages/shared"
const found = [...copyPaths].some(
(cp) => cp === dirPath || dirPath.startsWith(cp + '/') || cp.startsWith(dirPath)
);
if (!found) {
errors.push(`MISSING: ${dep} → add: COPY ${dirPath} ./${dirPath}`);
}
}
// Check patches
if (!hasPatchesCopy) {
errors.push('MISSING: patches/ directory → add: COPY patches/ ./patches/');
}
// Check for imports in hooks.server.ts and vite.config.ts not in package.json
const hooksPath = join(appsDir, appName, 'apps', 'web', 'src', 'hooks.server.ts');
const vitePath = join(appsDir, appName, 'apps', 'web', 'vite.config.ts');
for (const filePath of [hooksPath, vitePath]) {
const imports = extractImports(filePath);
const fileName = filePath.includes('hooks.server') ? 'hooks.server.ts' : 'vite.config.ts';
for (const imp of imports) {
// Only check workspace packages (ones we know about)
if (packageMap.has(imp) && !workspaceDeps.includes(imp)) {
warnings.push(`NOT IN package.json: ${imp} (imported in ${fileName})`);
}
}
}
if (errors.length > 0) hasErrors = true;
results.push({
path: `apps/${appName}/apps/web/Dockerfile`,
errors,
warnings,
depCount: workspaceDeps.length,
});
}
// Print results
for (const result of results) {
const totalIssues = result.errors.length + result.warnings.length;
if (totalIssues === 0) {
console.log(`\u2713 ${result.path} - all ${result.depCount} deps covered`);
} else {
if (result.errors.length > 0) {
console.log(
`\u2717 ${result.path} - ${result.errors.length} issue${result.errors.length !== 1 ? 's' : ''}:`
);
for (const err of result.errors) {
console.log(` ${err}`);
}
}
if (result.warnings.length > 0) {
if (result.errors.length === 0) {
console.log(
`\u26A0 ${result.path} - ${result.warnings.length} warning${result.warnings.length !== 1 ? 's' : ''}:`
);
}
for (const warn of result.warnings) {
console.log(` \u26A0 ${warn}`);
}
}
if (result.errors.length === 0 && result.warnings.length > 0) {
// Already printed the warning header above
}
}
}
// Summary
const errorCount = results.reduce((sum, r) => sum + r.errors.length, 0);
const warnCount = results.reduce((sum, r) => sum + r.warnings.length, 0);
console.log('');
console.log(
`Checked ${results.length} Dockerfiles: ${errorCount} error${errorCount !== 1 ? 's' : ''}, ${warnCount} warning${warnCount !== 1 ? 's' : ''}`
);
if (hasErrors) {
process.exit(1);
}
}
main();