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/package.json b/acumate-plugin/package.json index 532319c..e0987b5 100644 --- a/acumate-plugin/package.json +++ b/acumate-plugin/package.json @@ -131,6 +131,16 @@ "command": "acumate.dropCache", "title": "Drop Local Cache", "category": "AcuMate" + }, + { + "command": "acumate.validateScreens", + "title": "Validate Screens (HTML)", + "category": "AcuMate" + }, + { + "command": "acumate.validateTypeScriptScreens", + "title": "Validate Screens (TypeScript)", + "category": "AcuMate" } ], "menus": { @@ -164,7 +174,9 @@ "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", + "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 e9fbd06..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,10 +114,16 @@ 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), 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 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). 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. diff --git a/acumate-plugin/scripts/validate-screens.js b/acumate-plugin/scripts/validate-screens.js new file mode 100644 index 0000000..648ce3e --- /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: process.env.SCREEN_VALIDATION_ROOT || 'src/screens' + }, + stdio: 'inherit' +}); + +child.on('exit', code => { + process.exit(code ?? 1); +}); 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/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 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/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/extension.ts b/acumate-plugin/src/extension.ts index ef9977c..9e28b92 100644 --- a/acumate-plugin/src/extension.ts +++ b/acumate-plugin/src/extension.ts @@ -11,7 +11,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`); const findConfig = require('find-config'); import { validateHtmlFile } from './validation/htmlValidation/html-validation'; import { registerHtmlDefinitionProvider } from './providers/html-definition-provider'; @@ -21,6 +20,10 @@ import { registerGraphInfoValidation } from './validation/tsValidation/graph-inf 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(); export function activate(context: vscode.ExtensionContext) { init(context); @@ -30,7 +33,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); @@ -41,16 +44,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); } }); } @@ -191,8 +251,19 @@ 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); + + disposable = vscode.commands.registerCommand('acumate.validateTypeScriptScreens', async () => { + await runWorkspaceTypeScriptValidation(); + }); + context.subscriptions.push(disposable); } + function createIntelliSenseProviders(context: vscode.ExtensionContext) { if (!AcuMateContext.ConfigurationService.useBackend) { return; 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/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( 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/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/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/test/suite/projectScreenValidation.test.ts b/acumate-plugin/src/test/suite/projectScreenValidation.test.ts new file mode 100644 index 0000000..1df27d7 --- /dev/null +++ b/acumate-plugin/src/test/suite/projectScreenValidation.test.ts @@ -0,0 +1,105 @@ +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; + +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'); + } + }); + + it('validates HTML screens under configured 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')}`; +} 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..871d4a1 --- /dev/null +++ b/acumate-plugin/src/test/suite/projectTsValidation.test.ts @@ -0,0 +1,110 @@ +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; + +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); + + 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')}`; +} diff --git a/acumate-plugin/src/test/suite/tsGraphInfo.test.ts b/acumate-plugin/src/test/suite/tsGraphInfo.test.ts index 2d439d3..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, @@ -285,6 +309,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, @@ -304,6 +376,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; +} 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)) { 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) { 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)); +}