177 lines
8.5 KiB
JavaScript
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`,
|
|
});
|
|
}
|