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

+183
View File
@@ -0,0 +1,183 @@
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
import { ModelProblem, Registry } from './format';
/**
* Architecture decisions of a model (see docs/model.md): one
* `*.archify-decision` file per decision, anywhere under the model root.
* Pure functions, no VS Code imports.
*/
export const DECISION_EXT = '.archify-decision';
export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'rejected'] as const;
export type DecisionStatus = (typeof DECISION_STATUSES)[number];
export interface Decision {
id: string;
title?: string;
status?: string;
date?: string;
/** Registry element ids the decision is about. */
elements: string[];
/** Diagram paths (relative to the model root) the decision names. */
diagrams: string[];
/** Ids of older decisions this one replaces. */
supersedes: string[];
context?: string;
decision?: string;
consequences?: string;
/** Decision file relative to the model root, and 0-based lines for diagnostics. */
file: string;
line: number;
lines: { id: number; status: number; elements: number[]; diagrams: number[]; supersedes: number[] };
}
export function isDecisionFile(fileName: string): boolean {
return fileName.endsWith(DECISION_EXT);
}
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim().length > 0 ? v : v !== undefined && v !== null && typeof v !== 'object' ? String(v) : undefined);
/** "OpenSearch for product search" → "opensearch-for-product-search". */
export function slugify(title: string): string {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'decision';
}
/** Parses one decision file. Problems carry 0-based lines. */
export function parseDecision(text: string, file: string): { decision?: Decision; problems: ModelProblem[] } {
const problems: ModelProblem[] = [];
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false });
for (const error of doc.errors) {
problems.push({ message: `Decision is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: error.linePos?.[0].line ? error.linePos[0].line - 1 : 0 });
}
if (doc.errors.length) return { problems };
const lineOf = (offset: number | undefined) => (offset === undefined ? 0 : text.slice(0, offset).split('\n').length - 1);
const root = doc.contents;
if (!isMap(root)) {
problems.push({ message: 'A decision file is a mapping with a "decision" block and context, decision and consequences.', severity: 'error', file, line: 0 });
return { problems };
}
const headNode = root.get('decision', true);
if (!isMap(headNode)) {
problems.push({ message: 'A decision needs a "decision" block with id, title and status.', severity: 'error', file, line: 0, pointer: '/decision' });
return { problems };
}
const headPair = root.items.find((pair) => isScalar(pair.key) && pair.key.value === 'decision');
const headLine = headPair && isScalar(headPair.key) ? lineOf(headPair.key.range?.[0]) : 0;
const keyLine = (key: string) => {
const pair = headNode.items.find((p) => isScalar(p.key) && p.key.value === key);
return pair && isScalar(pair.key) ? lineOf(pair.key.range?.[0]) : headLine;
};
const list = (key: string): { values: string[]; lines: number[] } => {
const node = headNode.get(key, true);
const values: string[] = [];
const lines: number[] = [];
if (isSeq(node)) {
for (const item of node.items) {
if (!isScalar(item)) continue;
if (typeof item.value === 'number') {
problems.push({ message: `"${item.value}" in ${key} is a number to YAML. Quote it or use words.`, severity: 'error', file, line: lineOf(item.range?.[0]), pointer: `/decision/${key}` });
continue;
}
if (str(item.value)) {
values.push(String(item.value));
lines.push(lineOf(item.range?.[0]));
}
}
} else if (node !== undefined) {
problems.push({ message: `"${key}" must be a list.`, severity: 'error', file, line: keyLine(key), pointer: `/decision/${key}` });
}
return { values, lines };
};
const head = headNode.toJSON() as Record<string, unknown>;
if (typeof head.id === 'number') {
problems.push({ message: `decision.id ${head.id} is a number to YAML (0001 becomes 1). Quote it ("${head.id}") or use words.`, severity: 'error', file, line: keyLine('id'), pointer: '/decision/id' });
return { problems };
}
const id = str(head.id);
if (!id) {
problems.push({ message: 'decision.id is missing.', severity: 'error', file, line: headLine, pointer: '/decision/id' });
return { problems };
}
const status = str(head.status);
if (status && !(DECISION_STATUSES as readonly string[]).includes(status)) {
problems.push({ message: `Unknown status "${status}"; one of ${DECISION_STATUSES.join(', ')}.`, severity: 'error', file, line: keyLine('status'), pointer: '/decision/status' });
}
const elements = list('elements');
const diagrams = list('diagrams');
const supersedes = list('supersedes');
const body = root.toJSON() as Record<string, unknown>;
return {
problems,
decision: {
id,
title: str(head.title),
status,
date: str(head.date),
elements: elements.values,
diagrams: diagrams.values,
supersedes: supersedes.values,
context: str(body.context),
decision: str(body.decision),
consequences: str(body.consequences),
file,
line: headLine,
lines: { id: keyLine('id'), status: keyLine('status'), elements: elements.lines, diagrams: diagrams.lines, supersedes: supersedes.lines },
},
};
}
export interface DecisionSet {
decisions: Map<string, Decision>;
files: string[];
problems: ModelProblem[];
}
/** Merges parsed decision files; an id used twice is an error on the second file. */
export function mergeDecisions(parts: { file: string; decision?: Decision; problems: ModelProblem[] }[]): DecisionSet {
const decisions = new Map<string, Decision>();
const problems: ModelProblem[] = [];
for (const part of parts) {
problems.push(...part.problems);
if (!part.decision) continue;
const existing = decisions.get(part.decision.id);
if (existing) {
problems.push({ message: `Decision "${part.decision.id}" is defined twice (also in ${existing.file}).`, severity: 'error', file: part.file, line: part.decision.lines.id });
continue;
}
decisions.set(part.decision.id, part.decision);
}
return { decisions, files: parts.map((p) => p.file), problems };
}
/** The status the pages show: a decision another one supersedes is superseded, whatever its file says. */
export function effectiveStatus(decision: Decision, all: Map<string, Decision>): { status: string; supersededBy?: string } {
for (const other of all.values()) {
if (other !== decision && other.supersedes.includes(decision.id) && other.status !== 'rejected') return { status: 'superseded', supersededBy: other.id };
}
return { status: decision.status ?? 'proposed' };
}
/** Link checks across the model: elements, diagrams and supersedes must exist. */
export function validateDecisions(set: DecisionSet, registry: Registry | undefined, diagramPaths: Set<string>): ModelProblem[] {
const problems: ModelProblem[] = [];
for (const d of set.decisions.values()) {
d.elements.forEach((id, i) => {
if (!registry?.elements.has(id)) problems.push({ message: `Unknown element "${id}": no registry of this model defines it.`, severity: 'error', file: d.file, line: d.lines.elements[i], pointer: `/decision/elements/${i}` });
});
d.diagrams.forEach((path, i) => {
if (!diagramPaths.has(path)) problems.push({ message: `Diagram "${path}" does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.diagrams[i], pointer: `/decision/diagrams/${i}` });
});
d.supersedes.forEach((id, i) => {
if (!set.decisions.has(id)) problems.push({ message: `Decision "${id}" (in supersedes) does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
else if (id === d.id) problems.push({ message: 'A decision cannot supersede itself.', severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
});
}
return problems;
}
/** Sort for lists: live decisions newest first, superseded and rejected last. */
export function sortDecisions(decisions: Decision[], all: Map<string, Decision>): Decision[] {
const rank = (d: Decision) => (['superseded', 'rejected'].includes(effectiveStatus(d, all).status) ? 1 : 0);
return [...decisions].sort((a, b) => rank(a) - rank(b) || (b.date ?? '').localeCompare(a.date ?? '') || a.id.localeCompare(b.id));
}
+75 -6
View File
@@ -150,6 +150,19 @@ export function mergeRegistries(parts: { elements: RegistryElement[]; problems:
return { elements, files: parts.map((p) => p.file), problems };
}
/** Where a diagram attaches a decision: the diagram itself, a node or an edge. */
export type DecisionAttachment = { kind: 'diagram' } | { kind: 'node'; id: string } | { kind: 'edge'; from: string; to: string; label?: string };
export interface DecisionRef {
id: string;
pointer: string;
attachedTo: DecisionAttachment;
}
/** Collections whose items may carry `decisions` (and `diagram` links), per diagram kind. */
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'] as const;
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'] as const;
export interface ResolvedDiagram {
/** The diagram as Archify expects it: refs expanded, model block and links removed. */
diagram: Obj;
@@ -162,14 +175,66 @@ export interface ResolvedDiagram {
used: Set<string>;
/** Node id → registry description, for the Elements panel. */
descriptions: Map<string, string>;
/** Decisions the diagram points at, with where they are attached. */
decisionRefs: DecisionRef[];
}
export interface ResolveOptions {
/** Undefined when the diagram is not inside a model. */
registry?: Registry;
/** Known decision ids of the model; undefined outside a model or when unknown. */
decisions?: Set<string>;
fileName: string;
}
/** Strips `decisions` keys from a diagram and collects what they point at. Mutates `diagram`. */
function takeDecisionRefs(diagram: Obj, options: ResolveOptions, problems: ModelProblem[]): DecisionRef[] {
const refs: DecisionRef[] = [];
const take = (owner: Obj, pointer: string, attachedTo: DecisionAttachment) => {
if (!('decisions' in owner)) return;
const raw = owner.decisions;
delete owner.decisions;
const ids = Array.isArray(raw) ? raw : [raw];
ids.forEach((value, i) => {
const id = value === undefined || value === null ? undefined : String(value);
if (!id) return;
const at = `${pointer}/decisions/${i}`;
if (typeof value === 'number') {
problems.push({ message: `Decision id ${value} is a number to YAML (0001 becomes 1). Quote it ("${value}") or use words.`, severity: 'error', pointer: at });
return;
}
if (!options.registry) {
problems.push({ message: `Decision "${id}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`, severity: 'error', pointer: at });
} else if (options.decisions && !options.decisions.has(id)) {
problems.push({ message: `Unknown decision "${id}": no .archify-decision file of this model defines it.`, severity: 'error', pointer: at });
}
refs.push({ id, pointer: at, attachedTo });
});
};
take(diagram, '', { kind: 'diagram' });
for (const key of NODE_COLLECTIONS) {
const items = diagram[key];
if (!Array.isArray(items)) continue;
diagram[key] = items.map((item, index) => {
if (!isObj(item)) return item;
const copy = { ...item };
take(copy, `/${key}/${index}`, { kind: 'node', id: str(copy.id) ?? str(copy.ref) ?? String(index) });
return copy;
});
}
for (const key of EDGE_COLLECTIONS) {
const items = diagram[key];
if (!Array.isArray(items)) continue;
diagram[key] = items.map((item, index) => {
if (!isObj(item)) return item;
const copy = { ...item };
take(copy, `/${key}/${index}`, { kind: 'edge', from: str(copy.from) ?? '', to: str(copy.to) ?? '', label: str(copy.label) });
return copy;
});
}
return refs;
}
/**
* Expands `ref` components from the registry and strips what Archify does
* not know. Component order and indexes are kept, so Archify's diagnostics
@@ -181,12 +246,13 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
const links = new Map<string, string>();
const used = new Set<string>();
const descriptions = new Map<string, string>();
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions };
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions, decisionRefs: [] };
const diagram: Obj = { ...doc };
delete diagram.model;
const decisionRefs = takeDecisionRefs(diagram, options, problems);
const { registry } = options;
const components = Array.isArray(doc.components) ? doc.components : undefined;
if (!components) return { diagram, problems, refs, links, used, descriptions };
const components = Array.isArray(diagram.components) ? diagram.components : undefined;
if (!components) return { diagram, problems, refs, links, used, descriptions, decisionRefs };
diagram.components = components.map((raw, index) => {
if (!isObj(raw)) return raw;
@@ -233,7 +299,7 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
if (link) links.set(id, link);
return merged;
});
return { diagram, problems, refs, links, used, descriptions };
return { diagram, problems, refs, links, used, descriptions, decisionRefs };
}
/** Group of a diagram inside a model: '' for the root folder, else the folder path with '/' separators. */
@@ -249,6 +315,8 @@ export interface DiagramSummary {
kind?: string;
title?: string;
used: string[];
/** Decisions the diagram points at. */
decisions: DecisionRef[];
parseError?: string;
}
@@ -258,13 +326,14 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum
try {
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
} catch (error) {
return { relativePath, group: groupOf(relativePath), used: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
return { relativePath, group: groupOf(relativePath), used: [], decisions: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
}
const obj = isObj(doc) ? doc : {};
const meta = isObj(obj.meta) ? obj.meta : {};
const components = Array.isArray(obj.components) ? obj.components : [];
const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used };
const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []);
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions };
}
/** Groups in display order: the root group first, then folders in path order. */
+89 -5
View File
@@ -1,6 +1,18 @@
import { DiagramSummary, RegistryElement } from './format';
import { Decision, effectiveStatus, sortDecisions } from './decisions';
import { DecisionAttachment, DiagramSummary, RegistryElement } from './format';
import { ModelIndex } from './service';
/** A decision as the lists show it. */
export interface DecisionRow {
id: string;
title?: string;
/** Status shown: superseded when another decision supersedes it. */
status: string;
supersededBy?: string;
date?: string;
file: string;
}
/** A diagram an element is used in, as the pages show it. */
export interface UsedIn {
path: string;
@@ -22,6 +34,8 @@ export interface ElementCard {
file: string;
line: number;
usedIn: UsedIn[];
/** Decisions about this element, from the decision files and from diagram attachments. */
decisions: DecisionRow[];
}
/** What the model page shows; serialised into the webview. */
@@ -31,10 +45,27 @@ export interface ModelPanel {
hasDiagram: boolean;
groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[];
registry: ElementCard[];
decisions: DecisionRow[];
problems: string[];
nested: string[];
}
/** What the decision page (one .archify-decision file) shows. */
export interface DecisionPanel {
file: string;
inModel: boolean;
model?: { id?: string; name?: string };
decision?: DecisionRow & { context?: string; decision?: string; consequences?: string; supersedes: DecisionRow[] };
/** Decisions that supersede this one. */
supersededBy: DecisionRow[];
elements: ElementCard[];
/** Diagrams the decision names. */
diagrams: UsedIn[];
/** Diagrams that point at the decision, with where. */
referencedBy: { path: string; title?: string; kind?: string; attachedTo: DecisionAttachment }[];
problems: string[];
}
/** What the registry page (one .archify-registry file) shows. */
export interface RegistryPanel {
/** The registry file, relative to the model root (or its base name outside a model). */
@@ -52,7 +83,22 @@ function usedIn(id: string, usage: Map<string, string[]>, diagrams: DiagramSumma
});
}
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[]): ElementCard {
export function decisionRow(d: Decision, all: Map<string, Decision>): DecisionRow {
const { status, supersededBy } = effectiveStatus(d, all);
return { id: d.id, title: d.title, status, supersededBy, date: d.date, file: d.file };
}
/** Decisions about an element: named by the decision, or attached to its node in some diagram. */
function decisionsOfElement(id: string, index: Pick<ModelIndex, 'decisions' | 'decisionUsage'> | undefined): DecisionRow[] {
if (!index) return [];
const all = index.decisions.decisions;
const ids = new Set<string>();
for (const d of all.values()) if (d.elements.includes(id)) ids.add(d.id);
for (const [decisionId, uses] of index.decisionUsage) if (uses.some((u) => u.attachedTo.kind === 'node' && u.attachedTo.id === id) && all.has(decisionId)) ids.add(decisionId);
return sortDecisions([...ids].map((x) => all.get(x)!), all).map((d) => decisionRow(d, all));
}
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[], index?: Pick<ModelIndex, 'decisions' | 'decisionUsage'>): ElementCard {
return {
id: e.id,
label: e.label,
@@ -64,22 +110,25 @@ export function elementCard(e: RegistryElement, usage: Map<string, string[]>, di
file: e.file,
line: e.line,
usedIn: usedIn(e.id, usage, diagrams),
decisions: decisionsOfElement(e.id, index),
};
}
const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id);
export function panelFromIndex(index: ModelIndex): ModelPanel {
const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams));
const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams, index));
const all = index.decisions.decisions;
return {
meta: index.meta,
decisions: sortDecisions([...all.values()], all).map((d) => decisionRow(d, all)),
hasDiagram: index.mainHasDiagram,
groups: index.groups.map((g) => ({
group: g.group,
diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })),
})),
registry,
problems: index.registry.problems.map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)),
problems: [...index.registry.problems, ...index.decisions.problems].map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)),
nested: index.nested,
};
}
@@ -89,7 +138,7 @@ export function registryPanelFromIndex(index: ModelIndex, file: string): Registr
const elements = [...index.registry.elements.values()]
.filter((e) => e.file === file)
.sort((a, b) => a.line - b.line)
.map((e) => elementCard(e, index.usage, index.diagrams));
.map((e) => elementCard(e, index.usage, index.diagrams, index));
return {
file,
inModel: true,
@@ -108,3 +157,38 @@ export function registryPanelStandalone(file: string, parsed: { elements: Regist
problems: parsed.problems.map((p) => p.message),
};
}
/** The decision page of one file inside a model. */
export function decisionPanelFromIndex(index: ModelIndex, file: string): DecisionPanel {
const all = index.decisions.decisions;
const d = [...all.values()].find((x) => x.file === file);
const problems = index.decisions.problems.filter((p) => p.file === file).map((p) => p.message);
const base = { file, inModel: true, model: { id: index.meta.id, name: index.meta.name }, problems };
if (!d) return { ...base, supersededBy: [], elements: [], diagrams: [], referencedBy: [] };
const rows = (ids: string[]) => ids.filter((id) => all.has(id)).map((id) => decisionRow(all.get(id)!, all));
const elements = d.elements.filter((id) => index.registry.elements.has(id)).map((id) => elementCard(index.registry.elements.get(id)!, index.usage, index.diagrams, index));
const summary = (path: string) => index.diagrams.find((x) => x.relativePath === path);
return {
...base,
decision: { ...decisionRow(d, all), context: d.context, decision: d.decision, consequences: d.consequences, supersedes: rows(d.supersedes) },
supersededBy: [...all.values()].filter((x) => x.supersedes.includes(d.id)).map((x) => decisionRow(x, all)),
elements,
diagrams: d.diagrams.map((path) => ({ path, title: summary(path)?.title, kind: summary(path)?.kind })),
referencedBy: (index.decisionUsage.get(d.id) ?? []).map((u) => ({ path: u.path, title: summary(u.path)?.title, kind: summary(u.path)?.kind, attachedTo: u.attachedTo })),
};
}
/** The decision page of a file outside a model: the text only, and a note that it has lost its model. */
export function decisionPanelStandalone(file: string, parsed: { decision?: Decision; problems: { message: string }[] }): DecisionPanel {
const d = parsed.decision;
return {
file,
inModel: false,
decision: d ? { id: d.id, title: d.title, status: d.status ?? 'proposed', date: d.date, file, context: d.context, decision: d.decision, consequences: d.consequences, supersedes: d.supersedes.map((id) => ({ id, status: 'unknown', file: '' })) } : undefined,
supersededBy: [],
elements: [],
diagrams: d ? d.diagrams.map((path) => ({ path })) : [],
referencedBy: [],
problems: parsed.problems.map((p) => p.message),
};
}
+37 -5
View File
@@ -1,6 +1,8 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { DiagramSource, readDiagramSource } from '../sources/detect';
import { ModelProblem, Registry, resolveRefs } from './format';
import { DecisionAttachment, ModelProblem, Registry, resolveRefs } from './format';
import { effectiveStatus } from './decisions';
import { ModelService } from './service';
/**
@@ -18,6 +20,16 @@ export interface PreparedDiagram {
links: Map<string, string>;
/** Node id → registry description. */
descriptions: Map<string, string>;
/** Decisions of this diagram for the Decisions rail panel: pointed at by the diagram, or naming it. */
decisions: DiagramDecision[];
}
export interface DiagramDecision {
id: string;
title?: string;
status: string;
file: string;
attachedTo: DecisionAttachment[];
}
/**
@@ -28,12 +40,32 @@ export interface PreparedDiagram {
export async function prepareDiagram(models: ModelService, uri: vscode.Uri, text: string): Promise<PreparedDiagram | undefined> {
const source = readDiagramSource(text, uri.path);
if (!source) return undefined;
const found = await models.registryFor(uri);
const resolved = resolveRefs(source.diagram, { registry: found?.registry, fileName: uri.path });
const index = await models.indexFor(uri);
const found = index ? { root: index.root, registry: index.registry } : undefined;
const resolved = resolveRefs(source.diagram, { registry: found?.registry, decisions: index ? new Set(index.decisions.decisions.keys()) : undefined, fileName: uri.path });
// Only diagrams that use refs (or carry a model block) need the resolved form.
const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0;
const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0 || resolved.decisionRefs.length > 0;
const prepared: DiagramSource = changed
? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) }
: source;
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions };
// Decisions for the rail: the ones this diagram points at, plus the ones that name this diagram.
const decisions: DiagramDecision[] = [];
if (index) {
const rel = path.relative(index.root.fsPath, uri.fsPath).split(path.sep).join('/');
const byId = new Map<string, DiagramDecision>();
const add = (id: string, attachedTo?: DecisionAttachment) => {
const d = index.decisions.decisions.get(id);
if (!d) return;
let row = byId.get(id);
if (!row) {
row = { id, title: d.title, status: effectiveStatus(d, index.decisions.decisions).status, file: d.file, attachedTo: [] };
byId.set(id, row);
decisions.push(row);
}
if (attachedTo && !row.attachedTo.some((a) => JSON.stringify(a) === JSON.stringify(attachedTo))) row.attachedTo.push(attachedTo);
};
for (const ref of resolved.decisionRefs) add(ref.id, ref.attachedTo);
for (const d of index.decisions.decisions.values()) if (d.diagrams.includes(rel)) add(d.id, { kind: 'diagram' });
}
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions, decisions };
}
+27 -3
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import * as vscode from 'vscode';
import { log } from '../util/log';
import {
DecisionAttachment,
DiagramSummary,
MAIN_FILE,
ModelMeta,
@@ -17,6 +18,7 @@ import {
summarizeDiagram,
} from './format';
import { parse as parseYaml } from 'yaml';
import { DECISION_EXT, DecisionSet, isDecisionFile, mergeDecisions, parseDecision, validateDecisions } from './decisions';
/**
* Finds models in the workspace and keeps what they contain. A model is the
@@ -36,6 +38,10 @@ export interface ModelIndex {
groups: { group: string; diagrams: DiagramSummary[] }[];
/** Element id → diagrams (relative paths) that refer to it. */
usage: Map<string, string[]>;
/** The model's decisions, with link problems already checked. */
decisions: DecisionSet;
/** Decision id → diagrams that point at it, with where. */
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
/** Nested main.archify files found below the root; not supported. */
nested: string[];
}
@@ -71,7 +77,7 @@ export class ModelService implements vscode.Disposable {
private readonly disposables: vscode.Disposable[];
constructor() {
const watcher = vscode.workspace.createFileSystemWatcher(`**/*{.archify,${REGISTRY_EXT}}`);
const watcher = vscode.workspace.createFileSystemWatcher(`**/*{.archify,${REGISTRY_EXT},${DECISION_EXT}}`);
const changed = (uri: vscode.Uri) => void this.onFileChanged(uri);
this.disposables = [
watcher,
@@ -80,7 +86,7 @@ export class ModelService implements vscode.Disposable {
watcher.onDidChange(changed),
vscode.workspace.onDidChangeTextDocument((event) => {
// Registry edits in the editor should re-render open diagrams before a save.
if (isRegistryFile(event.document.fileName) || isMainFile(event.document.fileName)) changed(event.document.uri);
if (isRegistryFile(event.document.fileName) || isMainFile(event.document.fileName) || isDecisionFile(event.document.fileName)) changed(event.document.uri);
}),
this.changeEmitter,
];
@@ -118,6 +124,12 @@ export class ModelService implements vscode.Disposable {
return found ?? undefined;
}
/** The model index of the model a file belongs to, or undefined outside a model. */
async indexFor(uri: vscode.Uri): Promise<ModelIndex | undefined> {
const root = await this.rootOf(uri);
return root ? this.index(root) : undefined;
}
/** The registry of the model a file belongs to, or undefined outside a model. */
async registryFor(uri: vscode.Uri): Promise<{ root: vscode.Uri; registry: Registry } | undefined> {
const root = await this.rootOf(uri);
@@ -175,7 +187,19 @@ export class ModelService implements vscode.Disposable {
}
const usage = new Map<string, string[]>();
for (const d of diagrams) for (const id of d.used) usage.set(id, [...(usage.get(id) ?? []), d.relativePath]);
return { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, nested };
const decisionFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${DECISION_EXT}`), SKIP);
const decisionParts = await Promise.all(
decisionFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => {
const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/');
return { file: rel, ...parseDecision(await readText(file), rel) };
}),
);
const decisions = mergeDecisions(decisionParts);
decisions.problems.push(...validateDecisions(decisions, registry, new Set(diagrams.map((d) => d.relativePath))));
const decisionUsage = new Map<string, { path: string; attachedTo: DecisionAttachment }[]>();
for (const d of diagrams) for (const ref of d.decisions) decisionUsage.set(ref.id, [...(decisionUsage.get(ref.id) ?? []), { path: d.relativePath, attachedTo: ref.attachedTo }]);
return { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, decisions, decisionUsage, nested };
}
dispose(): void {