Merge branch 'feature/flow-diagram-screen-communication' into develop

This commit is contained in:
nejimakibird 2026-07-06 06:57:09 +09:00
commit a34a6254d1
27 changed files with 2064 additions and 259 deletions

View file

@ -0,0 +1,92 @@
# FORMAT-flow_diagram
Japanese Version: [日本語版](../ja/formats/FORMAT-flow_diagram.md)
`flow_diagram` defines user-facing communication and data handoff flows between screens, contexts, processes, stores, and external systems.
It is rendered as a Mermaid flowchart. Internally, the MVP uses a DFD-like Objects / Flows table structure, but the format is distinct from `dfd_diagram` and should not be presented as a classic DFD.
## Minimal Example
```markdown
---
type: flow_diagram
id: FLOW-ORDER-SCREEN-COMMUNICATION
name: Order Screen Communication Flow
kind: screen_communication
---
## Objects
| id | label | kind | ref | domain | notes |
|---|---|---|---|---|---|
| order_screen | Order Screen | screen | [[SCR-ORDER]] | order | Source screen |
| order_process | Order Process | app_process | [[PROC-ORDER]] | order | Application process |
| session_store | Session Store | session | | order | Temporary state |
## Flows
| id | from | to | data | notes |
|---|---|---|---|---|
| FLOW-001 | order_screen | order_process | [[DATA-ORDER-REQUEST]] | Submit order |
| FLOW-002 | order_process | session_store | Order result | Store result |
```
## Frontmatter
| key | required | value |
|---|---|---|
| `type` | yes | `flow_diagram` |
| `id` | yes | Unique model id |
| `name` | yes | Human-readable name |
| `kind` | yes | `screen_communication` for the MVP |
## Objects
Expected header:
```markdown
| id | label | kind | ref | domain | notes |
```
`Objects.id` is the local node id used by `Flows.from` and `Flows.to`.
Supported MVP object kinds:
| kind | Mermaid shape | class |
|---|---|---|
| `screen` | `curv-trap` | `screen` |
| `process` | `rect` | `process` |
| `app_process` | `rect` | `process` |
| `context` | `rect` | `context` |
| `work_object` | `rect` | `context` |
| `session` | `lin-cyl` | `store` |
| `store` | `lin-cyl` | `store` |
| `datastore` | `lin-cyl` | `store` |
| `external` | `rect` | `external` |
| unknown values | `rect` | fallback |
`Objects.ref` may point to `screen`, `app_process`, `data_object`, `dfd_object`, or another model asset. Unknown object kinds should not block rendering.
## Flows
Expected header:
```markdown
| id | from | to | data | notes |
```
`Flows.from` and `Flows.to` refer to local `Objects.id` values. `Flows.data` is rendered as the Mermaid edge label. `data_object` references in `Flows.data` are allowed and do not become graph nodes by default.
## Rendering
The MVP uses Internal Detail View only and renders the raw Objects / Flows graph without projection, folding, or view selectors.
## AI Generation Notes
- Use `type: flow_diagram`.
- Use `kind: screen_communication`.
- Keep table headers exactly as documented.
- Use local `Objects.id` values in `Flows.from` and `Flows.to`.
- Put the handoff payload or data reference in `Flows.data`.
- Do not add generated state matrices, folding rules, or automatic screen/process derivations.

View file

@ -24,6 +24,7 @@ Stable / primary formats are the recommended starting points for most users.
Experimental / evolving formats are usable, but their documentation and modeling conventions may continue to change.
* [flow_diagram](FORMAT-flow_diagram.md)
* [screen](FORMAT-screen.md)
* [app_process](FORMAT-app_process.md)
* [rule](FORMAT-rule.md)
@ -42,7 +43,8 @@ You do not need to learn every format first. Start from the format that matches
2. [er_entity](FORMAT-er_entity.md) / [er_diagram](FORMAT-er_diagram.md)
3. [data_object](FORMAT-data_object.md)
4. [dfd_object](FORMAT-dfd_object.md) / [dfd_diagram](FORMAT-dfd_diagram.md)
5. [app_process](FORMAT-app_process.md)
5. [flow_diagram](FORMAT-flow_diagram.md)
6. [app_process](FORMAT-app_process.md)
6. Experimental formats as needed
## How FORMAT docs are organized

View file

@ -0,0 +1,92 @@
# FORMAT-flow_diagram
English Version: [English](../../formats/FORMAT-flow_diagram.md)
`flow_diagram` は、画面、コンテキスト、プロセス、ストア、外部システムの間の communication / data handoff flow を表す user-facing diagram format です。
MVPでは DFD-like な Objects / Flows テーブル構造を内部的に使います。ただし `dfd_diagram` とは別のフォーマットであり、利用者向けには classic DFD として扱いません。
## 最小例
```markdown
---
type: flow_diagram
id: FLOW-ORDER-SCREEN-COMMUNICATION
name: Order Screen Communication Flow
kind: screen_communication
---
## Objects
| id | label | kind | ref | domain | notes |
|---|---|---|---|---|---|
| order_screen | Order Screen | screen | [[SCR-ORDER]] | order | Source screen |
| order_process | Order Process | app_process | [[PROC-ORDER]] | order | Application process |
| session_store | Session Store | session | | order | Temporary state |
## Flows
| id | from | to | data | notes |
|---|---|---|---|---|
| FLOW-001 | order_screen | order_process | [[DATA-ORDER-REQUEST]] | Submit order |
| FLOW-002 | order_process | session_store | Order result | Store result |
```
## Frontmatter
| key | required | value |
|---|---|---|
| `type` | yes | `flow_diagram` |
| `id` | yes | 一意な model id |
| `name` | yes | 表示名 |
| `kind` | yes | MVPでは `screen_communication` |
## Objects
期待されるヘッダー:
```markdown
| id | label | kind | ref | domain | notes |
```
`Objects.id` はローカルードIDです。`Flows.from` と `Flows.to` はこの値を参照します。
MVPで対応する object kind:
| kind | Mermaid shape | class |
|---|---|---|
| `screen` | `curv-trap` | `screen` |
| `process` | `rect` | `process` |
| `app_process` | `rect` | `process` |
| `context` | `rect` | `context` |
| `work_object` | `rect` | `context` |
| `session` | `lin-cyl` | `store` |
| `store` | `lin-cyl` | `store` |
| `datastore` | `lin-cyl` | `store` |
| `external` | `rect` | `external` |
| unknown values | `rect` | fallback |
`Objects.ref``screen`, `app_process`, `data_object`, `dfd_object` または他の model asset を参照できます。未知の object kind はレンダリングを止めません。
## Flows
期待されるヘッダー:
```markdown
| id | from | to | data | notes |
```
`Flows.from``Flows.to` はローカルの `Objects.id` を参照します。`Flows.data` は Mermaid edge label として表示されます。`Flows.data` 内の `data_object` 参照は許容され、既定では graph node にはなりません。
## Rendering
MVPでは Internal Detail View のみを使います。Objects / Flows の raw graph を表示し、projection、folding、view selector は実装しません。
## AI生成時の注意
- `type: flow_diagram` を使う。
- `kind: screen_communication` を使う。
- テーブルヘッダーを仕様通りに保つ。
- `Flows.from``Flows.to` にはローカル `Objects.id` を使う。
- handoff payload や data reference は `Flows.data` に書く。
- state matrix、folding rule、自動生成された screen/process derivation は追加しない。

View file

@ -26,6 +26,7 @@ Markdownファイルを正本として扱い、図、プレビュー、診断、
実験中 / 発展中フォーマットは利用可能ですが、ドキュメントやモデリング上の慣習は今後変わる可能性があります。
- [flow_diagram](FORMAT-flow_diagram.md)
- [screen](FORMAT-screen.md)
- [app_process](FORMAT-app_process.md)
- [rule](FORMAT-rule.md)
@ -45,7 +46,8 @@ Markdownファイルを正本として扱い、図、プレビュー、診断、
2. [er_entity](FORMAT-er_entity.md) / [er_diagram](FORMAT-er_diagram.md)
3. [data_object](FORMAT-data_object.md)
4. [dfd_object](FORMAT-dfd_object.md) / [dfd_diagram](FORMAT-dfd_diagram.md)
5. [app_process](FORMAT-app_process.md)
5. [flow_diagram](FORMAT-flow_diagram.md)
6. [app_process](FORMAT-app_process.md)
6. 必要に応じて実験中フォーマット
## FORMATドキュメントの構成

693
main.js

File diff suppressed because it is too large Load diff

View file

