refactor: adapter 구현중..

This commit is contained in:
DongHyeonka
2026-08-13 16:02:21 +09:00
parent 30ceac23c1
commit 4dc033cf33
72 changed files with 13370 additions and 1549 deletions
+298 -11
View File
@@ -10,21 +10,37 @@ import type {
} from "../contracts/ci-gates.ts";
import { ciContractReportSchema } from "./ci-contract-report.ts";
import {
architectureDependencyReportArtifactSchema,
automatedA11yArtifactSchema,
buildManifestArtifactSchema,
bundlePerformanceArtifactSchema,
compatibilityFixturesArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
designSystemReportArtifactSchema,
diagnosticsReportArtifactSchema,
documentationReviewArtifactSchema,
fieldWebVitalsArtifactSchema,
hostingHeadersArtifactSchema,
i18nReportArtifactSchema,
jsonSchemaDocumentArtifactSchema,
labPerformanceArtifactSchema,
licenseReportArtifactSchema,
manualA11yReportArtifactSchema,
moduleInventoryArtifactSchema,
optionalRecipeFixturesArtifactSchema,
optionalRecipesArtifactSchema,
provenanceArtifactSchema,
realtimeBoundariesArtifactSchema,
registryGovernanceRunArtifactSchema,
registryCompatibilityFixturesArtifactSchema,
registrySnapshotArtifactSchema,
releaseVerificationArtifactSchema,
reproducibleBuildArtifactSchema,
runbookRecordArtifactSchema,
sbomArtifactSchema,
supplyChainFixturesArtifactSchema,
supplyChainProviderFixturesArtifactSchema,
supplyChainVerificationArtifactSchema,
vulnerabilityReportArtifactSchema,
} from "../contracts/release-artifacts.ts";
@@ -40,10 +56,6 @@ import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"
import { secretScanSarifSchema } from "./secret-scan-evaluator.ts";
import { testEvidenceReportSchema } from "./test-evidence-artifact.ts";
const jsonObjectSchema = z.record(z.string(), z.json()).refine(
(value) => Object.keys(value).length > 0,
"generic JSON artifact must be a non-empty object",
);
const coverageCounterSchema = z
.object({
total: z.number().int().nonnegative(),
@@ -184,7 +196,22 @@ type ExecutableJsonSchemaId = Extract<
>["executableSchemaId"];
const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> = Object.freeze({
"generic-json-object": jsonObjectSchema,
"automated-a11y": automatedA11yArtifactSchema,
"manual-a11y": manualA11yReportArtifactSchema,
"architecture-dependency-report": architectureDependencyReportArtifactSchema,
"design-system-contract": designSystemReportArtifactSchema,
"i18n-contract": i18nReportArtifactSchema,
"diagnostics-contract": diagnosticsReportArtifactSchema,
"realtime-boundaries": realtimeBoundariesArtifactSchema,
"optional-recipes": optionalRecipesArtifactSchema,
"optional-recipe-fixtures": optionalRecipeFixturesArtifactSchema,
"registry-compatibility-fixtures": registryCompatibilityFixturesArtifactSchema,
"reproducible-build": reproducibleBuildArtifactSchema,
"supply-chain-fixtures": supplyChainFixturesArtifactSchema,
"supply-chain-provider-fixtures": supplyChainProviderFixturesArtifactSchema,
"compatibility-fixtures": compatibilityFixturesArtifactSchema,
"documentation-review": documentationReviewArtifactSchema,
"hosting-headers": hostingHeadersArtifactSchema,
"coverage-summary-v8": coverageSummarySchema,
"risk-coverage-v3": riskCoverageArtifactSchema,
"build-manifest": buildManifestArtifactSchema,
@@ -213,6 +240,12 @@ const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> =
"ci-contract-report": ciContractReportSchema,
});
export function hasCiArtifactSemanticValidator(
schema: CiGateArtifactSchema,
): boolean {
return schema.kind !== "json" || schema.executableSchemaId in executableJsonSchemas;
}
type ReadHandle = Readonly<{
stat(): Promise<Stats>;
read(
@@ -253,9 +286,7 @@ export async function validateCiArtifact(
if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`);
return;
case "html":
if (!/^\s*(?:<!doctype\s+html\s*>\s*)?<html\b[^>]*>[\s\S]*<\/html\s*>\s*$/iu.test(text)) {
throw new TypeError(`invalid HTML artifact: ${relative}`);
}
assertWellFormedHtml(text, relative);
return;
case "junit":
assertWellFormedJUnitXml(text, relative);
@@ -355,51 +386,85 @@ async function readHandleBounded(
return captured.subarray(0, offset);
}
const MAX_DOCUMENT_DEPTH = 256;
const MAX_DOCUMENT_UNITS = 100_000;
function assertWellFormedJUnitXml(source: string, relative: string): void {
const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`);
if (/<!DOCTYPE\b|<!ENTITY\b/iu.test(source)) throw invalid();
if (!hasOnlyXmlCharacters(source)) throw invalid();
const stack: string[] = [];
let root: string | undefined;
let rootClosed = false;
let declarationSeen = false;
let units = 0;
let cursor = 0;
while (cursor < source.length) {
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if ((text.includes("]]>") || !hasValidXmlEntities(text)) && text.length > 0) throw invalid();
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<![CDATA[", open)) {
const close = source.indexOf("]]>", open + 9);
if (stack.length === 0 || close < 0) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
if (source.startsWith("<?", open)) {
const close = source.indexOf("?>", open + 2);
if (root || close < 0) throw invalid();
const processingInstruction = source.slice(open, close + 2);
const match = /^<\?([A-Za-z_][\w:.-]*)(?:\s+[\s\S]*?)?\?>$/u.exec(
processingInstruction,
);
if (!match) throw invalid();
if (match[1]!.toLowerCase() === "xml") {
if (
declarationSeen ||
source.slice(0, open).trim() ||
!/^<\?xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?\s*\?>$/u.test(
processingInstruction,
)
) {
throw invalid();
}
declarationSeen = true;
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 2;
continue;
}
const close = source.indexOf(">", open + 1);
if (source.startsWith("<!", open)) throw invalid();
const close = markupEnd(source, open + 1);
if (close < 0) throw invalid();
const tag = source.slice(open, close + 1);
const closing = /^<\/([A-Za-z_][\w:.-]*)\s*>$/u.exec(tag);
if (closing) {
if (stack.pop() !== closing[1]) throw invalid();
if (stack.length === 0) rootClosed = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
continue;
}
const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid();
root ??= opening[1];
if (opening[3] !== "/") stack.push(opening[1]!);
if (opening[3] !== "/") {
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
stack.push(opening[1]!);
}
else if (stack.length === 0) rootClosed = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
}
if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) {
@@ -412,14 +477,236 @@ function hasValidXmlAttributes(source: string): boolean {
const names = new Set<string>();
while (remaining.length > 0) {
if (!remaining.trim()) return true;
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"[^"<]*"|'[^'<]*')/u.exec(remaining);
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/u.exec(remaining);
if (!match || names.has(match[1]!)) return false;
if (!hasValidXmlEntities(match[2] ?? match[3] ?? "")) return false;
names.add(match[1]!);
remaining = remaining.slice(match[0].length);
}
return true;
}
function hasOnlyXmlCharacters(source: string): boolean {
for (const character of source) {
const codePoint = character.codePointAt(0)!;
if (
codePoint !== 0x09 &&
codePoint !== 0x0a &&
codePoint !== 0x0d &&
(codePoint < 0x20 ||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
(codePoint > 0xfffd && codePoint < 0x10000) ||
codePoint > 0x10ffff)
) {
return false;
}
}
return true;
}
function hasValidXmlEntities(source: string): boolean {
let cursor = 0;
while (cursor < source.length) {
const ampersand = source.indexOf("&", cursor);
if (ampersand < 0) return true;
const semicolon = source.indexOf(";", ampersand + 1);
if (semicolon < 0) return false;
const entity = source.slice(ampersand + 1, semicolon);
if (!["amp", "lt", "gt", "apos", "quot"].includes(entity)) {
const decimal = /^#([0-9]+)$/u.exec(entity);
const hexadecimal = /^#x([a-fA-F0-9]+)$/u.exec(entity);
if (!decimal && !hexadecimal) return false;
const codePoint = Number.parseInt((decimal ?? hexadecimal)![1]!, decimal ? 10 : 16);
if (
!Number.isSafeInteger(codePoint) ||
(codePoint !== 0x09 &&
codePoint !== 0x0a &&
codePoint !== 0x0d &&
(codePoint < 0x20 ||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
(codePoint > 0xfffd && codePoint < 0x10000) ||
codePoint > 0x10ffff))
) {
return false;
}
}
cursor = semicolon + 1;
}
return true;
}
const HTML_VOID_ELEMENTS = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
const HTML_RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
function assertWellFormedHtml(source: string, relative: string): void {
const invalid = () => new TypeError(`invalid HTML artifact: ${relative}`);
if (/<!ENTITY\b|<!DOCTYPE\s+html\s+[^>]*\[/iu.test(source)) throw invalid();
const stack: string[] = [];
let cursor = 0;
let units = 0;
let doctypeSeen = false;
let rootSeen = false;
let rootClosed = false;
let playwrightPayloadSeen = false;
while (cursor < source.length) {
const rawElement = stack.at(-1);
if (rawElement && HTML_RAW_TEXT_ELEMENTS.has(rawElement)) {
const closingStart = source.toLowerCase().indexOf(`</${rawElement}`, cursor);
if (closingStart < 0) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = closingStart;
}
const open = source.indexOf("<", cursor);
const text = source.slice(cursor, open < 0 ? source.length : open);
if (stack.length === 0 && text.trim()) throw invalid();
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
if (open < 0) break;
if (source.startsWith("<!--", open)) {
const close = source.indexOf("-->", open + 4);
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 3;
continue;
}
const declarationEnd = source.indexOf(">", open + 2);
if (source.slice(open, open + 9).toLowerCase() === "<!doctype") {
if (
declarationEnd < 0 ||
doctypeSeen ||
rootSeen ||
source.slice(open, declarationEnd + 1).toLowerCase() !== "<!doctype html>"
) {
throw invalid();
}
doctypeSeen = true;
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = declarationEnd + 1;
continue;
}
if (source.startsWith("<!", open) || source.startsWith("<?", open)) throw invalid();
const close = markupEnd(source, open + 1);
if (close < 0) throw invalid();
const tag = source.slice(open, close + 1);
const closing = /^<\/([A-Za-z][A-Za-z0-9:-]*)\s*>$/u.exec(tag);
if (closing) {
const name = closing[1]!.toLowerCase();
if (stack.pop() !== name) throw invalid();
if (stack.length === 0) {
if (name === "html") rootClosed = true;
else if (name === "template" && playwrightPayloadSeen) {
// Playwright emits its base64 report template after </html>; HTML5
// reparents this token into the document body. It is the sole
// permitted generated-report sidecar and does not create a new root.
} else {
throw invalid();
}
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
continue;
}
const opening = /^<([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
if (!opening) throw invalid();
const name = opening[1]!.toLowerCase();
const attributes = parseHtmlAttributes(opening[2] ?? "");
if (!attributes || name.includes(":")) throw invalid();
if (!rootSeen) {
if (name !== "html" || opening[3] === "/") throw invalid();
rootSeen = true;
} else if (name === "html") {
throw invalid();
}
if (rootClosed && stack.length === 0) {
if (
playwrightPayloadSeen ||
name !== "template" ||
attributes.get("id") !== "playwrightReportBase64" ||
opening[3] === "/"
) {
throw invalid();
}
playwrightPayloadSeen = true;
}
if (!HTML_VOID_ELEMENTS.has(name) && opening[3] !== "/") {
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
stack.push(name);
} else if (stack.length === 0 && name === "html") {
rootClosed = true;
}
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
cursor = close + 1;
}
if (stack.length > 0 || !rootSeen || !rootClosed) throw invalid();
}
function markupEnd(source: string, start: number): number {
let quote: "\"" | "'" | undefined;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (character === quote) quote = undefined;
} else if (character === "\"" || character === "'") {
quote = character;
} else if (character === ">") {
return index;
}
}
return -1;
}
function parseHtmlAttributes(source: string): ReadonlyMap<string, string> | null {
const attributes = new Map<string, string>();
let cursor = 0;
while (cursor < source.length) {
const whitespace = /^\s+/u.exec(source.slice(cursor));
if (!whitespace) return source.slice(cursor).trim() ? null : attributes;
cursor += whitespace[0].length;
if (cursor >= source.length) return attributes;
const nameMatch = /^[A-Za-z_:][A-Za-z0-9:._-]*/u.exec(source.slice(cursor));
if (!nameMatch) return null;
const name = nameMatch[0].toLowerCase();
if (attributes.has(name) || name.includes(":")) return null;
cursor += nameMatch[0].length;
const spacing = /^\s*/u.exec(source.slice(cursor))![0];
cursor += spacing.length;
let value = "";
if (source[cursor] === "=") {
cursor += 1;
cursor += /^\s*/u.exec(source.slice(cursor))![0].length;
const quote = source[cursor];
if (quote === "\"" || quote === "'") {
const end = source.indexOf(quote, cursor + 1);
if (end < 0) return null;
value = source.slice(cursor + 1, end);
if (value.includes("<")) return null;
cursor = end + 1;
} else {
const unquoted = /^[^\s"'`=<>]+/u.exec(source.slice(cursor));
if (!unquoted) return null;
value = unquoted[0];
cursor += value.length;
}
}
attributes.set(name, value);
}
return attributes;
}
function assertSameIdentity(before: Stats, after: Stats, relative: string): void {
if (
!Number.isSafeInteger(before.dev) ||