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
This commit is contained in:
1 parent
33ebdd6328
commit
ebf320a023
49 files changed
+1637
-55
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Decision, DECISION_STATUSES, effectiveStatus, isDecisionFile } from '../model/decisions';
|
||||
import { ModelIndex, ModelService } from '../model/service';
|
||||
|
||||
/** Diagrams (decisions: […]) and decision files (elements: […], diagrams: […], supersedes: […]). */
|
||||
export const DECISION_SELECTOR: vscode.DocumentSelector = [{ pattern: '**/*.archify' }, { pattern: '**/*.archify-decision' }];
|
||||
|
||||
type ListKey = 'decisions' | 'elements' | 'diagrams' | 'supersedes';
|
||||
|
||||
/** Which list the cursor is in on a `key: [a, b, …` line, and what has been typed of the current item. */
|
||||
export function listValueAt(linePrefix: string, fileName: string): { key: ListKey; typed: string } | undefined {
|
||||
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
|
||||
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)$`).exec(linePrefix);
|
||||
if (match) {
|
||||
const items = match[2].split(',');
|
||||
return { key: match[1] as ListKey, typed: items[items.length - 1].replace(/^[\s"']+/, '') };
|
||||
}
|
||||
// Block list item under the key: " - 00" (the key is on a line above; the caller checks).
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The list item under a position on a flow-list line, with its range. */
|
||||
export function listItemAt(line: string, character: number, fileName: string): { key: ListKey; value: string; start: number; end: number } | undefined {
|
||||
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
|
||||
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)\\]?`).exec(line);
|
||||
if (!match) return undefined;
|
||||
const listStart = match.index + match[0].indexOf('[') + 1;
|
||||
const re = /[^,\s"'\]]+/g;
|
||||
const inner = match[2];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(inner))) {
|
||||
const start = listStart + m.index;
|
||||
const end = start + m[0].length;
|
||||
if (character >= start && character <= end) return { key: match[1] as ListKey, value: m[0], start, end };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeDecision(d: Decision, index: ModelIndex): vscode.MarkdownString {
|
||||
const md = new vscode.MarkdownString();
|
||||
const { status, supersededBy } = effectiveStatus(d, index.decisions.decisions);
|
||||
md.appendMarkdown(`**${d.id}**${d.title ? ` · ${d.title}` : ''} \`${status}\`${supersededBy ? ` by ${supersededBy}` : ''}${d.date ? ` · ${d.date}` : ''}\n\n`);
|
||||
if (d.decision) md.appendMarkdown(`${d.decision.trim()}\n\n`);
|
||||
md.appendMarkdown(`*${d.file}*`);
|
||||
return md;
|
||||
}
|
||||
|
||||
/** Completion, hover and go-to-definition for decision ids in diagrams and for links inside decision files. */
|
||||
export class DecisionLanguage implements vscode.CompletionItemProvider, vscode.HoverProvider, vscode.DefinitionProvider {
|
||||
constructor(private readonly models: ModelService) {}
|
||||
|
||||
async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.CompletionItem[] | undefined> {
|
||||
const at = listValueAt(document.lineAt(position.line).text.slice(0, position.character), document.fileName);
|
||||
if (!at) return undefined;
|
||||
const index = await this.models.indexFor(document.uri);
|
||||
if (!index) return undefined;
|
||||
const range = new vscode.Range(position.translate(0, -at.typed.length), position);
|
||||
const item = (label: string, description: string | undefined, detail: string, doc?: vscode.MarkdownString) => {
|
||||
const it = new vscode.CompletionItem({ label, description }, vscode.CompletionItemKind.Reference);
|
||||
it.detail = detail;
|
||||
it.documentation = doc;
|
||||
it.filterText = `${label} ${description ?? ''}`;
|
||||
it.range = range;
|
||||
return it;
|
||||
};
|
||||
if (at.key === 'decisions' || at.key === 'supersedes') {
|
||||
return [...index.decisions.decisions.values()].map((d) => item(d.id, d.title, effectiveStatus(d, index.decisions.decisions).status, describeDecision(d, index)));
|
||||
}
|
||||
if (at.key === 'elements') {
|
||||
return [...index.registry.elements.values()].map((e) => item(e.id, e.label, [e.type, e.sublabel].filter(Boolean).join(' · ')));
|
||||
}
|
||||
return index.diagrams.map((d) => item(d.relativePath, d.title, d.kind ?? 'diagram'));
|
||||
}
|
||||
|
||||
async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Hover | undefined> {
|
||||
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
|
||||
if (!hit || (hit.key !== 'decisions' && hit.key !== 'supersedes')) return undefined;
|
||||
const index = await this.models.indexFor(document.uri);
|
||||
const range = new vscode.Range(position.line, hit.start, position.line, hit.end);
|
||||
const d = index?.decisions.decisions.get(hit.value);
|
||||
if (!index) return new vscode.Hover(`\`${hit.value}\` cannot be resolved: this file is not inside a model.`, range);
|
||||
if (!d) return new vscode.Hover(`Unknown decision \`${hit.value}\`: no .archify-decision file of this model defines it.`, range);
|
||||
return new vscode.Hover(describeDecision(d, index), range);
|
||||
}
|
||||
|
||||
async provideDefinition(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Location | undefined> {
|
||||
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
|
||||
if (!hit) return undefined;
|
||||
const index = await this.models.indexFor(document.uri);
|
||||
if (!index) return undefined;
|
||||
if (hit.key === 'decisions' || hit.key === 'supersedes') {
|
||||
const d = index.decisions.decisions.get(hit.value);
|
||||
return d ? new vscode.Location(vscode.Uri.joinPath(index.root, ...d.file.split('/')), new vscode.Position(d.lines.id, 2)) : undefined;
|
||||
}
|
||||
if (hit.key === 'elements') {
|
||||
const e = index.registry.elements.get(hit.value);
|
||||
return e ? new vscode.Location(vscode.Uri.joinPath(index.root, ...e.file.split(/[\\/]/)), new vscode.Position(e.line, 2)) : undefined;
|
||||
}
|
||||
const diagram = index.diagrams.find((d) => d.relativePath === hit.value);
|
||||
return diagram ? new vscode.Location(vscode.Uri.joinPath(index.root, ...diagram.relativePath.split('/')), new vscode.Position(0, 0)) : undefined;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -70,7 +71,7 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
private schedule(doc: vscode.TextDocument, delay: number): void {
|
||||
if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName) && !isRegistryFile(doc.fileName)) return;
|
||||
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));
|
||||
@@ -91,6 +92,10 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
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.
|
||||
@@ -179,6 +184,27 @@ export class DiagnosticsController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
Reference in new issue
Block a user