Files
CASAN/scripts/casan-project.mjs
T

177 lines
8.5 KiB
JavaScript

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/level5/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`,
});
}