@suluk/harden 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/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.0",
3
+ "version": "0.1.2",
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,7 @@
19
19
  ".": "./src/index.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@suluk/core": "^0.1.7"
22
+ "@suluk/core": "^0.1.11"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@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/harden.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The hardening TRANSFORM — the answer to {@link auditDocument}'s findings. It adds sensible default BOUNDS so
3
+ * untrusted input can't be unboundedly large or carry control characters that break parsers: every string gets a
4
+ * maxLength + a control-char-rejecting pattern, every number a maximum/minimum, every array a maxItems, every object
5
+ * is closed (additionalProperties:false). Authors TIGHTEN per field (a slug isn't 1024 chars) — this is the floor
6
+ * that turns an F/D contract into a B. The inverse of the audit: audit grades the gaps, harden fills them.
7
+ */
8
+ import type { OpenAPIv4Document } from "@suluk/core";
9
+
10
+ type S = Record<string, unknown>;
11
+
12
+ /** Overridable floors — defaults match the baseline (1024 chars / ±1e12 / 1000 items / no control chars). */
13
+ export interface HardenOptions {
14
+ maxLength?: number;
15
+ /** reject NUL + control chars (tab/newline/CR allowed). Pass null to skip adding a pattern. */
16
+ textPattern?: string | null;
17
+ numberMax?: number;
18
+ numberMin?: number;
19
+ maxItems?: number;
20
+ }
21
+
22
+ // Reject NUL + control chars that break parsers (tab/newline/CR are allowed by the range below).
23
+ const SAFE_TEXT = "^[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f]*$";
24
+ const DEFAULTS: Required<HardenOptions> = { maxLength: 1024, textPattern: SAFE_TEXT, numberMax: 1_000_000_000_000, numberMin: -1_000_000_000_000, maxItems: 1000 };
25
+
26
+ /** Recursively add baseline bounds to a JSON Schema. Idempotent — never overrides an author-set bound. */
27
+ export function hardenSchema(schema: unknown, opts: HardenOptions = {}): unknown {
28
+ const o = { ...DEFAULTS, ...opts };
29
+ const go = (sch: unknown): unknown => {
30
+ if (sch == null || typeof sch !== "object") return sch;
31
+ if (Array.isArray(sch)) return sch.map(go);
32
+ const s: S = { ...(sch as S) };
33
+ const t = Array.isArray(s.type) ? (s.type as string[])[0] : s.type;
34
+ if (s.properties) { const p: S = {}; for (const [k, v] of Object.entries(s.properties as S)) p[k] = go(v); s.properties = p; if (s.additionalProperties === undefined) s.additionalProperties = false; }
35
+ if (s.items) s.items = go(s.items);
36
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) if (Array.isArray(s[key])) s[key] = (s[key] as unknown[]).map(go);
37
+ const bounded = s.enum !== undefined || s.const !== undefined || s.format !== undefined;
38
+ if (t === "string" && !bounded) {
39
+ if (s.maxLength === undefined) s.maxLength = o.maxLength;
40
+ if (s.pattern === undefined && o.textPattern != null) s.pattern = o.textPattern;
41
+ }
42
+ if (t === "integer" || t === "number") {
43
+ if (s.maximum === undefined && s.exclusiveMaximum === undefined) s.maximum = o.numberMax;
44
+ if (s.minimum === undefined && s.exclusiveMinimum === undefined) s.minimum = o.numberMin;
45
+ }
46
+ if (t === "array" && s.maxItems === undefined) s.maxItems = o.maxItems;
47
+ return s;
48
+ };
49
+ return go(schema);
50
+ }
51
+
52
+ /** Harden EVERY input schema in a built v4 document IN PLACE — request bodies + all parameter slots (incl. the route
53
+ * generator's path params, otherwise unbounded strings). Idempotent. The transform that makes assertGrade pass. */
54
+ export function hardenDocument<T extends OpenAPIv4Document>(doc: T, opts: HardenOptions = {}): T {
55
+ const d = doc as unknown as { paths?: Record<string, { requests?: Record<string, Record<string, unknown>> }> };
56
+ for (const pi of Object.values(d.paths ?? {})) {
57
+ for (const req of Object.values(pi.requests ?? {})) {
58
+ if (req.contentSchema) req.contentSchema = hardenSchema(req.contentSchema, opts);
59
+ const ps = req.parameterSchema as Record<string, unknown> | undefined;
60
+ if (ps) for (const loc of ["query", "path", "header", "cookie", "body"]) if (ps[loc]) ps[loc] = hardenSchema(ps[loc], opts);
61
+ }
62
+ }
63
+ return doc;
64
+ }
package/src/index.ts CHANGED
@@ -9,5 +9,10 @@
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";
17
+ // the inverse of the audit — the hardening TRANSFORM that adds the baseline bounds the audit grades for.
18
+ export { hardenSchema, hardenDocument, type HardenOptions } from "./harden";
@@ -1,5 +1,5 @@
1
1
  import { test, expect, describe } from "bun:test";
