diff --git a/.gts-spec b/.gts-spec index caecc27..deec643 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit caecc273aad0aff47d77e05b87ed4b944af85e99 +Subproject commit deec64342510e2456a7afd11f7cb42c426f8fda7 diff --git a/README.md b/README.md index d1e6115..7731417 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A three-headed application for validating and viewing the [GTS](https://github.c This project is aimed at exploring and using GTS schemas and instances across platforms. +The repository is conformant with gts-spec v0.13.4. + ![GTS Viewer](./docs/gts-viewer.png) ## Quick Start diff --git a/apps/vscode-extension/CHANGELOG.md b/apps/vscode-extension/CHANGELOG.md index afacad6..486e847 100644 --- a/apps/vscode-extension/CHANGELOG.md +++ b/apps/vscode-extension/CHANGELOG.md @@ -2,36 +2,97 @@ All notable changes to the GTS Viewer extension will be documented in this file. -## [1.0.0] - 2025-10-08 +## [Unreleased] + +## [0.3.0] - 2026-09-18 + + + +## [0.2.6] - 2026-09-07 + +### Fixed +- Malformed `gts://` schema IDs now surface correctly everywhere instead of failing silently +- Gray chip shown for unresolved GTS IDs in schema examples +- Compact web UI layout for the VS Code webview; validation errors now wrap properly +- `GTS: Open Viewer` now opens the currently selected file ### Added -- **Context Menu Integration**: Right-click `.json` and `.gts` files to preview layouts -- **Visual Layout Viewer**: Interactive diagram showing JSON schemas and instances -- **Layout Persistence**: Save and load custom layouts from workspace `.gts-viewer/` folder -- **Dual Panel View**: File opens in editor (left) with preview panel (right) -- **Welcome Message**: First-time user guidance -- **Auto-detection**: Automatic file validation for supported formats - -### Features -- Support for `.json` and `.gts` file types -- RepoLayoutStorage integration for team-shared layouts -- Version control friendly layout storage -- Error handling with user-friendly messages -- Webview-based rendering for rich visual experience - -### Commands -- `GTS: Preview Layout` - Open selected file in visual viewer -- `GTS: Open Viewer` - Show usage instructions - -### Context Menu Locations -- Explorer context menu (right-click files) -- Editor title context menu +- Diagnostics shown when the web server isn't running +- A progress bar for large repo scans -## [Unreleased] +## [0.2.5] - 2026-09-06 + +### Changed +- Renamed extension identifier to `gts-kit` (the `gts` name was already taken on the VS Code Marketplace) +- Reduced packaged extension size + +## [0.2.4] - 2026-09-06 + +### Fixed +- Rebased schema validation on `gts-ts` +- Invalid JSON is now rejected during editor validation +- Removed duplicated validation errors in the GTS viewer +- Consistent YAML parsing across shared registry and editor validation + +### Added +- All/errors/valid entities selector in the GTS viewer + +## [0.2.3] - 2026-08-31 + +### Added +- Scoped background validation for unopened GTS files +- GTS brand logo as extension and activity bar icon +- Enforced `gts://` URI prefix rules for JSON Schema fields + +### Fixed +- Two-phase prioritized file scan with `.gitignore` exclusion for better performance +- Persisted GTS registry to reduce editor open latency +- Removed redundant margins around GTS string annotations in the editor +- Consistent segment gap width across GTS segment styles -### Planned -- Multi-file comparison view -- Layout templates -- Export to image/SVG -- Search and filter capabilities -- Enhanced keyboard shortcuts +## [0.2.2] - 2026-08-31 + +### Added +- YAML file format support +- Schema examples preview feature +- NOTICE file with copyright and license information + +### Changed +- Aligned schema handling with GTS spec v0.7 +- Prioritize the GTS ID (`id`, `gtsId`, etc.) over the `type` field for schema resolution +- Disabled GTS reference validation for `/examples` in schemas + +### Fixed +- Slow GTS color annotations on file open +- Popup GTS error display position +- Restored VS Code editor inline validation +- Removed redundant file link in the web viewer + +## [0.2.1] - 2025-10-22 + +### Added +- Open the file containing a GTS node directly from the VS Code editor + +### Changed +- Cumulative visual style polish for the web view and VS Code +- Neutral file link color (blue was reserved for "schema" elsewhere) + +### Fixed +- Color annotations for broken GTS IDs +- GTS replacement when clicking an auto-suggestion popup +- Rescan JSON files on edits even when the web viewer isn't active + +## [0.2.0] - 2025-10-19 + +### Added +- Inline JSON/JSONC/GTS file validation inside the VS Code editor +- Colored GTS ID validation and suggestions in the editor +- Support for `.jsonc` and `.gts` file extensions + +### Changed +- Switched from `better-sqlite3` to `sql.js` (no native compilation required) + +## [0.1.0] - 2025-10-16 + +### Added +- Initial release of the GTS Viewer VS Code extension diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md index 6e33ac7..7aa5c69 100644 --- a/apps/vscode-extension/README.md +++ b/apps/vscode-extension/README.md @@ -111,9 +111,14 @@ See the full [GTS Specification](https://github.com/globaltypesystem/gts-spec) f - **`.json`** — Standard JSON (schemas and instances) - **`.jsonc`** — JSON with Comments (single-line, multi-line, trailing commas) -- **`.yaml` / `.yml`** — YAML files parsed and treated identically to JSON +- **`.yaml` / `.yml`** — YAML files parsed and treated like JSON, **plus** inline GTS definitions (see below) - **`.gts`** — GTS-specific files +#### Supported GTS Entity Definitions + +- **JSON / JSONC / `.gts`** — a document must be either a **single entity** or a **top-level array of entities**. GTS IDs anywhere else are treated as *references*. +- **YAML** — everything above, **plus** inline definitions: a config file may *define* GTS types/instances under any nested `entities:` array (e.g. a service's `types-registry.config.entities` seed block), even deep inside otherwise-non-GTS config. Each element is registered by its `$id` as a real definition, so it is not flagged as an unresolved reference. + ### Validation The extension validates GTS files automatically as you open, edit, and save them. diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 8b4a049..36137a8 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "gts-kit", "displayName": "Global Type System (GTS) Kit", "description": "Global Type System (GTS) support for VS Code — browse, validate,visualize, and manage GTS schemas and instances", - "version": "0.2.6", + "version": "0.3.0", "private": true, "publisher": "GlobalTypeSystem", "license": "Apache-2.0", @@ -19,16 +19,21 @@ "vscode": "^1.85.0" }, "categories": [ - "Visualization", - "Other" + "Formatters", + "Linters", + "Visualization" ], - "activationEvents": [ - "onCommand:gts.openViewer", - "onLanguage:json", - "onLanguage:jsonc", - "onLanguage:gts" + "tags": [ + "gts", + "json", + "jsonc", + "jsonchema", + "validation" ], "main": "./dist/extension.js", + "activationEvents": [ + "onStartupFinished" + ], "contributes": { "languages": [ { @@ -39,9 +44,15 @@ ], "commands": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "title": "GTS: Open Viewer", "category": "GTS" + }, + { + "command": "gts-kit.refreshFileExplorer", + "title": "GTS: Refresh Discovered Files", + "category": "GTS", + "icon": "$(refresh)" } ], "viewsContainers": { @@ -56,40 +67,54 @@ "views": { "gts-viewer": [ { - "type": "webview", - "id": "gts.viewerPanel", - "name": "Schema Viewer" + "id": "gts-kit.fileExplorer", + "name": "Discovered GTS files", + "icon": "resources/icon.svg", + "contextualTitle": "GTS Files" } ] }, + "viewsWelcome": [ + { + "view": "gts-kit.fileExplorer", + "contents": "No [GTS](https://globaltypesystem.org/#vscode-plugins) files discovered yet.\n[GTS](https://globaltypesystem.org/#vscode-plugins) files (schemas and instances) found anywhere in the workspace will be listed here." + } + ], "menus": { "explorer/context": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "navigation@100", "when": "resourceExtname =~ /\\.(json|jsonc|gts)$/" } ], + "view/title": [ + { + "command": "gts-kit.refreshFileExplorer", + "group": "navigation@1", + "when": "view == gts-kit.fileExplorer" + } + ], "editor/title": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "navigation@100", "when": "editorLangId == json" }, { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "navigation@100", "when": "editorLangId == jsonc" }, { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "navigation@100", "when": "editorLangId == gts" } ], "menubar/file": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "2_open" } ] @@ -116,6 +141,7 @@ "esbuild": "^0.19.0", "ignore": "^5.3.2", "jsonc-parser": "^3.3.1", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "yaml": "^2.9.1" } } diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index aac931d..39d586e 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,13 +1,16 @@ import * as vscode from 'vscode' import * as path from 'path' +import * as fs from 'fs' import { parseGtsFileContent, JsonRegistry, DEFAULT_GTS_CONFIG } from '@gts/shared' +import type { EntityValidationDto, ObjValidationDto, InvalidFileValidationDto, ValidationRelayPayload } from '@gts/shared' import { setLastScanFiles } from './scanStore' -import { rebuildRegistry, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry } from './registryStore' +import { rebuildRegistry, rebuildRegistryIfUnchanged, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry, getRegistry, getRegistryRevision } from './registryStore' import { getWorkspaceIgnore, resetWorkspaceIgnore, getCachedMatcher, isIgnoredRel } from './gitignore' import { RepoLayoutStorage } from './storage' -import { initValidation, validateOpenDocument, validateWorkspaceInBackground } from './validation' +import { initValidation, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents, onValidationCompleted } from './validation' import { isGtsCandidateFile } from './helpers' import { GtsLinkProvider } from './linkProvider' +import { registerGtsExplorer, type GtsExplorer } from './gtsExplorer' import type { LayoutSaveRequest, LayoutTarget, LayoutSnapshot } from '@gts/layout-storage' // Glob used for all GTS workspace scans and the on-disk file watcher. @@ -56,12 +59,82 @@ function isUriIgnored(uri: vscode.Uri, matcher = getCachedMatcher()): boolean { return isIgnoredRel(matcher, vscode.workspace.asRelativePath(uri, false)) } +// Maps a file's resolved *real* path -> the workspace path we index it under. +// The workspace symlinks (e.g. .gts-spec, .gts-spec-ext, .gears-rust/.gts-spec) +// can make the same physical file reachable via several paths; without this the +// same GTS entity would be scanned multiple times, producing duplicate tree rows +// and a nondeterministic id->file mapping. We index each physical file exactly +// once and let the most-recently-scanned/edited path win (so an open file, which +// is scanned first, stays canonical and gets its in-editor diagnostics). +const realPathIndex = new Map() + +/** Resolve a path to its canonical real path; fall back to the input on error. */ +function resolveRealPath(fsPath: string): string { + try { return fs.realpathSync.native(fsPath) } catch { return fsPath } +} + +/** Drop any canonical-path entries that point at `fsPath` (on delete/rename). */ +function forgetIndexedPath(fsPath: string): void { + for (const [real, p] of realPathIndex) { + if (p === fsPath) realPathIndex.delete(real) + } +} + +/** + * Keep only one URI per physical file, recording the canonical path chosen. + * First-seen wins, so callers should pass higher-priority paths (open files) + * first. Duplicates reached through other symlinks are dropped. + */ +function dedupeUrisByRealPath(uris: vscode.Uri[]): vscode.Uri[] { + const out: vscode.Uri[] = [] + for (const uri of uris) { + const real = resolveRealPath(uri.fsPath) + if (realPathIndex.has(real)) continue + realPathIndex.set(real, uri.fsPath) + out.push(uri) + } + return out +} + +/** + * Index a single file's live change, ensuring the physical file stays indexed + * under exactly one path. If another symlinked path currently owns this real + * file, drop it so the just-touched path becomes canonical (its diagnostics show + * in the editor). Returns nothing; callers still index the content themselves. + */ +function claimCanonicalPath(fsPath: string): void { + const real = resolveRealPath(fsPath) + const existing = realPathIndex.get(real) + if (existing && existing !== fsPath) { + removeFileFromRegistry(existing) + forgetIndexedPath(existing) + } + realPathIndex.set(real, fsPath) +} + let viewerPanel: vscode.WebviewPanel | null = null let layoutStorage: RepoLayoutStorage | null = null let hasPerformedInitialScan: boolean = false // Track if initial scan with default file has been done let gtsLinkProvider: GtsLinkProvider | null = null // File the user explicitly requested (context menu / command palette) — consumed by the first scanAndPost let pendingOpenFile: string | null = null +// Left-sidebar GTS file browser (tree view + red/green file decorations), shares the same registry as everything else. +let gtsExplorer: GtsExplorer | null = null +let workspaceMutationRevision = 0 +let fullScanQueue: Promise = Promise.resolve() + +type StableScanOperation = (expectedMutationRevision: number) => Promise + +function enqueueStableScan(operation: StableScanOperation): Promise { + const execute = async () => { + while (!await operation(workspaceMutationRevision)) { + await new Promise(resolve => setTimeout(resolve, 50)) + } + } + const run = fullScanQueue.then(execute, execute) + fullScanQueue = run.catch(() => {}) + return run +} function getNonce(): string { let text = '' @@ -72,8 +145,13 @@ function getNonce(): string { return text } -async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: boolean = false, refreshFilePath?: string | null) { +async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: boolean = false, refreshFilePath?: string | null): Promise { + await enqueueStableScan(expectedMutationRevision => scanAndPostPass(includeGlob, isInitialScan, refreshFilePath, expectedMutationRevision)) +} + +async function scanAndPostPass(includeGlob: string, isInitialScan: boolean, refreshFilePath: string | null | undefined, expectedMutationRevision: number): Promise { const hasViewer = viewerPanel !== null + const expectedRegistryRevision = getRegistryRevision() try { let selectedFilePath: string | null = null @@ -95,7 +173,11 @@ async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: b // GTS. const { matcher: ignoreMatcher, excludeGlobs: ignoreGlobs } = await getWorkspaceIgnore() const exclude = combineExcludeGlobs(FAST_EXCLUDE_GLOB, ignoreGlobs) - const uris = await vscode.workspace.findFiles(include, exclude, 40000) + const enumerated = await vscode.workspace.findFiles(include, exclude, 40000) + // A full (re)scan re-establishes the canonical physical-file set; collapse + // symlinked duplicates so the same GTS entity isn't scanned/listed twice. + realPathIndex.clear() + const uris = dedupeUrisByRealPath(enumerated) const total = uris.length const startTime = Date.now() @@ -141,10 +223,13 @@ async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: b // Update the shared, persistent, index-only registry (used by decorations, // links, hovers and as validation resolution context). This is cheap. - const registry = await rebuildRegistry(files, DEFAULT_GTS_CONFIG) + if (workspaceMutationRevision !== expectedMutationRevision) return false + const registry = await rebuildRegistryIfUnchanged(files, expectedRegistryRevision, DEFAULT_GTS_CONFIG) + if (!registry) return false if (selectedFilePath) { (registry as any).setDefaultFile?.(selectedFilePath) } + gtsExplorer?.refresh() // Send scan result with default file path so the webview can compute initial selection if (hasViewer) { @@ -167,10 +252,11 @@ async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: b try { const vreg = new JsonRegistry() await vreg.ingestFiles(files, DEFAULT_GTS_CONFIG) - const objs = Array.from(vreg.jsonObjs.values()).map(o => ({ id: o.id, listSequence: o.listSequence, filePath: o.file?.path, schemaId: o.schemaId, validation: o.validation })) - const schemas = Array.from(vreg.jsonSchemas.values()).map(s => ({ id: s.id, filePath: s.file?.path, validation: s.validation })) - const invalidFilesHost = Array.from(vreg.invalidFiles.values()).map(f => ({ path: f.path, name: f.name, validation: f.validation })) - viewerPanel!.webview.postMessage({ type: 'gts-validation-result', detail: { objs, schemas, invalidFiles: invalidFilesHost } }) + const objs: ObjValidationDto[] = Array.from(vreg.jsonObjs.values()).map(o => ({ id: o.id, listSequence: o.listSequence, filePath: o.file?.path, schemaId: o.schemaId, validation: o.validation })) + const schemas: EntityValidationDto[] = Array.from(vreg.jsonSchemas.values()).map(s => ({ id: s.id, filePath: s.file?.path, validation: s.validation })) + const invalidFiles: InvalidFileValidationDto[] = Array.from(vreg.invalidFiles.values()).map(f => ({ path: f.path, name: f.name, validation: f.validation })) + const payload: ValidationRelayPayload = { objs, schemas, invalidFiles } + viewerPanel!.webview.postMessage({ type: 'gts-validation-result', detail: payload }) } catch (ve: any) { viewerPanel!.webview.postMessage({ type: 'gts-validation-error', detail: { error: ve?.message || String(ve) } }) } @@ -185,7 +271,7 @@ async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: b // Publish workspace-wide file diagnostics for unopened files, then refresh // open-document diagnostics with precise ranges. - await validateWorkspaceInBackground(getBackgroundValidationRoots()) + await validateWorkspaceInBackground() // Re-validate all open documents now that we have the full registry console.log('[GTS] Re-validating all open documents...') @@ -194,36 +280,43 @@ async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: b void validateOpenDocument(doc) } }) + return workspaceMutationRevision === expectedMutationRevision } catch (error: any) { if (hasViewer) { viewerPanel!.webview.postMessage({ type: 'gts-scan-error', detail: { error: error.message || String(error) } }) } + return true } } export async function activate(context: vscode.ExtensionContext) { console.log('[GTS] Extension activating...') - // Perform initial workspace scan for validation (background, non-blocking) - console.log('[GTS] Starting initial workspace scan for validation...') - performInitialScan().catch(error => { - console.error('[GTS] Initial scan failed:', error) - }) - initValidation(context) // Create diagnostic collection for GTS validation - const gtsDiagnostics = vscode.languages.createDiagnosticCollection('gts') + const gtsDiagnostics = vscode.languages.createDiagnosticCollection('gts-link-format') context.subscriptions.push(gtsDiagnostics) // Initialize and register GTS link provider for clickable GTS IDs gtsLinkProvider = new GtsLinkProvider(gtsDiagnostics) + // Repaint editor decorations whenever document validation completes + context.subscriptions.push( + onValidationCompleted(uri => { + gtsLinkProvider?.updateDecorationsForUri(uri) + }) + ) + + // Left sidebar: file browser tree + red/green file decorations, sharing the same registry. + gtsExplorer = registerGtsExplorer(context) + // Register link provider for JSON, JSONC, and GTS files const documentSelector: vscode.DocumentSelector = [ { language: 'json', scheme: 'file' }, { language: 'jsonc', scheme: 'file' }, - { language: 'gts', scheme: 'file' } + { language: 'gts', scheme: 'file' }, + { language: 'yaml', scheme: 'file' } ] context.subscriptions.push( @@ -248,9 +341,7 @@ export async function activate(context: vscode.ExtensionContext) { // Keep the shared registry in sync with on-disk changes that don't go through // the editor: files edited outside the IDE (git pull/checkout, terminal, - // external tools) and create/rename/delete performed anywhere. The watcher - // also fires for in-IDE saves/creates/deletes; those cases either defer to the - // editor handlers (open documents) or are handled idempotently here. + // external tools). const gtsWatcher = vscode.workspace.createFileSystemWatcher(GTS_SCAN_GLOB) context.subscriptions.push(gtsWatcher) context.subscriptions.push( @@ -259,6 +350,35 @@ export async function activate(context: vscode.ExtensionContext) { gtsWatcher.onDidDelete(uri => { onDiskFileDeleted(uri) }) ) + // The recursive workspace watcher above does NOT follow directory symlinks + // that resolve outside the watched folder, so files reached only through such + // a symlink (e.g. `.examples -> ../gts-spec/...`) never emit create/change/ + // delete events. Add an explicit recursive watcher rooted at each symlinked + // directory so external OS edits under it are tracked too. + void watchSymlinkedDirs(context) + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { void watchSymlinkedDirs(context) }) + ) + + context.subscriptions.push( + vscode.workspace.onDidCreateFiles(event => { + for (const uri of event.files) { + if (!isGtsScanPath(uri.fsPath)) continue + const openDoc = vscode.workspace.textDocuments.find(doc => doc.uri.fsPath === uri.fsPath) + if (openDoc) { + handleFileChange(openDoc, 0) + } else { + void onDiskFileChanged(uri) + } + } + }), + vscode.workspace.onDidDeleteFiles(event => { + for (const uri of event.files) { + if (isGtsScanPath(uri.fsPath)) onDiskFileDeleted(uri) + } + }) + ) + // Handle in-IDE renames explicitly: the watcher's create event is skipped for // files open in the editor, and no text-change event fires on rename, so the // new path would otherwise stay unindexed. (External renames arrive as @@ -266,9 +386,11 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidRenameFiles(async event => { for (const { oldUri, newUri } of event.files) { + beginFileMutation(oldUri.fsPath) removeFileFromRegistry(oldUri.fsPath) + forgetIndexedPath(oldUri.fsPath) if (isIgnoredGtsPath(newUri.fsPath)) continue - if (!/\.(json|jsonc|gts|ya?ml)$/i.test(newUri.fsPath)) continue + if (!isGtsScanPath(newUri.fsPath)) continue const openDoc = vscode.workspace.textDocuments.find(d => d.uri.fsPath === newUri.fsPath) if (openDoc) { handleFileChange(openDoc, 0) @@ -285,6 +407,7 @@ export async function activate(context: vscode.ExtensionContext) { const gitignoreWatcher = vscode.workspace.createFileSystemWatcher('**/.gitignore') context.subscriptions.push(gitignoreWatcher) const onGitignoreChanged = () => { + workspaceMutationRevision++ resetWorkspaceIgnore() void performInitialScan() } @@ -294,6 +417,12 @@ export async function activate(context: vscode.ExtensionContext) { gitignoreWatcher.onDidDelete(onGitignoreChanged) ) + // Perform initial workspace scan for validation (background, non-blocking) + console.log('[GTS] Starting initial workspace scan for validation...') + performInitialScan().catch(error => { + console.error('[GTS] Initial scan failed:', error) + }) + // Initial decoration for all visible editors if (gtsLinkProvider) { for (const editor of vscode.window.visibleTextEditors) { @@ -305,8 +434,16 @@ export async function activate(context: vscode.ExtensionContext) { // Register commands context.subscriptions.push( - vscode.commands.registerCommand('gts.openViewer', (resource?: vscode.Uri) => { + vscode.commands.registerCommand('gts-kit.openViewer', (resource?: vscode.Uri) => { openViewer(context, resource) + }), + vscode.commands.registerCommand('gts-kit.refreshFileExplorer', async () => { + try { + await refreshGtsFileExplorer(gtsDiagnostics) + } catch (error: any) { + console.error('[GTS] Full refresh failed:', error) + vscode.window.showErrorMessage(`Failed to refresh GTS files: ${error?.message || String(error)}`) + } }) ) @@ -428,36 +565,32 @@ function revalidateOpenDocs(): void { }) } -/** - * Background validation scope: currently focused GTS folder(s), not whole repo. - * VS Code does not expose built-in Explorer expanded folders, so we scope by - * active/open GTS docs as the closest approximation. - */ -function getBackgroundValidationRoots(): string[] { - const roots: string[] = [] - const seen = new Set() - const add = (dir: string) => { - if (!seen.has(dir)) { - seen.add(dir) - roots.push(dir) - } - } - - const active = vscode.window.activeTextEditor?.document - if (active?.uri.scheme === 'file' && isGtsCandidateFile(active)) { - add(path.dirname(active.uri.fsPath)) - } - - for (const doc of vscode.workspace.textDocuments) { - if (doc.uri.scheme === 'file' && isGtsCandidateFile(doc)) { - add(path.dirname(doc.uri.fsPath)) - } - } +async function refreshGtsFileExplorer(linkDiagnostics: vscode.DiagnosticCollection): Promise { + workspaceMutationRevision++ + fileMutationRevisions.clear() + if (changeTimer) clearTimeout(changeTimer) + if (externalChangeTimer) clearTimeout(externalChangeTimer) + changeTimer = null + externalChangeTimer = null + preEditIdsByPath.clear() + pendingOpenFile = null + realPathIndex.clear() + resetWorkspaceIgnore() + setLastScanFiles([]) + await rebuildRegistry([], DEFAULT_GTS_CONFIG) + resetValidationDiagnostics() + linkDiagnostics.clear() + gtsExplorer?.reset() + await gtsLinkProvider?.refresh() + await performInitialScan() +} - return roots +async function performInitialScan(): Promise { + await enqueueStableScan(performInitialScanPass) } -async function performInitialScan() { +async function performInitialScanPass(expectedMutationRevision: number): Promise { + const expectedRegistryRevision = getRegistryRevision() try { // Load .gitignore rules first so both phases permanently exclude ignored // files/folders (at enumeration time via globs, plus an authoritative @@ -465,6 +598,9 @@ async function performInitialScan() { const { matcher: ignoreMatcher, excludeGlobs: ignoreGlobs } = await getWorkspaceIgnore() const openPaths = collectOpenGtsPaths() + // A full scan re-establishes the canonical set of physical files. + realPathIndex.clear() + // --- Phase 1: fast pass ------------------------------------------------- // Enumerate with FAST_EXCLUDE_GLOB (+ gitignore) so build-output/dependency // trees (target, node_modules, ...) and ignored paths aren't even walked. @@ -487,15 +623,23 @@ async function performInitialScan() { phase1Paths.add(uri.fsPath) } - console.log(`[GTS] Phase 1: ${phase1Candidates.length} candidate files (of ${fastUris.length} enumerated)`) - const files1 = await readGtsCandidateFiles(phase1Candidates) + // Collapse symlinked duplicates to one physical file each (open files first, + // so they stay canonical). + const phase1Deduped = dedupeUrisByRealPath(phase1Candidates) + console.log(`[GTS] Phase 1: ${phase1Deduped.length} candidate files (of ${fastUris.length} enumerated, ${phase1Candidates.length} before real-path dedup)`) + const files1 = await readGtsCandidateFiles(phase1Deduped) + if (workspaceMutationRevision !== expectedMutationRevision) return false + const registry = await rebuildRegistryIfUnchanged(files1, expectedRegistryRevision, DEFAULT_GTS_CONFIG) + if (!registry) return false setLastScanFiles(files1) - const registry = await rebuildRegistry(files1, DEFAULT_GTS_CONFIG) console.log(`[GTS] Phase 1 registry: ${registry.jsonSchemas.size} schemas, ${registry.jsonObjs.size} objects (${files1.length} GTS files)`) + gtsExplorer?.refresh() // Paint decorations + validate now that phase-1 registry is available. + // Validate the whole workspace (not just the folders of currently-open docs) + // so all findings/badge counts are present immediately after a window reload. await gtsLinkProvider?.refresh() - await validateWorkspaceInBackground(getBackgroundValidationRoots()) + await validateWorkspaceInBackground() revalidateOpenDocs() // --- Phase 2: background pass ------------------------------------------- @@ -504,18 +648,24 @@ async function performInitialScan() { // files. Runs after the UI is already coloured, so its cost is not visible. const phase2Exclude = combineExcludeGlobs(ALWAYS_EXCLUDE_GLOB, ignoreGlobs) const allUris = await vscode.workspace.findFiles(GTS_SCAN_GLOB, phase2Exclude, 100000) - const phase2Uris = allUris.filter(uri => !phase1Paths.has(uri.fsPath) && !isUriIgnored(uri, ignoreMatcher)) + // Skip anything already indexed in phase 1 and any symlinked duplicate of a + // physical file we've already taken (the realPathIndex still holds phase 1). + const phase2Prefiltered = allUris.filter(uri => !phase1Paths.has(uri.fsPath) && !isUriIgnored(uri, ignoreMatcher)) + const phase2Uris = dedupeUrisByRealPath(phase2Prefiltered) if (phase2Uris.length > 0) { const files2 = await readGtsCandidateFiles(phase2Uris) + if (workspaceMutationRevision !== expectedMutationRevision) return false if (files2.length > 0) { for (const f of files2) indexFileInRegistry(f.path, f.name, f.content) setLastScanFiles([...files1, ...files2]) + gtsExplorer?.refresh() await gtsLinkProvider?.refresh() - await validateWorkspaceInBackground(getBackgroundValidationRoots()) + await validateWorkspaceInBackground() revalidateOpenDocs() } console.log(`[GTS] Phase 2: merged ${files2.length} GTS files (of ${phase2Uris.length} deferred)`) } + return workspaceMutationRevision === expectedMutationRevision } catch (error) { console.error('[GTS] Initial scan error:', error) throw error @@ -535,12 +685,64 @@ export async function deactivate() { gtsLinkProvider = null } + gtsExplorer = null layoutStorage = null } // Debounced rescan on change to auto-refresh layout view while typing let changeTimer: NodeJS.Timeout | null = null +function isGtsScanPath(fsPath: string): boolean { + return /\.(json|jsonc|gts|ya?ml)$/i.test(fsPath) +} + +// Symlinked directories we've already attached a dedicated watcher to (keyed by +// the symlink's fsPath), so repeated setup calls don't create duplicate watchers. +const watchedSymlinkDirs = new Set() + +/** + * VS Code's recursive workspace watcher does not follow directory symlinks that + * point outside the watched folder. Create an explicit recursive watcher rooted + * at each top-level symlinked directory in every workspace folder so on-disk + * create/change/delete events under it are reported. VS Code preserves the + * watched (symlink) path in the emitted URIs, which matches how the scan indexes + * those files, so no path translation is needed. + */ +async function watchSymlinkedDirs(context: vscode.ExtensionContext): Promise { + const folders = vscode.workspace.workspaceFolders || [] + for (const folder of folders) { + let entries: [string, vscode.FileType][] + try { + entries = await vscode.workspace.fs.readDirectory(folder.uri) + } catch { + continue + } + for (const [name, type] of entries) { + if (!(type & vscode.FileType.SymbolicLink)) continue + const linkUri = vscode.Uri.joinPath(folder.uri, name) + if (watchedSymlinkDirs.has(linkUri.fsPath)) continue + // Only follow symlinks that resolve to a directory (stat follows the link). + try { + const stat = await vscode.workspace.fs.stat(linkUri) + if (!(stat.type & vscode.FileType.Directory)) continue + } catch { + continue + } + if (isIgnoredGtsPath(linkUri.fsPath + path.sep)) continue + watchedSymlinkDirs.add(linkUri.fsPath) + const pattern = new vscode.RelativePattern(linkUri, `**/*.{json,jsonc,gts,yaml,yml}`) + const watcher = vscode.workspace.createFileSystemWatcher(pattern) + context.subscriptions.push( + watcher, + watcher.onDidCreate(uri => { void onDiskFileChanged(uri) }), + watcher.onDidChange(uri => { void onDiskFileChanged(uri) }), + watcher.onDidDelete(uri => { onDiskFileDeleted(uri) }) + ) + console.log('[GTS] Watching symlinked directory:', linkUri.fsPath) + } + } +} + /** Paths we never index (build output, VCS internals, our own cache). */ function isIgnoredGtsPath(fsPath: string): boolean { return /(^|[\\/])(node_modules|\.gts-viewer|dist|\.git)[\\/]/.test(fsPath) @@ -561,13 +763,18 @@ async function onDiskFileChanged(uri: vscode.Uri): Promise { if (isIgnoredGtsPath(fsPath)) return if (isUriIgnored(uri)) return if (isOpenInEditor(fsPath)) return + const mutationRevision = beginFileMutation(fsPath) try { const data = await vscode.workspace.fs.readFile(uri) const text = Buffer.from(data).toString('utf8') const name = path.basename(fsPath) let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } + if (fileMutationRevisions.get(fsPath) !== mutationRevision || !fs.existsSync(fsPath)) return + // Keep one entry per physical file even when reached via a symlinked path. + claimCanonicalPath(fsPath) indexFileInRegistry(fsPath, name, content) + gtsExplorer?.refresh() } catch (e) { console.error('[GTS] Failed to reindex changed file from disk:', fsPath, e) return @@ -578,15 +785,25 @@ async function onDiskFileChanged(uri: vscode.Uri): Promise { /** A GTS file was deleted/renamed-away on disk. Drop its entities from the registry. */ function onDiskFileDeleted(uri: vscode.Uri): void { const fsPath = uri.fsPath - if (isIgnoredGtsPath(fsPath)) return - if (isUriIgnored(uri)) return + beginFileMutation(fsPath) removeFileFromRegistry(fsPath) + forgetIndexedPath(fsPath) + gtsExplorer?.refresh() scheduleExternalChangeSettle() } // Debounce a burst of on-disk changes (e.g. a git checkout touching many files) // into a single UI/validation refresh. let externalChangeTimer: NodeJS.Timeout | null = null +const fileMutationRevisions = new Map() + +function beginFileMutation(fsPath: string): number { + workspaceMutationRevision++ + const revision = (fileMutationRevisions.get(fsPath) || 0) + 1 + fileMutationRevisions.set(fsPath, revision) + return revision +} + function scheduleExternalChangeSettle(): void { if (externalChangeTimer) clearTimeout(externalChangeTimer) externalChangeTimer = setTimeout(() => { @@ -596,10 +813,12 @@ function scheduleExternalChangeSettle(): void { return } // No viewer: repaint + refresh workspace diagnostics and then re-validate - // open docs with precise ranges. + // open docs with precise ranges. A burst of on-disk changes can touch files + // anywhere in the repo (git checkout, external tools), so validate the whole + // workspace rather than only the currently-focused folders. void (async () => { await gtsLinkProvider?.refresh() - await validateWorkspaceInBackground(getBackgroundValidationRoots()) + await validateWorkspaceInBackground() vscode.workspace.textDocuments.forEach(doc => { if (isGtsCandidateFile(doc)) void validateOpenDocument(doc) }) @@ -607,17 +826,35 @@ function scheduleExternalChangeSettle(): void { }, 300) } +// Ids each file defined *before* the current burst of edits, captured prior to +// the first reindex so a renamed/removed id still revalidates its old referrers. +// Keyed by fsPath; cleared when the debounced revalidation fires. +const preEditIdsByPath = new Map>() + function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { if (!isGtsCandidateFile(doc)) return + const fsPath = doc.uri.fsPath + beginFileMutation(fsPath) + + // Snapshot the file's ids from before this edit burst (once per burst), before + // the immediate reindex below overwrites them in the registry. + if (!preEditIdsByPath.has(fsPath)) { + const registry = getRegistry() + preEditIdsByPath.set(fsPath, new Set(registry?.getEntityIdsForFile(fsPath) || [])) + } + // Immediate + cheap: keep the shared registry index and the editor's color // annotations in sync with the live document as the user types. No Ajv here. try { const text = doc.getText() - const name = path.basename(doc.uri.fsPath) + const name = path.basename(fsPath) let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } - indexFileInRegistry(doc.uri.fsPath, name, content) + // Ensure this physical file is indexed under exactly this (open) path. + claimCanonicalPath(fsPath) + indexFileInRegistry(fsPath, name, content) + gtsExplorer?.refresh() } catch (e) { console.error('[GTS] Incremental index failed:', e) } @@ -630,9 +867,17 @@ function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { // panel is open) run the full workspace rescan that feeds the webview. if (changeTimer) clearTimeout(changeTimer) changeTimer = setTimeout(() => { - void validateOpenDocument(doc) + const previousIds = preEditIdsByPath.get(fsPath) + preEditIdsByPath.clear() + void (async () => { + await validateOpenDocument(doc) + // Re-check everything that depends on this file (derived/instantiated + // types, $ref/allOf composers, and GTS-id referrers) so their markers + // reflect the edit, not just this doc. + await revalidateDependents(fsPath, previousIds) + })() if (viewerPanel) { - void scanAndPost(GTS_SCAN_GLOB, false, doc.uri.fsPath) + void scanAndPost(GTS_SCAN_GLOB, false, fsPath) } }, delayMsec) } diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts new file mode 100644 index 0000000..85b851b --- /dev/null +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -0,0 +1,289 @@ +import * as vscode from 'vscode' +import * as path from 'path' +import { getRegistry } from './registryStore' + +/** + * Left-sidebar file browser for GTS: shows every discovered file that holds at + * least one GTS schema/instance (or failed to parse), colors it green/red + * depending on whether it currently has GTS validation errors, and opens it in + * the editor on click. + * + * Both the file list and the error state are read straight from the shared + * registry (registryStore) and from VS Code's own diagnostics store — the same + * sources the link/decoration provider and the webview viewer already use — so + * the tree, the in-editor highlighting and the GTS Viewer diagrams always agree. + */ + +interface GtsFileNode { + kind: 'file' + label: string + fsPath: string +} + +interface GtsFolderNode { + kind: 'folder' + label: string + children: Map +} + +type GtsTreeElement = GtsFileNode | GtsFolderNode + +/** Build a nested folder/file tree (relative to the workspace root) from a flat list of absolute paths. */ +function buildFileTree(filePaths: string[], workspaceRoot: string): GtsFolderNode { + const root: GtsFolderNode = { kind: 'folder', label: '', children: new Map() } + for (const fsPath of filePaths) { + const rel = workspaceRoot ? path.relative(workspaceRoot, fsPath) : fsPath + const parts = rel.split(path.sep).filter(Boolean) + let current = root + parts.forEach((part, idx) => { + const isLast = idx === parts.length - 1 + if (isLast) { + current.children.set(part, { kind: 'file', label: part, fsPath }) + return + } + const existing = current.children.get(part) + if (existing && existing.kind === 'folder') { + current = existing + } else { + const folder: GtsFolderNode = { kind: 'folder', label: part, children: new Map() } + current.children.set(part, folder) + current = folder + } + }) + } + return root +} + +/** Folders first, then files, both alphabetically (case-insensitive). */ +function sortedChildren(folder: GtsFolderNode): GtsTreeElement[] { + return Array.from(folder.children.values()).sort((a, b) => { + if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1 + return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + }) +} + +/** Every file path the registry currently knows about (parsed GTS files + files that failed to parse). */ +function getDiscoveredFilePaths(): string[] { + const registry = getRegistry() + if (!registry) return [] + const paths = new Set([...registry.jsonFiles.keys(), ...registry.invalidFiles.keys()]) + return Array.from(paths) +} + +/** True if the given file currently has a GTS validation error reported on it. */ +export function hasGtsErrors(uri: vscode.Uri): boolean { + if (vscode.languages.getDiagnostics(uri).some(d => d.source === 'GTS')) return true + const registry = getRegistry() + if (!registry) return false + const fsPath = uri.fsPath + if (registry.invalidFiles.get(fsPath)?.validation?.errors.length) return true + const entities = [...(registry.jsonFileSchemas.get(fsPath) || []), ...(registry.jsonFileObjs.get(fsPath) || [])] + return entities.some(entity => Boolean(entity.validation?.errors.length)) +} + +/** Total number of GTS validation problems currently reported across the workspace. */ +function countGtsProblems(): number { + let count = 0 + for (const [, diagnostics] of vscode.languages.getDiagnostics()) { + for (const d of diagnostics) { + if (d.source === 'GTS') count++ + } + } + return count +} + +/** Collect every file path under an element (a single file, or all files under a folder subtree). */ +function collectFilePaths(element: GtsTreeElement, out: string[]): void { + if (element.kind === 'file') { + out.push(element.fsPath) + return + } + for (const child of element.children.values()) collectFilePaths(child, out) +} + +export class GtsFileTreeProvider + implements vscode.TreeDataProvider, vscode.TreeDragAndDropController +{ + // Advertise `text/uri-list` so dragged items are understood by the editor, + // the Explorer and the chat as regular file references. We don't accept drops. + readonly dragMimeTypes = ['text/uri-list'] + readonly dropMimeTypes: string[] = [] + + private readonly _onDidChangeTreeData = new vscode.EventEmitter() + readonly onDidChangeTreeData = this._onDidChangeTreeData.event + + private root: GtsFolderNode = { kind: 'folder', label: '', children: new Map() } + // The set of discovered file paths currently reflected in the tree. Used to + // avoid rebuilding (and thus visually flickering) the whole tree when only + // file *contents* changed but the file list is the same. + private knownPaths = new Set() + + /** + * Reconcile the tree with the current registry state. Only rebuilds (and fires + * a tree-data change) when the *set* of discovered files actually changed — + * green/red error state is handled separately via file decorations, so a plain + * content edit must not rebuild the tree. Returns the URIs that were added or + * removed so the caller can refresh just those decorations. + */ + refresh(): vscode.Uri[] { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' + const paths = getDiscoveredFilePaths() + const nextSet = new Set(paths) + + const changed: vscode.Uri[] = [] + for (const p of nextSet) if (!this.knownPaths.has(p)) changed.push(vscode.Uri.file(p)) + for (const p of this.knownPaths) if (!nextSet.has(p)) changed.push(vscode.Uri.file(p)) + if (changed.length === 0) return [] + + this.knownPaths = nextSet + this.root = buildFileTree(paths, workspaceRoot) + this._onDidChangeTreeData.fire() + return changed + } + + getTreeItem(element: GtsTreeElement): vscode.TreeItem { + if (element.kind === 'file') { + const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.None) + item.resourceUri = vscode.Uri.file(element.fsPath) + item.contextValue = 'gtsFile' + item.command = { + command: 'gts-kit.openFileFromTree', + title: 'Open GTS File', + arguments: [element.fsPath] + } + item.tooltip = hasGtsErrors(item.resourceUri) + ? 'Has GTS validation errors' + : 'No GTS validation errors' + return item + } + + const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.Expanded) + item.iconPath = vscode.ThemeIcon.Folder + item.contextValue = 'gtsFolder' + return item + } + + getChildren(element?: GtsTreeElement): GtsTreeElement[] { + const folder = element ? (element.kind === 'folder' ? element : undefined) : this.root + if (!folder) return [] + return sortedChildren(folder) + } + + /** + * Expose dragged files (and every file under a dragged folder) as a + * `text/uri-list` payload — a newline-separated list of file URIs — which the + * editor and chat accept as file references, so items can be dropped there. + */ + handleDrag( + source: readonly GtsTreeElement[], + dataTransfer: vscode.DataTransfer, + _token: vscode.CancellationToken + ): void { + const fsPaths: string[] = [] + for (const element of source) collectFilePaths(element, fsPaths) + if (fsPaths.length === 0) return + const uriList = fsPaths.map(p => vscode.Uri.file(p).toString()).join('\r\n') + dataTransfer.set('text/uri-list', new vscode.DataTransferItem(uriList)) + } +} + +/** + * Colors every GTS file (in the sidebar tree, the OS-style Explorer, and open + * editor tabs) light green when it has no GTS errors and light red when it + * does. Driven by the same registry + diagnostics the rest of the extension + * uses, so the color always matches the squiggles in the open document. + */ +export class GtsFileDecorationProvider implements vscode.FileDecorationProvider { + private readonly _onDidChangeFileDecorations = new vscode.EventEmitter() + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event + + refresh(uris?: vscode.Uri[]): void { + this._onDidChangeFileDecorations.fire(uris) + } + + provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined { + const registry = getRegistry() + if (!registry) return undefined + const fsPath = uri.fsPath + const isDiscoveredGtsFile = registry.jsonFiles.has(fsPath) || registry.invalidFiles.has(fsPath) + if (!isDiscoveredGtsFile) return undefined + + if (hasGtsErrors(uri)) { + return new vscode.FileDecoration('!', 'GTS: file has validation errors', new vscode.ThemeColor('charts.red')) + } + return new vscode.FileDecoration(undefined, 'GTS: file is valid', new vscode.ThemeColor('charts.green')) + } +} + +export interface GtsExplorer { + treeProvider: GtsFileTreeProvider + decorationProvider: GtsFileDecorationProvider + reset(): void + /** Call after the registry's set of discovered files may have changed (rescan, index/remove file). */ + refresh(): void +} + +/** Update the small rounded problem-count badge shown next to the view title. */ +function updateBadge(treeView: vscode.TreeView): void { + const count = countGtsProblems() + treeView.badge = count > 0 + ? { value: count, tooltip: `${count} GTS problem${count === 1 ? '' : 's'}` } + : undefined + console.log(`[GTS Explorer] badge updated: ${count} problem(s)`) +} + +/** Wires up the tree view + file decorations and returns handles for the extension to drive refreshes with. */ +export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplorer { + const treeProvider = new GtsFileTreeProvider() + const decorationProvider = new GtsFileDecorationProvider() + + const treeView = vscode.window.createTreeView('gts-kit.fileExplorer', { + treeDataProvider: treeProvider, + dragAndDropController: treeProvider, + canSelectMany: true, + showCollapseAll: true + }) + + context.subscriptions.push( + treeView, + vscode.window.registerFileDecorationProvider(decorationProvider), + vscode.commands.registerCommand('gts-kit.openFileFromTree', async (fsPath: string) => { + try { + const uri = vscode.Uri.file(fsPath) + await vscode.window.showTextDocument(uri, { preview: false }) + } catch (error: any) { + vscode.window.showErrorMessage(`Failed to open GTS file: ${error?.message || String(error)}`) + } + }), + // Diagnostics (and therefore per-file error state and the problem-count + // badge) change independently of the file list — repaint whenever a URI's + // diagnostics changed. + vscode.languages.onDidChangeDiagnostics(event => { + const changed = treeProvider.refresh() + decorationProvider.refresh([...event.uris, ...changed] as vscode.Uri[]) + updateBadge(treeView) + }) + ) + + treeProvider.refresh() + decorationProvider.refresh() + updateBadge(treeView) + + return { + treeProvider, + decorationProvider, + reset() { + treeProvider.refresh() + decorationProvider.refresh() + updateBadge(treeView) + }, + refresh() { + // Only the added/removed files need a decoration repaint; error-state + // changes on existing files are repainted by the onDidChangeDiagnostics + // handler above. A global decoration refresh here would flicker every file. + const changed = treeProvider.refresh() + if (changed.length > 0) decorationProvider.refresh(changed) + updateBadge(treeView) + } + } +} diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index 6a44561..df73774 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -1,8 +1,10 @@ import * as vscode from 'vscode' -import { JsonRegistry, GTS_COLORS, GTS_URI_PREFIX, parseGtsIdParts, findSimilarEntityIds, normalizeGtsId, checkGtsUriPrefix, isGtsId, isGtsIdOrPattern, isGtsPattern } from '@gts/shared' +import { JsonRegistry, GTS_COLORS, GTS_URI_PREFIX, parseGtsIdParts, analyzeGtsIdForStyling, findSimilarEntityIds, normalizeGtsId, checkGtsUriPrefix, isGtsId, isGtsIdOrPattern, isGtsPattern, isYamlFileName } from '@gts/shared' import type { GtsPrefixIssue } from '@gts/shared' import { getRegistry } from './registryStore' +import { getDocumentValidationErrors } from './validation' import * as jsonc from 'jsonc-parser' +import * as YAML from 'yaml' /** * Represents a GTS ID reference found in the document @@ -246,6 +248,17 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } } + /** + * Update decorations for visible editors showing the given document URI. + */ + public updateDecorationsForUri(uri: vscode.Uri): void { + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === uri.toString()) { + this.updateDecorations(editor) + } + } + } + /** * Update decorations for a specific editor */ @@ -255,12 +268,20 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } const document = editor.document + const filePath = document.uri.fsPath - // Only decorate JSON/JSONC/GTS files - if (!['json', 'jsonc', 'gts'].includes(document.languageId)) { + // Only decorate JSON/JSONC/GTS/YAML files + if (!['json', 'jsonc', 'gts', 'yaml'].includes(document.languageId) && !isYamlFileName(document.fileName)) { return } + const docErrors = [ + ...getDocumentValidationErrors(document.uri), + ...(this.registry.jsonFileSchemas.get(filePath) || []).flatMap(e => e.validation?.errors || []), + ...(this.registry.jsonFileObjs.get(filePath) || []).flatMap(e => e.validation?.errors || []), + ...(this.registry.invalidFiles.get(filePath)?.validation?.errors || []) + ] + const schemaRanges: vscode.Range[] = [] const instanceRanges: vscode.Range[] = [] const errorRanges: vscode.Range[] = [] @@ -330,8 +351,35 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove continue } - // Parse the GTS ID into parts - const parts = parseGtsIdParts(ref.id) + // Classify the segments using the shared, core-backed styling analyzer so + // schema-vs-instance is derived STRUCTURALLY from the GTS ID via gts-ts. + // Correctness (red vs blue/green) comes from the authoritative gts-ts + // validation results: a *schema* segment whose entity failed gts-ts + // validation (e.g. an invalid derived schema in the chain) is `isValid: + // false` → red. Instance-level, field-specific errors (abstract type, + // x-gts-ref, ...) are handled by `hasFieldError` below, not by flagging the + // whole instance entity, so an instance's own id is not reddened merely + // because some other field of it failed. No GTS rules are re-derived here. + const registry = this.registry + const analysis = analyzeGtsIdForStyling(ref.id, (entityId: string) => { + const schema = registry.jsonSchemas.get(entityId) + if (schema) { + return { exists: true, isSchema: true, isValid: !schema.validation?.errors?.length } + } + const obj = registry.jsonObjs.get(entityId) + if (obj) { + return { exists: true, isSchema: false } + } + return { exists: false } + }) + + // A gts-ts validation error reported at (or under) this field's instance + // path means the value written here is what's wrong — colour every segment + // red regardless of its structural classification. + const refInstancePath = '/' + ref.sourcePath.replace(/\./g, '/').replace(/\[(\d+)\]/g, '/$1') + const hasFieldError = docErrors.some(err => { + return Boolean(err.instancePath && (err.instancePath === refInstancePath || err.instancePath.startsWith(refInstancePath + '/'))) + }) // Calculate the offset of the string value (excluding quotes) const text = document.getText() @@ -345,11 +393,14 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } gtsStartOffset += ref.uriPrefixLength - let currentOffset = gtsStartOffset - for (let segIndex = 0; segIndex < parts.length; segIndex++) { - const part = parts[segIndex] - const partStartPos = document.positionAt(currentOffset) - const partEndPos = document.positionAt(currentOffset + part.length) + // References inside an "examples" field show missing entities as a neutral + // gray chip instead of a red error. + const inExamples = ref.sourcePath.split('.').some(seg => seg === 'examples') + + for (let segIndex = 0; segIndex < analysis.segments.length; segIndex++) { + const seg = analysis.segments[segIndex] + const partStartPos = document.positionAt(gtsStartOffset + seg.startOffset) + const partEndPos = document.positionAt(gtsStartOffset + seg.endOffset) const partRange = new vscode.Range(partStartPos, partEndPos) // Every segment after the first gets a uniform leading gap, so the @@ -359,46 +410,22 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove gapRanges.push(partRange) } - // Determine the full entity ID to look up - let entityIdToLookup: string - if (parts.length === 1) { - entityIdToLookup = part - } else if (part === parts[0]) { - entityIdToLookup = part - } else { - entityIdToLookup = parts[0] + part - } - - // Look up the entity in the registry - const entity = this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) - - if (entity) { - if (entity.isSchema) { - schemaRanges.push(partRange) - } else { - instanceRanges.push(partRange) - } + if (hasFieldError) { + errorRanges.push(partRange) + } else if (seg.type === 'schema') { + schemaRanges.push(partRange) + } else if (seg.type === 'instance') { + instanceRanges.push(partRange) + } else if (inExamples) { + // Entity not found inside an examples block — neutral gray chip. + unresolvedRanges.push(partRange) } else { - // Entity not found — if the reference is inside an "examples" field, - // show a neutral gray chip instead of a red error. - const inExamples = ref.sourcePath.split('.').some(seg => seg === 'examples') - if (inExamples) { - unresolvedRanges.push(partRange) - } else { - errorRanges.push(partRange) - - // Create diagnostic for missing entity - const diagnostic = new vscode.Diagnostic( - partRange, - `GTS entity not found: "${entityIdToLookup}"`, - vscode.DiagnosticSeverity.Error - ) - diagnostic.source = 'gts' - diagnostics.push(diagnostic) - } + // Red chip only — the authoritative "GTS reference not found" + // diagnostic for this is published by the shared validator + // (registry.validateEntity, surfaced via validation.ts) so we + // don't publish a second, duplicate diagnostic for the same miss. + errorRanges.push(partRange) } - - currentOffset += part.length } } @@ -414,9 +441,115 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } /** - * Find all GTS ID references in the document using jsonc-parser + * Find all GTS ID references in the document, using the parser appropriate + * for its format (YAML vs JSON/JSONC), so YAML files get exactly the same + * blue/red/gray annotations, hovers and links as JSON files do. */ private findGtsReferences(document: vscode.TextDocument): GtsIdReference[] { + if (document.languageId === 'yaml' || isYamlFileName(document.fileName)) { + return this.findGtsReferencesYaml(document) + } + return this.findGtsReferencesJson(document) + } + + /** + * Build a GtsIdReference from a raw string value found at a known document + * offset range. Shared by both the JSON and YAML reference finders. + */ + private buildGtsIdReference(rawValue: string, fieldName: string, sourcePath: string, range: vscode.Range): GtsIdReference { + const id = normalizeGtsId(rawValue) + const uriPrefixLength = rawValue.startsWith(GTS_URI_PREFIX) ? GTS_URI_PREFIX.length : 0 + const isValid = isGtsId(id) + // Also accept wildcard patterns (e.g. "gts.*") using gts-ts validation + const isWildcardPattern = !isValid && isGtsPattern(id) + const urlPrefixIssue = checkGtsUriPrefix(fieldName, rawValue) + + return { + id, + rawValue, + uriPrefixLength, + fieldName, + range, + sourcePath, + isValid: isValid || isWildcardPattern, + isPattern: isWildcardPattern, + urlPrefixIssue + } + } + + /** + * Find all GTS ID references in a YAML document. + * + * YAML has no widely-used equivalent of jsonc-parser's offset-tracking + * visitor for JSON, so we use the `yaml` package's CST, which records a + * `range` (character offsets into the source text) on every scalar node. + * `range.start` here is normalized to point at the first character of the + * actual string content (skipping any opening quote), independent of the + * quote style used, so downstream consumers that were written for the JSON + * path (which skip a leading `"` themselves) work unchanged for YAML too. + */ + private findGtsReferencesYaml(document: vscode.TextDocument): GtsIdReference[] { + const references: GtsIdReference[] = [] + const text = document.getText() + + let doc: YAML.Document.Parsed + try { + doc = YAML.parseDocument(text) + } catch (error) { + console.error('[GTS LinkProvider] Error parsing YAML document:', error) + return references + } + if (doc.contents == null) { + return references + } + + try { + YAML.visit(doc, { + Scalar: (key, node, path) => { + const value = (node as YAML.Scalar).value + // Only interested in string values; never the YAML key tokens. + if (key === 'key' || typeof value !== 'string') return + if (!(value.startsWith('gts.') || value.startsWith(GTS_URI_PREFIX))) return + + const nodeRange = node.range + if (!nodeRange) return + const [startOffset, valueEndOffset] = nodeRange + + // Skip the opening quote (if any) so the range points directly at + // the string's content, matching what downstream code expects. + const raw = text.slice(startOffset, valueEndOffset) + const quoteLen = raw.startsWith('"') || raw.startsWith("'") ? 1 : 0 + const valueStartOffset = startOffset + quoteLen + + const startPos = document.positionAt(valueStartOffset) + const endPos = document.positionAt(valueStartOffset + value.length) + const range = new vscode.Range(startPos, endPos) + + // Build the ancestor key path (used only to detect "examples" + // context, same as the JSON path) from the enclosing Map Pairs. + const pathKeys: string[] = [] + for (const ancestor of path) { + if (YAML.isPair(ancestor) && YAML.isScalar(ancestor.key)) { + pathKeys.push(String((ancestor.key as YAML.Scalar).value)) + } + } + const fieldName = pathKeys[pathKeys.length - 1] || '' + const sourcePath = pathKeys.join('.') + + references.push(this.buildGtsIdReference(value, fieldName, sourcePath, range)) + } + }) + } catch (error) { + console.error('[GTS LinkProvider] Error visiting YAML document:', error) + } + + return references + } + + /** + * Find all GTS ID references in a JSON/JSONC document using jsonc-parser + */ + private findGtsReferencesJson(document: vscode.TextDocument): GtsIdReference[] { const references: GtsIdReference[] = [] const text = document.getText() @@ -442,12 +575,11 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // Get the property path for this value const node = jsonc.findNodeAtOffset(root, offset) - const path = jsonc.getNodePath(node?.parent || node || root) - const sourcePath = path.join('.') + const valuePath = jsonc.getNodePath(node || root) + const sourcePath = valuePath.join('.') // Determine the leaf field name this value is assigned to. The value // node's own path ends with its property key (or an array index). - const valuePath = jsonc.getNodePath(node || root) let fieldName = '' for (let i = valuePath.length - 1; i >= 0; i--) { if (typeof valuePath[i] === 'string') { @@ -523,26 +655,20 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove gtsStartOffset += ref.uriPrefixLength let currentOffset = gtsStartOffset - for (const part of parts) { + let hasMissingAncestor = false + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const part = parts[partIndex] const partStartPos = document.positionAt(currentOffset) const partEndPos = document.positionAt(currentOffset + part.length) const partRange = new vscode.Range(partStartPos, partEndPos) // Determine the full entity ID to look up - let entityIdToLookup: string - if (parts.length === 1) { - // Only one part, use it as-is - entityIdToLookup = part - } else if (part === parts[0]) { - // First part (schema type) - entityIdToLookup = part - } else { - // Second part (instance), combine with first part - entityIdToLookup = parts[0] + part - } + const entityIdToLookup = parts.slice(0, partIndex + 1).join('') // Look up the entity in the registry - const entity = this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) + const entity = hasMissingAncestor + ? undefined + : this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) if (entity && entity.file) { // Create a document link @@ -563,6 +689,8 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // Don't set tooltip - we provide rich hover via HoverProvider instead links.push(link) + } else if (!entity) { + hasMissingAncestor = true } currentOffset += part.length @@ -727,26 +855,63 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove let entityIdToLookup = gtsId let hoverRange = matchedRef.range - - if (parts.length > 1) { - const firstPartLength = parts[0].length - if (relativeOffset < firstPartLength) { - // Cursor is on the first part - entityIdToLookup = parts[0] - const startPos = document.positionAt(gtsBodyOffset) - const endPos = document.positionAt(gtsBodyOffset + firstPartLength) - hoverRange = new vscode.Range(startPos, endPos) - } else { - // Cursor is on the second part - entityIdToLookup = parts[0] + parts[1] - const startPos = document.positionAt(gtsBodyOffset + firstPartLength) - const endPos = document.positionAt(gtsBodyOffset + gtsId.length) + let hoveredSegmentIndex: number | undefined + + let segmentStartOffset = 0 + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const part = parts[partIndex] + const segmentEndOffset = segmentStartOffset + part.length + if (relativeOffset >= segmentStartOffset && relativeOffset < segmentEndOffset) { + hoveredSegmentIndex = partIndex + entityIdToLookup = parts.slice(0, partIndex + 1).join('') + const startPos = document.positionAt(gtsBodyOffset + segmentStartOffset) + const endPos = document.positionAt(gtsBodyOffset + segmentEndOffset) hoverRange = new vscode.Range(startPos, endPos) + break + } + segmentStartOffset = segmentEndOffset + } + + // Classify the hovered segment with the SAME analyzer that drives the + // colouring, so the hover verdict never contradicts the red/blue/green chip + // and we don't hand-roll a second, divergent notion of "missing". + const registry = this.registry + const analysis = analyzeGtsIdForStyling(gtsId, (id: string) => { + const schema = registry.jsonSchemas.get(id) + if (schema) return { exists: true, isSchema: true, isValid: !schema.validation?.errors?.length } + const obj = registry.jsonObjs.get(id) + if (obj) return { exists: true, isSchema: false } + return { exists: false } + }) + const hoveredSeg = hoveredSegmentIndex !== undefined ? analysis.segments[hoveredSegmentIndex] : undefined + + if (hoveredSeg && hoveredSeg.type === 'error') { + const firstErrorIdx = analysis.segments.findIndex(s => s.type === 'error') + // An earlier segment is the real cause; this one only cascades from it. + if (firstErrorIdx !== -1 && hoveredSegmentIndex !== undefined && firstErrorIdx < hoveredSegmentIndex) { + const culprit = analysis.segments[firstErrorIdx].entityId + markdown.appendMarkdown(`GTS Parent Type Not Found\n\n`) + markdown.appendMarkdown(`This segment derives from \`${escapeMarkdown(culprit)}\`, which is not a defined GTS type.`) + return new vscode.Hover(markdown, hoverRange) + } + // This segment itself is the cause. A "~"-terminated id that resolves only + // to an instance document (or nothing) names a TYPE that is not defined — + // it is NOT an ancestor/derivation problem. + const schemaHere = this.registry.jsonSchemas.get(entityIdToLookup) + const objHere = this.registry.jsonObjs.get(entityIdToLookup) + if (!schemaHere && objHere) { + markdown.appendMarkdown(`⚠️ GTS Type Not Found\n\n`) + markdown.appendMarkdown(`ID: ${escapeMarkdown(entityIdToLookup)}\n\n`) + markdown.appendMarkdown(`This is a GTS type identifier, but no type (schema) with this id is defined.`) + return new vscode.Hover(markdown, hoverRange) } + // Not found at all → fall through to the "GTS Entity Not Found" + suggestions block. } - // Look up the entity in the registry - const entity = this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) + // Look up the entity in the registry (only when the segment is not an error). + const entity = hoveredSeg && hoveredSeg.type === 'error' + ? undefined + : this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) if (!entity) { // Entity not found - show error with suggestions @@ -808,7 +973,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // Make the GTS ID itself clickable markdown.appendMarkdown(`GTS ID: [${escapeMarkdown(entityIdToLookup)}](${fileUri.toString()})\n\n`) - markdown.appendMarkdown(`Type: ${entityType}\n\n`) + markdown.appendMarkdown(`Kind: ${entityType}\n\n`) markdown.appendMarkdown(`Definition: [${escapeMarkdown(relativePath)}](${fileUri.toString()})`) // Add description if available (on a new line, no label) diff --git a/apps/vscode-extension/src/registryStore.ts b/apps/vscode-extension/src/registryStore.ts index ef8edd7..08c7fcd 100644 --- a/apps/vscode-extension/src/registryStore.ts +++ b/apps/vscode-extension/src/registryStore.ts @@ -14,23 +14,49 @@ import type { GtsConfig } from '@gts/shared' * this same registry as the resolution context. */ +export interface RegistryFileInput { + path: string + name: string + content: any +} + let registry: JsonRegistry | null = null let activeConfig: GtsConfig = DEFAULT_GTS_CONFIG +let revision = 0 /** Get the shared registry, or null if it hasn't been built yet. */ export function getRegistry(): JsonRegistry | null { return registry } +export function getRegistryRevision(): number { + return revision +} + /** Rebuild the shared registry from a full set of scanned files (index-only). */ export async function rebuildRegistry( - files: Array<{ path: string; name: string; content: any }>, + files: RegistryFileInput[], cfg: GtsConfig = DEFAULT_GTS_CONFIG ): Promise { activeConfig = cfg const next = new JsonRegistry() await next.ingestFiles(files, cfg, { skipValidation: true }) registry = next + revision++ + return next +} + +export async function rebuildRegistryIfUnchanged( + files: RegistryFileInput[], + expectedRevision: number, + cfg: GtsConfig = DEFAULT_GTS_CONFIG +): Promise { + const next = new JsonRegistry() + await next.ingestFiles(files, cfg, { skipValidation: true }) + if (revision !== expectedRevision) return null + activeConfig = cfg + registry = next + revision++ return next } @@ -38,11 +64,14 @@ export async function rebuildRegistry( export function indexFile(path: string, name: string, content: any): void { if (!registry) return registry.indexFile(path, name, content, activeConfig) + revision++ } /** Remove a single file's entities from the shared registry. */ export function removeFile(path: string): void { - registry?.invalidateFile(path) + if (!registry) return + registry.invalidateFile(path) + revision++ } /** The GTS config the registry was built with. */ diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index 0500ee6..5c94afe 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -1,14 +1,30 @@ import * as vscode from 'vscode' import * as path from 'path' +import * as YAML from 'yaml' +import * as jsonc from 'jsonc-parser' import { ValidationError, DEFAULT_GTS_CONFIG, parseGtsFileContent, isYamlFileName } from '@gts/shared' import { getLastScanFiles } from './scanStore' -import { getRegistry, rebuildRegistry, indexFile } from './registryStore' +import { getRegistry, getRegistryRevision, rebuildRegistry, indexFile } from './registryStore' import { isGtsCandidateFile } from './helpers' let diagnosticCollection: vscode.DiagnosticCollection let workspaceDiagnosticCollection: vscode.DiagnosticCollection let isInitialScanComplete = false +const documentValidationErrors = new Map() +const documentValidationGenerations = new Map() +const validationCompletedListeners = new Set<(uri: vscode.Uri) => void>() +let workspaceValidationGeneration = 0 + +export function getDocumentValidationErrors(uri: vscode.Uri): ValidationError[] { + return documentValidationErrors.get(uri.toString()) || [] +} + +export function onValidationCompleted(listener: (uri: vscode.Uri) => void): vscode.Disposable { + validationCompletedListeners.add(listener) + return new vscode.Disposable(() => validationCompletedListeners.delete(listener)) +} + function isPathUnderAnyRoot(filePath: string, roots: string[] | undefined): boolean { if (!roots || roots.length === 0) return true for (const root of roots) { @@ -72,19 +88,81 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, // Remove leading slash from instancePath (e.g., '/users/0/email' -> 'users/0/email') const path = instancePath.replace(/^\//, '') - // For gts:// prefix violations and x-gts-ref mismatches, highlight the + // 1. Required property missing: the property is not in data, highlight the parent object opening brace + if (error.keyword === 'required' && error.params && 'missingProperty' in error.params) { + if (!path) { + // Error at root level - find first opening brace + const rootMatch = text.match(/\{/) + if (rootMatch && rootMatch.index !== undefined) { + const pos = document.positionAt(rootMatch.index) + return new vscode.Range(pos, pos.translate(0, 1)) + } + } else { + const position = findObjectAtPath(text, document, path) + if (position) { + return position + } + } + } + + // 2. Additional properties error: highlight the unexpected property key + if (error.keyword === 'additionalProperties' && error.params && 'additionalProperty' in error.params) { + const additionalProp = (error.params as any).additionalProperty + const keyRange = findKeyRangeAtInstancePath(document, instancePath, additionalProp) + if (keyRange) { + return keyRange + } + const searchPattern = keyRegex(additionalProp) + const match = searchPattern.exec(text) + if (match) { + const quoteLen = match[1] ? 1 : 0 + const startPos = document.positionAt(match.index + quoteLen) + const endPos = document.positionAt(match.index + quoteLen + additionalProp.length) + return new vscode.Range(startPos, endPos) + } + } + + // 3. For any error carrying an instance path, underline the offending node. + // The choice of what to underline is STRUCTURAL, not keyword-specific: a + // scalar value (format/uuid/pattern/x-gts-abstract/x-gts-ref/type/enum on a + // leaf, or the schema's own $id) is underlined directly; when the path + // resolves to an object/array subschema (e.g. an OP#12 derivation error at + // `/allOf/1/properties/level`, whose value is itself a schema) the property + // key is underlined instead. + if (instancePath && instancePath !== '/') { + const valueRange = findValueRangeAtInstancePath(document, instancePath) + if (valueRange) { + return valueRange + } + const keyRange = findKeyRangeAtInstancePath(document, instancePath) + if (keyRange) { + return keyRange + } + } + + // 4. For gts:// prefix violations and x-gts-ref mismatches, highlight the // offending string value precisely. if ((error.keyword === 'gts-uri-prefix' || error.keyword === 'x-gts-ref') && error.params && 'value' in error.params) { const value = String((error.params as any).value) - const idx = text.indexOf(`"${value}"`) + // Quoted (JSON, or a quoted YAML scalar) first, then bare YAML scalar. + let idx = text.indexOf(`"${value}"`) + let quoteLen = 1 + if (idx === -1) { + idx = text.indexOf(`'${value}'`) + quoteLen = idx !== -1 ? 1 : 0 + } + if (idx === -1) { + idx = text.indexOf(value) + quoteLen = 0 + } if (idx !== -1) { - const startPos = document.positionAt(idx + 1) // +1 to skip opening quote - const endPos = document.positionAt(idx + 1 + value.length) + const startPos = document.positionAt(idx + quoteLen) + const endPos = document.positionAt(idx + quoteLen + value.length) return new vscode.Range(startPos, endPos) } } - // For schema errors, find the object that references the missing schema + // 5. For schema errors without a resolvable instancePath, search by schemaId in params if (error.keyword === 'schema') { console.log(`[GTS Validation] Schema error detected, path='${path}'`) @@ -106,51 +184,23 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, } } - // For additionalProperties errors, look for the actual property mentioned in params - if (error.keyword === 'additionalProperties' && error.params && 'additionalProperty' in error.params) { - const additionalProp = (error.params as any).additionalProperty - const searchPattern = new RegExp(`["']${escapeRegex(additionalProp)}["']\\s*:`, 'g') - const match = searchPattern.exec(text) - if (match) { - const startPos = document.positionAt(match.index + 1) // +1 to skip opening quote - const endPos = document.positionAt(match.index + 1 + additionalProp.length) - return new vscode.Range(startPos, endPos) - } - } - - // For required property errors, find the parent object and place error at the opening brace - if (error.keyword === 'required' && error.params && 'missingProperty' in error.params) { - const missingProp = (error.params as any).missingProperty - - // Try to find the parent object by navigating through the path - if (!path) { - // Error at root level - find first opening brace - const rootMatch = text.match(/\{/) - if (rootMatch && rootMatch.index !== undefined) { - const pos = document.positionAt(rootMatch.index) - return new vscode.Range(pos, pos.translate(0, 1)) - } - } else { - // Find the object that should contain this property - const position = findObjectAtPath(text, document, path) - if (position) { - return position - } - } - } - - // General case: try to find the property mentioned in the path + // 6. Fallback: try to find the property key using AST or quoted regex if (path) { + const keyRange = findKeyRangeAtInstancePath(document, instancePath) + if (keyRange) { + return keyRange + } const segments = path.split('/') const lastSegment = segments[segments.length - 1] if (lastSegment && !/^\d+$/.test(lastSegment)) { // Not an array index, try to find the property name - const searchPattern = new RegExp(`["']${escapeRegex(lastSegment)}["']\\s*:`, 'g') + const searchPattern = keyRegex(lastSegment) const match = searchPattern.exec(text) if (match) { - const startPos = document.positionAt(match.index + 1) // +1 to skip opening quote - const endPos = document.positionAt(match.index + 1 + lastSegment.length) + const quoteLen = match[1] ? 1 : 0 + const startPos = document.positionAt(match.index + quoteLen) + const endPos = document.positionAt(match.index + quoteLen + lastSegment.length) return new vscode.Range(startPos, endPos) } } @@ -170,14 +220,15 @@ function findTypeFieldByValue(text: string, document: vscode.TextDocument, typeV // Escape the typeValue for use in regex const escapedValue = escapeRegex(typeValue) - // Search for: "type": "typeValue" - const searchPattern = new RegExp(`"type"\\s*:\\s*"${escapedValue}"`, 'g') + // Search for: type: typeValue (key and/or value optionally quoted, so this + // matches both JSON's `"type": "typeValue"` and YAML's bare `type: typeValue`) + const searchPattern = new RegExp(`(["']?)type\\1\\s*:\\s*(["']?)${escapedValue}\\2`, 'g') const match = searchPattern.exec(text) if (match) { // Highlight the "type" property name (not the value) - const typeKeyStart = match.index + 1 // +1 to skip opening quote - const typeKeyEnd = match.index + 5 // "type" is 4 characters, +1 for the quote + const typeKeyStart = match.index + (match[1] ? 1 : 0) + const typeKeyEnd = typeKeyStart + 4 // "type" is 4 characters const startPos = document.positionAt(typeKeyStart) const endPos = document.positionAt(typeKeyEnd) @@ -312,6 +363,163 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +/** + * Build a regex matching a quoted property key followed by `:`. + * Requires quotes so it never accidentally matches bare words inside comments. + */ +function keyRegex(name: string): RegExp { + const esc = escapeRegex(name) + return new RegExp(`(["'])${esc}\\1\\s*:`, 'g') +} + +/** + * Split an AJV-style instancePath ("/tokens/2/subject_type") into path segments, + * converting numeric segments into numbers so array indices resolve to the + * correct list item rather than being treated as a property key. Empty segments + * (from the leading slash or a "/" root path) are dropped. + */ +function instancePathSegments(instancePath: string): Array { + return instancePath + .split('/') + .filter(seg => seg.length > 0) + .map(seg => (/^\d+$/.test(seg) ? Number(seg) : seg)) +} + +/** + * Resolve the document range of a property key at a given instancePath. + * If keyName is provided, searches for a child property with that name under instancePath. + */ +function findKeyRangeAtInstancePath(document: vscode.TextDocument, instancePath: string, keyName?: string): vscode.Range | null { + const segments = instancePathSegments(instancePath) + if (keyName) { + segments.push(keyName) + } + if (segments.length === 0) return null + + const text = document.getText() + const isYaml = document.languageId === 'yaml' || isYamlFileName(document.fileName) + + if (isYaml) { + let doc: YAML.Document.Parsed + try { + doc = YAML.parseDocument(text) + } catch { + return null + } + if (doc.contents == null) return null + + const parentSegments = segments.slice(0, -1) + const targetKey = String(segments[segments.length - 1]) + const parentNode = parentSegments.length === 0 ? doc.contents : doc.getIn(parentSegments, true) + if (YAML.isMap(parentNode)) { + const pair = parentNode.items.find(item => YAML.isScalar(item.key) && String(item.key.value) === targetKey) + if (pair && YAML.isScalar(pair.key) && pair.key.range) { + const [startOffset, endOffset] = pair.key.range + const raw = text.slice(startOffset, endOffset) + const quoteLen = raw.startsWith('"') || raw.startsWith("'") ? 1 : 0 + const start = startOffset + quoteLen + const end = endOffset - quoteLen + return new vscode.Range(document.positionAt(start), document.positionAt(end)) + } + } + return null + } + + const root = jsonc.parseTree(text, undefined, { allowTrailingComma: true }) + if (!root) return null + + const node = jsonc.findNodeAtLocation(root, segments) + if (node && node.parent && node.parent.type === 'property' && node.parent.children) { + const keyNode = node.parent.children[0] + if (keyNode) { + let offset = keyNode.offset + let length = keyNode.length + if (keyNode.type === 'string') { + offset += 1 + length = Math.max(0, length - 2) + } + return new vscode.Range(document.positionAt(offset), document.positionAt(offset + length)) + } + } + return null +} + +/** + * Resolve the document range of the *value* at a given instancePath, honoring + * array indices. This is what lets repeated keys under different array items + * (e.g. `subject_type` inside several `tokens`) each resolve to their own + * occurrence instead of every error collapsing onto the first textual match. + * + * Returns null when the path cannot be resolved (e.g. multi-entity files whose + * per-entity paths are not rooted at the document), so callers can fall back to + * the coarser text-search strategies. + */ +function findValueRangeAtInstancePath(document: vscode.TextDocument, instancePath: string): vscode.Range | null { + const segments = instancePathSegments(instancePath) + if (segments.length === 0) return null + + const text = document.getText() + const isYaml = document.languageId === 'yaml' || isYamlFileName(document.fileName) + return isYaml + ? findValueRangeYaml(text, document, segments) + : findValueRangeJson(text, document, segments) +} + +/** Resolve a value range by navigating the YAML CST to the node at `segments`. */ +function findValueRangeYaml(text: string, document: vscode.TextDocument, segments: Array): vscode.Range | null { + let doc: YAML.Document.Parsed + try { + doc = YAML.parseDocument(text) + } catch { + return null + } + if (doc.contents == null) return null + + const node = doc.getIn(segments, true) + if (!YAML.isScalar(node) || !node.range) return null + + const [startOffset, valueEndOffset] = node.range + // Skip the opening quote (if any) so the range points at the string content. + const raw = text.slice(startOffset, valueEndOffset) + const quoteLen = raw.startsWith('"') || raw.startsWith("'") ? 1 : 0 + const valueStart = startOffset + quoteLen + const value = String(node.value) + + const startPos = document.positionAt(valueStart) + const endPos = document.positionAt(valueStart + value.length) + return new vscode.Range(startPos, endPos) +} + +/** + * Resolve the document range of the *scalar value* at a given instancePath, + * honoring array indices. Returns null when the node is not a scalar (an + * object/array subschema — e.g. a derivation error pointing at a property whose + * value is itself a schema), so callers can fall back to highlighting the key. + */ +function findValueRangeJson(text: string, document: vscode.TextDocument, segments: Array): vscode.Range | null { + const root = jsonc.parseTree(text, undefined, { allowTrailingComma: true }) + if (!root) return null + + const node = jsonc.findNodeAtLocation(root, segments) + if (!node) return null + // Only scalars have a meaningful "value" range to underline; objects/arrays + // resolve to the property key instead (handled by findKeyRangeAtInstancePath). + if (node.type === 'object' || node.type === 'array') return null + + let offset = node.offset + let length = node.length + // jsonc node offsets for strings include the surrounding quotes; strip them + // so the range covers only the string content. + if (node.type === 'string') { + offset += 1 + length = Math.max(0, length - 2) + } + + const startPos = document.positionAt(offset) + const endPos = document.positionAt(offset + length) + return new vscode.Range(startPos, endPos) +} + /** * Validate a document and update diagnostics */ @@ -320,6 +528,10 @@ export async function validateOpenDocument(document: vscode.TextDocument) { return } + const validationKey = document.uri.toString() + const validationGeneration = (documentValidationGenerations.get(validationKey) || 0) + 1 + documentValidationGenerations.set(validationKey, validationGeneration) + try { const text = document.getText() const fileName = path.basename(document.fileName) @@ -384,20 +596,37 @@ export async function validateOpenDocument(document: vscode.TextDocument) { } } + if ( + documentValidationGenerations.get(validationKey) !== validationGeneration || + getRegistry() !== registry + ) return + // This document is now open and gets precise diagnostics; drop any coarse // background diagnostic so markers aren't duplicated. workspaceDiagnosticCollection?.delete(document.uri) if (errors.length > 0) { + documentValidationErrors.set(document.uri.toString(), errors) const diagnostics = validationErrorsToDiagnostics(errors, document) diagnosticCollection.set(document.uri, diagnostics) console.log(`[GTS Validation] ✗ Got ${diagnostics.length} GTS diagnostics errors for ${fileName} - Errors:`, diagnostics.map(d => ({ message: d.message, range: d.range }))) } else { + documentValidationErrors.delete(document.uri.toString()) diagnosticCollection.delete(document.uri) console.log(`[GTS Validation] ✓ No errors, cleared diagnostics for ${fileName}`) } + + for (const listener of validationCompletedListeners) { + try { + listener(document.uri) + } catch (err) { + console.error('[GTS Validation] Error in validation completed listener:', err) + } + } } catch (error) { console.error('[GTS Validation] ✗ Error validating document:', error) + if (documentValidationGenerations.get(validationKey) !== validationGeneration) return + documentValidationErrors.delete(validationKey) diagnosticCollection.delete(document.uri) } } @@ -410,6 +639,8 @@ export async function validateOpenDocument(document: vscode.TextDocument) { export async function validateWorkspaceInBackground(scopeRoots?: string[]): Promise { const registry = getRegistry() if (!registry || !workspaceDiagnosticCollection) return + const validationGeneration = ++workspaceValidationGeneration + const registryRevision = getRegistryRevision() const openPaths = new Set() for (const doc of vscode.workspace.textDocuments) { @@ -444,11 +675,20 @@ export async function validateWorkspaceInBackground(scopeRoots?: string[]): Prom const entities = [...registry.jsonSchemas.values(), ...registry.jsonObjs.values()] for (const entity of entities) { await registry.validateEntity(entity) + if ( + workspaceValidationGeneration !== validationGeneration || + getRegistryRevision() !== registryRevision + ) return if (!entity.file?.path) continue const errors = entity.validation?.errors || [] for (const error of errors) addError(entity.file.path, error) } + if ( + workspaceValidationGeneration !== validationGeneration || + getRegistryRevision() !== registryRevision + ) return + const entries: Array<[vscode.Uri, vscode.Diagnostic[]]> = [] for (const [filePath, diagnostics] of diagnosticsByPath.entries()) { entries.push([vscode.Uri.file(filePath), diagnostics]) @@ -456,11 +696,115 @@ export async function validateWorkspaceInBackground(scopeRoots?: string[]): Prom workspaceDiagnosticCollection.set(entries) } +/** + * Validate a single file that is NOT open in an editor and publish coarse + * (line-0) workspace diagnostics for it, using the shared registry as context. + * Uses the single-URI overload of `set` so only this file's markers change. + */ +async function validateClosedFile(filePath: string): Promise { + const registry = getRegistry() + if (!registry || !workspaceDiagnosticCollection) return + + // Entity validation itself is shared registry logic; here we only turn the + // resulting errors into coarse (line-0) workspace diagnostics. + await registry.validateFile(filePath) + + const diagnostics: vscode.Diagnostic[] = [] + const push = (error: ValidationError) => { + const diagnostic = new vscode.Diagnostic( + new vscode.Range(0, 0, 0, 1), + error.message, + vscode.DiagnosticSeverity.Error + ) + diagnostic.source = 'GTS' + diagnostic.code = error.keyword + diagnostics.push(diagnostic) + } + + const invalid = registry.invalidFiles.get(filePath) + if (invalid?.validation && invalid.validation.errors.length > 0) { + for (const error of invalid.validation.errors) push(error) + } else { + const fileSchemas = registry.jsonFileSchemas.get(filePath) || [] + const fileObjs = registry.jsonFileObjs.get(filePath) || [] + for (const entity of [...fileSchemas, ...fileObjs]) { + for (const error of entity.validation?.errors || []) push(error) + } + } + + const uri = vscode.Uri.file(filePath) + workspaceDiagnosticCollection.set(uri, diagnostics.length > 0 ? diagnostics : undefined) +} + +/** + * Re-read a (now-closed) file from disk and re-index it into the shared registry. + * + * When an editor closes, any unsaved buffer edits are discarded, so the registry + * may still hold the stale live content that `validateOpenDocument` indexed. Re- + * indexing from disk makes the subsequent closed-file validation reflect what is + * actually on disk. No-op for non-file schemes or unreadable/deleted files. + */ +async function reindexClosedFileFromDisk(uri: vscode.Uri): Promise { + if (uri.scheme !== 'file') return + try { + const data = await vscode.workspace.fs.readFile(uri) + const text = Buffer.from(data).toString('utf8') + const name = path.basename(uri.fsPath) + let content: any + try { content = parseGtsFileContent(name, text) } catch { content = text } + indexFile(uri.fsPath, name, content) + } catch { + // File may have been deleted/renamed; leave the registry as-is so the + // watcher's delete handler can drop it. + } +} + +/** + * Revalidate every file that depends on `changedPath` (instances of a changed + * type, schemas derived from it, or entities that GTS-reference it). Open files + * get precise in-editor diagnostics; closed files get coarse workspace markers. + * This is what keeps derived types/instances in sync when a base file changes. + */ +export async function revalidateDependents(changedPath: string, previousIds?: Iterable): Promise { + const registry = getRegistry() + if (!registry) return + + // `previousIds` carries the ids the file defined *before* the edit so that a + // renamed/removed id still revalidates whatever referenced its old id. + const dependentPaths = registry.getDependentFilePaths(changedPath, previousIds) + if (dependentPaths.size === 0) return + + const openByPath = new Map() + for (const doc of vscode.workspace.textDocuments) { + if (doc.uri.scheme === 'file' && isGtsCandidateFile(doc)) { + openByPath.set(doc.uri.fsPath, doc) + } + } + + console.log(`[GTS Validation] Revalidating ${dependentPaths.size} dependents of ${path.basename(changedPath)}`) + for (const dependentPath of dependentPaths) { + const openDoc = openByPath.get(dependentPath) + if (openDoc) { + await validateOpenDocument(openDoc) + } else { + await validateClosedFile(dependentPath) + } + } +} + +export function resetValidationDiagnostics(): void { + documentValidationErrors.clear() + documentValidationGenerations.clear() + workspaceValidationGeneration++ + diagnosticCollection?.clear() + workspaceDiagnosticCollection?.clear() +} + export function initValidation(context: vscode.ExtensionContext) { console.log('[GTS Validation] Initializing validation system...') // Create diagnostic collection for validation errors - diagnosticCollection = vscode.languages.createDiagnosticCollection('gts') + diagnosticCollection = vscode.languages.createDiagnosticCollection('gts-validation') context.subscriptions.push(diagnosticCollection) workspaceDiagnosticCollection = vscode.languages.createDiagnosticCollection('gts-workspace') context.subscriptions.push(workspaceDiagnosticCollection) @@ -481,12 +825,22 @@ export function initValidation(context: vscode.ExtensionContext) { }) ) - // Clear diagnostics when document is closed + // When a document is closed (e.g. a preview tab replaced by clicking another + // file in the Explorer), drop its precise in-editor diagnostics and republish + // the coarse workspace diagnostic so the file keeps showing as invalid in the + // Explorer/tree. Without this the file would go green: validateOpenDocument + // removed the workspace marker when it was opened, and nothing restores it. context.subscriptions.push( - vscode.workspace.onDidCloseTextDocument(doc => { + vscode.workspace.onDidCloseTextDocument(async doc => { if (!isGtsCandidateFile(doc)) return console.log(`[GTS Validation] Document closed: ${doc.fileName}`) + const validationKey = doc.uri.toString() + documentValidationGenerations.set(validationKey, (documentValidationGenerations.get(validationKey) || 0) + 1) + documentValidationErrors.delete(validationKey) diagnosticCollection.delete(doc.uri) + if (doc.uri.scheme !== 'file') return + await reindexClosedFileFromDisk(doc.uri) + await validateClosedFile(doc.uri.fsPath) }) ) diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json index 70fc931..6336cba 100644 --- a/apps/vscode-extension/tsconfig.json +++ b/apps/vscode-extension/tsconfig.json @@ -1,17 +1,17 @@ { "compilerOptions": { "target": "ES2020", - "module": "CommonJS", + "module": "ESNext", "lib": ["ES2020"], + "types": ["node"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, - "baseUrl": ".", "paths": { "@gts/shared": ["../../packages/shared/dist"], "@gts/shared/*": ["../../packages/shared/dist/*"] diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index d3d47b3..58222cb 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -3,9 +3,7 @@ module.exports = { env: { browser: true, es2020: true }, extends: [ 'eslint:recommended', - '@typescript-eslint/recommended', - 'eslint:recommended', - '@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended', ], ignorePatterns: ['dist', '.eslintrc.cjs'], parser: '@typescript-eslint/parser', diff --git a/apps/web/package.json b/apps/web/package.json index a4aea16..c1736e1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@gts/web", - "version": "0.2.6", + "version": "0.2.7", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/JsonCode.tsx b/apps/web/src/components/JsonCode.tsx index fa8e2af..d653a4a 100644 --- a/apps/web/src/components/JsonCode.tsx +++ b/apps/web/src/components/JsonCode.tsx @@ -32,7 +32,7 @@ export function JsonCode({ code, language = 'json', className, registry = null, } // Define GTS highlighting function - returns JSX for GTS IDs, null for non-GTS - const renderGtsOverlay = (text: string): React.ReactNode => { + const renderGtsOverlay = (text: string, hasLineError?: boolean): React.ReactNode => { // Remove surrounding quotes if present to test the raw value const raw = text.replace(/^"/, '').replace(/"$/, '') @@ -41,12 +41,13 @@ export function JsonCode({ code, language = 'json', className, registry = null, return null // Not a GTS ID, no overlay needed } - // Analyze the GTS ID for styling + // Analyze the GTS ID for styling. Correctness is driven by gts-ts validation + // results surfaced via `isValid` (per entity) and the line's `hasLineError`. const analysis = analyzeGtsIdForStyling(raw, (entityId: string) => { if (!registry) return { exists: false } const schema = registry.jsonSchemas.get(entityId) const obj = registry.jsonObjs.get(entityId) - if (schema) return { exists: true, isSchema: true } + if (schema) return { exists: true, isSchema: true, isValid: !schema.validation?.errors?.length } if (obj) return { exists: true, isSchema: false } return { exists: false } }) @@ -120,23 +121,24 @@ export function JsonCode({ code, language = 'json', className, registry = null, let style: CSSProperties let tooltip: string | undefined - if (segment.type === 'schema') { + if (hasLineError || segment.type === 'error' || segment.type === 'invalid') { + style = { + color: GTS_COLORS.invalid.foreground, + backgroundColor: GTS_COLORS.invalid.background + } + } else if (segment.type === 'schema') { style = { color: GTS_COLORS.schema.foreground, backgroundColor: GTS_COLORS.schema.background } - } else if (segment.type === 'instance') { + } else { style = { color: GTS_COLORS.instance.foreground, backgroundColor: GTS_COLORS.instance.background } - } else { - // Invalid segment - entity not found - style = { - color: GTS_COLORS.invalid.foreground, - backgroundColor: GTS_COLORS.invalid.background - } + } + if (segment.type === 'error' || segment.type === 'invalid') { // Build detailed error message for missing entity let errorMessage = `⚠️ GTS Entity Not Found!\n\nID: ${segment.text}\n\n` @@ -222,7 +224,7 @@ export function JsonCode({ code, language = 'json', className, registry = null, // For string values (not property names), check if GTS overlay exists if (isString && !isProperty && typeof token.content === 'string') { - const gtsOverlay = renderGtsOverlay(token.content) + const gtsOverlay = renderGtsOverlay(token.content, hasError) if (gtsOverlay) { // GTS ID detected - render overlay INSTEAD of Prism styling diff --git a/apps/web/src/components/PropertyViewer.tsx b/apps/web/src/components/PropertyViewer.tsx index aee9623..90e08ab 100644 --- a/apps/web/src/components/PropertyViewer.tsx +++ b/apps/web/src/components/PropertyViewer.tsx @@ -17,7 +17,7 @@ interface ValidationError { /** * Render a GTS ID value with proper color-coding for each part */ -function renderGtsValue(value: string, registry: JsonRegistry | null) { +function renderGtsValue(value: string, registry: JsonRegistry | null, hasError?: boolean) { // Remove quotes if present const raw = value.replace(/^"/, '').replace(/"$/, '') @@ -26,12 +26,14 @@ function renderGtsValue(value: string, registry: JsonRegistry | null) { return {value} } - // Analyze the GTS ID for styling + // Analyze the GTS ID for styling. Correctness is driven by gts-ts validation + // results surfaced via `isValid` (per entity) and the `hasError` flag (a + // gts-ts validation error reported on this exact property). const analysis = analyzeGtsIdForStyling(raw, (entityId: string) => { if (!registry) return { exists: false } const schema = registry.jsonSchemas.get(entityId) const obj = registry.jsonObjs.get(entityId) - if (schema) return { exists: true, isSchema: true } + if (schema) return { exists: true, isSchema: true, isValid: !schema.validation?.errors?.length } if (obj) return { exists: true, isSchema: false } return { exists: false } }) @@ -62,15 +64,15 @@ function renderGtsValue(value: string, registry: JsonRegistry | null) { let bgColor: string let textColor: string - if (segment.type === 'schema') { + if (hasError || segment.type === 'error' || segment.type === 'invalid') { + bgColor = '#fee2e2' // red-100 + textColor = '#991b1b' // red-800 + } else if (segment.type === 'schema') { bgColor = '#dbeafe' // blue-100 textColor = '#1e40af' // blue-800 - } else if (segment.type === 'instance') { + } else { bgColor = '#dcfce7' // green-100 textColor = '#166534' // green-800 - } else { - bgColor = '#fee2e2' // red-100 - textColor = '#991b1b' // red-800 } return ( @@ -274,7 +276,7 @@ function PropertyItem({ property, level, pathKey, sectionStates, onToggleSection } catch {} }} > - {renderGtsValue(String(property.value), registry)} + {renderGtsValue(String(property.value), registry, hasError)} )} diff --git a/apps/web/src/hooks/useJsonFiles.ts b/apps/web/src/hooks/useJsonFiles.ts index 1592056..d111b13 100644 --- a/apps/web/src/hooks/useJsonFiles.ts +++ b/apps/web/src/hooks/useJsonFiles.ts @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react' import { JsonRegistry, parseJSONC, parseYAML } from '@gts/shared' -import { Scanner } from '../../../../packages/fs-adapters/types' +import { Scanner, FileChange } from '../../../../packages/fs-adapters/types' // Use the smart scanner that automatically chooses the best implementation import { WebSmartScanner } from '../../../../packages/fs-adapters/fs-adapter-web/src/index' import { AppConfig } from '@/lib/config' @@ -18,6 +18,10 @@ export function useJsonObjsWithScanner(createScanner: () => Scanner) { const registryRef = useRef(new JsonRegistry()) const watcherRef = useRef<(() => void) | null>(null) const hasInitiallySelectedRef = useRef(false) + // Serializes incremental file-change handling so overlapping watch events + // don't validate against a half-updated registry. + const changeQueueRef = useRef>(Promise.resolve()) + const versionBumpTimerRef = useRef | null>(null) // Browser/Electron init path: // - Prompt for directory, scan and ingest files @@ -133,7 +137,47 @@ export function useJsonObjsWithScanner(createScanner: () => Scanner) { } } - // Watch for file changes and trigger reloads to keep registry/layout in sync + // Coalesce a burst of incremental changes into a single re-render. + function scheduleVersionBump() { + if (versionBumpTimerRef.current) clearTimeout(versionBumpTimerRef.current) + versionBumpTimerRef.current = setTimeout(() => setVersion(v => v + 1), 100) + } + + // Incrementally apply a single file change through the shared registry logic: + // reindex the changed file and revalidate it plus everything that depends on it + // (derivation, instantiation, $ref/allOf, GTS-id references). This is the same + // revalidation the VS Code extension performs, so behavior is identical across + // Web, Electron and VS Code. + async function applyIncrementalChange(change: FileChange) { + const scanner = scannerRef.current + const registry = registryRef.current + if (!scanner) return + const { type, doc } = change + try { + if (type === 'unlink') { + await registry.applyFileChange(doc.path, doc.name, null, AppConfig.get().gts) + } else { + const text = await scanner.read(doc.path) + const isYaml = doc.name.endsWith('.yaml') || doc.name.endsWith('.yml') + let content: any + try { + content = isYaml ? parseYAML(text) : parseJSONC(text) + } catch { + // Surface parse errors only for files that look GTS-related; ignore + // unrelated malformed JSON (matches loadFromScanner's filter). + content = text.includes('gts.') ? text : null + } + await registry.applyFileChange(doc.path, doc.name, content, AppConfig.get().gts) + } + scheduleVersionBump() + } catch (err) { + console.error('Failed to revalidate after file change:', err) + } + } + + // Watch for file changes and revalidate incrementally to keep registry/layout + // in sync (dependent types/instances are revalidated too, not just the file + // that changed). function startWatching() { const scanner = scannerRef.current if (!scanner) return @@ -148,20 +192,21 @@ export function useJsonObjsWithScanner(createScanner: () => Scanner) { { glob: '**/*.{json,jsonc,gts,yaml,yml}' }, (change) => { console.log('File change detected:', change) - // Reload data when files change - loadFromScanner().catch(err => { - console.error('Failed to reload after file change:', err) - }) + // Serialize changes so each validates against a fully-updated registry. + changeQueueRef.current = changeQueueRef.current.then(() => applyIncrementalChange(change)) } ) } - // Cleanup watcher on unmount + // Cleanup watcher and pending timers on unmount React.useEffect(() => { return () => { if (watcherRef.current) { watcherRef.current() } + if (versionBumpTimerRef.current) { + clearTimeout(versionBumpTimerRef.current) + } } }, []) diff --git a/apps/web/src/hooks/useJsonFilesVscode.ts b/apps/web/src/hooks/useJsonFilesVscode.ts index 56e3ece..37232b5 100644 --- a/apps/web/src/hooks/useJsonFilesVscode.ts +++ b/apps/web/src/hooks/useJsonFilesVscode.ts @@ -1,5 +1,6 @@ import React from 'react' import { JsonRegistry } from '@gts/shared' +import type { ValidationRelayPayload } from '@gts/shared' import { AppConfig } from '@/lib/config' import { ViewerModel } from './viewerModel' @@ -17,6 +18,34 @@ export function useJsonObjsVscode() { const registryRef = React.useRef(new JsonRegistry()) const hasInitiallySelectedRef = React.useRef(false) const pendingSelectFileRef = React.useRef(null) + // The webview runs under a CSP that blocks Ajv's code generation, so the + // extension host computes validation and relays it via `gts-validation-result`. + // That message and the (async) `gts-scan-result` ingest race: if validation + // arrives while `ingestFiles` is still populating the registry, the entity + // lookups miss and the errors would be lost forever. Keep the latest payload + // so it can be (re)applied once ingest has finished. + const pendingValidationRef = React.useRef(null) + + // Merge host-computed validation onto the current registry entities (matched + // by id / path). Safe to call repeatedly; missing entities are skipped. + const applyValidation = React.useCallback((payload: ValidationRelayPayload | null): boolean => { + if (!payload) return false + const reg = registryRef.current + let applied = false + for (const o of payload.objs || []) { + const ent = reg.jsonObjs.get(o.id) as any + if (ent && o.validation) { ent.validation = o.validation; applied = true } + } + for (const s of payload.schemas || []) { + const ent = reg.jsonSchemas.get(s.id) as any + if (ent && s.validation) { ent.validation = s.validation; applied = true } + } + for (const f of payload.invalidFiles || []) { + const ent = reg.invalidFiles.get(f.path) as any + if (ent && f.validation) { ent.validation = f.validation; applied = true } + } + return applied + }, []) // Helper function to find and select entity from a file path const selectEntityFromFile = React.useCallback((filePath: string) => { @@ -34,6 +63,9 @@ export function useJsonObjsVscode() { registry.reset() await registry.ingestFiles(files, AppConfig.get().gts) try { (registry as any).setDefaultFile?.(defaultFilePath) } catch {} + // Registry is now populated: (re)apply any host validation that arrived + // before/while this ingest was running (see pendingValidationRef). + applyValidation(pendingValidationRef.current) setVersion(v => v + 1) const defaultPath = (registry as any).getDefaultFilePath?.() @@ -48,7 +80,7 @@ export function useJsonObjsVscode() { pendingSelectFileRef.current = null setTimeout(() => selectEntityFromFile(target), 0) } - }, [selectEntityFromFile]) + }, [selectEntityFromFile, applyValidation]) // Listen for scan events and trigger scan on mount React.useEffect(() => { @@ -66,6 +98,9 @@ export function useJsonObjsVscode() { async function onResult(e: any) { const files = e?.detail?.files || [] const defaultFilePath = e?.detail?.defaultFilePath || null + // A fresh scan invalidates the previous scan's validation; the host always + // follows a scan-result with a matching validation-result for this set. + pendingValidationRef.current = null try { await buildEntities(files, defaultFilePath) } finally { @@ -100,15 +135,11 @@ export function useJsonObjsVscode() { window.addEventListener('gts-select-file' as any, onSelectFile) function onValidationResult(e: any) { - const detail = e?.detail || {} - const objList: Array<{id: string; validation?: any}> = detail.objs || [] - const schemaList: Array<{id: string; validation?: any}> = detail.schemas || [] - const invalidList: Array<{path: string; validation?: any}> = detail.invalidFiles || [] - - const reg = registryRef.current - objList.forEach(o => { const ent = reg.jsonObjs.get(o.id) as any; if (ent && o.validation) ent.validation = o.validation }) - schemaList.forEach(s => { const ent = reg.jsonSchemas.get(s.id) as any; if (ent && s.validation) ent.validation = s.validation }) - invalidList.forEach(f => { const ent = reg.invalidFiles.get(f.path) as any; if (ent && f.validation) ent.validation = f.validation }) + const payload = (e?.detail || null) as ValidationRelayPayload | null + // Buffer so it can be re-applied if the scan-result ingest is still in + // flight (the two messages race), then apply against whatever is ready now. + pendingValidationRef.current = payload + applyValidation(payload) setVersion(v => v + 1) } @@ -137,7 +168,7 @@ export function useJsonObjsVscode() { window.removeEventListener('gts-validation-error' as any, onValidationError) window.removeEventListener('gts-select-file' as any, onSelectFile) } - }, [buildEntities]) + }, [buildEntities, applyValidation]) // Refresh from webview: ask the extension to rescan; SharedApp coordinates viewport/entity restoration const reload = React.useCallback(async () => { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f91e301..803ff46 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -21,7 +21,6 @@ "noFallthroughCasesInSwitch": true, /* Path mapping */ - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/package-lock.json b/package-lock.json index 36cef02..62a7dc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gts-monorepo", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gts-monorepo", - "version": "0.2.6", + "version": "0.2.7", "workspaces": [ "apps/*", "packages/*" @@ -22,7 +22,7 @@ }, "apps/electron": { "name": "@gts-viewer/electron", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "@dagrejs/dagre": "^1.1.5", "@gts-viewer/shared": "file:../../packages/shared", @@ -75,7 +75,7 @@ }, "apps/server": { "name": "@gts/server", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "@gts/shared": "*", "cors": "2.8.5", @@ -108,7 +108,7 @@ }, "apps/vscode-extension": { "name": "gts-kit", - "version": "0.2.6", + "version": "0.2.7", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.11.30", @@ -119,7 +119,8 @@ "esbuild": "^0.19.0", "ignore": "^5.3.2", "jsonc-parser": "^3.3.1", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "yaml": "^2.9.1" }, "engines": { "vscode": "^1.85.0" @@ -141,7 +142,7 @@ }, "apps/web": { "name": "@gts/web", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "@dagrejs/dagre": "^1.1.5", "@gts/layout-storage": "*", @@ -1733,16 +1734,16 @@ } }, "node_modules/@globaltypesystem/gts-ts": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@globaltypesystem/gts-ts/-/gts-ts-0.4.0.tgz", - "integrity": "sha512-Hg+FSIHULo2Y9ZOhe66/XW6pNy/9HBPk+n29/w9TqYdaw5mAUpd9st0eQ11OAe5cp4QOk+aeVupNMYZnfNEysg==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@globaltypesystem/gts-ts/-/gts-ts-0.5.0.tgz", + "integrity": "sha512-tdYFL7SD12t/qM0+KFUYSMZ6GZq32LqdmtwbZLlGbF6VRCa3UK/qza0FxHIfWgAHQRyciGJsZ88kf0+7yEiWPg==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.18.0", "ajv-formats": "^2.1.1", "commander": "^12.0.0", - "fastify": "^5.8.1", - "uuid": "^9.0.1" + "fastify": "^5.12.2", + "uuid": "^11.1.1" }, "bin": { "gts": "dist/cli/index.js", @@ -12808,17 +12809,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -13600,6 +13600,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", @@ -13751,7 +13767,7 @@ }, "packages/layout-storage": { "name": "@gts/layout-storage", - "version": "0.2.6", + "version": "0.2.7", "devDependencies": { "typescript": "^5.3.3" } @@ -13772,9 +13788,9 @@ }, "packages/shared": { "name": "@gts/shared", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { - "@globaltypesystem/gts-ts": "^0.4.0", + "@globaltypesystem/gts-ts": "^0.5.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.0", diff --git a/packages/shared/README.md b/packages/shared/README.md new file mode 100644 index 0000000..3f86828 --- /dev/null +++ b/packages/shared/README.md @@ -0,0 +1,29 @@ +# @gts/shared + +Shared GTS models, parsing, and the `JsonRegistry` used by every app (web, +server, electron, VS Code) to index and validate GTS entities. + +## How files are recognized as GTS entity sources + +`isGtsCandidateFileName` accepts `.json`, `.jsonc`, `.gts`, `.yaml`, and `.yml`. +How a file's contents are interpreted depends on its extension: + +- **JSON / JSONC / .gts** — the document is either a **single entity** or a + **top-level array of entities**. Nothing else is scanned; any GTS id that + appears elsewhere is treated as a *reference*. + +- **YAML** — everything above, **plus** inline definitions. A YAML config file + may *define* GTS types/instances inline under any nested `entities:` array + (for example a service's `types-registry.config.entities` seed block), even + when it is buried several levels deep inside otherwise-non-GTS config. Each + element of such an array is registered as a real definition (keyed by its + `$id`), so its `$id` is treated as a **definition**, not as a dangling + reference. + +This YAML-only rule exists because runtime config files legitimately seed GTS +types inline. Without it, every inline `$id` was harvested by the generic id +walker and reported as `GTS reference not found`. Non-GTS entries under an +`entities:` array are ignored automatically (they fail the `isGtsEntity` gate). + +See `collectInlineEntityDefinitions` and `processFileContent` in +`src/registry.ts`. diff --git a/packages/shared/package.json b/packages/shared/package.json index e73bccb..877efb9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@gts/shared", - "version": "0.2.6", + "version": "0.2.7", "private": true, "type": "module", "main": "./dist/index.js", @@ -16,7 +16,7 @@ "dev": "tsc --watch" }, "dependencies": { - "@globaltypesystem/gts-ts": "^0.4.0", + "@globaltypesystem/gts-ts": "^0.5.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.0", diff --git a/packages/shared/src/entities.ts b/packages/shared/src/entities.ts index 6fac9f6..0545866 100644 --- a/packages/shared/src/entities.ts +++ b/packages/shared/src/entities.ts @@ -371,6 +371,55 @@ export interface ValidationResult { errors: ValidationError[] } +/** + * Host-computed validation for a single entity, relayed to the VS Code webview. + * + * The webview runs under a strict Content-Security-Policy that forbids the + * code generation Ajv relies on (`new Function`), so it cannot run the + * JSON-Schema / gts-ts validation itself. The extension host computes it and + * ships these DTOs across the message channel; the webview merges the + * `validation` back onto its own registry entities (matched by `id`). + */ +export interface EntityValidationDto { + /** Entity id, used to match the webview's registry entity. */ + id: string + /** Absolute path of the file the entity was parsed from. */ + filePath?: string + /** The host-computed validation result to apply (absent if not validated). */ + validation?: ValidationResult +} + +/** {@link EntityValidationDto} plus the extra fields a JsonObj needs to re-key. */ +export interface ObjValidationDto extends EntityValidationDto { + /** Index of the object within a multi-document file (if applicable). */ + listSequence?: number + /** Resolved schema id for the object (if any). */ + schemaId?: string +} + +/** A file that failed to parse/index, with the errors that explain why. */ +export interface InvalidFileValidationDto { + /** Absolute path of the invalid file. */ + path: string + /** Base name of the invalid file. */ + name: string + /** The parse/index errors for the file (absent if none). */ + validation?: ValidationResult +} + +/** + * Full validation payload relayed from the extension host to the webview + * (the `detail` of a `gts-validation-result` message). + */ +export interface ValidationRelayPayload { + /** Per-instance validation. */ + objs: ObjValidationDto[] + /** Per-schema validation. */ + schemas: EntityValidationDto[] + /** Files that couldn't be parsed/indexed. */ + invalidFiles: InvalidFileValidationDto[] +} + export class JsonFile { path: string name: string diff --git a/packages/shared/src/gts-styling.ts b/packages/shared/src/gts-styling.ts index 3cf262b..f6d2a05 100644 --- a/packages/shared/src/gts-styling.ts +++ b/packages/shared/src/gts-styling.ts @@ -1,4 +1,4 @@ -import { isGtsId, normalizeGtsId } from './entities.js' +import { isGtsId, isGtsType, normalizeGtsId } from './entities.js' /** * Parse a GTS ID string and extract its parts @@ -14,25 +14,20 @@ export function parseGtsIdParts(gtsId: string): string[] { const normalizedId = normalizeGtsId(gtsId) const parts: string[] = [] - // Find the first tilde - const firstTildeIndex = normalizedId.indexOf('~') - if (firstTildeIndex === -1) { - // No tilde found, return the whole ID - return [normalizedId] - } + let segmentStart = 0 + let separatorIndex = normalizedId.indexOf('~') - // First part: from start to first tilde (inclusive) - const firstPart = normalizedId.substring(0, firstTildeIndex + 1) - parts.push(firstPart) + while (separatorIndex !== -1) { + parts.push(normalizedId.substring(segmentStart, separatorIndex + 1)) + segmentStart = separatorIndex + 1 + separatorIndex = normalizedId.indexOf('~', segmentStart) + } - // Check if there's a second part after the first tilde - const remainingPart = normalizedId.substring(firstTildeIndex + 1) - if (remainingPart.length > 0) { - // Second part exists - parts.push(remainingPart) + if (segmentStart < normalizedId.length) { + parts.push(normalizedId.substring(segmentStart)) } - return parts + return parts.length > 0 ? parts : [normalizedId] } /** @@ -64,10 +59,20 @@ export interface GtsStyleAnalysis { } /** - * Analyze a GTS ID and determine how each part should be styled + * Analyze a GTS ID and determine how each part should be styled. + * + * Schema-vs-instance classification is derived STRUCTURALLY from the GTS ID via + * gts-ts (`isGtsType`). Correctness (blue/green vs red) is derived from the + * authoritative gts-ts validation results surfaced through `entityLookup`: + * a segment whose cumulative entity failed gts-ts validation (`isValid: false`) + * is rendered as an error. GTS *rule* violations (abstract instantiation, + * derivation incompatibility, x-gts-ref, ...) are therefore not re-derived here; + * they are read back from `entityLookup`/the caller's validation errors, keeping + * gts-ts the single source of truth. * * @param gtsId - The GTS ID to analyze (may have gts:// prefix which is stripped) - * @param entityLookup - Function to look up whether an entity exists and its type + * @param entityLookup - Function to look up whether an entity exists, its kind, + * and whether gts-ts validation found it valid * @returns Analysis result with styled segments * * @example @@ -83,7 +88,7 @@ export interface GtsStyleAnalysis { */ export function analyzeGtsIdForStyling( gtsId: string, - entityLookup: (entityId: string) => { exists: boolean; isSchema?: boolean } + entityLookup: (entityId: string) => { exists: boolean; isSchema?: boolean; isValid?: boolean } ): GtsStyleAnalysis { // Normalize to strip gts:// prefix per GTS spec const normalizedId = normalizeGtsId(gtsId) @@ -106,25 +111,40 @@ export function analyzeGtsIdForStyling( const parts = parseGtsIdParts(normalizedId) let currentOffset = 0 - for (const part of parts) { - // Determine the full entity ID to look up - let entityIdToLookup: string - if (parts.length === 1) { - entityIdToLookup = part - } else if (part === parts[0]) { - entityIdToLookup = part - } else { - entityIdToLookup = parts[0] + part - } + let hasMissingAncestor = false + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const part = parts[partIndex] + const entityIdToLookup = parts.slice(0, partIndex + 1).join('') + + // A cumulative id ending in "~" names a TYPE (schema); otherwise it names + // an INSTANCE. This shape is derived structurally from the GTS ID itself. + const structuralIsType = isGtsType(entityIdToLookup) - // Look up the entity - const lookupResult = entityLookup(entityIdToLookup) + // Existence + validity lookup (skipped once an ancestor is already + // missing/invalid, so the error cascades to the rest of the chain). + const lookupResult = hasMissingAncestor ? { exists: false } : entityLookup(entityIdToLookup) let segmentType: 'schema' | 'instance' | 'error' - if (lookupResult.exists) { - segmentType = lookupResult.isSchema ? 'schema' : 'instance' - } else { + if (!lookupResult.exists || lookupResult.isValid === false) { + // Missing entity, or an entity gts-ts validation rejected → error. + segmentType = 'error' + hasMissingAncestor = true + } else if (structuralIsType) { + // A "~"-terminated (type) segment is valid only when a *schema* with that + // id actually exists. A type-shaped id backed only by an instance document + // (or nothing) is an error — e.g. an instance whose own id ends in "~" + // has no backing schema. + if (lookupResult.isSchema === true) { + segmentType = 'schema' + } else { + segmentType = 'error' + hasMissingAncestor = true + } + } else if (lookupResult.isSchema === true) { + // Instance-shaped id backed by a schema document → malformed. segmentType = 'error' + } else { + segmentType = 'instance' } segments.push({ @@ -236,7 +256,14 @@ export function levenshteinDistance(a: string, b: string): number { * ``` */ export function findSimilarEntityIds(targetId: string, allIds: string[], maxResults: number = 3): string[] { - const similarities = allIds.map(id => ({ + // Deduplicate the candidate list and never suggest the queried id itself. + // Callers typically build `allIds` by concatenating several registry maps + // (e.g. schemas + instances), so the same id can appear more than once; and a + // "Did you mean...?" that echoes the exact id the user hovered is noise that + // renders as a confusing self-reference / duplicated row. + const candidates = Array.from(new Set(allIds)).filter(id => id !== targetId) + + const similarities = candidates.map(id => ({ id, distance: levenshteinDistance(targetId, id) })) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 742b2c4..ddc458e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,9 +1,11 @@ export * from './types.js' export * from './entities.js' export { JsonRegistry } from './registry.js' +export type { RevalidationResult } from './registry.js' export * from './jsonc.js' export * from './yaml.js' export * from './parse.js' export { isGtsCandidateFileName } from './registry.js' export { GTS_COLORS } from './entities.js' export * from './gts-styling.js' +export { findSchemaPropertyPath } from './schemaParser.js' diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index d0f8d04..2b1372a 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -1,13 +1,93 @@ import { JsonFile, JsonObj, JsonSchema, createEntity, getGtsConfig, decodeGtsId, createAbsentEntity, normalizeGtsId, findGtsPrefixViolations } from './entities.js' import type { GtsConfig, JsonEntity, ValidationResult, ValidationError } from './entities.js' +import { isYamlFileName } from './parse.js' +import { findSchemaPropertyPath } from './schemaParser.js' import Ajv, { type ValidateFunction, type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' import { GtsModifiers, GtsStore, createJsonEntity } from '@globaltypesystem/gts-ts' // XGtsRefValidator is not re-exported from the package index, so import it from // its published subpath module. import { XGtsRefValidator } from '@globaltypesystem/gts-ts/dist/x-gts-ref.js' +import type { Format } from 'ajv' import * as path from 'path' +const JSON_SCHEMA_ANNOTATION_KEYWORDS = new Set([ + '$comment', + 'title', + 'description', + 'default', + 'deprecated', + 'readOnly', + 'writeOnly', + 'examples', + 'contentEncoding', + 'contentMediaType', + 'contentSchema' +]) + +/** + * Prepare a schema for Ajv instance validation by removing the `x-gts-ref` + * keyword, mirroring gts-ts's own `GtsStore.normalizeSchema` (the reference + * implementation strips `x-gts-ref` "so Ajv never sees the unknown keyword" + * and then prunes combinator branches that were `x-gts-ref`-only). + * + * This matters for combinators: a branch like `{ "x-gts-ref": "…" }` becomes an + * empty schema once the keyword is dropped, i.e. always-true. Left in place, an + * `oneOf` of two such branches matches *both* and Ajv spuriously reports + * "must match exactly one schema in oneOf" for a value that is perfectly valid. + * The actual `x-gts-ref` assertions — including correct oneOf/anyOf branch + * counting — are enforced separately by `XGtsRefValidator` (§9.6). + * + * Unlike gts-ts's full `normalizeSchema`, this intentionally does NOT rewrite + * `$id`/`$ref` (it leaves any `gts://` prefixes untouched) because the + * registry's Ajv instance resolves those via its own `gts://`-aware loader. + */ +function stripXGtsRefForAjv(schema: any): any { + if (schema === null || typeof schema !== 'object') return schema + if (Array.isArray(schema)) return schema.map(stripXGtsRefForAjv) + + const normalized: Record = {} + for (const [key, value] of Object.entries(schema)) { + if (key === 'x-gts-ref') continue + normalized[key] = value && typeof value === 'object' ? stripXGtsRefForAjv(value) : value + } + + // Drop combinator subschemas whose only assertion was `x-gts-ref`, so Ajv + // doesn't treat their remaining annotations as always-true branches. + for (const combinator of ['oneOf', 'anyOf', 'allOf'] as const) { + if (!Array.isArray(normalized[combinator])) continue + normalized[combinator] = normalized[combinator].filter((_sub: any, idx: number) => { + const original = (schema as any)[combinator]?.[idx] + const hasOnlyXGtsRefAssertion = + original && + typeof original === 'object' && + !Array.isArray(original) && + original['x-gts-ref'] !== undefined && + Object.keys(original).every(key => key === 'x-gts-ref' || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(key)) + return !hasOnlyXGtsRefAssertion + }) + if (normalized[combinator].length === 0) delete normalized[combinator] + } + + return normalized +} + +/** + * Evaluate one of `ajv-formats`' standard `Format` definitions against a string. + * A `Format` may be a `RegExp`, a validator function, or a + * `{ validate }` object whose `validate` is again a regex or a function. + */ +function matchesFormat(format: Format, value: string): boolean { + if (format instanceof RegExp) return format.test(value) + // The `Format` union also covers async/number variants; the temporal string + // formats we compose here are synchronous, so narrow the callable/regex forms. + const def = format as { validate?: RegExp | ((v: string) => boolean) } | ((v: string) => boolean) + const validate = typeof def === 'function' ? def : def?.validate + if (validate instanceof RegExp) return validate.test(value) + if (typeof validate === 'function') return Boolean(validate(value)) + return false +} + /** * Convert an XGtsRefValidator field path (dot/bracket notation, e.g. * `value.tags[0]`) into a JSON-Pointer-style instancePath (`/value/tags/0`) @@ -25,6 +105,36 @@ function normalizeToArray(content: any): any[] { return Array.isArray(content) ? content : [content] } +/** + * Reverse-dependency graph, split by how a change propagates. + * + * `structural.get(id)` — entity ids whose *effective schema* incorporates `id` + * (derivation, multi-level derivation, instantiation, `$ref`/`allOf`). A change + * to `id` changes their meaning, so this relation is followed **transitively**. + * + * `references.get(id)` — entity ids that merely *point at* `id` by GTS id (any + * GTS id field, `x-gts-ref`). They must be re-checked when `id` changes/renames, + * but their own shape is unaffected, so this relation is applied **depth-1**. + */ +interface DependencyGraph { + structural: Map> + references: Map> +} + +/** + * Outcome of handling a single file change (see JsonRegistry.applyFileChange / + * revalidateAfterChange). All paths are the entity file paths that were + * revalidated in the registry so callers can refresh exactly those. + */ +export interface RevalidationResult { + /** The file that changed. */ + changedPath: string + /** Files whose entities depend on the changed file and were revalidated. */ + dependentPaths: Set + /** All revalidated paths (the changed file, if still present, plus dependents). */ + revalidatedPaths: string[] +} + /** * JsonRegistry: central store and fetch cache for JsonFile/JsonObj/JsonSchema */ @@ -52,6 +162,11 @@ export class JsonRegistry { // gts-ts store (register() throws these per §9.11.1), keyed by schema id. private gtsStoreDeclErrors: Map = new Map() + // Cached reverse-dependency graph used by getDependentFilePaths(). Rebuilt + // lazily and invalidated whenever the entity set changes (any indexFile / + // invalidateFile / reset). See buildDependencyGraph() for the edge model. + private depGraph: DependencyGraph | null = null + constructor() { this.jsonObjs = new Map() this.jsonSchemas = new Map() @@ -74,6 +189,7 @@ export class JsonRegistry { this.jsonFileSchemas.clear() this.defaultFilePath = null this.invalidateGtsStore() + this.depGraph = null } /** Drop the cached gts-ts store so it is rebuilt from current schemas on next use. */ @@ -83,11 +199,14 @@ export class JsonRegistry { } /** - * Build (once, then cache) a gts-ts GtsStore mirroring every schema currently - * in the registry, so ancestor-chain-dependent checks (derivation, traits, - * final/abstract guards) can be delegated to the reference implementation. - * Modifier-declaration errors that gts-ts throws at registration time are - * captured per schema id rather than aborting the whole build. + * Build (once, then cache) a gts-ts GtsStore mirroring every schema *and + * instance* currently in the registry, so both ancestor-chain-dependent + * schema checks (derivation, traits, final/abstract guards) and instance + * checks (`validateInstance`) can be delegated to the reference + * implementation instead of being re-derived here. Modifier-declaration + * errors that gts-ts throws when registering a schema are captured per id + * rather than aborting the whole build; instances that fail to register are + * skipped (they surface through the normal instance-validation path). */ private getGtsStore(): GtsStore { if (this.gtsStore) return this.gtsStore @@ -100,6 +219,14 @@ export class JsonRegistry { this.gtsStoreDeclErrors.set(schema.id, err instanceof Error ? err.message : String(err)) } } + for (const obj of this.jsonObjs.values()) { + try { + store.register(createJsonEntity(obj.content)) + } catch { + // Instance couldn't be registered (e.g. malformed/UUID-less id); it is + // reported through validateEntity's normal path, not via the store. + } + } this.gtsStore = store return store } @@ -108,8 +235,10 @@ export class JsonRegistry { * Invalidate a file and remove its JsonFile and associated records from the registry. */ invalidateFile(path: string): void { - // Any schema set change invalidates the derived gts-ts store. + // Any schema set change invalidates the derived gts-ts store and the + // reverse-dependency graph (both are rebuilt lazily on next use). this.invalidateGtsStore() + this.depGraph = null if (this.jsonFiles.has(path)) { this.jsonFiles.delete(path) } @@ -132,6 +261,260 @@ export class JsonRegistry { } } + /** + * Cumulative `~`-terminated prefixes of a GTS id — its ancestor *type* chain. + * + * GTS encodes derivation directly in the id: a type id and every derived type + * appended after it are separated by `~`. So for + * `gts.a.b.c.d.v1~k.l.m.n.v1~` the ancestor type ids are + * `gts.a.b.c.d.v1~` (the base) and `gts.a.b.c.d.v1~k.l.m.n.v1~` (the full id). + * This is how single-level derivation, multi-level derivation and + * instantiation are all reduced to one relation: "does this id's type chain + * contain the changed type id?". + */ + private static ancestorTypeIds(id: string): string[] { + const out: string[] = [] + if (!id) return out + let idx = id.indexOf('~') + while (idx !== -1) { + out.push(id.slice(0, idx + 1)) + idx = id.indexOf('~', idx + 1) + } + return out + } + + /** All entity ids currently defined in the given file (schemas + instances). */ + getEntityIdsForFile(path: string): string[] { + const ids: string[] = [] + for (const s of this.jsonFileSchemas.get(path) || []) ids.push(s.id) + for (const o of this.jsonFileObjs.get(path) || []) ids.push(o.id) + return ids + } + + /** + * Build (once, then cache) the reverse-dependency graph. See DependencyGraph + * for the two edge kinds and how each propagates. Edges recorded per entity: + * + * structural (transitive — Type #1 schema dependency): + * - derivation / multi-level derivation: a schema whose own id has the + * target in its ancestor type chain (id prefix at `~` boundaries) + * - instantiation: an instance whose `schemaId` chain contains the target + * (its direct type and every base of that type) + * - `$ref` / `allOf` / ...: a schema whose JSON-Schema refs point at target + * (JsonSchema.schemaRefs) + * + * references (depth-1 — Type #2 id reference): + * - any GTS id used anywhere in the entity's content (JsonEntity.gtsRefs), + * which already includes `x-gts-ref` targets (their concrete values are + * valid GTS ids). Wildcard `x-gts-ref` *patterns* are authoring + * constraints; the concrete instance value that matches carries the real + * id edge via gtsRefs, so patterns need no separate reverse edge. + */ + private buildDependencyGraph(): DependencyGraph { + if (this.depGraph) return this.depGraph + + const structural = new Map>() + const references = new Map>() + const link = (map: Map>, target: string, dependent: string) => { + if (!target || !dependent || target === dependent) return + let set = map.get(target) + if (!set) { set = new Set(); map.set(target, set) } + set.add(dependent) + } + + const addEntity = (entity: JsonEntity, isSchema: boolean) => { + const eid = entity.id + if (!eid) return + + // Structural: derivation & instantiation via the type chain. Schemas + // derive from their proper ancestors (exclude their own full id); an + // instance depends on every type in its schemaId chain (incl. direct type). + const chainSource = isSchema ? eid : (entity.schemaId || '') + for (const ancestor of JsonRegistry.ancestorTypeIds(chainSource)) { + if (isSchema && ancestor === eid) continue + link(structural, ancestor, eid) + } + // Structural: JSON-Schema $ref / allOf composition (schemas only). + if (isSchema) { + const schemaRefs = (entity as JsonSchema).schemaRefs + if (schemaRefs) for (const ref of schemaRefs) link(structural, ref.id, eid) + } + + // References (depth-1): every GTS id the entity points at. + if (entity.gtsRefs) { + for (const ref of entity.gtsRefs) link(references, ref.id, eid) + } + } + + for (const schema of this.jsonSchemas.values()) addEntity(schema, true) + for (const obj of this.jsonObjs.values()) addEntity(obj, false) + + this.depGraph = { structural, references } + return this.depGraph + } + + /** + * Return the set of file paths (excluding `changedPath`) whose entities must be + * revalidated when `changedPath` changes. + * + * Two relations are combined (see DependencyGraph): + * 1. structural dependents are followed **transitively** (a derived type's + * own dependents are affected too); + * 2. plain id-reference dependents of the changed (seed) ids are added + * **depth-1**. A structural descendant's *shape* may change, but a plain + * id reference only checks its target's existence/id — which is unchanged + * — so references are not propagated through the structural closure. + * + * The structural walk is breadth-first guarded by a `visited` set, so + * derivation can never cycle and reference cycles (schema A `$ref`s B and B + * `$ref`s A) terminate. + * + * `extraSeedIds` lets callers add ids that existed *before* an edit (captured + * prior to reindexing) so that renaming/removing an id still revalidates the + * files that referenced its old id. + */ + getDependentFilePaths(changedPath: string, extraSeedIds?: Iterable): Set { + const paths = new Set() + + const seedIds = new Set(this.getEntityIdsForFile(changedPath)) + if (extraSeedIds) for (const id of extraSeedIds) if (id) seedIds.add(id) + if (seedIds.size === 0) return paths + + const { structural, references } = this.buildDependencyGraph() + + const addFile = (entityId: string) => { + const filePath = this.jsonSchemas.get(entityId)?.file?.path + || this.jsonObjs.get(entityId)?.file?.path + if (filePath && filePath !== changedPath) paths.add(filePath) + } + + // 1. Transitive structural closure over the changed ids. `visited` guards + // the BFS against cycles (reference-induced or otherwise). + const visited = new Set(seedIds) + const queue = [...seedIds] + while (queue.length > 0) { + const current = queue.shift()! + const dependents = structural.get(current) + if (!dependents) continue + for (const dependent of dependents) { + if (visited.has(dependent)) continue + visited.add(dependent) + queue.push(dependent) + addFile(dependent) + } + } + + // 2. Depth-1 id-reference dependents of the changed (seed) ids only. + for (const id of seedIds) { + const referrers = references.get(id) + if (!referrers) continue + for (const referrer of referrers) addFile(referrer) + } + + return paths + } + + /** + * Validate every entity currently indexed for `path` (schemas first, then + * instances) against the current registry context. Files that failed to parse + * already carry their error on the JsonFile in `invalidFiles`, so they are + * skipped here. + */ + async validateFile(path: string): Promise { + if (this.invalidFiles.has(path)) return + for (const schema of this.jsonFileSchemas.get(path) || []) { + await this.validateEntity(schema) + } + for (const obj of this.jsonFileObjs.get(path) || []) { + await this.validateEntity(obj) + } + } + + /** + * Revalidate `changedPath` and every file that (transitively/­referentially) + * depends on it. Assumes the changed file's new content is *already indexed* + * (via `indexFile`/`invalidateFile`). `previousIds` should carry the ids the + * file defined before the edit so a rename/removal still revalidates the files + * that referenced the old id. + * + * Returns the affected file paths (the changed file, when it still holds + * entities, plus all dependents) so callers can refresh their UI/markers. + */ + async revalidateAfterChange( + changedPath: string, + previousIds?: Iterable + ): Promise { + const dependents = this.getDependentFilePaths(changedPath, previousIds) + + const revalidatedPaths: string[] = [] + const changedStillPresent = this.jsonFiles.has(changedPath) || this.invalidFiles.has(changedPath) + if (changedStillPresent) { + await this.validateFile(changedPath) + revalidatedPaths.push(changedPath) + } + for (const dependentPath of dependents) { + await this.validateFile(dependentPath) + revalidatedPaths.push(dependentPath) + } + + return { changedPath, dependentPaths: dependents, revalidatedPaths } + } + + /** + * End-to-end handler for a single file change, shared by every app (Web, + * Electron, VS Code) so revalidation behaves identically everywhere: + * 1. snapshot the file's previous entity ids (for rename/removal), + * 2. (re)index the new `content` — or drop the file when `content` is + * null/undefined (deletion), + * 3. revalidate the changed file and all of its dependents. + */ + async applyFileChange( + path: string, + name: string, + content: any, + cfg: GtsConfig = getGtsConfig(undefined) + ): Promise { + const previousIds = this.getEntityIdsForFile(path) + if (content === null || content === undefined) { + this.invalidateFile(path) + } else { + this.indexFile(path, name, content, cfg) + } + return this.revalidateAfterChange(path, previousIds) + } + + /** + * Recursively collect GTS entity definitions embedded inline under any nested + * `entities:` array within a parsed (YAML) document. This supports config + * files that seed GTS types/instances inline — e.g. a service's + * `types-registry.config.entities` block — where each array element is a full + * JSON Schema / instance keyed by its own `$id`. + * + * The returned contents are handed to the normal entity pipeline + * (`createEntity` + `isGtsEntity`), so non-GTS `entities` entries are filtered + * out naturally and only genuine definitions are registered. + */ + private static collectInlineEntityDefinitions(root: any): any[] { + const out: any[] = [] + const visit = (node: any): void => { + if (!node || typeof node !== 'object') return + if (Array.isArray(node)) { + node.forEach(visit) + return + } + for (const [key, value] of Object.entries(node)) { + if (key === 'entities' && Array.isArray(value)) { + for (const el of value) { + if (el && typeof el === 'object' && !Array.isArray(el)) out.push(el) + } + } + visit(value) + } + } + visit(root) + return out + } + /** * Process a file and store its entities if they are GTS entities. * This is a helper used by both scanFile and ingestFiles. @@ -157,18 +540,11 @@ export class JsonRegistry { // when a raw string was passed in. const parsedContent = jsonFile.content - // Normalize content to array and process each entity - const entities = normalizeToArray(parsedContent) - entities.forEach((entityContent: any, idx: number) => { - const seq = Array.isArray(parsedContent) ? idx : undefined - const entity = createEntity({ - file: jsonFile, - listSequence: seq, - content: entityContent, - cfg - }) - - if (entity && entity.isGtsEntity()) { + // Register one entity content as a schema/instance if it is a GTS entity. + const registerEntity = (entityContent: any, seq: number | undefined, requireSelectedId = false) => { + const entity = createEntity({ file: jsonFile, listSequence: seq, content: entityContent, cfg }) + const hasSelectedId = entity?.selectedEntityIdField !== undefined || entity?.selectedSchemaIdField !== undefined + if (entity && (!requireSelectedId || hasSelectedId) && entity.isGtsEntity()) { hasGtsEntities = true if (entity instanceof JsonSchema) { this.jsonSchemas.set(entity.id, entity) @@ -178,8 +554,27 @@ export class JsonRegistry { this.jsonFileObjs.set(path, [...this.jsonFileObjs.get(path) || [], entity as JsonObj]) } } + } + + // Top level: a single entity, or a top-level array of entities. This is the + // only shape recognized for JSON/JSONC/.gts files. + const entities = normalizeToArray(parsedContent) + entities.forEach((entityContent: any, idx: number) => { + registerEntity(entityContent, Array.isArray(parsedContent) ? idx : undefined, isYamlFileName(name)) }) + // YAML ONLY: config files may additionally *define* GTS types/instances + // inline under nested `entities:` arrays (e.g. a types-registry + // `config.entities` seed block), possibly buried several levels deep inside + // otherwise-non-GTS config. Register each such element as a real definition + // so its `$id` is treated as a definition instead of being harvested as a + // dangling reference. JSON files intentionally keep the strict shape above. + if (isYamlFileName(name)) { + for (const def of JsonRegistry.collectInlineEntityDefinitions(parsedContent)) { + registerEntity(def, undefined) + } + } + // Only store the JsonFile once if it contains GTS entities if (hasGtsEntities && !this.jsonFiles.has(path)) { this.jsonFiles.set(path, jsonFile) @@ -323,13 +718,19 @@ export class JsonRegistry { } else { const result = store.validateSchemaAgainstParent(entity.id) if (!result.ok && result.error) { - entity.validation.errors.push({ - instancePath: '', - schemaPath: '#', - keyword: 'x-gts-schema', - message: result.error, - params: {} - }) + const rawMessages = result.error.split('; ') + for (const msg of rawMessages) { + const propMatch = msg.match(/^Property '([^']+)'/) + const propPath = propMatch ? propMatch[1] : null + const instancePath = propPath ? findSchemaPropertyPath(entity.content, propPath) : '/$id' + entity.validation.errors.push({ + instancePath: instancePath || '/$id', + schemaPath: '#', + keyword: 'x-gts-schema', + message: msg, + params: propPath ? { property: propPath } : {} + }) + } } } @@ -346,10 +747,39 @@ export class JsonRegistry { params: { value: err.value, refPattern: err.refPattern } }) } + + if (entity.validation.errors.length === 0) { + const ancestors = JsonRegistry.ancestorTypeIds(entity.id) + const parentId = ancestors.length > 1 ? ancestors[ancestors.length - 2] : null + const parent = parentId ? this.jsonSchemas.get(parentId) : undefined + if (parent) { + await this.validateEntity(parent) + if (parent.validation?.errors.length) { + entity.validation.errors.push({ + instancePath: '/$id', + schemaPath: '#', + keyword: 'x-gts-schema', + message: `Parent schema '${parentId}' has GTS validation errors`, + params: { schemaId: parentId } + }) + } + } + } } else if (entity instanceof JsonObj) { // Validate the object against its schema if (!entity.schemaId) { - // No schema to validate against + const store = this.getGtsStore() + const result = store.validateInstance(entity.id) + if (!result.ok) { + const idField = (entity as any).selectedSchemaIdField || (entity as any).selectedEntityIdField || 'id' + entity.validation.errors.push({ + instancePath: '/' + String(idField), + schemaPath: '#', + keyword: 'schema', + message: result.error, + params: { gtsId: entity.id } + }) + } return } @@ -386,8 +816,13 @@ export class JsonRegistry { try { const ajv = this.createAjvInstance() - // Compile the schema with async $ref resolution - const validate = await ajv.compileAsync(schema.content) + // Compile the schema with async $ref resolution. Strip `x-gts-ref` + // first (mirroring gts-ts's normalizeSchema) so Ajv never sees the + // unknown keyword and, crucially, so `x-gts-ref`-only combinator + // branches don't collapse into always-true schemas and make e.g. + // `oneOf: [{x-gts-ref}, {x-gts-ref}]` fail. The `x-gts-ref` assertions + // themselves are enforced by XGtsRefValidator below. + const validate = await ajv.compileAsync(stripXGtsRefForAjv(schema.content)) const valid = validate(entity.content) as boolean @@ -534,9 +969,25 @@ export class JsonRegistry { } }) - // Add format validation (email, uri, date-time, etc.) + // Add format validation (email, uri, date-time, etc.). Default "full" mode + // validates real value ranges (e.g. rejects month 13, offset +25:00). addFormats(ajv) + // Tighten the temporal formats to strict RFC 3339. ajv-formats' full-mode + // date/time splits on `/t|\s/i`, so it accepts a space instead of `T` + // (permitted by RFC 3339 §5.6's NOTE, but not by the ABNF grammar GTS + // requires). Compose the two *standard* ajv-formats validators so a value + // must satisfy BOTH: the "fast" grammar (strict `T` separator + mandatory + // time-offset) AND the "full" validator (real calendar/clock ranges). + for (const name of ['date', 'time', 'date-time'] as const) { + const fast = addFormats.get(name, 'fast') + const full = addFormats.get(name, 'full') + ajv.addFormat(name, { + type: 'string', + validate: (value: string) => matchesFormat(fast, value) && matchesFormat(full, value), + }) + } + // Add custom schema loader that resolves GTS IDs from the registry ajv.addKeyword({ keyword: 'gtsRef', diff --git a/packages/shared/src/schemaParser.ts b/packages/shared/src/schemaParser.ts index 6eece15..fa6c6c2 100644 --- a/packages/shared/src/schemaParser.ts +++ b/packages/shared/src/schemaParser.ts @@ -352,3 +352,51 @@ function getSchemaChildren(schema: any): PropertyInfo[] | undefined { return undefined } + +/** + * Locate the JSON Pointer instancePath of a property within a schema document + * (including inside allOf branches or top-level properties). + */ +export function findSchemaPropertyPath(content: any, propPath: string): string | null { + if (!content || typeof content !== 'object') return null + const parts = propPath.split('.') + + function walk(node: any, currentPath: string): string | null { + if (!node || typeof node !== 'object') return null + + if (node.properties && typeof node.properties === 'object') { + let cur = node.properties + let curPath = currentPath ? `${currentPath}/properties` : '/properties' + let found = true + for (let i = 0; i < parts.length; i++) { + const p = parts[i] + if (cur && cur[p] !== undefined) { + curPath += `/${p}` + cur = cur[p] + } else if (cur && cur.properties && cur.properties[p] !== undefined) { + curPath += `/properties/${p}` + cur = cur.properties[p] + } else if (cur && cur.items && p === 'items') { + curPath += '/items' + cur = cur.items + } else { + found = false + break + } + } + if (found) return curPath + } + + if (Array.isArray(node.allOf)) { + for (let i = 0; i < node.allOf.length; i++) { + const branchPath = currentPath ? `${currentPath}/allOf/${i}` : `/allOf/${i}` + const res = walk(node.allOf[i], branchPath) + if (res) return res + } + } + + return null + } + + return walk(content, '') +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index d2e159e..34f6a63 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "lib": ["ES2020"], "moduleResolution": "bundler", + "types": ["node"], "declaration": true, "composite": true, "outDir": "./dist", diff --git a/tsconfig.json b/tsconfig.json index c4ae6a1..307f435 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,12 +5,11 @@ "strict": true, "skipLibCheck": true, "moduleResolution": "bundler", - "baseUrl": ".", "paths": { - "@gts-viewer/shared": ["packages/shared/dist"], - "@gts-viewer/shared/*": ["packages/shared/dist/*"], - "@gts-viewer/ui": ["packages/ui/dist"], - "@gts-viewer/ui/*": ["packages/ui/dist/*"] + "@gts-viewer/shared": ["./packages/shared/dist"], + "@gts-viewer/shared/*": ["./packages/shared/dist/*"], + "@gts-viewer/ui": ["./packages/ui/dist"], + "@gts-viewer/ui/*": ["./packages/ui/dist/*"] } } }