Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/project/specs/allium-diagram-ergonomics-behaviour.allium
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ rule RendererGroupsNodesByKind {
ensures: GroupContainerRendered(kind: trigger)
}

rule DeclaredFieldTypeReferencesAreLinked {
when: DiagramRendered(format)
requires: DiagramContainsDeclaredEntityOrValueFieldTypeReference()

ensures: EdgeRendered(label: "type")
}

rule ReverseLinksOptionAddsInverseEdges {
when: DiagramCommandInvoked(args)
requires: args includes "--reverse-links"
Expand Down
99 changes: 98 additions & 1 deletion extensions/allium/src/language-tools/diagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ export function buildDiagramResult(text: string): DiagramBuildResult {
edges.push({ from: from.id, to: to.id, label, sourceOffset });
};

const resolveDeclaredTypeNode = (rawTypeName: string): DiagramNode | null => {
const typeName = rawTypeName.includes("/")
? rawTypeName.split("/")[1]
: rawTypeName;
const candidateKinds: DiagramNodeKind[] = [
"entity",
"value",
"variant",
"enum",
];
for (const kind of candidateKinds) {
const node = nodeByKey.get(`${kind}:${typeName}`);
if (node) {
return node;
}
}
return null;
};

const blocks = parseAlliumBlocks(text);
for (const block of blocks) {
if (block.kind === "rule") {
Expand Down Expand Up @@ -155,6 +174,44 @@ export function buildDiagramResult(text: string): DiagramBuildResult {
const target = ensureNode("entity", targetType, "entity");
addEdge(source, target, "rel");
}
for (const typeRef of parseFieldTypeReferences(body)) {
const target = resolveDeclaredTypeNode(typeRef);
if (target) {
addEdge(source, target, "type");
}
}
}

const valueBlockPattern =
/^\s*value\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)^\s*\}/gm;
for (
let value = valueBlockPattern.exec(text);
value;
value = valueBlockPattern.exec(text)
) {
const source = ensureNode("value", value[1], "value");
for (const typeRef of parseFieldTypeReferences(value[2])) {
const target = resolveDeclaredTypeNode(typeRef);
if (target) {
addEdge(source, target, "type");
}
}
}

const variantBlockPattern =
/^\s*variant\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*[A-Za-z_][A-Za-z0-9_]*)?\s*\{([\s\S]*?)^\s*\}/gm;
for (
let variant = variantBlockPattern.exec(text);
variant;
variant = variantBlockPattern.exec(text)
) {
const source = ensureNode("variant", variant[1], "variant");
for (const typeRef of parseFieldTypeReferences(variant[2])) {
const target = resolveDeclaredTypeNode(typeRef);
if (target) {
addEdge(source, target, "type");
}
}
}

