// SPDX-FileCopyrightText: 2026 Kerstin Humm // // SPDX-License-Identifier: AGPL-3.0-or-later // export function isUrl(url: string): boolean { try { new URL(url); return true; } catch { return false; } } // For expressing errors as return values export type Result = | { ok: true; value: T } | { ok: false; error: E }; export type AnnotationKind = // The value violates a MUST, REQUIRED or SHALL | "Violation" | // The value violates a SHOULD "Warning" | // Some other info that could be interesting to the implementer "Note" | // The annotated value complies with the standard "Correct"; export type Annotation = { kind: AnnotationKind; id?: string; text: string; reference?: Reference; }; export type Reference = { url: string; quote?: string; }; export function isAnnotation(value: unknown): value is Annotation { if ( typeof value === "object" && value !== null && "kind" in value && "text" in value ) { const { kind, text } = value as Annotation; return ( (kind === "Violation" || kind === "Warning" || kind === "Note" || kind === "Correct") && typeof text === "string" ); } return false; } function isAnnotations(value: unknown): value is Annotation[] { return Array.isArray(value) && value.every((i) => isAnnotation(i)); } type JsonPrimitive = number | string | boolean | null; export function isString(value: unknown): value is string { return typeof value === "string"; } export function isNumber(value: unknown): value is number { return typeof value === "number"; } export function isBoolean(value: unknown): value is boolean { return typeof value === "boolean"; } export function isPrimitive( value: unknown, ): value is JsonPrimitive { return ( value === null || isString(value) || isNumber(value) || isBoolean(value) ); } export type JsonValue = | JsonPrimitive | { [key: string]: JsonValue } | JsonValue[]; export function isJsonObject( value: unknown, ): value is { [key: string]: JsonValue } { return !Array.isArray(value) && typeof value === "object" && value !== null && Object.values(value).reduce((acc, v) => acc && isJsonValue(v), true); } export function isJsonArray(value: unknown): value is JsonValue[] { return Array.isArray(value) && value.reduce((acc, v) => acc && isJsonValue(v), true); } function isJsonValue(value: unknown): value is JsonValue { return isJsonObject(value) || isJsonArray(value) || isPrimitive(value); } // A way of finding a location in a JSON structure. Think of jq queries like .myname[1][0].value export type JsonPath = JsonPathElement[]; // Either an object name or an array index type JsonPathElement = string | number; // Get the JsonValue at path inside data. // Returns an error if the path doesn't match the data. export function getPath( data: JsonValue, path: JsonPath, ): Result { if (path.length === 0) { return { ok: true, value: data }; } // First element, we know that path is not empty const [p, ...rest] = path; if (typeof p === "string") { if (isJsonObject(data)) { if (p in data) { return getPath(data[p]!, rest); } else { return { ok: false, error: `${JSON.stringify(data)} doesn't have name ${ JSON.stringify(p) }`, }; } } else { return { ok: false, error: `${JSON.stringify(data)} is not an Object` }; } } else if (typeof p === "number") { if (isJsonArray(data)) { if (p >= 0 && p < data.length) { return getPath(data[p]!, rest); } else { return { ok: false, error: `${JSON.stringify(p)} isn't a correct index in ${ JSON.stringify(data) }`, }; } } else { return { ok: false, error: `${JSON.stringify(data)} is not an Array` }; } } else { return {} as never; } } export type AnnotatedJson = // This is just a convenience shortcut for T[] | T | T[] | { annotations: T[]; object: { [key: string]: AnnotatedJson } } | { annotations: T[]; array: AnnotatedJson[] }; export function isAnnotatedObject( value: unknown, ): value is { annotations: Annotation[]; object: { [key: string]: AnnotatedJson }; } { return typeof value === "object" && value !== null && "annotations" in value && "object" in value; } export function isAnnotatedArray( value: unknown, ): value is { annotations: Annotation[]; array: AnnotatedJson[] } { return typeof value === "object" && value !== null && "annotations" in value && "array" in value; } // Recursively merge AnnotatedJson, never losing information, returning errors on incompatible types // This should never return just the Annotation variant, but Annotation[] instead. // Optionally supply a JsonPath where the b should be merged into a. export function mergeAnnotatedJson( a: AnnotatedJson, b: AnnotatedJson, path: JsonPath = [], ): Result, string> { if (path.length !== 0) { const [p, ...rest] = path; if (isAnnotatedObject(a) && isString(p) && p in a.object) { const merge = mergeAnnotatedJson(a.object[p]!, b, rest); if (merge.ok) { a.object[p] = merge.value; return { ok: true, value: a }; } else { return merge; } } else if ( isAnnotatedArray(a) && isNumber(p) && p >= 0 && p < a.array.length ) { const merge = mergeAnnotatedJson(a.array[p]!, b, rest); if (merge.ok) { a.array.splice(p, 0, merge.value); return { ok: true, value: a }; } else { return merge; } } else { return { ok: false, error: `Can't locate JsonPath ${p} in ${JSON.stringify(a)}`, }; } } // Annotation, Annotation if (isAnnotation(a) && isAnnotation(b)) { return { ok: true, value: [a, b] }; } // Annotation[], Annotation else if ( Array.isArray(a) && a.every((i) => isAnnotation(i)) && isAnnotation(b) ) { return { ok: true, value: [...a, b] }; } // Annotation, Annotation[] else if ( isAnnotation(a) && Array.isArray(b) && b.every((i) => isAnnotation(i)) ) { return { ok: true, value: [a, ...b] }; } // Annotation[], Annotation[] else if ( Array.isArray(a) && a.every((i) => isAnnotation(i)) && Array.isArray(b) && b.every((i) => isAnnotation(i)) ) { return { ok: true, value: [...a, ...b] }; } else if (isAnnotatedObject(a) && isAnnotatedObject(b)) { const mergedObject: { [key: string]: AnnotatedJson } = {}; for (const [k, v] of Object.entries(a.object)) { if (isAnnotation(v)) { // Normalise Annotation to Annotation[] mergedObject[k] = [v]; } else { mergedObject[k] = v; } } for (const [k, v] of Object.entries(b.object)) { if (k in mergedObject) { const merge = mergeAnnotatedJson(mergedObject[k]!, b.object[k]!); if (merge.ok) { mergedObject[k] = merge.value; } else { return merge; } } else if (isAnnotation(v)) { mergedObject[k] = [v]; } else { mergedObject[k] = v; } } return { ok: true, value: { annotations: [...a.annotations, ...b.annotations], object: mergedObject, }, }; } else if (isAnnotatedArray(a) && isAnnotatedArray(b)) { return { ok: true, value: { annotations: [...a.annotations, ...b.annotations], array: [...a.array, ...b.array], }, }; } else { const aType = (ann: AnnotatedJson) => isAnnotatedObject(ann) ? "object" : (isAnnotatedArray(ann) ? "array" : "annotation"); return { ok: false, error: `Can't merge as types differ: ${aType(a)}, ${aType(b)}`, }; } } // Fold over all Annotations in an AnnotatedJson in unspecified order. export function foldAnnotatedJson( f: (_acc: T, _annotation: Annotation) => T, acc: T, data: AnnotatedJson, ): T { if (isAnnotation(data)) { return f(acc, data); } else if (isAnnotations(data)) { data satisfies Annotation[]; data.forEach((a) => { acc = f(acc, a); }); return acc; } else if (isAnnotatedObject(data)) { acc = foldAnnotatedJson(f, acc, data.annotations); Object.values(data.object).forEach((aj) => { acc = foldAnnotatedJson(f, acc, aj); }); return acc; } else if (isAnnotatedArray(data)) { acc = foldAnnotatedJson(f, acc, data.annotations); data.array.forEach((aj) => { acc = foldAnnotatedJson(f, acc, aj); }); return acc; } // For some reason Typescript doesn't get that we tested all variants return {} as never; } // Turn a JsonValue into an AnnotatedJson with no annotations export function fromJsonValue(value: JsonValue): AnnotatedJson { if (isPrimitive(value)) { return []; } else if (isJsonArray(value)) { return { annotations: [], array: value.map((v) => fromJsonValue(v)), }; } else if (isJsonObject(value)) { return { annotations: [], object: Object.fromEntries( Object.entries(value).map(([k, v]) => [k, fromJsonValue(v)]), ), }; } else { return {} as never; } } // Take an AnnotatedJson and insert an Annotation on the location specified path export function annotateAt( annotated: AnnotatedJson, path: JsonPath, annotation: Annotation, ): Result, string> { // In case path is empty we can just add the annotation to the current node. if (path.length === 0) { if (isAnnotatedObject(annotated) || isAnnotatedArray(annotated)) { annotated.annotations.push(annotation); } else if ( Array.isArray(annotated) && annotated.every((i) => isAnnotation(i)) ) { if (annotated.length === 0) { // So we don't build nested arrays annotated = annotation; } else { annotated.push(annotation); } } else { annotated = [annotated, annotation] as Annotation[]; } return { ok: true, value: annotated }; } // First element, we know that path is not empty const [p, ...rest] = path; if (typeof p === "string" && isAnnotatedObject(annotated)) { const res = annotateAt(annotated.object[p] ?? [], rest, annotation); if (res.ok) { annotated.object[p] = res.value; return { ok: true, value: annotated }; } else { return res; } } else if (typeof p === "number" && isAnnotatedArray(annotated)) { const res = annotateAt(annotated.array[p] ?? [], rest, annotation); if (res.ok) { annotated.array.splice(p, 0, res.value); return { ok: true, value: annotated }; } else { return res; } } else { // annotated doesn't match what the path is telling us return { ok: false, error: `annotateAt wasn't able to follow the JsonPath ${ JSON.stringify(path) } in ${JSON.stringify(annotated)}`, }; } } // Place annotation only when the value in prev pointed to by path is a string. // Intended to save on a lot of boilerplate function assertPredicateAt( prev: AnnotatedJson, data: JsonValue, path: JsonPath, predicate: (v: JsonValue) => boolean, annotation: Annotation, ): Result, AnnotatedJson> { const queryResult = getPath(data, path); if (queryResult.ok && predicate(queryResult.value)) { return { ok: true, value: prev }; } else { const res = annotateAt(prev, path, annotation); if (res.ok) { // We actually return the value as error and use the ok flag to signal wether annotation happened or not. // Maybe this is a bit too dirty and this deserves its own return type instead. return { ok: false, error: res.value }; } else { // We don't pass this along but throw it throw new Error(res.error); } } } // Ergonomics experiment to make things less awkward in the rules. export class AnnotatedWrapper { value: AnnotatedJson; constructor() { // This is a sensible default this.value = { annotations: [], object: {} }; } merge(annotated: AnnotatedJson): void { const result = mergeAnnotatedJson(this.value, annotated); if (result.ok) { this.value = result.value; } else { throw new Error(result.error); } } assert( json: JsonValue, path: JsonPath, predicate: (v: JsonValue) => boolean, annotation: Annotation, ): void { const result = assertPredicateAt( this.value, json, path, predicate, annotation, ); if (result.ok) { this.value = result.value; } else { this.value = result.error; } } assertString(json: JsonValue, path: JsonPath, annotation: Annotation): void { this.assert(json, path, (v) => { return typeof v === "string"; }, annotation); } assertBoolean(json: JsonValue, path: JsonPath, annotation: Annotation): void { this.assert(json, path, (v) => { return typeof v === "boolean"; }, annotation); } annotateAt(path: JsonPath, annotation: Annotation) { const result = annotateAt(this.value, path, annotation); if (result.ok) { this.value = result.value; } else { throw new Error(result.error); } } } type Test = { value: JsonValue; result: AnnotatedJson; }; export type Rule = { name: string; validate: ( self: { [key: string]: JsonValue }, ) => AnnotatedJson; tests: Test[]; };