@suluk/cockpit 0.1.8 → 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.8",
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",
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Contract converge — a coherence audit over a WHOLE contract (the burhan-converge spirit). installModule
3
+ * refuses NAME collisions at merge time; converge catches the cross-cutting CONTRADICTIONS that survive a clean
4
+ * merge: an operation requiring a scope no security scheme declares, a security requirement naming a scheme
5
+ * that isn't defined, a $ref to a schema nothing provides, a path with no operations, an entity referenced by
6
+ * nothing. Run it after composing modules to know the merged platform is self-consistent, not just collision-free.
7
+ * Pure (no host) → unit-tested.
8
+ */
9
+ import { buildAda } from "@suluk/core";
10
+ import type { OpenAPIv4Document, Request, SchemaOrRef, SecurityRequirement } from "@suluk/core";
11
+ import { schemaRefName } from "@suluk/builder";
12
+
13
+ export type ConvergeCode = "dangling-ref" | "undeclared-scheme" | "orphan-scope" | "empty-path" | "unreferenced-entity";
14
+
15
+ export interface ConvergeFinding {
16
+ code: ConvergeCode;
17
+ severity: "error" | "warn" | "info";
18
+ message: string;
19
+ where?: string;
20
+ }
21
+ export interface ConvergeReport {
22
+ findings: ConvergeFinding[];
23
+ /** true ⇒ no error-severity findings — the contract is self-consistent */
24
+ clean: boolean;
25
+ }
26
+
27
+ const hasOwn = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);
28
+
29
+ /**
30
+ * The scope universe a scheme allows, for orphan-scope checking — or NULL when the scopes are not LOCALLY
31
+ * knowable (openIdConnect defines its scopes in a remote discovery document), so converge must not flag them.
32
+ * oauth2 → the union of its flows' declared scopes; apiKey/http/mutualTLS → the empty set (they declare none).
33
+ */
34
+ function scopeUniverse(scheme: unknown): Set<string> | null {
35
+ const s = scheme as { type?: string; flows?: Record<string, { scopes?: Record<string, string> }> };
36
+ if (s?.type === "openIdConnect") return null; // scopes live in the OIDC discovery doc — not locally checkable
37
+ if (s?.type !== "oauth2") return new Set();
38
+ const out = new Set<string>();
39
+ for (const flow of Object.values(s.flows ?? {})) for (const name of Object.keys(flow?.scopes ?? {})) out.add(name);
40
+ return out;
41
+ }
42
+
43
+ /** Every schema name targeted by a `#/components/schemas/X` $ref anywhere in the document (deep/escaped-safe). */
44
+ function referencedSchemas(doc: OpenAPIv4Document): Set<string> {
45
+ const names = new Set<string>();
46
+ const walk = (v: unknown): void => {
47
+ if (Array.isArray(v)) { v.forEach(walk); return; }
48
+ if (v && typeof v === "object") {
49
+ const o = v as Record<string, unknown>;
50
+ if (typeof o.$ref === "string") { const name = schemaRefName(o.$ref); if (name) names.add(name); }
51
+ for (const val of Object.values(o)) walk(val);
52
+ }
53
+ };
54
+ walk(doc);
55
+ return names;
56
+ }
57
+
58
+ /** Audit a contract for coherence contradictions a clean merge can still leave behind. */
59
+ export function convergeContract(doc: OpenAPIv4Document): ConvergeReport {
60
+ const findings: ConvergeFinding[] = [];
61
+ const schemas = (doc.components?.schemas ?? {}) as Record<string, SchemaOrRef>;
62
+ const schemes = (doc.components?.securitySchemes ?? {}) as Record<string, unknown>;
63
+
64
+ // dangling $refs — a reference to a schema nothing provides
65
+ const referenced = referencedSchemas(doc);
66
+ for (const name of referenced) if (!hasOwn(schemas, name)) findings.push({ code: "dangling-ref", severity: "error", message: `$ref to schema "${name}" — nothing provides it`, where: name });
67
+
68
+ // security coherence — every required scheme is declared, and every required scope is declared by that scheme.
69
+ // Covers BOTH path operations AND webhooks (both carry security; the dangling-ref walk already covers webhooks).
70
+ const secured = [
71
+ ...buildAda(doc).operations.map((o) => ({ name: o.name, security: o.request.security as SecurityRequirement[] | undefined })),
72
+ ...Object.entries((doc as { webhooks?: Record<string, Request> }).webhooks ?? {}).map(([name, req]) => ({ name, security: req.security as SecurityRequirement[] | undefined })),
73
+ ];
74
+ for (const op of secured) {
75
+ for (const req of op.security ?? []) {
76
+ for (const [schemeName, scopes] of Object.entries(req)) {
77
+ if (!hasOwn(schemes, schemeName)) {
78
+ findings.push({ code: "undeclared-scheme", severity: "error", message: `operation "${op.name}" requires security scheme "${schemeName}", which is not declared in components.securitySchemes`, where: op.name });
79
+ continue;
80
+ }
81
+ const universe = scopeUniverse(schemes[schemeName]);
82
+ if (universe && Array.isArray(scopes)) for (const s of scopes) if (!universe.has(s)) findings.push({ code: "orphan-scope", severity: "error", message: `operation "${op.name}" requires scope "${s}", which scheme "${schemeName}" does not declare`, where: op.name });
83
+ }
84
+ }
85
+ }
86
+
87
+ // empty path items — a path that declares no operations
88
+ for (const [p, pi] of Object.entries(doc.paths ?? {})) {
89
+ if (!Object.keys((pi as { requests?: object }).requests ?? {}).length) findings.push({ code: "empty-path", severity: "error", message: `path "${p}" declares no operations`, where: p });
90
+ }
91
+
92
+ // unreferenced entities — a schema nothing $refs (dead weight; info, not an error)
93
+ for (const name of Object.keys(schemas)) if (!referenced.has(name)) findings.push({ code: "unreferenced-entity", severity: "info", message: `entity "${name}" is referenced by no $ref`, where: name });
94
+
95
+ return { findings, clean: !findings.some((f) => f.severity === "error") };
96
+ }
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
@@ -14,6 +14,10 @@ export type { DeployPlan, DeployStep, DeployProvider } from "@suluk/deploy";
14
14
  export { diffContracts, canonical, type ContractDiff, type ChangedOp, type OpRef, type ProviderDelta, type ProviderChange } from "./drift";
