diff --git a/.gts-spec b/.gts-spec index caecc27..deec643 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit caecc273aad0aff47d77e05b87ed4b944af85e99 +Subproject commit deec64342510e2456a7afd11f7cb42c426f8fda7 diff --git a/README.md b/README.md index d1e6115..7731417 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A three-headed application for validating and viewing the [GTS](https://github.c This project is aimed at exploring and using GTS schemas and instances across platforms. +The repository is conformant with gts-spec v0.13.4. + ![GTS Viewer](./docs/gts-viewer.png) ## Quick Start diff --git a/apps/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 afacad6..cbe09f9 100644 --- a/apps/vscode-extension/CHANGELOG.md +++ b/apps/vscode-extension/CHANGELOG.md @@ -2,36 +2,123 @@ All notable changes to the GTS Viewer extension will be documented in this file. -## [1.0.0] - 2025-10-08 +## [Unreleased] + +## [0.3.0] - 2026-09-18 ### 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 +- 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 -### 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 +### 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 -### Commands -- `GTS: Preview Layout` - Open selected file in visual viewer -- `GTS: Open Viewer` - Show usage instructions +### 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 -### Context Menu Locations -- Explorer context menu (right-click files) -- Editor title context menu +## [0.2.6] - 2026-09-07 -## [Unreleased] +### 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 +- Diagnostics shown when the web server isn't running +- A progress bar for large repo scans + +## [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 diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md index 6e33ac7..7aa5c69 100644 --- a/apps/vscode-extension/README.md +++ b/apps/vscode-extension/README.md @@ -111,9 +111,14 @@ See the full [GTS Specification](https://github.com/globaltypesystem/gts-spec) f - **`.json`** — Standard JSON (schemas and instances) - **`.jsonc`** — JSON with Comments (single-line, multi-line, trailing commas) -- **`.yaml` / `.yml`** — YAML files parsed and treated identically to JSON +- **`.yaml` / `.yml`** — YAML files parsed and treated like JSON, **plus** inline GTS definitions (see below) - **`.gts`** — GTS-specific files +#### Supported GTS Entity Definitions + +- **JSON / JSONC / `.gts`** — a document must be either a **single entity** or a **top-level array of entities**. GTS IDs anywhere else are treated as *references*. +- **YAML** — everything above, **plus** inline definitions: a config file may *define* GTS types/instances under any nested `entities:` array (e.g. a service's `types-registry.config.entities` seed block), even deep inside otherwise-non-GTS config. Each element is registered by its `$id` as a real definition, so it is not flagged as an unresolved reference. + ### Validation The extension validates GTS files automatically as you open, edit, and save them. diff --git a/apps/vscode-extension/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, ], }) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 8b4a049..aa193d4 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "gts-kit", "displayName": "Global Type System (GTS) Kit", "description": "Global Type System (GTS) support for VS Code — browse, validate,visualize, and manage GTS schemas and instances", - "version": "0.2.6", + "version": "0.3.0", "private": true, "publisher": "GlobalTypeSystem", "license": "Apache-2.0", @@ -19,16 +19,26 @@ "vscode": "^1.85.0" }, "categories": [ - "Visualization", - "Other" + "Formatters", + "Linters", + "Visualization" ], + "tags": [ + "gts", + "json", + "jsonc", + "jsonchema", + "validation" + ], + "main": "./dist/extension.js", "activationEvents": [ - "onCommand:gts.openViewer", - "onLanguage:json", - "onLanguage:jsonc", + "workspaceContains:**/gts.*.json", + "workspaceContains:**/gts.*.jsonc", + "workspaceContains:**/gts.*.yaml", + "workspaceContains:**/gts.*.yml", + "workspaceContains:**/*.gts", "onLanguage:gts" ], - "main": "./dist/extension.js", "contributes": { "languages": [ { @@ -39,9 +49,59 @@ ], "commands": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "title": "GTS: Open Viewer", "category": "GTS" + }, + { + "command": "gts-kit.refreshFileExplorer", + "title": "GTS: Refresh Discovered Files", + "category": "GTS", + "icon": "$(refresh)" + }, + { + "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": { @@ -56,40 +116,116 @@ "views": { "gts-viewer": [ { - "type": "webview", - "id": "gts.viewerPanel", - "name": "Schema Viewer" + "id": "gts-kit.fileExplorer", + "name": "Discovered GTS files", + "icon": "resources/icon.svg", + "contextualTitle": "GTS Files" } ] }, + "viewsWelcome": [ + { + "view": "gts-kit.fileExplorer", + "contents": "No [GTS](https://globaltypesystem.org/#vscode-plugins) files discovered yet.\n[GTS](https://globaltypesystem.org/#vscode-plugins) files (schemas and instances) found anywhere in the workspace will be listed here." + } + ], "menus": { "explorer/context": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "navigation@100", "when": "resourceExtname =~ /\\.(json|jsonc|gts)$/" } ], + "view/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": [ + { + "command": "gts-kit.refreshFileExplorer", + "group": "navigation@1", + "when": "view == gts-kit.fileExplorer" + } + ], "editor/title": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "navigation@100", "when": "editorLangId == json" }, { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "navigation@100", "when": "editorLangId == jsonc" }, { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "navigation@100", "when": "editorLangId == gts" } ], "menubar/file": [ { - "command": "gts.openViewer", + "command": "gts-kit.openViewer", "group": "2_open" } ] @@ -116,6 +252,7 @@ "esbuild": "^0.19.0", "ignore": "^5.3.2", "jsonc-parser": "^3.3.1", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "yaml": "^2.9.1" } } diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index aac931d..9f193f4 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,30 +1,47 @@ import * as vscode from 'vscode' import * as path from 'path' -import { parseGtsFileContent, JsonRegistry, DEFAULT_GTS_CONFIG } from '@gts/shared' +import * as fs from 'fs' +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 } from './registryStore' -import { getWorkspaceIgnore, resetWorkspaceIgnore, getCachedMatcher, isIgnoredRel } from './gitignore' -import { RepoLayoutStorage } from './storage' -import { initValidation, validateOpenDocument, validateWorkspaceInBackground } from './validation' -import { isGtsCandidateFile } from './helpers' +import { rebuildRegistry, indexFile as indexFileInRegistry, removeFile as removeFileFromRegistry, getRegistry } from './registryStore' +import { getWorkspaceIgnore, resetWorkspaceIgnore, isGitIgnored, type FolderIgnore } from './gitignore' +import { WorkspaceLayoutStorage } from './storage' +import { initValidation, resetValidationDiagnostics, validateOpenDocument, validateWorkspaceInBackground, revalidateDependents, onValidationCompleted } from './validation' +import { isGtsCandidateFile, isIndexableGtsDocument } 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. 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. @@ -51,17 +68,180 @@ 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. +// 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 layoutStorage: WorkspaceLayoutStorage | null = null let hasPerformedInitialScan: boolean = false // Track if initial scan with default file has been done let gtsLinkProvider: GtsLinkProvider | null = null // File the user explicitly requested (context menu / command palette) — consumed by the first scanAndPost let pendingOpenFile: string | null = null +// Left-sidebar GTS file browser (tree view + red/green file decorations), shares the same registry as everything else. +let gtsExplorer: GtsExplorer | null = null +let fullScanQueue: Promise = Promise.resolve() + +/** 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) || isExcludedFile(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 = '' @@ -72,8 +252,23 @@ function getNonce(): string { return text } -async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: boolean = false, refreshFilePath?: string | null) { - const hasViewer = viewerPanel !== 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 postRegistryToViewer(refreshFilePath: string | null | undefined): Promise { + const panel = viewerPanel + if (!panel) return try { let selectedFilePath: string | null = null @@ -88,142 +283,65 @@ async function scanAndPost(includeGlob: string = GTS_SCAN_GLOB, isInitialScan: b : null } - 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 uris = await vscode.workspace.findFiles(include, exclude, 40000) - - 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 (!text.includes('gts.') && !text.includes('gts://')) { - 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) - 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) } // 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) - } - } - - // 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 = 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 } }) - } catch (ve: any) { - viewerPanel!.webview.postMessage({ type: 'gts-validation-error', detail: { error: ve?.message || String(ve) } }) - } - } + panel.webview.postMessage({ type: 'gts-scan-result', detail: { files, defaultFilePath: selectedFilePath } }) + + // 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 (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(getBackgroundValidationRoots()) - - // 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) { - 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) } }) } } export async function activate(context: vscode.ExtensionContext) { console.log('[GTS] Extension activating...') - // Perform initial workspace scan for validation (background, non-blocking) - console.log('[GTS] Starting initial workspace scan for validation...') - performInitialScan().catch(error => { - console.error('[GTS] Initial scan failed:', error) - }) - initValidation(context) - // Create diagnostic collection for GTS validation - const gtsDiagnostics = vscode.languages.createDiagnosticCollection('gts') - 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( + onValidationCompleted(uri => { + gtsLinkProvider?.updateDecorationsForUri(uri) + }) + ) + + // Left sidebar: file browser tree + red/green file decorations, sharing the same registry. + gtsExplorer = registerGtsExplorer(context) // Register link provider for JSON, JSONC, and GTS files const documentSelector: vscode.DocumentSelector = [ { language: 'json', scheme: 'file' }, { language: 'jsonc', scheme: 'file' }, - { language: 'gts', scheme: 'file' } + { language: 'gts', scheme: 'file' }, + { language: 'yaml', scheme: 'file' } ] context.subscriptions.push( @@ -248,27 +366,84 @@ 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( 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 (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 (isAlwaysExcluded(uri.fsPath)) return + onDiskPathRemoved(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) + } + } + }), + // In-IDE deletes; a deleted folder arrives as a single folder URI. + vscode.workspace.onDidDeleteFiles(event => { + 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) + }) ) // 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) { - removeFileFromRegistry(oldUri.fsPath) - if (isIgnoredGtsPath(newUri.fsPath)) continue - if (!/\.(json|jsonc|gts|ya?ml)$/i.test(newUri.fsPath)) continue + dropIndexedPath(oldUri.fsPath) + if (isAlwaysExcluded(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) @@ -276,6 +451,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() }) ) @@ -294,6 +471,12 @@ export async function activate(context: vscode.ExtensionContext) { gitignoreWatcher.onDidDelete(onGitignoreChanged) ) + // Perform initial workspace scan for validation (background, non-blocking) + console.log('[GTS] Starting initial workspace scan for validation...') + performInitialScan().catch(error => { + console.error('[GTS] Initial scan failed:', error) + }) + // Initial decoration for all visible editors if (gtsLinkProvider) { for (const editor of vscode.window.visibleTextEditors) { @@ -305,8 +488,16 @@ export async function activate(context: vscode.ExtensionContext) { // Register commands context.subscriptions.push( - vscode.commands.registerCommand('gts.openViewer', (resource?: vscode.Uri) => { + vscode.commands.registerCommand('gts-kit.openViewer', (resource?: vscode.Uri) => { openViewer(context, resource) + }), + vscode.commands.registerCommand('gts-kit.refreshFileExplorer', async () => { + try { + await refreshGtsFileExplorer() + } catch (error: any) { + console.error('[GTS] Full refresh failed:', error) + vscode.window.showErrorMessage(`Failed to refresh GTS files: ${error?.message || String(error)}`) + } }) ) @@ -362,26 +553,21 @@ 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). */ 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 } @@ -395,30 +581,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 (!text.includes('gts.') && !text.includes('gts://')) 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. */ @@ -429,50 +630,72 @@ 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. + * 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. */ -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)) - } +async function resetGtsStore(): Promise { + fileMutationRevisions.clear() + clearChangeTimers() + if (externalChangeTimer) clearTimeout(externalChangeTimer) + externalChangeTimer = null + preEditIdsByPath.clear() + realPathIndex.clear() + resetWorkspaceIgnore() + setLastScanFiles([]) + await rebuildRegistry([], DEFAULT_GTS_CONFIG) + resetValidationDiagnostics() + gtsExplorer?.reset() + await gtsLinkProvider?.refresh() +} - for (const doc of vscode.workspace.textDocuments) { - if (doc.uri.scheme === 'file' && isGtsCandidateFile(doc)) { - add(path.dirname(doc.uri.fsPath)) - } - } +async function refreshGtsFileExplorer(): Promise { + pendingOpenFile = null + await performInitialScan(true) +} - return roots +/** + * 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 performInitialScan() { +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 // 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. + 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. // 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) { @@ -482,40 +705,52 @@ async function performInitialScan() { 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) } - console.log(`[GTS] Phase 1: ${phase1Candidates.length} candidate files (of ${fastUris.length} enumerated)`) - const files1 = await readGtsCandidateFiles(phase1Candidates) - setLastScanFiles(files1) + // 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) 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() // 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 ------------------------------------------- // 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 phase2Uris = allUris.filter(uri => !phase1Paths.has(uri.fsPath) && !isUriIgnored(uri, ignoreMatcher)) - if (phase2Uris.length > 0) { - const files2 = await readGtsCandidateFiles(phase2Uris) - if (files2.length > 0) { - for (const f of files2) indexFileInRegistry(f.path, f.name, f.content) - setLastScanFiles([...files1, ...files2]) - await gtsLinkProvider?.refresh() - await validateWorkspaceInBackground(getBackgroundValidationRoots()) - revalidateOpenDocs() - } - console.log(`[GTS] Phase 2: merged ${files2.length} GTS files (of ${phase2Uris.length} deferred)`) + 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) && !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) + 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() } + 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 @@ -535,15 +770,69 @@ export async function deactivate() { gtsLinkProvider = null } + gtsExplorer = null layoutStorage = null } -// Debounced rescan on change to auto-refresh layout view while typing -let changeTimer: NodeJS.Timeout | null = null +// 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() + +function clearChangeTimers(): void { + for (const timer of changeTimers.values()) clearTimeout(timer) + changeTimers.clear() +} -/** 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) +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 (isExcludedFolder(linkUri)) 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 => { onDiskPathRemoved(uri) }) + ) + console.log('[GTS] Watching symlinked directory:', linkUri.fsPath) + } + } } /** True if the file is currently open as a text document (editor owns its content). */ @@ -558,16 +847,23 @@ 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 { 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 } - indexFileInRegistry(fsPath, name, content) + if (fileMutationRevisions.get(fsPath) !== mutationRevision || !fs.existsSync(fsPath)) return + // 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) return @@ -575,49 +871,136 @@ 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 - if (isIgnoredGtsPath(fsPath)) return - if (isUriIgnored(uri)) return - removeFileFromRegistry(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 (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. + await mapWithConcurrency(uris, FILE_READ_CONCURRENCY, fileUri => 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 +const fileMutationRevisions = new Map() + +function beginFileMutation(fsPath: string): number { + pathsTouchedDuringScan?.add(fsPath) + const revision = (fileMutationRevisions.get(fsPath) || 0) + 1 + fileMutationRevisions.set(fsPath, revision) + return revision +} + 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. + // 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(getBackgroundValidationRoots()) - vscode.workspace.textDocuments.forEach(doc => { - if (isGtsCandidateFile(doc)) void validateOpenDocument(doc) - }) + await validateWorkspaceInBackground() + 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; an entry is removed when that file's debounced revalidation fires. +const preEditIdsByPath = new Map>() + function handleFileChange(doc: vscode.TextDocument, delayMsec: number = 500) { if (!isGtsCandidateFile(doc)) return + const fsPath = doc.uri.fsPath + beginFileMutation(fsPath) + + // Snapshot the file's ids from before this edit burst (once per burst), before + // the immediate reindex below overwrites them in the registry. + if (!preEditIdsByPath.has(fsPath)) { + const registry = getRegistry() + preEditIdsByPath.set(fsPath, new Set(registry?.getEntityIdsForFile(fsPath) || [])) + } + // Immediate + cheap: keep the shared registry index and the editor's color // annotations in sync with the live document as the user types. No Ajv here. try { const text = doc.getText() - const name = path.basename(doc.uri.fsPath) + const name = path.basename(fsPath) let content: any try { content = parseGtsFileContent(name, text) } catch { content = text } - indexFileInRegistry(doc.uri.fsPath, name, content) + // Ensure this physical file is indexed under exactly this (open) path. + claimCanonicalPath(fsPath) + indexFileInRegistry(fsPath, name, content) + gtsExplorer?.refresh() } catch (e) { console.error('[GTS] Incremental index failed:', e) } @@ -626,15 +1009,32 @@ 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(() => { - void validateOpenDocument(doc) - if (viewerPanel) { - void scanAndPost(GTS_SCAN_GLOB, false, doc.uri.fsPath) - } - }, delayMsec) + // 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(() => { + changeTimers.delete(fsPath) + const previousIds = preEditIdsByPath.get(fsPath) + preEditIdsByPath.delete(fsPath) + 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) await scanAndPost(fsPath) + })() + }, delayMsec)) +} + +/** 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) { @@ -660,16 +1060,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', @@ -724,12 +1122,13 @@ function openViewer(context: vscode.ExtensionContext, resource?: vscode.Uri) { case 'scanWorkspaceJson': { try { - const include: string = message.options?.include || GTS_SCAN_GLOB const isInitialScan = !hasPerformedInitialScan - if (isInitialScan) { - hasPerformedInitialScan = true - } - await scanAndPost(include, isInitialScan) + hasPerformedInitialScan = true + // 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/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 new file mode 100644 index 0000000..b8e7f15 --- /dev/null +++ b/apps/vscode-extension/src/gtsExplorer.ts @@ -0,0 +1,342 @@ +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 + * 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 + +/** + * 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 parts = treePathParts(fsPath, multiRoot) + 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 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 [] + return Array.from(new Set([ + ...registry.jsonFiles.keys(), + ...registry.invalidFiles.keys(), + ...getPathsWithMalformedIds() + ])) +} + +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 validation error. */ +export function hasGtsErrors(uri: vscode.Uri): boolean { + if (gtsDiagnosticsOf(uri).length > 0) 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)) +} + +/** Collect every file path under an element (a single file, or all files under a folder subtree). */ +function collectFilePaths(element: GtsTreeElement, out: string[]): void { + if (element.kind === 'file') { + out.push(element.fsPath) + return + } + for (const child of element.children.values()) collectFilePaths(child, out) +} + +export class GtsFileTreeProvider + implements vscode.TreeDataProvider, vscode.TreeDragAndDropController +{ + // Advertise `text/uri-list` so dragged items are understood by the editor, + // the Explorer and the chat as regular file references. We don't accept drops. + readonly dragMimeTypes = ['text/uri-list'] + readonly dropMimeTypes: string[] = [] + + private readonly _onDidChangeTreeData = new vscode.EventEmitter() + readonly onDidChangeTreeData = this._onDidChangeTreeData.event + + private root: GtsFolderNode = { kind: 'folder', label: '', children: new Map() } + // The set of discovered file paths currently reflected in the tree. Used to + // avoid rebuilding (and thus visually flickering) the whole tree when only + // file *contents* changed but the file list is the same. + private knownPaths = new Set() + + /** + * Reconcile the tree with the current registry state. Only rebuilds (and fires + * a tree-data change) when the *set* of discovered files actually changed — + * green/red error state is handled separately via file decorations, so a plain + * content edit must not rebuild the tree. Returns the URIs that were added or + * removed so the caller can refresh just those decorations. + */ + /** 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) + + 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) + this._onDidChangeTreeData.fire() + return changed + } + + getTreeItem(element: GtsTreeElement): vscode.TreeItem { + if (element.kind === 'file') { + const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.None) + item.resourceUri = vscode.Uri.file(element.fsPath) + item.contextValue = 'gtsFile' + item.command = { + command: 'gts-kit.openFileFromTree', + title: 'Open GTS File', + arguments: [element.fsPath] + } + item.tooltip = hasGtsErrors(item.resourceUri) + ? 'Has GTS validation errors' + : 'No GTS validation errors' + return item + } + + const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.Expanded) + item.iconPath = vscode.ThemeIcon.Folder + item.contextValue = 'gtsFolder' + return item + } + + getChildren(element?: GtsTreeElement): GtsTreeElement[] { + const folder = element ? (element.kind === 'folder' ? element : undefined) : this.root + if (!folder) return [] + return sortedChildren(folder) + } + + /** + * Expose dragged files (and every file under a dragged folder) as a + * `text/uri-list` payload — a newline-separated list of file URIs — which the + * editor and chat accept as file references, so items can be dropped there. + */ + handleDrag( + source: readonly GtsTreeElement[], + dataTransfer: vscode.DataTransfer, + _token: vscode.CancellationToken + ): void { + const fsPaths: string[] = [] + for (const element of source) collectFilePaths(element, fsPaths) + if (fsPaths.length === 0) return + const uriList = fsPaths.map(p => vscode.Uri.file(p).toString()).join('\r\n') + dataTransfer.set('text/uri-list', new vscode.DataTransferItem(uriList)) + } +} + +/** + * Colors every 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() + readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event + + refresh(uris?: vscode.Uri[]): void { + this._onDidChangeFileDecorations.fire(uris) + } + + provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined { + if (!isDiscoveredGtsFile(uri.fsPath)) return undefined + + if (hasGtsErrors(uri)) return undefined + return new vscode.FileDecoration(undefined, 'GTS: file is valid', new vscode.ThemeColor('charts.green')) + } +} + +export interface GtsExplorer { + treeProvider: GtsFileTreeProvider + decorationProvider: GtsFileDecorationProvider + reset(): void + /** Call after the registry's set of discovered files may have changed (rescan, index/remove file). */ + refresh(): void +} + +/** Update the small rounded problem-count badge shown next to the view title. */ +function updateBadge(treeView: vscode.TreeView): void { + const count = countPublishedProblems() + treeView.badge = count > 0 + ? { value: count, tooltip: `${count} GTS problem${count === 1 ? '' : 's'}` } + : 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() + const decorationProvider = new GtsFileDecorationProvider() + + const treeView = vscode.window.createTreeView('gts-kit.fileExplorer', { + treeDataProvider: treeProvider, + dragAndDropController: treeProvider, + canSelectMany: true, + showCollapseAll: true + }) + + context.subscriptions.push( + treeView, + vscode.window.registerFileDecorationProvider(decorationProvider), + 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) + 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. 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() + if (changed.length > 0) decorationProvider.refresh(changed) + updateBadge(treeView) + }, 150) + } + + treeProvider.refresh() + decorationProvider.refresh() + updateBadge(treeView) + + return { + treeProvider, + decorationProvider, + reset() { + treeProvider.refresh() + decorationProvider.refresh() + updateBadge(treeView) + }, + refresh() { + // Only the added/removed files need a decoration repaint; error-state + // changes on existing files are repainted by the onDidChangeDiagnostics + // handler above. A global decoration refresh here would flicker every file. + // 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) + scheduleTreeUpdate() + } + } +} 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/helpers.ts b/apps/vscode-extension/src/helpers.ts index 3dbecd1..2839d77 100644 --- a/apps/vscode-extension/src/helpers.ts +++ b/apps/vscode-extension/src/helpers.ts @@ -1,8 +1,18 @@ import * as vscode from 'vscode' +import * as fs from 'fs' 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) } + +export function isIndexableGtsDocument(document: vscode.TextDocument): boolean { + return isGtsCandidateFile(document) && (document.isDirty || fs.existsSync(document.uri.fsPath)) +} diff --git a/apps/vscode-extension/src/linkProvider.ts b/apps/vscode-extension/src/linkProvider.ts index 6a44561..fd09145 100644 --- a/apps/vscode-extension/src/linkProvider.ts +++ b/apps/vscode-extension/src/linkProvider.ts @@ -1,8 +1,11 @@ import * as vscode from 'vscode' -import { JsonRegistry, GTS_COLORS, GTS_URI_PREFIX, parseGtsIdParts, findSimilarEntityIds, normalizeGtsId, checkGtsUriPrefix, isGtsId, isGtsIdOrPattern, isGtsPattern } from '@gts/shared' -import type { GtsPrefixIssue } from '@gts/shared' -import { getRegistry } from './registryStore' +import { JsonRegistry, GTS_COLORS, GTS_URI_PREFIX, parseGtsIdParts, analyzeGtsIdForStyling, findSimilarEntityIds, normalizeGtsId, checkGtsUriPrefix, isGtsId, isGtsIdOrPattern, isGtsPattern, isYamlFileName } from '@gts/shared' +import type { GtsPrefixIssue, JsonEntity } from '@gts/shared' +import * as fs from 'fs' +import { getRegistry, getRegistryRevision } from './registryStore' +import { getDocumentValidationErrors, entityErrorsInDocument } from './validation' import * as jsonc from 'jsonc-parser' +import * as YAML from 'yaml' /** * Represents a GTS ID reference found in the document @@ -17,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.*"). */ @@ -26,99 +31,119 @@ 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, "'") + '`' } /** - * Get workspace-relative path from absolute path + * 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. */ -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) - } +const HOVER_TRUST = { enabledCommands: ['gts.replaceGtsId'] } - return absolutePath +/** + * 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 { + 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 +} - return 0 // Default to first line +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 +} + +/** 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 { - private diagnosticCollection: vscode.DiagnosticCollection +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 @@ -139,9 +164,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 @@ -215,7 +238,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() @@ -223,7 +246,6 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove this.errorDecorationType.dispose() this.unresolvedDecorationType.dispose() this.segmentGapDecorationType.dispose() - this.diagnosticCollection.clear() } /** @@ -246,6 +268,17 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } } + /** + * Update decorations for visible editors showing the given document URI. + */ + public updateDecorationsForUri(uri: vscode.Uri): void { + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === uri.toString()) { + this.updateDecorations(editor) + } + } + } + /** * Update decorations for a specific editor */ @@ -255,18 +288,26 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove } const document = editor.document + const filePath = document.uri.fsPath - // Only decorate JSON/JSONC/GTS files - if (!['json', 'jsonc', 'gts'].includes(document.languageId)) { + // Only decorate JSON/JSONC/GTS/YAML files + if (!['json', 'jsonc', 'gts', 'yaml'].includes(document.languageId) && !isYamlFileName(document.fileName)) { return } + const docErrors = [ + ...getDocumentValidationErrors(document.uri), + // 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 || []) + ] + const schemaRanges: vscode.Range[] = [] const instanceRanges: vscode.Range[] = [] 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) @@ -276,80 +317,76 @@ 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)) 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 - if (text[refOffset] === '"') { - gtsStartOffset = refOffset + 1 - } + const gtsStartOffset = ref.contentOffset 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 - ) - diagnostic.source = 'gts' - diagnostics.push(diagnostic) - + errorRanges.push(new vscode.Range(startPos, endPos)) continue } // 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)) continue } - // Parse the GTS ID into parts - const parts = parseGtsIdParts(ref.id) + // Classify the segments using the shared, core-backed styling analyzer so + // schema-vs-instance is derived STRUCTURALLY from the GTS ID via gts-ts. + // Correctness (red vs blue/green) comes from the authoritative gts-ts + // validation results: a *schema* segment whose entity failed gts-ts + // validation (e.g. an invalid derived schema in the chain) is `isValid: + // false` → red. Instance-level, field-specific errors (abstract type, + // x-gts-ref, ...) are handled by `hasFieldError` below, not by flagging the + // whole instance entity, so an instance's own id is not reddened merely + // because some other field of it failed. No GTS rules are re-derived here. + const registry = this.registry + const analysis = analyzeGtsIdForStyling(ref.id, (entityId: string) => { + const schema = registry.jsonSchemas.get(entityId) + if (schema) { + return { exists: true, isSchema: true, isValid: !schema.validation?.errors?.length } + } + const obj = registry.jsonObjs.get(entityId) + if (obj) { + return { exists: true, isSchema: false } + } + return { exists: false } + }) + + // A gts-ts validation error reported at (or under) this field's instance + // path means the value written here is what's wrong — colour every segment + // red regardless of its structural classification. + const refInstancePath = '/' + ref.sourcePath.replace(/\./g, '/').replace(/\[(\d+)\]/g, '/$1') + const hasFieldError = docErrors.some(err => { + return Boolean(err.instancePath && (err.instancePath === refInstancePath || err.instancePath.startsWith(refInstancePath + '/'))) + }) // Calculate the offset of the string value (excluding quotes) - const text = document.getText() - 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 - let currentOffset = gtsStartOffset - for (let segIndex = 0; segIndex < parts.length; segIndex++) { - const part = parts[segIndex] - const partStartPos = document.positionAt(currentOffset) - const partEndPos = document.positionAt(currentOffset + part.length) + // References inside an "examples" field show missing entities as a neutral + // gray chip instead of a red error. + const inExamples = ref.sourcePath.split('.').some(seg => seg === 'examples') + + for (let segIndex = 0; segIndex < analysis.segments.length; segIndex++) { + const seg = analysis.segments[segIndex] + const partStartPos = document.positionAt(gtsStartOffset + seg.startOffset) + const partEndPos = document.positionAt(gtsStartOffset + seg.endOffset) const partRange = new vscode.Range(partStartPos, partEndPos) // Every segment after the first gets a uniform leading gap, so the @@ -359,46 +396,22 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove gapRanges.push(partRange) } - // Determine the full entity ID to look up - let entityIdToLookup: string - if (parts.length === 1) { - entityIdToLookup = part - } else if (part === parts[0]) { - entityIdToLookup = part - } else { - entityIdToLookup = parts[0] + part - } - - // Look up the entity in the registry - const entity = this.registry.jsonSchemas.get(entityIdToLookup) || this.registry.jsonObjs.get(entityIdToLookup) - - if (entity) { - if (entity.isSchema) { - schemaRanges.push(partRange) - } else { - instanceRanges.push(partRange) - } + if (hasFieldError) { + errorRanges.push(partRange) + } else if (seg.type === 'schema') { + schemaRanges.push(partRange) + } else if (seg.type === 'instance') { + instanceRanges.push(partRange) + } else if (inExamples) { + // Entity not found inside an examples block — neutral gray chip. + unresolvedRanges.push(partRange) } else { - // Entity not found — if the reference is inside an "examples" field, - // show a neutral gray chip instead of a red error. - const inExamples = ref.sourcePath.split('.').some(seg => seg === 'examples') - if (inExamples) { - unresolvedRanges.push(partRange) - } else { - errorRanges.push(partRange) - - // Create diagnostic for missing entity - const diagnostic = new vscode.Diagnostic( - partRange, - `GTS entity not found: "${entityIdToLookup}"`, - vscode.DiagnosticSeverity.Error - ) - diagnostic.source = 'gts' - diagnostics.push(diagnostic) - } + // Red chip only — the authoritative "GTS reference not found" + // diagnostic for this is published by the shared validator + // (registry.validateEntity, surfaced via validation.ts) so we + // don't publish a second, duplicate diagnostic for the same miss. + errorRanges.push(partRange) } - - currentOffset += part.length } } @@ -408,15 +421,122 @@ 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) } /** - * 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[] { + 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, contentOffset: number): 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, + contentOffset, + 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, valueStartOffset)) + } + }) + } catch (error) { + console.error('[GTS LinkProvider] Error visiting YAML document:', error) + } + + return references + } + + /** + * Find all GTS ID references in a JSON/JSONC document using jsonc-parser + */ + private findGtsReferencesJson(document: vscode.TextDocument): GtsIdReference[] { const references: GtsIdReference[] = [] const text = document.getText() @@ -442,12 +562,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') { @@ -456,25 +575,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)) } } }) @@ -511,58 +613,31 @@ 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 - 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 - 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 } currentOffset += part.length @@ -572,6 +647,57 @@ 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 + * 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 */ @@ -603,16 +729,11 @@ 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() - markdown.isTrusted = true + markdown.isTrusted = HOVER_TRUST markdown.supportHtml = false // Malformed gts:// prefix usage - explain the rule and offer a one-click fix. @@ -683,32 +804,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.*`) } @@ -727,25 +831,67 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove let entityIdToLookup = gtsId let hoverRange = matchedRef.range - - if (parts.length > 1) { - const firstPartLength = parts[0].length - if (relativeOffset < firstPartLength) { - // Cursor is on the first part - entityIdToLookup = parts[0] - const startPos = document.positionAt(gtsBodyOffset) - const endPos = document.positionAt(gtsBodyOffset + firstPartLength) - hoverRange = new vscode.Range(startPos, endPos) - } else { - // Cursor is on the second part - entityIdToLookup = parts[0] + parts[1] - const startPos = document.positionAt(gtsBodyOffset + firstPartLength) - const endPos = document.positionAt(gtsBodyOffset + gtsId.length) + let hoveredSegmentIndex: number | undefined + + let segmentStartOffset = 0 + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const part = parts[partIndex] + const segmentEndOffset = segmentStartOffset + part.length + if (relativeOffset >= segmentStartOffset && relativeOffset < segmentEndOffset) { + hoveredSegmentIndex = partIndex + entityIdToLookup = parts.slice(0, partIndex + 1).join('') + const startPos = document.positionAt(gtsBodyOffset + segmentStartOffset) + const endPos = document.positionAt(gtsBodyOffset + segmentEndOffset) hoverRange = new vscode.Range(startPos, endPos) + break + } + segmentStartOffset = segmentEndOffset + } + + // Classify the hovered segment with the SAME analyzer that drives the + // colouring, so the hover verdict never contradicts the red/blue/green chip + // and we don't hand-roll a second, divergent notion of "missing". + const registry = this.registry + const analysis = analyzeGtsIdForStyling(gtsId, (id: string) => { + const schema = registry.jsonSchemas.get(id) + if (schema) return { exists: true, isSchema: true, isValid: !schema.validation?.errors?.length } + const obj = registry.jsonObjs.get(id) + if (obj) return { exists: true, isSchema: false } + return { exists: false } + }) + const hoveredSeg = hoveredSegmentIndex !== undefined ? analysis.segments[hoveredSegmentIndex] : undefined + + if (hoveredSeg && hoveredSeg.type === 'error') { + const firstErrorIdx = analysis.segments.findIndex(s => s.type === 'error') + // An earlier segment is the real cause; this one only cascades from it. + if (firstErrorIdx !== -1 && hoveredSegmentIndex !== undefined && firstErrorIdx < hoveredSegmentIndex) { + const culprit = analysis.segments[firstErrorIdx].entityId + markdown.appendMarkdown(`GTS Parent Type Not Found\n\n`) + markdown.appendMarkdown(`This segment derives from ${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 + // 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 + // 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) { @@ -759,32 +905,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.*`) } @@ -799,8 +928,20 @@ 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 lineNumber = await findDefinitionLine(entity) const fileUri = vscode.Uri.file(entity.file.path).with({ fragment: `L${lineNumber + 1}` }) @@ -808,7 +949,7 @@ export class GtsLinkProvider implements vscode.DocumentLinkProvider, vscode.Hove // Make the GTS ID itself clickable markdown.appendMarkdown(`GTS ID: [${escapeMarkdown(entityIdToLookup)}](${fileUri.toString()})\n\n`) - markdown.appendMarkdown(`Type: ${entityType}\n\n`) + markdown.appendMarkdown(`Kind: ${entityType}\n\n`) markdown.appendMarkdown(`Definition: [${escapeMarkdown(relativePath)}](${fileUri.toString()})`) // Add description if available (on a new line, no label) diff --git a/apps/vscode-extension/src/registryStore.ts b/apps/vscode-extension/src/registryStore.ts index ef8edd7..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. @@ -14,23 +15,60 @@ 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 +// 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 { 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 }) + malformedIdsByPath.clear() + for (const file of files) updateMalformedIds(file.path, file.content) registry = next + revision++ return next } @@ -38,11 +76,16 @@ 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++ } /** 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) + malformedIdsByPath.delete(path) + revision++ } /** The GTS config the registry was built with. */ 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 0500ee6..8ab771e 100644 --- a/apps/vscode-extension/src/validation.ts +++ b/apps/vscode-extension/src/validation.ts @@ -1,23 +1,66 @@ 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 type { JsonRegistry, JsonSchema, JsonObj } from '@gts/shared' import { getLastScanFiles } from './scanStore' -import { getRegistry, rebuildRegistry, indexFile } from './registryStore' -import { isGtsCandidateFile } from './helpers' +import { getRegistry, rebuildRegistry, indexFile, removeFile, getMalformedIds, getPathsWithMalformedIds } from './registryStore' +import { malformedGtsIdMessage } from './gtsIdFormat' +import { isGtsCandidateFile, isIndexableGtsDocument } from './helpers' let diagnosticCollection: vscode.DiagnosticCollection let workspaceDiagnosticCollection: vscode.DiagnosticCollection -let isInitialScanComplete = false - -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 + +const documentValidationErrors = new Map() +const documentValidationGenerations = new Map() +const validationCompletedListeners = new Set<(uri: vscode.Uri) => void>() +let workspaceValidationGeneration = 0 + +export function getDocumentValidationErrors(uri: vscode.Uri): ValidationError[] { + return documentValidationErrors.get(uri.toString()) || [] +} + +export function onValidationCompleted(listener: (uri: vscode.Uri) => void): vscode.Disposable { + validationCompletedListeners.add(listener) + return new vscode.Disposable(() => validationCompletedListeners.delete(listener)) +} + +/** + * 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 } - return false } /** @@ -25,35 +68,14 @@ function isPathUnderAnyRoot(filePath: string, roots: string[] | undefined): bool */ function validationErrorsToDiagnostics(errors: ValidationError[], 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 + // 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) - // 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) - } - - const diagnostic = new vscode.Diagnostic( - range, - error.message, - vscode.DiagnosticSeverity.Error - ) + const diagnostic = new vscode.Diagnostic(range, error.message, vscode.DiagnosticSeverity.Error) diagnostic.source = 'GTS' diagnostic.code = error.keyword @@ -66,91 +88,125 @@ function validationErrorsToDiagnostics(errors: ValidationError[], document: vsco /** * 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(/^\//, '') - // For gts:// prefix violations and x-gts-ref mismatches, highlight the - // offending string value precisely. - if ((error.keyword === 'gts-uri-prefix' || error.keyword === 'x-gts-ref') && error.params && 'value' in error.params) { - const value = String((error.params as any).value) - const idx = text.indexOf(`"${value}"`) - if (idx !== -1) { - const startPos = document.positionAt(idx + 1) // +1 to skip opening quote - const endPos = document.positionAt(idx + 1 + value.length) - return new vscode.Range(startPos, endPos) - } - } - - // For schema errors, find the object that references the missing schema - 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) - if (position) { - return position + // 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 if (path) { - const position = findObjectAtPath(text, document, path) - console.log(`[GTS Validation] findObjectAtPath returned:`, position) + } else { + // 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 } } } - // For additionalProperties errors, look for the actual property mentioned in params + // 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 searchPattern = new RegExp(`["']${escapeRegex(additionalProp)}["']\\s*:`, 'g') + const keyRange = findKeyRangeAtInstancePath(parsed, instancePath, additionalProp) + if (keyRange) { + return keyRange + } + 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) } } - // 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 + // 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(parsed, instancePath) + if (valueRange) { + return valueRange + } + const keyRange = findKeyRangeAtInstancePath(parsed, instancePath) + if (keyRange) { + return keyRange + } + } - // 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)) + // 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}"`) + 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 + quoteLen) + const endPos = document.positionAt(idx + quoteLen + value.length) + return new vscode.Range(startPos, endPos) + } + } + + // 5. For schema errors without a resolvable instancePath, search by schemaId in params + if (error.keyword === 'schema') { + + // 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 + const position = findTypeFieldByValue(parsed, schemaId) + if (position) { + return position } - } else { - // Find the object that should contain this property - const position = findObjectAtPath(text, document, path) + } else if (path) { + const position = findKeyRangeAtInstancePath(parsed, instancePath) ?? findObjectAtPath(parsed, 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(parsed, instancePath) + if (keyRange) { + return keyRange + } const segments = path.split('/') const lastSegment = segments[segments.length - 1] if (lastSegment && !/^\d+$/.test(lastSegment)) { // Not an array index, try to find the property name - const searchPattern = new RegExp(`["']${escapeRegex(lastSegment)}["']\\s*:`, 'g') + const searchPattern = keyRegex(lastSegment) const match = searchPattern.exec(text) if (match) { - const startPos = document.positionAt(match.index + 1) // +1 to skip opening quote - const endPos = document.positionAt(match.index + 1 + lastSegment.length) + const quoteLen = match[1] ? 1 : 0 + const startPos = document.positionAt(match.index + quoteLen) + const endPos = document.positionAt(match.index + quoteLen + lastSegment.length) return new vscode.Range(startPos, endPos) } } @@ -164,29 +220,28 @@ 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) - // 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) - 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 } @@ -194,7 +249,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(/\{/) @@ -211,7 +267,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 @@ -219,7 +275,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 @@ -238,8 +294,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 @@ -250,7 +306,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 } @@ -260,25 +315,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) } @@ -287,13 +338,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++ @@ -301,7 +350,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 } @@ -312,20 +360,168 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +/** + * Build a regex matching a quoted property key followed by `:`. + * Requires quotes so it never accidentally matches bare words inside comments. + */ +function keyRegex(name: string): RegExp { + const esc = escapeRegex(name) + return new RegExp(`(["'])${esc}\\1\\s*:`, 'g') +} + +/** + * Split an AJV-style instancePath ("/tokens/2/subject_type") into path segments, + * converting numeric segments into numbers so array indices resolve to the + * correct list item rather than being treated as a property key. Empty segments + * (from the leading slash or a "/" root path) are dropped. 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.replace(/~1/g, '/').replace(/~0/g, '~'))) +} + +/** + * 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(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 } = parsed + + if (parsed.isYaml) { + const doc = parsed.yamlDoc + if (!doc) 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 = parsed.jsonRoot + if (!root) return null + + const node = jsonc.findNodeAtLocation(root, segments) + if (node && node.parent && node.parent.type === 'property' && node.parent.children) { + const keyNode = node.parent.children[0] + if (keyNode) { + let offset = keyNode.offset + let length = keyNode.length + if (keyNode.type === 'string') { + offset += 1 + length = Math.max(0, length - 2) + } + return new vscode.Range(document.positionAt(offset), document.positionAt(offset + length)) + } + } + return null +} + +/** + * Resolve the document range of the *value* at a given instancePath, honoring + * array indices. This is what lets repeated keys under different array items + * (e.g. `subject_type` inside several `tokens`) each resolve to their own + * occurrence instead of every error collapsing onto the first textual match. + * + * Returns null when the path cannot be resolved (e.g. multi-entity files whose + * per-entity paths are not rooted at the document), so callers can fall back to + * the coarser text-search strategies. + */ +function findValueRangeAtInstancePath(parsed: ParsedDocument, instancePath: string): vscode.Range | null { + const segments = instancePathSegments(instancePath) + if (segments.length === 0) return null + 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(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 + + const [startOffset, valueEndOffset] = node.range + // Skip the opening quote (if any) so the range points at the string content. + const raw = text.slice(startOffset, valueEndOffset) + const quoteLen = raw.startsWith('"') || raw.startsWith("'") ? 1 : 0 + const valueStart = startOffset + quoteLen + const value = String(node.value) + + const startPos = document.positionAt(valueStart) + const endPos = document.positionAt(valueStart + value.length) + return new vscode.Range(startPos, endPos) +} + +/** + * Resolve the document range of the *scalar value* at a given instancePath, + * honoring array indices. Returns null when the node is not a scalar (an + * object/array subschema — e.g. a derivation error pointing at a property whose + * value is itself a schema), so callers can fall back to highlighting the key. + */ +function findValueRangeJson(parsed: ParsedDocument, segments: Array): vscode.Range | null { + const { document } = parsed + const root = parsed.jsonRoot + if (!root) return null + + const node = jsonc.findNodeAtLocation(root, segments) + if (!node) return null + // Only scalars have a meaningful "value" range to underline; objects/arrays + // resolve to the property key instead (handled by findKeyRangeAtInstancePath). + if (node.type === 'object' || node.type === 'array') return null + + let offset = node.offset + let length = node.length + // jsonc node offsets for strings include the surrounding quotes; strip them + // so the range covers only the string content. + if (node.type === 'string') { + offset += 1 + length = Math.max(0, length - 2) + } + + const startPos = document.positionAt(offset) + const endPos = document.positionAt(offset + length) + return new vscode.Range(startPos, endPos) +} + /** * Validate a document and update diagnostics */ export async function validateOpenDocument(document: vscode.TextDocument) { - if (!isGtsCandidateFile(document)) { + if (!isIndexableGtsDocument(document)) { 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) 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. @@ -335,7 +531,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 } @@ -357,8 +552,6 @@ export async function validateOpenDocument(document: vscode.TextDocument) { let errors: ValidationError[] = [] - const invalid = registry.invalidFiles.get(filePath) - if (parseErrorMessage) { errors = [{ instancePath: '', @@ -367,100 +560,410 @@ 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) + for (const e of entities) await registry.validateEntity(e) + errors = collectValidationErrors(registry, filePath, buildDefinitionIndex(registry)) } + if ( + documentValidationGenerations.get(validationKey) !== validationGeneration || + getRegistry() !== registry + ) return + // This document is now open and gets precise diagnostics; drop any coarse // background diagnostic so markers aren't duplicated. workspaceDiagnosticCollection?.delete(document.uri) if (errors.length > 0) { + documentValidationErrors.set(document.uri.toString(), errors) const diagnostics = validationErrorsToDiagnostics(errors, document) diagnosticCollection.set(document.uri, diagnostics) - console.log(`[GTS Validation] ✗ Got ${diagnostics.length} GTS diagnostics errors for ${fileName} - Errors:`, diagnostics.map(d => ({ message: d.message, range: d.range }))) } else { + documentValidationErrors.delete(document.uri.toString()) diagnosticCollection.delete(document.uri) - console.log(`[GTS Validation] ✓ No errors, cleared diagnostics for ${fileName}`) + } + + for (const listener of validationCompletedListeners) { + try { + listener(document.uri) + } catch (err) { + console.error('[GTS Validation] Error in validation completed listener:', err) + } } } catch (error) { console.error('[GTS Validation] ✗ Error validating document:', error) + if (documentValidationGenerations.get(validationKey) !== validationGeneration) return + documentValidationErrors.delete(validationKey) diagnosticCollection.delete(document.uri) } } +// 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 { - const registry = getRegistry() - if (!registry || !workspaceDiagnosticCollection) return +export function validateWorkspaceInBackground(): Promise { + const published = new Promise(resolve => workspaceValidationWaiters.push(resolve)) + if (!workspaceValidationRunning) void drainWorkspaceValidation() + return published +} - const openPaths = new Set() - for (const doc of vscode.workspace.textDocuments) { - if (doc.uri.scheme === 'file' && isGtsCandidateFile(doc)) { - openPaths.add(doc.uri.fsPath) +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 } +} - 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), - error.message, - vscode.DiagnosticSeverity.Error - ) - diagnostic.source = 'GTS' - diagnostic.code = error.keyword - diagnostics.push(diagnostic) - diagnosticsByPath.set(filePath, diagnostics) +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) +} + +/** Coarse (line 0) diagnostic for a file that isn't open in an editor. */ +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 +} + +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 +} - // 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) +/** 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}` +} + +/** + * 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 + +/** + * 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): ValidationError[] { + const problems: ValidationError[] = [] + 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 } + }) } + return problems +} - // 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 (!entity.file?.path) continue - const errors = entity.validation?.errors || [] - for (const error of errors) addError(entity.file.path, error) +function malformedIdProblems(filePath: string): ValidationError[] { + 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 errors. The single source for open-file, + * closed-file and workspace-wide diagnostics, so all three always agree. + */ +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(entityErrorsInDocument), + ...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() + 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 = entitiesByFile(registry).filter(entity => !validated.has(entity)) + if (pending.length === 0) break + for (const entity of pending) { + validated.add(entity) + if (!isIndexedInItsFile(registry, entity)) 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 (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() + for (const filePath of filePaths) { + if (openPaths.has(filePath)) continue + const problems = collectValidationErrors(registry, filePath, index) + if (problems.length > 0) diagnosticsByPath.set(filePath, problems.map(coarseDiagnostic)) } - 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 problems (${Date.now() - startTime}ms)`) + return true +} + +/** + * 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, index?: DefinitionIndex): Promise { + const registry = getRegistry() + if (!registry || !workspaceDiagnosticCollection) return + + // 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 = collectValidationErrors(registry, filePath, index ?? buildDefinitionIndex(registry)).map(coarseDiagnostic) + + const uri = vscode.Uri.file(filePath) + workspaceDiagnosticCollection.set(uri, diagnostics.length > 0 ? diagnostics : undefined) +} + +/** + * Re-read a (now-closed) file from disk and re-index it into the shared registry. + * + * When an editor closes, any unsaved buffer edits are discarded, so the registry + * may still hold the stale live content that `validateOpenDocument` indexed. Re- + * indexing from disk makes the subsequent closed-file validation reflect what is + * actually on disk. Deleted or unreadable files are removed from the registry. + */ +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 { + removeFile(uri.fsPath) + } +} + +/** + * 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 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 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) || []) { + if (definingPath !== changedPath) dependentPaths.add(definingPath) + } + } + 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) + } + } + + for (const dependentPath of dependentPaths) { + const openDoc = openByPath.get(dependentPath) + if (openDoc) { + await validateOpenDocument(openDoc) + } else { + await validateClosedFile(dependentPath, index) + } + } +} + +/** + * GTS problems currently published by this extension. Reads only our own + * diagnostic collections instead of every diagnostic in VS Code. + */ +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 count +} + +export function resetValidationDiagnostics(): void { + documentValidationErrors.clear() + documentValidationGenerations.clear() + workspaceValidationGeneration++ + diagnosticCollection?.clear() + workspaceDiagnosticCollection?.clear() } export function initValidation(context: vscode.ExtensionContext) { console.log('[GTS Validation] Initializing validation system...') // Create diagnostic collection for validation errors - diagnosticCollection = vscode.languages.createDiagnosticCollection('gts') + diagnosticCollection = vscode.languages.createDiagnosticCollection('gts-validation') context.subscriptions.push(diagnosticCollection) workspaceDiagnosticCollection = vscode.languages.createDiagnosticCollection('gts-workspace') context.subscriptions.push(workspaceDiagnosticCollection) @@ -476,17 +979,25 @@ 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) }) ) - // Clear diagnostics when document is closed + // When a document is closed (e.g. a preview tab replaced by clicking another + // file in the Explorer), drop its precise in-editor diagnostics and republish + // the coarse workspace diagnostic so the file keeps showing as invalid in the + // Explorer/tree. Without this the file would go green: validateOpenDocument + // removed the workspace marker when it was opened, and nothing restores it. context.subscriptions.push( - vscode.workspace.onDidCloseTextDocument(doc => { + vscode.workspace.onDidCloseTextDocument(async doc => { if (!isGtsCandidateFile(doc)) return - console.log(`[GTS Validation] Document closed: ${doc.fileName}`) + const validationKey = doc.uri.toString() + documentValidationGenerations.set(validationKey, (documentValidationGenerations.get(validationKey) || 0) + 1) + documentValidationErrors.delete(validationKey) diagnosticCollection.delete(doc.uri) + if (doc.uri.scheme !== 'file') return + await reindexClosedFileFromDisk(doc.uri) + await validateClosedFile(doc.uri.fsPath) }) ) diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json index 70fc931..6336cba 100644 --- a/apps/vscode-extension/tsconfig.json +++ b/apps/vscode-extension/tsconfig.json @@ -1,17 +1,17 @@ { "compilerOptions": { "target": "ES2020", - "module": "CommonJS", + "module": "ESNext", "lib": ["ES2020"], + "types": ["node"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, - "baseUrl": ".", "paths": { "@gts/shared": ["../../packages/shared/dist"], "@gts/shared/*": ["../../packages/shared/dist/*"] diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index d3d47b3..58222cb 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -3,9 +3,7 @@ module.exports = { env: { browser: true, es2020: true }, extends: [ 'eslint:recommended', - '@typescript-eslint/recommended', - 'eslint:recommended', - '@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended', ], ignorePatterns: ['dist', '.eslintrc.cjs'], parser: '@typescript-eslint/parser', diff --git a/apps/web/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/apps/web/package.json b/apps/web/package.json index a4aea16..c1736e1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@gts/web", - "version": "0.2.6", + "version": "0.2.7", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/JsonCode.tsx b/apps/web/src/components/JsonCode.tsx index fa8e2af..d653a4a 100644 --- a/apps/web/src/components/JsonCode.tsx +++ b/apps/web/src/components/JsonCode.tsx @@ -32,7 +32,7 @@ export function JsonCode({ code, language = 'json', className, registry = null, } // Define GTS highlighting function - returns JSX for GTS IDs, null for non-GTS - const renderGtsOverlay = (text: string): React.ReactNode => { + const renderGtsOverlay = (text: string, hasLineError?: boolean): React.ReactNode => { // Remove surrounding quotes if present to test the raw value const raw = text.replace(/^"/, '').replace(/"$/, '') @@ -41,12 +41,13 @@ export function JsonCode({ code, language = 'json', className, registry = null, return null // Not a GTS ID, no overlay needed } - // Analyze the GTS ID for styling + // Analyze the GTS ID for styling. Correctness is driven by gts-ts validation + // results surfaced via `isValid` (per entity) and the line's `hasLineError`. const analysis = analyzeGtsIdForStyling(raw, (entityId: string) => { if (!registry) return { exists: false } const schema = registry.jsonSchemas.get(entityId) const obj = registry.jsonObjs.get(entityId) - if (schema) return { exists: true, isSchema: true } + if (schema) return { exists: true, isSchema: true, isValid: !schema.validation?.errors?.length } if (obj) return { exists: true, isSchema: false } return { exists: false } }) @@ -120,23 +121,24 @@ export function JsonCode({ code, language = 'json', className, registry = null, let style: CSSProperties let tooltip: string | undefined - if (segment.type === 'schema') { + if (hasLineError || segment.type === 'error' || segment.type === 'invalid') { + style = { + color: GTS_COLORS.invalid.foreground, + backgroundColor: GTS_COLORS.invalid.background + } + } else if (segment.type === 'schema') { style = { color: GTS_COLORS.schema.foreground, backgroundColor: GTS_COLORS.schema.background } - } else if (segment.type === 'instance') { + } else { style = { color: GTS_COLORS.instance.foreground, backgroundColor: GTS_COLORS.instance.background } - } else { - // Invalid segment - entity not found - style = { - color: GTS_COLORS.invalid.foreground, - backgroundColor: GTS_COLORS.invalid.background - } + } + if (segment.type === 'error' || segment.type === 'invalid') { // Build detailed error message for missing entity let errorMessage = `⚠️ GTS Entity Not Found!\n\nID: ${segment.text}\n\n` @@ -222,7 +224,7 @@ export function JsonCode({ code, language = 'json', className, registry = null, // For string values (not property names), check if GTS overlay exists if (isString && !isProperty && typeof token.content === 'string') { - const gtsOverlay = renderGtsOverlay(token.content) + const gtsOverlay = renderGtsOverlay(token.content, hasError) if (gtsOverlay) { // GTS ID detected - render overlay INSTEAD of Prism styling diff --git a/apps/web/src/components/PropertyViewer.tsx b/apps/web/src/components/PropertyViewer.tsx index aee9623..90e08ab 100644 --- a/apps/web/src/components/PropertyViewer.tsx +++ b/apps/web/src/components/PropertyViewer.tsx @@ -17,7 +17,7 @@ interface ValidationError { /** * Render a GTS ID value with proper color-coding for each part */ -function renderGtsValue(value: string, registry: JsonRegistry | null) { +function renderGtsValue(value: string, registry: JsonRegistry | null, hasError?: boolean) { // Remove quotes if present const raw = value.replace(/^"/, '').replace(/"$/, '') @@ -26,12 +26,14 @@ function renderGtsValue(value: string, registry: JsonRegistry | null) { return {value} } - // Analyze the GTS ID for styling + // Analyze the GTS ID for styling. Correctness is driven by gts-ts validation + // results surfaced via `isValid` (per entity) and the `hasError` flag (a + // gts-ts validation error reported on this exact property). const analysis = analyzeGtsIdForStyling(raw, (entityId: string) => { if (!registry) return { exists: false } const schema = registry.jsonSchemas.get(entityId) const obj = registry.jsonObjs.get(entityId) - if (schema) return { exists: true, isSchema: true } + if (schema) return { exists: true, isSchema: true, isValid: !schema.validation?.errors?.length } if (obj) return { exists: true, isSchema: false } return { exists: false } }) @@ -62,15 +64,15 @@ function renderGtsValue(value: string, registry: JsonRegistry | null) { let bgColor: string let textColor: string - if (segment.type === 'schema') { + if (hasError || segment.type === 'error' || segment.type === 'invalid') { + bgColor = '#fee2e2' // red-100 + textColor = '#991b1b' // red-800 + } else if (segment.type === 'schema') { bgColor = '#dbeafe' // blue-100 textColor = '#1e40af' // blue-800 - } else if (segment.type === 'instance') { + } else { bgColor = '#dcfce7' // green-100 textColor = '#166534' // green-800 - } else { - bgColor = '#fee2e2' // red-100 - textColor = '#991b1b' // red-800 } return ( @@ -274,7 +276,7 @@ function PropertyItem({ property, level, pathKey, sectionStates, onToggleSection } catch {} }} > - {renderGtsValue(String(property.value), registry)} + {renderGtsValue(String(property.value), registry, hasError)} )} diff --git a/apps/web/src/hooks/useJsonFiles.ts b/apps/web/src/hooks/useJsonFiles.ts index 1592056..d111b13 100644 --- a/apps/web/src/hooks/useJsonFiles.ts +++ b/apps/web/src/hooks/useJsonFiles.ts @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react' import { JsonRegistry, parseJSONC, parseYAML } from '@gts/shared' -import { Scanner } from '../../../../packages/fs-adapters/types' +import { Scanner, FileChange } from '../../../../packages/fs-adapters/types' // Use the smart scanner that automatically chooses the best implementation import { WebSmartScanner } from '../../../../packages/fs-adapters/fs-adapter-web/src/index' import { AppConfig } from '@/lib/config' @@ -18,6 +18,10 @@ export function useJsonObjsWithScanner(createScanner: () => Scanner) { const registryRef = useRef(new JsonRegistry()) const watcherRef = useRef<(() => void) | null>(null) const hasInitiallySelectedRef = useRef(false) + // Serializes incremental file-change handling so overlapping watch events + // don't validate against a half-updated registry. + const changeQueueRef = useRef>(Promise.resolve()) + const versionBumpTimerRef = useRef | null>(null) // Browser/Electron init path: // - Prompt for directory, scan and ingest files @@ -133,7 +137,47 @@ export function useJsonObjsWithScanner(createScanner: () => Scanner) { } } - // Watch for file changes and trigger reloads to keep registry/layout in sync + // Coalesce a burst of incremental changes into a single re-render. + function scheduleVersionBump() { + if (versionBumpTimerRef.current) clearTimeout(versionBumpTimerRef.current) + versionBumpTimerRef.current = setTimeout(() => setVersion(v => v + 1), 100) + } + + // Incrementally apply a single file change through the shared registry logic: + // reindex the changed file and revalidate it plus everything that depends on it + // (derivation, instantiation, $ref/allOf, GTS-id references). This is the same + // revalidation the VS Code extension performs, so behavior is identical across + // Web, Electron and VS Code. + async function applyIncrementalChange(change: FileChange) { + const scanner = scannerRef.current + const registry = registryRef.current + if (!scanner) return + const { type, doc } = change + try { + if (type === 'unlink') { + await registry.applyFileChange(doc.path, doc.name, null, AppConfig.get().gts) + } else { + const text = await scanner.read(doc.path) + const isYaml = doc.name.endsWith('.yaml') || doc.name.endsWith('.yml') + let content: any + try { + content = isYaml ? parseYAML(text) : parseJSONC(text) + } catch { + // Surface parse errors only for files that look GTS-related; ignore + // unrelated malformed JSON (matches loadFromScanner's filter). + content = text.includes('gts.') ? text : null + } + await registry.applyFileChange(doc.path, doc.name, content, AppConfig.get().gts) + } + scheduleVersionBump() + } catch (err) { + console.error('Failed to revalidate after file change:', err) + } + } + + // Watch for file changes and revalidate incrementally to keep registry/layout + // in sync (dependent types/instances are revalidated too, not just the file + // that changed). function startWatching() { const scanner = scannerRef.current if (!scanner) return @@ -148,20 +192,21 @@ export function useJsonObjsWithScanner(createScanner: () => Scanner) { { glob: '**/*.{json,jsonc,gts,yaml,yml}' }, (change) => { console.log('File change detected:', change) - // Reload data when files change - loadFromScanner().catch(err => { - console.error('Failed to reload after file change:', err) - }) + // Serialize changes so each validates against a fully-updated registry. + changeQueueRef.current = changeQueueRef.current.then(() => applyIncrementalChange(change)) } ) } - // Cleanup watcher on unmount + // Cleanup watcher and pending timers on unmount React.useEffect(() => { return () => { if (watcherRef.current) { watcherRef.current() } + if (versionBumpTimerRef.current) { + clearTimeout(versionBumpTimerRef.current) + } } }, []) diff --git a/apps/web/src/hooks/useJsonFilesVscode.ts b/apps/web/src/hooks/useJsonFilesVscode.ts index 56e3ece..37232b5 100644 --- a/apps/web/src/hooks/useJsonFilesVscode.ts +++ b/apps/web/src/hooks/useJsonFilesVscode.ts @@ -1,5 +1,6 @@ import React from 'react' import { JsonRegistry } from '@gts/shared' +import type { ValidationRelayPayload } from '@gts/shared' import { AppConfig } from '@/lib/config' import { ViewerModel } from './viewerModel' @@ -17,6 +18,34 @@ export function useJsonObjsVscode() { const registryRef = React.useRef(new JsonRegistry()) const hasInitiallySelectedRef = React.useRef(false) const pendingSelectFileRef = React.useRef(null) + // The webview runs under a CSP that blocks Ajv's code generation, so the + // extension host computes validation and relays it via `gts-validation-result`. + // That message and the (async) `gts-scan-result` ingest race: if validation + // arrives while `ingestFiles` is still populating the registry, the entity + // lookups miss and the errors would be lost forever. Keep the latest payload + // so it can be (re)applied once ingest has finished. + const pendingValidationRef = React.useRef(null) + + // Merge host-computed validation onto the current registry entities (matched + // by id / path). Safe to call repeatedly; missing entities are skipped. + const applyValidation = React.useCallback((payload: ValidationRelayPayload | null): boolean => { + if (!payload) return false + const reg = registryRef.current + let applied = false + for (const o of payload.objs || []) { + const ent = reg.jsonObjs.get(o.id) as any + if (ent && o.validation) { ent.validation = o.validation; applied = true } + } + for (const s of payload.schemas || []) { + const ent = reg.jsonSchemas.get(s.id) as any + if (ent && s.validation) { ent.validation = s.validation; applied = true } + } + for (const f of payload.invalidFiles || []) { + const ent = reg.invalidFiles.get(f.path) as any + if (ent && f.validation) { ent.validation = f.validation; applied = true } + } + return applied + }, []) // Helper function to find and select entity from a file path const selectEntityFromFile = React.useCallback((filePath: string) => { @@ -34,6 +63,9 @@ export function useJsonObjsVscode() { registry.reset() await registry.ingestFiles(files, AppConfig.get().gts) try { (registry as any).setDefaultFile?.(defaultFilePath) } catch {} + // Registry is now populated: (re)apply any host validation that arrived + // before/while this ingest was running (see pendingValidationRef). + applyValidation(pendingValidationRef.current) setVersion(v => v + 1) const defaultPath = (registry as any).getDefaultFilePath?.() @@ -48,7 +80,7 @@ export function useJsonObjsVscode() { pendingSelectFileRef.current = null setTimeout(() => selectEntityFromFile(target), 0) } - }, [selectEntityFromFile]) + }, [selectEntityFromFile, applyValidation]) // Listen for scan events and trigger scan on mount React.useEffect(() => { @@ -66,6 +98,9 @@ export function useJsonObjsVscode() { async function onResult(e: any) { const files = e?.detail?.files || [] const defaultFilePath = e?.detail?.defaultFilePath || null + // A fresh scan invalidates the previous scan's validation; the host always + // follows a scan-result with a matching validation-result for this set. + pendingValidationRef.current = null try { await buildEntities(files, defaultFilePath) } finally { @@ -100,15 +135,11 @@ export function useJsonObjsVscode() { window.addEventListener('gts-select-file' as any, onSelectFile) function onValidationResult(e: any) { - const detail = e?.detail || {} - const objList: Array<{id: string; validation?: any}> = detail.objs || [] - const schemaList: Array<{id: string; validation?: any}> = detail.schemas || [] - const invalidList: Array<{path: string; validation?: any}> = detail.invalidFiles || [] - - const reg = registryRef.current - objList.forEach(o => { const ent = reg.jsonObjs.get(o.id) as any; if (ent && o.validation) ent.validation = o.validation }) - schemaList.forEach(s => { const ent = reg.jsonSchemas.get(s.id) as any; if (ent && s.validation) ent.validation = s.validation }) - invalidList.forEach(f => { const ent = reg.invalidFiles.get(f.path) as any; if (ent && f.validation) ent.validation = f.validation }) + const payload = (e?.detail || null) as ValidationRelayPayload | null + // Buffer so it can be re-applied if the scan-result ingest is still in + // flight (the two messages race), then apply against whatever is ready now. + pendingValidationRef.current = payload + applyValidation(payload) setVersion(v => v + 1) } @@ -137,7 +168,7 @@ export function useJsonObjsVscode() { window.removeEventListener('gts-validation-error' as any, onValidationError) window.removeEventListener('gts-select-file' as any, onSelectFile) } - }, [buildEntities]) + }, [buildEntities, applyValidation]) // Refresh from webview: ask the extension to rescan; SharedApp coordinates viewport/entity restoration const reload = React.useCallback(async () => { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f91e301..803ff46 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -21,7 +21,6 @@ "noFallthroughCasesInSwitch": true, /* Path mapping */ - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/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/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 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" }, diff --git a/package-lock.json b/package-lock.json index 36cef02..1761749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gts-monorepo", - "version": "0.2.6", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gts-monorepo", - "version": "0.2.6", + "version": "0.3.0", "workspaces": [ "apps/*", "packages/*" @@ -22,7 +22,7 @@ }, "apps/electron": { "name": "@gts-viewer/electron", - "version": "0.2.6", + "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.6", + "version": "0.3.0", "dependencies": { "@gts/shared": "*", "cors": "2.8.5", @@ -108,7 +108,7 @@ }, "apps/vscode-extension": { "name": "gts-kit", - "version": "0.2.6", + "version": "0.3.0", "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": "*", @@ -1627,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", @@ -1713,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", @@ -1733,20 +1734,27 @@ } }, "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.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", "ajv-formats": "^2.1.1", "commander": "^12.0.0", - "fastify": "^5.8.1", - "uuid": "^9.0.1" + "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": { @@ -7974,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", @@ -8022,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", @@ -9208,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" @@ -10898,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", @@ -11051,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", @@ -12808,17 +12836,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -13600,6 +13627,21 @@ "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==", + "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 +13793,7 @@ }, "packages/layout-storage": { "name": "@gts/layout-storage", - "version": "0.2.6", + "version": "0.3.0", "devDependencies": { "typescript": "^5.3.3" } @@ -13772,9 +13814,9 @@ }, "packages/shared": { "name": "@gts/shared", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { - "@globaltypesystem/gts-ts": "^0.4.0", + "@globaltypesystem/gts-ts": "^0.8.1", "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/README.md b/packages/shared/README.md new file mode 100644 index 0000000..3f86828 --- /dev/null +++ b/packages/shared/README.md @@ -0,0 +1,29 @@ +# @gts/shared + +Shared GTS models, parsing, and the `JsonRegistry` used by every app (web, +server, electron, VS Code) to index and validate GTS entities. + +## How files are recognized as GTS entity sources + +`isGtsCandidateFileName` accepts `.json`, `.jsonc`, `.gts`, `.yaml`, and `.yml`. +How a file's contents are interpreted depends on its extension: + +- **JSON / JSONC / .gts** — the document is either a **single entity** or a + **top-level array of entities**. Nothing else is scanned; any GTS id that + appears elsewhere is treated as a *reference*. + +- **YAML** — everything above, **plus** inline definitions. A YAML config file + may *define* GTS types/instances inline under any nested `entities:` array + (for example a service's `types-registry.config.entities` seed block), even + when it is buried several levels deep inside otherwise-non-GTS config. Each + element of such an array is registered as a real definition (keyed by its + `$id`), so its `$id` is treated as a **definition**, not as a dangling + reference. + +This YAML-only rule exists because runtime config files legitimately seed GTS +types inline. Without it, every inline `$id` was harvested by the generic id +walker and reported as `GTS reference not found`. Non-GTS entries under an +`entities:` array are ignored automatically (they fail the `isGtsEntity` gate). + +See `collectInlineEntityDefinitions` and `processFileContent` in +`src/registry.ts`. diff --git a/packages/shared/package.json b/packages/shared/package.json index e73bccb..7d57a7b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@gts/shared", - "version": "0.2.6", + "version": "0.2.7", "private": true, "type": "module", "main": "./dist/index.js", @@ -16,7 +16,7 @@ "dev": "tsc --watch" }, "dependencies": { - "@globaltypesystem/gts-ts": "^0.4.0", + "@globaltypesystem/gts-ts": "^0.8.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.0", diff --git a/packages/shared/src/entities.ts b/packages/shared/src/entities.ts index 6fac9f6..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 } @@ -371,6 +365,55 @@ export interface ValidationResult { errors: ValidationError[] } +/** + * Host-computed validation for a single entity, relayed to the VS Code webview. + * + * The webview runs under a strict Content-Security-Policy that forbids the + * code generation Ajv relies on (`new Function`), so it cannot run the + * JSON-Schema / gts-ts validation itself. The extension host computes it and + * ships these DTOs across the message channel; the webview merges the + * `validation` back onto its own registry entities (matched by `id`). + */ +export interface EntityValidationDto { + /** Entity id, used to match the webview's registry entity. */ + id: string + /** Absolute path of the file the entity was parsed from. */ + filePath?: string + /** The host-computed validation result to apply (absent if not validated). */ + validation?: ValidationResult +} + +/** {@link EntityValidationDto} plus the extra fields a JsonObj needs to re-key. */ +export interface ObjValidationDto extends EntityValidationDto { + /** Index of the object within a multi-document file (if applicable). */ + listSequence?: number + /** Resolved schema id for the object (if any). */ + schemaId?: string +} + +/** A file that failed to parse/index, with the errors that explain why. */ +export interface InvalidFileValidationDto { + /** Absolute path of the invalid file. */ + path: string + /** Base name of the invalid file. */ + name: string + /** The parse/index errors for the file (absent if none). */ + validation?: ValidationResult +} + +/** + * Full validation payload relayed from the extension host to the webview + * (the `detail` of a `gts-validation-result` message). + */ +export interface ValidationRelayPayload { + /** Per-instance validation. */ + objs: ObjValidationDto[] + /** Per-schema validation. */ + schemas: EntityValidationDto[] + /** Files that couldn't be parsed/indexed. */ + invalidFiles: InvalidFileValidationDto[] +} + export class JsonFile { path: string name: string diff --git a/packages/shared/src/gts-styling.ts b/packages/shared/src/gts-styling.ts index 3cf262b..f6d2a05 100644 --- a/packages/shared/src/gts-styling.ts +++ b/packages/shared/src/gts-styling.ts @@ -1,4 +1,4 @@ -import { isGtsId, normalizeGtsId } from './entities.js' +import { isGtsId, isGtsType, normalizeGtsId } from './entities.js' /** * Parse a GTS ID string and extract its parts @@ -14,25 +14,20 @@ export function parseGtsIdParts(gtsId: string): string[] { const normalizedId = normalizeGtsId(gtsId) const parts: string[] = [] - // Find the first tilde - const firstTildeIndex = normalizedId.indexOf('~') - if (firstTildeIndex === -1) { - // No tilde found, return the whole ID - return [normalizedId] - } + let segmentStart = 0 + let separatorIndex = normalizedId.indexOf('~') - // First part: from start to first tilde (inclusive) - const firstPart = normalizedId.substring(0, firstTildeIndex + 1) - parts.push(firstPart) + while (separatorIndex !== -1) { + parts.push(normalizedId.substring(segmentStart, separatorIndex + 1)) + segmentStart = separatorIndex + 1 + separatorIndex = normalizedId.indexOf('~', segmentStart) + } - // Check if there's a second part after the first tilde - const remainingPart = normalizedId.substring(firstTildeIndex + 1) - if (remainingPart.length > 0) { - // Second part exists - parts.push(remainingPart) + if (segmentStart < normalizedId.length) { + parts.push(normalizedId.substring(segmentStart)) } - return parts + return parts.length > 0 ? parts : [normalizedId] } /** @@ -64,10 +59,20 @@ export interface GtsStyleAnalysis { } /** - * Analyze a GTS ID and determine how each part should be styled + * Analyze a GTS ID and determine how each part should be styled. + * + * Schema-vs-instance classification is derived STRUCTURALLY from the GTS ID via + * gts-ts (`isGtsType`). Correctness (blue/green vs red) is derived from the + * authoritative gts-ts validation results surfaced through `entityLookup`: + * a segment whose cumulative entity failed gts-ts validation (`isValid: false`) + * is rendered as an error. GTS *rule* violations (abstract instantiation, + * derivation incompatibility, x-gts-ref, ...) are therefore not re-derived here; + * they are read back from `entityLookup`/the caller's validation errors, keeping + * gts-ts the single source of truth. * * @param gtsId - The GTS ID to analyze (may have gts:// prefix which is stripped) - * @param entityLookup - Function to look up whether an entity exists and its type + * @param entityLookup - Function to look up whether an entity exists, its kind, + * and whether gts-ts validation found it valid * @returns Analysis result with styled segments * * @example @@ -83,7 +88,7 @@ export interface GtsStyleAnalysis { */ export function analyzeGtsIdForStyling( gtsId: string, - entityLookup: (entityId: string) => { exists: boolean; isSchema?: boolean } + entityLookup: (entityId: string) => { exists: boolean; isSchema?: boolean; isValid?: boolean } ): GtsStyleAnalysis { // Normalize to strip gts:// prefix per GTS spec const normalizedId = normalizeGtsId(gtsId) @@ -106,25 +111,40 @@ export function analyzeGtsIdForStyling( const parts = parseGtsIdParts(normalizedId) let currentOffset = 0 - for (const part of parts) { - // Determine the full entity ID to look up - let entityIdToLookup: string - if (parts.length === 1) { - entityIdToLookup = part - } else if (part === parts[0]) { - entityIdToLookup = part - } else { - entityIdToLookup = parts[0] + part - } + let hasMissingAncestor = false + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const part = parts[partIndex] + const entityIdToLookup = parts.slice(0, partIndex + 1).join('') + + // A cumulative id ending in "~" names a TYPE (schema); otherwise it names + // an INSTANCE. This shape is derived structurally from the GTS ID itself. + const structuralIsType = isGtsType(entityIdToLookup) - // Look up the entity - const lookupResult = entityLookup(entityIdToLookup) + // Existence + validity lookup (skipped once an ancestor is already + // missing/invalid, so the error cascades to the rest of the chain). + const lookupResult = hasMissingAncestor ? { exists: false } : entityLookup(entityIdToLookup) let segmentType: 'schema' | 'instance' | 'error' - if (lookupResult.exists) { - segmentType = lookupResult.isSchema ? 'schema' : 'instance' - } else { + if (!lookupResult.exists || lookupResult.isValid === false) { + // Missing entity, or an entity gts-ts validation rejected → error. + segmentType = 'error' + hasMissingAncestor = true + } else if (structuralIsType) { + // A "~"-terminated (type) segment is valid only when a *schema* with that + // id actually exists. A type-shaped id backed only by an instance document + // (or nothing) is an error — e.g. an instance whose own id ends in "~" + // has no backing schema. + if (lookupResult.isSchema === true) { + segmentType = 'schema' + } else { + segmentType = 'error' + hasMissingAncestor = true + } + } else if (lookupResult.isSchema === true) { + // Instance-shaped id backed by a schema document → malformed. segmentType = 'error' + } else { + segmentType = 'instance' } segments.push({ @@ -236,7 +256,14 @@ export function levenshteinDistance(a: string, b: string): number { * ``` */ export function findSimilarEntityIds(targetId: string, allIds: string[], maxResults: number = 3): string[] { - const similarities = allIds.map(id => ({ + // Deduplicate the candidate list and never suggest the queried id itself. + // Callers typically build `allIds` by concatenating several registry maps + // (e.g. schemas + instances), so the same id can appear more than once; and a + // "Did you mean...?" that echoes the exact id the user hovered is noise that + // renders as a confusing self-reference / duplicated row. + const candidates = Array.from(new Set(allIds)).filter(id => id !== targetId) + + const similarities = candidates.map(id => ({ id, distance: levenshteinDistance(targetId, id) })) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 742b2c4..ddc458e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,9 +1,11 @@ export * from './types.js' export * from './entities.js' export { JsonRegistry } from './registry.js' +export type { RevalidationResult } from './registry.js' export * from './jsonc.js' export * from './yaml.js' export * from './parse.js' export { isGtsCandidateFileName } from './registry.js' export { GTS_COLORS } from './entities.js' export * from './gts-styling.js' +export { findSchemaPropertyPath } from './schemaParser.js' diff --git a/packages/shared/src/registry.ts b/packages/shared/src/registry.ts index d0f8d04..b6c0c91 100644 --- a/packages/shared/src/registry.ts +++ b/packages/shared/src/registry.ts @@ -1,13 +1,93 @@ import { JsonFile, JsonObj, JsonSchema, createEntity, getGtsConfig, decodeGtsId, createAbsentEntity, normalizeGtsId, findGtsPrefixViolations } from './entities.js' import type { GtsConfig, JsonEntity, ValidationResult, ValidationError } from './entities.js' +import { isYamlFileName } from './parse.js' +import { findSchemaPropertyPath, findXGtsRefPath, findTraitRequiredPath, findRegexPatternPath, 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' +import type { Format } from 'ajv' import * as path from 'path' +const JSON_SCHEMA_ANNOTATION_KEYWORDS = new Set([ + '$comment', + 'title', + 'description', + 'default', + 'deprecated', + 'readOnly', + 'writeOnly', + 'examples', + 'contentEncoding', + 'contentMediaType', + 'contentSchema' +]) + +/** + * Prepare a schema for Ajv instance validation by removing the `x-gts-ref` + * keyword, mirroring gts-ts's own `GtsStore.normalizeSchema` (the reference + * implementation strips `x-gts-ref` "so Ajv never sees the unknown keyword" + * and then prunes combinator branches that were `x-gts-ref`-only). + * + * This matters for combinators: a branch like `{ "x-gts-ref": "…" }` becomes an + * empty schema once the keyword is dropped, i.e. always-true. Left in place, an + * `oneOf` of two such branches matches *both* and Ajv spuriously reports + * "must match exactly one schema in oneOf" for a value that is perfectly valid. + * The actual `x-gts-ref` assertions — including correct oneOf/anyOf branch + * counting — are enforced separately by `XGtsRefValidator` (§9.6). + * + * Unlike gts-ts's full `normalizeSchema`, this intentionally does NOT rewrite + * `$id`/`$ref` (it leaves any `gts://` prefixes untouched) because the + * registry's Ajv instance resolves those via its own `gts://`-aware loader. + */ +function stripXGtsRefForAjv(schema: any): any { + if (schema === null || typeof schema !== 'object') return schema + if (Array.isArray(schema)) return schema.map(stripXGtsRefForAjv) + + const normalized: Record = {} + for (const [key, value] of Object.entries(schema)) { + if (key === 'x-gts-ref') continue + normalized[key] = value && typeof value === 'object' ? stripXGtsRefForAjv(value) : value + } + + // Drop combinator subschemas whose only assertion was `x-gts-ref`, so Ajv + // doesn't treat their remaining annotations as always-true branches. + for (const combinator of ['oneOf', 'anyOf', 'allOf'] as const) { + if (!Array.isArray(normalized[combinator])) continue + normalized[combinator] = normalized[combinator].filter((_sub: any, idx: number) => { + const original = (schema as any)[combinator]?.[idx] + const hasOnlyXGtsRefAssertion = + original && + typeof original === 'object' && + !Array.isArray(original) && + original['x-gts-ref'] !== undefined && + Object.keys(original).every(key => key === 'x-gts-ref' || JSON_SCHEMA_ANNOTATION_KEYWORDS.has(key)) + return !hasOnlyXGtsRefAssertion + }) + if (normalized[combinator].length === 0) delete normalized[combinator] + } + + return normalized +} + +/** + * Evaluate one of `ajv-formats`' standard `Format` definitions against a string. + * A `Format` may be a `RegExp`, a validator function, or a + * `{ validate }` object whose `validate` is again a regex or a function. + */ +function matchesFormat(format: Format, value: string): boolean { + if (format instanceof RegExp) return format.test(value) + // The `Format` union also covers async/number variants; the temporal string + // formats we compose here are synchronous, so narrow the callable/regex forms. + const def = format as { validate?: RegExp | ((v: string) => boolean) } | ((v: string) => boolean) + const validate = typeof def === 'function' ? def : def?.validate + if (validate instanceof RegExp) return validate.test(value) + if (typeof validate === 'function') return Boolean(validate(value)) + return false +} + /** * Convert an XGtsRefValidator field path (dot/bracket notation, e.g. * `value.tags[0]`) into a JSON-Pointer-style instancePath (`/value/tags/0`) @@ -25,6 +105,54 @@ 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. + * + * `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 +180,29 @@ 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 + + // 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< + 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. + private instanceValidators: { generation: number; byContent: Map> } = + { generation: -1, byContent: new Map() } + constructor() { this.jsonObjs = new Map() this.jsonSchemas = new Map() @@ -74,6 +225,8 @@ export class JsonRegistry { this.jsonFileSchemas.clear() 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. */ @@ -83,11 +236,14 @@ export class JsonRegistry { } /** - * Build (once, then cache) a gts-ts GtsStore mirroring every schema currently - * in the registry, so ancestor-chain-dependent checks (derivation, traits, - * final/abstract guards) can be delegated to the reference implementation. - * Modifier-declaration errors that gts-ts throws at registration time are - * captured per schema id rather than aborting the whole build. + * Build (once, then cache) a gts-ts GtsStore mirroring every schema *and + * instance* currently in the registry, so both ancestor-chain-dependent + * schema checks (derivation, traits, final/abstract guards) and instance + * checks (`validateInstance`) can be delegated to the reference + * implementation instead of being re-derived here. Modifier-declaration + * errors that gts-ts throws when registering a schema are captured per id + * rather than aborting the whole build; instances that fail to register are + * skipped (they surface through the normal instance-validation path). */ private getGtsStore(): GtsStore { if (this.gtsStore) return this.gtsStore @@ -100,6 +256,14 @@ export class JsonRegistry { this.gtsStoreDeclErrors.set(schema.id, err instanceof Error ? err.message : String(err)) } } + for (const obj of this.jsonObjs.values()) { + try { + store.register(createJsonEntity(obj.content)) + } catch { + // Instance couldn't be registered (e.g. malformed/UUID-less id); it is + // reported through validateEntity's normal path, not via the store. + } + } this.gtsStore = store return store } @@ -108,8 +272,11 @@ 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 + this.contentGeneration++ if (this.jsonFiles.has(path)) { this.jsonFiles.delete(path) } @@ -132,6 +299,260 @@ export class JsonRegistry { } } + /** + * Cumulative `~`-terminated prefixes of a GTS id — its ancestor *type* chain. + * + * GTS encodes derivation directly in the id: a type id and every derived type + * appended after it are separated by `~`. So for + * `gts.a.b.c.d.v1~k.l.m.n.v1~` the ancestor type ids are + * `gts.a.b.c.d.v1~` (the base) and `gts.a.b.c.d.v1~k.l.m.n.v1~` (the full id). + * This is how single-level derivation, multi-level derivation and + * instantiation are all reduced to one relation: "does this id's type chain + * contain the changed type id?". + */ + private static ancestorTypeIds(id: string): string[] { + const out: string[] = [] + if (!id) return out + let idx = id.indexOf('~') + while (idx !== -1) { + out.push(id.slice(0, idx + 1)) + idx = id.indexOf('~', idx + 1) + } + return out + } + + /** All entity ids currently defined in the given file (schemas + instances). */ + getEntityIdsForFile(path: string): string[] { + const ids: string[] = [] + for (const s of this.jsonFileSchemas.get(path) || []) ids.push(s.id) + for (const o of this.jsonFileObjs.get(path) || []) ids.push(o.id) + return ids + } + + /** + * Build (once, then cache) the reverse-dependency graph. See DependencyGraph + * for the two edge kinds and how each propagates. Edges recorded per entity: + * + * structural (transitive — Type #1 schema dependency): + * - derivation / multi-level derivation: a schema whose own id has the + * target in its ancestor type chain (id prefix at `~` boundaries) + * - instantiation: an instance whose `schemaId` chain contains the target + * (its direct type and every base of that type) + * - `$ref` / `allOf` / ...: a schema whose JSON-Schema refs point at target + * (JsonSchema.schemaRefs) + * + * references (depth-1 — Type #2 id reference): + * - any GTS id used anywhere in the entity's content (JsonEntity.gtsRefs), + * which already includes `x-gts-ref` targets (their concrete values are + * valid GTS ids). Wildcard `x-gts-ref` *patterns* are authoring + * constraints; the concrete instance value that matches carries the real + * id edge via gtsRefs, so patterns need no separate reverse edge. + */ + private buildDependencyGraph(): DependencyGraph { + if (this.depGraph) return this.depGraph + + const structural = new Map>() + const references = new Map>() + const link = (map: Map>, target: string, dependent: string) => { + if (!target || !dependent || target === dependent) return + let set = map.get(target) + if (!set) { set = new Set(); map.set(target, set) } + set.add(dependent) + } + + const addEntity = (entity: JsonEntity, isSchema: boolean) => { + const eid = entity.id + if (!eid) return + + // Structural: derivation & instantiation via the type chain. Schemas + // derive from their proper ancestors (exclude their own full id); an + // instance depends on every type in its schemaId chain (incl. direct type). + const chainSource = isSchema ? eid : (entity.schemaId || '') + for (const ancestor of JsonRegistry.ancestorTypeIds(chainSource)) { + if (isSchema && ancestor === eid) continue + link(structural, ancestor, eid) + } + // Structural: JSON-Schema $ref / allOf composition (schemas only). + if (isSchema) { + const schemaRefs = (entity as JsonSchema).schemaRefs + if (schemaRefs) for (const ref of schemaRefs) link(structural, ref.id, eid) + } + + // References (depth-1): every GTS id the entity points at. + if (entity.gtsRefs) { + for (const ref of entity.gtsRefs) link(references, ref.id, eid) + } + } + + for (const schema of this.jsonSchemas.values()) addEntity(schema, true) + for (const obj of this.jsonObjs.values()) addEntity(obj, false) + + this.depGraph = { structural, references } + return this.depGraph + } + + /** + * Return the set of file paths (excluding `changedPath`) whose entities must be + * revalidated when `changedPath` changes. + * + * Two relations are combined (see DependencyGraph): + * 1. structural dependents are followed **transitively** (a derived type's + * own dependents are affected too); + * 2. plain id-reference dependents of the changed (seed) ids are added + * **depth-1**. A structural descendant's *shape* may change, but a plain + * id reference only checks its target's existence/id — which is unchanged + * — so references are not propagated through the structural closure. + * + * The structural walk is breadth-first guarded by a `visited` set, so + * derivation can never cycle and reference cycles (schema A `$ref`s B and B + * `$ref`s A) terminate. + * + * `extraSeedIds` lets callers add ids that existed *before* an edit (captured + * prior to reindexing) so that renaming/removing an id still revalidates the + * files that referenced its old id. + */ + getDependentFilePaths(changedPath: string, extraSeedIds?: Iterable): Set { + const paths = new Set() + + const seedIds = new Set(this.getEntityIdsForFile(changedPath)) + if (extraSeedIds) for (const id of extraSeedIds) if (id) seedIds.add(id) + if (seedIds.size === 0) return paths + + const { structural, references } = this.buildDependencyGraph() + + const addFile = (entityId: string) => { + const filePath = this.jsonSchemas.get(entityId)?.file?.path + || this.jsonObjs.get(entityId)?.file?.path + if (filePath && filePath !== changedPath) paths.add(filePath) + } + + // 1. Transitive structural closure over the changed ids. `visited` guards + // the BFS against cycles (reference-induced or otherwise). + const visited = new Set(seedIds) + const queue = [...seedIds] + while (queue.length > 0) { + const current = queue.shift()! + const dependents = structural.get(current) + if (!dependents) continue + for (const dependent of dependents) { + if (visited.has(dependent)) continue + visited.add(dependent) + queue.push(dependent) + addFile(dependent) + } + } + + // 2. Depth-1 id-reference dependents of the changed (seed) ids only. + for (const id of seedIds) { + const referrers = references.get(id) + if (!referrers) continue + for (const referrer of referrers) addFile(referrer) + } + + return paths + } + + /** + * Validate every entity currently indexed for `path` (schemas first, then + * instances) against the current registry context. Files that failed to parse + * already carry their error on the JsonFile in `invalidFiles`, so they are + * skipped here. + */ + async validateFile(path: string): Promise { + if (this.invalidFiles.has(path)) return + for (const schema of this.jsonFileSchemas.get(path) || []) { + await this.validateEntity(schema) + } + for (const obj of this.jsonFileObjs.get(path) || []) { + await this.validateEntity(obj) + } + } + + /** + * Revalidate `changedPath` and every file that (transitively/­referentially) + * depends on it. Assumes the changed file's new content is *already indexed* + * (via `indexFile`/`invalidateFile`). `previousIds` should carry the ids the + * file defined before the edit so a rename/removal still revalidates the files + * that referenced the old id. + * + * Returns the affected file paths (the changed file, when it still holds + * entities, plus all dependents) so callers can refresh their UI/markers. + */ + async revalidateAfterChange( + changedPath: string, + previousIds?: Iterable + ): Promise { + const dependents = this.getDependentFilePaths(changedPath, previousIds) + + const revalidatedPaths: string[] = [] + const changedStillPresent = this.jsonFiles.has(changedPath) || this.invalidFiles.has(changedPath) + if (changedStillPresent) { + await this.validateFile(changedPath) + revalidatedPaths.push(changedPath) + } + for (const dependentPath of dependents) { + await this.validateFile(dependentPath) + revalidatedPaths.push(dependentPath) + } + + return { changedPath, dependentPaths: dependents, revalidatedPaths } + } + + /** + * End-to-end handler for a single file change, shared by every app (Web, + * Electron, VS Code) so revalidation behaves identically everywhere: + * 1. snapshot the file's previous entity ids (for rename/removal), + * 2. (re)index the new `content` — or drop the file when `content` is + * null/undefined (deletion), + * 3. revalidate the changed file and all of its dependents. + */ + async applyFileChange( + path: string, + name: string, + content: any, + cfg: GtsConfig = getGtsConfig(undefined) + ): Promise { + const previousIds = this.getEntityIdsForFile(path) + if (content === null || content === undefined) { + this.invalidateFile(path) + } else { + this.indexFile(path, name, content, cfg) + } + return this.revalidateAfterChange(path, previousIds) + } + + /** + * Recursively collect GTS entity definitions embedded inline under any nested + * `entities:` array within a parsed (YAML) document. This supports config + * files that seed GTS types/instances inline — e.g. a service's + * `types-registry.config.entities` block — where each array element is a full + * JSON Schema / instance keyed by its own `$id`. + * + * The returned contents are handed to the normal entity pipeline + * (`createEntity` + `isGtsEntity`), so non-GTS `entities` entries are filtered + * out naturally and only genuine definitions are registered. + */ + private static collectInlineEntityDefinitions(root: any): any[] { + const out: any[] = [] + const visit = (node: any): void => { + if (!node || typeof node !== 'object') return + if (Array.isArray(node)) { + node.forEach(visit) + return + } + for (const [key, value] of Object.entries(node)) { + if (key === 'entities' && Array.isArray(value)) { + for (const el of value) { + if (el && typeof el === 'object' && !Array.isArray(el)) out.push(el) + } + } + visit(value) + } + } + visit(root) + return out + } + /** * Process a file and store its entities if they are GTS entities. * This is a helper used by both scanFile and ingestFiles. @@ -157,18 +578,11 @@ export class JsonRegistry { // when a raw string was passed in. const parsedContent = jsonFile.content - // Normalize content to array and process each entity - const entities = normalizeToArray(parsedContent) - entities.forEach((entityContent: any, idx: number) => { - const seq = Array.isArray(parsedContent) ? idx : undefined - const entity = createEntity({ - file: jsonFile, - listSequence: seq, - content: entityContent, - cfg - }) - - if (entity && entity.isGtsEntity()) { + // Register one entity content as a schema/instance if it is a GTS entity. + const registerEntity = (entityContent: any, seq: number | undefined, requireSelectedId = false) => { + const entity = createEntity({ file: jsonFile, listSequence: seq, content: entityContent, cfg }) + const hasSelectedId = entity?.selectedEntityIdField !== undefined || entity?.selectedSchemaIdField !== undefined + if (entity && (!requireSelectedId || hasSelectedId) && entity.isGtsEntity()) { hasGtsEntities = true if (entity instanceof JsonSchema) { this.jsonSchemas.set(entity.id, entity) @@ -178,8 +592,27 @@ export class JsonRegistry { this.jsonFileObjs.set(path, [...this.jsonFileObjs.get(path) || [], entity as JsonObj]) } } + } + + // Top level: a single entity, or a top-level array of entities. This is the + // only shape recognized for JSON/JSONC/.gts files. + const entities = normalizeToArray(parsedContent) + entities.forEach((entityContent: any, idx: number) => { + registerEntity(entityContent, Array.isArray(parsedContent) ? idx : undefined, isYamlFileName(name)) }) + // YAML ONLY: config files may additionally *define* GTS types/instances + // inline under nested `entities:` arrays (e.g. a types-registry + // `config.entities` seed block), possibly buried several levels deep inside + // otherwise-non-GTS config. Register each such element as a real definition + // so its `$id` is treated as a definition instead of being harvested as a + // dangling reference. JSON files intentionally keep the strict shape above. + if (isYamlFileName(name)) { + for (const def of JsonRegistry.collectInlineEntityDefinitions(parsedContent)) { + registerEntity(def, undefined) + } + } + // Only store the JsonFile once if it contains GTS entities if (hasGtsEntities && !this.jsonFiles.has(path)) { this.jsonFiles.set(path, jsonFile) @@ -207,10 +640,88 @@ 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: [] } + 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) { + 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)) + } + + /** + * 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, + chain: ReadonlySet = new Set([entity]) + ): 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. @@ -218,7 +729,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', @@ -234,6 +745,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' @@ -245,7 +773,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)) @@ -254,7 +796,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: '', @@ -268,7 +810,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) { @@ -286,13 +828,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', @@ -313,7 +855,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', @@ -323,13 +865,52 @@ 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 + // 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 '([^']+)'/) + // 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:') + // 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) { + instancePath = findSchemaPropertyPath(entity.content, propPath) + params = { property: propPath } + } 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 } : {} + } else if (regexPath) { + instancePath = regexPath + } + validation.errors.push({ + instancePath: instancePath || '/$id', + schemaPath: '#', + keyword: 'x-gts-schema', + message: msg, + params + }) + } } } @@ -338,7 +919,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', @@ -346,11 +927,40 @@ export class JsonRegistry { params: { value: err.value, refPattern: err.refPattern } }) } + + 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) { + validation.errors.push({ + instancePath: '/$id', + schemaPath: '#', + keyword: 'x-gts-schema', + message: `Parent schema '${parentId}' has GTS validation errors`, + params: { schemaId: parentId } + }) + } + } + } } else if (entity instanceof JsonObj) { // Validate the object against its schema if (!entity.schemaId) { - // No schema to validate against - return + const store = this.getGtsStore() + const result = store.validateInstance(entity.id) + if (!result.ok) { + const idField = (entity as any).selectedSchemaIdField || (entity as any).selectedEntityIdField || 'id' + validation.errors.push({ + instancePath: '/' + String(idField), + schemaPath: '#', + keyword: 'schema', + message: result.error, + params: { gtsId: entity.id } + }) + } + return validation } const schema = this.resolveSchema(entity.schemaId) @@ -358,14 +968,33 @@ 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 + } + + // 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 @@ -374,7 +1003,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', @@ -384,29 +1013,34 @@ export class JsonRegistry { } try { - const ajv = this.createAjvInstance() - - // Compile the schema with async $ref resolution - const validate = await ajv.compileAsync(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 // 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 // (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). 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) { - entity.validation.errors.push({ + if (/^Referenced entity '.+' not found in registry$/.test(err.reason)) continue + validation.errors.push({ instancePath: fieldPathToInstancePath(err.fieldPath), schemaPath: '#', keyword: 'x-gts-ref', @@ -414,8 +1048,29 @@ 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) { - entity.validation.errors.push({ + validation.errors.push({ instancePath: '', schemaPath: '#', keyword: 'validation', @@ -424,6 +1079,7 @@ export class JsonRegistry { }) } } + return validation } /** @@ -534,9 +1190,35 @@ 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), + }) + } + + // 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', diff --git a/packages/shared/src/schemaParser.ts b/packages/shared/src/schemaParser.ts index 6eece15..884067e 100644 --- a/packages/shared/src/schemaParser.ts +++ b/packages/shared/src/schemaParser.ts @@ -352,3 +352,192 @@ 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, '') +} + +/** + * 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, '') +} + +/** + * 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 + * 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' +} + +/** + * 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 +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index d2e159e..34f6a63 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "lib": ["ES2020"], "moduleResolution": "bundler", + "types": ["node"], "declaration": true, "composite": true, "outDir": "./dist", diff --git a/tsconfig.json b/tsconfig.json index c4ae6a1..307f435 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,12 +5,11 @@ "strict": true, "skipLibCheck": true, "moduleResolution": "bundler", - "baseUrl": ".", "paths": { - "@gts-viewer/shared": ["packages/shared/dist"], - "@gts-viewer/shared/*": ["packages/shared/dist/*"], - "@gts-viewer/ui": ["packages/ui/dist"], - "@gts-viewer/ui/*": ["packages/ui/dist/*"] + "@gts-viewer/shared": ["./packages/shared/dist"], + "@gts-viewer/shared/*": ["./packages/shared/dist/*"], + "@gts-viewer/ui": ["./packages/ui/dist"], + "@gts-viewer/ui/*": ["./packages/ui/dist/*"] } } }