const rulePattern =
Expand Down Expand Up @@ -343,6 +400,7 @@ export function renderDiagram(
function renderD2(model: DiagramModel): string {
const lines: string[] = ["direction: right", ""];
const nodesByKind = groupNodesByKind(model.nodes);
const nodeKindById = new Map(model.nodes.map((node) => [node.id, node.kind]));
for (const [kind, nodes] of nodesByKind) {
lines.push(`${kind}_group: {`);
lines.push(` label: "${escapeD2(kindLabel(kind))}"`);
Expand All @@ -358,7 +416,11 @@ function renderD2(model: DiagramModel): string {
}

for (const edge of model.edges) {
lines.push(`${edge.from} -> ${edge.to}: "${escapeD2(edge.label)}"`);
const fromKind = nodeKindById.get(edge.from);
const toKind = nodeKindById.get(edge.to);
const fromRef = fromKind ? `${fromKind}_group.${edge.from}` : edge.from;
const toRef = toKind ? `${toKind}_group.${edge.to}` : edge.to;
lines.push(`${fromRef} -> ${toRef}: "${escapeD2(edge.label)}"`);
}
return `${lines.join("\n").replace(/\n+$/g, "")}\n`;
}
Expand Down Expand Up @@ -553,3 +615,38 @@ function parseSurfaceProvidesCalls(body: string): string[] {
}
return calls;
}

function parseFieldTypeReferences(body: string): string[] {
const refs: string[] = [];
const seen = new Set<string>();
const fieldPattern = /^\s*[A-Za-z_][A-Za-z0-9_]*\s*:\s*(.+)$/gm;
for (
let field = fieldPattern.exec(body);
field;
field = fieldPattern.exec(body)
) {
const annotation = field[1].trim();
if (/\bfor\s+this\b/.test(annotation)) {
continue;
}
const tokenPattern = /[A-Za-z_][A-Za-z0-9_]*(?:\/[A-Za-z_][A-Za-z0-9_]*)?/g;
for (
let token = tokenPattern.exec(annotation);
token;
token = tokenPattern.exec(annotation)
) {
const candidate = token[0];
const localName = candidate.includes("/")
? candidate.split("/")[1]
: candidate;
if (!/^[A-Z]/.test(localName)) {
continue;
}
if (!seen.has(candidate)) {
seen.add(candidate);
refs.push(candidate);
}
}
}
return refs;
}
37 changes: 36 additions & 1 deletion extensions/allium/test/diagram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,51 @@ test("applies focus and kind filters", () => {
);
});

test("links declared field type references for entities and values", () => {
const model = buildDiagramResult(
`entity Ticket {\n status: Status\n}\n\nenum Status {\n open\n closed\n}\n\nvalue TicketView {\n ticket: Ticket\n status: Status\n}\n\nentity DashboardState {\n selected: TicketView\n}\n`,
).model;

assert.ok(
model.edges.some(
(e) =>
e.label === "type" &&
e.from === "value_TicketView" &&
e.to === "entity_Ticket",
),
);
assert.ok(
model.edges.some(
(e) =>
e.label === "type" &&
e.from === "value_TicketView" &&
e.to === "enum_Status",
),
);
assert.ok(
model.edges.some(
(e) =>
e.label === "type" &&
e.from === "entity_DashboardState" &&
e.to === "value_TicketView",
),
);
});

test("renders grouped d2 and mermaid output", () => {
const model = buildDiagramResult(
`entity Ticket {\n status: open | closed\n}\nrule Close {\n when: CloseTicket(ticket)\n ensures: Ticket.created(status: closed)\n}\n`,
`entity Ticket {\n status: open | closed\n}\nrule Close {\n when: CloseTicket(ticket)\n ensures: Ticket.created(status: closed)\n}\nsurface Console {\n for user: User\n provides:\n CloseTicket(ticket)\n}\n`,
).model;

const d2 = renderDiagram(model, "d2");
const mermaid = renderDiagram(model, "mermaid");

assert.match(d2, /entity_group: \{/);
assert.match(d2, /rule_group: \{/);
assert.match(
d2,
/surface_group\.surface_Console -> trigger_group\.trigger_CloseTicket: "provides"/,
);
assert.match(mermaid, /subgraph entity_group/);
assert.match(mermaid, /subgraph rule_group/);
});
99 changes: 98 additions & 1 deletion packages/allium-cli/src/diagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,25 @@ function buildDiagramResult(text: string): DiagramBuildResult {
edges.push({ from: from.id, to: to.id, label });
};

const resolveDeclaredTypeNode = (rawTypeName: string): DiagramNode | null => {
const typeName = rawTypeName.includes("/")
? rawTypeName.split("/")[1]
: rawTypeName;
const candidateKinds: DiagramNodeKind[] = [
"entity",
"value",
"variant",
"enum",
];
for (const kind of candidateKinds) {
const node = nodeByKey.get(`${kind}:${typeName}`);
if (node) {
return node;
}
}
return null;
};

const topLevelPattern =
/^\s*(external\s+entity|entity|value|variant|rule|surface|actor|enum)\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*:\s*([A-Za-z_][A-Za-z0-9_]*))?\s*\{/gm;
for (
Expand Down Expand Up @@ -499,6 +518,44 @@ function buildDiagramResult(text: string): DiagramBuildResult {
const target = ensureNode("entity", targetType, "entity");
addEdge(source, target, "rel");
}
for (const typeRef of parseFieldTypeReferences(body)) {
const target = resolveDeclaredTypeNode(typeRef);
if (target) {
addEdge(source, target, "type");
}
}
}

const valueBlockPattern =
/^\s*value\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)^\s*\}/gm;
for (
let value = valueBlockPattern.exec(text);
value;
value = valueBlockPattern.exec(text)
) {
const source = ensureNode("value", value[1], "value");
for (const typeRef of parseFieldTypeReferences(value[2])) {
const target = resolveDeclaredTypeNode(typeRef);
if (target) {
addEdge(source, target, "type");
}
}
}

