From da262fe94bf5de50b7f3c498fd405c44242519c7 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 3 Dec 2025 20:08:56 +0100 Subject: [PATCH 01/14] feat: enhance HTML validation with debounce and context handling --- acumate-plugin/src/extension.ts | 72 ++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/acumate-plugin/src/extension.ts b/acumate-plugin/src/extension.ts index dc2071a..024f097 100644 --- a/acumate-plugin/src/extension.ts +++ b/acumate-plugin/src/extension.ts @@ -20,6 +20,9 @@ import { registerGraphInfoValidation } from './validation/tsValidation/graph-inf import { registerSuppressionCodeActions } from './providers/suppression-code-actions'; import { registerTsHoverProvider } from './providers/ts-hover-provider'; +const HTML_VALIDATION_DEBOUNCE_MS = 250; +const pendingHtmlValidationTimers = new Map(); + export function activate(context: vscode.ExtensionContext) { init(context); @@ -28,7 +31,7 @@ export function activate(context: vscode.ExtensionContext) { createCommands(context); - createHtmlDiagnostics(); + createHtmlDiagnostics(context); // HTML providers share the same metadata to supply navigation + IntelliSense inside markup. registerHtmlDefinitionProvider(context); @@ -39,16 +42,73 @@ export function activate(context: vscode.ExtensionContext) { } -function createHtmlDiagnostics() { - vscode.workspace.onDidChangeTextDocument(event => { +function createHtmlDiagnostics(context: vscode.ExtensionContext) { + const scheduleHtmlValidation = (document: vscode.TextDocument, immediate = false) => { + if (document.isClosed) { + return; + } + const key = document.uri.toString(); + const existing = pendingHtmlValidationTimers.get(key); + if (existing) { + clearTimeout(existing); + pendingHtmlValidationTimers.delete(key); + } + + const runValidation = () => { + pendingHtmlValidationTimers.delete(key); + if (!document.isClosed) { + validateHtmlFile(document); + } + }; + + if (immediate) { + runValidation(); + return; + } + + const handle = setTimeout(runValidation, HTML_VALIDATION_DEBOUNCE_MS); + pendingHtmlValidationTimers.set(key, handle); + }; + + const changeDisposable = vscode.workspace.onDidChangeTextDocument(event => { if (event.document.languageId === 'html') { - validateHtmlFile(event.document); + scheduleHtmlValidation(event.document); + } + }); + context.subscriptions.push(changeDisposable); + + const openDisposable = vscode.workspace.onDidOpenTextDocument(doc => { + if (doc.languageId === 'html') { + scheduleHtmlValidation(doc, true); + } + }); + context.subscriptions.push(openDisposable); + + const activeEditorDisposable = vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor?.document.languageId === 'html') { + scheduleHtmlValidation(editor.document, true); + } + }); + context.subscriptions.push(activeEditorDisposable); + + const closeDisposable = vscode.workspace.onDidCloseTextDocument(doc => { + if (doc.languageId !== 'html') { + return; + } + + const key = doc.uri.toString(); + const pending = pendingHtmlValidationTimers.get(key); + if (pending) { + clearTimeout(pending); + pendingHtmlValidationTimers.delete(key); } + AcuMateContext.HtmlValidator.delete(doc.uri); }); + context.subscriptions.push(closeDisposable); - vscode.workspace.onDidOpenTextDocument(doc => { + vscode.workspace.textDocuments.forEach(doc => { if (doc.languageId === 'html') { - validateHtmlFile(doc); + scheduleHtmlValidation(doc, true); } }); } From ed263147a75b0eaebd927eea1df4624e58f72ed0 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 3 Dec 2025 20:26:02 +0100 Subject: [PATCH 02/14] fix: update snippet formatting for HTML completion provider --- acumate-plugin/src/providers/html-completion-provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts index 57e7731..0ad1aa8 100644 --- a/acumate-plugin/src/providers/html-completion-provider.ts +++ b/acumate-plugin/src/providers/html-completion-provider.ts @@ -216,7 +216,7 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { else { placeholder = '${1:null}'; } - return new vscode.SnippetString(`"${name}": ${placeholder}`); + return new vscode.SnippetString(`${name}: ${placeholder}`); } private tryProvideControlTypeCompletions( @@ -439,7 +439,7 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { private createIncludeParameterSnippet(parameter: IncludeMetadata['parameters'][number]): vscode.SnippetString { const placeholder = parameter.defaultValue ? `\${1:${parameter.defaultValue}}` : '${1}'; - return new vscode.SnippetString(`${parameter.name}="${placeholder}"`); + return new vscode.SnippetString(`${parameter.name}=${placeholder}`); } private getTagCompletionContext( From 4b964e285a3738ccd16d454023abf68178595b3d Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 3 Dec 2025 20:45:44 +0100 Subject: [PATCH 03/14] feat: add HTML screen validation command and related functionality --- acumate-plugin/package.json | 8 +- acumate-plugin/readme.md | 4 + acumate-plugin/scripts/validate-screens.js | 18 +++ acumate-plugin/src/extension.ts | 138 +++++++++++++++++- .../suite/projectScreenValidation.test.ts | 102 +++++++++++++ 5 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 acumate-plugin/scripts/validate-screens.js create mode 100644 acumate-plugin/src/test/suite/projectScreenValidation.test.ts diff --git a/acumate-plugin/package.json b/acumate-plugin/package.json index 3f80a89..85972f9 100644 --- a/acumate-plugin/package.json +++ b/acumate-plugin/package.json @@ -131,6 +131,11 @@ "command": "acumate.dropCache", "title": "Drop Local Cache", "category": "AcuMate" + }, + { + "command": "acumate.validateScreens", + "title": "Validate Screens (HTML)", + "category": "AcuMate" } ], "menus": { @@ -164,7 +169,8 @@ "watch": "tsc -watch -p ./", "pretest": "npm run compile && npm run lint", "lint": "eslint src", - "test": "vscode-test" + "test": "vscode-test", + "validate:screens": "node ./scripts/validate-screens.js" }, "devDependencies": { "@types/mocha": "^10.0.9", diff --git a/acumate-plugin/readme.md b/acumate-plugin/readme.md index e9fbd06..e7fe255 100644 --- a/acumate-plugin/readme.md +++ b/acumate-plugin/readme.md @@ -112,10 +112,14 @@ The **AcuMate** extension provides several commands to streamline development ta | `acumate.watchCurrentScreen` | **Watch Current Screen** | Watches the currently active screen for changes and rebuilds as needed. | | `acumate.repeatLastBuildCommand` | **Repeat Last Build Command** | Repeats the last executed build command, useful for quick iterations. | | `acumate.dropCache` | **Drop Local Cache** | Clears the local cache, ensuring that the next build retrieves fresh data from the backend. | +| `acumate.validateScreens` | **Validate Screens (HTML)** | Scans every `.html` under `src/screens` (or a folder you choose) and logs validator diagnostics to the **AcuMate Validation** output channel without failing on warnings. | ### Quality & CI 1. **Automated Tests** - Run `npm test` locally to compile, lint, and execute the VS Code integration suites (metadata, HTML providers, validator, scaffolding, build commands). - The GitHub Actions workflow in `.github/workflows/ci.yml` performs `npm ci` + `npm test` for every pull request (regardless of branch) and on pushes to `main`, using a Node 18.x / 20.x matrix to catch regressions before and after merges. +2. **Project Screen Validation** + - Inside VS Code, run **AcuMate: Validate Screens (HTML)** to queue the validator against all HTML files beneath `src/screens` (or any folder you input). Results are aggregated in the **AcuMate Validation** output channel so you can inspect warnings without breaking your workflow. + - From the CLI, run `npm run validate:screens` to execute the same scan headlessly via the VS Code test runner (`SCREEN_VALIDATION_ROOT` defaults to `src/screens` but can be overridden). The script never fails on warnings; it simply dumps a consolidated summary so you can check that the extension processes every file without crashing. diff --git a/acumate-plugin/scripts/validate-screens.js b/acumate-plugin/scripts/validate-screens.js new file mode 100644 index 0000000..79f7bf3 --- /dev/null +++ b/acumate-plugin/scripts/validate-screens.js @@ -0,0 +1,18 @@ +const { spawn } = require('child_process'); +const path = require('path'); + +const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const repoRoot = path.resolve(__dirname, '..'); + +const child = spawn(npmCmd, ['test'], { + cwd: repoRoot, + env: { + ...process.env, + SCREEN_VALIDATION_ROOT: 'src/screens' + }, + stdio: 'inherit' +}); + +child.on('exit', code => { + process.exit(code ?? 1); +}); diff --git a/acumate-plugin/src/extension.ts b/acumate-plugin/src/extension.ts index 024f097..8a44f0d 100644 --- a/acumate-plugin/src/extension.ts +++ b/acumate-plugin/src/extension.ts @@ -1,4 +1,6 @@ import vscode from 'vscode'; +import path from 'path'; +import * as fs from 'fs'; import { CachedDataService } from './api/cached-data-service'; import { AcuMateApiClient } from './api/api-service'; import { AcuMateContext } from './plugin-context'; @@ -11,7 +13,6 @@ import { buildScreens, CommandsCache, openBuildMenu } from './build-commands/bui import { createScreen } from './scaffolding/create-screen/create-screen'; import { createScreenExtension } from './scaffolding/create-screen-extension/create-screen-extension'; import { provideTSCompletionItems } from './completionItemProviders/ts-completion-provider'; -const fs = require(`fs`); import { validateHtmlFile } from './validation/htmlValidation/html-validation'; import { registerHtmlDefinitionProvider } from './providers/html-definition-provider'; import { registerHtmlCompletionProvider } from './providers/html-completion-provider'; @@ -22,6 +23,7 @@ import { registerTsHoverProvider } from './providers/ts-hover-provider'; const HTML_VALIDATION_DEBOUNCE_MS = 250; const pendingHtmlValidationTimers = new Map(); +const htmlValidationOutput = vscode.window.createOutputChannel('AcuMate Validation'); export function activate(context: vscode.ExtensionContext) { init(context); @@ -249,6 +251,140 @@ function createCommands(context: vscode.ExtensionContext) { context.globalState.keys().forEach(key => context.globalState.update(key, undefined)); }); context.subscriptions.push(disposable); + + disposable = vscode.commands.registerCommand('acumate.validateScreens', async () => { + await runWorkspaceScreenValidation(); + }); + context.subscriptions.push(disposable); +} + +async function runWorkspaceScreenValidation() { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showWarningMessage('Open a workspace folder before running AcuMate screen validation.'); + return; + } + + const defaultRoot = path.join(workspaceFolder.uri.fsPath, 'src', 'screens'); + const defaultExists = fsExists(defaultRoot); + const initialValue = defaultExists ? defaultRoot : workspaceFolder.uri.fsPath; + const targetInput = await vscode.window.showInputBox({ + title: 'Screen validation root', + prompt: 'Folder containing HTML screens (absolute path or relative to workspace).', + value: initialValue, + ignoreFocusOut: true + }); + if (!targetInput) { + return; + } + + const resolvedRoot = path.isAbsolute(targetInput) + ? path.normalize(targetInput) + : path.normalize(path.join(workspaceFolder.uri.fsPath, targetInput)); + const stats = safeStat(resolvedRoot); + if (!stats?.isDirectory()) { + vscode.window.showErrorMessage(`Folder does not exist: ${resolvedRoot}`); + return; + } + + const htmlFiles = collectHtmlFiles(resolvedRoot); + if (!htmlFiles.length) { + vscode.window.showInformationMessage(`No HTML files found under ${resolvedRoot}.`); + return; + } + + htmlValidationOutput.clear(); + htmlValidationOutput.appendLine(`[AcuMate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); + + const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; + await vscode.window.withProgress( + { + title: 'AcuMate HTML validation', + location: vscode.ProgressLocation.Notification, + cancellable: false + }, + async progress => { + for (const file of htmlFiles) { + const relative = path.relative(workspaceFolder.uri.fsPath, file); + progress.report({ message: relative, increment: (1 / htmlFiles.length) * 100 }); + const document = await vscode.workspace.openTextDocument(file); + await validateHtmlFile(document); + const diagnostics = [...(AcuMateContext.HtmlValidator?.get(document.uri) ?? [])]; + if (diagnostics.length) { + issues.push({ file, diagnostics }); + } + AcuMateContext.HtmlValidator?.delete(document.uri); + } + } + ); + + const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + if (!totalDiagnostics) { + htmlValidationOutput.appendLine('[AcuMate] No diagnostics reported.'); + vscode.window.showInformationMessage(`AcuMate validation complete: ${htmlFiles.length} files, no diagnostics.`); + return; + } + + for (const entry of issues) { + htmlValidationOutput.appendLine(path.relative(workspaceFolder.uri.fsPath, entry.file) || entry.file); + for (const diag of entry.diagnostics) { + const severity = diag.severity === vscode.DiagnosticSeverity.Error ? 'Error' : 'Warning'; + const line = (diag.range?.start?.line ?? 0) + 1; + const normalizedMessage = diag.message.replace(/\s+/g, ' ').trim(); + htmlValidationOutput.appendLine(` [${severity}] line ${line}: ${normalizedMessage}`); + } + htmlValidationOutput.appendLine(''); + } + + const summary = `AcuMate validation complete: ${totalDiagnostics} diagnostics across ${issues.length} file(s).`; + htmlValidationOutput.appendLine(`[AcuMate] ${summary}`); + const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); + if (choice === 'Open Output') { + htmlValidationOutput.show(true); + } +} + +function collectHtmlFiles(root: string): string[] { + const stack: string[] = [root]; + const files: string[] = []; + const excluded = new Set(['node_modules', '.git', '.vscode-test', 'out', 'dist', 'bin', 'obj']); + + while (stack.length) { + const current = stack.pop()!; + const stats = safeStat(current); + if (!stats) { + continue; + } + + if (stats.isDirectory()) { + for (const entry of fs.readdirSync(current)) { + if (excluded.has(entry)) { + continue; + } + stack.push(path.join(current, entry)); + } + continue; + } + + if (stats.isFile() && current.toLowerCase().endsWith('.html')) { + files.push(current); + } + } + + return files.sort((a, b) => a.localeCompare(b)); +} + +function safeStat(targetPath: string): fs.Stats | undefined { + try { + return fs.statSync(targetPath); + } + catch { + return undefined; + } +} + +function fsExists(targetPath: string): boolean { + return Boolean(safeStat(targetPath)); } function createIntelliSenseProviders(context: vscode.ExtensionContext) { diff --git a/acumate-plugin/src/test/suite/projectScreenValidation.test.ts b/acumate-plugin/src/test/suite/projectScreenValidation.test.ts new file mode 100644 index 0000000..ef00c07 --- /dev/null +++ b/acumate-plugin/src/test/suite/projectScreenValidation.test.ts @@ -0,0 +1,102 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import vscode from 'vscode'; +import { describe, it, before } from 'mocha'; +import { validateHtmlFile } from '../../validation/htmlValidation/html-validation'; +import { AcuMateContext } from '../../plugin-context'; + +const screenRootSetting = process.env.SCREEN_VALIDATION_ROOT; +const shouldSkip = !screenRootSetting; +const describeMaybe = shouldSkip ? describe.skip : describe; + +describeMaybe('Project screen validation', () => { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + + before(() => { + if (!AcuMateContext.HtmlValidator) { + AcuMateContext.HtmlValidator = vscode.languages.createDiagnosticCollection('htmlValidatorProject'); + } + }); + + it('reports no HTML diagnostics under configured screens root', async function () { + this.timeout(600000); + + const resolvedRoot = path.resolve(workspaceRoot, screenRootSetting!); + + if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { + throw new Error(`SCREEN_VALIDATION_ROOT path does not exist: ${resolvedRoot}`); + } + + const htmlFiles = collectHtmlFiles(resolvedRoot); + if (!htmlFiles.length) { + throw new Error(`No HTML files found under ${resolvedRoot}`); + } + + console.log(`[acumate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); + + const failures: { file: string; diagnostics: vscode.Diagnostic[] }[] = []; + for (const file of htmlFiles) { + const document = await vscode.workspace.openTextDocument(file); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + if (diagnostics.length) { + failures.push({ file, diagnostics: [...diagnostics] }); + } + AcuMateContext.HtmlValidator?.delete(document.uri); + } + + if (failures.length) { + const totalDiagnostics = failures.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + console.warn( + `[acumate] Validation complete with ${totalDiagnostics} diagnostics across ${failures.length} file(s).` + ); + for (const entry of failures) { + console.warn(formatDiagnosticSummary(entry.file, entry.diagnostics)); + } + } + else { + console.log('[acumate] Validation complete with no diagnostics.'); + } + }); +}); + +function collectHtmlFiles(root: string): string[] { + const files: string[] = []; + const stack: string[] = [root]; + const excluded = new Set(['node_modules', '.git', '.vscode-test', 'out', 'dist', 'bin', 'obj']); + + while (stack.length) { + const current = stack.pop()!; + if (!fs.existsSync(current)) { + continue; + } + + const stats = fs.statSync(current); + if (stats.isDirectory()) { + const entries = fs.readdirSync(current); + for (const entry of entries) { + if (excluded.has(entry)) { + continue; + } + stack.push(path.join(current, entry)); + } + continue; + } + + if (stats.isFile() && current.toLowerCase().endsWith('.html')) { + files.push(current); + } + } + + return files.sort(); +} + +function formatDiagnosticSummary(filePath: string, diagnostics: vscode.Diagnostic[]): string { + const relative = path.relative(process.cwd(), filePath) || filePath; + const lines = diagnostics.map(diag => { + const severity = diag.severity === vscode.DiagnosticSeverity.Error ? 'Error' : 'Warning'; + const line = diag.range?.start?.line ?? 0; + return ` [${severity}] line ${line + 1}: ${diag.message}`; + }); + return `${relative}\n${lines.join('\n')}`; +} From 48f638ddaac5af71d9d77ee60d26bd16c43041c7 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 09:00:16 +0100 Subject: [PATCH 04/14] feat: add TypeScript screen validation command and related functionality --- acumate-plugin/package.json | 8 +- acumate-plugin/readme.md | 4 +- acumate-plugin/scripts/validate-screens.js | 2 +- acumate-plugin/scripts/validate-ts-screens.js | 18 ++ acumate-plugin/src/extension.ts | 172 ++++++++++++++++-- .../test/suite/projectTsValidation.test.ts | 106 +++++++++++ 6 files changed, 290 insertions(+), 20 deletions(-) create mode 100644 acumate-plugin/scripts/validate-ts-screens.js create mode 100644 acumate-plugin/src/test/suite/projectTsValidation.test.ts diff --git a/acumate-plugin/package.json b/acumate-plugin/package.json index 85972f9..e5db79e 100644 --- a/acumate-plugin/package.json +++ b/acumate-plugin/package.json @@ -136,6 +136,11 @@ "command": "acumate.validateScreens", "title": "Validate Screens (HTML)", "category": "AcuMate" + }, + { + "command": "acumate.validateTypeScriptScreens", + "title": "Validate Screens (TypeScript)", + "category": "AcuMate" } ], "menus": { @@ -170,7 +175,8 @@ "pretest": "npm run compile && npm run lint", "lint": "eslint src", "test": "vscode-test", - "validate:screens": "node ./scripts/validate-screens.js" + "validate:screens": "node ./scripts/validate-screens.js", + "validate:screens:ts": "node ./scripts/validate-ts-screens.js" }, "devDependencies": { "@types/mocha": "^10.0.9", diff --git a/acumate-plugin/readme.md b/acumate-plugin/readme.md index e7fe255..1547032 100644 --- a/acumate-plugin/readme.md +++ b/acumate-plugin/readme.md @@ -113,6 +113,7 @@ The **AcuMate** extension provides several commands to streamline development ta | `acumate.repeatLastBuildCommand` | **Repeat Last Build Command** | Repeats the last executed build command, useful for quick iterations. | | `acumate.dropCache` | **Drop Local Cache** | Clears the local cache, ensuring that the next build retrieves fresh data from the backend. | | `acumate.validateScreens` | **Validate Screens (HTML)** | Scans every `.html` under `src/screens` (or a folder you choose) and logs validator diagnostics to the **AcuMate Validation** output channel without failing on warnings. | +| `acumate.validateTypeScriptScreens`| **Validate Screens (TypeScript)** | Iterates through screen `.ts` files, runs the backend-powered `graphInfo` validator, and streams any warnings/errors to the **AcuMate Validation** output channel so you can review them without interrupting development. | ### Quality & CI @@ -121,5 +122,6 @@ The **AcuMate** extension provides several commands to streamline development ta - The GitHub Actions workflow in `.github/workflows/ci.yml` performs `npm ci` + `npm test` for every pull request (regardless of branch) and on pushes to `main`, using a Node 18.x / 20.x matrix to catch regressions before and after merges. 2. **Project Screen Validation** - Inside VS Code, run **AcuMate: Validate Screens (HTML)** to queue the validator against all HTML files beneath `src/screens` (or any folder you input). Results are aggregated in the **AcuMate Validation** output channel so you can inspect warnings without breaking your workflow. - - From the CLI, run `npm run validate:screens` to execute the same scan headlessly via the VS Code test runner (`SCREEN_VALIDATION_ROOT` defaults to `src/screens` but can be overridden). The script never fails on warnings; it simply dumps a consolidated summary so you can check that the extension processes every file without crashing. + - For TypeScript coverage, run **AcuMate: Validate Screens (TypeScript)** to traverse the same folder structure, execute `collectGraphInfoDiagnostics` for each screen `.ts`, and summarize backend metadata mismatches in the output channel (requires `acuMate.useBackend = true`). + - From the CLI, run `npm run validate:screens` (HTML) or `npm run validate:screens:ts` (TypeScript) to execute the same scans headlessly via the VS Code test runner. Override the roots with `SCREEN_VALIDATION_ROOT` or `TS_SCREEN_VALIDATION_ROOT` environment variables when needed. Both scripts log summaries instead of failing on warnings, ensuring automated runs focus on catching crashes. diff --git a/acumate-plugin/scripts/validate-screens.js b/acumate-plugin/scripts/validate-screens.js index 79f7bf3..648ce3e 100644 --- a/acumate-plugin/scripts/validate-screens.js +++ b/acumate-plugin/scripts/validate-screens.js @@ -8,7 +8,7 @@ const child = spawn(npmCmd, ['test'], { cwd: repoRoot, env: { ...process.env, - SCREEN_VALIDATION_ROOT: 'src/screens' + SCREEN_VALIDATION_ROOT: process.env.SCREEN_VALIDATION_ROOT || 'src/screens' }, stdio: 'inherit' }); diff --git a/acumate-plugin/scripts/validate-ts-screens.js b/acumate-plugin/scripts/validate-ts-screens.js new file mode 100644 index 0000000..b70ca46 --- /dev/null +++ b/acumate-plugin/scripts/validate-ts-screens.js @@ -0,0 +1,18 @@ +const { spawn } = require('child_process'); +const path = require('path'); + +const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const repoRoot = path.resolve(__dirname, '..'); + +const child = spawn(npmCmd, ['test'], { + cwd: repoRoot, + env: { + ...process.env, + TS_SCREEN_VALIDATION_ROOT: process.env.TS_SCREEN_VALIDATION_ROOT || 'src/screens' + }, + stdio: 'inherit' +}); + +child.on('exit', code => { + process.exit(code ?? 1); +}); diff --git a/acumate-plugin/src/extension.ts b/acumate-plugin/src/extension.ts index 8a44f0d..2b48214 100644 --- a/acumate-plugin/src/extension.ts +++ b/acumate-plugin/src/extension.ts @@ -17,13 +17,13 @@ import { validateHtmlFile } from './validation/htmlValidation/html-validation'; import { registerHtmlDefinitionProvider } from './providers/html-definition-provider'; import { registerHtmlCompletionProvider } from './providers/html-completion-provider'; import { registerHtmlHoverProvider } from './providers/html-hover-provider'; -import { registerGraphInfoValidation } from './validation/tsValidation/graph-info-validation'; +import { collectGraphInfoDiagnostics, registerGraphInfoValidation } from './validation/tsValidation/graph-info-validation'; import { registerSuppressionCodeActions } from './providers/suppression-code-actions'; import { registerTsHoverProvider } from './providers/ts-hover-provider'; const HTML_VALIDATION_DEBOUNCE_MS = 250; const pendingHtmlValidationTimers = new Map(); -const htmlValidationOutput = vscode.window.createOutputChannel('AcuMate Validation'); +const validationOutput = vscode.window.createOutputChannel('AcuMate Validation'); export function activate(context: vscode.ExtensionContext) { init(context); @@ -256,6 +256,11 @@ function createCommands(context: vscode.ExtensionContext) { await runWorkspaceScreenValidation(); }); context.subscriptions.push(disposable); + + disposable = vscode.commands.registerCommand('acumate.validateTypeScriptScreens', async () => { + await runWorkspaceTypeScriptValidation(); + }); + context.subscriptions.push(disposable); } async function runWorkspaceScreenValidation() { @@ -293,8 +298,8 @@ async function runWorkspaceScreenValidation() { return; } - htmlValidationOutput.clear(); - htmlValidationOutput.appendLine(`[AcuMate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); + validationOutput.clear(); + validationOutput.appendLine(`[AcuMate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; await vscode.window.withProgress( @@ -320,27 +325,18 @@ async function runWorkspaceScreenValidation() { const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); if (!totalDiagnostics) { - htmlValidationOutput.appendLine('[AcuMate] No diagnostics reported.'); + validationOutput.appendLine('[AcuMate] No diagnostics reported.'); vscode.window.showInformationMessage(`AcuMate validation complete: ${htmlFiles.length} files, no diagnostics.`); return; } - for (const entry of issues) { - htmlValidationOutput.appendLine(path.relative(workspaceFolder.uri.fsPath, entry.file) || entry.file); - for (const diag of entry.diagnostics) { - const severity = diag.severity === vscode.DiagnosticSeverity.Error ? 'Error' : 'Warning'; - const line = (diag.range?.start?.line ?? 0) + 1; - const normalizedMessage = diag.message.replace(/\s+/g, ' ').trim(); - htmlValidationOutput.appendLine(` [${severity}] line ${line}: ${normalizedMessage}`); - } - htmlValidationOutput.appendLine(''); - } + appendDiagnosticsToOutput(workspaceFolder.uri.fsPath, issues); const summary = `AcuMate validation complete: ${totalDiagnostics} diagnostics across ${issues.length} file(s).`; - htmlValidationOutput.appendLine(`[AcuMate] ${summary}`); + validationOutput.appendLine(`[AcuMate] ${summary}`); const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); if (choice === 'Open Output') { - htmlValidationOutput.show(true); + validationOutput.show(true); } } @@ -374,6 +370,148 @@ function collectHtmlFiles(root: string): string[] { return files.sort((a, b) => a.localeCompare(b)); } +async function runWorkspaceTypeScriptValidation() { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showWarningMessage('Open a workspace folder before running AcuMate TypeScript validation.'); + return; + } + + if (!AcuMateContext.ConfigurationService?.useBackend) { + vscode.window.showWarningMessage('TypeScript validation requires backend metadata. Enable acuMate.useBackend to continue.'); + return; + } + + if (!AcuMateContext.ApiService) { + vscode.window.showErrorMessage('AcuMate backend client is not initialized yet. Try again once initialization completes.'); + return; + } + + const defaultRoot = path.join(workspaceFolder.uri.fsPath, 'src', 'screens'); + const defaultExists = fsExists(defaultRoot); + const initialValue = defaultExists ? defaultRoot : workspaceFolder.uri.fsPath; + const targetInput = await vscode.window.showInputBox({ + title: 'TypeScript validation root', + prompt: 'Folder containing screen TypeScript files (absolute path or relative to workspace).', + value: initialValue, + ignoreFocusOut: true + }); + if (!targetInput) { + return; + } + + const resolvedRoot = path.isAbsolute(targetInput) + ? path.normalize(targetInput) + : path.normalize(path.join(workspaceFolder.uri.fsPath, targetInput)); + const stats = safeStat(resolvedRoot); + if (!stats?.isDirectory()) { + vscode.window.showErrorMessage(`Folder does not exist: ${resolvedRoot}`); + return; + } + + const tsFiles = collectTypeScriptFiles(resolvedRoot); + if (!tsFiles.length) { + vscode.window.showInformationMessage(`No TypeScript files found under ${resolvedRoot}.`); + return; + } + + validationOutput.clear(); + validationOutput.appendLine(`[AcuMate] Validating ${tsFiles.length} TypeScript files under ${resolvedRoot}`); + + const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; + await vscode.window.withProgress( + { + title: 'AcuMate TypeScript validation', + location: vscode.ProgressLocation.Notification, + cancellable: false + }, + async progress => { + for (const file of tsFiles) { + const relative = path.relative(workspaceFolder.uri.fsPath, file); + progress.report({ message: relative, increment: (1 / tsFiles.length) * 100 }); + try { + const document = await vscode.workspace.openTextDocument(file); + const diagnostics = await collectGraphInfoDiagnostics(document); + if (diagnostics.length) { + issues.push({ file, diagnostics }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + validationOutput.appendLine(`[AcuMate] Failed to validate ${relative || file}: ${message}`); + } + } + } + ); + + const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + if (!totalDiagnostics) { + validationOutput.appendLine('[AcuMate] No diagnostics reported.'); + vscode.window.showInformationMessage(`AcuMate TypeScript validation complete: ${tsFiles.length} files, no diagnostics.`); + return; + } + + appendDiagnosticsToOutput(workspaceFolder.uri.fsPath, issues); + + const summary = `AcuMate TypeScript validation complete: ${totalDiagnostics} diagnostics across ${issues.length} file(s).`; + validationOutput.appendLine(`[AcuMate] ${summary}`); + const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); + if (choice === 'Open Output') { + validationOutput.show(true); + } +} + +function collectTypeScriptFiles(root: string): string[] { + const stack: string[] = [root]; + const files: string[] = []; + const excluded = new Set(['node_modules', '.git', '.vscode-test', 'out', 'dist', 'bin', 'obj']); + + while (stack.length) { + const current = stack.pop()!; + const stats = safeStat(current); + if (!stats) { + continue; + } + + if (stats.isDirectory()) { + for (const entry of fs.readdirSync(current)) { + if (excluded.has(entry)) { + continue; + } + stack.push(path.join(current, entry)); + } + continue; + } + + if (!stats.isFile()) { + continue; + } + + const normalized = current.toLowerCase(); + if (normalized.endsWith('.ts') && !normalized.endsWith('.d.ts')) { + files.push(current); + } + } + + return files.sort((a, b) => a.localeCompare(b)); +} + +function appendDiagnosticsToOutput( + workspaceRoot: string, + entries: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> +) { + for (const entry of entries) { + validationOutput.appendLine(path.relative(workspaceRoot, entry.file) || entry.file); + for (const diag of entry.diagnostics) { + const severity = diag.severity === vscode.DiagnosticSeverity.Error ? 'Error' : 'Warning'; + const line = (diag.range?.start?.line ?? 0) + 1; + const normalizedMessage = diag.message.replace(/\s+/g, ' ').trim(); + validationOutput.appendLine(` [${severity}] line ${line}: ${normalizedMessage}`); + } + validationOutput.appendLine(''); + } +} + function safeStat(targetPath: string): fs.Stats | undefined { try { return fs.statSync(targetPath); diff --git a/acumate-plugin/src/test/suite/projectTsValidation.test.ts b/acumate-plugin/src/test/suite/projectTsValidation.test.ts new file mode 100644 index 0000000..9303a7c --- /dev/null +++ b/acumate-plugin/src/test/suite/projectTsValidation.test.ts @@ -0,0 +1,106 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import vscode from 'vscode'; +import { describe, it } from 'mocha'; +import { collectGraphInfoDiagnostics } from '../../validation/tsValidation/graph-info-validation'; +import { AcuMateContext } from '../../plugin-context'; + +const tsRootSetting = process.env.TS_SCREEN_VALIDATION_ROOT; +const describeMaybe = tsRootSetting ? describe : describe.skip; + +describeMaybe('Project TypeScript validation', () => { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + + it('reports graphInfo diagnostics under configured TypeScript root', async function () { + this.timeout(600000); + + if (!AcuMateContext.ConfigurationService?.useBackend) { + this.skip(); + return; + } + + const resolvedRoot = path.resolve(workspaceRoot, tsRootSetting!); + if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { + throw new Error(`TS_SCREEN_VALIDATION_ROOT path does not exist: ${resolvedRoot}`); + } + + const tsFiles = collectTypeScriptFiles(resolvedRoot); + if (!tsFiles.length) { + throw new Error(`No TypeScript files found under ${resolvedRoot}`); + } + + console.log(`[acumate] Validating ${tsFiles.length} TypeScript files under ${resolvedRoot}`); + + const failures: { file: string; diagnostics: vscode.Diagnostic[] }[] = []; + for (const file of tsFiles) { + try { + const document = await vscode.workspace.openTextDocument(file); + const diagnostics = await collectGraphInfoDiagnostics(document); + if (diagnostics.length) { + failures.push({ file, diagnostics: [...diagnostics] }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[acumate] Failed to validate ${file}: ${message}`); + } + } + + if (failures.length) { + const totalDiagnostics = failures.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + console.warn( + `[acumate] Validation complete with ${totalDiagnostics} diagnostics across ${failures.length} file(s).` + ); + for (const entry of failures) { + console.warn(formatDiagnosticSummary(entry.file, entry.diagnostics)); + } + } + else { + console.log('[acumate] Validation complete with no diagnostics.'); + } + }); +}); + +function collectTypeScriptFiles(root: string): string[] { + const files: string[] = []; + const stack: string[] = [root]; + const excluded = new Set(['node_modules', '.git', '.vscode-test', 'out', 'dist', 'bin', 'obj']); + + while (stack.length) { + const current = stack.pop()!; + if (!fs.existsSync(current)) { + continue; + } + + const stats = fs.statSync(current); + if (stats.isDirectory()) { + const entries = fs.readdirSync(current); + for (const entry of entries) { + if (excluded.has(entry)) { + continue; + } + stack.push(path.join(current, entry)); + } + continue; + } + + if (stats.isFile()) { + const normalized = current.toLowerCase(); + if (normalized.endsWith('.ts') && !normalized.endsWith('.d.ts')) { + files.push(current); + } + } + } + + return files.sort(); +} + +function formatDiagnosticSummary(filePath: string, diagnostics: vscode.Diagnostic[]): string { + const relative = path.relative(process.cwd(), filePath) || filePath; + const lines = diagnostics.map(diag => { + const severity = diag.severity === vscode.DiagnosticSeverity.Error ? 'Error' : 'Warning'; + const line = diag.range?.start?.line ?? 0; + return ` [${severity}] line ${line + 1}: ${diag.message}`; + }); + return `${relative}\n${lines.join('\n')}`; +} From f7ab95d6e41ae8d768880cd681b03359786da12b Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 09:12:21 +0100 Subject: [PATCH 05/14] fix: reduce noise in HTML validation diagnostics by commenting out missing property checks --- acumate-plugin/src/test/suite/htmlValidation.test.ts | 4 ---- .../src/validation/htmlValidation/html-validation.ts | 5 +++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index c71a2a4..56f2681 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -281,10 +281,6 @@ describe('HTML validation diagnostics', () => { const document = await openFixtureDocument('TestConfigBindingInvalid.html'); await validateHtmlFile(document); const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; - assert.ok( - diagnostics.some(d => d.message.includes('missing required property "enabled"')), - 'Expected diagnostic for missing required config property' - ); assert.ok( diagnostics.some(d => d.message.includes('property "bogus"')), 'Expected diagnostic for unknown config property' diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 06c4cac..a253554 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -461,7 +461,8 @@ function validateDom( } const providedKeys = new Set(Object.keys(configObject)); - for (const property of definition.properties) { + // commented out to reduce noise in diagnostics + /*for (const property of definition.properties) { if (!property.optional && !providedKeys.has(property.name)) { pushHtmlDiagnostic( diagnostics, @@ -470,7 +471,7 @@ function validateDom( `The ${node.name} config.bind is missing required property "${property.name}".` ); } - } + }*/ for (const key of providedKeys) { if (!definition.properties.some((property) => property.name === key)) { From b674556c0d755c4e1291eb4ecd91c0a687bb9e7c Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 10:44:02 +0100 Subject: [PATCH 06/14] feat: implement caching for API calls in LayeredDataService to optimize performance --- .../src/api/layered-data-service.ts | 60 ++++++++++++++++--- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/acumate-plugin/src/api/layered-data-service.ts b/acumate-plugin/src/api/layered-data-service.ts index c0c3cc9..0a9f6af 100644 --- a/acumate-plugin/src/api/layered-data-service.ts +++ b/acumate-plugin/src/api/layered-data-service.ts @@ -8,6 +8,10 @@ import { FeatureModel } from "../model/FeatureModel"; export class LayeredDataService implements IAcuMateApiClient { + private inflightGraphs?: Promise; + private inflightFeatures?: Promise; + private readonly inflightStructures = new Map>(); + constructor(private cacheService: CachedDataService, private apiService: AcuMateApiClient) { } @@ -18,9 +22,21 @@ export class LayeredDataService implements IAcuMateApiClient { return cachedResult; } - const apiResult = await this.apiService.getGraphs(); - this.cacheService.store(GraphAPICache, apiResult); - return apiResult; + if (this.inflightGraphs) { + return this.inflightGraphs; + } + + this.inflightGraphs = this.apiService + .getGraphs() + .then(result => { + this.cacheService.store(GraphAPICache, result); + return result; + }) + .finally(() => { + this.inflightGraphs = undefined; + }); + + return this.inflightGraphs; } @@ -30,9 +46,23 @@ export class LayeredDataService implements IAcuMateApiClient { return cachedResult; } - const apiResult = await this.apiService.getGraphStructure(graphName); - this.cacheService.store(GraphAPIStructureCachePrefix + graphName, apiResult); - return apiResult; + const existing = this.inflightStructures.get(graphName); + if (existing) { + return existing; + } + + const pending = this.apiService + .getGraphStructure(graphName) + .then(result => { + this.cacheService.store(GraphAPIStructureCachePrefix + graphName, result); + return result; + }) + .finally(() => { + this.inflightStructures.delete(graphName); + }); + + this.inflightStructures.set(graphName, pending); + return pending; } async getFeatures(): Promise { @@ -41,8 +71,20 @@ export class LayeredDataService implements IAcuMateApiClient { return cachedResult; } - const apiResult = await this.apiService.getFeatures(); - this.cacheService.store(FeaturesCache, apiResult); - return apiResult; + if (this.inflightFeatures) { + return this.inflightFeatures; + } + + this.inflightFeatures = this.apiService + .getFeatures() + .then(result => { + this.cacheService.store(FeaturesCache, result); + return result; + }) + .finally(() => { + this.inflightFeatures = undefined; + }); + + return this.inflightFeatures; } } \ No newline at end of file From 3409c3868ddb3c6fa3c6694d2aebafa390527c23 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 11:15:11 +0100 Subject: [PATCH 07/14] feat: enhance backend metadata handling with new action and view support in hover provider --- acumate-plugin/src/backend-metadata-utils.ts | 35 +++- acumate-plugin/src/model/view.ts | 1 + .../src/providers/ts-hover-provider.ts | 182 +++++++++++++----- .../src/test/suite/tsGraphInfo.test.ts | 48 +++++ 4 files changed, 215 insertions(+), 51 deletions(-) diff --git a/acumate-plugin/src/backend-metadata-utils.ts b/acumate-plugin/src/backend-metadata-utils.ts index f111c5d..488bd7b 100644 --- a/acumate-plugin/src/backend-metadata-utils.ts +++ b/acumate-plugin/src/backend-metadata-utils.ts @@ -1,5 +1,5 @@ import { GraphStructure } from './model/graph-structure'; -import { Field, View } from './model/view'; +import { Action, Field, View } from './model/view'; export interface BackendFieldMetadata { fieldName: string; @@ -14,6 +14,12 @@ export interface BackendViewMetadata { fields: Map; } +export interface BackendActionMetadata { + actionName: string; + normalizedName: string; + action: Action; +} + export function normalizeMetaName(value: string | undefined): string | undefined { if (typeof value !== 'string') { return undefined; @@ -25,14 +31,35 @@ export function normalizeMetaName(value: string | undefined): string | undefined export function buildBackendActionSet(structure: GraphStructure | undefined): Set { const actions = new Set(); + const map = buildBackendActionMap(structure); + for (const key of map.keys()) { + actions.add(key); + } + return actions; +} + +export function buildBackendActionMap(structure: GraphStructure | undefined): Map { + const actions = new Map(); if (!structure?.actions) { return actions; } for (const action of structure.actions) { - const normalized = normalizeMetaName(action?.name); - if (normalized) { - actions.add(normalized); + if (!action) { + continue; + } + + const normalized = normalizeMetaName(action.name); + if (!normalized) { + continue; + } + + if (!actions.has(normalized)) { + actions.set(normalized, { + actionName: action.name ?? normalized, + normalizedName: normalized, + action + }); } } diff --git a/acumate-plugin/src/model/view.ts b/acumate-plugin/src/model/view.ts index bed07a0..d64fa35 100644 --- a/acumate-plugin/src/model/view.ts +++ b/acumate-plugin/src/model/view.ts @@ -3,6 +3,7 @@ import { BaseMetaItem } from "./base-meta-item"; export class View extends BaseMetaItem { public cacheType?: string; public cacheName?: string; + public displayName?: string; public extension?: string; public fields?: { [x: string] : Field }; diff --git a/acumate-plugin/src/providers/ts-hover-provider.ts b/acumate-plugin/src/providers/ts-hover-provider.ts index 05b15b9..d2d881a 100644 --- a/acumate-plugin/src/providers/ts-hover-provider.ts +++ b/acumate-plugin/src/providers/ts-hover-provider.ts @@ -8,7 +8,14 @@ import { tryGetGraphTypeFromExtension } from '../utils'; import { AcuMateContext } from '../plugin-context'; -import { buildBackendViewMap, BackendFieldMetadata, normalizeMetaName } from '../backend-metadata-utils'; +import { GraphStructure } from '../model/graph-structure'; +import { + buildBackendViewMap, + BackendFieldMetadata, + BackendViewMetadata, + buildBackendActionMap, + normalizeMetaName +} from '../backend-metadata-utils'; export function registerTsHoverProvider(context: vscode.ExtensionContext) { const selector: vscode.DocumentSelector = [{ language: 'typescript', scheme: 'file' }]; @@ -35,7 +42,7 @@ export async function provideTSFieldHover( } const offset = document.offsetAt(position); - const hoverTarget = findHoverTarget(classInfos, document.fileName, offset); + const hoverTarget = findHoverPropertyTarget(classInfos, document.fileName, offset); if (!hoverTarget) { return undefined; } @@ -51,68 +58,44 @@ export async function provideTSFieldHover( } const backendViews = buildBackendViewMap(graphStructure); - if (!backendViews.size) { - return undefined; - } - - const referencingViews = collectReferencingViews(classInfos, hoverTarget.classInfo.className); - if (!referencingViews.size) { - return undefined; - } - const normalizedFieldName = normalizeMetaName(hoverTarget.property.name); - if (!normalizedFieldName) { - return undefined; - } - - let matchedField: BackendFieldMetadata | undefined; - let matchedViewName: string | undefined; - for (const viewKey of referencingViews) { - const backendView = backendViews.get(viewKey); - if (!backendView) { - continue; - } + const range = new vscode.Range( + document.positionAt(hoverTarget.property.node.name.getStart()), + document.positionAt(hoverTarget.property.node.name.getEnd()) + ); - const candidate = backendView.fields.get(normalizedFieldName); - if (candidate) { - matchedField = candidate; - matchedViewName = backendView.viewName; - break; + if (hoverTarget.property.kind === 'field' && hoverTarget.classInfo.type === 'PXView') { + const markdown = buildFieldHoverMarkdown(classInfos, hoverTarget, backendViews); + if (markdown) { + return new vscode.Hover(markdown, range); } } - if (!matchedField) { - return undefined; + if (hoverTarget.property.kind === 'view' || hoverTarget.property.kind === 'viewCollection') { + const markdown = buildViewHoverMarkdown(hoverTarget.property, backendViews); + if (markdown) { + return new vscode.Hover(markdown, range); + } } - const markdown = buildHoverMarkdown(matchedField, matchedViewName, hoverTarget.property); - if (!markdown) { - return undefined; + if (hoverTarget.property.kind === 'action') { + const markdown = buildActionHoverMarkdown(hoverTarget.property, graphStructure); + if (markdown) { + return new vscode.Hover(markdown, range); + } } - const range = new vscode.Range( - document.positionAt(hoverTarget.property.node.name.getStart()), - document.positionAt(hoverTarget.property.node.name.getEnd()) - ); - return new vscode.Hover(markdown, range); + return undefined; } -function findHoverTarget( +function findHoverPropertyTarget( classInfos: CollectedClassInfo[], documentPath: string, offset: number ): { classInfo: CollectedClassInfo; property: ClassPropertyInfo } | undefined { const normalizedDocumentPath = path.normalize(documentPath).toLowerCase(); for (const classInfo of classInfos) { - if (classInfo.type !== 'PXView') { - continue; - } - for (const property of classInfo.properties.values()) { - if (property.kind !== 'field') { - continue; - } - const propertyPath = path.normalize(property.sourceFile.fileName).toLowerCase(); if (propertyPath !== normalizedDocumentPath) { continue; @@ -148,7 +131,112 @@ function collectReferencingViews(classInfos: CollectedClassInfo[], targetClassNa return referencing; } -function buildHoverMarkdown( + +function buildFieldHoverMarkdown( + classInfos: CollectedClassInfo[], + hoverTarget: { classInfo: CollectedClassInfo; property: ClassPropertyInfo }, + backendViews: Map +): vscode.MarkdownString | undefined { + const referencingViews = collectReferencingViews(classInfos, hoverTarget.classInfo.className); + if (!referencingViews.size) { + return undefined; + } + + const normalizedFieldName = normalizeMetaName(hoverTarget.property.name); + if (!normalizedFieldName) { + return undefined; + } + + let matchedField: BackendFieldMetadata | undefined; + let matchedViewName: string | undefined; + for (const viewKey of referencingViews) { + const backendView = backendViews.get(viewKey); + if (!backendView) { + continue; + } + + const candidate = backendView.fields.get(normalizedFieldName); + if (candidate) { + matchedField = candidate; + matchedViewName = backendView.viewName; + break; + } + } + + if (!matchedField) { + return undefined; + } + + return createFieldMarkdown(matchedField, matchedViewName, hoverTarget.property); +} + +function buildViewHoverMarkdown( + property: ClassPropertyInfo, + backendViews: Map +): vscode.MarkdownString | undefined { + const normalizedViewName = normalizeMetaName(property.name); + if (!normalizedViewName) { + return undefined; + } + + const backendView = backendViews.get(normalizedViewName); + if (!backendView) { + return undefined; + } + + const title = backendView.view.displayName ?? backendView.viewName ?? property.name; + if (!title) { + return undefined; + } + + const details: string[] = []; + if (backendView.view.cacheType) { + details.push(`- Cache type: \`${backendView.view.cacheType}\``); + } + if (backendView.view.cacheName) { + details.push(`- Cache name: \`${backendView.view.cacheName}\``); + } + if (!details.length) { + details.push(`- View: \`${backendView.viewName}\``); + } + + const markdown = new vscode.MarkdownString([`**${title}**`, ...details].join('\n')); + markdown.isTrusted = false; + return markdown; +} + +function buildActionHoverMarkdown( + property: ClassPropertyInfo, + structure: GraphStructure +): vscode.MarkdownString | undefined { + const backendActions = buildBackendActionMap(structure); + if (!backendActions.size) { + return undefined; + } + + const normalizedActionName = normalizeMetaName(property.name); + if (!normalizedActionName) { + return undefined; + } + + const backendAction = backendActions.get(normalizedActionName); + if (!backendAction) { + return undefined; + } + + const title = backendAction.action.displayName ?? backendAction.actionName ?? property.name; + if (!title) { + return undefined; + } + + const actionName = backendAction.action.name ?? property.name; + const details = [`- Action: \`${actionName}\``]; + const markdown = new vscode.MarkdownString([`**${title}**`, ...details].join('\n')); + markdown.isTrusted = false; + return markdown; +} + +function createFieldMarkdown( fieldMetadata: BackendFieldMetadata, viewName: string | undefined, property: ClassPropertyInfo diff --git a/acumate-plugin/src/test/suite/tsGraphInfo.test.ts b/acumate-plugin/src/test/suite/tsGraphInfo.test.ts index 2d439d3..c20984f 100644 --- a/acumate-plugin/src/test/suite/tsGraphInfo.test.ts +++ b/acumate-plugin/src/test/suite/tsGraphInfo.test.ts @@ -285,6 +285,54 @@ describe('graphInfo decorator assistance', () => { assert.ok(/qp-text-box/.test(value), 'hover should show default control type'); }); + it('shows backend metadata when hovering PXView properties', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + Document: { + name: 'Document', + displayName: 'Bill Of Materials', + cacheType: 'AMBomMatl', + cacheName: 'BOM Material' + } + } + }; + AcuMateContext.ApiService = new MockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(viewFieldMatchFixture); + const marker = 'Document = createSingle'; + const markerIndex = document.getText().indexOf(marker); + assert.ok(markerIndex >= 0, 'view property marker not found'); + const position = document.positionAt(markerIndex + 1); + const hover = await provideTSFieldHover(document, position); + assert.ok(hover, 'expected hover result for PXView property'); + const contents = Array.isArray(hover!.contents) ? hover!.contents : [hover!.contents]; + const first = contents[0]; + const value = first instanceof vscode.MarkdownString ? first.value : `${first}`; + assert.ok(/AMBomMatl/.test(value), 'hover should show cache type'); + assert.ok(/BOM Material/.test(value), 'hover should show cache name'); + }); + + it('shows PXAction display names when hovering PXActionState properties', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + actions: [{ name: 'SaveAction', displayName: 'Save Current Document' }] + }; + AcuMateContext.ApiService = new MockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(matchFixture); + const marker = 'SaveAction!: PXActionState'; + const markerIndex = document.getText().indexOf(marker); + assert.ok(markerIndex >= 0, 'PXAction property marker not found'); + const position = document.positionAt(markerIndex + 1); + const hover = await provideTSFieldHover(document, position); + assert.ok(hover, 'expected hover result for PXActionState property'); + const contents = Array.isArray(hover!.contents) ? hover!.contents : [hover!.contents]; + const first = contents[0]; + const value = first instanceof vscode.MarkdownString ? first.value : `${first}`; + assert.ok(/Save Current Document/.test(value), 'hover should surface PXAction display name'); + }); + it('respects acumate-disable-next-line directives for graphInfo diagnostics', async () => { const graphStructure: GraphStructure = { name: backendGraphName, From d9e29f94c46d1388d4b6bc2b349ebe9bfff32a80 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 11:25:03 +0100 Subject: [PATCH 08/14] feat: add link command completion support and related utility functions --- .../ts-completion-provider.ts | 51 +++++++++++++++ .../src/test/suite/tsGraphInfo.test.ts | 23 +++++++ .../src/typescript/link-command-utils.ts | 64 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 acumate-plugin/src/typescript/link-command-utils.ts diff --git a/acumate-plugin/src/completionItemProviders/ts-completion-provider.ts b/acumate-plugin/src/completionItemProviders/ts-completion-provider.ts index b7a84e5..25df8b7 100644 --- a/acumate-plugin/src/completionItemProviders/ts-completion-provider.ts +++ b/acumate-plugin/src/completionItemProviders/ts-completion-provider.ts @@ -13,6 +13,7 @@ import { getAvailableGraphs } from '../services/graph-metadata-service'; import { getAvailableFeatures } from '../services/feature-metadata-service'; import { getGraphTypeLiteralAtPosition } from '../typescript/graph-info-utils'; import { getFeatureInstalledLiteralAtPosition } from '../typescript/feature-installed-utils'; +import { getLinkCommandLiteralAtPosition } from '../typescript/link-command-utils'; import { buildBackendViewMap, normalizeMetaName } from '../backend-metadata-utils'; export async function provideTSCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, context: vscode.CompletionContext): Promise { @@ -32,6 +33,11 @@ export async function provideTSCompletionItems(document: vscode.TextDocument, po return featureInstalledCompletions; } + const linkCommandCompletions = await provideLinkCommandCompletions(document, position, sourceFile, documentText); + if (linkCommandCompletions?.length) { + return linkCommandCompletions; + } + let activeClassName: string | undefined; let activeClassKind: 'PXScreen' | 'PXView' | undefined; @@ -279,5 +285,50 @@ async function provideFeatureInstalledCompletions( items.push(item); } + return items.length ? items : undefined; +} + +async function provideLinkCommandCompletions( + document: vscode.TextDocument, + position: vscode.Position, + sourceFile: ts.SourceFile, + documentText: string +): Promise { + const offset = document.offsetAt(position); + const literalInfo = getLinkCommandLiteralAtPosition(sourceFile, offset); + if (!literalInfo) { + return undefined; + } + + const graphName = tryGetGraphType(documentText) ?? tryGetGraphTypeFromExtension(document.fileName); + if (!graphName || !AcuMateContext.ApiService) { + return undefined; + } + + const graphStructure = await AcuMateContext.ApiService.getGraphStructure(graphName); + if (!graphStructure?.actions?.length) { + return undefined; + } + + const range = getStringContentRange(document, literalInfo.literal); + const items: vscode.CompletionItem[] = []; + for (const action of graphStructure.actions) { + if (!action?.name) { + continue; + } + + const item = new vscode.CompletionItem(action.name, vscode.CompletionItemKind.EnumMember); + item.insertText = action.name; + item.range = range; + item.sortText = action.name.toLowerCase(); + item.detail = action.displayName ? `${action.name} (${action.displayName})` : action.name; + const docLines = [`PXAction from graph ${graphName}.`]; + if (action.displayName) { + docLines.push(`Display name: ${action.displayName}`); + } + item.documentation = new vscode.MarkdownString(docLines.join('\n\n')); + items.push(item); + } + return items.length ? items : undefined; } \ No newline at end of file diff --git a/acumate-plugin/src/test/suite/tsGraphInfo.test.ts b/acumate-plugin/src/test/suite/tsGraphInfo.test.ts index c20984f..db20b46 100644 --- a/acumate-plugin/src/test/suite/tsGraphInfo.test.ts +++ b/acumate-plugin/src/test/suite/tsGraphInfo.test.ts @@ -352,6 +352,29 @@ describe('graphInfo decorator assistance', () => { ); }); + it('suggests backend actions for linkCommand decorators', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + Document: { name: 'Document' } + }, + actions: [{ name: 'ExistingBackendAction', displayName: 'Existing Action' }] + }; + AcuMateContext.ApiService = new MockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(linkCommandValidFixture); + const marker = '@linkCommand("'; + const caret = document.positionAt(document.getText().indexOf(marker) + marker.length); + const completions = await provideTSCompletionItems( + document, + caret, + new vscode.CancellationTokenSource().token, + { triggerKind: vscode.CompletionTriggerKind.Invoke } as vscode.CompletionContext + ); + const labels = (completions ?? []).map(item => item.label); + assert.ok(labels.includes('ExistingBackendAction'), 'linkCommand completion should include backend actions'); + }); + it('validates linkCommand decorators against backend actions', async () => { const graphStructure: GraphStructure = { name: backendGraphName, diff --git a/acumate-plugin/src/typescript/link-command-utils.ts b/acumate-plugin/src/typescript/link-command-utils.ts new file mode 100644 index 0000000..0dd91eb --- /dev/null +++ b/acumate-plugin/src/typescript/link-command-utils.ts @@ -0,0 +1,64 @@ +import ts from 'typescript'; +import { getDecoratorIdentifier } from './decorator-utils'; + +export interface LinkCommandLiteralInfo { + literal: ts.StringLiteralLike; + decorator: ts.Decorator; + property: ts.PropertyDeclaration; +} + +export function getLinkCommandLiteralAtPosition( + sourceFile: ts.SourceFile, + offset: number +): LinkCommandLiteralInfo | undefined { + let match: LinkCommandLiteralInfo | undefined; + + const visit = (node: ts.Node) => { + if (match) { + return; + } + + if (offset < node.getFullStart() || offset > node.getEnd()) { + return; + } + + if (ts.isStringLiteralLike(node)) { + const callExpression = node.parent; + if (!ts.isCallExpression(callExpression)) { + return; + } + + if (!callExpression.arguments.some(arg => arg === node)) { + return; + } + + const decorator = callExpression.parent; + if (!decorator || !ts.isDecorator(decorator)) { + return; + } + + const property = decorator.parent; + if (!property || !ts.isPropertyDeclaration(property)) { + return; + } + + const expression = callExpression.expression; + if (!ts.isLeftHandSideExpression(expression)) { + return; + } + + const decoratorName = getDecoratorIdentifier(expression); + if (!decoratorName || decoratorName.toLowerCase() !== 'linkcommand') { + return; + } + + match = { literal: node, decorator, property }; + return; + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return match; +} From b6c0bf3ed0028ea771d8df7875f25f9a99ad9a59 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 12:29:28 +0100 Subject: [PATCH 09/14] feat: enable cancellation for HTML and TypeScript validation processes --- acumate-plugin/src/extension.ts | 51 +++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/acumate-plugin/src/extension.ts b/acumate-plugin/src/extension.ts index 2b48214..2beb370 100644 --- a/acumate-plugin/src/extension.ts +++ b/acumate-plugin/src/extension.ts @@ -302,14 +302,20 @@ async function runWorkspaceScreenValidation() { validationOutput.appendLine(`[AcuMate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; - await vscode.window.withProgress( + const cancelled = await vscode.window.withProgress( { title: 'AcuMate HTML validation', location: vscode.ProgressLocation.Notification, - cancellable: false + cancellable: true }, - async progress => { - for (const file of htmlFiles) { + async (progress, token) => { + for (let index = 0; index < htmlFiles.length; index++) { + if (token.isCancellationRequested) { + validationOutput.appendLine('[AcuMate] HTML validation cancelled by user.'); + return true; + } + + const file = htmlFiles[index]; const relative = path.relative(workspaceFolder.uri.fsPath, file); progress.report({ message: relative, increment: (1 / htmlFiles.length) * 100 }); const document = await vscode.workspace.openTextDocument(file); @@ -320,10 +326,22 @@ async function runWorkspaceScreenValidation() { } AcuMateContext.HtmlValidator?.delete(document.uri); } + + return false; } ); const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + if (cancelled) { + const summary = `AcuMate HTML validation cancelled after processing ${issues.length} file(s).`; + validationOutput.appendLine(`[AcuMate] ${summary}`); + vscode.window.showInformationMessage(summary, 'Open Output').then(choice => { + if (choice === 'Open Output') { + validationOutput.show(true); + } + }); + return; + } if (!totalDiagnostics) { validationOutput.appendLine('[AcuMate] No diagnostics reported.'); vscode.window.showInformationMessage(`AcuMate validation complete: ${htmlFiles.length} files, no diagnostics.`); @@ -419,14 +437,20 @@ async function runWorkspaceTypeScriptValidation() { validationOutput.appendLine(`[AcuMate] Validating ${tsFiles.length} TypeScript files under ${resolvedRoot}`); const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; - await vscode.window.withProgress( + const cancelled = await vscode.window.withProgress( { title: 'AcuMate TypeScript validation', location: vscode.ProgressLocation.Notification, - cancellable: false + cancellable: true }, - async progress => { - for (const file of tsFiles) { + async (progress, token) => { + for (let index = 0; index < tsFiles.length; index++) { + if (token.isCancellationRequested) { + validationOutput.appendLine('[AcuMate] TypeScript validation cancelled by user.'); + return true; + } + + const file = tsFiles[index]; const relative = path.relative(workspaceFolder.uri.fsPath, file); progress.report({ message: relative, increment: (1 / tsFiles.length) * 100 }); try { @@ -441,10 +465,21 @@ async function runWorkspaceTypeScriptValidation() { validationOutput.appendLine(`[AcuMate] Failed to validate ${relative || file}: ${message}`); } } + + return false; } ); const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + if (cancelled) { + const summary = `AcuMate TypeScript validation cancelled after processing ${issues.length} file(s).`; + validationOutput.appendLine(`[AcuMate] ${summary}`); + const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); + if (choice === 'Open Output') { + validationOutput.show(true); + } + return; + } if (!totalDiagnostics) { validationOutput.appendLine('[AcuMate] No diagnostics reported.'); vscode.window.showInformationMessage(`AcuMate TypeScript validation complete: ${tsFiles.length} files, no diagnostics.`); From 0ea381041a18bc644c4836f8f1ba5c80a3d187d2 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 12:40:44 +0100 Subject: [PATCH 10/14] feat: add support for double underscore fields in PXView diagnostics --- .../GraphInfoViewFieldDoubleUnderscore.ts | 11 +++++++++ .../src/test/suite/tsGraphInfo.test.ts | 24 +++++++++++++++++++ .../tsValidation/graph-info-validation.ts | 8 +++++-- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/typescript/GraphInfoViewFieldDoubleUnderscore.ts diff --git a/acumate-plugin/src/test/fixtures/typescript/GraphInfoViewFieldDoubleUnderscore.ts b/acumate-plugin/src/test/fixtures/typescript/GraphInfoViewFieldDoubleUnderscore.ts new file mode 100644 index 0000000..8de809b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/typescript/GraphInfoViewFieldDoubleUnderscore.ts @@ -0,0 +1,11 @@ +@graphInfo({ + graphType: "PX.SM.ProjectNewUiFrontendFileMaintenance", + primaryView: "Document", +}) +export class GraphInfoViewFieldDoubleUnderscore extends PXScreen { + Document = createSingle(DoubleUnderscoreView); +} + +export class DoubleUnderscoreView extends PXView { + __CustomField!: PXFieldState; +} diff --git a/acumate-plugin/src/test/suite/tsGraphInfo.test.ts b/acumate-plugin/src/test/suite/tsGraphInfo.test.ts index db20b46..744d912 100644 --- a/acumate-plugin/src/test/suite/tsGraphInfo.test.ts +++ b/acumate-plugin/src/test/suite/tsGraphInfo.test.ts @@ -21,6 +21,7 @@ const matchFixture = path.join(fixturesRoot, 'GraphInfoScreenMatch.ts'); const viewFieldMismatchFixture = path.join(fixturesRoot, 'GraphInfoViewFieldMismatch.ts'); const viewFieldMatchFixture = path.join(fixturesRoot, 'GraphInfoViewFieldMatch.ts'); const viewFieldCompletionFixture = path.join(fixturesRoot, 'GraphInfoViewFieldCompletion.ts'); +const viewFieldDoubleUnderscoreFixture = path.join(fixturesRoot, 'GraphInfoViewFieldDoubleUnderscore.ts'); const caseInsensitiveFixture = path.join(fixturesRoot, 'GraphInfoScreenCaseInsensitive.ts'); const suppressedFixture = path.join(fixturesRoot, 'GraphInfoScreenSuppressed.ts'); const extensionFixture = path.resolve( @@ -220,6 +221,29 @@ describe('graphInfo decorator assistance', () => { assert.strictEqual(matchDiagnostics.length, 0, 'expected no diagnostics when PXView fields align with backend metadata'); }); + it('ignores PXView fields with double underscores when backend metadata is missing', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + Document: { + name: 'Document', + fields: { + ExistingField: { name: 'ExistingField' } + } + } + } + }; + AcuMateContext.ApiService = new MockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(viewFieldDoubleUnderscoreFixture); + const diagnostics = await collectGraphInfoDiagnostics(document, sampleGraphs); + assert.strictEqual( + diagnostics.length, + 0, + 'expected double-underscore fields to skip backend metadata diagnostics' + ); + }); + it('suggests PXFieldState declarations for PXView classes from backend metadata', async () => { const graphStructure: GraphStructure = { name: backendGraphName, diff --git a/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts b/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts index 27f0fcc..0f50d86 100644 --- a/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts +++ b/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts @@ -283,7 +283,7 @@ function compareViewClassesWithGraph( const backendFields = backendView?.fields; if (backendView && backendFields?.size) { for (const fieldProperty of viewClassInfo.properties.values()) { - if (fieldProperty.kind !== 'field') { + if (fieldProperty.kind !== 'field' || shouldIgnoreFieldDiagnostics(fieldProperty)) { continue; } @@ -307,7 +307,7 @@ function compareViewClassesWithGraph( } for (const fieldProperty of viewClassInfo.properties.values()) { - if (fieldProperty.kind !== 'field') { + if (fieldProperty.kind !== 'field' || shouldIgnoreFieldDiagnostics(fieldProperty)) { continue; } @@ -401,6 +401,10 @@ function createPropertyDiagnostic( return diagnostic; } +function shouldIgnoreFieldDiagnostics(property: ClassPropertyInfo): boolean { + return property.name?.includes('__') ?? false; +} + function buildDisabledFeatureSet(features: FeatureModel[] | undefined): Set { const disabled = new Set(); if (!features?.length) { From ad35064823bdbf506faa309505fd807f199b10dd Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 12:42:58 +0100 Subject: [PATCH 11/14] feat: enhance PXView field hover and validation features with backend-driven completions and cancellable progress notifications --- acumate-plugin/readme.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/acumate-plugin/readme.md b/acumate-plugin/readme.md index 1547032..60668bf 100644 --- a/acumate-plugin/readme.md +++ b/acumate-plugin/readme.md @@ -50,8 +50,10 @@ The **AcuMate** extension for Visual Studio Code offers a range of powerful feat - The same metadata powers TypeScript diagnostics, warning when a `graphType` string does not match any graph returned by the backend so you can catch typos before running the UI. - The `@featureInstalled("FeatureName")` decorator gains backend-driven IntelliSense as you type the feature name and raises diagnostics when a missing/disabled feature is referenced, helping ensure feature-gated screens follow the site configuration. - Validates `@linkCommand("ActionName")` decorators on PXFieldState members, ensuring the referenced PXAction exists on the backend graph (case-insensitive comparison). + - While editing a `@linkCommand("...")` decorator, AcuMate now surfaces completion items sourced from the backend action list so you can insert the exact action name without leaving the editor. - Screen extension TypeScript files (under `.../extensions/...`) automatically reuse the parent screen's `@graphInfo` metadata so backend validations, completions, and linkCommand checks keep working even when the extension file lacks its own decorator. - - Hovering PXView field declarations displays the backend field’s display name, raw name, type, and default control, mirroring the HTML hover experience described below. + - Hovering PXView field declarations displays the backend field’s display name, raw name, type, and default control. Hovering PXView properties shows cache type/name metadata, and hovering PXActionState members surfaces their backend display names so you can confirm bindings at a glance. + - Fields whose names contain a double underscore (e.g., `__CustomField`) are treated as intentionally custom and are skipped by `graphInfo` diagnostics to avoid noise while prototyping. ### HTML Features @@ -112,8 +114,8 @@ The **AcuMate** extension provides several commands to streamline development ta | `acumate.watchCurrentScreen` | **Watch Current Screen** | Watches the currently active screen for changes and rebuilds as needed. | | `acumate.repeatLastBuildCommand` | **Repeat Last Build Command** | Repeats the last executed build command, useful for quick iterations. | | `acumate.dropCache` | **Drop Local Cache** | Clears the local cache, ensuring that the next build retrieves fresh data from the backend. | -| `acumate.validateScreens` | **Validate Screens (HTML)** | Scans every `.html` under `src/screens` (or a folder you choose) and logs validator diagnostics to the **AcuMate Validation** output channel without failing on warnings. | -| `acumate.validateTypeScriptScreens`| **Validate Screens (TypeScript)** | Iterates through screen `.ts` files, runs the backend-powered `graphInfo` validator, and streams any warnings/errors to the **AcuMate Validation** output channel so you can review them without interrupting development. | +| `acumate.validateScreens` | **Validate Screens (HTML)** | Scans every `.html` under `src/screens` (or a folder you choose), reports progress with a cancellable notification, and logs validator diagnostics to the **AcuMate Validation** output channel without failing on warnings. | +| `acumate.validateTypeScriptScreens`| **Validate Screens (TypeScript)** | Iterates through screen `.ts` files, runs the backend-powered `graphInfo` validator with cancellable progress, and streams any warnings/errors to the **AcuMate Validation** output channel so you can review them without interrupting development. | ### Quality & CI @@ -121,7 +123,7 @@ The **AcuMate** extension provides several commands to streamline development ta - Run `npm test` locally to compile, lint, and execute the VS Code integration suites (metadata, HTML providers, validator, scaffolding, build commands). - The GitHub Actions workflow in `.github/workflows/ci.yml` performs `npm ci` + `npm test` for every pull request (regardless of branch) and on pushes to `main`, using a Node 18.x / 20.x matrix to catch regressions before and after merges. 2. **Project Screen Validation** - - Inside VS Code, run **AcuMate: Validate Screens (HTML)** to queue the validator against all HTML files beneath `src/screens` (or any folder you input). Results are aggregated in the **AcuMate Validation** output channel so you can inspect warnings without breaking your workflow. - - For TypeScript coverage, run **AcuMate: Validate Screens (TypeScript)** to traverse the same folder structure, execute `collectGraphInfoDiagnostics` for each screen `.ts`, and summarize backend metadata mismatches in the output channel (requires `acuMate.useBackend = true`). + - Inside VS Code, run **AcuMate: Validate Screens (HTML)** to queue the validator against all HTML files beneath `src/screens` (or any folder you input). A cancellable progress notification tracks the run, and results are aggregated in the **AcuMate Validation** output channel so you can inspect warnings without breaking your workflow. + - For TypeScript coverage, run **AcuMate: Validate Screens (TypeScript)** to traverse the same folder structure, execute `collectGraphInfoDiagnostics` for each screen `.ts`, and summarize backend metadata mismatches in the output channel (requires `acuMate.useBackend = true`). This command is also cancellable so you can stop long-running validations instantly. - From the CLI, run `npm run validate:screens` (HTML) or `npm run validate:screens:ts` (TypeScript) to execute the same scans headlessly via the VS Code test runner. Override the roots with `SCREEN_VALIDATION_ROOT` or `TS_SCREEN_VALIDATION_ROOT` environment variables when needed. Both scripts log summaries instead of failing on warnings, ensuring automated runs focus on catching crashes. From c8711a0c421cdeb8e10c2e53afdbfbedca342b83 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 12:50:25 +0100 Subject: [PATCH 12/14] feat: refactor project screen and TypeScript validation tests to improve conditional execution and error handling --- .../suite/projectScreenValidation.test.ts | 85 +++++++++--------- .../test/suite/projectTsValidation.test.ts | 88 ++++++++++--------- 2 files changed, 90 insertions(+), 83 deletions(-) diff --git a/acumate-plugin/src/test/suite/projectScreenValidation.test.ts b/acumate-plugin/src/test/suite/projectScreenValidation.test.ts index ef00c07..e84d3b4 100644 --- a/acumate-plugin/src/test/suite/projectScreenValidation.test.ts +++ b/acumate-plugin/src/test/suite/projectScreenValidation.test.ts @@ -6,59 +6,62 @@ import { validateHtmlFile } from '../../validation/htmlValidation/html-validatio import { AcuMateContext } from '../../plugin-context'; const screenRootSetting = process.env.SCREEN_VALIDATION_ROOT; -const shouldSkip = !screenRootSetting; -const describeMaybe = shouldSkip ? describe.skip : describe; -describeMaybe('Project screen validation', () => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); +if (!screenRootSetting) { + console.warn('[acumate] Skipping project screen validation test because SCREEN_VALIDATION_ROOT is not set.'); +} +else { + describe('Project screen validation', () => { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); - before(() => { - if (!AcuMateContext.HtmlValidator) { - AcuMateContext.HtmlValidator = vscode.languages.createDiagnosticCollection('htmlValidatorProject'); - } - }); + before(() => { + if (!AcuMateContext.HtmlValidator) { + AcuMateContext.HtmlValidator = vscode.languages.createDiagnosticCollection('htmlValidatorProject'); + } + }); - it('reports no HTML diagnostics under configured screens root', async function () { - this.timeout(600000); + it('reports no HTML diagnostics under configured screens root', async function () { + this.timeout(600000); - const resolvedRoot = path.resolve(workspaceRoot, screenRootSetting!); + const resolvedRoot = path.resolve(workspaceRoot, screenRootSetting!); - if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { - throw new Error(`SCREEN_VALIDATION_ROOT path does not exist: ${resolvedRoot}`); - } + if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { + throw new Error(`SCREEN_VALIDATION_ROOT path does not exist: ${resolvedRoot}`); + } - const htmlFiles = collectHtmlFiles(resolvedRoot); - if (!htmlFiles.length) { - throw new Error(`No HTML files found under ${resolvedRoot}`); - } + const htmlFiles = collectHtmlFiles(resolvedRoot); + if (!htmlFiles.length) { + throw new Error(`No HTML files found under ${resolvedRoot}`); + } - console.log(`[acumate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); + console.log(`[acumate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); - const failures: { file: string; diagnostics: vscode.Diagnostic[] }[] = []; - for (const file of htmlFiles) { - const document = await vscode.workspace.openTextDocument(file); - await validateHtmlFile(document); - const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; - if (diagnostics.length) { - failures.push({ file, diagnostics: [...diagnostics] }); + const failures: { file: string; diagnostics: vscode.Diagnostic[] }[] = []; + for (const file of htmlFiles) { + const document = await vscode.workspace.openTextDocument(file); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + if (diagnostics.length) { + failures.push({ file, diagnostics: [...diagnostics] }); + } + AcuMateContext.HtmlValidator?.delete(document.uri); } - AcuMateContext.HtmlValidator?.delete(document.uri); - } - if (failures.length) { - const totalDiagnostics = failures.reduce((sum, entry) => sum + entry.diagnostics.length, 0); - console.warn( - `[acumate] Validation complete with ${totalDiagnostics} diagnostics across ${failures.length} file(s).` - ); - for (const entry of failures) { - console.warn(formatDiagnosticSummary(entry.file, entry.diagnostics)); + if (failures.length) { + const totalDiagnostics = failures.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + console.warn( + `[acumate] Validation complete with ${totalDiagnostics} diagnostics across ${failures.length} file(s).` + ); + for (const entry of failures) { + console.warn(formatDiagnosticSummary(entry.file, entry.diagnostics)); + } } - } - else { - console.log('[acumate] Validation complete with no diagnostics.'); - } + else { + console.log('[acumate] Validation complete with no diagnostics.'); + } + }); }); -}); +} function collectHtmlFiles(root: string): string[] { const files: string[] = []; diff --git a/acumate-plugin/src/test/suite/projectTsValidation.test.ts b/acumate-plugin/src/test/suite/projectTsValidation.test.ts index 9303a7c..871d4a1 100644 --- a/acumate-plugin/src/test/suite/projectTsValidation.test.ts +++ b/acumate-plugin/src/test/suite/projectTsValidation.test.ts @@ -6,60 +6,64 @@ import { collectGraphInfoDiagnostics } from '../../validation/tsValidation/graph import { AcuMateContext } from '../../plugin-context'; const tsRootSetting = process.env.TS_SCREEN_VALIDATION_ROOT; -const describeMaybe = tsRootSetting ? describe : describe.skip; -describeMaybe('Project TypeScript validation', () => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); +if (!tsRootSetting) { + console.warn('[acumate] Skipping project TypeScript validation test because TS_SCREEN_VALIDATION_ROOT is not set.'); +} +else { + describe('Project TypeScript validation', () => { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); - it('reports graphInfo diagnostics under configured TypeScript root', async function () { - this.timeout(600000); + it('reports graphInfo diagnostics under configured TypeScript root', async function () { + this.timeout(600000); - if (!AcuMateContext.ConfigurationService?.useBackend) { - this.skip(); - return; - } + if (!AcuMateContext.ConfigurationService?.useBackend) { + this.skip(); + return; + } - const resolvedRoot = path.resolve(workspaceRoot, tsRootSetting!); - if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { - throw new Error(`TS_SCREEN_VALIDATION_ROOT path does not exist: ${resolvedRoot}`); - } + const resolvedRoot = path.resolve(workspaceRoot, tsRootSetting!); + if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) { + throw new Error(`TS_SCREEN_VALIDATION_ROOT path does not exist: ${resolvedRoot}`); + } - const tsFiles = collectTypeScriptFiles(resolvedRoot); - if (!tsFiles.length) { - throw new Error(`No TypeScript files found under ${resolvedRoot}`); - } + const tsFiles = collectTypeScriptFiles(resolvedRoot); + if (!tsFiles.length) { + throw new Error(`No TypeScript files found under ${resolvedRoot}`); + } - console.log(`[acumate] Validating ${tsFiles.length} TypeScript files under ${resolvedRoot}`); + console.log(`[acumate] Validating ${tsFiles.length} TypeScript files under ${resolvedRoot}`); - const failures: { file: string; diagnostics: vscode.Diagnostic[] }[] = []; - for (const file of tsFiles) { - try { - const document = await vscode.workspace.openTextDocument(file); - const diagnostics = await collectGraphInfoDiagnostics(document); - if (diagnostics.length) { - failures.push({ file, diagnostics: [...diagnostics] }); + const failures: { file: string; diagnostics: vscode.Diagnostic[] }[] = []; + for (const file of tsFiles) { + try { + const document = await vscode.workspace.openTextDocument(file); + const diagnostics = await collectGraphInfoDiagnostics(document); + if (diagnostics.length) { + failures.push({ file, diagnostics: [...diagnostics] }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[acumate] Failed to validate ${file}: ${message}`); } } - catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[acumate] Failed to validate ${file}: ${message}`); - } - } - if (failures.length) { - const totalDiagnostics = failures.reduce((sum, entry) => sum + entry.diagnostics.length, 0); - console.warn( - `[acumate] Validation complete with ${totalDiagnostics} diagnostics across ${failures.length} file(s).` - ); - for (const entry of failures) { - console.warn(formatDiagnosticSummary(entry.file, entry.diagnostics)); + if (failures.length) { + const totalDiagnostics = failures.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + console.warn( + `[acumate] Validation complete with ${totalDiagnostics} diagnostics across ${failures.length} file(s).` + ); + for (const entry of failures) { + console.warn(formatDiagnosticSummary(entry.file, entry.diagnostics)); + } } - } - else { - console.log('[acumate] Validation complete with no diagnostics.'); - } + else { + console.log('[acumate] Validation complete with no diagnostics.'); + } + }); }); -}); +} function collectTypeScriptFiles(root: string): string[] { const files: string[] = []; From fcf338fcb294212d6da4c8d6883d68fe06bd1807 Mon Sep 17 00:00:00 2001 From: Alexander <36587900+anesvijskij@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:28:52 +0100 Subject: [PATCH 13/14] Update acumate-plugin/src/test/suite/projectScreenValidation.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- acumate-plugin/src/test/suite/projectScreenValidation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acumate-plugin/src/test/suite/projectScreenValidation.test.ts b/acumate-plugin/src/test/suite/projectScreenValidation.test.ts index e84d3b4..1df27d7 100644 --- a/acumate-plugin/src/test/suite/projectScreenValidation.test.ts +++ b/acumate-plugin/src/test/suite/projectScreenValidation.test.ts @@ -20,7 +20,7 @@ else { } }); - it('reports no HTML diagnostics under configured screens root', async function () { + it('validates HTML screens under configured root', async function () { this.timeout(600000); const resolvedRoot = path.resolve(workspaceRoot, screenRootSetting!); From f3c879afae10881c196e6e0e479478b6b7c5e19d Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Thu, 4 Dec 2025 17:07:54 +0100 Subject: [PATCH 14/14] feat: add workspace validation functions for HTML and TypeScript files --- acumate-plugin/package-lock.json | 2 +- acumate-plugin/src/extension.ts | 302 +---------------- .../src/validation/workspace-validation.ts | 306 ++++++++++++++++++ 3 files changed, 309 insertions(+), 301 deletions(-) create mode 100644 acumate-plugin/src/validation/workspace-validation.ts diff --git a/acumate-plugin/package-lock.json b/acumate-plugin/package-lock.json index 89ed55a..c582d66 100644 --- a/acumate-plugin/package-lock.json +++ b/acumate-plugin/package-lock.json @@ -8,8 +8,8 @@ "name": "acumate", "version": "0.0.1", "dependencies": { - "find-config": "^1.0.0", "css-select": "^5.1.0", + "find-config": "^1.0.0", "handlebars": "^4.7.8", "htmlparser2": "^9.1.0", "jsonic": "^1.0.1", diff --git a/acumate-plugin/src/extension.ts b/acumate-plugin/src/extension.ts index 032814f..44ad8a6 100644 --- a/acumate-plugin/src/extension.ts +++ b/acumate-plugin/src/extension.ts @@ -1,6 +1,4 @@ import vscode from 'vscode'; -import path from 'path'; -import * as fs from 'fs'; import { CachedDataService } from './api/cached-data-service'; import { AcuMateApiClient } from './api/api-service'; import { AcuMateContext } from './plugin-context'; @@ -18,14 +16,14 @@ import { validateHtmlFile } from './validation/htmlValidation/html-validation'; import { registerHtmlDefinitionProvider } from './providers/html-definition-provider'; import { registerHtmlCompletionProvider } from './providers/html-completion-provider'; import { registerHtmlHoverProvider } from './providers/html-hover-provider'; -import { collectGraphInfoDiagnostics, registerGraphInfoValidation } from './validation/tsValidation/graph-info-validation'; +import { registerGraphInfoValidation } from './validation/tsValidation/graph-info-validation'; import { registerSuppressionCodeActions } from './providers/suppression-code-actions'; import { registerTsHoverProvider } from './providers/ts-hover-provider'; import { getFrontendSourcesPath } from './utils'; +import { runWorkspaceScreenValidation, runWorkspaceTypeScriptValidation } from './validation/workspace-validation'; const HTML_VALIDATION_DEBOUNCE_MS = 250; const pendingHtmlValidationTimers = new Map(); -const validationOutput = vscode.window.createOutputChannel('AcuMate Validation'); export function activate(context: vscode.ExtensionContext) { init(context); @@ -265,302 +263,6 @@ function createCommands(context: vscode.ExtensionContext) { context.subscriptions.push(disposable); } -async function runWorkspaceScreenValidation() { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showWarningMessage('Open a workspace folder before running AcuMate screen validation.'); - return; - } - - const defaultRoot = path.join(workspaceFolder.uri.fsPath, 'src', 'screens'); - const defaultExists = fsExists(defaultRoot); - const initialValue = defaultExists ? defaultRoot : workspaceFolder.uri.fsPath; - const targetInput = await vscode.window.showInputBox({ - title: 'Screen validation root', - prompt: 'Folder containing HTML screens (absolute path or relative to workspace).', - value: initialValue, - ignoreFocusOut: true - }); - if (!targetInput) { - return; - } - - const resolvedRoot = path.isAbsolute(targetInput) - ? path.normalize(targetInput) - : path.normalize(path.join(workspaceFolder.uri.fsPath, targetInput)); - const stats = safeStat(resolvedRoot); - if (!stats?.isDirectory()) { - vscode.window.showErrorMessage(`Folder does not exist: ${resolvedRoot}`); - return; - } - - const htmlFiles = collectHtmlFiles(resolvedRoot); - if (!htmlFiles.length) { - vscode.window.showInformationMessage(`No HTML files found under ${resolvedRoot}.`); - return; - } - - validationOutput.clear(); - validationOutput.appendLine(`[AcuMate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); - - const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; - const cancelled = await vscode.window.withProgress( - { - title: 'AcuMate HTML validation', - location: vscode.ProgressLocation.Notification, - cancellable: true - }, - async (progress, token) => { - for (let index = 0; index < htmlFiles.length; index++) { - if (token.isCancellationRequested) { - validationOutput.appendLine('[AcuMate] HTML validation cancelled by user.'); - return true; - } - - const file = htmlFiles[index]; - const relative = path.relative(workspaceFolder.uri.fsPath, file); - progress.report({ message: relative, increment: (1 / htmlFiles.length) * 100 }); - const document = await vscode.workspace.openTextDocument(file); - await validateHtmlFile(document); - const diagnostics = [...(AcuMateContext.HtmlValidator?.get(document.uri) ?? [])]; - if (diagnostics.length) { - issues.push({ file, diagnostics }); - } - AcuMateContext.HtmlValidator?.delete(document.uri); - } - - return false; - } - ); - - const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); - if (cancelled) { - const summary = `AcuMate HTML validation cancelled after processing ${issues.length} file(s).`; - validationOutput.appendLine(`[AcuMate] ${summary}`); - vscode.window.showInformationMessage(summary, 'Open Output').then(choice => { - if (choice === 'Open Output') { - validationOutput.show(true); - } - }); - return; - } - if (!totalDiagnostics) { - validationOutput.appendLine('[AcuMate] No diagnostics reported.'); - vscode.window.showInformationMessage(`AcuMate validation complete: ${htmlFiles.length} files, no diagnostics.`); - return; - } - - appendDiagnosticsToOutput(workspaceFolder.uri.fsPath, issues); - - const summary = `AcuMate validation complete: ${totalDiagnostics} diagnostics across ${issues.length} file(s).`; - validationOutput.appendLine(`[AcuMate] ${summary}`); - const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); - if (choice === 'Open Output') { - validationOutput.show(true); - } -} - -function collectHtmlFiles(root: string): string[] { - const stack: string[] = [root]; - const files: string[] = []; - const excluded = new Set(['node_modules', '.git', '.vscode-test', 'out', 'dist', 'bin', 'obj']); - - while (stack.length) { - const current = stack.pop()!; - const stats = safeStat(current); - if (!stats) { - continue; - } - - if (stats.isDirectory()) { - for (const entry of fs.readdirSync(current)) { - if (excluded.has(entry)) { - continue; - } - stack.push(path.join(current, entry)); - } - continue; - } - - if (stats.isFile() && current.toLowerCase().endsWith('.html')) { - files.push(current); - } - } - - return files.sort((a, b) => a.localeCompare(b)); -} - -async function runWorkspaceTypeScriptValidation() { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showWarningMessage('Open a workspace folder before running AcuMate TypeScript validation.'); - return; - } - - if (!AcuMateContext.ConfigurationService?.useBackend) { - vscode.window.showWarningMessage('TypeScript validation requires backend metadata. Enable acuMate.useBackend to continue.'); - return; - } - - if (!AcuMateContext.ApiService) { - vscode.window.showErrorMessage('AcuMate backend client is not initialized yet. Try again once initialization completes.'); - return; - } - - const defaultRoot = path.join(workspaceFolder.uri.fsPath, 'src', 'screens'); - const defaultExists = fsExists(defaultRoot); - const initialValue = defaultExists ? defaultRoot : workspaceFolder.uri.fsPath; - const targetInput = await vscode.window.showInputBox({ - title: 'TypeScript validation root', - prompt: 'Folder containing screen TypeScript files (absolute path or relative to workspace).', - value: initialValue, - ignoreFocusOut: true - }); - if (!targetInput) { - return; - } - - const resolvedRoot = path.isAbsolute(targetInput) - ? path.normalize(targetInput) - : path.normalize(path.join(workspaceFolder.uri.fsPath, targetInput)); - const stats = safeStat(resolvedRoot); - if (!stats?.isDirectory()) { - vscode.window.showErrorMessage(`Folder does not exist: ${resolvedRoot}`); - return; - } - - const tsFiles = collectTypeScriptFiles(resolvedRoot); - if (!tsFiles.length) { - vscode.window.showInformationMessage(`No TypeScript files found under ${resolvedRoot}.`); - return; - } - - validationOutput.clear(); - validationOutput.appendLine(`[AcuMate] Validating ${tsFiles.length} TypeScript files under ${resolvedRoot}`); - - const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; - const cancelled = await vscode.window.withProgress( - { - title: 'AcuMate TypeScript validation', - location: vscode.ProgressLocation.Notification, - cancellable: true - }, - async (progress, token) => { - for (let index = 0; index < tsFiles.length; index++) { - if (token.isCancellationRequested) { - validationOutput.appendLine('[AcuMate] TypeScript validation cancelled by user.'); - return true; - } - - const file = tsFiles[index]; - const relative = path.relative(workspaceFolder.uri.fsPath, file); - progress.report({ message: relative, increment: (1 / tsFiles.length) * 100 }); - try { - const document = await vscode.workspace.openTextDocument(file); - const diagnostics = await collectGraphInfoDiagnostics(document); - if (diagnostics.length) { - issues.push({ file, diagnostics }); - } - } - catch (error) { - const message = error instanceof Error ? error.message : String(error); - validationOutput.appendLine(`[AcuMate] Failed to validate ${relative || file}: ${message}`); - } - } - - return false; - } - ); - - const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); - if (cancelled) { - const summary = `AcuMate TypeScript validation cancelled after processing ${issues.length} file(s).`; - validationOutput.appendLine(`[AcuMate] ${summary}`); - const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); - if (choice === 'Open Output') { - validationOutput.show(true); - } - return; - } - if (!totalDiagnostics) { - validationOutput.appendLine('[AcuMate] No diagnostics reported.'); - vscode.window.showInformationMessage(`AcuMate TypeScript validation complete: ${tsFiles.length} files, no diagnostics.`); - return; - } - - appendDiagnosticsToOutput(workspaceFolder.uri.fsPath, issues); - - const summary = `AcuMate TypeScript validation complete: ${totalDiagnostics} diagnostics across ${issues.length} file(s).`; - validationOutput.appendLine(`[AcuMate] ${summary}`); - const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); - if (choice === 'Open Output') { - validationOutput.show(true); - } -} - -function collectTypeScriptFiles(root: string): string[] { - const stack: string[] = [root]; - const files: string[] = []; - const excluded = new Set(['node_modules', '.git', '.vscode-test', 'out', 'dist', 'bin', 'obj']); - - while (stack.length) { - const current = stack.pop()!; - const stats = safeStat(current); - if (!stats) { - continue; - } - - if (stats.isDirectory()) { - for (const entry of fs.readdirSync(current)) { - if (excluded.has(entry)) { - continue; - } - stack.push(path.join(current, entry)); - } - continue; - } - - if (!stats.isFile()) { - continue; - } - - const normalized = current.toLowerCase(); - if (normalized.endsWith('.ts') && !normalized.endsWith('.d.ts')) { - files.push(current); - } - } - - return files.sort((a, b) => a.localeCompare(b)); -} - -function appendDiagnosticsToOutput( - workspaceRoot: string, - entries: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> -) { - for (const entry of entries) { - validationOutput.appendLine(path.relative(workspaceRoot, entry.file) || entry.file); - for (const diag of entry.diagnostics) { - const severity = diag.severity === vscode.DiagnosticSeverity.Error ? 'Error' : 'Warning'; - const line = (diag.range?.start?.line ?? 0) + 1; - const normalizedMessage = diag.message.replace(/\s+/g, ' ').trim(); - validationOutput.appendLine(` [${severity}] line ${line}: ${normalizedMessage}`); - } - validationOutput.appendLine(''); - } -} - -function safeStat(targetPath: string): fs.Stats | undefined { - try { - return fs.statSync(targetPath); - } - catch { - return undefined; - } -} - -function fsExists(targetPath: string): boolean { - return Boolean(safeStat(targetPath)); -} function createIntelliSenseProviders(context: vscode.ExtensionContext) { if (!AcuMateContext.ConfigurationService.useBackend) { diff --git a/acumate-plugin/src/validation/workspace-validation.ts b/acumate-plugin/src/validation/workspace-validation.ts new file mode 100644 index 0000000..a129fdc --- /dev/null +++ b/acumate-plugin/src/validation/workspace-validation.ts @@ -0,0 +1,306 @@ +import path from 'path'; +import * as fs from 'fs'; +import vscode from 'vscode'; +import { AcuMateContext } from '../plugin-context'; +import { validateHtmlFile } from './htmlValidation/html-validation'; +import { collectGraphInfoDiagnostics } from './tsValidation/graph-info-validation'; + +const validationOutput = vscode.window.createOutputChannel('AcuMate Validation'); +export const workspacePath = 'WebSites\\Pure\\Site\\FrontendSources\\'; + +export async function runWorkspaceScreenValidation(): Promise { + const workspaceFolderUri = vscode.Uri.file(`${AcuMateContext.repositoryPath}${workspacePath}`); + if (!workspaceFolderUri) { + vscode.window.showWarningMessage('Open a workspace folder before running AcuMate screen validation.'); + return; + } + + const defaultRoot = path.join(workspaceFolderUri.fsPath, 'src', 'screens'); + const defaultExists = fsExists(defaultRoot); + const initialValue = defaultExists ? defaultRoot : workspaceFolderUri.fsPath; + const targetInput = await vscode.window.showInputBox({ + title: 'Screen validation root', + prompt: 'Folder containing HTML screens (absolute path or relative to workspace).', + value: initialValue, + ignoreFocusOut: true + }); + if (!targetInput) { + return; + } + + const resolvedRoot = path.isAbsolute(targetInput) + ? path.normalize(targetInput) + : path.normalize(path.join(workspaceFolderUri.fsPath, targetInput)); + const stats = safeStat(resolvedRoot); + if (!stats?.isDirectory()) { + vscode.window.showErrorMessage(`Folder does not exist: ${resolvedRoot}`); + return; + } + + const htmlFiles = collectHtmlFiles(resolvedRoot); + if (!htmlFiles.length) { + vscode.window.showInformationMessage(`No HTML files found under ${resolvedRoot}.`); + return; + } + + validationOutput.clear(); + validationOutput.appendLine(`[AcuMate] Validating ${htmlFiles.length} HTML files under ${resolvedRoot}`); + + const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; + const cancelled = await vscode.window.withProgress( + { + title: 'AcuMate HTML validation', + location: vscode.ProgressLocation.Notification, + cancellable: true + }, + async (progress, token) => { + for (let index = 0; index < htmlFiles.length; index++) { + if (token.isCancellationRequested) { + validationOutput.appendLine('[AcuMate] HTML validation cancelled by user.'); + return true; + } + + const file = htmlFiles[index]; + const relative = path.relative(workspaceFolderUri.fsPath, file); + progress.report({ message: relative, increment: (1 / htmlFiles.length) * 100 }); + const document = await vscode.workspace.openTextDocument(file); + await validateHtmlFile(document); + const diagnostics = [...(AcuMateContext.HtmlValidator?.get(document.uri) ?? [])]; + if (diagnostics.length) { + issues.push({ file, diagnostics }); + } + AcuMateContext.HtmlValidator?.delete(document.uri); + } + + return false; + } + ); + + const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + if (cancelled) { + const summary = `AcuMate HTML validation cancelled after processing ${issues.length} file(s).`; + validationOutput.appendLine(`[AcuMate] ${summary}`); + vscode.window.showInformationMessage(summary, 'Open Output').then(choice => { + if (choice === 'Open Output') { + validationOutput.show(true); + } + }); + return; + } + if (!totalDiagnostics) { + validationOutput.appendLine('[AcuMate] No diagnostics reported.'); + vscode.window.showInformationMessage(`AcuMate validation complete: ${htmlFiles.length} files, no diagnostics.`); + return; + } + + appendDiagnosticsToOutput(workspaceFolderUri.fsPath, issues); + + const summary = `AcuMate validation complete: ${totalDiagnostics} diagnostics across ${issues.length} file(s).`; + validationOutput.appendLine(`[AcuMate] ${summary}`); + const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); + if (choice === 'Open Output') { + validationOutput.show(true); + } +} + +export async function runWorkspaceTypeScriptValidation(): Promise { + const workspaceFolderUri = vscode.Uri.file(`${AcuMateContext.repositoryPath}${workspacePath}`); + if (!workspaceFolderUri) { + vscode.window.showWarningMessage('Open a workspace folder before running AcuMate TypeScript validation.'); + return; + } + + if (!AcuMateContext.ConfigurationService?.useBackend) { + vscode.window.showWarningMessage('TypeScript validation requires backend metadata. Enable acuMate.useBackend to continue.'); + return; + } + + if (!AcuMateContext.ApiService) { + vscode.window.showErrorMessage('AcuMate backend client is not initialized yet. Try again once initialization completes.'); + return; + } + + const defaultRoot = path.join(workspaceFolderUri.fsPath, 'src', 'screens'); + const defaultExists = fsExists(defaultRoot); + const initialValue = defaultExists ? defaultRoot : workspaceFolderUri.fsPath; + const targetInput = await vscode.window.showInputBox({ + title: 'TypeScript validation root', + prompt: 'Folder containing screen TypeScript files (absolute path or relative to workspace).', + value: initialValue, + ignoreFocusOut: true + }); + if (!targetInput) { + return; + } + + const resolvedRoot = path.isAbsolute(targetInput) + ? path.normalize(targetInput) + : path.normalize(path.join(workspaceFolderUri.fsPath, targetInput)); + const stats = safeStat(resolvedRoot); + if (!stats?.isDirectory()) { + vscode.window.showErrorMessage(`Folder does not exist: ${resolvedRoot}`); + return; + } + + const tsFiles = collectTypeScriptFiles(resolvedRoot); + if (!tsFiles.length) { + vscode.window.showInformationMessage(`No TypeScript files found under ${resolvedRoot}.`); + return; + } + + validationOutput.clear(); + validationOutput.appendLine(`[AcuMate] Validating ${tsFiles.length} TypeScript files under ${resolvedRoot}`); + + const issues: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> = []; + const cancelled = await vscode.window.withProgress( + { + title: 'AcuMate TypeScript validation', + location: vscode.ProgressLocation.Notification, + cancellable: true + }, + async (progress, token) => { + for (let index = 0; index < tsFiles.length; index++) { + if (token.isCancellationRequested) { + validationOutput.appendLine('[AcuMate] TypeScript validation cancelled by user.'); + return true; + } + + const file = tsFiles[index]; + const relative = path.relative(workspaceFolderUri.fsPath, file); + progress.report({ message: relative, increment: (1 / tsFiles.length) * 100 }); + try { + const document = await vscode.workspace.openTextDocument(file); + const diagnostics = await collectGraphInfoDiagnostics(document); + if (diagnostics.length) { + issues.push({ file, diagnostics }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + validationOutput.appendLine(`[AcuMate] Failed to validate ${relative || file}: ${message}`); + } + } + + return false; + } + ); + + const totalDiagnostics = issues.reduce((sum, entry) => sum + entry.diagnostics.length, 0); + if (cancelled) { + const summary = `AcuMate TypeScript validation cancelled after processing ${issues.length} file(s).`; + validationOutput.appendLine(`[AcuMate] ${summary}`); + const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); + if (choice === 'Open Output') { + validationOutput.show(true); + } + return; + } + if (!totalDiagnostics) { + validationOutput.appendLine('[AcuMate] No diagnostics reported.'); + vscode.window.showInformationMessage(`AcuMate TypeScript validation complete: ${tsFiles.length} files, no diagnostics.`); + return; + } + + appendDiagnosticsToOutput(workspaceFolderUri.fsPath, issues); + + const summary = `AcuMate TypeScript validation complete: ${totalDiagnostics} diagnostics across ${issues.length} file(s).`; + validationOutput.appendLine(`[AcuMate] ${summary}`); + const choice = await vscode.window.showInformationMessage(summary, 'Open Output'); + if (choice === 'Open Output') { + validationOutput.show(true); + } +} + +function collectHtmlFiles(root: string): string[] { + const stack: string[] = [root]; + const files: string[] = []; + const excluded = new Set(['node_modules', '.git', '.vscode-test', 'out', 'dist', 'bin', 'obj']); + + while (stack.length) { + const current = stack.pop()!; + const stats = safeStat(current); + if (!stats) { + continue; + } + + if (stats.isDirectory()) { + for (const entry of fs.readdirSync(current)) { + if (excluded.has(entry)) { + continue; + } + stack.push(path.join(current, entry)); + } + continue; + } + + if (stats.isFile() && current.toLowerCase().endsWith('.html')) { + files.push(current); + } + } + + return files.sort((a, b) => a.localeCompare(b)); +} + +function collectTypeScriptFiles(root: string): string[] { + const stack: string[] = [root]; + const files: string[] = []; + const excluded = new Set(['node_modules', '.git', '.vscode-test', 'out', 'dist', 'bin', 'obj']); + + while (stack.length) { + const current = stack.pop()!; + const stats = safeStat(current); + if (!stats) { + continue; + } + + if (stats.isDirectory()) { + for (const entry of fs.readdirSync(current)) { + if (excluded.has(entry)) { + continue; + } + stack.push(path.join(current, entry)); + } + continue; + } + + if (!stats.isFile()) { + continue; + } + + const normalized = current.toLowerCase(); + if (normalized.endsWith('.ts') && !normalized.endsWith('.d.ts')) { + files.push(current); + } + } + + return files.sort((a, b) => a.localeCompare(b)); +} + +function appendDiagnosticsToOutput( + workspaceRoot: string, + entries: Array<{ file: string; diagnostics: vscode.Diagnostic[] }> +) { + for (const entry of entries) { + validationOutput.appendLine(path.relative(workspaceRoot, entry.file) || entry.file); + for (const diag of entry.diagnostics) { + const severity = diag.severity === vscode.DiagnosticSeverity.Error ? 'Error' : 'Warning'; + const line = (diag.range?.start?.line ?? 0) + 1; + const normalizedMessage = diag.message.replace(/\s+/g, ' ').trim(); + validationOutput.appendLine(` [${severity}] line ${line}: ${normalizedMessage}`); + } + validationOutput.appendLine(''); + } +} + +function safeStat(targetPath: string): fs.Stats | undefined { + try { + return fs.statSync(targetPath); + } + catch { + return undefined; + } +} + +function fsExists(targetPath: string): boolean { + return Boolean(safeStat(targetPath)); +}