#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = dirname(dirname(fileURLToPath(import.meta.url))); const failures = []; const warnings = []; const ignoredArtifacts = []; const showLocalArtifacts = process.env.PROJECTBRAIN_AUDIT_VERBOSE_LOCAL_ARTIFACTS === '1'; const gitLines = (args) => { try { return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); } catch { return []; } }; const isTracked = (path) => gitLines(['ls-files', '--', path]).length > 0; const isIgnored = (path) => gitLines(['check-ignore', '--', path]).length > 0; const reportArtifact = (path, kind = 'path') => { const absolute = join(root, path); if (!existsSync(absolute)) return; if (isTracked(path)) { failures.push(`Forbidden tracked release artifact/${kind} exists: ${path}`); } else if (isIgnored(path)) { ignoredArtifacts.push(`Ignored local artifact/${kind} present but not release-blocking: ${path}`); } else { failures.push(`Forbidden unignored release artifact/${kind} exists: ${path}`); } }; const forbiddenPaths = [ 'node_modules', 'backend/node_modules', 'dist', 'backend/dist', 'checkpoints', 'src/App.css', 'src/globals.css', ]; for (const path of forbiddenPaths) { reportArtifact(path); } const forbiddenFilePatterns = [/^vite-.*\.log$/, /\.zip$/i, /\.log$/i]; const rootFiles = existsSync(root) ? readdirSync(root) : []; for (const file of rootFiles) { if (forbiddenFilePatterns.some((pattern) => pattern.test(file))) { reportArtifact(file, 'file'); } } const textFilesToScan = [ 'package.json', 'vite.config.ts', 'index.html', 'README.md', 'backend/README.md', ]; const forbiddenText = [/componentTagger/i, /component-tagger/i]; for (const path of textFilesToScan) { const absolute = join(root, path); if (!existsSync(absolute)) { warnings.push(`Expected scan file missing: ${path}`); continue; } const content = readFileSync(absolute, 'utf8'); for (const pattern of forbiddenText) { if (pattern.test(content)) { failures.push(`Forbidden branding/tooling text ${pattern} found in ${path}`); } } } const expectedFiles = [ '.env.example', 'backend/.env.example', 'Dockerfile.frontend', 'backend/Dockerfile', 'docker-compose.yml', 'docker-compose.dev.yml', 'docker-compose.release.yml', 'scripts/stage-release-oci.sh', 'docs/PROJECTBRAIN_DOCUMENTATION_INDEX.md', 'docs/PROJECTBRAIN_OPERATOR_RELEASE_GUIDE.md', 'docs/FULL_VERSION_ROADMAP.md', 'docs/VERTICAL_SLICE_CORE_FLOW_PLAN.md', 'public/site.webmanifest', 'public/favicon.svg', 'public/favicon.ico', ]; for (const path of expectedFiles) { if (!existsSync(join(root, path))) { warnings.push(`Expected project hygiene file missing: ${path}`); } } const workflowPaths = ['.gitea/workflows/ci.yml', '.gitea/workflows/release.yml']; const workflow = workflowPaths .map((path) => readFileSync(join(root, path), 'utf8')) .join('\n'); for (const marker of [ 'file: Dockerfile.unraid', 'Record immutable archive digests', 'Scan Unraid release image', 'archive-sha256.txt', 'trivy-unraid.json', 'VITE_PROJECTBRAIN_COMMIT=${{ github.sha }}', 'org.opencontainers.image.revision=${{ github.sha }}', ]) { if (!workflow.includes(marker)) failures.push(`Release workflow is missing required artifact evidence marker: ${marker}`); } if (workflow.includes('Dockerfile.backend')) { failures.push('Release workflow references nonexistent Dockerfile.backend; use backend/Dockerfile.'); } if (/aquasec\/trivy(?::[\w.-]+)(?!@sha256:)/.test(workflow) || !workflow.includes('aquasec/trivy@sha256:')) { failures.push('Trivy containers must be pinned to an immutable sha256 digest.'); } if (/uses:\s+(?!docker:\/\/)[^\s@]+@(?![a-f0-9]{40}(?:\s|$))/.test(workflow)) { failures.push('External workflow actions must be pinned to immutable 40-character commit SHAs.'); } const maxLargeFileBytes = 5 * 1024 * 1024; const ignoredDirs = new Set(['.git', 'node_modules', 'backend/node_modules']); function walk(dir) { for (const entry of readdirSync(dir)) { const absolute = join(dir, entry); const rel = relative(root, absolute).replaceAll('\\\\', '/'); if (ignoredDirs.has(rel) || ignoredDirs.has(entry)) continue; const stat = statSync(absolute); if (stat.isDirectory()) { walk(absolute); } else if (stat.size > maxLargeFileBytes) { warnings.push(`Large file in source tree (${Math.round(stat.size / 1024 / 1024)}MB): ${rel}`); } } } walk(root); console.log('ProjectBrain release hygiene check'); console.log('===================================='); if (ignoredArtifacts.length) { if (showLocalArtifacts) { console.log('\nLocal ignored artifacts:'); for (const artifact of ignoredArtifacts) console.log(`- ${artifact}`); } } if (warnings.length) { console.log('\nWarnings:'); for (const warning of warnings) console.log(`- ${warning}`); } if (failures.length) { console.log('\nFailures:'); for (const failure of failures) console.log(`- ${failure}`); process.exit(1); } console.log('\nPASS: no blocking release hygiene issues found.');