diff --git a/packages/patchlogr-core/src/diff/__tests__/detectVersionBump.test.ts b/packages/patchlogr-core/src/diff/__tests__/detectVersionBump.test.ts new file mode 100644 index 0000000..b9e521a --- /dev/null +++ b/packages/patchlogr-core/src/diff/__tests__/detectVersionBump.test.ts @@ -0,0 +1,191 @@ +import { describe, test, expect } from "vitest"; +import { detectVersionBump } from "../detectVersionBump.js"; +import type { SpecChangeSet } from "../diffChangeSet.js"; + +describe("detectVersionBump", () => { + describe("none", () => { + test("변경사항이 없으면 none을 반환한다", () => { + const changeSet: SpecChangeSet = { + baseHash: "abc", + headHash: "abc", + changes: [], + }; + + const result = detectVersionBump(changeSet); + + expect(result.recommendedBump).toBe("none"); + expect(result.isBreaking).toBe(false); + expect(result.reasons).toHaveLength(0); + }); + }); + + describe("major (breaking changes)", () => { + test("removed 타입은 major를 반환한다", () => { + const changeSet: SpecChangeSet = { + baseHash: "abc", + headHash: "def", + changes: [ + { + type: "removed", + path: [], + key: "GET /users", + baseHash: "hash1", + }, + ], + }; + + const result = detectVersionBump(changeSet); + + expect(result.recommendedBump).toBe("major"); + expect(result.isBreaking).toBe(true); + expect(result.reasons).toHaveLength(1); + }); + + test("type_changed는 major를 반환한다", () => { + const changeSet: SpecChangeSet = { + baseHash: "abc", + headHash: "def", + changes: [ + { + type: "type_changed", + path: [], + key: "GET /users", + baseHash: "hash1", + headHash: "hash2", + baseNodeType: "leaf", + headNodeType: "node", + }, + ], + }; + + const result = detectVersionBump(changeSet); + + expect(result.recommendedBump).toBe("major"); + expect(result.isBreaking).toBe(true); + expect(result.reasons).toHaveLength(1); + }); + }); + + describe("minor", () => { + test("added 타입은 minor를 반환한다", () => { + const changeSet: SpecChangeSet = { + baseHash: "abc", + headHash: "def", + changes: [ + { + type: "added", + path: [], + key: "POST /users", + headHash: "hash1", + }, + ], + }; + + const result = detectVersionBump(changeSet); + + expect(result.recommendedBump).toBe("minor"); + expect(result.isBreaking).toBe(false); + expect(result.reasons).toHaveLength(1); + }); + + test("modified 타입은 minor를 반환한다", () => { + const changeSet: SpecChangeSet = { + baseHash: "abc", + headHash: "def", + changes: [ + { + type: "modified", + path: [], + key: "GET /users", + baseHash: "hash1", + headHash: "hash2", + }, + ], + }; + + const result = detectVersionBump(changeSet); + + expect(result.recommendedBump).toBe("minor"); + expect(result.isBreaking).toBe(false); + expect(result.reasons).toHaveLength(1); + }); + }); + + describe("patch", () => { + // TODO: Operation 내부 변경 분석 기능 추가 시 patch 케이스 개선 필요 + // (현재는 알 수 없는 ChangeType만 patch로 분류됨) + test("알 수 없는 타입은 patch를 반환한다", () => { + const changeSet: SpecChangeSet = { + baseHash: "abc", + headHash: "def", + changes: [ + { + type: "unknown" as any, + path: [], + key: "GET /users", + }, + ], + }; + + const result = detectVersionBump(changeSet); + + expect(result.recommendedBump).toBe("patch"); + expect(result.isBreaking).toBe(false); + expect(result.reasons).toHaveLength(1); + }); + }); + + describe("version bump 우선순위", () => { + test("major > minor: major가 있으면 major를 반환한다", () => { + const changeSet: SpecChangeSet = { + baseHash: "abc", + headHash: "def", + changes: [ + { + type: "added", + path: [], + key: "POST /users", + headHash: "hash1", + }, + { + type: "removed", + path: [], + key: "DELETE /users", + baseHash: "hash2", + }, + ], + }; + + const result = detectVersionBump(changeSet); + + expect(result.recommendedBump).toBe("major"); + expect(result.isBreaking).toBe(true); + expect(result.reasons).toHaveLength(2); + }); + + test("reasons에는 해당 레벨의 모든 변경 이유가 포함된다", () => { + const changeSet: SpecChangeSet = { + baseHash: "abc", + headHash: "def", + changes: [ + { + type: "removed", + path: [], + key: "DELETE /users", + baseHash: "hash1", + }, + { + type: "removed", + path: [], + key: "DELETE /posts", + baseHash: "hash2", + }, + ], + }; + + const result = detectVersionBump(changeSet); + + expect(result.reasons).toHaveLength(2); + }); + }); +}); diff --git a/packages/patchlogr-core/src/diff/classifyChange.ts b/packages/patchlogr-core/src/diff/classifyChange.ts new file mode 100644 index 0000000..bdffc6c --- /dev/null +++ b/packages/patchlogr-core/src/diff/classifyChange.ts @@ -0,0 +1,48 @@ +import type { VersionBump } from "./detectVersionBump"; +import type { SpecChange } from "./diffChangeSet"; + +export type ChangeClassification = { + level: VersionBump; + reason: string; +}; + +export function classifyChange(change: SpecChange): ChangeClassification { + const pathStr = change.path.map(String).join(" > "); + const keyStr = String(change.key); + + switch (change.type) { + case "removed": + // operation 삭제, 필드 삭제 등 + return { + level: "major", + reason: `Removed: ${pathStr} > ${keyStr}`, + }; + + case "type_changed": + // node, leaf 타입 변경 + return { + level: "major", + reason: `Type changed at: ${pathStr} > ${keyStr} (${change.baseNodeType} → ${change.headNodeType})`, + }; + + case "added": + // 새로운 operation, 필드 추가 + return { + level: "minor", + reason: `Added: ${pathStr} > ${keyStr}`, + }; + + case "modified": + // TODO: required 추가 => major, description 변경 => patch ... + return { + level: "minor", + reason: `Modified: ${pathStr} > ${keyStr}`, + }; + + default: + return { + level: "patch", + reason: `Unknown change at: ${pathStr} > ${keyStr}`, + }; + } +} diff --git a/packages/patchlogr-core/src/diff/detectVersionBump.ts b/packages/patchlogr-core/src/diff/detectVersionBump.ts new file mode 100644 index 0000000..966e45e --- /dev/null +++ b/packages/patchlogr-core/src/diff/detectVersionBump.ts @@ -0,0 +1,44 @@ +import { classifyChange } from "./classifyChange.js"; +import type { SpecChangeSet } from "./diffChangeSet.js"; + +export type VersionBump = "major" | "minor" | "patch" | "none"; + +export type VersionBumpResult = { + recommendedBump: VersionBump; + isBreaking: boolean; + reasons: string[]; +}; + +export function detectVersionBump( + changeSet: SpecChangeSet, +): VersionBumpResult { + if (changeSet.changes.length === 0) { + return { + recommendedBump: "none", + isBreaking: false, + reasons: [], + }; + } + + const reasons: string[] = []; + let maxBump: VersionBump = "none"; + + for (const change of changeSet.changes) { + const bump = classifyChange(change); + + if (bump.level === "major") { + maxBump = "major"; + } else if (bump.level === "minor" && maxBump !== "major") { + maxBump = "minor"; + } else if (bump.level === "patch" && maxBump === "none") { + maxBump = "patch"; + } + reasons.push(bump.reason); + } + + return { + recommendedBump: maxBump, + isBreaking: maxBump === "major", + reasons, + }; +} diff --git a/packages/patchlogr-core/src/diff/index.ts b/packages/patchlogr-core/src/diff/index.ts index bb1d7d9..21e3d69 100644 --- a/packages/patchlogr-core/src/diff/index.ts +++ b/packages/patchlogr-core/src/diff/index.ts @@ -4,3 +4,4 @@ export * from "./diffLeafNodes"; export * from "./diffTypeChange"; export * from "./diffChildNodes"; export * from "./diffSpec"; +export * from "./detectVersionBump"; diff --git a/packages/patchlogr-core/src/partition/index.ts b/packages/patchlogr-core/src/partition/index.ts index d34c01f..fb2015a 100644 --- a/packages/patchlogr-core/src/partition/index.ts +++ b/packages/patchlogr-core/src/partition/index.ts @@ -1,5 +1,5 @@ -export type { Hash, HashNode } from "./types/hashNode"; -export type { PartitionedSpec } from "./types/partitionedSpec"; +export type { Hash, HashNode } from "./types/HashNode"; +export type { PartitionedSpec } from "./types/PartitionedSpec"; export { partitionByMethod } from "./partitionByMethod"; export { partitionByTag } from "./partitionByTag"; diff --git a/packages/patchlogr-core/src/partition/partitionByMethod.ts b/packages/patchlogr-core/src/partition/partitionByMethod.ts index fdfdf2a..fd2092d 100644 --- a/packages/patchlogr-core/src/partition/partitionByMethod.ts +++ b/packages/patchlogr-core/src/partition/partitionByMethod.ts @@ -4,9 +4,9 @@ import type { CanonicalOperation, } from "@patchlogr/types"; -import type { HashNode } from "./types/hashNode"; -import type { PartitionedSpec } from "./types/partitionedSpec"; -import type { HashObject } from "./types/hashObject"; +import type { HashNode } from "./types/HashNode"; +import type { PartitionedSpec } from "./types/PartitionedSpec"; +import type { HashObject } from "./types/HashObject"; import { createSHA256Hash } from "../utils/createHash"; import stableStringify from "fast-json-stable-stringify"; @@ -38,7 +38,7 @@ export function partitionByMethod( const hash = createSHA256Hash(stableStringify(operation)); hashObjects.push({ hash, data: operation }); return { - type: "leaf" as const, + type: "leaf", key, hash, }; diff --git a/packages/patchlogr-core/src/partition/partitionByTag.ts b/packages/patchlogr-core/src/partition/partitionByTag.ts index 944eee8..b8f763a 100644 --- a/packages/patchlogr-core/src/partition/partitionByTag.ts +++ b/packages/patchlogr-core/src/partition/partitionByTag.ts @@ -1,8 +1,8 @@ import type { CanonicalSpec, CanonicalOperation } from "@patchlogr/types"; -import type { PartitionedSpec } from "./types/partitionedSpec"; -import type { HashNode } from "./types/hashNode"; -import type { HashObject } from "./types/hashObject"; +import type { PartitionedSpec } from "./types/PartitionedSpec"; +import type { HashNode } from "./types/HashNode"; +import type { HashObject } from "./types/HashObject"; import { createSHA256Hash } from "../utils/createHash"; import stableStringify from "fast-json-stable-stringify"; diff --git a/packages/patchlogr-core/src/partition/types/hashNode.ts b/packages/patchlogr-core/src/partition/types/HashNode.ts similarity index 100% rename from packages/patchlogr-core/src/partition/types/hashNode.ts rename to packages/patchlogr-core/src/partition/types/HashNode.ts diff --git a/packages/patchlogr-core/src/partition/types/hashObject.ts b/packages/patchlogr-core/src/partition/types/HashObject.ts similarity index 100% rename from packages/patchlogr-core/src/partition/types/hashObject.ts rename to packages/patchlogr-core/src/partition/types/HashObject.ts diff --git a/packages/patchlogr-core/src/partition/types/partitionedSpec.ts b/packages/patchlogr-core/src/partition/types/PartitionedSpec.ts similarity index 63% rename from packages/patchlogr-core/src/partition/types/partitionedSpec.ts rename to packages/patchlogr-core/src/partition/types/PartitionedSpec.ts index 9d00f2b..c0c4a18 100644 --- a/packages/patchlogr-core/src/partition/types/partitionedSpec.ts +++ b/packages/patchlogr-core/src/partition/types/PartitionedSpec.ts @@ -1,5 +1,5 @@ -import type { HashNode } from "./hashNode"; -import type { HashObject } from "./hashObject"; +import type { HashNode } from "./HashNode"; +import type { HashObject } from "./HashObject"; export type PartitionedSpec = { root: HashNode; diff --git a/packages/patchlogr-core/src/partition/utils/createNode.ts b/packages/patchlogr-core/src/partition/utils/createNode.ts index e7a848d..7371697 100644 --- a/packages/patchlogr-core/src/partition/utils/createNode.ts +++ b/packages/patchlogr-core/src/partition/utils/createNode.ts @@ -1,4 +1,4 @@ -import type { HashNode } from "../types/hashNode"; +import type { HashNode } from "../types/HashNode"; import { createSHA256Hash } from "../../utils/createHash"; import stableStringify from "fast-json-stable-stringify"; diff --git a/packages/patchlogr-core/src/storage/ContentAddressableStorage.ts b/packages/patchlogr-core/src/storage/ContentAddressableStorage.ts new file mode 100644 index 0000000..11c9fdf --- /dev/null +++ b/packages/patchlogr-core/src/storage/ContentAddressableStorage.ts @@ -0,0 +1,10 @@ +import type { HashObject } from "../partition/types/HashObject"; + +export interface ContentAddressableStorage { + get(hash: string): Promise; + + has(hash: string): Promise; + + put(entry: HashObject): Promise; + putMany(entries: HashObject[]): Promise; +}