- *.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
234 lines
9.9 KiB
TypeScript
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();
|
|
}
|
|
}
|