15
15
  // cross-cut (M1): one contract refracted through every viewer — the scope-gated surface, the moat.
16
16
  export { crossCut, documentScopes, defaultViewers, type Viewer, type ViewerView, type GatedOp, type CrossCut } from "./crosscut";
17
+ // converge: a coherence audit over a whole contract — the cross-cutting contradictions a clean merge leaves behind.
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";
17
21
  // cost formatting, re-exported so the extension shell can render a live /cost ledger without a direct @suluk/cost dep.
18
22
  export { formatMicroUsd, summarize, type CostSummary } from "@suluk/cost";
19
23
  // modules (C021): install a contract fragment into the hub doc — the cockpit then re-projects it for free.
@@ -0,0 +1,131 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseDocument } from "@suluk/core";
3
+ import { installModule, ECOMMERCE, composeModules, resolveTemplate, STACK_TEMPLATES } from "@suluk/builder";
4
+ import { convergeContract } from "../src/converge";
5
+
6
+ const doc = (s: string) => parseDocument(s);
7
+
8
+ describe("convergeContract — coherence over a whole contract", () => {
9
+ test("a coherent contract is clean", () => {
10
+ const d = doc(`openapi: 4.0.0-candidate
11
+ info: { title: T, version: 1.0.0 }
12
+ paths:
13
+ "pet":
14
+ requests:
15
+ listPets: { method: get, responses: { ok: { status: 200, contentSchema: { $ref: "#/components/schemas/Pet" } } } }
16
+ createPet: { method: post, security: [ { auth: [ "write:pets" ] } ], responses: { created: { status: 201 } } }
17
+ components:
18
+ schemas: { Pet: { type: object, properties: { name: { type: string } } } }
19
+ securitySchemes: { auth: { type: oauth2, flows: { implicit: { authorizationUrl: "https://x", scopes: { "write:pets": "w" } } } } }
20
+ `);
21
+ const r = convergeContract(d);
22
+ expect(r.clean).toBe(true);
23
+ expect(r.findings.filter((f) => f.severity === "error")).toHaveLength(0);
24
+ });
25
+
26
+ test("an operation requiring an UNDECLARED scope is an error", () => {
27
+ const d = doc(`openapi: 4.0.0-candidate
28
+ info: { title: T, version: 1.0.0 }
29
+ paths: { "pet": { requests: { createPet: { method: post, security: [ { auth: [ "delete:everything" ] } ], responses: { ok: { status: 201 } } } } } }
30
+ components: { securitySchemes: { auth: { type: oauth2, flows: { implicit: { authorizationUrl: "https://x", scopes: { "write:pets": "w" } } } } } }
31
+ `);
32
+ const r = convergeContract(d);
33
+ expect(r.clean).toBe(false);
34
+ expect(r.findings.some((f) => f.code === "orphan-scope" && f.message.includes("delete:everything"))).toBe(true);
35
+ });
36
+
37
+ test("an operation requiring an UNDECLARED scheme is an error", () => {
38
+ const d = doc(`openapi: 4.0.0-candidate
39
+ info: { title: T, version: 1.0.0 }
40
+ paths: { "pet": { requests: { createPet: { method: post, security: [ { ghostAuth: [] } ], responses: { ok: { status: 201 } } } } } }
41
+ components: { securitySchemes: {} }
42
+ `);
43
+ const r = convergeContract(d);
44
+ expect(r.findings.some((f) => f.code === "undeclared-scheme" && f.message.includes("ghostAuth"))).toBe(true);
45
+ expect(r.clean).toBe(false);
46
+ });
47
+
48
+ test("a $ref to a missing schema is a dangling-ref error", () => {
49
+ const d = doc(`openapi: 4.0.0-candidate
50
+ info: { title: T, version: 1.0.0 }
51
+ paths: { "pet": { requests: { getPet: { method: get, responses: { ok: { status: 200, contentSchema: { $ref: "#/components/schemas/Ghost" } } } } } } }
52
+ components: { schemas: {} }
53
+ `);
54
+ const r = convergeContract(d);
55
+ expect(r.findings.some((f) => f.code === "dangling-ref" && f.where === "Ghost")).toBe(true);
56
+ });
57
+
58
+ test("an unreferenced entity is reported as info (not an error)", () => {
59
+ const d = doc(`openapi: 4.0.0-candidate
60
+ info: { title: T, version: 1.0.0 }
61
+ paths: { "pet": { requests: { ping: { method: get, responses: { ok: { status: 200 } } } } } }
62
+ components: { schemas: { Orphan: { type: object } } }
63
+ `);
64
+ const r = convergeContract(d);
65
+ expect(r.clean).toBe(true); // info, not error
66
+ expect(r.findings.some((f) => f.code === "unreferenced-entity" && f.where === "Orphan")).toBe(true);
67
+ });
68
+
69
+ test("an openIdConnect scheme's scopes are NOT locally checkable — no false orphan-scope", () => {
70
+ const d = doc(`openapi: 4.0.0-candidate
71
+ info: { title: T, version: 1.0.0 }
72
+ paths: { "x": { requests: { getX: { method: get, security: [ { oidc: [ "profile", "email" ] } ], responses: { ok: { status: 200 } } } } } }
73
+ components: { securitySchemes: { oidc: { type: openIdConnect, openIdConnectUrl: "https://idp/.well-known/openid-configuration" } } }
74
+ `);
75
+ const r = convergeContract(d);
76
+ expect(r.findings.filter((f) => f.code === "orphan-scope")).toHaveLength(0);
77
+ expect(r.clean).toBe(true);
78
+ });
79
+
80
+ test("a DEEP $ref (.../Order/properties/id) resolves to its root — not a false dangling-ref", () => {
81
+ const d = doc(`openapi: 4.0.0-candidate
82
+ info: { title: T, version: 1.0.0 }
83
+ paths: { "o": { requests: { getId: { method: get, responses: { ok: { status: 200, contentSchema: { $ref: "#/components/schemas/Order/properties/id" } } } } } } }
84
+ components: { schemas: { Order: { type: object, properties: { id: { type: integer } } } } }
85
+ `);
86
+ const r = convergeContract(d);
87
+ expect(r.findings.some((f) => f.code === "dangling-ref")).toBe(false);
88
+ expect(r.findings.some((f) => f.code === "unreferenced-entity" && f.where === "Order")).toBe(false); // Order IS referenced
89
+ });
90
+
91
+ test("a security issue inside a WEBHOOK is caught (not just path operations)", () => {
92
+ const d = doc(`openapi: 4.0.0-candidate
93
+ info: { title: T, version: 1.0.0 }
94
+ paths: {}
95
+ webhooks: { onEvent: { method: post, security: [ { ghostHook: [] } ], responses: { ok: { status: 200 } } } }
96
+ components: { securitySchemes: {} }
97
+ `);
98
+ expect(convergeContract(d).findings.some((f) => f.code === "undeclared-scheme" && f.message.includes("ghostHook"))).toBe(true);
99
+ });
100
+
101
+ test("a malformed (non-array) scope value never iterates character-by-character", () => {
102
+ const d = doc(`openapi: 4.0.0-candidate
103
+ info: { title: T, version: 1.0.0 }
104
+ paths: { "x": { requests: { getX: { method: get, security: [ { auth: "write" } ], responses: { ok: { status: 200 } } } } } }
105
+ components: { securitySchemes: { auth: { type: oauth2, flows: { implicit: { authorizationUrl: "https://x", scopes: { write: "w" } } } } } }
106
+ `);
107
+ const r = convergeContract(d);
108
+ expect(r.findings.filter((f) => f.code === "orphan-scope")).toHaveLength(0); // not w,r,i,t,e
109
+ });
110
+
111
+ test("a freshly composed platform (auth + ecommerce + crm) converges clean", () => {
112
+ const empty = doc(`openapi: 4.0.0-candidate
113
+ info: { title: P, version: 1.0.0 }
114
+ paths: {}
115
+ components: { schemas: {} }`);
116
+ const composed = composeModules(empty, resolveTemplate(STACK_TEMPLATES.find((t) => t.name === "Everything")!).modules);
117
+ expect(composed.ok).toBe(true);
118
+ expect(convergeContract(composed.doc).clean).toBe(true); // no dangling refs, no orphan scopes across modules
119
+ });
120
+
121
+ test("a module installed WITHOUT its required entity would dangle — but install refuses it; converge catches a hand-built dangle", () => {
122
+ // installModule refuses ecommerce without User; convergeContract independently flags a doc someone hand-merged wrong
123
+ const broken = installModule(doc(`openapi: 4.0.0-candidate
124
+ info: { title: H, version: 1.0.0 }
125
+ paths: {}
126
+ components: { schemas: { User: { type: object } } }`), ECOMMERCE).doc;
127
+ // remove User after the fact (simulating a bad hand-edit) → Order.customer now dangles
128
+ delete (broken.components!.schemas as Record<string, unknown>).User;
129
+ expect(convergeContract(broken).findings.some((f) => f.code === "dangling-ref" && f.where === "User")).toBe(true);
130
+ });
131
+ });
@@ -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
+ });