Skip to content

Commit cc73f36

Browse files
Merge pull request #2063 from rocketstack-matt/feat/vscode-validation
Introduce architecture validation to VSCode Extension
2 parents a0605bc + f12931b commit cc73f36

File tree

20 files changed

+1654
-222
lines changed

20 files changed

+1654
-222
lines changed

calm-plugins/sample-architecture/payment-service.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"$schema": "https://calm.finos.org/release/1.1/meta/calm.json",
23
"nodes": [
34
{
45
"unique-id": "api-gateway",

calm-plugins/sample-architecture/system.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"$schema": "https://calm.finos.org/release/1.1/meta/calm.json",
23
"nodes": [
34
{
45
"unique-id": "ecommerce-system",

calm-plugins/vscode/AGENTS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,19 @@ export class StoreReactionMediator {
183183
- `tree-data-provider.ts` - VSCode TreeDataProvider
184184
- `view-model/tree-view-model.ts` - Business logic (framework-free)
185185

186+
#### Validation Service
187+
- **Purpose**: Real-time CALM document validation with Problems panel integration
188+
- **Location**: `src/features/validation/`
189+
- **Key Files**:
190+
- `validation-service.ts` - Main service, handles document events and diagnostics
191+
- `validation-service.spec.ts` - Unit tests
192+
- **Behavior**:
193+
- Validates on document open, save, and editor activation
194+
- Clears diagnostics when editor tab is closed
195+
- Uses content-based detection (checks `$schema` field for known CALM schemas)
196+
- Produces precise line numbers for error positioning using shared enrichment logic
197+
- **Dependencies**: Uses `runValidation`, `enrichWithDocumentPositions` from `@finos/calm-shared`
198+
186199
#### Webview Preview
187200
- **Purpose**: Multi-tab preview (Model, Docify, Template)
188201
- **Location**: `src/features/preview/`
@@ -331,6 +344,28 @@ npm run build --workspace calm-widgets
331344
npm run build --workspace shared
332345
```
333346

347+
## Bundled CALM Schemas
348+
349+
The extension bundles CALM schemas from the `calm/` directory at the repository root. This allows validation to work without network access.
350+
351+
### Build Process
352+
The `postbuild` script (`scripts/copy-calm-schemas.js`) copies schemas from:
353+
- `calm/release/*/meta/*.json``dist/calm/release/*/meta/`
354+
- `calm/draft/*/meta/*.json``dist/calm/draft/*/meta/`
355+
356+
Schemas are indexed by their `$id` field for lookup when validating documents.
357+
358+
### Schema Registry
359+
`CalmSchemaRegistry` (`src/core/services/calm-schema-registry.ts`) manages schema discovery:
360+
- Loads bundled schemas from `dist/calm/`
361+
- Loads additional schemas from folders configured in `calm.schemas.additionalFolders`
362+
- Provides `isKnownCalmSchema(url)` to check if a schema URL is available locally
363+
364+
### Adding New Schema Versions
365+
When new CALM schema versions are released:
366+
1. Add the schema files to `calm/{release|draft}/{version}/meta/`
367+
2. Rebuild the extension - schemas are automatically copied and indexed
368+
334369
## Common Pitfalls
335370

336371
1. **Importing vscode in ViewModels**: ViewModels must be framework-free!

calm-plugins/vscode/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ Live-visualize CALM architecture models while you edit them. Features an interac
1818
- **Quick Navigation**: Jump between editor and preview
1919
- **Search & Filter**: Find elements across large models
2020

21+
### ✅ Real-Time Validation
22+
- **Automatic Validation**: Documents are validated on open, save, and when switching editors
23+
- **Problems Panel Integration**: Errors and warnings appear in the VS Code Problems panel
24+
- **Click-to-Navigate**: Click any issue to jump directly to the problematic line in your document
25+
- **Bundled Schemas**: CALM schemas are bundled with the extension - no network access required
26+
- **Schema Detection**: Documents are identified as CALM files by their `$schema` reference
27+
2128
### ✨ Smart Editor Features
2229
- **Hover Information**: Rich tooltips for model elements
2330
- **Auto-Refresh**: Preview updates automatically on save
@@ -56,12 +63,21 @@ Navigate between related CALM files using `detailed-architecture` references.
5663
"calm.urlMapping": "calm-mapping.json"
5764
```
5865

66+
### Schema Development
67+
For schema developers working on custom CALM schemas, you can configure additional local folders to load schemas from:
68+
69+
```json
70+
"calm.schemas.additionalFolders": ["./my-schemas", "./custom-calm-schemas"]
71+
```
72+
73+
Schemas in these folders are indexed by their `$id` field and can be referenced in your CALM documents.
74+
5975
### File Discovery
6076
Customize how the extension finds your CALM models and templates.
6177

6278
- `calm.files.globs`: Patterns for CALM model files (Default: `["calm/**/*.json", "calm/**/*.y?(a)ml"]`)
6379
- `calm.template.globs`: Patterns for template files (Default: `["**/*.md", "**/*.hbs", ...]`)
64-
- `calm.cli.path`: Path to the CALM CLI executable (Default: `./cli`)
80+
- `calm.schemas.additionalFolders`: Additional folders containing CALM schemas for validation (Default: `[]`)
6581

6682
## Getting Involved
6783

calm-plugins/vscode/package.json

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@
117117
"type": "string",
118118
"description": "Path to a JSON file mapping URLs to local file paths for detailed-architecture navigation.",
119119
"default": ""
120+
},
121+
"calm.schemas.additionalFolders": {
122+
"type": "array",
123+
"items": {
124+
"type": "string"
125+
},
126+
"default": [],
127+
"markdownDescription": "Additional folders containing CALM schemas for validation. Useful for schema developers testing local schema changes. Paths are relative to the workspace root."
120128
}
121129
}
122130
},
@@ -155,7 +163,7 @@
155163
},
156164
"scripts": {
157165
"build": "tsup",
158-
"postbuild": "node ./scripts/copy-calm-assets.js",
166+
"postbuild": "node ./scripts/copy-calm-assets.js && copyfiles \"../../calm/release/**/meta/*\" \"../../calm/draft/**/meta/*\" dist/calm/ --up 3",
159167
"watch": "tsup --watch",
160168
"test": "vitest run",
161169
"test:watch": "vitest",
@@ -174,12 +182,13 @@
174182
"@typescript-eslint/eslint-plugin": "^8.29.1",
175183
"@typescript-eslint/parser": "^8.29.1",
176184
"@vscode/dts": "^0.4.1",
185+
"@vscode/vsce": "^3.7.1",
186+
"copyfiles": "^2.4.1",
177187
"eslint": "^9.24.0",
178188
"eslint-plugin-import": "^2.32.0",
179189
"globals": "^16.0.0",
180190
"tsup": "^8.2.4",
181-
"typescript": "^5.6.2",
182-
"@vscode/vsce": "^3.7.1"
191+
"typescript": "^5.6.2"
183192
},
184193
"dependencies": {
185194
"@finos/calm-models": "file:../../calm-models",

calm-plugins/vscode/src/calm-extension-controller.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { CommandRegistrar } from './commands/command-registrar'
1515
import { DiagnosticsService } from './core/services/diagnostics-service'
1616
import { createApplicationStore, type ApplicationStoreApi } from './application-store'
1717
import { setWidgetLogger } from '@finos/calm-shared'
18+
import { ValidationService } from './features/validation/validation-service'
1819

1920
/**
2021
* Main extension controller that orchestrates all VS Code extension functionality
@@ -84,6 +85,10 @@ export class CalmExtensionController {
8485

8586
new CommandRegistrar(context, store).registerAll()
8687

88+
// Initialize validation service (await to ensure schemas are loaded before validating documents)
89+
const validationService = new ValidationService(log, configService)
90+
await validationService.register(context)
91+
8792
const storeReactionMediator = new StoreReactionMediator(
8893
store,
8994
previewPanelFactory,
@@ -100,7 +105,8 @@ export class CalmExtensionController {
100105
previewPanelFactory,
101106
treeManager,
102107
editorFactory,
103-
storeReactionMediator
108+
storeReactionMediator,
109+
validationService
104110
)
105111
}
106112

calm-plugins/vscode/src/core/ports/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ export interface Config {
99
showLabels(): boolean
1010
urlMapping(): string | undefined
1111
docifyTheme(): string
12+
schemaAdditionalFolders(): string[]
1213
}
13-
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { CalmSchemaRegistry } from './calm-schema-registry'
3+
import type { Logger } from '../ports/logger'
4+
import type { Config } from '../ports/config'
5+
6+
// Mock vscode module
7+
vi.mock('vscode', () => ({
8+
workspace: {
9+
workspaceFolders: [{ uri: { fsPath: '/workspace' } }],
10+
fs: {
11+
readDirectory: vi.fn(),
12+
readFile: vi.fn()
13+
}
14+
},
15+
Uri: {
16+
file: vi.fn((path: string) => ({ fsPath: path })),
17+
joinPath: vi.fn((_base: any, ...parts: string[]) => ({ fsPath: `${_base.fsPath}/${parts.join('/')}` }))
18+
},
19+
FileType: {
20+
File: 1,
21+
Directory: 2
22+
}
23+
}))
24+
25+
describe('CalmSchemaRegistry', () => {
26+
let registry: CalmSchemaRegistry
27+
let mockLogger: Logger
28+
let mockConfig: Config
29+
let mockExtensionUri: any
30+
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
34+
mockLogger = {
35+
info: vi.fn(),
36+
warn: vi.fn(),
37+
error: vi.fn(),
38+
debug: vi.fn()
39+
}
40+
41+
mockConfig = {
42+
filesGlobs: vi.fn(() => []),
43+
templateGlobs: vi.fn(() => []),
44+
previewLayout: vi.fn(() => 'dagre'),
45+
showLabels: vi.fn(() => true),
46+
urlMapping: vi.fn(() => undefined),
47+
schemaAdditionalFolders: vi.fn(() => [])
48+
}
49+
50+
mockExtensionUri = { fsPath: '/extension' }
51+
52+
registry = new CalmSchemaRegistry(mockExtensionUri, mockLogger, mockConfig)
53+
})
54+
55+
describe('isKnownCalmSchema', () => {
56+
it('should recognize CALM release schema URLs', () => {
57+
expect(registry.isKnownCalmSchema('https://calm.finos.org/release/1.1/meta/calm.json')).toBe(true)
58+
expect(registry.isKnownCalmSchema('https://calm.finos.org/release/1.0/meta/calm.json')).toBe(true)
59+
})
60+
61+
it('should recognize CALM draft schema URLs', () => {
62+
expect(registry.isKnownCalmSchema('https://calm.finos.org/draft/2025-03/meta/calm.json')).toBe(true)
63+
})
64+
65+
it('should recognize other CALM meta schema files', () => {
66+
expect(registry.isKnownCalmSchema('https://calm.finos.org/release/1.1/meta/core.json')).toBe(true)
67+
expect(registry.isKnownCalmSchema('https://calm.finos.org/release/1.1/meta/flow.json')).toBe(true)
68+
})
69+
70+
it('should not recognize non-CALM URLs', () => {
71+
expect(registry.isKnownCalmSchema('https://json-schema.org/draft/2020-12/schema')).toBe(false)
72+
expect(registry.isKnownCalmSchema('https://example.com/schema.json')).toBe(false)
73+
})
74+
75+
it('should not recognize URLs with wrong path structure', () => {
76+
expect(registry.isKnownCalmSchema('https://calm.finos.org/other/path/schema.json')).toBe(false)
77+
})
78+
})
79+
80+
describe('reset', () => {
81+
it('should clear schemas and mark as not initialized', async () => {
82+
// First initialize
83+
const vscode = await import('vscode')
84+
vi.mocked(vscode.workspace.fs.readDirectory).mockResolvedValue([])
85+
86+
await registry.initialize()
87+
88+
// Then reset
89+
registry.reset()
90+
91+
// getRegisteredSchemaUrls should be empty after reset
92+
expect(registry.getRegisteredSchemaUrls()).toHaveLength(0)
93+
})
94+
})
95+
96+
describe('getSchemaPath', () => {
97+
it('should return undefined for unregistered schemas', () => {
98+
expect(registry.getSchemaPath('https://calm.finos.org/release/1.1/meta/calm.json')).toBeUndefined()
99+
})
100+
})
101+
102+
describe('getRegisteredSchemaUrls', () => {
103+
it('should return empty array initially', () => {
104+
expect(registry.getRegisteredSchemaUrls()).toEqual([])
105+
})
106+
})
107+
108+
describe('initialize', () => {
109+
it('should load schemas from bundled directory', async () => {
110+
const vscode = await import('vscode')
111+
112+
// Mock directory structure: dist/calm/release/1.1/meta/calm.json
113+
vi.mocked(vscode.workspace.fs.readDirectory)
114+
.mockResolvedValueOnce([['release', 2]]) // dist/calm
115+
.mockResolvedValueOnce([['1.1', 2]]) // dist/calm/release
116+
.mockResolvedValueOnce([['meta', 2]]) // dist/calm/release/1.1
117+
.mockResolvedValueOnce([['calm.json', 1]]) // dist/calm/release/1.1/meta
118+
119+
vi.mocked(vscode.workspace.fs.readFile).mockResolvedValue(
120+
Buffer.from(JSON.stringify({
121+
$id: 'https://calm.finos.org/release/1.1/meta/calm.json',
122+
title: 'CALM Schema'
123+
}))
124+
)
125+
126+
await registry.initialize()
127+
128+
expect(registry.getSchemaPath('https://calm.finos.org/release/1.1/meta/calm.json')).toBeDefined()
129+
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('initialized with'))
130+
})
131+
132+
it('should handle missing directories gracefully', async () => {
133+
const vscode = await import('vscode')
134+
135+
vi.mocked(vscode.workspace.fs.readDirectory).mockRejectedValue(new Error('Directory not found'))
136+
137+
await registry.initialize()
138+
139+
// Should not throw - silently handles missing directory
140+
expect(registry.getRegisteredSchemaUrls()).toHaveLength(0)
141+
})
142+
143+
it('should only initialize once', async () => {
144+
const vscode = await import('vscode')
145+
vi.mocked(vscode.workspace.fs.readDirectory).mockResolvedValue([])
146+
147+
await registry.initialize()
148+
await registry.initialize()
149+
150+
// readDirectory should only be called for the first initialization
151+
expect(vscode.workspace.fs.readDirectory).toHaveBeenCalledTimes(1)
152+
})
153+
154+
it('should load schemas from additional folders', async () => {
155+
const vscode = await import('vscode')
156+
157+
// Configure additional folders
158+
vi.mocked(mockConfig.schemaAdditionalFolders).mockReturnValue(['my-schemas'])
159+
160+
// Mock bundled schemas directory (empty)
161+
vi.mocked(vscode.workspace.fs.readDirectory)
162+
.mockResolvedValueOnce([]) // dist/calm (empty)
163+
.mockResolvedValueOnce([['custom.json', 1]]) // my-schemas
164+
165+
vi.mocked(vscode.workspace.fs.readFile).mockResolvedValue(
166+
Buffer.from(JSON.stringify({
167+
$id: 'https://my-org.com/schemas/custom.json',
168+
title: 'Custom Schema'
169+
}))
170+
)
171+
172+
await registry.initialize()
173+
174+
expect(registry.getSchemaPath('https://my-org.com/schemas/custom.json')).toBeDefined()
175+
})
176+
})
177+
})

0 commit comments

Comments
 (0)