credl-parser-evaluator
Version:
TypeScript-based CREDL Parser and Evaluator that processes CREDL files and outputs complete Intermediate Representations
1,075 lines • 106 kB
JavaScript
"use strict";
/**
* CREDL Schema Validator
*
* Validates CREDL file structure, types, and cross-references according to the
* CREDL v0.2 specification. Implements strict validation with fail-fast behavior.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SchemaValidator = void 0;
const CREDLTypes_1 = require("../types/CREDLTypes");
const validation_messages_1 = require("./validation-messages");
class SchemaValidator {
/**
* Validate a complete CREDL file
*/
static validate(credlFile, options) {
const opts = { ...this.DEFAULT_OPTIONS, ...options };
const errors = [];
const warnings = [];
try {
// Validate required core blocks
this.validateMetadata(credlFile.metadata, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
this.validateAssets(credlFile.assets, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
this.validateSpaces(credlFile.spaces, errors, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
this.validateAssumptions(credlFile.assumptions, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
this.validateModels(credlFile.models, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
this.validateSimulation(credlFile.simulation, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
this.validateOutputs(credlFile.outputs, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
// Validate optional first-class blocks (per Behavioral Specification 8.1)
if (credlFile.scenarios) {
this.validateScenarios(credlFile.scenarios, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
}
if (credlFile.waterfall) {
this.validateWaterfall(credlFile.waterfall, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
}
// Validate legacy extension block if present (backward compatibility)
if (credlFile.extensions) {
this.validateExtensions(credlFile.extensions, errors, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
}
if (credlFile.presets) {
this.validatePresets(credlFile.presets, errors, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
}
if (credlFile.templates) {
this.validateTemplates(credlFile.templates, errors, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
}
// Validate cross-references
if (opts.checkCrossReferences) {
this.validateCrossReferences(credlFile, errors, warnings, opts);
if (opts.failFast && errors.length > 0) {
return { isValid: false, errors, warnings };
}
}
return {
isValid: errors.length === 0,
errors,
warnings
};
}
catch (error) {
// Enhanced validation error handling with categorized messages
let errorMessage = validation_messages_1.VALIDATION_MESSAGES.GENERAL.VALIDATION_FAILED;
let helpText = validation_messages_1.VALIDATION_HELP.GENERAL.VALIDATION_ERRORS;
if (error instanceof Error) {
// Categorize common validation errors
if (error.message.includes('circular') || error.message.includes('reference')) {
errorMessage = 'Circular reference detected during validation';
helpText = 'Remove circular references between blocks (e.g., space → assumption → space).';
}
else if (error.message.includes('memory') || error.message.includes('heap')) {
errorMessage = 'File too complex for validation';
helpText = 'Consider breaking large files into smaller components or reducing cross-references.';
}
else if (error.message.includes('stack') || error.message.includes('recursion')) {
errorMessage = 'Maximum validation depth exceeded';
helpText = 'Reduce nesting levels or circular references in your CREDL structure.';
}
else {
errorMessage = validation_messages_1.VALIDATION_MESSAGES.GENERAL.UNEXPECTED_ERROR(error.message);
helpText = validation_messages_1.VALIDATION_HELP.GENERAL.VALIDATION_ERRORS;
}
}
errors.push({
field: 'validation',
message: errorMessage,
severity: 'error',
help: helpText
});
return { isValid: false, errors, warnings };
}
}
/**
* Validate metadata block
*/
static validateMetadata(metadata, errors, warnings, _opts) {
if (!metadata) {
errors.push({
field: 'metadata',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.MISSING_BLOCK,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.VERSION_FORMAT
});
return;
}
// Version validation per behavioral specification
// Parser version 1.0 supports CREDL specification versions 0.1 and 0.2
const SUPPORTED_CREDL_VERSIONS = { min: 0.1, max: 0.2 };
if (typeof metadata.version !== 'string' || metadata.version.trim().length === 0) {
errors.push({
field: 'metadata.version',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.MISSING_VERSION,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.VERSION_FORMAT
});
}
else {
// Parse CREDL specification version string and validate compatibility
const credlSpecVersion = parseFloat(metadata.version);
if (isNaN(credlSpecVersion)) {
errors.push({
field: 'metadata.version',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.INVALID_VERSION,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.VERSION_FORMAT
});
}
else if (credlSpecVersion < SUPPORTED_CREDL_VERSIONS.min) {
errors.push({
field: 'metadata.version',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.UNSUPPORTED_VERSION(metadata.version, SUPPORTED_CREDL_VERSIONS.min.toString()),
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.VERSION_FORMAT
});
}
else if (credlSpecVersion > SUPPORTED_CREDL_VERSIONS.max) {
// Future CREDL specification versions generate warnings but don't stop processing
warnings.push({
field: 'metadata.version',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.NEWER_VERSION(metadata.version, SUPPORTED_CREDL_VERSIONS.max.toString()),
severity: 'warning',
help: validation_messages_1.VALIDATION_HELP.METADATA.VERSION_FORMAT
});
}
// Versions 0.1 and 0.2 are fully supported - no warnings
}
// Name validation
if (typeof metadata.name !== 'string' || metadata.name.trim().length === 0) {
errors.push({
field: 'metadata.name',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.MISSING_NAME,
severity: 'error'
});
}
if (typeof metadata.description !== 'string' || metadata.description.trim().length === 0) {
errors.push({
field: 'metadata.description',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.MISSING_DESCRIPTION,
severity: 'error'
});
}
// New required field per behavioral specification
if (typeof metadata.analysis_start_date !== 'string' || metadata.analysis_start_date.trim().length === 0) {
errors.push({
field: 'metadata.analysis_start_date',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.MISSING_ANALYSIS_START,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.ANALYSIS_START
});
}
else {
// Validate ISO 8601 date format (YYYY-MM-DD)
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(metadata.analysis_start_date)) {
errors.push({
field: 'metadata.analysis_start_date',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.INVALID_DATE_FORMAT,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.DATE_FORMAT
});
}
else {
// Validate it's a real date
const date = new Date(metadata.analysis_start_date);
if (isNaN(date.getTime())) {
errors.push({
field: 'metadata.analysis_start_date',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.INVALID_DATE,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.DATE_FORMAT
});
}
}
}
if (typeof metadata.created_date !== 'string' || metadata.created_date.trim().length === 0) {
errors.push({
field: 'metadata.created_date',
message: 'created_date is required and must be a non-empty string',
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.CREATED_DATE
});
}
else {
// Validate ISO 8601 date format (YYYY-MM-DD)
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(metadata.created_date)) {
errors.push({
field: 'metadata.created_date',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.INVALID_DATE_FORMAT,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.DATE_FORMAT
});
}
else {
// Validate it's a real date
const date = new Date(metadata.created_date);
if (isNaN(date.getTime())) {
errors.push({
field: 'metadata.created_date',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.INVALID_DATE,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.DATE_FORMAT
});
}
}
}
// Optional fields validation
if (metadata.modified_date !== undefined) {
if (typeof metadata.modified_date !== 'string') {
errors.push({
field: 'metadata.modified_date',
message: 'modified_date must be a string if provided',
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.DATE_FORMAT
});
}
else {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(metadata.modified_date)) {
errors.push({
field: 'metadata.modified_date',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.INVALID_DATE_FORMAT,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.DATE_FORMAT
});
}
else {
// Validate it's a real date
const date = new Date(metadata.modified_date);
if (isNaN(date.getTime())) {
errors.push({
field: 'metadata.modified_date',
message: validation_messages_1.VALIDATION_MESSAGES.METADATA.INVALID_DATE,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.METADATA.DATE_FORMAT
});
}
}
}
}
if (metadata.author && typeof metadata.author !== 'string') {
errors.push({
field: 'metadata.author',
message: 'author must be a string if provided',
severity: 'error'
});
}
}
/**
* Validate assets block with enhanced error handling
*/
static validateAssets(assets, errors, warnings, opts) {
if (!Array.isArray(assets)) {
errors.push({
field: 'assets',
message: validation_messages_1.VALIDATION_MESSAGES.ASSETS.MISSING_ARRAY,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.ASSETS.REQUIRED_FIELDS
});
return;
}
if (assets.length === 0) {
errors.push({
field: 'assets',
message: validation_messages_1.VALIDATION_MESSAGES.ASSETS.EMPTY_ARRAY,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.ASSETS.REQUIRED_FIELDS
});
return;
}
const assetIds = new Set();
for (let i = 0; i < assets.length; i++) {
const asset = assets[i];
const fieldPrefix = `assets[${i}]`;
if (!asset || typeof asset !== 'object') {
errors.push({
field: fieldPrefix,
message: 'asset must be an object',
severity: 'error'
});
continue;
}
// Enhanced field validation with specific error messages and help text
if (typeof asset.id !== 'string' || asset.id.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.id`,
message: validation_messages_1.VALIDATION_MESSAGES.ASSETS.MISSING_ID,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.ASSETS.REQUIRED_FIELDS
});
}
else {
// Check for duplicate IDs
if (assetIds.has(asset.id)) {
errors.push({
field: `${fieldPrefix}.id`,
message: validation_messages_1.VALIDATION_MESSAGES.ASSETS.DUPLICATE_ID(asset.id),
severity: 'error',
help: 'Each asset must have a unique identifier. Change the id field to a unique value.'
});
}
else {
assetIds.add(asset.id);
}
}
if (typeof asset.name !== 'string' || asset.name.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.name`,
message: validation_messages_1.VALIDATION_MESSAGES.ASSETS.MISSING_NAME,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.ASSETS.REQUIRED_FIELDS
});
}
// Enhanced property_type validation with actionable guidance
if (!Object.values(CREDLTypes_1.PropertyType).includes(asset.property_type)) {
errors.push({
field: `${fieldPrefix}.property_type`,
message: validation_messages_1.VALIDATION_MESSAGES.ASSETS.INVALID_PROPERTY_TYPE(Object.values(CREDLTypes_1.PropertyType)),
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.ASSETS.PROPERTY_TYPES
});
}
if (typeof asset.location !== 'string' || asset.location.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.location`,
message: validation_messages_1.VALIDATION_MESSAGES.ASSETS.MISSING_LOCATION,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.ASSETS.REQUIRED_FIELDS
});
}
if (typeof asset.total_area_sf !== 'number' || asset.total_area_sf <= 0) {
errors.push({
field: `${fieldPrefix}.total_area_sf`,
message: validation_messages_1.VALIDATION_MESSAGES.ASSETS.MISSING_AREA,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.ASSETS.REQUIRED_FIELDS
});
}
// Validate buildings array
if (!Array.isArray(asset.buildings)) {
errors.push({
field: `${fieldPrefix}.buildings`,
message: 'buildings must be an array',
severity: 'error'
});
}
else if (asset.buildings.length === 0) {
errors.push({
field: `${fieldPrefix}.buildings`,
message: 'at least one building is required',
severity: 'error'
});
}
else {
this.validateBuildings(asset.buildings, `${fieldPrefix}.buildings`, errors, opts);
// Asset area validation per behavioral specification (10% tolerance for common areas)
if (typeof asset.total_area_sf === 'number' && asset.total_area_sf > 0) {
const totalBuildingArea = asset.buildings
.filter((b) => b && typeof b.total_area_sf === 'number')
.reduce((sum, b) => sum + (b.total_area_sf || 0), 0);
if (totalBuildingArea > 0) {
const ratio = totalBuildingArea / asset.total_area_sf;
if (ratio > 1.1) { // >10% over asset area
warnings.push({
field: `${fieldPrefix}.total_area_sf`,
message: `Building areas (${totalBuildingArea.toLocaleString()} SF) exceed asset area by ${((ratio - 1) * 100).toFixed(1)}%. Consider accounting for common areas.`,
severity: 'warning'
});
}
else if (ratio < 0.5) { // <50% of asset area - unusually low
warnings.push({
field: `${fieldPrefix}.total_area_sf`,
message: `Building areas (${totalBuildingArea.toLocaleString()} SF) are only ${(ratio * 100).toFixed(1)}% of asset area (${asset.total_area_sf.toLocaleString()} SF). This seems unusually low.`,
severity: 'warning'
});
}
}
}
}
}
}
/**
* Validate buildings within an asset
*/
static validateBuildings(buildings, fieldPrefix, errors, _opts) {
const buildingIds = new Set();
for (let i = 0; i < buildings.length; i++) {
const building = buildings[i];
const buildingFieldPrefix = `${fieldPrefix}[${i}]`;
if (!building || typeof building !== 'object') {
errors.push({
field: buildingFieldPrefix,
message: 'building must be an object',
severity: 'error'
});
continue;
}
// Validate required fields
if (typeof building.id !== 'string' || building.id.trim().length === 0) {
errors.push({
field: `${buildingFieldPrefix}.id`,
message: 'id is required and must be a non-empty string',
severity: 'error'
});
}
else {
// Check for duplicate building IDs
if (buildingIds.has(building.id)) {
errors.push({
field: `${buildingFieldPrefix}.id`,
message: `duplicate building id: ${building.id}`,
severity: 'error'
});
}
else {
buildingIds.add(building.id);
}
}
if (typeof building.name !== 'string' || building.name.trim().length === 0) {
errors.push({
field: `${buildingFieldPrefix}.name`,
message: 'name is required and must be a non-empty string',
severity: 'error'
});
}
// Validate optional numeric fields
if (building.floors !== undefined && (typeof building.floors !== 'number' || building.floors <= 0)) {
errors.push({
field: `${buildingFieldPrefix}.floors`,
message: 'floors must be a positive number if provided',
severity: 'error'
});
}
if (building.total_area_sf !== undefined && (typeof building.total_area_sf !== 'number' || building.total_area_sf <= 0)) {
errors.push({
field: `${buildingFieldPrefix}.total_area_sf`,
message: 'total_area_sf must be a positive number if provided',
severity: 'error'
});
}
if (building.year_built !== undefined && (typeof building.year_built !== 'number' || building.year_built < 1800 || building.year_built > new Date().getFullYear() + 10)) {
errors.push({
field: `${buildingFieldPrefix}.year_built`,
message: 'year_built must be a reasonable year if provided',
severity: 'error'
});
}
}
}
/**
* Validate spaces with field exclusivity and conditional requirements
*/
static validateSpaces(spaces, errors, opts) {
if (!Array.isArray(spaces)) {
errors.push({
field: 'spaces',
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.MISSING_ARRAY,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.REQUIRED_FIELDS
});
return;
}
const spaceIds = new Set();
for (let i = 0; i < spaces.length; i++) {
const space = spaces[i];
const fieldPrefix = `spaces[${i}]`;
if (!space || typeof space !== 'object') {
errors.push({
field: fieldPrefix,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.INVALID_SPACE,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.REQUIRED_FIELDS
});
continue;
}
// Validate required fields
if (typeof space.id !== 'string' || space.id.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.id`,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.MISSING_ID,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.REQUIRED_FIELDS
});
}
else {
// Check for duplicate space IDs
if (spaceIds.has(space.id)) {
errors.push({
field: `${fieldPrefix}.id`,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.DUPLICATE_ID(space.id),
severity: 'error',
help: 'Each space must have a unique identifier across your entire CREDL file'
});
}
spaceIds.add(space.id);
}
if (typeof space.parent_building !== 'string' || space.parent_building.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.parent_building`,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.MISSING_PARENT_BUILDING,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.PARENT_BUILDING
});
}
if (!Object.values(CREDLTypes_1.SpaceType).includes(space.type)) {
errors.push({
field: `${fieldPrefix}.type`,
message: `type must be one of: ${Object.values(CREDLTypes_1.SpaceType).join(', ')}`,
severity: 'error'
});
}
if (typeof space.area_sf !== 'number' || space.area_sf <= 0) {
errors.push({
field: `${fieldPrefix}.area_sf`,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.MISSING_AREA,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.REQUIRED_FIELDS
});
}
// Validate auto-populated source_template field cannot be manually specified
if (space.source_template !== undefined) {
errors.push({
field: `${fieldPrefix}.source_template`,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.AUTO_POPULATED_FIELD,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.SOURCE_TEMPLATE
});
}
// Validate field exclusivity: prevent conflicts between preset references and direct field specifications
// This ensures spaces use either preset values OR direct values, not both which would create ambiguity
if (space.preset_lease && space.lease) {
errors.push({
field: `${fieldPrefix}`,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.PRESET_LEASE_CONFLICT,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.PRESET_CONFLICTS
});
}
if (space.preset_expense && space.expense) {
errors.push({
field: `${fieldPrefix}`,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.PRESET_EXPENSE_CONFLICT,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.PRESET_CONFLICTS
});
}
// Validate lease information (behavioral specification 4.1 & 4.2)
// Spaces must have either direct blocks OR use preset references
const hasDirectLease = !!space.lease;
const hasDirectLeaseAssumptions = !!space.lease_assumptions;
const hasPresetReferences = !!(space.preset_lease || space.preset_expenses);
// Check if space has neither direct fields nor preset references
if (!hasDirectLease && !hasDirectLeaseAssumptions && !hasPresetReferences) {
errors.push({
field: fieldPrefix,
message: validation_messages_1.VALIDATION_MESSAGES.SPACES.MISSING_LEASE_INFO,
severity: 'error',
help: validation_messages_1.VALIDATION_HELP.SPACES.LEASE_OPTIONS
});
}
// Validate direct lease block if present
if (hasDirectLease) {
this.validateLease(space.lease, `${fieldPrefix}.lease`, errors, opts);
}
// Validate direct lease_assumptions block if present
if (hasDirectLeaseAssumptions) {
this.validateLeaseAssumptions(space.lease_assumptions, `${fieldPrefix}.lease_assumptions`, errors, opts);
}
}
}
/**
* Validate lease information
*/
static validateLease(lease, fieldPrefix, errors, _opts) {
if (!lease || typeof lease !== 'object') {
errors.push({
field: fieldPrefix,
message: 'lease must be an object',
severity: 'error'
});
return;
}
// Validate required fields
if (!Object.values(CREDLTypes_1.LeaseStatus).includes(lease.status)) {
errors.push({
field: `${fieldPrefix}.status`,
message: `status must be one of: ${Object.values(CREDLTypes_1.LeaseStatus).join(', ')}`,
severity: 'error'
});
}
if (typeof lease.rent_psf !== 'number' || lease.rent_psf < 0) {
errors.push({
field: `${fieldPrefix}.rent_psf`,
message: 'rent_psf is required and must be a non-negative number',
severity: 'error'
});
}
if (!Object.values(CREDLTypes_1.LeaseType).includes(lease.lease_type)) {
errors.push({
field: `${fieldPrefix}.lease_type`,
message: `lease_type must be one of: ${Object.values(CREDLTypes_1.LeaseType).join(', ')}`,
severity: 'error'
});
}
// Conditional field requirements based on lease status
// Leased spaces require tenant information, vacant spaces should not have tenant specified
if (lease.status === CREDLTypes_1.LeaseStatus.LEASED) {
// Required fields for leased spaces
if (typeof lease.tenant !== 'string' || lease.tenant.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.tenant`,
message: 'tenant is required for leased spaces and must be a non-empty string',
severity: 'error'
});
}
}
else if (lease.status === CREDLTypes_1.LeaseStatus.VACANT) {
// For vacant spaces, tenant should not be specified
if (lease.tenant !== undefined) {
errors.push({
field: `${fieldPrefix}.tenant`,
message: 'tenant field is not allowed for vacant spaces',
severity: 'error'
});
}
}
// Validate optional escalation_rate
if (lease.escalation_rate !== undefined) {
if (typeof lease.escalation_rate !== 'number') {
errors.push({
field: `${fieldPrefix}.escalation_rate`,
message: 'escalation_rate must be a number if provided',
severity: 'error'
});
}
}
// Validate renewal_probability (behavioral specification 4.3)
if (lease.renewal_probability !== undefined) {
if (typeof lease.renewal_probability !== 'number' || lease.renewal_probability < 0 || lease.renewal_probability > 1) {
errors.push({
field: `${fieldPrefix}.renewal_probability`,
message: 'renewal_probability must be a number between 0 and 1 if provided',
severity: 'error'
});
}
}
// Validate optional financial fields
if (lease.security_deposit !== undefined) {
if (typeof lease.security_deposit !== 'number' || lease.security_deposit < 0) {
errors.push({
field: `${fieldPrefix}.security_deposit`,
message: 'security_deposit must be a non-negative number if provided',
severity: 'error'
});
}
}
if (lease.tenant_improvements !== undefined) {
if (typeof lease.tenant_improvements !== 'number' || lease.tenant_improvements < 0) {
errors.push({
field: `${fieldPrefix}.tenant_improvements`,
message: 'tenant_improvements must be a non-negative number if provided',
severity: 'error'
});
}
}
if (lease.leasing_commissions !== undefined) {
if (typeof lease.leasing_commissions !== 'number' || lease.leasing_commissions < 0) {
errors.push({
field: `${fieldPrefix}.leasing_commissions`,
message: 'leasing_commissions must be a non-negative number if provided',
severity: 'error'
});
}
}
// start_date and end_date are only required for leased spaces (behavioral specification 4.1)
if (lease.status === CREDLTypes_1.LeaseStatus.LEASED) {
if (typeof lease.start_date !== 'string' || lease.start_date.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.start_date`,
message: 'start_date is required for leased spaces and must be a non-empty string',
severity: 'error'
});
}
if (typeof lease.end_date !== 'string' || lease.end_date.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.end_date`,
message: 'end_date is required for leased spaces and must be a non-empty string',
severity: 'error'
});
}
}
else {
// For vacant/proposed spaces, dates are optional market assumptions (behavioral specification 4.2)
if (lease.start_date !== undefined && (typeof lease.start_date !== 'string' || lease.start_date.trim().length === 0)) {
errors.push({
field: `${fieldPrefix}.start_date`,
message: 'start_date must be a non-empty string if provided',
severity: 'error'
});
}
if (lease.end_date !== undefined && (typeof lease.end_date !== 'string' || lease.end_date.trim().length === 0)) {
errors.push({
field: `${fieldPrefix}.end_date`,
message: 'end_date must be a non-empty string if provided',
severity: 'error'
});
}
}
}
/**
* Validate lease assumptions
*/
static validateLeaseAssumptions(assumptions, fieldPrefix, errors, _opts) {
if (!assumptions || typeof assumptions !== 'object') {
errors.push({
field: fieldPrefix,
message: 'lease_assumptions must be an object',
severity: 'error'
});
return;
}
if (typeof assumptions.rent_psf !== 'number' || assumptions.rent_psf < 0) {
errors.push({
field: `${fieldPrefix}.rent_psf`,
message: 'rent_psf is required and must be a non-negative number',
severity: 'error'
});
}
if (!Object.values(CREDLTypes_1.LeaseType).includes(assumptions.lease_type)) {
errors.push({
field: `${fieldPrefix}.lease_type`,
message: `lease_type must be one of: ${Object.values(CREDLTypes_1.LeaseType).join(', ')}`,
severity: 'error'
});
}
// Validate optional leasing timeline assumptions (behavioral specification 4.2)
if (assumptions.expected_turnover_months !== undefined) {
if (typeof assumptions.expected_turnover_months !== 'number' || assumptions.expected_turnover_months < 0) {
errors.push({
field: `${fieldPrefix}.expected_turnover_months`,
message: 'expected_turnover_months must be a non-negative number if provided',
severity: 'error'
});
}
}
if (assumptions.absorption_sf_per_month !== undefined) {
if (typeof assumptions.absorption_sf_per_month !== 'number' || assumptions.absorption_sf_per_month < 0) {
errors.push({
field: `${fieldPrefix}.absorption_sf_per_month`,
message: 'absorption_sf_per_month must be a non-negative number if provided',
severity: 'error'
});
}
}
// Validate optional renewal probability for future leases (behavioral specification 4.3)
if (assumptions.renewal_probability !== undefined) {
if (typeof assumptions.renewal_probability !== 'number' || assumptions.renewal_probability < 0 || assumptions.renewal_probability > 1) {
errors.push({
field: `${fieldPrefix}.renewal_probability`,
message: 'renewal_probability must be a number between 0 and 1 if provided',
severity: 'error'
});
}
}
}
/**
* Validate assumptions block with scope resolution and conflict detection
*/
static validateAssumptions(assumptions, errors, warnings, opts) {
if (!Array.isArray(assumptions)) {
errors.push({
field: 'assumptions',
message: 'assumptions must be an array',
severity: 'error'
});
return;
}
const scopeMap = new Map(); // scope -> assumption names
for (let i = 0; i < assumptions.length; i++) {
const assumption = assumptions[i];
const fieldPrefix = `assumptions[${i}]`;
if (!assumption || typeof assumption !== 'object') {
errors.push({
field: fieldPrefix,
message: 'assumption must be an object',
severity: 'error'
});
continue;
}
// Validate required fields
if (typeof assumption.name !== 'string' || assumption.name.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.name`,
message: 'name is required and must be a non-empty string',
severity: 'error'
});
}
// Validate assumption type
if (!Object.values(CREDLTypes_1.AssumptionType).includes(assumption.type)) {
errors.push({
field: `${fieldPrefix}.type`,
message: `type must be one of: ${Object.values(CREDLTypes_1.AssumptionType).join(', ')}`,
severity: 'error'
});
continue;
}
// Validate scope format and apply defaults
this.validateAssumptionScope(assumption, fieldPrefix, errors, warnings);
// Validate tag taxonomy when tags are provided
this.validateAssumptionTags(assumption, fieldPrefix, errors, warnings);
// Track name+scope combinations for conflict detection
const normalizedScope = this.normalizeAssumptionScope(assumption.scope);
const scopeKey = `${assumption.name}@${normalizedScope}`;
if (scopeMap.has(scopeKey)) {
// Duplicate name+scope combination found
errors.push({
field: `${fieldPrefix}`,
message: `Scope conflict detected: Assumption '${assumption.name}' is defined multiple times for scope '${normalizedScope}'`,
severity: 'error'
});
}
else {
scopeMap.set(scopeKey, [assumption.name]);
}
// Type-specific validation
this.validateAssumptionByType(assumption, fieldPrefix, errors, opts);
}
// Check for assumptions targeting the same scope
// this.validateAssumptionScopeConflicts(scopeMap, errors); // Now handled inline above
}
/**
* Validate assumption based on its type
*/
static validateAssumptionByType(assumption, fieldPrefix, errors, opts) {
if ((0, CREDLTypes_1.isFixedAssumption)(assumption)) {
this.validateFixedAssumption(assumption, fieldPrefix, errors, opts);
}
else if ((0, CREDLTypes_1.isDistributionAssumption)(assumption)) {
this.validateDistributionAssumption(assumption, fieldPrefix, errors, opts);
}
else if ((0, CREDLTypes_1.isExpressionAssumption)(assumption)) {
this.validateExpressionAssumption(assumption, fieldPrefix, errors, opts);
}
else if ((0, CREDLTypes_1.isTableAssumption)(assumption)) {
this.validateTableAssumption(assumption, fieldPrefix, errors, opts);
}
}
/**
* Validate fixed assumption
*/
static validateFixedAssumption(assumption, fieldPrefix, errors, _opts) {
if (typeof assumption.value !== 'number') {
errors.push({
field: `${fieldPrefix}.value`,
message: 'value is required and must be a number for fixed assumptions',
severity: 'error'
});
}
}
/**
* Validate distribution assumption
*/
static validateDistributionAssumption(assumption, fieldPrefix, errors, _opts) {
if (!Object.values(CREDLTypes_1.DistributionType).includes(assumption.distribution)) {
errors.push({
field: `${fieldPrefix}.distribution`,
message: `distribution must be one of: ${Object.values(CREDLTypes_1.DistributionType).join(', ')}`,
severity: 'error'
});
}
if (!assumption.parameters || typeof assumption.parameters !== 'object') {
errors.push({
field: `${fieldPrefix}.parameters`,
message: 'parameters is required and must be an object for distribution assumptions',
severity: 'error'
});
return;
}
// Validate parameters based on distribution type
switch (assumption.distribution) {
case CREDLTypes_1.DistributionType.NORMAL:
if (typeof assumption.parameters.mean !== 'number') {
errors.push({
field: `${fieldPrefix}.parameters.mean`,
message: 'mean is required for normal distribution',
severity: 'error'
});
}
if (typeof assumption.parameters.stddev !== 'number' || assumption.parameters.stddev <= 0) {
errors.push({
field: `${fieldPrefix}.parameters.stddev`,
message: 'stddev is required and must be positive for normal distribution',
severity: 'error'
});
}
break;
case CREDLTypes_1.DistributionType.UNIFORM:
if (typeof assumption.parameters.min !== 'number') {
errors.push({
field: `${fieldPrefix}.parameters.min`,
message: 'min is required for uniform distribution',
severity: 'error'
});
}
if (typeof assumption.parameters.max !== 'number') {
errors.push({
field: `${fieldPrefix}.parameters.max`,
message: 'max is required for uniform distribution',
severity: 'error'
});
}
if (typeof assumption.parameters.min === 'number' && typeof assumption.parameters.max === 'number' &&
assumption.parameters.min >= assumption.parameters.max) {
errors.push({
field: `${fieldPrefix}.parameters`,
message: 'min must be less than max for uniform distribution',
severity: 'error'
});
}
break;
case CREDLTypes_1.DistributionType.TRIANGULAR:
if (typeof assumption.parameters.min !== 'number') {
errors.push({
field: `${fieldPrefix}.parameters.min`,
message: 'min is required for triangular distribution',
severity: 'error'
});
}
if (typeof assumption.parameters.max !== 'number') {
errors.push({
field: `${fieldPrefix}.parameters.max`,
message: 'max is required for triangular distribution',
severity: 'error'
});
}
if (typeof assumption.parameters.mode !== 'number') {
errors.push({
field: `${fieldPrefix}.parameters.mode`,
message: 'mode is required for triangular distribution',
severity: 'error'
});
}
break;
}
}
/**
* Validate expression assumption
*/
static validateExpressionAssumption(assumption, fieldPrefix, errors, _opts) {
if (typeof assumption.formula !== 'string' || assumption.formula.trim().length === 0) {
errors.push({
field: `${fieldPrefix}.formula`,
message: 'formula is required and must be a non-empty string for expression assumptions',
severity: 'error'
});
}
}
/**
* Validate table assumption
*/
static validateTableAssumption(assumption, fieldPrefix, errors, _opts) {
if (!Array.isArray(assumption.values)) {
errors.push({
field: `${fieldPrefix}.values`,
message: 'values is required and must be an array for table assumptions',
severity: 'error'
});
return;
}
if (assumption.values.length === 0) {
errors.push({
field: `${fieldPrefix}.values`,
message: 'values array cannot be empty for table assumptions',
severity: 'error'
});
return;
}
for (let i = 0; i < assumption.values.length; i++) {
const value = assumption.values[i];
const valueFieldPrefix = `${fieldPrefix}.values[${i}]`;
if (!value || typeof value !== 'object') {
errors.push({
field: valueFieldPrefix,
message: 'table value must be an object',
severity: 'error'
});
continue;
}
if (typeof value.name !== 'string' || value.name.trim().length === 0) {
errors.push({
field: `${valueFieldPrefix}.name`,
message: 'name is required and must be a non-empty string for table values',
severity: 'error'
});
}
}
}
/**
* Validate models block per CREDL Behavioral Specification
*/
static validateModels(models, errors, warnings, _opts) {
if (!Array.isArray(models)) {
errors.push({
field: 'models',
message: 'models must be an array',
severity: 'error'
});
return;
}
if (models.length === 0) {
errors.push({
field: 'models',
message: 'at least one model is required',
severity: 'error'
});
return;
}
// Behavioral Specification 6.1: Exactly one model per CREDL file
if (models.length > 1) {
errors.push({
field: 'models'