const variantBlockPattern =
/^\s*variant\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*[A-Za-z_][A-Za-z0-9_]*)?\s*\{([\s\S]*?)^\s*\}/gm;
for (
let variant = variantBlockPattern.exec(text);
variant;
variant = variantBlockPattern.exec(text)
) {
const source = ensureNode("variant", variant[1], "variant");
for (const typeRef of parseFieldTypeReferences(variant[2])) {
const target = resolveDeclaredTypeNode(typeRef);
if (target) {
addEdge(source, target, "type");
}
}
}

const rulePattern =
Expand Down Expand Up @@ -652,6 +709,7 @@ function renderDiagram(model: DiagramModel, format: DiagramFormat): string {
function renderD2(model: DiagramModel): string {
const lines: string[] = ["direction: right", ""];
const nodesByKind = groupNodesByKind(model.nodes);
const nodeKindById = new Map(model.nodes.map((node) => [node.id, node.kind]));
for (const [kind, nodes] of nodesByKind) {
lines.push(`${kind}_group: {`);
lines.push(` label: "${escapeD2(kindLabel(kind))}"`);
Expand All @@ -667,7 +725,11 @@ function renderD2(model: DiagramModel): string {
}

for (const edge of model.edges) {
lines.push(`${edge.from} -> ${edge.to}: "${escapeD2(edge.label)}"`);
const fromKind = nodeKindById.get(edge.from);
const toKind = nodeKindById.get(edge.to);
const fromRef = fromKind ? `${fromKind}_group.${edge.from}` : edge.from;
const toRef = toKind ? `${toKind}_group.${edge.to}` : edge.to;
lines.push(`${fromRef} -> ${toRef}: "${escapeD2(edge.label)}"`);
}
return `${lines.join("\n").replace(/\n+$/g, "")}\n`;
}
Expand Down Expand Up @@ -977,5 +1039,40 @@ function parseSurfaceProvidesCalls(body: string): string[] {
return calls;
}

function parseFieldTypeReferences(body: string): string[] {
const refs: string[] = [];
const seen = new Set<string>();
const fieldPattern = /^\s*[A-Za-z_][A-Za-z0-9_]*\s*:\s*(.+)$/gm;
for (
let field = fieldPattern.exec(body);
field;
field = fieldPattern.exec(body)
) {
const annotation = field[1].trim();
if (/\bfor\s+this\b/.test(annotation)) {
continue;
}
const tokenPattern = /[A-Za-z_][A-Za-z0-9_]*(?:\/[A-Za-z_][A-Za-z0-9_]*)?/g;
for (
let token = tokenPattern.exec(annotation);
token;
token = tokenPattern.exec(annotation)
) {
const candidate = token[0];
const localName = candidate.includes("/")
? candidate.split("/")[1]
: candidate;
if (!/^[A-Z]/.test(localName)) {
continue;
}
if (!seen.has(candidate)) {
seen.add(candidate);
refs.push(candidate);
}
}
}
return refs;
}

const exitCode = main(process.argv.slice(2));
process.exitCode = exitCode;