From 4ede56d9ce08f1bf5b3d2d938645ef9326e1dbfe Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 12:41:29 +0300 Subject: [PATCH 01/35] docs: update CHANGELOG.md Signed-off-by: Artifizer --- README.md | 2 + apps/vscode-extension/CHANGELOG.md | 117 ++++++++++++++++++++++------- 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d1e6115..4ec0ba8 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.0. + ![GTS Viewer](./docs/gts-viewer.png) ## Quick Start diff --git a/apps/vscode-extension/CHANGELOG.md b/apps/vscode-extension/CHANGELOG.md index afacad6..5f280aa 100644 --- a/apps/vscode-extension/CHANGELOG.md +++ b/apps/vscode-extension/CHANGELOG.md @@ -2,36 +2,95 @@ All notable changes to the GTS Viewer extension will be documented in this file. -## [1.0.0] - 2025-10-08 +## [Unreleased] + +## [0.2.7] - 2026-09-13 + +## [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 + +## [0.2.2] - 2026-08-31 -### Planned -- Multi-file comparison view -- Layout templates -- Export to image/SVG -- Search and filter capabilities -- Enhanced keyboard shortcuts +### 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 From a16d22bd433919ae635a5ff31ec4a50925d1a80c Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 12:43:05 +0300 Subject: [PATCH 02/35] feat: implement GTS top menu item to browse GTS files and oversee problems count Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 20 ++++++++++---------- apps/vscode-extension/src/extension.ts | 13 +++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 8b4a049..162d2c9 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.2.7", "private": true, "publisher": "GlobalTypeSystem", "license": "Apache-2.0", @@ -22,12 +22,6 @@ "Visualization", "Other" ], - "activationEvents": [ - "onCommand:gts.openViewer", - "onLanguage:json", - "onLanguage:jsonc", - "onLanguage:gts" - ], "main": "./dist/extension.js", "contributes": { "languages": [ @@ -56,12 +50,18 @@ "views": { "gts-viewer": [ { - "type": "webview", - "id": "gts.viewerPanel", - "name": "Schema Viewer" + "id": "gts.fileExplorer", + "name": "Discovered GTS files", + "contextualTitle": "GTS Files" } ] }, + "viewsWelcome": [ + { + "view": "gts.fileExplorer", + "contents": "No GTS files discovered yet.\nGTS files (schemas and instances) found anywhere in the workspace will be listed here." + } + ], "menus": { "explorer/context": [ { diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index aac931d..beb297f 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -8,6 +8,7 @@ import { RepoLayoutStorage } from './storage' import { initValidation, validateOpenDocument, validateWorkspaceInBackground } 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. @@ -62,6 +63,8 @@ let hasPerformedInitialScan: boolean = false // Track if initial scan with defau 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 function getNonce(): string { let text = '' @@ -145,6 +148,7 @@ async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: b 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) { @@ -219,6 +223,9 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize and register GTS link provider for clickable GTS IDs gtsLinkProvider = new GtsLinkProvider(gtsDiagnostics) + // 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' }, @@ -492,6 +499,7 @@ async function performInitialScan() { 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. await gtsLinkProvider?.refresh() @@ -510,6 +518,7 @@ async function performInitialScan() { 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()) revalidateOpenDocs() @@ -535,6 +544,7 @@ export async function deactivate() { gtsLinkProvider = null } + gtsExplorer = null layoutStorage = null } @@ -568,6 +578,7 @@ async function onDiskFileChanged(uri: vscode.Uri): Promise { let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } indexFileInRegistry(fsPath, name, content) + gtsExplorer?.refresh() } catch (e) { console.error('[GTS] Failed to reindex changed file from disk:', fsPath, e) return @@ -581,6 +592,7 @@ function onDiskFileDeleted(uri: vscode.Uri): void { if (isIgnoredGtsPath(fsPath)) return if (isUriIgnored(uri)) return removeFileFromRegistry(fsPath) + gtsExplorer?.refresh() scheduleExternalChangeSettle() } @@ -618,6 +630,7 @@ function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } indexFileInRegistry(doc.uri.fsPath, name, content) + gtsExplorer?.refresh() } catch (e) { console.error('[GTS] Incremental index failed:', e) } From 4ad6cc6fa86485e0f5e8c6f9056fdf22618703ea Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 13:32:28 +0300 Subject: [PATCH 03/35] feat(vscode): add GTS sidebar file explorer and YAML annotation support - Add a proper GTS sidebar view (gts.fileExplorer) that lists every discovered GTS file in a folder tree, colors files green/red via a shared FileDecorationProvider driven by the same registry/diagnostics used elsewhere in the extension, shows a problem-count badge, and opens files in the editor on click. Replaces the previously dead webview-type sidebar view that never had a provider registered. - Extend GTS ID decorations, hovers and document links (previously JSON/JSONC only) to YAML files, using the yaml package's CST to get offset-accurate ranges for scalar nodes. Broaden the Ajv error position-matching regexes to also handle unquoted/single-quoted YAML keys and values, not just double-quoted JSON. - Remove a duplicate GTS entity not found diagnostic in the link provider that double-reported broken GTS references already surfaced by the shared validator. Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 4 +- apps/vscode-extension/src/extension.ts | 3 +- apps/vscode-extension/src/gtsExplorer.ts | 216 ++++++++++++++++++++++ apps/vscode-extension/src/linkProvider.ts | 128 +++++++++++-- apps/vscode-extension/src/validation.ts | 50 +++-- package-lock.json | 35 +++- 6 files changed, 399 insertions(+), 37 deletions(-) create mode 100644 apps/vscode-extension/src/gtsExplorer.ts diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 162d2c9..1c62b05 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -52,6 +52,7 @@ { "id": "gts.fileExplorer", "name": "Discovered GTS files", + "icon": "resources/icon.svg", "contextualTitle": "GTS Files" } ] @@ -116,6 +117,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 beb297f..1779c36 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -230,7 +230,8 @@ export async function activate(context: vscode.ExtensionContext) { 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( diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts new file mode 100644 index 0000000..2a0cec5 --- /dev/null +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -0,0 +1,216 @@ +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 { + return vscode.languages.getDiagnostics(uri).some(d => d.source === 'GTS') +} + +/** 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 +} + +export class GtsFileTreeProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeData = new vscode.EventEmitter() + readonly onDidChangeTreeData = this._onDidChangeTreeData.event + + private root: GtsFolderNode = { kind: 'folder', label: '', children: new Map() } + + /** Rebuild the tree structure from the current registry state. */ + refresh(): void { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' + this.root = buildFileTree(getDiscoveredFilePaths(), workspaceRoot) + this._onDidChangeTreeData.fire() + } + + 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.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) + } +} + +/** + * 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 + /** 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.fileExplorer', { + treeDataProvider: treeProvider, + showCollapseAll: true + }) + + context.subscriptions.push( + treeView, + vscode.window.registerFileDecorationProvider(decorationProvider), + vscode.commands.registerCommand('gts.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 => { + decorationProvider.refresh(event.uris as vscode.Uri[]) + updateBadge(treeView) + }) + ) + + updateBadge(treeView) + + return { + treeProvider, + decorationProvider, + refresh() { + treeProvider.refresh() + decorationProvider.refresh() + updateBadge(treeView) + } + } +} diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index 6a44561..05a098f 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -1,8 +1,9 @@ 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, findSimilarEntityIds, normalizeGtsId, checkGtsUriPrefix, isGtsId, isGtsIdOrPattern, isGtsPattern, isYamlFileName } from '@gts/shared' import type { GtsPrefixIssue } from '@gts/shared' import { getRegistry } from './registryStore' import * as jsonc from 'jsonc-parser' +import * as YAML from 'yaml' /** * Represents a GTS ID reference found in the document @@ -256,8 +257,8 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove const document = editor.document - // 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 } @@ -385,16 +386,11 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove if (inExamples) { unresolvedRanges.push(partRange) } else { + // 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) - - // 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) } } @@ -414,9 +410,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() diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index 0500ee6..e0ce0c4 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -76,10 +76,20 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, // 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) } } @@ -109,11 +119,12 @@ 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 searchPattern = keyRegex(additionalProp) 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) + 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) } } @@ -146,11 +157,12 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, 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 +182,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 +325,17 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +/** + * Build a regex matching a property key followed by `:`, whether the key is + * quoted (JSON, or a quoted YAML key) or bare (a typical unquoted YAML key). + * Capture group 1 is the opening quote character, or empty for a bare key — + * used by callers to compute the correct offset past it. + */ +function keyRegex(name: string): RegExp { + const esc = escapeRegex(name) + return new RegExp(`(["']?)${esc}\\1\\s*:`, 'g') +} + /** * Validate a document and update diagnostics */ diff --git a/package-lock.json b/package-lock.json index 36cef02..1418aed 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": "*", @@ -13600,6 +13601,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 +13768,7 @@ }, "packages/layout-storage": { "name": "@gts/layout-storage", - "version": "0.2.6", + "version": "0.2.7", "devDependencies": { "typescript": "^5.3.3" } @@ -13772,7 +13789,7 @@ }, "packages/shared": { "name": "@gts/shared", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "@globaltypesystem/gts-ts": "^0.4.0", "ajv": "^8.17.1", From 6253842fb15807f3921577f17799b7edb605c711 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 14:22:12 +0300 Subject: [PATCH 04/35] feat(gts): revalidate dependent entities on file change across all apps Changing a GTS file now revalidates the files whose entities depend on it, not just the file itself. Previously the VS Code extension only revalidated the changed document, and the Web/Electron apps did a full re-scan on every change, so broken derived types/instances went unnoticed and workspace findings were incomplete after a reload. Centralize the logic in the shared JsonRegistry so behavior is identical in VS Code, the Web app and Electron: - Add a cached, cycle-safe reverse-dependency graph with two edge kinds: structural (derivation, multi-level derivation, instantiation, $ref/allOf) followed transitively, and id references (any GTS id, x-gts-ref) applied depth-1. - Add getDependentFilePaths / validateFile / revalidateAfterChange and a single applyFileChange entry point (handles reindex, deletion and rename/removal via previous-id capture). - VS Code: revalidate dependents (open docs precise, closed docs coarse) and validate the whole workspace on initial/viewer/disk-settle scans so the problem count is complete after a window reload. - Web/Electron: route watcher events through applyFileChange for incremental, serialized revalidation instead of a full re-ingest. Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 77 ++++--- apps/vscode-extension/src/validation.ts | 73 +++++++ apps/web/src/hooks/useJsonFiles.ts | 60 +++++- packages/shared/src/index.ts | 1 + packages/shared/src/registry.ts | 262 +++++++++++++++++++++++- 5 files changed, 425 insertions(+), 48 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 1779c36..d8cb152 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -2,10 +2,10 @@ import * as vscode from 'vscode' import * as path from 'path' import { parseGtsFileContent, JsonRegistry, DEFAULT_GTS_CONFIG } from '@gts/shared' import { setLastScanFiles } from './scanStore' -import { rebuildRegistry, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry } from './registryStore' +import { rebuildRegistry, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry, getRegistry } from './registryStore' import { getWorkspaceIgnore, resetWorkspaceIgnore, getCachedMatcher, isIgnoredRel } from './gitignore' import { RepoLayoutStorage } from './storage' -import { initValidation, validateOpenDocument, validateWorkspaceInBackground } from './validation' +import { initValidation, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents } from './validation' import { isGtsCandidateFile } from './helpers' import { GtsLinkProvider } from './linkProvider' import { registerGtsExplorer, type GtsExplorer } from './gtsExplorer' @@ -189,7 +189,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...') @@ -436,35 +436,6 @@ 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)) - } - } - - return roots -} - async function performInitialScan() { try { // Load .gitignore rules first so both phases permanently exclude ignored @@ -503,8 +474,10 @@ async function performInitialScan() { 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 ------------------------------------------- @@ -521,7 +494,7 @@ async function performInitialScan() { 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)`) @@ -609,10 +582,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) }) @@ -620,17 +595,31 @@ 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 + + // 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) + indexFileInRegistry(fsPath, name, content) gtsExplorer?.refresh() } catch (e) { console.error('[GTS] Incremental index failed:', e) @@ -644,9 +633,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/validation.ts b/apps/vscode-extension/src/validation.ts index e0ce0c4..fa1d044 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -480,6 +480,79 @@ 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) +} + +/** + * 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 initValidation(context: vscode.ExtensionContext) { console.log('[GTS Validation] Initializing validation system...') diff --git a/apps/web/src/hooks/useJsonFiles.ts b/apps/web/src/hooks/useJsonFiles.ts index 1592056..e56eadb 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,48 @@ 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). + if (!text.includes('gts.')) return + content = text + } + 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 +193,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/packages/shared/src/index.ts b/packages/shared/src/index.ts index 742b2c4..a3bf274 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ 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' diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index d0f8d04..3b97d9c 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -25,6 +25,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 +82,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 +109,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. */ @@ -108,8 +144,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 +170,228 @@ 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) + } + /** * Process a file and store its entities if they are GTS entities. * This is a helper used by both scanFile and ingestFiles. From 9ef50a7d07592a4156e1fe509ab800d91b46c2c6 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 14:49:05 +0300 Subject: [PATCH 05/35] fix(vscode): stop file-tree flicker and dedupe symlinked scans Two issues in the GTS sidebar ("Discovered GTS Files"): 1. Every change refreshed the whole explorer, which fired both a full tree rebuild and a global file-decoration invalidation. That made all rows flash white->green twice on each edit. The tree now only rebuilds when the set of discovered files actually changes, and decorations are refreshed per-URI (error-state changes are already handled by the onDidChangeDiagnostics listener), so unrelated files no longer blink. 2. Workspace symlinks (.gts-spec, .gts-spec-ext, .gears-rust/.gts-spec) made the same physical file reachable via several paths, so the same GTS entity was scanned multiple times -> duplicate tree rows and a nondeterministic id->file mapping. Scans now index each physical file once (resolved via real path), preferring open files as canonical, and the incremental change/delete/rename handlers keep a single entry per physical file. Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 81 ++++++++++++++++++++++-- apps/vscode-extension/src/gtsExplorer.ts | 35 ++++++++-- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index d8cb152..bb306db 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,5 +1,6 @@ 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 { setLastScanFiles } from './scanStore' import { rebuildRegistry, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry, getRegistry } from './registryStore' @@ -57,6 +58,59 @@ 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 @@ -98,7 +152,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() @@ -275,6 +333,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.onDidRenameFiles(async event => { for (const { oldUri, newUri } of event.files) { removeFileFromRegistry(oldUri.fsPath) + forgetIndexedPath(oldUri.fsPath) if (isIgnoredGtsPath(newUri.fsPath)) continue if (!/\.(json|jsonc|gts|ya?ml)$/i.test(newUri.fsPath)) continue const openDoc = vscode.workspace.textDocuments.find(d => d.uri.fsPath === newUri.fsPath) @@ -444,6 +503,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. @@ -466,8 +528,11 @@ 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) 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)`) @@ -486,7 +551,10 @@ 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 (files2.length > 0) { @@ -551,6 +619,8 @@ async function onDiskFileChanged(uri: vscode.Uri): Promise { const name = path.basename(fsPath) let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } + // Keep one entry per physical file even when reached via a symlinked path. + claimCanonicalPath(fsPath) indexFileInRegistry(fsPath, name, content) gtsExplorer?.refresh() } catch (e) { @@ -566,6 +636,7 @@ function onDiskFileDeleted(uri: vscode.Uri): void { if (isIgnoredGtsPath(fsPath)) return if (isUriIgnored(uri)) return removeFileFromRegistry(fsPath) + forgetIndexedPath(fsPath) gtsExplorer?.refresh() scheduleExternalChangeSettle() } @@ -619,6 +690,8 @@ function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { const name = path.basename(fsPath) let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } + // Ensure this physical file is indexed under exactly this (open) path. + claimCanonicalPath(fsPath) indexFileInRegistry(fsPath, name, content) gtsExplorer?.refresh() } catch (e) { diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index 2a0cec5..abeb7d3 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -91,12 +91,32 @@ export class GtsFileTreeProvider implements vscode.TreeDataProvider() + + /** + * 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 || '' - this.root = buildFileTree(getDiscoveredFilePaths(), workspaceRoot) + 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 { @@ -208,8 +228,11 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor treeProvider, decorationProvider, refresh() { - treeProvider.refresh() - decorationProvider.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) } } From 0125310be6b0107745d26b5c9303b6b360a8bd56 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 15:36:53 +0300 Subject: [PATCH 06/35] feat(shared): register inline GTS entity definitions in YAML config files YAML config files can define GTS types/instances inline under nested `entities:` arrays (e.g. a service's `types-registry.config.entities` seed block). Previously the whole document was flattened by the generic id walker, so those `$id` definitions were harvested as references and reported as "GTS reference not found". Recognize and register such inline definitions (YAML only; JSON/JSONC/.gts keep the strict single-entity / top-level-array shape) and document the behavior in the shared and VS Code extension READMEs. Signed-off-by: Artifizer --- apps/vscode-extension/README.md | 7 +++- packages/shared/README.md | 29 +++++++++++++++ packages/shared/src/registry.ts | 66 +++++++++++++++++++++++++++------ 3 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 packages/shared/README.md 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/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/src/registry.ts b/packages/shared/src/registry.ts index 3b97d9c..e379363 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -1,5 +1,6 @@ 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 Ajv, { type ValidateFunction, type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' import { GtsModifiers, GtsStore, createJsonEntity } from '@globaltypesystem/gts-ts' @@ -392,6 +393,38 @@ export class JsonRegistry { 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. @@ -417,17 +450,9 @@ 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 - }) - + // Register one entity content as a schema/instance if it is a GTS entity. + const registerEntity = (entityContent: any, seq: number | undefined) => { + const entity = createEntity({ file: jsonFile, listSequence: seq, content: entityContent, cfg }) if (entity && entity.isGtsEntity()) { hasGtsEntities = true if (entity instanceof JsonSchema) { @@ -438,8 +463,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) }) + // 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) From b60a4ef34a00ea7e594d219597a25a4244b3e8da Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 15:41:06 +0300 Subject: [PATCH 07/35] feat(vscode): support dragging discovered GTS files Signed-off-by: Artifizer --- apps/vscode-extension/src/gtsExplorer.ts | 36 +++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index abeb7d3..a577da1 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -86,7 +86,23 @@ function countGtsProblems(): number { return count } -export class GtsFileTreeProvider implements vscode.TreeDataProvider { +/** 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 @@ -146,6 +162,23 @@ export class GtsFileTreeProvider implements vscode.TreeDataProvider vscode.Uri.file(p).toString()).join('\r\n') + dataTransfer.set('text/uri-list', new vscode.DataTransferItem(uriList)) + } } /** @@ -199,6 +232,7 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor const treeView = vscode.window.createTreeView('gts.fileExplorer', { treeDataProvider: treeProvider, + dragAndDropController: treeProvider, showCollapseAll: true }) From 7f689cb70fd140f59baac11cb789adbeb524a560 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 15:47:49 +0300 Subject: [PATCH 08/35] chore: update gts-spec and TypeScript configs Signed-off-by: Artifizer --- .gts-spec | 2 +- apps/vscode-extension/tsconfig.json | 5 ++--- tsconfig.json | 9 ++++----- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.gts-spec b/.gts-spec index caecc27..5b5d578 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit caecc273aad0aff47d77e05b87ed4b944af85e99 +Subproject commit 5b5d5786bab1de1192d977a94752e492b66d17a6 diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json index 70fc931..a3e9547 100644 --- a/apps/vscode-extension/tsconfig.json +++ b/apps/vscode-extension/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "module": "CommonJS", + "module": "ESNext", "lib": ["ES2020"], "outDir": "./dist", "rootDir": "./src", @@ -9,9 +9,8 @@ "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/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/*"] } } } From 4c3c787a2f6ecdae0fc2e3e8cf4caf6e083b11f9 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 16:09:15 +0300 Subject: [PATCH 09/35] chore: rename gts.* VS Code commands to gts-kit.* Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 16 ++++++++-------- apps/vscode-extension/src/extension.ts | 2 +- apps/vscode-extension/src/gtsExplorer.ts | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 1c62b05..fb9d9ce 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -33,7 +33,7 @@ ], "commands": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "title": "GTS: Open Viewer", "category": "GTS" } @@ -50,7 +50,7 @@ "views": { "gts-viewer": [ { - "id": "gts.fileExplorer", + "id": "gts-kit.fileExplorer", "name": "Discovered GTS files", "icon": "resources/icon.svg", "contextualTitle": "GTS Files" @@ -59,38 +59,38 @@ }, "viewsWelcome": [ { - "view": "gts.fileExplorer", + "view": "gts-kit.fileExplorer", "contents": "No GTS files discovered yet.\nGTS 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)$/" } ], "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" } ] diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index bb306db..6eab5f6 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -372,7 +372,7 @@ 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) }) ) diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index a577da1..c9b0714 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -141,7 +141,7 @@ export class GtsFileTreeProvider item.resourceUri = vscode.Uri.file(element.fsPath) item.contextValue = 'gtsFile' item.command = { - command: 'gts.openFileFromTree', + command: 'gts-kit.openFileFromTree', title: 'Open GTS File', arguments: [element.fsPath] } @@ -230,7 +230,7 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor const treeProvider = new GtsFileTreeProvider() const decorationProvider = new GtsFileDecorationProvider() - const treeView = vscode.window.createTreeView('gts.fileExplorer', { + const treeView = vscode.window.createTreeView('gts-kit.fileExplorer', { treeDataProvider: treeProvider, dragAndDropController: treeProvider, showCollapseAll: true @@ -239,7 +239,7 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor context.subscriptions.push( treeView, vscode.window.registerFileDecorationProvider(decorationProvider), - vscode.commands.registerCommand('gts.openFileFromTree', async (fsPath: string) => { + vscode.commands.registerCommand('gts-kit.openFileFromTree', async (fsPath: string) => { try { const uri = vscode.Uri.file(fsPath) await vscode.window.showTextDocument(uri, { preview: false }) From 31c5d226b869f95bc343d4abd3ee35ab92ac55f7 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 16:10:43 +0300 Subject: [PATCH 10/35] chore: enable Node typings for VS Code extension Add `"types": ["node"]` to the VS Code extension tsconfig so TypeScript resolves Node modules and globals used by the extension, including `fs`, `path`, `Buffer`, and `console`. Signed-off-by: Artifizer --- apps/vscode-extension/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json index a3e9547..6336cba 100644 --- a/apps/vscode-extension/tsconfig.json +++ b/apps/vscode-extension/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2020", "module": "ESNext", "lib": ["ES2020"], + "types": ["node"], "outDir": "./dist", "rootDir": "./src", "strict": true, From dfe030e6e26bb0c437e6b0a642020aeba7897218 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 16:55:20 +0300 Subject: [PATCH 11/35] fix(vscode): start GTS scanner on extension startup Activate after VS Code finishes startup so the existing workspace scan runs when projects open and after extension host reloads or updates. Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index fb9d9ce..8de0e30 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -23,6 +23,9 @@ "Other" ], "main": "./dist/extension.js", + "activationEvents": [ + "onStartupFinished" + ], "contributes": { "languages": [ { From c914a2990976e3672cbaadc3ef57b879423f1bca Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 21:14:03 +0300 Subject: [PATCH 12/35] feat: VS Code plugin - GTS menu panel - added refresh file explorer button Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 13 +++++++++++ apps/vscode-extension/src/extension.ts | 28 +++++++++++++++++++++++- apps/vscode-extension/src/gtsExplorer.ts | 6 +++++ apps/vscode-extension/src/validation.ts | 5 +++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 8de0e30..b8878ed 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -39,6 +39,12 @@ "command": "gts-kit.openViewer", "title": "GTS: Open Viewer", "category": "GTS" + }, + { + "command": "gts-kit.refreshFileExplorer", + "title": "GTS: Refresh Discovered Files", + "category": "GTS", + "icon": "$(refresh)" } ], "viewsContainers": { @@ -74,6 +80,13 @@ "when": "resourceExtname =~ /\\.(json|jsonc|gts)$/" } ], + "view/title": [ + { + "command": "gts-kit.refreshFileExplorer", + "group": "navigation@1", + "when": "view == gts-kit.fileExplorer" + } + ], "editor/title": [ { "command": "gts-kit.openViewer", diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 6eab5f6..7cb08df 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -6,7 +6,7 @@ import { setLastScanFiles } from './scanStore' import { rebuildRegistry, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry, getRegistry } from './registryStore' import { getWorkspaceIgnore, resetWorkspaceIgnore, getCachedMatcher, isIgnoredRel } from './gitignore' import { RepoLayoutStorage } from './storage' -import { initValidation, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents } from './validation' +import { initValidation, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents } from './validation' import { isGtsCandidateFile } from './helpers' import { GtsLinkProvider } from './linkProvider' import { registerGtsExplorer, type GtsExplorer } from './gtsExplorer' @@ -374,6 +374,14 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( 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)}`) + } }) ) @@ -495,6 +503,24 @@ function revalidateOpenDocs(): void { }) } +async function refreshGtsFileExplorer(linkDiagnostics: vscode.DiagnosticCollection): Promise { + 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() +} + async function performInitialScan() { try { // Load .gitignore rules first so both phases permanently exclude ignored diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index c9b0714..f2f49b0 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -212,6 +212,7 @@ export class GtsFileDecorationProvider implements vscode.FileDecorationProvider 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 } @@ -261,6 +262,11 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor 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 diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index fa1d044..cbaded0 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -553,6 +553,11 @@ export async function revalidateDependents(changedPath: string, previousIds?: It } } +export function resetValidationDiagnostics(): void { + diagnosticCollection?.clear() + workspaceDiagnosticCollection?.clear() +} + export function initValidation(context: vscode.ExtensionContext) { console.log('[GTS Validation] Initializing validation system...') From 1db83a177e299654f98af0b3a31b0c0dcb023e92 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 21:37:39 +0300 Subject: [PATCH 13/35] fix(vscode): cascade invalid GTS chain segments Support arbitrary-length GTS chains by resolving cumulative segment IDs and marking all descendants after a missing ancestor as invalid. Keep decorations, links, and hover information consistent with broken GTS inheritance chains. Signed-off-by: Artifizer --- apps/vscode-extension/src/linkProvider.ts | 91 +++++++++++++---------- packages/shared/src/gts-styling.ts | 42 ++++------- 2 files changed, 69 insertions(+), 64 deletions(-) diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index 05a098f..2f1f5d6 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -347,6 +347,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove gtsStartOffset += ref.uriPrefixLength let currentOffset = gtsStartOffset + let hasMissingAncestor = false for (let segIndex = 0; segIndex < parts.length; segIndex++) { const part = parts[segIndex] const partStartPos = document.positionAt(currentOffset) @@ -361,17 +362,12 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } // 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 - } + const entityIdToLookup = parts.slice(0, segIndex + 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) { if (entity.isSchema) { @@ -383,7 +379,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // 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) { + if (inExamples && !hasMissingAncestor) { unresolvedRanges.push(partRange) } else { // Red chip only — the authoritative "GTS reference not found" @@ -391,6 +387,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // (registry.validateEntity, surfaced via validation.ts) so we // don't publish a second, duplicate diagnostic for the same miss. errorRanges.push(partRange) + hasMissingAncestor = true } } @@ -625,26 +622,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 @@ -665,6 +656,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 @@ -829,26 +822,48 @@ 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 + } + + let hasMissingAncestor = false + let missingAncestorId: string | undefined + if (hoveredSegmentIndex !== undefined) { + for (let partIndex = 0; partIndex <= hoveredSegmentIndex; partIndex++) { + const ancestorId = parts.slice(0, partIndex + 1).join('') + const ancestor = this.registry.jsonSchemas.get(ancestorId) || this.registry.jsonObjs.get(ancestorId) + if (!ancestor) { + hasMissingAncestor = true + missingAncestorId = ancestorId + break + } } } // 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 (missingAncestorId) { + markdown.appendMarkdown(`GTS Parent Type Not Found\n\n`) + markdown.appendMarkdown(`Missing ancestor: ${escapeMarkdown(missingAncestorId)}\n\n`) + markdown.appendMarkdown(`This segment is invalid because it derives from a missing type.`) + return new vscode.Hover(markdown, hoverRange) + } if (!entity) { // Entity not found - show error with suggestions diff --git a/packages/shared/src/gts-styling.ts b/packages/shared/src/gts-styling.ts index 3cf262b..bfd4e14 100644 --- a/packages/shared/src/gts-styling.ts +++ b/packages/shared/src/gts-styling.ts @@ -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] } /** @@ -106,25 +101,20 @@ 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('') // Look up the entity - const lookupResult = entityLookup(entityIdToLookup) + const lookupResult = hasMissingAncestor ? { exists: false } : entityLookup(entityIdToLookup) let segmentType: 'schema' | 'instance' | 'error' if (lookupResult.exists) { segmentType = lookupResult.isSchema ? 'schema' : 'instance' } else { segmentType = 'error' + hasMissingAncestor = true } segments.push({ From db4aa08c636fceb9ff065094771d98c15e69c28a Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 15:17:38 +0300 Subject: [PATCH 14/35] fix(vscode): resolve GTS reference errors to their own array item The "GTS reference not found" diagnostics carry a precise instancePath that includes array indices, but findErrorPosition fell through to a regex that matched the first textual occurrence of the leaf key. All errors on a repeated key (e.g. subject_type across several tokens) collapsed onto the first instance. Add a path-aware resolver that navigates the YAML/JSON AST to the exact node at the instancePath (honoring array indices) and use it for value-leaf errors (gtsId reference, x-gts-ref, gts-uri-prefix), so each error lands on its own instance value. Falls back to the previous text-search strategies when the path cannot be resolved. Signed-off-by: Artifizer --- apps/vscode-extension/src/validation.ts | 101 ++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index cbaded0..f8450b9 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -1,5 +1,7 @@ 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' @@ -72,6 +74,24 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, // Remove leading slash from instancePath (e.g., '/users/0/email' -> 'users/0/email') const path = instancePath.replace(/^\//, '') + // Errors that point at a specific scalar *value* — GTS reference-not-found + // (params.gtsId), x-gts-ref assertion failures, and gts:// prefix violations — + // all carry a precise instancePath that includes array indices. Resolve the + // exact value node at that path first so repeated values under different array + // items (e.g. the same subject_type inside several tokens) each get their own + // marker instead of collapsing onto the first textual match. Falls through to + // the coarser strategies below when the path can't be resolved. + const targetsScalarValue = + (error.params != null && 'gtsId' in error.params) || + error.keyword === 'x-gts-ref' || + error.keyword === 'gts-uri-prefix' + if (targetsScalarValue && instancePath && instancePath !== '/') { + const pathRange = findValueRangeAtInstancePath(document, instancePath) + if (pathRange) { + return pathRange + } + } + // 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) { @@ -336,6 +356,87 @@ function keyRegex(name: string): RegExp { 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 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 a value range by navigating the JSON tree to the node at `segments`. */ +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 + + 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 */ From 2d5564b9a7e7791242d198feb92cb4230953dfc7 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 15:41:18 +0300 Subject: [PATCH 15/35] fix(validation): enforce strict RFC 3339 for date/time formats ajv-formats' default "full" mode splits date-time on /t|\s/i, so it accepts a space instead of the RFC 3339 "T" separator (e.g. "2008-10-12 10:30:00Z"). Compose the standard ajv-formats "fast" and "full" validators for date, time and date-time so a value must satisfy both the strict grammar (T separator + mandatory offset) and the real value-range checks (month/day, leap second, offset <= 23:59). No hand-rolled RFC logic. Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 35 ++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index e379363..504d12f 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -7,8 +7,25 @@ import { GtsModifiers, GtsStore, createJsonEntity } from '@globaltypesystem/gts- // 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' +/** + * 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`) @@ -838,9 +855,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', From 0c67ce028202fc7b490781ad973c21c3a35831b6 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 16:15:15 +0300 Subject: [PATCH 16/35] fix(styling): classify GTS ID segments structurally via core gts-ts GTS ID segments were colored schema/instance from the resolved entity's isSchema flag, which reflects the referenced document shape rather than the structural type-ness of the ID itself. A malformed instance whose id ends in "~" was thus painted green (instance) even though the trailing "~" makes it a type. Derive schema-vs-instance in the shared analyzeGtsIdForStyling from the core library (isGtsType/Gts.isType), using the registry lookup only for existence. Refactor the VS Code linkProvider to consume the shared analyzer instead of its own duplicated registry-isSchema loop, so all clients share the core logic. Signed-off-by: Artifizer --- apps/vscode-extension/src/linkProvider.ts | 68 ++++++++++------------- packages/shared/src/gts-styling.ts | 12 +++- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index 2f1f5d6..73966d7 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode' -import { JsonRegistry, GTS_COLORS, GTS_URI_PREFIX, parseGtsIdParts, findSimilarEntityIds, normalizeGtsId, checkGtsUriPrefix, isGtsId, isGtsIdOrPattern, isGtsPattern, isYamlFileName } 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 * as jsonc from 'jsonc-parser' @@ -331,8 +331,15 @@ 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 — + // NOT from how the referenced document happens to be shaped. The registry + // lookup below only informs existence (found → valid, missing → error). + const registry = this.registry + const analysis = analyzeGtsIdForStyling(ref.id, (entityId: string) => { + const entity = registry.jsonSchemas.get(entityId) || registry.jsonObjs.get(entityId) + return entity ? { exists: true, isSchema: entity.isSchema } : { exists: false } + }) // Calculate the offset of the string value (excluding quotes) const text = document.getText() @@ -346,12 +353,14 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } gtsStartOffset += ref.uriPrefixLength - let currentOffset = gtsStartOffset - let hasMissingAncestor = false - 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 @@ -361,37 +370,20 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove gapRanges.push(partRange) } - // Determine the full entity ID to look up - const entityIdToLookup = parts.slice(0, segIndex + 1).join('') - - // Look up the entity in the registry - const entity = hasMissingAncestor - ? undefined - : this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) - - if (entity) { - if (entity.isSchema) { - schemaRanges.push(partRange) - } else { - instanceRanges.push(partRange) - } + 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 && !hasMissingAncestor) { - unresolvedRanges.push(partRange) - } else { - // 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) - hasMissingAncestor = true - } + // 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 } } diff --git a/packages/shared/src/gts-styling.ts b/packages/shared/src/gts-styling.ts index bfd4e14..b64bce0 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 @@ -106,12 +106,18 @@ export function analyzeGtsIdForStyling( const part = parts[partIndex] const entityIdToLookup = parts.slice(0, partIndex + 1).join('') - // Look up the entity + // Look up the entity (existence only — see below). const lookupResult = hasMissingAncestor ? { exists: false } : entityLookup(entityIdToLookup) let segmentType: 'schema' | 'instance' | 'error' if (lookupResult.exists) { - segmentType = lookupResult.isSchema ? 'schema' : 'instance' + // Classify schema vs instance STRUCTURALLY from the GTS ID itself via the + // core gts-ts library: a segment whose cumulative ID ends in "~" is a type + // (schema), otherwise an instance. This is deliberately independent of how + // the referenced *document* happens to be shaped (its `isSchema` flag), + // so a malformed instance whose id ends in "~" is still styled as a type. + // The registry lookup above is used only to decide existence (error/valid). + segmentType = isGtsType(entityIdToLookup) ? 'schema' : 'instance' } else { segmentType = 'error' hasMissingAncestor = true From e81ba8c0b31303c2ea0dc6ef420e012440223c94 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 16:19:54 +0300 Subject: [PATCH 17/35] chore: update .gts-spec to v0.13.4 Signed-off-by: Artifizer --- .gts-spec | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gts-spec b/.gts-spec index 5b5d578..deec643 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 5b5d5786bab1de1192d977a94752e492b66d17a6 +Subproject commit deec64342510e2456a7afd11f7cb42c426f8fda7 diff --git a/README.md b/README.md index 4ec0ba8..7731417 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 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.0. +The repository is conformant with gts-spec v0.13.4. ![GTS Viewer](./docs/gts-viewer.png) From 75fba9ee7e1a561d934036416b5293711eca6767 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 16:24:53 +0300 Subject: [PATCH 18/35] fix(vscode): enable multi-file chat drag Signed-off-by: Artifizer --- apps/vscode-extension/src/gtsExplorer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index f2f49b0..213bcdb 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -234,6 +234,7 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor const treeView = vscode.window.createTreeView('gts-kit.fileExplorer', { treeDataProvider: treeProvider, dragAndDropController: treeProvider, + canSelectMany: true, showCollapseAll: true }) From 6260ccec706372d5fb0fa298d0d88c48edc937ff Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 17:05:09 +0300 Subject: [PATCH 19/35] fix(vscode): keep invalid files marked red after preview tab closes When a GTS file was opened as a preview tab, validateOpenDocument replaced its coarse workspace diagnostic with precise in-editor diagnostics. Closing that preview (by clicking another file in the Explorer) only deleted the precise diagnostic and never restored the workspace marker, so the file lost all GTS diagnostics and the Explorer decoration painted it green despite still being invalid. Republish the coarse workspace diagnostic on close via validateClosedFile, reindexing from disk first so a discarded unsaved buffer does not leave stale content in the registry. Also give the two GTS diagnostic collections distinct names (gts-validation / gts-link-format) so ownership is unambiguous. Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 2 +- apps/vscode-extension/src/validation.ts | 36 ++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 7cb08df..c3a20d8 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -275,7 +275,7 @@ export async function activate(context: vscode.ExtensionContext) { 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 diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index f8450b9..3724433 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -621,6 +621,29 @@ async function validateClosedFile(filePath: string): Promise { 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 @@ -663,7 +686,7 @@ 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) @@ -684,12 +707,19 @@ 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}`) diagnosticCollection.delete(doc.uri) + if (doc.uri.scheme !== 'file') return + await reindexClosedFileFromDisk(doc.uri) + await validateClosedFile(doc.uri.fsPath) }) ) From 65b81749e58ea3e3876704c08056eae29535fce1 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 20:58:54 +0300 Subject: [PATCH 20/35] chore: update VS Code plugin category and tags Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index b8878ed..eb08ffc 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -19,8 +19,16 @@ "vscode": "^1.85.0" }, "categories": [ - "Visualization", - "Other" + "Formatters", + "Linters", + "Visualization" + ], + "tags": [ + "gts", + "json", + "jsonc", + "jsonchema", + "validation" ], "main": "./dist/extension.js", "activationEvents": [ From 89f73c836ed9b21ba0a075c72a3c51c2bc97f7a6 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 23:51:17 +0300 Subject: [PATCH 21/35] chore: rebase gts-kit on gts-ts v0.5.0 to support additional validations Signed-off-by: Artifizer --- package-lock.json | 21 ++++++++++----------- packages/shared/package.json | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1418aed..62a7dc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1734,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", @@ -12809,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": { @@ -13791,7 +13790,7 @@ "name": "@gts/shared", "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/package.json b/packages/shared/package.json index e73bccb..83bcc70 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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", From 7cf9e4e8ad57310909272b9a3a8abf284a5bcc47 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 15:16:38 +0300 Subject: [PATCH 22/35] fix(validation): surface GTS validation errors across VS Code viewer - relay typed validation results from the extension host to the webview - preserve validation during scan-result race conditions - render invalid GTS IDs and affected properties with error styling - improve diagnostic ranges for JSON/YAML validation errors - validate instances through gts-ts and normalize x-gts-ref combinators for Ajv Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 19 +- apps/vscode-extension/src/linkProvider.ts | 116 +++++++++--- apps/vscode-extension/src/validation.ts | 210 +++++++++++++++------ apps/web/package.json | 2 +- apps/web/src/components/JsonCode.tsx | 26 +-- apps/web/src/components/PropertyViewer.tsx | 20 +- apps/web/src/hooks/useJsonFilesVscode.ts | 53 ++++-- packages/shared/package.json | 2 +- packages/shared/src/entities.ts | 49 +++++ packages/shared/src/gts-styling.ts | 50 +++-- packages/shared/src/index.ts | 1 + packages/shared/src/registry.ts | 133 +++++++++++-- packages/shared/src/schemaParser.ts | 48 +++++ 13 files changed, 574 insertions(+), 155 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index c3a20d8..d1ced56 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -2,11 +2,12 @@ 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, getRegistry } from './registryStore' import { getWorkspaceIgnore, resetWorkspaceIgnore, getCachedMatcher, isIgnoredRel } from './gitignore' import { RepoLayoutStorage } from './storage' -import { initValidation, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents } 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' @@ -229,10 +230,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) } }) } @@ -281,6 +283,13 @@ export async function activate(context: vscode.ExtensionContext) { // 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) diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index 73966d7..df73774 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode' 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' @@ -247,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 */ @@ -256,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/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[] = [] @@ -332,13 +352,33 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } // Classify the segments using the shared, core-backed styling analyzer so - // schema-vs-instance is derived STRUCTURALLY from the GTS ID via gts-ts — - // NOT from how the referenced document happens to be shaped. The registry - // lookup below only informs existence (found → valid, missing → error). + // 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 entity = registry.jsonSchemas.get(entityId) || registry.jsonObjs.get(entityId) - return entity ? { exists: true, isSchema: entity.isSchema } : { exists: false } + 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) @@ -370,7 +410,9 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove gapRanges.push(partRange) } - if (seg.type === 'schema') { + if (hasFieldError) { + errorRanges.push(partRange) + } else if (seg.type === 'schema') { schemaRanges.push(partRange) } else if (seg.type === 'instance') { instanceRanges.push(partRange) @@ -533,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') { @@ -831,32 +872,47 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove segmentStartOffset = segmentEndOffset } - let hasMissingAncestor = false - let missingAncestorId: string | undefined - if (hoveredSegmentIndex !== undefined) { - for (let partIndex = 0; partIndex <= hoveredSegmentIndex; partIndex++) { - const ancestorId = parts.slice(0, partIndex + 1).join('') - const ancestor = this.registry.jsonSchemas.get(ancestorId) || this.registry.jsonObjs.get(ancestorId) - if (!ancestor) { - hasMissingAncestor = true - missingAncestorId = ancestorId - break - } + // 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 = hasMissingAncestor + // 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 (missingAncestorId) { - markdown.appendMarkdown(`GTS Parent Type Not Found\n\n`) - markdown.appendMarkdown(`Missing ancestor: ${escapeMarkdown(missingAncestorId)}\n\n`) - markdown.appendMarkdown(`This segment is invalid because it derives from a missing type.`) - return new vscode.Hover(markdown, hoverRange) - } - if (!entity) { // Entity not found - show error with suggestions markdown.appendMarkdown(`⚠️ GTS Entity Not Found!\n\n`) @@ -917,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/validation.ts b/apps/vscode-extension/src/validation.ts index 3724433..c7bf728 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -11,6 +11,18 @@ let diagnosticCollection: vscode.DiagnosticCollection let workspaceDiagnosticCollection: vscode.DiagnosticCollection let isInitialScanComplete = false +const documentValidationErrors = new Map() +const validationCompletedListeners = new Set<(uri: vscode.Uri) => void>() + +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) { @@ -74,25 +86,59 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, // Remove leading slash from instancePath (e.g., '/users/0/email' -> 'users/0/email') const path = instancePath.replace(/^\//, '') - // Errors that point at a specific scalar *value* — GTS reference-not-found - // (params.gtsId), x-gts-ref assertion failures, and gts:// prefix violations — - // all carry a precise instancePath that includes array indices. Resolve the - // exact value node at that path first so repeated values under different array - // items (e.g. the same subject_type inside several tokens) each get their own - // marker instead of collapsing onto the first textual match. Falls through to - // the coarser strategies below when the path can't be resolved. - const targetsScalarValue = - (error.params != null && 'gtsId' in error.params) || - error.keyword === 'x-gts-ref' || - error.keyword === 'gts-uri-prefix' - if (targetsScalarValue && instancePath && instancePath !== '/') { - const pathRange = findValueRangeAtInstancePath(document, instancePath) - if (pathRange) { - return pathRange + // 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 } } - // For gts:// prefix violations and x-gts-ref mismatches, highlight the + // 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) @@ -114,7 +160,7 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, } } - // 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}'`) @@ -136,42 +182,12 @@ 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 = 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) - } - } - - // 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] @@ -346,14 +362,12 @@ function escapeRegex(str: string): string { } /** - * Build a regex matching a property key followed by `:`, whether the key is - * quoted (JSON, or a quoted YAML key) or bare (a typical unquoted YAML key). - * Capture group 1 is the opening quote character, or empty for a bare key — - * used by callers to compute the correct offset past it. + * 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') + return new RegExp(`(["'])${esc}\\1\\s*:`, 'g') } /** @@ -369,6 +383,65 @@ function instancePathSegments(instancePath: string): Array { .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 @@ -415,13 +488,21 @@ function findValueRangeYaml(text: string, document: vscode.TextDocument, segment return new vscode.Range(startPos, endPos) } -/** Resolve a value range by navigating the JSON tree to the node at `segments`. */ +/** + * 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 @@ -514,15 +595,26 @@ export async function validateOpenDocument(document: vscode.TextDocument) { 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) + documentValidationErrors.delete(document.uri.toString()) diagnosticCollection.delete(document.uri) } } @@ -678,6 +770,7 @@ export async function revalidateDependents(changedPath: string, previousIds?: It } export function resetValidationDiagnostics(): void { + documentValidationErrors.clear() diagnosticCollection?.clear() workspaceDiagnosticCollection?.clear() } @@ -716,6 +809,7 @@ export function initValidation(context: vscode.ExtensionContext) { vscode.workspace.onDidCloseTextDocument(async doc => { if (!isGtsCandidateFile(doc)) return console.log(`[GTS Validation] Document closed: ${doc.fileName}`) + documentValidationErrors.delete(doc.uri.toString()) diagnosticCollection.delete(doc.uri) if (doc.uri.scheme !== 'file') return await reindexClosedFileFromDisk(doc.uri) 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/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/packages/shared/package.json b/packages/shared/package.json index 83bcc70..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", 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 b64bce0..1513f18 100644 --- a/packages/shared/src/gts-styling.ts +++ b/packages/shared/src/gts-styling.ts @@ -59,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 @@ -78,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,21 +116,35 @@ export function analyzeGtsIdForStyling( const part = parts[partIndex] const entityIdToLookup = parts.slice(0, partIndex + 1).join('') - // Look up the entity (existence only — see below). + // 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) + + // 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) { - // Classify schema vs instance STRUCTURALLY from the GTS ID itself via the - // core gts-ts library: a segment whose cumulative ID ends in "~" is a type - // (schema), otherwise an instance. This is deliberately independent of how - // the referenced *document* happens to be shaped (its `isSchema` flag), - // so a malformed instance whose id ends in "~" is still styled as a type. - // The registry lookup above is used only to decide existence (error/valid). - segmentType = isGtsType(entityIdToLookup) ? '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({ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a3bf274..ddc458e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,3 +8,4 @@ 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 504d12f..dfe2733 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -1,6 +1,7 @@ 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' @@ -10,6 +11,53 @@ import { XGtsRefValidator } from '@globaltypesystem/gts-ts/dist/x-gts-ref.js' import type { Format } from 'ajv' import * as path from 'path' +/** + * 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 that were `x-gts-ref`-only (now empty), so Ajv + // doesn't treat them 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 isXGtsRefOnly = + original && + typeof original === 'object' && + !Array.isArray(original) && + Object.keys(original).length === 1 && + original['x-gts-ref'] !== undefined + return !isXGtsRefOnly + }) + 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 @@ -137,11 +185,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 @@ -154,6 +205,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 } @@ -644,13 +703,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 } : {} + }) + } } } @@ -670,7 +735,40 @@ export class JsonRegistry { } else if (entity instanceof JsonObj) { // Validate the object against its schema if (!entity.schemaId) { - // No schema to validate against + // gts-ts's GtsExtractor derived no type_id for this instance, so there + // is no GTS type to validate it against. gts-ts cannot, on its own, + // distinguish the two shapes that land here (both return `ok:false` + // "No schema found" from validateInstance and both register), so we + // split on the one signal gts-ts DOES compute — `selected_entity_field`: + // + // - Schema-shaped documents identified via the JSON-Schema `$id` + // keyword (e.g. `{ "$$schema": …, "$id": "gts://…v1~" }`, the + // .gts-spec DoubleDollarSchemaWithRealId case shipped under valid/): + // the reference server records these as VALID via registration, so we + // defer to that verdict (accept if gts-ts registered them). + // - Plain instances (`id`/`gtsId`/…) whose id is a bare *type* id have + // no instance segment and no resolvable type; OP#6 validation rejects + // them ("No schema found for instance"). Surface that gts-ts verdict + // so they are flagged (matches .examples/invalid/instances/*). + // + // NOTE: this `$id` split is a workaround for a gts-ts gap — see + // docs/GTS_TS_MIGRATION.md Gap I (no single validate verdict that + // separates a schema-shaped schema-less entity from a malformed + // bare-type-id instance). + const store = this.getGtsStore() + const identifiedBySchemaIdentityField = entity.selectedEntityIdField === '$id' + if (identifiedBySchemaIdentityField && store.get(entity.id)) return + 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 } @@ -707,8 +805,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 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, '') +} From f1fcaf444ff2d715fdd86d1d14cc4d65421b4a49 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 16:10:51 +0300 Subject: [PATCH 23/35] fix(styling): avoid duplicate entity suggestions Signed-off-by: Artifizer --- packages/shared/src/gts-styling.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/gts-styling.ts b/packages/shared/src/gts-styling.ts index 1513f18..f6d2a05 100644 --- a/packages/shared/src/gts-styling.ts +++ b/packages/shared/src/gts-styling.ts @@ -256,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) })) From 012c0bb922b1ab2e6824e28e793f0fb2571c8caf Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 21:34:56 +0300 Subject: [PATCH 24/35] feat: apps/vscore-extension - watch file changes in symlinked directories Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 86 ++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index d1ced56..549742b 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -323,9 +323,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( @@ -334,6 +332,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 @@ -344,7 +371,7 @@ export async function activate(context: vscode.ExtensionContext) { 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) @@ -628,6 +655,57 @@ export async function deactivate() { // 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) From ce1cb5c40b9c74d792f915b6937390c2c2c43b0e Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 23:00:51 +0300 Subject: [PATCH 25/35] fix(vscode): keep discovered GTS files synchronized Prevent deleted files from remaining in the Discovered GTS files tree and ensure validation decorations consistently reflect the latest workspace state. Serialize full workspace scans and retry scans invalidated by concurrent filesystem or editor mutations. Track registry revisions and replace the registry only when its source snapshot is still current, preventing older scans from overwriting newer incremental updates. Version per-file mutations so asynchronous reads from create/change events cannot re-index a file after a newer delete or rename event. Always invalidate deleted paths, and verify that files still exist before adding their contents to the registry. Filter missing filesystem paths from the explorer as a final consistency check. Initialize the tree after its providers are registered and reconcile it when diagnostics change so newly discovered files receive decorations immediately. Add generations to document and workspace validation runs. Discard validation results when a newer run or registry revision supersedes them, preventing partial or stale valid/invalid statuses from being published. Start the initial workspace scan only after diagnostics, filesystem watchers, and the GTS explorer have been registered. Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 74 ++++++++++++++++++---- apps/vscode-extension/src/gtsExplorer.ts | 8 ++- apps/vscode-extension/src/registryStore.ts | 33 +++++++++- apps/vscode-extension/src/validation.ts | 33 +++++++++- 4 files changed, 128 insertions(+), 20 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 549742b..39d586e 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -4,7 +4,7 @@ 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, getRegistry } 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, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents, onValidationCompleted } from './validation' @@ -120,6 +120,21 @@ let gtsLinkProvider: GtsLinkProvider | null = null 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 = '' @@ -130,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 @@ -203,7 +223,9 @@ 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) } @@ -258,22 +280,18 @@ 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 @@ -368,6 +386,7 @@ 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 @@ -388,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() } @@ -397,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) { @@ -540,6 +566,8 @@ function revalidateOpenDocs(): void { } async function refreshGtsFileExplorer(linkDiagnostics: vscode.DiagnosticCollection): Promise { + workspaceMutationRevision++ + fileMutationRevisions.clear() if (changeTimer) clearTimeout(changeTimer) if (externalChangeTimer) clearTimeout(externalChangeTimer) changeTimer = null @@ -557,7 +585,12 @@ async function refreshGtsFileExplorer(linkDiagnostics: vscode.DiagnosticCollecti await performInitialScan() } -async function performInitialScan() { +async function performInitialScan(): Promise { + await enqueueStableScan(performInitialScanPass) +} + +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 @@ -595,8 +628,10 @@ async function performInitialScan() { 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() @@ -619,6 +654,7 @@ async function performInitialScan() { 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]) @@ -629,6 +665,7 @@ async function performInitialScan() { } 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 @@ -726,12 +763,14 @@ 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) @@ -746,8 +785,7 @@ 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() @@ -757,6 +795,15 @@ function onDiskFileDeleted(uri: vscode.Uri): void { // 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(() => { @@ -788,6 +835,7 @@ 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. diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index 213bcdb..d980bd7 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode' import * as path from 'path' +import * as fs from 'fs' import { getRegistry } from './registryStore' /** @@ -67,7 +68,7 @@ function getDiscoveredFilePaths(): string[] { const registry = getRegistry() if (!registry) return [] const paths = new Set([...registry.jsonFiles.keys(), ...registry.invalidFiles.keys()]) - return Array.from(paths) + return Array.from(paths).filter(fs.existsSync) } /** True if the given file currently has a GTS validation error reported on it. */ @@ -253,11 +254,14 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor // badge) change independently of the file list — repaint whenever a URI's // diagnostics changed. vscode.languages.onDidChangeDiagnostics(event => { - decorationProvider.refresh(event.uris as vscode.Uri[]) + const changed = treeProvider.refresh() + decorationProvider.refresh([...event.uris, ...changed] as vscode.Uri[]) updateBadge(treeView) }) ) + treeProvider.refresh() + decorationProvider.refresh() updateBadge(treeView) return { 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 c7bf728..5c94afe 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -4,7 +4,7 @@ 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 @@ -12,7 +12,9 @@ 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()) || [] @@ -526,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) @@ -590,6 +596,11 @@ 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) @@ -614,7 +625,8 @@ export async function validateOpenDocument(document: vscode.TextDocument) { } } catch (error) { console.error('[GTS Validation] ✗ Error validating document:', error) - documentValidationErrors.delete(document.uri.toString()) + if (documentValidationGenerations.get(validationKey) !== validationGeneration) return + documentValidationErrors.delete(validationKey) diagnosticCollection.delete(document.uri) } } @@ -627,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) { @@ -661,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]) @@ -771,6 +794,8 @@ export async function revalidateDependents(changedPath: string, previousIds?: It export function resetValidationDiagnostics(): void { documentValidationErrors.clear() + documentValidationGenerations.clear() + workspaceValidationGeneration++ diagnosticCollection?.clear() workspaceDiagnosticCollection?.clear() } @@ -809,7 +834,9 @@ export function initValidation(context: vscode.ExtensionContext) { vscode.workspace.onDidCloseTextDocument(async doc => { if (!isGtsCandidateFile(doc)) return console.log(`[GTS Validation] Document closed: ${doc.fileName}`) - documentValidationErrors.delete(doc.uri.toString()) + 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) From b265cf0a22093d09176021f030e065c0bdd8b02d Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 23:41:47 +0300 Subject: [PATCH 26/35] fix(vscode): surface inherited schema validation failures Propagate a direct parent's GTS validation failure to an otherwise-valid derived schema so inherited errors produce an authoritative diagnostic on the derived file instead of only red identifier segments. Make Explorer file status consult registry validation results in addition to VS Code diagnostics. This preserves the invariant that any failed GTS validation marks the complete file as problematic, even while diagnostic publication is pending. Signed-off-by: Artifizer --- apps/vscode-extension/src/gtsExplorer.ts | 8 +++++++- packages/shared/src/registry.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index d980bd7..3f062bb 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -73,7 +73,13 @@ function getDiscoveredFilePaths(): string[] { /** True if the given file currently has a GTS validation error reported on it. */ export function hasGtsErrors(uri: vscode.Uri): boolean { - return vscode.languages.getDiagnostics(uri).some(d => d.source === 'GTS') + 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. */ diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index dfe2733..067c517 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -732,6 +732,24 @@ 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) { From dd873ff45bc4ef6ab73230c3c330f43b36c445e7 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 23:42:19 +0300 Subject: [PATCH 27/35] fix(validation): reject schema-less dollar-id instances Remove the registration-success workaround that accepted an entity identified by `$id` even when gts-ts classified it as an instance with no schema. Always preserve the validateInstance verdict for schema-less entities so double-dollar schema-marker artifacts produce a diagnostic, an editor underline, and a problematic file decoration. Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 067c517..59453c0 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -753,29 +753,7 @@ export class JsonRegistry { } else if (entity instanceof JsonObj) { // Validate the object against its schema if (!entity.schemaId) { - // gts-ts's GtsExtractor derived no type_id for this instance, so there - // is no GTS type to validate it against. gts-ts cannot, on its own, - // distinguish the two shapes that land here (both return `ok:false` - // "No schema found" from validateInstance and both register), so we - // split on the one signal gts-ts DOES compute — `selected_entity_field`: - // - // - Schema-shaped documents identified via the JSON-Schema `$id` - // keyword (e.g. `{ "$$schema": …, "$id": "gts://…v1~" }`, the - // .gts-spec DoubleDollarSchemaWithRealId case shipped under valid/): - // the reference server records these as VALID via registration, so we - // defer to that verdict (accept if gts-ts registered them). - // - Plain instances (`id`/`gtsId`/…) whose id is a bare *type* id have - // no instance segment and no resolvable type; OP#6 validation rejects - // them ("No schema found for instance"). Surface that gts-ts verdict - // so they are flagged (matches .examples/invalid/instances/*). - // - // NOTE: this `$id` split is a workaround for a gts-ts gap — see - // docs/GTS_TS_MIGRATION.md Gap I (no single validate verdict that - // separates a schema-shaped schema-less entity from a malformed - // bare-type-id instance). const store = this.getGtsStore() - const identifiedBySchemaIdentityField = entity.selectedEntityIdField === '$id' - if (identifiedBySchemaIdentityField && store.get(entity.id)) return const result = store.validateInstance(entity.id) if (!result.ok) { const idField = (entity as any).selectedSchemaIdField || (entity as any).selectedEntityIdField || 'id' From b45edad088330d7f41768892cf6384e5fe32c119 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Wed, 16 Sep 2026 23:45:00 +0300 Subject: [PATCH 28/35] fix(types): include Node globals in shared package Load the Node type library explicitly for the shared package so path imports and runtime globals such as console resolve consistently in editors as well as command-line builds. Signed-off-by: Artifizer --- packages/shared/tsconfig.json | 1 + 1 file changed, 1 insertion(+) 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", From 4a7fe45c6dfb9ee4ef869673af9645a367e0c77f Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 17 Sep 2026 21:35:16 +0300 Subject: [PATCH 29/35] docs: update VS Code default message used while GTS file scanner is running Signed-off-by: Artifizer --- apps/vscode-extension/CHANGELOG.md | 4 +++- apps/vscode-extension/package.json | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/vscode-extension/CHANGELOG.md b/apps/vscode-extension/CHANGELOG.md index 5f280aa..486e847 100644 --- a/apps/vscode-extension/CHANGELOG.md +++ b/apps/vscode-extension/CHANGELOG.md @@ -4,7 +4,9 @@ All notable changes to the GTS Viewer extension will be documented in this file. ## [Unreleased] -## [0.2.7] - 2026-09-13 +## [0.3.0] - 2026-09-18 + + ## [0.2.6] - 2026-09-07 diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index eb08ffc..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.7", + "version": "0.3.0", "private": true, "publisher": "GlobalTypeSystem", "license": "Apache-2.0", @@ -77,7 +77,7 @@ "viewsWelcome": [ { "view": "gts-kit.fileExplorer", - "contents": "No GTS files discovered yet.\nGTS files (schemas and instances) found anywhere in the workspace will be listed here." + "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": { From 032f096c5470a486cb4a1efa5bdaa71aff19010a Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:27:10 +0300 Subject: [PATCH 30/35] perf(vscode): avoid synchronous explorer file scans Use the registry as the authoritative discovered-file set during refreshes. Delete and rename handlers already invalidate removed paths, so filesystem existence checks only block the extension host without improving consistency. Signed-off-by: Artifizer --- apps/vscode-extension/src/gtsExplorer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index 3f062bb..85b851b 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -1,6 +1,5 @@ import * as vscode from 'vscode' import * as path from 'path' -import * as fs from 'fs' import { getRegistry } from './registryStore' /** @@ -68,7 +67,7 @@ function getDiscoveredFilePaths(): string[] { const registry = getRegistry() if (!registry) return [] const paths = new Set([...registry.jsonFiles.keys(), ...registry.invalidFiles.keys()]) - return Array.from(paths).filter(fs.existsSync) + return Array.from(paths) } /** True if the given file currently has a GTS validation error reported on it. */ From a4cb2440df9b747ae1de395c6912f70e1a283e77 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:27:23 +0300 Subject: [PATCH 31/35] fix(web): invalidate stale files after parse errors Send null through the incremental registry update when malformed content no longer looks GTS-related. This removes entities retained from the previous valid file state and revalidates their dependents. Signed-off-by: Artifizer --- apps/web/src/hooks/useJsonFiles.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useJsonFiles.ts b/apps/web/src/hooks/useJsonFiles.ts index e56eadb..d111b13 100644 --- a/apps/web/src/hooks/useJsonFiles.ts +++ b/apps/web/src/hooks/useJsonFiles.ts @@ -165,8 +165,7 @@ export function useJsonObjsWithScanner(createScanner: () => Scanner) { } catch { // Surface parse errors only for files that look GTS-related; ignore // unrelated malformed JSON (matches loadFromScanner's filter). - if (!text.includes('gts.')) return - content = text + content = text.includes('gts.') ? text : null } await registry.applyFileChange(doc.path, doc.name, content, AppConfig.get().gts) } From ca64f6bc37eaef3b6b8650ea6e857242bd43aa8b Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:28:06 +0300 Subject: [PATCH 32/35] fix(validation): prune annotation-only GTS ref branches Treat JSON Schema annotation keywords as non-asserting when normalizing x-gts-ref combinator branches for Ajv. This prevents descriptions and other metadata from leaving always-true oneOf branches that produce false validation failures. Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 59453c0..4357212 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -11,6 +11,20 @@ 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 @@ -38,19 +52,19 @@ function stripXGtsRefForAjv(schema: any): any { normalized[key] = value && typeof value === 'object' ? stripXGtsRefForAjv(value) : value } - // Drop combinator subschemas that were `x-gts-ref`-only (now empty), so Ajv - // doesn't treat them as always-true branches. + // 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 isXGtsRefOnly = + const hasOnlyXGtsRefAssertion = original && typeof original === 'object' && !Array.isArray(original) && - Object.keys(original).length === 1 && - original['x-gts-ref'] !== undefined - return !isXGtsRefOnly + 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] } From 72846f2768d0a4a94e891cecd54c0fa4078fd515 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:28:49 +0300 Subject: [PATCH 33/35] fix(registry): skip ID-less YAML containers Require top-level YAML entries to expose a selected entity or schema ID before registration. Inline definitions remain recursively registered, but their nested GTS references can no longer make the surrounding configuration object appear as an entity. Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 4357212..2b1372a 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -541,9 +541,10 @@ export class JsonRegistry { const parsedContent = jsonFile.content // Register one entity content as a schema/instance if it is a GTS entity. - const registerEntity = (entityContent: any, seq: number | undefined) => { + const registerEntity = (entityContent: any, seq: number | undefined, requireSelectedId = false) => { const entity = createEntity({ file: jsonFile, listSequence: seq, content: entityContent, cfg }) - if (entity && entity.isGtsEntity()) { + 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) @@ -559,7 +560,7 @@ export class JsonRegistry { // 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) + registerEntity(entityContent, Array.isArray(parsedContent) ? idx : undefined, isYamlFileName(name)) }) // YAML ONLY: config files may additionally *define* GTS types/instances From e1f750b84a8ef09de1cfaa578e42206c3eddd230 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:31:20 +0300 Subject: [PATCH 34/35] fix(web): remove deprecated TypeScript base URL Resolve the existing path mapping relative to the web tsconfig directly. Modern TypeScript no longer requires baseUrl for paths, avoiding the TypeScript 6 deprecation without suppressing diagnostics. Signed-off-by: Artifizer --- apps/web/tsconfig.json | 1 - 1 file changed, 1 deletion(-) 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/*"] } From d6d0defcee7e20f91c5b4ef7eb8e926caa320305 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Fri, 18 Sep 2026 01:32:29 +0300 Subject: [PATCH 35/35] fix(web): correct TypeScript ESLint preset Use the plugin-qualified recommended preset and remove duplicate entries. This allows ESLint to resolve the intended TypeScript configuration before loading the installed plugin dependency tree. Signed-off-by: Artifizer --- apps/web/.eslintrc.cjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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',