2
- import { auditOperation, auditDocument, assertGrade, grade } 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: {
@@ -63,3 +63,76 @@ describe("@suluk/harden — schema hardening as a scored facet", () => {
63
63
  expect(d.findings.filter((f) => f.rule === "string-max-length").length).toBe(1);
64
64
  });
65
65
  });
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
+
94
+ describe("hardenSchema / hardenDocument — the transform (inverse of the audit)", () => {
95
+ test("adds baseline bounds: string→maxLength+pattern, number→min/max, array→maxItems, object→closed", () => {
96
+ const h = hardenSchema({ type: "object", properties: {
97
+ name: { type: "string" }, age: { type: "integer" }, tags: { type: "array", items: { type: "string" } },
98
+ meta: { type: "object", properties: { k: { type: "string" } } },
99
+ } }) as Record<string, any>;
100
+ expect(h.additionalProperties).toBe(false);
101
+ expect(h.properties.name.maxLength).toBe(1024);
102
+ expect(h.properties.name.pattern).toContain("u0000");
103
+ expect(h.properties.age.maximum).toBe(1_000_000_000_000);
104
+ expect(h.properties.age.minimum).toBe(-1_000_000_000_000);
105
+ expect(h.properties.tags.maxItems).toBe(1000);
106
+ expect(h.properties.tags.items.maxLength).toBe(1024);
107
+ expect(h.properties.meta.additionalProperties).toBe(false); // nested objects WITH properties get closed
108
+ });
109
+
110
+ test("never overrides an author-set bound, and leaves enum/const/format strings alone", () => {
111
+ const h = hardenSchema({ type: "string", maxLength: 64 }) as Record<string, unknown>;
112
+ expect(h.maxLength).toBe(64);
113
+ expect(hardenSchema({ type: "string", enum: ["a", "b"] })).not.toHaveProperty("maxLength");
114
+ expect(hardenSchema({ type: "string", format: "email" })).not.toHaveProperty("pattern");
115
+ });
116
+
117
+ test("respects overridable floors", () => {
118
+ const h = hardenSchema({ type: "string" }, { maxLength: 80, textPattern: null }) as Record<string, unknown>;
119
+ expect(h.maxLength).toBe(80);
120
+ expect(h).not.toHaveProperty("pattern");
121
+ });
122
+
123
+ test("INVERSE PROPERTY: hardenDocument fills the BOUND gaps → a bounds-only-weak doc then passes assertGrade('A')", () => {
124
+ // gaps are ONLY missing bounds (every field typed, objects have properties) — exactly what the floor can fill.
125
+ const boundsOnly = { method: "post", contentSchema: { type: "object", properties: {
126
+ name: { type: "string" }, age: { type: "integer" }, tags: { type: "array", items: { type: "string" } },
127
+ } } };
128
+ const doc2 = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { w: { requests: { createW: boundsOnly } } } } as unknown as OpenAPIv4Document;
129
+ expect(() => assertGrade(doc2, "A")).toThrow(); // unbounded today
130
+ hardenDocument(doc2); // in place
131
+ expect(assertGrade(doc2, "A").grade).toBe("A"); // the audit's gaps are now filled
132
+ });
133
+
134
+ test("idempotent — hardening twice is a no-op", () => {
135
+ const once = hardenSchema({ type: "object", properties: { a: { type: "string" } } });
136
+ expect(hardenSchema(once)).toEqual(once);
137
+ });
138
+ });