@ -58,6 +58,11 @@ const SECTION_HEADERS: Partial<Record<FileType, Record<string, string>>> = {
"dfd-object": {
"source links": SOURCE_LINKS_HEADER
},
"flow-diagram": {
objects: "id | label | kind | ref | domain | notes",
flows: "id | from | to | data | notes",
"source links": SOURCE_LINKS_HEADER
},
"domain-diagram": {
"domain sources": DOMAIN_SOURCES_HEADER,
"source links": SOURCE_LINKS_HEADER
@ -121,6 +126,8 @@ const FILE_TYPE_ALIASES: Record<string, FileType> = {
dataobject: "data-object",
dfd_diagram: "dfd-diagram",
dfddiagram: "dfd-diagram",
flow_diagram: "flow-diagram",
flowdiagram: "flow-diagram",
dfd_object: "dfd-object",
dfdobject: "dfd-object",
domain_diagram: "domain-diagram",
@ -151,6 +158,9 @@ export function attachDiagnosticModelContext(
export function resolveDiagnosticSectionGuidance(
diagnostic: ValidationWarning
): DiagnosticSectionGuidance | null {
if (!isTableHeaderDiagnostic(diagnostic)) {
return null;
}
const fileType = normalizeFileType(getDiagnosticStringValue(diagnostic.context?.fileType));
const section = getDiagnosticSectionName(diagnostic);
return resolveSectionGuidance(fileType, section);
@ -227,6 +237,14 @@ function getSectionFromField(field: string | undefined): string | null {
return section || null;
}
function isTableHeaderDiagnostic(diagnostic: ValidationWarning): boolean {
return diagnostic.code === "invalid-table-column" ||
/table columns in section/i.test(diagnostic.message) ||
/table should use:/i.test(diagnostic.message) ||
/do not match expected .*headers/i.test(diagnostic.message) ||
/do not match supported .*headers/i.test(diagnostic.message);
}
function normalizeSectionName(sectionName: string | null | undefined): string | null {
const value = sectionName?.trim().toLowerCase();
return value ? value.replace(/\s+/g, " ") : null;
@ -283,6 +301,7 @@ function isKnownFileType(value: string): value is FileType {
"domain-diagram",
"dfd-object",
"dfd-diagram",
"flow-diagram",
"er-entity",
"markdown"
].includes(value);

View file

@ -137,7 +137,7 @@ export function getImpactRelationshipCategoryKey(
if (type === "mapping") {
return "mappings";
}
if (["dfd-diagram", "er-diagram", "class-diagram", "domain-diagram", "diagram"].includes(type)) {
if (["dfd-diagram", "flow-diagram", "er-diagram", "class-diagram", "domain-diagram", "diagram"].includes(type)) {
return "diagrams";
}
if (type === "class" || type === "object") {
@ -222,18 +222,21 @@ function collectModelReferences(model: ParsedFileModel): CollectedReference[] {
}
break;
case "dfd-diagram":
case "flow-diagram": {
const relationPrefix = model.fileType === "flow-diagram" ? "flow diagram" : "dfd";
for (const ref of model.objectRefs) {
add(ref, "dfd object", "Objects", "objectRefs");
add(ref, `${relationPrefix} object`, "Objects", "objectRefs");
}
for (const object of model.objectEntries) {
add(object.ref, "dfd object", "Objects", "ref", object.notes);
add(object.ref, `${relationPrefix} object`, "Objects", "ref", object.notes);
}
for (const flow of model.flows) {
add(flow.from, "dfd flow", "Flows", "from", flow.notes);
add(flow.to, "dfd flow", "Flows", "to", flow.notes);
add(flow.data, "dfd data", "Flows", "data", flow.notes);
add(flow.from, `${relationPrefix} flow`, "Flows", "from", flow.notes);
add(flow.to, `${relationPrefix} flow`, "Flows", "to", flow.notes);
add(flow.data, `${relationPrefix} data`, "Flows", "data", flow.notes);
}
break;
}
case "domains":
break;
case "data-object":
@ -861,6 +864,7 @@ function getModelId(model: ParsedFileModel): string | undefined {
case "er-entity":
case "dfd-object":
case "dfd-diagram":
case "flow-diagram":
case "data-object":
case "app-process":
case "screen":

View file

@ -0,0 +1,22 @@
import type { FileType, ParsedFileModel } from "../types/models";
export function isDfdLikeDiagramPreviewFileType(
fileType: FileType
): fileType is "dfd-diagram" | "flow-diagram" {
return fileType === "dfd-diagram" || fileType === "flow-diagram";
}
export function isDiagramPreviewRouteFileType(fileType: FileType): boolean {
return fileType === "diagram" || isDfdLikeDiagramPreviewFileType(fileType);
}
export type DfdLikeDiagramPreviewModel = Extract<
ParsedFileModel,
{ fileType: "dfd-diagram" | "flow-diagram" }
>;
export function isDfdLikeDiagramPreviewModel(
model: ParsedFileModel
): model is DfdLikeDiagramPreviewModel {
return isDfdLikeDiagramPreviewFileType(model.fileType);
}

View file

@ -482,6 +482,7 @@ export function getReferencedModelDisplayName(model: ParsedFileModel): string {
case "color-scheme":
case "dfd-object":
case "dfd-diagram":
case "flow-diagram":
case "domains":
case "domain-diagram":
case "object":
@ -515,6 +516,7 @@ function getResolvedModelId(model: ParsedFileModel | null): string | undefined {
case "er-entity":
case "dfd-object":
case "dfd-diagram":
case "flow-diagram":
case "data-object":
case "app-process":
case "screen":
@ -560,7 +562,7 @@ function isLikelySingleModelReference(value: string): boolean {
const target = parsed?.target ?? trimmed;
const basename = getBasename(target);
return /^(?:APP|CLASS|CLD|CLS|CODE|CODESET|CS|DATA|DFD|DFDO|DOMAIN|DOMAINS|ENT|ERD|MAP|MSG|PROC|REL|RULE|SCR|SRC)[-_][A-Z0-9][A-Z0-9_-]*$/.test(
return /^(?:APP|CLASS|CLD|CLS|CODE|CODESET|CS|DATA|DFD|DFDO|FLOW|DOMAIN|DOMAINS|ENT|ERD|MAP|MSG|PROC|REL|RULE|SCR|SRC)[-_][A-Z0-9][A-Z0-9_-]*$/.test(
basename
);
}

View file

@ -5,8 +5,8 @@ import type {
DiagramNode,
DfdDiagramModel,
DfdDiagramObjectEntry,
DfdDiagramObjectKind,
DfdObjectModel,
FlowDiagramModel,
DomainDiagramSourceSummary,
DomainEntry,
DomainsModel,
@ -46,19 +46,19 @@ interface ResolvedDfdDiagramObject {
entry: DfdDiagramObjectEntry;
node: DiagramNode & { object?: DfdObjectModel };
object?: DfdObjectModel;
kind: DfdDiagramObjectKind;
kind: string;
}
export function resolveDiagramRelations(
diagram: DiagramModel | DfdDiagramModel,
diagram: DiagramModel | DfdDiagramModel | FlowDiagramModel,
index: ModelingVaultIndex
): ResolvedDiagram {
if (diagram.kind === "er") {
return resolveErDiagramRelations(diagram, index);
}
if (diagram.kind === "dfd") {
return resolveDfdDiagramRelations(diagram as DfdDiagramModel, index);
if (diagram.kind === "dfd" || diagram.schema === "flow_diagram") {
return resolveDfdDiagramRelations(diagram as DfdDiagramModel | FlowDiagramModel, index);
}
const warnings: ValidationWarning[] = [];
@ -122,20 +122,26 @@ function resolveErDiagramRelations(
}
function resolveDfdDiagramRelations(
diagram: DfdDiagramModel,
diagram: DfdDiagramModel | FlowDiagramModel,
index: ModelingVaultIndex
): ResolvedDiagram {
const warnings: ValidationWarning[] = [];
const domainResolution = resolveDfdDiagramDomains(diagram, index);
const isFlowDiagram = diagram.schema === "flow_diagram";
const domainResolution = isFlowDiagram
? { warnings: [], sourceSummaries: [], domains: [] }
: resolveDfdDiagramDomains(diagram, index);
warnings.push(...domainResolution.warnings);
const resolvedDiagram: DfdDiagramModel = {
...diagram,
domainSourceSummaries: domainResolution.sourceSummaries,
domains: domainResolution.domains
};
const resolvedDiagram: DfdDiagramModel | FlowDiagramModel = isFlowDiagram
? diagram
: {
...diagram,
domainSourceSummaries: domainResolution.sourceSummaries,
domains: domainResolution.domains
};
const objectResolution = resolveDfdDiagramObjects(resolvedDiagram, index, {
hasDomainSources: diagram.domainSources.length > 0
hasDomainSources: diagram.schema === "dfd_diagram" && diagram.domainSources.length > 0
});
const hasUnreadableFlowObjects = isFlowDiagram && hasInvalidDfdLikeSectionHeader(diagram.path, index, "Objects");
const edges: DiagramEdge[] = [];
diagram.flows.forEach((flow, rowIndex) => {
@ -144,11 +150,18 @@ function resolveDfdDiagramRelations(
rowIndex: rowIndex + 1,
relatedId: flow.id
};
const sourceEntry = resolveDfdFlowEndpoint(flow.from, objectResolution, index);
const targetEntry = resolveDfdFlowEndpoint(flow.to, objectResolution, index);
const sourceEntry = isFlowDiagram
? objectResolution.byId.get(flow.from.trim()) ?? null
: resolveDfdFlowEndpoint(flow.from, objectResolution, index);
const targetEntry = isFlowDiagram
? objectResolution.byId.get(flow.to.trim()) ?? null
: resolveDfdFlowEndpoint(flow.to, objectResolution, index);
if (!sourceEntry) {
const listedObject = resolveDfdObjectReference(flow.from, index);
if (hasUnreadableFlowObjects) {
return;
}
const listedObject = isFlowDiagram ? null : resolveDfdObjectReference(flow.from, index);
if (listedObject) {
warnings.push({
code: "unresolved-reference",
@ -161,17 +174,22 @@ function resolveDfdDiagramRelations(
}
warnings.push({
code: "unresolved-reference",
message: `unresolved DFD flow source "${flow.from}"`,
message: isFlowDiagram
? "Flow Diagram flow source \"" + flow.from + "\" is not defined in the local ## Objects table."
: "unresolved DFD flow source \"" + flow.from + "\"",
severity: "error",
path: diagram.path,
field: "Flows",
context
field: isFlowDiagram ? "Flows.from" : "Flows",
context: isFlowDiagram ? { ...context, referenceKind: "local-object-id" } : context
});
return;
}
if (!targetEntry) {
const listedObject = resolveDfdObjectReference(flow.to, index);
if (hasUnreadableFlowObjects) {
return;
}
const listedObject = isFlowDiagram ? null : resolveDfdObjectReference(flow.to, index);
if (listedObject) {
warnings.push({
code: "unresolved-reference",
@ -184,11 +202,13 @@ function resolveDfdDiagramRelations(
}
warnings.push({
code: "unresolved-reference",
message: `unresolved DFD flow target "${flow.to}"`,
message: isFlowDiagram
? "Flow Diagram flow target \"" + flow.to + "\" is not defined in the local ## Objects table."
: "unresolved DFD flow target \"" + flow.to + "\"",
severity: "error",
path: diagram.path,
field: "Flows",
context
field: isFlowDiagram ? "Flows.to" : "Flows",
context: isFlowDiagram ? { ...context, referenceKind: "local-object-id" } : context
});
return;
}
@ -196,7 +216,7 @@ function resolveDfdDiagramRelations(
if (sourceEntry.node.id === targetEntry.node.id) {
warnings.push({
code: "invalid-structure",
message: `DFD flow "${flow.id ?? rowIndex + 1}" is a self-loop`,
message: `${isFlowDiagram ? "Flow Diagram" : "DFD"} flow "${flow.id ?? rowIndex + 1}" is a self-loop`,
severity: "warning",
path: diagram.path,
field: "Flows",
@ -204,15 +224,17 @@ function resolveDfdDiagramRelations(
});
}
if (sourceEntry.kind === "external" && targetEntry.kind === "external") {
warnings.push(createDfdFlowShapeWarning(diagram.path, context, "external -> external"));
} else if (sourceEntry.kind === "external" && targetEntry.kind === "datastore") {
warnings.push(createDfdFlowShapeWarning(diagram.path, context, "external -> datastore"));
} else if (sourceEntry.kind === "datastore" && targetEntry.kind === "datastore") {
warnings.push(createDfdFlowShapeWarning(diagram.path, context, "datastore -> datastore"));
if (!isFlowDiagram) {
if (sourceEntry.kind === "external" && targetEntry.kind === "external") {
warnings.push(createDfdFlowShapeWarning(diagram.path, context, "external -> external"));
} else if (sourceEntry.kind === "external" && targetEntry.kind === "datastore") {
warnings.push(createDfdFlowShapeWarning(diagram.path, context, "external -> datastore"));
} else if (sourceEntry.kind === "datastore" && targetEntry.kind === "datastore") {
warnings.push(createDfdFlowShapeWarning(diagram.path, context, "datastore -> datastore"));
}
}
const flowData = resolveDfdFlowDataDisplay(flow.data, index);
const flowData = resolveDfdFlowDataDisplay(flow.data, index, { suppressUnresolvedWarning: isFlowDiagram });
if (flowData.warning) {
warnings.push({
code: "unresolved-reference",
@ -251,6 +273,24 @@ function resolveDfdDiagramRelations(
};
}
function hasInvalidDfdLikeSectionHeader(
path: string,
index: ModelingVaultIndex,
section: string
): boolean {
return (index.warningsByFilePath[path] ?? []).some((warning) =>
warning.code === "invalid-table-column" &&
getDiagnosticSectionName(warning) === section.toLowerCase()
);
}
function getDiagnosticSectionName(warning: ValidationWarning): string | null {
const contextSection = typeof warning.context?.section === "string" ? warning.context.section : "";
const section = contextSection || warning.message.match(/section "([^"]+)"/i)?.[1] || warning.section || warning.field || "";
const normalized = section.split(".")[0]?.split(":")[0]?.trim().toLowerCase();
return normalized || null;
}
function resolveDfdDiagramDomains(
diagram: DfdDiagramModel,
index: ModelingVaultIndex
@ -384,7 +424,7 @@ function createDfdDomainSourceWarning(
}
function resolveDfdDiagramObjects(
diagram: DfdDiagramModel,
diagram: DfdDiagramModel | FlowDiagramModel,
index: ModelingVaultIndex,
domainContext: {
hasDomainSources: boolean;
@ -409,26 +449,30 @@ function resolveDfdDiagramObjects(
rowIndex,
compatibilityMode: "legacy_ref_only" as const
}));
const localDomainIds = new Set((diagram.domains ?? []).map((domain) => domain.id));
const localDomainIds = new Set((diagram.schema === "dfd_diagram" ? diagram.domains ?? [] : []).map((domain) => domain.id));
for (const entry of entries) {
const ref = entry.ref?.trim();
const resolvedObject = ref ? resolveDfdObjectReference(ref, index) ?? undefined : undefined;
const resolvedIdentity = ref ? resolveReferenceIdentity(ref, index) : undefined;
if (!ref) {
warnings.push({
code: "invalid-structure",
message: `DFD local object "${entry.id ?? entry.label ?? entry.rowIndex + 1}" is treated as an inline object without ref.`,
severity: "info",
path: diagram.path,
field: "Objects",
context: { rowIndex: entry.rowIndex + 1 }
});
if (diagram.schema === "dfd_diagram") {
warnings.push({
code: "invalid-structure",
message: `DFD local object "${entry.id ?? entry.label ?? entry.rowIndex + 1}" is treated as an inline object without ref.`,
severity: "info",
path: diagram.path,
field: "Objects",
context: { rowIndex: entry.rowIndex + 1 }
});
}
} else if (!resolvedObject && !resolvedIdentity?.resolvedModel) {
missingObjects.push(ref);
warnings.push({
code: "unresolved-reference",
message: `unresolved DFD object ref "${ref}"`,
message: diagram.schema === "flow_diagram"
? `unresolved Flow Diagram object ref "${ref}"`
: `unresolved DFD object ref "${ref}"`,
severity: "warning",
path: diagram.path,
field: "Objects",
@ -436,8 +480,8 @@ function resolveDfdDiagramObjects(
});
}
const effectiveKind = entry.kind ?? resolvedObject?.kind ?? "other";
if (!entry.kind && !resolvedObject?.kind) {
const effectiveKind = entry.kind ?? resolvedObject?.kind ?? (diagram.schema === "flow_diagram" ? "unknown" : "other");
if (diagram.schema === "dfd_diagram" && !entry.kind && !resolvedObject?.kind) {
warnings.push({
code: "invalid-structure",
message: `DFD object "${entry.id ?? ref ?? entry.rowIndex + 1}" has no kind, and it could not be inferred from ref.`,
@ -451,7 +495,7 @@ function resolveDfdDiagramObjects(
const resolvedLabel = getDfdDiagramNodeDisplayName(entry, resolvedObject);
const nodeId = entry.id?.trim() || resolvedObject?.id || ref || `dfd-object-${entry.rowIndex + 1}`;
const domain = entry.domain?.trim();
if (domain && localDomainIds.size === 0 && !domainContext.hasDomainSources) {
if (diagram.schema === "dfd_diagram" && domain && localDomainIds.size === 0 && !domainContext.hasDomainSources) {
warnings.push({
code: "unresolved-reference",
message: formatDfdObjectDomainWithoutLocalDomainsMessage(
@ -463,7 +507,7 @@ function resolveDfdDiagramObjects(
field: "Objects.domain",
context: { rowIndex: entry.rowIndex + 1 }
});
} else if (domain && !localDomainIds.has(domain)) {
} else if (diagram.schema === "dfd_diagram" && domain && !localDomainIds.has(domain)) {
warnings.push({
code: "unresolved-reference",
message: domainContext.hasDomainSources
@ -557,7 +601,8 @@ function resolveDfdFlowEndpoint(
function resolveDfdFlowDataDisplay(
rawValue: string | undefined,
index: ModelingVaultIndex
index: ModelingVaultIndex,
options: { suppressUnresolvedWarning?: boolean } = {}
): {
label?: string;
reference?: ReturnType<typeof parseReferenceValue>;
@ -609,7 +654,7 @@ function resolveDfdFlowDataDisplay(
return {
label: getReferenceDisplayName(trimmed),
reference,
warning: `unresolved flow data reference "${trimmed}"`
warning: options.suppressUnresolvedWarning ? undefined : `unresolved flow data reference "${trimmed}"`
};
}
@ -617,7 +662,7 @@ function resolveDfdFlowDataDisplay(
}
function dedupeDiagramNodes<TObject extends ObjectModel | ErEntity | DfdObjectModel>(
diagram: DiagramModel | DfdDiagramModel,
diagram: DiagramModel | DfdDiagramModel | FlowDiagramModel,
resolveObject: (objectRef: string) => TObject | undefined,
buildResolvedId: (object: TObject | undefined, objectRef: string) => string,
buildCanonicalKey: (object: TObject | undefined, objectRef: string) => string,

View file

@ -137,6 +137,7 @@ export function getFormatDefaultRenderMode(
): EffectiveRenderMode {
switch (formatType) {
case "dfd-diagram":
case "flow-diagram":
return "mermaid";
case "domains":
case "domain-diagram":
@ -173,6 +174,7 @@ function getForcedRenderModes(
case "er-entity":
return ["custom", "mermaid", "mermaid-detail"];
case "dfd-diagram":
case "flow-diagram":
return ["mermaid"];
case "dfd-object":
return [];
@ -328,6 +330,7 @@ function getRendererImplementation(
if (
(mode === "mermaid" || mode === "mermaid-detail") &&
(formatType === "dfd-diagram" ||
formatType === "flow-diagram" ||
formatType === "object" ||
formatType === "er-entity" ||
(formatType === "diagram" && (modelKind === "class" || modelKind === "er")))

View file

@ -19,6 +19,8 @@ const TYPE_TO_FILE_TYPE: Record<string, FileType> = {
domain_diagram: "domain-diagram",
dfd_object: "dfd-object",
dfd_diagram: "dfd-diagram",
flow_diagram: "flow-diagram",
"flow-diagram": "flow-diagram",
er_entity: "er-entity",
er_diagram: "diagram",
class_diagram: "diagram"

View file

@ -7,6 +7,7 @@ export const SUPPORTED_MODEL_WEAVE_FORMATS = [
"er_diagram",
"dfd_object",
"dfd_diagram",
"flow_diagram",
"data_object",
"app_process",
"screen",
@ -28,6 +29,7 @@ const SUPPORTED_MODEL_WEAVE_FILE_TYPES = new Set<FileType>([
"diagram",
"dfd-object",
"dfd-diagram",
"flow-diagram",
"data-object",
"app-process",
"screen",

View file

@ -4,6 +4,7 @@ import type {
DomainEntry,
DfdDiagramModel,
DfdDiagramObjectEntry,
FlowDiagramModel,
ObjectKind,
ObjectModel,
RelationKind,
@ -214,7 +215,7 @@ function compareStandaloneDomainFields(
}
function validateDiagram(
diagram: DiagramModel | DfdDiagramModel,
diagram: DiagramModel | DfdDiagramModel | FlowDiagramModel,
index: ModelingVaultIndex,
warnings: ValidationWarning[]
): void {

View file

@ -9,6 +9,7 @@ import type {
FileType,
DfdDiagramModel,
DfdObjectModel,
FlowDiagramModel,
ErEntity,
GenericFrontmatter,
MessageModel,
@ -30,7 +31,7 @@ import { extractMarkdownSections } from "../parsers/markdown-sections";
import { parseObjectFile } from "../parsers/object-parser";
import { parseRelationsFile } from "../parsers/relations-parser";
import { parseDiagramFile } from "../parsers/diagram-parser";
import { parseDfdDiagramFile } from "../parsers/dfd-diagram-parser";
import { parseDfdDiagramFile, parseFlowDiagramFile } from "../parsers/dfd-diagram-parser";
import { parseDfdObjectFile } from "../parsers/dfd-object-parser";
import { parseDataObjectFile } from "../parsers/data-object-parser";
import { parseDomainsFile } from "../parsers/domains-parser";
@ -69,7 +70,7 @@ export interface ModelingVaultIndex {
erEntitiesById: Record<string, ErEntity>;
erEntitiesByPhysicalName: Record<string, ErEntity>;
relationsFilesById: Record<string, RelationsFileModel>;
diagramsById: Record<string, DiagramModel | DfdDiagramModel>;
diagramsById: Record<string, DiagramModel | DfdDiagramModel | FlowDiagramModel>;
modelsByFilePath: Record<string, ParsedFileModel>;
relationsById: Record<string, RelationModel>;
relationsByObjectId: Record<string, RelationModel[]>;
@ -433,7 +434,8 @@ function indexSingleFile(
);
break;
}
case "dfd-diagram": {
case "dfd-diagram":
case "flow-diagram": {
addModelById(
index.diagramsById,
parseResult.file.id,
@ -680,6 +682,9 @@ function parseVaultFile(file: VaultFileInput, parseMode: VaultParseMode): {
if (frontmatter?.type === "domain_diagram") {
return parseDomainDiagramFile(content, file.path);
}
if (frontmatter?.type === "flow_diagram") {
return parseFlowDiagramFile(content, file.path);
}
const fileType = detectFileType(frontmatter);
switch (fileType) {
@ -711,6 +716,8 @@ function parseVaultFile(file: VaultFileInput, parseMode: VaultParseMode): {
return parseDiagramFile(content, file.path);
case "dfd-diagram":
return parseDfdDiagramFile(content, file.path);
case "flow-diagram":
return parseFlowDiagramFile(content, file.path);
case "er-entity":
return parseErEntityFile(content, file.path);
case "markdown":
@ -928,6 +935,20 @@ function createShallowModel(
edges: [],
flows: []
};
case "flow-diagram":
return {
...common,
fileType: "flow-diagram",
schema: "flow_diagram",
id,
name,
kind: "screen_communication",
objectRefs: [],
objectEntries: [],
nodes: [],
edges: [],
flows: []
};
case "er-entity":
return {
...common,
@ -1316,6 +1337,7 @@ function removeModelFromIndexes(
delete index.dataObjectsById[model.id];
break;
case "dfd-diagram":
case "flow-diagram":
delete index.diagramsById[model.id];
break;
case "er-entity":
@ -1406,6 +1428,7 @@ function getModelId(
| DataObjectModel
| DfdObjectModel
| DfdDiagramModel
| FlowDiagramModel
| ErEntity
): string {
if ("id" in model && typeof model.id === "string" && model.id.trim()) {

View file

@ -274,6 +274,9 @@ export function getWeaveMapLayerForModelType(modelType: string): WeaveMapLayer {
case "dfd-diagram":
case "dfd_diagram":
return "Data Flow";
case "flow-diagram":
case "flow_diagram":
return "Process";
case "relations":
return "Relationship";
case "source-link":

View file

@ -199,6 +199,9 @@ export const EN_MESSAGES: ModelWeaveMessageDictionary = {
"diagnostics.guidance.reference.what": "The reference could not be resolved from the indexed model files.",
"diagnostics.guidance.reference.cause": "The referenced model may not exist yet, the reference may contain a typo, or the file may be outside the indexed model set.",
"diagnostics.guidance.reference.fix": "Check spelling and use [[...]] completion when the target should exist; planned targets can stay as reminder warnings.",
"diagnostics.guidance.flowEndpoint.what": "The flow endpoint object ID is not defined in the local `Objects` table.",
"diagnostics.guidance.flowEndpoint.cause": "Common causes are a typo in `Flows.from` or `Flows.to`, a missing object row in `Objects`, or a malformed `Objects` table that prevented object IDs from being read.",
"diagnostics.guidance.flowEndpoint.fix": "Add the missing object row, or correct the from/to ID to match an existing `Objects.id`.",
"diagnostics.guidance.referenceSeparator.what": "This cell appears to contain multiple references separated by comma, japanese comma, or 'and'.",
"diagnostics.guidance.referenceSeparator.fix": "Use ' / ' between multiple model references in one cell.",
"diagnostics.guidance.referenceSeparator.example": "[[data-a]] / [[data-b]]", "objectContext.title": "Related objects",

View file

@ -191,6 +191,9 @@ export const JA_MESSAGES: ModelWeaveMessageDictionary = {
"diagnostics.guidance.reference.what": "参照先が indexed Model Weave files から解決できませんでした。",
"diagnostics.guidance.reference.cause": "参照先モデルがまだ作成されていない、id/link に typo がある、または indexed model set の外にファイルがある可能性があります。",
"diagnostics.guidance.reference.fix": "既に存在するはずの参照なら spelling を確認し、編集時は Obsidian の [[...]] 補完を使ってください。後で作る予定のモデルなら、この warning をリマインダーとして残せます。",
"diagnostics.guidance.flowEndpoint.what": "flow の endpoint object ID がローカルの Objects テーブルに定義されていません。",
"diagnostics.guidance.flowEndpoint.cause": "Flows.from / Flows.to の typo、Objects の object 行の不足、または Objects テーブルのヘッダー崩れで object ID を読み取れなかった可能性があります。",
"diagnostics.guidance.flowEndpoint.fix": "不足している object 行を追加するか、from/to ID を既存の Objects.id と一致するように修正してください。",
"diagnostics.guidance.referenceSeparator.what": "このセルには、カンマ、日本語の読点、and などで区切られた複数参照が含まれているようです。",
"diagnostics.guidance.referenceSeparator.fix": "Model Weave では、1つのセル内の複数モデル参照は ' / ' で区切ることを推奨します。",
"diagnostics.guidance.referenceSeparator.example": "[[DATA-A]] / [[DATA-B]]",

View file

@ -24,6 +24,7 @@ import {
} from "./core/impact-analyzer";
import { buildWeaveMapModel } from "./core/weave-map";
import { resolveDiagramRelations } from "./core/relation-resolver";
import { isDfdLikeDiagramPreviewModel } from "./core/preview-routing";
import {
parseQualifiedRef,
parseReferenceValue,
@ -355,6 +356,14 @@ export default class ModelWeavePlugin extends Plugin {
}
});
this.addCommand({
id: "insert-flow-diagram-template",
name: "Insert flow diagram template",
callback: async () => {
await this.insertTemplateIntoActiveFile("flowDiagram");
}
});
this.addCommand({
id: "insert-data-object-template",
name: "Insert data object template",
@ -1316,12 +1325,19 @@ export default class ModelWeavePlugin extends Plugin {
);
return;
}
case "dfd-diagram": {
if (model.fileType === "dfd-diagram") {
case "dfd-diagram":
case "flow-diagram": {
const dfdLikeDiagramModel = isDfdLikeDiagramPreviewModel(model)
? model
: null;
if (dfdLikeDiagramModel) {
await this.ensureFullParsedFiles((candidate) =>
candidate.fileType === "dfd-object" ||
candidate.fileType === "color-scheme" ||
candidate.fileType === "domains"
candidate.fileType === "domains" ||
candidate.fileType === "screen" ||
candidate.fileType === "app-process" ||
candidate.fileType === "data-object"
);
}
const colorSchemeResult = resolveDefaultColorScheme(
@ -1329,8 +1345,8 @@ export default class ModelWeavePlugin extends Plugin {
this.settings.defaultColorSchemeRef
);
const resolved =
model.fileType === "dfd-diagram" && this.index
? resolveDiagramRelations(model, this.index)
dfdLikeDiagramModel && this.index
? resolveDiagramRelations(dfdLikeDiagramModel, this.index)
: null;
const warnings = [
...(this.index.warningsByFilePath[file.path] ?? []),

View file

@ -11,11 +11,27 @@ import type {
DfdDiagramModel,
DfdDiagramObjectEntry,
DfdFlowModel,
FlowDiagramModel,
FlowDiagramObjectKind,
ValidationWarning
} from "../types/models";
const FLOW_HEADERS = ["id", "from", "to", "data", "notes"];
const OBJECT_HEADERS = ["id", "label", "kind", "ref", "domain", "notes"];
const DFD_OBJECT_HEADERS_WITHOUT_DOMAIN = ["id", "label", "kind", "ref", "notes"];
const LEGACY_OBJECT_HEADERS = ["ref", "notes"];
const FLOW_OBJECT_KINDS = new Set<string>([
"screen",
"process",
"app_process",
"context",
"work_object",
"session",
"store",
"datastore",
"external",
"unknown"
]);
export function parseDfdDiagramFile(
markdown: string,
@ -23,6 +39,51 @@ export function parseDfdDiagramFile(
): {
file: DfdDiagramModel | null;
warnings: ValidationWarning[];
} {
return parseDfdLikeDiagramFile(markdown, path, {
type: "dfd_diagram",
fileType: "dfd-diagram",
schema: "dfd_diagram",
defaultTitle: "Untitled DFD Diagram",
defaultKind: "dfd",
allowLegacyObjects: true,
requireObjectId: false
}) as { file: DfdDiagramModel | null; warnings: ValidationWarning[] };
}
export function parseFlowDiagramFile(
markdown: string,
path: string
): {
file: FlowDiagramModel | null;
warnings: ValidationWarning[];
} {
return parseDfdLikeDiagramFile(markdown, path, {
type: "flow_diagram",
fileType: "flow-diagram",
schema: "flow_diagram",
defaultTitle: "Untitled Flow Diagram",
defaultKind: "screen_communication",
allowLegacyObjects: false,
requireObjectId: true
}) as { file: FlowDiagramModel | null; warnings: ValidationWarning[] };
}
function parseDfdLikeDiagramFile(
markdown: string,
path: string,
options: {
type: "dfd_diagram" | "flow_diagram";
fileType: "dfd-diagram" | "flow-diagram";
schema: "dfd_diagram" | "flow_diagram";
defaultTitle: string;
defaultKind: "dfd" | "screen_communication";
allowLegacyObjects: boolean;
requireObjectId: boolean;
}
): {
file: DfdDiagramModel | FlowDiagramModel | null;
warnings: ValidationWarning[];
} {
const frontmatterResult = parseFrontmatter(markdown);
const frontmatter = frontmatterResult.file.frontmatter ?? {};
@ -38,9 +99,15 @@ export function parseDfdDiagramFile(
typeof frontmatter.level === "string" || typeof frontmatter.level === "number"
? String(frontmatter.level).trim()
: undefined;
const kind = typeof frontmatter.kind === "string" && frontmatter.kind.trim()
? frontmatter.kind.trim()
: options.defaultKind;
if (frontmatter.type !== "dfd_diagram") {
warnings.push(createWarning(path, "type", 'expected type "dfd_diagram"'));
const rawType = typeof frontmatter.type === "string" ? frontmatter.type.trim() : "";
const isAcceptedType = rawType === options.type ||
(options.type === "flow_diagram" && rawType === "flow-diagram");
if (!isAcceptedType) {
warnings.push(createWarning(path, "type", `expected type "${options.type}"`));
}
if (!id) {
warnings.push(createWarning(path, "id", 'required frontmatter "id" is missing'));
@ -48,11 +115,25 @@ export function parseDfdDiagramFile(
if (!name) {
warnings.push(createWarning(path, "name", 'required frontmatter "name" is missing'));
}
if (options.schema === "flow_diagram" && kind !== "screen_communication") {
warnings.push(createWarning(path, "kind", 'expected kind "screen_communication"'));
}
const objectsTable = parseDfdObjectsTable(sections.Objects, path);
const domainsTable = parseDomainEntries(sections.Domains, path);
const domainSourcesTable = parseDomainSourcesTable(sections["Domain Sources"], path);
const objectsTable = parseDfdObjectsTable(sections.Objects, path, {
schema: options.schema,
allowLegacyObjects: options.allowLegacyObjects,
requireObjectId: options.requireObjectId
});
const domainsTable = options.schema === "dfd_diagram"
? parseDomainEntries(sections.Domains, path)
: { rows: [], warnings: [] };
const domainSourcesTable = options.schema === "dfd_diagram"
? parseDomainSourcesTable(sections["Domain Sources"], path)
: { rows: [], warnings: [] };
const flowsTable = parseMarkdownTable(sections.Flows, FLOW_HEADERS, path, "Flows");
const hasInvalidFlowsHeader = flowsTable.warnings.some(
(warning) => warning.code === "invalid-table-column"
);
warnings.push(
...domainsTable.warnings,
...validateDomainEntries(path, domainsTable.rows, {
@ -63,7 +144,7 @@ export function parseDfdDiagramFile(
...flowsTable.warnings
);
const fallbackTitle = name || id || getFileStem(path) || "Untitled DFD Diagram";
const fallbackTitle = name || id || getFileStem(path) || options.defaultTitle;
const objectEntries = objectsTable.rows;
const objectRefs = objectEntries
@ -82,35 +163,92 @@ export function parseDfdDiagramFile(
const flows: DfdFlowModel[] = [];
const edges: DiagramEdge[] = [];
flowsTable.rows.forEach((row, rowIndex) => {
const from = row.from?.trim() ?? "";
const to = row.to?.trim() ?? "";
const data = row.data?.trim() ?? "";
const notes = row.notes?.trim() ?? "";
const flowId = row.id?.trim() ?? "";
if (!hasInvalidFlowsHeader) {
flowsTable.rows.forEach((row, rowIndex) => {
const from = row.from?.trim() ?? "";
const to = row.to?.trim() ?? "";
const data = row.data?.trim() ?? "";
const notes = row.notes?.trim() ?? "";
const flowId = row.id?.trim() ?? "";
flows.push({
id: flowId || undefined,
from,
to,
data: data || undefined,
dataRef: data ? parseReferenceValue(data) ?? undefined : undefined,
notes: notes || undefined,
rowIndex
});
if (!flowId) {
warnings.push({
code: "invalid-structure",
message: `${options.schema === "flow_diagram" ? "Flow Diagram" : "DFD"} Flows row must have "id".`,
severity: "error",
path,
field: "Flows",
context: { rowIndex: rowIndex + 1 }
});
}
if (!from) {
warnings.push({
code: "invalid-structure",
message: `${options.schema === "flow_diagram" ? "Flow Diagram" : "DFD"} Flows row must have "from".`,
severity: "error",
path,
field: "Flows",
context: { rowIndex: rowIndex + 1 }
});
}
if (!to) {
warnings.push({
code: "invalid-structure",
message: `${options.schema === "flow_diagram" ? "Flow Diagram" : "DFD"} Flows row must have "to".`,
severity: "error",
path,
field: "Flows",
context: { rowIndex: rowIndex + 1 }
});
}
edges.push({
id: flowId || undefined,
source: from,
target: to,
kind: "flow",
label: data || undefined,
metadata: {
flows.push({
id: flowId || undefined,
from,
to,
data: data || undefined,
dataRef: data ? parseReferenceValue(data) ?? undefined : undefined,
notes: notes || undefined,
rowIndex
}
});
edges.push({
id: flowId || undefined,
source: from,
target: to,
kind: "flow",
label: data || undefined,
metadata: {
notes: notes || undefined,
rowIndex
}
});
});
});
}
if (options.schema === "flow_diagram") {
return {
file: {
fileType: "flow-diagram",
schema: "flow_diagram",
path,
title: fallbackTitle,
frontmatter,
sections,
sourceLinks: parseSourceLinks(sections["Source Links"]),
id,
name: name || fallbackTitle,
kind: "screen_communication",
description: joinSectionLines(sections.Summary),
objectRefs,
objectEntries,
nodes,
edges,
flows
},
warnings
};
}
return {
file: {
@ -161,9 +299,28 @@ function createWarning(
};
}
function createTableWarning(
path: string,
field: string,
message: string
): ValidationWarning {
return {
code: "invalid-table-column",
message,
severity: "warning",
path,
field
};
}
function parseDfdObjectsTable(
lines: string[] | undefined,
path: string
path: string,
options: {
schema: "dfd_diagram" | "flow_diagram";
allowLegacyObjects: boolean;
requireObjectId: boolean;
}
): {
rows: DfdDiagramObjectEntry[];
warnings: ValidationWarning[];
@ -182,27 +339,33 @@ function parseDfdObjectsTable(
warnings:
normalizedLines.length === 0
? []
: [createWarning(path, "Objects", 'table in section "Objects" is incomplete')]
: [{
code: "invalid-table-row",
message: 'table in section "Objects" is incomplete',
severity: "warning",
path,
field: "Objects"
}]
};
}
const headers = splitMarkdownTableRow(normalizedLines[0]) ?? [];
const warnings: ValidationWarning[] = [];
const hasLegacyHeaders = sameHeaders(headers, LEGACY_OBJECT_HEADERS);
const hasLocalHeaders =
headers.includes("id") &&
headers.includes("label") &&
headers.includes("kind") &&
headers.includes("ref");
const hasLegacyHeaders = options.allowLegacyObjects && sameHeaders(headers, LEGACY_OBJECT_HEADERS);
const hasLocalHeaders = sameHeaders(headers, OBJECT_HEADERS) ||
(options.schema === "dfd_diagram" && sameHeaders(headers, DFD_OBJECT_HEADERS_WITHOUT_DOMAIN));
if (!hasLegacyHeaders && !hasLocalHeaders) {
warnings.push(
createWarning(
createTableWarning(
path,
"Objects",
'table columns in section "Objects" do not match supported DFD object headers'
options.schema === "flow_diagram"
? 'table columns in section "Objects" do not match expected headers'
: 'table columns in section "Objects" do not match supported DFD object headers'
)
);
return { rows: [], warnings };
}
if (hasLegacyHeaders) {
@ -224,13 +387,13 @@ function parseDfdObjectsTable(
return;
}
if (values.length !== headers.length) {
warnings.push(
createWarning(
path,
"Objects",
`table row in section "Objects" has ${values.length} columns, expected ${headers.length}`
)
);
warnings.push({
code: "invalid-table-row",
message: `table row in section "Objects" has ${values.length} columns, expected ${headers.length}`,
severity: "warning",
path,
field: "Objects"
});
return;
}
@ -246,10 +409,12 @@ function parseDfdObjectsTable(
const domain = row.domain?.trim() || "";
const notes = row.notes?.trim() || "";
if (!id && !ref) {
if (options.requireObjectId ? !id : !id && !ref) {
warnings.push({
code: "invalid-structure",
message: 'DFD Objects row must have "id" or "ref".',
message: options.schema === "flow_diagram"
? 'Flow Diagram Objects row must have "id".'
: 'DFD Objects row must have "id" or "ref".',
severity: "error",
path,
field: "Objects",
@ -262,7 +427,7 @@ function parseDfdObjectsTable(
if (seenIds.has(id)) {
warnings.push({
code: "invalid-structure",
message: `duplicate DFD Objects.id "${id}"`,
message: `duplicate ${options.schema === "flow_diagram" ? "Flow Diagram" : "DFD"} Objects.id "${id}"`,
severity: "error",
path,
field: "Objects",
@ -273,7 +438,7 @@ function parseDfdObjectsTable(
}
}
if (kind && !isSupportedDfdDiagramObjectKind(kind)) {
if (kind && options.schema === "dfd_diagram" && !isSupportedDfdDiagramObjectKind(kind)) {
warnings.push({
code: "invalid-structure",
message: `unknown DFD object kind "${kind}"`,
@ -287,7 +452,7 @@ function parseDfdObjectsTable(
rows.push({
id: id || undefined,
label: label || undefined,
kind: kind ? normalizeDfdDiagramObjectKind(kind) : undefined,
kind: kind ? normalizeDiagramObjectKind(kind, options.schema) : undefined,
ref: ref || undefined,
domain: domain || undefined,
notes: notes || undefined,
@ -299,6 +464,16 @@ function parseDfdObjectsTable(
return { rows, warnings };
}
function normalizeDiagramObjectKind(
value: string,
schema: "dfd_diagram" | "flow_diagram"
): DfdDiagramObjectEntry["kind"] {
if (schema === "flow_diagram") {
return (FLOW_OBJECT_KINDS.has(value) ? value : "unknown") as FlowDiagramObjectKind;
}
return normalizeDfdDiagramObjectKind(value);
}
function normalizeDfdDiagramObjectKind(value: string): "external" | "process" | "datastore" | "other" {
switch (value) {
case "external":

View file

@ -68,8 +68,14 @@ export function renderDfdMermaidDiagram(
}
): HTMLElement {
const shell = createMermaidShell({
className: "mdspec-diagram mdspec-diagram--dfd",
title: options?.hideTitle ? undefined : `${diagram.diagram.name} (dfd)`,
className: isFlowDiagramModel(diagram.diagram)
? "mdspec-diagram mdspec-diagram--flow-diagram"
: "mdspec-diagram mdspec-diagram--dfd",
title: options?.hideTitle
? undefined
: isFlowDiagramModel(diagram.diagram)
? `${diagram.diagram.name} (flow diagram)`
: `${diagram.diagram.name} (dfd)`,
forExport: options?.forExport,
onExportPng: options?.onExportPng,
onExportAndOpenPng: options?.onExportAndOpenPng,
@ -169,6 +175,10 @@ export function buildDfdMermaidSource(
diagram: ResolvedDiagram,
colorScheme?: ResolvedColorScheme
): string {
if (isFlowDiagramModel(diagram.diagram)) {
return buildFlowDiagramMermaidSource(diagram, colorScheme);
}
const palette = getModelWeaveMermaidPalette();
const lines: string[] = ["flowchart LR"];
const colorClasses = new Map<string, ResolvedColorStyle>();
@ -253,6 +263,235 @@ export function buildDfdMermaidSource(
return lines.join("\n");
}
function buildFlowDiagramMermaidSource(
diagram: ResolvedDiagram,
colorScheme?: ResolvedColorScheme
): string {
const palette = getModelWeaveMermaidPalette();
const lines: string[] = [
"flowchart LR",
` ${buildModelWeaveMermaidClassDef("screen", palette.dfdProcessFill, palette.dfdProcessBorder, { strokeWidth: 1.5 })}`,
` ${buildModelWeaveMermaidClassDef("process", palette.dfdProcessFill, palette.dfdProcessBorder, { strokeWidth: 1.5 })}`,
` ${buildModelWeaveMermaidClassDef("context", palette.dfdOtherFill, palette.dfdOtherBorder, { strokeWidth: 1.5 })}`,
` ${buildModelWeaveMermaidClassDef("store", palette.dfdDatastoreFill, palette.dfdDatastoreBorder, { strokeWidth: 1.5 })}`,
` ${buildModelWeaveMermaidClassDef("external", palette.dfdExternalFill, palette.dfdExternalBorder, { strokeWidth: 1.5 })}`
];
const nodeIds = new Map<string, string>();
const domainStyles: string[] = [];
const flowDomains = getFlowDiagramDomains(diagram);
const flowDomainsById = new Map(flowDomains.map((domain) => [domain.id, domain]));
const groupedNodes = new Map<string, Array<typeof diagram.nodes[number]>>();
const ungroupedNodes: typeof diagram.nodes = [];
for (const node of diagram.nodes) {
const domainId = getNodeDomainId(node);
if (domainId) {
if (!flowDomainsById.has(domainId)) {
flowDomainsById.set(domainId, createSyntheticDomainEntry(domainId, flowDomainsById.size));
}
if (!groupedNodes.has(domainId)) {
groupedNodes.set(domainId, []);
}
groupedNodes.get(domainId)!.push(node);
} else {
ungroupedNodes.push(node);
}
}
for (const root of buildDomainTree(Array.from(flowDomainsById.values()))) {
appendFlowDiagramDomainSubgraph(
lines,
root,
groupedNodes,
nodeIds,
1,
colorScheme,
domainStyles
);
}
for (const node of ungroupedNodes) {
appendFlowDiagramNode(lines, node, nodeIds, 1);
}
for (const edge of diagram.edges) {
const from = nodeIds.get(edge.source);
const to = nodeIds.get(edge.target);
if (!from || !to) {
continue;
}
const label = sanitizeMermaidEdgeLabel(edge.label);
if (label) {
lines.push(` ${from} -->|${label}| ${to}`);
} else {
lines.push(` ${from} --> ${to}`);
}
}
if (domainStyles.length > 0) {
lines.push("", ...domainStyles);
}
return lines.join("\n");
}
function appendFlowDiagramDomainSubgraph(
lines: string[],
domainNode: DomainTreeNode,
groupedNodes: Map<string, Array<DiagramNode & { object?: unknown }>>,
nodeIds: Map<string, string>,
depth: number,
colorScheme: ResolvedColorScheme | undefined,
domainStyles: string[]
): boolean {
const childLines: string[] = [];
for (const child of domainNode.children) {
appendFlowDiagramDomainSubgraph(
childLines,
child,
groupedNodes,
nodeIds,
depth + 1,
colorScheme,
domainStyles
);
}
const nodes = groupedNodes.get(domainNode.domain.id) ?? [];
if (nodes.length === 0 && childLines.length === 0) {
return false;
}
const indent = " ".repeat(depth);
const domainMermaidId = toFlowMermaidDomainId(domainNode.domain.id);
lines.push(`${indent}subgraph ${domainMermaidId}["${buildFlowDomainLabel(domainNode.domain)}"]`);
lines.push(...childLines);
for (const node of nodes) {
appendFlowDiagramNode(lines, node, nodeIds, depth + 1);
}
lines.push(`${indent}end`);
if (colorScheme) {
domainStyles.push(
` style ${domainMermaidId} ${formatMermaidClassDefStyle(
resolveColorStyle(colorScheme, "domain", getFlowDomainColorKind(domainNode.domain))
)}`
);
}
return true;
}
function appendFlowDiagramNode(
lines: string[],
node: DiagramNode,
nodeIds: Map<string, string>,
depth: number
): void {
const mermaidId = toMermaidNodeId(node.id);
const shape = toFlowDiagramMermaidShape(node.kind);
const className = toFlowDiagramClassName(node.kind);
const indent = " ".repeat(depth);
nodeIds.set(node.id, mermaidId);
lines.push(`${indent}${mermaidId}@{ shape: ${shape}, label: "${escapeMermaidLabel(node.label ?? node.ref ?? node.id)}" }`);
lines.push(`${indent}class ${mermaidId} ${className}`);
}
function getFlowDiagramDomains(diagram: ResolvedDiagram): DomainEntry[] {
const resolvedDomains = getOptionalResolvedDomains(diagram);
const domainsById = new Map(resolvedDomains.map((domain) => [domain.id, domain]));
for (const node of diagram.nodes) {
const domainId = getNodeDomainId(node);
if (domainId && !domainsById.has(domainId)) {
domainsById.set(domainId, createSyntheticDomainEntry(domainId, domainsById.size));
}
}
return Array.from(domainsById.values());
}
function getOptionalResolvedDomains(diagram: ResolvedDiagram): DomainEntry[] {
const maybeDomains = (diagram.diagram as { domains?: unknown }).domains;
return Array.isArray(maybeDomains) ? maybeDomains.filter(isDomainEntry) : [];
}
function isDomainEntry(value: unknown): value is DomainEntry {
return Boolean(value && typeof value === "object" && "id" in value && typeof value.id === "string");
}
function createSyntheticDomainEntry(id: string, rowIndex: number): DomainEntry {
return { id, name: id, kind: id, rowIndex };
}
function toFlowMermaidDomainId(value: string): string {
return `DOMAIN_${toMermaidNodeId(value)}`;
}
function buildFlowDomainLabel(domain: DomainEntry): string {
return escapeMermaidLabel(domain.name?.trim() || domain.id);
}
function getFlowDomainColorKind(domain: DomainEntry): string {
return domain.kind?.trim() || domain.id;
}
export function getDfdMermaidColorSchemeTargets(diagram: ResolvedDiagram): string[] {
if (isFlowDiagramModel(diagram.diagram)) {
return hasNodesWithDomain(diagram) ? ["domain"] : [];
}
if (isDfdDiagramModel(diagram.diagram)) {
return hasNodesWithDomain(diagram) || (diagram.diagram.domains?.length ?? 0) > 0
? ["dfd", "domain"]
: ["dfd"];
}
return [];
}
function hasNodesWithDomain(diagram: ResolvedDiagram): boolean {
return diagram.nodes.some((node) => Boolean(getNodeDomainId(node)));
}
function toFlowDiagramMermaidShape(kind: unknown): string {
switch (kind) {
case "screen":
return "curv-trap";
case "session":
case "store":
case "datastore":
return "lin-cyl";
case "process":
case "app_process":
case "context":
case "work_object":
case "external":
case "unknown":
default:
return "rect";
}
}
function toFlowDiagramClassName(kind: unknown): string {
switch (kind) {
case "screen":
return "screen";
case "session":
case "store":
case "datastore":
return "store";
case "context":
case "work_object":
return "context";
case "process":
case "app_process":
return "process";
case "external":
return "external";
case "unknown":
default:
return "process";
}
}
function appendDfdDomainSubgraph(
lines: string[],
domainNode: DomainTreeNode,
@ -315,6 +554,10 @@ function isDfdDiagramModel(diagram: ResolvedDiagram["diagram"]): diagram is DfdD
return diagram.schema === "dfd_diagram";
}
function isFlowDiagramModel(diagram: ResolvedDiagram["diagram"]): boolean {
return diagram.schema === "flow_diagram";
}
function getDfdObject(node: DiagramNode & { object?: unknown }): DfdObjectModel | undefined {
return node.object && typeof node.object === "object" && "fileType" in node.object &&
node.object.fileType === "dfd-object"

View file

@ -50,6 +50,10 @@ export function renderDiagramModel(
classDetailLabels?: ClassDetailLabels;
}
): HTMLElement {
if (diagram.diagram.schema === "flow_diagram") {
return renderDfdMermaidDiagram(diagram, options);
}
switch (diagram.diagram.kind) {
case "class":
return renderClassDiagramByMode(diagram, options);

View file

@ -175,6 +175,39 @@ tags:
## Notes
- Add \`## Domain Sources\` only when referenced \`type: domains\` files already exist.
`,
flowDiagram: `---
type: flow_diagram
id: FLOW-
name:
kind: screen_communication
tags:
- FlowDiagram
- ScreenCommunication
---
#
## Summary
## Objects
| id | label | kind | ref | domain | notes |
|---|---|---|---|---|---|
| order_screen | Order Screen | screen | [[SCR-ORDER]] | | Source screen |
| order_process | Order Process | app_process | [[PROC-ORDER]] | | Application process |
| session_store | Session Store | session | | | Screen/session state |
| external_system | External System | external | | | External handoff target |
## Flows
| id | from | to | data | notes |
|---|---|---|---|---|
| FLOW-001 | order_screen | order_process | [[DATA-ORDER-REQUEST]] | Submit order request |
| FLOW-002 | order_process | session_store | Order result | Save result for display |
## Notes
`,
dataObject: `---
type: data_object

View file

@ -14,6 +14,7 @@ export const FILE_TYPES = [
"domain-diagram",
"dfd-object",
"dfd-diagram",
"flow-diagram",
"er-entity",
"markdown"
] as const;
@ -60,6 +61,7 @@ export const CORE_DIAGRAM_KINDS = [
"er",
"dfd",
"flow",
"screen_communication",
"component"
] as const;

View file

@ -17,6 +17,17 @@ import type { AppProcessBusinessFlowDirection } from "../core/app-process-busine
export type FileType = (typeof FILE_TYPES)[number];
export type DfdObjectKind = (typeof DFD_OBJECT_KINDS)[number];
export type DfdDiagramObjectKind = DfdObjectKind | "other";
export type FlowDiagramObjectKind =
| "screen"
| "process"
| "app_process"
| "context"
| "work_object"
| "session"
| "store"
| "datastore"
| "external"
| "unknown";
export type CoreObjectKind = (typeof CORE_OBJECT_KINDS)[number];
export type ReservedObjectKind = (typeof RESERVED_OBJECT_KINDS)[number];
@ -507,7 +518,7 @@ export interface DiagramNode {
id: string;
ref?: string;
label?: string;
kind?: ObjectKind | DfdDiagramObjectKind;
kind?: ObjectKind | DfdDiagramObjectKind | FlowDiagramObjectKind;
metadata?: Record<string, unknown>;
}
@ -649,7 +660,7 @@ export interface ResolvedAppProcessDomainPlacement {
export interface DfdDiagramObjectEntry {
id?: string;
label?: string;
kind?: DfdDiagramObjectKind;
kind?: DfdDiagramObjectKind | FlowDiagramObjectKind;
ref?: string;
domain?: string;
notes?: string;
@ -674,6 +685,19 @@ export interface DfdDiagramModel extends BaseFileModel<"dfd-diagram"> {
flows: DfdFlowModel[];
}
export interface FlowDiagramModel extends BaseFileModel<"flow-diagram"> {
schema: "flow_diagram";
id: string;
name: string;
kind: "screen_communication";
description?: string;
objectRefs: string[];
objectEntries: DfdDiagramObjectEntry[];
nodes: DiagramNode[];
edges: DiagramEdge[];
flows: DfdFlowModel[];
}
export interface ErColumn {
logicalName: string;
physicalName: string;
@ -743,6 +767,7 @@ export type ParsedFileModel =
| RelationsFileModel
| DiagramModel
| DfdDiagramModel
| FlowDiagramModel
| ErEntity
| MarkdownFileModel;
@ -826,7 +851,7 @@ export interface ParseResult<TParsed extends ParsedFileModel = ParsedFileModel>
}
export interface ResolvedDiagram {
diagram: DiagramModel | DfdDiagramModel;
diagram: DiagramModel | DfdDiagramModel | FlowDiagramModel;
nodes: Array<
DiagramNode & {
object?: ObjectModel | ErEntity | DfdObjectModel;

View file

@ -22,6 +22,7 @@ import {
exportDiagramSnapshotAsPng
} from "../export/png-export";
import { renderDiagramModel } from "../renderers/diagram-renderer";
import { getDfdMermaidColorSchemeTargets } from "../renderers/dfd-mermaid";
import {
getAppProcessBusinessFlowColorSchemeTargets,
renderAppProcessBusinessFlow,
@ -85,7 +86,6 @@ import type {
ResolvedColorScheme,
ColorSchemeModel,
ColorSchemeEntry,
DfdDiagramModel,
DfdObjectModel,
ErEntity,
ImpactReference,
@ -447,6 +447,15 @@ const DEFAULT_VIEWER_PREFERENCES: ModelWeaveViewerPreferences = {
showMermaidRenderDebug: false
};
export type AppliedColorSchemeLowerPaneSlot = "details";
export function getAppliedColorSchemeLowerPaneSlot(
_modelType: string | undefined
): AppliedColorSchemeLowerPaneSlot {
return "details";
}
export class ModelingPreviewView extends ItemView {
private readonly lowerPanelDomIdPrefix = `model-weave-lower-${nextViewerLowerPanelInstanceId++}`;
@ -4193,8 +4202,11 @@ export class ModelingPreviewView extends ItemView {
state.colorScheme
);
this.renderSourceLinksSection(lowerSlots.sourceLinks, state.diagram.diagram.sourceLinks);
const appliedColorSchemeSlot = getAppliedColorSchemeLowerPaneSlot(
state.diagram.diagram.schema
);
this.renderAppliedColorScheme(
lowerSlots.impact,
lowerSlots[appliedColorSchemeSlot],
state.colorScheme,
this.getImpactColorSchemeTargets(
this.getDiagramColorSchemeTargets(state.diagram),
@ -4205,13 +4217,7 @@ export class ModelingPreviewView extends ItemView {
}
private getDiagramColorSchemeTargets(diagram: ResolvedDiagram): string[] {
if (this.isDfdDiagramModel(diagram.diagram)) {
return (diagram.diagram.domains?.length ?? 0) > 0
? ["dfd", "domain"]
: ["dfd"];
}
return [];
return getDfdMermaidColorSchemeTargets(diagram);
}
private getImpactColorSchemeTargets(
@ -4221,9 +4227,6 @@ export class ModelingPreviewView extends ItemView {
return impactSummary ? [...baseTargets, "weave_map"] : baseTargets;
}
private isDfdDiagramModel(diagram: ResolvedDiagram["diagram"]): diagram is DfdDiagramModel {
return diagram.schema === "dfd_diagram";
}
private applyLowerPanelTabs(): void {
const panes = Array.from(
@ -6440,10 +6443,13 @@ function getDiagnosticGuidanceEntries(
}
if (diagnostic.code === "unresolved-reference") {
const guidancePrefix = isFlowDiagramLocalEndpointDiagnostic(diagnostic)
? "diagnostics.guidance.flowEndpoint"
: "diagnostics.guidance.reference";
guidance.push(
{ label: t("diagnostics.guidance.whatWrong"), value: t("diagnostics.guidance.reference.what") },
{ label: t("diagnostics.guidance.likelyCause"), value: t("diagnostics.guidance.reference.cause") },
{ label: t("diagnostics.guidance.manualFix"), value: t("diagnostics.guidance.reference.fix") }
{ label: t("diagnostics.guidance.whatWrong"), value: t(guidancePrefix + ".what") },
{ label: t("diagnostics.guidance.likelyCause"), value: t(guidancePrefix + ".cause") },
{ label: t("diagnostics.guidance.manualFix"), value: t(guidancePrefix + ".fix") }
);
}
@ -6458,6 +6464,12 @@ function getDiagnosticGuidanceEntries(
return guidance;
}
function isFlowDiagramLocalEndpointDiagnostic(diagnostic: ValidationWarning): boolean {
return diagnostic.code === "unresolved-reference" &&
diagnostic.context?.referenceKind === "local-object-id" &&
(diagnostic.field === "Flows.from" || diagnostic.field === "Flows.to");
}
function isFrontmatterDiagnostic(diagnostic: ValidationWarning): boolean {
return /required frontmatter/i.test(diagnostic.message) ||
/frontmatter "id" is missing/i.test(diagnostic.message) ||

522
test/flow-diagram.test.mjs Normal file
View file

@ -0,0 +1,522 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { build } from "esbuild";
const outputFile = "dist/test-flow-diagram.mjs";
await build({
stdin: {
contents: [
'export { parseFlowDiagramFile } from "./src/parsers/dfd-diagram-parser";',
'export { buildVaultIndex } from "./src/core/vault-index";',
'export { detectFileType } from "./src/core/schema-detector";',
'export { isDiagramPreviewRouteFileType, isDfdLikeDiagramPreviewFileType } from "./src/core/preview-routing";',
'export { isModelWeavePreviewSupportedFileType } from "./src/core/supported-formats";',
'export { resolveDiagramRelations } from "./src/core/relation-resolver";',
'export { buildDfdMermaidSource, getDfdMermaidColorSchemeTargets } from "./src/renderers/dfd-mermaid";',
'export { getAppliedColorSchemeRowsForTargets } from "./src/core/color-scheme";',
'export { buildCurrentDiagramDiagnostics } from "./src/core/current-file-diagnostics";',
'export { createModelWeaveTranslator } from "./src/i18n/messages";',
'export { getAppliedColorSchemeLowerPaneSlot, getDiagnosticActionCandidates, getDiagnosticDetailEntries } from "./src/views/modeling-preview-view";',
'export { getExpectedHeaderForDiagnostic } from "./src/core/diagnostic-section-guidance";'
].join("\n"),
resolveDir: ".",
sourcefile: "test-flow-diagram-entry.ts",
loader: "ts"
},
bundle: true,
format: "esm",
platform: "browser",
outfile: outputFile,
plugins: [
{
name: "stub-platform-modules",
setup(buildApi) {
buildApi.onResolve({ filter: /^obsidian$/ }, () => ({
path: "obsidian",
namespace: "stub"
}));
buildApi.onLoad({ filter: /^obsidian$/, namespace: "stub" }, () => ({
contents: [
"export class ItemView {}",
"export const MarkdownRenderer = { renderMarkdown: async () => {} };",
"export class Notice { constructor() {} }",
"export class TFile {}",
"export class WorkspaceLeaf {}",
"export const getLanguage = () => 'en';",
"export const loadMermaid = async () => ({ initialize: () => {}, render: async () => ({ svg: '' }) });"
].join("\n"),
loader: "js"
}));
buildApi.onResolve({ filter: /^electron$/ }, () => ({
path: "electron",
namespace: "stub"
}));
buildApi.onLoad({ filter: /^electron$/, namespace: "stub" }, () => ({
contents: "export const shell = { openPath: async () => '', openExternal: async () => {} };",
loader: "js"
}));
buildApi.onResolve({ filter: /^fs$/ }, () => ({
path: "fs",
namespace: "stub"
}));
buildApi.onLoad({ filter: /^fs$/, namespace: "stub" }, () => ({
contents: "export const existsSync = () => false; export const statSync = () => ({ isFile: () => false, isDirectory: () => false }); export const promises = {}; export default { existsSync, statSync, promises };",
loader: "js"
}));
buildApi.onResolve({ filter: /^path$/ }, () => ({
path: "path",
namespace: "stub"
}));
buildApi.onLoad({ filter: /^path$/, namespace: "stub" }, () => ({
contents: "export const basename = (value) => String(value).split('/').pop() ?? ''; export const extname = () => ''; export const join = (...parts) => parts.join('/'); export default { basename, extname, join };",
loader: "js"
}));
}
}
],
logLevel: "silent"
});
const {
buildCurrentDiagramDiagnostics,
buildDfdMermaidSource,
createModelWeaveTranslator,
getAppliedColorSchemeLowerPaneSlot,
getAppliedColorSchemeRowsForTargets,
getDfdMermaidColorSchemeTargets,
buildVaultIndex,
detectFileType,
getDiagnosticActionCandidates,
getDiagnosticDetailEntries,
getExpectedHeaderForDiagnostic,
isDfdLikeDiagramPreviewFileType,
isDiagramPreviewRouteFileType,
isModelWeavePreviewSupportedFileType,
parseFlowDiagramFile,
resolveDiagramRelations
} = await import(`../${outputFile}?t=${Date.now()}`);
globalThis.activeDocument = {
body: {
classList: {
contains: () => false
}
}
};
const flowMarkdown = `---
type: flow_diagram
id: FLOW-ORDER-SCREEN-COMMUNICATION
name: Order Screen Communication Flow
kind: screen_communication
---
# Order Screen Communication Flow
## Objects
| id | label | kind | ref | domain | notes |
|---|---|---|---|---|---|
| order_screen | Order Screen | screen | [[SCR-ORDER]] | order | Source screen |
| order_process | Order Process | app_process | [[PROC-ORDER]] | order | Application process |
| session_store | Session Store | session | | order | Session state |
| data_store | Data Store | datastore | | order | Persistent state |
| mystery | Mystery | something_new | | order | Unknown kind |
## Flows
| id | from | to | data | notes |
|---|---|---|---|---|
| FLOW-001 | order_screen | order_process | [[DATA-ORDER-REQUEST]] | Submit order |
| FLOW-002 | order_process | session_store | Order result | Store result |
| FLOW-003 | session_store | data_store | Snapshot | Persist snapshot |
| FLOW-004 | mystery | order_screen | Unknown handoff | Unknown source |
`;
function parseFlow(markdown = flowMarkdown) {
const result = parseFlowDiagramFile(markdown, "FLOW-ORDER-SCREEN-COMMUNICATION.md");
assert.ok(result.file);
return result;
}
function resolveFlow(markdown = flowMarkdown, extraFiles = []) {
const index = buildVaultIndex([
{ path: "FLOW-ORDER-SCREEN-COMMUNICATION.md", content: markdown },
...extraFiles
], { parseMode: "full", validate: false });
const model = index.modelsByFilePath["FLOW-ORDER-SCREEN-COMMUNICATION.md"];
assert.equal(model.fileType, "flow-diagram");
return { index, model, resolved: resolveDiagramRelations(model, index) };
}
test("flow_diagram is detected and parsed as a distinct model type", () => {
assert.equal(detectFileType({ type: "flow_diagram" }), "flow-diagram");
const { file, warnings } = parseFlow();
assert.equal(file.fileType, "flow-diagram");
assert.equal(file.schema, "flow_diagram");
assert.equal(file.kind, "screen_communication");
assert.equal(warnings.length, 0);
});
test("flow_diagram follows the same preview support and diagram route checks", () => {
const fileType = detectFileType({ type: "flow_diagram" });
assert.equal(fileType, "flow-diagram");
assert.equal(isModelWeavePreviewSupportedFileType(fileType), true);
assert.equal(isDfdLikeDiagramPreviewFileType(fileType), true);
assert.equal(isDiagramPreviewRouteFileType(fileType), true);
});
test("flow-diagram alias normalizes to the same internal preview route", () => {
const fileType = detectFileType({ type: "flow-diagram" });
assert.equal(fileType, "flow-diagram");
assert.equal(isModelWeavePreviewSupportedFileType(fileType), true);
assert.equal(isDfdLikeDiagramPreviewFileType(fileType), true);
});
test("dfd_diagram preview route remains DFD-like diagram", () => {
const fileType = detectFileType({ type: "dfd_diagram" });
assert.equal(fileType, "dfd-diagram");
assert.equal(isModelWeavePreviewSupportedFileType(fileType), true);
assert.equal(isDfdLikeDiagramPreviewFileType(fileType), true);
assert.equal(isDiagramPreviewRouteFileType(fileType), true);
});
test("unsupported type still does not use the diagram preview route", () => {
const fileType = detectFileType({ type: "not_a_model_weave_type" });
assert.equal(fileType, "markdown");
assert.equal(isModelWeavePreviewSupportedFileType(fileType), false);
assert.equal(isDfdLikeDiagramPreviewFileType(fileType), false);
assert.equal(isDiagramPreviewRouteFileType(fileType), false);
});
test("flow_diagram Objects table parses MVP columns", () => {
const { file } = parseFlow();
const first = file.objectEntries[0];
assert.equal(first.id, "order_screen");
assert.equal(first.label, "Order Screen");
assert.equal(first.kind, "screen");
assert.equal(first.ref, "[[SCR-ORDER]]");
assert.equal(first.domain, "order");
assert.equal(first.notes, "Source screen");
});
test("flow_diagram Flows table parses MVP columns", () => {
const { file } = parseFlow();
const first = file.flows[0];
assert.equal(first.id, "FLOW-001");
assert.equal(first.from, "order_screen");
assert.equal(first.to, "order_process");
assert.equal(first.data, "[[DATA-ORDER-REQUEST]]");
assert.equal(first.notes, "Submit order");
});
test("flow_diagram Mermaid renders screen as curv-trap", () => {
const { resolved } = resolveFlow();
const source = buildDfdMermaidSource(resolved);
assert.match(source, /order_screen@\{ shape: curv-trap, label: "Order Screen" \}/);
assert.match(source, /class order_screen screen/);
});
test("flow_diagram Mermaid renders store kinds as lin-cyl", () => {
const { resolved } = resolveFlow();
const source = buildDfdMermaidSource(resolved);
assert.match(source, /session_store@\{ shape: lin-cyl, label: "Session Store" \}/);
assert.match(source, /data_store@\{ shape: lin-cyl, label: "Data Store" \}/);
assert.match(source, /class session_store store/);
assert.match(source, /class data_store store/);
});
test("flow_diagram unknown object kind falls back to rect", () => {
const { model, resolved } = resolveFlow();
const source = buildDfdMermaidSource(resolved);
assert.equal(model.objectEntries.find((entry) => entry.id === "mystery")?.kind, "unknown");
assert.match(source, /mystery@\{ shape: rect, label: "Mystery" \}/);
});
test("flow_diagram Flows.data appears as edge label", () => {
const { resolved } = resolveFlow();
const source = buildDfdMermaidSource(resolved);
assert.match(source, /order_screen -->\|DATA-ORDER-REQUEST\| order_process/);
assert.match(source, /order_process -->\|Order result\| session_store/);
});
test("flow_diagram refs can point to screen, app_process, and data_object without DFD compatibility warnings", () => {
const markdown = flowMarkdown.replace(
"| data_store | Data Store | datastore | | order | Persistent state |",
"| data_store | Data Store | datastore | [[DATA-ORDER-REQUEST]] | order | Persistent state |"
);
const { index, model, resolved } = resolveFlow(markdown, [
{ path: "SCR-ORDER.md", content: "---\ntype: screen\nid: SCR-ORDER\nname: Order Screen\n---\n" },
{ path: "PROC-ORDER.md", content: "---\ntype: app_process\nid: PROC-ORDER\nname: Order Process\n---\n" },
{ path: "DATA-ORDER-REQUEST.md", content: "---\ntype: data_object\nid: DATA-ORDER-REQUEST\nname: Order Request\n---\n" }
]);
const messages = [
...(index.warningsByFilePath[model.path] ?? []),
...resolved.warnings
].map((warning) => warning.message);
assert.equal(messages.some((message) => /DFD/.test(message)), false);
assert.equal(messages.some((message) => /unresolved Flow Diagram object ref/.test(message)), false);
});
test("flow_diagram malformed Objects header exposes expected header guidance", () => {
const markdown = flowMarkdown.replace("| id | label | kind | ref | domain | notes |", "| id | label | kind | notes |");
const parsed = parseFlow(markdown);
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: parsed.file }, parsed.warnings);
const tableDiagnostic = diagnostics.find((diagnostic) => diagnostic.code === "invalid-table-column" && /Objects/.test(diagnostic.message));
assert.ok(tableDiagnostic);
assert.equal(getExpectedHeaderForDiagnostic(tableDiagnostic), "id | label | kind | ref | domain | notes");
});
test("flow_diagram malformed Flows header exposes expected header and suppresses row cascade", () => {
const markdown = flowMarkdown.replace("| id | from | to | data | notes |", "| id | frow | to | data | notes |");
const parsed = parseFlow(markdown);
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: parsed.file }, parsed.warnings);
const tableDiagnostic = diagnostics.find((diagnostic) => diagnostic.code === "invalid-table-column" && /Flows/.test(diagnostic.message));
assert.ok(tableDiagnostic);
assert.equal(getExpectedHeaderForDiagnostic(tableDiagnostic), "id | from | to | data | notes");
assert.equal(diagnostics.some((diagnostic) => /Flow Diagram Flows row must have "from"/.test(diagnostic.message)), false);
assert.equal(diagnostics.some((diagnostic) => /unresolved Flow Diagram flow source/.test(diagnostic.message)), false);
});
test("flow_diagram unresolved Objects.ref keeps external reference guidance without expected header", () => {
const markdown = flowMarkdown.replace("[[SCR-ORDER]]", "[[SCR-MISSING]]");
const { model, resolved } = resolveFlow(markdown);
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: model }, [
...(resolved.warnings ?? [])
]);
const diagnostic = diagnostics.find((entry) =>
entry.code === "unresolved-reference" && /object ref/.test(entry.message)
);
assert.ok(diagnostic);
assert.equal(diagnostic.severity, "warning");
assert.equal(getExpectedHeaderForDiagnostic(diagnostic), null);
const t = createModelWeaveTranslator("en");
const details = getDiagnosticDetailEntries(diagnostic, t);
const actions = getDiagnosticActionCandidates(diagnostic, diagnostic.message, t, undefined);
assert.equal(details.some((entry) => entry.label === "Expected header"), false);
assert.equal(actions.some((action) => action.id === "copy-expected-header"), false);
assert.equal(
details.some((entry) => /indexed model files/.test(entry.value)),
true
);
});
test("flow_diagram unresolved Flows.from reports local Objects.id guidance", () => {
const markdown = flowMarkdown.replace("| FLOW-001 | order_screen | order_process |", "| FLOW-001 | ORDER_ENTRY | order_process |");
const { model, resolved } = resolveFlow(markdown);
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: model }, resolved.warnings);
const diagnostic = diagnostics.find((entry) =>
entry.code === "unresolved-reference" && entry.field === "Flows.from"
);
assert.ok(diagnostic);
assert.equal(diagnostic.severity, "error");
assert.match(diagnostic.message, /local ## Objects table/);
assert.doesNotMatch(diagnostic.message, /indexed Model Weave files|indexed model files/);
assert.equal(getExpectedHeaderForDiagnostic(diagnostic), null);
const t = createModelWeaveTranslator("en");
const details = getDiagnosticDetailEntries(diagnostic, t);
assert.equal(details.some((entry) => entry.label === "Expected header"), false);
assert.equal(
details.some((entry) => /local .*Objects.* table/.test(entry.value) && /object ID/.test(entry.value)),
true
);
});
test("flow_diagram unresolved Flows.to reports local Objects.id guidance", () => {
const markdown = flowMarkdown.replace("| FLOW-001 | order_screen | order_process |", "| FLOW-001 | order_screen | ORDER_DONE |");
const { model, resolved } = resolveFlow(markdown);
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: model }, resolved.warnings);
const diagnostic = diagnostics.find((entry) =>
entry.code === "unresolved-reference" && entry.field === "Flows.to"
);
assert.ok(diagnostic);
assert.equal(diagnostic.severity, "error");
assert.match(diagnostic.message, /local ## Objects table/);
assert.doesNotMatch(diagnostic.message, /indexed Model Weave files|indexed model files/);
assert.equal(getExpectedHeaderForDiagnostic(diagnostic), null);
});
test("flow_diagram malformed Objects header suppresses flow endpoint cascade", () => {
const markdown = flowMarkdown.replace("| id | label | kind | ref | domain | notes |", "| id | label | kind | notes |");
const { index, model, resolved } = resolveFlow(markdown);
const diagnostics = buildCurrentDiagramDiagnostics({ diagram: model }, [
...(index.warningsByFilePath[model.path] ?? []),
...resolved.warnings
]);
const tableDiagnostics = diagnostics.filter((entry) =>
entry.code === "invalid-table-column" && /Objects/.test(entry.message)
);
assert.equal(tableDiagnostics.length, 1);
assert.equal(getExpectedHeaderForDiagnostic(tableDiagnostics[0]), "id | label | kind | ref | domain | notes");
assert.equal(
diagnostics.some((entry) => entry.code === "unresolved-reference" && (entry.field === "Flows.from" || entry.field === "Flows.to")),
false
);
});
const flowDomainMarkdown = `---
type: flow_diagram
id: FLOW-ORDER-SCREEN-COMMUNICATION
name: Order Screen Communication Flow
kind: screen_communication
---
## Objects
| id | label | kind | ref | domain | notes |
|---|---|---|---|---|---|
| ORDER_ENTRY | Order Entry Screen | screen | [[SCR-ORDER-ENTRY]] | sales | Parent screen |
| CUSTOMER_SEARCH | Customer Search Screen | screen | [[SCR-CUSTOMER-SEARCH]] | sales | Helper search screen |
| ORDER_CONTEXT | Order Wizard Context | context | | sales | Temporary working context |
| SESSION_STORE | Session Store | store | | platform | Temporary persistence |
| ORDER_SUBMIT | Order Submit Process | app_process | [[PROC-ORDER-SUBMIT]] | application | Submit process |
## Flows
| id | from | to | data | notes |
|---|---|---|---|---|
| F01 | ORDER_ENTRY | ORDER_CONTEXT | [[DATA-ORDER-DRAFT]] | Keep draft |
| F02 | ORDER_CONTEXT | CUSTOMER_SEARCH | [[DATA-CUSTOMER-SEARCH-CONDITION]] | Open search |
| F03 | CUSTOMER_SEARCH | ORDER_CONTEXT | [[DATA-CUSTOMER-SELECTION]] | Return selection |
| F04 | ORDER_CONTEXT | ORDER_ENTRY | [[DATA-CUSTOMER-SELECTION]] | Apply selection |
| F05 | ORDER_CONTEXT | SESSION_STORE | [[DATA-ORDER-DRAFT]] | Save draft |
| F06 | ORDER_ENTRY | ORDER_SUBMIT | [[DATA-ORDER-DRAFT]] | Submit |
`;
test("flow_diagram Objects.domain renders Mermaid domain subgraphs", () => {
const { resolved } = resolveFlow(flowDomainMarkdown);
const source = buildDfdMermaidSource(resolved);
assert.match(source, /subgraph DOMAIN_sales\["sales"\]/);
assert.match(source, /subgraph DOMAIN_platform\["platform"\]/);
assert.match(source, /subgraph DOMAIN_application\["application"\]/);
assert.match(source, /subgraph DOMAIN_sales[\s\S]*ORDER_ENTRY@\{ shape: curv-trap, label: "Order Entry Screen" \}[\s\S]*CUSTOMER_SEARCH@\{ shape: curv-trap, label: "Customer Search Screen" \}[\s\S]*ORDER_CONTEXT@\{ shape: rect, label: "Order Wizard Context" \}[\s\S]*end/);
});
test("flow_diagram domain grouping preserves kind shapes and flow data labels", () => {
const { resolved } = resolveFlow(flowDomainMarkdown);
const source = buildDfdMermaidSource(resolved);
assert.match(source, /ORDER_ENTRY@\{ shape: curv-trap, label: "Order Entry Screen" \}/);
assert.match(source, /SESSION_STORE@\{ shape: lin-cyl, label: "Session Store" \}/);
assert.match(source, /ORDER_SUBMIT@\{ shape: rect, label: "Order Submit Process" \}/);
assert.match(source, /ORDER_ENTRY -->\|DATA-ORDER-DRAFT\| ORDER_CONTEXT/);
assert.match(source, /ORDER_CONTEXT -->\|DATA-ORDER-DRAFT\| SESSION_STORE/);
});
test("flow_diagram without Objects.domain renders without domain subgraphs", () => {
const markdown = flowDomainMarkdown.replaceAll("| sales |", "| |").replaceAll("| platform |", "| |").replaceAll("| application |", "| |");
const { resolved } = resolveFlow(markdown);
const source = buildDfdMermaidSource(resolved);
assert.doesNotMatch(source, /subgraph DOMAIN_/);
assert.match(source, /ORDER_ENTRY@\{ shape: curv-trap, label: "Order Entry Screen" \}/);
assert.match(source, /SESSION_STORE@\{ shape: lin-cyl, label: "Session Store" \}/);
});
test("flow_diagram domain IDs are sanitized for Mermaid subgraphs", () => {
const markdown = flowDomainMarkdown.replaceAll("| sales |", "| sales/platform ops |");
const { resolved } = resolveFlow(markdown);
const source = buildDfdMermaidSource(resolved);
assert.match(source, /subgraph DOMAIN_sales_platform_ops\["sales\/platform ops"\]/);
assert.match(source, /DOMAIN_sales_platform_ops/);
});
const flowDomainColorScheme = {
id: "COLOR-FLOW-DOMAINS",
name: "Flow Domain Colors",
entries: [
{ target: "domain", kind: "sales", fill: "#7ddea7", stroke: "#000000", text: "#ffffff", rowIndex: 0 },
{ target: "domain", kind: "application", fill: "#DDEBFF", stroke: "#4F81BD", text: "#111111", rowIndex: 1 }
],
defaultStyle: {
fill: "#f5f5f5",
stroke: "#9e9e9e",
text: "#111111"
}
};
test("flow_diagram domain Color Scheme rows emit Mermaid subgraph style lines", () => {
const { resolved } = resolveFlow(flowDomainMarkdown);
const source = buildDfdMermaidSource(resolved, flowDomainColorScheme);
assert.match(source, /style DOMAIN_sales fill:#7ddea7,stroke:#000000,color:#ffffff/);
assert.match(source, /style DOMAIN_application fill:#DDEBFF,stroke:#4F81BD,color:#111111/);
assert.match(source, /style DOMAIN_platform fill:#f5f5f5,stroke:#9e9e9e,color:#111111/);
assert.match(source, /ORDER_ENTRY@\{ shape: curv-trap, label: "Order Entry Screen" \}/);
assert.match(source, /SESSION_STORE@\{ shape: lin-cyl, label: "Session Store" \}/);
assert.match(source, /ORDER_ENTRY -->\|DATA-ORDER-DRAFT\| ORDER_CONTEXT/);
});
test("flow_diagram Applied Color Scheme targets include used domain rows", () => {
const { resolved } = resolveFlow(flowDomainMarkdown);
const targets = getDfdMermaidColorSchemeTargets(resolved);
const rows = getAppliedColorSchemeRowsForTargets(flowDomainColorScheme, targets);
assert.deepEqual(targets, ["domain"]);
assert.equal(rows.some((row) => row.entry.target === "domain" && row.entry.kind === "sales"), true);
assert.equal(rows.some((row) => row.entry.target === "domain" && row.entry.kind === "application"), true);
assert.equal(rows.some((row) => row.entry.target === "app_process"), false);
assert.equal(rows.some((row) => row.entry.target === "weave_map"), false);
});
test("dfd_diagram Applied Color Scheme targets include dfd and domain when domains are used", () => {
const targets = getDfdMermaidColorSchemeTargets({
diagram: {
schema: "dfd_diagram",
kind: "dfd",
domains: [{ id: "sales", name: "Sales", kind: "sales", rowIndex: 0 }]
},
nodes: [{ id: "process", metadata: { domain: "sales" } }],
edges: []
});
const rows = getAppliedColorSchemeRowsForTargets({
...flowDomainColorScheme,
entries: [
...flowDomainColorScheme.entries,
{ target: "dfd", kind: "process", fill: "#9bbb59", stroke: "#6f8a3f", text: "#000000", rowIndex: 2 }
]
}, targets);
assert.deepEqual(targets, ["dfd", "domain"]);
assert.equal(rows.some((row) => row.entry.target === "domain" && row.entry.kind === "sales"), true);
assert.equal(rows.some((row) => row.entry.target === "dfd" && row.entry.kind === "process"), true);
assert.equal(rows.some((row) => row.entry.target === "app_process"), false);
});
test("Applied Color Scheme placement policy uses Details for color-aware previews", () => {
assert.equal(getAppliedColorSchemeLowerPaneSlot("app_process"), "details");
assert.equal(getAppliedColorSchemeLowerPaneSlot("domains"), "details");
assert.equal(getAppliedColorSchemeLowerPaneSlot("domain_diagram"), "details");
assert.equal(getAppliedColorSchemeLowerPaneSlot("dfd_diagram"), "details");
assert.equal(getAppliedColorSchemeLowerPaneSlot("flow_diagram"), "details");
});
test("DFD and Flow Applied Color Scheme placement is not Relationships", () => {
assert.notEqual(getAppliedColorSchemeLowerPaneSlot("dfd_diagram"), "impact");
assert.notEqual(getAppliedColorSchemeLowerPaneSlot("flow_diagram"), "impact");
});