@suluk/cockpit 0.1.9 → 0.1.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/cockpit",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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/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,74 @@
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
+
6
+ const host = () => parseDocument(`openapi: 4.0.0-candidate
7
+ info: { title: Shop, version: 1.0.0 }
8
+ paths: {}
9
+ components: { schemas: { User: { type: object, required: [ email ], properties: { id: { type: integer }, email: { type: string } } } } }`);
10
+
11
+ describe("diagramViews", () => {
12
+ test("lists the available views", () => {
13
+ expect(diagramViews().map((v) => v.id)).toEqual(["erd", "cycle", "operations"]);
14
+ });
15
+ });
16
+
17
+ describe("contractToD2 — ERD", () => {
18
+ const doc = installModule(host(), ECOMMERCE).doc; // User + Product + Order (Order.customer -> User)
19
+ const d2 = contractToD2(doc, "erd");
20
+ test("emits a sql_table per entity", () => {
21
+ expect(d2).toContain("User: {");
22
+ expect(d2).toContain("Order: {");
23
+ expect(d2).toContain("shape: sql_table");
24
+ });
25
+ test("draws the cross-entity reference as an edge", () => {
26
+ expect(d2).toContain("Order.customer -> User: references");
27
+ });
28
+ test("marks required fields NOT NULL", () => {
29
+ expect(d2).toContain("NOT NULL"); // Order.items / Product.name are required
30
+ });
31
+ test("quotes a field whose name isn't a bare word", () => {
32
+ const odd = parseDocument(`openapi: 4.0.0-candidate
33
+ info: { title: T, version: 1.0.0 }
34
+ paths: {}
35
+ components: { schemas: { "Weird Name": { type: object, properties: { "a-b": { type: string } } } } }`);
36
+ const e = contractToD2(odd, "erd");
37
+ expect(e).toContain('"Weird Name": {');
38
+ expect(e).toContain('"a-b"');
39
+ });
40
+ });
41
+
42
+ describe("contractToD2 — cycle + operations", () => {
43
+ const doc = installModule(host(), ECOMMERCE).doc;
44
+ test("the cycle view links a distinct hub to its projected layers (no contract/contract collision or self-loop)", () => {
45
+ const d2 = contractToD2(doc, "cycle");
46
+ expect(d2).toContain("_hub: {"); // the document-shaped hub, named so it can't collide with the contract layer
47
+ expect(d2).toContain("_hub -> data");
48
+ expect(d2).toContain("_hub -> cost");
49
+ expect(d2).not.toContain("contract -> contract"); // no self-loop
50
+ expect(d2).toContain("contract: {"); // the Contract (API) layer still renders as its own node
51
+ });
52
+ test("a schema field named a D2 reserved keyword (shape) is quoted, not a directive", () => {
53
+ const odd = parseDocument(`openapi: 4.0.0-candidate
54
+ info: { title: T, version: 1.0.0 }
55
+ paths: {}
56
+ components: { schemas: { Box: { type: object, properties: { shape: { type: string }, label: { type: string } } } } }`);
57
+ const e = contractToD2(odd, "erd");
58
+ expect(e).toContain('"shape": string'); // quoted as a field, not a bare `shape:` directive
59
+ expect(e).toContain('"label": string');
60
+ });
61
+ test("the operations view groups operations and shows method + path", () => {
62
+ const d2 = contractToD2(doc, "operations");
63
+ expect(d2).toContain("shape: package");
64
+ expect(d2).toContain("listProduct");
65
+ expect(d2).toContain("POST"); // createProduct etc.
66
+ });
67
+ test("an empty contract still produces valid (non-empty) D2 for every view", () => {
68
+ const empty = parseDocument(`openapi: 4.0.0-candidate
69
+ info: { title: E, version: 1.0.0 }
70
+ paths: {}
71
+ components: { schemas: {} }`);
72
+ for (const v of diagramViews()) expect(contractToD2(empty, v.id).length).toBeGreaterThan(0);
73
+ });
74
+ });