Files
archify-vscode-ext/src/language/diagnostics.ts
T
root-at-skic ebf320a023 Architecture decisions: files, diagram references, views and panels
- *.archify-decision files: id (unique, words), title, status, date,
  elements, diagrams, supersedes, and prose; parsed, merged and link-checked
  in the model index; diagnostics on the file
- diagrams point at decisions with decisions: [...] on the diagram, nodes
  and edges; stripped before Archify, unknown ids reported, completion,
  hover and go-to-definition for the ids and for elements/diagrams/
  supersedes in decision files
- Decisions panel on the main.archify page; Decisions rail panel under a
  diagram (hover lights up the node or connection); decisions on element
  cards and in the element dialog; decision view for the file, with a
  lost-model note outside a model
- Archify | New Decision… command and template; editor schemas; the three
  New Model examples carry decisions; docs, changelog, 0.8.0
2026-10-19 23:13:05 +03:00

234 lines
9.9 KiB
TypeScript

import { parse, ParseError } from 'jsonc-parser';
import * as vscode from 'vscode';
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
import { evidenceMode, runtimeFor } from '../archify/runtime';
import { ModelProblem, isRegistryFile, parseRegistry } from '../model/format';
import { isDecisionFile, parseDecision } from '../model/decisions';
import { prepareDiagram } from '../model/prepare';
import { ModelService } from '../model/service';
import { isArchifyFile } from '../sources/detect';
import { log } from '../util/log';
import path from 'node:path';
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
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,
private readonly models: ModelService,
) {
this.disposables = [
this.collection,
models.onDidChangeModel(() => this.revalidateAll()),
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) && !isRegistryFile(doc.fileName) && !isDecisionFile(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 (isRegistryFile(doc.fileName)) {
await this.validateRegistry(doc, seq);
return;
}
if (isDecisionFile(doc.fileName)) {
await this.validateDecision(doc, seq);
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 prepared = await prepareDiagram(this.models, doc.uri, text);
if (seq !== this.sequence.get(key) || doc.isClosed) return;
if (!prepared) {
this.collection.delete(doc.uri);
return;
}
const source = prepared.source;
const modelDiagnostics = prepared.problems
.filter((problem) => !problem.file)
.map((problem) => this.modelDiagnostic(doc, text, source.format, problem));
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, [
...modelDiagnostics,
...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)}`);
}
}
private modelDiagnostic(doc: vscode.TextDocument, text: string, format: 'yaml' | 'json', problem: ModelProblem): vscode.Diagnostic {
const offsets = format === 'yaml' ? rangeForPointerYaml(text, problem.pointer) : rangeForPointer(text, problem.pointer);
const range = problem.line !== undefined && !problem.pointer ? doc.lineAt(Math.min(problem.line, doc.lineCount - 1)).range : new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
const diagnostic = new vscode.Diagnostic(range, problem.message, problem.severity === 'warning' ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Error);
diagnostic.source = 'archify';
return diagnostic;
}
/** A registry file: its own YAML and element problems, plus duplicates across the model. */
private async validateDecision(doc: vscode.TextDocument, seq: number): Promise<void> {
const text = doc.getText();
const root = await this.models.rootOf(doc.uri);
const rel = root ? path.relative(root.fsPath, doc.uri.fsPath).split(path.sep).join('/') : path.basename(doc.uri.fsPath);
const problems: ModelProblem[] = [...parseDecision(text, rel).problems];
if (root) {
try {
const index = await this.models.index(root);
for (const problem of index.decisions.problems) {
if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem);
}
} catch {
// The index reports its own failures when the model page opens.
}
} else {
problems.push({ message: `This decision has lost its model: no main.archify above ${path.basename(doc.uri.fsPath)}, so its elements and diagrams cannot be resolved.`, severity: 'warning', line: 0 });
}
if (seq !== this.sequence.get(doc.uri.toString()) || doc.isClosed) return;
this.collection.set(doc.uri, problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem)));
}
private async validateRegistry(doc: vscode.TextDocument, seq: number): Promise<void> {
const text = doc.getText();
const root = await this.models.rootOf(doc.uri);
const rel = root ? path.relative(root.fsPath, doc.uri.fsPath) : path.basename(doc.uri.fsPath);
const problems: ModelProblem[] = [...parseRegistry(text, rel).problems];
if (root) {
try {
const index = await this.models.index(root);
for (const problem of index.registry.problems) {
if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem);
}
} catch {
// The index reports its own failures when the model page opens.
}
} else {
problems.push({ message: `This registry is not inside a model: no main.archify above ${path.basename(doc.uri.fsPath)}.`, severity: 'warning', line: 0 });
}
if (seq !== this.sequence.get(doc.uri.toString()) || doc.isClosed) return;
this.collection.set(doc.uri, problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem)));
}
dispose(): void {
for (const timer of this.timers.values()) clearTimeout(timer);
for (const disposable of this.disposables.splice(0)) disposable.dispose();
}
}