diff --git a/specs/capabilities/trade-show-signup.capability.yaml b/specs/capabilities/trade-show-signup.capability.yaml index 4006705..0c234de 100644 --- a/specs/capabilities/trade-show-signup.capability.yaml +++ b/specs/capabilities/trade-show-signup.capability.yaml @@ -13,6 +13,10 @@ scope: - specs/features/mobile-signup.feature models: - specs/models/account.model.yaml + - specs/models/account.lifecycle.yaml + - specs/models/trade-show-onboarding.aggregate.model.yaml + - specs/models/trade-show-signup.catalog.model.yaml + - specs/models/shared-value-objects.model.yaml contracts: - specs/contracts/openapi/api.yaml fixtures: diff --git a/specs/features/mobile-signup.feature b/specs/features/mobile-signup.feature index 71460f6..75f1058 100644 --- a/specs/features/mobile-signup.feature +++ b/specs/features/mobile-signup.feature @@ -1,7 +1,7 @@ # id: mobile-signup # type: feature # story: specs/stories/mobile-signup.story.md -# journey: specs/journeys/trade-show-signup.journey.md +# journey: specs/journeys/trade-show-signup.journey.md (steps 1, 2) # contract: POST /accounts Feature: Mobile signup diff --git a/specs/models/account.lifecycle.yaml b/specs/models/account.lifecycle.yaml new file mode 100644 index 0000000..f14274b --- /dev/null +++ b/specs/models/account.lifecycle.yaml @@ -0,0 +1,42 @@ +id: account-lifecycle +type: model +entity: Account +description: "State transitions for a trade-show account as signup completes." +sources: + stories: + - specs/stories/mobile-signup.story.md + - specs/stories/quick-start-audit.story.md + journeys: + - specs/journeys/trade-show-signup.journey.md + +initial_state: initialized + +states: + initialized: + description: "Prospect has opened the flow but has not submitted details." + terminal: false + + starting: + description: "Signup request is being processed." + terminal: false + + active: + description: "Account is ready for first-audit actions." + terminal: false + + abandoned: + description: "Prospect left before completing the signup." + terminal: true + +transitions: + submit_signup: + from: [initialized, starting] + to: [starting, active] + trigger: user + description: "The prospect submits or retries the signup form." + + abandon: + from: [initialized, starting] + to: abandoned + trigger: system + description: "The flow expires before account activation." diff --git a/specs/models/shared-value-objects.model.yaml b/specs/models/shared-value-objects.model.yaml new file mode 100644 index 0000000..d8415d8 --- /dev/null +++ b/specs/models/shared-value-objects.model.yaml @@ -0,0 +1,26 @@ +id: onboarding-shared-value-objects +type: model +description: "Reusable value objects shared across onboarding-oriented model examples." +sources: + stories: + - specs/stories/mobile-signup.story.md + - specs/stories/quick-start-audit.story.md + journeys: + - specs/journeys/trade-show-signup.journey.md + +value_objects: + boothCode: + value_object: BoothCode + description: "A booth-specific code that associates a signup with an event interaction." + attributes: + value: + type: string + required: true + + prospectContact: + value_object: ProspectContact + description: "Normalized contact details captured during signup." + attributes: + email: + type: email + required: true diff --git a/specs/models/trade-show-onboarding.aggregate.model.yaml b/specs/models/trade-show-onboarding.aggregate.model.yaml new file mode 100644 index 0000000..e84ec99 --- /dev/null +++ b/specs/models/trade-show-onboarding.aggregate.model.yaml @@ -0,0 +1,40 @@ +id: trade-show-onboarding +type: model +aggregate: TradeShowOnboardingAggregate +description: "Aggregate view of the signup conversation and immediate first-audit handoff." +sources: + stories: + - specs/stories/mobile-signup.story.md + - specs/stories/quick-start-audit.story.md + journeys: + - specs/journeys/trade-show-signup.journey.md + +entities: + account: + entity: Account + description: "The account created from the trade-show signup flow." + + firstAudit: + entity: Audit + description: "The first audit started directly after account activation." + +value_objects: + boothContext: + value_object: BoothContext + description: "Context carried through the booth conversation." + attributes: + boothCode: + type: string + required: true + representativeId: + type: string + required: true + +rules: + - id: first-audit-requires-active-account + description: "A quick-start audit can only begin after the account becomes active." + enforced: domain + + - id: booth-context-is-captured-on-signup + description: "Each signup retains booth metadata for follow-up and attribution." + enforced: domain diff --git a/specs/models/trade-show-signup.catalog.model.yaml b/specs/models/trade-show-signup.catalog.model.yaml new file mode 100644 index 0000000..2a70f74 --- /dev/null +++ b/specs/models/trade-show-signup.catalog.model.yaml @@ -0,0 +1,23 @@ +id: trade-show-signup-catalog +type: model +catalog: TradeShowSignupCatalog +description: "Shared catalog of fields reused across the booth signup examples." +sources: + stories: + - specs/stories/mobile-signup.story.md + journeys: + - specs/journeys/trade-show-signup.journey.md + +properties: + boothCode: + type: string + required: true + + acquisitionChannel: + type: enum + required: true + values: [qr-code, walk-up] + + consentCapturedAt: + type: datetime + required: false diff --git a/tools/validate-models.js b/tools/validate-models.js index d5a88bb..954d328 100644 --- a/tools/validate-models.js +++ b/tools/validate-models.js @@ -103,93 +103,241 @@ function validateModelFile(filePath) { return validateLifecycle(model, errors, warnings); } - return validateEntityModel(model, errors, warnings); + return validateModelDocument(model, errors, warnings); } -function validateEntityModel(model, errors, warnings) { - if (!model.entity && !model.value_object) { - errors.push('Model must have "entity" or "value_object" field'); - return { errors, warnings }; +function isObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function validateAttributeBlock(block, blockLabel, itemLabel, errors, warnings) { + if (!isObject(block)) { + errors.push(`${blockLabel} must be an object`); + return; } - const isEntity = Boolean(model.entity); + for (const [attrName, attr] of Object.entries(block)) { + if (!isObject(attr)) { + errors.push(`${itemLabel} "${attrName}" must be an object`); + continue; + } - if (isEntity) { - if (!model.description) { - warnings.push('Entity should have a description'); + if (!attr.type) { + errors.push(`${itemLabel} "${attrName}" missing type`); + } else if (!VALID_TYPES.includes(attr.type)) { + warnings.push(`${itemLabel} "${attrName}" has unknown type: ${attr.type}`); } - if (!model.identity) { - warnings.push('Entity should define identity field'); - } else { - if (!model.identity.field) { - errors.push('Identity must have a field name'); - } - if (!model.identity.type) { - warnings.push('Identity should specify type'); - } + if (!/^[a-z][a-zA-Z0-9]*$/.test(attrName)) { + warnings.push(`${itemLabel} "${attrName}" should be camelCase`); } + } +} - if (model.attributes && typeof model.attributes === 'object') { - for (const [attrName, attr] of Object.entries(model.attributes)) { - if (!attr || typeof attr !== 'object') { - errors.push(`Attribute "${attrName}" must be an object`); - continue; - } +function validateRelationshipBlock(relationships, errors, warnings) { + if (!isObject(relationships)) { + errors.push('Relationships must be an object'); + return; + } - if (!attr.type) { - errors.push(`Attribute "${attrName}" missing type`); - } else if (!VALID_TYPES.includes(attr.type)) { - warnings.push(`Attribute "${attrName}" has unknown type: ${attr.type}`); - } + for (const [relName, rel] of Object.entries(relationships)) { + if (!isObject(rel)) { + errors.push(`Relationship "${relName}" must be an object`); + continue; + } - if (!/^[a-z][a-zA-Z0-9]*$/.test(attrName)) { - warnings.push(`Attribute "${attrName}" should be camelCase`); - } - } + if (!rel.type) { + errors.push(`Relationship "${relName}" missing type`); + } else if (!VALID_RELATIONSHIP_TYPES.includes(rel.type)) { + warnings.push(`Relationship "${relName}" has unknown type: ${rel.type}`); } - if (model.relationships && typeof model.relationships === 'object') { - for (const [relName, rel] of Object.entries(model.relationships)) { - if (!rel || typeof rel !== 'object') { - errors.push(`Relationship "${relName}" must be an object`); - continue; - } + if (!rel.entity) { + errors.push(`Relationship "${relName}" missing target entity`); + } + } +} - if (!rel.type) { - errors.push(`Relationship "${relName}" missing type`); - } else if (!VALID_RELATIONSHIP_TYPES.includes(rel.type)) { - warnings.push(`Relationship "${relName}" has unknown type: ${rel.type}`); - } +function validateRulesBlock(rules, warnings) { + if (!Array.isArray(rules)) { + warnings.push('Business rules should be a list'); + return; + } + + for (const rule of rules) { + if (!isObject(rule)) { + warnings.push('Business rule entry should be an object'); + continue; + } + if (!rule.id) { + warnings.push('Business rule should have an id'); + } + if (!rule.description) { + warnings.push(`Rule "${rule.id || 'unknown'}" should have a description`); + } + } +} + +function validateSources(model, label, warnings) { + if (!model.sources || (!model.sources.stories && !model.sources.journeys)) { + warnings.push(`${label} should reference source stories or journeys`); + } +} + +function validateEntityModel(model, errors, warnings) { + if (!model.description) { + warnings.push('Entity should have a description'); + } + + if (!model.identity) { + warnings.push('Entity should define identity field'); + } else { + if (!model.identity.field) { + errors.push('Identity must have a field name'); + } + if (!model.identity.type) { + warnings.push('Identity should specify type'); + } + } + + if (model.attributes) { + validateAttributeBlock(model.attributes, 'Attributes', 'Attribute', errors, warnings); + } + + if (model.relationships) { + validateRelationshipBlock(model.relationships, errors, warnings); + } + + if (model.rules) { + validateRulesBlock(model.rules, warnings); + } + + validateSources(model, 'Entity', warnings); +} + +function validateAggregateModel(model, errors, warnings) { + if (typeof model.aggregate !== 'string' || model.aggregate.trim() === '') { + errors.push('Aggregate model must have a non-empty "aggregate" field'); + } + + if (!model.description) { + warnings.push('Aggregate should have a description'); + } + + if (!model.entities && !model.value_objects) { + warnings.push('Aggregate should define nested "entities" or "value_objects"'); + } - if (!rel.entity) { - errors.push(`Relationship "${relName}" missing target entity`); + if (model.entities) { + if (!isObject(model.entities)) { + errors.push('Aggregate "entities" must be an object'); + } else { + for (const [name, entity] of Object.entries(model.entities)) { + if (!isObject(entity)) { + errors.push(`Aggregate entity "${name}" must be an object`); + continue; + } + if (!entity.entity && !entity.ref) { + warnings.push(`Aggregate entity "${name}" should declare "entity" or "ref"`); } } } + } - if (Array.isArray(model.rules)) { - for (const rule of model.rules) { - if (!rule || typeof rule !== 'object') { - warnings.push('Business rule entry should be an object'); + if (model.value_objects) { + if (!isObject(model.value_objects)) { + errors.push('Aggregate "value_objects" must be an object'); + } else { + for (const [name, valueObject] of Object.entries(model.value_objects)) { + if (!isObject(valueObject)) { + errors.push(`Aggregate value object "${name}" must be an object`); continue; } - if (!rule.id) { - warnings.push('Business rule should have an id'); - } - if (!rule.description) { - warnings.push(`Rule "${rule.id || 'unknown'}" should have a description`); + if (!valueObject.value_object && !valueObject.ref && !valueObject.attributes && !valueObject.properties) { + warnings.push(`Aggregate value object "${name}" should declare "value_object", "ref", "attributes", or "properties"`); } } } + } + + if (model.rules) { + validateRulesBlock(model.rules, warnings); + } + + validateSources(model, 'Aggregate', warnings); +} + +function validateCatalogModel(model, errors, warnings) { + if (typeof model.catalog !== 'string' || model.catalog.trim() === '') { + errors.push('Catalog model must have a non-empty "catalog" field'); + } + + if (!model.properties && !model.entries && !model.items) { + warnings.push('Catalog should define "properties", "entries", or "items"'); + } + + if (model.properties) { + validateAttributeBlock(model.properties, 'Properties', 'Property', errors, warnings); + } + + if (model.entries && !isObject(model.entries)) { + errors.push('Catalog "entries" must be an object'); + } + + if (model.items && !isObject(model.items)) { + errors.push('Catalog "items" must be an object'); + } + + validateSources(model, 'Catalog', warnings); +} + +function validateValueObjectBundle(model, errors, warnings) { + if (!isObject(model.value_objects)) { + errors.push('"value_objects" must be an object when used as a shared bundle'); + return; + } + + for (const [name, valueObject] of Object.entries(model.value_objects)) { + if (!isObject(valueObject)) { + errors.push(`Shared value object "${name}" must be an object`); + continue; + } - if (!model.sources || (!model.sources.stories && !model.sources.journeys)) { - warnings.push('Entity should reference source stories or journeys'); + if (!valueObject.value_object && !valueObject.attributes && !valueObject.properties) { + warnings.push(`Shared value object "${name}" should declare "value_object", "attributes", or "properties"`); } - } else if (!model.type) { - errors.push('Value object must have a type'); } + validateSources(model, 'Shared value object bundle', warnings); +} + +function validateModelDocument(model, errors, warnings) { + if (model.entity) { + validateEntityModel(model, errors, warnings); + return { errors, warnings }; + } + + if (model.value_object) { + validateSources(model, 'Value object', warnings); + return { errors, warnings }; + } + + if (model.aggregate) { + validateAggregateModel(model, errors, warnings); + return { errors, warnings }; + } + + if (model.catalog) { + validateCatalogModel(model, errors, warnings); + return { errors, warnings }; + } + + if (model.value_objects) { + validateValueObjectBundle(model, errors, warnings); + return { errors, warnings }; + } + + errors.push('Model must have one of "entity", "value_object", "aggregate", "catalog", or "value_objects"'); return { errors, warnings }; } @@ -228,18 +376,27 @@ function validateLifecycle(model, errors, warnings) { if (!transition.from) { errors.push(`Transition "${transitionName}" missing "from" state`); - } else if (!stateNames.includes(transition.from)) { - errors.push(`Transition "${transitionName}" references unknown "from" state: ${transition.from}`); + } else { + const fromStates = Array.isArray(transition.from) ? transition.from : [transition.from]; + const unknownFromStates = fromStates.filter((state) => !stateNames.includes(state)); + if (unknownFromStates.length > 0) { + errors.push(`Transition "${transitionName}" references unknown "from" state(s): ${unknownFromStates.join(', ')}`); + } + + const terminalOrigins = fromStates.filter((state) => model.states[state] && model.states[state].terminal); + if (terminalOrigins.length > 0) { + warnings.push(`Transition "${transitionName}" originates from terminal state(s): ${terminalOrigins.join(', ')}`); + } } if (!transition.to) { errors.push(`Transition "${transitionName}" missing "to" state`); - } else if (!stateNames.includes(transition.to)) { - errors.push(`Transition "${transitionName}" references unknown "to" state: ${transition.to}`); - } - - if (transition.from && model.states[transition.from] && model.states[transition.from].terminal) { - warnings.push(`Transition "${transitionName}" originates from terminal state "${transition.from}"`); + } else { + const toStates = Array.isArray(transition.to) ? transition.to : [transition.to]; + const unknownToStates = toStates.filter((state) => !stateNames.includes(state)); + if (unknownToStates.length > 0) { + errors.push(`Transition "${transitionName}" references unknown "to" state(s): ${unknownToStates.join(', ')}`); + } } } } diff --git a/tools/validate-traceability.js b/tools/validate-traceability.js index fdbadcb..c47cc6e 100755 --- a/tools/validate-traceability.js +++ b/tools/validate-traceability.js @@ -82,8 +82,14 @@ function isScopedRef(ref) { return typeof ref === 'string' && (rootPrefix === '' || ref.startsWith(`${rootPrefix}/`)); } +function normalizeReferencePath(ref) { + if (typeof ref !== 'string') return ref; + return ref.replace(/\s+\([^)]*\)\s*$/, ''); +} + function fileExistsIfScoped(ref) { - return !isScopedRef(ref) || fileExists(ref); + const normalizedRef = normalizeReferencePath(ref); + return !isScopedRef(normalizedRef) || fileExists(normalizedRef); } function filterFilesBySelection(files) {