@suluk/cockpit 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/builder.ts +19 -0
- package/src/diagram.ts +109 -0
- package/src/index.ts +2 -0
- package/test/diagram.test.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/cockpit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "The pure cockpit core (cycle model · builder model · codegen · deploy planning · validate/audit/preview) shared by the vscode extension and the /superadmin web admin panel. CANDIDATE tooling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/builder.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { OpenAPIv4Document, SchemaOrRef } from "@suluk/core";
|
|
9
9
|
import { buildApp, toShadcnRegistry, type Entity, type BuiltApp, type DslDocument, type ParamSpec } from "@suluk/builder";
|
|
10
10
|
import type { Registry } from "@suluk/builder";
|
|
11
|
+
import { contractToD2, diagramViews } from "./diagram";
|
|
11
12
|
|
|
12
13
|
export interface BuilderNode {
|
|
13
14
|
tier: "page" | "section" | "block" | "component";
|
|
@@ -88,9 +89,27 @@ export function generateAppFiles(doc: OpenAPIv4Document): GeneratedFile[] {
|
|
|
88
89
|
const reg = toShadcnRegistry(app, { name: (doc.info?.title ?? "app").toLowerCase().replace(/\s+/g, "-") });
|
|
89
90
|
files.push({ path: "registry.json", content: JSON.stringify(reg.index, null, 2) });
|
|
90
91
|
for (const item of reg.items) files.push({ path: `registry/${item.name}.json`, content: JSON.stringify(item, null, 2) });
|
|
92
|
+
// diagrams — another projection: the app ships D2 of its own data model, cycle, and operation surface
|
|
93
|
+
for (const v of diagramViews()) files.push({ path: `docs/${v.id}.d2`, content: contractToD2(doc, v.id) });
|
|
94
|
+
files.push({ path: "docs/README.md", content: diagramsReadme(doc) });
|
|
91
95
|
return files;
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
function diagramsReadme(doc: OpenAPIv4Document): string {
|
|
99
|
+
const title = doc.info?.title ?? "this app";
|
|
100
|
+
return [
|
|
101
|
+
`# ${title} — diagrams`,
|
|
102
|
+
"",
|
|
103
|
+
"Generated from the v4 contract by Suluk. Each is [D2](https://d2lang.com) source — render with the d2 CLI",
|
|
104
|
+
"(`d2 erd.d2 erd.svg`), the D2 VS Code extension, or paste into <https://play.d2lang.com>.",
|
|
105
|
+
"",
|
|
106
|
+
...diagramViews().map((v) => `- **[\`${v.id}.d2\`](./${v.id}.d2)** — ${v.description}`),
|
|
107
|
+
"",
|
|
108
|
+
"Regenerate after the contract changes: re-run **Generate full app** in the Suluk extension.",
|
|
109
|
+
"",
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
/** The shadcn registry (index + items) as a pretty JSON string — the "Export shadcn registry" action. */
|
|
95
114
|
export function generateRegistryJson(doc: OpenAPIv4Document): string {
|
|
96
115
|
const { app } = buildBuilderModel(doc);
|
package/src/diagram.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagrams (D2) — ANOTHER projection from the one v4 contract. Suluk already derives data/API/docs/UI/cost from
|
|
3
|
+
* the contract; a diagram is the same move in visual form. contractToD2 emits D2 (d2lang.com) source for a few
|
|
4
|
+
* views: an ERD (entities + their references), the declarative cycle (one contract → every layer), and the
|
|
5
|
+
* operation surface (paths → operations, grouped by tag). Pure text generation (no host, no renderer) →
|
|
6
|
+
* unit-tested; the extension/docs render the D2 (d2 CLI, the playground, or a service like kroki.io).
|
|
7
|
+
*/
|
|
8
|
+
import { buildAda, type OpenAPIv4Document, type SchemaOrRef } from "@suluk/core";
|
|
9
|
+
import { schemaRefName } from "@suluk/builder";
|
|
10
|
+
import { buildCycle } from "./cycle";
|
|
11
|
+
|
|
12
|
+
export type DiagramView = "erd" | "cycle" | "operations";
|
|
13
|
+
export function diagramViews(): { id: DiagramView; title: string; description: string }[] {
|
|
14
|
+
return [
|
|
15
|
+
{ id: "erd", title: "Entity relationships", description: "components.schemas as tables + their $ref relationships" },
|
|
16
|
+
{ id: "cycle", title: "The declarative cycle", description: "one v4 contract → every projected layer" },
|
|
17
|
+
{ id: "operations", title: "Operation surface", description: "paths → operations, grouped by tag/entity" },
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// D2 reserved keywords — interpreted specially when they appear in a KEY position (inside any map), so a schema
|
|
22
|
+
// field literally named "shape"/"label"/… must be QUOTED there or D2 treats it as a directive and corrupts the node.
|
|
23
|
+
const D2_RESERVED = new Set([
|
|
24
|
+
"shape", "label", "style", "constraint", "width", "height", "icon", "near", "tooltip", "link", "class", "direction",
|
|
25
|
+
"grid-rows", "grid-columns", "source-arrowhead", "target-arrowhead", "vertical-gap", "horizontal-gap", "top", "left",
|
|
26
|
+
]);
|
|
27
|
+
const quote = (name: string): string => `"${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
28
|
+
/** A D2 name on the VALUE side (a type) — quote only when it isn't a bare word (reserved words are safe as values). */
|
|
29
|
+
function d2id(name: string): string {
|
|
30
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : quote(name);
|
|
31
|
+
}
|
|
32
|
+
/** A D2 name in a KEY position (node/field name, edge endpoint) — also quote reserved keywords. */
|
|
33
|
+
function d2key(name: string): string {
|
|
34
|
+
return D2_RESERVED.has(name) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? quote(name) : name;
|
|
35
|
+
}
|
|
36
|
+
function d2label(s: string): string {
|
|
37
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The display type of a property, and the entity it references (if any). */
|
|
41
|
+
function fieldType(schema: unknown, entities: Set<string>): { type: string; ref?: string } {
|
|
42
|
+
const s = schema as { $ref?: string; type?: string; items?: unknown; enum?: unknown[] };
|
|
43
|
+
if (typeof s?.$ref === "string") { const r = schemaRefName(s.$ref); return { type: r ?? "ref", ref: r && entities.has(r) ? r : undefined }; }
|
|
44
|
+
if (s?.type === "array") { const inner = fieldType(s.items, entities); return { type: `${inner.type}[]`, ref: inner.ref }; }
|
|
45
|
+
if (Array.isArray(s?.enum)) return { type: "enum" };
|
|
46
|
+
return { type: s?.type ?? "any" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function erdD2(doc: OpenAPIv4Document): string {
|
|
50
|
+
const schemas = (doc.components?.schemas ?? {}) as Record<string, SchemaOrRef>;
|
|
51
|
+
const entities = new Set(Object.keys(schemas));
|
|
52
|
+
const lines: string[] = ["# Suluk ERD — entities + references (generated from the v4 contract)", "direction: right", ""];
|
|
53
|
+
const edges: string[] = [];
|
|
54
|
+
for (const [name, sObj] of Object.entries(schemas)) {
|
|
55
|
+
const s = sObj as { properties?: Record<string, unknown>; required?: string[] };
|
|
56
|
+
const required = new Set(s.required ?? []);
|
|
57
|
+
lines.push(`${d2key(name)}: {`, " shape: sql_table");
|
|
58
|
+
for (const [fname, fObj] of Object.entries(s.properties ?? {})) {
|
|
59
|
+
const { type, ref } = fieldType(fObj, entities);
|
|
60
|
+
lines.push(` ${d2key(fname)}: ${d2id(type)}${required.has(fname) ? " {constraint: NOT NULL}" : ""}`);
|
|
61
|
+
if (ref) edges.push(`${d2key(name)}.${d2key(fname)} -> ${d2key(ref)}: references`);
|
|
62
|
+
}
|
|
63
|
+
if (!Object.keys(s.properties ?? {}).length) lines.push(' "(no fields)": ""');
|
|
64
|
+
lines.push("}");
|
|
65
|
+
}
|
|
66
|
+
if (!entities.size) lines.push("note: 'no components.schemas entities'");
|
|
67
|
+
return [...lines, "", ...edges].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cycleD2(doc: OpenAPIv4Document): string {
|
|
71
|
+
const cyc = buildCycle(doc);
|
|
72
|
+
const layers = cyc.layers.filter((l) => l.id !== "document"); // document is the hub itself, drawn as `contract`
|
|
73
|
+
return [
|
|
74
|
+
"# Suluk — one v4 contract projected into every layer",
|
|
75
|
+
"direction: down",
|
|
76
|
+
"",
|
|
77
|
+
// the hub is named `_hub` (NOT `contract`) so it can't collide with the "contract" (API) layer node
|
|
78
|
+
`_hub: {label: "v4 contract\\n${d2label(doc.info?.title ?? "")}"; shape: document; style.bold: true}`,
|
|
79
|
+
...layers.map((l) => `${l.id}: {label: "${d2label(l.title)}\\n${d2label(l.summary)}"; shape: rectangle}`),
|
|
80
|
+
"",
|
|
81
|
+
...layers.map((l) => `_hub -> ${l.id}`),
|
|
82
|
+
].join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function operationsD2(doc: OpenAPIv4Document): string {
|
|
86
|
+
const ops = buildAda(doc).operations;
|
|
87
|
+
const byTag = new Map<string, { name: string; method: string; path: string }[]>();
|
|
88
|
+
for (const o of ops) {
|
|
89
|
+
const tag = (o.request.tags?.[0] as string | undefined) ?? "untagged";
|
|
90
|
+
(byTag.get(tag) ?? byTag.set(tag, []).get(tag)!).push({ name: o.name, method: o.request.method.toUpperCase(), path: o.pathTemplate });
|
|
91
|
+
}
|
|
92
|
+
const lines: string[] = ["# Suluk — operation surface, grouped by tag", "direction: right", ""];
|
|
93
|
+
for (const [tag, list] of byTag) {
|
|
94
|
+
lines.push(`${d2key(tag)}: {`, " shape: package");
|
|
95
|
+
for (const o of list) lines.push(` ${d2key(o.name)}: {label: "${d2label(`${o.method} ${o.path}`)}"}`);
|
|
96
|
+
lines.push("}");
|
|
97
|
+
}
|
|
98
|
+
if (!ops.length) lines.push("note: 'no operations'");
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Generate D2 diagram source for a view of the contract. */
|
|
103
|
+
export function contractToD2(doc: OpenAPIv4Document, view: DiagramView): string {
|
|
104
|
+
switch (view) {
|
|
105
|
+
case "erd": return erdD2(doc);
|
|
106
|
+
case "cycle": return cycleD2(doc);
|
|
107
|
+
case "operations": return operationsD2(doc);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { diffContracts, canonical, type ContractDiff, type ChangedOp, type OpRef
|
|
|
16
16
|
export { crossCut, documentScopes, defaultViewers, type Viewer, type ViewerView, type GatedOp, type CrossCut } from "./crosscut";
|
|
17
17
|
// converge: a coherence audit over a whole contract — the cross-cutting contradictions a clean merge leaves behind.
|
|
18
18
|
export { convergeContract, type ConvergeReport, type ConvergeFinding, type ConvergeCode } from "./converge";
|
|
19
|
+
// diagrams: D2 source for views of the contract (ERD / the declarative cycle / the operation surface) — another projection.
|
|
20
|
+
export { contractToD2, diagramViews, type DiagramView } from "./diagram";
|
|
19
21
|
// cost formatting, re-exported so the extension shell can render a live /cost ledger without a direct @suluk/cost dep.
|
|
20
22
|
export { formatMicroUsd, summarize, type CostSummary } from "@suluk/cost";
|
|
21
23
|
// modules (C021): install a contract fragment into the hub doc — the cockpit then re-projects it for free.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { parseDocument } from "@suluk/core";
|
|
3
|
+
import { installModule, ECOMMERCE } from "@suluk/builder";
|
|
4
|
+
import { contractToD2, diagramViews } from "../src/diagram";
|
|
5
|
+
import { generateAppFiles } from "../src/builder";
|
|
6
|
+
|
|
7
|
+
const host = () => parseDocument(`openapi: 4.0.0-candidate
|
|
8
|
+
info: { title: Shop, version: 1.0.0 }
|
|
9
|
+
paths: {}
|
|
10
|
+
components: { schemas: { User: { type: object, required: [ email ], properties: { id: { type: integer }, email: { type: string } } } } }`);
|
|
11
|
+
|
|
12
|
+
describe("diagramViews", () => {
|
|
13
|
+
test("lists the available views", () => {
|
|
14
|
+
expect(diagramViews().map((v) => v.id)).toEqual(["erd", "cycle", "operations"]);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("contractToD2 — ERD", () => {
|
|
19
|
+
const doc = installModule(host(), ECOMMERCE).doc; // User + Product + Order (Order.customer -> User)
|
|
20
|
+
const d2 = contractToD2(doc, "erd");
|
|
21
|
+
test("emits a sql_table per entity", () => {
|
|
22
|
+
expect(d2).toContain("User: {");
|
|
23
|
+
expect(d2).toContain("Order: {");
|
|
24
|
+
expect(d2).toContain("shape: sql_table");
|
|
25
|
+
});
|
|
26
|
+
test("draws the cross-entity reference as an edge", () => {
|
|
27
|
+
expect(d2).toContain("Order.customer -> User: references");
|
|
28
|
+
});
|
|
29
|
+
test("marks required fields NOT NULL", () => {
|
|
30
|
+
expect(d2).toContain("NOT NULL"); // Order.items / Product.name are required
|
|
31
|
+
});
|
|
32
|
+
test("quotes a field whose name isn't a bare word", () => {
|
|
33
|
+
const odd = parseDocument(`openapi: 4.0.0-candidate
|
|
34
|
+
info: { title: T, version: 1.0.0 }
|
|
35
|
+
paths: {}
|
|
36
|
+
components: { schemas: { "Weird Name": { type: object, properties: { "a-b": { type: string } } } } }`);
|
|
37
|
+
const e = contractToD2(odd, "erd");
|
|
38
|
+
expect(e).toContain('"Weird Name": {');
|
|
39
|
+
expect(e).toContain('"a-b"');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("contractToD2 — cycle + operations", () => {
|
|
44
|
+
const doc = installModule(host(), ECOMMERCE).doc;
|
|
45
|
+
test("the cycle view links a distinct hub to its projected layers (no contract/contract collision or self-loop)", () => {
|
|
46
|
+
const d2 = contractToD2(doc, "cycle");
|
|
47
|
+
expect(d2).toContain("_hub: {"); // the document-shaped hub, named so it can't collide with the contract layer
|
|
48
|
+
expect(d2).toContain("_hub -> data");
|
|
49
|
+
expect(d2).toContain("_hub -> cost");
|
|
50
|
+
expect(d2).not.toContain("contract -> contract"); // no self-loop
|
|
51
|
+
expect(d2).toContain("contract: {"); // the Contract (API) layer still renders as its own node
|
|
52
|
+
});
|
|
53
|
+
test("a schema field named a D2 reserved keyword (shape) is quoted, not a directive", () => {
|
|
54
|
+
const odd = parseDocument(`openapi: 4.0.0-candidate
|
|
55
|
+
info: { title: T, version: 1.0.0 }
|
|
56
|
+
paths: {}
|
|
57
|
+
components: { schemas: { Box: { type: object, properties: { shape: { type: string }, label: { type: string } } } } }`);
|
|
58
|
+
const e = contractToD2(odd, "erd");
|
|
59
|
+
expect(e).toContain('"shape": string'); // quoted as a field, not a bare `shape:` directive
|
|
60
|
+
expect(e).toContain('"label": string');
|
|
61
|
+
});
|
|
62
|
+
test("the operations view groups operations and shows method + path", () => {
|
|
63
|
+
const d2 = contractToD2(doc, "operations");
|
|
64
|
+
expect(d2).toContain("shape: package");
|
|
65
|
+
expect(d2).toContain("listProduct");
|
|
66
|
+
expect(d2).toContain("POST"); // createProduct etc.
|
|
67
|
+
});
|
|
68
|
+
test("the GENERATED APP ships the contract's diagrams (docs/*.d2 + a README)", () => {
|
|
69
|
+
const files = generateAppFiles(doc);
|
|
70
|
+
const paths = files.map((f) => f.path);
|
|
71
|
+
for (const v of diagramViews()) expect(paths).toContain(`docs/${v.id}.d2`);
|
|
72
|
+
expect(paths).toContain("docs/README.md");
|
|
73
|
+
const erd = files.find((f) => f.path === "docs/erd.d2")!;
|
|
74
|
+
expect(erd.content).toContain("shape: sql_table"); // the app's own data model, as D2
|
|
75
|
+
});
|
|
76
|
+
test("an empty contract still produces valid (non-empty) D2 for every view", () => {
|
|
77
|
+
const empty = parseDocument(`openapi: 4.0.0-candidate
|
|
78
|
+
info: { title: E, version: 1.0.0 }
|
|
79
|
+
paths: {}
|
|
80
|
+
components: { schemas: {} }`);
|
|
81
|
+
for (const v of diagramViews()) expect(contractToD2(empty, v.id).length).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
});
|