feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
canonicalizeRegistryValue,
|
||||
diffRegistrySnapshots,
|
||||
registrySnapshotDigest,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "./lib/registry-compatibility.ts";
|
||||
|
||||
type RegistryRow = Record<string, unknown>;
|
||||
type RegistryRows = Record<string, RegistryRow>;
|
||||
type RegistryReference = Readonly<{
|
||||
registryId: string;
|
||||
field: string;
|
||||
targetField: string;
|
||||
}>;
|
||||
type RegistryConsumer = Readonly<{ path: string; token: string }>;
|
||||
type RegistrySpecification = Readonly<{
|
||||
registryId: string;
|
||||
owner: string;
|
||||
path: string;
|
||||
exportName: string;
|
||||
declaredRows?: unknown;
|
||||
requiredFields: readonly string[];
|
||||
fieldTypes?: Readonly<Record<string, string>>;
|
||||
keyField?: string;
|
||||
uniqueFields?: readonly string[];
|
||||
allowedValues?: Readonly<Record<string, readonly unknown[]>>;
|
||||
references?: readonly RegistryReference[];
|
||||
breakingFields?: readonly string[];
|
||||
consumers?: readonly RegistryConsumer[];
|
||||
consumerIdentityField?: string;
|
||||
consumerDirectories?: readonly string[];
|
||||
orphanExemptRows?: readonly string[];
|
||||
}>;
|
||||
type RegistryGovernance = Readonly<{
|
||||
registries: readonly RegistrySpecification[];
|
||||
sourceDirectories?: readonly string[];
|
||||
}>;
|
||||
type RegistrySnapshot = Record<string, unknown>;
|
||||
type CompatibilitySummary = Readonly<{
|
||||
impact: string;
|
||||
changes: readonly unknown[];
|
||||
}>;
|
||||
|
||||
function argumentValue(
|
||||
name: string,
|
||||
fallback: string | undefined,
|
||||
): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1]
|
||||
? process.argv[index + 1]
|
||||
: fallback;
|
||||
}
|
||||
|
||||
const defaultGovernancePath = "config/contracts/registry-governance.json";
|
||||
const governancePath =
|
||||
argumentValue("--governance", defaultGovernancePath) ??
|
||||
defaultGovernancePath;
|
||||
const artifactPath =
|
||||
argumentValue("--artifact", "artifacts/quality/registries.json") ??
|
||||
"artifacts/quality/registries.json";
|
||||
const usesRepositoryBaseline =
|
||||
governancePath === defaultGovernancePath &&
|
||||
!process.argv.includes("--no-baseline");
|
||||
const baselinePath = argumentValue(
|
||||
"--baseline",
|
||||
usesRepositoryBaseline
|
||||
? "config/contracts/registry-baseline.json"
|
||||
: undefined,
|
||||
);
|
||||
const approvalPath = argumentValue(
|
||||
"--approval",
|
||||
usesRepositoryBaseline
|
||||
? "config/contracts/registry-baseline.approval.json"
|
||||
: undefined,
|
||||
);
|
||||
const evidencePath = argumentValue(
|
||||
"--compatibility-evidence",
|
||||
usesRepositoryBaseline
|
||||
? "config/contracts/registry-change-evidence.json"
|
||||
: undefined,
|
||||
);
|
||||
const governance = JSON.parse(
|
||||
await readFile(governancePath, "utf8"),
|
||||
) as RegistryGovernance;
|
||||
const failures: string[] = [];
|
||||
const owners = new Map<string, string>();
|
||||
const snapshots: RegistrySnapshot[] = [];
|
||||
const rowsByRegistry = new Map<string, RegistryRows>();
|
||||
const sourcesByRegistry = new Map<string, string>();
|
||||
const registryExtensions = [".ts", ".tsx", ".mts", ".cts"];
|
||||
|
||||
async function resolveRegistrySource(
|
||||
declaredPath: string,
|
||||
): Promise<string | null> {
|
||||
const extension = path.extname(declaredPath);
|
||||
const basePath = extension
|
||||
? declaredPath.slice(0, -extension.length)
|
||||
: declaredPath;
|
||||
const candidates: string[] = [];
|
||||
for (const candidateExtension of registryExtensions) {
|
||||
const candidate = `${basePath}${candidateExtension}`;
|
||||
try {
|
||||
await access(candidate);
|
||||
candidates.push(candidate);
|
||||
} catch {
|
||||
// Continue through the supported TypeScript source extensions.
|
||||
}
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
failures.push(
|
||||
`ambiguous registry source ${declaredPath}: ${candidates.join(", ")}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function runtimeType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (Number.isInteger(value)) return "integer";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function matchesDeclaredType(value: unknown, declaration: string): boolean {
|
||||
const actual = runtimeType(value);
|
||||
return declaration
|
||||
.split("|")
|
||||
.some(
|
||||
(candidate) =>
|
||||
candidate === actual ||
|
||||
(candidate === "number" && actual === "integer"),
|
||||
);
|
||||
}
|
||||
|
||||
async function filesBelow(directory: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesBelow(target) : [target];
|
||||
}),
|
||||
);
|
||||
return groups.flat().filter((file) =>
|
||||
/\.(?:ts|tsx|mts|cts)$/.test(file),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
for (const specification of governance.registries) {
|
||||
if (owners.has(specification.registryId)) {
|
||||
failures.push(`duplicate owner for ${specification.registryId}`);
|
||||
}
|
||||
owners.set(specification.registryId, specification.owner);
|
||||
|
||||
let rows = specification.declaredRows;
|
||||
const sourcePath = await resolveRegistrySource(specification.path);
|
||||
try {
|
||||
if (!sourcePath) throw new Error("missing registry source");
|
||||
const registryModule = (await import(
|
||||
`${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}`
|
||||
)) as Record<string, unknown>;
|
||||
rows = registryModule[specification.exportName];
|
||||
} catch {
|
||||
if (!rows) failures.push(`missing registry source ${specification.path}`);
|
||||
}
|
||||
|
||||
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
|
||||
failures.push(`${specification.registryId} is not an object registry`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const registryRows = rows as RegistryRows;
|
||||
rowsByRegistry.set(specification.registryId, registryRows);
|
||||
sourcesByRegistry.set(
|
||||
specification.registryId,
|
||||
sourcePath ?? specification.path,
|
||||
);
|
||||
|
||||
for (const [rowName, row] of Object.entries(registryRows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
||||
failures.push(`${specification.registryId}.${rowName} is not an object`);
|
||||
continue;
|
||||
}
|
||||
for (const field of specification.requiredFields) {
|
||||
if (!(field in row)) {
|
||||
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
|
||||
}
|
||||
}
|
||||
for (const [field, declaredType] of Object.entries(
|
||||
specification.fieldTypes ?? {},
|
||||
)) {
|
||||
if (
|
||||
field in row &&
|
||||
!matchesDeclaredType(row[field], String(declaredType))
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(row[field])}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
specification.keyField &&
|
||||
row[specification.keyField] !== rowName
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of specification.uniqueFields ?? []) {
|
||||
const values = new Map<string, string>();
|
||||
for (const [rowName, row] of Object.entries(registryRows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
const value = row[field];
|
||||
if (value === undefined) continue;
|
||||
const identity = JSON.stringify(canonicalizeRegistryValue(value));
|
||||
if (values.has(identity)) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(identity)}`,
|
||||
);
|
||||
} else {
|
||||
values.set(identity, rowName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, allowed] of Object.entries(
|
||||
specification.allowedValues ?? {},
|
||||
)) {
|
||||
for (const [rowName, row] of Object.entries(registryRows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
if (
|
||||
!allowed.some((value) => Object.is(value, row[field]))
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contract = Object.freeze({
|
||||
requiredFields: specification.requiredFields,
|
||||
fieldTypes: specification.fieldTypes ?? {},
|
||||
uniqueFields: specification.uniqueFields ?? [],
|
||||
allowedValues: specification.allowedValues ?? {},
|
||||
references: specification.references ?? [],
|
||||
keyField: specification.keyField ?? null,
|
||||
breakingFields: specification.breakingFields ?? [],
|
||||
});
|
||||
snapshots.push({
|
||||
registryId: specification.registryId,
|
||||
owner: specification.owner,
|
||||
source: sourcePath ?? specification.path,
|
||||
rowCount: Object.keys(registryRows).length,
|
||||
contract,
|
||||
rows: canonicalizeRegistryValue(registryRows),
|
||||
});
|
||||
}
|
||||
|
||||
for (const specification of governance.registries) {
|
||||
const rows = rowsByRegistry.get(specification.registryId);
|
||||
if (!rows) continue;
|
||||
for (const reference of specification.references ?? []) {
|
||||
const targetRows = rowsByRegistry.get(reference.registryId);
|
||||
if (!targetRows) {
|
||||
failures.push(
|
||||
`${specification.registryId} references unknown registry ${reference.registryId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetValues = new Set(
|
||||
Object.values(targetRows)
|
||||
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
||||
.map((row) => row[reference.targetField])
|
||||
.filter((value) => value !== undefined && value !== null),
|
||||
);
|
||||
for (const [rowName, row] of Object.entries(rows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
const value = row[reference.field];
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
!targetValues.has(value)
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const consumer of specification.consumers ?? []) {
|
||||
try {
|
||||
const source = await readFile(consumer.path, "utf8");
|
||||
if (!source.includes(consumer.token)) {
|
||||
failures.push(
|
||||
`${specification.registryId} consumer ${consumer.path} is missing ${consumer.token}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
failures.push(
|
||||
`${specification.registryId} consumer source is missing: ${consumer.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (specification.consumerIdentityField) {
|
||||
const consumerFiles = (
|
||||
await Promise.all(
|
||||
(specification.consumerDirectories ?? []).map(filesBelow),
|
||||
)
|
||||
).flat();
|
||||
const sourcePath = sourcesByRegistry.get(specification.registryId);
|
||||
const consumerText = (
|
||||
await Promise.all(
|
||||
consumerFiles
|
||||
.filter((file) => file !== sourcePath)
|
||||
.map((file) => readFile(file, "utf8")),
|
||||
)
|
||||
).join("\n");
|
||||
const exemptions = new Set(specification.orphanExemptRows ?? []);
|
||||
for (const [rowName, row] of Object.entries(rows)) {
|
||||
if (
|
||||
!row ||
|
||||
typeof row !== "object" ||
|
||||
Array.isArray(row) ||
|
||||
exemptions.has(rowName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const identity = row[specification.consumerIdentityField];
|
||||
if (
|
||||
(typeof identity !== "string" &&
|
||||
typeof identity !== "number") ||
|
||||
!consumerText.includes(String(identity))
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName} has no executable consumer for ${specification.consumerIdentityField}=${String(identity)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceFiles = governance.sourceDirectories ?? [
|
||||
"src/application",
|
||||
"src/presentation",
|
||||
"src/domain",
|
||||
];
|
||||
const adHocPatterns = [
|
||||
{ name: "direct fetch", expression: /\bfetch\s*\(/ },
|
||||
{
|
||||
name: "direct localStorage",
|
||||
expression: /\blocalStorage\.(?:get|set|remove)Item/,
|
||||
},
|
||||
{ name: "direct import.meta.env", expression: /\bimport\.meta\.env\./ },
|
||||
{ name: "raw API path", expression: /["']\/api\// },
|
||||
];
|
||||
|
||||
for (const sourceDirectory of sourceFiles) {
|
||||
for (const file of await filesBelow(sourceDirectory)) {
|
||||
const content = await readFile(file, "utf8");
|
||||
for (const pattern of adHocPatterns) {
|
||||
if (pattern.expression.test(content)) {
|
||||
failures.push(`ad-hoc ${pattern.name} in ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentSnapshot = canonicalizeRegistryValue({
|
||||
schemaVersion: 2,
|
||||
registries: snapshots,
|
||||
}) as Readonly<Record<string, unknown>>;
|
||||
let baselineDigest: string | null = null;
|
||||
const currentDigest = registrySnapshotDigest(currentSnapshot);
|
||||
let compatibility: CompatibilitySummary = {
|
||||
impact: "not-evaluated",
|
||||
changes: [],
|
||||
};
|
||||
|
||||
if (baselinePath && approvalPath && evidencePath) {
|
||||
try {
|
||||
const baseline = JSON.parse(
|
||||
await readFile(baselinePath, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const approval = JSON.parse(
|
||||
await readFile(approvalPath, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const approvalResult = verifyRegistryBaselineApproval(baseline, approval);
|
||||
baselineDigest = approvalResult.actualDigest;
|
||||
if (!approvalResult.passed) {
|
||||
failures.push(
|
||||
`registry baseline approval digest mismatch: approved=${approvalResult.approvedDigest} actual=${approvalResult.actualDigest}`,
|
||||
);
|
||||
}
|
||||
const registryDiff = diffRegistrySnapshots(baseline, currentSnapshot);
|
||||
compatibility = registryDiff;
|
||||
const evidence = JSON.parse(
|
||||
await readFile(evidencePath, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const evidenceResult = validateBreakingEvidence(registryDiff, evidence);
|
||||
failures.push(...evidenceResult.failures);
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`registry compatibility evidence unavailable: ${
|
||||
error instanceof Error ? error.name : "unknown"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 2,
|
||||
generatedAt: new Date().toISOString(),
|
||||
baselineDigest,
|
||||
currentDigest,
|
||||
compatibility,
|
||||
failures,
|
||||
registries: snapshots,
|
||||
};
|
||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Registry governance: ${snapshots.length} registries PASS; compatibility=${compatibility.impact}\n`,
|
||||
);
|
||||
Reference in New Issue
Block a user