@suluk/cockpit 0.1.0 → 0.1.2

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.0",
3
+ "version": "0.1.2",
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/drift.ts ADDED
Binary file
package/src/index.ts CHANGED
@@ -10,3 +10,9 @@ export { buildBuilderModel, builderTree, entitiesFromDoc, generateAppFiles, gene
10
10
  export { entityNames, generateForm, generateTable, generateStoresModule, exportV4Json } from "./codegen";
11
11
  export { deployPlan, deployMarkdown } from "./deploy";
12
12
  export type { DeployPlan, DeployStep, DeployProvider } from "@suluk/deploy";
13
+ // drift (OBSERVE): compare a LOCAL contract against a DEPLOYED one — the "what's drifted in prod" view (C020).
14
+ export { diffContracts, canonical, type ContractDiff, type ChangedOp, type OpRef } from "./drift";
15
+ // cost formatting, re-exported so the extension shell can render a live /cost ledger without a direct @suluk/cost dep.
16
+ export { formatMicroUsd, summarize, type CostSummary } from "@suluk/cost";
17
+ // modules (C021): install a contract fragment into the hub doc — the cockpit then re-projects it for free.
18
+ export { installModule, namespaceModule, ECOMMERCE, type SulukModule, type InstallResult } from "@suluk/builder";
@@ -0,0 +1,152 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseDocument } from "@suluk/core";
3
+ import { diffContracts, canonical } from "../src/drift";
4
+
5
+ // A small but realistic v4 contract: two ops + one schema. We mutate copies of it to provoke each drift kind.
6
+ const BASE = `openapi: 4.0.0-candidate
7
+ info: { title: T, version: 1.0.0 }
8
+ paths:
9
+ "project":
10
+ requests:
11
+ listProject:
12
+ method: get
13
+ responses: { ok: { status: 200, contentType: application/json, contentSchema: { type: array, items: { $ref: "#/components/schemas/Project" } } } }
14
+ createProject:
15
+ method: post
16
+ security: [ { bearerAuth: [ "write:project" ] } ]
17
+ contentSchema: { $ref: "#/components/schemas/Project" }
18
+ responses: { created: { status: 201 } }
19
+ components:
20
+ schemas:
21
+ Project: { type: object, required: [ name ], properties: { id: { type: integer }, name: { type: string } } }
22
+ `;
23
+ const doc = (s: string) => parseDocument(s);
24
+
25
+ describe("diffContracts", () => {
26
+ test("identical contracts ⇒ no drift", () => {
27
+ const d = diffContracts(doc(BASE), doc(BASE));
28
+ expect(d.identical).toBe(true);
29
+ expect(d.summary).toContain("in sync");
30
+ expect(d.operations.added).toHaveLength(0);
31
+ expect(d.operations.changed).toHaveLength(0);
32
+ expect(d.schemas.changed).toHaveLength(0);
33
+ });
34
+
35
+ test("key reordering is NOT drift (canonical comparison)", () => {
36
+ const reordered = `openapi: 4.0.0-candidate
37
+ info: { version: 1.0.0, title: T }
38
+ paths:
39
+ "project":
40
+ requests:
41
+ listProject:
42
+ responses: { ok: { contentType: application/json, status: 200, contentSchema: { items: { $ref: "#/components/schemas/Project" }, type: array } } }
43
+ method: get
44
+ createProject:
45
+ responses: { created: { status: 201 } }
46
+ method: post
47
+ contentSchema: { $ref: "#/components/schemas/Project" }
48
+ security: [ { bearerAuth: [ "write:project" ] } ]
49
+ components:
50
+ schemas:
51
+ Project: { properties: { name: { type: string }, id: { type: integer } }, required: [ name ], type: object }
52
+ `;
53
+ expect(diffContracts(doc(BASE), doc(reordered)).identical).toBe(true);
54
+ });
55
+
56
+ test("an op present locally but not deployed ⇒ added (not yet shipped)", () => {
57
+ // deployed = BASE without createProject
58
+ const deployed = BASE.replace(/ createProject:[\s\S]*?responses: \{ created: \{ status: 201 \} \}\n/, "");
59
+ const d = diffContracts(doc(BASE), doc(deployed));
60
+ expect(d.identical).toBe(false);
61
+ expect(d.operations.added.map((o) => o.name)).toEqual(["createProject"]);
62
+ expect(d.operations.added[0].detail).toBe("POST project");
63
+ expect(d.operations.removed).toHaveLength(0);
64
+ });
65
+
66
+ test("an op deployed but gone locally ⇒ removed (prod still serves it)", () => {
67
+ const local = BASE.replace(/ createProject:[\s\S]*?responses: \{ created: \{ status: 201 \} \}\n/, "");
68
+ const d = diffContracts(doc(local), doc(BASE));
69
+ expect(d.operations.removed.map((o) => o.name)).toEqual(["createProject"]);
70
+ expect(d.operations.added).toHaveLength(0);
71
+ });
72
+
73
+ test("a changed method/scope/response ⇒ changed with named field deltas", () => {
74
+ // local bumps createProject to PUT, drops the scope, adds a 400 response
75
+ const local = BASE
76
+ .replace(" createProject:\n method: post", " createProject:\n method: put")
77
+ .replace(' security: [ { bearerAuth: [ "write:project" ] } ]\n', "")
78
+ .replace("responses: { created: { status: 201 } }", "responses: { created: { status: 201 }, bad: { status: 400 } }");
79
+ const d = diffContracts(doc(local), doc(BASE));
80
+ const c = d.operations.changed.find((o) => o.name === "createProject")!;
81
+ expect(c).toBeDefined();
82
+ expect(c.changes.some((x) => x.includes("method"))).toBe(true);
83
+ expect(c.changes.some((x) => x.includes("scopes"))).toBe(true);
84
+ expect(c.changes.some((x) => x.includes("responses"))).toBe(true);
85
+ });
86
+
87
+ test("schema add / remove / change are tracked independently", () => {
88
+ const local = BASE
89
+ .replace("name: { type: string }", "name: { type: string }, slug: { type: string }") // change Project
90
+ .replace("components:\n schemas:", "components:\n schemas:\n Tag: { type: object, properties: { label: { type: string } } }"); // add Tag
91
+ const d = diffContracts(doc(local), doc(BASE));
92
+ expect(d.schemas.added).toContain("Tag");
93
+ expect(d.schemas.changed).toContain("Project");
94
+ expect(d.schemas.removed).toHaveLength(0);
95
+ });
96
+
97
+ // regression — operations named the same across DIFFERENT paths must not collide (the C009 name is unique
98
+ // only within one pathItem). Keying by bare name silently hid drift; identity is pathTemplate + name.
99
+ const TWOPATH = `openapi: 4.0.0-candidate
100
+ info: { title: T, version: 1.0.0 }
101
+ paths:
102
+ "project": { requests: { get: { method: get, responses: { ok: { status: 200 } } } } }
103
+ "user": { requests: { get: { method: get, responses: { ok: { status: 200 } } } } }
104
+ `;
105
+ test("cross-path same-name op REMOVED is reported (not masked by a sibling)", () => {
106
+ const deployed = TWOPATH.replace(/ "user":[\s\S]*\n/, ""); // drop the user path
107
+ const d = diffContracts(doc(TWOPATH), doc(deployed));
108
+ expect(d.identical).toBe(false);
109
+ expect(d.operations.added.map((o) => o.detail)).toContain("GET user");
110
+ // and the project/get sibling must NOT be falsely reported as changed
111
+ expect(d.operations.changed).toHaveLength(0);
112
+ });
113
+ test("cross-path same-name op CHANGED is not hidden by an identical sibling", () => {
114
+ // local drifts ONLY project/get (200→500); user/get stays identical
115
+ const local = TWOPATH.replace('"project": { requests: { get: { method: get, responses: { ok: { status: 200 } } } } }', '"project": { requests: { get: { method: get, responses: { ok: { status: 500 } } } } }');
116
+ const d = diffContracts(doc(local), doc(TWOPATH));
117
+ expect(d.identical).toBe(false); // must NOT report "in sync"
118
+ const c = d.operations.changed.filter((o) => o.detail === "GET project");
119
+ expect(c).toHaveLength(1);
120
+ expect(c[0].changes.some((x) => x.includes("responses"))).toBe(true);
121
+ });
122
+ test("response status 200 (number) vs \"200\" (string) is NOT drift", () => {
123
+ const numStatus = `openapi: 4.0.0-candidate
124
+ info: { title: T, version: 1.0.0 }
125
+ paths: { "p": { requests: { g: { method: get, responses: { ok: { status: 200 } } } } } }
126
+ `;
127
+ const strStatus = numStatus.replace("status: 200", 'status: "200"');
128
+ expect(diffContracts(doc(numStatus), doc(strStatus)).identical).toBe(true);
129
+ });
130
+ test("deeper response-body drift is caught even alongside a named change", () => {
131
+ const base = `openapi: 4.0.0-candidate
132
+ info: { title: T, version: 1.0.0 }
133
+ paths: { "p": { requests: { g: { method: get, responses: { ok: { status: 200 } } } } } }
134
+ `;
135
+ // local changes method AND adds a content schema to the same response
136
+ const local = base.replace("g: { method: get, responses: { ok: { status: 200 } } }", "g: { method: post, responses: { ok: { status: 200, contentSchema: { type: object } } } }");
137
+ const c = diffContracts(doc(local), doc(base)).operations.changed.find((o) => o.name === "g")!;
138
+ expect(c.changes.some((x) => x.includes("method"))).toBe(true);
139
+ expect(c.changes.some((x) => x.includes("shape"))).toBe(true); // the residual is not suppressed
140
+ });
141
+
142
+ test("canonical() is cycle-safe (does not overflow the stack)", () => {
143
+ const a: Record<string, unknown> = { x: 1 };
144
+ a.self = a;
145
+ expect(() => canonical(a)).not.toThrow();
146
+ });
147
+
148
+ test("canonical() is stable under key permutation", () => {
149
+ expect(canonical({ b: 1, a: { y: 2, x: 3 } })).toBe(canonical({ a: { x: 3, y: 2 }, b: 1 }));
150
+ expect(canonical([1, 2, 3])).not.toBe(canonical([3, 2, 1])); // array order IS significant
151
+ });
152
+ });
@@ -0,0 +1,46 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import type { OpenAPIv4Document, PathItem } from "@suluk/core";
3
+ import { installModule, ECOMMERCE } from "@suluk/builder";
4
+ import { buildCycle } from "../src/cycle";
5
+
6
+ // The whole point of a module being a contract fragment: after install, EVERY cockpit layer re-projects to
7
+ // include the new entities/operations/cost — with no change to the cockpit. This test is the witness.
8
+ const host = (): OpenAPIv4Document => ({
9
+ openapi: "4.0.0-candidate",
10
+ info: { title: "Host", version: "1.0.0" },
11
+ paths: { user: { requests: { listUser: { method: "get", responses: { ok: { status: 200 } } } } } } as Record<string, PathItem>,
12
+ components: { schemas: { User: { type: "object", properties: { id: { type: "integer" }, email: { type: "string" } } } } },
13
+ });
14
+
15
+ describe("installModule → the cockpit cycle lights up", () => {
16
+ const before = buildCycle(host());
17
+ const installed = installModule(host(), ECOMMERCE);
18
+ const after = buildCycle(installed.doc);
19
+ const layer = (m: typeof after, id: string) => m.layers.find((l) => l.id === id)!;
20
+
21
+ test("the host alone has only User", () => {
22
+ expect(layer(before, "data").items.map((i) => i.label)).toEqual(["User"]);
23
+ });
24
+ test("after install, the data layer gains Product + Order", () => {
25
+ const entities = layer(after, "data").items.map((i) => i.label).sort();
26
+ expect(entities).toEqual(["Order", "Product", "User"]);
27
+ });
28
+ test("the contract layer gains the module's operations (incl. checkout)", () => {
29
+ const ops = layer(after, "contract").items.map((i) => i.label);
30
+ expect(ops).toContain("listProduct");
31
+ expect(ops).toContain("createOrder");
32
+ expect(ops).toContain("checkoutOrder");
33
+ });
34
+ test("the cost layer prices the module's operations", () => {
35
+ const costed = layer(after, "cost").items.map((i) => i.label);
36
+ expect(costed).toContain("createOrder");
37
+ expect(costed).toContain("checkoutOrder");
38
+ });
39
+ test("the UI layer derives a form/table for the new entities", () => {
40
+ const ui = layer(after, "ui").items.map((i) => i.label).sort();
41
+ expect(ui).toEqual(["Order", "Product", "User"]);
42
+ });
43
+ test("the document still validates against the v4 meta-schema after the merge", () => {
44
+ expect(after.valid).toBe(true);
45
+ });
46
+ });