Files
archify-vscode-ext/src/diagnostics.ts
T
root-at-skic adc5cdbe97 Suggest brand IDs after brand: and place unknown-brand errors on their line
- Completion for brand values in .archify (YAML) and Archify JSON sources,
  from the bundled brand-marks/catalog.json (107 marks); aliases and
  product names match, so "kafka" offers apache-kafka
- brand/unknown diagnostics carry their JSON pointer only at the start of
  the message; use it when subject.path is missing, so the error lands on
  the brand line instead of the top of the file
- Unit tests for catalog loading, value detection and pointer fallback;
  integration checks for completion and diagnostic placement

Closes #5
2026-10-04 22:05:30 +03:00

160 lines
5.9 KiB
TypeScript

import { parse, ParseError } from 'jsonc-parser';
import * as vscode from 'vscode';
import { isArchifyFile, readDiagramSource } from './detect';
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
import { evidenceMode, log, runtimeFor } from './runtime';
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
function severity(diagnostic: ArchifyDiagnostic, downgradeEvidence: boolean): vscode.DiagnosticSeverity {
if (downgradeEvidence && isEvidenceError(`${diagnostic.code ?? ''} ${diagnostic.message}`)) {
return vscode.DiagnosticSeverity.Warning;
}
switch (diagnostic.severity) {
case 'warning':
return vscode.DiagnosticSeverity.Warning;
case 'info':
return vscode.DiagnosticSeverity.Information;
default:
return vscode.DiagnosticSeverity.Error;
}
}
/** Runs `archify validate` on diagram sources and reports into Problems. */
export class DiagnosticsController implements vscode.Disposable {
private readonly collection = vscode.languages.createDiagnosticCollection('archify');
private readonly timers = new Map<string, NodeJS.Timeout>();
private readonly sequence = new Map<string, number>();
private readonly disposables: vscode.Disposable[];
constructor(private readonly context: vscode.ExtensionContext) {
this.disposables = [
this.collection,
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
vscode.workspace.onDidChangeTextDocument((event) => {
if (event.contentChanges.length) this.schedule(event.document, 700);
}),
vscode.workspace.onDidSaveTextDocument((doc) => this.schedule(doc, 0)),
vscode.workspace.onDidCloseTextDocument((doc) => this.clear(doc.uri)),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify')) this.revalidateAll();
}),
];
this.revalidateAll();
}
private enabled(uri: vscode.Uri): boolean {
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('validation.enabled', true);
}
revalidateAll(): void {
for (const doc of vscode.workspace.textDocuments) this.schedule(doc, 0);
}
private clear(uri: vscode.Uri): void {
const key = uri.toString();
clearTimeout(this.timers.get(key));
this.timers.delete(key);
this.sequence.set(key, (this.sequence.get(key) ?? 0) + 1);
this.collection.delete(uri);
}
private schedule(doc: vscode.TextDocument, delay: number): void {
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName)) return;
const key = doc.uri.toString();
clearTimeout(this.timers.get(key));
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
}
private async validate(doc: vscode.TextDocument): Promise<void> {
const key = doc.uri.toString();
this.timers.delete(key);
const seq = (this.sequence.get(key) ?? 0) + 1;
this.sequence.set(key, seq);
const text = doc.getText();
if (!this.enabled(doc.uri) || doc.isClosed) {
this.collection.delete(doc.uri);
return;
}
if (isArchifyFile(doc.fileName)) {
// Nothing else reports YAML syntax errors in .archify files, and a file
// that does not parse has no diagram to validate yet.
const problems = yamlSyntaxProblems(text);
if (problems.length) {
this.collection.set(
doc.uri,
problems.map((problem) => {
const diagnostic = new vscode.Diagnostic(
new vscode.Range(doc.positionAt(problem.start), doc.positionAt(problem.end)),
`YAML: ${problem.message}`,
vscode.DiagnosticSeverity.Error,
);
diagnostic.source = 'archify';
return diagnostic;
}),
);
return;
}
}
const source = readDiagramSource(text, doc.fileName);
if (!source) {
this.collection.delete(doc.uri);
return;
}
if (source.format === 'json') {
// Leave JSON syntax errors to the JSON language service.
const syntax: ParseError[] = [];
parse(text, syntax, { allowTrailingComma: false });
if (syntax.length) {
this.collection.delete(doc.uri);
return;
}
}
try {
const runtime = await runtimeFor(this.context, doc.uri);
const result = await validate(runtime, source.type, source.json);
if (seq !== this.sequence.get(key) || doc.isClosed) return;
if (result.crash) {
const diagnostic = new vscode.Diagnostic(
new vscode.Range(0, 0, 0, 0),
`Archify validation did not complete: ${result.crash.split('\n')[0]}`,
vscode.DiagnosticSeverity.Error,
);
diagnostic.source = 'archify';
this.collection.set(doc.uri, [diagnostic]);
return;
}
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
this.collection.set(
doc.uri,
result.diagnostics.map((item) => {
const pointer = diagnosticPointer(item);
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer);
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
const fix = item.supportedFixes?.[0];
const diagnostic = new vscode.Diagnostic(
range,
fix ? `${item.message}\nFix: ${fix}` : item.message,
severity(item, downgradeEvidence),
);
diagnostic.source = 'archify';
if (item.code) diagnostic.code = item.code;
return diagnostic;
}),
);
} catch (error) {
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
dispose(): void {
for (const timer of this.timers.values()) clearTimeout(timer);
for (const disposable of this.disposables.splice(0)) disposable.dispose();
}
}