170 lines
16 KiB
TypeScript
170 lines
16 KiB
TypeScript
import type { components } from "../../contracts/studio/generated.ts";
|
|
import type { ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
|
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
|
|
import { isSupportedEvidenceKey } from "../static/evidence-assets.ts";
|
|
|
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
|
type WorkingCopyInput = components["schemas"]["WorkingCopyInput"];
|
|
type FieldError = components["schemas"]["FieldError"];
|
|
type ValidationIssue = components["schemas"]["ValidationIssue"];
|
|
|
|
export type ValidationDependencies = {
|
|
now: Date; validationId: string; dependencyRevision: string;
|
|
catalog: ReadonlyArray<CatalogEntry>; documents: ReadonlyArray<WorkingCopy>;
|
|
};
|
|
|
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
const length = (value: string) => [...value].length;
|
|
const object = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
const escape = (value: string) => value.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
|
|
function runtimeShapeErrors(value: unknown): FieldError[] {
|
|
if (!object(value)) return [{ path: "", message: "Document input must be an object." }];
|
|
const errors: FieldError[] = [];
|
|
const kind = value.kind;
|
|
const base = ["kind", "title", "slug", "summary", "topicId", "projectId", "relations"];
|
|
const branch = kind === "CASE"
|
|
? ["problem", "conclusion", "environment", "reproduction", "lastVerifiedOn", "bodyMarkdown"]
|
|
: kind === "REFERENCE"
|
|
? ["purpose", "rules", "applyWhen", "exceptions", "examples", "verifiedOn"]
|
|
: kind === "QUESTION"
|
|
? ["questionStatus", "facts", "assumptions", "unknowns", "constraints", "options", "nextValidation", "resolution"]
|
|
: [];
|
|
if (!(["CASE", "REFERENCE", "QUESTION"] as unknown[]).includes(kind)) errors.push({ path: "/kind", message: "Invalid record kind." });
|
|
const allowed = new Set([...base, ...branch]);
|
|
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push({ path: `/${escape(key)}`, message: "Additional properties are not allowed." });
|
|
for (const key of [...base, ...branch]) if (!Object.hasOwn(value, key)) errors.push({ path: `/${key}`, message: "Field is required." });
|
|
const stringFields = ["title", "slug", "summary", ...(kind === "CASE" ? ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] : []), ...(kind === "REFERENCE" ? ["purpose"] : []), ...(kind === "QUESTION" ? ["nextValidation"] : [])];
|
|
for (const key of stringFields) if (Object.hasOwn(value, key) && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a string." });
|
|
for (const key of ["topicId", "projectId"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a UUID or null." });
|
|
for (const key of ["lastVerifiedOn", "verifiedOn"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a date or null." });
|
|
if (kind === "QUESTION" && Object.hasOwn(value, "questionStatus") && ![null, "OPEN", "RESOLVED"].includes(value.questionStatus as never)) errors.push({ path: "/questionStatus", message: "Invalid question status." });
|
|
|
|
const checkItem = (item: unknown, path: string, fields: Record<string, "string" | "nullable" | "number">) => {
|
|
if (!object(item)) { errors.push({ path, message: "Must be an object." }); return; }
|
|
for (const key of Object.keys(item)) if (!Object.hasOwn(fields, key)) errors.push({ path: `${path}/${escape(key)}`, message: "Additional properties are not allowed." });
|
|
for (const [key, expected] of Object.entries(fields)) {
|
|
if (!Object.hasOwn(item, key)) errors.push({ path: `${path}/${key}`, message: "Field is required." });
|
|
else if (expected === "string" && typeof item[key] !== "string") errors.push({ path: `${path}/${key}`, message: "Must be a string." });
|
|
else if (expected === "nullable" && item[key] !== null && typeof item[key] !== "string") errors.push({ path: `${path}/${key}`, message: "Must be a string or null." });
|
|
else if (expected === "number" && typeof item[key] !== "number") errors.push({ path: `${path}/${key}`, message: "Must be a number." });
|
|
}
|
|
};
|
|
const checkArray = (key: string, fields: Record<string, "string" | "nullable" | "number">) => {
|
|
const array = value[key];
|
|
if (!Array.isArray(array)) { if (Object.hasOwn(value, key)) errors.push({ path: `/${key}`, message: "Must be an array." }); return; }
|
|
array.forEach((item, index) => checkItem(item, `/${key}/${index}`, fields));
|
|
};
|
|
checkArray("relations", { id: "nullable", targetId: "nullable", reason: "string", order: "number" });
|
|
if (kind === "REFERENCE") {
|
|
checkArray("rules", { id: "string", title: "string", body: "string", order: "number" });
|
|
for (const key of ["applyWhen", "exceptions", "examples"]) checkArray(key, { id: "string", text: "string", order: "number" });
|
|
}
|
|
if (kind === "QUESTION") {
|
|
for (const key of ["facts", "assumptions", "unknowns", "constraints"]) checkArray(key, { id: "string", text: "string", order: "number" });
|
|
checkArray("options", { id: "string", title: "string", description: "string", order: "number" });
|
|
if (Object.hasOwn(value, "resolution") && value.resolution !== null) checkItem(value.resolution, "/resolution", { summary: "string", evidenceTargetId: "nullable", linkLabel: "string" });
|
|
}
|
|
return errors.filter((entry, index) => errors.findIndex(({ path }) => path === entry.path) === index);
|
|
}
|
|
|
|
function text(errors: FieldError[], path: string, value: string, max: number, min = 0) {
|
|
if (length(value) < min) errors.push({ path, message: `Must contain at least ${min} character(s).` });
|
|
else if (length(value) > max) errors.push({ path, message: `Must contain at most ${max} characters.` });
|
|
}
|
|
|
|
function uuid(errors: FieldError[], path: string, value: string | null, nullable = false) {
|
|
if (nullable && value === null) return;
|
|
if (value === null || !UUID.test(value)) errors.push({ path, message: "Must be a UUID." });
|
|
}
|
|
|
|
function date(errors: FieldError[], path: string, value: string | null) {
|
|
if (value === null) return;
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
if (!match || new Date(Date.UTC(+match[1], +match[2] - 1, +match[3])).toISOString().slice(0, 10) !== value) errors.push({ path, message: "Must be a valid YYYY-MM-DD date." });
|
|
}
|
|
|
|
function ordered(errors: FieldError[], path: string, items: Array<{ id: string; order: number }>, fields: Array<[string, number, number]>) {
|
|
if (items.length > 50) { errors.push({ path, message: "Must contain at most 50 items." }); return; }
|
|
const ids = new Set<string>(); const orders = new Set<number>();
|
|
items.forEach((item, index) => {
|
|
uuid(errors, `${path}/${index}/id`, item.id);
|
|
if (ids.has(item.id)) errors.push({ path: `${path}/${index}/id`, message: "IDs must be unique." }); ids.add(item.id);
|
|
if (!Number.isInteger(item.order) || item.order < 0 || orders.has(item.order)) errors.push({ path: `${path}/${index}/order`, message: "Order must be unique and non-negative." }); orders.add(item.order);
|
|
for (const [field, max, min] of fields) text(errors, `${path}/${index}/${field}`, String((item as unknown as Record<string, unknown>)[field] ?? ""), max, min);
|
|
});
|
|
}
|
|
|
|
export function validateWorkingCopyInputStructure(input: WorkingCopyInput): FieldError[] {
|
|
const shape = runtimeShapeErrors(input); if (shape.length) return shape;
|
|
const errors: FieldError[] = [];
|
|
text(errors, "/title", input.title, 120);
|
|
if (input.slug !== "" && (length(input.slug) < 3 || length(input.slug) > 100 || !SLUG.test(input.slug))) errors.push({ path: "/slug", message: "Invalid slug." });
|
|
text(errors, "/summary", input.summary, 300); uuid(errors, "/topicId", input.topicId, true); uuid(errors, "/projectId", input.projectId, true);
|
|
if (input.relations.length > 20) errors.push({ path: "/relations", message: "Must contain at most 20 relations." });
|
|
else {
|
|
const ids = new Set<string>(); const targets = new Set<string>(); const orders = new Set<number>();
|
|
input.relations.forEach((relation, index) => {
|
|
uuid(errors, `/relations/${index}/id`, relation.id, true); uuid(errors, `/relations/${index}/targetId`, relation.targetId);
|
|
if (relation.id && ids.has(relation.id)) errors.push({ path: `/relations/${index}/id`, message: "IDs must be unique." }); if (relation.id) ids.add(relation.id);
|
|
if (relation.targetId && targets.has(relation.targetId)) errors.push({ path: `/relations/${index}/targetId`, message: "Targets must be unique." }); if (relation.targetId) targets.add(relation.targetId);
|
|
if (!Number.isInteger(relation.order) || relation.order < 0 || orders.has(relation.order)) errors.push({ path: `/relations/${index}/order`, message: "Order must be unique and non-negative." }); orders.add(relation.order);
|
|
text(errors, `/relations/${index}/reason`, relation.reason, 100_000);
|
|
});
|
|
}
|
|
if (input.kind === "CASE") {
|
|
for (const field of ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] as const) text(errors, `/${field}`, input[field], 100_000);
|
|
date(errors, "/lastVerifiedOn", input.lastVerifiedOn);
|
|
} else if (input.kind === "REFERENCE") {
|
|
text(errors, "/purpose", input.purpose, 100_000);
|
|
ordered(errors, "/rules", input.rules, [["title", 120, 1], ["body", 100_000, 1]]);
|
|
for (const [path, items] of [["/applyWhen", input.applyWhen], ["/exceptions", input.exceptions], ["/examples", input.examples]] as const) ordered(errors, path, items, [["text", 100_000, 1]]);
|
|
date(errors, "/verifiedOn", input.verifiedOn);
|
|
} else {
|
|
for (const [path, items] of [["/facts", input.facts], ["/assumptions", input.assumptions], ["/unknowns", input.unknowns], ["/constraints", input.constraints]] as const) ordered(errors, path, items, [["text", 100_000, 1]]);
|
|
ordered(errors, "/options", input.options, [["title", 120, 1], ["description", 100_000, 0]]);
|
|
text(errors, "/nextValidation", input.nextValidation, 100_000);
|
|
if (input.resolution) { text(errors, "/resolution/summary", input.resolution.summary, 100_000); uuid(errors, "/resolution/evidenceTargetId", input.resolution.evidenceTargetId, true); text(errors, "/resolution/linkLabel", input.resolution.linkLabel, 120); }
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
const blank = (value: string) => value.trim().length === 0;
|
|
|
|
export function validateWorkingCopy(document: WorkingCopy, dependencies: ValidationDependencies): ValidationReport {
|
|
const issues: ValidationIssue[] = [];
|
|
const add = (severity: "ERROR" | "WARNING", code: string, path: string, message: string) => issues.push({ severity, code, path, message });
|
|
const error = (code: string, path: string, message: string) => add("ERROR", code, path, message);
|
|
const warning = (code: string, path: string, message: string) => add("WARNING", code, path, message);
|
|
const has = (id: string | null, type: CatalogEntry["type"]) => Boolean(id && dependencies.catalog.some((entry) => entry.id === id && entry.type === type));
|
|
if (blank(document.title)) error("TITLE_REQUIRED", "/title", "제목을 입력하세요.");
|
|
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
|
|
if (blank(document.summary)) error("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
|
|
if (!has(document.topicId, "TOPIC")) error("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
|
|
if (!document.projectId) warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다."); else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
|
|
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
|
|
if (document.kind === "CASE") {
|
|
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
|
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
|
else try {
|
|
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
|
|
if (!isSupportedEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
|
else if (!dependencies.catalog.some((entry) => entry.type === "EVIDENCE" && (entry.id === block.key || entry.label === block.key || entry.publicPath === `/media/${block.key}.svg`))) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`);
|
|
}
|
|
} catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); }
|
|
if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
|
|
} else if (document.kind === "REFERENCE") {
|
|
if (blank(document.purpose)) error("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) error("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) error("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) error("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
|
|
} else {
|
|
if (!document.questionStatus) error("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) error("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) error("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
|
|
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
|
|
if (document.questionStatus === "RESOLVED") { if (!document.resolution) error("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) error("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) error("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
|
|
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
|
|
}
|
|
const validatedAt = dependencies.now.toISOString();
|
|
return { validationId: dependencies.validationId, documentId: document.id, validatedVersion: document.version,
|
|
status: issues.some(({ severity }) => severity === "ERROR") ? "INVALID" : issues.some(({ severity }) => severity === "WARNING") ? "WARNINGS" : "VALID",
|
|
issues, validatedAt, validUntil: new Date(dependencies.now.getTime() + 30 * 60_000).toISOString(), dependencyRevision: dependencies.dependencyRevision };
|
|
}
|