From 4ede56d9ce08f1bf5b3d2d938645ef9326e1dbfe Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 12:41:29 +0300 Subject: [PATCH 01/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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/57] 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', From 3cec75dc4c6327c0939d5420cd26d1ccafb71742 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 27 Sep 2026 21:36:15 +0300 Subject: [PATCH 36/57] chore: rebase gts-kit on gts-ts v0.7.0 and document 0.3.0 release Bump @globaltypesystem/gts-ts from ^0.5.0 to ^0.7.0 in the shared package so all apps inherit the newer validation behavior, and regenerate the lockfile accordingly. The full workspace build passes unchanged. Fill in the previously empty 0.3.0 section of the VS Code extension changelog and add matching CHANGELOG.md files for the electron, server, and web apps, bumping the suite to 0.3.0 (web stays at 0.2.7). Signed-off-by: Artifizer --- apps/electron/CHANGELOG.md | 47 +++++++++++++++++++ apps/electron/package.json | 2 +- apps/server/CHANGELOG.md | 48 ++++++++++++++++++++ apps/server/package.json | 2 +- apps/vscode-extension/CHANGELOG.md | 26 +++++++++++ apps/web/CHANGELOG.md | 67 ++++++++++++++++++++++++++++ package-lock.json | 25 ++++++----- package.json | 2 +- packages/layout-storage/package.json | 2 +- packages/shared/package.json | 2 +- 10 files changed, 206 insertions(+), 17 deletions(-) create mode 100644 apps/electron/CHANGELOG.md create mode 100644 apps/server/CHANGELOG.md create mode 100644 apps/web/CHANGELOG.md diff --git a/apps/electron/CHANGELOG.md b/apps/electron/CHANGELOG.md new file mode 100644 index 0000000..9c809ab --- /dev/null +++ b/apps/electron/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to the GTS Electron app will be documented in this file. + +## [Unreleased] + +## [0.3.0] - 2026-09-18 + +### Changed +- Rebased validation on `gts-ts` v0.7.0 for additional checks + +### Fixed +- Reject schema-less `$id` instances +- Prune annotation-only GTS reference branches and skip ID-less YAML containers +- Enforce strict RFC 3339 for date/time formats + +## [0.2.6] - 2026-09-07 + +_Released alongside the rest of the suite._ + +## [0.2.5] - 2026-09-06 + +_Released alongside the rest of the suite._ + +## [0.2.4] - 2026-09-06 + +### Added +- YAML file format support + +### Changed +- Rebased schema validation on `gts-ts` +- Aligned schema handling with GTS spec v0.7 + +## [0.2.1] - 2025-10-22 + +_Released alongside the rest of the suite._ + +## [0.2.0] - 2025-10-19 + +### Fixed +- Fixed errors on Electron start +- Moved the primary web server port to 7805 and the DB server to 7806 for npm/docker + +## [0.1.0] - 2025-10-16 + +### Added +- Initial release of the GTS Electron viewer diff --git a/apps/electron/package.json b/apps/electron/package.json index b6b03f9..cf3edec 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "@gts-viewer/electron", - "version": "0.2.6", + "version": "0.3.0", "description": "GTS Viewer Electron Application", "main": "dist/main/main.js", "scripts": { diff --git a/apps/server/CHANGELOG.md b/apps/server/CHANGELOG.md new file mode 100644 index 0000000..f85e8dc --- /dev/null +++ b/apps/server/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to the GTS server will be documented in this file. + +## [Unreleased] + +## [0.3.0] - 2026-09-18 + +### Changed +- Rebased validation on `gts-ts` v0.7.0 for additional checks + +### Fixed +- Reject schema-less `$id` instances +- Prune annotation-only GTS reference branches and skip ID-less YAML containers +- Enforce strict RFC 3339 for date/time formats + +## [0.2.6] - 2026-09-07 + +_Released alongside the rest of the suite._ + +## [0.2.5] - 2026-09-06 + +_Released alongside the rest of the suite._ + +## [0.2.4] - 2026-09-06 + +### Added +- YAML file format support + +### Changed +- Rebased schema validation on `gts-ts` + +## [0.2.1] - 2025-10-22 + +_Released alongside the rest of the suite._ + +## [0.2.0] - 2025-10-19 + +### Changed +- Switched from `better-sqlite3` to `sql.js` (no native compilation required) + +### Fixed +- Moved the primary web server port to 7805 and the DB server to 7806 for npm/docker + +## [0.1.0] - 2025-10-16 + +### Added +- Initial release of the GTS server diff --git a/apps/server/package.json b/apps/server/package.json index 402bf5a..844398f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@gts/server", - "version": "0.2.6", + "version": "0.3.0", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/apps/vscode-extension/CHANGELOG.md b/apps/vscode-extension/CHANGELOG.md index 486e847..cbe09f9 100644 --- a/apps/vscode-extension/CHANGELOG.md +++ b/apps/vscode-extension/CHANGELOG.md @@ -6,7 +6,33 @@ All notable changes to the GTS Viewer extension will be documented in this file. ## [0.3.0] - 2026-09-18 +### Added +- GTS activity-bar panel with a file explorer to browse discovered GTS files and see an at-a-glance problem count +- Refresh button in the GTS panel to rescan the workspace file explorer +- YAML annotation support, including inline GTS entity definitions declared in YAML config files +- Drag-and-drop of discovered GTS files, including multi-file drag into chat +- Automatic revalidation of dependent entities when a file changes, across all apps +- File watching inside symlinked directories + +### Changed +- Rebased validation on `gts-ts` v0.7.0 for additional checks, and updated to gts-spec v0.13.4 +- Renamed all `gts.*` commands to `gts-kit.*` +- Updated the Marketplace category and tags +- Enabled Node typings for the extension +- Improved the default message shown while the GTS file scanner is running +### Fixed +- GTS scanner now starts on extension startup +- Discovered GTS files stay synchronized, with reduced file-tree flicker and deduplicated symlinked scans +- Explorer file scans are no longer synchronous, avoiding UI stalls on large repos +- Invalid files stay marked red after a preview tab closes +- Inherited schema validation failures are now surfaced +- Invalid GTS chain segments cascade correctly, and reference errors resolve to their own array item +- GTS ID segments are classified structurally via core gts-ts +- Strict RFC 3339 enforcement for date/time formats +- Schema-less `$id` instances are rejected; annotation-only GTS reference branches and ID-less YAML containers are skipped +- Duplicate entity suggestions removed +- Stale files invalidated after parse errors ## [0.2.6] - 2026-09-07 diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md new file mode 100644 index 0000000..09bba8d --- /dev/null +++ b/apps/web/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +All notable changes to the GTS web app will be documented in this file. + +## [Unreleased] + +## [0.2.7] - 2026-09-16 + +### Added +- Dependent entities are revalidated automatically when a file changes + +### Fixed +- GTS validation errors are now surfaced across the VS Code viewer +- Stale files are invalidated after parse errors +- Removed a deprecated TypeScript base URL and corrected the TypeScript ESLint preset + +## [0.2.6] - 2026-09-07 + +### Added +- Diagnostics shown when the web server isn't running +- Progress bar for large repo scans + +### Fixed +- Compact UI layout for the VS Code webview; validation errors now wrap properly + +## [0.2.5] - 2026-09-06 + +_No web-specific changes; released alongside the rest of the suite._ + +## [0.2.4] - 2026-09-06 + +### Added +- YAML file format support +- Schema examples preview feature +- All/errors/valid entities selector in the GTS viewer + +### Changed +- Rebased schema validation on `gts-ts` +- Made `config.schema_if_fields` consistent with the gts-rust and gts-go implementations + +### Fixed +- Removed duplicated validation errors in the GTS viewer + +## [0.2.1] - 2025-10-22 + +### Added +- Open the file containing a GTS node directly from the VS Code editor +- Neutral file link color (blue was reserved for "schema" elsewhere) + +### Changed +- Cumulative visual style polish for the web view +- Improved invalid GTS format error display + +### Fixed +- Popup GTS error display position +- Color annotations for broken GTS IDs +- Removed the redundant file link in the VS Code web viewer + +## [0.2.0] - 2025-10-19 + +### Changed +- Switched the backing store from `better-sqlite3` to `sql.js` (no native compilation required) + +## [0.1.0] - 2025-10-16 + +### Added +- Initial release of the GTS web viewer diff --git a/package-lock.json b/package-lock.json index 62a7dc5..30744c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gts-monorepo", - "version": "0.2.7", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gts-monorepo", - "version": "0.2.7", + "version": "0.3.0", "workspaces": [ "apps/*", "packages/*" @@ -22,7 +22,7 @@ }, "apps/electron": { "name": "@gts-viewer/electron", - "version": "0.2.7", + "version": "0.3.0", "dependencies": { "@dagrejs/dagre": "^1.1.5", "@gts-viewer/shared": "file:../../packages/shared", @@ -75,7 +75,7 @@ }, "apps/server": { "name": "@gts/server", - "version": "0.2.7", + "version": "0.3.0", "dependencies": { "@gts/shared": "*", "cors": "2.8.5", @@ -108,7 +108,7 @@ }, "apps/vscode-extension": { "name": "gts-kit", - "version": "0.2.7", + "version": "0.3.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.11.30", @@ -1734,16 +1734,18 @@ } }, "node_modules/@globaltypesystem/gts-ts": { - "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==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@globaltypesystem/gts-ts/-/gts-ts-0.7.0.tgz", + "integrity": "sha512-ABeE2KLKvc7JBtzsgKXXsGUpF8YFfPJlmO0HLJVlOeo6Z0ggH2b4j7t0s7mJg3kkmZv3E7UAo6DhtEhD55EQYA==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.18.0", "ajv-formats": "^2.1.1", "commander": "^12.0.0", "fastify": "^5.12.2", - "uuid": "^11.1.1" + "jsonc-parser": "^3.3.1", + "uuid": "^11.1.1", + "yaml": "^2.9.1" }, "bin": { "gts": "dist/cli/index.js", @@ -13604,7 +13606,6 @@ "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" @@ -13767,7 +13768,7 @@ }, "packages/layout-storage": { "name": "@gts/layout-storage", - "version": "0.2.7", + "version": "0.3.0", "devDependencies": { "typescript": "^5.3.3" } @@ -13790,7 +13791,7 @@ "name": "@gts/shared", "version": "0.2.7", "dependencies": { - "@globaltypesystem/gts-ts": "^0.5.0", + "@globaltypesystem/gts-ts": "^0.7.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index 17390a2..b7b10cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gts-monorepo", - "version": "0.2.6", + "version": "0.3.0", "private": true, "workspaces": [ "apps/*", diff --git a/packages/layout-storage/package.json b/packages/layout-storage/package.json index 5a1dda3..a737a2c 100644 --- a/packages/layout-storage/package.json +++ b/packages/layout-storage/package.json @@ -1,6 +1,6 @@ { "name": "@gts/layout-storage", - "version": "0.2.6", + "version": "0.3.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/shared/package.json b/packages/shared/package.json index 877efb9..54b050d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,7 +16,7 @@ "dev": "tsc --watch" }, "dependencies": { - "@globaltypesystem/gts-ts": "^0.5.0", + "@globaltypesystem/gts-ts": "^0.7.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.0", From 4ecb8239dde8aaf40147232dbb0c89564c272156 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 09:14:46 +0300 Subject: [PATCH 37/57] fix(vscode): report invalid GTS entities accurately in hovers/diagnostics Two related correctness fixes to how the extension surfaces GTS entities that exist but fail validation. 1. Hover no longer says "GTS Entity Not Found" for an entity that is present but invalid. The hover previously forced the lookup result to undefined for any segment the styling analyzer flagged as an error (which includes existing-but-invalid entities), so it fell into the not-found branch and contradicted Cmd+Click, which resolves the same entity via a plain existence lookup. The hover now looks the entity up by presence (matching the document-link provider) and, when it exists but is invalid, shows a "GTS Entity Invalid" note with the underlying validation error while keeping the navigable definition link. 2. "Referenced x-gts-ref entity '' is invalid" diagnostics now anchor to the offending x-gts-ref node instead of the schema's $id. The gts-ts error mapper only resolved a precise location for messages beginning with "Property '...'", falling back to /$id for everything else, so the squiggle landed on the $id line rather than the x-gts-ref that caused it. A new findXGtsRefPath() locates the x-gts-ref whose value matches the referenced id and the mapper uses that path (still falling back to /$id when it cannot be located). Signed-off-by: Artifizer --- apps/vscode-extension/src/linkProvider.ts | 119 +++++++++++++--------- packages/shared/src/registry.ts | 20 +++- packages/shared/src/schemaParser.ts | 33 ++++++ 3 files changed, 119 insertions(+), 53 deletions(-) diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index df73774..c9795e1 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -700,6 +700,42 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove return links } + /** + * Turn a list of candidate suggestion ids into rendered markdown list items, + * skipping any that don't resolve to a real registry entity. Returning the + * concrete lines (rather than appending directly) lets callers decide whether + * to show the "Did you mean" header at all — it must never appear with no + * suggestions under it. + */ + private buildSuggestionLines( + suggestions: string[], + document: vscode.TextDocument, + hoverRange: vscode.Range + ): string[] { + if (!this.registry) return [] + const lines: string[] = [] + for (const suggestion of suggestions) { + const suggestionEntity = this.registry.jsonSchemas.get(suggestion) || this.registry.jsonObjs.get(suggestion) + if (!suggestionEntity) continue + const entityType = suggestionEntity.isSchema ? '📘 Schema' : '📄 Instance' + // Serialize range as a plain object for the replace command. + const rangeData = { + start: { line: hoverRange.start.line, character: hoverRange.start.character }, + end: { line: hoverRange.end.line, character: hoverRange.end.character } + } + const commandUri = vscode.Uri.parse( + `command:gts.replaceGtsId?${encodeURIComponent(JSON.stringify([ + document.uri.toString(), + rangeData, + suggestion, + false // includeQuotes - range already excludes quotes + ]))}` + ) + lines.push(`- ${entityType}: [${escapeMarkdown(suggestion)}](${commandUri.toString()})\n`) + } + return lines + } + /** * Provide hover information for GTS IDs */ @@ -811,32 +847,15 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove ...Array.from(this.registry.jsonObjs.keys()) ].filter(id => isGtsId(id)) // Only suggest valid GTS IDs - // Find similar entities + // Find similar entities that actually resolve to a registry entity, so + // the "Did you mean" header is only shown when there is at least one + // clickable suggestion to render underneath it. const suggestions = findSimilarEntityIds(gtsId, allEntityIds, 3) + const suggestionLines = this.buildSuggestionLines(suggestions, document, hoverRange) - if (suggestions.length > 0) { + if (suggestionLines.length > 0) { markdown.appendMarkdown(`**Did you mean:** (click to replace)\n\n`) - for (const suggestion of suggestions) { - const suggestionEntity = this.registry.jsonSchemas.get(suggestion) || this.registry.jsonObjs.get(suggestion) - if (suggestionEntity) { - const entityType = suggestionEntity.isSchema ? '📘 Schema' : '📄 Instance' - // Create command URI to replace the erroneous GTS ID - // Serialize range as plain object - const rangeData = { - start: { line: hoverRange.start.line, character: hoverRange.start.character }, - end: { line: hoverRange.end.line, character: hoverRange.end.character } - } - const commandUri = vscode.Uri.parse( - `command:gts.replaceGtsId?${encodeURIComponent(JSON.stringify([ - document.uri.toString(), - rangeData, - suggestion, - false // includeQuotes - range already excludes quotes - ]))}` - ) - markdown.appendMarkdown(`- ${entityType}: [${escapeMarkdown(suggestion)}](${commandUri.toString()})\n`) - } - } + for (const line of suggestionLines) markdown.appendMarkdown(line) } else { markdown.appendMarkdown(`*No similar entities found in the registry.*`) } @@ -908,10 +927,15 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // Not found at all → fall through to the "GTS Entity Not Found" + suggestions block. } - // 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) + // Look up the entity in the registry. A segment flagged 'error' by the + // analyzer can mean two different things: the entity is *absent*, or it + // *exists but failed GTS validation* (e.g. it references another invalid + // schema). We must not report an existing entity as "not found" — Cmd+Click + // resolves it via the same registry, so a "not found" hover would directly + // contradict the working link. Look it up unconditionally and let its + // *presence* (not its validity) decide between the "not found" block and the + // found hover; the invalid state is annotated on the found hover below. + const entity = this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) if (!entity) { // Entity not found - show error with suggestions @@ -924,32 +948,15 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove ...Array.from(this.registry.jsonObjs.keys()) ].filter(id => isGtsId(id)) // Only suggest valid GTS IDs - // Find similar entities + // Find similar entities that actually resolve to a registry entity, so + // the "Did you mean" header is only shown when there is at least one + // clickable suggestion to render underneath it. const suggestions = findSimilarEntityIds(entityIdToLookup, allEntityIds, 3) + const suggestionLines = this.buildSuggestionLines(suggestions, document, hoverRange) - if (suggestions.length > 0) { + if (suggestionLines.length > 0) { markdown.appendMarkdown(`**Did you mean:** (click to replace)\n\n`) - for (const suggestion of suggestions) { - const suggestionEntity = this.registry.jsonSchemas.get(suggestion) || this.registry.jsonObjs.get(suggestion) - if (suggestionEntity) { - const entityType = suggestionEntity.isSchema ? '📘 Schema' : '📄 Instance' - // Create command URI to replace the erroneous GTS ID - // Serialize range as plain object - const rangeData = { - start: { line: hoverRange.start.line, character: hoverRange.start.character }, - end: { line: hoverRange.end.line, character: hoverRange.end.character } - } - const commandUri = vscode.Uri.parse( - `command:gts.replaceGtsId?${encodeURIComponent(JSON.stringify([ - document.uri.toString(), - rangeData, - suggestion, - false // includeQuotes - range already excludes quotes - ]))}` - ) - markdown.appendMarkdown(`- ${entityType}: [${escapeMarkdown(suggestion)}](${commandUri.toString()})\n`) - } - } + for (const line of suggestionLines) markdown.appendMarkdown(line) } else { markdown.appendMarkdown(`*No similar entities found in the registry.*`) } @@ -964,6 +971,18 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // Determine entity type const entityType = entity.isSchema ? 'Schema' : 'Instance' + // The analyzer flagged this segment as an error even though the entity + // exists → it is present but invalid. Surface that up front (with the + // underlying GTS validation error when known) instead of masquerading as + // "not found"; the definition link below still lets the user navigate to it. + if (hoveredSeg && hoveredSeg.type === 'error') { + markdown.appendMarkdown(`⚠️ GTS Entity Invalid\n\n`) + const firstError = entity.validation?.errors?.[0] + if (firstError?.message) { + markdown.appendMarkdown(`${escapeMarkdown(firstError.message)}\n\n`) + } + } + // Add file path as a clickable link const lineNumber = findEntityLineInFile(entity.file.path, entityIdToLookup) const fileUri = vscode.Uri.file(entity.file.path).with({ diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 2b1372a..49bd8fc 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -1,7 +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 { findSchemaPropertyPath, findXGtsRefPath } from './schemaParser.js' import Ajv, { type ValidateFunction, type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' import { GtsModifiers, GtsStore, createJsonEntity } from '@globaltypesystem/gts-ts' @@ -722,13 +722,27 @@ export class JsonRegistry { for (const msg of rawMessages) { const propMatch = msg.match(/^Property '([^']+)'/) const propPath = propMatch ? propMatch[1] : null - const instancePath = propPath ? findSchemaPropertyPath(entity.content, propPath) : '/$id' + // A "Referenced x-gts-ref entity '' is invalid: ..." message is + // caused by a specific `x-gts-ref` node, not by the schema's own + // `$id`; anchor it to that node so the squiggle lands on the ref + // (e.g. line with `"x-gts-ref": ""`) rather than the document + // root. Everything else keeps the previous `$id` fallback. + const refMatch = propPath ? null : msg.match(/^Referenced x-gts-ref entity '([^']+)'/) + let instancePath: string | null = null + let params: Record = {} + if (propPath) { + instancePath = findSchemaPropertyPath(entity.content, propPath) + params = { property: propPath } + } else if (refMatch) { + instancePath = findXGtsRefPath(entity.content, refMatch[1]) + params = { refValue: refMatch[1] } + } entity.validation.errors.push({ instancePath: instancePath || '/$id', schemaPath: '#', keyword: 'x-gts-schema', message: msg, - params: propPath ? { property: propPath } : {} + params }) } } diff --git a/packages/shared/src/schemaParser.ts b/packages/shared/src/schemaParser.ts index fa6c6c2..54c1581 100644 --- a/packages/shared/src/schemaParser.ts +++ b/packages/shared/src/schemaParser.ts @@ -400,3 +400,36 @@ export function findSchemaPropertyPath(content: any, propPath: string): string | return walk(content, '') } + +/** + * Locate the JSON Pointer instancePath of the `x-gts-ref` keyword whose value + * equals `refValue` (any `gts://` prefix ignored), searching every position in + * the schema document. Used to anchor a "Referenced x-gts-ref entity '' is + * invalid" diagnostic to the offending `x-gts-ref` node rather than the + * document root. Returns null when no matching `x-gts-ref` is present. + */ +export function findXGtsRefPath(content: any, refValue: string): string | null { + const target = normalizeGtsId(refValue) + + function walk(node: any, currentPath: string): string | null { + if (!node || typeof node !== 'object') return null + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) { + const res = walk(node[i], `${currentPath}/${i}`) + if (res) return res + } + return null + } + for (const [key, value] of Object.entries(node)) { + const childPath = `${currentPath}/${key}` + if (key === 'x-gts-ref' && typeof value === 'string' && normalizeGtsId(value) === target) { + return childPath + } + const res = walk(value, childPath) + if (res) return res + } + return null + } + + return walk(content, '') +} From 383a987894bb18039daca5fc5421d52405820312 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 09:51:58 +0300 Subject: [PATCH 38/57] fix(shared): anchor trait-completeness diagnostics to x-gts-traits-schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OP#13 trait-completeness failures from gts-ts arrive as "trait validation: ..." messages with no field path, so the extension's error mapper fell back to anchoring them on the schema's $id — the squiggle landed on line 2 instead of the unmet trait requirement. Add findTraitRequiredPath(), which resolves the offending requirement to the specific `required` entry in the document's top-level x-gts-traits-schema (e.g. /x-gts-traits-schema/required/0) when the missing trait can be named, or to /x-gts-traits-schema otherwise, and null when the requirement comes from an ancestor (so the mapper keeps the /$id fallback). Wire it into the gts-ts error mapping: messages beginning with "trait validation:" now extract the missing trait name and anchor there, alongside the existing Property '...' and Referenced x-gts-ref entity '...' handling. Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 12 +++++++++++- packages/shared/src/schemaParser.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 49bd8fc..c1fe806 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -1,7 +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, findXGtsRefPath } from './schemaParser.js' +import { findSchemaPropertyPath, findXGtsRefPath, findTraitRequiredPath } from './schemaParser.js' import Ajv, { type ValidateFunction, type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' import { GtsModifiers, GtsStore, createJsonEntity } from '@globaltypesystem/gts-ts' @@ -728,6 +728,11 @@ export class JsonRegistry { // (e.g. line with `"x-gts-ref": ""`) rather than the document // root. Everything else keeps the previous `$id` fallback. const refMatch = propPath ? null : msg.match(/^Referenced x-gts-ref entity '([^']+)'/) + // A "trait validation: ..." message comes from the OP#13 trait + // completeness check; anchor it to the unmet `x-gts-traits-schema` + // requirement (the specific `required` entry when we can name the + // missing trait) instead of the schema's `$id`. + const isTraitError = !propPath && !refMatch && msg.startsWith('trait validation:') let instancePath: string | null = null let params: Record = {} if (propPath) { @@ -736,6 +741,11 @@ export class JsonRegistry { } else if (refMatch) { instancePath = findXGtsRefPath(entity.content, refMatch[1]) params = { refValue: refMatch[1] } + } else if (isTraitError) { + const traitMatch = msg.match(/required (?:property|trait) '([^']+)'/) + const traitName = traitMatch ? traitMatch[1] : undefined + instancePath = findTraitRequiredPath(entity.content, traitName) + params = traitName ? { trait: traitName } : {} } entity.validation.errors.push({ instancePath: instancePath || '/$id', diff --git a/packages/shared/src/schemaParser.ts b/packages/shared/src/schemaParser.ts index 54c1581..8dcbb2a 100644 --- a/packages/shared/src/schemaParser.ts +++ b/packages/shared/src/schemaParser.ts @@ -433,3 +433,22 @@ export function findXGtsRefPath(content: any, refValue: string): string | null { return walk(content, '') } + +/** + * Locate the JSON Pointer instancePath to anchor a trait-completeness error + * (OP#13) on. When `traitName` is a required trait declared in this document's + * top-level `x-gts-traits-schema`, point at that specific `required` entry + * (e.g. `/x-gts-traits-schema/required/0`); otherwise fall back to the + * `x-gts-traits-schema` node. Returns null when the document has no local + * `x-gts-traits-schema` (the requirement came from an ancestor in the chain), + * so callers can fall back to `/$id`. + */ +export function findTraitRequiredPath(content: any, traitName?: string): string | null { + const traitSchema = content && typeof content === 'object' ? content['x-gts-traits-schema'] : undefined + if (!traitSchema || typeof traitSchema !== 'object' || Array.isArray(traitSchema)) return null + if (traitName && Array.isArray(traitSchema.required)) { + const idx = traitSchema.required.indexOf(traitName) + if (idx >= 0) return `/x-gts-traits-schema/required/${idx}` + } + return '/x-gts-traits-schema' +} From 73ff4669ba6a4c749fb3b701e91e02c17332d639 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 10:21:55 +0300 Subject: [PATCH 39/57] fix(shared): stop flagging scalar GTS-id-shaped values as missing references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GTS references were harvested purely syntactically: every GTS-id-shaped string in an instance was treated as a reference and checked for existence, regardless of the field's schema. A value that merely looks like a GTS id in a plain data field (e.g. a `format: uuid` string whose value is "gts.x…v1~") was therefore reported as "GTS reference not found" on top of its real format error. Make the existence check schema-aware for instances: - add getInstanceFieldSubschema(), which resolves the subschema governing a value at an instance source path (through properties, array items, and allOf/anyOf/oneOf branches); - add isScalarDataField(), true when a subschema constrains the value with a scalar type/format/enum/const and is NOT a reference (x-gts-ref/$ref); - resolve the instance's schema and skip the existence check for such scalar data fields. Configured GTS id/schema fields (id, gtsIid, type, $schema, ...) are never exempted, and genuine reference fields (x-gts-ref/$ref) still report missing targets. When the schema cannot be resolved locally the previous behaviour is preserved. Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 53 +++++++++++++++++++++++++++-- packages/shared/src/schemaParser.ts | 50 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index c1fe806..d61b53b 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -1,7 +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, findXGtsRefPath, findTraitRequiredPath } from './schemaParser.js' +import { findSchemaPropertyPath, findXGtsRefPath, findTraitRequiredPath, getInstanceFieldSubschema } from './schemaParser.js' import Ajv, { type ValidateFunction, type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' import { GtsModifiers, GtsStore, createJsonEntity } from '@globaltypesystem/gts-ts' @@ -105,6 +105,24 @@ function normalizeToArray(content: any): any[] { return Array.isArray(content) ? content : [content] } +/** + * True when a resolved subschema describes a *scalar data value* rather than a + * GTS reference: it constrains the value with a scalar `type`, a `format`, an + * `enum`, or a `const`, and does NOT declare it as a reference via `x-gts-ref` + * or `$ref`. Used to suppress the syntactic "GTS reference not found" check for + * fields whose GTS-id-shaped value is just data (e.g. a `format: uuid` string). + */ +function isScalarDataField(sub: any): boolean { + if (!sub || typeof sub !== 'object') return false + if (sub['x-gts-ref'] !== undefined || sub['$ref'] !== undefined) return false + const scalarTypes = new Set(['string', 'number', 'integer', 'boolean', 'null']) + const t = sub.type + const hasScalarType = typeof t === 'string' + ? scalarTypes.has(t) + : Array.isArray(t) && t.length > 0 && t.every((x: any) => scalarTypes.has(x)) + return hasScalarType || sub.format !== undefined || sub.enum !== undefined || sub.const !== undefined +} + /** * Reverse-dependency graph, split by how a change propagates. * @@ -629,6 +647,23 @@ export class JsonRegistry { // Check if all GTS references exist in the registry if (entity.gtsRefs && entity.gtsRefs.length > 0) { + // For an instance, resolve its schema so we can tell a genuine GTS + // reference field apart from a scalar data value that merely *looks* like + // a GTS id (e.g. a `format: uuid` string). References are harvested + // syntactically (any GTS-id-shaped string, regardless of field), so + // without this a value like "gts.x…v1~" in a plain typed field + // would be wrongly reported as a missing reference. Only instances carry + // a separate schema to consult; schema-shaped entities keep prior behaviour. + const instanceSchema = (entity instanceof JsonObj && entity.schemaId) + ? this.resolveSchema(entity.schemaId)?.content ?? null + : null + // Configured GTS id/schema fields (id, gtsIid, type, $schema, ...) are + // references by definition and must keep being checked even when a schema + // types them as a plain `string`; only *other* scalar-typed fields are + // exempted below. + const cfg = getGtsConfig(undefined) + const idFieldNames = new Set([...cfg.entity_id_fields, ...cfg.schema_id_fields]) + for (const ref of entity.gtsRefs) { // Skip reference validation for refs inside /examples field in schemas // Match 'examples' at root or after array indices (e.g., allOf[0].examples), but NOT after 'properties' @@ -640,7 +675,21 @@ export class JsonRegistry { if (isInExamples) { continue } - + + // Schema-aware skip: when the instance's schema types this field as a + // scalar data value (a `format`/scalar `type`/`enum`/`const`, and NOT a + // reference via `x-gts-ref`/`$ref`), its GTS-id-shaped value is data, + // not a reference, so its existence must not be checked. Configured + // id/schema fields are never exempted (they are references by design). + const lastSegment = ref.sourcePath.replace(/\[\d+\]/g, '').split('.').pop() || '' + if ( + instanceSchema && + !idFieldNames.has(lastSegment) && + isScalarDataField(getInstanceFieldSubschema(instanceSchema, ref.sourcePath)) + ) { + continue + } + const refExists = this.jsonSchemas.has(ref.id) || this.jsonObjs.has(ref.id) if (!refExists) { this.absentGtsEntities.set(ref.id, createAbsentEntity(ref.id)) diff --git a/packages/shared/src/schemaParser.ts b/packages/shared/src/schemaParser.ts index 8dcbb2a..36751f5 100644 --- a/packages/shared/src/schemaParser.ts +++ b/packages/shared/src/schemaParser.ts @@ -452,3 +452,53 @@ export function findTraitRequiredPath(content: any, traitName?: string): string } return '/x-gts-traits-schema' } + +/** + * Navigate a JSON Schema to the subschema that governs the value at a given + * *instance* source path (dot/bracket notation as produced by the reference + * walker, e.g. "uuidValue", "contact.gtsIid", "items[0].sku"). Follows + * `properties`, array `items` (single-schema or tuple), and searches + * `allOf`/`anyOf`/`oneOf` branches. + * + * Returns the subschema, or null when it cannot be resolved locally (e.g. the + * field is an `additionalProperties` value or is inherited from an ancestor + * schema not present in this document) — callers should treat null + * conservatively rather than assuming the field is unconstrained. + */ +export function getInstanceFieldSubschema(schemaContent: any, sourcePath: string): any | null { + if (!schemaContent || typeof schemaContent !== 'object') return null + if (!sourcePath || sourcePath === 'root') return null + + const segments = sourcePath + .replace(/\[(\d+)\]/g, '.$1') // arr[0] -> arr.0 + .split('.') + .filter(s => s.length > 0) + + function resolveKey(node: any, key: string, depth: number): any | null { + if (!node || typeof node !== 'object' || depth > 20) return null + const isIndex = /^\d+$/.test(key) + if (isIndex) { + if (node.items !== undefined) { + return Array.isArray(node.items) ? (node.items[Number(key)] ?? null) : node.items + } + } else if (node.properties && typeof node.properties === 'object' && node.properties[key] !== undefined) { + return node.properties[key] + } + for (const comb of ['allOf', 'anyOf', 'oneOf'] as const) { + if (Array.isArray(node[comb])) { + for (const branch of node[comb]) { + const res = resolveKey(branch, key, depth + 1) + if (res) return res + } + } + } + return null + } + + let current: any = schemaContent + for (const seg of segments) { + current = resolveKey(current, seg, 0) + if (!current || typeof current !== 'object') return null + } + return current +} From abd4a6fc83a1b91560b9ac3bdfc707a5cbbf494e Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 11:16:14 +0300 Subject: [PATCH 40/57] fix(vscode): make re-scan fully rebuild the GTS store and harden validation Reworks the scan/registry/validation lifecycle so a re-scan can no longer leave stale state behind, and so workspace validation stays responsive. - Re-scan is now a full rebuild. A user-triggered rescan first tears the store down to empty (resetGtsStore: registry, symlink-dedup index, .gitignore cache, cached scan files, and all validation/link diagnostics) and then repopulates from a fresh enumeration, so entities for files or directories removed since the last scan can't survive as stale "ghost" tree entries. refreshGtsFileExplorer reuses the same reset. - registryStore: drop rebuildRegistryIfUnchanged; scans rebuild the registry unconditionally, so a fresh full file set always replaces the previous one instead of being discarded on a revision race. - validation: make validateWorkspaceInBackground single-flight and time-sliced. Overlapping requests coalesce into one follow-up pass (no starvation), the pass yields to the event loop every ~25ms (no multi-second freeze of hovers/tree/typing on large workspaces), and it keeps sweeping until every current entity is validated instead of aborting when the registry changes mid-pass. Removes the unused scope-roots filtering. - helpers: isGtsCandidateFile ignores non-file URI schemes (git:/ quick-diff originals, compare views, untitled buffers) so virtual documents sharing a real file's fsPath can't overwrite the working copy's entities or churn the registry. Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 225 +++++++++++++++------ apps/vscode-extension/src/helpers.ts | 5 + apps/vscode-extension/src/registryStore.ts | 14 -- apps/vscode-extension/src/validation.ts | 130 ++++++++---- 4 files changed, 265 insertions(+), 109 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 39d586e..ace6f93 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, rebuildRegistryIfUnchanged, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry, getRegistry, getRegistryRevision } 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, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents, onValidationCompleted } from './validation' @@ -116,26 +116,93 @@ let viewerPanel: vscode.WebviewPanel | null = null let layoutStorage: RepoLayoutStorage | null = null let hasPerformedInitialScan: boolean = false // Track if initial scan with default file has been done let gtsLinkProvider: GtsLinkProvider | null = null +// The GTS link-format diagnostic collection, kept at module scope so a full +// store reset (resetGtsStore) can clear it from outside `activate`. +let gtsLinkFormatDiagnostics: vscode.DiagnosticCollection | null = null // File the user explicitly requested (context menu / command palette) — consumed by the first scanAndPost let pendingOpenFile: string | null = null // Left-sidebar GTS file browser (tree view + red/green file decorations), shares the same registry as everything else. let gtsExplorer: GtsExplorer | null = null -let workspaceMutationRevision = 0 let fullScanQueue: Promise = Promise.resolve() -type StableScanOperation = (expectedMutationRevision: number) => Promise - -function enqueueStableScan(operation: StableScanOperation): Promise { - const execute = async () => { - while (!await operation(workspaceMutationRevision)) { - await new Promise(resolve => setTimeout(resolve, 50)) - } - } - const run = fullScanQueue.then(execute, execute) +/** Run full scans one at a time, in request order. */ +function enqueueScan(operation: () => Promise): Promise { + const run = fullScanQueue.then(operation, operation) fullScanQueue = run.catch(() => {}) return run } +// Paths changed by live events (editor edits, on-disk changes, renames, closes) +// while a full scan is in flight. The scan's snapshot may predate those events, +// so instead of discarding the whole scan and starting over (which never +// converges in a busy workspace), they are re-synced from their current source +// right after the scan commits. null when no scan is running. +let pathsTouchedDuringScan: Set | null = null + +/** True if the text can hold a GTS id ("gts." canonical or "gts://" URI form). */ +function mayContainGts(text: string): boolean { + return text.includes('gts.') || text.includes('gts://') +} + +/** Parse + index one file's text under `fsPath`, as the canonical path for its physical file. */ +function indexFileText(fsPath: string, text: string): void { + const name = path.basename(fsPath) + let content: any + try { content = parseGtsFileContent(name, text) } catch { content = text } + claimCanonicalPath(fsPath) + indexFileInRegistry(fsPath, name, content) +} + +/** Drop a file's entities from the registry and the symlink-dedup index. */ +function dropFileFromRegistry(fsPath: string): void { + removeFileFromRegistry(fsPath) + forgetIndexedPath(fsPath) +} + +/** + * Bring one file's registry entry in line with its current source of truth: the + * live buffer if it's open, otherwise the file on disk — or drop it if it's + * gone, ignored, or no longer mentions GTS. + */ +async function resyncFile(fsPath: string): Promise { + const openDoc = vscode.workspace.textDocuments.find(d => d.uri.fsPath === fsPath && isGtsCandidateFile(d)) + if (openDoc) { + indexFileText(fsPath, openDoc.getText()) + return + } + const uri = vscode.Uri.file(fsPath) + if (!isGtsScanPath(fsPath) || isIgnoredGtsPath(fsPath) || isUriIgnored(uri) || !fs.existsSync(fsPath)) { + dropFileFromRegistry(fsPath) + return + } + const revision = fileMutationRevisions.get(fsPath) + let text: string + try { + text = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf8') + } catch { + dropFileFromRegistry(fsPath) + return + } + // A live handler indexed a newer version while we were reading; it wins. + if (fileMutationRevisions.get(fsPath) !== revision) return + if (mayContainGts(text)) indexFileText(fsPath, text) + else dropFileFromRegistry(fsPath) +} + +/** + * Re-apply every path touched since tracking (re)started on top of the freshly + * committed registry. With `keepTracking`, recording continues afterwards (used + * between the two scan phases). Returns how many paths were re-synced. + */ +async function resyncPathsTouchedDuringScan(keepTracking: boolean): Promise { + const touched = pathsTouchedDuringScan + pathsTouchedDuringScan = keepTracking ? new Set() : null + if (!touched || touched.size === 0) return 0 + console.log(`[GTS] Re-syncing ${touched.size} file(s) changed during the scan`) + for (const fsPath of touched) await resyncFile(fsPath) + return touched.size +} + function getNonce(): string { let text = '' const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' @@ -145,13 +212,20 @@ function getNonce(): string { return text } -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 scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: boolean = false, refreshFilePath?: string | null, reset: boolean = false): Promise { + await enqueueScan(async () => { + if (reset) await resetGtsStore() + pathsTouchedDuringScan = new Set() + try { + await scanAndPostPass(includeGlob, isInitialScan, refreshFilePath) + } finally { + pathsTouchedDuringScan = null + } + }) } -async function scanAndPostPass(includeGlob: string, isInitialScan: boolean, refreshFilePath: string | null | undefined, expectedMutationRevision: number): Promise { +async function scanAndPostPass(includeGlob: string, isInitialScan: boolean, refreshFilePath: string | null | undefined): Promise { const hasViewer = viewerPanel !== null - const expectedRegistryRevision = getRegistryRevision() try { let selectedFilePath: string | null = null @@ -197,7 +271,7 @@ async function scanAndPostPass(includeGlob: string, isInitialScan: boolean, refr // Quick pre-filter: a file with no GTS-like substring cannot hold a GTS id. // Check for both "gts." (canonical form) and "gts://" (URI form) so that // malformed identifiers like "gts://gtx.foo.bar.v1~" are still surfaced. - if (!text.includes('gts.') && !text.includes('gts://')) { + if (!mayContainGts(text)) { continue } const name = path.basename(uri.fsPath) @@ -223,9 +297,8 @@ async function scanAndPostPass(includeGlob: string, isInitialScan: boolean, refr // Update the shared, persistent, index-only registry (used by decorations, // links, hovers and as validation resolution context). This is cheap. - if (workspaceMutationRevision !== expectedMutationRevision) return false - const registry = await rebuildRegistryIfUnchanged(files, expectedRegistryRevision, DEFAULT_GTS_CONFIG) - if (!registry) return false + const registry = await rebuildRegistry(files, DEFAULT_GTS_CONFIG) + await resyncPathsTouchedDuringScan(false) if (selectedFilePath) { (registry as any).setDefaultFile?.(selectedFilePath) } @@ -280,12 +353,11 @@ async function scanAndPostPass(includeGlob: string, isInitialScan: boolean, refr void validateOpenDocument(doc) } }) - return workspaceMutationRevision === expectedMutationRevision } catch (error: any) { + console.error('[GTS] Viewer scan failed:', error) if (hasViewer) { viewerPanel!.webview.postMessage({ type: 'gts-scan-error', detail: { error: error.message || String(error) } }) } - return true } } @@ -296,6 +368,7 @@ export async function activate(context: vscode.ExtensionContext) { // Create diagnostic collection for GTS validation const gtsDiagnostics = vscode.languages.createDiagnosticCollection('gts-link-format') + gtsLinkFormatDiagnostics = gtsDiagnostics context.subscriptions.push(gtsDiagnostics) // Initialize and register GTS link provider for clickable GTS IDs @@ -376,6 +449,11 @@ export async function activate(context: vscode.ExtensionContext) { for (const uri of event.files) { if (isGtsScanPath(uri.fsPath)) onDiskFileDeleted(uri) } + }), + // Closing a document discards unsaved edits (validation.ts reindexes it from + // disk). If a scan read the old buffer, re-sync the file after it commits. + vscode.workspace.onDidCloseTextDocument(doc => { + if (isGtsCandidateFile(doc)) pathsTouchedDuringScan?.add(doc.uri.fsPath) }) ) @@ -407,7 +485,6 @@ export async function activate(context: vscode.ExtensionContext) { const gitignoreWatcher = vscode.workspace.createFileSystemWatcher('**/.gitignore') context.subscriptions.push(gitignoreWatcher) const onGitignoreChanged = () => { - workspaceMutationRevision++ resetWorkspaceIgnore() void performInitialScan() } @@ -546,7 +623,7 @@ async function readGtsCandidateFiles( // Quick pre-filter: a file with no GTS-like substring cannot hold a GTS id. // Check for both "gts." (canonical form) and "gts://" (URI form) so that // malformed identifiers like "gts://gtx.foo.bar.v1~" are still surfaced. - if (!text.includes('gts.') && !text.includes('gts://')) continue + if (!mayContainGts(text)) continue const name = path.basename(uri.fsPath) let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } @@ -565,32 +642,58 @@ function revalidateOpenDocs(): void { }) } -async function refreshGtsFileExplorer(linkDiagnostics: vscode.DiagnosticCollection): Promise { - workspaceMutationRevision++ +/** + * Tear the GTS store all the way down to empty: drop every registry entity, + * the symlink-dedup index, the .gitignore cache, cached scan files, and all + * validation/link diagnostics, then repaint the (now empty) tree and + * decorations. A subsequent scan repopulates everything from a fresh + * enumeration, so entities for files/directories removed since the last scan + * cannot survive as stale ("ghost") entries. Does NOT itself scan. + */ +async function resetGtsStore(): Promise { fileMutationRevisions.clear() if (changeTimer) clearTimeout(changeTimer) if (externalChangeTimer) clearTimeout(externalChangeTimer) changeTimer = null externalChangeTimer = null preEditIdsByPath.clear() - pendingOpenFile = null realPathIndex.clear() resetWorkspaceIgnore() setLastScanFiles([]) await rebuildRegistry([], DEFAULT_GTS_CONFIG) resetValidationDiagnostics() - linkDiagnostics.clear() + gtsLinkFormatDiagnostics?.clear() gtsExplorer?.reset() await gtsLinkProvider?.refresh() - await performInitialScan() } -async function performInitialScan(): Promise { - await enqueueStableScan(performInitialScanPass) +async function refreshGtsFileExplorer(_linkDiagnostics: vscode.DiagnosticCollection): Promise { + pendingOpenFile = null + await performInitialScan(true) } -async function performInitialScanPass(expectedMutationRevision: number): Promise { - const expectedRegistryRevision = getRegistryRevision() +/** + * Queue a full two-phase workspace scan (optionally from an emptied store, so + * nothing from before can survive). Shows progress on the GTS files view. + */ +async function performInitialScan(reset: boolean = false): Promise { + await vscode.window.withProgress( + { location: { viewId: 'gts-kit.fileExplorer' }, title: 'Scanning GTS files' }, + () => enqueueScan(async () => { + // Reset inside the queue so it can't interleave with a scan in flight. + if (reset) await resetGtsStore() + pathsTouchedDuringScan = new Set() + try { + await performInitialScanPass() + } finally { + pathsTouchedDuringScan = null + } + }) + ) +} + +async function performInitialScanPass(): Promise { + const startTime = Date.now() try { // Load .gitignore rules first so both phases permanently exclude ignored // files/folders (at enumeration time via globs, plus an authoritative @@ -628,9 +731,9 @@ async function performInitialScanPass(expectedMutationRevision: number): Promise 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 + const registry = await rebuildRegistry(files1, DEFAULT_GTS_CONFIG) + // Keep recording: phase 2 below may also race with live changes. + await resyncPathsTouchedDuringScan(true) setLastScanFiles(files1) console.log(`[GTS] Phase 1 registry: ${registry.jsonSchemas.size} schemas, ${registry.jsonObjs.size} objects (${files1.length} GTS files)`) gtsExplorer?.refresh() @@ -652,20 +755,19 @@ async function performInitialScanPass(expectedMutationRevision: number): Promise // physical file we've already taken (the realPathIndex still holds phase 1). const phase2Prefiltered = allUris.filter(uri => !phase1Paths.has(uri.fsPath) && !isUriIgnored(uri, ignoreMatcher)) const phase2Uris = dedupeUrisByRealPath(phase2Prefiltered) - if (phase2Uris.length > 0) { - const files2 = await readGtsCandidateFiles(phase2Uris) - if (workspaceMutationRevision !== expectedMutationRevision) return false - if (files2.length > 0) { - for (const f of files2) indexFileInRegistry(f.path, f.name, f.content) - setLastScanFiles([...files1, ...files2]) - gtsExplorer?.refresh() - await gtsLinkProvider?.refresh() - await validateWorkspaceInBackground() - revalidateOpenDocs() - } - console.log(`[GTS] Phase 2: merged ${files2.length} GTS files (of ${phase2Uris.length} deferred)`) + const files2 = phase2Uris.length > 0 ? await readGtsCandidateFiles(phase2Uris) : [] + for (const f of files2) indexFileInRegistry(f.path, f.name, f.content) + if (files2.length > 0) setLastScanFiles([...files1, ...files2]) + console.log(`[GTS] Phase 2: merged ${files2.length} GTS files (of ${phase2Uris.length} deferred)`) + const resynced = await resyncPathsTouchedDuringScan(false) + if (files2.length > 0 || resynced > 0) { + gtsExplorer?.refresh() + await gtsLinkProvider?.refresh() + await validateWorkspaceInBackground() + revalidateOpenDocs() } - return workspaceMutationRevision === expectedMutationRevision + const finalRegistry = getRegistry() + console.log(`[GTS] Scan complete in ${Date.now() - startTime}ms: ${finalRegistry?.jsonFiles.size ?? 0} GTS files, ${finalRegistry?.invalidFiles.size ?? 0} unparsable`) } catch (error) { console.error('[GTS] Initial scan error:', error) throw error @@ -767,13 +869,16 @@ async function onDiskFileChanged(uri: vscode.Uri): Promise { 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) + // Same pre-filter as the full scan, so a changed non-GTS JSON file (e.g. a + // broken build artifact) never shows up in the tree, and a file whose GTS + // content was removed drops out of it. + if (mayContainGts(text)) { + // Keep one entry per physical file even when reached via a symlinked path. + indexFileText(fsPath, text) + } else { + dropFileFromRegistry(fsPath) + } gtsExplorer?.refresh() } catch (e) { console.error('[GTS] Failed to reindex changed file from disk:', fsPath, e) @@ -798,7 +903,7 @@ let externalChangeTimer: NodeJS.Timeout | null = null const fileMutationRevisions = new Map() function beginFileMutation(fsPath: string): number { - workspaceMutationRevision++ + pathsTouchedDuringScan?.add(fsPath) const revision = (fileMutationRevisions.get(fsPath) || 0) + 1 fileMutationRevisions.set(fsPath, revision) return revision @@ -971,10 +1076,12 @@ function openViewer(context: vscode.ExtensionContext, resource?: vscode.Uri) { try { const include: string = message.options?.include || GTS_SCAN_GLOB const isInitialScan = !hasPerformedInitialScan - if (isInitialScan) { - hasPerformedInitialScan = true - } - await scanAndPost(include, isInitialScan) + hasPerformedInitialScan = true + // A user-triggered rescan must rebuild the whole GTS store from + // scratch. Reset to empty first (inside the scan queue) so entities + // for files/directories removed since the last scan can't linger as + // stale entries; the scan then repopulates from a fresh enumeration. + await scanAndPost(include, isInitialScan, undefined, !isInitialScan) } catch (error: any) { viewerPanel!.webview.postMessage({ type: 'gts-scan-error', detail: { error: error.message || String(error) } }) } diff --git a/apps/vscode-extension/src/helpers.ts b/apps/vscode-extension/src/helpers.ts index 3dbecd1..58835e3 100644 --- a/apps/vscode-extension/src/helpers.ts +++ b/apps/vscode-extension/src/helpers.ts @@ -2,6 +2,11 @@ import * as vscode from 'vscode' import { isGtsCandidateFileName } from '@gts/shared' export function isGtsCandidateFile(document: vscode.TextDocument): boolean { + // Only real on-disk files belong in the workspace registry. Virtual documents + // (git:/ quick-diff originals, compare views, untitled buffers) share the real + // file's fsPath but hold different content; indexing them would overwrite the + // working copy's entities and churn the registry on every file open. + if (document.uri.scheme !== 'file') return false return document.languageId === 'json' || document.languageId === 'jsonc' || isGtsCandidateFileName(document.fileName) diff --git a/apps/vscode-extension/src/registryStore.ts b/apps/vscode-extension/src/registryStore.ts index 08c7fcd..18b0d00 100644 --- a/apps/vscode-extension/src/registryStore.ts +++ b/apps/vscode-extension/src/registryStore.ts @@ -46,20 +46,6 @@ export async function rebuildRegistry( 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 -} - /** Incrementally upsert a single file's entities into the shared registry. */ export function indexFile(path: string, name: string, content: any): void { if (!registry) return diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index 5c94afe..1e31b1c 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, getRegistryRevision, rebuildRegistry, indexFile } from './registryStore' +import { getRegistry, rebuildRegistry, indexFile } from './registryStore' import { isGtsCandidateFile } from './helpers' let diagnosticCollection: vscode.DiagnosticCollection @@ -25,17 +25,6 @@ export function onValidationCompleted(listener: (uri: vscode.Uri) => void): vsco 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) { - const rel = path.relative(root, filePath) - if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) { - return true - } - } - return false -} - /** * Convert validation errors to VSCode diagnostics */ @@ -631,28 +620,102 @@ export async function validateOpenDocument(document: vscode.TextDocument) { } } +// Workspace validation is single-flight: requests made while a pass is running +// coalesce into one follow-up pass instead of cancelling the running one, so a +// steady stream of edits/file events can no longer starve it. +let workspaceValidationRunning = false +let workspaceValidationWaiters: Array<() => void> = [] + +// Max time a pass validates before yielding back to the extension host's event +// loop. Entity validation never yields on its own (it's all microtasks), so +// without this a large workspace freezes hovers/tree/typing for seconds. +const WORKSPACE_VALIDATION_SLICE_MS = 25 + +function yieldToEventLoop(): Promise { + return new Promise(resolve => setImmediate(resolve)) +} + /** * Validate all indexed entities and publish coarse diagnostics for files that are * not currently open in an editor. This makes unopened invalid files visible in * Explorer/Problems without replacing precise in-editor diagnostics. + * + * Resolves once a complete pass that started after this call has published. */ -export async function validateWorkspaceInBackground(scopeRoots?: string[]): Promise { +export function validateWorkspaceInBackground(): Promise { + const published = new Promise(resolve => workspaceValidationWaiters.push(resolve)) + if (!workspaceValidationRunning) void drainWorkspaceValidation() + return published +} + +async function drainWorkspaceValidation(): Promise { + workspaceValidationRunning = true + try { + while (workspaceValidationWaiters.length > 0) { + const waiters = workspaceValidationWaiters + workspaceValidationWaiters = [] + // A pass is only abandoned when the registry itself was replaced (full + // rescan) or reset; rerun against the new one so waiters still get a + // complete, published result. + let complete = false + while (!complete) { + try { + complete = await runWorkspaceValidationPass() + } catch (error) { + console.error('[GTS Validation] Workspace validation failed:', error) + complete = true + } + } + for (const resolve of waiters) resolve() + } + } finally { + workspaceValidationRunning = false + } +} + +/** One full validation pass. Returns false if it was superseded and must rerun. */ +async function runWorkspaceValidationPass(): Promise { const registry = getRegistry() - if (!registry || !workspaceDiagnosticCollection) return - const validationGeneration = ++workspaceValidationGeneration - const registryRevision = getRegistryRevision() + if (!registry || !workspaceDiagnosticCollection) return true + const validationGeneration = workspaceValidationGeneration + const isSuperseded = () => + getRegistry() !== registry || workspaceValidationGeneration !== validationGeneration + const startTime = Date.now() + + // Re-indexing a file replaces its entity objects, so rather than aborting when + // the registry changes mid-pass, keep sweeping until every *current* entity has + // been validated. Edits that land during the pass are picked up by the next + // sweep; replaced entities are skipped. + const validated = new Set() + let sliceStart = Date.now() + for (;;) { + const pending = [...registry.jsonSchemas.values(), ...registry.jsonObjs.values()] + .filter(entity => !validated.has(entity)) + if (pending.length === 0) break + for (const entity of pending) { + validated.add(entity) + const isCurrent = registry.jsonSchemas.get(entity.id) === entity || registry.jsonObjs.get(entity.id) === entity + if (!isCurrent) continue + await registry.validateEntity(entity) + if (Date.now() - sliceStart >= WORKSPACE_VALIDATION_SLICE_MS) { + await yieldToEventLoop() + if (isSuperseded()) return false + sliceStart = Date.now() + } + } + } + if (isSuperseded()) return false + // Collect from the registry's current state (open documents get precise + // diagnostics from validateOpenDocument instead). const openPaths = new Set() for (const doc of vscode.workspace.textDocuments) { - if (doc.uri.scheme === 'file' && isGtsCandidateFile(doc)) { - openPaths.add(doc.uri.fsPath) - } + if (isGtsCandidateFile(doc)) openPaths.add(doc.uri.fsPath) } const diagnosticsByPath = new Map() const addError = (filePath: string, error: ValidationError) => { if (openPaths.has(filePath)) return - if (!isPathUnderAnyRoot(filePath, scopeRoots)) return const diagnostics = diagnosticsByPath.get(filePath) || [] const diagnostic = new vscode.Diagnostic( new vscode.Range(0, 0, 0, 1), @@ -671,29 +734,24 @@ export async function validateWorkspaceInBackground(scopeRoots?: string[]): Prom for (const error of errors) addError(invalidFile.path, error) } - // Validate all indexed entities against current registry context. - const entities = [...registry.jsonSchemas.values(), ...registry.jsonObjs.values()] - for (const entity of entities) { - await registry.validateEntity(entity) - if ( - workspaceValidationGeneration !== validationGeneration || - getRegistryRevision() !== registryRevision - ) return + for (const entity of [...registry.jsonSchemas.values(), ...registry.jsonObjs.values()]) { if (!entity.file?.path) continue - const errors = entity.validation?.errors || [] - for (const error of errors) addError(entity.file.path, error) + for (const error of entity.validation?.errors || []) addError(entity.file.path, error) } - if ( - workspaceValidationGeneration !== validationGeneration || - getRegistryRevision() !== registryRevision - ) return - - const entries: Array<[vscode.Uri, vscode.Diagnostic[]]> = [] + // Replace the collection's full contents. `set(entries)` only touches the + // listed files, so files that became valid must be cleared explicitly or they + // keep stale red markers. + const entries: Array<[vscode.Uri, vscode.Diagnostic[] | undefined]> = [] + workspaceDiagnosticCollection.forEach(uri => { + if (!diagnosticsByPath.has(uri.fsPath)) entries.push([uri, undefined]) + }) for (const [filePath, diagnostics] of diagnosticsByPath.entries()) { entries.push([vscode.Uri.file(filePath), diagnostics]) } workspaceDiagnosticCollection.set(entries) + console.log(`[GTS Validation] Workspace pass: ${validated.size} entities, ${diagnosticsByPath.size} file(s) with errors (${Date.now() - startTime}ms)`) + return true } /** From 1877fac11d3a6cf6508969157c69b10f3f7682c2 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 11:34:21 +0300 Subject: [PATCH 41/57] fix(vscode): accurate file statuses, folder-level sync and safe hovers Fixes files showing green in the GTS tree until opened, stale entries after folder deletes/renames, and a command-injection hole in hovers. - validation: the workspace pass iterated the id-keyed jsonSchemas/jsonObjs maps, which hold one entity per id (last indexed file wins). Any entity whose id is also defined in another file was never validated, so its file stayed green until opened. With gts-spec/gts-test-examples and a second copy of gts-test-examples in the workspace, 1172 of 2259 entities were skipped this way. The pass now walks jsonFileSchemas/jsonFileObjs, i.e. every entity of every indexed file. - linkProvider (security): hovers set isTrusted = true, enabling every command: link, while escapeMarkdown left backslashes unescaped, so file content such as `\[x\](command:...)` rendered as a live link that runs an arbitrary VS Code command on click. Hovers now enable only gts.replaceGtsId, and escapeMarkdown escapes all CommonMark-escapable punctuation including the backslash. - extension: folder deletes and renames are reported by VS Code as a single event for the folder, which the file-extension checks and the GTS glob watcher never matched, leaving every nested file indexed. In-IDE and on-disk folder deletes/renames now drop everything indexed under the folder, and folders that appear (created, moved in, renamed) are indexed. Folder-create bursts are batched so each new top-level folder is scanned once. - extension: the viewer ran its own fast-pass-only scan and then replaced the shared registry with it, so files only found by phase 2 (build/, vendor/, out/... when not gitignored) disappeared from the tree and from validation on every edit while the viewer was open. The viewer is now fed from the shared registry; its rescan button runs the regular full scan. - extension: editor changes used one shared debounce timer, so editing a second file within 500ms cancelled the first file's validation and dependents revalidation. Timers are now per file, with a separate debounce for viewer refreshes. - linkProvider: "Invalid GTS ID format" diagnostics used source 'gts' while the tree, badge and file decorations count only 'GTS', so such files showed green. They now use 'GTS' and are cleared when the document closes instead of going stale. Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 354 ++++++++++++---------- apps/vscode-extension/src/linkProvider.ts | 44 ++- apps/vscode-extension/src/validation.ts | 33 +- 3 files changed, 245 insertions(+), 186 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index ace6f93..d42a373 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -212,20 +212,23 @@ function getNonce(): string { return text } -async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: boolean = false, refreshFilePath?: string | null, reset: boolean = false): Promise { - await enqueueScan(async () => { - if (reset) await resetGtsStore() - pathsTouchedDuringScan = new Set() - try { - await scanAndPostPass(includeGlob, isInitialScan, refreshFilePath) - } finally { - pathsTouchedDuringScan = null - } - }) +/** + * Push the shared registry to the GTS Viewer webview. With `fullRescan` the + * workspace is first rescanned from disk (from an emptied store). Either way the + * viewer is fed from the same registry as the file tree, decorations and + * diagnostics, so they always show the same file set. (The viewer used to run + * its own fast-pass-only scan and then *replace* the shared registry with it, + * dropping every phase-2 file from the tree on each edit while it was open.) + */ +async function scanAndPost(refreshFilePath?: string | null, fullRescan: boolean = false): Promise { + if (fullRescan) await performInitialScan(true) + // Queued behind any scan in flight, so the viewer never gets a partial registry. + await enqueueScan(() => postRegistryToViewer(refreshFilePath)) } -async function scanAndPostPass(includeGlob: string, isInitialScan: boolean, refreshFilePath: string | null | undefined): Promise { - const hasViewer = viewerPanel !== null +async function postRegistryToViewer(refreshFilePath: string | null | undefined): Promise { + const panel = viewerPanel + if (!panel) return try { let selectedFilePath: string | null = null @@ -239,125 +242,43 @@ async function scanAndPostPass(includeGlob: string, isInitialScan: boolean, refr ? activeDoc.uri.fsPath : null } + console.log('[GTS Extension] postRegistryToViewer: selectedFilePath=', selectedFilePath) - console.log('[GTS Extension] scanAndPost: selectedFilePath=', selectedFilePath) - const include = includeGlob - // Skip build-output/dependency dirs + gitignored paths; the substring filter - // below drops the remaining non-GTS files so we only parse files that mention - // GTS. - const { matcher: ignoreMatcher, excludeGlobs: ignoreGlobs } = await getWorkspaceIgnore() - const exclude = combineExcludeGlobs(FAST_EXCLUDE_GLOB, ignoreGlobs) - 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() - let progressShown = false - - const files: Array<{ path: string; name: string; content: any }> = [] - let processed = 0 - - for (const uri of uris) { - try { - // Belt-and-suspenders: skip anything gitignored the glob missed. - if (isUriIgnored(uri, ignoreMatcher)) { - continue - } - const data = await vscode.workspace.fs.readFile(uri) - const text = Buffer.from(data).toString('utf8') - // Quick pre-filter: a file with no GTS-like substring cannot hold a GTS id. - // Check for both "gts." (canonical form) and "gts://" (URI form) so that - // malformed identifiers like "gts://gtx.foo.bar.v1~" are still surfaced. - if (!mayContainGts(text)) { - continue - } - const name = path.basename(uri.fsPath) - try { - const content = parseGtsFileContent(name, text) - files.push({ path: uri.fsPath, name, content }) - } catch (e) { - files.push({ path: uri.fsPath, name, content: text }) - } - } catch (e) { - } finally { - processed++ - const elapsed = Date.now() - startTime - if (hasViewer && !progressShown && elapsed > 500) { - progressShown = true - viewerPanel!.webview.postMessage({ type: 'gts-scan-started', detail: { total } }) - } - if (hasViewer && progressShown && (processed % 50 === 0 || processed === total)) { - viewerPanel!.webview.postMessage({ type: 'gts-scan-progress', detail: { processed, total } }) - } - } - } - - // 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) - await resyncPathsTouchedDuringScan(false) - if (selectedFilePath) { + const registry = getRegistry() + // Every indexed file: files holding GTS entities, plus files that failed to + // parse (their JsonFile keeps the raw text as content). + const files: Array<{ path: string; name: string; content: any }> = registry + ? [...registry.jsonFiles.values(), ...registry.invalidFiles.values()] + .map(f => ({ path: f.path, name: f.name, content: f.content })) + : [] + if (registry && selectedFilePath) { (registry as any).setDefaultFile?.(selectedFilePath) } - gtsExplorer?.refresh() // Send scan result with default file path so the webview can compute initial selection - if (hasViewer) { - viewerPanel!.webview.postMessage({ type: 'gts-scan-result', detail: { files, defaultFilePath: selectedFilePath } }) - } - try { setLastScanFiles(files) } catch {} - - // Refresh the link provider (repaint from the shared registry) - if (gtsLinkProvider) { - try { - await gtsLinkProvider.refresh() - } catch (e) { - console.error('[GTS] Error refreshing link provider:', e) - } - } + panel.webview.postMessage({ type: 'gts-scan-result', detail: { files, defaultFilePath: selectedFilePath } }) // The viewer needs full Ajv validation results for every entity. This is the // only consumer that pays that cost, and only while the panel is open. - if (hasViewer) { - try { - const vreg = new JsonRegistry() - await vreg.ingestFiles(files, DEFAULT_GTS_CONFIG) - 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) } }) - } + try { + const vreg = new JsonRegistry() + await vreg.ingestFiles(files, DEFAULT_GTS_CONFIG) + 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 } + panel.webview.postMessage({ type: 'gts-validation-result', detail: payload }) + } catch (ve: any) { + panel.webview.postMessage({ type: 'gts-validation-error', detail: { error: ve?.message || String(ve) } }) } // After scan + validation updates are delivered, instruct the webview to refresh diagrams for the updated file - if (hasViewer && refreshFilePath) { - try { - viewerPanel!.webview.postMessage({ type: 'gts-refresh-layout', detail: { filePath: refreshFilePath } }) - } catch {} + if (refreshFilePath) { + panel.webview.postMessage({ type: 'gts-refresh-layout', detail: { filePath: refreshFilePath } }) } - - // Publish workspace-wide file diagnostics for unopened files, then refresh - // open-document diagnostics with precise ranges. - await validateWorkspaceInBackground() - - // Re-validate all open documents now that we have the full registry - console.log('[GTS] Re-validating all open documents...') - vscode.workspace.textDocuments.forEach(doc => { - if (isGtsCandidateFile(doc)) { - void validateOpenDocument(doc) - } - }) } catch (error: any) { - console.error('[GTS] Viewer scan failed:', error) - if (hasViewer) { - viewerPanel!.webview.postMessage({ type: 'gts-scan-error', detail: { error: error.message || String(error) } }) - } + console.error('[GTS] Posting registry to viewer failed:', error) + panel.webview.postMessage({ type: 'gts-scan-error', detail: { error: error.message || String(error) } }) } } @@ -420,7 +341,29 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( gtsWatcher.onDidCreate(uri => { void onDiskFileChanged(uri) }), gtsWatcher.onDidChange(uri => { void onDiskFileChanged(uri) }), - gtsWatcher.onDidDelete(uri => { onDiskFileDeleted(uri) }) + gtsWatcher.onDidDelete(uri => { onDiskPathRemoved(uri) }) + ) + + // Folder-level on-disk changes (rm -rf, mv, git checkout of a directory). + // VS Code reports a folder delete/move as ONE event for the folder itself — + // never for the files inside — and the GTS glob above never matches a folder, + // so without this every file under a removed folder stays indexed (ghost + // entries in the tree and in reference resolution) until a full refresh. + const folderWatcher = vscode.workspace.createFileSystemWatcher('**/*', false, true, false) + context.subscriptions.push( + folderWatcher, + folderWatcher.onDidCreate(uri => { + if (isGtsScanPath(uri.fsPath)) return // file: handled by gtsWatcher + if (isIgnoredGtsPath(uri.fsPath + path.sep) || isUriIgnored(uri)) return + void (async () => { + if (await isDirectory(uri)) scheduleFolderScan(uri.fsPath) + })() + }), + folderWatcher.onDidDelete(uri => { + if (isGtsScanPath(uri.fsPath)) return // file: handled by gtsWatcher + if (isIgnoredGtsPath(uri.fsPath + path.sep)) return + onDiskPathRemoved(uri) + }) ) // The recursive workspace watcher above does NOT follow directory symlinks @@ -445,30 +388,35 @@ export async function activate(context: vscode.ExtensionContext) { } } }), + // In-IDE deletes; a deleted folder arrives as a single folder URI. vscode.workspace.onDidDeleteFiles(event => { - for (const uri of event.files) { - if (isGtsScanPath(uri.fsPath)) onDiskFileDeleted(uri) - } + for (const uri of event.files) onDiskPathRemoved(uri) }), // Closing a document discards unsaved edits (validation.ts reindexes it from // disk). If a scan read the old buffer, re-sync the file after it commits. vscode.workspace.onDidCloseTextDocument(doc => { - if (isGtsCandidateFile(doc)) pathsTouchedDuringScan?.add(doc.uri.fsPath) + if (!isGtsCandidateFile(doc)) return + pathsTouchedDuringScan?.add(doc.uri.fsPath) + // Link-format markers are computed from the live buffer; drop them so a + // closed file doesn't keep stale ones. + gtsLinkProvider?.clearDocument(doc.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 - // delete+create through the watcher and are handled above.) + // new path would otherwise stay unindexed. A renamed/moved folder arrives as a + // single folder URI pair, so everything under it is moved over. (External + // renames arrive as delete+create through the watchers and are handled above.) context.subscriptions.push( vscode.workspace.onDidRenameFiles(async event => { for (const { oldUri, newUri } of event.files) { - beginFileMutation(oldUri.fsPath) - removeFileFromRegistry(oldUri.fsPath) - forgetIndexedPath(oldUri.fsPath) + dropIndexedPath(oldUri.fsPath) if (isIgnoredGtsPath(newUri.fsPath)) continue - if (!isGtsScanPath(newUri.fsPath)) continue + if (!isGtsScanPath(newUri.fsPath)) { + if (await isDirectory(newUri)) await indexFolder(newUri) + continue + } const openDoc = vscode.workspace.textDocuments.find(d => d.uri.fsPath === newUri.fsPath) if (openDoc) { handleFileChange(openDoc, 0) @@ -476,6 +424,8 @@ export async function activate(context: vscode.ExtensionContext) { await onDiskFileChanged(newUri) } } + // Repaint even when nothing was re-indexed (e.g. renamed to a non-GTS name). + gtsExplorer?.refresh() scheduleExternalChangeSettle() }) ) @@ -652,9 +602,8 @@ function revalidateOpenDocs(): void { */ async function resetGtsStore(): Promise { fileMutationRevisions.clear() - if (changeTimer) clearTimeout(changeTimer) + clearChangeTimers() if (externalChangeTimer) clearTimeout(externalChangeTimer) - changeTimer = null externalChangeTimer = null preEditIdsByPath.clear() realPathIndex.clear() @@ -791,8 +740,19 @@ export async function deactivate() { layoutStorage = null } -// Debounced rescan on change to auto-refresh layout view while typing -let changeTimer: NodeJS.Timeout | null = null +// Per-file debounce timers for editor changes. One shared timer meant editing +// file B within the debounce window cancelled file A's pending validation and +// dependents revalidation, leaving A's markers stale. +const changeTimers = new Map() +// Separate debounce for pushing the (whole) registry to an open viewer. +let viewerRefreshTimer: NodeJS.Timeout | null = null + +function clearChangeTimers(): void { + for (const timer of changeTimers.values()) clearTimeout(timer) + changeTimers.clear() + if (viewerRefreshTimer) clearTimeout(viewerRefreshTimer) + viewerRefreshTimer = null +} function isGtsScanPath(fsPath: string): boolean { return /\.(json|jsonc|gts|ya?ml)$/i.test(fsPath) @@ -838,7 +798,7 @@ async function watchSymlinkedDirs(context: vscode.ExtensionContext): Promise { void onDiskFileChanged(uri) }), watcher.onDidChange(uri => { void onDiskFileChanged(uri) }), - watcher.onDidDelete(uri => { onDiskFileDeleted(uri) }) + watcher.onDidDelete(uri => { onDiskPathRemoved(uri) }) ) console.log('[GTS] Watching symlinked directory:', linkUri.fsPath) } @@ -887,16 +847,75 @@ async function onDiskFileChanged(uri: vscode.Uri): Promise { scheduleExternalChangeSettle() } -/** 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 - beginFileMutation(fsPath) - removeFileFromRegistry(fsPath) - forgetIndexedPath(fsPath) +/** Indexed file paths (GTS files and unparsable ones) strictly under `dirPath`. */ +function indexedPathsUnder(dirPath: string): string[] { + const registry = getRegistry() + if (!registry) return [] + const prefix = dirPath.endsWith(path.sep) ? dirPath : dirPath + path.sep + const out: string[] = [] + for (const p of [...registry.jsonFiles.keys(), ...registry.invalidFiles.keys()]) { + if (p.startsWith(prefix)) out.push(p) + } + return out +} + +/** + * Drop a removed path from the registry: the file itself, or — when it was a + * folder — every indexed file under it. Returns whether anything was indexed. + */ +function dropIndexedPath(fsPath: string): boolean { + // A GTS-named path is always marked, even if not indexed yet, so an in-flight + // onDiskFileChanged read for it can't resurrect it. + const targets = isGtsScanPath(fsPath) ? [fsPath, ...indexedPathsUnder(fsPath)] : indexedPathsUnder(fsPath) + for (const p of targets) { + beginFileMutation(p) + dropFileFromRegistry(p) + } + return targets.length > 0 +} + +/** A file or folder was deleted/renamed-away. Drop its entities from the registry. */ +function onDiskPathRemoved(uri: vscode.Uri): void { + if (!dropIndexedPath(uri.fsPath)) return gtsExplorer?.refresh() scheduleExternalChangeSettle() } +async function isDirectory(uri: vscode.Uri): Promise { + try { + return ((await vscode.workspace.fs.stat(uri)).type & vscode.FileType.Directory) !== 0 + } catch { + return false + } +} + +/** Index every GTS file under a folder that just appeared (created, moved in, renamed). */ +async function indexFolder(uri: vscode.Uri): Promise { + if (isIgnoredGtsPath(uri.fsPath + path.sep) || isUriIgnored(uri)) return + const uris = await vscode.workspace.findFiles(new vscode.RelativePattern(uri, GTS_SCAN_GLOB), ALWAYS_EXCLUDE_GLOB) + // onDiskFileChanged applies the ignore rules and skips files open in an editor. + for (const fileUri of uris) await onDiskFileChanged(fileUri) +} + +// Folders reported as created, batched so a burst (git checkout, unzip) scans +// each new top-level folder once instead of once per nested directory. +const pendingNewFolders = new Set() +let newFolderTimer: NodeJS.Timeout | null = null + +function scheduleFolderScan(fsPath: string): void { + pendingNewFolders.add(fsPath) + if (newFolderTimer) clearTimeout(newFolderTimer) + newFolderTimer = setTimeout(() => { + newFolderTimer = null + const folders = [...pendingNewFolders].sort() + pendingNewFolders.clear() + const roots = folders.filter((f, i) => !folders.slice(0, i).some(r => f.startsWith(r + path.sep))) + void (async () => { + for (const folder of roots) await indexFolder(vscode.Uri.file(folder)) + })() + }, 300) +} + // 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 @@ -912,28 +931,22 @@ function beginFileMutation(fsPath: string): number { function scheduleExternalChangeSettle(): void { if (externalChangeTimer) clearTimeout(externalChangeTimer) externalChangeTimer = setTimeout(() => { - if (viewerPanel) { - // The viewer needs the full authoritative scan (also repaints + revalidates). - void scanAndPost(GTS_SCAN_GLOB, false) - return - } - // No viewer: repaint + refresh workspace diagnostics and then re-validate - // 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. + // Repaint + refresh workspace diagnostics and then re-validate 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() - vscode.workspace.textDocuments.forEach(doc => { - if (isGtsCandidateFile(doc)) void validateOpenDocument(doc) - }) + revalidateOpenDocs() + if (viewerPanel) await scanAndPost() })() }, 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. +// Keyed by fsPath; an entry is removed when that file's debounced revalidation fires. const preEditIdsByPath = new Map>() function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { @@ -968,12 +981,14 @@ function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { gtsLinkProvider.updateDecorations(editor) } - // Debounced + heavier: validate just this document, and (only when the viewer - // panel is open) run the full workspace rescan that feeds the webview. - if (changeTimer) clearTimeout(changeTimer) - changeTimer = setTimeout(() => { + // Debounced + heavier: validate just this document and its dependents, and + // (only when the viewer panel is open) push the updated registry to it. + const pending = changeTimers.get(fsPath) + if (pending) clearTimeout(pending) + changeTimers.set(fsPath, setTimeout(() => { + changeTimers.delete(fsPath) const previousIds = preEditIdsByPath.get(fsPath) - preEditIdsByPath.clear() + preEditIdsByPath.delete(fsPath) void (async () => { await validateOpenDocument(doc) // Re-check everything that depends on this file (derived/instantiated @@ -981,10 +996,14 @@ function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { // reflect the edit, not just this doc. await revalidateDependents(fsPath, previousIds) })() - if (viewerPanel) { - void scanAndPost(GTS_SCAN_GLOB, false, fsPath) - } - }, delayMsec) + }, delayMsec)) + if (viewerPanel) { + if (viewerRefreshTimer) clearTimeout(viewerRefreshTimer) + viewerRefreshTimer = setTimeout(() => { + viewerRefreshTimer = null + void scanAndPost(fsPath) + }, delayMsec) + } } function openViewer(context: vscode.ExtensionContext, resource?: vscode.Uri) { @@ -1074,14 +1093,13 @@ function openViewer(context: vscode.ExtensionContext, resource?: vscode.Uri) { case 'scanWorkspaceJson': { try { - const include: string = message.options?.include || GTS_SCAN_GLOB const isInitialScan = !hasPerformedInitialScan hasPerformedInitialScan = true - // A user-triggered rescan must rebuild the whole GTS store from - // scratch. Reset to empty first (inside the scan queue) so entities - // for files/directories removed since the last scan can't linger as - // stale entries; the scan then repopulates from a fresh enumeration. - await scanAndPost(include, isInitialScan, undefined, !isInitialScan) + // The first request (viewer just opened) is served from the registry + // the background scan already maintains. A later, user-triggered + // rescan rebuilds the whole GTS store from scratch, so entities for + // files/directories removed since the last scan can't linger. + await scanAndPost(undefined, !isInitialScan) } catch (error: any) { viewerPanel!.webview.postMessage({ type: 'gts-scan-error', detail: { error: error.message || String(error) } }) } diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index c9795e1..2359770 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -28,20 +28,28 @@ interface GtsIdReference { } /** - * Escape markdown special characters, especially tildes + * Escape text taken from workspace files (ids, descriptions, error messages) so + * it renders literally in hover markdown. Every ASCII punctuation character that + * CommonMark lets you backslash-escape is escaped — including the backslash + * itself: escaping only `[`/`]` lets `\[x\](command:...)` collapse back into a + * live link, which in a command-enabled hover runs a VS Code command on click. */ function escapeMarkdown(text: string): string { - // Escape tildes and other markdown special characters - return text - .replace(/~/g, '\\~') - .replace(/\*/g, '\\*') - .replace(/_/g, '\\_') - .replace(/\[/g, '\\[') - .replace(/\]/g, '\\]') - .replace(//g, '\\>') + return text.replace(/[\\`*_{}\[\]()<>#+\-.!|~"'&:=]/g, '\\$&') } +/** Render text as an inline code span (backslash escapes don't apply inside one). */ +function codeSpan(text: string): string { + return '`' + text.replace(/`/g, "'") + '`' +} + +/** + * Hovers may only run this extension's own replace command (used by the "Did + * you mean" / "Fix" links). Never `isTrusted = true`, which enables every + * command for any link that ends up in the markdown. + */ +const HOVER_TRUST = { enabledCommands: ['gts.replaceGtsId'] } + /** * Get workspace-relative path from absolute path */ @@ -228,6 +236,14 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove this.diagnosticCollection.clear() } + /** + * Drop this document's link-format diagnostics. They are computed from the + * live buffer only while it is open; once closed they would go stale. + */ + public clearDocument(uri: vscode.Uri): void { + this.diagnosticCollection.delete(uri) + } + /** * Repaint decorations from the current shared registry. * @@ -329,7 +345,9 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove `Invalid GTS ID format: "${ref.rawValue}". Expected pattern: gts.....v[.[~...]]`, vscode.DiagnosticSeverity.Error ) - diagnostic.source = 'gts' + // Same source as the validator's diagnostics: the file tree, badge and + // red/green decorations only count source 'GTS'. + diagnostic.source = 'GTS' diagnostics.push(diagnostic) continue @@ -776,7 +794,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // Create hover content const markdown = new vscode.MarkdownString() - markdown.isTrusted = true + markdown.isTrusted = HOVER_TRUST markdown.supportHtml = false // Malformed gts:// prefix usage - explain the rule and offer a one-click fix. @@ -910,7 +928,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove 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.`) + markdown.appendMarkdown(`This segment derives from ${codeSpan(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 diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index 1e31b1c..b68d466 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -3,6 +3,7 @@ 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 type { JsonRegistry, JsonSchema, JsonObj } from '@gts/shared' import { getLastScanFiles } from './scanStore' import { getRegistry, rebuildRegistry, indexFile } from './registryStore' import { isGtsCandidateFile } from './helpers' @@ -673,6 +674,30 @@ async function drainWorkspaceValidation(): Promise { } } +type RegistryEntity = JsonSchema | JsonObj + +/** + * Every entity defined in every indexed file. Deliberately NOT the id-keyed + * `jsonSchemas`/`jsonObjs` maps: those hold one entity per id (last file + * indexed wins), so any entity whose id is also defined in another file (e.g. + * a copied test-examples folder) would never be validated and its file would + * show green until opened. + */ +function entitiesByFile(registry: JsonRegistry): RegistryEntity[] { + const entities: RegistryEntity[] = [] + for (const list of registry.jsonFileSchemas.values()) entities.push(...list) + for (const list of registry.jsonFileObjs.values()) entities.push(...list) + return entities +} + +/** False once the entity's file has been re-indexed (the entity object was replaced). */ +function isIndexedInItsFile(registry: JsonRegistry, entity: RegistryEntity): boolean { + const filePath = entity.file?.path + if (!filePath) return false + return (registry.jsonFileSchemas.get(filePath) || []).includes(entity as JsonSchema) || + (registry.jsonFileObjs.get(filePath) || []).includes(entity as JsonObj) +} + /** One full validation pass. Returns false if it was superseded and must rerun. */ async function runWorkspaceValidationPass(): Promise { const registry = getRegistry() @@ -689,13 +714,11 @@ async function runWorkspaceValidationPass(): Promise { const validated = new Set() let sliceStart = Date.now() for (;;) { - const pending = [...registry.jsonSchemas.values(), ...registry.jsonObjs.values()] - .filter(entity => !validated.has(entity)) + const pending = entitiesByFile(registry).filter(entity => !validated.has(entity)) if (pending.length === 0) break for (const entity of pending) { validated.add(entity) - const isCurrent = registry.jsonSchemas.get(entity.id) === entity || registry.jsonObjs.get(entity.id) === entity - if (!isCurrent) continue + if (!isIndexedInItsFile(registry, entity)) continue await registry.validateEntity(entity) if (Date.now() - sliceStart >= WORKSPACE_VALIDATION_SLICE_MS) { await yieldToEventLoop() @@ -734,7 +757,7 @@ async function runWorkspaceValidationPass(): Promise { for (const error of errors) addError(invalidFile.path, error) } - for (const entity of [...registry.jsonSchemas.values(), ...registry.jsonObjs.values()]) { + for (const entity of entitiesByFile(registry)) { if (!entity.file?.path) continue for (const error of entity.validation?.errors || []) addError(entity.file.path, error) } From 05cba1581ebbe5dde2f3ef68840a04ea2941e79d Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 13:13:04 +0300 Subject: [PATCH 42/57] fix(vscode): report duplicate and malformed GTS ids everywhere; unify exclusions, multi-root Makes file statuses independent of which files happen to be open, and makes scanning consistent across code paths and workspace folders. - Duplicate ids: every entity whose id is also defined in another file (or again in the same file) now gets a 'gts-duplicate-id' warning naming the other definitions. The registry resolves an id to one definition only (last indexed wins), so references to a duplicated id silently depended on scan order. Warnings show precisely on the id field for open files and coarsely for closed ones; editing or removing one copy revalidates the others. The tree colours warning-only files yellow (errors stay red) and the badge counts errors and warnings. - Malformed ids in closed files: the "Invalid GTS ID format" check ran only in the link provider, i.e. only for open editors, so a closed file went green. Detection now runs on indexed content (new gtsIdFormat.ts, same rules as the link provider) and is reported by the validator for open and closed files alike; the link provider keeps the red chip but no longer publishes a second marker. Files whose only GTS content is malformed are now listed in the tree. - validation: open-file, closed-file and workspace diagnostics are built by one collectFileProblems() (parse errors, entity errors, malformed ids, duplicate warnings), so the three paths always agree. - One exclusion rule: scans, file watchers, folder handling and re-syncs all skip exactly .git/ and .gts-viewer/ plus whatever .gitignore excludes. The watcher used to hard-code node_modules/ and dist/, so a non-gitignored dist/ was indexed by the scan but its changes were never tracked. Build/dependency directories are still only deferred to phase 2. - No silent truncation: phase 2 and the .gitignore enumeration are no longer capped. Phase 1 keeps its latency cap (per folder), logs when it is hit, and phase 2 indexes everything beyond it. - shared: validateEntity builds its result locally and assigns it once. It awaits (Ajv compileAsync, parent validation), so overlapping validations of the same entity reset and appended to the shared entity.validation in interleaved order, duplicating or dropping errors. - Multi-root workspaces: .gitignore rules are loaded and applied per workspace folder (one folder's rules no longer hide paths in another), files are enumerated per folder with that folder's excludes, the tree gets one top-level node per folder, displayed paths are folder-qualified, and viewer layouts are stored in the .gts-viewer/ of the workspace folder that holds the diagram's entity. Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 154 ++++++++------ apps/vscode-extension/src/gitignore.ts | 63 ++++-- apps/vscode-extension/src/gtsExplorer.ts | 93 ++++++--- apps/vscode-extension/src/gtsIdFormat.ts | 48 +++++ apps/vscode-extension/src/linkProvider.ts | 57 +----- apps/vscode-extension/src/registryStore.ts | 28 +++ apps/vscode-extension/src/storage.ts | 33 +++ apps/vscode-extension/src/validation.ts | 221 +++++++++++++-------- packages/shared/src/registry.ts | 50 +++-- 9 files changed, 494 insertions(+), 253 deletions(-) create mode 100644 apps/vscode-extension/src/gtsIdFormat.ts diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index d42a373..c5fef16 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -5,8 +5,8 @@ import { parseGtsFileContent, JsonRegistry, DEFAULT_GTS_CONFIG } from '@gts/shar 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 { getWorkspaceIgnore, resetWorkspaceIgnore, isGitIgnored, type FolderIgnore } from './gitignore' +import { WorkspaceLayoutStorage } from './storage' import { initValidation, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents, onValidationCompleted } from './validation' import { isGtsCandidateFile } from './helpers' import { GtsLinkProvider } from './linkProvider' @@ -16,18 +16,32 @@ import type { LayoutSaveRequest, LayoutTarget, LayoutSnapshot } from '@gts/layou // Glob used for all GTS workspace scans and the on-disk file watcher. const GTS_SCAN_GLOB = '**/*.{json,jsonc,gts,yaml,yml}' -// Directories that never contain user GTS entities and are huge/binary — excluded -// from every scan (both phases). -const ALWAYS_EXCLUDE_GLOB = '**/{.git,.gts-viewer}/**' - -// Fast-pass exclude: also drops build-output / dependency directories so their -// (often enormous) trees aren't even enumerated on the first, latency-sensitive -// pass. Applied at the findFiles level. -const FAST_EXCLUDE_GLOB = '**/{.git,.gts-viewer,node_modules,target,build,out,dist,.next,.nuxt,.svelte-kit,coverage,vendor,bin,obj,__pycache__}/**' - -// Build-output / dependency directories skipped on the fast first pass and only -// looked at on the second (background) pass. -const PHASE2_DIR_RE = /[\\/](node_modules|target|build|out|dist|\.next|\.nuxt|\.svelte-kit|coverage|vendor|bin|obj|__pycache__)[\\/]/i +// Directories that are never indexed by ANY code path: full scans, file +// watchers, folder handling and re-syncs all use this one list. Everything else +// is governed by .gitignore alone, so every path agrees on what belongs in the +// index. (Build-output/dependency directories below are only *deferred* to the +// second scan phase, not excluded: they are indexed unless gitignored.) +const ALWAYS_EXCLUDED_DIRS = ['.git', '.gts-viewer'] +const ALWAYS_EXCLUDE_GLOB = `**/{${ALWAYS_EXCLUDED_DIRS.join(',')}}/**` +const ALWAYS_EXCLUDED_RE = dirSegmentRegExp(ALWAYS_EXCLUDED_DIRS) + +// Build-output / dependency directories. Their (often enormous) trees aren't +// even enumerated on the fast, latency-sensitive first pass; the second +// (background) pass indexes whatever in them isn't gitignored. +const PHASE2_DIRS = ['node_modules', 'target', 'build', 'out', 'dist', '.next', '.nuxt', '.svelte-kit', 'coverage', 'vendor', 'bin', 'obj', '__pycache__'] +const FAST_EXCLUDE_GLOB = `**/{${[...ALWAYS_EXCLUDED_DIRS, ...PHASE2_DIRS].join(',')}}/**` +const PHASE2_DIR_RE = dirSegmentRegExp(PHASE2_DIRS, 'i') + +// Phase 1 enumerates at most this many files per workspace folder to bound its +// latency. Nothing beyond the cap is lost: phase 2 enumerates without a cap and +// indexes whatever phase 1 didn't. +const PHASE1_FILE_CAP = 40000 + +/** Regex matching a path that has one of `dirs` as a directory segment. */ +function dirSegmentRegExp(dirs: string[], flags = ''): RegExp { + const names = dirs.map(d => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + return new RegExp(`(^|[\\\\/])(${names})([\\\\/]|$)`, flags) +} // Framework/runtime files that are valid JSON but essentially never hold GTS // entities. Deferred to the second pass so they don't slow the first one. @@ -54,9 +68,38 @@ function combineExcludeGlobs(base: string, extra: string[]): string { return `{${base},${extra.join(',')}}` } -/** True if the given file URI is gitignored (per the cached matcher). */ -function isUriIgnored(uri: vscode.Uri, matcher = getCachedMatcher()): boolean { - return isIgnoredRel(matcher, vscode.workspace.asRelativePath(uri, false)) +/** True if the path lies inside an always-excluded directory (see ALWAYS_EXCLUDED_DIRS). */ +function isAlwaysExcluded(fsPath: string): boolean { + return ALWAYS_EXCLUDED_RE.test(fsPath) +} + +/** True if the file must never be indexed: always-excluded dir, or gitignored. */ +function isExcludedFile(uri: vscode.Uri, ignores?: Map): boolean { + return isAlwaysExcluded(uri.fsPath) || isGitIgnored(uri, false, ignores) +} + +/** Folder counterpart of isExcludedFile. */ +function isExcludedFolder(uri: vscode.Uri): boolean { + return isAlwaysExcluded(uri.fsPath) || isGitIgnored(uri, true) +} + +/** + * Enumerate GTS-candidate files in every workspace folder. Each folder is + * searched separately with its own .gitignore-derived excludes, so one folder's + * rules never hide paths in another (exclude globs are matched relative to the + * folder). No cap unless `capPerFolder` is given; hitting it is logged. + */ +async function findGtsFiles(ignores: Map, baseExclude: string, capPerFolder?: number): Promise { + const uris: vscode.Uri[] = [] + for (const folder of vscode.workspace.workspaceFolders || []) { + const exclude = combineExcludeGlobs(baseExclude, ignores.get(folder.uri.fsPath)?.excludeGlobs || []) + const found = await vscode.workspace.findFiles(new vscode.RelativePattern(folder, GTS_SCAN_GLOB), exclude, capPerFolder) + if (capPerFolder !== undefined && found.length >= capPerFolder) { + console.warn(`[GTS] Phase 1 hit its ${capPerFolder}-file cap in "${folder.name}"; the rest is indexed by phase 2`) + } + uris.push(...found) + } + return uris } // Maps a file's resolved *real* path -> the workspace path we index it under. @@ -113,12 +156,9 @@ function claimCanonicalPath(fsPath: string): void { } let viewerPanel: vscode.WebviewPanel | null = null -let layoutStorage: RepoLayoutStorage | null = null +let layoutStorage: WorkspaceLayoutStorage | null = null let hasPerformedInitialScan: boolean = false // Track if initial scan with default file has been done let gtsLinkProvider: GtsLinkProvider | null = null -// The GTS link-format diagnostic collection, kept at module scope so a full -// store reset (resetGtsStore) can clear it from outside `activate`. -let gtsLinkFormatDiagnostics: vscode.DiagnosticCollection | 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. @@ -171,7 +211,7 @@ async function resyncFile(fsPath: string): Promise { return } const uri = vscode.Uri.file(fsPath) - if (!isGtsScanPath(fsPath) || isIgnoredGtsPath(fsPath) || isUriIgnored(uri) || !fs.existsSync(fsPath)) { + if (!isGtsScanPath(fsPath) || isExcludedFile(uri) || !fs.existsSync(fsPath)) { dropFileFromRegistry(fsPath) return } @@ -287,13 +327,8 @@ export async function activate(context: vscode.ExtensionContext) { initValidation(context) - // Create diagnostic collection for GTS validation - const gtsDiagnostics = vscode.languages.createDiagnosticCollection('gts-link-format') - gtsLinkFormatDiagnostics = gtsDiagnostics - context.subscriptions.push(gtsDiagnostics) - // Initialize and register GTS link provider for clickable GTS IDs - gtsLinkProvider = new GtsLinkProvider(gtsDiagnostics) + gtsLinkProvider = new GtsLinkProvider() // Repaint editor decorations whenever document validation completes context.subscriptions.push( @@ -354,14 +389,14 @@ export async function activate(context: vscode.ExtensionContext) { folderWatcher, folderWatcher.onDidCreate(uri => { if (isGtsScanPath(uri.fsPath)) return // file: handled by gtsWatcher - if (isIgnoredGtsPath(uri.fsPath + path.sep) || isUriIgnored(uri)) return + if (isExcludedFolder(uri)) return void (async () => { if (await isDirectory(uri)) scheduleFolderScan(uri.fsPath) })() }), folderWatcher.onDidDelete(uri => { if (isGtsScanPath(uri.fsPath)) return // file: handled by gtsWatcher - if (isIgnoredGtsPath(uri.fsPath + path.sep)) return + if (isAlwaysExcluded(uri.fsPath)) return onDiskPathRemoved(uri) }) ) @@ -395,11 +430,7 @@ export async function activate(context: vscode.ExtensionContext) { // Closing a document discards unsaved edits (validation.ts reindexes it from // disk). If a scan read the old buffer, re-sync the file after it commits. vscode.workspace.onDidCloseTextDocument(doc => { - if (!isGtsCandidateFile(doc)) return - pathsTouchedDuringScan?.add(doc.uri.fsPath) - // Link-format markers are computed from the live buffer; drop them so a - // closed file doesn't keep stale ones. - gtsLinkProvider?.clearDocument(doc.uri) + if (isGtsCandidateFile(doc)) pathsTouchedDuringScan?.add(doc.uri.fsPath) }) ) @@ -412,7 +443,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.onDidRenameFiles(async event => { for (const { oldUri, newUri } of event.files) { dropIndexedPath(oldUri.fsPath) - if (isIgnoredGtsPath(newUri.fsPath)) continue + if (isAlwaysExcluded(newUri.fsPath)) continue if (!isGtsScanPath(newUri.fsPath)) { if (await isDirectory(newUri)) await indexFolder(newUri) continue @@ -466,7 +497,7 @@ export async function activate(context: vscode.ExtensionContext) { }), vscode.commands.registerCommand('gts-kit.refreshFileExplorer', async () => { try { - await refreshGtsFileExplorer(gtsDiagnostics) + await refreshGtsFileExplorer() } catch (error: any) { console.error('[GTS] Full refresh failed:', error) vscode.window.showErrorMessage(`Failed to refresh GTS files: ${error?.message || String(error)}`) @@ -611,12 +642,11 @@ async function resetGtsStore(): Promise { setLastScanFiles([]) await rebuildRegistry([], DEFAULT_GTS_CONFIG) resetValidationDiagnostics() - gtsLinkFormatDiagnostics?.clear() gtsExplorer?.reset() await gtsLinkProvider?.refresh() } -async function refreshGtsFileExplorer(_linkDiagnostics: vscode.DiagnosticCollection): Promise { +async function refreshGtsFileExplorer(): Promise { pendingOpenFile = null await performInitialScan(true) } @@ -647,7 +677,7 @@ async function performInitialScanPass(): Promise { // Load .gitignore rules first so both phases permanently exclude ignored // files/folders (at enumeration time via globs, plus an authoritative // matcher for edge cases such as negations and nested ignores). - const { matcher: ignoreMatcher, excludeGlobs: ignoreGlobs } = await getWorkspaceIgnore() + const ignores = await getWorkspaceIgnore() const openPaths = collectOpenGtsPaths() // A full scan re-establishes the canonical set of physical files. @@ -659,8 +689,7 @@ async function performInitialScanPass(): Promise { // Also skip known framework files by name. Currently-open files are always // included and go first so the file you are looking at colors ASAP (an open // file is an explicit user action, so it is coloured even if gitignored). - const phase1Exclude = combineExcludeGlobs(FAST_EXCLUDE_GLOB, ignoreGlobs) - const fastUris = await vscode.workspace.findFiles(GTS_SCAN_GLOB, phase1Exclude, 40000) + const fastUris = await findGtsFiles(ignores, FAST_EXCLUDE_GLOB, PHASE1_FILE_CAP) const phase1Candidates: vscode.Uri[] = [] const phase1Paths = new Set() for (const p of openPaths) { @@ -670,7 +699,7 @@ async function performInitialScanPass(): Promise { for (const uri of fastUris) { if (phase1Paths.has(uri.fsPath)) continue if (isDeferredToPhase2(uri.fsPath)) continue - if (isUriIgnored(uri, ignoreMatcher)) continue + if (isExcludedFile(uri, ignores)) continue phase1Candidates.push(uri) phase1Paths.add(uri.fsPath) } @@ -698,11 +727,10 @@ async function performInitialScanPass(): Promise { // Enumerate the full set (only .git / our cache + gitignore excluded) and // process whatever phase 1 didn't: non-ignored deferred dirs and framework // 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 allUris = await findGtsFiles(ignores, ALWAYS_EXCLUDE_GLOB) // 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 phase2Prefiltered = allUris.filter(uri => !phase1Paths.has(uri.fsPath) && !isExcludedFile(uri, ignores)) const phase2Uris = dedupeUrisByRealPath(phase2Prefiltered) const files2 = phase2Uris.length > 0 ? await readGtsCandidateFiles(phase2Uris) : [] for (const f of files2) indexFileInRegistry(f.path, f.name, f.content) @@ -790,7 +818,7 @@ async function watchSymlinkedDirs(context: vscode.ExtensionContext): Promise d.uri.fsPath === fsPath) @@ -822,8 +845,7 @@ function isOpenInEditor(fsPath: string): boolean { */ async function onDiskFileChanged(uri: vscode.Uri): Promise { const fsPath = uri.fsPath - if (isIgnoredGtsPath(fsPath)) return - if (isUriIgnored(uri)) return + if (isExcludedFile(uri)) return if (isOpenInEditor(fsPath)) return const mutationRevision = beginFileMutation(fsPath) try { @@ -891,8 +913,12 @@ async function isDirectory(uri: vscode.Uri): Promise { /** Index every GTS file under a folder that just appeared (created, moved in, renamed). */ async function indexFolder(uri: vscode.Uri): Promise { - if (isIgnoredGtsPath(uri.fsPath + path.sep) || isUriIgnored(uri)) return - const uris = await vscode.workspace.findFiles(new vscode.RelativePattern(uri, GTS_SCAN_GLOB), ALWAYS_EXCLUDE_GLOB) + if (isExcludedFolder(uri)) return + // Exclude globs are matched relative to the containing workspace folder, so + // that folder's .gitignore globs apply here too. + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri) + const ignoreGlobs = workspaceFolder ? (await getWorkspaceIgnore()).get(workspaceFolder.uri.fsPath)?.excludeGlobs || [] : [] + const uris = await vscode.workspace.findFiles(new vscode.RelativePattern(uri, GTS_SCAN_GLOB), combineExcludeGlobs(ALWAYS_EXCLUDE_GLOB, ignoreGlobs)) // onDiskFileChanged applies the ignore rules and skips files open in an editor. for (const fileUri of uris) await onDiskFileChanged(fileUri) } @@ -1006,6 +1032,14 @@ function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { } } +/** Root of the workspace folder that holds the target entity's file (first folder if unknown). */ +function layoutRootFor(target: Partial): string { + const registry = getRegistry() + const entity = target.id ? (registry?.jsonSchemas.get(target.id) || registry?.jsonObjs.get(target.id)) : undefined + const folder = entity?.file?.path ? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(entity.file.path)) : undefined + return (folder ?? vscode.workspace.workspaceFolders![0]).uri.fsPath +} + function openViewer(context: vscode.ExtensionContext, resource?: vscode.Uri) { // If viewer already exists, just reveal it (do not change selection or default file) if (viewerPanel) { @@ -1029,16 +1063,14 @@ function openViewer(context: vscode.ExtensionContext, resource?: vscode.Uri) { // Store so the first scanAndPost picks it up as defaultFilePath pendingOpenFile = selectedPath || null - // Initialize layout storage with workspace root + // Layouts live in the .gts-viewer/ folder of the workspace folder that holds + // the diagram's entity (multi-root), falling back to the first folder. const workspaceFolders = vscode.workspace.workspaceFolders if (!workspaceFolders || workspaceFolders.length === 0) { vscode.window.showErrorMessage('Please open a workspace folder to use GTS Viewer') return } - - const workspaceRoot = workspaceFolders[0].uri.fsPath - layoutStorage = new RepoLayoutStorage(workspaceRoot) - console.log(`[GTS] Using layout storage at: ${workspaceRoot}/.gts-viewer`) + layoutStorage = new WorkspaceLayoutStorage(layoutRootFor) viewerPanel = vscode.window.createWebviewPanel( 'gtsViewer', diff --git a/apps/vscode-extension/src/gitignore.ts b/apps/vscode-extension/src/gitignore.ts index fdf0935..3b04ed8 100644 --- a/apps/vscode-extension/src/gitignore.ts +++ b/apps/vscode-extension/src/gitignore.ts @@ -3,17 +3,26 @@ import * as path from 'path' import ignore, { type Ignore } from 'ignore' /** - * Loads every .gitignore in the workspace and exposes: + * Loads every .gitignore in the workspace and exposes, per workspace folder: * - `matcher`: an authoritative gitignore matcher (correct semantics, including * negations and nesting) for single-path checks. * - `excludeGlobs`: VS Code exclude globs derived from the same rules, used to * keep `findFiles` from even enumerating ignored (often huge) directories. * - * Nested .gitignore files are rebased so their patterns are workspace-root - * relative, letting one matcher/one glob set cover the whole workspace. + * Nested .gitignore files are rebased so their patterns are relative to their + * workspace folder's root, letting one matcher/one glob set cover the folder. + * Folders are kept apart: in a multi-root workspace one folder's rules must not + * hide same-named paths in another folder. */ -let cached: { matcher: Ignore; excludeGlobs: string[] } | null = null +export interface FolderIgnore { + /** fsPath of the workspace folder these rules are relative to. */ + root: string + matcher: Ignore + excludeGlobs: string[] +} + +let cached: Map | null = null interface RebasedPattern { pattern: string; negated: boolean } @@ -60,33 +69,41 @@ function patternToGlobs(rootRelPattern: string): string[] { : [`**/${p}`, `**/${p}/**`] } -/** Load (and cache) the workspace gitignore matcher + derived exclude globs. */ -export async function getWorkspaceIgnore(): Promise<{ matcher: Ignore; excludeGlobs: string[] }> { +/** Load (and cache) the gitignore rules of every workspace folder, keyed by folder fsPath. */ +export async function getWorkspaceIgnore(): Promise> { if (cached) return cached - const matcher = ignore() - const globs = new Set() + const byFolder = new Map }>() + for (const folder of vscode.workspace.workspaceFolders || []) { + byFolder.set(folder.uri.fsPath, { matcher: ignore(), globs: new Set() }) + } try { - const uris = await vscode.workspace.findFiles('**/.gitignore', '**/{.git,node_modules}/**', 500) + // No result cap: a missed .gitignore silently un-ignores whole trees. + const uris = await vscode.workspace.findFiles('**/.gitignore', '**/{.git,node_modules}/**') for (const uri of uris) { + const folder = vscode.workspace.getWorkspaceFolder(uri) + const entry = folder && byFolder.get(folder.uri.fsPath) + if (!folder || !entry) continue let text: string try { text = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf8') } catch { continue } - const dirRel = path.dirname(vscode.workspace.asRelativePath(uri, false)) + const dirRel = path.relative(folder.uri.fsPath, path.dirname(uri.fsPath)) for (const line of text.split('\n')) { const r = rebasePattern(line, dirRel) if (!r) continue - matcher.add(r.pattern) + entry.matcher.add(r.pattern) // Negations can't be expressed as an exclude glob; the matcher remains // authoritative for those. Only non-negated rules feed the glob set. - if (!r.negated) for (const g of patternToGlobs(r.pattern)) globs.add(g) + if (!r.negated) for (const g of patternToGlobs(r.pattern)) entry.globs.add(g) } } } catch (e) { console.error('[GTS] Failed to load .gitignore rules:', e) } - cached = { matcher, excludeGlobs: [...globs] } - return cached + const result = new Map() + for (const [root, { matcher, globs }] of byFolder) result.set(root, { root, matcher, excludeGlobs: [...globs] }) + cached = result + return result } /** Drop the cache so the next call re-reads .gitignore files. */ @@ -94,12 +111,22 @@ export function resetWorkspaceIgnore(): void { cached = null } -/** The cached matcher, or null if not loaded yet (sync accessor for hot paths). */ -export function getCachedMatcher(): Ignore | null { - return cached?.matcher ?? null +/** + * True if the file (or, with `isDirectory`, the folder) is gitignored by the + * rules of the workspace folder that contains it. Uses the given rules, or the + * cached ones (sync, for hot paths); false before the rules have been loaded + * and for paths outside every workspace folder. + */ +export function isGitIgnored(uri: vscode.Uri, isDirectory = false, ignores = cached): boolean { + if (!ignores) return false + const folder = vscode.workspace.getWorkspaceFolder(uri) + const entry = folder && ignores.get(folder.uri.fsPath) + if (!folder || !entry) return false + const rel = path.relative(folder.uri.fsPath, uri.fsPath) + return isIgnoredRel(entry.matcher, isDirectory && rel ? rel + '/' : rel) } -/** True if a workspace-relative path is gitignored per the given matcher. */ +/** True if a folder-relative path is gitignored per the given matcher. */ export function isIgnoredRel(matcher: Ignore | null, relPath: string): boolean { if (!matcher || !relPath || relPath.startsWith('..')) return false const posix = relPath.replace(/\\/g, '/') diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index 85b851b..75014e4 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode' import * as path from 'path' -import { getRegistry } from './registryStore' +import { getRegistry, getMalformedIds, getPathsWithMalformedIds } from './registryStore' /** * Left-sidebar file browser for GTS: shows every discovered file that holds at @@ -28,12 +28,24 @@ interface GtsFolderNode { 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 { +/** + * Tree path segments for a file: relative to its workspace folder, under a + * top-level node named after that folder when the workspace has several roots. + * Files outside every folder keep their absolute path. + */ +function treePathParts(fsPath: string, multiRoot: boolean): string[] { + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(fsPath)) + if (!folder) return fsPath.split(path.sep).filter(Boolean) + const rel = path.relative(folder.uri.fsPath, fsPath).split(path.sep).filter(Boolean) + return multiRoot ? [folder.name, ...rel] : rel +} + +/** Build a nested folder/file tree (per workspace folder) from a flat list of absolute paths. */ +function buildFileTree(filePaths: string[]): GtsFolderNode { const root: GtsFolderNode = { kind: 'folder', label: '', children: new Map() } + const multiRoot = (vscode.workspace.workspaceFolders?.length ?? 0) > 1 for (const fsPath of filePaths) { - const rel = workspaceRoot ? path.relative(workspaceRoot, fsPath) : fsPath - const parts = rel.split(path.sep).filter(Boolean) + const parts = treePathParts(fsPath, multiRoot) let current = root parts.forEach((part, idx) => { const isLast = idx === parts.length - 1 @@ -62,34 +74,59 @@ function sortedChildren(folder: GtsFolderNode): GtsTreeElement[] { }) } -/** Every file path the registry currently knows about (parsed GTS files + files that failed to parse). */ +/** + * Every file the registry currently knows about: parsed GTS files, files that + * failed to parse, and files whose only GTS content is malformed ids (those + * hold no valid entity, so the registry itself doesn't list them). + */ 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(new Set([ + ...registry.jsonFiles.keys(), + ...registry.invalidFiles.keys(), + ...getPathsWithMalformedIds() + ])) } -/** True if the given file currently has a GTS validation error reported on it. */ +function isDiscoveredGtsFile(fsPath: string): boolean { + const registry = getRegistry() + if (!registry) return false + return registry.jsonFiles.has(fsPath) || registry.invalidFiles.has(fsPath) || getMalformedIds(fsPath).length > 0 +} + +function gtsDiagnosticsOf(uri: vscode.Uri): vscode.Diagnostic[] { + return vscode.languages.getDiagnostics(uri).filter(d => d.source === 'GTS') +} + +/** True if the given file currently has a GTS error (warnings such as duplicate ids don't count). */ export function hasGtsErrors(uri: vscode.Uri): boolean { - if (vscode.languages.getDiagnostics(uri).some(d => d.source === 'GTS')) return true + if (gtsDiagnosticsOf(uri).some(d => d.severity === vscode.DiagnosticSeverity.Error)) return true const registry = getRegistry() if (!registry) return false const fsPath = uri.fsPath if (registry.invalidFiles.get(fsPath)?.validation?.errors.length) return true + if (getMalformedIds(fsPath).length > 0) return true const entities = [...(registry.jsonFileSchemas.get(fsPath) || []), ...(registry.jsonFileObjs.get(fsPath) || [])] return entities.some(entity => Boolean(entity.validation?.errors.length)) } -/** Total number of GTS validation problems currently reported across the workspace. */ -function countGtsProblems(): number { - let count = 0 +/** True if the given file has GTS warnings (e.g. a duplicate id). */ +function hasGtsWarnings(uri: vscode.Uri): boolean { + return gtsDiagnosticsOf(uri).some(d => d.severity === vscode.DiagnosticSeverity.Warning) +} + +/** GTS errors and warnings currently reported across the workspace. */ +function countGtsProblems(): { errors: number; warnings: number } { + const counts = { errors: 0, warnings: 0 } for (const [, diagnostics] of vscode.languages.getDiagnostics()) { for (const d of diagnostics) { - if (d.source === 'GTS') count++ + if (d.source !== 'GTS') continue + if (d.severity === vscode.DiagnosticSeverity.Error) counts.errors++ + else if (d.severity === vscode.DiagnosticSeverity.Warning) counts.warnings++ } } - return count + return counts } /** Collect every file path under an element (a single file, or all files under a folder subtree). */ @@ -126,7 +163,6 @@ export class GtsFileTreeProvider * removed so the caller can refresh just those decorations. */ refresh(): vscode.Uri[] { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' const paths = getDiscoveredFilePaths() const nextSet = new Set(paths) @@ -136,7 +172,7 @@ export class GtsFileTreeProvider if (changed.length === 0) return [] this.knownPaths = nextSet - this.root = buildFileTree(paths, workspaceRoot) + this.root = buildFileTree(paths) this._onDidChangeTreeData.fire() return changed } @@ -153,7 +189,7 @@ export class GtsFileTreeProvider } item.tooltip = hasGtsErrors(item.resourceUri) ? 'Has GTS validation errors' - : 'No GTS validation errors' + : hasGtsWarnings(item.resourceUri) ? 'Has GTS warnings' : 'No GTS problems' return item } @@ -189,8 +225,8 @@ export class GtsFileTreeProvider /** * 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 + * editor tabs) red when it has GTS errors, yellow when it only has warnings + * (e.g. a duplicate id), and green otherwise. 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 { @@ -202,15 +238,14 @@ export class GtsFileDecorationProvider implements vscode.FileDecorationProvider } 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 (!isDiscoveredGtsFile(uri.fsPath)) return undefined if (hasGtsErrors(uri)) { return new vscode.FileDecoration('!', 'GTS: file has validation errors', new vscode.ThemeColor('charts.red')) } + if (hasGtsWarnings(uri)) { + return new vscode.FileDecoration('!', 'GTS: file has warnings', new vscode.ThemeColor('charts.yellow')) + } return new vscode.FileDecoration(undefined, 'GTS: file is valid', new vscode.ThemeColor('charts.green')) } } @@ -225,11 +260,11 @@ export interface GtsExplorer { /** 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'}` } + const { errors, warnings } = countGtsProblems() + const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}` + treeView.badge = errors + warnings > 0 + ? { value: errors + warnings, tooltip: `GTS: ${plural(errors, 'error')}, ${plural(warnings, 'warning')}` } : 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. */ diff --git a/apps/vscode-extension/src/gtsIdFormat.ts b/apps/vscode-extension/src/gtsIdFormat.ts new file mode 100644 index 0000000..7557c4a --- /dev/null +++ b/apps/vscode-extension/src/gtsIdFormat.ts @@ -0,0 +1,48 @@ +import { GTS_URI_PREFIX, normalizeGtsId, isGtsId, isGtsPattern, checkGtsUriPrefix } from '@gts/shared' + +/** A string value that looks like a GTS id (starts with "gts." or "gts://") but isn't one. */ +export interface MalformedGtsId { + /** The raw value as written (possibly with a gts:// prefix). */ + value: string + /** JSON-pointer-style path to the value, e.g. "/0/payload/type". */ + instancePath: string +} + +export const GTS_ID_FORMAT_HINT = + 'gts.....v[.[~...]]' + +export function malformedGtsIdMessage(value: string): string { + return `Invalid GTS ID format: "${value}". Expected pattern: ${GTS_ID_FORMAT_HINT}` +} + +/** + * Find malformed GTS ids in parsed file content (JSON/JSONC/YAML object model). + * + * Same rules the editor's link provider applies to a live buffer: every string + * value starting with "gts." or "gts://" must be a valid GTS id or wildcard + * pattern. Values with a gts:// prefix problem are skipped — the validator + * reports those itself (keyword 'gts-uri-prefix'). Computing this from content + * rather than from an open editor is what lets closed files carry the error. + */ +export function findMalformedGtsIds(content: unknown): MalformedGtsId[] { + const out: MalformedGtsId[] = [] + const walk = (node: unknown, segments: Array, fieldName: string) => { + if (typeof node === 'string') { + if (!node.startsWith('gts.') && !node.startsWith(GTS_URI_PREFIX)) return + if (checkGtsUriPrefix(fieldName, node)) return + const id = normalizeGtsId(node) + if (isGtsId(id) || isGtsPattern(id)) return + out.push({ value: node, instancePath: segments.length ? '/' + segments.join('/') : '' }) + return + } + if (Array.isArray(node)) { + node.forEach((item, i) => walk(item, [...segments, i], fieldName)) + return + } + if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) walk(value, [...segments, key], key) + } + } + walk(content, [], '') + return out +} diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index 2359770..150be78 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -51,20 +51,11 @@ function codeSpan(text: string): string { const HOVER_TRUST = { enabledCommands: ['gts.replaceGtsId'] } /** - * Get workspace-relative path from absolute path + * Workspace-relative path for display. In a multi-root workspace this is + * prefixed with the owning folder's name; outside every folder it stays absolute. */ function getRelativePath(absolutePath: string): string { - const workspaceFolders = require('vscode').workspace.workspaceFolders - if (!workspaceFolders || workspaceFolders.length === 0) { - return absolutePath - } - - const workspaceRoot = workspaceFolders[0].uri.fsPath - if (absolutePath.startsWith(workspaceRoot)) { - return absolutePath.substring(workspaceRoot.length + 1) - } - - return absolutePath + return vscode.workspace.asRelativePath(absolutePath) } /** @@ -128,8 +119,6 @@ function findEntityLineInFile(filePath: string, entityId: string): number { * Makes GTS IDs clickable and provides hover information */ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.HoverProvider { - private diagnosticCollection: vscode.DiagnosticCollection - /** * The registry is the shared, persistent, index-only registry maintained in * registryStore. We never build our own here — decorations/links/hovers just @@ -149,9 +138,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // colour/style (schema/instance/error) of the following segment. private segmentGapDecorationType: vscode.TextEditorDecorationType - constructor(diagnosticCollection: vscode.DiagnosticCollection) { - this.diagnosticCollection = diagnosticCollection - + constructor() { const schemaBackgroundColor = 'background-color: ' + GTS_COLORS.schema.background_transparent const instanceBackgroundColor = 'background-color: ' + GTS_COLORS.instance.background_transparent @@ -225,7 +212,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } /** - * Dispose of decoration types and clear diagnostics + * Dispose of decoration types */ dispose(): void { this.schemaDecorationType.dispose() @@ -233,15 +220,6 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove this.errorDecorationType.dispose() this.unresolvedDecorationType.dispose() this.segmentGapDecorationType.dispose() - this.diagnosticCollection.clear() - } - - /** - * Drop this document's link-format diagnostics. They are computed from the - * live buffer only while it is open; once closed they would go stale. - */ - public clearDocument(uri: vscode.Uri): void { - this.diagnosticCollection.delete(uri) } /** @@ -303,7 +281,6 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove const errorRanges: vscode.Range[] = [] const unresolvedRanges: vscode.Range[] = [] const gapRanges: vscode.Range[] = [] - const diagnostics: vscode.Diagnostic[] = [] // Find all GTS references const references = this.findGtsReferences(document) @@ -325,9 +302,11 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove continue } - // Check if the GTS ID is valid using gts-ts validation + // Malformed GTS id: red chip only. The diagnostic itself is published by + // the validator (validation.ts, keyword 'gts-id-format') for open and + // closed files alike, so the file's status doesn't depend on whether it + // happens to be open. if (!ref.isValid) { - // Invalid GTS format - mark the entire string as error const text = document.getText() const refOffset = document.offsetAt(ref.range.start) let gtsStartOffset = refOffset @@ -336,20 +315,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } const startPos = document.positionAt(gtsStartOffset) const endPos = document.positionAt(gtsStartOffset + ref.rawValue.length) - const errorRange = new vscode.Range(startPos, endPos) - errorRanges.push(errorRange) - - // Create diagnostic for invalid GTS format - const diagnostic = new vscode.Diagnostic( - errorRange, - `Invalid GTS ID format: "${ref.rawValue}". Expected pattern: gts.....v[.[~...]]`, - vscode.DiagnosticSeverity.Error - ) - // Same source as the validator's diagnostics: the file tree, badge and - // red/green decorations only count source 'GTS'. - diagnostic.source = 'GTS' - diagnostics.push(diagnostic) - + errorRanges.push(new vscode.Range(startPos, endPos)) continue } @@ -453,9 +419,6 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove editor.setDecorations(this.errorDecorationType, errorRanges) editor.setDecorations(this.unresolvedDecorationType, unresolvedRanges) editor.setDecorations(this.segmentGapDecorationType, gapRanges) - - // Update diagnostics for this document - this.diagnosticCollection.set(document.uri, diagnostics) } /** diff --git a/apps/vscode-extension/src/registryStore.ts b/apps/vscode-extension/src/registryStore.ts index 18b0d00..301eb5f 100644 --- a/apps/vscode-extension/src/registryStore.ts +++ b/apps/vscode-extension/src/registryStore.ts @@ -1,5 +1,6 @@ import { JsonRegistry, DEFAULT_GTS_CONFIG } from '@gts/shared' import type { GtsConfig } from '@gts/shared' +import { findMalformedGtsIds, type MalformedGtsId } from './gtsIdFormat' /** * Long-lived, shared GTS registry for the extension host. @@ -23,6 +24,29 @@ export interface RegistryFileInput { let registry: JsonRegistry | null = null let activeConfig: GtsConfig = DEFAULT_GTS_CONFIG let revision = 0 +// Malformed GTS ids per file, kept in lockstep with the registry so closed files +// report them too (the registry itself only indexes *valid* GTS entities, so a +// file whose only GTS content is malformed would otherwise not exist for us). +const malformedIdsByPath = new Map() + +const GTS_VIEWER_DIR_RE = /(^|[\\/])\.gts-viewer[\\/]/ + +function updateMalformedIds(path: string, content: any): void { + // Unparsable files (raw string content) are reported as parse errors instead. + const issues = typeof content === 'string' || GTS_VIEWER_DIR_RE.test(path) ? [] : findMalformedGtsIds(content) + if (issues.length > 0) malformedIdsByPath.set(path, issues) + else malformedIdsByPath.delete(path) +} + +/** Malformed GTS ids found in a file's current indexed content. */ +export function getMalformedIds(path: string): readonly MalformedGtsId[] { + return malformedIdsByPath.get(path) || [] +} + +/** Every indexed file that contains at least one malformed GTS id. */ +export function getPathsWithMalformedIds(): string[] { + return [...malformedIdsByPath.keys()] +} /** Get the shared registry, or null if it hasn't been built yet. */ export function getRegistry(): JsonRegistry | null { @@ -41,6 +65,8 @@ export async function rebuildRegistry( activeConfig = cfg const next = new JsonRegistry() await next.ingestFiles(files, cfg, { skipValidation: true }) + malformedIdsByPath.clear() + for (const file of files) updateMalformedIds(file.path, file.content) registry = next revision++ return next @@ -50,6 +76,7 @@ export async function rebuildRegistry( export function indexFile(path: string, name: string, content: any): void { if (!registry) return registry.indexFile(path, name, content, activeConfig) + updateMalformedIds(path, content) revision++ } @@ -57,6 +84,7 @@ export function indexFile(path: string, name: string, content: any): void { export function removeFile(path: string): void { if (!registry) return registry.invalidateFile(path) + malformedIdsByPath.delete(path) revision++ } diff --git a/apps/vscode-extension/src/storage.ts b/apps/vscode-extension/src/storage.ts index 9674163..53aa19c 100644 --- a/apps/vscode-extension/src/storage.ts +++ b/apps/vscode-extension/src/storage.ts @@ -108,3 +108,36 @@ export class RepoLayoutStorage implements ILayoutStorage { }] } } + +/** + * Layout storage for a (possibly multi-root) workspace: each layout goes to the + * `.gts-viewer/` folder of the workspace folder chosen by `resolveRoot` for its + * target, so a diagram's layout is stored next to the files it depicts. + */ +export class WorkspaceLayoutStorage implements ILayoutStorage { + private readonly stores = new Map() + + constructor(private readonly resolveRoot: (target: Partial) => string) {} + + private storeFor(target: Partial): RepoLayoutStorage { + const root = this.resolveRoot(target) + let store = this.stores.get(root) + if (!store) { + store = new RepoLayoutStorage(root) + this.stores.set(root, store) + } + return store + } + + getLatestLayout(target: Partial): Promise { + return this.storeFor(target).getLatestLayout(target) + } + + saveLayout(request: LayoutSaveRequest): Promise { + return this.storeFor(request.target).saveLayout(request) + } + + listVersions(target: Partial): Promise> { + return this.storeFor(target).listVersions(target) + } +} diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index b68d466..7588311 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -5,12 +5,12 @@ import * as jsonc from 'jsonc-parser' import { ValidationError, DEFAULT_GTS_CONFIG, parseGtsFileContent, isYamlFileName } from '@gts/shared' import type { JsonRegistry, JsonSchema, JsonObj } from '@gts/shared' import { getLastScanFiles } from './scanStore' -import { getRegistry, rebuildRegistry, indexFile } from './registryStore' +import { getRegistry, rebuildRegistry, indexFile, getMalformedIds, getPathsWithMalformedIds } from './registryStore' +import { malformedGtsIdMessage } from './gtsIdFormat' import { isGtsCandidateFile } from './helpers' let diagnosticCollection: vscode.DiagnosticCollection let workspaceDiagnosticCollection: vscode.DiagnosticCollection -let isInitialScanComplete = false const documentValidationErrors = new Map() const documentValidationGenerations = new Map() @@ -29,7 +29,7 @@ export function onValidationCompleted(listener: (uri: vscode.Uri) => void): vsco /** * Convert validation errors to VSCode diagnostics */ -function validationErrorsToDiagnostics(errors: ValidationError[], document: vscode.TextDocument): vscode.Diagnostic[] { +function validationErrorsToDiagnostics(errors: FileProblem[], document: vscode.TextDocument): vscode.Diagnostic[] { const diagnostics: vscode.Diagnostic[] = [] for (const error of errors) { @@ -55,11 +55,7 @@ function validationErrorsToDiagnostics(errors: ValidationError[], document: vsco range = new vscode.Range(0, 0, 0, 1) } - const diagnostic = new vscode.Diagnostic( - range, - error.message, - vscode.DiagnosticSeverity.Error - ) + const diagnostic = new vscode.Diagnostic(range, error.message, problemSeverity(error)) diagnostic.source = 'GTS' diagnostic.code = error.keyword @@ -130,9 +126,10 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, } } - // 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) { + // 4. For gts:// prefix violations, x-gts-ref mismatches, malformed ids and + // duplicate ids without a resolvable path, highlight the offending string value. + const valueKeywords = ['gts-uri-prefix', 'x-gts-ref', 'gts-id-format', 'gts-duplicate-id'] + if (valueKeywords.includes(error.keyword) && error.params && 'value' in error.params) { const value = String((error.params as any).value) // Quoted (JSON, or a quoted YAML scalar) first, then bare YAML scalar. let idx = text.indexOf(`"${value}"`) @@ -557,9 +554,7 @@ export async function validateOpenDocument(document: vscode.TextDocument) { // any new invalid-file entry or updated entity list for this open buffer. registry = getRegistry() || registry - let errors: ValidationError[] = [] - - const invalid = registry.invalidFiles.get(filePath) + let errors: FileProblem[] = [] if (parseErrorMessage) { errors = [{ @@ -569,21 +564,13 @@ export async function validateOpenDocument(document: vscode.TextDocument) { message: `Invalid ${isYamlFileName(fileName) ? 'YAML' : 'JSON'}: ${parseErrorMessage}`, params: { error: parseErrorMessage } }] - } else if (invalid?.validation && invalid.validation.errors.length > 0) { - errors = invalid.validation.errors } else { // Only validate the entities defined in THIS document. Other files remain // indexed (for $ref / GTS-reference resolution) but are not re-validated. - const fileSchemas = registry.jsonFileSchemas.get(filePath) || [] - const fileObjs = registry.jsonFileObjs.get(filePath) || [] - - console.log(`[GTS Validation] Validating ${fileSchemas.length + fileObjs.length} entities in ${fileName}...`) - for (const e of [...fileSchemas, ...fileObjs]) { - await registry.validateEntity(e) - if (e.validation && e.validation.errors.length > 0) { - errors.push(...e.validation.errors) - } - } + const entities = fileEntities(registry, filePath) + console.log(`[GTS Validation] Validating ${entities.length} entities in ${fileName}...`) + for (const e of entities) await registry.validateEntity(e) + errors = collectFileProblems(registry, filePath, buildDefinitionIndex(registry)) } if ( @@ -596,7 +583,8 @@ export async function validateOpenDocument(document: vscode.TextDocument) { workspaceDiagnosticCollection?.delete(document.uri) if (errors.length > 0) { - documentValidationErrors.set(document.uri.toString(), errors) + // Only errors colour GTS id chips red; a duplicate-id warning must not. + documentValidationErrors.set(document.uri.toString(), errors.filter(e => e.severity !== 'warning')) 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 }))) @@ -698,6 +686,108 @@ function isIndexedInItsFile(registry: JsonRegistry, entity: RegistryEntity): boo (registry.jsonFileObjs.get(filePath) || []).includes(entity as JsonObj) } +/** A validation finding plus its severity (errors unless marked otherwise). */ +type FileProblem = ValidationError & { severity?: 'warning' } + +function problemSeverity(problem: FileProblem): vscode.DiagnosticSeverity { + return problem.severity === 'warning' ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Error +} + +/** Coarse (line 0) diagnostic for a file that isn't open in an editor. */ +function coarseDiagnostic(problem: FileProblem): vscode.Diagnostic { + const diagnostic = new vscode.Diagnostic(new vscode.Range(0, 0, 0, 1), problem.message, problemSeverity(problem)) + diagnostic.source = 'GTS' + diagnostic.code = problem.keyword + return diagnostic +} + +function fileEntities(registry: JsonRegistry, filePath: string): RegistryEntity[] { + return [...(registry.jsonFileSchemas.get(filePath) || []), ...(registry.jsonFileObjs.get(filePath) || [])] +} + +/** id -> path of every file defining it, one entry per definition. */ +type DefinitionIndex = Map + +function buildDefinitionIndex(registry: JsonRegistry): DefinitionIndex { + const index: DefinitionIndex = new Map() + for (const entity of entitiesByFile(registry)) { + const filePath = entity.file?.path + if (!entity.id || !filePath) continue + const paths = index.get(entity.id) + if (paths) paths.push(filePath) + else index.set(entity.id, [filePath]) + } + return index +} + +/** Path to the field that holds the entity's id, e.g. "/2/$id" in a list file. */ +function idInstancePath(entity: RegistryEntity): string { + const field = entity.selectedEntityIdField || entity.selectedSchemaIdField + if (!field) return '' + const seq = (entity as { listSequence?: number }).listSequence + return (seq !== undefined ? `/${seq}` : '') + `/${field}` +} + +const MAX_LISTED_DUPLICATE_FILES = 3 + +/** + * A warning for every entity of the file whose id is also defined elsewhere (in + * another file, or again in this one). The registry resolves an id to a single + * definition — whichever was indexed last — so references to a duplicated id + * silently depend on scan order. + */ +function duplicateIdProblems(registry: JsonRegistry, filePath: string, index: DefinitionIndex): FileProblem[] { + const problems: FileProblem[] = [] + for (const entity of fileEntities(registry, filePath)) { + const definitions = index.get(entity.id) || [] + if (definitions.length < 2) continue + const otherFiles = [...new Set(definitions.filter(p => p !== filePath))] + const timesHere = definitions.length - definitions.filter(p => p !== filePath).length + const details: string[] = [] + if (otherFiles.length > 0) { + const listed = otherFiles.slice(0, MAX_LISTED_DUPLICATE_FILES).map(p => vscode.workspace.asRelativePath(p)) + const more = otherFiles.length - listed.length + details.push(`also defined in ${listed.join(', ')}${more > 0 ? ` and ${more} more` : ''}`) + } + if (timesHere > 1) details.push(`defined ${timesHere} times in this file`) + problems.push({ + instancePath: idInstancePath(entity), + schemaPath: '#', + keyword: 'gts-duplicate-id', + message: `Duplicate GTS id "${entity.id}": ${details.join('; ')}. References resolve to only one of these definitions.`, + params: { value: entity.id, otherFiles }, + severity: 'warning' + }) + } + return problems +} + +function malformedIdProblems(filePath: string): FileProblem[] { + return getMalformedIds(filePath).map(issue => ({ + instancePath: issue.instancePath, + schemaPath: '#', + keyword: 'gts-id-format', + message: malformedGtsIdMessage(issue.value), + params: { value: issue.value } + })) +} + +/** + * Everything to report for one file, read from the registry's current state + * (entities must already be validated): parse errors, entity validation errors, + * malformed GTS ids and duplicate-id warnings. The single source for open-file, + * closed-file and workspace-wide diagnostics, so all three always agree. + */ +function collectFileProblems(registry: JsonRegistry, filePath: string, index: DefinitionIndex): FileProblem[] { + const invalid = registry.invalidFiles.get(filePath) + if (invalid?.validation && invalid.validation.errors.length > 0) return [...invalid.validation.errors] + return [ + ...fileEntities(registry, filePath).flatMap(entity => entity.validation?.errors || []), + ...malformedIdProblems(filePath), + ...duplicateIdProblems(registry, filePath, index) + ] +} + /** One full validation pass. Returns false if it was superseded and must rerun. */ async function runWorkspaceValidationPass(): Promise { const registry = getRegistry() @@ -736,30 +826,18 @@ async function runWorkspaceValidationPass(): Promise { if (isGtsCandidateFile(doc)) openPaths.add(doc.uri.fsPath) } + const index = buildDefinitionIndex(registry) + const filePaths = new Set([ + ...registry.jsonFileSchemas.keys(), + ...registry.jsonFileObjs.keys(), + ...registry.invalidFiles.keys(), + ...getPathsWithMalformedIds() + ]) const diagnosticsByPath = new Map() - const addError = (filePath: string, error: ValidationError) => { - if (openPaths.has(filePath)) return - const diagnostics = diagnosticsByPath.get(filePath) || [] - 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) - diagnosticsByPath.set(filePath, diagnostics) - } - - // Files that failed parsing/indexing. - for (const invalidFile of registry.invalidFiles.values()) { - const errors = invalidFile.validation?.errors || [] - for (const error of errors) addError(invalidFile.path, error) - } - - for (const entity of entitiesByFile(registry)) { - if (!entity.file?.path) continue - for (const error of entity.validation?.errors || []) addError(entity.file.path, error) + for (const filePath of filePaths) { + if (openPaths.has(filePath)) continue + const problems = collectFileProblems(registry, filePath, index) + if (problems.length > 0) diagnosticsByPath.set(filePath, problems.map(coarseDiagnostic)) } // Replace the collection's full contents. `set(entries)` only touches the @@ -773,7 +851,7 @@ async function runWorkspaceValidationPass(): Promise { entries.push([vscode.Uri.file(filePath), diagnostics]) } workspaceDiagnosticCollection.set(entries) - console.log(`[GTS Validation] Workspace pass: ${validated.size} entities, ${diagnosticsByPath.size} file(s) with errors (${Date.now() - startTime}ms)`) + console.log(`[GTS Validation] Workspace pass: ${validated.size} entities, ${diagnosticsByPath.size} file(s) with problems (${Date.now() - startTime}ms)`) return true } @@ -782,36 +860,14 @@ async function runWorkspaceValidationPass(): Promise { * (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 { +async function validateClosedFile(filePath: string, index?: DefinitionIndex): 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. + // resulting problems 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 diagnostics = collectFileProblems(registry, filePath, index ?? buildDefinitionIndex(registry)).map(coarseDiagnostic) const uri = vscode.Uri.file(filePath) workspaceDiagnosticCollection.set(uri, diagnostics.length > 0 ? diagnostics : undefined) @@ -852,7 +908,16 @@ export async function revalidateDependents(changedPath: string, previousIds?: It // `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) + const oldIds = previousIds ? [...previousIds] : [] + const dependentPaths = new Set(registry.getDependentFilePaths(changedPath, oldIds)) + // Other files defining any of this file's old or new ids gain or lose a + // duplicate-id warning when those ids change. + const index = buildDefinitionIndex(registry) + for (const id of new Set([...oldIds, ...registry.getEntityIdsForFile(changedPath)])) { + for (const definingPath of index.get(id) || []) { + if (definingPath !== changedPath) dependentPaths.add(definingPath) + } + } if (dependentPaths.size === 0) return const openByPath = new Map() @@ -868,7 +933,7 @@ export async function revalidateDependents(changedPath: string, previousIds?: It if (openDoc) { await validateOpenDocument(openDoc) } else { - await validateClosedFile(dependentPath) + await validateClosedFile(dependentPath, index) } } } diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index d61b53b..574b5e6 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -620,10 +620,19 @@ export class JsonRegistry { /** * Validate a single entity against its schema. + * + * The result is built locally and published with a single assignment. The + * computation awaits (Ajv compileAsync, parent validation), so two overlapping + * validations of the same entity (e.g. an open-document check racing a + * workspace pass) used to reset and append to the shared `entity.validation` + * in interleaved order, duplicating or dropping errors. */ async validateEntity(entity: JsonEntity): Promise { - // Initialize validation result - entity.validation = { errors: [] } + entity.validation = await this.computeEntityValidation(entity) + } + + private async computeEntityValidation(entity: JsonEntity): Promise { + const validation: ValidationResult = { errors: [] } // Enforce gts:// URI-prefix rules: the prefix is required in JSON Schema URL // fields ($id, $ref, x-gts-traits-schema) and forbidden everywhere else. @@ -631,7 +640,7 @@ export class JsonRegistry { const instancePath = violation.sourcePath === 'root' ? '/' : '/' + violation.sourcePath.replace(/\./g, '/').replace(/\[(\d+)\]/g, '/$1') - entity.validation.errors.push({ + validation.errors.push({ instancePath, schemaPath: '#', keyword: 'gts-uri-prefix', @@ -698,7 +707,7 @@ export class JsonRegistry { const instancePath = ref.sourcePath === 'root' ? '/' : '/' + ref.sourcePath.replace(/\./g, '/').replace(/\[(\d+)\]/g, '/$1') - entity.validation.errors.push({ + validation.errors.push({ instancePath, schemaPath: '#', keyword: '', @@ -712,7 +721,7 @@ export class JsonRegistry { // In VS Code webview environment, skip Ajv validation to comply with CSP const g: any = (typeof globalThis !== 'undefined') ? (globalThis as any) : {} if (g && (g.acquireVsCodeApi || (g.__GTS_APP_API__ && (g.__GTS_APP_API__.type === 'vscode' || g.__GTS_APP_API__.disableValidation === true)))) { - return + return validation } if (entity instanceof JsonSchema) { @@ -730,13 +739,13 @@ export class JsonRegistry { e.keyword = e.keyword || 'schema' e.message = e.message || 'Invalid JSON Schema' }) - entity.validation.errors.push(...detailed) + validation.errors.push(...detailed) } else { // Fallback: try to extract a path from error.message like "data/xxx ..." const msg: string = String(error?.message || 'Unknown schema error') const m = msg.match(/data(\/[A-Za-z0-9_\-\.\[\]\/]+)\b/) const instancePath = m ? m[1] : '' - entity.validation.errors.push({ + validation.errors.push({ instancePath, schemaPath: '#', keyword: 'schema', @@ -757,7 +766,7 @@ export class JsonRegistry { if (declError) { // register() rejected this schema outright (§9.11.1); surface it and // skip chain validation (the schema isn't in the store). - entity.validation.errors.push({ + validation.errors.push({ instancePath: '', schemaPath: '#', keyword: 'x-gts-schema', @@ -796,7 +805,7 @@ export class JsonRegistry { instancePath = findTraitRequiredPath(entity.content, traitName) params = traitName ? { trait: traitName } : {} } - entity.validation.errors.push({ + validation.errors.push({ instancePath: instancePath || '/$id', schemaPath: '#', keyword: 'x-gts-schema', @@ -812,7 +821,7 @@ export class JsonRegistry { // JSON Pointer). This checks the schema authoring, not an instance value. const refDeclErrors = new XGtsRefValidator().validateSchema(entity.content) for (const err of refDeclErrors) { - entity.validation.errors.push({ + validation.errors.push({ instancePath: fieldPathToInstancePath(err.fieldPath), schemaPath: '#', keyword: 'x-gts-ref', @@ -821,14 +830,14 @@ export class JsonRegistry { }) } - if (entity.validation.errors.length === 0) { + if (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({ + validation.errors.push({ instancePath: '/$id', schemaPath: '#', keyword: 'x-gts-schema', @@ -845,7 +854,7 @@ export class JsonRegistry { const result = store.validateInstance(entity.id) if (!result.ok) { const idField = (entity as any).selectedSchemaIdField || (entity as any).selectedEntityIdField || 'id' - entity.validation.errors.push({ + validation.errors.push({ instancePath: '/' + String(idField), schemaPath: '#', keyword: 'schema', @@ -853,7 +862,7 @@ export class JsonRegistry { params: { gtsId: entity.id } }) } - return + return validation } const schema = this.resolveSchema(entity.schemaId) @@ -861,14 +870,14 @@ export class JsonRegistry { // Prefer pointing to the field that produced schemaId const idField = (entity as any).selectedSchemaIdField || (entity as any).selectedEntityIdField || 'id' const instancePath = '/' + String(idField) - entity.validation.errors.push({ + validation.errors.push({ instancePath, schemaPath: '#', keyword: 'schema', message: `Schema not found: ${entity.schemaId}`, params: { schemaId: entity.schemaId } }) - return + return validation } // §9.11.3 (OP#6): an instance's rightmost type must be instantiable. A @@ -877,7 +886,7 @@ export class JsonRegistry { // explicitly here (mirrors gts-ts store.validateInstance). if (GtsModifiers.isAbstract(schema.content)) { const idField = (entity as any).selectedSchemaIdField || (entity as any).selectedEntityIdField || 'id' - entity.validation.errors.push({ + validation.errors.push({ instancePath: '/' + String(idField), schemaPath: '#', keyword: 'x-gts-abstract', @@ -902,7 +911,7 @@ export class JsonRegistry { // Merge AJV errors with previously collected GTS reference errors if (!valid && validate.errors) { const formatted = this.formatValidationErrors(validate.errors) - entity.validation.errors.push(...formatted) + validation.errors.push(...formatted) } // §9.6: `x-gts-ref` is an assertion keyword on instance string values @@ -914,7 +923,7 @@ export class JsonRegistry { // GTS-ID format and the prefix/pattern constraint. const xGtsRefErrors = new XGtsRefValidator().validateInstance(entity.content, schema.content) for (const err of xGtsRefErrors) { - entity.validation.errors.push({ + validation.errors.push({ instancePath: fieldPathToInstancePath(err.fieldPath), schemaPath: '#', keyword: 'x-gts-ref', @@ -923,7 +932,7 @@ export class JsonRegistry { }) } } catch (error: any) { - entity.validation.errors.push({ + validation.errors.push({ instancePath: '', schemaPath: '#', keyword: 'validation', @@ -932,6 +941,7 @@ export class JsonRegistry { }) } } + return validation } /** From ca9655b778bb361e53fb3b236cceef83f177474b Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 14:36:01 +0300 Subject: [PATCH 43/57] perf(vscode): lazy activation, cached validation, cheaper editor and viewer paths Cuts the extension's steady-state and scan cost without changing any validation result (verified: all 2224 entity results in the gts workspace are identical before and after). - shared: validateEntity results are memoized per entity for the current registry content generation (bumped on every index/invalidate/reset), so a pass validates each entity once instead of re-validating the whole parent chain for every derived schema, and concurrent callers share one computation. Instance validators are compiled once per schema per generation instead of once per instance (each still gets its own Ajv, as before). The gts-instance-id/gts-type-id annotation formats (paired with x-gts-ref, which enforces them) and schemars' sized-integer formats are registered as accept-all, which is how Ajv already treated them, without the "unknown format" warning on every compile. Full workspace pass 9.0s -> 6.1s; an unchanged repeat pass 9.4s -> <1ms; warnings 232 -> 12 (the rest come from gts-ts's internal Ajv). - Activation: replace onStartupFinished with workspaceContains for gts.*.{json,jsonc,yaml,yml} and *.gts files plus onLanguage:gts (views and commands activate implicitly), so unrelated workspaces are no longer scanned. Drop the "GTS Viewer is ready!" popup shown on every start. - linkProvider: parsed references are cached per document version and carry their content offset, so decorations, hovers and links no longer copy the whole document text per reference or re-parse it per hover. Definition links are resolved lazily (resolveDocumentLink) and locate the entity's id field (or its list item) via the JSON/YAML tree, read asynchronously and cached per registry revision; previously every link request did a synchronous disk read per segment of every reference and matched the first line merely containing the id. - validation: error positions share one lazily parsed document per validation instead of re-reading and re-parsing the text per error. - Viewer: validation results come from the shared registry, which the scan's workspace pass and per-edit revalidation keep current; the viewer is refreshed after the edited file and its dependents are re-checked. It used to build a second registry and re-run Ajv over the whole workspace on every debounced edit. - Explorer: diagnostics changes of other extensions (e.g. TypeScript on every keystroke) no longer refresh the tree or recount every diagnostic in VS Code; only GTS files are repainted, tree/badge work is debounced, and the badge counts this extension's own collections. - Scans read files 32 at a time (order preserved, so duplicate-id resolution is unchanged) and always use real file documents, never a git:/ quick-diff copy with the same fsPath. Folder indexing likewise. - Logging: drop per-error, per-object and per-validation logs from the editor paths; only lifecycle and per-scan/per-pass summaries remain. Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 7 +- apps/vscode-extension/src/extension.ts | 93 ++++---- apps/vscode-extension/src/gtsExplorer.ts | 51 +++-- apps/vscode-extension/src/linkProvider.ts | 251 ++++++++++------------ apps/vscode-extension/src/validation.ts | 178 +++++++-------- packages/shared/src/registry.ts | 75 ++++++- 6 files changed, 351 insertions(+), 304 deletions(-) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 36137a8..850de9d 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -32,7 +32,12 @@ ], "main": "./dist/extension.js", "activationEvents": [ - "onStartupFinished" + "workspaceContains:**/gts.*.json", + "workspaceContains:**/gts.*.jsonc", + "workspaceContains:**/gts.*.yaml", + "workspaceContains:**/gts.*.yml", + "workspaceContains:**/*.gts", + "onLanguage:gts" ], "contributes": { "languages": [ diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index c5fef16..ff49f58 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,7 +1,7 @@ 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 { parseGtsFileContent, 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' @@ -282,7 +282,6 @@ async function postRegistryToViewer(refreshFilePath: string | null | undefined): ? activeDoc.uri.fsPath : null } - console.log('[GTS Extension] postRegistryToViewer: selectedFilePath=', selectedFilePath) const registry = getRegistry() // Every indexed file: files holding GTS entities, plus files that failed to @@ -298,19 +297,16 @@ async function postRegistryToViewer(refreshFilePath: string | null | undefined): // Send scan result with default file path so the webview can compute initial selection panel.webview.postMessage({ type: 'gts-scan-result', detail: { files, defaultFilePath: selectedFilePath } }) - // The viewer needs full Ajv validation results for every entity. This is the - // only consumer that pays that cost, and only while the panel is open. - try { - const vreg = new JsonRegistry() - await vreg.ingestFiles(files, DEFAULT_GTS_CONFIG) - 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 } - panel.webview.postMessage({ type: 'gts-validation-result', detail: payload }) - } catch (ve: any) { - panel.webview.postMessage({ type: 'gts-validation-error', detail: { error: ve?.message || String(ve) } }) - } + // Full validation results for the viewer, taken from the shared registry. + // They are already current: the scan's workspace pass validated everything, + // and edits re-validate the edited file and its dependents before the viewer + // is refreshed (see handleFileChange). The viewer used to build a second + // registry and re-run Ajv over the whole workspace on every debounced edit. + const objs: ObjValidationDto[] = registry ? Array.from(registry.jsonObjs.values()).map(o => ({ id: o.id, listSequence: o.listSequence, filePath: o.file?.path, schemaId: o.schemaId, validation: o.validation })) : [] + const schemas: EntityValidationDto[] = registry ? Array.from(registry.jsonSchemas.values()).map(s => ({ id: s.id, filePath: s.file?.path, validation: s.validation })) : [] + const invalidFiles: InvalidFileValidationDto[] = registry ? Array.from(registry.invalidFiles.values()).map(f => ({ path: f.path, name: f.name, validation: f.validation })) : [] + const payload: ValidationRelayPayload = { objs, schemas, invalidFiles } + panel.webview.postMessage({ type: 'gts-validation-result', detail: payload }) // After scan + validation updates are delivered, instruct the webview to refresh diagrams for the updated file if (refreshFilePath) { @@ -557,9 +553,6 @@ export async function activate(context: vscode.ExtensionContext) { handleFileChange(event.document, 500) }) ) - - // Show welcome message - vscode.window.showInformationMessage('GTS Viewer is ready! Use "GTS: Open Viewer" to start.') } /** Paths of GTS-candidate files currently open in the editor (active first). */ @@ -590,30 +583,45 @@ function collectOpenGtsPaths(): string[] { async function readGtsCandidateFiles( uris: vscode.Uri[] ): Promise> { - const files: Array<{ path: string; name: string; content: any }> = [] - for (const uri of uris) { + // Only real files: a git:/ quick-diff document shares the fsPath but holds HEAD content. + const openDocs = new Map(vscode.workspace.textDocuments.filter(d => d.uri.scheme === 'file').map(d => [d.uri.fsPath, d])) + const results = await mapWithConcurrency(uris, FILE_READ_CONCURRENCY, async uri => { try { - const openDoc = vscode.workspace.textDocuments.find(d => d.uri.fsPath === uri.fsPath) - let text: string - if (openDoc) { - text = openDoc.getText() - } else { - const data = await vscode.workspace.fs.readFile(uri) - text = Buffer.from(data).toString('utf8') - } + const openDoc = openDocs.get(uri.fsPath) + const text = openDoc ? openDoc.getText() : Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf8') // Quick pre-filter: a file with no GTS-like substring cannot hold a GTS id. // Check for both "gts." (canonical form) and "gts://" (URI form) so that // malformed identifiers like "gts://gtx.foo.bar.v1~" are still surfaced. - if (!mayContainGts(text)) continue + if (!mayContainGts(text)) return null const name = path.basename(uri.fsPath) let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } - files.push({ path: uri.fsPath, name, content }) - } catch (e) { - // Unreadable file — skip. + return { path: uri.fsPath, name, content } + } catch { + return null // Unreadable file — skip. + } + }) + // Results keep input order, so indexing order (and thus which definition of a + // duplicated id wins) is the same as with sequential reads. + return results.filter((f): f is { path: string; name: string; content: any } => f !== null) +} + +// Files read concurrently during a scan. Sequential reads left most of the scan +// waiting on I/O one file at a time. +const FILE_READ_CONCURRENCY = 32 + +/** Map over `items` with at most `limit` operations in flight; results keep input order. */ +async function mapWithConcurrency(items: readonly T[], limit: number, fn: (item: T) => Promise): Promise { + const results = new Array(items.length) + let next = 0 + const worker = async () => { + while (next < items.length) { + const i = next++ + results[i] = await fn(items[i]) } } - return files + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return results } /** Re-validate all open GTS documents against the current registry. */ @@ -772,14 +780,10 @@ export async function deactivate() { // file B within the debounce window cancelled file A's pending validation and // dependents revalidation, leaving A's markers stale. const changeTimers = new Map() -// Separate debounce for pushing the (whole) registry to an open viewer. -let viewerRefreshTimer: NodeJS.Timeout | null = null function clearChangeTimers(): void { for (const timer of changeTimers.values()) clearTimeout(timer) changeTimers.clear() - if (viewerRefreshTimer) clearTimeout(viewerRefreshTimer) - viewerRefreshTimer = null } function isGtsScanPath(fsPath: string): boolean { @@ -920,7 +924,7 @@ async function indexFolder(uri: vscode.Uri): Promise { const ignoreGlobs = workspaceFolder ? (await getWorkspaceIgnore()).get(workspaceFolder.uri.fsPath)?.excludeGlobs || [] : [] const uris = await vscode.workspace.findFiles(new vscode.RelativePattern(uri, GTS_SCAN_GLOB), combineExcludeGlobs(ALWAYS_EXCLUDE_GLOB, ignoreGlobs)) // onDiskFileChanged applies the ignore rules and skips files open in an editor. - for (const fileUri of uris) await onDiskFileChanged(fileUri) + await mapWithConcurrency(uris, FILE_READ_CONCURRENCY, fileUri => onDiskFileChanged(fileUri)) } // Folders reported as created, batched so a burst (git checkout, unzip) scans @@ -1007,8 +1011,9 @@ function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { gtsLinkProvider.updateDecorations(editor) } - // Debounced + heavier: validate just this document and its dependents, and - // (only when the viewer panel is open) push the updated registry to it. + // Debounced + heavier: validate just this document and its dependents, then + // (only when the viewer panel is open) push the updated registry to it, so + // the viewer gets results that already reflect this edit. const pending = changeTimers.get(fsPath) if (pending) clearTimeout(pending) changeTimers.set(fsPath, setTimeout(() => { @@ -1021,15 +1026,9 @@ function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { // types, $ref/allOf composers, and GTS-id referrers) so their markers // reflect the edit, not just this doc. await revalidateDependents(fsPath, previousIds) + if (viewerPanel) await scanAndPost(fsPath) })() }, delayMsec)) - if (viewerPanel) { - if (viewerRefreshTimer) clearTimeout(viewerRefreshTimer) - viewerRefreshTimer = setTimeout(() => { - viewerRefreshTimer = null - void scanAndPost(fsPath) - }, delayMsec) - } } /** Root of the workspace folder that holds the target entity's file (first folder if unknown). */ diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index 75014e4..035b29d 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode' import * as path from 'path' import { getRegistry, getMalformedIds, getPathsWithMalformedIds } from './registryStore' +import { countPublishedProblems } from './validation' /** * Left-sidebar file browser for GTS: shows every discovered file that holds at @@ -116,19 +117,6 @@ function hasGtsWarnings(uri: vscode.Uri): boolean { return gtsDiagnosticsOf(uri).some(d => d.severity === vscode.DiagnosticSeverity.Warning) } -/** GTS errors and warnings currently reported across the workspace. */ -function countGtsProblems(): { errors: number; warnings: number } { - const counts = { errors: 0, warnings: 0 } - for (const [, diagnostics] of vscode.languages.getDiagnostics()) { - for (const d of diagnostics) { - if (d.source !== 'GTS') continue - if (d.severity === vscode.DiagnosticSeverity.Error) counts.errors++ - else if (d.severity === vscode.DiagnosticSeverity.Warning) counts.warnings++ - } - } - return counts -} - /** 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') { @@ -162,6 +150,11 @@ export class GtsFileTreeProvider * content edit must not rebuild the tree. Returns the URIs that were added or * removed so the caller can refresh just those decorations. */ + /** True if the tree currently lists this file. */ + lists(fsPath: string): boolean { + return this.knownPaths.has(fsPath) + } + refresh(): vscode.Uri[] { const paths = getDiscoveredFilePaths() const nextSet = new Set(paths) @@ -260,7 +253,7 @@ export interface GtsExplorer { /** Update the small rounded problem-count badge shown next to the view title. */ function updateBadge(treeView: vscode.TreeView): void { - const { errors, warnings } = countGtsProblems() + const { errors, warnings } = countPublishedProblems() const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}` treeView.badge = errors + warnings > 0 ? { value: errors + warnings, tooltip: `GTS: ${plural(errors, 'error')}, ${plural(warnings, 'warning')}` } @@ -291,14 +284,31 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor } }), // 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. + // badge) change independently of the file list. This event fires for every + // diagnostics change of every extension (e.g. the TypeScript server on each + // keystroke), so only GTS files are repainted, and the tree/badge work is + // debounced. vscode.languages.onDidChangeDiagnostics(event => { + const relevant = event.uris.filter(uri => + uri.scheme === 'file' && (isDiscoveredGtsFile(uri.fsPath) || treeProvider.lists(uri.fsPath))) + if (relevant.length === 0) return + decorationProvider.refresh(relevant) + scheduleTreeUpdate() + }), + new vscode.Disposable(() => { if (updateTimer) clearTimeout(updateTimer) }) + ) + + // Coalesces tree reconciliation + badge recount across bursts of changes. + let updateTimer: NodeJS.Timeout | null = null + function scheduleTreeUpdate(): void { + if (updateTimer) return + updateTimer = setTimeout(() => { + updateTimer = null const changed = treeProvider.refresh() - decorationProvider.refresh([...event.uris, ...changed] as vscode.Uri[]) + if (changed.length > 0) decorationProvider.refresh(changed) updateBadge(treeView) - }) - ) + }, 150) + } treeProvider.refresh() decorationProvider.refresh() @@ -316,9 +326,10 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor // 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. + // Called on every keystroke in a GTS file, so the badge recount is debounced. const changed = treeProvider.refresh() if (changed.length > 0) decorationProvider.refresh(changed) - updateBadge(treeView) + scheduleTreeUpdate() } } } diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index 150be78..fad1aaf 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -1,7 +1,8 @@ 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 type { GtsPrefixIssue, JsonEntity } from '@gts/shared' +import * as fs from 'fs' +import { getRegistry, getRegistryRevision } from './registryStore' import { getDocumentValidationErrors } from './validation' import * as jsonc from 'jsonc-parser' import * as YAML from 'yaml' @@ -19,6 +20,8 @@ interface GtsIdReference { /** The JSON leaf field name this value is assigned to (e.g. "$id", "type"). */ fieldName: string range: vscode.Range + /** Offset of the value's first character (after any opening quote). */ + contentOffset: number sourcePath: string isValid: boolean // Whether the ID is a valid GTS identifier or pattern /** Whether the value is a valid GTS wildcard pattern (e.g. "gts.*"). */ @@ -58,67 +61,90 @@ function getRelativePath(absolutePath: string): string { return vscode.workspace.asRelativePath(absolutePath) } +// Definition-line lookups, valid for one registry revision (any re-index +// invalidates them, since the defining file may have changed). +let definitionLineCache = { revision: -1, lines: new Map() } + /** - * Find the line number where a JSON entity is defined in its file + * Zero-based line on which `entity` is defined: the line of its id field (or, + * failing that, of its list item). Reads the live buffer when the file is open, + * otherwise the file on disk (asynchronously), and caches the result. This used + * to be a synchronous disk read + line scan for every segment of every link on + * every link request, and matched the first line merely *containing* the id. */ -function findEntityLineInFile(filePath: string, entityId: string): number { +async function findDefinitionLine(entity: JsonEntity): Promise { + const filePath = entity.file?.path + if (!filePath) return 0 + const revision = getRegistryRevision() + if (definitionLineCache.revision !== revision) definitionLineCache = { revision, lines: new Map() } + const key = `${filePath}\0${entity.id}\0${entity.listSequence ?? ''}` + const cached = definitionLineCache.lines.get(key) + if (cached !== undefined) return cached + + let line = 0 try { - const fs = require('fs') - const content = fs.readFileSync(filePath, 'utf8') - const lines = content.split('\n') - - // Look for the entity ID in the file - // It could be in various fields like "$id", "id", "type", etc. - let foundLine = -1 - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - if (line.includes(entityId)) { - foundLine = i - break - } - } + const openDoc = vscode.workspace.textDocuments.find(d => d.uri.scheme === 'file' && d.uri.fsPath === filePath) + const text = openDoc ? openDoc.getText() : await fs.promises.readFile(filePath, 'utf8') + line = lineOfOffset(text, locateDefinitionOffset(text, filePath, entity)) + } catch (error) { + console.error(`[GTS] Could not locate ${entity.id} in ${filePath}:`, error) + } + if (definitionLineCache.revision === revision) definitionLineCache.lines.set(key, line) + return line +} - if (foundLine === -1) { - return 0 // Not found, default to first line +/** Offset of the entity's id field value (or its list item) in the file's text. */ +function locateDefinitionOffset(text: string, filePath: string, entity: JsonEntity): number { + const seq = entity.listSequence + const idField = entity.selectedEntityIdField || entity.selectedSchemaIdField + const itemPath: Array = seq !== undefined ? [seq] : [] + const idPath = idField ? [...itemPath, idField] : itemPath + + let offset: number | undefined + if (isYamlFileName(filePath)) { + const doc = YAML.parseDocument(text) + const rangeStart = (path: Array) => { + const node = path.length > 0 ? doc.getIn(path, true) : doc.contents + return (node as { range?: [number, number, number] } | null | undefined)?.range?.[0] } - - // Found the entity ID, now find the start of the JSON object - // Go backwards to find the opening brace - let braceCount = 0 - let inString = false - let escapeNext = false - - for (let j = foundLine; j >= 0; j--) { - const line = lines[j] - - // Simple approach: look for a line that starts with { or ends with { - const trimmed = line.trim() - if (trimmed === '{' || trimmed.endsWith('{')) { - // Check if this is the outermost brace for this object - // by verifying we haven't seen any closing braces before this - return j - } - - // Also check for array element start - if (trimmed.startsWith('{')) { - return j - } + offset = rangeStart(idPath) ?? (itemPath.length > 0 ? rangeStart(itemPath) : undefined) + } else { + const root = jsonc.parseTree(text, undefined, { allowTrailingComma: true }) + if (root) { + offset = (idPath.length > 0 ? jsonc.findNodeAtLocation(root, idPath)?.offset : undefined) + ?? (itemPath.length > 0 ? jsonc.findNodeAtLocation(root, itemPath)?.offset : undefined) } - - // If we didn't find an opening brace, return the line where we found the ID - return foundLine - } catch (error) { - console.error(`Error finding entity line in file ${filePath}:`, error) } + // Last resort (e.g. YAML entities defined inline deep in a config file). + if (offset === undefined) { + const idx = text.indexOf(entity.id) + offset = idx >= 0 ? idx : 0 + } + return offset +} + +function lineOfOffset(text: string, offset: number): number { + let line = 0 + for (let i = text.indexOf('\n'); i !== -1 && i < offset; i = text.indexOf('\n', i + 1)) line++ + return line +} - return 0 // Default to first line +/** A link to an entity's definition; its target is computed only when the link is used. */ +class GtsDefinitionLink extends vscode.DocumentLink { + constructor(range: vscode.Range, readonly entityId: string) { + super(range) + } } /** * DocumentLinkProvider for GTS IDs * Makes GTS IDs clickable and provides hover information */ -export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.HoverProvider { +export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.HoverProvider { + // Parsed GTS references per open document version. Decorations, links and + // every hover over the same unchanged document reuse one parse. + private referenceCache = new WeakMap() + /** * The registry is the shared, persistent, index-only registry maintained in * registryStore. We never build our own here — decorations/links/hovers just @@ -290,12 +316,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // elsewhere). Highlight the whole value in red; the authoritative diagnostic is // published by the shared validator (validation.ts) to avoid duplicate markers. if (ref.urlPrefixIssue) { - const text = document.getText() - const refOffset = document.offsetAt(ref.range.start) - let gtsStartOffset = refOffset - if (text[refOffset] === '"') { - gtsStartOffset = refOffset + 1 - } + const gtsStartOffset = ref.contentOffset const startPos = document.positionAt(gtsStartOffset) const endPos = document.positionAt(gtsStartOffset + ref.rawValue.length) errorRanges.push(new vscode.Range(startPos, endPos)) @@ -307,12 +328,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // closed files alike, so the file's status doesn't depend on whether it // happens to be open. if (!ref.isValid) { - const text = document.getText() - const refOffset = document.offsetAt(ref.range.start) - let gtsStartOffset = refOffset - if (text[refOffset] === '"') { - gtsStartOffset = refOffset + 1 - } + const gtsStartOffset = ref.contentOffset const startPos = document.positionAt(gtsStartOffset) const endPos = document.positionAt(gtsStartOffset + ref.rawValue.length) errorRanges.push(new vscode.Range(startPos, endPos)) @@ -322,13 +338,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // Wildcard patterns (e.g. "gts.*" in x-gts-ref) are valid GTS references // but don't resolve to specific entities — show as schema decoration if (ref.isPattern) { - const text = document.getText() - const refOffset = document.offsetAt(ref.range.start) - let gtsStartOffset = refOffset - if (text[refOffset] === '"') { - gtsStartOffset = refOffset + 1 - } - gtsStartOffset += ref.uriPrefixLength + const gtsStartOffset = ref.contentOffset + ref.uriPrefixLength const startPos = document.positionAt(gtsStartOffset) const endPos = document.positionAt(gtsStartOffset + ref.id.length) schemaRanges.push(new vscode.Range(startPos, endPos)) @@ -366,16 +376,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove }) // Calculate the offset of the string value (excluding quotes) - const text = document.getText() - const refOffset = document.offsetAt(ref.range.start) - - // Find the actual start of the GTS ID (after the opening quote and any - // gts:// URI prefix, so the highlighted segments align with the canonical id). - let gtsStartOffset = refOffset - if (text[refOffset] === '"') { - gtsStartOffset = refOffset + 1 - } - gtsStartOffset += ref.uriPrefixLength + const gtsStartOffset = ref.contentOffset + ref.uriPrefixLength // References inside an "examples" field show missing entities as a neutral // gray chip instead of a red error. @@ -427,17 +428,20 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove * 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) + const cached = this.referenceCache.get(document) + if (cached && cached.version === document.version) return cached.references + const references = document.languageId === 'yaml' || isYamlFileName(document.fileName) + ? this.findGtsReferencesYaml(document) + : this.findGtsReferencesJson(document) + this.referenceCache.set(document, { version: document.version, references }) + return references } /** * 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 { + private buildGtsIdReference(rawValue: string, fieldName: string, sourcePath: string, range: vscode.Range, contentOffset: number): GtsIdReference { const id = normalizeGtsId(rawValue) const uriPrefixLength = rawValue.startsWith(GTS_URI_PREFIX) ? GTS_URI_PREFIX.length : 0 const isValid = isGtsId(id) @@ -451,6 +455,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove uriPrefixLength, fieldName, range, + contentOffset, sourcePath, isValid: isValid || isWildcardPattern, isPattern: isWildcardPattern, @@ -517,7 +522,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove const fieldName = pathKeys[pathKeys.length - 1] || '' const sourcePath = pathKeys.join('.') - references.push(this.buildGtsIdReference(value, fieldName, sourcePath, range)) + references.push(this.buildGtsIdReference(value, fieldName, sourcePath, range, valueStartOffset)) } }) } catch (error) { @@ -569,25 +574,8 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } } - const rawValue = value - 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) - - references.push({ - id, - rawValue, - uriPrefixLength, - fieldName, - range, - sourcePath, - isValid: isValid || isWildcardPattern, - isPattern: isWildcardPattern, - urlPrefixIssue - }) + // String literals are always double-quoted in JSON; content starts after the quote. + references.push(this.buildGtsIdReference(value, fieldName, sourcePath, range, offset + 1)) } } }) @@ -624,16 +612,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove const parts = parseGtsIdParts(ref.id) // Calculate the offset of the string value (excluding quotes) - const text = document.getText() - const refOffset = document.offsetAt(ref.range.start) - - // Find the actual start of the GTS ID (after the opening quote and any - // gts:// URI prefix). - let gtsStartOffset = refOffset - if (text[refOffset] === '"') { - gtsStartOffset = refOffset + 1 - } - gtsStartOffset += ref.uriPrefixLength + const gtsStartOffset = ref.contentOffset + ref.uriPrefixLength let currentOffset = gtsStartOffset let hasMissingAncestor = false @@ -652,24 +631,10 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove : this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) if (entity && entity.file) { - // Create a document link - const link = new vscode.DocumentLink(partRange) - - // Find the line number where the entity is defined - const lineNumber = findEntityLineInFile(entity.file.path, entityIdToLookup) - - // Create a command URI that opens the file at the specific line - const uri = vscode.Uri.parse( - `command:vscode.open?${encodeURIComponent(JSON.stringify([ - vscode.Uri.file(entity.file.path), - { selection: new vscode.Range(lineNumber, 0, lineNumber, 0) } - ]))}` - ) - - link.target = uri - // Don't set tooltip - we provide rich hover via HoverProvider instead - - links.push(link) + // The target (which needs the definition line) is filled in by + // resolveDocumentLink, only for a link that is actually followed. + // No tooltip: the HoverProvider supplies a rich hover instead. + links.push(new GtsDefinitionLink(partRange, entityIdToLookup)) } else if (!entity) { hasMissingAncestor = true } @@ -681,6 +646,21 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove return links } + /** Fill in a definition link's target (file + line) when the link is used. */ + async resolveDocumentLink(link: vscode.DocumentLink): Promise { + if (!(link instanceof GtsDefinitionLink) || !this.registry) return link + const entity = this.registry.jsonSchemas.get(link.entityId) || this.registry.jsonObjs.get(link.entityId) + if (!entity?.file) return link + const lineNumber = await findDefinitionLine(entity) + link.target = vscode.Uri.parse( + `command:vscode.open?${encodeURIComponent(JSON.stringify([ + vscode.Uri.file(entity.file.path), + { selection: new vscode.Range(lineNumber, 0, lineNumber, 0) } + ]))}` + ) + return link + } + /** * Turn a list of candidate suggestion ids into rendered markdown list items, * skipping any that don't resolve to a real registry entity. Returning the @@ -748,12 +728,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove const gtsId = matchedRef.id // Calculate the offset within the string value (excluding quotes) - const text = document.getText() - const refOffset = document.offsetAt(matchedRef.range.start) - let gtsStartOffset = refOffset - if (text[refOffset] === '"') { - gtsStartOffset = refOffset + 1 - } + const gtsStartOffset = matchedRef.contentOffset // Create hover content const markdown = new vscode.MarkdownString() @@ -965,7 +940,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } // Add file path as a clickable link - const lineNumber = findEntityLineInFile(entity.file.path, entityIdToLookup) + const lineNumber = await findDefinitionLine(entity) const fileUri = vscode.Uri.file(entity.file.path).with({ fragment: `L${lineNumber + 1}` }) diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index 7588311..6c82899 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -26,34 +26,54 @@ export function onValidationCompleted(listener: (uri: vscode.Uri) => void): vsco return new vscode.Disposable(() => validationCompletedListeners.delete(listener)) } +/** + * A document's text plus its syntax tree, parsed lazily at most once and shared + * by every error-position lookup of one validation (previously each error + * re-read the text and re-parsed the whole document, several times over). + */ +class ParsedDocument { + readonly text: string + readonly isYaml: boolean + private jsonTree: jsonc.Node | null | undefined + private yamlTree: YAML.Document.Parsed | null | undefined + + constructor(readonly document: vscode.TextDocument) { + this.text = document.getText() + this.isYaml = document.languageId === 'yaml' || isYamlFileName(document.fileName) + } + + get jsonRoot(): jsonc.Node | null { + if (this.jsonTree === undefined) { + this.jsonTree = jsonc.parseTree(this.text, undefined, { allowTrailingComma: true }) ?? null + } + return this.jsonTree + } + + /** The parsed YAML document, or null if it can't be parsed or is empty. */ + get yamlDoc(): YAML.Document.Parsed | null { + if (this.yamlTree === undefined) { + try { + const doc = YAML.parseDocument(this.text) + this.yamlTree = doc.contents == null ? null : doc + } catch { + this.yamlTree = null + } + } + return this.yamlTree + } +} + /** * Convert validation errors to VSCode diagnostics */ function validationErrorsToDiagnostics(errors: FileProblem[], document: vscode.TextDocument): vscode.Diagnostic[] { const diagnostics: vscode.Diagnostic[] = [] + const parsed = new ParsedDocument(document) for (const error of errors) { - console.log(`[GTS Validation] Processing error:`, { - keyword: error.keyword, - instancePath: error.instancePath, - message: error.message, - params: error.params - }) - - // Try to find the error location in the document - let range: vscode.Range - - // Try to find position using instancePath (even if empty) or error-specific logic - const position = findErrorPosition(document, error.instancePath || '', error) - console.log(`[GTS Validation] Position found for path '${error.instancePath}':`, position ? `line ${position.start.line}` : 'null') - - if (position) { - range = position - } else { - // Fallback to start of document - console.log(`[GTS Validation] Using fallback position (start of document)`) - range = new vscode.Range(0, 0, 0, 1) - } + // Locate the error via its instancePath or error-specific logic; fall back + // to the start of the document. + const range = findErrorPosition(parsed, error.instancePath || '', error) ?? new vscode.Range(0, 0, 0, 1) const diagnostic = new vscode.Diagnostic(range, error.message, problemSeverity(error)) @@ -68,8 +88,8 @@ function validationErrorsToDiagnostics(errors: FileProblem[], document: vscode.T /** * Find the range of an error in the document based on instancePath and error details */ -function findErrorPosition(document: vscode.TextDocument, instancePath: string, error: ValidationError): vscode.Range | null { - const text = document.getText() +function findErrorPosition(parsed: ParsedDocument, instancePath: string, error: ValidationError): vscode.Range | null { + const { text, document } = parsed // Remove leading slash from instancePath (e.g., '/users/0/email' -> 'users/0/email') const path = instancePath.replace(/^\//, '') @@ -84,7 +104,7 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, return new vscode.Range(pos, pos.translate(0, 1)) } } else { - const position = findObjectAtPath(text, document, path) + const position = findObjectAtPath(parsed, path) if (position) { return position } @@ -94,7 +114,7 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, // 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) + const keyRange = findKeyRangeAtInstancePath(parsed, instancePath, additionalProp) if (keyRange) { return keyRange } @@ -116,11 +136,11 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, // `/allOf/1/properties/level`, whose value is itself a schema) the property // key is underlined instead. if (instancePath && instancePath !== '/') { - const valueRange = findValueRangeAtInstancePath(document, instancePath) + const valueRange = findValueRangeAtInstancePath(parsed, instancePath) if (valueRange) { return valueRange } - const keyRange = findKeyRangeAtInstancePath(document, instancePath) + const keyRange = findKeyRangeAtInstancePath(parsed, instancePath) if (keyRange) { return keyRange } @@ -151,20 +171,16 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, // 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}'`) // If instancePath is empty, search by schemaId in params if (!path && error.params && 'schemaId' in error.params) { const schemaId = (error.params as any).schemaId as string - console.log(`[GTS Validation] Searching for type field with value: ${schemaId}`) - const position = findTypeFieldByValue(text, document, schemaId) - console.log(`[GTS Validation] findTypeFieldByValue returned:`, position) + const position = findTypeFieldByValue(parsed, schemaId) if (position) { return position } } else if (path) { - const position = findObjectAtPath(text, document, path) - console.log(`[GTS Validation] findObjectAtPath returned:`, position) + const position = findObjectAtPath(parsed, path) if (position) { return position } @@ -173,7 +189,7 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, // 6. Fallback: try to find the property key using AST or quoted regex if (path) { - const keyRange = findKeyRangeAtInstancePath(document, instancePath) + const keyRange = findKeyRangeAtInstancePath(parsed, instancePath) if (keyRange) { return keyRange } @@ -201,8 +217,8 @@ function findErrorPosition(document: vscode.TextDocument, instancePath: string, * Find a "type" field with a specific value in the JSON * Returns a range highlighting the "type" field name */ -function findTypeFieldByValue(text: string, document: vscode.TextDocument, typeValue: string): vscode.Range | null { - console.log(`[GTS Validation] Searching for "type" field with value: ${typeValue}`) +function findTypeFieldByValue(parsed: ParsedDocument, typeValue: string): vscode.Range | null { + const { text, document } = parsed // Escape the typeValue for use in regex const escapedValue = escapeRegex(typeValue) @@ -220,11 +236,9 @@ function findTypeFieldByValue(text: string, document: vscode.TextDocument, typeV const startPos = document.positionAt(typeKeyStart) const endPos = document.positionAt(typeKeyEnd) - console.log(`[GTS Validation] Found "type" field at line ${startPos.line}, col ${startPos.character}`) return new vscode.Range(startPos, endPos) } - console.log(`[GTS Validation] Did not find "type" field with value: ${typeValue}`) return null } @@ -232,7 +246,8 @@ function findTypeFieldByValue(text: string, document: vscode.TextDocument, typeV * Find an object in the JSON at the given path (handles array indices) * Returns a range highlighting the object's "id" or "type" field, or opening brace */ -function findObjectAtPath(text: string, document: vscode.TextDocument, path: string): vscode.Range | null { +function findObjectAtPath(parsed: ParsedDocument, path: string): vscode.Range | null { + const { text, document } = parsed if (!path) { // Root level - find first opening brace const rootMatch = text.match(/\{/) @@ -249,7 +264,7 @@ function findObjectAtPath(text: string, document: vscode.TextDocument, path: str if (segments.length === 1 && /^\d+$/.test(segments[0])) { // Root-level array, e.g., path = "1" means second item in array const arrayIndex = parseInt(segments[0], 10) - return findNthObjectInArray(text, document, arrayIndex) + return findNthObjectInArray(parsed, arrayIndex) } // For nested paths, navigate through the structure @@ -257,7 +272,7 @@ function findObjectAtPath(text: string, document: vscode.TextDocument, path: str const lastSegment = segments[segments.length - 1] if (/^\d+$/.test(lastSegment)) { const arrayIndex = parseInt(lastSegment, 10) - return findNthObjectInArray(text, document, arrayIndex) + return findNthObjectInArray(parsed, arrayIndex) } // Try to find a property by name @@ -276,8 +291,8 @@ function findObjectAtPath(text: string, document: vscode.TextDocument, path: str * Find the Nth object in a root-level array * Highlights the object's "id" or "type" field, or opening brace */ -function findNthObjectInArray(text: string, document: vscode.TextDocument, index: number): vscode.Range | null { - console.log(`[GTS Validation] findNthObjectInArray looking for index=${index}`) +function findNthObjectInArray(parsed: ParsedDocument, index: number): vscode.Range | null { + const { text, document } = parsed let braceCount = 0 let objectCount = 0 let inArray = false @@ -288,7 +303,6 @@ function findNthObjectInArray(text: string, document: vscode.TextDocument, index if (char === '[' && braceCount === 0) { inArray = true - console.log(`[GTS Validation] Found array start at position ${i}`) continue } @@ -298,25 +312,21 @@ function findNthObjectInArray(text: string, document: vscode.TextDocument, index if (braceCount === 0) { // Start of a new object at array level currentObjectStart = i - console.log(`[GTS Validation] Found object start at position ${i}, objectCount=${objectCount}`) } braceCount++ } else if (char === '}') { braceCount-- if (braceCount === 0) { // End of object at array level - console.log(`[GTS Validation] Object ${objectCount} ended at position ${i}`) if (objectCount === index) { // Found the target object, now find its "id" or "type" field const objectText = text.substring(currentObjectStart, i + 1) - console.log(`[GTS Validation] Found target object at index ${index}, text length=${objectText.length}`) // Try to find "id" field first const idMatch = objectText.match(/"id"\s*:\s*"([^"]+)"/) if (idMatch && idMatch.index !== undefined) { const idStartPos = document.positionAt(currentObjectStart + idMatch.index + 1) // +1 to skip opening quote const idEndPos = document.positionAt(currentObjectStart + idMatch.index + 3) // "id" length - console.log(`[GTS Validation] Highlighting "id" field at line ${idStartPos.line}`) return new vscode.Range(idStartPos, idEndPos) } @@ -325,13 +335,11 @@ function findNthObjectInArray(text: string, document: vscode.TextDocument, index if (typeMatch && typeMatch.index !== undefined) { const typeStartPos = document.positionAt(currentObjectStart + typeMatch.index + 1) const typeEndPos = document.positionAt(currentObjectStart + typeMatch.index + 5) // "type" length - console.log(`[GTS Validation] Highlighting "type" field at line ${typeStartPos.line}`) return new vscode.Range(typeStartPos, typeEndPos) } // Fallback: highlight opening brace const pos = document.positionAt(currentObjectStart) - console.log(`[GTS Validation] Highlighting opening brace at line ${pos.line}`) return new vscode.Range(pos, pos.translate(0, 1)) } objectCount++ @@ -339,7 +347,6 @@ function findNthObjectInArray(text: string, document: vscode.TextDocument, index } } - console.log(`[GTS Validation] Did not find object at index ${index}, only found ${objectCount} objects`) return null } @@ -376,24 +383,18 @@ function instancePathSegments(instancePath: string): Array { * 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 { +function findKeyRangeAtInstancePath(parsed: ParsedDocument, 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) + const { text, document } = parsed - if (isYaml) { - let doc: YAML.Document.Parsed - try { - doc = YAML.parseDocument(text) - } catch { - return null - } - if (doc.contents == null) return null + if (parsed.isYaml) { + const doc = parsed.yamlDoc + if (!doc) return null const parentSegments = segments.slice(0, -1) const targetKey = String(segments[segments.length - 1]) @@ -412,7 +413,7 @@ function findKeyRangeAtInstancePath(document: vscode.TextDocument, instancePath: return null } - const root = jsonc.parseTree(text, undefined, { allowTrailingComma: true }) + const root = parsed.jsonRoot if (!root) return null const node = jsonc.findNodeAtLocation(root, segments) @@ -441,26 +442,17 @@ function findKeyRangeAtInstancePath(document: vscode.TextDocument, instancePath: * 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 { +function findValueRangeAtInstancePath(parsed: ParsedDocument, 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) + return parsed.isYaml ? findValueRangeYaml(parsed, segments) : findValueRangeJson(parsed, 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 +function findValueRangeYaml(parsed: ParsedDocument, segments: Array): vscode.Range | null { + const { text, document } = parsed + const doc = parsed.yamlDoc + if (!doc) return null const node = doc.getIn(segments, true) if (!YAML.isScalar(node) || !node.range) return null @@ -483,8 +475,9 @@ function findValueRangeYaml(text: string, document: vscode.TextDocument, segment * 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 }) +function findValueRangeJson(parsed: ParsedDocument, segments: Array): vscode.Range | null { + const { document } = parsed + const root = parsed.jsonRoot if (!root) return null const node = jsonc.findNodeAtLocation(root, segments) @@ -524,7 +517,6 @@ export async function validateOpenDocument(document: vscode.TextDocument) { const fileName = path.basename(document.fileName) const filePath = document.uri.fsPath - console.log(`[GTS Validation] Validating: ${filePath}`) // Parse the document content, choosing the parser by extension so YAML files // are not mis-parsed as JSONC. @@ -534,7 +526,6 @@ export async function validateOpenDocument(document: vscode.TextDocument) { content = parseGtsFileContent(fileName, text) } catch (parseError: any) { parseErrorMessage = parseError?.message || String(parseError) - console.log(`[GTS Validation] Failed to parse ${isYamlFileName(fileName) ? 'YAML' : 'JSON'}: ${parseErrorMessage}`) // If parsing fails, store as text and let registry handle it content = text } @@ -568,7 +559,6 @@ export async function validateOpenDocument(document: vscode.TextDocument) { // Only validate the entities defined in THIS document. Other files remain // indexed (for $ref / GTS-reference resolution) but are not re-validated. const entities = fileEntities(registry, filePath) - console.log(`[GTS Validation] Validating ${entities.length} entities in ${fileName}...`) for (const e of entities) await registry.validateEntity(e) errors = collectFileProblems(registry, filePath, buildDefinitionIndex(registry)) } @@ -587,11 +577,9 @@ export async function validateOpenDocument(document: vscode.TextDocument) { documentValidationErrors.set(document.uri.toString(), errors.filter(e => e.severity !== 'warning')) 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) { @@ -927,7 +915,6 @@ export async function revalidateDependents(changedPath: string, previousIds?: It } } - console.log(`[GTS Validation] Revalidating ${dependentPaths.size} dependents of ${path.basename(changedPath)}`) for (const dependentPath of dependentPaths) { const openDoc = openByPath.get(dependentPath) if (openDoc) { @@ -938,6 +925,23 @@ export async function revalidateDependents(changedPath: string, previousIds?: It } } +/** + * GTS errors and warnings currently published by this extension. Reads only our + * own diagnostic collections instead of every diagnostic in VS Code. + */ +export function countPublishedProblems(): { errors: number; warnings: number } { + const counts = { errors: 0, warnings: 0 } + const tally = (_uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[]) => { + for (const d of diagnostics) { + if (d.severity === vscode.DiagnosticSeverity.Error) counts.errors++ + else if (d.severity === vscode.DiagnosticSeverity.Warning) counts.warnings++ + } + } + diagnosticCollection?.forEach(tally) + workspaceDiagnosticCollection?.forEach(tally) + return counts +} + export function resetValidationDiagnostics(): void { documentValidationErrors.clear() documentValidationGenerations.clear() @@ -966,7 +970,6 @@ export function initValidation(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidOpenTextDocument(doc => { if (!isGtsCandidateFile(doc)) return - console.log(`[GTS Validation] Document opened: ${doc.fileName} (language: ${doc.languageId})`) void validateOpenDocument(doc) }) ) @@ -979,7 +982,6 @@ export function initValidation(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidCloseTextDocument(async doc => { if (!isGtsCandidateFile(doc)) return - console.log(`[GTS Validation] Document closed: ${doc.fileName}`) const validationKey = doc.uri.toString() documentValidationGenerations.set(validationKey, (documentValidationGenerations.get(validationKey) || 0) + 1) documentValidationErrors.delete(validationKey) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 574b5e6..64508f8 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -185,6 +185,21 @@ export class JsonRegistry { // invalidateFile / reset). See buildDependencyGraph() for the edge model. private depGraph: DependencyGraph | null = null + // Bumped on every change to the indexed content (index/invalidate/reset). + // Validation results and compiled validators are only reused within one + // generation, because an entity's result depends on the rest of the registry + // ($ref targets, parent schemas, referenced ids). + private contentGeneration = 0 + // Per-entity validation result for the current generation. Shared by + // concurrent callers, and lets a pass validate each entity once instead of + // re-validating the whole parent chain for every derived schema. + private validationMemo = new WeakMap }>() + // Compiled Ajv validators for instance validation, keyed by the (winning) + // schema's content object. Each gets its own Ajv instance, exactly as before, + // but is compiled once per generation instead of once per instance. + private instanceValidators: { generation: number; byContent: Map> } = + { generation: -1, byContent: new Map() } + constructor() { this.jsonObjs = new Map() this.jsonSchemas = new Map() @@ -208,6 +223,7 @@ export class JsonRegistry { this.defaultFilePath = null this.invalidateGtsStore() this.depGraph = null + this.contentGeneration++ } /** Drop the cached gts-ts store so it is rebuilt from current schemas on next use. */ @@ -257,6 +273,7 @@ export class JsonRegistry { // reverse-dependency graph (both are rebuilt lazily on next use). this.invalidateGtsStore() this.depGraph = null + this.contentGeneration++ if (this.jsonFiles.has(path)) { this.jsonFiles.delete(path) } @@ -628,7 +645,42 @@ export class JsonRegistry { * in interleaved order, duplicating or dropping errors. */ async validateEntity(entity: JsonEntity): Promise { - entity.validation = await this.computeEntityValidation(entity) + const generation = this.contentGeneration + let memo = this.validationMemo.get(entity) + if (!memo || memo.generation !== generation) { + const result = this.computeEntityValidation(entity) + memo = { generation, result } + this.validationMemo.set(entity, memo) + // Don't cache a failure: let the next call retry. + const current = memo + result.catch(() => { if (this.validationMemo.get(entity) === current) this.validationMemo.delete(entity) }) + } + const validation = await memo.result + // A newer computation (registry changed meanwhile) owns the published result. + if (this.validationMemo.get(entity) === memo) entity.validation = validation + } + + /** + * Compiled validator for instance validation against `schemaContent`, reused + * across instances within the current content generation. + */ + private getInstanceValidator(schemaContent: object): Promise { + if (this.instanceValidators.generation !== this.contentGeneration) { + this.instanceValidators = { generation: this.contentGeneration, byContent: new Map() } + } + const cache = this.instanceValidators.byContent + let validator = cache.get(schemaContent) + if (!validator) { + // 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. + validator = this.createAjvInstance().compileAsync(stripXGtsRefForAjv(schemaContent)) + cache.set(schemaContent, validator) + validator.catch(() => { if (cache.get(schemaContent) === validator) cache.delete(schemaContent) }) + } + return validator } private async computeEntityValidation(entity: JsonEntity): Promise { @@ -896,15 +948,8 @@ export class JsonRegistry { } try { - const ajv = this.createAjvInstance() - - // 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)) + // Compiled with async $ref resolution, once per schema per generation. + const validate = await this.getInstanceValidator(schema.content) const valid = validate(entity.content) as boolean @@ -1071,6 +1116,16 @@ export class JsonRegistry { }) } + // GTS id formats emitted by schema generators (e.g. gts-rust) as annotations + // next to an `x-gts-ref` that does the actual checking. Registered as + // accept-all — exactly how Ajv treated them before (ignored as unknown) — + // so each compile no longer logs an "unknown format" warning. + // Same for the sized-integer formats Rust's schemars emits (int32/int64 are + // already provided by ajv-formats). + for (const name of ['gts-instance-id', 'gts-type-id', 'int8', 'int16', 'uint', 'uint8', 'uint16', 'uint32', 'uint64']) { + ajv.addFormat(name, true) + } + // Add custom schema loader that resolves GTS IDs from the registry ajv.addKeyword({ keyword: 'gtsRef', From 0e478a96e1073d0576f90b1e67c51a1387085581 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 14:51:17 +0300 Subject: [PATCH 44/57] fix(shared): resolve gts:// $refs during x-gts-ref instance validation Instances of any type derived via `allOf: [{ "$ref": "gts://" }]` were reported with "Cannot resolve $ref 'gts://' for x-gts-ref traversal", even though the parent schema exists and Cmd+click resolves it. The XGtsRefValidator was constructed without a store, so it could never follow a gts:// $ref into the parent schema. That used to be skipped silently; gts-ts now fails closed on an unresolvable $ref (0390a1b, "fix(x-gts-ref): preserve selected leaf through composition"), turning every such instance red. Pass the registry's gts-ts store so the parent's x-gts-ref constraints are actually traversed and enforced. Existence checks stay off (GtsRefValidationMode.None): referenced-entity existence is already reported by the registry's own gtsRefs check, so enabling them would duplicate "not found" errors. The entity's own id is passed as selfId. In the gts workspace this removes the false error from 160 entities (145 were red only because of it) and surfaces the real x-gts-ref violations it was masking in the 8 invalid/testref_selfref test instances; no other validation result changes. Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 64508f8..52c6435 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -4,7 +4,7 @@ import { isYamlFileName } from './parse.js' import { findSchemaPropertyPath, findXGtsRefPath, findTraitRequiredPath, getInstanceFieldSubschema } from './schemaParser.js' import Ajv, { type ValidateFunction, type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' -import { GtsModifiers, GtsStore, createJsonEntity } from '@globaltypesystem/gts-ts' +import { GtsModifiers, GtsStore, GtsRefValidationMode, createJsonEntity } from '@globaltypesystem/gts-ts' // XGtsRefValidator is not re-exported from the package index, so import it from // its published subpath module. import { XGtsRefValidator } from '@globaltypesystem/gts-ts/dist/x-gts-ref.js' @@ -963,10 +963,16 @@ export class JsonRegistry { // (the value must be a GTS ID matching the declared prefix/pattern). // Ajv treats it as an unknown keyword and silently ignores it, so it is // enforced explicitly here (mirrors gts-ts store.validateInstance). - // No store is passed: referenced-entity existence is already covered by - // the gtsRefs registry check above, so this validator only enforces the - // GTS-ID format and the prefix/pattern constraint. - const xGtsRefErrors = new XGtsRefValidator().validateInstance(entity.content, schema.content) + // The validator gets the registry's gts-ts store so it can follow + // `gts://` `$ref`s (e.g. `allOf: [{ $ref: }]`) into parent + // schemas: without one, every such `$ref` is unresolvable and gts-ts + // reports "Cannot resolve $ref ... for x-gts-ref traversal" (it fails + // closed rather than skip the parent's constraints). Existence checks + // stay off (mode None): referenced-entity existence is already reported + // by the gtsRefs registry check above, so this validator only enforces + // the GTS-ID format and the prefix/pattern constraint. + const xGtsRefErrors = new XGtsRefValidator(this.getGtsStore(), GtsRefValidationMode.None) + .validateInstance(entity.content, schema.content, '', undefined, entity.id) for (const err of xGtsRefErrors) { validation.errors.push({ instancePath: fieldPathToInstancePath(err.fieldPath), From e9688c372638ccf5b93814e549435d56868dad46 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 14:57:18 +0300 Subject: [PATCH 45/57] feat(vscode): add viewer tree context menu Expose GTS: Open Viewer when right-clicking a file in the Discovered GTS files tree, matching the action available in the native Explorer. Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 850de9d..0edca46 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -93,6 +93,13 @@ "when": "resourceExtname =~ /\\.(json|jsonc|gts)$/" } ], + "view/item/context": [ + { + "command": "gts-kit.openViewer", + "group": "navigation@100", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile" + } + ], "view/title": [ { "command": "gts-kit.refreshFileExplorer", From 1ef048219b926963276976643806502ead0372ba Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 15:10:34 +0300 Subject: [PATCH 46/57] fix(vscode): use standard problem decorations Defer error and warning file styling to VS Code's Problems provider so Explorer entries use the configured standard colors and counts without an extra GTS exclamation badge. Keep the green decoration for valid GTS files. Signed-off-by: Artifizer --- apps/vscode-extension/src/gtsExplorer.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index 035b29d..1995317 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -233,12 +233,7 @@ export class GtsFileDecorationProvider implements vscode.FileDecorationProvider provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined { if (!isDiscoveredGtsFile(uri.fsPath)) return undefined - if (hasGtsErrors(uri)) { - return new vscode.FileDecoration('!', 'GTS: file has validation errors', new vscode.ThemeColor('charts.red')) - } - if (hasGtsWarnings(uri)) { - return new vscode.FileDecoration('!', 'GTS: file has warnings', new vscode.ThemeColor('charts.yellow')) - } + if (hasGtsErrors(uri) || hasGtsWarnings(uri)) return undefined return new vscode.FileDecoration(undefined, 'GTS: file is valid', new vscode.ThemeColor('charts.green')) } } From e46e6b0a761861a5cb52bdb78052f6ecd6823910 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 29 Sep 2026 02:08:08 +0300 Subject: [PATCH 47/57] fix(vscode): ship re2.wasm next to the bundled extension gts-ts now compiles schema patterns with re2-wasm (c9070a8, "compile schema patterns with RE2 to prevent ReDoS"). re2-wasm loads its re2.wasm binary from the directory of the running script, which for the esbuild bundle is dist/, so every build since then threw ENOENT on load and the extension never activated. Copy re2.wasm (resolved through gts-ts, i.e. the exact copy it depends on) into dist/ at the end of every successful build, including watch rebuilds. The .vsix staging already copies all of dist/, so packaged builds get it too. Signed-off-by: Artifizer --- apps/vscode-extension/build.mjs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/vscode-extension/build.mjs b/apps/vscode-extension/build.mjs index 8cf5236..56475d5 100644 --- a/apps/vscode-extension/build.mjs +++ b/apps/vscode-extension/build.mjs @@ -1,6 +1,29 @@ import * as esbuild from 'esbuild' import { promises as fs } from 'fs' import path from 'path' +import { createRequire } from 'module' + +const require = createRequire(import.meta.url) + +/** + * gts-ts compiles schema patterns with re2-wasm, which loads its `re2.wasm` + * binary from the directory of the running script. Bundled, that is `dist/`, + * so the binary must sit next to `dist/extension.js` or the extension throws + * on load. Resolved through gts-ts so it's the exact copy gts-ts depends on. + * + * @type {esbuild.Plugin} + */ +const copyRe2WasmPlugin = { + name: 'copy-re2-wasm', + setup(build) { + build.onEnd(async result => { + if (result.errors.length > 0) return + const gtsTsDir = path.dirname(require.resolve('@globaltypesystem/gts-ts/package.json', { paths: ['../../packages/shared'] })) + const re2Dir = path.dirname(require.resolve('re2-wasm/package.json', { paths: [gtsTsDir] })) + await fs.copyFile(path.join(re2Dir, 'build', 'wasm', 're2.wasm'), path.join('dist', 're2.wasm')) + }) + }, +} const production = process.argv.includes('--production') const watch = process.argv.includes('--watch') @@ -41,6 +64,7 @@ async function main() { mainFields: ['module', 'main'], plugins: [ esbuildProblemMatcherPlugin, + copyRe2WasmPlugin, ], }) From 85c16153b749d0d7b3c576458687cd33069aaa6b Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 29 Sep 2026 02:08:08 +0300 Subject: [PATCH 48/57] fix(shared): report instances of an invalid type as invalid An instance was validated only against its own content, so an instance whose type (or an ancestor of it) is invalid still showed as valid. This is how gts-ts's store.validateInstance and the gts-test-examples expectations treat it: e.g. invalid/instances/123e4567-...-426614174000 and gts.x.test13.aggaddbad...example.v1, whose ancestor type is missing a required trait, were green. After resolving the instance's type, validate it (a memoized lookup within the registry generation) and, if it has errors, add "Instance type '' is invalid: " on the instance's type/id field. The type's result already carries its ancestor chain, so a broken ancestor propagates to instances too. Against the previous commit, built with the same gts-ts, exactly 49 entities change in the gts workspace and each one only gains this error. Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 52c6435..7bd2c99 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -932,6 +932,25 @@ export class JsonRegistry { return validation } + // An instance of an invalid type is itself invalid (as gts-ts's + // store.validateInstance reports it). The type's own result already + // includes its ancestor chain ("Parent schema ... has GTS validation + // errors"), so a broken ancestor reaches the instance too. Validation + // results are memoized per registry generation, so this is a cache hit + // during a workspace pass. + await this.validateEntity(schema) + const typeErrors = schema.validation?.errors || [] + if (typeErrors.length > 0) { + const idField = (entity as any).selectedSchemaIdField || (entity as any).selectedEntityIdField || 'id' + validation.errors.push({ + instancePath: '/' + String(idField), + schemaPath: '#', + keyword: 'x-gts-schema', + message: `Instance type '${entity.schemaId}' is invalid: ${typeErrors[0].message}`, + params: { schemaId: entity.schemaId } + }) + } + // §9.11.3 (OP#6): an instance's rightmost type must be instantiable. A // type marked `x-gts-abstract: true` is a template and MUST NOT be // instantiated directly. Ajv has no notion of this keyword, so enforce it From cd92f580123d406e78ce654f72d9c65ac980a7ab Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 29 Sep 2026 02:14:26 +0300 Subject: [PATCH 49/57] fix(shared): stop flagging gts:// outside JSON Schema URL fields The gts:// prefix check (0e819cb) reported a GTS id with the "gts://" prefix in any field other than $id, $ref and x-gts-traits-schema as a malformed identifier. Neither the GTS spec nor gts-ts forbids that: the spec only requires the prefix where JSON Schema consumes the value as a URL ($id/$ref), and implementations strip it everywhere else. The spec's own fixture relies on it - valid/types/gts.x.test6.dref_dd.standalone.v1~ puts "gts://..." under the non-keyword "$$ref" and expects the type (and its instance) to be valid, but gts-kit showed both red. Align with the spec and gts-ts: keep flagging a missing gts:// in the URL fields and a gts:// prefix followed by an invalid GTS identifier, and drop the 'unexpected-uri-prefix' case. In the gts workspace, built against the same gts-ts, exactly 8 entities change and each one only loses these errors: the dref_dd type and instance (both copies of gts-test-examples) and four schemas under gts-kit/examples/events/schemas that use gts:// in id/$schema/type/ subjectType values. Signed-off-by: Artifizer --- packages/shared/src/entities.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/entities.ts b/packages/shared/src/entities.ts index 0545866..2c12daa 100644 --- a/packages/shared/src/entities.ts +++ b/packages/shared/src/entities.ts @@ -151,7 +151,7 @@ export function fieldRequiresGtsUriPrefix(fieldName: string | undefined | null): } /** Kind of gts:// prefix problem detected for a GTS identifier in a specific field. */ -export type GtsPrefixIssueKind = 'missing-uri-prefix' | 'unexpected-uri-prefix' | 'invalid-gts-uri' +export type GtsPrefixIssueKind = 'missing-uri-prefix' | 'invalid-gts-uri' /** A detected gts:// prefix problem, with a human-readable explanation. */ export interface GtsPrefixIssue { @@ -166,11 +166,13 @@ export interface GtsPrefixIssue { * Validate gts:// prefix usage of a GTS identifier value against the field it lives * in. Returns a {@link GtsPrefixIssue} when the prefix usage is wrong, otherwise null. * - * Rules (per GTS spec): + * Rules (per GTS spec, matching gts-ts): * - In JSON Schema URL fields ({@link GTS_URI_PREFIX_FIELDS}) a GTS identifier MUST * start with "gts://gts." — a bare "gts." value is malformed. - * - In every other field a GTS identifier MUST start with "gts." — a "gts://" value - * is malformed. + * - Anywhere, a "gts://" value must be followed by a valid GTS identifier. + * - A "gts://" prefix in any other field is NOT an error: neither the spec nor + * gts-ts forbids it (implementations strip the prefix), and the spec's own + * test fixtures use it, e.g. under a non-keyword `$$ref`. * * Only actual GTS identifiers are considered; non-GTS strings return null so that * malformed-format handling stays a separate concern. @@ -208,14 +210,6 @@ export function checkGtsUriPrefix(fieldName: string | undefined | null, rawValue message: `Malformed GTS identifier in "${fieldName}": JSON Schema treats this value as a URL, so it must start with "${GTS_URI_PREFIX}gts.". Use "${GTS_URI_PREFIX}${canonical}".` } } - if (!requiresPrefix && hasPrefix) { - const where = fieldName ? ` in "${fieldName}"` : '' - return { - kind: 'unexpected-uri-prefix', - suggestion: canonical, - message: `Malformed GTS identifier${where}: the "${GTS_URI_PREFIX}" URI prefix is only allowed in JSON Schema URL fields (${GTS_URI_PREFIX_FIELDS.join(', ')}). Use "${canonical}".` - } - } return null } From fa84377a7114696dd25cfd6454744184685dc39b Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 29 Sep 2026 02:23:05 +0300 Subject: [PATCH 50/57] fix(vscode): one problem severity; place list-file errors on the right item Two problems visible in the GTS file tree and in list (array) files: - Two colors for GTS problems. Duplicate ids were published as warnings, so with VS Code's standard Problems decorations (1ef0482) files with only a duplicate id showed in the warning color while other GTS files showed in the error color. Duplicate ids are now errors like every other GTS problem, so all problem files share the standard error color and count. The now-unused warning plumbing is removed (per-problem severity, warning-only tree tooltip), and the view badge counts all GTS problems. - Errors on the wrong item of a list file. Entity validation reports instance paths relative to the entity, which equals the document only for single-entity files. In a top-level array every item's error (e.g. an invalid `clientId` in each of three instances) resolved to the first item's field, stacking all markers there. Errors of list entities are now prefixed with the entity's index (entity-level errors point at that item's id field) before positions are resolved - for diagnostics and for the link provider's red field chips. The `required` and `schema` position lookups also resolve the object structurally first instead of text-searching for the first `"": {`, which had put e.g. a missing-property error of the 4th item on the 1st item's object. Signed-off-by: Artifizer --- apps/vscode-extension/src/gtsExplorer.ts | 28 +++----- apps/vscode-extension/src/linkProvider.ts | 7 +- apps/vscode-extension/src/validation.ts | 88 ++++++++++++----------- 3 files changed, 63 insertions(+), 60 deletions(-) diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index 1995317..49d0d50 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -100,9 +100,9 @@ function gtsDiagnosticsOf(uri: vscode.Uri): vscode.Diagnostic[] { return vscode.languages.getDiagnostics(uri).filter(d => d.source === 'GTS') } -/** True if the given file currently has a GTS error (warnings such as duplicate ids don't count). */ +/** True if the given file currently has a GTS validation error. */ export function hasGtsErrors(uri: vscode.Uri): boolean { - if (gtsDiagnosticsOf(uri).some(d => d.severity === vscode.DiagnosticSeverity.Error)) return true + if (gtsDiagnosticsOf(uri).length > 0) return true const registry = getRegistry() if (!registry) return false const fsPath = uri.fsPath @@ -112,11 +112,6 @@ export function hasGtsErrors(uri: vscode.Uri): boolean { return entities.some(entity => Boolean(entity.validation?.errors.length)) } -/** True if the given file has GTS warnings (e.g. a duplicate id). */ -function hasGtsWarnings(uri: vscode.Uri): boolean { - return gtsDiagnosticsOf(uri).some(d => d.severity === vscode.DiagnosticSeverity.Warning) -} - /** 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') { @@ -182,7 +177,7 @@ export class GtsFileTreeProvider } item.tooltip = hasGtsErrors(item.resourceUri) ? 'Has GTS validation errors' - : hasGtsWarnings(item.resourceUri) ? 'Has GTS warnings' : 'No GTS problems' + : 'No GTS validation errors' return item } @@ -217,10 +212,10 @@ export class GtsFileTreeProvider } /** - * Colors every GTS file (in the sidebar tree, the OS-style Explorer, and open - * editor tabs) red when it has GTS errors, yellow when it only has warnings - * (e.g. a duplicate id), and green otherwise. Driven by the same registry + diagnostics the rest of the extension - * uses, so the color always matches the squiggles in the open document. + * Colors every valid GTS file green (in the sidebar tree, the OS-style Explorer + * and editor tabs). Files with GTS problems get no decoration of their own: + * VS Code's Problems decoration colors them with its standard error color and + * count, so every GTS problem looks the same everywhere. */ export class GtsFileDecorationProvider implements vscode.FileDecorationProvider { private readonly _onDidChangeFileDecorations = new vscode.EventEmitter() @@ -233,7 +228,7 @@ export class GtsFileDecorationProvider implements vscode.FileDecorationProvider provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined { if (!isDiscoveredGtsFile(uri.fsPath)) return undefined - if (hasGtsErrors(uri) || hasGtsWarnings(uri)) return undefined + if (hasGtsErrors(uri)) return undefined return new vscode.FileDecoration(undefined, 'GTS: file is valid', new vscode.ThemeColor('charts.green')) } } @@ -248,10 +243,9 @@ export interface GtsExplorer { /** Update the small rounded problem-count badge shown next to the view title. */ function updateBadge(treeView: vscode.TreeView): void { - const { errors, warnings } = countPublishedProblems() - const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}` - treeView.badge = errors + warnings > 0 - ? { value: errors + warnings, tooltip: `GTS: ${plural(errors, 'error')}, ${plural(warnings, 'warning')}` } + const count = countPublishedProblems() + treeView.badge = count > 0 + ? { value: count, tooltip: `${count} GTS problem${count === 1 ? '' : 's'}` } : undefined } diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index fad1aaf..fd09145 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -3,7 +3,7 @@ import { JsonRegistry, GTS_COLORS, GTS_URI_PREFIX, parseGtsIdParts, analyzeGtsId import type { GtsPrefixIssue, JsonEntity } from '@gts/shared' import * as fs from 'fs' import { getRegistry, getRegistryRevision } from './registryStore' -import { getDocumentValidationErrors } from './validation' +import { getDocumentValidationErrors, entityErrorsInDocument } from './validation' import * as jsonc from 'jsonc-parser' import * as YAML from 'yaml' @@ -297,8 +297,9 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider e.validation?.errors || []), - ...(this.registry.jsonFileObjs.get(filePath) || []).flatMap(e => e.validation?.errors || []), + // Document-relative paths, so list-file errors colour the right item's field. + ...(this.registry.jsonFileSchemas.get(filePath) || []).flatMap(entityErrorsInDocument), + ...(this.registry.jsonFileObjs.get(filePath) || []).flatMap(entityErrorsInDocument), ...(this.registry.invalidFiles.get(filePath)?.validation?.errors || []) ] diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index 6c82899..9ac44c6 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -66,7 +66,7 @@ class ParsedDocument { /** * Convert validation errors to VSCode diagnostics */ -function validationErrorsToDiagnostics(errors: FileProblem[], document: vscode.TextDocument): vscode.Diagnostic[] { +function validationErrorsToDiagnostics(errors: ValidationError[], document: vscode.TextDocument): vscode.Diagnostic[] { const diagnostics: vscode.Diagnostic[] = [] const parsed = new ParsedDocument(document) @@ -75,7 +75,7 @@ function validationErrorsToDiagnostics(errors: FileProblem[], document: vscode.T // to the start of the document. const range = findErrorPosition(parsed, error.instancePath || '', error) ?? new vscode.Range(0, 0, 0, 1) - const diagnostic = new vscode.Diagnostic(range, error.message, problemSeverity(error)) + const diagnostic = new vscode.Diagnostic(range, error.message, vscode.DiagnosticSeverity.Error) diagnostic.source = 'GTS' diagnostic.code = error.keyword @@ -104,7 +104,10 @@ function findErrorPosition(parsed: ParsedDocument, instancePath: string, error: return new vscode.Range(pos, pos.translate(0, 1)) } } else { - const position = findObjectAtPath(parsed, path) + // Resolve the object structurally (honoring list indices) before the + // text search, which finds the first `"": {` in the document and so + // put every list item's error on the first item. + const position = findKeyRangeAtInstancePath(parsed, instancePath) ?? findObjectAtPath(parsed, path) if (position) { return position } @@ -180,7 +183,7 @@ function findErrorPosition(parsed: ParsedDocument, instancePath: string, error: return position } } else if (path) { - const position = findObjectAtPath(parsed, path) + const position = findKeyRangeAtInstancePath(parsed, instancePath) ?? findObjectAtPath(parsed, path) if (position) { return position } @@ -545,7 +548,7 @@ export async function validateOpenDocument(document: vscode.TextDocument) { // any new invalid-file entry or updated entity list for this open buffer. registry = getRegistry() || registry - let errors: FileProblem[] = [] + let errors: ValidationError[] = [] if (parseErrorMessage) { errors = [{ @@ -560,7 +563,7 @@ export async function validateOpenDocument(document: vscode.TextDocument) { // indexed (for $ref / GTS-reference resolution) but are not re-validated. const entities = fileEntities(registry, filePath) for (const e of entities) await registry.validateEntity(e) - errors = collectFileProblems(registry, filePath, buildDefinitionIndex(registry)) + errors = collectValidationErrors(registry, filePath, buildDefinitionIndex(registry)) } if ( @@ -573,8 +576,7 @@ export async function validateOpenDocument(document: vscode.TextDocument) { workspaceDiagnosticCollection?.delete(document.uri) if (errors.length > 0) { - // Only errors colour GTS id chips red; a duplicate-id warning must not. - documentValidationErrors.set(document.uri.toString(), errors.filter(e => e.severity !== 'warning')) + documentValidationErrors.set(document.uri.toString(), errors) const diagnostics = validationErrorsToDiagnostics(errors, document) diagnosticCollection.set(document.uri, diagnostics) } else { @@ -674,16 +676,9 @@ function isIndexedInItsFile(registry: JsonRegistry, entity: RegistryEntity): boo (registry.jsonFileObjs.get(filePath) || []).includes(entity as JsonObj) } -/** A validation finding plus its severity (errors unless marked otherwise). */ -type FileProblem = ValidationError & { severity?: 'warning' } - -function problemSeverity(problem: FileProblem): vscode.DiagnosticSeverity { - return problem.severity === 'warning' ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Error -} - /** Coarse (line 0) diagnostic for a file that isn't open in an editor. */ -function coarseDiagnostic(problem: FileProblem): vscode.Diagnostic { - const diagnostic = new vscode.Diagnostic(new vscode.Range(0, 0, 0, 1), problem.message, problemSeverity(problem)) +function coarseDiagnostic(problem: ValidationError): vscode.Diagnostic { + const diagnostic = new vscode.Diagnostic(new vscode.Range(0, 0, 0, 1), problem.message, vscode.DiagnosticSeverity.Error) diagnostic.source = 'GTS' diagnostic.code = problem.keyword return diagnostic @@ -716,16 +711,35 @@ function idInstancePath(entity: RegistryEntity): string { return (seq !== undefined ? `/${seq}` : '') + `/${field}` } +/** + * An entity's validation errors with instance paths relative to the whole + * document. The validator reports paths relative to the entity itself, which + * is the document only for a single-entity file: in a list file (a top-level + * array of entities) every path must be prefixed with the entity's index, or + * each item's error resolves to the first item's field (e.g. three instances + * with a bad `clientId` all underlined the first `clientId`). Errors on the + * entity as a whole point at that item's id field. + */ +export function entityErrorsInDocument(entity: RegistryEntity): ValidationError[] { + const errors = entity.validation?.errors || [] + const seq = entity.listSequence + if (seq === undefined) return errors + return errors.map(error => { + const relative = error.instancePath && error.instancePath !== '/' ? error.instancePath : '' + return { ...error, instancePath: relative ? `/${seq}${relative}` : (idInstancePath(entity) || `/${seq}`) } + }) +} + const MAX_LISTED_DUPLICATE_FILES = 3 /** - * A warning for every entity of the file whose id is also defined elsewhere (in + * An error for every entity of the file whose id is also defined elsewhere (in * another file, or again in this one). The registry resolves an id to a single * definition — whichever was indexed last — so references to a duplicated id * silently depend on scan order. */ -function duplicateIdProblems(registry: JsonRegistry, filePath: string, index: DefinitionIndex): FileProblem[] { - const problems: FileProblem[] = [] +function duplicateIdProblems(registry: JsonRegistry, filePath: string, index: DefinitionIndex): ValidationError[] { + const problems: ValidationError[] = [] for (const entity of fileEntities(registry, filePath)) { const definitions = index.get(entity.id) || [] if (definitions.length < 2) continue @@ -743,14 +757,13 @@ function duplicateIdProblems(registry: JsonRegistry, filePath: string, index: De schemaPath: '#', keyword: 'gts-duplicate-id', message: `Duplicate GTS id "${entity.id}": ${details.join('; ')}. References resolve to only one of these definitions.`, - params: { value: entity.id, otherFiles }, - severity: 'warning' + params: { value: entity.id, otherFiles } }) } return problems } -function malformedIdProblems(filePath: string): FileProblem[] { +function malformedIdProblems(filePath: string): ValidationError[] { return getMalformedIds(filePath).map(issue => ({ instancePath: issue.instancePath, schemaPath: '#', @@ -763,14 +776,14 @@ function malformedIdProblems(filePath: string): FileProblem[] { /** * Everything to report for one file, read from the registry's current state * (entities must already be validated): parse errors, entity validation errors, - * malformed GTS ids and duplicate-id warnings. The single source for open-file, + * malformed GTS ids and duplicate-id errors. The single source for open-file, * closed-file and workspace-wide diagnostics, so all three always agree. */ -function collectFileProblems(registry: JsonRegistry, filePath: string, index: DefinitionIndex): FileProblem[] { +function collectValidationErrors(registry: JsonRegistry, filePath: string, index: DefinitionIndex): ValidationError[] { const invalid = registry.invalidFiles.get(filePath) if (invalid?.validation && invalid.validation.errors.length > 0) return [...invalid.validation.errors] return [ - ...fileEntities(registry, filePath).flatMap(entity => entity.validation?.errors || []), + ...fileEntities(registry, filePath).flatMap(entityErrorsInDocument), ...malformedIdProblems(filePath), ...duplicateIdProblems(registry, filePath, index) ] @@ -824,7 +837,7 @@ async function runWorkspaceValidationPass(): Promise { const diagnosticsByPath = new Map() for (const filePath of filePaths) { if (openPaths.has(filePath)) continue - const problems = collectFileProblems(registry, filePath, index) + const problems = collectValidationErrors(registry, filePath, index) if (problems.length > 0) diagnosticsByPath.set(filePath, problems.map(coarseDiagnostic)) } @@ -855,7 +868,7 @@ async function validateClosedFile(filePath: string, index?: DefinitionIndex): Pr // Entity validation itself is shared registry logic; here we only turn the // resulting problems into coarse (line-0) workspace diagnostics. await registry.validateFile(filePath) - const diagnostics = collectFileProblems(registry, filePath, index ?? buildDefinitionIndex(registry)).map(coarseDiagnostic) + const diagnostics = collectValidationErrors(registry, filePath, index ?? buildDefinitionIndex(registry)).map(coarseDiagnostic) const uri = vscode.Uri.file(filePath) workspaceDiagnosticCollection.set(uri, diagnostics.length > 0 ? diagnostics : undefined) @@ -899,7 +912,7 @@ export async function revalidateDependents(changedPath: string, previousIds?: It const oldIds = previousIds ? [...previousIds] : [] const dependentPaths = new Set(registry.getDependentFilePaths(changedPath, oldIds)) // Other files defining any of this file's old or new ids gain or lose a - // duplicate-id warning when those ids change. + // duplicate-id error when those ids change. const index = buildDefinitionIndex(registry) for (const id of new Set([...oldIds, ...registry.getEntityIdsForFile(changedPath)])) { for (const definingPath of index.get(id) || []) { @@ -926,20 +939,15 @@ export async function revalidateDependents(changedPath: string, previousIds?: It } /** - * GTS errors and warnings currently published by this extension. Reads only our - * own diagnostic collections instead of every diagnostic in VS Code. + * GTS problems currently published by this extension. Reads only our own + * diagnostic collections instead of every diagnostic in VS Code. */ -export function countPublishedProblems(): { errors: number; warnings: number } { - const counts = { errors: 0, warnings: 0 } - const tally = (_uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[]) => { - for (const d of diagnostics) { - if (d.severity === vscode.DiagnosticSeverity.Error) counts.errors++ - else if (d.severity === vscode.DiagnosticSeverity.Warning) counts.warnings++ - } - } +export function countPublishedProblems(): number { + let count = 0 + const tally = (_uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[]) => { count += diagnostics.length } diagnosticCollection?.forEach(tally) workspaceDiagnosticCollection?.forEach(tally) - return counts + return count } export function resetValidationDiagnostics(): void { From 1f88cadcbede2e67a6e3fe0780f0700f1919e61c Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 29 Sep 2026 02:49:37 +0300 Subject: [PATCH 51/57] fix(vscode): underline regex compile errors on the regex, not $id A schema whose regex gts-ts cannot compile (e.g. "failed to compile trait schema: Unsupported pattern /^(a+)+(?=b)/: ...") was reported on the schema's `$id`: the shared registry anchors gts-ts schema messages by recognising a few shapes (property, x-gts-ref, missing trait) and falls back to `/$id` for everything else. - shared: add findRegexPatternPath, which locates the `pattern` value or `patternProperties` key whose source the message names as `//`, and use it for regex compile errors ("Unsupported pattern", "Invalid regular expression"). Path segments are escaped per RFC 6901, since `patternProperties` keys often contain `/`. Errors about a regex of an ancestor schema keep the `$id` anchor. - vscode: unescape RFC 6901 `~1` / `~0` in instance-path segments, so such paths (and Ajv's own escaped instancePaths) resolve to the right key or value. Signed-off-by: Artifizer --- apps/vscode-extension/src/validation.ts | 6 ++-- packages/shared/src/registry.ts | 11 ++++++- packages/shared/src/schemaParser.ts | 39 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index 9ac44c6..d4cb580 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -373,13 +373,15 @@ function keyRegex(name: string): RegExp { * 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. + * (from the leading slash or a "/" root path) are dropped. Segments are + * unescaped per RFC 6901 (`~1` -> `/`, `~0` -> `~`), as Ajv and the shared + * path finders escape keys that contain those characters. */ function instancePathSegments(instancePath: string): Array { return instancePath .split('/') .filter(seg => seg.length > 0) - .map(seg => (/^\d+$/.test(seg) ? Number(seg) : seg)) + .map(seg => (/^\d+$/.test(seg) ? Number(seg) : seg.replace(/~1/g, '/').replace(/~0/g, '~'))) } /** diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 7bd2c99..4ff37cb 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -1,7 +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, findXGtsRefPath, findTraitRequiredPath, getInstanceFieldSubschema } from './schemaParser.js' +import { findSchemaPropertyPath, findXGtsRefPath, findTraitRequiredPath, findRegexPatternPath, getInstanceFieldSubschema } from './schemaParser.js' import Ajv, { type ValidateFunction, type ErrorObject } from 'ajv' import addFormats from 'ajv-formats' import { GtsModifiers, GtsStore, GtsRefValidationMode, createJsonEntity } from '@globaltypesystem/gts-ts' @@ -843,6 +843,13 @@ export class JsonRegistry { // requirement (the specific `required` entry when we can name the // missing trait) instead of the schema's `$id`. const isTraitError = !propPath && !refMatch && msg.startsWith('trait validation:') + // A regex that can't be compiled (e.g. "failed to compile trait + // schema: Unsupported pattern /.../: ..."): anchor it to the + // offending `pattern` / `patternProperties` entry, when it is in + // this document, instead of the schema's `$id`. + const regexPath = !propPath && !refMatch && !isTraitError && /regular expression|Unsupported pattern/.test(msg) + ? findRegexPatternPath(entity.content, msg) + : null let instancePath: string | null = null let params: Record = {} if (propPath) { @@ -856,6 +863,8 @@ export class JsonRegistry { const traitName = traitMatch ? traitMatch[1] : undefined instancePath = findTraitRequiredPath(entity.content, traitName) params = traitName ? { trait: traitName } : {} + } else if (regexPath) { + instancePath = regexPath } validation.errors.push({ instancePath: instancePath || '/$id', diff --git a/packages/shared/src/schemaParser.ts b/packages/shared/src/schemaParser.ts index 36751f5..884067e 100644 --- a/packages/shared/src/schemaParser.ts +++ b/packages/shared/src/schemaParser.ts @@ -434,6 +434,45 @@ export function findXGtsRefPath(content: any, refValue: string): string | null { return walk(content, '') } +/** + * Locate the JSON Pointer instancePath of the regular expression a compile + * error is about, e.g. "failed to compile trait schema: Unsupported pattern + * /^(a+)+(?=b)/: ..." or "... Invalid regular expression: /x(/u: ...": the + * `pattern` value, or `patternProperties` key, whose source appears in + * `message` as `//`. Used to anchor such a diagnostic on the + * offending regex rather than the schema's `$id`. Keys are escaped per RFC + * 6901 (`patternProperties` keys often contain `/`). Returns null when no + * regex of this document is named (e.g. it belongs to an ancestor schema). + */ +export function findRegexPatternPath(content: any, message: string): string | null { + const escape = (key: string) => key.replace(/~/g, '~0').replace(/\//g, '~1') + const named = (source: string) => source !== '' && message.includes(`/${source}/`) + + function walk(node: any, currentPath: string): string | null { + if (!node || typeof node !== 'object') return null + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i++) { + const res = walk(node[i], `${currentPath}/${i}`) + if (res) return res + } + return null + } + for (const [key, value] of Object.entries(node)) { + const childPath = `${currentPath}/${escape(key)}` + if (key === 'pattern' && typeof value === 'string' && named(value)) return childPath + if (key === 'patternProperties' && value && typeof value === 'object' && !Array.isArray(value)) { + const regexKey = Object.keys(value).find(named) + if (regexKey !== undefined) return `${childPath}/${escape(regexKey)}` + } + const res = walk(value, childPath) + if (res) return res + } + return null + } + + return walk(content, '') +} + /** * Locate the JSON Pointer instancePath to anchor a trait-completeness error * (OP#13) on. When `traitName` is a required trait declared in this document's From 96890bc52360497ee1e9bba330adf73aa97fa4bf Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 1 Oct 2026 02:02:04 +0300 Subject: [PATCH 52/57] build(shared): rebase gts-ts onto published npm 0.8.1 Switch @globaltypesystem/gts-ts in packages/shared from the local `file:../../../gts-ts` link to the published npm package `^0.8.1`, now that 0.8.1 is released. The lockfile now resolves it from the registry instead of a local symlink. Remove the temporary `preserveSymlinks: true` workaround in the web Vite config, which only existed to make the local file: link resolve its named exports. Document the dependency in GTS_TS_MIGRATION.md. Signed-off-by: Artifizer --- docs/GTS_TS_MIGRATION.md | 107 +++++++++++++++++++++++++++++++++++ package-lock.json | 59 +++++++++++++------ packages/shared/package.json | 2 +- 3 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 docs/GTS_TS_MIGRATION.md diff --git a/docs/GTS_TS_MIGRATION.md b/docs/GTS_TS_MIGRATION.md new file mode 100644 index 0000000..1772af6 --- /dev/null +++ b/docs/GTS_TS_MIGRATION.md @@ -0,0 +1,107 @@ +# `@globaltypesystem/gts-ts` as the validation source of truth + +## Current state + +gts-ts now owns parsing, entity extraction, schema meta-validation, instance +validation, derivation, traits, modifiers, formats, `$ref` resolution, and +`x-gts-ref` validation. gts-kit consumes the structured results and only maps +JSON Pointer paths to editor ranges. + +`packages/shared` depends on the official `@globaltypesystem/gts-ts` NPM +package (`^0.8.1`). + +## Added gts-ts APIs + +### Structured validation issues + +`ValidationResult` retains every existing field and adds an optional array: + +```ts +interface ValidationIssue { + instancePath: string + schemaPath: string + keyword: string + message: string + params: Record + data?: unknown +} + +interface ValidationResult { + id: string + ok: boolean + valid?: boolean + error: string + is_wildcard?: boolean + errors?: ValidationIssue[] +} +``` + +The legacy joined `error` string remains unchanged for compatibility. Structured +issues are returned for JSON Schema assertions, schema meta-validation, +derivation constraints, traits, abstract instances, and `x-gts-ref` failures. + +### Full schema validation + +`GtsStore.validateSchema(id)` and `GTS.validateSchema(id)` combine: + +- JSON Schema meta-validation; +- document-level GTS rules; +- derivation compatibility; +- trait validation; +- GTS `$ref` resolution; +- `x-gts-ref` declaration, existence, and transitive validity checks. + +The additive `validateSchemaAsync` and `validateInstanceAsync` methods provide +Promise-based entry points without changing synchronous APIs. + +### JSON Schema dialects + +Validation selects the correct Ajv implementation for Draft 7, Draft 2019-09, +and Draft 2020-12. Equivalent HTTP/HTTPS and trailing-fragment dialect URLs are +normalized internally. Registered schemas remain available for synchronous GTS +`$ref` resolution in the matching dialect registry. + +### File parsing + +gts-ts exports: + +- `isYamlFileName`; +- `parseJSONC` / `tryParseJSONC`; +- `parseYAML` / `tryParseYAML`; +- `parseGtsFileContent`; +- `parseGtsFile`. + +JSONC comments and trailing commas are supported. YAML selection is based on the +`.yaml` or `.yml` extension. `parseGtsFile` returns parsed content and +`JsonEntity` values without registering them. + +## Removed from gts-kit + +`packages/shared/src/registry.ts` no longer contains or invokes: + +- a local Ajv instance or schema loader; +- local format registration and temporal-format composition; +- local `x-gts-ref` stripping/combinator normalization; +- local Ajv error formatting; +- local abstract-instance checks; +- local `x-gts-ref` instance validation; +- derivation error-string parsing and schema-property path reconstruction. + +Direct `ajv` and `ajv-formats` dependencies were removed from all gts-kit +package manifests. Shared JSONC/YAML helpers now re-export the gts-ts parsers. + +## Responsibilities that remain in gts-kit + +These are application/editor concerns rather than duplicated schema validation: + +- mapping `ValidationIssue.instancePath` to JSONC/YAML source ranges; +- choosing whether a diagnostic underlines a key or a value; +- maintaining the reverse dependency graph used for incremental revalidation; +- enforcing and positioning the gts-kit-specific `gts://` field-placement + diagnostic; +- reporting general GTS references found outside schema assertion contexts; +- relaying host-side validation results into CSP-restricted VS Code webviews. + +The gts-ts Ajv engine still uses generated validator functions, so validation +continues to run in Node/extension-host contexts rather than inside the VS Code +webview. diff --git a/package-lock.json b/package-lock.json index 30744c1..1761749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1628,9 +1628,9 @@ } }, "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", - "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", "funding": [ { "type": "github", @@ -1714,9 +1714,9 @@ } }, "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", + "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==", "funding": [ { "type": "github", @@ -1734,9 +1734,9 @@ } }, "node_modules/@globaltypesystem/gts-ts": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@globaltypesystem/gts-ts/-/gts-ts-0.7.0.tgz", - "integrity": "sha512-ABeE2KLKvc7JBtzsgKXXsGUpF8YFfPJlmO0HLJVlOeo6Z0ggH2b4j7t0s7mJg3kkmZv3E7UAo6DhtEhD55EQYA==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@globaltypesystem/gts-ts/-/gts-ts-0.8.1.tgz", + "integrity": "sha512-uwNcWjWT/iekUo14z7c1uPbLKcSq2jne/d5gOKA3XvBDKhH1fvfji2AArIPIUbNtu+LrNy0JsdQ+Eid5caHzqw==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.18.0", @@ -1744,12 +1744,17 @@ "commander": "^12.0.0", "fastify": "^5.12.2", "jsonc-parser": "^3.3.1", + "re2-wasm": "^1.0.2", + "regjsparser": "^0.13.3", "uuid": "^11.1.1", "yaml": "^2.9.1" }, "bin": { "gts": "dist/cli/index.js", "gts-server": "dist/server/index.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@globaltypesystem/gts-ts/node_modules/ajv-formats": { @@ -7977,9 +7982,9 @@ } }, "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", - "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", "funding": [ { "type": "github", @@ -8025,9 +8030,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", - "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", "funding": [ { "type": "github", @@ -9211,7 +9216,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -10901,6 +10905,15 @@ "node": ">=0.10.0" } }, + "node_modules/re2-wasm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/re2-wasm/-/re2-wasm-1.0.2.tgz", + "integrity": "sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -11054,6 +11067,18 @@ "node": ">= 12.13.0" } }, + "node_modules/regjsparser": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", + "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -13791,7 +13816,7 @@ "name": "@gts/shared", "version": "0.2.7", "dependencies": { - "@globaltypesystem/gts-ts": "^0.7.0", + "@globaltypesystem/gts-ts": "^0.8.1", "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 54b050d..7d57a7b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,7 +16,7 @@ "dev": "tsc --watch" }, "dependencies": { - "@globaltypesystem/gts-ts": "^0.7.0", + "@globaltypesystem/gts-ts": "^0.8.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.0", From ab61d794ff25b2df127cc19bdf0d9673c1b7811e Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 1 Oct 2026 02:02:39 +0300 Subject: [PATCH 53/57] feat(shared): enforce x-gts-ref any-valid transitive validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the spec's default gts-ref-validation=any-valid policy (§9.6): an entity referenced through an x-gts-ref field must itself be valid, transitively, otherwise the referencing instance is reported invalid. Run the XGtsRefValidator in any-present mode to collect referenced ids (dropping its "not found" errors, already covered by the gtsRefs registry check), then validate each referenced entity. Referenced schemas are validated normally; referenced instances are resolved within the current validation chain to break reference cycles (treated as valid, matching gts-ts's visiting guard and avoid deadlocks. Results for cut cycles are recomputed within the chain and not cached. Memoize settled validation results so referenced-entity checks can reuse a finished result for the current generation instead of re-awaiting an in-progress computation. EOF ) Signed-off-by: Artifizer --- packages/shared/src/registry.ts | 77 +++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index 4ff37cb..b6c0c91 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -193,7 +193,10 @@ export class JsonRegistry { // Per-entity validation result for the current generation. Shared by // concurrent callers, and lets a pass validate each entity once instead of // re-validating the whole parent chain for every derived schema. - private validationMemo = new WeakMap }>() + private validationMemo = new WeakMap< + JsonEntity, + { generation: number; result: Promise; settled?: ValidationResult } + >() // Compiled Ajv validators for instance validation, keyed by the (winning) // schema's content object. Each gets its own Ajv instance, exactly as before, // but is compiled once per generation instead of once per instance. @@ -657,7 +660,38 @@ export class JsonRegistry { } const validation = await memo.result // A newer computation (registry changed meanwhile) owns the published result. - if (this.validationMemo.get(entity) === memo) entity.validation = validation + if (this.validationMemo.get(entity) === memo) { + memo.settled = validation + entity.validation = validation + } + } + + /** + * Validation result of an entity that an instance references through an + * `x-gts-ref` field, evaluated within the chain of instances being validated + * (`chain`). Returns null for a reference cycle, which is treated as valid + * (like gts-ts's visiting guard). + * + * Schemas are validated normally: a schema's result never depends on an + * instance, so waiting on it can't form a cycle. Instances can reference + * each other, so an instance is never waited on while its validation is in + * progress elsewhere (that could deadlock on a cycle); unless a finished + * result for the current generation exists it is recomputed within `chain`, + * and not cached, since a cut cycle can make that result differ from the + * entity's standalone one. + */ + private async referencedEntityValidation( + dependency: JsonEntity, + chain: ReadonlySet + ): Promise { + if (dependency instanceof JsonSchema) { + await this.validateEntity(dependency) + return dependency.validation ?? null + } + if (chain.has(dependency)) return null + const memo = this.validationMemo.get(dependency) + if (memo && memo.generation === this.contentGeneration && memo.settled) return memo.settled + return this.computeEntityValidation(dependency, new Set(chain).add(dependency)) } /** @@ -683,7 +717,10 @@ export class JsonRegistry { return validator } - private async computeEntityValidation(entity: JsonEntity): Promise { + private async computeEntityValidation( + entity: JsonEntity, + chain: ReadonlySet = new Set([entity]) + ): Promise { const validation: ValidationResult = { errors: [] } // Enforce gts:// URI-prefix rules: the prefix is required in JSON Schema URL @@ -995,13 +1032,14 @@ export class JsonRegistry { // `gts://` `$ref`s (e.g. `allOf: [{ $ref: }]`) into parent // schemas: without one, every such `$ref` is unresolvable and gts-ts // reports "Cannot resolve $ref ... for x-gts-ref traversal" (it fails - // closed rather than skip the parent's constraints). Existence checks - // stay off (mode None): referenced-entity existence is already reported - // by the gtsRefs registry check above, so this validator only enforces - // the GTS-ID format and the prefix/pattern constraint. - const xGtsRefErrors = new XGtsRefValidator(this.getGtsStore(), GtsRefValidationMode.None) - .validateInstance(entity.content, schema.content, '', undefined, entity.id) + // closed rather than skip the parent's constraints). It runs in + // any-present mode to collect the referenced ids for the any-valid + // check below; its own "not found" errors repeat the gtsRefs registry + // check above and are dropped. + const refValidator = new XGtsRefValidator(this.getGtsStore(), GtsRefValidationMode.AnyPresent) + const xGtsRefErrors = refValidator.validateInstance(entity.content, schema.content, '', undefined, entity.id) for (const err of xGtsRefErrors) { + if (/^Referenced entity '.+' not found in registry$/.test(err.reason)) continue validation.errors.push({ instancePath: fieldPathToInstancePath(err.fieldPath), schemaPath: '#', @@ -1010,6 +1048,27 @@ export class JsonRegistry { params: { value: err.value, refPattern: err.refPattern } }) } + + // §9.6 `gts-ref-validation=any-valid` (the spec's default policy): an + // entity referenced through an `x-gts-ref` field must itself be valid, + // transitively, or the referencing instance is invalid too. + for (const referencedId of refValidator.getReferencedIds()) { + const dependency = this.jsonObjs.get(referencedId) ?? this.jsonSchemas.get(referencedId) + if (!dependency || dependency === entity) continue + const dependencyValidation = await this.referencedEntityValidation(dependency, chain) + const firstError = dependencyValidation?.errors[0] + if (!firstError) continue + const sourcePath = entity.gtsRefs?.find(ref => ref.id === referencedId)?.sourcePath + validation.errors.push({ + instancePath: sourcePath && sourcePath !== 'root' + ? '/' + sourcePath.replace(/\./g, '/').replace(/\[(\d+)\]/g, '/$1') + : '/', + schemaPath: '#', + keyword: 'x-gts-ref', + message: `Referenced entity '${referencedId}' is invalid: ${firstError.message}`, + params: { value: referencedId } + }) + } } catch (error: any) { validation.errors.push({ instancePath: '', From 3509537883e5d6f165d8f489d1ef651dbf2e095b Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 1 Oct 2026 02:02:43 +0300 Subject: [PATCH 54/57] fix(examples): use absolute x-gts-type ref for topic id Change the topic schema's `id` field x-gts-type from the relative `./$id` to the absolute `/$id` so it resolves to the document's own $id as intended. Signed-off-by: Artifizer --- examples/events/schemas/gts.x.core.events.topic.v1~.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/events/schemas/gts.x.core.events.topic.v1~.schema.json b/examples/events/schemas/gts.x.core.events.topic.v1~.schema.json index 9b47302..52ce887 100644 --- a/examples/events/schemas/gts.x.core.events.topic.v1~.schema.json +++ b/examples/events/schemas/gts.x.core.events.topic.v1~.schema.json @@ -13,7 +13,7 @@ "id": { "description": "Identifier for the topic/stream in GTS notation.", "type": "string", - "x-gts-type": "./$id" + "x-gts-type": "/$id" }, "name": { "type": "string", "minLength": 1 }, "description": { "type": "string" }, From 2987934d182ea3006c6f7864d837814fcc8a06f5 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 1 Oct 2026 02:02:49 +0300 Subject: [PATCH 55/57] docs: remove docs/LLM_RULES.md Drop the LLM contributor rules file; the guidance is no longer maintained here. Signed-off-by: Artifizer --- docs/LLM_RULES.md | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 docs/LLM_RULES.md diff --git a/docs/LLM_RULES.md b/docs/LLM_RULES.md deleted file mode 100644 index 427d01b..0000000 --- a/docs/LLM_RULES.md +++ /dev/null @@ -1,4 +0,0 @@ -**LLM Rules** - -- Never create anonymous stuct and pass it between functions, always define meaningful named classes -- Put the code, data struct and classes shared between apps to packages/shared/src From 725dc2be6332038b4cc9398683d89a30e81baec2 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 1 Oct 2026 02:50:36 +0300 Subject: [PATCH 56/57] fix(vscode): drop deleted files from discovery Exclude clean open buffers whose files no longer exist from workspace scans and document validation so refresh cannot resurrect deleted GTS entries. Preserve dirty buffers until their edits are resolved, and remove stale registry entries when deleted buffers are closed. Signed-off-by: Artifizer --- apps/vscode-extension/src/extension.ts | 16 +++++++--------- apps/vscode-extension/src/helpers.ts | 5 +++++ apps/vscode-extension/src/validation.ts | 11 +++++------ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index ff49f58..9f193f4 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -8,7 +8,7 @@ import { rebuildRegistry, indexFile as indexFileInRegistry, removeFile as remove import { getWorkspaceIgnore, resetWorkspaceIgnore, isGitIgnored, type FolderIgnore } from './gitignore' import { WorkspaceLayoutStorage } from './storage' import { initValidation, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents, onValidationCompleted } from './validation' -import { isGtsCandidateFile } from './helpers' +import { isGtsCandidateFile, isIndexableGtsDocument } from './helpers' import { GtsLinkProvider } from './linkProvider' import { registerGtsExplorer, type GtsExplorer } from './gtsExplorer' import type { LayoutSaveRequest, LayoutTarget, LayoutSnapshot } from '@gts/layout-storage' @@ -559,17 +559,15 @@ export async function activate(context: vscode.ExtensionContext) { function collectOpenGtsPaths(): string[] { const ordered: string[] = [] const seen = new Set() - const add = (fsPath: string) => { + const add = (doc: vscode.TextDocument) => { + if (!isIndexableGtsDocument(doc)) return + const fsPath = doc.uri.fsPath if (!seen.has(fsPath)) { seen.add(fsPath); ordered.push(fsPath) } } const active = vscode.window.activeTextEditor?.document - if (active && active.uri.scheme === 'file' && isGtsCandidateFile(active)) add(active.uri.fsPath) - for (const ed of vscode.window.visibleTextEditors) { - if (ed.document.uri.scheme === 'file' && isGtsCandidateFile(ed.document)) add(ed.document.uri.fsPath) - } - for (const d of vscode.workspace.textDocuments) { - if (d.uri.scheme === 'file' && isGtsCandidateFile(d)) add(d.uri.fsPath) - } + if (active) add(active) + for (const ed of vscode.window.visibleTextEditors) add(ed.document) + for (const d of vscode.workspace.textDocuments) add(d) return ordered } diff --git a/apps/vscode-extension/src/helpers.ts b/apps/vscode-extension/src/helpers.ts index 58835e3..2839d77 100644 --- a/apps/vscode-extension/src/helpers.ts +++ b/apps/vscode-extension/src/helpers.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode' +import * as fs from 'fs' import { isGtsCandidateFileName } from '@gts/shared' export function isGtsCandidateFile(document: vscode.TextDocument): boolean { @@ -11,3 +12,7 @@ export function isGtsCandidateFile(document: vscode.TextDocument): boolean { document.languageId === 'jsonc' || isGtsCandidateFileName(document.fileName) } + +export function isIndexableGtsDocument(document: vscode.TextDocument): boolean { + return isGtsCandidateFile(document) && (document.isDirty || fs.existsSync(document.uri.fsPath)) +} diff --git a/apps/vscode-extension/src/validation.ts b/apps/vscode-extension/src/validation.ts index d4cb580..8ab771e 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -5,9 +5,9 @@ import * as jsonc from 'jsonc-parser' import { ValidationError, DEFAULT_GTS_CONFIG, parseGtsFileContent, isYamlFileName } from '@gts/shared' import type { JsonRegistry, JsonSchema, JsonObj } from '@gts/shared' import { getLastScanFiles } from './scanStore' -import { getRegistry, rebuildRegistry, indexFile, getMalformedIds, getPathsWithMalformedIds } from './registryStore' +import { getRegistry, rebuildRegistry, indexFile, removeFile, getMalformedIds, getPathsWithMalformedIds } from './registryStore' import { malformedGtsIdMessage } from './gtsIdFormat' -import { isGtsCandidateFile } from './helpers' +import { isGtsCandidateFile, isIndexableGtsDocument } from './helpers' let diagnosticCollection: vscode.DiagnosticCollection let workspaceDiagnosticCollection: vscode.DiagnosticCollection @@ -509,7 +509,7 @@ function findValueRangeJson(parsed: ParsedDocument, segments: Array { if (uri.scheme !== 'file') return @@ -894,8 +894,7 @@ async function reindexClosedFileFromDisk(uri: vscode.Uri): Promise { 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. + removeFile(uri.fsPath) } } From 3cc2d4350555408f794f95a068c1e6e45d6631f2 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Thu, 1 Oct 2026 03:04:05 +0300 Subject: [PATCH 57/57] feat(vscode): add Explorer actions to GTS file tree Signed-off-by: Artifizer --- apps/vscode-extension/package.json | 99 ++++++++++++++++++++++++ apps/vscode-extension/src/gtsExplorer.ts | 18 +++++ 2 files changed, 117 insertions(+) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 0edca46..aa193d4 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -58,6 +58,50 @@ "title": "GTS: Refresh Discovered Files", "category": "GTS", "icon": "$(refresh)" + }, + { + "command": "gts-kit.openToSide", + "title": "Open to the Side" + }, + { + "command": "gts-kit.openWith", + "title": "Open With..." + }, + { + "command": "gts-kit.revealInFinder", + "title": "Reveal in Finder" + }, + { + "command": "gts-kit.revealInFileExplorer", + "title": "Reveal in File Explorer" + }, + { + "command": "gts-kit.openContainingFolder", + "title": "Open Containing Folder" + }, + { + "command": "gts-kit.openInIntegratedTerminal", + "title": "Open in Integrated Terminal" + }, + { + "command": "gts-kit.selectForCompare", + "title": "Select for Compare" + }, + { + "command": "gts-kit.compareWithSelected", + "title": "Compare with Selected" + }, + { + "command": "gts-kit.openTimeline", + "title": "Open Timeline" + }, + { + "command": "gts-kit.copyPath", + "title": "Copy Path" + }, + { + "command": "gts-kit.copyRelativePath", + "title": "Copy Relative Path" } ], "viewsContainers": { @@ -94,10 +138,65 @@ } ], "view/item/context": [ + { + "command": "gts-kit.openToSide", + "group": "navigation@10", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile" + }, + { + "command": "gts-kit.openWith", + "group": "navigation@20", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile" + }, + { + "command": "gts-kit.revealInFinder", + "group": "navigation@30", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile && isMac" + }, + { + "command": "gts-kit.revealInFileExplorer", + "group": "navigation@30", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile && isWindows" + }, + { + "command": "gts-kit.openContainingFolder", + "group": "navigation@30", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile && isLinux" + }, + { + "command": "gts-kit.openInIntegratedTerminal", + "group": "navigation@40", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile" + }, { "command": "gts-kit.openViewer", "group": "navigation@100", "when": "view == gts-kit.fileExplorer && viewItem == gtsFile" + }, + { + "command": "gts-kit.compareWithSelected", + "group": "3_compare@10", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile && resourceSelectedForCompare" + }, + { + "command": "gts-kit.selectForCompare", + "group": "3_compare@20", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile" + }, + { + "command": "gts-kit.openTimeline", + "group": "4_timeline@10", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile && timelineHasProvider" + }, + { + "command": "gts-kit.copyPath", + "group": "6_copypath@10", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile" + }, + { + "command": "gts-kit.copyRelativePath", + "group": "6_copypath@20", + "when": "view == gts-kit.fileExplorer && viewItem == gtsFile" } ], "view/title": [ diff --git a/apps/vscode-extension/src/gtsExplorer.ts b/apps/vscode-extension/src/gtsExplorer.ts index 49d0d50..b8e7f15 100644 --- a/apps/vscode-extension/src/gtsExplorer.ts +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -249,6 +249,13 @@ function updateBadge(treeView: vscode.TreeView): void { : undefined } +function registerNativeFileCommand(command: string, nativeCommand: string): vscode.Disposable { + return vscode.commands.registerCommand(command, async (element: GtsTreeElement) => { + if (element?.kind !== 'file') return + await vscode.commands.executeCommand(nativeCommand, vscode.Uri.file(element.fsPath)) + }) +} + /** 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() @@ -264,6 +271,17 @@ export function registerGtsExplorer(context: vscode.ExtensionContext): GtsExplor context.subscriptions.push( treeView, vscode.window.registerFileDecorationProvider(decorationProvider), + registerNativeFileCommand('gts-kit.openToSide', 'explorer.openToSide'), + registerNativeFileCommand('gts-kit.openWith', 'explorer.openWith'), + registerNativeFileCommand('gts-kit.revealInFinder', 'revealFileInOS'), + registerNativeFileCommand('gts-kit.revealInFileExplorer', 'revealFileInOS'), + registerNativeFileCommand('gts-kit.openContainingFolder', 'revealFileInOS'), + registerNativeFileCommand('gts-kit.openInIntegratedTerminal', 'openInIntegratedTerminal'), + registerNativeFileCommand('gts-kit.selectForCompare', 'selectForCompare'), + registerNativeFileCommand('gts-kit.compareWithSelected', 'compareFiles'), + registerNativeFileCommand('gts-kit.openTimeline', 'files.openTimeline'), + registerNativeFileCommand('gts-kit.copyPath', 'copyFilePath'), + registerNativeFileCommand('gts-kit.copyRelativePath', 'copyRelativeFilePath'), vscode.commands.registerCommand('gts-kit.openFileFromTree', async (fsPath: string) => { try { const uri = vscode.Uri.file(fsPath)