@suluk/harden 0.1.1 → 0.2.0

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/README.md ADDED
@@ -0,0 +1,161 @@
1
+ <p align="center">
2
+ <a href="https://github.com/MahmoodKhalil57/suluk">
3
+ <img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
4
+ </a>
5
+ </p>
6
+
7
+ # @suluk/harden
8
+
9
+ **Schema hardening as a derived, scored contract facet — grade a v4 document's input schemas A–F, fail CI below a floor, and fill the gaps with one transform.**
10
+
11
+ > **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
12
+ > OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
13
+ > to ratify anything on the SIG's behalf.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ bun add @suluk/harden
19
+ ```
20
+
21
+ ## What it does
22
+
23
+ It audits the **input** surface of an OpenAPI v4 "Suluk" document — every request body and typed parameter slot (`path` / `query` / `header` / `cookie` / `body`) — for the validations that keep malformed or oversized input from breaking the system:
24
+
25
+ - **no `any`/`unknown`** — every input has a determinable `type` (or is bounded by `enum`/`const`);
26
+ - **every string** a `maxLength` **and** a `pattern` (a character allowlist) — unless bounded by `format`/`enum`/`const`;
27
+ - **every number** a `maximum` (and ideally a `minimum`);
28
+ - **every array** a `maxItems` (a DoS guard);
29
+ - **every object** closed (`additionalProperties: false`) and typed (defined `properties`).
30
+
31
+ It scores each operation and the whole document (0–100 → letter grade), emits concrete `Finding`s with a `fix` string, and gives you a **CI gate** (`assertGrade`) so a regression below the floor throws. The inverse half — `hardenSchema` / `hardenDocument` — is the transform that *fills* those gaps with sensible baseline bounds, turning an F/D contract into a B with one call (authors then tighten per field).
32
+
33
+ `$ref`'d models are walked once and deduped across operations, so a shared schema is audited (and reported) a single time.
34
+
35
+ ## When to reach for it
36
+
37
+ - You build a v4 document (from `@suluk/drizzle`, `@suluk/hono`, or by hand) and want **input-validation coverage** scored and visible — to incentivise hardening rather than hope for it.
38
+ - You want to **gate CI**: `assertGrade(doc, "A")` in a test throws if the authored surface regresses below the floor.
39
+ - You want to **auto-tighten** loose schemas: run `hardenDocument` over a built document to add baseline bounds doc-wide (including the route generator's otherwise-unbounded path-param strings).
40
+
41
+ This package only *reads and rewrites* the schemas inside a v4 document. It does not validate live requests (that's the wire / `@suluk/core`), and it does not render the grade into a UI — the `@suluk/reference` hardening panel and the `@suluk/editor` diagnostics bar do that by calling `auditDocument` themselves.
42
+
43
+ ## Usage
44
+
45
+ ### Audit — grade the input surface
46
+
47
+ ```ts
48
+ import { auditDocument } from "@suluk/harden";
49
+
50
+ const report = auditDocument(doc); // doc: OpenAPIv4Document
51
+ report.grade; // "A" | "B" | "C" | "D" | "F"
52
+ report.score; // 0–100
53
+ report.bySeverity; // { high, medium, low } — counts across all findings
54
+ report.byOperation; // per-operation audits, weakest first
55
+ report.findings; // Finding[] — { rule, severity, path, message, fix }, deduped by rule@path
56
+
57
+ for (const f of report.findings) {
58
+ console.log(`${f.severity.toUpperCase()} ${f.path}: ${f.message} → ${f.fix}`);
59
+ }
60
+ ```
61
+
62
+ Audit a single operation, or skip surfaces you don't author (e.g. a merged third-party auth surface) so they don't count toward the grade:
63
+
64
+ ```ts
65
+ import { auditOperation, auditDocument } from "@suluk/harden";
66
+
67
+ // One operation: auditOperation(doc, uri, operationName, rawRequest)
68
+ const op = auditOperation(doc, "/users", "createUser", req);
69
+
70
+ // Exclude ingested surfaces from the rollup:
71
+ const report = auditDocument(doc, {
72
+ ignore: (uri, name) => uri.toLowerCase().includes("auth"),
73
+ });
74
+ ```
75
+
76
+ ### Gate CI — `assertGrade`
77
+
78
+ The hard incentive: throw if the document's hardening grade falls below `min`. Returns the full `DocAudit` when it passes.
79
+
80
+ ```ts
81
+ import { test } from "bun:test";
82
+ import { assertGrade } from "@suluk/harden";
83
+
84
+ test("contract input surface stays fully bounded (grade A)", async () => {
85
+ const document = await app.request("/openapi.json").then((r) => r.json());
86
+ const audit = assertGrade(document, "A", {
87
+ ignore: (uri) => uri.toLowerCase().includes("auth"), // auth is third-party
88
+ });
89
+ // throws below A; on pass you get the report:
90
+ expect(audit.bySeverity.high).toBe(0);
91
+ });
92
+ ```
93
+
94
+ The thrown message names the worst operations and the high/medium counts, so a failing build tells the author exactly what to add.
95
+
96
+ ### Unified contract grade — combine the input-schema grade with the agent grade
97
+
98
+ A v4 document has two graded dimensions: its **input schemas** (this package) and its **agent composition**
99
+ ([`@suluk/agents`](../agents)' `gradeAgent`). `combineGrades` folds them into one contract grade — on the **letter**,
100
+ never the raw score (the two scores are non-comparable: a clean/nodes ratio vs an absolute `100 − Σ penalty`). It's a
101
+ pure combinator, so no package depends on the other — the caller passes the letters:
102
+
103
+ ```ts
104
+ import { auditDocument, combineGrades, assertCombinedGrade } from "@suluk/harden";
105
+ import { gradeAgents } from "@suluk/agents";
106
+
107
+ const grades = [auditDocument(doc).grade, ...gradeAgents(doc).map((g) => g.grade)];
108
+ combineGrades(grades); // { worst, average, grades } — `worst` is the value to GATE on (a contract is as strong as its weakest dimension)
109
+ // `average` is informational; its ties round toward the higher letter, so it only ever masks optimistically
110
+ assertCombinedGrade(grades, "B"); // CI gate on the worst dimension; pass `"average"` to soften. Pass at least the doc grade — an empty set passes vacuously.
111
+ ```
112
+
113
+ ### Harden — the inverse transform
114
+
115
+ `hardenSchema` adds baseline bounds to a single JSON Schema; `hardenDocument` does it to **every** input schema in a built v4 document, in place. Both are idempotent and **never override an author-set bound** — they only fill gaps. Strings bounded by `enum`/`const`/`format` are left alone.
116
+
117
+ ```ts
118
+ import { hardenSchema, hardenDocument } from "@suluk/harden";
119
+
120
+ // Per schema — layer it after your own per-field validations so it only fills what's left:
121
+ const schema = hardenSchema(myInsertSchema);
122
+ // strings → maxLength + a control-char-rejecting pattern; numbers → min/max;
123
+ // arrays → maxItems; objects → additionalProperties:false
124
+
125
+ // Doc-wide, in place — the transform that makes assertGrade pass:
126
+ const document = hardenDocument(builtDoc);
127
+ ```
128
+
129
+ Override the floors when the defaults (1024 chars / ±1e12 / 1000 items) are wrong for you, or pass `textPattern: null` to skip the string pattern entirely:
130
+
131
+ ```ts
132
+ import { hardenSchema, type HardenOptions } from "@suluk/harden";
133
+
134
+ const opts: HardenOptions = { maxLength: 80, numberMax: 1_000, maxItems: 50 };
135
+ const tightened = hardenSchema(schema, opts);
136
+ ```
137
+
138
+ ## API
139
+
140
+ | Export | What it does |
141
+ | --- | --- |
142
+ | `auditDocument(doc, opts?)` | Audit the whole document → `DocAudit` (per-op grades, deduped rollup, severity breakdown). |
143
+ | `auditOperation(doc, uri, name, req)` | Audit one operation's input surface → `OpAudit`. |
144
+ | `assertGrade(doc, min, opts?)` | CI gate: throw if the grade is below `min`; else return the `DocAudit`. |
145
+ | `grade(score)` | Map a 0–100 score to a letter grade (`A` ≥ 90, `B` ≥ 75, `C` ≥ 60, `D` ≥ 40, else `F`). |
146
+ | `combineGrades(grades)` / `assertCombinedGrade(grades, min, mode?)` | UNIFIED contract grade (Stage 1.5): combine this input-schema grade with `@suluk/agents`' `gradeAgent` grade on the LETTER → `{ worst, average, grades }`; gate on the worst (safe) or `"average"`. |
147
+ | `hardenSchema(schema, opts?)` | Add baseline bounds to one JSON Schema (idempotent; never overrides). |
148
+ | `hardenDocument(doc, opts?)` | Harden every input schema in a built v4 document, in place. |
149
+ | `HardenOptions` | `{ maxLength?, textPattern?, numberMax?, numberMin?, maxItems? }` — overridable floors for the transform. |
150
+
151
+ Types `Audit`, `OpAudit`, `DocAudit`, `Finding`, `Severity` (`"high" \| "medium" \| "low"`), and `Grade` (`"A" \| "B" \| "C" \| "D" \| "F"`) are exported alongside.
152
+
153
+ ## Boundary
154
+
155
+ The audit↔transform pair is the template for "ship both halves": the audit grades the gaps, `harden` fills them. Hardening is a **derived contract facet** — like `x-suluk-cost`/`x-suluk-access`/`x-suluk-source`, it is *projected from* the contract, not authored separately.
156
+
157
+ This package stays L3 — **render/generate, never host**. It reads and rewrites the schemas inside a v4 document and returns reports/transformed documents; it does not run a server, validate live wire requests, or store anything. You inject the document (the built v4 doc) and consume the result (a grade, findings, or a hardened copy). Surfacing the grade to a developer (a docs panel, an editor bar) is the consumer's job; enforcing the bounds at runtime is the wire's job.
158
+
159
+ ## License
160
+
161
+ Apache-2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/harden",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Schema HARDENING as a derived contract facet: audit a v4 'Suluk' document's input schemas for the validations that keep weird/oversized input from breaking the system — every string a maxLength + a pattern, every number a maximum, every array a maxItems, every object closed + typed, no any/unknown — score it (A-F), surface the grade to INCENTIVISE the author, and gate CI on a minimum. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,7 +19,8 @@
19
19
  ".": "./src/index.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@suluk/core": "^0.1.11"
22
+ "@suluk/core": "^0.1.13",
23
+ "@suluk/examples": "^0.1.0"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@types/bun": "latest"
package/src/audit.ts CHANGED
@@ -125,3 +125,39 @@ export function assertGrade(doc: OpenAPIv4Document, min: Grade, opts: AuditOptio
125
125
  }
126
126
  return a;
127
127
  }
128
+
129
+ // ─────────────────────────── UNIFIED CONTRACT GRADE (Stage 1.5) ───────────────────────────
130
+ // Combine grades from DIFFERENT dimensions (this package's input-schema grade + `@suluk/agents`' agent-composition
131
+ // grade `gradeAgent`) into ONE contract grade — on the LETTER (ordinal), NEVER the raw score: the two scores live on
132
+ // non-comparable scales (harden = a clean/nodes RATIO; gradeAgent = an absolute 100−Σpenalty), so only the letter is
133
+ // shared. This is a PURE combinator (no `@suluk/agents` dependency — the caller passes the letters): the unified grade
134
+ // is `combineGrades([auditDocument(doc).grade, ...gradeAgents(doc).map(g => g.grade)])`.
135
+
136
+ export interface CombinedGrade {
137
+ /** the WORST letter — a contract is as strong as its weakest graded dimension (the safe value to GATE on). */
138
+ worst: Grade;
139
+ /** the rounded-mean letter (informational — can mask a single failing dimension, so do not gate on it blindly; ties
140
+ * round toward the HIGHER letter, so the masking is always optimistic). */
141
+ average: Grade;
142
+ /** the input letters, as given. */
143
+ grades: Grade[];
144
+ }
145
+
146
+ /** Combine per-dimension letters into one contract grade (worst + average). Empty ⇒ vacuously A — a caller MUST pass at
147
+ * least the doc grade, since gating an empty set passes vacuously (`worst:"A"`). */
148
+ export function combineGrades(grades: Grade[]): CombinedGrade {
149
+ if (!grades.length) return { worst: "A", average: "A", grades: [] };
150
+ const ord = grades.map((g) => ORDER.indexOf(g));
151
+ const worst = ORDER[Math.min(...ord)]!;
152
+ const average = ORDER[Math.round(ord.reduce((a, b) => a + b, 0) / ord.length)]!;
153
+ return { worst, average, grades };
154
+ }
155
+
156
+ /** CI gate over a combined grade. Gates on the WORST dimension by default (safe); pass `mode: "average"` to soften. */
157
+ export function assertCombinedGrade(grades: Grade[], min: Grade, mode: "worst" | "average" = "worst"): CombinedGrade {
158
+ const c = combineGrades(grades);
159
+ const g = mode === "average" ? c.average : c.worst;
160
+ if (ORDER.indexOf(g) < ORDER.indexOf(min))
161
+ throw new Error(`@suluk/harden: combined contract grade ${g} (${mode} of ${grades.join(", ") || "—"}) is below the required ${min}`);
162
+ return c;
163
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,13 @@
9
9
  */
10
10
  export {
11
11
  auditDocument, auditOperation, assertGrade, grade,
12
+ // Stage 1.5: combine THIS package's input-schema grade with @suluk/agents' agent-composition grade into one
13
+ // contract grade — on the LETTER (the scores are non-comparable). Pure combinator; the caller passes the letters.
14
+ combineGrades, assertCombinedGrade, type CombinedGrade,
12
15
  type Audit, type OpAudit, type DocAudit, type Finding, type Severity, type Grade,
13
16
  } from "./audit";
14
17
  // the inverse of the audit — the hardening TRANSFORM that adds the baseline bounds the audit grades for.
15
18
  export { hardenSchema, hardenDocument, type HardenOptions } from "./harden";
19
+ // C043: a SECOND dimension — schema-fact READINESS (computed-required, request-without-example), separate from the
20
+ // security grade. Fold its letter into `combineGrades` alongside the security grade + journeys coverage.
21
+ export { auditReadiness, type ReadinessAudit, type ReadinessOptions } from "./readiness";
@@ -0,0 +1,79 @@
1
+ /**
2
+ * READINESS audit (C043) — a SECOND, separate harden dimension (kept apart from the security `auditDocument` grade so a
3
+ * security score never mixes with a readiness score). It grades schema-FACT readiness the doc can answer alone — the
4
+ * concerns C040/C041 made expressible:
5
+ * • `computed-required` — a request field marked `computed` (or `readOnly`) AND `required`: a client CANNOT send it,
6
+ * so the request can never be satisfied (a real contract bug). HIGH.
7
+ * • `request-without-example` — a request body with no curated `examples`/`example` (author one in `.meta`, or promote
8
+ * a tester `@public` row). A docs/demo readiness gap. LOW.
9
+ *
10
+ * BDD COVERAGE is the OTHER readiness gap — but it needs the `.feature` files, so `@suluk/journeys` computes it and the
11
+ * caller folds its letter in via `combineGrades` (harden never depends on journeys — the established harden+agents seam).
12
+ */
13
+ import type { OpenAPIv4Document } from "@suluk/core";
14
+ import { fieldOrigin, type JsonSchema } from "@suluk/examples";
15
+ import { grade, type Finding, type Grade } from "./audit";
16
+
17
+ export interface ReadinessAudit {
18
+ findings: Finding[];
19
+ nodes: number;
20
+ clean: number;
21
+ score: number;
22
+ grade: Grade;
23
+ }
24
+
25
+ export interface ReadinessOptions {
26
+ /** skip operations (e.g. third-party/ingested surfaces) — they don't count toward the readiness grade. */
27
+ ignore?: (uri: string, name: string) => boolean;
28
+ }
29
+
30
+ interface RawReq {
31
+ method: string;
32
+ contentSchema?: unknown;
33
+ parameterSchema?: { body?: unknown };
34
+ }
35
+
36
+ const hasExample = (s: JsonSchema): boolean => (Array.isArray(s.examples) && s.examples.length > 0) || "example" in s;
37
+
38
+ /** Audit the document's request bodies for client-sendability + example presence → findings + a readiness grade. */
39
+ export function auditReadiness(doc: OpenAPIv4Document, opts: ReadinessOptions = {}): ReadinessAudit {
40
+ const findings: Finding[] = [];
41
+ let nodes = 0;
42
+ let clean = 0;
43
+ const pass = () => {
44
+ nodes++;
45
+ clean++;
46
+ };
47
+ const fail = (f: Finding) => {
48
+ nodes++;
49
+ findings.push(f);
50
+ };
51
+
52
+ for (const [uri, piRaw] of Object.entries(doc.paths ?? {})) {
53
+ const pi = piRaw as { requests?: Record<string, RawReq> };
54
+ for (const [name, req] of Object.entries(pi.requests ?? {})) {
55
+ if (opts.ignore?.(uri, name)) continue;
56
+ const body = (req.contentSchema ?? req.parameterSchema?.body) as JsonSchema | undefined;
57
+ if (!body || typeof body !== "object") continue; // no request body → nothing to assess
58
+
59
+ // node: a curated example present?
60
+ if (hasExample(body)) pass();
61
+ else fail({ rule: "request-without-example", severity: "low", path: `${name}/body`, message: `request '${name}' has no example`, fix: "author one in .meta({ examples }), or promote a tester @public row via `journeys promote`" });
62
+
63
+ // node per REQUIRED field: is it client-sendable (not computed/readOnly)?
64
+ const props = (body.properties ?? {}) as Record<string, JsonSchema>;
65
+ const required = new Set(Array.isArray(body.required) ? (body.required as string[]) : []);
66
+ for (const [k, sub] of Object.entries(props)) {
67
+ if (!required.has(k)) continue;
68
+ if (fieldOrigin(sub) === "computed") {
69
+ fail({ rule: "computed-required", severity: "high", path: `${name}/body/${k}`, message: `required field '${k}' is computed/readOnly — a client cannot send it`, fix: "make it optional or non-computed (drop x-suluk-origin:computed / readOnly), or remove it from required" });
70
+ } else {
71
+ pass();
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ const score = nodes === 0 ? 100 : Math.round((clean / nodes) * 100);
78
+ return { findings, nodes, clean, score, grade: grade(score) };
79
+ }
@@ -1,5 +1,5 @@
1
1
  import { test, expect, describe } from "bun:test";
2
- import { auditOperation, auditDocument, assertGrade, grade, hardenSchema, hardenDocument } from "../src/index";
2
+ import { auditOperation, auditDocument, assertGrade, grade, hardenSchema, hardenDocument, combineGrades, assertCombinedGrade } from "../src/index";
3
3
  import type { OpenAPIv4Document } from "@suluk/core";
4
4
 
5
5
  const weakReq = { method: "post", contentSchema: { type: "object", properties: {
@@ -64,6 +64,33 @@ describe("@suluk/harden — schema hardening as a scored facet", () => {
64
64
  });
65
65
  });
66
66
 
67
+ describe("combineGrades — the unified contract grade (Stage 1.5: harden doc-grade × agent grade, on the LETTER)", () => {
68
+ test("worst is the weakest dimension; average is the rounded mean letter", () => {
69
+ expect(combineGrades(["A", "F"])).toEqual({ worst: "F", average: "C", grades: ["A", "F"] }); // A=4,F=0 → mean 2 → C
70
+ expect(combineGrades(["B", "B", "A"])).toEqual({ worst: "B", average: "B", grades: ["B", "B", "A"] });
71
+ expect(combineGrades(["A"])).toEqual({ worst: "A", average: "A", grades: ["A"] }); // single dimension (e.g. no agents)
72
+ });
73
+
74
+ test("empty ⇒ vacuously A (nothing graded)", () => {
75
+ expect(combineGrades([])).toEqual({ worst: "A", average: "A", grades: [] });
76
+ });
77
+
78
+ test("the unified grade composes the doc grade + every agent grade as letters (the documented bridge)", () => {
79
+ const docGrade = grade(82); // a 'B' document
80
+ const agentGrades = ["A", "C"] as const; // e.g. gradeAgents(doc).map(g => g.grade)
81
+ const unified = combineGrades([docGrade, ...agentGrades]);
82
+ expect(unified.worst).toBe("C"); // a contract is as strong as its weakest dimension
83
+ expect(unified.grades).toEqual(["B", "A", "C"]);
84
+ });
85
+
86
+ test("assertCombinedGrade gates on the WORST by default; `average` softens the gate; passing returns the combined grade", () => {
87
+ expect(() => assertCombinedGrade(["A", "F"], "B")).toThrow(/below the required B/); // worst F < B → throws
88
+ expect(() => assertCombinedGrade(["A", "F"], "B", "average")).toThrow(); // average C < B → still throws
89
+ expect(() => assertCombinedGrade(["A", "F"], "C", "average")).not.toThrow(); // average C ≥ C → passes (worst F would have failed)
90
+ expect(assertCombinedGrade(["A", "B"], "B").worst).toBe("B"); // worst B ≥ B → passes, returns the combined grade
91
+ });
92
+ });
93
+
67
94
  describe("hardenSchema / hardenDocument — the transform (inverse of the audit)", () => {
68
95
  test("adds baseline bounds: string→maxLength+pattern, number→min/max, array→maxItems, object→closed", () => {
69
96
  const h = hardenSchema({ type: "object", properties: {
@@ -0,0 +1,85 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { auditReadiness } from "../src/index";
3
+ import type { OpenAPIv4Document } from "@suluk/core";
4
+
5
+ /**
6
+ * C043 — the readiness dimension: computed-required (a required field a client can't send) + request-without-example.
7
+ * Separate from the security grade; folded into combineGrades alongside security + journeys coverage.
8
+ */
9
+ const doc = {
10
+ openapi: "4.0.0-candidate",
11
+ info: { title: "Billing" },
12
+ paths: {
13
+ "/charge": {
14
+ requests: {
15
+ charge: {
16
+ method: "post",
17
+ contentSchema: {
18
+ type: "object",
19
+ required: ["amountCents", "balance"],
20
+ properties: {
21
+ amountCents: { type: "integer" },
22
+ balance: { type: "integer", "x-suluk-origin": "computed" }, // required AND computed → a bug
23
+ },
24
+ },
25
+ },
26
+ },
27
+ },
28
+ "/good": {
29
+ requests: {
30
+ good: {
31
+ method: "post",
32
+ contentSchema: {
33
+ type: "object",
34
+ required: ["name"],
35
+ properties: { name: { type: "string" } },
36
+ examples: [{ name: "ok" }], // has an example
37
+ },
38
+ },
39
+ },
40
+ },
41
+ "/health": { requests: { health: { method: "get", responses: { "200": { status: 200 } } } } }, // no body → not assessed
42
+ },
43
+ } as unknown as OpenAPIv4Document;
44
+
45
+ describe("auditReadiness", () => {
46
+ const audit = auditReadiness(doc);
47
+ const rules = audit.findings.map((f) => f.rule);
48
+
49
+ test("flags a required computed/readOnly field as not client-sendable (high)", () => {
50
+ const f = audit.findings.find((x) => x.rule === "computed-required");
51
+ expect(f).toBeDefined();
52
+ expect(f!.severity).toBe("high");
53
+ expect(f!.path).toBe("charge/body/balance");
54
+ });
55
+
56
+ test("flags a request body with no example (low)", () => {
57
+ const f = audit.findings.find((x) => x.rule === "request-without-example" && x.path === "charge/body");
58
+ expect(f).toBeDefined();
59
+ expect(f!.severity).toBe("low");
60
+ });
61
+
62
+ test("a body WITH an example + only sendable required fields raises no finding", () => {
63
+ expect(rules).not.toContain("good/body");
64
+ expect(audit.findings.some((f) => f.path.startsWith("good/"))).toBe(false);
65
+ });
66
+
67
+ test("a body-less op is not assessed (no nodes for it)", () => {
68
+ expect(audit.findings.some((f) => f.path.startsWith("health/"))).toBe(false);
69
+ });
70
+
71
+ test("produces a score + grade from the clean/nodes ratio", () => {
72
+ expect(audit.nodes).toBeGreaterThan(0);
73
+ expect(["A", "B", "C", "D", "F"]).toContain(audit.grade);
74
+ });
75
+
76
+ test("readOnly is treated as computed (no x-suluk-origin needed)", () => {
77
+ const ro = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { "/x": { requests: { x: { method: "post", contentSchema: { type: "object", required: ["id"], properties: { id: { type: "string", readOnly: true } } } } } } } } as unknown as OpenAPIv4Document;
78
+ expect(auditReadiness(ro).findings.some((f) => f.rule === "computed-required")).toBe(true);
79
+ });
80
+
81
+ test("a clean contract grades A", () => {
82
+ const clean = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { "/x": { requests: { x: { method: "post", contentSchema: { type: "object", required: ["n"], properties: { n: { type: "string" } }, examples: [{ n: "a" }] } } } } } } as unknown as OpenAPIv4Document;
83
+ expect(auditReadiness(clean).grade).toBe("A");
84
+ });
85
+ });