import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const MODULE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const PROJECT_ID = /^[a-z][a-z0-9-]{1,62}$/; const FEATURE_ID = /^[0-9]{3}-[a-z0-9-]+$/; const MODULE_ID = /^MOD-[0-9]{2,}$/; const SLUG = /^[a-z0-9][a-z0-9-]*$/; function fail(message) { throw new Error(`CASAN_PROJECT_MANIFEST_INVALID: ${message}`); } export function safeRelativePath(value, label = 'path') { if (typeof value !== 'string' || value.length === 0 || value.includes('\0') || value.includes('\\') || isAbsolute(value)) { fail(`${label} must be a non-empty repository-relative POSIX path`); } const normalized = normalize(value).split(sep).join('/').replace(/^\.\//, ''); if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) { fail(`${label} escapes the repository root`); } return normalized; } export function resolveWithinRoot(root, value, label = 'path', mustExist = true) { const rel = safeRelativePath(value, label); const rootPath = realpathSync(root); const candidate = resolve(rootPath, rel); const relation = relative(rootPath, candidate); if (relation.startsWith('..') || isAbsolute(relation)) fail(`${label} escapes the repository root`); if (mustExist && !existsSync(candidate)) fail(`${label} does not exist: ${rel}`); if (mustExist) { const real = realpathSync(candidate); const realRelation = relative(rootPath, real); if (realRelation.startsWith('..') || isAbsolute(realRelation)) fail(`${label} resolves outside the repository root`); } return candidate; } function validateCommands(commands, label, allowed) { if (!Array.isArray(commands)) fail(`${label} must be an array`); return commands.map((command, index) => { if (!Array.isArray(command) || command.length === 0 || command.some((part) => typeof part !== 'string' || part.length === 0)) { fail(`${label}[${index}] must be a non-empty argv array`); } if (!allowed.has(command[0])) fail(`${label}[${index}] executable is not allowed: ${command[0]}`); return [...command]; }); } function readJson(path, label) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) { fail(`${label} is not valid JSON: ${error.message}`); } } function locateManifest(root, { manifestPath, projectId } = {}) { const selected = manifestPath || process.env.CASAN_PROJECT_MANIFEST; if (selected) return resolveWithinRoot(root, selected, 'manifest', true); const requestedProject = projectId || process.env.CASAN_PROJECT_ID; if (requestedProject) { const registryPath = join(root, 'packages/casan-harness/config/project-registry.json'); const registry = readJson(registryPath, 'project registry'); const entry = registry.projects?.find((item) => item.project_id === requestedProject || item.manifest === requestedProject); if (!entry) fail(`project is not registered: ${requestedProject}`); const candidate = entry.manifest || `${safeRelativePath(entry.domain_root, 'registry domain_root')}/project.manifest.json`; return resolveWithinRoot(root, candidate, 'registered manifest', true); } return resolveWithinRoot(root, 'apps/okr/domain/project.manifest.json', 'default manifest', true); } export function validateProjectManifest(raw, root = MODULE_ROOT) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('manifest must be an object'); if (raw.schema_version !== 1) fail('schema_version must be 1'); if (!PROJECT_ID.test(raw.project_id || '')) fail('project_id must be a lowercase slug'); if (typeof raw.display_name !== 'string' || raw.display_name.trim().length < 2) fail('display_name is required'); if (!FEATURE_ID.test(raw.feature?.id || '')) fail('feature.id must match NNN-slug'); if (!MODULE_ID.test(raw.feature?.module_id || '')) fail('feature.module_id must match MOD-NN'); if (!SLUG.test(raw.feature?.slug || '')) fail('feature.slug must be a lowercase slug'); if (typeof raw.feature?.title !== 'string' || raw.feature.title.trim().length < 2) fail('feature.title is required'); const paths = {}; for (const key of ['domain_root', 'requirements', 'architecture', 'quality_profile']) { paths[key] = safeRelativePath(raw[key], key); resolveWithinRoot(root, paths[key], key, true); } paths.artifacts_root = safeRelativePath(raw.artifacts_root || 'docs/output', 'artifacts_root'); if (!Array.isArray(raw.source_roots) || raw.source_roots.length === 0) fail('source_roots must contain at least one path'); const sourceRoots = [...new Set(raw.source_roots.map((item, index) => safeRelativePath(item, `source_roots[${index}]`)))]; sourceRoots.forEach((item, index) => resolveWithinRoot(root, item, `source_roots[${index}]`, true)); const profilePath = resolveWithinRoot(root, paths.quality_profile, 'quality_profile', true); const profile = readJson(profilePath, 'quality profile'); if (profile.schema_version !== 1 || typeof profile.profile_id !== 'string') fail('quality profile version/id is invalid'); const allowed = new Set(profile.allowed_command_executables || []); if (allowed.size === 0) fail('quality profile must declare allowed_command_executables'); const commands = { build: validateCommands(raw.commands?.build, 'commands.build', allowed), test: validateCommands(raw.commands?.test, 'commands.test', allowed), }; if (profile.require_build_commands && commands.build.length === 0) fail('build commands are required by the quality profile'); if (profile.require_test_commands && commands.test.length === 0) fail('test commands are required by the quality profile'); if (!Array.isArray(raw.verification) || (profile.require_verification_mapping && raw.verification.length === 0)) { fail('verification mapping is required'); } const verification = raw.verification.map((rule, index) => ({ path_prefix: `${safeRelativePath(rule.path_prefix, `verification[${index}].path_prefix`).replace(/\/$/, '')}/`, commands: validateCommands(rule.commands, `verification[${index}].commands`, allowed), })); if (profile.fail_on_unmapped_source_root) { for (const sourceRoot of sourceRoots) { const prefix = `${sourceRoot.replace(/\/$/, '')}/`; if (!verification.some((rule) => prefix.startsWith(rule.path_prefix) || rule.path_prefix.startsWith(prefix))) { fail(`source root has no verification rule: ${sourceRoot}`); } } } const implementationEvidence = (raw.implementation_evidence || []).map((item, index) => safeRelativePath(item, `implementation_evidence[${index}]`)); return Object.freeze({ ...raw, ...paths, display_name: raw.display_name.trim(), feature: Object.freeze({ ...raw.feature, title: raw.feature.title.trim() }), source_roots: Object.freeze(sourceRoots), commands: Object.freeze({ build: Object.freeze(commands.build), test: Object.freeze(commands.test) }), verification: Object.freeze(verification), implementation_evidence: Object.freeze(implementationEvidence), quality: Object.freeze(profile), }); } export function loadProjectManifest(options = {}) { const root = realpathSync(options.root || MODULE_ROOT); const path = locateManifest(root, options); const manifest = validateProjectManifest(readJson(path, 'project manifest'), root); return Object.freeze({ ...manifest, manifest_path: relative(root, path).split(sep).join('/'), root }); } export function extractIds(text, prefix) { const pattern = new RegExp(`\\b${prefix}-\\d{2,}\\b`, 'g'); return [...new Set(String(text).match(pattern) || [])]; } export function requirementLines(text, ids) { const lines = String(text).split(/\r?\n/); return ids.map((id) => { const line = lines.find((candidate) => candidate.includes(id)) || id; return line.replace(/^\s*[-|#*]+\s*/, '').replace(/\s*\|\s*$/g, '').trim(); }); } export function projectArtifactPaths(project) { const feature = project.feature.id; const moduleSlug = `${project.feature.module_id.toLowerCase().replace('-', '')}-${project.feature.slug}`; const root = project.artifacts_root; return Object.freeze({ logDir: `${root}/output_logs/${feature}`, reportsDir: `${root}/output_logs/${feature}/reports`, specsDir: `${root}/specs/${feature}`, srsPath: `${root}/ipa-docs/srs/srs-${moduleSlug}.md`, bdPath: `${root}/ipa-docs/bd/bd-${moduleSlug}.md`, ddPath: `${root}/ipa-docs/dd/dd-${moduleSlug}.md`, testcasePath: `${root}/ipa-docs/testcase/testcase-${moduleSlug}.md`, }); }