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:
root-at-skic committed 2026-10-19 23:13:05 +03:00
1 parent 33ebdd6328
commit ebf320a023
49 files changed
+1637 -55

No files matched your search

+102
View File
@@ -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;
}
}
+27 -1
View File
@@ -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);