diff --git a/.github/actions/idd-check/action.yml b/.github/actions/idd-check/action.yml index 3f6c781..0c22a21 100644 --- a/.github/actions/idd-check/action.yml +++ b/.github/actions/idd-check/action.yml @@ -90,7 +90,7 @@ runs: # Determine which core checks to run CHECKS="${{ inputs.checks }}" if [ "$CHECKS" = "all" ]; then - CHECKS="traceability front-matter capability-scope fixtures models journey-maps" + CHECKS="contracts traceability front-matter capability-scope fixtures models journey-maps" fi # Run each core validator individually, capturing JSON per check @@ -118,6 +118,8 @@ runs: echo "openapi_exit=$OPENAPI_EXIT" >> "$GITHUB_OUTPUT" echo "openapi_ran=$OPENAPI_RAN" >> "$GITHUB_OUTPUT" + # AsyncAPI and JSON-RPC contract validation is covered by the core "contracts" validator. + # Run Gherkin lint if config and features exist GHERKIN_EXIT=0 GHERKIN_RAN=false @@ -163,6 +165,7 @@ runs: // ── Pretty names for checks ── const LABELS = { 'traceability': 'Traceability', + 'contracts': 'Contracts', 'front-matter': 'Front-Matter', 'capability-scope': 'Capability Scope', 'fixtures': 'Fixtures', diff --git a/.gitignore b/.gitignore index 414920e..06a77d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env .idea .DS_Store +.claude/settings.local.json build/ node_modules/ tools/node_modules/ diff --git a/README.md b/README.md index 2ce8281..9b043fa 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The result is a framework where AI agents can autonomously implement, verify, an ├─────────────────────────────────────────────────────────────┤ │ CONTRACT LAYER │ │ Features ◀── Contracts ──▶ Fixtures │ -│ (Gherkin) (OpenAPI) (test data) │ +│ (Gherkin) (OpenAPI / AsyncAPI / JSON-RPC) │ │ /behavior-contract │ ├──────────────────────────────────────────────────────────────┤ │ IMPLEMENTATION LAYER │ @@ -66,7 +66,9 @@ IDD breaks this into a traceable chain: | Narrative | `specs/stories/onboarding/mobile-signup.md` | Capability: quick mobile account creation | | Model | `specs/models/audit/audit.model.yaml` | Concept: Audit entity, states, rules | | Contract | `specs/features/onboarding/mobile-signup.feature` | Behavior: Gherkin scenarios | -| Contract | `specs/contracts/openapi/api.yaml` | API: `POST /accounts`, `POST /audits` | +| Contract | `specs/contracts/openapi/api.yaml` | HTTP boundary: `POST /accounts`, `POST /audits` | +| Contract | `specs/contracts/asyncapi/audit-events.yaml` | Event boundary: `publish audits/created` | +| Contract | `specs/contracts/json-rpc/account-service.yaml` | RPC boundary: `account.getQuickStartPrompt` | | Contract | `specs/fixtures/onboarding/mobile-signup.json` | Test data: request/response pairs | | Implementation | Backend + Frontend code | Derived from contracts | | Validation | `frontend/e2e/journeys/trade-show-signup.spec.ts` | E2E test following the journey | @@ -94,7 +96,7 @@ node tools/graph-generation/generate-spec-graph.js examples --format mermaid 1. **Intent precedes code.** No implementation without an explicit intent artifact. 2. **Shared mental models are artifacts, not conversations.** If a concept matters, it has a file. -3. **Contracts define reality at boundaries.** API contracts are the source of truth, not implementation. +3. **Contracts define reality at boundaries.** OpenAPI, AsyncAPI, and JSON-RPC contracts are the source of truth, not implementation. 4. **Assumptions must become executable.** Untested assumptions are technical debt. 5. **Feedback must be fast, honest, and automated.** Evidence, not confidence theater. 6. **Human cognition is protected.** Agents handle bookkeeping; humans handle meaning. @@ -190,7 +192,7 @@ The `idd-workflow` skill should load that overlay before picking backend/fronten |-------|---------|------------| | **Solution Narrative** | Personas, journeys, stories — the "why" | `/solution-narrative` | | **Domain Modeling** | Entities, aggregates, business rules | `/domain-modeling` | -| **Behavior Contract** | BDD features, OpenAPI contracts, fixtures | `/behavior-contract` | +| **Behavior Contract** | BDD features, OpenAPI/AsyncAPI/JSON-RPC contracts, fixtures | `/behavior-contract` | | **E2E Journey Testing** | Playwright tests from journey maps | `/e2e-journey-testing` | | **Certification** | Traceability verification and evidence manifests | `/certification` | | **IDD Workflow** | Meta-skill: when to use which skill | `/idd-workflow` | @@ -227,7 +229,7 @@ docs/idd/ IDD philosophy and concept library skills/ IDD methodology skills (bundled in package) ├── solution-narrative/ Personas, journeys, stories ├── domain-modeling/ Entities, aggregates, business rules -├── behavior-contract/ BDD features, OpenAPI contracts, fixtures +├── behavior-contract/ BDD features, protocol contracts, fixtures ├── e2e-journey-testing/ Playwright journey tests ├── certification/ Traceability verification and evidence └── idd-workflow/ Meta-skill: when to use which skill @@ -242,7 +244,8 @@ tools/ Validators and generators ├── validate-front-matter.js Validate required/recommended metadata fields ├── validate-traceability.js Validate cross-artifact reference integrity ├── validate-capability-scope.js Validate capability scope coverage -├── validate-fixtures.js Validate fixtures against OpenAPI request/response schemas +├── validate-contracts.js Validate OpenAPI, AsyncAPI, and JSON-RPC contracts +├── validate-fixtures.js Validate fixtures against protocol-specific contract schemas ├── validate-models.js Validate model/lifecycle structural rules ├── validate-journey-maps.js Validate journey map structural rules ├── generate-evidence.js Scaffold certification evidence manifests @@ -267,7 +270,7 @@ idd validate traceability front-matter --json idd validate fixtures models --strict ``` -Available validators: `traceability`, `front-matter`, `capability-scope`, `fixtures`, `models`, `journey-maps`, `evidence`. +Available validators: `contracts`, `traceability`, `front-matter`, `capability-scope`, `fixtures`, `models`, `journey-maps`, `evidence`. Common CLI options: - `--files ` limit checks to specific files diff --git a/bin/idd.js b/bin/idd.js index 5886fe3..5419517 100755 --- a/bin/idd.js +++ b/bin/idd.js @@ -38,6 +38,7 @@ if (commands[subcommand]) { function cmdValidate(argv) { const VALIDATORS = { + contracts: 'validate-contracts.js', traceability: 'validate-traceability.js', 'front-matter': 'validate-front-matter.js', 'capability-scope': 'validate-capability-scope.js', @@ -246,6 +247,8 @@ function cmdInit(argv) { 'specs/stories', 'specs/features', 'specs/contracts/openapi', + 'specs/contracts/asyncapi', + 'specs/contracts/json-rpc', 'specs/fixtures', 'specs/models', 'specs/capabilities', diff --git a/docs/idd/concepts.md b/docs/idd/concepts.md index a63c235..83e211e 100644 --- a/docs/idd/concepts.md +++ b/docs/idd/concepts.md @@ -33,12 +33,12 @@ knowledge. If a concept matters, it has a file. ## C3 — Contracts Define Reality at Boundaries -API contracts (OpenAPI, Gherkin features) are the authoritative source of truth +Boundary contracts (OpenAPI, AsyncAPI, JSON-RPC, plus Gherkin features) are the authoritative source of truth for how systems interact. Implementation must conform to the contract, not the other way around. **Manifesto principle**: 3 -**Artifacts involved**: features, contracts/openapi, fixtures +**Artifacts involved**: features, contracts/*, fixtures --- @@ -95,7 +95,7 @@ The capability groups artifacts into a certifiable scope. No link in the chain is optional. If an artifact exists, its provenance is declared. **Enforced via**: YAML front-matter (preferred), comment headers, -`x-story`/`x-feature`/`x-journey` OpenAPI extensions, `_meta` blocks in +`x-story`/`x-feature`/`x-journey` contract extensions, `_meta` blocks in fixtures, test file headers. See `docs/idd/front-matter-spec.md` for the uniform front-matter schema. diff --git a/docs/idd/front-matter-spec.md b/docs/idd/front-matter-spec.md index 8847a13..1b9329e 100644 --- a/docs/idd/front-matter-spec.md +++ b/docs/idd/front-matter-spec.md @@ -7,7 +7,7 @@ IDD artifacts use five different reference conventions parsed five different way | Artifact | Reference mechanism | |----------|-------------------| | Feature files | `# Story:` / `# Journey:` Gherkin comments | -| OpenAPI operations | `x-story` / `x-feature` / `x-journey` extensions | +| Contract operations | `x-story` / `x-feature` / `x-journey` extensions | | Fixtures | `_meta.story` / `_meta.feature` JSON blocks | | Journey maps | `sources.journey` / `sources.stories` YAML fields | | Stories/journeys | `Journey:` / `Persona:` embedded in markdown prose | @@ -199,7 +199,7 @@ sources: ## Relationship to capability artifacts -Capabilities are the highest-level grouping artifact. They define the scope of what gets certified. The capability's `scope` block is the authoritative enumeration of which artifacts belong together — it replaces the inline `intent` block that previously lived only in `certification/{capability}/evidence.yaml`. +Capabilities are the highest-level grouping artifact. They define the scope of what gets certified. The capability's `scope` block is the authoritative enumeration of which artifacts belong together, including OpenAPI, AsyncAPI, and JSON-RPC contracts. It replaces the inline `intent` block that previously lived only in `certification/{capability}/evidence.yaml`. See `docs/idd/certification-guide.md` for how capabilities relate to certification. diff --git a/docs/idd/project-template.md b/docs/idd/project-template.md index 5a07ff3..c6bd375 100644 --- a/docs/idd/project-template.md +++ b/docs/idd/project-template.md @@ -7,7 +7,7 @@ 3. `specs/stories/` 4. `specs/models/` 5. `specs/features/` (BDD) -6. `specs/contracts/openapi/` +6. `specs/contracts/openapi/`, `specs/contracts/asyncapi/`, and/or `specs/contracts/json-rpc/` 7. `specs/fixtures/` 8. `specs/journey-maps/` 9. `specs/capabilities/` (created as a stub after narrative, finalized after models/contracts/features exist) @@ -18,7 +18,7 @@ 1. Capture intent in persona + journey + story artifacts. 2. Create a capability stub with personas, journeys, and stories. 3. Model domain concepts, rules, and lifecycles. -4. Convert story acceptance criteria into BDD features + API contracts, then finalize the capability scope with models, features, and contracts. +4. Convert story acceptance criteria into BDD features + boundary contracts, then finalize the capability scope with models, features, and contracts. 5. Implement code from contracts and feature expectations. 6. Produce executable verification: unit, contract, e2e, regression. 7. Publish evidence under `certification/` before merge (`/certification`). diff --git a/package.json b/package.json index b5a5b1f..165fcc6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "bin": { "idd": "./bin/idd.js" }, + "scripts": { + "test": "node --test" + }, "files": [ "bin/", "tools/lib/", diff --git a/skills/behavior-contract/SKILL.md b/skills/behavior-contract/SKILL.md index 8d5fd3f..791477f 100644 --- a/skills/behavior-contract/SKILL.md +++ b/skills/behavior-contract/SKILL.md @@ -1,6 +1,6 @@ --- name: behavior-contract -description: "Convert solution narratives into BDD feature files and OpenAPI contracts. Use when translating user stories into testable specifications and API definitions. Consumes output from solution-narrative skill, produces artifacts consumed by architecture skills." +description: "Convert solution narratives into BDD feature files and protocol contracts. Use when translating user stories into testable specifications and API/event/RPC definitions. Consumes output from solution-narrative skill, produces artifacts consumed by architecture skills." argument-hint: "[feature-area or story]" allowed-tools: - Read @@ -20,7 +20,7 @@ Transform narrative artifacts into executable specifications and API contracts. 1. Review journey and story from specs/journeys/ and specs/stories/. 2. Write Gherkin feature files capturing behavior. 3. Identify API touchpoints from journey system responses. -4. Define or update OpenAPI contract. +4. Define or update the appropriate OpenAPI, AsyncAPI, or JSON-RPC contract. 5. Create fixtures for test data. 6. Finalize the capability scope by adding the relevant models, features, and contracts. 7. Ensure traceability: story → feature → contract → implementation. @@ -35,12 +35,12 @@ specs/ │ └── {feature-area}/ │ └── {feature-name}.feature ├── contracts/ -│ └── openapi/ -│ ├── api.yaml ← main spec -│ └── components/ -│ ├── schemas.yaml -│ ├── parameters.yaml -│ └── responses.yaml +│ ├── openapi/ +│ │ └── api.yaml ← HTTP boundary +│ ├── asyncapi/ +│ │ └── events.yaml ← event boundary +│ └── json-rpc/ +│ └── service.yaml ← RPC boundary └── fixtures/ └── {feature-area}/ └── {fixture-name}.json @@ -74,7 +74,7 @@ Before defining contract schemas, check specs/models/: ## Traceability Requirements - Feature files must reference the source story, journey, and contract at the top of the file. -- OpenAPI operations must include `x-story`, `x-feature`, and `x-journey`. +- Contract operations must include `x-story`, `x-feature`, and `x-journey`. - Fixtures must include a `_meta` block with the story and scenario they support. ## Feature File Template diff --git a/specs/README.md b/specs/README.md index 88c9901..690cc36 100644 --- a/specs/README.md +++ b/specs/README.md @@ -20,7 +20,7 @@ The example chain models a trade-show workflow: - `personas/` → who the system serves - `journeys/` → end-to-end user experience - `stories/` → scoped user capabilities -- `features/` + `contracts/` + `fixtures/` → executable behavior +- `features/` + `contracts/` + `fixtures/` → executable behavior across HTTP, event, and RPC boundaries - `models/` → domain concepts and rules - `journey-maps/` → E2E validation spine - `capabilities/` → certification scope boundary @@ -30,9 +30,9 @@ The example chain models a trade-show workflow: Run checks against this directory directly: ```bash -node tools/check-front-matter.js examples/ -node tools/check-traceability.js examples/ -node tools/check-capability-scope.js examples/ +node tools/check-front-matter.js specs/ +node tools/check-traceability.js specs/ +node tools/check-capability-scope.js specs/ ``` Real adopters should place equivalent artifacts under `specs/` in their own repositories. diff --git a/specs/capabilities/trade-show-signup.capability.yaml b/specs/capabilities/trade-show-signup.capability.yaml index 0c234de..7801790 100644 --- a/specs/capabilities/trade-show-signup.capability.yaml +++ b/specs/capabilities/trade-show-signup.capability.yaml @@ -11,6 +11,7 @@ scope: - specs/stories/quick-start-audit.story.md features: - specs/features/mobile-signup.feature + - specs/features/quick-start-audit.feature models: - specs/models/account.model.yaml - specs/models/account.lifecycle.yaml @@ -19,7 +20,11 @@ scope: - specs/models/shared-value-objects.model.yaml contracts: - specs/contracts/openapi/api.yaml + - specs/contracts/asyncapi/audit-events.yaml + - specs/contracts/json-rpc/account-service.yaml fixtures: - specs/fixtures/mobile-signup.fixture.yaml + - specs/fixtures/quick-start-audit-rpc.fixture.yaml + - specs/fixtures/quick-start-audit-event.fixture.yaml journey_maps: - specs/journey-maps/trade-show-signup.journey-map.yaml diff --git a/specs/contracts/asyncapi/audit-events.yaml b/specs/contracts/asyncapi/audit-events.yaml new file mode 100644 index 0000000..6ab5bff --- /dev/null +++ b/specs/contracts/asyncapi/audit-events.yaml @@ -0,0 +1,28 @@ +asyncapi: 2.6.0 +info: + title: Trade Show Audit Events + version: 1.0.0 + description: Event contract for quick-start audit orchestration. +channels: + audits/created: + publish: + operationId: publishAuditCreated + summary: Publish a domain event when the first audit is created + x-story: specs/stories/quick-start-audit.story.md + x-feature: specs/features/quick-start-audit.feature + x-journey: specs/journeys/trade-show-signup.journey.md + message: + name: AuditCreated + payload: + type: object + required: [auditId, accountId, templateId, createdAt] + properties: + auditId: + type: string + accountId: + type: string + templateId: + type: string + createdAt: + type: string + format: date-time diff --git a/specs/contracts/json-rpc/account-service.yaml b/specs/contracts/json-rpc/account-service.yaml new file mode 100644 index 0000000..5f9b888 --- /dev/null +++ b/specs/contracts/json-rpc/account-service.yaml @@ -0,0 +1,27 @@ +jsonrpc: '2.0' +info: + title: Trade Show Account RPC + version: 1.0.0 + description: RPC contract for quick-start account guidance. +methods: + account.getQuickStartPrompt: + summary: Load quick-start prompt details for a newly created account + x-story: specs/stories/quick-start-audit.story.md + x-feature: specs/features/quick-start-audit.feature + x-journey: specs/journeys/trade-show-signup.journey.md + paramsSchema: + type: object + required: [accountId] + properties: + accountId: + type: string + resultSchema: + type: object + required: [accountId, cta, recommendedTemplateId] + properties: + accountId: + type: string + cta: + type: string + recommendedTemplateId: + type: string diff --git a/specs/contracts/openapi/api.yaml b/specs/contracts/openapi/api.yaml index b33a0f1..f3170fa 100644 --- a/specs/contracts/openapi/api.yaml +++ b/specs/contracts/openapi/api.yaml @@ -41,7 +41,7 @@ paths: post: operationId: createInitialAudit x-story: specs/stories/quick-start-audit.story.md - x-feature: specs/features/mobile-signup.feature + x-feature: specs/features/quick-start-audit.feature x-journey: specs/journeys/trade-show-signup.journey.md summary: Create a first audit from quick-start prompt parameters: diff --git a/specs/features/quick-start-audit.feature b/specs/features/quick-start-audit.feature new file mode 100644 index 0000000..3885673 --- /dev/null +++ b/specs/features/quick-start-audit.feature @@ -0,0 +1,23 @@ +# id: quick-start-audit +# type: feature +# story: specs/stories/quick-start-audit.story.md +# journey: specs/journeys/trade-show-signup.journey.md +# contract: openapi POST /accounts/{accountId}/audits +# contract: asyncapi publish audits/created +# contract: json-rpc account.getQuickStartPrompt + +Feature: Quick-start audit + In order to see product value before leaving the booth + As a newly signed-up prospect + I want the system to guide and confirm my first audit quickly + + Scenario: The confirmation screen loads quick-start guidance + Given the prospect has just created an account + When the client requests quick-start prompt details + Then the response includes the primary call to action + And the prompt recommends a default audit template + + Scenario: Creating the first audit emits a contract event + Given the prospect starts the first audit from the confirmation screen + When the system creates the audit + Then an audit created event is published for downstream consumers diff --git a/specs/fixtures/quick-start-audit-event.fixture.yaml b/specs/fixtures/quick-start-audit-event.fixture.yaml new file mode 100644 index 0000000..6b0376c --- /dev/null +++ b/specs/fixtures/quick-start-audit-event.fixture.yaml @@ -0,0 +1,16 @@ +id: quick-start-audit-event +type: fixture +story: specs/stories/quick-start-audit.story.md +feature: specs/features/quick-start-audit.feature +scenario: Creating the first audit emits a contract event +contract: + protocol: asyncapi + ref: specs/contracts/asyncapi/audit-events.yaml + channel: audits/created + action: publish +request: + payload: + auditId: audit_001 + accountId: acct_001 + templateId: template_trade_show_default + createdAt: '2026-03-21T10:15:00Z' diff --git a/specs/fixtures/quick-start-audit-rpc.fixture.yaml b/specs/fixtures/quick-start-audit-rpc.fixture.yaml new file mode 100644 index 0000000..be79eea --- /dev/null +++ b/specs/fixtures/quick-start-audit-rpc.fixture.yaml @@ -0,0 +1,17 @@ +id: quick-start-audit-rpc +type: fixture +story: specs/stories/quick-start-audit.story.md +feature: specs/features/quick-start-audit.feature +scenario: The confirmation screen loads quick-start guidance +contract: + protocol: json-rpc + ref: specs/contracts/json-rpc/account-service.yaml + method: account.getQuickStartPrompt +request: + params: + accountId: acct_001 +response: + result: + accountId: acct_001 + cta: Start first audit + recommendedTemplateId: template_trade_show_default diff --git a/specs/journey-maps/trade-show-signup.journey-map.yaml b/specs/journey-maps/trade-show-signup.journey-map.yaml index ce2e9e9..2081732 100644 --- a/specs/journey-maps/trade-show-signup.journey-map.yaml +++ b/specs/journey-maps/trade-show-signup.journey-map.yaml @@ -8,9 +8,14 @@ sources: - specs/stories/quick-start-audit.story.md features: - specs/features/mobile-signup.feature + - specs/features/quick-start-audit.feature fixtures: mobile-signup-happy-path: ref: specs/fixtures/mobile-signup.fixture.yaml + quick-start-audit-rpc: + ref: specs/fixtures/quick-start-audit-rpc.fixture.yaml + quick-start-audit-event: + ref: specs/fixtures/quick-start-audit-event.fixture.yaml steps: - id: scan-qr story: specs/stories/mobile-signup.story.md diff --git a/specs/journeys/trade-show-signup.journey.md b/specs/journeys/trade-show-signup.journey.md index 057cc37..728e446 100644 --- a/specs/journeys/trade-show-signup.journey.md +++ b/specs/journeys/trade-show-signup.journey.md @@ -2,7 +2,7 @@ id: trade-show-signup type: journey refs: - persona: examples/personas/trade-show-prospect.persona.md + persona: specs/personas/trade-show-prospect.persona.md --- # Journey: Trade Show Signup diff --git a/specs/models/account.model.yaml b/specs/models/account.model.yaml index f003e9f..d8b733c 100644 --- a/specs/models/account.model.yaml +++ b/specs/models/account.model.yaml @@ -2,13 +2,17 @@ id: account type: model entity: Account aggregate: AccountAggregate +description: Trade-show signup account state used by onboarding and quick-start audit flows. +identity: + field: accountId + type: string sources: stories: - specs/stories/mobile-signup.story.md - specs/stories/quick-start-audit.story.md journeys: - specs/journeys/trade-show-signup.journey.md -fields: +attributes: accountId: type: string constraints: @@ -26,5 +30,6 @@ fields: - active rules: - id: account-activates-on-signup + description: A successful mobile signup immediately activates the newly created account. when: signup-submitted then: status=active diff --git a/specs/stories/mobile-signup.story.md b/specs/stories/mobile-signup.story.md index 412c26e..a526cb9 100644 --- a/specs/stories/mobile-signup.story.md +++ b/specs/stories/mobile-signup.story.md @@ -2,8 +2,8 @@ id: mobile-signup type: story refs: - journey: examples/journeys/trade-show-signup.journey.md - persona: examples/personas/trade-show-prospect.persona.md + journey: specs/journeys/trade-show-signup.journey.md + persona: specs/personas/trade-show-prospect.persona.md steps: [1, 2] --- diff --git a/specs/stories/quick-start-audit.story.md b/specs/stories/quick-start-audit.story.md index 1cd9e32..85aa271 100644 --- a/specs/stories/quick-start-audit.story.md +++ b/specs/stories/quick-start-audit.story.md @@ -2,8 +2,8 @@ id: quick-start-audit type: story refs: - journey: examples/journeys/trade-show-signup.journey.md - persona: examples/personas/trade-show-prospect.persona.md + journey: specs/journeys/trade-show-signup.journey.md + persona: specs/personas/trade-show-prospect.persona.md steps: [3, 4] --- diff --git a/test/contract-layer-support.test.js b/test/contract-layer-support.test.js new file mode 100644 index 0000000..e559edb --- /dev/null +++ b/test/contract-layer-support.test.js @@ -0,0 +1,64 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const IDD_BIN = path.join(REPO_ROOT, 'bin', 'idd.js'); + +function runNode(args) { + return execFileSync(process.execPath, args, { + cwd: REPO_ROOT, + encoding: 'utf8', + }); +} + +function runJson(args) { + return JSON.parse(runNode(args)); +} + +test('contract validator supports all configured contract families', () => { + const result = runJson([IDD_BIN, 'validate', 'contracts', '--json']); + + assert.deepEqual(result.errors, []); + assert.match(result.info.join('\n'), /asyncapi/i); + assert.match(result.info.join('\n'), /json-rpc/i); + assert.match(result.info.join('\n'), /openapi/i); +}); + +test('fixture validator validates OpenAPI, AsyncAPI, and JSON-RPC fixtures', () => { + const result = runJson([IDD_BIN, 'validate', 'fixtures', '--json']); + + assert.deepEqual(result.errors, []); + assert.match(result.info.join('\n'), /mobile-signup\.fixture\.yaml: valid/); + assert.match(result.info.join('\n'), /quick-start-audit-event\.fixture\.yaml: valid/); + assert.match(result.info.join('\n'), /quick-start-audit-rpc\.fixture\.yaml: valid/); +}); + +test('traceability validator includes all contract families', () => { + const result = runJson([IDD_BIN, 'validate', 'traceability', '--json']); + + assert.deepEqual(result.errors, []); + assert.match(result.info.join('\n'), /Checked 4 contract operation\(s\) across 3 contract file\(s\)/); +}); + +test('spec graph includes asyncapi and json-rpc contract nodes', () => { + const graph = runJson([path.join(REPO_ROOT, 'tools', 'graph-generation', 'generate-spec-graph.js'), 'specs', '--format', 'json']); + const labels = graph.nodes.filter(node => node.type === 'contract').map(node => node.label); + + assert.ok(labels.some(label => label.includes('asyncapi:'))); + assert.ok(labels.some(label => label.includes('json-rpc:'))); + assert.ok(labels.some(label => label.includes('openapi:'))); +}); + +test('idd init scaffolds protocol-specific contract directories', () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'idd-init-')); + + runNode([IDD_BIN, 'init', targetDir]); + + assert.ok(fs.existsSync(path.join(targetDir, 'specs', 'contracts', 'openapi'))); + assert.ok(fs.existsSync(path.join(targetDir, 'specs', 'contracts', 'asyncapi'))); + assert.ok(fs.existsSync(path.join(targetDir, 'specs', 'contracts', 'json-rpc'))); +}); diff --git a/tools/generate-evidence.js b/tools/generate-evidence.js index 45632a6..b8b1e54 100755 --- a/tools/generate-evidence.js +++ b/tools/generate-evidence.js @@ -34,6 +34,7 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); +const { extractContractOperations, parseContractDocument } = require('./lib/contracts'); const { parseFrontMatter, fileExists, findFiles, formatResults } = require('./lib/parse-front-matter'); const args = process.argv.slice(2); @@ -198,38 +199,40 @@ function loadFeatureHeaders(featurePaths) { return featureHeaders; } -function loadOpenApiOperations(contractPaths) { +function loadContractOperations(contractPaths) { const operations = []; for (const contractPath of contractPaths) { + const resolvedPath = path.resolve(contractPath); + const relativePath = normalizeRef(contractPath); + try { - const api = readYaml(path.resolve(contractPath)); - if (!api || !api.paths) continue; - - for (const [pathKey, pathItem] of Object.entries(api.paths)) { - if (!pathItem || typeof pathItem !== 'object' || pathItem.$ref) continue; - - for (const method of ['get', 'post', 'put', 'patch', 'delete']) { - const operation = pathItem[method]; - if (!operation) continue; - // Support x-feature as string or array - const rawFeature = operation['x-feature']; - const xFeatures = rawFeature - ? (Array.isArray(rawFeature) ? rawFeature.map(normalizeRef) : [normalizeRef(rawFeature)]) - : []; - - operations.push({ - method, - pathKey, - operationId: operation.operationId || `${method.toUpperCase()} ${pathKey}`, - xStory: normalizeRef(operation['x-story']), - xFeatures, - xJourney: normalizeRef(operation['x-journey']), - }); - } + const document = parseContractDocument(resolvedPath); + let protocol = null; + if (document && document.openapi) protocol = 'openapi'; + else if (document && document.asyncapi) protocol = 'asyncapi'; + else if (document && (document.jsonrpc || document.methods)) protocol = 'json-rpc'; + + if (!protocol) { + results.warnings.push(`${contractPath}: Unable to infer contract protocol for evidence generation`); + continue; } + + const contract = { + protocol, + filePath: resolvedPath, + relativePath, + document, + }; + + operations.push(...extractContractOperations(contract).map(operation => ({ + signature: operation.signature, + xStory: operation.storyRefs[0] || '', + xFeatures: operation.featureRefs, + xJourney: operation.journeyRefs[0] || '', + }))); } catch (error) { - results.errors.push(`${contractPath}: Failed to parse OpenAPI contract: ${error.message}`); + results.errors.push(`${contractPath}: Failed to parse contract: ${error.message}`); } } @@ -244,7 +247,7 @@ function computeTraceability(scope) { const journeyMaps = getScopeEntries(scope, 'journeyMaps'); const featureHeaders = loadFeatureHeaders(features); - const operations = loadOpenApiOperations(contracts); + const operations = loadContractOperations(contracts); let coveredStories = 0; for (const story of stories) { @@ -302,7 +305,7 @@ function computeTraceability(scope) { } const endpointTraceability = contracts.length > 0 - ? results.warnings.push('Endpoint-to-test coverage is not fully inferable yet; endpoints_with_tests requires report-aware or test-aware validation') + ? results.warnings.push('Contract-operation-to-test coverage is not fully inferable yet; endpoints_with_tests requires report-aware or test-aware validation') : null; void endpointTraceability; diff --git a/tools/graph-generation/generate-interactive-graph.js b/tools/graph-generation/generate-interactive-graph.js index 42d508e..c05551d 100644 --- a/tools/graph-generation/generate-interactive-graph.js +++ b/tools/graph-generation/generate-interactive-graph.js @@ -18,6 +18,8 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); +const { extractContractOperations, loadContractDocuments } = require('../lib/contracts'); +const { parseFrontMatter } = require('../lib/parse-front-matter'); const SPECS_DIR = process.argv[2] ? path.resolve(process.argv[2]) @@ -198,28 +200,18 @@ function parseLifecycles() { } function parseContracts() { - const apiFile = path.join(SPECS_DIR, 'contracts/openapi/api.yaml'); - if (!fs.existsSync(apiFile)) return; - let api; - try { api = yaml.load(fs.readFileSync(apiFile, 'utf8')); } catch (_) { return; } - if (!api.paths) return; - - for (const [pathKey, pathItem] of Object.entries(api.paths)) { - if (pathItem.$ref) continue; - for (const method of ['get', 'post', 'put', 'patch', 'delete']) { - const op = pathItem[method]; - if (!op) continue; - const id = `contract:${method.toUpperCase()} ${pathKey}`; - const tag = op.tags?.[0] || ''; - addNode(id, 'contract', `${method.toUpperCase()} ${pathKey}`, { - operationId: op.operationId, tag, summary: op.summary || '', + const contracts = loadContractDocuments(SPECS_DIR, null); + for (const contract of contracts) { + for (const operation of extractContractOperations(contract)) { + const id = `contract:${operation.signature}`; + addNode(id, 'contract', operation.signature, { + operationId: operation.operationId || operation.methodName || '', + summary: operation.label || '', + protocol: operation.protocol, }); - if (op['x-journey']) addEdge(id, op['x-journey'], 'serves'); - if (op['x-story']) { - const sp = resolveStoryName(op['x-story'], tag); - if (sp) addEdge(id, sp, 'implements'); - } - if (op['x-feature']) addEdge(id, op['x-feature'], 'tested-by'); + for (const journeyRef of operation.journeyRefs) addEdge(id, journeyRef, 'serves'); + for (const storyRef of operation.storyRefs) addEdge(id, storyRef, 'implements'); + for (const featureRef of operation.featureRefs) addEdge(id, featureRef, 'tested-by'); } } } @@ -245,13 +237,18 @@ function parseJourneyMaps() { } function parseFixtures() { - for (const f of findFiles(path.join(SPECS_DIR, 'fixtures'), /\.json$/)) { + for (const f of findFiles(path.join(SPECS_DIR, 'fixtures'), /\.(json|ya?ml)$/)) { const id = relPath(f); let fixture; - try { fixture = JSON.parse(fs.readFileSync(f, 'utf8')); } catch (_) { continue; } + try { + const parsed = parseFrontMatter(f, fs.readFileSync(f, 'utf8')); + fixture = parsed.body; + } catch (_) { continue; } addNode(id, 'fixture', titleCase(path.basename(f, '.json')), { file: id }); - if (fixture._meta?.story) addEdge(id, fixture._meta.story, 'test-data-for'); - if (fixture._meta?.feature) addEdge(id, fixture._meta.feature, 'feeds'); + const storyRef = fixture?.story || fixture?._meta?.story; + const featureRef = fixture?.feature || fixture?._meta?.feature; + if (storyRef) addEdge(id, storyRef, 'test-data-for'); + if (featureRef) addEdge(id, featureRef, 'feeds'); } } diff --git a/tools/graph-generation/generate-spec-graph.js b/tools/graph-generation/generate-spec-graph.js index 19f5cb9..4aecd42 100644 --- a/tools/graph-generation/generate-spec-graph.js +++ b/tools/graph-generation/generate-spec-graph.js @@ -13,7 +13,7 @@ const fs = require('fs'); const path = require('path'); -const yaml = require('js-yaml'); +const { extractContractOperations, loadContractDocuments } = require('../lib/contracts'); const { getExpectedType, parseFrontMatter, @@ -33,8 +33,6 @@ const NODE_TYPES = new Set([ 'capability', 'contract', ]); -const OPERATION_METHODS = ['get', 'post', 'put', 'patch', 'delete']; - function parseArgs(argv) { const args = { specsDir: null, @@ -134,10 +132,7 @@ function toArray(value) { function normalizeContractSignature(value) { if (!value || typeof value !== 'string') return null; - const trimmed = value.trim().replace(/\s+/g, ' '); - const match = trimmed.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/i); - if (!match) return null; - return `${match[1].toUpperCase()} ${match[2]}`; + return value.trim().replace(/\s+/g, ' '); } function escapeRegExp(value) { @@ -339,65 +334,53 @@ function registerArtifactsFromFrontMatter(state, specsDir) { } } -function registerContractsFromOpenApi(state, specsDir) { - const contractsDir = path.join(specsDir, 'contracts', 'openapi'); - const openApiFiles = findFiles(contractsDir, /\.ya?ml$/i); - - for (const filePath of openApiFiles) { - const relativePath = toPosix(path.relative(process.cwd(), filePath)); - - let doc; - try { - doc = yaml.load(fs.readFileSync(filePath, 'utf8')); - } catch (error) { - state.warnings.push(`Failed to parse OpenAPI file ${relativePath}: ${error.message}`); - continue; - } - - if (!doc || typeof doc !== 'object' || !doc.paths) { - continue; - } - - for (const [routePath, pathItem] of Object.entries(doc.paths)) { - if (!pathItem || typeof pathItem !== 'object' || pathItem.$ref) { - continue; - } - - for (const method of OPERATION_METHODS) { - const operation = pathItem[method]; - if (!operation || typeof operation !== 'object') { - continue; - } +function registerContracts(state, specsDir) { + const contracts = loadContractDocuments(specsDir, { + errors: state.warnings, + warnings: state.warnings, + info: [], + }); - const signature = normalizeContractSignature(`${method.toUpperCase()} ${routePath}`); - const id = operationSlug(method, routePath); - const uid = addNode(state, { - type: 'contract', - id, - path: `${relativePath}#${method.toUpperCase()} ${routePath}`, - label: `contract:${id}`, - meta: { - file: relativePath, - signature, - operationId: operation.operationId || '', - }, - }); + for (const contract of contracts) { + const relativePath = toPosix(path.relative(process.cwd(), contract.filePath)); + const operations = extractContractOperations(contract); + + for (const operation of operations) { + const operationKey = operation.operationId + || operation.methodName + || `${operation.protocol}_${operation.action || operation.method || 'op'}_${operation.channelName || operation.routePath || operation.label}`; + const id = operationSlug(operation.protocol, operationKey); + const signature = normalizeContractSignature(operation.signature); + const uid = addNode(state, { + type: 'contract', + id, + path: `${relativePath}#${signature}`, + label: `${operation.protocol}:${operation.label}`, + meta: { + file: relativePath, + signature, + operationId: operation.operationId || '', + }, + }); - if (!uid) continue; + if (!uid) continue; - if (signature) { - if (!state.contractsBySignature.has(signature)) { - state.contractsBySignature.set(signature, new Set()); - } - state.contractsBySignature.get(signature).add(uid); - } - if (!state.contractUidsByFile.has(relativePath)) { - state.contractUidsByFile.set(relativePath, []); + if (signature) { + if (!state.contractsBySignature.has(signature)) { + state.contractsBySignature.set(signature, new Set()); } - state.contractUidsByFile.get(relativePath).push(uid); - - addContractReferenceEdges(state, uid, operation, relativePath); + state.contractsBySignature.get(signature).add(uid); } + if (!state.contractUidsByFile.has(relativePath)) { + state.contractUidsByFile.set(relativePath, []); + } + state.contractUidsByFile.get(relativePath).push(uid); + + addContractReferenceEdges(state, uid, { + 'x-story': operation.storyRefs, + 'x-feature': operation.featureRefs, + 'x-journey': operation.journeyRefs, + }, relativePath); } } } @@ -759,7 +742,7 @@ function buildGraph(specsDir) { state.specRootPrefix = toPosix(path.relative(process.cwd(), specsDir)); registerArtifactsFromFrontMatter(state, specsDir); - registerContractsFromOpenApi(state, specsDir); + registerContracts(state, specsDir); addArtifactEdges(state); addCapabilityScope(state); diff --git a/tools/lib/contracts.js b/tools/lib/contracts.js new file mode 100644 index 0000000..0b49def --- /dev/null +++ b/tools/lib/contracts.js @@ -0,0 +1,303 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { findFiles } = require('./parse-front-matter'); + +const OPENAPI_METHODS = ['get', 'post', 'put', 'patch', 'delete']; +const CONTRACT_PROTOCOLS = { + openapi: { + directory: 'openapi', + displayName: 'OpenAPI', + }, + asyncapi: { + directory: 'asyncapi', + displayName: 'AsyncAPI', + }, + 'json-rpc': { + directory: 'json-rpc', + displayName: 'JSON-RPC', + }, +}; + +function toPosix(value) { + return String(value || '').replace(/\\/g, '/'); +} + +function normalizeRef(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? toPosix(trimmed.replace(/^\.\//, '')) : null; +} + +function toArray(value) { + if (value == null) return []; + return Array.isArray(value) ? value.filter(Boolean) : [value]; +} + +function normalizeProtocol(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'openapi') return 'openapi'; + if (normalized === 'asyncapi') return 'asyncapi'; + if (normalized === 'json-rpc' || normalized === 'jsonrpc') return 'json-rpc'; + return null; +} + +function protocolDisplayName(protocol) { + const normalized = normalizeProtocol(protocol); + return normalized ? CONTRACT_PROTOCOLS[normalized].displayName : String(protocol || 'Contract'); +} + +function parseContractDocument(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const extension = path.extname(filePath).toLowerCase(); + if (extension === '.json') { + return JSON.parse(content); + } + return yaml.load(content); +} + +function discoverContractFiles(specsDir) { + const contractsDir = path.join(specsDir, 'contracts'); + const entries = []; + + for (const [protocol, config] of Object.entries(CONTRACT_PROTOCOLS)) { + const dir = path.join(contractsDir, config.directory); + for (const filePath of findFiles(dir, /\.(json|ya?ml)$/i)) { + entries.push({ + protocol, + filePath, + relativePath: toPosix(path.relative(process.cwd(), filePath)), + }); + } + } + + return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} + +function loadContractDocuments(specsDir, results) { + const documents = []; + + for (const entry of discoverContractFiles(specsDir)) { + try { + documents.push({ + ...entry, + document: parseContractDocument(entry.filePath), + }); + } catch (error) { + if (results) { + results.errors.push(`${entry.relativePath}: Failed to parse ${protocolDisplayName(entry.protocol)} contract: ${error.message}`); + } + } + } + + return documents; +} + +function resolvePointer(document, ref) { + if (!ref || typeof ref !== 'string' || !ref.startsWith('#/')) { + return null; + } + + const parts = ref + .slice(2) + .split('/') + .map(part => part.replace(/~1/g, '/').replace(/~0/g, '~')); + + let current = document; + for (const part of parts) { + if (!current || typeof current !== 'object' || !(part in current)) { + return null; + } + current = current[part]; + } + + return current; +} + +function dereference(value, document) { + if (!value || typeof value !== 'object' || Array.isArray(value) || !value.$ref) { + return value; + } + + return resolvePointer(document, value.$ref) || value; +} + +function schemaFromOpenApiContent(content, document) { + if (!content || typeof content !== 'object') return null; + + if (content['application/json'] && content['application/json'].schema) { + return dereference(content['application/json'].schema, document); + } + + for (const mediaType of Object.values(content)) { + if (mediaType && typeof mediaType === 'object' && mediaType.schema) { + return dereference(mediaType.schema, document); + } + } + + return null; +} + +function responseForStatus(responses, status) { + if (!responses || typeof responses !== 'object') return null; + if (status && responses[status]) return dereference(responses[status], responses); + if (status && status.length === 3 && responses[`${status[0]}XX`]) return dereference(responses[`${status[0]}XX`], responses); + if (responses.default) return dereference(responses.default, responses); + return null; +} + +function extractRefs(source) { + return { + storyRefs: toArray(source && source['x-story']).map(normalizeRef).filter(Boolean), + featureRefs: toArray(source && source['x-feature']).map(normalizeRef).filter(Boolean), + journeyRefs: toArray(source && source['x-journey']).map(normalizeRef).filter(Boolean), + }; +} + +function openApiOperations(contract) { + const operations = []; + const document = contract.document; + const paths = document && document.paths; + + if (!paths || typeof paths !== 'object') { + return operations; + } + + for (const [routePath, pathItem] of Object.entries(paths)) { + if (!pathItem || typeof pathItem !== 'object' || pathItem.$ref) continue; + + for (const method of OPENAPI_METHODS) { + const operation = pathItem[method]; + if (!operation || typeof operation !== 'object') continue; + + operations.push({ + protocol: 'openapi', + filePath: contract.filePath, + relativePath: contract.relativePath, + source: operation, + signature: `openapi ${method.toUpperCase()} ${routePath}`, + aliases: [`${method.toUpperCase()} ${routePath}`], + label: operation.operationId || `${method.toUpperCase()} ${routePath}`, + routePath, + method, + operationId: operation.operationId || null, + requestSchema: schemaFromOpenApiContent(operation.requestBody && dereference(operation.requestBody, document).content, document), + responseSchemaForStatus(status) { + const response = responseForStatus(operation.responses, status); + return response ? schemaFromOpenApiContent(response.content, document) : null; + }, + ...extractRefs(operation), + }); + } + } + + return operations; +} + +function asyncApiOperations(contract) { + const operations = []; + const document = contract.document; + const channels = document && document.channels; + + if (!channels || typeof channels !== 'object') { + return operations; + } + + for (const [channelName, channelItem] of Object.entries(channels)) { + if (!channelItem || typeof channelItem !== 'object') continue; + + for (const action of ['publish', 'subscribe']) { + const operation = channelItem[action]; + if (!operation || typeof operation !== 'object') continue; + + const resolvedOperation = dereference(operation, document); + const resolvedMessage = dereference(resolvedOperation.message, document); + const payloadSchema = resolvedMessage + ? dereference(resolvedMessage.payload, document) + : null; + + operations.push({ + protocol: 'asyncapi', + filePath: contract.filePath, + relativePath: contract.relativePath, + source: resolvedOperation, + signature: `asyncapi ${action} ${channelName}`, + aliases: [`${action} ${channelName}`], + label: resolvedOperation.operationId || `${action} ${channelName}`, + channelName, + action, + operationId: resolvedOperation.operationId || null, + payloadSchema, + ...extractRefs(resolvedOperation), + }); + } + } + + return operations; +} + +function jsonRpcOperations(contract) { + const operations = []; + const document = contract.document; + const methods = document && document.methods; + + if (!methods || (typeof methods !== 'object' && !Array.isArray(methods))) { + return operations; + } + + const methodEntries = Array.isArray(methods) + ? methods.map((value) => [value && value.name, value]) + : Object.entries(methods).map(([name, value]) => [name, value]); + + for (const [rawName, rawMethod] of methodEntries) { + const methodDef = dereference(rawMethod, document); + if (!methodDef || typeof methodDef !== 'object') continue; + + const methodName = String(methodDef.name || rawName || '').trim(); + if (!methodName) continue; + + const paramsSchema = dereference(methodDef.paramsSchema || methodDef.params, document); + const resultSchema = dereference(methodDef.resultSchema || methodDef.result, document); + const errorSchema = dereference(methodDef.errorSchema || methodDef.error, document); + + operations.push({ + protocol: 'json-rpc', + filePath: contract.filePath, + relativePath: contract.relativePath, + source: methodDef, + signature: `json-rpc ${methodName}`, + aliases: [`jsonrpc ${methodName}`], + label: methodDef.summary || methodName, + methodName, + paramsSchema, + resultSchema, + errorSchema, + ...extractRefs(methodDef), + }); + } + + return operations; +} + +function extractContractOperations(contract) { + if (contract.protocol === 'openapi') return openApiOperations(contract); + if (contract.protocol === 'asyncapi') return asyncApiOperations(contract); + if (contract.protocol === 'json-rpc') return jsonRpcOperations(contract); + return []; +} + +module.exports = { + CONTRACT_PROTOCOLS, + OPENAPI_METHODS, + discoverContractFiles, + extractContractOperations, + loadContractDocuments, + normalizeProtocol, + normalizeRef, + parseContractDocument, + protocolDisplayName, + responseForStatus, + schemaFromOpenApiContent, + toArray, + toPosix, +}; diff --git a/tools/lib/parse-front-matter.js b/tools/lib/parse-front-matter.js index 5fdded3..ca21e58 100644 --- a/tools/lib/parse-front-matter.js +++ b/tools/lib/parse-front-matter.js @@ -98,7 +98,13 @@ function parseGherkinFrontMatter(content) { if (match) { const key = match[1]; const value = match[2].trim(); - frontMatter[key] = value; + if (frontMatter[key] === undefined) { + frontMatter[key] = value; + } else if (Array.isArray(frontMatter[key])) { + frontMatter[key].push(value); + } else { + frontMatter[key] = [frontMatter[key], value]; + } lastHeaderLine = i; } else if (line.startsWith('#') && lastHeaderLine === -1) { // Regular comment before any headers — skip diff --git a/tools/validate-contracts.js b/tools/validate-contracts.js new file mode 100644 index 0000000..4466eea --- /dev/null +++ b/tools/validate-contracts.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +/** + * Validates contract artifacts across supported protocol families. + * + * Usage: + * node tools/validate-contracts.js [specs-dir] + * node tools/validate-contracts.js --files specs/contracts/asyncapi/events.yaml + * + * Options: + * --files Validate only specific contract files + * --json Output results as JSON + * --strict Treat warnings as errors + */ + +const path = require('path'); +const { + discoverContractFiles, + extractContractOperations, + loadContractDocuments, + protocolDisplayName, +} = require('./lib/contracts'); +const { formatResults } = require('./lib/parse-front-matter'); + +const args = process.argv.slice(2); +let specsDir = null; +let specificFiles = null; +let jsonOutput = false; +let strict = false; + +for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--files') { + specificFiles = []; + i += 1; + while (i < args.length && !args[i].startsWith('--')) { + specificFiles.push(path.resolve(args[i])); + i += 1; + } + i -= 1; + } else if (args[i] === '--json') { + jsonOutput = true; + } else if (args[i] === '--strict') { + strict = true; + } else if (!args[i].startsWith('--')) { + specsDir = path.resolve(args[i]); + } +} + +if (!specsDir) { + specsDir = path.join(process.cwd(), 'specs'); +} + +function outputResults(results) { + if (jsonOutput) { + process.stdout.write(`${JSON.stringify(results, null, 2)}\n`); + return; + } + + process.stdout.write('Validating contract artifacts...\n\n'); + process.stdout.write(`${formatResults(results)}\n`); +} + +function selectedContracts() { + if (!specificFiles) return null; + const selected = new Set(specificFiles); + return discoverContractFiles(specsDir).filter(entry => selected.has(entry.filePath)); +} + +function validateContractShape(contract, results) { + const label = contract.relativePath; + const { document, protocol } = contract; + + if (!document || typeof document !== 'object') { + results.errors.push(`${label}: ${protocolDisplayName(protocol)} contract did not parse to an object`); + return; + } + + if (protocol === 'openapi') { + if (!document.openapi) { + results.errors.push(`${label}: Missing required field: openapi`); + } + if (!document.paths || typeof document.paths !== 'object') { + results.errors.push(`${label}: Missing required object: paths`); + } + return; + } + + if (protocol === 'asyncapi') { + if (!document.asyncapi) { + results.errors.push(`${label}: Missing required field: asyncapi`); + } + if (!document.channels || typeof document.channels !== 'object') { + results.errors.push(`${label}: Missing required object: channels`); + } + return; + } + + if (protocol === 'json-rpc') { + if (!document.jsonrpc) { + results.warnings.push(`${label}: Missing jsonrpc version marker; expected jsonrpc: "2.0"`); + } + if (!document.methods || (typeof document.methods !== 'object' && !Array.isArray(document.methods))) { + results.errors.push(`${label}: Missing required collection: methods`); + } + } +} + +function validateOperations(contract, results) { + const operations = extractContractOperations(contract); + + if (operations.length === 0) { + results.warnings.push(`${contract.relativePath}: No contract operations found`); + return; + } + + for (const operation of operations) { + if (operation.protocol === 'openapi') { + if (!operation.source.responses || typeof operation.source.responses !== 'object') { + results.warnings.push(`${contract.relativePath}: ${operation.label}: Missing responses block`); + } + } + + if (operation.protocol === 'asyncapi' && !operation.payloadSchema) { + results.warnings.push(`${contract.relativePath}: ${operation.label}: Missing message payload schema`); + } + + if (operation.protocol === 'json-rpc' && !operation.paramsSchema && !operation.resultSchema) { + results.warnings.push(`${contract.relativePath}: ${operation.label}: Missing paramsSchema/resultSchema`); + } + } + + results.info.push(`${contract.relativePath}: validated ${operations.length} ${operations.length === 1 ? 'operation' : 'operations'}`); +} + +function main() { + const results = { errors: [], warnings: [], info: [] }; + const loaded = loadContractDocuments(specsDir, results); + + const selected = selectedContracts(); + const candidateSet = selected ? new Set(selected.map(entry => entry.filePath)) : null; + const contracts = candidateSet + ? loaded.filter(contract => candidateSet.has(contract.filePath)) + : loaded; + + if (contracts.length === 0) { + results.info.push('No contract files found to validate'); + outputResults(results); + process.exit(0); + } + + for (const contract of contracts) { + validateContractShape(contract, results); + validateOperations(contract, results); + } + + results.info.push(`Validated ${contracts.length} contract file(s)`); + + if (strict && results.warnings.length > 0) { + results.errors.push(...results.warnings.map(warning => `${warning} (strict mode)`)); + results.warnings = []; + } + + outputResults(results); + process.exit(results.errors.length > 0 ? 1 : 0); +} + +main(); diff --git a/tools/validate-fixtures.js b/tools/validate-fixtures.js index b8d72ce..cdbdfcb 100644 --- a/tools/validate-fixtures.js +++ b/tools/validate-fixtures.js @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Validates fixture files against OpenAPI request/response schemas. + * Validates fixture files against supported contract schemas. * * Usage: * node tools/validate-fixtures.js [specs-dir] @@ -17,6 +17,13 @@ const fs = require('fs'); const path = require('path'); const Ajv = require('ajv'); const addFormats = require('ajv-formats'); +const { + extractContractOperations, + loadContractDocuments, + normalizeProtocol, + normalizeRef, + protocolDisplayName, +} = require('./lib/contracts'); const { findFiles, formatResults, parseFrontMatter } = require('./lib/parse-front-matter'); const args = process.argv.slice(2); @@ -47,34 +54,8 @@ if (!specsDir) { specsDir = path.join(process.cwd(), 'specs'); } -const API_FILE = path.join(specsDir, 'contracts', 'openapi', 'api.yaml'); const FIXTURES_DIR = path.join(specsDir, 'fixtures'); -function loadApi(results) { - if (!fs.existsSync(API_FILE)) { - results.warnings.push(`OpenAPI file not found: ${path.relative(process.cwd(), API_FILE)}`); - return null; - } - - try { - const content = fs.readFileSync(API_FILE, 'utf8'); - const parsed = parseFrontMatter(API_FILE, content); - if (parsed.parseError) { - results.errors.push(`${path.relative(process.cwd(), API_FILE)}: Failed to parse OpenAPI file: ${parsed.parseError}`); - return null; - } - const api = parsed.body; - if (!api || typeof api !== 'object') { - results.errors.push(`${path.relative(process.cwd(), API_FILE)}: OpenAPI file did not parse to an object`); - return null; - } - return api; - } catch (error) { - results.errors.push(`${path.relative(process.cwd(), API_FILE)}: Failed to parse OpenAPI file: ${error.message}`); - return null; - } -} - function hasFixtureExtension(filePath) { return /\.(json|ya?ml)$/i.test(filePath); } @@ -104,71 +85,93 @@ function parseFixtureFile(fixturePath) { return parsed.body; } -function schemaFromContent(content) { - if (!content || typeof content !== 'object') return null; - if (content['application/json'] && content['application/json'].schema) { - return content['application/json'].schema; - } +function createAjv() { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + return ajv; +} - for (const mediaType of Object.values(content)) { - if (mediaType && typeof mediaType === 'object' && mediaType.schema) { - return mediaType.schema; +function validateWithSchema(ajv, schema, value, label) { + try { + const validate = ajv.compile(schema); + if (!validate(value)) { + return { + valid: false, + error: `${label} validation failed: ${ajv.errorsText(validate.errors)}`, + }; } + return { valid: true }; + } catch (error) { + return { + valid: false, + error: `${label} schema could not be compiled: ${error.message}`, + }; } - - return null; } -function responseForStatus(responses, status) { - if (!responses || typeof responses !== 'object') return null; - if (status && responses[status]) return responses[status]; - if (status && status.length === 3 && responses[`${status[0]}XX`]) { - return responses[`${status[0]}XX`]; - } - if (responses.default) return responses.default; - return null; +function buildContractIndex(results) { + const contracts = loadContractDocuments(specsDir, results); + const operations = contracts.flatMap(contract => extractContractOperations(contract).map(operation => ({ + ...operation, + contract, + }))); + + return { + contracts, + operations, + openapiContracts: contracts.filter(contract => contract.protocol === 'openapi'), + }; } -function findOperation(api, method, routePath) { - const paths = api && api.paths; - if (!paths || typeof paths !== 'object') return null; +function pathMatcher(candidatePath, routePath) { + const matcher = new RegExp(`^${candidatePath + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\\\{[^}]+\\\}/g, '[^/]+')}$`); - const exact = paths[routePath] && paths[routePath][method]; - if (exact) { - return { operation: exact, normalizedPath: routePath }; - } + return matcher.test(routePath); +} - for (const [candidatePath, candidateItem] of Object.entries(paths)) { - if (!candidateItem || typeof candidateItem !== 'object' || !candidateItem[method]) continue; +function normalizeContractRef(ref) { + const normalized = normalizeRef(ref); + if (!normalized) return null; + return path.resolve(process.cwd(), normalized); +} - const matcher = new RegExp(`^${candidatePath - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - .replace(/\\\{[^}]+\\\}/g, '[^/]+')}$`); +function getContractMetadata(fixture) { + if (fixture && fixture.contract && typeof fixture.contract === 'object') { + return fixture.contract; + } - if (matcher.test(routePath)) { - return { operation: candidateItem[method], normalizedPath: candidatePath }; - } + if (fixture && fixture._meta && fixture._meta.contract && typeof fixture._meta.contract === 'object') { + return fixture._meta.contract; } return null; } -function validateWithSchema(ajv, schema, value, label) { - const validate = ajv.compile(schema); - if (!validate(value)) { - return { - valid: false, - error: `${label} validation failed: ${ajv.errorsText(validate.errors)}`, - }; +function getLegacySchemas(index) { + const schemas = {}; + + for (const contract of index.openapiContracts) { + const components = contract.document && contract.document.components; + if (!components || !components.schemas || typeof components.schemas !== 'object') { + continue; + } + + for (const [name, schema] of Object.entries(components.schemas)) { + if (!(name in schemas)) { + schemas[name] = schema; + } + } } - return { valid: true }; + return schemas; } function validateLegacyFixture(fixture, schemas, ajv) { const schemaName = fixture._meta && fixture._meta.schema; if (!schemaName) { - return { valid: true, warning: 'No _meta.schema or request mapping specified, skipping schema validation' }; + return { valid: true, warning: 'No contract mapping specified, skipping schema validation' }; } const responseSchema = schemas[schemaName]; @@ -195,62 +198,153 @@ function validateLegacyFixture(fixture, schemas, ajv) { return { valid: true }; } -function validateMappedFixture(fixture, api, ajv) { +function matchOpenApiOperation(index, fixture, metadata) { const method = String(fixture.request && fixture.request.method ? fixture.request.method : '').toLowerCase(); const routePath = String(fixture.request && fixture.request.path ? fixture.request.path : ''); + const refPath = normalizeContractRef(metadata && metadata.ref); if (!method || !routePath) { - return { valid: true, warning: 'No _meta.schema or request mapping specified, skipping schema validation' }; + return null; } - const match = findOperation(api, method, routePath); - if (!match) { - return { valid: false, error: `OpenAPI operation not found: ${method.toUpperCase()} ${routePath}` }; + const candidates = index.operations.filter(operation => + operation.protocol === 'openapi' + && operation.method === method + && (!refPath || operation.contract.filePath === refPath) + ); + + return candidates.find(operation => + operation.routePath === routePath || pathMatcher(operation.routePath, routePath) + ) || null; +} + +function validateOpenApiFixture(fixture, index, ajv, metadata) { + const operation = matchOpenApiOperation(index, fixture, metadata); + if (!operation) { + const method = String(fixture.request && fixture.request.method ? fixture.request.method : '').toUpperCase(); + const routePath = String(fixture.request && fixture.request.path ? fixture.request.path : ''); + return { valid: false, error: `OpenAPI operation not found: ${method} ${routePath}` }; } const requestBody = fixture.request && fixture.request.body; if (requestBody !== undefined) { - const requestSchema = schemaFromContent(match.operation.requestBody && match.operation.requestBody.content); - if (!requestSchema) { + if (!operation.requestSchema) { return { valid: true, - warning: `No request schema found for ${method.toUpperCase()} ${match.normalizedPath}`, + warning: `No request schema found for ${operation.method.toUpperCase()} ${operation.routePath}`, }; } - const requestResult = validateWithSchema(ajv, requestSchema, requestBody, 'Request body'); + const requestResult = validateWithSchema(ajv, operation.requestSchema, requestBody, 'Request body'); if (!requestResult.valid) return requestResult; } const responseBody = fixture.response && fixture.response.body; if (responseBody !== undefined) { const status = fixture.response && fixture.response.status != null ? String(fixture.response.status) : ''; - const response = responseForStatus(match.operation.responses, status); + const responseSchema = operation.responseSchemaForStatus(status); - if (!response) { + if (!responseSchema) { const label = status ? `status ${status}` : 'fixture status'; return { valid: true, - warning: `No response definition found for ${label} on ${method.toUpperCase()} ${match.normalizedPath}`, + warning: `No response schema found for ${label} on ${operation.method.toUpperCase()} ${operation.routePath}`, }; } - const responseSchema = schemaFromContent(response.content); - if (!responseSchema) { - return { - valid: true, - warning: `No response schema found for ${method.toUpperCase()} ${match.normalizedPath}`, - }; + const responseResult = validateWithSchema(ajv, responseSchema, responseBody, 'Response body'); + if (!responseResult.valid) return responseResult; + } + + return { valid: true }; +} + +function validateAsyncApiFixture(fixture, index, ajv, metadata) { + const protocol = normalizeProtocol(metadata && metadata.protocol) || 'asyncapi'; + if (protocol !== 'asyncapi') { + return { valid: false, error: `Expected AsyncAPI metadata, found ${metadata && metadata.protocol}` }; + } + + const channel = String((metadata && metadata.channel) || (fixture.request && fixture.request.channel) || '').trim(); + const action = String((metadata && metadata.action) || 'publish').toLowerCase(); + const refPath = normalizeContractRef(metadata && metadata.ref); + + if (!channel) { + return { valid: true, warning: 'AsyncAPI fixture is missing contract.channel metadata' }; + } + + const operation = index.operations.find(candidate => + candidate.protocol === 'asyncapi' + && candidate.channelName === channel + && candidate.action === action + && (!refPath || candidate.contract.filePath === refPath) + ); + + if (!operation) { + return { valid: false, error: `AsyncAPI operation not found: ${action} ${channel}` }; + } + + const payload = (fixture.request && fixture.request.payload !== undefined) + ? fixture.request.payload + : (fixture.message && fixture.message.payload !== undefined ? fixture.message.payload : undefined); + + if (payload === undefined) { + return { valid: true, warning: `AsyncAPI fixture did not include request.payload for ${action} ${channel}` }; + } + + if (!operation.payloadSchema) { + return { valid: true, warning: `No message payload schema found for ${action} ${channel}` }; + } + + return validateWithSchema(ajv, operation.payloadSchema, payload, 'Message payload'); +} + +function validateJsonRpcFixture(fixture, index, ajv, metadata) { + const protocol = normalizeProtocol(metadata && metadata.protocol) || 'json-rpc'; + if (protocol !== 'json-rpc') { + return { valid: false, error: `Expected JSON-RPC metadata, found ${metadata && metadata.protocol}` }; + } + + const methodName = String((metadata && metadata.method) || (fixture.request && fixture.request.method) || '').trim(); + const refPath = normalizeContractRef(metadata && metadata.ref); + + if (!methodName) { + return { valid: true, warning: 'JSON-RPC fixture is missing contract.method metadata' }; + } + + const operation = index.operations.find(candidate => + candidate.protocol === 'json-rpc' + && candidate.methodName === methodName + && (!refPath || candidate.contract.filePath === refPath) + ); + + if (!operation) { + return { valid: false, error: `JSON-RPC method not found: ${methodName}` }; + } + + if (fixture.request && fixture.request.params !== undefined && operation.paramsSchema) { + const requestResult = validateWithSchema(ajv, operation.paramsSchema, fixture.request.params, 'Request params'); + if (!requestResult.valid) return requestResult; + } + + if (fixture.response && fixture.response.result !== undefined) { + if (!operation.resultSchema) { + return { valid: true, warning: `No resultSchema found for JSON-RPC method ${methodName}` }; } - const responseResult = validateWithSchema(ajv, responseSchema, responseBody, 'Response body'); + const responseResult = validateWithSchema(ajv, operation.resultSchema, fixture.response.result, 'Response result'); if (!responseResult.valid) return responseResult; } + if (fixture.response && fixture.response.error !== undefined && operation.errorSchema) { + const errorResult = validateWithSchema(ajv, operation.errorSchema, fixture.response.error, 'Response error'); + if (!errorResult.valid) return errorResult; + } + return { valid: true }; } -function validateFixture(fixturePath, api, schemas, ajv) { +function validateFixture(fixturePath, index, schemas, ajv) { let fixture; try { fixture = parseFixtureFile(fixturePath); @@ -266,11 +360,30 @@ function validateFixture(fixturePath, api, schemas, ajv) { return { valid: false, error: `Invalid fixture format: ${fixture.parseError}` }; } - if (fixture._meta && typeof fixture._meta === 'object') { + if (fixture._meta && typeof fixture._meta === 'object' && fixture._meta.schema) { return validateLegacyFixture(fixture, schemas, ajv); } - return validateMappedFixture(fixture, api, ajv); + const metadata = getContractMetadata(fixture); + const protocol = normalizeProtocol(metadata && metadata.protocol); + + if (protocol === 'asyncapi') { + return validateAsyncApiFixture(fixture, index, ajv, metadata); + } + + if (protocol === 'json-rpc') { + return validateJsonRpcFixture(fixture, index, ajv, metadata); + } + + if (protocol === 'openapi' || (fixture.request && fixture.request.method && fixture.request.path)) { + return validateOpenApiFixture(fixture, index, ajv, metadata); + } + + if (metadata && metadata.protocol) { + return { valid: true, warning: `Unsupported contract.protocol "${metadata.protocol}"` }; + } + + return { valid: true, warning: 'No contract mapping specified, skipping schema validation' }; } function outputResults(results) { @@ -279,36 +392,20 @@ function outputResults(results) { return; } - process.stdout.write('Validating fixtures against OpenAPI schemas...\n\n'); + process.stdout.write('Validating fixtures against contract schemas...\n\n'); process.stdout.write(`${formatResults(results)}\n`); } function main() { const results = { errors: [], warnings: [], info: [] }; + const index = buildContractIndex(results); + const schemas = getLegacySchemas(index); + const ajv = createAjv(); - const api = loadApi(results); - if (!api || results.errors.length > 0) { - if (!api && results.errors.length === 0) { - results.info.push('Fixture schema validation skipped because OpenAPI contract is unavailable'); - } - if (strict && results.warnings.length > 0) { - results.errors.push(...results.warnings.map((w) => `${w} (strict mode)`)); - results.warnings = []; - } + if (index.contracts.length === 0 && results.errors.length === 0) { + results.info.push('Fixture schema validation skipped because no contract artifacts are available'); outputResults(results); - process.exit(results.errors.length > 0 ? 1 : 0); - } - - const schemas = api.components && api.components.schemas ? api.components.schemas : {}; - const ajv = new Ajv({ allErrors: true, strict: false }); - addFormats(ajv); - - for (const [name, schema] of Object.entries(schemas)) { - try { - ajv.addSchema(schema, `#/components/schemas/${name}`); - } catch (_) { - // Ignore duplicate/unusable schema additions. - } + process.exit(0); } const fixtureFiles = collectFixtureFiles(); @@ -320,7 +417,7 @@ function main() { for (const fixturePath of fixtureFiles) { const relativePath = path.relative(process.cwd(), fixturePath); - const result = validateFixture(fixturePath, api, schemas, ajv); + const result = validateFixture(fixturePath, index, schemas, ajv); if (!result.valid) { results.errors.push(`${relativePath}: ${result.error}`); @@ -331,10 +428,14 @@ function main() { } } + const availableProtocols = Array.from(new Set(index.contracts.map(contract => protocolDisplayName(contract.protocol)))); + if (availableProtocols.length > 0) { + results.info.push(`Loaded contract families: ${availableProtocols.join(', ')}`); + } results.info.push(`Validated ${fixtureFiles.length} fixture file(s)`); if (strict && results.warnings.length > 0) { - results.errors.push(...results.warnings.map((w) => `${w} (strict mode)`)); + results.errors.push(...results.warnings.map(warning => `${warning} (strict mode)`)); results.warnings = []; } diff --git a/tools/validate-traceability.js b/tools/validate-traceability.js index c47cc6e..5609c9a 100755 --- a/tools/validate-traceability.js +++ b/tools/validate-traceability.js @@ -5,7 +5,7 @@ * * Validates: * - Feature files reference stories and journeys - * - OpenAPI operations reference features + * - Contract operations reference stories, features, and journeys * - Journey maps reference journeys * - Models reference stories * - Stories reference journeys @@ -30,6 +30,11 @@ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); +const { + extractContractOperations, + loadContractDocuments, + protocolDisplayName, +} = require('./lib/contracts'); const { parseFrontMatter, findFiles, @@ -164,7 +169,7 @@ function checkFeatureFiles() { } if (!contractFound) { - const contractMatch = content.match(/# [Cc]ontract:\s*(GET|POST|PUT|PATCH|DELETE)\s/); + const contractMatch = content.match(/^#\s*[Cc]ontract:\s*(.+)$/m); contractFound = !!contractMatch; } @@ -183,77 +188,67 @@ function checkFeatureFiles() { results.info.push(`Checked ${features.length} feature file(s)`); } -// ── OpenAPI contract ────────────────────────────────────────────────── -function checkOpenAPIContract() { - const openApiDir = path.join(specsDir, 'contracts/openapi'); - const apiFiles = filterFilesBySelection(findFiles(openApiDir, /\.ya?ml$/)); +// ── Contracts ───────────────────────────────────────────────────────── +function checkContracts() { + const selectedPaths = specificFileSet + ? new Set(Array.from(specificFileSet).map(filePath => path.resolve(filePath))) + : null; + const loadedContracts = loadContractDocuments(specsDir, results) + .filter(contract => !selectedPaths || selectedPaths.has(contract.filePath)); - if (apiFiles.length === 0) { - results.info.push('OpenAPI contract not found, skipping'); + if (loadedContracts.length === 0) { + results.info.push('No contract artifacts found, skipping'); return; } let operationCount = 0; - for (const apiFile of apiFiles) { - let api; - let contractLabel = path.relative(process.cwd(), apiFile); + for (const contract of loadedContracts) { + const contractLabel = contract.relativePath; + const operations = extractContractOperations(contract); - try { - const content = fs.readFileSync(apiFile, 'utf8'); - api = yaml.load(content); - } catch (e) { - results.errors.push(`${contractLabel}: Failed to parse OpenAPI spec: ${e.message}`); + if (operations.length === 0) { + results.info.push(`${contractLabel}: ${protocolDisplayName(contract.protocol)} contract has no operations defined`); continue; } - if (!api || !api.paths) { - results.info.push(`${contractLabel}: OpenAPI spec has no paths defined`); - continue; - } - - for (const [pathKey, pathItem] of Object.entries(api.paths)) { - // Skip $ref paths (they'll be validated when resolved) - if (pathItem.$ref) continue; - - for (const method of ['get', 'post', 'put', 'patch', 'delete']) { - const operation = pathItem[method]; - if (!operation) continue; + for (const operation of operations) { + operationCount += 1; + const opId = operation.label || operation.signature; - operationCount++; - const opId = operation.operationId || `${method.toUpperCase()} ${pathKey}`; - - // Check for x-story - if (!operation['x-story']) { - results.warnings.push(`${contractLabel}: Operation ${opId}: Missing x-story extension`); + if (operation.storyRefs.length === 0) { + results.warnings.push(`${contractLabel}: Operation ${opId}: Missing x-story extension`); + } else { + for (const storyRef of operation.storyRefs) { + if (!fileExistsIfScoped(storyRef)) { + results.errors.push(`${contractLabel}: Operation ${opId}: Referenced story not found: ${storyRef}`); + } } + } - // Check for x-feature (supports string or array) - const rawFeature = operation['x-feature']; - const xFeatures = rawFeature - ? (Array.isArray(rawFeature) ? rawFeature : [rawFeature]) - : []; - if (xFeatures.length === 0) { - results.warnings.push(`${contractLabel}: Operation ${opId}: Missing x-feature extension`); - } else { - for (const feat of xFeatures) { - if (!fileExistsIfScoped(feat)) { - results.errors.push(`${contractLabel}: Operation ${opId}: Referenced feature not found: ${feat}`); - } + if (operation.featureRefs.length === 0) { + results.warnings.push(`${contractLabel}: Operation ${opId}: Missing x-feature extension`); + } else { + for (const featureRef of operation.featureRefs) { + if (!fileExistsIfScoped(featureRef)) { + results.errors.push(`${contractLabel}: Operation ${opId}: Referenced feature not found: ${featureRef}`); } } + } - // Check for x-journey (new — from front-matter conventions) - if (!operation['x-journey']) { - results.info.push(`${contractLabel}: Operation ${opId}: No x-journey extension (optional)`); - } else if (!fileExistsIfScoped(operation['x-journey'])) { - results.errors.push(`${contractLabel}: Operation ${opId}: Referenced journey not found: ${operation['x-journey']}`); + if (operation.journeyRefs.length === 0) { + results.info.push(`${contractLabel}: Operation ${opId}: No x-journey extension (optional)`); + } else { + for (const journeyRef of operation.journeyRefs) { + if (!fileExistsIfScoped(journeyRef)) { + results.errors.push(`${contractLabel}: Operation ${opId}: Referenced journey not found: ${journeyRef}`); + } } } } } - results.info.push(`Checked ${operationCount} API operation(s) across ${apiFiles.length} contract file(s)`); + results.info.push(`Checked ${operationCount} contract operation(s) across ${loadedContracts.length} contract file(s)`); } // ── Journey maps ────────────────────────────────────────────────────── @@ -555,7 +550,7 @@ function main() { checkJourneys(); checkStories(); checkFeatureFiles(); - checkOpenAPIContract(); + checkContracts(); checkFixtures(); checkJourneyMaps(); checkModels();