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,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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user