From eccd8b0cc15ab65cd91b9172814edd647b2bf5e8 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 10 Jul 2026 16:47:18 +0200 Subject: [PATCH] fix(library-decisions): traceSchema accepts the committed trace shape The strict schema rejected 38 of 39 approved traces in docs/library-decisions: lastRevalidated was required (most traces omit it), and the compliance frontmatter fields the _template.md documents (is-sub-processor, processes-pii, plus the sub-processor block: data-sent, region, dpa-signed, sccs-required, contact) were unrecognized keys. Make lastRevalidated optional, add the compliance fields (booleanish coercion for YAML string scalars), and require data-sent when is-sub-processor is true. New loop test asserts every committed trace parses so the schema can never drift away from the repo's own corpus again. Co-Authored-By: Claude Fable 5 --- scripts/library-decisions/schema.mjs | 32 +++++++++++- scripts/library-decisions/schema.test.mjs | 62 +++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/scripts/library-decisions/schema.mjs b/scripts/library-decisions/schema.mjs index eb37ad7..5756c57 100644 --- a/scripts/library-decisions/schema.mjs +++ b/scripts/library-decisions/schema.mjs @@ -7,6 +7,12 @@ import fs from "node:fs"; // ---- Zod schema ---- +/** YAML scalars arrive as strings — coerce "true"/"false" (or booleans). */ +const booleanish = z.preprocess( + (v) => (v === "true" ? true : v === "false" ? false : v), + z.boolean(), +); + const filterResultsSchema = z .object({ license: z.string().min(1), @@ -30,12 +36,34 @@ export const traceSchema = z date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), deciders: z.array(z.string()), adr: z.string().nullable(), - lastRevalidated: z.string().nullable(), + // Optional: most committed traces have never been revalidated and omit + // the key entirely (requiring it rejected 38 of 39 approved traces). + lastRevalidated: z.string().nullable().optional(), "filter-results": filterResultsSchema, "verification-commands": z.array(z.string()), "accepted-cves": z.array(z.string()).optional(), + // ADR-022 amendment (compliance frontmatter, see _template.md): every + // newer trace declares whether the dependency ships data to a third + // party; sub-processor traces carry the extra contact/DPA fields. + "is-sub-processor": booleanish.optional(), + "processes-pii": booleanish.optional(), + "data-sent": z.string().nullable().optional(), + region: z.string().nullable().optional(), + "dpa-signed": z.union([booleanish, z.string()]).nullable().optional(), + "sccs-required": z.union([booleanish, z.string()]).nullable().optional(), + contact: z.string().nullable().optional(), }) - .strict(); + .strict() + .superRefine((trace, ctx) => { + if (trace["is-sub-processor"] === true && trace["data-sent"] == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "sub-processor traces must declare data-sent (what leaves the machine)", + path: ["data-sent"], + }); + } + }); // ---- Helpers ---- diff --git a/scripts/library-decisions/schema.test.mjs b/scripts/library-decisions/schema.test.mjs index 2b285e3..8ebd6b0 100644 --- a/scripts/library-decisions/schema.test.mjs +++ b/scripts/library-decisions/schema.test.mjs @@ -218,3 +218,65 @@ describe("parseTrace", () => { assert.throws(() => parseTrace(file), /invalid_type|Required/i); }); }); + +describe("validateTrace > compliance frontmatter (ADR-022 amendment)", () => { + test("accepts is-sub-processor / processes-pii as YAML string booleans", () => { + const parsed = validateTrace( + validRaw({ "is-sub-processor": "false", "processes-pii": "true" }), + ); + assert.equal(parsed["is-sub-processor"], false); + assert.equal(parsed["processes-pii"], true); + }); + + test("accepts a trace that omits lastRevalidated entirely", () => { + const raw = validRaw(); + delete raw.lastRevalidated; + assert.equal(validateTrace(raw).lastRevalidated, undefined); + }); + + test("accepts a full sub-processor block", () => { + const parsed = validateTrace( + validRaw({ + "is-sub-processor": "true", + "processes-pii": "true", + "data-sent": "error events, stack traces", + region: "eu", + "dpa-signed": "true", + "sccs-required": "false", + contact: "https://example.com/dpa", + }), + ); + assert.equal(parsed["is-sub-processor"], true); + assert.equal(parsed["data-sent"], "error events, stack traces"); + }); + + test("rejects is-sub-processor: true without data-sent", () => { + assert.throws( + () => validateTrace(validRaw({ "is-sub-processor": "true" })), + /data-sent/, + ); + }); +}); + +describe("parseTrace > every committed trace parses", () => { + // The schema exists to validate the traces actually in the repo — a schema + // that rejects committed, approved traces is broken (it silently rejected + // 38 of 39 before lastRevalidated became optional and the compliance + // fields were added). + test("all docs/library-decisions traces validate", () => { + const docsDir = path.join( + path.dirname(new URL(import.meta.url).pathname), + "..", + "..", + "docs", + "library-decisions", + ); + const files = fs + .globSync(path.join(docsDir, "**", "*.md")) + .filter((f) => !f.endsWith("_template.md")); + assert.ok(files.length > 0, "expected committed traces to exist"); + for (const f of files) { + assert.doesNotThrow(() => parseTrace(f), `trace failed to parse: ${f}`); + } + }); +});